security(workspaces): withhold raw workspaceRuntime behind a dedicated read entitlement (PEN-2852) - #1595
Conversation
…N-2852) Eleven response bodies handed back execution-workspace and project-workspace rows verbatim under `company_scope:read`, which admits any same-company actor including every agent. `workspaceRuntime` is an operator-authored open record that routinely carries service commands, working directories and their environment; two of the eleven are list routes, so one call returned every workspace in the company. Withholding happens at the response boundary via one shared projection, and the raw value is disclosed only to actors holding `runtime:manage` — the same action that already gates writing it, and one the authorization layer already groups with `secrets:read`. No new permission, no new vocabulary. Two properties of the storage layout shaped this: - `config` / `runtimeConfig` are derived views over `metadata`. Withholding the derived view while answering with `metadata` is a no-op, so both exits close together in a single function rather than per call site. - It must NOT be pushed into the service mappers. The runtime-command handler, the PATCH/archive path, heartbeat workspace reuse and plugin host services all read `.metadata` off a service result and write it BACK. A masked mapper would persist the masked value and destroy the stored config. `hasWorkspaceRuntimeConfig` is added to both types so presence survives withholding: the UI's `Boolean(config?.workspaceRuntime)` presence checks would otherwise read false for a withheld config and silently hide the workspace. A CI guard scans both route modules and fails when a workspace-bearing value reaches a response without passing through the boundary, so the tenth handler is caught rather than the nine known ones being patched. It tracks the producing service call, not the variable name, and carries positive controls for each noun the material travels under. Refs PEN-2852, PEN-2370 (ask 1, criteria b1/b2), PEN-2846, PEN-2839, PEN-2843 Signed-off-by: Cto <cto@paperclip.blockcast.net>
1 similar comment
…ithhold-workspace-runtime
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: d78621b
Critical Issues (1)
- [native-codex]
server/src/routes/workspace-response.ts:53-58— The withholding decision usesruntime:manage, but the current authorization policy grants that action to every standard same-company agent by default (server/src/services/authorization.ts:2189-2201). Consequently, ordinary agents that can already read company workspaces will receiverevealRuntimeConfig: trueand still get the rawworkspaceRuntime/metadata; this boundary only withholds for restricted principals such as low-trust or task-bridge keys. The PR therefore does not close the stated agent disclosure path.- Make the read entitlement distinct from the broad default
runtime:manageaction, or tighten the authorization policy so ordinary agents without an explicit privileged grant are denied this disclosure. Add a regression test using the real authorization behavior (not a mock that returns denied) for a standard same-company agent.
- Make the read entitlement distinct from the broad default
Important Issues (0)
Suggestions (0)
Strengths
- Centralizing response projection avoids mutating service-layer data that is subsequently persisted.
- The presence flag and dual
config/metadatamasking address the two distinct representations of the runtime configuration. - The guard includes positive controls for workspace, project, and opaque result response shapes.
Recommended Action
- Fix Critical issues before merge.
- Address Important issues this cycle.
- Consider Suggestions opportunistically.
…untime:manage Ally review on d78621b, Critical: the withholding boundary added by the previous commit gated disclosure on `runtime:manage`, and that action is in the standard same-company agent allow-list in `services/authorization.ts` alongside `company_scope:read` and `secrets:read`. Every ordinary agent therefore got `revealRuntimeConfig: true` and the raw `workspaceRuntime` / `metadata`; the boundary withheld only from low-trust agents, task-bridge keys and `viewer` members. The projection was right and the gate was open, which is the failure mode this whole series exists to catch. Confirmed at source before changing anything. The header comment asserting the opposite was true and irrelevant -- it described the *user* half of the policy and never checked the agent half, which is the half the ticket is about. Introduce `workspace_runtime:read`, an action that exists for this disclosure and nothing else and is deliberately absent from the `allow_company_agent` list. Not the alternative of narrowing `runtime:manage` itself: that action also gates break-glass reconcile and the runtime-control routes agents legitimately use, so tightening it would change behaviour well outside this change. It is also unmapped in `permissionForAction`, so there is no generic grant path onto it -- no agent gets this today by any route, and a future need should be an explicit, auditable grant. Explicitly refused to low-trust, task-bridge and skill-test principals so the denial carries a precise explanation rather than falling through to the generic tail; allowed to active non-viewer members and instance admins, which is what the two runtime editors need. Regression test against the REAL authorization service on embedded Postgres, with no `access.decide` mock: `PEN-2852 workspace_runtime:read`, four cases. The first carries its control inline -- it asserts `runtime:manage` still allows AND `workspace_runtime:read` denies for the same standard same-company agent -- because a single-action assertion would not have caught the original bug. Mutation-checked: adding the new action to the allow-list turns that one case red and leaves the other three green, which is why the list now carries a comment saying the omission is load-bearing. Also pin the `onBehalfOfUserId` case. A human operator behind an agent JWT does not re-open the disclosure, because the responsible-user intersection can only narrow an agent decision -- but reasoning is what produced the original bug, so it is asserted rather than argued. An earlier draft of the new code comment claimed the opposite; the test corrected it. The route-level tests keep their mock, but it now allows `runtime:manage` while denying `workspace_runtime:read`, so it models the real policy instead of contradicting it, and its docstring states that the denial itself is proven elsewhere. That division was implicit before, which is how a mock came to certify a policy claim. Refs PEN-2852, PEN-2370. Signed-off-by: Cto <cto@paperclip.blockcast.net>
Re: Critical —
|
| principal | before (runtime:manage) |
after (workspace_runtime:read) |
|---|---|---|
| standard same-company agent | raw | withheld |
agent w/ onBehalfOfUserId (non-viewer operator) |
raw | withheld |
low_trust_review agent |
withheld | withheld |
| task-bridge key / skill-test run token | withheld | withheld |
viewer board member |
withheld | withheld |
| active non-viewer member / instance admin | raw | raw |
The last row is the constraint that rules out the simpler option: routes 1 and 4 back the two runtime editors (ExecutionWorkspaceDetail, ProjectWorkspaceDetail), so an unconditional mask breaks them.
I did not take your second option — tightening runtime:manage itself — because that action also gates the break-glass reconcile and the runtime-control routes that agents legitimately use. Narrowing it to fix a read boundary would have changed behaviour well outside this PR's blast radius. Splitting the read out is the smaller change and leaves the write gate exactly where it was.
The action is deliberately unmapped in permissionForAction, so there is no generic-grant path onto it either: no agent gets this today, by any route. If an agent workflow turns out to need it, that should be an explicit, auditable grant added on purpose.
The regression test you asked for
authorization-service.test.ts → PEN-2852 workspace_runtime:read, four cases against the real service on embedded Postgres — no access.decide mock anywhere in them. The first one carries the control inline, because a single-action assertion would not have caught this:
// The control: this is the action the boundary used to gate on, and it allows.
await expect(authorization.decide({ actor, action: "runtime:manage", resource }))
.resolves.toMatchObject({ allowed: true, reason: "allow_company_agent" });
// The assertion: the read entitlement must NOT follow it into the blanket allow.
await expect(authorization.decide({ actor, action: "workspace_runtime:read", resource }))
.resolves.toMatchObject({ allowed: false, reason: "deny_missing_grant" });Mutation-checked rather than assumed. Adding one line — input.action === "workspace_runtime:read" || to the allow_company_agent list — turns that case red and the other three stay green:
FAIL authorization service > PEN-2852 workspace_runtime:read
> denies a standard same-company agent, while runtime:manage still allows it
AssertionError: expected { …(4) } to match object { allowed: false, …(1) }
- "allowed": false
+ "allowed": true
Tests 1 failed | 3 passed
That is the exact regression, and it is the reason there is now a comment on that allow-list saying the omission is load-bearing — the failure mode here is a future editor adding the action to a list of six that all look alike.
Two things I fixed on my own account while in there, since the first attempt shows I do not get to claim these by inspection:
- The
onBehalfOfUserIdcase is pinned by its own test. A human operator behind an agent JWT does not re-open the disclosure —applyResponsibleUserIntersectioncan only narrow an agent decision — but that is a property I should assert rather than reason about, since reasoning is what produced the original bug. - The route-level tests keep their mock, but the mock now allows
runtime:managewhile denyingworkspace_runtime:read, so it models the real policy instead of contradicting it. Their docstring says explicitly that they prove the projection is applied given a denial, and that the denial itself is proven elsewhere. That division was implicit before, which is how a mock got to certify a policy claim.
Verification
server: tsc --noEmit # clean
server: vitest authorization-service (-t PEN-2852) # 4 passed
server: vitest workspace-runtime-response-withholding
workspace-response-withholding-guard
workspace-runtime-routes-authz
workspace-runtime-service-authz # 36 passed
No endpoint was called against a populated workspace; every claim above is read from source, and the mutation output is from a local run I reverted. PR title and body updated — the old ones named runtime:manage as the gate.
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: 4266567
Prior Findings Dispositioned (1)
- prior:d78621b critical 1 — fixed —
server/src/routes/workspace-response.ts:61-72— The response boundary now authorizes disclosure with the dedicatedworkspace_runtime:readaction, andserver/src/services/authorization.ts:2202-2226keeps that action out of the blanket same-company agent allow-list; the real authorization-service regression test covers the standard same-company agent denial.
Critical Issues (0)
Important Issues (0)
Suggestions (0)
Strengths
- The dedicated entitlement closes the previously identified authorization gap without narrowing
runtime:managebehavior. - Centralized projections mask both derived runtime views and raw metadata while preserving presence flags for UI consumers.
- The response-site guard and positive controls cover workspace-bearing route shapes, including project embeddings and opaque reconcile results.
Recommended Action
- No Critical or Important issues found; the App-authored PR can proceed through its normal review gate.
- Consider Suggestions opportunistically.
Thinking Path
Linked Issues or Issue Description
Refs PEN-2852 (door #13), PEN-2370 (parent invariant — ask 1, criteria b1/b2), PEN-2846 / #1586, PEN-2839 / #1581, PEN-2843 / #1583.
Related open PRs, checked for overlap: #1586 masks this material in the issue projection (
routes/issues.ts) and explicitly namesGET /execution-workspaces/:idas "a different route, untouched here" — this PR is that route and its ten siblings. No file overlap. #1583 is the logger. #1592, #1393, #1444 touch workspaces but not response shapes.What Changed
server/src/routes/workspace-response.ts— one withholding boundary.resolveWorkspaceRuntimeViewermakes a singleworkspace_runtime:readdecision;publicExecutionWorkspace(s),publicProjectWorkspace(s)andpublicProject(s)apply it. Withholding isnull, not a recursive scrub, so there is no second walk to drift away from security(issues): mask workspaceRuntime values crossing the issue projection (PEN-2846) #1586's.execution-workspaces.ts: company list, get-by-id, runtime-command, reconcile-branch, PATCH.projects.ts: project list, project get, project create, project update, workspace list, workspace create, workspace update, workspace runtime-command, workspace delete.hasWorkspaceRuntimeConfigadded toExecutionWorkspaceandProjectWorkspace, computed from the stored row in both service mappers, and the UI's presence checks moved onto it.workspace_runtime:readauthorization action (services/authorization.ts) — deliberately absent from the standard same-company agent allow-list, granted to active non-viewer members and instance admins, explicitly refused to low-trust, task-bridge and skill-test principals. See the review-round section below for why it is notruntime:manage.workspace-response-withholding-guard.test.ts) — fails when a workspace-bearing value reaches a response in either module without passing through the boundary.project-workspaces-tab.tsandExecutionWorkspaceDetail.tsxread the presence flag; 19 fixtures updated.Three findings from inside the remediation, which is why the shape is what it is
config/runtimeConfigare derived views overmetadata(metadata.config.workspaceRuntime,metadata.runtimeConfig.workspaceRuntime), and both mappers emit both. The linked issue's suggested shape — maskconfig.workspaceRuntime— is a no-op on its own: the identical bytes leave one key over. Both exits close in one function for that reason.handleExecutionWorkspaceRuntimeCommand, the PATCH/archive path,heartbeat.tsworkspace reuse andplugin-host-services.tsall read.metadataoff a service result and write it back. A masked mapper would not hide the config — it would persist the mask and destroy the stored value.res.json(project)carriesworkspaces[]andprimaryWorkspace, built by the same mapper — soGET /companies/:id/projectsis the widest exit of the eleven, and the word "workspace" never appears in it. Found by running the parent ticket's method clause against this PR's own first draft, along withres.json(result)on reconcile-branch, where the workspace hides under an opaque local name.Verification
Fail-first, checked per test rather than per suite. Reverting only the route wiring — keeping the helper, the types and the tests — turns exactly three tests red: withholding on
GET /execution-workspaces/:id, on the execution-workspace list route, and on the project-workspace list route. The other seven are green on both trees by design, and it is worth being explicit about why: four are unit tests of the projection functions themselves, and three are regression guards whose job is to prove the fix did not break the entitled-reader path or summary mode. (This paragraph describes the first commit's fail-first analysis; the entitlement it names wasruntime:manageat the time and isworkspace_runtime:readafter review round 1 below. The three red tests are the same three.) A suite total would have hidden that distinction.The CI guard has its own controls. It is red on the unfixed routes (both modules) and green on the fixed ones, and it carries three positive controls proving the detector fires — on a bare
workspace, on aprojectthat embeds workspaces, and on an opaqueresultlocal. Without those, a green run could equally mean "no violations" or "the detector matches nothing". Its first draft flagged 33 false positives by matching the word "workspace" inside error-message prose; it now strips string literals before testing.ui/src/pages/ProjectWorkspaceDetail.test.tsxhas 8 pre-existing failures (TypeError: act is not a function). Verified as not mine by running that file with my change stashed: identical 8 failures, identical error.Review Round 1 — the gate was open (fixed)
Ally's consolidated review on
d78621bfound the Critical that this PR existed to prevent: the projection was correct but the entitlement was not.resolveWorkspaceRuntimeViewergated onruntime:manage, andruntime:managesits in the standard same-company agent allow-list inservices/authorization.tsalongsidecompany_scope:readandsecrets:read. Every ordinary agent therefore gotrevealRuntimeConfig: trueand the raw blob; the boundary withheld only from low-trust, task-bridge andviewerprincipals. Confirmed at source before changing anything.The header comment I had written asserting the opposite was true and irrelevant — it described the user half of the policy and never checked the agent half, which is the half this ticket is about.
Fix: a dedicated
workspace_runtime:readaction, which exists for this disclosure and nothing else and is deliberately not in that allow-list. I did not take the alternative of narrowingruntime:manageitself: that action also gates break-glass reconcile and the runtime-control routes agents legitimately use, so tightening it would change behaviour well outside this PR. The new action is also unmapped inpermissionForAction, so there is no generic-grant path onto it — no agent gets this today by any route; a future need should be an explicit, auditable grant.Regression test, against the real service.
authorization-service.test.ts→PEN-2852 workspace_runtime:read: four cases on embedded Postgres with noaccess.decidemock. The first carries its control inline, because a single-action assertion would not have caught the original bug — it assertsruntime:managestill allows andworkspace_runtime:readdenies, for the same standard same-company agent.Mutation-checked. Adding
input.action === "workspace_runtime:read" ||to theallow_company_agentlist turns that one case red (allowed: truewherefalsewas expected) and leaves the other three green. That is why the allow-list now carries a comment saying the omission is load-bearing: the failure mode is a future editor widening a list of six entries that all look alike.The route-level tests keep their mock, but it now allows
runtime:managewhile denyingworkspace_runtime:read, so it models the real policy rather than contradicting it — and their docstring states that they prove the projection is applied given a denial, with the denial itself proven elsewhere. That division was implicit before, which is how a mock came to certify a policy claim.Risks
workspace_runtime:readnow receiveconfig.workspaceRuntime: nullandmetadata: nullon these responses. The withheld set is: every standard same-company agent (including one carrying anonBehalfOfUserId),viewer-role members,low_trust_reviewagents, task-bridge keys and skill-test run tokens. Active non-viewer members and instance admins keep the raw value, which is what the two runtime editors need.lib/project-workspaces-tab.tscomputedhasRuntimeConfigasBoolean(config?.workspaceRuntime); under withholding that reads false and the workspace would vanish from the tab. It now readshasWorkspaceRuntimeConfig. Same forExecutionWorkspaceDetail'sinheritRuntime, which would otherwise have reported an own-config workspace as inheriting.metadatatakes it from the service, which is unchanged — that separation is deliberate and is the subject of finding 2 above.runtime:manage, so it 403s) and an untouched form emits no patch, so there is no write hazard — but the page does not yet say the value is withheld. Named rather than hidden; it is a UX follow-on, not a correctness one.workspace_runtime:readis server-internal (AuthorizationActionhas no client or DB surface — it is not aPermissionKeyand needs no migration). No existing decision changed:runtime:manageand every other action decide exactly as before.Model Used
Claude Opus 5 (
claude-opus-5[1m]), 1M context window, extended thinking enabled, driven through the Claude Code agent harness with tool use (file edits, local typecheck and test execution, GitHub API reads).Handling
No credential value was read, quoted, or committed; every fixture is invented. None of the eleven endpoints was called against a populated workspace — reading one is the exposure, and doing so would place another party's material in a second transcript for no informational gain. Every claim above was read from source at
masterc1b69a9.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template4266567(run 33549142167);security-reviewandStorybook visual regressionreportskipping, which is their normal state on this repo.4266567and dispositions the round-1 Critical as fixed;review/ally-commentis green.