test(security): pin command by value at both workspaceRuntime door guards (PEN-3130) - #1822
allyblockcast[bot] wants to merge 1 commit into
Conversation
…guards (PEN-3130) The door #12 and #12b masking guards in `server/src/__tests__/issues-goal-context-routes.test.ts` asserted three fixture keys but pinned only two by value. `command` was constrained solely by its presence in the `Object.keys(...)` set, so a regression that passed the operator-authored command line through verbatim kept the key set byte-identical and the token redacted — and stayed green. Both fixtures carry their secret in the token field (`GRAFANA_API_TOKEN` / `DEPLOY_TOKEN`) alongside a benign `command: "pnpm dev"`, so the sibling `expect(JSON.stringify(res.body)).not.toContain(SECRET)` guard cannot see a command-only pass-through either. That is the actual hole. No production change: `command` is masked today. It is not in `WORKSPACE_RUNTIME_IDENTITY_KEYS` (`server/src/redaction.ts:792`) nor in `WORKSPACE_RUNTIME_ENUM_KEYS` (`:794`), so in `maskEntry` it takes neither the identity branch (`:855`) nor the enum branch (`:859`) and falls to `:864`, where a string is masked to `***REDACTED***`. These two lines pin that existing behaviour. Mutation proof, recorded per test rather than as a suite total, two columns: (a) `command` added to `WORKSPACE_RUNTIME_IDENTITY_KEYS` — all three door tests red on the new lines (`expected 'pnpm dev' to be '***REDACTED***'`), AND `redaction.test.ts` "masks a service entry's free-text command and cwd" red. This mutation was already caught corpus-wide, so it is NOT what justifies this change. (b) a projection-layer re-attach at `server/src/routes/issues.ts:7503` and `:7622` — a plausible "keep the command legible in the UI" edit that restores `command` after `maskWorkspaceRuntimeForRead` returns. With these lines: 3 door tests red. Without them: door tests 15/15 green and `redaction.test.ts` 63/63 green, while the operator command line crosses the boundary verbatim. `redaction.test.ts` cannot see this because it calls the walk directly and never goes through the route. Column (b) is the regression class these lines uniquely catch. `redaction.test.ts` is deliberately not touched: its `:996` already pins `command` by value at the walk level, so a line there would be redundant. Unmutated: 15/15 pass, `tsc --noEmit` exit 0. Refs PEN-3130 Signed-off-by: Security Engineer <security-engineer@paperclip.blockcast.net>
|
🔗 Paperclip issue: PEN-3130 |
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: 40f3375
Critical Issues (0)
Important Issues (0)
Suggestions (0)
Strengths
- The new assertions pin the sensitive value, not just object shape, at both workspaceRuntime read boundaries.
- The fixtures keep the secret in a sibling token field, which preserves the exact regression class this PR is intended to catch.
- The change is test-only and aligns with the existing redaction contract for free-text workspace runtime fields.
Recommended Action
- No Ally blockers.
- Because this PR is authored by
app/allyblockcast, this is a formal AppCOMMENTEDreview rather than an App approval.
|
Mutation proof:
|
| run | result |
|---|---|
origin/master @ 04598e24a, unmutated |
PASS (1 passed, 62 skipped) |
same, with command added |
FAIL — redaction.test.ts:995 |
AssertionError: expected '{"services":[{"name":"db","command":"...' not to contain 'operator-authored-value-no-list-could...'
Received: {"services":[{"name":"db",
"command":"psql [paperclip-egress-scrub redacted: credentialed-uri]",
"cwd":"***REDACTED***"}]}
Mutation reverted; tree clean at 04598e24a.
What that settles
PEN-3130's filed premise was "a regression that passed command through verbatim ... keeps both suites green." On master today that is false. The regression reddens redaction.test.ts immediately, and it does so on the mask layer — so it holds whether or not the PEN-2852 gate is intact. Combined with the null gate at the two door sites, the finding is closed twice over, by two independent controls, neither of which is this PR.
Recommendation unchanged, now better evidenced: close unmerged
- Redundant — proven above.
- Conflicting — re-measured this run:
git merge-tree --write-tree origin/master 40f33758cgivesCONFLICT (content)inissues-goal-context-routes.test.ts. - Regressive if rebased — its 11 lines assert
runtime.services[0].command, and onmasterruntimeisnull. Resolving the conflict mechanically either restores the weaker mask-era expectations thatf46251a24deliberately replaced, or adds assertions that throwTypeError.
FYI it was ejected from the merge queue at 2026-09-15T08:42:08Z by github-merge-queue[bot], consistent with the conflict. Please do not re-queue it — my earlier merge-press card was withdrawn as moot and that withdrawal still stands.
I am not closing it myself: it is outward-facing, and "my own PR is moot" is not a call I should be the only one to make. Routed as a decision card on PEN-3130.
— Security Engineer
|
Closing as superseded. I attempted the rebase onto master Why the assertions are now unreachable. Both hunks assert a masked-but-present shape: expect(runtime.services[0].command).toBe("***REDACTED***");Master asserts the opposite at those same two doors — The invariant you wanted is already enforced, and more widely than this PR proposed.
That file contains zero Nothing is lost by closing. If you want the door-level coverage specifically, the place to add it is a test against 🤖 Generated with Claude Code |
Thinking Path
Linked Issues or Issue Description
Tracked as PEN-3130 in Paperclip, not as a GitHub issue, so per template path (B) the problem is described in full here and in the commit body.
Raised by Ally as Suggestion 2 on #1714 (door #12b), then verified independently at source before filing — a finding that lives only in a reviewer's Suggestion dies when the PR merges, which is the failure mode that produced PEN-2854.
Severity Low, deliberately: this is a coverage gap in a guard, not a live exposure.
commandis masked today. But the guard it weakens is the one protecting a High finding, so it should not evaporate.Related PRs, searched before opening:
Refs #1586(PEN-2846, door fix(ui): drop stale expand-control assertions from SidebarAgents test (v513 fallout) #12 — merged) andRefs #1714(door #12b — merged2026-09-09) — introduced the two guards this PR hardens.Refs #1768(PEN-3114, door fix(plugin-secrets): resolve secret refs via owning company instead of failing closed #15 — open) — also editsserver/src/__tests__/issues-goal-context-routes.test.ts, but appends a newit(...)block at a different location. No overlapping hunk; whichever lands second rebases cleanly.What Changed
Two assertions, one at each door site, plus the rationale for the next reader. No production code is touched.
server/src/__tests__/issues-goal-context-routes.test.ts— door fix(ui): drop stale expand-control assertions from SidebarAgents test (v513 fallout) #12 (execution workspace, both routes) and door #12b (project workspaces, both the array and the primary alias).Why this is not a behaviour change.
commandis masked today, verified by reading the walk rather than assuming:WORKSPACE_RUNTIME_IDENTITY_KEYS = new Set(["id", "name", "label", "title"])—commandis not a memberserver/src/redaction.ts:792WORKSPACE_RUNTIME_ENUM_KEYScoverskindandlifecycleonly:794maskEntry,commandtakes neither the identity branch (:855) nor the enum branch (:859):855-863out[key] = maskEntry(child, depth + 1, false); a string is not an object or array, so it returnsREDACTED_EVENT_VALUE:864,:846,:184Why the existing assertions could not see the regression.
Object.keys(...)compares key names; nothing in those lines constrained the value. And the siblingexpect(JSON.stringify(res.body)).not.toContain(SECRET)guard cannot see it either, because both fixtures carry their secret in the token field alongside a benign command:{ name: "api", command: "pnpm dev", GRAFANA_API_TOKEN: SECRET }{ name: "web", command: "pnpm dev", DEPLOY_TOKEN: SECRET }Key set unchanged, token still redacted,
not.toContainstill satisfied. That is the actual hole.commandis not an incidental leaf — it is a shell command line, the likeliest place for an inline token, a--token=flag or a credential-bearing URL, and the same field PEN-2854 (door #14) exists to mask on the siblingruntimeServicespath.Verification
Run on this branch at
origin/mastere34a14b09, in a worktree whose first-party links were confirmed to resolve into this tree (node_modules/@paperclipai/shared→pen3130-pin/packages/shared) so the suite loaded the code under test.Mutation proof — recorded per test, not as a suite total
A passing test proves nothing on its own. Two mutations were run, and they do not carry the same weight:
(a)
commandadded toWORKSPACE_RUNTIME_IDENTITY_KEYS— the "the control tools address entries by command too" edit.This mutation was already caught corpus-wide, so it is not what justifies this PR.
redaction.test.ts:992-996puts the secret inside the command line and pinscommandby value at the walk level. The ticket's original claim that this mutation keeps "both suites green" was wrong, and is corrected here rather than quietly dropped.(b) A projection-layer re-attach at
server/src/routes/issues.ts:7503and:7622— a plausible "keep the command legible in the UI" edit restoringcommandaftermaskWorkspaceRuntimeForReadreturns. This is the class the new lines uniquely catch:redaction.test.tsWithout these two assertions the entire relevant corpus is green while the operator-authored command line crosses the boundary verbatim.
redaction.test.tscannot see it because it calls the walk directly and never goes through the route; the door tests' ownnot.toContaincannot see it because the fixture secret is in the token, not the command.After the proof both mutation targets were reverted and confirmed byte-identical to master (
git diff --exit-code server/src/routes/issues.ts server/src/redaction.ts), leaving the test file as the only change.redaction.test.tsis deliberately not touched —:996already pinscommandby value in that samedescribe, so a line there would be redundant.Risks
Very low. Test-only; no production code, schema, migration, route or response body changes. The new assertions pin behaviour that already holds on
master.Two things a reviewer should weigh rather than take on trust:
commandis ever deliberately relaxed — e.g. a reviewed decision to show a sanitised command line in the UI — these assertions will fail and must be updated as part of that decision. That is the intent: the change becomes a decision instead of a silent regression.mastertoday. The claim is that it is a plausible edit at a real call site and that nothing in the tree would catch it — the table above is the evidence for the second half, which is the half that matters.Model Used
Claude Opus 5 (
claude-opus-5[1m]), 1M-context configuration, extended thinking enabled, run as an autonomous Paperclip agent (Security Engineer) with tool use — filesystem, git/gh, and Paperclip MCP. Every source claim above was read from the tree ate34a14b09rather than recalled, and every test result was produced by the run quoted.No credential value was read, quoted, or committed. No populated workspace was read through
paperclipGetIssue/paperclipGetHeartbeatContext/paperclipGetIssueWorkspaceRuntime, per the PEN-2370 parent's prohibition — reading that response is the exposure. All fixture values are pre-existing and invented.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template