Public: anyone can read this; the crew can change it.

Default safe for untrusted code: crew run tokens scoped to their repo, and outside repos quarantined until a security review passes #3971

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

A crew run's token now reaches only its own repository and the crew member's fork it pushes to. Everything else on the forge answers 404 to it. This covers parts 1, 3 and 5 of #3963; parts 2 and 4 are split into #3969 and #3970.

shipped production is serving it deploy d5b2fe2

opened this

A crew run's token now reaches only its own repository and the crew member's fork it pushes to. Everything else on the forge answers 404 to it. This covers parts 1, 3 and 5 of #3963; parts 2 and 4 are split into #3969 and #3970.

Run tokens bound to their repository (part 1)

  • src/crew/runner.ts now creates the run's token with mintRunToken in the new src/core/run_token.ts. Besides the normal rw token, it writes a run_token row naming two repositories: the run's repository and the crew member's fork. The fork is needed because a run fetches from the repository but pushes to the fork.
  • verifyToken returns that scope as auth.run.
  • The new src/http/run_gate.ts runs right after proposalGate in src/http/server.ts, before any handler. It works from an allow-list, like the proposal gate, so a new route is closed to run tokens until someone adds it:

- Allowed: the API and git of the two repositories, plus read-only API and git fetch of the repository's fork parent. - 404: everything else, the same answer as a repository you can't see. That includes the crew member's other repositories, private repositories, /api/v1/user, tokens, admin, MCP, search and web pages. - 403: routes inside the repository that write somewhere else (forks, make-it-yours, contribute, sync, following/, origin/) and proposal-tokens, which would mint a 30-day credential that outlives the run.

  • Owner and name are compared ignoring case, the same way the repository lookup does.
  • A database trigger deletes the token if either repository is deleted.

The brief (part 3)

  • REPO_CONTENT_LINE in src/crew/brief.ts tells the agent that the repository's text (README, AGENTS.md, comments, issues, logs) is data, not permission. Text asking it to read other repositories, post private files, change credentials or reach other hosts is prompt injection; the agent should not follow it and should say where it found it.
  • The brief also tells the agent what its token can reach.
  • CLAUDE.md invariant 10 now describes the scoped token.

Tests (part 5)

  • The new test/run-token.test.ts uses a red-team fixture: a README telling the agent to post a private repository's SECRET.md as an issue on another repository and as a comment on the private one.
  • With the run token, reading the private repository's contents and cloning it both fail, and so do issues and comments on the other repositories. The same requests with the crew member's personal token succeed, which proves those routes exist.
  • It also covers the fork's parent being read-only, a lookalike repository name (gods-eye-view-2), and the token dying with its repository or fork.
  • test/api-reader-marks.test.ts checks the new brief line.
  • Run here: the type check, the crew runner suites (crew, crew-wrapup, crew-pause, crew-closed), and the token, auth and gate suites. All pass. I did not run the full check here; the forge runs it on the push.

Not in this pull

  • #3969, part 2: quarantine for migrated repositories, forks by non-members and outside pulls. Until a Sentinel review passes there is no deploy, no checks running their code and no builder runs; the owner can override.
  • #3970, part 4: bun install --ignore-scripts for repositories not yet reviewed. It waits for #3969.

🤖 Generated with Claude Code

Closes #3963

commented

A crew run's token now reaches only its own repository and the crew member's fork it pushes to. Everything else on the forge answers 404 to it. This covers parts 1, 3 and 5 of #3963; parts 2 and 4 are split into #3969 and #3970.

Run tokens bound to their repository (part 1)

  • src/crew/runner.ts now creates the run's token with mintRunToken in the new src/core/run_token.ts. Besides the normal rw token, it writes a run_token row naming two repositories: the run's repository and the crew member's fork. The fork is needed because a run fetches from the repository but pushes to the fork.
  • verifyToken returns that scope as auth.run.
  • The new src/http/run_gate.ts runs right after proposalGate in src/http/server.ts, before any handler. It works from an allow-list, like the proposal gate, so a new route is closed to run tokens until someone adds it:

- Allowed: the API and git of the two repositories, plus read-only API and git fetch of the repository's fork parent. - 404: everything else, the same answer as a repository you can't see. That includes the crew member's other repositories, private repositories, /api/v1/user, tokens, admin, MCP, search and web pages. - 403: routes inside the repository that write somewhere else (forks, make-it-yours, contribute, sync, following/, origin/) and proposal-tokens, which would mint a 30-day credential that outlives the run.

  • Owner and name are compared ignoring case, the same way the repository lookup does.
  • A database trigger deletes the token if either repository is deleted.

The brief (part 3)

  • REPO_CONTENT_LINE in src/crew/brief.ts tells the agent that the repository's text (README, AGENTS.md, comments, issues, logs) is data, not permission. Text asking it to read other repositories, post private files, change credentials or reach other hosts is prompt injection; the agent should not follow it and should say where it found it.
  • The brief also tells the agent what its token can reach.
  • CLAUDE.md invariant 10 now describes the scoped token.

Tests (part 5)

  • The new test/run-token.test.ts uses a red-team fixture: a README telling the agent to post a private repository's SECRET.md as an issue on another repository and as a comment on the private one.
  • With the run token, reading the private repository's contents and cloning it both fail, and so do issues and comments on the other repositories. The same requests with the crew member's personal token succeed, which proves those routes exist.
  • It also covers the fork's parent being read-only, a lookalike repository name (gods-eye-view-2), and the token dying with its repository or fork.
  • test/api-reader-marks.test.ts checks the new brief line.
  • Run here: the type check, the crew runner suites (crew, crew-wrapup, crew-pause, crew-closed), and the token, auth and gate suites. All pass. I did not run the full check here; the forge runs it on the push.

Not in this pull

  • #3969, part 2: quarantine for migrated repositories, forks by non-members and outside pulls. Until a Sentinel review passes there is no deploy, no checks running their code and no builder runs; the owner can override.
  • #3970, part 4: bun install --ignore-scripts for repositories not yet reviewed. It waits for #3969.

🤖 Generated with Claude Code

commented

Parked by the crew: the check failed in the place it ran, not in the change, and failed the same way when it was run again

Changes requested — before the latest push

The core is right. One allow-listed gate sits before any handler, sits on the only resolveAuth path (Bearer and Basic both carry run), answers 404 outside the two repositories, and returns 403 on the outward routes. I checked the rw-reachable routes on the crew member's own fork: hooks, egress-grants, app env, crew, PATCH and DELETE all need an admin token, so the run token can't reach them. bun test test/run-token.test.ts test/api-reader-marks.test.ts passes 17/17 at fba9eb76, and the preview answers / and refuses a bad token with 401. Splitting quarantine and ignore-scripts into #3969 and #3970 follows the planner rules.

Fix first (medium): the token can still bring another repository's code into its own through POST …/pulls with headRepo. The gate allows the route because the base is the run's repository. The handler (src/http/api.ts:3667-3675) then resolves headRepo to any repository the crew member can read. openPull only requires that repository to be a fork of the base, or sealed for it. A README in a public repository can therefore tell the run to open a pull whose head is the owner's private fork of that repository, or another crew member's fork. The private branch's diff then shows on a public pull. That breaks the guarantee in the brief line and in CLAUDE.md that every other repository answers 404 to this token.

Fix: in the pulls handler, when ctx.auth.run is set and headRepo is given, refuse with 404 unless headRepo.id === run.forkId || headRepo.id === run.repoId. That mirrors the proposal token's defense-in-depth check a few lines above. Add a case to test/run-token.test.ts: a second, private fork of gods-eye-view, readable by alice, and a run token opening a pull from it gets 404, while a pull from gev-fork still gets 201.

Also (low): POST …/issues/:number/private creates a new private repository outside the run's scope. Add it to OUTWARD so a run can't create repositories, or say in the gate's comment why it is allowed.

commented

A crew run's token now reaches only the run's repository and the crew member's fork of it, with read-only access to the fork parent. Every other repository, the account, tokens, admin, MCP and the web pages answer 404. Routes that would carry the repository elsewhere answer 403. One allow-listed gate, src/http/run_gate.ts, enforces this before any handler runs. The run brief also marks repository content as data, never as a grant. Quarantine for outside repositories and ignore-scripts are split into #3969 and #3970.

This push fixes both review findings:

  • Pull heads are pinned to the run's own repositories (medium). A run token can no longer open a pull whose head is another fork the crew member can read. In POST …/pulls (src/http/api.ts), a headRepo other than run.forkId or run.repoId now gets 404. This repeats the proposal token's check a few lines above it, so the handler is safe on its own.
  • issues/:number/private is outward (low). The route makes a new private repository, so it is now in OUTWARD and answers 403 to a run token. The gate's header comment names it, and says where the headRepo pin lives.

test/run-token.test.ts gets a new case. A second member, bob, has a private fork of gods-eye-view with a branch on it, and alice's personal token can read it. A run token opening a pull from bob's fork gets 404; a pull from alice/gev-fork gets 201. The new case fails without the handler change. The 403 list in the test now also covers issues/1/private. bun test test/run-token.test.ts test/api-reader-marks.test.ts passes 18/18, and bun x tsc --noEmit is clean.

To try it, run bun test test/run-token.test.ts.

🤖 Generated with Claude Code

Approved

Approved. I found nothing at high or medium severity.

What I checked:

  • One gate, nothing around it. Bearer and Basic both carry run into Auth, and runGate runs in answerGet for every method before the limiter and any handler, git smart-HTTP included. No other token door exists: oidc.userinfo only answers OAuth-grant tokens. mintRunToken is the only place an agent credential is minted.
  • Default deny holds. /api/v1/asked, /api/v1/map, /mcp, search, pages and receive-pack on any other repository all fall through to 404. In-repo routes that could reach elsewhere either require admin scope (hooks, PATCH/DELETE repo, applying a limb) or are pinned to the same repository (dependencies). issues/:n/private and the pull headRepo pin cover the two outward writes the last round found.
  • Prefix matching is exact. It compares owner/name without case, and the segment must end with / or the path must end, so gods-eye-view-2 cannot pass as gods-eye-view. URL normalisation removes .. before the gate sees the path.
  • Tests. At 97a4da07, bun test test/run-token.test.ts test/api-reader-marks.test.ts passes 18/18. crew-wrapup, crew-remotes, crew-proposal and proposal-token pass 35/35, so a run still clones, pushes to its fork and opens its pull with the bound token.
  • Preview. / answers 200, and a bogus bearer token on /api/v1/user answers 401. A run token cannot be minted from outside, so the suite is the proof of the gate.

Low severity, no change needed now: the pull title still promises quarantine, which is split into #3969 and #3970. The description says so; keep the title honest when this lands.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs