fix(server): redact agent secrets on mutating responses (BLO-18969) - #835
Conversation
GET /api/agents/:id ran adapterConfig through redactAgentSecrets; every mutating route returned the row verbatim. A budget-only PATCH therefore handed any caller with agents:configure the agent's whole credential set — plaintext env bindings plus `Bearer …` in mcpServers headers — and those responses land in agent transcripts and run logs, which are read far more widely than the secret store. Redact inside buildAgentDetail so the seam is closed by default, and wrap the routes that return a raw agent row: PATCH /agents/:id, /permissions (via buildAgentDetail), pause, resume, clear-error, approve, terminate, config-revision rollback, and both create paths. The hire response also redacts approval.payload, which embeds the requested adapterConfig twice. secret_ref / user_secret_ref bindings stay pointers — redactEventPayload passes them through without ever attaching a resolved value. Refs BLO-18969 Co-Authored-By: Claude <noreply@anthropic.com>
|
🔗 Paperclip issue: BLO-18969 |
1 similar comment
|
🔗 Paperclip issue: BLO-18969 |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: ef5544a
Critical Issues (1)
- [pr-review-toolkit + gstack/review + native-codex]
server/src/routes/agents.ts:2749— Nested adapter env bindings can still leave this mutation response in plaintext.redactAgentSecrets()force-masks only the top-levelagent.adapterConfig.env; the approval payload here, andruntimeConfig.modelProfiles.*.adapterConfig.envon the returned agent, go throughredactEventPayload(). That generic sanitizer preserves{ "type": "plain", "value": "..." }when the env variable name does not match its secret-key regex, so a credential stored under a valid ordinary name such asSIGNING_MATERIALorFOOis echoed by the route, including the duplicaterequestedConfigurationSnapshotcopy. This leaves the exact transcript-exposure class the PR intends to close. Add a structural adapter-config redactor that masks everyenvvalue independent of its key, apply it recursively to runtime model-profile configs and both approval payload copies, and add success-path tests for the hire response using a non-secret-looking env key in canonical plain-binding form.
Strengths
- The changed raw-agent mutation paths consistently route through one redaction helper.
- The new tests assert successful status codes, preventing error responses from passing vacuous leak checks.
- Top-level env values and MCP authorization headers are covered clearly.
Recommended Action
- Fix the nested env redaction gap before merge.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
|
@ally pushed CI cleanup fix in 39edd78. Root cause: server 3/4 failed on a transient PostgreSQL 40P01 deadlock from Verification:
|
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 39edd78
Prior Findings Dispositioned (1)
- prior:ef5544a critical 1 — still-present —
server/src/routes/agents.ts:2749— The exact current head still serializes the hire approval throughredactEventPayload(), whileredactAgentSecrets()likewise sendsruntimeConfigthrough that generic sanitizer at line 1845.redactEventPayload()preserves{ "type": "plain", "value": "..." }under ordinary env names, so nested model-profile adapter configs and both approval payload copies can still expose plaintext.
Critical Issues (1)
- [prior:ef5544a critical 1; pr-review-toolkit + gstack/review + native-codex]
server/src/routes/agents.ts:2749— Nested adapter env bindings remain incompletely redacted. The route supportsruntimeConfig.modelProfiles.*.adapterConfig.env, but only top-levelagent.adapterConfig.envis force-masked; nested configs and the approval payload use key-name-based generic redaction. A valid ordinary env key such asSIGNING_MATERIALorFOOwith a plain binding is therefore echoed, including underrequestedConfigurationSnapshot. Apply structural adapter-config redaction recursively to model-profile runtime configs and both approval payload copies, then add success-path tests using a non-secret-looking env key.
Strengths
- The CI cleanup change retries the transient
TRUNCATEwith bounded jittered backoff. - Cause-chain traversal covers the Drizzle wrapper shape that hid PostgreSQL
40P01errors. - Mutation-route tests assert successful responses before checking for leaked credentials.
Recommended Action
- Fix the carried-forward Critical nested env-redaction gap before merge.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
|
Merged current Verification on the merge result:
|
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 531036e
Prior Findings Dispositioned (1)
- prior:ef5544a critical 1 — still-present —
server/src/routes/agents.ts:2786— The exact current head still sends the hire approval payload throughredactEventPayload(), whileruntimeConfiguses the same generic sanitizer at lines 1845-1847. Unlike the structural masking applied to top-leveladapterConfig.env, this can preserve plain bindings under ordinary nested env names.
Critical Issues (1)
- [prior:ef5544a critical 1; pr-review-toolkit + gstack/review + native-codex]
server/src/routes/agents.ts:2786— Nested adapter env bindings remain incompletely redacted.runtimeConfig.modelProfiles.*.adapterConfig.envand both approval-payload copies still rely on key-name-based generic redaction, so a valid ordinary env key such asSIGNING_MATERIALorFOOcan be echoed with its{ "type": "plain", "value": "..." }value. Apply structural adapter-config redaction recursively to runtime model profiles and the approval payload, then add success-path tests using a non-secret-looking env key.
Strengths
- Raw-agent mutation responses consistently use the centralized redaction helper.
- Mutation-route tests assert successful status codes before checking for leaked credentials.
- The retry helper now recognizes PostgreSQL SQLSTATEs through the error cause chain, and cleanup adds jitter to reduce repeated deadlock collisions.
Recommended Action
- Fix the carried-forward Critical nested env-redaction gap before merge.
|
This merged (20:25Z, by @kkroo) while I was working the review — the Critical from Ally's last review at head Reproduced before fixing: four new tests fail against this PR's merged head and pass with the follow-up. The gap is that redaction decided what to mask from the key's name, so a Follow-up: #844 — makes the agent-config redaction structural rather than key-name based, and extends it to both approval read paths (a No action needed here; tracking on BLO-18969. |
…e (BLO-18969 follow-up) (#844) * fix(server): redact agent config secrets structurally, not by key name Ally's review of #835 found the carried-forward critical: redaction still decided what to mask from the key's *name*, so a `{type:"plain",value}` binding under an ordinary key kept its plaintext. The reported `PATCH /api/agents/:id` leak was closed, but the same credential shape one level down was not — `runtimeConfig.modelProfiles.*.adapterConfig.env` entries such as `SIGNING_MATERIAL`, or a bare `FOO`, were echoed verbatim. Add `redactAgentConfigPayload()`: a stricter sibling of `redactEventPayload` for anything embedding an agent config. Two structural rules at any depth — every plain binding is masked, and every `env` value is masked (covering the legacy bare-string form `envBindingSchema` still accepts). `secret_ref` / `user_secret_ref` bindings stay readable as pointers but lose any resolved `value`; the schema has no such field, so its presence only ever means plaintext leaked in, whatever `projectionClass` claims (AC3). Applied to every agent-config-serializing response: the agent row redactor, `/agents/:id/configuration`, config-revision snapshots, the hire 201, and both approval read paths — a `hire_agent` payload embeds adapterConfig, so payloads stored before this change stop leaking on read too. The stored hire snapshot deliberately keeps the generic redactor. It is replayed verbatim over the agent row by `activatePendingApproval`, which is what stops a pending agent tampering with its own config before the board sees it; masking it harder would write masks back over live credentials. It is kept safe on the way out instead. `PATCH` now restores redacted nested model-profile adapterConfigs against the stored config, so a UI round-trip of the newly-masked values does not hit `normalizeEnvConfig`'s sentinel rejection. Tests use keys no secret-name regex matches; all four fail on the parent commit and pass here. * test(server): unit-test redactAgentConfigPayload directly Pins the two redactors apart: the agent-config one must mask plain bindings and env values under ordinary key names at any depth, while redactEventPayload stays unchanged for its many other callers (events, heartbeat, tool guards). * fix(server): close structural redaction review gaps Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix(server): validate structural secret pointers Co-Authored-By: Paperclip <noreply@paperclip.ing> --------- Co-authored-by: kkroo <kkroo@users.noreply.github.com> Co-authored-by: Omar Ramadan <omar@blockcast.net> Co-authored-by: Paperclip <noreply@paperclip.ing>
Thinking Path
Linked Issues or Issue Description
Refs BLO-18969 (Paperclip-internal). No public GitHub issue.
Bug:
GET /api/agents/:idreturns"LINEAR_API_KEY": "***"and"Authorization": "***REDACTED***".PATCH /api/agents/:idreturned the same object un-redacted, with live credentials inline as{"type":"plain","value":"<secret>"}andBearer <token>inmcpServers.*.headers.Related prior work (both merged, both read-path only): #640 "[codex] redact nested agent adapter secrets" and #642 "[codex] preserve nested redacted adapter values". This PR extends the same redaction to the write paths those two left uncovered. No open duplicate PR.
Found when a routine cap-adjustment pass issued 12
PATCH /api/agents/{id}calls carrying only{budgetMonthlyCents}and harvested ~9 credential categories from the responses. Rotation of the exposed material is tracked separately from this code fix.What Changed
buildAgentDetail()now redacts on the non-restricted branch, so the seam is closed by default rather than at each call site. The two GET call sites that wrapped it externally drop their now-redundant wrapper.PATCH /agents/:id,POST /agents/:id/{pause,resume,clear-error,approve,terminate},POST /agents/:id/config-revisions/:revisionId/rollback,POST /companies/:companyId/agents.PATCH /agents/:id/permissionsis covered viabuildAgentDetail.POST /companies/:companyId/agent-hiresalso redactsapproval.payload— it embeds the requestedadapterConfigtwice (once directly, once underrequestedConfigurationSnapshot), so redacting onlyagentwould have left the same credentials on the wire one key over.redactAgentSecrets: every agent-serializing response must go through it,redactForRestrictedAgentView, orredactAgentConfiguration.agent secret redaction on mutating responsesblock inserver/src/__tests__/agent-secret-redaction.test.ts.createDbStubto return a real thenable. Itsthenwasvi.fn().mockResolvedValue(...), which returns a promise instead of invoking the awaiting continuation — any route that awaited adb.select()…where()hung for the full 60s test timeout rather than failing. No existing test reached that path.secret_ref/user_secret_refbindings are unaffected:redactEventPayloadpasses them through as pointers, so they never carry a resolvedvalueon a response regardless ofprojectionClass.Routes deliberately not changed, having been checked:
PATCH /agents/:id/instructions-path,PATCH /agents/:id/instructions-bundle,PUT|DELETE /agents/:id/instructions-bundle/filereturn narrow payloads that never serializeadapterConfig;DELETE /agents/:idreturns{ok: true}.Verification
Fail-then-pass on the new tests, which is the point — they must not pass against unpatched
master:Regression sweep over the agent route suites — 13 files, 182 tests, all passing:
Response shape for a budget-only
PATCH /api/agents/:id, before → after:Risks
Low, but two things a reviewer should weigh:
redactAgentSecretsreplaces env values with the"***"sentinel, so a UI read-edit-save could previously persist the sentinel over a live value. That hazard already existed on the GET path and is already handled bystripRedactedEnvBindingsFromAdapterConfigon the PATCH/POST ingress (BLO-5xxx). This PR widens which responses carry the sentinel; it does not change the ingress guard. Worth confirming no client depends on reading back a real credential from a mutation response — none in this repo does."***"/"***REDACTED***". That is the intended fix, not a regression, but it is a wire-visible change.Model Used
Claude Opus 5 (
claude-opus-5), 1M context window, extended thinking, with tool use and code execution via Claude Code.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue templateredactAgentSecrets