Security design¶
← README · Architecture · Roadmap
What the issue assistant protects, what it trusts and which risks remain. SECURITY.md says how to report a vulnerability and how to verify a release, and argues why this repository and its releases are safe. Issues come from anyone, so the design assumes that an issue tries to steer the AI.
What you can expect¶
- The AI only reads: the checkout of the default branch and the context that the assistant writes for it. It runs no commands, opens no web pages and reads no files outside the checkout, on a runner whose network allows only the hosts of
.github/egress-firewall.yaml. - Nothing the AI writes reaches GitHub unchecked. A job that runs no AI and never sees its secret checks the answer and writes it, only to the issue of the event.
- No comment of the assistant mentions anyone, links to a site that
config.tomldoesn't allow, to another repository or to an e-mail address, shows an image or HTML, or contains something that looks like a token or a key. What counts is the whole comment as GitHub shows it, not each field alone: the text of a field is as safe under a heading as in a list or behind an issue number. - The same holds for the AI's words outside its comments: in the log of the run, in its summary and in the reasons for labels and duplicates that GitHub shows on the timeline of the issue. They stay on one line there, and no answer can run a workflow command, such as
::add-mask::or::stop-commands::. - No issue can stall the assistant. It reads every text in time that grows only with the length of the text, and no more of a text than GitHub keeps, 65,536 characters.
- No stranger can run up the bill of the engine. The runs of the AI that people who don't maintain the repository start stay within the limits of
config.toml: by default 20 analyses of new issues, follow-ups and replies after a release in 24 hours, 3 analyses of one reporter's new issues, and 2 comments of the reporter after each release that closed the issue. Every new issue counts, also while the first analyses still run, and so does every follow-up and reply after a release, also when the reporter deletes their comments. - No text of an issue can pass for someone else or hide from the maintainer. The AI reads each text in a code fence that nothing in the text can close, under a heading of the assistant that names its author and role, and without the characters that GitHub shows as nothing.
- No issue can close another reporter's issue. A sure duplicate closes on its own only when a maintainer opened or triaged the original, and a comment of anyone, or a thumbs down of the reporter or of anyone who may push to the repository, keeps it open.
- The secret of the engine stays in the environment
issue-assistant, which only the default branch may use; only the job that runs the engine sees it. - No code of a pull request runs in a workflow of the assistant.
- The jobs that write reach only GitHub, in a public repository and in a private one whose owner subscribes to StepSecurity. Every job without the AI blocks every host but
api.github.comandgithub.comwith Harden-Runner, and no step in it can use sudo or containers. - An open finding of code scanning is named in a public log only by the number and link of its alert.
- The action stores no secrets and no data outside GitHub. Only Harden-Runner, in the jobs without the AI, reports to StepSecurity, as the residual risks say.
What the checks make of an answer that an issue steered, in a demo project:
Against PromptPwnd and Comment and Control¶
Two published attacks show what goes wrong when an AI in GitHub Actions reads text that anyone can write:
- PromptPwnd, published by Aikido Security in December 2025: the text of an issue or a pull request lands in the prompt of an AI agent that holds a strong token, and makes it run commands or write secrets into the issue.
- Comment and Control, published in April 2026, as SecurityWeek reports: one pull request or comment made Claude Code Security Review, the Gemini CLI action and the GitHub Copilot agent leak their secrets into public logs or comments.
The research note of the Cloud Security Alliance of May 2026 describes both and lists what protects a workflow. The difference to the assistant in one picture:
flowchart TB
accTitle: The pattern that PromptPwnd attacks, and the assistant's
accDescr: In the attacked pattern, the text of an issue goes into the prompt of an AI that holds a write token, a shell and the network, so a steered AI writes to GitHub or sends secrets away. In the assistant, the text of the issue goes into files that the prompt calls material; the AI reads them with a read-only token and reading tools alone, behind a firewall, and answers with JSON. A job without AI and without the engine's secret checks that answer and is the only one that writes to GitHub.
subgraph attacked ["the attacked pattern"]
direction TB
issue1["issue text"] --> prompt1["in the prompt"]
prompt1 --> agent1["`**AI**<br>write token, shell,<br>network`"]
agent1 --> github1[("GitHub")]
agent1 --> leak(["secrets sent away"])
end
subgraph assistant ["the issue assistant"]
direction TB
issue2["issue text"] --> files["files that the prompt<br>calls material"]
files --> agent2["`**AI**<br>read-only token, Read,<br>Grep, Glob, firewall`"]
agent2 -- "JSON" --> apply("`**apply**<br>no AI, no secret:<br>checks, then writes`")
apply --> github2[("GitHub")]
end
classDef untrusted fill:#cf222e26,stroke:#cf222e,stroke-width:2px
classDef ai fill:#8250df33,stroke:#8250df,stroke-width:2px
classDef job fill:#8c959f1f,stroke:#8c959f,stroke-width:2px
classDef safe fill:#1a7f3726,stroke:#1a7f37,stroke-width:2px
class issue1,issue2,leak untrusted
class agent1,agent2 ai
class prompt1,files,apply job
class github1,github2 safe
Each mitigation that the three sources recommend, how the assistant implements it, and the test that pins it:
| Mitigation | Recommended by | In the assistant | Pinned by |
|---|---|---|---|
| Give the agent no tool that writes to issues or pull requests, and no shell | Aikido, CSA | Claude Code runs with --restricted and the tools Read, Grep and Glob alone, with a token that only reads |
test_each_engine_can_only_read_the_checkout, test_only_the_analyze_job_runs_an_engine_and_only_with_read_permissions in tests/test_templates.py |
| Keep untrusted text out of the prompt, or clearly apart from its instructions | Aikido, CSA | The rules are the engine's system prompt, apart from its task, which names files. The issue, its comments, the other issues and the discussions reach the AI in them, and the rules call them material to analyze, never instructions. In issue.md, each text stands in a code fence that it can't close, under a heading that only the script writes, and no text keeps a character that GitHub shows as nothing. No expression of their text stands in a script of a workflow |
test_the_prompt_keeps_the_text_of_issues_apart_from_its_instructions, test_no_text_forms_a_heading_or_a_comment_in_issue_md, test_no_invisible_character_reaches_the_engine in tests/test_assistant.py; test_untrusted_text_never_reaches_a_shell_script and test_each_engine_can_only_read_the_checkout, which pins the system prompt, in tests/test_templates.py |
| Treat the output of the AI as untrusted | Aikido | apply checks the answer against its schema, the labels and the issues that exist, refuses it when it holds anything like a secret, and cleans every text of mentions, images, HTML and foreign links |
test_an_answer_that_breaks_the_schema_is_refused, test_any_answer_is_checked_or_refused, test_tricky_text_is_cleaned_safely in tests/test_assistant.py; tests/test_properties.py; fuzz/fuzz_sanitize.py, fuzz/fuzz_render.py |
| Keep the AI apart from the credentials that write, and write only after its output is checked | CSA | analyze holds the engine's secret and a token that only reads. apply holds the token that writes, runs no AI, never sees the engine's secret and starts only after analyze |
test_the_answer_reaches_github_only_through_the_checks, test_only_the_analyze_job_runs_an_engine_and_only_with_read_permissions |
| Limit what a leaked token can reach: Aikido suggests GitHub's IP allow lists, CSA monitoring on the runner | Aikido, CSA | The AI's own token only reads and ends with its job, and the AI runs behind GitHub's egress firewall with an allow list. Every job with a token that writes starts with Harden-Runner, which blocks every host but GitHub's | test_the_firewall_template_allows_only_named_hosts, test_jobs_without_the_ai_reach_only_github |
| Keep secrets out of public logs and comments | Comment and Control, CSA | The AI has no shell and reads no file outside the checkout, so its environment stays out of reach. An answer with anything like a token is refused as a whole, and no answer can run a workflow command | test_a_token_anywhere_in_the_answer_is_refused, test_no_text_of_the_engine_runs_a_workflow_command |
| Explicit, least permissions in every workflow | CSA | Each template starts with permissions: {}, and each job names the permissions it needs; no job may ask for an OIDC token |
test_templates_start_without_permissions_and_not_in_forks |
| Pin every action to a full commit hash | CSA | Every action of the templates is pinned, and check keeps each repository's workflows equal to the templates |
test_actions_are_pinned_and_checkouts_keep_no_credentials |
No pull_request_target that runs code of the pull request |
CSA | The lifecycle starts on pull_request_target only to learn which issues a merge fixes; it checks out the default branch and runs no code of the pull request |
test_merges_and_releases_start_the_lifecycle_without_running_pull_request_code |
| Keys for the AI that are scoped to their environment | CSA | The engine's secret lives in the environment issue-assistant, which only the default branch may use, and only analyze gets it. How long the key lives is the choice of whoever creates it |
test_only_the_analyze_job_runs_an_engine_and_only_with_read_permissions |
| Attack the workflow with prompt injection before it ships | CSA | Steered answers are tests: crafted examples, and property tests and fuzzing of the cleaned text and of whole comments, on every change and every week, judged by a model of GitHub's rendering of their own | test_tricky_text_is_cleaned_safely; tests/test_properties.py; tests/test_oracle.py; fuzz/fuzz_sanitize.py, fuzz/fuzz_render.py |
Two of the recommendations of the Cloud Security Alliance have nothing to apply to here: the templates sign in to no cloud, so there is no OIDC trust policy to narrow, and the assistant keeps no credential file of an AI tool in a repository.
What is protected¶
| Asset | Where it lives | Protection |
|---|---|---|
The token of the engine, such as CLAUDE_CODE_OAUTH_TOKEN |
The environment issue-assistant of the repository |
Only the default branch may use the environment, and only the job that runs the engine gets the secret |
| The issues, labels and comments of the repository | GitHub | Written only by apply, sweep, sync-labels and event, which run no AI, with the permissions their job grants, and whose network reaches GitHub alone where Harden-Runner blocks |
| The findings of code scanning | GitHub's code scanning | Visible only to maintainers; the Findings workflow names them by number and link only |
| The workflows of the repositories that use the action | Their .github/workflows/ |
check fails when a workflow differs from its template in anything but the commit hashes of its actions |
Trust boundaries¶
Red is what anyone can write; the numbers are those of the list below.
flowchart TB
accTitle: The trust boundaries of the issue assistant
accDescr: Anyone can write an issue, its comments, other issues and discussions, and a pull request. The text reaches the event job, which runs no AI and chooses the task, and, as data in files, the job analyze: the first boundary. There the AI reads with Read, Grep and Glob alone, behind a firewall, with a read-only token and the engine's secret. Its answer in JSON crosses the second boundary into the job apply, which runs no AI and holds no secret. Its checks want the schema, labels and issues that exist, and no secret, mention, image, HTML or foreign link. When they fail, nothing is posted; when they pass, apply writes to GitHub, only to the issue of the event: the third boundary. A merged pull request starts issue-lifecycle.yml on the default branch, without AI, which learns only the issues that it fixes, the fourth boundary, and writes to GitHub.
text["issue, comments,<br>other issues, discussions"]
merged(["merged pull request"])
event["`**event**<br>no AI`"]
subgraph firewall ["analyze: firewall, read-only token"]
analyze["`**the AI** with Read,<br>Grep and Glob,<br>the engine's secret`"]
end
subgraph checked ["apply: no AI, no secret"]
checks("`**checks**<br>schema, labels and<br>issues that exist; no<br>secret, mention, image,<br>HTML or foreign link`")
refused["nothing is posted"]
end
text -- "1 · as data, in files" --> firewall
text --> event
event -- "the task" --> firewall
firewall -- "2 · an answer in JSON" --> checked
checks -- "fails" --> refused
checks -- "3 · passes: only the<br>issue of the event" --> github[("GitHub")]
merged -- "4 · only the issues it fixes" --> lifecycle["`**issue-lifecycle.yml**<br>the default branch, no AI`"]
lifecycle --> github
classDef untrusted fill:#cf222e26,stroke:#cf222e,stroke-width:2px
classDef ai fill:#8250df33,stroke:#8250df,stroke-width:2px
classDef job fill:#8c959f1f,stroke:#8c959f,stroke-width:2px
classDef safe fill:#1a7f3726,stroke:#1a7f37,stroke-width:2px
class text,merged untrusted
class analyze ai
class event,lifecycle,checks,refused job
class github safe
- Issues and comments → the assistant. Their text is untrusted. It reaches the AI as files that the prompt calls data, not instructions, each text in a code fence that it can't close and without invisible characters, as the next section shows, and reaches a shell script only through environment variables.
- The AI → the
applyjob. The answer is untrusted. It must be JSON that fits the schema of its task, with labels that exist and numbers of issues that exist; anything that looks like a secret refuses the whole answer. - The
applyjob → GitHub. Only the issue of the event, with the permissions of its job. - Pull requests → the lifecycle. The lifecycle workflow starts on
pull_request_targetonly to learn which issues a merged pull request fixes. It checks out the default branch, runs no AI and skips Dependabot's pull requests. - The action → the repository that uses it. The repository pins the action by the commit hash of a release, and the workflows grant each job only the permissions it needs.
How the text of an issue reaches the AI¶
The AI learns who wrote what from issue.md: a heading for the issue and for each comment names the author, the role, such as reporter or maintainer, and the date, and the AI's own earlier comments are marked as its own. The text under a heading is written by anyone, so it must not be able to write such a heading itself, nor hide what the maintainer can't see:
flowchart TB
accTitle: How the text of an issue and its comments reaches the AI
accDescr: Each text of the issue and its comments loses its HTML, as GitHub hides it, then the characters that GitHub shows as nothing, and is cut off after 20,000 characters. In this order, no invisible character can end an HTML comment that GitHub never ends. It goes into a code fence with one backtick more than its longest run of backticks, so that no line of the text can end it. The heading above the fence, which only the script writes, names the author, the role and the date. The other issues and discussions become excerpts without HTML comments, lose their invisible characters too and go into JSON. A notice in the log counts the characters that were removed.
text["a text of the issue<br>or a comment"] --> html["HTML out,<br>as GitHub hides it"]
html --> invisible["`**invisible characters out**<br>Unicode tags, zero-width,<br>direction controls`"]
invisible --> fence["`at most 20,000 characters<br>in **a code fence** one<br>backtick longer than any<br>run in the text`"]
heading["the heading: author,<br>role and date"] --> issue[("issue.md")]
fence --> issue
others["other issues and<br>discussions"] --> excerpts["excerpts without HTML<br>comments, then invisible<br>characters out, in JSON"]
excerpts --> jsonl[("issues.jsonl,<br>discussions.jsonl")]
invisible -.-> notice["a notice in the log:<br>how many"]
excerpts -.-> notice
classDef untrusted fill:#cf222e26,stroke:#cf222e,stroke-width:2px
classDef job fill:#8c959f1f,stroke:#8c959f,stroke-width:2px
classDef safe fill:#1a7f3726,stroke:#1a7f37,stroke-width:2px
class text,others untrusted
class invisible,html,fence,heading,excerpts,notice job
class issue,jsonl safe
- Nothing in a text can end its fence. A fence ends only at a line of at least as many backticks as the line that opened it, and the assistant opens it with one backtick more than the longest run in the text, at least three. So
## Comment 4 by @owner (maintainer)in a text stays a line of that text, and so does a fence of the text's own, whatever its length. The prompt tells the AI that a fence holds only that person's text, whatever it looks like. - What GitHub doesn't show, the AI doesn't read. The tag characters of Unicode, U+E0001 to U+E007F, spell a whole sentence in ASCII that no browser shows, and zero-width spaces, joiners and the controls of the direction of writing hide or reorder text. The assistant removes every format character of Unicode and every code point that Unicode calls default ignorable, such as variation selectors, which can carry a byte each, from the issue and its comments, from the excerpts of the other issues and discussions, and from the AI's answer before its checks, so that none can hide a token from them either. They go after the HTML, which goes as GitHub hides it: a zero-width space in the
-->of an HTML comment keeps the comment from ending on GitHub, so that it hides the rest of the text, and taken out first, it would end the comment and show the AI what GitHub hides. And they go before the fence is chosen, so that none can split a run of backticks that would end it.
Every analysis reads the excerpts of every open issue and of the closed ones that changed last, so a text planted in one issue reaches the AI again with each new issue. Its worst use would be to call new issues its duplicates, which close on their own. So only an original that a maintainer vouches for can close another issue:
flowchart TB
accTitle: When a duplicate that the AI found closes on its own
accDescr: A duplicate that the AI is sure of, of an older open issue, gets the notice that closes it after three days only when a maintainer opened the original or triaged it, so that it no longer has needs-triage. Otherwise it becomes a suggestion for the maintainer and a related issue in the comment. A notice closes nothing when someone comments, when the reporter or anyone who may push to the repository reacts with a thumbs down, or when the original is closed or no longer vouched for by then.
sure["a sure duplicate of an<br>older, open issue"] --> vouched{"a maintainer opened<br>it or triaged it?"}
vouched -- "no" --> suggestion["a suggestion for the<br>maintainer, a related issue"]
vouched -- "yes" --> notice["`**possible-duplicate**<br>the notice`"]
notice --> objected{"in 3 days: a comment,<br>or a thumbs down of the<br>reporter or of someone<br>who may push?"}
objected -- "yes" --> open["stays open for<br>the maintainer"]
objected -- "no" --> closed["closed as a duplicate"]
classDef question fill:#d4a72c33,stroke:#bf8700,stroke-width:2px
classDef job fill:#8c959f1f,stroke:#8c959f,stroke-width:2px
classDef safe fill:#1a7f3726,stroke:#1a7f37,stroke-width:2px
class vouched,objected question
class sure,notice,closed job
class suggestion,open safe
Where the AI's words go¶
The comment is not the only place that shows what the AI wrote. Each place gets its own guard, so that the words stay text wherever they appear:
flowchart TB
accTitle: Where the AI's words go and how each place is guarded
accDescr: The answer of the AI must fit its schema and hold nothing like a secret. Its texts for the comment are cleaned before they are posted. Its reasons are put on one line and cleaned, at most 280 characters, before they become a rationale on the timeline or a line of the log and the job summary; a possible security report gets a fixed rationale. The log prints them while workflow commands are turned off with a random token, and errors are escaped. The schema and the model reach the command line of the engine only in a form that adds no arguments.
answer["the AI's answer"] --> checks("schema and<br>secret check")
checks -- "summary, analysis,<br>steps, questions" --> cleaned["cleaned: no mention,<br>image, HTML or<br>foreign link"]
cleaned --> posted[("comment on<br>the issue")]
checks -- "reasons" --> oneline["one line, then cleaned,<br>at most 280 characters"]
oneline -- "labels, duplicates;<br>fixed for a security report" --> rationale[("rationale on<br>the timeline")]
oneline --> jobsummary[("job summary")]
oneline --> runlog[("log of the run")]
checks -- "refused" --> refusal["an escaped<br>::error::"]
refusal --> runlog
settings["labels.toml,<br>config.toml"] -- "schema with \u0027,<br>model as a name" --> cli[("command line<br>of the engine")]
classDef untrusted fill:#cf222e26,stroke:#cf222e,stroke-width:2px
classDef job fill:#8c959f1f,stroke:#8c959f,stroke-width:2px
classDef safe fill:#1a7f3726,stroke:#1a7f37,stroke-width:2px
class answer untrusted
class checks,cleaned,oneline,refusal,settings job
class posted,rationale,jobsummary,runlog,cli safe
The log prints the lines and the comments of a dry run between ::stop-commands:: and a random token, so that the runner reads none of them as a workflow command. Only a terminal outside a runner, such as one running check on your own computer, shows them without the token.
How the text of the AI is cleaned¶
Every text of an answer passes sanitize in the apply job before it becomes part of a comment. Code stays as it is, so that a snippet of the AI can show @dataclass or #include; everything else is cleaned of what GitHub would turn into a mention, a reference, a link, an image or HTML.
flowchart TB
accTitle: How the apply job cleans a text of the AI
accDescr: The job removes control characters and trims the text, then splits it into code and the text around the code. Code stays as it is. In the text, the removals repeat until nothing changes; then the rewrites, which remove nothing, turn what GitHub would link elsewhere into code and keep allowed links and checked issue numbers. Last, characters that could start code, a link, an image or a fence are escaped, and the parts go into the comment in a way that keeps code in code.
answer(["a text of the<br>AI's answer"]) --> normal["control characters removed,<br>spaces trimmed"]
normal --> split{"code? a fence at the<br>start of a line,<br>or a span"}
split -- "yes" --> code["stays as it is"]
split -- "no" --> removals["`**removals**, until nothing<br>changes: HTML, images,<br>headings, foreign links`"]
removals --> rewrites["`**rewrites**, which remove<br>nothing: mentions, e-mail,<br>commits and references<br>elsewhere become code`"]
rewrites --> escapes["`**escapes** of what could<br>start code, a link,<br>an image or a fence`"]
code --> comment["`**the comment**: list<br>items indented as a<br>whole, reasons on one line`"]
escapes --> comment
comment --> github[("GitHub")]
classDef untrusted fill:#cf222e26,stroke:#cf222e,stroke-width:2px
classDef question fill:#d4a72c33,stroke:#bf8700,stroke-width:2px
classDef job fill:#8c959f1f,stroke:#8c959f,stroke-width:2px
classDef safe fill:#1a7f3726,stroke:#1a7f37,stroke-width:2px
class answer untrusted
class split question
class normal,code,removals,rewrites,escapes,comment job
class github safe
- Code is what Markdown takes for code. A fenced block counts only when its fence starts a line: indented, the fence could belong to a list item before it, which the next line without indentation ends, and the code with it. Only a fence at least as long as all of the opening one closes the block; GitHub's block would run on past a shorter one, into the next text of the comment. Control characters go first, because Python and Markdown don't agree on which of them are spaces.
- Removals first, then rewrites. A removal can join the characters around it into something new, such as
foo/bar<b></b>#12into a reference to another repository. So the removals repeat until nothing changes, and only then is anything kept as a link or written as code; the rewrites remove nothing, so the characters next to what they keep stay the ones they were checked with. Text next to code starts anew for GitHub, so an issue number after an@, as in#0@GH-7, becomes code too: with#0as code,@GH-7would mention someone. - A bare address stays a link only where GitHub links all of it and nothing more. GitHub's link runs on to the next space, past brackets and even into the code that follows, so an allowed address glued to other text becomes code. A link that leaves the repository through
..or%2e, also written with entities such as., or that names a user, a password or a port, isn't allowed, and neither is a link whose target holds a(, since Markdown's target runs on past the)that closes it. - The place in the comment keeps code in code. The lines of a list item are indented under its bullet, and the reasons of related issues, duplicates and references are put on one line, where a fence starts no code block.
- No table cuts code in two. GitHub splits every row of a table at each
|, also inside code, so that the rest of the code would be text. A table needs its delimiter row, a line of hyphens with a colon or a pipe such as:-:or--|--, so in a text of more than one line a hyphen next to a colon or a pipe is escaped, and no table forms. The AI is asked not to write tables, which would show as plain lines of text.
How the cleaning is tested¶
What the cleaning lets through is judged where it ends up, by an oracle of the tests' own, tests/oracle.py, which shares no code with the cleaning. A CommonMark parser decides what is code. On top of it, the oracle follows what GitHub adds: tables that split code at a pipe, autolinks that run over the source to the next space, past brackets, entities and into code, where a browser goes for a link, and mentions and references, also of commits of other repositories. tests/test_oracle.py holds it to what GitHub's own renderer made of the same Markdown.
flowchart TB
accTitle: How the tests judge what the cleaning lets through
accDescr: Four kinds of input reach the checks: the texts that once got past the cleaning, random text of the pieces that matter to Markdown from Hypothesis, whole answers that Hypothesis builds from the schema of the answer, and inputs that Atheris mutates with a dictionary of Markdown, starting from seeds. Their text is cleaned alone and in each place of a comment, or apply posts the whole comment. The oracle decides what GitHub would make of the result. Anything that would mention, reference, link elsewhere or show HTML, other than the assistant's own words, fails the test.
known(["texts that once<br>got through"]) --> clean
random(["random text of the<br>pieces that matter"]) --> clean
answers(["whole answers built<br>from the schema"]) --> post
fuzzed(["fuzzed inputs, with a<br>dictionary and seeds"]) --> clean
fuzzed --> post
clean["`**sanitize**: the text alone,<br>under a heading, in a list,<br>behind a number`"] --> oracle
post["`**apply** posts the<br>whole comment`"] --> oracle
oracle{"`**the oracle**: what would<br>GitHub mention, reference,<br>link or show?`"}
oracle -- "only the assistant's<br>own words" --> passes(["the test passes"])
oracle -- "anything else" --> fails(["the test fails"])
classDef untrusted fill:#cf222e26,stroke:#cf222e,stroke-width:2px
classDef question fill:#d4a72c33,stroke:#bf8700,stroke-width:2px
classDef job fill:#8c959f1f,stroke:#8c959f,stroke-width:2px
classDef safe fill:#1a7f3726,stroke:#1a7f37,stroke-width:2px
class known,random,answers,fuzzed,fails untrusted
class oracle question
class clean,post job
class passes safe
- Each place of a comment. The property tests in
tests/test_properties.pyclean random text alone, under a heading, in a list and behind an issue number, and letapplypost whole comments for answers that they build from the schema of the answer itself, so that they try what the AI may answer, and nothing else. Only the assistant's own words may mention the reporter, show HTML or link to the security policy. - Every change, every night and every week. The CI tries the same inputs on every change and the nightly workflow new ones; the profile
nightlytries fifty times as many, for a longer search. Atheris fuzzes the cleaning,fuzz/fuzz_sanitize.py, and whole comments,fuzz/fuzz_render.py, with a dictionary of Markdown, for a minute on every change and a quarter of an hour every week. - Every text that once got through is a test with its fix, an example that the property tests try first, and a seed of the fuzzers in
fuzz/corpus/.
What counts as a secret¶
One text that looks like a token or a key refuses the whole answer: nothing is posted, and the run fails. So the check tells keys apart from the links, paths and names of code that answers are full of:
flowchart TB
accTitle: How the apply job decides whether a text of the answer holds a secret
accDescr: A known kind of token refuses the answer wherever it is. Any other text is looked at in runs of 40 or more letters, digits and the characters plus, slash, equals sign, underscore and dash, after the owner, the repository and the commit or branch of every link to GitHub are left out, unless the name of the repository or the branch has 32 or more characters. A run without lower case, upper case and digits is harmless. A run with the plus or equals sign of Base64, or whose letters read like noise, is a key, unless it is part of the path of a file that git tracks. Any other run is harmless.
text["a text of<br>the answer"] --> known{"a known kind<br>of token?"}
known -- "yes" --> refused["the answer<br>is refused"]
known -- "no" --> runs["runs of 40 or more<br>letters, digits and +/=_-,<br>without the repository<br>of links to GitHub"]
runs --> mixed{"lower case,<br>upper case<br>and digits?"}
mixed -- "no" --> harmless["harmless"]
mixed -- "yes" --> random{"+ or = of Base64,<br>or letters that read<br>like noise?"}
random -- "no" --> harmless
random -- "yes" --> tracked{"part of a path<br>that git tracks?"}
tracked -- "yes" --> harmless
tracked -- "no" --> refused
classDef question fill:#d4a72c33,stroke:#bf8700,stroke-width:2px
classDef job fill:#8c959f1f,stroke:#8c959f,stroke-width:2px
classDef untrusted fill:#cf222e26,stroke:#cf222e,stroke-width:2px
classDef safe fill:#1a7f3726,stroke:#1a7f37,stroke-width:2px
class known,mixed,random,tracked question
class text,runs job
class refused untrusted
class harmless safe
- Known kinds of tokens count wherever they are: tokens of GitHub, Anthropic, OpenAI and Slack, access keys of AWS, JSON Web Tokens, private keys, and the addresses of Slack and Microsoft Teams webhooks, which let anyone post as their owner. A placeholder without the secret at the end of such an address, such as
https://hooks.slack.com/services/YOUR/WEBHOOK/URL, is none. - Letters that read like noise change between lower and upper case at least once in five pairs of neighbors, in runs of lower case of 3.5 letters or fewer on average. A random key, such as the 40 characters of a secret key of AWS, changes at about every second pair, in runs of two letters. Words change only where a new word starts, and run on in lower case:
src/main/java/com/example/OAuth2ClientConfiguration.javaandcustom_components/autodarts/AutodartsBoardManager2Coordinator.pyare names, not keys. - Links to GitHub name owners and repositories that people chose, such as
Dennis-Otto, and commits; the rest of the link, such asissues/12or the path of a file, is checked like any other text. So is a repository or a branch with a name of 32 or more characters, so that no key passes for one. - The files of the repository are public, whatever their names look like, such as the test devices of a project named after random identifiers. Git lists them only for an answer that would be refused without them.
Of 134,000 paths in five large projects, Spring Security, Home Assistant, VS Code, Nextcloud and Elasticsearch, the check takes one for a key, a test device named after a random identifier, which passes in its own repository; before, it took 6,700. Of 4,000 links to issues, releases, pull requests and lines on GitHub it takes none, where it took 1,100. Of 20,000 random secret keys of AWS, it lets 36 pass instead of 23: those that lack a digit, lower case or upper case, as before, and a few whose letters happen to read like words.
Threats and countermeasures¶
| Threat | Countermeasure | Evidence |
|---|---|---|
| An issue tells the AI to run a command, read a secret or reach a server | The engine runs with the tools Read, Grep and Glob alone, with a read-only token, and the egress firewall allows only named hosts |
test_each_engine_can_only_read_the_checkout, test_only_the_analyze_job_runs_an_engine_and_only_with_read_permissions, test_the_firewall_template_allows_only_named_hosts in tests/test_templates.py |
A text of an issue or a comment passes for the maintainer, for another comment or for the AI's earlier answer, with a heading like those of issue.md |
Each text stands in a code fence one backtick longer than its longest run of backticks, so that nothing in it can end the fence; only the assistant writes the headings with author, role and date, and the prompt says that a fence holds only that person's text | test_no_text_forms_a_heading_or_a_comment_in_issue_md in tests/test_assistant.py; test_no_text_forms_the_structure_of_issue_md_or_hides_in_it in tests/test_properties.py |
| An issue hides instructions for the AI in characters that GitHub shows as nothing, such as Unicode tags, zero-width spaces or the controls of the direction of writing, also for the analyses of later issues | The format characters of Unicode and the code points it calls default ignorable are removed from issue.md, the excerpts of the other issues and discussions, and the AI's answer before its checks; a notice in the log counts them |
test_the_invisible_characters_are_those_that_unicode_names, test_no_invisible_character_reaches_the_engine, test_the_answer_loses_its_invisible_characters_before_the_checks, test_the_engine_reads_no_text_that_github_hides, and test_no_file_of_the_project_hides_characters for the project's own files, in tests/test_assistant.py; test_no_text_forms_the_structure_of_issue_md_or_hides_in_it and test_the_engine_reads_no_text_that_github_hides in tests/test_properties.py |
| An issue makes the AI post a mention, a link, an image or HTML | apply removes images, HTML and headings, turns mentions, e-mail addresses, references and addresses that GitHub would link elsewhere into code, and keeps only links to allowed sites and to issues that exist; it places the text in the comment so that its code stays code |
test_mentions_images_html_and_headings_are_removed, test_only_links_to_known_places_stay_links, test_tricky_text_is_cleaned_safely, test_what_github_would_link_elsewhere_becomes_code, test_the_whole_comment_mentions_nobody_and_links_nowhere_else, test_no_table_cuts_code_in_two in tests/test_assistant.py; property tests of the text, of the places it takes in a comment and of whole comments in tests/test_properties.py, judged by the oracle of tests/oracle.py; fuzzing with Atheris in fuzz/fuzz_sanitize.py and fuzz/fuzz_render.py |
| An issue keeps the assistant busy, so that no analysis of the repository finishes, because each one reads many issues; or a repository's tens of thousands of issues and pull requests do | An analysis reads every open issue but only the max_issues closed issues and the max_discussions discussions that changed last, in pages of a hundred without pull requests, and a reply only its issue. Each text is read up to GitHub's 65,536 characters, by patterns that look at each character a bounded number of times; the AI reads at most 20,000 characters of each text of its issue, and HTML comments are removed as GitHub hides them, also one that starts a line and never ends |
test_the_context_reads_every_open_issue_and_the_closed_ones_that_changed_last, test_the_discussions_end_at_their_limit, test_no_text_makes_reading_an_issue_slow, test_no_text_makes_the_cleaning_slow, test_forms_read_as_they_always_did in tests/test_assistant.py; property tests against the earlier patterns in tests/test_properties.py; fuzzing with Atheris in fuzz/fuzz_issue_text.py, which fails on a reading that takes longer than half a second |
| Someone steers the assistant with a command: mutes it, closes an issue as a duplicate of their own, asks the AI to post something, or makes it analyze again and again | A command counts only from a maintainer or the reporter of the issue; from anyone else, it is an ordinary comment. The reporter may only say not-duplicate and ask once per issue for a new analysis, while it waits for triage, within the limits of the day, which record the run in the marker of the assistant's own analysis. The question of /assistant ask reaches the AI in comment.md, never in the prompt, and its answer passes every check of apply, once for each question; the event job runs no AI |
test_a_command_is_the_first_line_of_a_maintainer_s_or_the_reporter_s_comment, test_a_command_of_anyone_else_is_an_ordinary_comment, test_the_reporter_may_ask_for_a_new_analysis_once, test_the_reporter_s_again_counts_for_the_limits_of_the_day, test_the_answer_to_a_maintainer_s_question_is_checked_and_posted_once, test_an_answer_goes_only_to_a_maintainer_s_question in tests/test_assistant.py; test_a_whole_comment_mentions_nobody_and_links_only_allowed_places in tests/test_properties.py |
| The AI posts a secret that it found or was given | An answer with anything that looks like a token or a key is never posted, as What counts as a secret shows | test_an_answer_with_something_like_a_secret_is_never_posted, test_ordinary_long_words_links_and_paths_are_no_secrets, test_a_file_that_git_tracks_is_no_secret in tests/test_assistant.py; test_a_token_anywhere_in_the_answer_is_refused, test_a_path_that_git_tracks_is_never_a_secret in tests/test_properties.py |
An answer runs a workflow command in the log of the run, such as ::add-mask::, ::stop-commands:: or a forged ::error::, or its comment ends the code block of the preview in the summary of a dry run |
Every error and notice of the assistant is escaped as GitHub's Actions toolkit escapes it, and a key of the answer that the schema doesn't know shows as a short JSON string. The summary turns workflow commands off with a random token while it prints the engine's words, and the fence of a preview is longer than any run of backticks in the comment | test_no_text_of_the_engine_runs_a_workflow_command, test_a_workflow_command_keeps_its_message_on_its_line, test_each_summary_stops_the_commands_with_a_token_of_its_own, test_only_a_terminal_outside_a_runner_gets_the_summary_alone, test_a_preview_can_t_close_its_fence in tests/test_assistant.py |
| An answer mentions someone or links elsewhere in a reason outside its comment: a line of the log and the summary, or the reason for a label or a duplicate on the timeline | Such reasons are cleaned like a comment, joined into one line before the cleaning, so that no code block keeps a mention as it is, and shortened to 280 characters before the cleaning, so that no cut loses the backtick that keeps a mention in its code | test_the_reasons_on_the_timeline_are_cleaned_like_comments, test_the_engine_s_reasons_in_the_log_are_one_cleaned_line, test_a_shortened_line_keeps_its_cleaning in tests/test_assistant.py; property tests in tests/test_properties.py; fuzzing with Atheris in fuzz/fuzz_sanitize.py |
A label name or the model of config.toml adds arguments to the command line of the engine, or breaks it, so that no issue gets an analysis |
The schema writes an apostrophe of a label name as \u0027, which ends no quotes and reads back as the same JSON. check and the engine command refuse a model that is not a name of letters, digits and ._:@/[]-, and one that starts with a dash, which the engine's action would read as another flag |
test_the_schemas_fit_into_the_engine_arguments, test_a_model_that_would_add_arguments_is_refused in tests/test_assistant.py; test_a_model_that_would_add_arguments_fails_the_check_and_the_engine in tests/test_setup.py |
| Anyone opens many issues, also from many accounts and all at once, or comments again and again after a release, so that the paid engine runs without end or uses up a subscription that other repositories share (denial of wallet) | The event job starts an analysis, a follow-up or a reply after a release only while the 24 hours before hold fewer than max_per_day new issues of people who don't maintain the repository, follow-ups and replies after a release; a new issue counts every issue opened before it, also one that a limit kept from the AI, so neither a flood at once nor issues that keep the AI from answering get past the count. A new issue also needs its reporter to have opened fewer than max_per_author_per_day, and the AI reads only release_replies comments of the reporter after each release. The event job records each follow-up and reply after a release as it starts, in the marker of the assistant's own comment that the run answers, which only the assistant can change, so neither runs that still go on nor deleted comments get past the count. Over a limit, the issue keeps its labels and needs-triage for the maintainer, and the run says so in a notice. A limit that is no whole number fails check and stops the run instead of lifting the limit |
test_a_new_issue_beyond_the_daily_limit_waits_for_the_maintainer, test_only_the_new_issues_of_reporters_before_this_one_count, test_the_answers_of_the_day_count_toward_the_daily_limit, test_a_follow_up_counts_while_it_still_runs, test_one_reporter_gets_three_analyses_a_day, test_the_ai_reads_two_comments_of_the_reporter_after_the_release, test_deleted_comments_after_the_release_get_no_more_replies, test_maintainers_are_never_limited, test_a_limit_that_is_no_whole_number_stops_the_run in tests/test_assistant.py; test_config_problems_are_found in tests/test_setup.py |
| Someone comments while a reply of the AI waits in line, so that it judges their comment instead of the one that started it: a "still broken for me too" that reopens an issue whose reporter just confirmed the fix, or a question of someone else that marks the issue as waiting for its reporter | The context step and the apply job read the comment of the run's event. The AI judges that comment alone, in comment.md, and apply reads it again and acts only when its author is the reporter (release-reply) or a maintainer (maintainer-reply); a run by hand judges the newest such comment |
test_a_reply_reads_the_issue_and_the_comment_that_started_it, test_a_release_reply_acts_only_on_the_reporter_s_comment_that_it_judged, test_a_maintainer_reply_acts_only_on_the_maintainer_s_comment_that_it_judged, test_context_and_apply_judge_the_comment_of_the_event in tests/test_assistant.py |
| An issue steers the analyses of later issues into calling them its sure duplicates, so that they close after 3 days | Only an open original that a maintainer opened or triaged gets the notice that closes a duplicate; any other becomes a suggestion for the maintainer. A comment of anyone, and a thumbs down of the reporter or of anyone who may push to the repository, keep the issue open, and so does an original that is closed or no longer vouched for when the 3 days are over | test_a_sure_duplicate_of_an_untriaged_issue_is_only_suggested, test_a_thumbs_down_of_the_reporter_or_a_maintainer_keeps_the_issue_open, test_other_reactions_do_not_object, test_a_notice_against_an_untriaged_original_closes_nothing in tests/test_assistant.py |
| An answer that breaks the schema changes the wrong labels or issues | The answer is checked against the schema of its task, the labels and the issues that exist | test_an_answer_that_breaks_the_schema_is_refused, test_any_answer_is_checked_or_refused |
| Text of an issue runs as a shell command in a workflow | Every input goes through the environment; no expression of untrusted text stands in a run: script |
test_untrusted_text_never_reaches_a_shell_script, test_the_action_passes_every_input_through_the_environment |
| A pull request runs its code with the permissions of the lifecycle | The lifecycle checks out the default branch and runs no code of the pull request | test_merges_and_releases_start_the_lifecycle_without_running_pull_request_code |
| A compromised action or tool in a job with a write token sends the token elsewhere | Harden-Runner blocks every host but api.github.com and github.com in every job without the AI, before any other step, and turns off sudo and containers so that no step lifts the block; the Action test runs the action behind the same block |
test_jobs_without_the_ai_reach_only_github, test_the_action_runs_in_ci_behind_the_block_of_the_templates in tests/test_templates.py |
| A public log or timeline reveals a possible vulnerability | The Findings workflow names an open alert by number and link only, and can only dismiss alerts. The comment on a possible security report only points to private reporting, the timeline shows a fixed reason for its labels, and the prompt asks for one neutral sentence without details in its summary, analysis and reason | test_other_findings_stay_open_and_name_only_number_and_link, test_the_findings_workflow_may_only_dismiss_alerts_and_runs_no_ai, test_a_possible_vulnerability_is_not_discussed_in_public |
| A repository's copy of a workflow drifts into something weaker | check compares every workflow with its template, and the tests pin the security rules of the templates |
tests/test_templates.py |
Residual risks¶
- The AI can be wrong, or be steered into a wrong but harmless answer, such as a wrong label or a misleading analysis. The maintainer reads every issue and has the last word.
- Within the limits of the AI runs, strangers still spend the repository's money: by default about 20 runs a day, about $4 at the prices of the API, each run at most its turns and the dollars of
max_budget_usd; answers that come in within the same few seconds can pass the count together, because each event job counts before it records its run. A flood of issues uses up the day, so that the issues of real reporters wait for the maintainer until 24 hours have passed. A secret of the engine that belongs to the repository alone caps the cost: an API key of a workspace with a monthly spend limit. The token of a subscription shares its usage limits with every other repository and person that uses it, so a flood in one repository can stop the analyses of all of them. - The reporter of an issue that a maintainer triaged can still edit its text later, and steer the analyses of later issues into calling them its duplicates. Their reporters get the notice and keep the issue open with a comment or a thumbs down, and the maintainer sees the label
possible-duplicatefor three days. - The AI reads the whole checkout of the default branch. Whatever is in the repository may appear in its analysis, so keep secrets out of the repository, as every repository should.
- The provider of the engine receives the text of the issue and the parts of the repository that the AI reads. The notice in
SUPPORT.mdtells reporters which AI reads their issue. - The assistant is as safe as the permissions that the workflows of a repository grant it;
checkkeeps them equal to the templates. - Harden-Runner fails open. In a private repository whose owner has no subscription or trial of StepSecurity, and when StepSecurity answers at the start of a job that its service is unavailable, Harden-Runner installs no agent, and the job runs without the block. Its write token could then reach any host, as before the block; the pins of the actions to commit hashes and jobs that run no AI and no code of an issue or a pull request stay the defense.
- StepSecurity, the maker of Harden-Runner, is a third party that receives data from every job without the AI: the repository, the run and the job at its start, and what its agent records during the job, the hosts that the steps connect to, blocked or allowed, and the processes that connect. Its insights page for the run shows them.
- The runner shows the inputs of a step in its log, and the log of a public repository's run is public, for 90 days unless the repository keeps logs for less. So the log of the
applyjob holds the whole answer of the AI, also what is never posted, such as its summary of a possible security report. The prompt asks for one neutral sentence without details there, but an AI can miss that; hiding the answer would take an artifact between the jobs. A maintainer who finds too much in a run deletes its logs.

