Skip to content

security(mcp-gateway): the name guard reached two of three call sites (PEN-2370) - #1552

Merged
kkroo merged 1 commit into
masterfrom
security/pen-2370-array-entry-name-guard
Aug 30, 2026
Merged

security(mcp-gateway): the name guard reached two of three call sites (PEN-2370)#1552
kkroo merged 1 commit into
masterfrom
security/pen-2370-array-entry-name-guard

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Aug 30, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • Agents reach Kubernetes through paperclip-mcp-gateway, which runs scrubResponseBody over every proxied response so that pods_get cannot hand an agent another agent's live credentials
  • That scrubber preserves variable names on purpose — knowing which variables are set is the diagnostic value the read-only grant exists for — so it has rules that keep a name and drop a value
  • Twice already (d741f104, b43437f4, cd14342) those rules got the same thing wrong: they answered "does this scalar have a name worth preserving?" with indexOf("="), which promotes material into the name position and prints it in the clear beside its own <redacted> marker
  • Each fix hoisted the answer into a shared, validated predicate — and each reached only the call sites its author was looking at. A third call site, the JSON env array entry, still holds the original indexOf("=")
  • This pull request routes that third site through the same constant, and widens the invariant test from "the two paths that print a name" to all three
  • The benefit is that the guard now covers the class rather than the two spellings someone happened to enumerate — PEN-2370 ask 3 criterion (b2)

Linked Issues or Issue Description

Related PRs, all in this chain and none touching this code path: #1435 (the scrubber itself), #1518d741f104 and #1530b43437f4/cd14342 (the two prior name-guard fixes this one completes), #1544 (routed k8s-ro through the gateway, which is what made the scrubber load-bearing), #1551 (open — edits SEED_COVERAGE rationales in a different file). Searched open/closed/merged for response-scrub, ENV_KEY_VALUE, REQUIRE_VALUE_AFTER_EQ and name position; no other PR touches this branch of the code.

What Changed

  • scrubJsonValue, JSON env array entry (response-scrub.ts): replaced entry.indexOf("=") slicing with the shared ENV_KEY_VALUE_SCALAR guard, matching the JSON scalar branch and the YAML sequence-entry rule. A prefix that is not a validated variable name now has no name to keep and redacts whole.
  • response-scrub.test.ts: the redacts <label> whole on every path that preserves names invariant now asserts the JSON array path alongside the JSON scalar and YAML paths. Its comment previously said "the two paths that print a name" and named two of three.
  • response-scrub.test.ts: the structural class assertion (whatever survives in the name position is a name, not material) now runs over every name-preserving path rather than only the scalar one.
  • response-scrub.test.ts: added the array-path counterweight (TOKEN=<base64> keeps its name), so the change cannot degenerate into "redact every =-bearing entry".

Verification

Measured through the real entry point scrubResponseBody, not by calling an inner function:

                              BEFORE                          AFTER
JSON array   [MATERIAL]=x     "[MATERIAL]=<redacted>"  LEAK   "<redacted>"
JSON scalar  [MATERIAL]=x     "<redacted>"                    "<redacted>"
YAML entry   [MATERIAL]=x     "<redacted>"                    "<redacted>"

JSON array   ZHVtbXk<M>==     "ZHVtbXk<M>=<redacted>"  LEAK   "<redacted>"
JSON scalar  ZHVtbXk<M>==     "<redacted>"                    "<redacted>"
  • Non-vacuity measured, not assumed. Reverting only response-scrub.ts with the new tests in place fails 5: the four material shapes plus the structural class assertion. 232 pass. (Reverted with git show HEAD:<path> > rather than git checkout <sha> -- <path> — the latter stages the old blob and is exactly how 14e54f1 silently reverted this file's fix once before, per e231c024.)
  • With the fix: vitest run346 passed (346) across the package. tsc --noEmit clean.
  • Controls pass in BOTH states, as controls must: TOKEN=<base64> keeps its name on the array path, and a bare scalar with no = redacts whole. These are what distinguish tightening the guard from redacting everything, which would satisfy every leak assertion above while destroying design note 2.

Fixtures use a synthetic MATERIALCANARY marker and assert on its absence. No pod was probed, and no credential value appears in this diff, its tests, or this description — per PEN-2370's standing constraint that a verification which reproduces the harm is not diligence.

Risks

Low risk, and the direction of any error is toward over-redaction rather than disclosure.

  • The behavioural change is confined to JSON env array entries whose prefix is not a valid variable name. Those previously printed the prefix; they now redact whole. Well-formed OCI Config.Env entries (KEY=VALUE) are unaffected — pinned by the counterweight test.
  • No new trade is introduced. REQUIRE_VALUE_AFTER_EQ carries a documented conservatism on the JSON path (# has no comment meaning there, so TOKEN= #x over-redacts). That trade was already accepted for the JSON scalar branch in cd14342; this change puts a third call site behind the same constant rather than inventing a fourth spelling tuned to it — which is the drift that produced this defect.

Scope honesty, stated plainly: this is a fallback-path defect, not a reachable incident. The Kubernetes API serializes container env as a list of {name, value} objects, which takes the object branch and was always clean. The string branch exists for the OCI/Docker Config.Env shape. It still matters, because the JSON path's stated reason to exist is that an upstream shape change cannot silently turn the scrubber into a no-op — and a shape it prints in the clear defeats that reason.

Found by the method PEN-2370 ask 3 names as its control: after a remediation lands, go looking for another route to the same material instead of re-reading the patch.

Model Used

Claude Opus 4.5 (claude-opus-4-5), 1M context, extended thinking, running as the Paperclip CTO agent via Claude Code with tool use (Bash, file edits, GitHub + Paperclip + k8s-ro MCP). One subagent performed the initial adversarial sweep of response-scrub.ts; its central finding was then re-derived independently by reading the source and re-measured through scrubResponseBody before any code was changed.

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 — 346/346 plus tsc --noEmit, and the 5-failure non-vacuity measurement above
  • 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 in-module comment at the changed call site now states why the shared constant is used there
  • I have considered and documented any risks above
  • All Paperclip CI gates are green — pending at time of writing
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups — not yet run
  • I will address all Greptile and reviewer comments before requesting merge

… (PEN-2370)

`b43437f4` and `cd14342` moved name preservation onto a shared, validated
predicate so that material could not be promoted into the name position.
Both fixed the JSON `env` *scalar* branch and the YAML sequence entry. The
JSON `env` **array** entry kept `indexOf("=")` and was not touched:

    const eq = entry.indexOf("=");
    return eq > 0 ? `${entry.slice(0, eq)}=${REDACTED}` : REDACTED;

Fifty lines below it, the branch that was fixed carries a comment saying
this exact expression "promotes `[LEAKED]` into the name position and emits
it in the clear next to a redaction marker". That is design note 4's
false-assurance failure -- the output is strictly worse than not scrubbing,
because the marker asserts a redaction happened beside the plaintext.

Measured through `scrubResponseBody`, same input to all three paths:

    JSON array   [MATERIAL]=x        -> "[MATERIAL]=<redacted>"      LEAK
    JSON scalar  [MATERIAL]=x        -> "<redacted>"
    YAML entry   [MATERIAL]=x        -> "<redacted>"

    JSON array   ZHVtbXk<MATERIAL>== -> "ZHVtbXk<MATERIAL>=<redacted>"  LEAK
    JSON scalar  ZHVtbXk<MATERIAL>== -> "<redacted>"
    YAML entry   ZHVtbXk<MATERIAL>== -> "<redacted>"

The fix is to use the constant, not to write a fourth spelling of it.
`ENV_KEY_VALUE_SCALAR` already encodes "a validated name followed by a real
value"; the array entry is the same question about the same shape. No new
trade is introduced -- the `#`-clause's JSON-path conservatism was already
accepted for the scalar branch this now joins.

SCOPE, stated plainly: this is a fallback-path defect, not a reachable
incident. The k8s API serializes container env as a list of {name, value}
objects, which takes the object branch and was always clean; the string
branch exists for the OCI/Docker `Config.Env` shape. It matters because
the JSON path's stated reason to exist is that an upstream shape change
cannot silently turn the scrubber into a no-op, and a shape it prints in
the clear defeats that.

Tests extend the existing invariant along the *path* axis rather than
adding fixtures. The list previously read "the two paths that print a
name" and asserted two of three -- the same enumeration-not-invariant
asymmetry this ticket is about, reproduced inside the test written to
prevent it. The structural assertion ("whatever survives in the name
position is a name, not material") now runs over every name-preserving
path instead of one, so it fails for an encoding nobody has thought of on
any of them.

Verified, run not assumed:
- Reverting ONLY response-scrub.ts with the new tests in place fails 5:
  the 4 material shapes plus the structural class assertion. 232 pass.
- With the fix: 346 passed (346) across the package; tsc --noEmit clean.
- The two counterweights (`TOKEN=<base64>` keeps its name on the array
  path; a bare scalar redacts whole) pass in BOTH states, as controls
  must -- they are what stops this degenerating into "redact every
  `=`-bearing entry" and destroying design note 2.

Found by the method PEN-2370 ask 3 names as the control: after the
remediation landed, go looking for another route to the same material
rather than re-reading the patch.

Refs: PEN-2370 ask 3 criteria (a2), (b2)
Signed-off-by: Cto <cto@paperclip.blockcast.net>
@allyblockcast

allyblockcast Bot commented Aug 30, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: PEN-2431
🔗 Paperclip issue: PEN-2370

1 similar comment
@allyblockcast

allyblockcast Bot commented Aug 30, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: PEN-2431
🔗 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) [degraded: skill unavailable] + gstack/review [degraded: executable unavailable] + native-codex.
Reviewed head: fc67d7b

Critical Issues (0)

Important Issues (0)

Suggestions (0)

Strengths

  • Routes the JSON env array scalar path through the same validated name predicate as the existing name-preserving paths.
  • Adds regression coverage for malformed name-shaped material, valid padded values, and the array path itself.
  • Keeps the change narrowly scoped and documents the security invariant and conservative redaction tradeoff.

Recommended Action

  1. No Critical or Important issues found; this review is ready for the next merge-gate evaluation.

@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: exact-head Ally review reports 0 Critical / 0 Important / 0 Suggestions; required verify passes; merge state is clean.

@kkroo
kkroo added this pull request to the merge queue Aug 30, 2026
Merged via the queue into master with commit 0e5d915 Aug 30, 2026
22 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