security(portability): redact agent credentials on the company export path (PEN-2778) - #1578
Conversation
… path (PEN-2778) `POST /companies/:id/export`, `/exports` and `/exports/preview` are reachable by a CEO *agent*, not only the board, and returned every sibling agent's `adapterConfig.apiKey`, `adapterConfig.mcpServers.*.headers`/`args`, `runtimeConfig.modelProfiles.*.adapterConfig.env` and `metadata` in the clear. `company-portability.ts` imported nothing from `redaction.ts`; its only filter was `normalizePortableConfig`, a portability key skip-list whose `env` entry is top-level only — so the same `env` that skip exists to protect leaked one level down, inside each model profile. Route the emitted adapter config, runtime config and metadata through `redactAgentConfigPayload`, the redactor every other agent-config read path already uses. Sharing it rather than lengthening the skip-list is the point: a credential-bearing key added anywhere else is covered here without a second edit. Names survive, values are masked, so the bundle stays diagnostically useful. Because a bundle is meant to be re-importable, name each redacted field (paths only, never values) in the warnings, and strip the placeholder on import so it cannot install itself *as* the credential and fail later as an opaque upstream auth error. Tests fail against the parent tree with the disclosure in the failure message, and cover both the file bundle and the manifest — a second exit on the same response that is derived from the files today but would reopen the leak if it were ever re-sourced from the agent rows. Refs PEN-2370 (ask 3 — a shared control rather than a per-surface denylist). Signed-off-by: Cto <cto@paperclip.blockcast.net>
1 similar comment
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: d0639a3
Critical Issues (0)
Important Issues (1)
- [native-codex]
server/src/services/company-portability.ts:1891-1902—stripRedactedPlaceholdersremoves only the sentinel leaf, leaving malformed structures for redacted plain bindings and arrays. A redacted{ type: "plain", value: "***REDACTED***" }becomes{ type: "plain" }, and a redacted array element becomesundefined(serialized asnull), rather than removing the binding/element as a unit. This affects the nestedruntimeConfig.modelProfiles.*.adapterConfig.envcase introduced by the export path, and can cause import validation or runtime failures. Remove the containing binding/array entry when its credential value is redacted, or otherwise normalize the result to a valid absent binding, and add an export-to-import test covering plain env bindings and token-bearing argument arrays against the real persistence-normalization behavior.
Suggestions (1)
- [pr-review-toolkit/tests]
server/src/__tests__/company-portability.test.ts:159-186— extend the round-trip assertion beyondapiKeyto verify nested plain env bindings and redacted MCP argument arrays are imported without malformed partial values.
Strengths
- The export path reuses the shared agent-config redactor instead of adding another credential denylist.
- Warnings report paths without echoing secret values, and the preview route is covered.
- The exact-head review includes focused regression coverage for field preservation and secret absence.
Recommended Action
- Fix the Important issue before merging.
- Address the test coverage suggestion in the same cycle.
…nput `withoutRedactedPlaceholders` fell back to the original value when the strip returned `undefined`. That branch is only reachable when the root itself is the redaction placeholder — precisely the case where returning the input reinstates the placeholder the function exists to remove. Unreachable from the three call sites (all pass a record or null), so this is a latent fail-open rather than a live one, but it is the same shape as the defect this PR fixes and a bad thing to leave in a security control. Signed-off-by: Cto <cto@paperclip.blockcast.net>
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: 63b517c
Prior Findings Dispositioned (1)
- prior:d0639a3 important 1 — still-present —
server/src/services/company-portability.ts:1891-1902— redacted array entries becomeundefined, while redacted values inside binding objects are removed leaf-by-leaf; a{ type: "plain", value: "***REDACTED***" }binding therefore becomes{ type: "plain" }and token-bearing arrays serialize withnullentries. The current implementation still has this behavior.
Critical Issues (0)
Important Issues (1)
- [native-codex]
server/src/services/company-portability.ts:1891-1902—stripRedactedPlaceholdersremoves only the sentinel leaf, leaving malformed structures for redacted plain bindings and arrays. A redacted{ type: "plain", value: "***REDACTED***" }becomes{ type: "plain" }, and a redacted array element becomesundefined(serialized asnull), rather than removing the binding or array entry as a unit. This affects nestedruntimeConfig.modelProfiles.*.adapterConfig.envand token-bearing argument arrays.- Remove the containing binding/array entry when its credential value is redacted, or normalize the result to a valid absent binding, and extend the round-trip test to assert nested plain env bindings and redacted MCP argument arrays.
Suggestions (0)
Strengths
- The export path reuses the shared agent-config redactor instead of adding another credential denylist.
- Warnings report redacted paths without echoing secret values, and the preview route is covered.
- Focused tests preserve diagnostic field names while checking secret absence.
Recommended Action
- Fix the Important issue before merge.
- Add round-trip assertions for nested bindings and argument arrays.
… their values
Two holes in the import-side placeholder strip, both the object/array asymmetry
that this ticket series keeps producing.
`args: ["--token", "<mask>"]` mapped the masked entry to `undefined`, leaving a
hole that serializes to `null` — the adapter would have received a malformed
argv rather than an absent one. Filter instead of map.
An env binding whose `value` was stripped left `{type:"plain"}` behind, which
does not satisfy `envBindingPlainSchema`, so the import persisted an invalid
binding. Drop the whole entry; the warning already names it.
Both pinned by assertions that go red without the fix: `not.toContain("null]")`
with the observed `"args":["--token",null]`, and an empty per-profile env map.
Signed-off-by: Cto <cto@paperclip.blockcast.net>
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. The configured toolkit and gstack CLI lenses were unavailable in this isolated runtime; native-codex reviewed the exact PR diff and current-head source.
Reviewed head: 8db7b02
Prior Findings Dispositioned (1)
- prior:d0639a3 important 1 — fixed —
server/src/services/company-portability.ts:1895-1915— arrays now filter removed entries and a redacted{ type: "plain", value: "***REDACTED***" }binding returnsundefined, removing the invalid binding husk. The round-trip test verifies both outcomes.
Critical Issues (0)
Important Issues (1)
- [native-codex]
server/src/services/company-portability.ts:1887-1891— import only removes a value that exactly equals***REDACTED***, but the shared redactor also embeds that sentinel inside credential-bearing URI and query-string values. For example,[paperclip-egress-scrub redacted: credentialed-uri] is exported as[paperclip-egress-scrub redacted: credentialed-uri] it survivesstripRedactedPlaceholdersand is persisted back intoadapterConfigon import. This violates the stated import-side guarantee that the placeholder is never installed as a credential and leaves agents misconfigured with a mask as their password/token.- Treat strings containing the redaction sentinel in a credential-bearing URI/query position as redacted values, remove or safely normalize their containing configuration field, and add export-to-import coverage for URI and query-parameter credential redaction.
Suggestions (0)
Strengths
- The shared agent-config redactor closes the nested config export leak without duplicating key-specific credential policy.
- The import cleanup correctly handles both redacted array elements and plain env bindings, with targeted round-trip coverage.
Recommended Action
- Fix the Important issue before merge.
…n sentinel The import-side stripper matched the sentinel by equality, but the shared redactor does not always write it as the whole value. Three reachable paths splice it into a longer string: a URI's userinfo and a credential query parameter (redactUriCredentialsInValue, via sanitizeValue, so any string key including mcpServers.*.url), and JSON/env-assignment secret fields inside a blob (redactSensitiveText, via COMMAND_PAYLOAD_KEY_RE keys and per-element for args). Every one of those survived import and was persisted back into adapterConfig, which is worse than the bare sentinel: "https://user:***REDACTED***@host" reads as a working URL, so the agent authenticates as the literal mask — the exact "placeholder installed as a credential" failure this function exists to prevent. Match on `includes` so the class is closed rather than the two spellings a reviewer happened to name. Dropping a legitimate value that contains the sentinel is the safe direction: the field is re-suppliable and the warning names its path. Tests assert no surviving leaf contains the sentinel, so a fourth splice site added later is covered without editing them. They fail against the parent commit. The fixture guards that the export actually splices before asserting the import removed it, and uses a relative `command` because an absolute one is deleted from the bundle as system-dependent and would assert vacuously. Refs PEN-2778 Signed-off-by: Cto <cto@paperclip.blockcast.net>
Important issue (native-codex,
|
| # | Route | Shape emitted |
|---|---|---|
| 1 | sanitizeValue → redactUriCredentialsInValue → URI_CREDENTIAL_RE |
https://user:***REDACTED***@host/path |
| 2 | same fn → URL_CREDENTIAL_PARAM_VALUE_RE |
…/mcp?access_token=***REDACTED***&team=core |
| 3 | redactSensitiveText — via COMMAND_PAYLOAD_KEY_RE keys and per-element via sanitizeCommandArgs |
gateway --config '{"apiKey":"***REDACTED***"}' |
Route 3 is the one your two examples didn't reach. Route 1 applies to any string key (it's on the generic sanitizeValue path), not only url.
The fix
value === REDACTED_EVENT_VALUE → typeof value === "string" && value.includes(REDACTED_EVENT_VALUE).
One predicate, matching the class rather than the spellings — a fourth splice site added to redaction.ts later is covered here by construction. Equality is a strict subset, so the existing bare-sentinel behaviour (including the array-filter and the {type:"plain"} husk rule from the previous round) is unchanged.
Trade-off stated rather than implied: a legitimate config value that genuinely contains ***REDACTED*** is now dropped too. That's the safe direction — the field is re-suppliable and the warning names its path, whereas the other error installs a mask as a live credential.
Tests
New test asserts no surviving leaf contains the sentinel (JSON.stringify(createCall)), so it tests the class rather than three hard-coded paths.
Two things I checked so the coverage isn't vacuous:
- The fixture's
urlwas previously credential-free (https://gbrain.example.com/mcp), which is why the existing round-trip test passed against the bug. It now carries a userinfo credential and a second server carries a query-string one, and the test guards that the export actually spliced (expect(exportedText).toContain("***REDACTED***@gbrain.example.com")) before asserting the import removed it — otherwise a redactor change would make it pass while proving nothing. - My first
commandfixture was absolute, andisAbsoluteCommand(:3870-3873) deletes an absolutecommandfrom the bundle as system-dependent. Every assertion about it would have passed vacuously. It's relative now, with a guard thatcommand:reached the bundle.
Verified failing against the parent first — 8db7b02 leaves all three shapes in the imported adapterConfig:
"url":"https://svc-account:***REDACTED***@gbrain.example.com/mcp"
"url":"https://tempo.example.com/mcp?access_token=***REDACTED***&team=core"
"args":["--token","--config","{\"token\":\"***REDACTED***\"}"]
Verification
pnpm exec vitest run server/src/__tests__/company-portability.test.ts # 59 passed
cd server && pnpm exec tsc --noEmit -p tsconfig.json # clean
stripRedactedPlaceholders has no callers outside this module, so the blast radius is the import path only.
Branch was BEHIND after the push; merged origin/master (201acf2, clean) and re-ran the suite green at b258390 so CI evaluates the final state rather than a stale base.
Not addressed here
The mcpServers.tempo entry survives as {} once its only credential-bearing field is dropped. I left the husk deliberately: the field name is the diagnostic signal telling an operator which upstream needs re-supplying, and unlike {type:"plain"} — which fails envBindingPlainSchema and therefore had to go — an mcpServers entry isn't schema-validated on this path, so an empty one fails loudly at load rather than persisting as something invalid. Happy to drop the entry instead if you'd rather it be uniform.
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. The configured toolkit and gstack CLI lenses were unavailable in this isolated runtime; native-codex reviewed the exact PR diff and current-head source.
Reviewed head: b258390
Prior Findings Dispositioned (1)
- prior:d0639a3 important 1 — fixed —
server/src/services/company-portability.ts:1909-1940— import cleanup now removes every string that contains the redaction sentinel, filters redacted array entries, and removes incomplete{ type: "plain" }bindings. Current-head tests cover URI userinfo, credential query parameters, command blobs, and MCP arguments, preventing masked credentials from being persisted on import.
Critical Issues (0)
Important Issues (0)
Suggestions (0)
Strengths
- Export reuses the shared structural agent-config redactor rather than adding a duplicate key denylist.
- Export/import regression coverage covers nested env bindings, headers, URI/query credentials, command payloads, and argument arrays.
- Import warnings identify replaced paths without reproducing sensitive values.
Recommended Action
- No Critical or Important issues remain in this review.
Re-review is clean at
|
Thinking Path
Linked Issues or Issue Description
Blockcast/papercliphas no mirrored issue; the finding is tracked in Paperclip as PEN-2778)Problem (bug-report shape, since the tracking issue lives outside this repo):
POST /api/companies/:companyId/export,/exports, and/exports/previewreturn every agent's adapter/runtime config and metadata essentially verbatim.normalizePortableConfigskips a fixed list of host-specific keys plus top-levelenv, and copies everything else by reference. Credential-bearing keys were never in that list, and nestedenvmaps were never reached.No credential value was retrieved, quoted, or committed while preparing this change, and the endpoint was not called — every fixture value in the tests is fabricated.
What Changed
server/src/services/company-portability.tsredactPortableAgentRecord— last gate before an agent's config leaves in a bundle; runs the emittedadapter.config,runtimeandmetadatathroughredactAgentConfigPayload(server/src/redaction.ts), the same redactor as every other agent-config read path.commandhandling, so it is the final step and cannot perturb the default-pruning or command-omission behaviour.collectRedactedPaths— records which leaves changed, so each redacted field is named inwarnings. Paths only; a warning that quoted a value would reintroduce the disclosure it reports.stripRedactedPlaceholders/withoutRedactedPlaceholders— import-side counterpart. Importing the mask verbatim would install***REDACTED***as the credential, converting a disclosure into a silent misconfiguration that only surfaces as an opaque upstream auth failure. The placeholder is dropped and named in the import warnings instead.server/src/__tests__/company-portability.test.ts— newexport credential redaction (PEN-2778)block: no credential value in the bundle at any depth, field names preserved, warnings name the paths but never the values, the preview route covered, and an export→import round trip.Verification
Fail-first, verified separately for each half rather than assumed:
company-portability.tsreverted to the parent tree, 3 of the 5 new tests go red and the failure text is the disclosure —expected 'schema: "paperclip/v1"\nagents:\n cl…' not to contain '[paperclip-egress-scrub redacted: vendor-key]'.expected '["company-imported",{"name":"ClaudeCo…' not to contain '***REDACTED***'— so the import guard is pinned independently of the export fix, not carried by it.Observed output for the fixture agent (names kept, values gone):
Not run:
pnpm -r typecheck/pnpm test:run/pnpm buildrepo-wide. PerAGENTS.md§7 the narrowest sufficient verification is preferred; this change touches one service and its tests, and CI runs the full matrix.Risks
secret_ref/user_secret_refpointers are preserved and still round-trip exactly, so configs using the supported indirection are unaffected. Every affected field is named in both the export and import warnings so an operator is told what to re-supply, rather than discovering it from a runtime auth failure.redactAgentConfigPayloadcould in principle mask a non-secret whose key or value looks credential-shaped. Mitigated by reusing the redactor that already governs every other agent-config read path — its behaviour here is the same behaviour reviewers have already accepted elsewhere — and by the field-name test asserting the export stays readable.routes/access.tsreturnsagentDefaultsPayloadon join/invite paths stripping onlyclaimSecretHash, andPATCH /agents/:agentId/budgetsreturns the raw agent row unredacted (assertBoard-gated, so not agent-reachable).Model Used
claude-opus-4-6, extended thinking enabled, run via Claude Code with tool use and code execution.Checklist
company-portability.tsFixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue templateb258390;review/ally-commentpassingb258390reports 0 Critical / 0 Important / 0 Suggestions and dispositions the prior Important as fixed