#!/usr/bin/env python3
"""Produce an issue-triage verdict with tool-less Anthropic Messages API calls.
Design rationale
----------------
This script holds ANTHROPIC_API_KEY, but the model it calls is given NO tools. A
prompt injection in untrusted issue content therefore has no mechanism to read the
runner environment or filesystem (e.g. /proc/self/environ) or to post anything; the
model can only return text. All repo/issue context is gathered deterministically by
the workflow and this script, and passed in as data. The comment is posted by a
separate job that holds the GitHub token but no Anthropic key, so the secret and the
write capability are never co-located.
Two calls are made:
1. The model proposes a few short keyword queries for finding duplicates. The script
(not the model) sanitizes them and runs `gh search issues` with a read-only token,
pinned to this repository. The worst an injection can do here is pick a poor query.
2. The model returns the verdict, given the issue, templates, releases, and the
duplicate candidates (with truncated bodies).
The prompts live in the workflow (TRIAGE_PROMPT, QUERY_PROMPT) so they are reviewed
alongside the workflow; this script only supplies the gathered context as data.
Output: a single line of JSON on stdout: {"should_comment": bool, "body": str}.
Fails closed (no comment) on any API or parse error; exits non-zero on configuration
errors (missing key/prompt, 4xx from the API) so they are not silently swallowed.
"""
import json
import os
import pathlib
import subprocess
import sys
import urllib.error
import urllib.request
API_URL = "https://api.anthropic.com/v1/messages"
MODEL = os.environ.get("ANTHROPIC_MODEL", "claude-sonnet-5")
MAX_QUERIES = 3
MAX_QUERY_LEN = 80
RESULTS_PER_QUERY = 5
MAX_CANDIDATES = 8
CANDIDATE_BODY_CHARS = 1500
class APIError(Exception):
def __init__(self, msg: str, fatal: bool = False):
super().__init__(msg)
self.fatal = fatal
def _read(path: str) -> str:
if not path:
return ""
try:
return pathlib.Path(path).read_text(encoding="utf-8", errors="replace")
except OSError:
return ""
def _fail_closed(msg: str, fatal: bool = False) -> None:
level = "error" if fatal else "warning"
sys.stderr.write(f"::{level}::triage: {msg}\n")
print(json.dumps({"should_comment": False, "body": ""}))
if fatal:
sys.exit(1)
def _load_issue() -> dict:
# Read the issue from the event payload on disk rather than env vars, which are
# capped at 128 KiB each and can be exceeded by a long non-ASCII body.
try:
event = json.loads(_read(os.environ.get("GITHUB_EVENT_PATH", "")) or "{}")
except json.JSONDecodeError:
event = {}
issue = event.get("issue") or {}
return {
"number": issue.get("number") or "",
"title": issue.get("title") or "",
"body": issue.get("body") or "",
}
def _call(api_key: str, system: str, user: str, max_tokens: int) -> str:
payload = json.dumps(
{
"model": MODEL,
"max_tokens": max_tokens,
"system": system,
"messages": [{"role": "user", "content": user}],
}
).encode()
req = urllib.request.Request(
API_URL,
data=payload,
headers={
"content-type": "application/json",
"anthropic-version": "2023-06-01",
"x-api-key": api_key,
},
)
try:
with urllib.request.urlopen(req, timeout=60) as resp:
data = json.loads(resp.read())
except urllib.error.HTTPError as exc:
# Bad key, retired model, malformed request: a config problem, so fail loudly.
raise APIError(f"API returned HTTP {exc.code}", fatal=400 <= exc.code < 500 and exc.code != 429)
except (urllib.error.URLError, json.JSONDecodeError, TimeoutError) as exc:
raise APIError(f"API call failed: {exc}")
if data.get("stop_reason") == "max_tokens":
raise APIError(f"model response truncated at max_tokens={max_tokens}")
text = "".join(
block.get("text", "")
for block in data.get("content", [])
if block.get("type") == "text"
).strip()
if text.startswith("```"):
text = text.strip("`")
brace = text.find("{")
text = text[brace:] if brace != -1 else text
return text
def _issue_xml(issue: dict) -> str:
return '\n{title}\n\n{body}\n\n'.format(**issue)
def _sanitize_query(query: str) -> str:
# Drop search qualifiers (repo:, org:, author:, ...) so a query cannot leave this
# repo, plus quotes, negation, and boolean operators.
words = []
for word in query.replace('"', " ").split():
word = word.lstrip("-")
if not word or ":" in word or word in ("OR", "AND", "NOT"):
continue
words.append(word)
return " ".join(words)[:MAX_QUERY_LEN].strip()
def _propose_queries(api_key: str, issue: dict) -> list[str]:
system = os.environ.get("QUERY_PROMPT", "").strip()
queries = []
if system:
try:
text = _call(api_key, system, _issue_xml(issue), max_tokens=256)
proposed = json.loads(text).get("queries", [])
if isinstance(proposed, list):
queries = [q for q in proposed if isinstance(q, str)]
except APIError as exc:
if exc.fatal:
raise
sys.stderr.write(f"::warning::triage: query generation failed: {exc}\n")
except (json.JSONDecodeError, AttributeError):
sys.stderr.write("::warning::triage: query generation returned unparseable JSON\n")
# Fall back to the title if the model proposed nothing usable.
queries = [s for s in (_sanitize_query(q) for q in queries[:MAX_QUERIES]) if s]
return queries or [s for s in [_sanitize_query(issue["title"])] if s]
def _search(query: str) -> list[dict]:
# Arguments are passed as a list (no shell); `--` stops the query being read as flags.
try:
result = subprocess.run(
[
"gh", "search", "issues",
"--repo", os.environ["GITHUB_REPOSITORY"],
"--limit", str(RESULTS_PER_QUERY),
"--json", "number,state,title,url,body",
"--", query,
],
capture_output=True, text=True, timeout=30, check=True,
)
return json.loads(result.stdout or "[]")
except (subprocess.SubprocessError, json.JSONDecodeError, KeyError, OSError) as exc:
sys.stderr.write(f"::warning::triage: search failed: {exc}\n")
return []
def _duplicate_candidates(queries: list[str], issue_number) -> str:
seen = {issue_number}
candidates = []
for query in queries:
for hit in _search(query):
if hit.get("number") in seen:
continue
seen.add(hit.get("number"))
candidates.append(hit)
entries = []
for hit in candidates[:MAX_CANDIDATES]:
body = (hit.get("body") or "")[:CANDIDATE_BODY_CHARS]
entries.append(
'\n{title}\n'
"\n{body}\n\n".format(
number=hit.get("number", ""),
state=hit.get("state", ""),
url=hit.get("url", ""),
title=hit.get("title", ""),
body=body,
)
)
return "\n".join(entries)
def main() -> None:
api_key = os.environ.get("ANTHROPIC_API_KEY")
if not api_key:
_fail_closed("ANTHROPIC_API_KEY not set", fatal=True)
system = os.environ.get("TRIAGE_PROMPT", "").strip()
if not system:
_fail_closed("TRIAGE_PROMPT not set", fatal=True)
issue = _load_issue()
if not issue["number"]:
_fail_closed("could not read issue from GITHUB_EVENT_PATH")
return
try:
queries = _propose_queries(api_key, issue)
except APIError as exc:
_fail_closed(str(exc), fatal=exc.fatal)
return
user = (
"{issue}\n\n"
"\n{templates}\n\n\n"
"\n{releases}\n\n\n"
"\n{dups}\n"
).format(
issue=_issue_xml(issue),
templates=_read(os.environ.get("TEMPLATES_PATH", "")),
releases=_read(os.environ.get("RELEASES_PATH", "")),
dups=_duplicate_candidates(queries, issue["number"]),
)
try:
text = _call(api_key, system, user, max_tokens=2048)
except APIError as exc:
_fail_closed(str(exc), fatal=exc.fatal)
return
try:
verdict = json.loads(text)
should = verdict.get("should_comment") is True
body = verdict.get("body") if should else ""
except (json.JSONDecodeError, AttributeError):
_fail_closed("model did not return parseable JSON")
return
# A verdict to comment is only usable with a non-empty string body.
if should and not (isinstance(body, str) and body.strip()):
_fail_closed("model set should_comment without a usable body")
return
print(json.dumps({"should_comment": should, "body": body}))
if __name__ == "__main__":
main()