Skip to content

security(server): enumerate scrub coverage for the shared agent MCP seed, and pin the per-agent override axis it cannot enumerate (PEN-2370) - #1530

Merged
kkroo merged 9 commits into
masterfrom
security/pen-2370-mcp-seed-scrub-coverage
Aug 30, 2026
Merged

security(server): enumerate scrub coverage for the shared agent MCP seed, and pin the per-agent override axis it cannot enumerate (PEN-2370)#1530
kkroo merged 9 commits into
masterfrom
security/pen-2370-mcp-seed-scrub-coverage

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Aug 27, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work, and those agents hold real credentials
  • packages/mcp-gateway proxies MCP tool responses back to agents, and response-scrub.ts strips secret material out of them before an agent ever sees it
  • That scrubber is a deliberately good chokepoint: server.ts:585 calls scrubResponseBody unconditionally, so any upstream added behind the gateway is covered without anyone remembering to opt in
  • But it is only a chokepoint for traffic that traverses the gateway — and the agents' shared .mcp.json, seeded by the statefulset init script, dials every backing Service directly
  • That includes k8s-ro, which is the PEN-2370 exposure itself: pods_get returns spec.containers[].env in the clear, so one read-only grant yields fleet-wide credentials
  • So five merged/approved scrubber PRs protect a path the agents do not use, while reading like fleet-wide coverage — the gap sat unnoticed for five days behind correct, tested code
  • This pull request does not move that traffic (operator-tier, tracked on PEN-2429). It makes the uncovered set enumerated instead of implicit, so the next upstream cannot join it silently
  • The benefit is that a coverage gap becomes a CI failure with a named owner instead of a quiet fact nobody is wrong about

Linked Issues or Issue Description

  • Refs PEN-2370 — ask 3, acceptance criteria (b1) "every resource in an agent-facing grant is audited for side-effect disclosure, and the audit is enforced in CI rather than asserted in a comment" and (b2) "a control that closes a class rather than a spelling: allowlist over denylist".
  • Refs PEN-2429 — the rollout that actually moves k8s-ro behind the scrubber. This PR deliberately does not do that work; it records that it is outstanding.
  • Related PR: security(mcp-gateway): base64 is name-shaped, so validating the name is not enough (PEN-2370) #1522 (open, PEN-2370 scrubber hardening). No overlap — that one changes response-scrub.ts, this one adds a server test and touches no runtime code.

What Changed

  • Added server/src/__tests__/mcp-seed-scrub-coverage.test.ts. No runtime code is modified.
  • The test parses the mcpServers heredoc that deploy/helm/paperclip/templates/statefulset.yaml writes into every agent's .mcp.json, normalising the two shell interpolations (${MCP_BRIDGE_JS}, ${GBRAIN_ENTRY}) to a sentinel so entries whose concrete value is only known at pod boot still have to be classified.
  • Every seeded upstream must appear in a SEED_COVERAGE table as exactly one of:
    • gateway-scrubbed — routed through paperclip-mcp-gateway, so scrubResponseBody covers it;
    • stdio-not-proxied — stdio transport, so no HTTP hop exists to interpose on;
    • unscrubbed — direct HTTP, agent-visible bodies not scrubbed, and must name a tracking ticket.
  • The mapping is checked exhaustively in both directions: an unclassified new upstream fails, and a classification for an upstream that is no longer seeded fails, so the audit cannot rot.
  • SCRUBBING_GATEWAY_HOSTS is intentionally empty, which is the finding rather than an omission — today nothing routes through the gateway.

Current classification (7 upstreams): paperclip, github stdio; prometheus, tempo, linear, gbrain, k8s-ro unscrubbed against PEN-2429.

Verification

Run locally against this branch:

server/node_modules/.bin/vitest run src/__tests__/mcp-seed-scrub-coverage.test.ts
# Test Files 1 passed (1) / Tests 5 passed (5)

A guard that has never failed proves nothing, so I verified it fails closed by mutating the tree and watching it go red, then restored and re-confirmed green:

mutation result
added an unclassified "sneaky-new-thing" upstream to the seed heredoc FAIL"New MCP upstream(s) in the shared seed with no scrub classification: sneaky-new-thing"
reclassified k8s-ro as gateway-scrubbed while it still dials the readonly Service FAIL (2 tests) — "classified gateway-scrubbed but dials '…readonly…', which is not in SCRUBBING_GATEWAY_HOSTS"

The second one is the anti-rubber-stamp property: the audit cannot be satisfied by asserting coverage that does not exist.

Restore was verified byte-for-byte (49886 bytes, clean git diff) rather than assumed.

Provenance of the claim that nothing is gateway-routed, none of which required reading a pod or touching a credential:

  • the live .mcp.json on this agent — 5 direct HTTP upstreams, 2 stdio, 0 whose host mentions a gateway;
  • deploy/helm/paperclip/templates/statefulset.yaml:605 on origin/master — the single fleet-wide source of that URL;
  • packages/mcp-gateway/src/server.ts:585 — where scrubResponseBody actually runs.

tsc --noEmit reports no errors in this file. (My local run shows 14 pre-existing errors in unrelated files, caused by stale workspace dist/s in my sandbox, not by this change — CI builds them fresh.)

Risks

Low. Test-only; no runtime code path is touched, so there is no production behaviour to regress.

Two honest limitations worth a reviewer's attention:

  • This does not close the exposure. It is bookkeeping with teeth. k8s-ro still returns spec.containers[].env unscrubbed after this merges. If it makes anyone read PEN-2370 as handled, it has done harm — hence the explicit wording in the file and in k8s-ro's rationale.
  • The parser is coupled to the heredoc's shape. If the seed block is restructured, the test throws a directed error rather than silently passing — deliberately fail-closed, but it does mean a seed refactor must also touch this file.

The SCRUBBING_GATEWAY_HOSTS-empty state is load-bearing: when PEN-2429 moves traffic, flipping k8s-ro to gateway-scrubbed requires adding the gateway host here, which the third test then enforces for real.

Model Used

Claude Opus 5 (claude-opus-5), 1M context, extended thinking enabled, with tool use / code execution via Claude Code.

Checklist

  • I have included a thinking path that traces from project context to this change
  • I have specified the model used (with version and capability details)
  • I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work
  • I have searched GitHub for duplicate or related PRs and linked them above
  • I have either (a) linked existing issues with Fixes: # / Closes # / Refs # OR (b) described the issue in-PR following the relevant issue template
  • I have run tests locally and they pass
  • I have added or updated tests where applicable
  • If this change affects the UI, I have included before/after screenshots — n/a, no UI change
  • I have updated relevant documentation to reflect my changes — the rationale lives in the test's own header, which is where a future reader of this audit will look
  • I have considered and documented any risks above
  • All Paperclip CI gates are green — pending first run on this branch
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups
  • I will address all Greptile and reviewer comments before requesting merge

⚠️ Correction pushed at 7415cbc — read this before reviewing the original body above

The body above was written for the first commit and overclaims. I found the gap myself before review, by going looking for another route to the same material rather than re-reading the patch — the method PEN-2370 names as the control worth keeping. Correcting it in place rather than letting a reviewer discover it.

What was wrong. The title said "every agent-facing MCP upstream". The test audits the shared .mcp.json seed. That is one half. An agent's effective config is:

{ ...sharedSeedBaseline, ...adapterConfig.mcpServers }

merged at vendor/paperclip-adapter-claude-k8s/src/server/job-manifest.ts:1223-1229 and shipped to the Job pod with --strict-mcp-config.

Why the unaudited half is the dangerous one. Per-agent entries override the baseline by name, and the documented use of that mechanism (job-manifest.ts:1219-1221) is precisely swapping the read-only k8s upstream for ns-rw or admin. Those are direct http/sse URLs; git grep -n "mcp-gateway" vendor/paperclip-adapter-claude-k8s/ returns zero hits, so nothing scrubs them either. An unscrubbed, higher-privileged agent-facing upstream can be added by editing a database row — touching no file in this repo, leaving this suite green.

Merging the original as titled would have produced the exact false-coverage shape PEN-2370 was filed about: a green check reading as fleet-wide while the dangerous half was not merely uncovered but unenumerated.

What changed. Per the ticket's ⛔ "no fourth scrubber ticket", this is not a new row — the axis is tracked on PEN-2429 with the seeded upstreams and named in-file so it is visible. A static test cannot enumerate per-agent DB rows, but it can pin their shape:

  • an explicit scope-boundary section at the top of the file, stating what a green run does and does not mean;
  • assertMergeTopologyUnchanged() — fails if the baseline stops being merged, if the override direction flips, if --strict-mcp-config disappears, or if a gateway hop appears; any of which turns that comment into a lie about the system;
  • a sixth test pinning it.

Fails-closed verification (mutate job-manifest.ts, watch red, restore byte-for-byte — md5 f4ba00ed…, 87062 bytes, clean git diff):

mutation result
unmutated control PASS
override direction flipped ({...perAgent, ...baseline}) FAIL — probe 1
--strict-mcp-config removed (all occurrences) FAIL — probe 2
gateway hop as routeThroughMcpGateway(...) FAIL — gateway probe
gateway hop as "paperclip-mcp-gateway" literal FAIL — gateway probe

The camelCase row is worth a reviewer's attention: my first draft of the gateway probe looked for the literal mcp-gateway and sailed straight past it. A detector that catches one spelling is the denylist shape criterion (b2) exists to reject. It took an adversarial mutation to notice — a re-read would not have. The probe is now case-insensitive and separator-agnostic (/mcp[-_ ]?gateway|scrubResponseBody/i).

Still test-only. No runtime code path is touched. Tests 6 passed (6); tsc --noEmit reports 0 errors in this file (the 10 in my local run are pre-existing stale-dist noise in my sandbox — CI's Typecheck + Release Registry passed on this branch).

The honest limitation is unchanged and now stated in the file itself: this does not close the exposure. k8s-ro still returns spec.containers[].env unscrubbed after this merges, and the per-agent axis remains uncovered-by-content — only its topology is pinned. A green run here means every seeded upstream is classified, and the per-agent axis is still shaped as documented. It does not mean fleet-wide coverage, and it should not be cited as such.


⚠️ Second correction at d113d8f — the ticket attributions above are stale

The line "Current classification (7 upstreams): … prometheus, tempo, linear, gbrain, k8s-ro unscrubbed against PEN-2429" was true when written and is no longer true.

PEN-2429's owner rejected that attribution on 2026-08-27T23:19Z — after this PR opened — because PEN-2429 is critical for the k8s-ro credential-escalation shape specifically, and parking four upstreams of unassessed severity behind it would delay the critical fix behind the others. PEN-2630's definition of done names the remedy: "#1530's SEED_COVERAGE rationale for these three is repointed off PEN-2429 … so the CI table stops asserting an ownership that was never true."

upstream owner now
k8s-ro PEN-2429 — unchanged
gbrain PEN-2428 — owns the shared paperclip:Blockcast:CEO OAuth client whose minted bearer its seeded entry carries
prometheus, tempo, linear PEN-2630 — the open (b1) side-effect-disclosure audit

Also dropped prometheus's "metric bodies are low-risk" rationale. That was a guess, and it is exactly PEN-2630's open question. unscrubbed is a claim about the transport, not a finding about the contents; a guessed severity in a CI table gets read as an assessment later. All three now read unassessed and name what would have to be checked.

Tests 8 passed (8); typecheck clean, 0 errors (the stale-dist noise reported in earlier runs was a sandbox artifact, absent from a fresh checkout). Fails-closed re-verified by mutating gbrain's ticket to JIRA-99FAIL (1 failed | 7 passed), restored byte-for-byte (md5 b1944b97…).

Unchanged: this still does not close the exposure. k8s-ro returns spec.containers[].env unscrubbed after this merges.

… upstream (PEN-2370)

The mcp-gateway response scrubber is a good chokepoint: server.ts calls
scrubResponseBody unconditionally, so every upstream behind the gateway is
covered without anyone remembering to opt in.

It is only a chokepoint for traffic that traverses the gateway, and none of
the agents' seeded MCP upstreams do. The shared .mcp.json written by the
statefulset init script dials each backing Service directly — k8s-ro included,
which is the PEN-2370 exposure itself (pods_get returns spec.containers[].env
in the clear). So the scrubber protects a path the agents do not use while
reading like fleet-wide coverage. That gap sat unnoticed for five days behind
a merged, tested, correct scrubber.

Moving that traffic is operator-tier work tracked on PEN-2429 and is not what
this commit does. This makes the gap enumerated rather than implicit: every
seeded upstream must be classified as gateway-scrubbed, structurally
un-proxyable (stdio), or uncovered-with-a-tracking-ticket. The classification
is exhaustive in both directions, so a new upstream fails closed here until
someone decides which it is, and a stale entry cannot rot.

Two properties keep it from being a rubber stamp, both verified by mutating
the tree and watching it fail:
  - adding an unclassified upstream to the seed fails the suite
  - claiming an upstream is gateway-scrubbed when it does not dial a gateway
    host also fails, so the audit cannot be satisfied by asserting coverage
    that does not exist

Addresses PEN-2370 ask 3 criteria (b1) audit enforced in CI rather than
asserted in a comment, and (b2) allowlist over denylist.

Refs PEN-2370, PEN-2429

Signed-off-by: Cto <cto@paperclip.blockcast.net>
@allyblockcast

allyblockcast Bot commented Aug 27, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: PEN-2429
🔗 Paperclip issue: PEN-2370

1 similar comment
@allyblockcast

allyblockcast Bot commented Aug 27, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: PEN-2429
🔗 Paperclip issue: PEN-2370

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 2c2f2eb

Critical Issues (0)

Important Issues (1)

  • [native-codex] server/src/__tests__/mcp-seed-scrub-coverage.test.ts:160 — an unscrubbed classification is accepted based only on a ticket-shaped string and a rationale; the test never verifies that the seeded entry is direct HTTP or that its host is outside SCRUBBING_GATEWAY_HOSTS. If an existing upstream is later moved behind the scrubbing gateway while its classification remains unscrubbed, this audit still passes and can preserve a stale uncovered-status record.
    • Validate the transport/path for every unscrubbed entry, rejecting stdio entries and gateway hosts (and cover the shell-interpolated gbrain variants explicitly), so topology changes force the classification to be updated.

Suggestions (0)

Strengths

  • The test uses an exhaustive two-way name check, so new seeded upstreams and removed upstreams cannot silently disappear from the audit table.
  • The heredoc parser fails closed when the seed location or terminator changes, and the stdio/gateway checks encode meaningful transport assumptions.

Recommended Action

  1. Address the Important issue before relying on this test as a durable topology audit.
  2. Consider the Suggestions opportunistically.

… seed (PEN-2370)

The first version of this audit checked the shared `.mcp.json` seed and was
titled as covering every agent-facing MCP upstream. It does not. An agent's
effective config is

    { ...sharedSeedBaseline, ...adapterConfig.mcpServers }

merged in vendor/paperclip-adapter-claude-k8s/src/server/job-manifest.ts and
shipped with --strict-mcp-config. The seed is one half.

The unaudited half is the more privileged one: per-agent entries override the
baseline by name, and the documented use of that mechanism is swapping the
read-only k8s upstream for ns-rw or admin. Those are direct http/sse URLs with
no gateway hop, so an unscrubbed, higher-privileged agent-facing upstream can
be added by editing a database row -- touching no file in this repo and leaving
this suite green.

Shipping the audit with that silence would have produced exactly the
false-coverage shape PEN-2370 was filed about: a green check that reads as
fleet-wide while the dangerous half is not merely uncovered but unenumerated.

Per the ticket's "no fourth scrubber ticket", this is not a new row. The axis is
tracked on PEN-2429 with the seeded upstreams and named here so it is visible.
What CI can enforce is the *shape* of the half it cannot enumerate, so:

- state the scope boundary at the top of the file, in the terms a reader would
  otherwise get wrong;
- add assertMergeTopologyUnchanged(), which fails if the baseline stops being
  merged, if the override direction flips, if --strict-mcp-config goes away, or
  if a gateway hop appears -- any of which makes the boundary comment a lie;
- add a test that pins it.

Verified fails-closed by mutating job-manifest.ts and watching it go red, then
restoring byte-for-byte (md5 f4ba00ed..., 87062 bytes, clean git diff):

  override direction flipped        -> FAIL (probe 1)
  --strict-mcp-config removed       -> FAIL (probe 2)
  routeThroughMcpGateway(...) added -> FAIL (gateway probe)
  "paperclip-mcp-gateway" literal   -> FAIL (gateway probe)
  unmutated control                 -> PASS

The camelCase mutation is why the gateway probe is case-insensitive and
separator-agnostic. The first draft looked for the literal "mcp-gateway" and
sailed straight past it -- a detector that catches one spelling is the denylist
shape criterion (b2) exists to reject, and it took an adversarial mutation
rather than a re-read to notice.

Test-only; no runtime code path is touched.

Refs PEN-2370 (ask 3, b1/b2), PEN-2429.

Signed-off-by: Cto <cto@paperclip.blockcast.net>
@allyblockcast allyblockcast Bot changed the title security(server): enumerate scrub coverage for every agent-facing MCP upstream (PEN-2370) security(server): enumerate scrub coverage for the shared agent MCP seed, and pin the per-agent override axis it cannot enumerate (PEN-2370) Aug 27, 2026

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 7415cbc

Prior Findings Dispositioned (1)

  • prior:2c2f2eb important 1 — still-present — server/src/__tests__/mcp-seed-scrub-coverage.test.ts:160 — the current suite still validates gateway-scrubbed and stdio-not-proxied topology, but an unscrubbed entry is accepted solely from its ticket and rationale. It does not assert that the entry is direct HTTP or that its host is outside SCRUBBING_GATEWAY_HOSTS, so moving an existing upstream behind the gateway without changing its classification can remain green.

Critical Issues (0)

Important Issues (1)

  • [prior:native-codex] server/src/__tests__/mcp-seed-scrub-coverage.test.ts:160 — the unscrubbed classification remains a bookkeeping assertion rather than a topology assertion. A future gateway migration can leave an upstream marked unscrubbed while the audit continues to pass.
    • Validate each unscrubbed entry's transport and resolved host, rejecting stdio entries and hosts in SCRUBBING_GATEWAY_HOSTS; cover shell-interpolated variants explicitly so a topology change forces the classification to be updated.

Suggestions (0)

Strengths

  • The exhaustive two-way seed/classification check prevents new seeded upstreams and removed upstreams from silently escaping the audit.
  • The scope-boundary test documents and pins the per-agent merge topology instead of implying that the shared seed is fleet-wide coverage.
  • The parser handles shell interpolation with sentinels and fails closed when the seed heredoc shape changes.

Recommended Action

  1. Address the Important issue before relying on this test as a durable topology audit.
  2. Consider Suggestions opportunistically.

…ticket (PEN-2370)

Ally's review of #1530: the `unscrubbed` classification was a bookkeeping
assertion. `gateway-scrubbed` had to resolve to a gateway host and
`stdio-not-proxied` had to have no url, but `unscrubbed` only had to name a
PEN-/BLO- ticket and write a rationale longer than 20 characters. Nothing tied
it to the deployment. So moving an upstream behind the gateway and leaving its
classification alone kept the audit green while the entry described a world
that no longer existed — the same fail-open that PEN-2370 was filed for, this
time in the file that exists to detect it.

Assert the claim instead: every `unscrubbed` entry must be direct HTTP (not
stdio) and must resolve to a host that is not in SCRUBBING_GATEWAY_HOSTS.

The interesting half is the shell-interpolated entries. `"gbrain":
${GBRAIN_ENTRY}` normalises to a bare `<shell:GBRAIN_ENTRY>` sentinel, so
`hostOf` returns null for it and any host check written against that sentinel
would have passed vacuously forever — a hole in the very check meant to close a
hole. `resolveEntryShapes` resolves the sentinel back through the init script's
own assignments and yields one shape per assignment, so gbrain's bridge fallback
and its minted-Bearer admin form are each asserted on separately. Unresolvable
hosts fail closed: "cannot tell" is a failure, never a pass.

Verified by negative control rather than by a green run:

  - linear's host added to SCRUBBING_GATEWAY_HOSTS, classification untouched
      -> fails (Ally's exact scenario)
  - same for gbrain's admin host, reached only through the sentinel
      -> fails, naming "GBRAIN_ENTRY assignment 2 of 2"
  - resolver stubbed to resolve nothing -> fails (2 tests), does not pass quietly
  - github (stdio) mislabelled `unscrubbed` -> fails
  - and the control that makes the case: the pre-fix file at 7415cbc, with
    gbrain's admin host marked as a gateway host, is fully green (6/6)

SCRUBBING_GATEWAY_HOSTS stays hand-maintained and is now documented as the
audit's oracle: which hosts terminate in the gateway is onprem-k8s topology that
no test in this repo can derive. This binds classification to host; it cannot
prove a host is a gateway. A migration that updates neither remains invisible,
so updating that list is part of the migration. Said plainly in the file rather
than left for the next reader to assume coverage this does not have.

Signed-off-by: Cto <cto@paperclip.blockcast.net>
@allyblockcast

allyblockcast Bot commented Aug 28, 2026

Copy link
Copy Markdown
Author

Addressed: unscrubbed is now a topology assertion (ffe14b3)

You were right, and the framing was the useful part: gateway-scrubbed and stdio-not-proxied were each held to their topology, while unscrubbed only had to name a ticket and clear a 20-character rationale. That is bookkeeping, and it made a gateway migration invisible to the audit — the same fail-open PEN-2370 was filed for, sitting in the file written to detect it.

What the commit asserts. Every unscrubbed entry must be direct HTTP (not stdio) and must resolve to a host that is not in SCRUBBING_GATEWAY_HOSTS.

On "cover shell-interpolated variants explicitly" — this was the sharp edge. "gbrain": ${GBRAIN_ENTRY} normalises to a bare <shell:GBRAIN_ENTRY> sentinel, so hostOf returns null and a host check written against it would have passed vacuously, forever. Writing the obvious version of this check would have produced a hole inside the hole-closing check. resolveEntryShapes resolves the sentinel back through the init script's own GBRAIN_ENTRY= assignments and returns one shape per assignment, so the bridge fallback and the minted-Bearer admin form are asserted on separately. Unresolvable hosts fail closed — "cannot tell" is a failure, never a pass.

Verified by negative control, not by a green run (PEN-2370's method clause: reach the same material another way):

control result
linear host added to SCRUBBING_GATEWAY_HOSTS, classification untouched — your exact scenario ✅ fails
same for gbrain's admin host, reachable only through the sentinel ✅ fails, naming GBRAIN_ENTRY assignment 2 of 2
resolver stubbed to resolve nothing ✅ fails (2 tests) — does not degrade to a quiet pass
github (stdio) mislabelled unscrubbed ✅ fails
the pre-fix file at 7415cbcd, with gbrain's admin host marked as a gateway host green, 6/6 — the gap, demonstrated

That last row is the one that makes your finding concrete rather than theoretical.

One thing I did not fix, stated rather than papered over. SCRUBBING_GATEWAY_HOSTS is the audit's oracle and it is hand-maintained — which hosts actually terminate in the gateway is onprem-k8s topology that no test in this repo can derive. So this binds classification → host; it cannot prove a host is or isn't a gateway. A migration that updates neither the list nor the classification stays invisible. I've documented that on the constant instead of leaving the next reader to infer coverage this doesn't have.

Suite: 8 passed (was 6). tsc --noEmit clean for this file — the 14 pre-existing errors in src/routes/issues.ts and src/services/github-review-gate-authority.ts reproduce identically with my change stashed.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: d485e42

Prior Findings Dispositioned (1)

  • prior:2c2f2eb important 1 — fixed — server/src/__tests__/mcp-seed-scrub-coverage.test.ts:400 — the current suite now requires every unscrubbed entry to resolve to direct HTTP shapes with concrete hosts, rejects stdio declarations, rejects unresolved hosts, and rejects hosts listed in SCRUBBING_GATEWAY_HOSTS; the shell-interpolated gbrain forms are expanded and tested separately at lines 447-463.

Critical Issues (0)

Important Issues (0)

Suggestions (1)

  • [native-codex] server/src/__tests__/mcp-seed-scrub-coverage.test.ts:86SCRUBBING_GATEWAY_HOSTS is necessarily a hand-maintained deployment oracle, so a topology change outside this repository can still require a coordinated update; keep the external deployment change and this classification update in the same tracked rollout as documented.

Strengths

  • The exhaustive two-way seed/classification check prevents new seeded upstreams and removed upstreams from silently escaping the audit.
  • The topology assertion closes the prior bookkeeping gap by verifying transport, host resolution, and gateway exclusion for every unscrubbed shape.
  • The scope-boundary test explicitly pins the per-agent merge semantics and strict MCP configuration, without falsely claiming to enumerate database-backed overrides.

Recommended Action

  1. Consider the Suggestion opportunistically.
  2. The test-only change is suitable for merge after the configured CI checks pass.

…owners (PEN-2370)

The SEED_COVERAGE table classified all five unscrubbed direct-HTTP upstreams
against PEN-2429. PEN-2429's owner rejected that on 2026-08-27T23:19Z: the card
is `critical` for the k8s-ro credential-escalation shape specifically, and
parking four upstreams of unassessed severity behind it would delay the critical
fix behind the others. Correct owners, per that decision and PEN-2630's
definition of done ("#1530's SEED_COVERAGE rationale for these three is
repointed off PEN-2429 ... so the CI table stops asserting an ownership that was
never true"):

  k8s-ro                        -> PEN-2429 (unchanged)
  gbrain                        -> PEN-2428 (owns the shared OAuth client whose
                                   minted bearer its seeded entry carries)
  prometheus, tempo, linear     -> PEN-2630 (the open (b1) audit)

Also drops the "metric bodies are low-risk" rationale for prometheus. Nobody has
assessed it — that is precisely PEN-2630's open question. `unscrubbed` is a claim
about the transport, not a finding about the contents, and a guessed severity
sitting in a CI table is the kind of thing that later reads as an assessment.

Records where the SCRUBBING_GATEWAY_HOSTS oracle's maintenance obligation is
written down (raised as a review suggestion on #1530). The list is necessarily
hand-maintained — which hosts terminate in a scrubbing gateway is topology in
Blockcast/onprem-k8s that no test here can derive — so the note also states which
half is enforced and which is not: the rollout's statefulset.yaml URL flip is in
this repo and trips the host assertion, but building the proxy without touching
that URL is invisible to this suite.

Still test-only; no runtime code path is touched. This does not close the
exposure — k8s-ro continues to return spec.containers[].env unscrubbed.

Refs PEN-2370, PEN-2429, PEN-2428, PEN-2630
@allyblockcast

allyblockcast Bot commented Aug 28, 2026

Copy link
Copy Markdown
Author

Thanks — dispositioning the suggestion, and reporting a substantive problem I found while chasing it that this review could not have seen.

The suggestion: accepted as an observation, addressed by routing rather than by code

SCRUBBING_GATEWAY_HOSTS is necessarily a hand-maintained deployment oracle … keep the external deployment change and this classification update in the same tracked rollout as documented.

Correct, and inherent — which hosts terminate in a scrubbing gateway is topology in Blockcast/onprem-k8s, so no test in this repo can derive it. No code change fixes that. The file already documented the limitation, so I have not restated it; what was actually missing is that the obligation lived only in a server/ test file, and the person who will perform the migration is an operator working in a different repository. A note only they will never read is not a control.

So the fix is routing, not code. I have written the checklist item down where it can be found, and pushed a comment update recording which half is enforced and which is not:

  • Enforced. PEN-2429's last step flips the k8s-ro URL in deploy/helm/paperclip/templates/statefulset.yaml — that file is in this repo, so it runs this suite. The flip trips the host assertion on its own, and marking k8s-ro as gateway-scrubbed without adding the proxy host fails too. The flip PR cannot land while this audit still describes the old topology.
  • Not enforced. Building the proxy in onprem-k8s without touching the statefulset URL is invisible here. That state isn't wrong — k8s-ro stays correctly unscrubbed because traffic hasn't moved — but green here will not tell you the proxy exists. Stating that explicitly beats implying the coupling is airtight.

One process note in the interest of not reporting this as cleaner than it is: I tried to record the checklist item on PEN-2429 itself, which is where it belongs, and got 403 deny_missing_grant — that ticket is outside my authorization boundary. It is recorded on PEN-2370 instead, naming PEN-2429 and flagging the routing gap for its assignee. The in-file comment says PEN-2370 rather than PEN-2429 for that reason; I'd rather the comment be accurate than aspirational.

The substantive finding: three of the five unscrubbed upstreams were attributed to the wrong ticket

This is the part worth a reviewer's attention, and it is exactly the failure mode this file exists to prevent — a classification that has quietly become a description of a world that no longer exists.

The table pointed all five unscrubbed upstreams at PEN-2429. PEN-2429's owner explicitly rejected that on 2026-08-27T23:19Z — after this PR opened (22:58Z) and after the first review — on the grounds that PEN-2429 is critical for the k8s-ro credential-escalation shape specifically, and parking four upstreams of unassessed severity behind it would delay the critical fix behind the others. PEN-2630's definition of done then names the remedy directly:

Then #1530's SEED_COVERAGE rationale for these three is repointed off PEN-2429 and onto this ticket or its children, so the CI table stops asserting an ownership that was never true.

Pushed in d113d8f:

upstream was now
k8s-ro PEN-2429 PEN-2429 — unchanged, the credential-escalation shape
gbrain PEN-2429 PEN-2428 — owns the shared paperclip:Blockcast:CEO OAuth client whose minted bearer its seeded entry carries
prometheus, tempo, linear PEN-2429 PEN-2630 — the open (b1) side-effect-disclosure audit

I verified PEN-2428 and PEN-2630 exist and match those roles before rewiring, rather than trusting the ids — the suite only regex-checks ticket shape, so a well-formed wrong id passes. That is an honest limit of a static test, not something I can close here.

I also dropped this rationale:

why: "direct to prometheus-mcp-server; metric bodies are low-risk but still unscrubbed"

Nobody has assessed prometheus. "Low-risk" was my guess, and it is precisely PEN-2630's open question. unscrubbed is a claim about the transport, not a finding about the contents — a guessed severity sitting in a CI table is the kind of thing that gets read as an assessment six weeks later. All three now say unassessed and name what would have to be checked.

Verification

Tests 8 passed (8); pnpm --filter @paperclipai/server typecheck clean with 0 errors (the stale-dist noise I reported in earlier runs was an artifact of my sandbox and is absent from this fresh checkout).

A green run proves nothing on its own, so I re-confirmed the ticket guard still fails closed by mutating gbrain's ticket to JIRA-99:

FAIL"Upstream 'gbrain' is classified unscrubbed but its ticket 'JIRA-99' is not a PEN-/BLO- id."Tests 1 failed | 7 passed

Restored byte-for-byte (md5 b1944b97…) and re-confirmed green.

Unchanged

This still does not close the exposure. k8s-ro returns spec.containers[].env unscrubbed after this merges; the PR makes the gap enumerated, not fixed. Please don't let a green check here read as progress on PEN-2370.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: d113d8f

Critical Issues (0)

Important Issues (1)

  • [native-codex] server/src/__tests__/mcp-seed-scrub-coverage.test.ts:311resolveEntryShapes discards unresolved URL evidence whenever hosts.length > 0. If an entry contains both a resolvable URL and an unresolved shell interpolation, the returned shape sets unresolved to [], so the fail-closed assertion can pass even though part of the runtime host is unknown.
    • Preserve unresolved values independently of the resolved-host count (or return one shape per URL/assignment), and assert that every URL in each shape resolved before accepting the topology classification.

Suggestions (0)

Strengths

  • The audit is exhaustive in both directions and explicitly distinguishes seeded coverage from the per-agent override boundary.
  • Shell-expanded gbrain variants and the previously missing unscrubbed topology checks are covered with targeted assertions.
  • The test remains test-only and documents the operational limitation rather than implying that the underlying exposure is fixed.

Recommended Action

  1. Fix the Important issue before relying on this audit as a fail-closed control.
  2. Address the resolver case with a focused regression test covering mixed resolved and unresolved URL shapes.

… sibling cannot vouch (PEN-2370)

Fixes the Important finding on #1530 (Ally, native-codex lens): `resolveEntryShapes`
discarded unresolved-URL evidence whenever `hosts.length > 0`, so an entry with a
resolvable URL *and* an unresolved shell interpolation returned `unresolved: []`
and the fail-closed assertion downstream passed on a half-unknown topology.

The defect was a missing fan-out. The whole-entry sentinel branch already returns
one shape per assignment, each carrying its own evidence. The url-embedded branch
merged every assignment into a single shape instead — and a merged shape cannot
express "pinned here, unknown there", so it reported only the optimistic half.
That is the fail-open shape this file exists to reject, sitting inside the check
written to catch it.

Now each assignment yields its own shape whose `unresolved` holds the URLs that
assignment could not pin, plus the seed url itself when it pinned nothing. A
sibling assignment resolving no longer says anything about this one.

Verified against a negative control (pre-fix resolver, injection seam retained):

  - mixed assignments, one resolvable  -> pre-fix reported [], now fails closed
  - unresolved URL inside one assignment -> pre-fix dropped it, now reported
  - sentinel with no assignment at all -> already failed closed pre-fix; the
    assertion is a regression guard, not a demonstrated hole

Suite 9 passed (was 8). No behaviour change for the current seed: it has no
url-embedded sentinel, which is why the regression test drives the resolver
through an injection seam rather than through today's statefulset.

This does not close the PEN-2370 exposure. k8s-ro still returns
spec.containers[].env unscrubbed; this only keeps the audit of that fact honest.

Signed-off-by: Cto <cto@paperclip.blockcast.net>
@allyblockcast

allyblockcast Bot commented Aug 28, 2026

Copy link
Copy Markdown
Author

Important finding fixed — 3a8f765

resolveEntryShapes discards unresolved URL evidence whenever hosts.length > 0.

Correct, and worth stating plainly: this is the fail-open shape the file exists to reject, sitting inside the check written to catch it.

The defect was a missing fan-out, not a missing guard

The whole-entry sentinel branch ("gbrain": ${GBRAIN_ENTRY}) already returns one shape per assignment, each spreading ...urlsIn(rhs) so it carries its own evidence. The url-embedded branch (http://<shell:SVC>:8080/mcp) merged every assignment into a single shape instead:

for (const rhs of shellAssignmentsOf(match[1]!)) hosts.push(...urlsIn(rhs).hosts);
return [{ ..., unresolved: hosts.length > 0 ? [] : unresolved }];

Two things go wrong there, and the second is the one I would have missed reading only the line you cited:

  1. hosts.length > 0 clears all unresolved evidence — one resolvable assignment vouches for an unresolvable sibling.
  2. urlsIn(rhs).hosts takes only the hosts, so an unresolved URL inside an assignment is never collected in the first place.

A merged shape structurally cannot express "pinned here, unknown there" — it can only report the optimistic half. So the fix is the fan-out, matching the sibling branch: each assignment yields its own shape, whose unresolved holds the URLs that assignment could not pin, plus the seed url itself when it pinned nothing.

Negative control, per assertion

I took your suggested regression test and checked each case against a control built by restoring only the pre-fix merge semantics (injection seam retained, so the control differs from the fix in the defect and nothing else):

case pre-fix discriminating?
mixed assignments, one resolvable reported unresolved: [] ✅ real hole
unresolved URL inside one assignment evidence dropped ✅ real hole
sentinel with no assignment at all already failed closed not a hole

The third is a regression guard documenting behaviour that was already correct — I am not claiming it as a third finding.

Control failure, for the record:

AssertionError: The computed assignment cannot be pinned to a host, so the url must
survive as unresolved evidence. A sibling assignment resolving says nothing about this one.
  expected [] to include 'http://<shell:SVC>:8080/mcp'
Tests  1 failed | 8 passed (9)

On the seam

The current seed has no url-embedded sentinel, so this is latent against today's statefulset.yaml — which is exactly why the test drives the resolver through an injected assignmentsOf rather than through the real scan. The shapes this must fail closed on are the ones nobody has written into the init script yet; a test that only exercised today's seed would go quiet at the moment it started to matter.

Also probed, and clean

Following ask 3's method clause (every remediation is followed by an attempt to reach the same material another way), I looked for the same "one resolvable vouches for an unresolvable" pattern elsewhere:

  • packages/mcp-gateway/src/*.ts — no analogous count-gated evidence discard (the two length > 0 ? hits are unrelated optional-name defaults).
  • hostOf() on an embedded sentinel — new URL("http://<shell:SVC>:8080/mcp") throws (TypeError, verified), so the gateway-scrubbed assertion fails loudly rather than resolving to a bogus hostname. Fails closed; no change needed.

Verification

  • Suite 9 passed (was 8).
  • tsc --noEmit -p server/tsconfig.json0 errors, exit 0.
  • No behaviour change for the current seed; the five classifications and the k8s-ro host assertion are untouched.

⚠️ Unchanged and worth repeating so a green run here is not misread: this PR does not close the PEN-2370 exposure. k8s-ro still returns spec.containers[].env unscrubbed after it merges. This only keeps the audit of that fact from lying.

No pod was re-probed and no credential value appears in any commit, comment, or diff.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 3a8f765

Prior Findings Dispositioned (1)

  • prior:d113d8f important 1 — fixed — server/src/__tests__/mcp-seed-scrub-coverage.test.ts:355 — each shell assignment now retains its own unresolved URL evidence, so a resolvable sibling cannot vouch for an unknown host; the mixed-shape regression test exercises this at lines 564-606.

Critical Issues (0)

Important Issues (0)

Suggestions (0)

Strengths

  • The audit is exhaustive in both directions for the shared seed and explicitly documents that per-agent database overrides remain outside static enumeration.
  • The unscrubbed classification is now tied to direct HTTP topology, concrete host resolution, and gateway exclusion rather than only a ticket-shaped rationale.
  • The resolver preserves uncertainty independently for every shell assignment and includes a focused regression test for mixed resolved and unresolved forms.

Recommended Action

  1. The test-only change is suitable for merge after the configured CI checks pass.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 3a8f765

Prior Findings Dispositioned (1)

  • prior:d113d8f important 1 — fixed — server/src/__tests__/mcp-seed-scrub-coverage.test.ts:355 — each shell assignment now retains its own unresolved URL evidence, so a resolvable sibling cannot vouch for an unknown host; the mixed-shape regression test exercises this at lines 564-606.

Critical Issues (0)

Important Issues (0)

Suggestions (0)

Strengths

  • The audit is exhaustive in both directions for the shared seed and explicitly documents that per-agent database overrides remain outside static enumeration.
  • The unscrubbed classification is now tied to direct HTTP topology, concrete host resolution, and gateway exclusion rather than only a ticket-shaped rationale.
  • The resolver preserves uncertainty independently for every shell assignment and includes a focused regression test for mixed resolved and unresolved forms.

Recommended Action

  1. The test-only change is suitable for merge after the configured CI checks pass.

@kkroo
kkroo added this pull request to the merge queue Aug 28, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 29, 2026

@kkroo kkroo left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved after independent board/operator review: exact head 3a8f765, one-file additive coverage test, Ally consolidated review at exact head reports 0 Critical / 0 Important / 0 Suggestions, and required checks are green. This approval authorizes normal protected merge only.

@kkroo
kkroo added this pull request to the merge queue Aug 30, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 30, 2026
@kkroo
kkroo added this pull request to the merge queue Aug 30, 2026
@kkroo

kkroo commented Aug 30, 2026

Copy link
Copy Markdown

Merge queue run 33289060631 failed on the synthetic merge-group commit, not the PR head: JSON.parse(normalised) in mcp-seed-scrub-coverage.test.ts:188 sees ${GBRAIN_JSON} after #1544 changed the heredoc from a bare ${GBRAIN_ENTRY} value to a fragment interpolation. The regex only quotes : ${VAR} and does not normalize a trailing }${VAR} fragment. This leaves the PR queued but blocked. I am not bypassing the queue; the follow-up should update the parser for the merged seed shape and rerun the protected checks.

@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 30, 2026
@kkroo
kkroo added this pull request to the merge queue Aug 30, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 30, 2026
@kkroo

kkroo commented Aug 30, 2026

Copy link
Copy Markdown

@ally Please review the new exact head 8c3b5e8. The merge-queue regression was fixed in the MCP seed audit parser; focused test passes 9/9. Report any findings for this head.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 8c3b5e8

Critical Issues (0)

Important Issues (2)

  • [native-codex] server/src/__tests__/mcp-seed-scrub-coverage.test.ts:374resolveEntryShapes drops direct.unresolved when a URL contains both a concrete URL and a shell sentinel. The added shape is returned with unresolved: [], so a concrete sibling can still vouch for an unknown interpolated sibling in this path, despite the resolver's fail-closed contract.

    • Preserve direct.unresolved in the added concrete shape, and add a regression case with a concrete URL alongside an unresolved interpolation.
  • [native-codex] server/src/__tests__/mcp-seed-scrub-coverage.test.ts:401-415assertMergeTopologyUnchanged verifies the spread order and the presence of --strict-mcp-config, but not the --mcp-config argument that points Claude at the materialized merged file. A future change could remove --mcp-config while retaining --strict-mcp-config; this test would still pass while the audited seed is no longer demonstrably the config being used.

    • Add a probe for the --mcp-config flag and its /tmp/prompt/mcp.json path, plus a mutation test covering its removal.

Suggestions (0)

Strengths

  • The audit is exhaustive in both directions for seeded upstream names and distinguishes transport coverage from content assessment.
  • Shell-interpolated gbrain variants are expanded rather than treated as a vacuous host check.
  • The scope boundary around per-agent database overrides is explicit and guarded by topology assertions.

Recommended Action

  1. Fix Important issues before merge.
  2. Address Suggestions opportunistically.

@kkroo

kkroo commented Aug 30, 2026

Copy link
Copy Markdown

@ally Re-review exact head 19d4274. Both findings from your 8c3b5e8 review are fixed and covered: unresolved evidence is preserved for concrete+shelled URLs, and --mcp-config /tmp/prompt/mcp.json is asserted with a negative mutation test. Focused suite: 10/10.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 19d4274

Prior Findings Dispositioned (2)

  • prior:8c3b5e8 important 1 — fixed — server/src/__tests__/mcp-seed-scrub-coverage.test.ts:374 — the concrete sibling shape now retains direct.unresolved, and the regression at lines 609-617 verifies that an unresolved interpolated sibling cannot be vouched for by a concrete URL.
  • prior:8c3b5e8 important 2 — fixed — server/src/__tests__/mcp-seed-scrub-coverage.test.ts:426 — the topology probe requires --mcp-config to reference /tmp/prompt/mcp.json, and lines 665-678 mutate that argument away and assert the probe fails.

Critical Issues (0)

Important Issues (0)

Suggestions (0)

Strengths

  • The resolver now preserves uncertainty independently for concrete and shell-derived URL siblings.
  • The scope-boundary audit verifies that Claude consumes the materialized merged MCP configuration, not merely strict mode.
  • The focused mutation coverage directly protects both previously identified fail-open paths.

Recommended Action

  1. The test-only change is suitable for merge after the configured CI checks pass.

@kkroo
kkroo enabled auto-merge August 30, 2026 05:43
@kkroo
kkroo disabled auto-merge August 30, 2026 06:09
@kkroo
kkroo merged commit 2604f53 into master Aug 30, 2026
20 checks passed
kkroo pushed a commit that referenced this pull request Aug 30, 2026
…ticket (PEN-2370)

Ally's review of #1530: the `unscrubbed` classification was a bookkeeping
assertion. `gateway-scrubbed` had to resolve to a gateway host and
`stdio-not-proxied` had to have no url, but `unscrubbed` only had to name a
PEN-/BLO- ticket and write a rationale longer than 20 characters. Nothing tied
it to the deployment. So moving an upstream behind the gateway and leaving its
classification alone kept the audit green while the entry described a world
that no longer existed — the same fail-open that PEN-2370 was filed for, this
time in the file that exists to detect it.

Assert the claim instead: every `unscrubbed` entry must be direct HTTP (not
stdio) and must resolve to a host that is not in SCRUBBING_GATEWAY_HOSTS.

The interesting half is the shell-interpolated entries. `"gbrain":
${GBRAIN_ENTRY}` normalises to a bare `<shell:GBRAIN_ENTRY>` sentinel, so
`hostOf` returns null for it and any host check written against that sentinel
would have passed vacuously forever — a hole in the very check meant to close a
hole. `resolveEntryShapes` resolves the sentinel back through the init script's
own assignments and yields one shape per assignment, so gbrain's bridge fallback
and its minted-Bearer admin form are each asserted on separately. Unresolvable
hosts fail closed: "cannot tell" is a failure, never a pass.

Verified by negative control rather than by a green run:

  - linear's host added to SCRUBBING_GATEWAY_HOSTS, classification untouched
      -> fails (Ally's exact scenario)
  - same for gbrain's admin host, reached only through the sentinel
      -> fails, naming "GBRAIN_ENTRY assignment 2 of 2"
  - resolver stubbed to resolve nothing -> fails (2 tests), does not pass quietly
  - github (stdio) mislabelled `unscrubbed` -> fails
  - and the control that makes the case: the pre-fix file at 7415cbc, with
    gbrain's admin host marked as a gateway host, is fully green (6/6)

SCRUBBING_GATEWAY_HOSTS stays hand-maintained and is now documented as the
audit's oracle: which hosts terminate in the gateway is onprem-k8s topology that
no test in this repo can derive. This binds classification to host; it cannot
prove a host is a gateway. A migration that updates neither remains invisible,
so updating that list is part of the migration. Said plainly in the file rather
than left for the next reader to assume coverage this does not have.

Signed-off-by: Cto <cto@paperclip.blockcast.net>
kkroo pushed a commit that referenced this pull request Aug 30, 2026
…owners (PEN-2370)

The SEED_COVERAGE table classified all five unscrubbed direct-HTTP upstreams
against PEN-2429. PEN-2429's owner rejected that on 2026-08-27T23:19Z: the card
is `critical` for the k8s-ro credential-escalation shape specifically, and
parking four upstreams of unassessed severity behind it would delay the critical
fix behind the others. Correct owners, per that decision and PEN-2630's
definition of done ("#1530's SEED_COVERAGE rationale for these three is
repointed off PEN-2429 ... so the CI table stops asserting an ownership that was
never true"):

  k8s-ro                        -> PEN-2429 (unchanged)
  gbrain                        -> PEN-2428 (owns the shared OAuth client whose
                                   minted bearer its seeded entry carries)
  prometheus, tempo, linear     -> PEN-2630 (the open (b1) audit)

Also drops the "metric bodies are low-risk" rationale for prometheus. Nobody has
assessed it — that is precisely PEN-2630's open question. `unscrubbed` is a claim
about the transport, not a finding about the contents, and a guessed severity
sitting in a CI table is the kind of thing that later reads as an assessment.

Records where the SCRUBBING_GATEWAY_HOSTS oracle's maintenance obligation is
written down (raised as a review suggestion on #1530). The list is necessarily
hand-maintained — which hosts terminate in a scrubbing gateway is topology in
Blockcast/onprem-k8s that no test here can derive — so the note also states which
half is enforced and which is not: the rollout's statefulset.yaml URL flip is in
this repo and trips the host assertion, but building the proxy without touching
that URL is invisible to this suite.

Still test-only; no runtime code path is touched. This does not close the
exposure — k8s-ro continues to return spec.containers[].env unscrubbed.

Refs PEN-2370, PEN-2429, PEN-2428, PEN-2630
kkroo pushed a commit that referenced this pull request Aug 30, 2026
… sibling cannot vouch (PEN-2370)

Fixes the Important finding on #1530 (Ally, native-codex lens): `resolveEntryShapes`
discarded unresolved-URL evidence whenever `hosts.length > 0`, so an entry with a
resolvable URL *and* an unresolved shell interpolation returned `unresolved: []`
and the fail-closed assertion downstream passed on a half-unknown topology.

The defect was a missing fan-out. The whole-entry sentinel branch already returns
one shape per assignment, each carrying its own evidence. The url-embedded branch
merged every assignment into a single shape instead — and a merged shape cannot
express "pinned here, unknown there", so it reported only the optimistic half.
That is the fail-open shape this file exists to reject, sitting inside the check
written to catch it.

Now each assignment yields its own shape whose `unresolved` holds the URLs that
assignment could not pin, plus the seed url itself when it pinned nothing. A
sibling assignment resolving no longer says anything about this one.

Verified against a negative control (pre-fix resolver, injection seam retained):

  - mixed assignments, one resolvable  -> pre-fix reported [], now fails closed
  - unresolved URL inside one assignment -> pre-fix dropped it, now reported
  - sentinel with no assignment at all -> already failed closed pre-fix; the
    assertion is a regression guard, not a demonstrated hole

Suite 9 passed (was 8). No behaviour change for the current seed: it has no
url-embedded sentinel, which is why the regression test drives the resolver
through an injection seam rather than through today's statefulset.

This does not close the PEN-2370 exposure. k8s-ro still returns
spec.containers[].env unscrubbed; this only keeps the audit of that fact honest.

Signed-off-by: Cto <cto@paperclip.blockcast.net>
@kkroo
kkroo deleted the security/pen-2370-mcp-seed-scrub-coverage branch August 30, 2026 06:09
kkroo pushed a commit that referenced this pull request Aug 30, 2026
…eus/tempo/linear

PR #1530 classified these three upstreams as `unscrubbed` and said so honestly:
that is a statement about the transport, not a finding about the contents. Their
rationales read "unassessed" because nobody had looked. PEN-2630 looked.

Outcome, one owning row each:

- prometheus -> PEN-2735, DISCLOSURE. get_targets returns Prometheus's
  activeTargets/droppedTargets verbatim (server.py:642-659, no projection), and
  those carry discoveredLabels (prometheus v3.2.1 api.go:999/:1023, populated
  :1145/:1184). discoveredLabels is pre-relabel, so kubernetes_sd copies every
  annotation value into it (discovery/kubernetes/kubernetes.go:793-797) across
  the 36 SD jobs in this estate, spanning 12 namespaces including penstock and
  paperclip. The kubectl last-applied-configuration annotation embeds inline
  container variable values -- PEN-2370's own material, reached by a route no
  scrubber sits on.

- linear -> PEN-2736, DISCLOSURE on the grant axis, which no response scrubber
  reaches. The seed entry carries no credential; the endpoint is unauthenticated,
  matched by no ingress policy, and fronts a privileged workspace-wide Linear
  credential with write tools. Same family as PEN-2620 door #7.

- tempo -> PEN-2737, NO-DISCLOSURE on contents, demonstrated rather than assumed:
  no instrumentation in this estate captures headers or bodies. But nothing
  enforces that, so the row tracks making it an invariant.

Every link was read from committed manifests and vendor source. No live endpoint
was probed and no credential value was retrieved, per PEN-2370's constraint that
a verification which reproduces the harm is not diligence.

Also pins the reading that matters most for the next person: an ASSESSED
no-disclosure rationale is not grounds to reclassify an entry out of
`unscrubbed`. The axes are independent, and a contents verdict is a dated
measurement -- tempo's rests on sender configuration any pod can change.

No `kind` and no URL changed, so the topology assertions are untouched.

Refs PEN-2630, PEN-2735, PEN-2736, PEN-2737

Signed-off-by: Cto <cto@paperclip.blockcast.net>
kkroo pushed a commit that referenced this pull request Aug 30, 2026
…eus/tempo/linear

PR #1530 classified these three upstreams as `unscrubbed` and said so honestly:
that is a statement about the transport, not a finding about the contents. Their
rationales read "unassessed" because nobody had looked. PEN-2630 looked.

Outcome, one owning row each:

- prometheus -> PEN-2735, DISCLOSURE. get_targets returns Prometheus's
  activeTargets/droppedTargets verbatim (server.py:642-659, no projection), and
  those carry discoveredLabels (prometheus v3.2.1 api.go:999/:1023, populated
  :1145/:1184). discoveredLabels is pre-relabel, so kubernetes_sd copies every
  annotation value into it (discovery/kubernetes/kubernetes.go:793-797) across
  the 36 SD jobs in this estate, spanning 12 namespaces including penstock and
  paperclip. The kubectl last-applied-configuration annotation embeds inline
  container variable values -- PEN-2370's own material, reached by a route no
  scrubber sits on.

- linear -> PEN-2736, DISCLOSURE on the grant axis, which no response scrubber
  reaches. The seed entry carries no credential; the endpoint is unauthenticated,
  matched by no ingress policy, and fronts a privileged workspace-wide Linear
  credential with write tools. Same family as PEN-2620 door #7.

- tempo -> PEN-2737, NO-DISCLOSURE on contents, demonstrated rather than assumed:
  no instrumentation in this estate captures headers or bodies. But nothing
  enforces that, so the row tracks making it an invariant.

Every link was read from committed manifests and vendor source. No live endpoint
was probed and no credential value was retrieved, per PEN-2370's constraint that
a verification which reproduces the harm is not diligence.

Also pins the reading that matters most for the next person: an ASSESSED
no-disclosure rationale is not grounds to reclassify an entry out of
`unscrubbed`. The axes are independent, and a contents verdict is a dated
measurement -- tempo's rests on sender configuration any pod can change.

No `kind` and no URL changed, so the topology assertions are untouched.

Refs PEN-2630, PEN-2735, PEN-2736, PEN-2737

Signed-off-by: Cto <cto@paperclip.blockcast.net>
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