Skip to content

fix(ai): mint evidence tokens with metadata:read on CopilotKit only - #289

Merged
jpr5 merged 1 commit into
mainfrom
fix/support-evidence-token-scope
Oct 28, 2026
Merged

jpr5 merged 1 commit into
mainfrom
fix/support-evidence-token-scope

Conversation

@jpr5

@jpr5 jpr5 commented Oct 28, 2026

Copy link
Copy Markdown
Contributor

Problem

The support agent's GitHub evidence reads (read_source, read_release) fail in production. github-evidence-auth.ts asks GitHub for an installation token narrowed to permissions: { contents: 'read' }. The Outpost App (installation 146859931) holds only Issues: write, Discussions: write and Metadata: read. GitHub refuses the token request with HTTP 422 ("The permissions requested are not granted to this installation."). The worker logs [SupportAgent] GitHub evidence authentication unavailable: { code: 'token_exchange_failed' } 21 times since Oct 23, and the bot replies without source evidence.

Why we did not grant Contents

The App is installed with repository_selection: all on 197 org repositories, 91 of them private. Contents: Read would apply to every token the App mints, not only this code path. The bot reads untrusted issue text and posts in public. The evidence allowlist (support-agent.ts) is two public repositories, CopilotKit/CopilotKit and ag-ui-protocol/ag-ui. GitHub serves commits, contents and releases of a public repository to any token, so the feature does not need Contents.

Fix

  • Request permissions: { metadata: 'read' } (the App already holds it) instead of contents: 'read'.
  • Narrow the token to the CopilotKit repository (repositoryNames). A leaked token then cannot read even the metadata of other org repositories. ag-ui-protocol/ag-ui is in another org and cannot be named, but it stays readable because it is public (proved below).
  • Unit tests updated to pin the new request shape. Both changed assertions fail against the old source.

Red-green proof (real App credentials, real GitHub API)

Harness: the real SupportAgent with aimock as the model, scripted to call read_source (README.md @ main) and read_release (latest release) on both allowlisted repositories. Everything from env credentials to token minting to the GitHub fetch is production code. A fetch observer prints only method, path and status. No key or token was printed or written to disk. The harness was not committed.

RED (main @ 52e6859), 10:29 AM PDT:

[github] POST /app/installations/146859931/access_tokens -> 422 (auth) message="The permissions requested are not granted to this installation."
POST /app/installations/146859931/access_tokens - 422 with id D364:1214FD:431CBAE:5F83131:6AE230F1 in 193ms
[SupportAgent] GitHub evidence authentication unavailable: { code: 'token_exchange_failed' }
[github] POST /app/installations/146859931/access_tokens -> 422 (auth) message="The permissions requested are not granted to this installation."
POST /app/installations/146859931/access_tokens - 422 with id D364:1214FD:431CC20:5F831FA:6AE230F2 in 156ms
[SupportAgent] GitHub evidence authentication unavailable: { code: 'token_exchange_failed' }
[github] POST /app/installations/146859931/access_tokens -> 422 (auth) message="The permissions requested are not granted to this installation."
POST /app/installations/146859931/access_tokens - 422 with id D364:1214FD:431CCA0:5F832B7:6AE230F2 in 324ms
[SupportAgent] GitHub evidence authentication unavailable: { code: 'token_exchange_failed' }
[github] POST /app/installations/146859931/access_tokens -> 422 (auth) message="The permissions requested are not granted to this installation."
POST /app/installations/146859931/access_tokens - 422 with id D364:1214FD:431CDA7:5F8342B:6AE230F2 in 142ms
[SupportAgent] GitHub evidence authentication unavailable: { code: 'token_exchange_failed' }
[investigate] ended with InvalidSupportReplyError (expected: scripted final reply is not a valid answer)
[tool] read_source {"repository":"CopilotKit/CopilotKit","path":"README.md","ref":"main"} => FAILED {"status":"unavailable","reason":"auth_unavailable","resource":"ref","repository":"CopilotKit/CopilotKit","ref":"main"}
[tool] read_release {"repository":"CopilotKit/CopilotKit","tag":"channels/v0.12.0"} => FAILED {"status":"unavailable","reason":"auth_unavailable","resource":"release","repository":"CopilotKit/CopilotKit","tag":"channels/v0.12.0"}
[tool] read_source {"repository":"ag-ui-protocol/ag-ui","path":"README.md","ref":"main"} => FAILED {"status":"unavailable","reason":"auth_unavailable","resource":"ref","repository":"ag-ui-protocol/ag-ui","ref":"main"}
[tool] read_release {"repository":"ag-ui-protocol/ag-ui","tag":"release/2026-10-21"} => FAILED {"status":"unavailable","reason":"auth_unavailable","resource":"release","repository":"ag-ui-protocol/ag-ui","tag":"release/2026-10-21"}

GREEN (this branch), 10:30 AM PDT:

[github] POST /app/installations/146859931/access_tokens -> 201 (auth) granted={"metadata":"read"} selection=selected
[github] GET /repos/CopilotKit/CopilotKit/commits/main -> 200 (auth)
[github] GET /repos/CopilotKit/CopilotKit/contents/README.md?… -> 200 (auth)
[github] GET /repos/CopilotKit/CopilotKit/releases/tags/channels%2Fv0.12.0 -> 200 (auth)
[github] GET /repos/ag-ui-protocol/ag-ui/commits/main -> 200 (auth)
[github] GET /repos/ag-ui-protocol/ag-ui/contents/README.md?… -> 200 (auth)
[github] GET /repos/ag-ui-protocol/ag-ui/releases/tags/release%2F2026-10-21 -> 200 (auth)
[investigate] ended with InvalidSupportReplyError (expected: scripted final reply is not a valid answer)
[tool] read_source {"repository":"CopilotKit/CopilotKit","path":"README.md","ref":"main"} => OK 1 source(s): https://github.com/CopilotKit/CopilotKit/blob/b30ff9a1dc115c06d406ebaec1b06b724fcf7ff5/README.md (6000 chars)
[tool] read_release {"repository":"CopilotKit/CopilotKit","tag":"channels/v0.12.0"} => OK 1 source(s): https://github.com/CopilotKit/CopilotKit/releases/tag/channels/v0.12.0 (1296 chars)
[tool] read_source {"repository":"ag-ui-protocol/ag-ui","path":"README.md","ref":"main"} => OK 1 source(s): https://github.com/ag-ui-protocol/ag-ui/blob/2eeaafd50fd913f0ace63c223c0133b091bf04be/README.md (6000 chars)
[tool] read_release {"repository":"ag-ui-protocol/ag-ui","tag":"release/2026-10-21"} => OK 1 source(s): https://github.com/ag-ui-protocol/ag-ui/releases/tag/release/2026-10-21 (5315 chars)

Scope check (same App, token request shapes probed directly): a metadata:read + repositoryNames: ['CopilotKit'] token reads all six public evidence URLs above with 200, and gets 404 on CopilotKit/internal-skills/contents/README.md (private). Without the repository narrowing the same private read gets 403.

Local checks

  • prettier --check and eslint on the changed files: clean
  • tsc --project ai/tsconfig.json --noEmit: clean
  • vitest run ai/: 3807/3807 pass. (On local Node v26.8.1, classifier-backtracking.test.ts "starts the probe child cleanly" fails only because Node prints a DEP0205 deprecation warning on stderr. It passes with NODE_OPTIONS=--no-deprecation. Unrelated to this change.)

Follow-up (not in this PR)

docs/deployment.md (GitHub App Setup) and docs/support-agent.md still tell operators to grant Contents: Read-only for evidence reads. That is now wrong and contrary to this decision; they need a separate docs update.

No App settings or Railway configuration were changed.

The support agent asked GitHub for an installation token narrowed to
contents:read. The Outpost App does not hold Contents, so GitHub refused
every exchange with HTTP 422 and every read_source/read_release call
reported auth_unavailable.

Both allowlisted evidence repositories are public, and GitHub serves
commits, contents and releases of a public repository to any token. Ask
for metadata:read, which the App already holds, and narrow the token to
the CopilotKit repository. ag-ui-protocol/ag-ui stays readable as a
public repository. The App is not granted Contents on purpose.
@jpr5
jpr5 marked this pull request as ready for review October 28, 2026 17:49
@jpr5
jpr5 merged commit 395f0dc into main Oct 28, 2026
3 checks passed
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