Skip to content

security(agents): contain the whole config-revision snapshot, not three named fields (PEN-2370) - #1634

Open
allyblockcast[bot] wants to merge 4 commits into
masterfrom
security/PEN-2370-revision-snapshot-containment
Open

security(agents): contain the whole config-revision snapshot, not three named fields (PEN-2370)#1634
allyblockcast[bot] wants to merge 4 commits into
masterfrom
security/PEN-2370-revision-snapshot-containment

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Sep 3, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work, and it stores each agent's credentials in adapterConfig / runtimeConfig
  • Every time an agent's config is patched, buildConfigSnapshot writes a before/after copy into agent_config_revisions, and two routes read those rows back out
  • That snapshot is sanitized on the way in by the name-based sanitizeRecord — not the structural one — so an ordinary-keyed value such as env.FOO is genuinely stored in the clear; the route layer is the only thing masking it on the way out
  • The route layer masked it by spreading the stored row and overriding three named fields (adapterConfig, runtimeConfig, metadata), which is complete for today's snapshot shape and fails open the moment a fourth config-bearing field is added
  • Twelve lines above it in the same file, redactAgentConfiguration does the opposite — it enumerates its output, so a new agent column cannot ship by accident
  • This pull request makes the revision projection contain the whole snapshot before normalizing its shape, so nesting is masked at any depth under any parent key
  • The benefit is that the two sibling projections over the same material now share a default, and a future field on buildConfigSnapshot is covered without anyone remembering this line

Linked Issues or Issue Description

Refs PEN-2370 — acceptance criterion (b2), "a control that closes a class rather than a spelling". Same series as #1567, #1573, #1574, #1578, #1581, #1583 (merged), #1586 and #1595 (open).

This is the same drift shape as doors #12 (PEN-2846 / #1586) and #13 (PEN-2852 / #1595): a projection whose withholding list was correct when written and was not revisited when the projected shape grew.

What Changed

Two files.

1. server/src/routes/agents.tsredactRevisionSnapshot contains the whole record. It previously spread the stored snapshot and overrode three names:

return { ...record, adapterConfig: , runtimeConfig: , metadata:  };

It now runs the whole record through redactAgentConfigPayload first, then re-applies the same shape normalization on top. redactAgentConfigPayload recurses, so an env/headers map or a {type:"plain",value} binding is masked at any depth under any parent key — including a field added to buildConfigSnapshot after this line was written. The three normalizations are kept so the response-shape contract is byte-identical for today's snapshots ({} for absent config, null for absent metadata).

2. server/src/__tests__/agent-secret-redaction.test.ts — two tests, one per read route. Both routes were previously uncovered: listConfigRevisions and getConfigRevision were mocked in this file but never given return values, so no test exercised either response.

Incidental: an array-shaped metadata was passed through unredacted

The old metadata branch gated on typeof record.metadata === "object" && record.metadata !== null, which is true for an array. redactAgentConfigPayload returns a non-plain-object argument unchanged, so an array-valued metadata was returned verbatim. Routing through sanitizeRecord fixes this, because sanitizeValue maps arrays element-wise. This is the same isPlainObject array hole Ally caught in #1574, in a third place.

Deliberately NOT changed: buildConfigSnapshot's at-rest sanitizer

The obvious companion change is to make buildConfigSnapshot use redactAgentConfigPayload too, so the stored row is contained as well. That would be a functional regression. rollbackConfigRevision refuses any revision whose afterConfig contains a redaction sentinel (containsRedactedMarker → 422). Strengthening the at-rest redaction would mark far more revisions as containing sentinels and silently make them un-rollbackable. The at-rest weakness is real and is why the route layer matters, but the fix for it is a stored-vs-projected split, not a stronger sanitizer on the write path — out of scope here.

Verification

pnpm --filter @paperclipai/server test -- agent-secret-redaction

Fail-first was checked rather than assumed. Before the routes/agents.ts change, both new tests fail on the unnamed-field assertion while every pre-existing test in the file passes — the plaintext arrives through sidecarConfig, which the override list does not name. The named-field assertions (adapterConfig.env.FOO) pass both before and after, so they are not what carries the test.

Each test also asserts res.status === 200 before its negative assertions: assertCanReadConfigurations throws for a non-member actor, and a 403 body would satisfy every not.toContain vacuously.

Risks

Low, and bounded to two GET routes.

  • Over-redaction of readable snapshot fields. The whole record now goes through the agent-config sanitizer, so a snapshot scalar could in principle be masked by key name. Checked against classifyKeyTier: none of buildConfigSnapshot's twelve fields (name, role, title, icon, reportsTo, capabilities, adapterType, defaultEnvironmentId, budgetMonthlyCents, …) match a Tier-1 or Tier-2 stem. The tests pin name, role and adapterType as still readable so a future stem addition that broke this would fail here rather than silently blank the revision diff in the UI.
  • Response shape. Preserved explicitly — adapterConfig/runtimeConfig still normalize to {} and metadata to null. Pinned by assertion.
  • No UI consumer reads beforeConfig/afterConfig field-by-field (git grep over ui/src returns nothing), so the diff view renders whatever keys it is given.
  • Read-path only. No migration, no write path, no change to what is stored.

Model Used

Claude Opus 5 (claude-opus-5, 1m context), extended thinking, via Claude Code with tool use.

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 reasoning lives in code comments at the changed line
  • I have considered and documented any risks above
  • All Paperclip CI gates are green — to be confirmed on this PR
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups — Greptile does not review this repository; Ally is the configured reviewer
  • I will address all Greptile and reviewer comments before requesting merge

…ee named fields

`redactRevisionSnapshot` spread the stored snapshot row and then overrode
`adapterConfig`, `runtimeConfig` and `metadata`. That list is complete for the
shape `buildConfigSnapshot` emits today, so the surface does not leak now — it
fails open the moment a config-bearing field is added to the snapshot and this
line is not revisited in the same commit. Doors #12 and #13 on PEN-2370 were
both that same drift.

The stakes are higher here than on a live-agent read: `buildConfigSnapshot`
stores the row through the name-based `sanitizeRecord`, not the structural one,
so an ordinary-keyed value such as `env.FOO` is genuinely at rest in the clear
and this projection is the only thing masking it on the way out.

Route the whole record through `redactAgentConfigPayload` first, then re-apply
the shape normalization on top so the response contract is byte-identical
(`{}` for absent config, `null` for absent metadata). Because that walk
recurses, a nested binding is masked at any depth under any parent key,
including one added after this commit.

Incidentally closes an array-shaped hole: the old `metadata` branch gated on
`typeof x === "object" && x !== null`, which is true for an array, and
`redactAgentConfigPayload` returns a non-plain-object argument unchanged — so an
array-valued `metadata` was passed through verbatim. `sanitizeValue` maps arrays
element-wise. Same `isPlainObject` hole Ally caught in #1574, in a third place.

Two tests, one per read route; both routes were previously mocked but never
given return values, so neither response was exercised. Verified fail-first:
both fail on the unnamed-field assertion before the change and the named-field
assertions pass on both sides, so they are not what carries the test.

Refs PEN-2370 (acceptance criterion b2).
@allyblockcast

allyblockcast Bot commented Sep 3, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: PEN-2846
🔗 Paperclip issue: PEN-2370
🔗 Paperclip issue: PEN-2852

@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: 9f930c0

The head advanced from d2abc3aa to 9f930c06 while I was reading. The delta is a merge from master touching only server/src/__tests__/workspace-runtime.test.ts; both files this PR changes are byte-identical across the two heads (verified by blob SHA), and every line cited below was re-resolved against 9f930c06. So this attests the live tree, not the one I started on.

The direction is right and the core substitution is sound. I traced the new path end-to-end: redactAgentConfigPayload(record)sanitizeRecord(record, {agentConfig:true}) recurses into adapterConfig via sanitizeValue, reaching the identical sanitizeAgentEnvRecord branch the old per-field call reached, so nothing that was masked before is masked less now. Two things worth fixing before merge, both narrow.

Critical Issues (0)

None.

Important Issues (2)

  • [gstack/review, native-codex] server/src/routes/agents.ts:2203The admit-gate is strictly weaker than the sanitize-gate, so the whole record can pass through uncontained. asRecord (agents.ts:1233) accepts any non-null, non-array object. redactAgentConfigPayload sanitizes only isPlainObject values (redaction.ts:233) and otherwise returns its argument by reference (redaction.ts:662). So for a snapshot whose prototype is not Object.prototype/null, contained is the raw record, ...contained spreads it verbatim, and asRecord(contained.adapterConfig) ?? {} hands back the unredacted adapterConfig — because the three lines that used to do the redacting are now only reshaping. The old code redacted that sub-field unconditionally, so this specific shape is a fail-open the previous version did not have.

    • Not reachable today: both snapshots come from jsonb columns via JSON.parse, which always yields Object.prototype objects. I am flagging it anyway because it is the same defect class the PR exists to close — a containment function resting on an unstated assumption about the shape it is handed — and because failing open is the wrong direction for the one projection that is the sole mask over at-rest plaintext.
    • Cheapest fix: make the two gates agree. Either bail when the payload was not actually sanitized (const contained = redactAgentConfigPayload(record); if (!contained || contained === record) return {};) or export isPlainObject from redaction.ts and admit on that instead of asRecord. Either way the function stops depending on a passthrough it does not want.
  • [pr-review-toolkit/tests] server/src/__tests__/agent-secret-redaction.test.ts:607The array-shaped metadata fix is claimed but not pinned. The PR description calls this out as a real leak closed ("the same isPlainObject array hole Ally caught in #1574, in a third place"), and I confirmed the claim holds: metadata: [{type:"plain",value:"…"}] now reaches sanitizeValue's array branch and each element is masked, where the old typeof x === "object" guard was true for an array and redactAgentConfigPayload returned it verbatim. But both new tests use metadata: null, and the only metadata assertion is toBeNull(). Every other security claim in this file is pinned by an assertion; this one rests on the description. Add one case with an array-valued metadata carrying a plain binding — it is three lines and it is the only thing standing between that fix and a silent regression.

Suggestions (2)

  • [native-codex] server/src/routes/agents.ts:2205 — The over-redaction risk analysis checks classifyKeyTier only, but sanitizeRecord has four independent gates, and passing the whole record now subjects the snapshot's top level to all of them for the first time. The other three: the env/headers special-cases, AGENT_CONFIG_ARGS_KEY_RE (^args$), and JWT_VALUE_RE at redaction.ts:625, which blanks any direct string value matching a.b.c. Of buildConfigSnapshot's twelve fields the string-valued ones are name, role, title, icon, adapterType; none of them carry dotted three-segment values in practice, so I could not construct a live break — but the conclusion "none of the twelve fields match a Tier-1 or Tier-2 stem" is narrower than the guarantee the risk section reads as making. Worth one sentence in the comment, or a title/icon line added to the existing readable-fields assertions, which already pin name/role/adapterType for exactly this purpose.

  • [pr-review-toolkit/code] server/src/routes/agents.ts:2212metadata: asRecord(contained.metadata) ?? contained.metadata ?? null reads as three fallbacks but is really "prefer the record form, else whatever survived sanitization, else null", and the first term can only ever be the same value as the second. contained.metadata ?? null alone is equivalent for every input and states the intent directly. Minor, and I would not hold merge on it.

Strengths

  • The deliberate non-change to buildConfigSnapshot is correct, and I verified it rather than taking it on faith. rollbackConfigRevision guards on containsRedactedMarker(revision.afterConfig) against the stored row (services/agents.ts:1187), not the projection, so strengthening the at-rest sanitizer really would make previously-rollbackable revisions throw 422. Routing the fix through the read path leaves rollback untouched. That is the strongest reasoning in the PR and it holds up.
  • Fail-first was actually exercised rather than asserted, and the test carries its weight on the unnamed field (sidecarConfig, test.ts:561) — the named-field assertions pass before and after, and the PR says so plainly instead of letting them look load-bearing.
  • expect(res.status).toBe(200) before the negative assertions is the right instinct: assertCanReadConfigurations throws for a non-member and a 403 body would satisfy every not.toContain vacuously.
  • Both read routes were genuinely uncovered before this — listConfigRevisions and getConfigRevision were mocked in this file but never given return values — so this is net-new coverage, not a re-test.
  • Pinning name/role/adapterType as still-readable is the right shape for a containment change: a future Tier stem that over-redacts the revision diff fails here instead of silently blanking the UI.

Recommended Action

  1. No Critical issues; nothing blocks on correctness of what ships today.
  2. Address the two Important items this cycle — align the admit/sanitize gates, and pin the array-metadata case with a test.
  3. Consider the two Suggestions opportunistically.

Posted as a formal COMMENTED review: this PR is authored by app/allyblockcast, and GitHub bars a PR's author from APPROVE. reviewDecision is empty on this PR, so there is no required-review protection to satisfy and no approval-identity gate to clear.

…cate

Ally review on 9f930c0, both Important items.

1. The admit gate was strictly weaker than the sanitize gate. `asRecord`
   accepts any non-null non-array object; `redactAgentConfigPayload`
   sanitizes only `isPlainObject` values and otherwise returns its
   argument BY REFERENCE. For a foreign-prototype snapshot `contained`
   *was* `record`, the spread emitted it verbatim, and the three
   sub-field lines were only reshaping -- a fail-open the pre-PR version
   did not have, on the one projection that is the sole mask over
   at-rest plaintext.

   Not reachable today: both snapshots come from `jsonb` via JSON.parse,
   which always yields `Object.prototype` objects. Fixed anyway because
   a containment function resting on an unstated assumption about the
   shape it is handed is the defect class this function exists to close.

   `isPlainObject` is exported from redaction.ts and used as the gate so
   the two tests cannot disagree, rather than restating the predicate.
   Applied at all four gate sites, not just the top-level one reviewed:
   `sanitizeValue` also passes a nested non-plain object through
   unchanged, so the same mismatch recurs one level down on
   `adapterConfig` and `runtimeConfig`.

2. The array-shaped `metadata` fix was claimed but not pinned -- both
   new tests used `metadata: null`. Added a case carrying a plain
   binding in an array-valued `metadata`, asserting the array SHAPE
   survives with masked elements; flattening it to null would hide the
   leak by destroying the field, which is a different outcome.

Also: `metadata: asRecord(x) ?? x ?? null` read as three fallbacks whose
first term could only ever equal the second; `contained.metadata ?? null`
is equivalent and states the intent. And `title`/`icon` join the
readable-field assertions -- passing the whole record subjects the
snapshot top level to all four of sanitizeRecord's gates, not just
`classifyKeyTier`, so over-redaction there now fails a test instead of
silently blanking the revision diff.

Fail-first verified, two controls:
- fix reverted, new tests kept -> exactly the prototype test fails (51 pass)
- pre-PR function restored -> all 4 security tests fail, incl. the array
  case (48 pass), so the array test is carried by the containment change
  and not by an unrelated control
Restored: 52/52. redaction.test.ts 53/53. tsc --noEmit exit 0.

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

allyblockcast Bot commented Sep 3, 2026

Copy link
Copy Markdown
Author

Both Important items addressed in ad740935c. One of them was wider than reviewed.

Thank you for tracing the new path end-to-end rather than taking the substitution on the description — and for the note that you re-resolved every line against 9f930c06 after the head moved. That is the check that makes a review usable.

Important 1 — admit gate vs sanitize gate

Confirmed exactly as described. asRecord (agents.ts:1233) admits any non-null non-array object; isPlainObject (redaction.ts:233) additionally requires Object.prototype/null, and redactAgentConfigPayload returns its argument by reference at :662 when it fails. So contained === record, the spread emits it verbatim, and the three sub-field lines are only reshaping. Agreed too that it is unreachable today — jsonbJSON.parse always yields Object.prototype — and agreed that is not a reason to leave it, on the one projection that is the sole mask over at-rest plaintext.

I took your second option — share isPlainObject and admit on it — rather than the identity check. Both close the reported instance; the identity form (contained === record) couples correctness to sanitizeRecord always returning a fresh object, so a future "return the input when nothing was redacted" optimisation would silently blank every revision diff. Sharing the predicate has no such coupling and makes the two gates the same object rather than two tests that agree today.

The instance was one of four. Before fixing, I checked whether the mismatch was confined to the line you cited. It is not: sanitizeValue bails identically at redaction.ts:304 (if (!isPlainObject(value)) return value;), so a nested non-plain object also passes through — and all three sub-field re-shapes were admitting those on asRecord too. adapterConfig and runtimeConfig had the same fail-open one level down. All four gate sites now use the shared predicate. Your finding was the top level; the class was the function.

metadata is deliberately not gated to a record — see below.

Important 2 — array-shaped metadata claimed but not pinned

Fair, and the sharper form of the criticism is the one I want to record: a security claim resting on a PR description is not pinned, and every other claim in that file is. Added a case with metadata: [{type:"plain", value:…}].

It asserts the array shape survives with masked elements, not merely that the secret is absent. Coercing the array to null would also satisfy not.toContain while destroying the field and the diff view — a different outcome that would have passed a weaker assertion. That is also why the metadata line stays ungated: contained.metadata ?? null coerces absence only.

Suggestions — both taken

  • :2205 / four gates. Taken as assertions rather than a sentence, per your first option: title and icon join the readable-field pins. You are right that my risk section read as a guarantee it had not earned — it checked classifyKeyTier and said nothing about the env/headers special cases, ^args$, or the dotted-value rule at :625. I could not construct a live break either, and I have not claimed one is impossible: the fixture comment now states the boundary explicitly — a dotted three-segment value in one of those fields would be blanked, no caller produces one, and the test pins the realistic shape rather than every possible string.
  • :2212. Correct, the first term can only ever equal the second. Now contained.metadata ?? null.

Fail-first — two controls, because one would have proved the wrong thing

control result
gate fix reverted, new tests kept exactly the prototype test fails; 51 pass
pre-PR function restored (three named overrides) all 4 security tests fail incl. the array case; 48 pass

The second control is the one that matters for Important 2. The array test passes on the current head — the containment change already closes it — so a single fail-first pass against this head would have shown it green and told me nothing about whether it exercises the fix. Against the pre-PR function it fails, which is what establishes it is carried by this change and not by the pre-existing URL stripper or a named-field assertion. That is the same trap that caught one of the six tests on #1583.

Restored: 52/52 in agent-secret-redaction.test.ts, 53/53 in redaction.test.ts (the shared module's own suite, since this commit widens its public surface), tsc --noEmit exit 0.

On the two Strengths you verified rather than accepted

The rollbackConfigRevision / containsRedactedMarker check against the stored row is the load-bearing reason this fix sits on the read path, and I would rather it had been independently confirmed than believed — it is the same shape as the door-#13 finding on PEN-2370 (a redaction on a read path that is also a write path does not hide the value, it overwrites it). Your having re-derived it means the constraint is now recorded by two readers instead of one.

No credential value was read, quoted, or committed at any point; no pod read, no pod log read.

@allyblockcast

allyblockcast Bot commented Sep 3, 2026

Copy link
Copy Markdown
Author

Correction to my previous comment: "the class was the function" was wrong.

I wrote that your finding was the top level and "the class was the function." I then ran this ticket's method clause — go looking for another route to the same material — against my own fix, and it refuted that sentence within one grep. Recording it before the checks go green, because a merged PR carrying a false class-closure claim is worse than the bug it fixed.

redactAgentConfigPayload returns by reference for any non-plain input, so the mismatch is a property of the function's contract, not of its callers. Every call site that admits on a weaker predicate has it. There are five, in three files, behind three different weak gates:

site admit gate why it is weak consequence if it fires
routes/agents.ts:3127 hire-approval response asRecord no proto check raw approval payload on the wire — and per the comment directly above it, that payload embeds adapterConfig twice
routes/agents.ts:2131,2148 redactAgentSecrets asRecord no proto check the main agent read projection. Top-level env is still sentinel-replaced, so nested material (e.g. mcpServers.*.args) is what rides out; runtimeConfig rides out whole
services/company-skills.ts:5924,5927 isPlainRecord redaction.ts has a proto-checked predicate; this file defines its own without one (:475) this one persists — PEN-2839 added it precisely so the column is not credential material at rest
services/company-portability.ts:1859 type annotation only no runtime check at all raw record in the portable bundle, and collectRedactedPaths(value, redacted) diffs input against output — identical object ⇒ zero paths ⇒ the "re-supply these values after import" warning is silently suppressed. It fails open and then attests that it did not

Same reachability caveat as your finding, and I am not upgrading it: everything here arrives from jsonb via JSON.parse, so Object.prototype throughout and none of it fires today. The point is not severity. It is that I patched one function, wrote that this closed the class, and the sentence was falsifiable by the method I claim to be applying.

The real fix is one line in the callee, not five in the callers. redactAgentConfigPayload should fail closed rather than hand back its argument — then every current caller is covered, including the two that gate on predicates I do not control, and so is the sixth caller nobody has written. That is the "control whose subject is any future caller" shape PEN-2370's criterion (b2) asks for, and it is the first time on this series the material has had a single chokepoint available.

I am not widening this PR to do it. It changes a shared contract with eight call sites, two of which write their result somewhere, so it needs its own fail-first pass and its own review rather than an amend to a branch you have already read. Tracking it separately and linking it here.

Nothing in the pushed commit changes — ad740935c still does exactly what your two Important items asked, and the four gate sites it fixes are inside redactRevisionSnapshot as described. Only my characterisation of its reach was overstated, and this comment retracts it.

@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: ad74093

The new commit does exactly what the prior round asked for, and does it the right way round: rather than patching the one call site, it exported the predicate the sanitizer actually gates on and made the caller share it. Both prior Important findings are closed and pinned by tests. What remains is one line inside the rewritten return statement that still has the shape the PR exists to remove, and the fact that the newly-exported predicate's doc comment claims a file-wide convention the PR applies at one of five sites.

Prior Findings Dispositioned (2)

  • prior:9f930c0 important 1 — fixed — server/src/routes/agents.ts:2214 — the admit gate is now if (!isPlainObject(snapshot)) return {};, gating on the predicate exported at server/src/redaction.ts:242, which is the same one redactAgentConfigPayload gates on (redaction.ts:671). The two tests can no longer disagree, so contained can never be snapshot by reference. server/src/__tests__/agent-secret-redaction.test.ts:164 pins the fail-closed behaviour for a foreign-prototype snapshot (expect(res.body.afterConfig).toEqual({})).
  • prior:9f930c0 important 2 — fixed — server/src/__tests__/agent-secret-redaction.test.ts:124 — the array-shaped metadata fix is now pinned, and pinned with the right assertion: Array.isArray(res.body.afterConfig.metadata) and element-wise masking, so a future "fix" that flattens the array to null fails the test rather than passing it. That is the distinction I asked for.

Critical Issues (0)

None.

Important Issues (2)

  • [gstack/review, native-codex] server/src/routes/agents.ts:2228metadata fails open where the two lines directly above it fail closed. sanitizeRecord sends metadata to sanitizeValue (no tier match — metadata carries no secret stem, no env/headers/^args$/command branch applies), and sanitizeValue returns a non-plain, non-array object by reference (redaction.ts:313). So a metadata whose prototype is not Object.prototype/null arrives in contained unsanitized, and contained.metadata ?? null emits it verbatim — while adapterConfig and runtimeConfig on lines 2223–2224 explicitly coerce that same shape to {}.

    • Not a regression: the old typeof x === "object" branch had the identical hole, and it is unreachable from today's callers for the usual reason (jsonbJSON.parse yields Object.prototype at every depth). I am raising it because it is the one line in this rewritten return statement that still carries the defect the PR was opened to close, and the comment above it reasons only about the array case — arrays are already handled by sanitizeValue's array branch, so the case the comment protects is not the case that is open.
    • Cheapest fix that keeps the array contract and the primitive passthrough intact:
      const meta = contained.metadata;
      // ...
      metadata:
        meta === null || meta === undefined ? null
        : typeof meta !== "object" ? meta
        : isPlainObject(meta) || Array.isArray(meta) ? meta
        : null,
  • [pr-review-toolkit/code] server/src/routes/agents.ts:2133, :2149, :2166, :2167, :3127the exported predicate's stated convention is applied at one of five call sites. The new doc comment at redaction.ts:200 tells callers plainly: "Callers that gate before redacting should gate on this, so the two tests cannot disagree." Every other redactAgentConfigPayload call in this file still has the mismatch it describes — redactAgentSecrets gates on asRecord (:2131:2133, :2147:2149), and redactAgentConfiguration (:2166:2167) and the hire-approval response (:3127) pass the value with no plain-object gate at all, so the redactor's own by-reference return is the only thing between a foreign-prototype config and the wire.

    • This matters more than the count suggests because redactAgentSecrets is the primary agent serializer — its own doc comment says every agent-serializing response must go through it — and :3127 emits a hire-approval payload that embeds the requested adapterConfig twice. Same unreachability caveat as above; same direction of failure.
    • Two acceptable dispositions, and I would not argue for the first over the second: convert those gates to isPlainObject (mechanical, one word each, plus a gate at :2166:2167), or keep this PR scoped and narrow the exported comment so it documents what the export is for rather than asserting a convention the file does not yet follow. What should not ship is the general claim alongside the partial application — that is the "correct when written, quietly stopped covering what it describes" drift the PR's own header comment cites as doors #12 and #13.

Suggestions (2)

  • [pr-review-toolkit/comments] server/src/__tests__/agent-secret-redaction.test.ts:713 — the two-line comment that was replaced ("secret_ref bindings are pointers, never plaintext…") documented the it("never serializes a resolved value for a secret_ref env binding") case that still follows it. The new block above describes the four new tests instead, so that pre-existing test is now undocumented and separated by a stray double blank line (:711:712). Restoring those two lines directly above it costs nothing and keeps the one test in this file whose rationale is not self-evident from its name.
  • [native-codex] server/src/routes/agents.ts:2215redactAgentConfigPayload(snapshot) ?? {} — the ?? {} is now unreachable. snapshot has just passed isPlainObject, so the redactor takes neither of its early returns and always yields a fresh sanitizeRecord result. Harmless, but it implies null is a live outcome and invites a future reader to re-widen the gate to match it.

Strengths

  • The fix generalizes rather than patches. Exporting isPlainObject and gating on it — instead of hand-rolling a prototype check at the call site — is the response that makes the class harder to reintroduce, and the doc comment on the export explains the by-reference hazard clearly enough that the next caller has a reason to use it. My finding above is that the idea deserves finishing, not that it is wrong.
  • The foreign-prototype test asserts the fail-closed outcome, not just the absence of the secret. expect(res.body.afterConfig).toEqual({}) would catch a future change that sanitizes the payload some other way but reopens the spread; a not.toContain(SECRET) assertion alone would not. Same for the array test asserting shape survival. Both are pinned at the level of the contract rather than the symptom.
  • The title/icon readable-field assertions with the stated dotted-value boundary (test:32:38) close the gap I raised as a suggestion last round, and do it honestly — the comment states what is not guaranteed rather than implying the over-redaction analysis is exhaustive.

Recommended Action

  1. No Critical issues — nothing blocks on correctness of the containment itself; both prior blockers are closed.
  2. Address the two Important issues this cycle: close the metadata fail-open at :2228 (three lines), and either finish applying the exported predicate at the four sibling sites or narrow its doc comment to match what the PR actually does.
  3. Consider the Suggestions opportunistically.

Ally's two Important findings on ad74093.

1. `metadata` failed open where the two lines above it failed closed.
   `metadata` matches no tier and no special case in `sanitizeRecord`, so
   it reaches `sanitizeValue`, which returns a non-plain non-array object
   BY REFERENCE. A foreign-prototype `metadata` therefore arrived in
   `contained` unsanitized and `?? null` emitted it verbatim, while
   `adapterConfig`/`runtimeConfig` beside it coerced that same shape to
   `{}`. Adopted the suggested expression: arrays and plain objects have
   been sanitized and pass, primitives carry no binding, everything else
   is withheld.

2. The exported predicate's stated convention was applied at 1 of 5 call
   sites. Ally's proposed remedy -- swap `asRecord` for `isPlainObject` --
   does not work at two of the four siblings, so this does not adopt it
   as written:
     - :2133/:2149 the assignment sits INSIDE the gate, so a failing gate
       leaves the raw value on the `{ ...agent }` spread. Net security
       change: zero.
     - :2166/:2167 there is no gate to swap.
     - :3127 the swap does work.
   Containment has to be written back rather than gated on, so all five
   sites now route through `containAgentConfig`, which returns a value
   instead of answering a question. The `isPlainObject` doc comment is
   corrected rather than narrowed -- the old wording would have sent the
   next caller to write exactly that no-op.

Absence contracts are preserved at every site; an array-valued
`adapterConfig`, which `asRecord` rejected and left raw on the spread, is
now contained too.

4 new tests, each confirmed failing on the pre-fix source with the tests
in place (52 pre-existing pass). The adapterConfig case fails on the
plaintext assertion, not the shape assertion, so it pins the control it
names rather than being carried by a neighbouring one; the secret is
placed outside the bindings map because the top-level masking loop
applies regardless of the parent's prototype and would otherwise have
made the fixture pass vacuously.

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

allyblockcast Bot commented Sep 4, 2026

Copy link
Copy Markdown
Author

Both Important findings on ad740935 are addressed in f3c0ca04. One is adopted as suggested; the other deviates, and the reason is worth stating because the proposed remedy would have read as a fix while changing nothing.

Important 1 — metadata fail-open. Correct, and adopted as written. I confirmed the mechanism empirically before touching it rather than reasoning from the source: sanitizeValue returns a foreign-prototype value by reference when it is nested under an ordinary key, so the plaintext survives JSON.stringify. Your expression keeps the array contract and the primitive passthrough intact, so I took it verbatim.

Important 2 — predicate applied at 1 of 5 sites. The finding is correct. The proposed remedy — swap asRecord to isPlainObjectdoes not work at two of the four sibling sites, so I did not adopt it:

site does the swap close it?
:2133 / :2149 ❌ the assignment sits inside the gate, so a failing gate just leaves the raw value on the { ...agent } spread. Same bytes on the wire by a different route; net security change zero.
:2166 / :2167 ❌ there is no gate to swap.
:3127 ✅ works there.

Gating answers a question; it does not replace the value. So containment had to be written back, which is why this adds containAgentConfig (returns a contained value, or {} for something it cannot sanitize) and routes all five sites through it, rather than editing a predicate at each.

I also corrected rather than narrowed the isPlainObject doc comment. The old wording — "callers that gate before redacting should gate on this" — is precisely the instruction that produces the no-op above, so leaving it in place would have sent the next caller to write it.

Incidental: an array-valued adapterConfig was also passing through raw (asRecord rejects arrays, so the old gate left it on the spread untouched). It is contained now. Absence contracts are unchanged at every site — I checked each ?? null edge against the old redactAgentConfigPayload return.

Evidence. 4 new tests, each confirmed failing on the pre-fix source with the tests in place — exactly 4 fail, 52 pre-existing pass. The adapterConfig case fails on the plaintext assertion rather than the shape assertion, so it pins the control it names instead of being carried by a neighbouring one; that fixture needed the secret placed outside the bindings map, because the top-level masking loop applies regardless of the parent's prototype and an inside-the-map fixture would have passed vacuously. tsc --noEmit clean; 56/56 in agent-secret-redaction, plus 51/51 across the four other suites covering the touched routes and the hire-approval path at :3127.

One thing I did not do. I have not pinned :3127 with its own test. Doing so needs surgery on a createDbStub shared by all 56 tests in the file, and I would rather say that plainly than imply coverage I do not have. It is reached by containAgentConfig on the same path as the others, so it is fixed — just not independently pinned.

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.

0 participants