Skip to content

feat(loop): gate the loop's reading list on author association, in CI - #285

Merged
gplanchat merged 2 commits into
mainfrom
feat/loop-issue-trust-gate
Sep 7, 2026
Merged

feat(loop): gate the loop's reading list on author association, in CI#285
gplanchat merged 2 commits into
mainfrom
feat/loop-issue-trust-gate

Conversation

@gplanchat

Copy link
Copy Markdown
Owner

Follow-up to #284. The loop it added could not tell a teammate's issue from a stranger's.

The problem

The agentic loop reads open issues to decide what to work on. This repository is public, and gh issue list reports no author:

264  OPEN  [plugin] Split the dashboard into a list route and a run route     2026-09-02

Number, state, title, labels, date. A stranger's issue and the owner's arrived at the conductor as the same thing — the model was not ignoring the distinction, it never had it.

This is not hypothetical: 12 of the 25 currently open issues carry author_association: NONE.

The gate

A CI workflow labels every issue by author_association on opened, edited, reopened and transferred; loop.sh then reads only issues carrying loop:trusted or loop:cleared.

Association Label In the loop's reading list
OWNER, MEMBER, COLLABORATOR loop:trusted yes
CONTRIBUTOR, FIRST_TIME_CONTRIBUTOR, NONE, anything else loop:untrusted only once a human adds loop:cleared

Four properties, in order of how much they matter:

  • The decision is metadata, not judgement. Author association cannot be spoofed by the issue text, which is the one thing an attacker controls. No model, no API key, no interpretation.
  • It fails closed. Anything that is not one of the three team roles is untrusted — an empty value from a malformed payload, a lowercase spelling, a role GitHub adds later. CONTRIBUTOR is the trap the test guards: it reads like membership and means one merged pull request.
  • An edit withdraws clearance. Otherwise the gate is defeated by opening something harmless, waiting for a human to clear it, then editing the text. Clearance is a statement about content that was read.
  • It runs once per issue, not once per tick. The boundary is both the right place for a trust decision and the cheap one.

Why there is no model in that workflow

An LLM in a job that reads attacker-controlled text while holding a token and issues: write is the classic pwn-request shape. It would also buy little: the deterministic gate already stops a stranger's issue from being acted on unsupervised, whatever the text says. The model-assisted injection scan stays in loop/triage.md, where the text has to be read anyway and the blast radius is a throwaway worktree.

If you want the model layer in CI as well, say so and I will add it — but I would not put the API key there for what it adds.

Tested

The classification is a security boundary, so it lives in bin/ with an offline test, following the existing bin/prises-check.sh + bin/prises-check-test.sh pattern. The workflow runs that test on every change to itself.

classification
  ok    OWNER                    -> loop:trusted
  ok    CONTRIBUTOR              -> loop:untrusted
  ok    (empty)                  -> loop:untrusted
  ok    owner                    -> loop:untrusted
  ok    SOME_FUTURE_ROLE         -> loop:untrusted
an edit withdraws a human's clearance
  ok    edited -> loop:cleared removed
  ok    opened -> clearance left alone

Inverting one branch of the classification (moving CONTRIBUTOR to trusted) makes the suite fail — verified, so the test is load-bearing rather than decorative.

After merging

Run the workflow once via workflow_dispatch to backfill. The event triggers only see new activity, so every already-open issue would otherwise stay invisible to the loop for ever. Until that backfill runs the loop reads no issues at all — which is the correct direction to fail, and was confirmed against the live repository.

Then the 12 NONE-authored issues need a decision: read and label loop:cleared one by one, or add the author as a collaborator, which flips them to loop:trusted on their next event.

Not covered

The loop currently reads issue titles only, so comments are not yet in its context. The moment bodies or comments enter the reading list, a trusted issue becomes a place a stranger can write. Noted in WA007 as the thing to revisit before that happens.

gplanchat and others added 2 commits September 7, 2026 10:50
The loop could not tell a teammate's issue from a stranger's, and this
repository is public: 12 of its 25 open issues are authored by accounts with no
association to it. `gh issue list` reports no author at all, so the information
never reached the model — it did not ignore the distinction, it never had it.

The gate belongs at the boundary rather than in the tick. A workflow labels each
issue by `author_association` when it is opened, edited, reopened or
transferred, and loop.sh reads only issues carrying `loop:trusted` (the team) or
`loop:cleared` (a human read it and said so).

Four properties, in order of how much they matter:

- The decision is metadata, not judgement. Author association cannot be spoofed
  by the issue text, which is the one thing an attacker controls.
- It fails closed. Anything that is not OWNER, MEMBER or COLLABORATOR is
  untrusted, including an empty value, a lowercase spelling, or a role GitHub
  adds later. CONTRIBUTOR is the trap: it reads like membership and means one
  merged pull request.
- An edit withdraws clearance. Otherwise the gate is defeated by opening
  something harmless, waiting for a human to clear it, then editing the text.
- It runs once per issue instead of once per tick.

No model in that workflow, deliberately. An LLM in a job that reads
attacker-controlled text while holding a token and `issues: write` is the
classic pwn-request shape, and it would buy little: the deterministic gate
already stops a stranger's issue being acted on unsupervised, whatever it says.
The model-assisted injection scan stays in triage.md, where the text is read
anyway and the blast radius is a throwaway worktree.

The classification is a security boundary, so it lives in bin/ with its own
offline test, following bin/prises-check.sh. The test asserts the fail-closed
cases and that only an edit withdraws clearance; inverting one branch of the
classification makes it fail.

workflow_dispatch backfills, because the event triggers only see new activity
and every already-open issue would otherwise stay invisible to the loop for
ever. Until that runs the loop reads no issues at all, which is the correct
direction to fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@gplanchat
gplanchat merged commit ca9ae79 into main Sep 7, 2026
35 checks passed
@gplanchat
gplanchat deleted the feat/loop-issue-trust-gate branch September 7, 2026 09:06
gplanchat added a commit that referenced this pull request Sep 7, 2026
Both merged (#284, #285). A claim left behind lies for as long as it stays,
and prises-check.sh would flag them stale on its next daily sweep.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant