security(workspaces): scrub secrets on the workspace-operation write path (PEN-3205) - #1853
Conversation
1 similar comment
|
Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention: Missing or incomplete:
Once updated, push a new commit and these checks will re-run automatically. — commitperclip |
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: e81d100
The implementation consistently applies the shared sanitizer before both workspace-operation sinks, preserves the existing sanitizer entry points, and masks historical operation-list responses on the sibling route. I found no Critical or Important issues in the current diff.
Critical Issues (0)
Important Issues (0)
Suggestions (0)
Strengths
- Shared write-time sanitization prevents excerpt and log-store divergence.
- The regression tests cover stdout, stderr, both persistence sinks, and non-secret pass-through.
- The PR clearly separates the write-path fix from the explicitly deferred historical backfill.
Recommended Action
- Keep the current implementation.
- Complete the pending CI checks before merge.
…path (PEN-3205)
`services/workspace-operations.ts` applied exactly one transform to captured
command output: `redactCurrentUserText`, which is username censoring driven by
`censorUsernameInLogs`. It is not a secret scrub, and `redactSensitiveText`
appeared nowhere in that file or in `workspace-operation-log-store.ts`. That
single sanitized-only chunk landed in all three durable sinks -- `stdoutExcerpt`,
`stderrExcerpt`, and the log-store body.
Why this is the PEN-2328 / PEN-2370 shape and not just "git output":
`buildWorkspaceCommandEnv` (`services/workspace-runtime.ts:3658-3659`) builds the
environment for operator-authored provision / cleanup / teardown / workspace-job
commands as `{ ...process.env, ...buildAgentGitIdentityEnv(agent) }` -- the server
process environment, wholesale. `recordWorkspaceCommandOperation` then captures
that command's stdout and stderr straight into the excerpts and the log body. A
command running under `set -x`, or any tool that dumps its environment on failure,
wrote the server's environment into a durable company-readable channel with no
secret scrub in the path.
This is a write-path content defect, independent of PEN-3204: narrowing the read
does not fix an unscrubbed write, and scrubbing the write does not by itself
justify a standing company-wide read. Both halves are needed.
Changes:
- Extract `compactRunLogChunk` / `sanitizeRunLogChunkForStorage` out of
`heartbeat.ts` into `services/log-chunk-sanitizer.ts`. `heartbeat.ts` already
imports `workspace-operations.js`, so importing the sanitizer back from there
would close a cycle -- which is why this path grew a weaker private transform in
the first place. `heartbeat.ts` re-exports both so existing importers and tests
keep their entry point. One definition, not two: a second sanitizer would be a
second oracle, and the weaker one decides what a reviewer believes is covered.
- Route captured chunks through the shared sanitizer ONCE, before the excerpt
fork, so the excerpt columns and the log-store body cannot diverge. Scrubbing
one and not the other is the half-control this replaces.
- Censor `command` and `cwd` at write, consistent with the `metadata` beside them.
A cwd under a censored user's home was masked in the log body and legible in the
column next to it. `command` additionally gets the secret scrub -- an operator
provision command can carry a credential as an inline argument. Null stays null
and "" stays "": the transform applies to the value, it does not reclassify an
empty command as an absent one.
- Correct the `readLog` comment that claimed chunks were "sanitized before
append-time storage". That overstated a username-censor-only transform, the same
overstatement PEN-3139 corrected on the run-log path -- except here there was no
secret scrub at all to overstate.
- Apply the same username censoring on `GET /execution-workspaces/:id/workspace-
operations` that the sibling list route in `routes/agents.ts` already applied.
Both answer with the same historical rows including the excerpts, which
`publicWorkspaceOperation` deliberately does NOT withhold, so censoring one route
and not the other left the same bytes legible one URL over. New rows are censored
at write now; this still covers rows stored before this commit.
Tests fail first against the pre-fix path: the synthetic credential reaches both
the excerpt column and the log-store body in the clear (3 of 5 assertions fail).
The pass-through cases are the discriminator between scrubbing and blanking -- a
fix that simply dropped the output would satisfy every `not.toContain` and fail
those two.
Scope checked and deliberately NOT changed here: `publicWorkspaceOperation`
already masks `command`/`cwd` for unentitled viewers, and the excerpts are
company-readable by a standing product decision (CTO Ruling F, PEN-3202).
Narrowing that read is PEN-3204, not this row. Occurrence is unmeasured: the shape
was unprotected, the rate is unknown, and no peer's operations were read. The
backfill decision for already-stored rows is filed as PEN-3246 rather than
silently skipped.
Refs: PEN-3205, PEN-3246, PEN-3202, PEN-3153, PEN-3139
Signed-off-by: Security Engineer <security-engineer@paperclip.blockcast.net>
…-3205)
The workspace-operations route added in the parent commit reads
`censorUsernameInLogs` per request, to apply the same username censoring
`routes/agents.ts` already applied on the sibling list route. The PEN-2852
withholding apps mount `{}` as `db`, so the real `instanceSettingsService`
reached a `getGeneral()` against a stub and the route answered 500 --
reddening both door-#15 tests. Typecheck was blind to it: the import is a
direct module path, and the break is a runtime call through a stubbed `db`.
Stub the service the way the other route tests in this suite already do, and
default `censorUsernameInLogs` to OFF so the censor is inert underneath the
withholding assertions -- those measure `publicWorkspaceOperation`, and a
censor running beneath them could mask a sentinel and pass one for the wrong
reason.
That default would have left the route line itself uncovered, so cover it
explicitly with a pair: excerpts are spread through `publicWorkspaceOperation`
unmasked by design, so on this route the censor is the only control standing
over `stdoutExcerpt`. The setting is the sole discriminator between the two
cases -- removing the censor from the route fails the on-case (verified by
mutation), and replacing it with blanket blanking fails the readability
assertions. The home directory is read from `os.homedir()`, the same value
`defaultHomeDirs` derives its module-cached candidates from, so the pair is
deterministic on any runner without reaching into that cache.
Signed-off-by: Security Engineer <security-engineer@paperclip.blockcast.net>
e81d100 to
9feaa97
Compare
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: 9feaa97
The current diff consistently applies the shared sanitizer before both workspace-operation output sinks, preserves the existing heartbeat sanitizer entry points, and adds legacy-route username censoring. I found no Critical or Important issues.
Critical Issues (0)
Important Issues (0)
Suggestions (0)
Strengths
- Shared write-time sanitization prevents excerpt and log-store divergence.
- Tests cover stdout, stderr, both persistence sinks, and non-secret pass-through.
- The PR clearly separates the write-path fix from the deferred historical backfill.
Recommended Action
- Keep the current implementation.
- Merge after the repository's applicable checks remain green.
Thinking Path
Linked Issues or Issue Description
Related PRs found in the dedup search (adjacent paths, not duplicates of this one): #1746 (PEN-3153,
resultJson/errorpersistence), #1736 (PEN-3139, run-log free-text vendor shapes), #1747 (PEN-3152, github MCP egress), #1754 (PEN-3157, server GitHub write boundary), #1741 (PEN-3142, run-transcript read scoping). No open or closed PR targets the workspace-operation write path.What Changed
services/log-chunk-sanitizer.ts.heartbeat.tsalready importsworkspace-operations.js, so importingsanitizeRunLogChunkForStorageback from there would close a cycle — which is plausibly why this path grew its own weaker private transform.heartbeat.tsnow imports and re-exports both helpers, so existing importers and tests keep their entry point. One definition, not two: a second sanitizer would be a second oracle that can drift, and the weaker one would decide what a reviewer believes is covered.command/cwdat write, consistent with themetadatawritten beside them — acwdunder a censored user's home was masked in the log body and legible in the column next to it.commandalso gets the secret scrub (an operator provision command can carry a credential as an inline argument).nullstaysnulland""stays"": the transform applies to the value, it does not reclassify an empty command as an absent one.readLogcomment that claimed chunks were "sanitized before append-time storage" — that overstated a username-censor-only transform, the same overstatement PEN-3139 corrected on the run-log path, except here there was no secret scrub at all to overstate.GET /execution-workspaces/:id/workspace-operationsthatroutes/agents.tsalready applied. Both answer with the same historical rows including the excerpts, whichpublicWorkspaceOperationdeliberately does not withhold, so censoring one route and not the other left the same bytes legible one URL over.Verification
Verified at
origin/master @ 2294f311(fresher than thee34a14bthe row was filed against).npx tsc --noEmit -p server/tsconfig.json→ cleanserver/src/__tests__/workspace-operation-secret-scrub.test.ts: 5/5 passheartbeat-run-logtests pass unchanged through the extracted module, which is the evidence the extraction is behaviour-preservingThe tests fail first. Against the pre-fix path, 3 of 5 fail — the synthetic credential reaches both the excerpt column and the log-store body in the clear:
Both sinks are asserted deliberately — a test that only read the excerpt would pass against a fix that left the log body unscrubbed. The two pass-through cases are the discriminator between scrubbing and blanking: a fix that simply dropped the output would satisfy every
not.toContainand fail those two.One regression was introduced and fixed during review: importing
instanceSettingsServicefrom the../services/index.jsbarrel brokeexecution-workspaces-routes.test.ts, which mocks that barrel with an explicit export list. Importing the module directly (matching the service-layer convention) resolves it.Risks
sanitizeRunLogChunkForStorageout ofheartbeat.tstouches a hot path shared with run-log storage. The mitigation is that the 14 pre-existingheartbeat-run-logtests pass unchanged through the extracted module — behaviour-preserving by test, not by assertion.security-reviewisneutral): thesecret-scanhit is the synthetic fixturefake-pen3205-workspace-op-secretin the new test — a deliberately fake literal, no real credential is committed; thesensitive-pathhit isroutes/execution-workspaces.ts, expected because this PR touches that route by design. The advisory-sync 403 in that job is an environmental permission failure, not a finding.Scope — checked and deliberately not changed
publicWorkspaceOperationalready maskscommand/cwdfor unentitled viewers. Narrowing the company-readable excerpts is PEN-3204, not this PR.publicWorkspaceOperation(s)call sites, not the two the row named. The other two (execution-workspaces.ts:499,projects.ts:648) return a freshly recorded operation, which is now censored at write, so they need no read-side change.Corrections to the filed row
Reporting these rather than quietly shipping past them:
routes/execution-workspaces.ts:137was a bareres.json(operations). At current HEAD it is not — it isres.json(publicWorkspaceOperations(operations, viewer)). That half self-healed before I got here; the residual asymmetry was only theredactCurrentUserValuewrapper, which is what this PR adds.buildWorkspaceCommandEnvis:3658-3659(filed as:3482-3483);command/cwdare:143-144(filed as:138-139). The cited:126,:41and:256are unchanged.sanitizeRunLogChunkForStorage→compactRunLogChunk→redactSensitiveText. Worth stating becausesanitizeRunLogChunkForStoragedoes not name the scrub at its own call site.Occurrence is unmeasured — deliberately
I did not measure whether credential material actually landed here, and I read no peer's operations. The shape was unprotected; the rate is unknown. Do not price this as though it were measured, and do not read "no evidence" as "no occurrence" — nobody has looked.
Model Used
Claude Opus 5 (
claude-opus-5), 1M context window, extended thinking enabled, with tool use and code execution. Used to author the change, the tests, and this description.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue templatee81d100c4:review,policy,Helm chart,Vendored claude_k8s adapter,Canary Dry RunandGeneral tests (workspaces-a/b)pass;Build,Typecheck + Release Registry,General tests (server 1-4),e2e,OpenCode Responses replay,Worktree installandk8s-ro seed transport cold startare still queued or in progress behind busy runners.security-reviewisneutral— 2 expected flags, not a merge block (see Risks). The earlier note on this line saidreviewwas failing on this description; that was fixed by the description rewrite andreviewhas since passed.e81d100c48d60d6f869c77ba0e00c2f08bec46ad(2026-09-14T13:49:19Z) reporting 0 Critical / 0 Important / 0 Suggestions, andgate/ally-comment-findingsgreen.