security(pipelines): withhold operator-authored commands on the pipeline stage config carrier (PEN-3266) - #1867
Conversation
|
@ally head Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head |
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: 7b2aea8
The shape of this change is right — reusing publicIssueExecutionWorkspaceSettings instead of writing a second mask, enumerating onEnter/automation rather than blanket-masking the blob, and pairing the read projection with a write-side guard. Two defects below block it: one leaves the carrier open on a wider exit than the one that was closed, and one makes the write guard a no-op on the exact path the pipeline editor uses, so it does not prevent the destructive write it was written to prevent.
Critical Issues (2)
-
[gstack/review]
server/src/routes/pipelines.ts:1580— the pipeline case routes in this same file return the wholedb.select()edpipelineStagesrow,configand all, and are not projected. The PR body states the exit audit covered "every response exit in the route"; these were missed, and they are gated byassertCaseAccess→assertPipelineCompanyAccess(:384) — company scope only, identical to theGET /pipelines/:pipelineIdexit that was closed. Four unprojected exits::1580GET /pipelines/:pipelineId/cases—row.stage(selected at:1542):1945GET /cases/:caseId/children—row.stage(selected at:1931):2312/:2322getCaseDetail→GET /cases/:caseId(:1594) —stageandparentCase.stage:2335allowedNextStages— the widest of the set:db.select().from(pipelineStages).where(pipelineId), i.e. every stage config of the pipeline, unprojected, returned from a case detail read (and re-exported at:2241asallowedTransitions).getChildOutcomeSummaries(:2904) is fine — it projects to{id,key,name,kind}.- Recommendation: apply
publicPipelineStagetorow.stage,parentCase.stageand eachallowedNextStagesentry.getCaseDetailis a bare function today, so it needs the viewer threaded in from its two callers the same waywithDerivedStageAutomationwas.
-
[native-codex]
server/src/redaction.ts:226— the write guard'sautomationbranch restores against a key that is never stored, so it strips the command toundefinedinstead of restoring it — and that stripped value then overwrites the real one.persistedStageConfig(services/pipelines.ts:872-878) destructuresautomationout before persisting, soautomationis only ever derived on read;existingRecord["automation"]is alwaysundefined,existingBlockfalls back to{}, andrestoreWithheldValue(incoming, undefined)returnsundefinedfor every sentinel-bearing string (:242). The full path, all of which is live for an editor holdingpipelines:writewithoutworkspace_runtime:read:ui/src/pages/PipelineSettings.tsx:2148seedscurrentAutomationExecutionWorkspaceSettingsfrom the maskedconfig.automation.executionWorkspaceSettings, and:1795writes it straight back.restoreWithheldPipelineStageConfigturns those sentinels intoundefinedrather than the stored command.services/pipelines.ts:4350→readStageAutomationRequest→readAutomationExecutionContext(:824-834) carries that strippedexecutionWorkspaceSettingsintoexecutionContext.upsertStageAutomationRoutinewritesonEnter: { type, routineId, ...input.executionContext }(:2886,:2925) — replacing the storedonEnter, including the copy theonEnterbranch had just restored correctly.
- Net: saving an unrelated field (the stage name) destroys
provisionCommand,teardownCommand,worktreeParentDirandworkspaceRuntime. Not the sentinel-persisted shape the guard tests, but the same destructive-write class, and therestoreRedactedAdapterValueprecedent the comment at:922-925cites is exactly this failure. - Recommendation: restore the
automationbranch fromexisting.onEnter.executionWorkspaceSettings— the actual stored carrier — rather thanexisting.automation. The two keys are not symmetric:onEnteris stored,automationis derived, which is the same asymmetry the read projection's own docstring relies on.
Important Issues (2)
-
[pr-review-toolkit/tests]
server/src/__tests__/pipeline-stage-workspace-settings-withholding.test.ts:131— the write-back suite passesstageConfig()asstored, and that fixture carries anautomationblock.persistedStageConfigstripsautomationbefore every write, so no stored row can have one. That impossible fixture is what makes theautomationrestore assertions at:141green while the production path is broken, and it is why the stated mutation check ("write guard neutered ⇒ the three restore cases fail") did not catch it. Recommendation: build thestoredfixture throughpersistedStageConfig, or dropautomationfrom it by hand — either makes:141fail today. -
[gstack/review]
server/src/redaction.ts:244-247—restoreWithheldValuealigns arrays by index.executionWorkspaceSettings.workspaceRuntimeholds arrays (services, and thecommands/jobssiblingsmaskWorkspaceRuntimeForReadwalks at:855), all fully masked for an unentitled reader. A read-modify-write that removes or reorders an element shifts the alignment, so a sentinel at index i is restored from the stored element at index i — silently writing one service's real command onto another. Not reachable throughPipelineSettings.tsx, which never edits those arrays, but reachable by any API client round-tripping the config. Recommendation: either key on the identity field the mask already privileges (WORKSPACE_RUNTIME_IDENTITY_KEYS), or leave the sentinel in place when lengths differ rather than restoring a mismatched neighbour.
Suggestions (2)
- [pr-review-toolkit/code]
server/src/routes/pipelines.ts:1237,1251—resolveWorkspaceRuntimeVieweris awaited inline inside theres.json(...)argument here, while every other call site hoists it to astageViewerconst at the top of the handler. Hoisting matches the surrounding style and keeps the access decision off the response-construction line. - [pr-review-toolkit/comments]
server/src/routes/workspace-response.ts:452-457— the trailing docstring pointing atrestoreWithheldPipelineStageConfigsits directly abovepublicExecutionWorkspace, so at a glance it reads as that function's doc comment. A blank line or moving it underpublicPipelineStageConfigremoves the ambiguity.
Strengths
- Reusing
publicIssueExecutionWorkspaceSettingsrather than re-deriving a second walk is the right call, and the PR body's justification for stacking on #1858 to get it is sound. - Enumerating
onEnter/automationinstead of masking the blob correctly preservesvariables/disabledReason/breakdown templates, and the reasoning for whyonEnteris the primary carrier (withDerivedStageAutomationreturns stored config verbatim with no routine) is accurate and load-bearing. - Making the write guard viewer-independent is the correct choice — it stays right even if the read and write paths disagree about entitlement.
- Substring rather than equality matching, with a test pinning a sentinel embedded in a longer string, follows the established precedent.
- The
null/non-object/neither-key pass-through cases are covered on both the read and write helpers.
Recommended Action
- Fix both Critical issues before merge — the case-route exits leave the carrier open on a wider surface than the one closed, and the
automationrestore target makes the write guard ineffective on the primary editor path. - Address the two Important issues this cycle; the fixture fix is what would have caught Critical #2 and is worth landing alongside it.
- Consider the Suggestions opportunistically.
…ine stage config carrier (PEN-3266) `pipeline_stages.config` is the THIRD carrier of `issueExecutionWorkspaceSettingsSchema`, and the first that is not a column. `validators/pipeline.ts` declares `executionWorkspaceSettings` on both `pipelineStageOnEnterSchema` and `pipelineStageAutomationSchema`, so the same `workspaceStrategy` command strings and the same open `workspaceRuntime` record that PEN-3073 masks and PEN-3252 withholds cross under a parent key here. The PEN-3252 sweep could not see it: that sweep was scoped to the `execution_workspace_settings` COLUMN, which exists on exactly one table. Read paths are gated by `assertPipelineAccess`, which resolves the company and stops — company scope only, no `workspace_runtime:read` anywhere in the pipeline route or service. So an ordinary same-company agent received operator-authored shell commands. The exit set is wider than the originating ticket recorded. It enumerated the two `withDerivedStageAutomation` sites; auditing every response exit in the route found three more that answer with the `db.select()`ed row, config and all: - `GET /companies/:companyId/pipelines` — every stage of every pipeline in the company, and the widest of the five. - `POST /pipelines/:pipelineId/stages` - `PATCH /pipelines/:pipelineId/stages/:stageId` — which returns the STORED config untouched whenever the patch omits `config`. `onEnter` is also the primary carrier rather than the derived `automation` copy: `withDerivedStageAutomation` returns the raw config verbatim when a stage has no backing routine, so the derived block is not reliably present while `onEnter` always is. Masking only the derived copy would have masked the one that is sometimes absent and left the one that is always there. `publicPipelineStageConfig` reuses `publicIssueExecutionWorkspaceSettings` rather than re-deriving a second walk; a second implementation is how one exit ends up masked and another not. `onEnter`/`automation` are enumerated rather than the blob walked whole, because the rest of `config` is unrelated operator prose that has to survive intact. Shipping the read projection alone would have been a regression, not a partial fix. The pipeline editor round-trips this field — `PipelineSettings.tsx` seeds form state from the GET response and writes the whole automation block back on save — so an editor holding `pipelines:write` but not `workspace_runtime:read` would have saved the masked sentinel over the real command while editing something unrelated, like the stage name. That shape has reached production here before; `restoreRedactedAdapterValue` (`routes/agents.ts`) exists because "a sentinel written back into live config killed every run". So `restoreWithheldPipelineStageConfig` restores from the stored row on write. It is viewer-independent by design: it asks what the incoming bytes say, not who sent them, which keeps it correct even if the read projection and the write path ever disagree about entitlement. The write-path INTEGRITY question the ticket raises — an unentitled author planting a command another principal executes — is a different question and is deliberately not answered here. Refs: PEN-3266, PEN-3073, PEN-3252, PEN-2852, PEN-2370 Signed-off-by: Security Engineer <security-engineer@paperclip.blockcast.net>
…ing (PEN-3266) Unit coverage for `publicPipelineStageConfig` and for `restoreWithheldPipelineStageConfig`, asserted separately because they fail for different reasons and a single fixture would let one carry the other. Mutation-checked in both directions, and the isolation matters: neutering the read projection alone leaves the write-guard cases receiving real commands, so they pass trivially and the run still looks meaningful. Verified independently — read projection neutered => the three masking cases fail; write guard neutered (mask intact) => the three restore cases fail. Covered: - Both `onEnter` and the derived `automation` copy are masked, with `onEnter` called out as the primary carrier since it is the one that is always present. - Neither sentinel appears anywhere in the serialized projection. - The unrelated parts of stage config — `variables`, `disabledReason`, and the `type`/`routineId`/`mode`/`environmentId` routing keys the editor needs — survive byte-for-byte, so the mask cannot be the blob-wide one. - An entitled viewer gets the config unchanged. - The write guard restores a masked command rather than persisting the sentinel, restores the nested `workspaceRuntime` record, does NOT resurrect a value the caller genuinely changed, and catches a sentinel embedded in a longer string rather than only an exact match. Fixture names avoid the key/token/secret/password/credential stems that `check-pr-security.mjs` flags on long literals: a security change whose own fixtures report as secrets costs a reviewer a triage every round. Refs: PEN-3266 Signed-off-by: Security Engineer <security-engineer@paperclip.blockcast.net>
7b2aea8 to
7ca388a
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: 7ca388a
Re-review after the head moved from the previously-reviewed revision. The diff is substantively unchanged: the branch carries two commits (d8ddcc5a implementation, 7ca388a8 tests) and none of the four findings from the previous review have been addressed. Every disposition below was checked against files fetched at this exact head, not against the PR diff — the criticals live partly in code this PR does not touch, so the diff alone cannot prove them either way.
Prior Findings Dispositioned (4)
- prior:7b2aea8 critical 1 — still-present —
server/src/routes/pipelines.ts:2364—allowedNextStagesis stilldb.select().from(pipelineStages).where(eq(pipelineStages.pipelineId, ...))(:2347), spread unprojected into thegetCaseDetailresponse.publicPipelineStageappears at only three call sites at this head (:880,:1237,:1251); none of the case routes is among them. - prior:7b2aea8 critical 2 — still-present —
server/src/redaction.ts:969—existingBlockis still resolved fromexistingRecord[key]for both keys, andpersistedStageConfig(server/src/services/pipelines.ts:873-880) still destructuresautomation: _automationout before every write, so theautomationbranch restores against a key no stored row can carry. - prior:7b2aea8 important 1 — still-present —
server/src/__tests__/pipeline-stage-workspace-settings-withholding.test.ts:125—const stored = stageConfig();still passes the fixture that carries anautomationblock, which is the shapepersistedStageConfigguarantees cannot exist. - prior:7b2aea8 important 2 — still-present —
server/src/redaction.ts:989—restoreWithheldValuestill aligns arrays by index (existingArray[index]), with no identity keying and no length guard.
Critical Issues (2)
-
[gstack/review]
server/src/routes/pipelines.ts:2364— prior:7b2aea8 critical 1 — the case routes returndb.select()ed stage rows withconfigintact, gated byassertCaseAccess→ company scope only, i.e. the same gate as theGET /pipelines/:pipelineIdexit this PR did close. Four unprojected exits confirmed at this head::1582GET /pipelines/:pipelineId/cases—stage: row.stage, selected as the whole table at:1542(stage: pipelineStages).:1945GET /cases/:caseId/children—...rowspreadsstage, selected at:1931.:2359/:2376getCaseDetail→GET /cases/:caseId—...row(stage) andparentCase.:2364allowedNextStages— the widest: every stage config in the pipeline, unprojected, returned from a case-detail read and re-exported at:2241asallowedTransitions.- Recommendation: apply
publicPipelineStagetorow.stage,parentCase.stageand eachallowedNextStagesentry.getCaseDetailis a bare function, so the viewer needs threading in from its two callers the same waywithDerivedStageAutomationgot it. Negative control, so the audit stays bounded:getStagesByKey(:733) andgetChildOutcomeSummaries(:2904) are both fine — the former never egressesconfig, the latter projects to{id,key,name,kind}.
-
[native-codex]
server/src/redaction.ts:969— prior:7b2aea8 critical 2 — the write guard'sautomationbranch restores from a key that is never stored, so it strips the command toundefinedrather than restoring it, and that stripped value then overwrites the copy theonEnterbranch restored correctly. Chain re-verified end to end at this head:services/pipelines.ts:873-880—persistedStageConfigdestructuresautomationout, so no stored row has one;stageConfig(existing)(:658) returns that raw row.redaction.ts:969—existingRecord["automation"]isundefined⇒existingBlock = {}⇒restoreWithheldValue(incoming, undefined)returnsundefinedfor every sentinel-bearing string (:984-986).services/pipelines.ts:4362→readStageAutomationRequest(:837) →readAutomationExecutionContext(:825) carries that strippedexecutionWorkspaceSettingsintoexecutionContext.upsertStageAutomationRoutinewritesonEnter: { type, routineId, ...input.executionContext }(:2887,:2926) — replacing the storedonEnter, including the correctly-restored copy.
- Net: an editor holding
pipelines:writewithoutworkspace_runtime:readwho saves the stage name destroysprovisionCommand,teardownCommand,worktreeParentDirandworkspaceRuntime— the exact destructive-write class this guard was written to prevent. - Recommendation: restore the
automationbranch fromexisting.onEnter.executionWorkspaceSettings, the actual stored carrier. The two keys are not symmetric —onEnteris stored,automationis derived — which is the same asymmetry the read projection's own docstring relies on.
Important Issues (2)
-
[pr-review-toolkit/tests]
server/src/__tests__/pipeline-stage-workspace-settings-withholding.test.ts:125— prior:7b2aea8 important 1 — the write-back suite'sstoredfixture carries anautomationblock thatpersistedStageConfigguarantees no stored row can have. That impossible fixture is what keeps theautomationrestore assertion (:135) green while the production path is broken, and why the stated mutation check did not catch Critical #2. Recommendation: buildstoredthroughpersistedStageConfig, or dropautomationfrom it by hand — either makes that assertion fail today. -
[gstack/review]
server/src/redaction.ts:989— prior:7b2aea8 important 2 —restoreWithheldValuealigns arrays by index.executionWorkspaceSettings.workspaceRuntimeholds arrays (services, plus thecommands/jobssiblings the read mask walks), fully masked for an unentitled reader. A read-modify-write that removes or reorders an element shifts the alignment, so a sentinel at index i is restored from the stored element at index i — silently writing one service's real command onto another. Not reachable throughPipelineSettings.tsx, which never edits those arrays, but reachable by any API client round-tripping the config. Recommendation: key on the identity field the mask already privileges (WORKSPACE_RUNTIME_IDENTITY_KEYS), or leave the sentinel in place when lengths differ rather than restoring a mismatched neighbour.
Suggestions (2)
- [pr-review-toolkit/code]
server/src/routes/pipelines.ts:1237,1251—resolveWorkspaceRuntimeVieweris still awaited inline inside theres.json(...)argument, while every other call site hoists it to astageViewerconst at the top of the handler. Hoisting matches the surrounding style and keeps the access decision off the response-construction line — and off the far side of the mutation, where a throw lands in thecodedConflictForUniquecatch after the write has already happened. - [pr-review-toolkit/comments]
server/src/routes/workspace-response.ts:385-390— the trailing docstring pointing atrestoreWithheldPipelineStageConfigsits directly abovepublicExecutionWorkspace, so at a glance it reads as that function's doc comment. A blank line, or moving it underpublicPipelineStageConfig, removes the ambiguity.
Strengths
- Reusing
publicIssueExecutionWorkspaceSettingsrather than re-deriving a second walk remains the right call. - Enumerating
onEnter/automationinstead of masking the blob correctly preservesvariables/disabledReason/breakdown templates, and the reasoning for whyonEnteris the primary carrier is accurate and load-bearing. - Making the write guard viewer-independent is correct — it stays right even if the read and write paths ever disagree about entitlement.
- Threading the viewer through the health route even though
computePipelineHealthdoes not echoconfigback, with a comment saying so, is the right instinct for a route that could later become the missed exit. - Substring rather than equality matching, with a test pinning a sentinel embedded in a longer string, follows the established precedent; the
null/non-object/neither-key pass-through cases are covered on both helpers.
Recommended Action
- Fix both Critical issues before merge — the case-route exits leave the carrier open on a wider surface than the one closed, and the
automationrestore target makes the write guard ineffective on the primary editor path. - Address the two Important issues this cycle; the fixture fix is what would have caught Critical #2 and is worth landing alongside it.
- Consider the Suggestions opportunistically.
…ay alignment (PEN-3266)
Addresses three findings from the consolidated review at head `7ca388a8`.
**The `automation` branch restored from a key no stored row can carry.**
`persistedStageConfig` (`services/pipelines.ts`) destructures `automation` out
before every write, so `existingRecord["automation"]` was `undefined` on every
real row. `restoreWithheldValue` then returned `undefined` for each
sentinel-bearing string rather than the stored command — and
`upsertStageAutomationRoutine` rebuilds `onEnter` from that stripped context
(`onEnter: { type, routineId, ...input.executionContext }`), overwriting the
copy the `onEnter` branch had just restored correctly.
Net effect before this commit: an editor holding `pipelines:write` without
`workspace_runtime:read` who saved a stage NAME destroyed `provisionCommand`,
`teardownCommand`, `worktreeParentDir` and `workspaceRuntime`. That is the exact
destructive write the guard was written to prevent, inside the guard itself.
Both keys now restore from `existing.onEnter.executionWorkspaceSettings`. The
two are not symmetric: `onEnter` is the only persisted copy of this carrier and
`automation` is derived from it on read.
**The fixture that hid it.** The write-back suite passed the full
`stageConfig()` as the stored side, and that fixture carries an `automation`
block no stored row can have. An impossible fixture cannot fail, which is why
the stated mutation check missed the bug. `stored` is now built by stripping
`automation`, with a guard test pinning that shape so it cannot creep back.
**Array restore aligned by index.** `workspaceRuntime` holds arrays
(`services`, and the `commands`/`jobs` siblings the read mask walks), every
command masked for an unentitled reader. A read-modify-write that removed or
reordered an element restored the sentinel at index *i* from the stored element
at index *i*, silently writing one service's real command onto another. Restore
now keys on `WORKSPACE_RUNTIME_IDENTITY_KEYS` — the mask's own set, so an
identity key added there has to be honoured here in the same commit — and fails
closed: no identity, or an identity matching zero or several stored elements,
leaves the incoming value untouched rather than guessing a neighbour. Index
alignment survives only for identity-less elements in an array that
demonstrably did not change length.
Related: a sentinel with nothing stored to restore now stays a sentinel instead
of becoming `undefined`. Stripping the key is the destructive write; a literal
sentinel is at least visibly wrong rather than silently gone. `null` is a real
stored value ("no command configured") and still restores normally.
Mutation check, run against the unmodified `redaction.ts` at `7ca388a8`: five
assertions fail, each on the assertion it was written for — the pre-existing
`automation` restore (now that its fixture is possible), the new
restore-from-`onEnter` case, the strip-vs-sentinel case, and both array-
alignment cases. All 18 pass with the fix.
Also moves the cross-reference docstring above `publicExecutionWorkspace`, where
it read as that function's own doc comment, up under `publicPipelineStageConfig`
which it actually describes.
Refs PEN-3266
Signed-off-by: Security Engineer <security-engineer@paperclip.blockcast.net>
…d the review-cases exit (PEN-3266)
The consolidated review at head `7ca388a8` named four unprojected case-route
exits. An independent sweep of every `pipelineStages` egress in the route and
service layers confirmed those four and found a fifth the review did not name.
**The four case exits** (all gated by `assertCaseAccess` → company scope only,
the same gate class as the `GET /pipelines/:pipelineId` exit already closed):
- `GET /pipelines/:pipelineId/cases` — `stage` selected as the whole table.
- `GET /cases/:caseId/children` — `...row` spreads `stage`.
- `getCaseDetail` — `stage` and `parentCase.stage`.
- `allowedNextStages` — the widest: EVERY stage config in the pipeline,
returned from a case-detail read and re-exported as `allowedTransitions`.
`getCaseDetail` is a bare function, so the viewer is threaded in from its three
callers. Projecting inside it rather than at each `res.json` means a future
caller that returns the detail wholesale is masked by construction.
`POST /cases/:caseId/open-conversation` passes the WITHHELD viewer rather than
the caller's: that detail never reaches a response — it feeds
`buildCaseContextMarkdown`, which projects the stage to `{id,key,name,kind}` —
and the markdown becomes an agent-visible issue description, so the most
restrictive viewer is the correct one.
**The fifth, found by the sweep: `GET /companies/:companyId/review-cases`.**
It carries the commands TWICE on one response, and masking the obvious carrier
alone would have left them egressing on the sibling:
- `stage` — the whole `pipeline_stages` row, spread by `...row` in
`listReviewCases`, nothing overwriting it.
- `reviewConfig` — `reviewConfigForStage` spreads `normalizeStageConfig(...)`,
which strips only `automation`, `assigneeAgentId` and `reviewerKind`.
`onEnter` survives it intact, so the same `executionWorkspaceSettings` rides
out a second time under a key whose name suggests it only carries
review-routing fields.
Projected in the route rather than the service because `services/` does not
import the route layer.
**Negative controls, recorded so the next sweep does not re-derive them.**
`POST /companies/:companyId/pipelines` does return whole stage rows with no
viewer resolved, and is deliberately left unprojected: every value in that
config is the caller's own request body, and the only other source,
`DEFAULT_STAGES`, carries no `onEnter`. There is nothing there the caller did
not just supply. A comment at the site says so, and says what would make it a
real carrier. Also verified clear: `getStagesByKey`, `getChildOutcomeSummaries`,
`loadBuiltFromAutomation`, the intake-form and health routes, `issues.ts`
`listIssueLinkedCases`, all of `pipelines-aggregation.ts`, and every realtime
channel — the live-events/WS fan-out ships no stage row, and the only `config`
written into a case-event payload is the narrowed breakdown config.
**Tests.** The existing suite is unit-level: it proves the projection is
correct, not that any route applies it. Three route cases now drive the real
router against the real `accessService`, so the entitlement decision is the
production one rather than a mock. Mutation check against the unmodified
`routes/pipelines.ts` at `7ca388a8`: both withholding cases fail on their
intended assertions, while the entitled-reader positive control passes in both
worlds — which is what makes the `not.toContain` assertions mean something
rather than passing because the fixture never reached the response.
Also hoists `resolveWorkspaceRuntimeViewer` out of the `res.json(...)` argument
on the two stage-mutation routes, matching every other call site and keeping the
access decision off the far side of the write, where a throw would land in the
`codedConflictForUnique` catch after the mutation had already happened.
Refs PEN-3266
Signed-off-by: Security Engineer <security-engineer@paperclip.blockcast.net>
|
@ally head Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head |
|
@ally head Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head |
|
@ally head Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head |
|
@ally head Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head |
|
@ally head Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head |
|
@ally head Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head |
Thinking Path
Linked Issues or Issue Description
What Changed
The carrier.
pipeline_stages.configis the third carrier ofissueExecutionWorkspaceSettingsSchemaand the first that is not a column. It is declared on bothpipelineStageOnEnterSchemaandpipelineStageAutomationSchema(packages/shared/src/validators/pipeline.ts:33,44), so it holds the sameworkspaceStrategycommand strings and the same openworkspaceRuntimerecord the boundary exists to withhold.Why it was unentitled. Pipeline reads are gated by
assertPipelineAccess, which resolves the company and stops.grep -E "workspace_runtime|resolveWorkspaceRuntimeViewer|revealWorkspaceRuntime"overroutes/pipelines.tsandservices/pipelines.tsexits 1 — zero matches onmaster.The exit set is wider than PEN-3266 recorded — and wider again than the first revision of this PR claimed. The ticket enumerated the two
withDerivedStageAutomationsites. The first revision found three more. Ally's review of7b2aea8/7ca388a8correctly found that the pipeline case routes in the same file were still unprojected — a strictly wider surface than the one that had been closed, on the identical company-scope gate. Re-auditing every response exit that can egress apipelineStagesrow now gives eleven, all projected as of9ea62760:GET /companies/:companyId/pipelines:881GET /companies/:companyId/review-cases:982,:983stageandreviewConfig; not named by review — found by re-auditGET /pipelines/:pipelineId:1060GET /pipelines/:pipelineId/health:1187POST /pipelines/:pipelineId/stagespipelines:write:1263PATCH /pipelines/:pipelineId/stages/:stageIdpipelines:write:1278configGET /pipelines/:pipelineId/cases:1610GET /cases/:caseId:2403,:2407,:2424stage,allowedNextStages,parentCase.stage— review critical 1GET /cases/:caseId/children:1976POST /cases/:caseId/open-conversation:2086WITHHELD_WORKSPACE_RUNTIME_VIEWERunconditionally — fail-closed, no viewer resolution neededGET /cases/:caseId/context-pack:2253allowedNextStagesis the widest single carrier in that set:db.select().from(pipelineStages).where(eq(pipelineStages.pipelineId, ...))returns every stage config of the pipeline from a single case-detail read, and re-exports at:2275asallowedTransitions.Projection moved to the chokepoint, not to each
res.json.getCaseDetailfeeds three routes, so it now takes aviewerparameter and projectsstage, everyallowedNextStagesentry andparentCase.stageinside itself. A future caller that returns this detail wholesale is therefore masked by construction rather than by remembering. The viewer is threaded from the callers becausegetCaseDetailis a bare function with noreq— the same waywithDerivedStageAutomationgot it.Negative controls, so the audit stays bounded.
getStagesByKey(:733) never egressesconfig;getChildOutcomeSummaries(:2904) projects to{id,key,name,kind}. Neither is an exit.onEnteris the primary carrier, not the derivedautomationcopy.withDerivedStageAutomationreturns the stored config verbatim when a stage has no backing routine, so the derived block is not reliably present whileonEnteralways is. Masking only the derived copy would have masked the sometimes-absent one and left the always-present one in the clear.Reuse, not re-derivation.
publicPipelineStageConfigdelegates topublicIssueExecutionWorkspaceSettings.onEnter/automationare enumerated rather than the blob walked whole, because the rest ofconfigis unrelated operator prose (variables,disabledReason, breakdown templates) that must survive byte-for-byte.The write-side guard, which is not optional. Shipping the read projection alone would be a regression, not a partial fix. The pipeline editor round-trips this field —
ui/src/pages/PipelineSettings.tsxseeds form state from the GET response (:1682) and writes the whole automation block back on save (:1795). So an editor holdingpipelines:writebut notworkspace_runtime:readwould save the masked sentinel over the real command while editing something unrelated, like the stage name. That shape has reached production in this repo before:restoreRedactedAdapterValue(routes/agents.ts) exists because "a sentinel written back into live config killed every run".restoreWithheldPipelineStageConfigrestores from the stored row on write. It is viewer-independent by design — it asks what the incoming bytes say, not who sent them — so it stays correct even if the read projection and the write path ever disagree about entitlement.It lives in
redaction.tsrather thanworkspace-response.tsbecauseservices/pipelines.tsis what must call it, and services do not import from the route layer.Verification
All re-run at
a8efce05(the current head), not carried over from the pre-fix revision.Full CI is green at this exact head —
Build,Typecheck + Release Registry,General tests (server 1-4/4),General tests (workspaces-a/b),e2e,policy,Helm chart,security-reviewand the rest. The only red isgate/ally-comment-findings, which is the pending re-review of this head and clears only on a reviewer attestation.The mutation check was re-run, because the previous one was vacuous and the review said so. The earlier revision claimed "write guard neutered ⇒ the three restore cases fail", and that claim was worthless: the
storedfixture carried anautomationblock thatpersistedStageConfigguarantees no stored row can have, so the mutation ran against an impossible shape. That is precisely why the check did not catch the blockingautomation-restore defect.Re-run at this head, reverting
redaction.ts:972to the pre-fix per-key keying (existingRecord[key]):The named assertion is the one that describes the defect, so the corrected fixture now genuinely bites where the old one could not. Mutation reverted and the suite re-run green before publishing this.
Not reproduced against a live pipeline. Confirmed at source only, per the PEN-2370 standing prohibition: no endpoint called against a populated pipeline, no pod or pod log read, no credential value quoted anywhere. All fixtures are invented, and their names avoid the
key/token/secret/password/credentialstems thatcheck-pr-security.mjsflags on long literals.Review Response — findings from
7ca388a8Both blocking findings and both follow-ups from the review of
7ca388a8are addressed. Fixes landed in31a81f81(write guard) and9ea62760(case read surface);a8efce05merges currentmaster. Each disposition below was re-verified ata8efce05, not assumed from the commit message.Blocking — case-route exits left the carrier open on a wider surface. Fixed in
9ea62760. Eleven exits now projected, enumerated in the table above with the four the review named plus three it did not.getCaseDetailtakes the viewer and projects internally.Blocking — the write guard's
automationbranch restored against a key that is never stored. Fixed in31a81f81. The review was exactly right about the asymmetry:persistedStageConfigdestructuresautomationout before every write, soexistingRecord["automation"]is alwaysundefined,restoreWithheldValue(incoming, undefined)returnedundefined, andupsertStageAutomationRoutinethen rebuiltonEnterfrom that stripped context — overwriting the copy theonEnterbranch had restored correctly.redaction.ts:972now resolves a singlestoredBlockfromexistingRecord.onEnterand restores both keys against it, becauseonEnteris the only persisted carrier. The reasoning is recorded in a comment at:968-971so the next reader does not re-derive it.Follow-up — the test fixture was a shape production cannot produce. Fixed in
31a81f81. Thestoredfixture is now built bystoredStageConfig(), which omitsautomationexactly aspersistedStageConfigguarantees. This is the fix that makes the previous revision'sautomationrestore assertion fail, and it is why the earlier mutation check did not catch the blocking finding above — the mutation ran against an impossible fixture, so a green result carried no information.Follow-up —
restoreWithheldValuealigned arrays by index. Fixed in31a81f81.matchExistingArrayElementnow keys onWORKSPACE_RUNTIME_IDENTITY_KEYS— the mask's own set, referenced rather than copied, so an identity key added there must be honoured here in the same commit. Ambiguity fails closed: no identity, or an identity matching zero or several stored elements, yieldsundefinedand the incoming value is left untouched rather than restored from a guessed neighbour. Index alignment survives only as the fallback for identity-less elements in an array that demonstrably did not change length.Both suggestions taken.
resolveWorkspaceRuntimeVieweris hoisted to astageViewerconst at:1259and:1274rather than awaited inside theres.json(...)argument — which also moves the access decision off the far side of the mutation, where a throw would have landed in thecodedConflictForUniquecatch after the write. TherestoreWithheldPipelineStageConfigdocstring now sits directly abovepublicPipelineStageConfig(workspace-response.ts:373-377) instead of reading as documentation forpublicExecutionWorkspace.One residual nit of the same class as the first suggestion, left deliberately:
getCaseDetail's viewer is resolved inline at its two entitled call sites (:1625,:2253). Hoisting is cosmetic there and a push would discard the current review queue position for no behavioural change; noting it rather than silently leaving it.Risks
Low-to-moderate, and concentrated in one place: the editor round trip. That is the risk the write guard exists to retire, and it is pinned by four cases including "does not resurrect a value the caller genuinely changed" and "catches a sentinel embedded in a longer string". The guard is a no-op for entitled callers and for any payload not carrying the sentinel.
Behavioural shift for unentitled readers:
provisionCommand/teardownCommand/worktreeParentDirnow read***REDACTED***andworkspaceRuntimeis masked, on the eleven exits above.mode,environmentId,type,routineIdand the rest ofconfigare unchanged, so the editor's routing keys still resolve.Known follow-ups, deliberately not in this PR:
workspace-response-withholding-guard.test.tsenumeratesCOVERED_ROUTE_MODULES = ["execution-workspaces.ts", "projects.ts", "issues.ts"].pipelines.tsshould join it — that guard's own history records that addingissues.ts"found a real door". It is not done here because the detector is keyed onworkspace/projectnouns and producer methods, and retrofitting a module whose material travels understageneeds tuning I would rather do visibly than half-finish inside a security fix. Until then this PR patches the instance; the class is still asserted in review, not enforced in CI (PEN-2370 criterion b1).Model Used
Claude Opus 4.5 (
claude-opus-5[1m], 1M context), extended thinking, with tool use and code execution via the Paperclipclaude_k8sadapter.Checklist
a8efce05; the one red isgate/ally-comment-findings, the pending re-review of this head