LearnNewsExamplesServices
Frontmatter
id16660
titleThe PR-body lint cannot scan for confidential tokens without leaking them into a public log
stateClosed
labels
bugaiagent-os
assigneesneo-opus-grace
createdAtAug 8, 2026, 8:24 AM
updatedAtAug 8, 2026, 11:24 AM
githubUrlhttps://github.com/neomjs/neo/issues/16660
authorneo-opus-grace
commentsCount1
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 8, 2026, 11:24 AM

The PR-body lint cannot scan for confidential tokens without leaking them into a public log

Closed Backlog/active-chunk-13 bugaiagent-os
neo-opus-grace
neo-opus-grace commented on Aug 8, 2026, 8:24 AM

Context

Successor to #16535, carved out while implementing PR #16659.

#16535's AC3 requires the confidentiality check on "the agent PR-body lint, issue bodies, issue comments, and Discussion bodies/comments — every surface the open internet can search." PR #16659 delivers all of those except the PR-body lint, because the MCP dispatch guard covers everything that goes through PUBLIC_GITHUB_WRITE_TOOLS and the Actions lint is the one path that does not.

Carving it out rather than rushing it: the Actions path has two design constraints that invert the local design, and neither is a wiring detail.

The Problem

.github/workflows/agent-pr-body-lint.yml and agent-pr-review-body-lint.yml are the guards on the two body surfaces authored outside MCP dispatch. Reusing confidentialTokenScanner there looks like a five-line step and is not.

1. The Actions log is public — the fix can commit the harm

The local design deliberately returns the matched token and its offset so the author can scrub. That is correct for a local tool response and catastrophic in Actions: workflow logs on a public repository are world-readable, so a lint that prints the match publishes the client name to the exact audience #16535 exists to protect against. It would be the harm delivered by the guard.

GitHub masks registered secret values in logs, but that masking is a backstop against accidental echo, not a design to lean on — and it does not cover derived output like an offset-annotated excerpt or a diff context line.

So this path needs the inverse output contract: report that a token matched and where, never which. matches[].token must not cross into Actions output, which the current scanner returns unconditionally.

2. Fork PRs do not receive secrets — coverage inverts against trust

The denylist cannot be committed (#16535 OQ1), so in Actions it must come from a repository secret. pull_request runs from forks receive no secrets. The check would therefore be unchecked precisely on contributions from outside the org — the least-trusted authors — and enforced only on branches from within it.

That is not a reason to skip it: an internal agent authoring a PR body is the actual recurrence population, all three recorded instances being ours. But a guard whose coverage is strongest where trust is highest and absent where it is lowest must say so, or it will be read as covering a surface it does not.

pull_request_target would supply secrets and is the wrong answer — it runs trusted-context workflows against untrusted head code, which trades a confidentiality gap for a code-execution one.

The Architectural Reality

  • .github/workflows/agent-pr-body-lint.yml, .github/workflows/agent-pr-review-body-lint.yml — the two Actions surfaces.
  • ai/services/shared/confidentiality/confidentialTokenScanner.mjs (PR #16659) — reusable, but its matches[].token is designed for a local caller and must be projected away here.
  • SCAN_OUTCOME.unchecked — already a first-class state, so "secret absent on a fork run" has a correct representation rather than needing a new one.
  • The MCP dispatch guard in toolService.mjs — covers issue/discussion/comment/review surfaces; a PR body created via gh pr create reaches none of it.

Contract Ledger

Target Surface Source of Authority Proposed Behavior Fallback Docs Evidence
Actions lint output this ticket reports outcome + offset count; never the matched token unchecked when the secret is absent workflow comment stating why the token is withheld here but returned locally a spec asserting no denylist value appears in the emitted output for a body that matches
denylist source in Actions repo secret supplied to non-fork runs unchecked, stated in the check summary note that fork runs are structurally unscanned a fork-shaped run reports unchecked, not clean
scanner projection confidentialTokenScanner a caller-selectable projection that omits matches[].token JSDoc naming the public-log hazard asserting the projected shape has no token field at all, not merely an empty one

Acceptance Criteria

  • A PR body containing a denylisted token fails the agent PR-body lint.
  • The failure output contains no denylist value — asserted by a spec that scans the emitted text for the token and fails if present. An assertion that the output "mentions a match" passes today and proves nothing.
  • A run without the secret reports unchecked in the check summary, never a pass, and names that enforcement did not run.
  • Fork-run coverage is documented where a reader meets it, not only in this ticket.
  • The same treatment covers agent-pr-review-body-lint.yml, since a review body is equally googleable.

Out of Scope

  • The MCP dispatch guard — delivered in PR #16659. This ticket is the one surface it structurally cannot reach.
  • pull_request_target as a route to secrets on fork runs. Named as rejected above; adopting it needs its own security review, not a line in this ticket.
  • Historical remediation of existing occurrences — operator-directed sweep, per #16535.
  • resources/content/ mirrors — a separate public surface reached by the sync pipeline; believed covered transitively but verified by nobody, and carried forward from #16535 in that wording rather than upgraded.

Avoided Traps

  • Reusing the local output contract. Returning the matched token is right locally and publishes it here. The same function, two callers, opposite disclosure rules.
  • Reading a green lint as coverage. Without the secret it is unchecked, and on forks it is structurally unchecked — a check that is green because it never ran.
  • pull_request_target for secret access. Trades a confidentiality gap for arbitrary-code execution in a trusted context.

Decision Record impact

none — extends an established boundary discipline to a surface outside MCP dispatch.

Related

#16535 (parent — MCP-dispatch surfaces delivered by PR #16659) · PR #16659 · #16520.

Live latest-open sweep: checked the latest 20 open issues at 2026-08-08T06:23:20Z; no equivalent found. A2A in-flight claim sweep: 8 most recent messages scanned, no competing [lane-claim] on this scope.

Origin Session ID: 9ced67a1-8f21-4da2-a1bf-a2a968c47ed2

Retrieval Hint: query_raw_memories("PR-body lint confidential token public Actions log leak, fork PRs receive no secrets, unchecked on fork run")

Retrieval Hint: the discriminating read is that the scanner's matches[].token is correct for a local author response and is the leak itself in a public workflow log.