security(issues): mask plugin-authored defaultsJson on the issue project projection (PEN-3114) - #1768
Conversation
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: 67834c4
Critical Issues (0)
Important Issues (0)
Suggestions (1)
- [native-codex]
server/src/routes/issues.ts:7565— Keep the projection helper focused; the explanatory block is substantially larger than the implementation, so consider moving the historical rationale to the PR description or a focused redaction-module comment if maintainability becomes an issue.
Strengths
- The projection now enumerates the managed-plugin fields instead of spreading the object, preventing future fields from crossing this response boundary silently.
defaultsJsondelegates to the existing fail-closed masking walk, preserving keys and shape while masking values, including arbitrary plugin settings.- The regression test uses a distinct fixture secret and verifies the actual response body, including the unfixed pass-through behavior.
Recommended Action
- Consider the Suggestion opportunistically.
- Safe to merge from the reviewed code perspective.
…ect projection (PEN-3114)
`compactIssueProject` in `server/src/routes/issues.ts` is a projection, and its
`env: null` line proves the author treated the response as a withholding
boundary. `managedByPlugin` crossed it verbatim.
`ProjectManagedByPlugin.defaultsJson` is an open `Record<string, unknown>` over
a `jsonb` column. Its `settings` leaf is copied straight out of a plugin
manifest's `PluginManagedProjectDeclaration.settings` ("Optional plugin-specific
defaults") by `buildManagedProjectDefaults`, so it is authored by a plugin
author rather than by a Paperclip operator — and integration config is a
natural home for a credential. The response is reachable by every same-company
agent: `assertIssueReadAllowed`'s decision union includes `allow_company_agent`,
and `paperclipGetIssue` is in every agent's MCP grant.
Mask the values, keep every key name (PEN-2370 ask 1 / criterion b2), by
delegating to the existing `maskWorkspaceRuntimeForRead` walk rather than
writing a second one — copying a walk is how the array-shaped (#1574) and
JSON-string (#1583) bypasses each shipped. Its `commands`/`services`/`jobs`
identity carve-out is inert against the keys the platform actually writes.
`withholdAgentConfigKeys` (#1581) was checked first and does not fit: it is
keyed on the literal names `adapterConfig`/`runtimeConfig` and blanks to `{}`,
erasing the key names ask 1 requires be kept.
`managedByPlugin` is now enumerated rather than passed through, so a field
added to the type later has to be considered instead of crossing silently.
Masking is safe for the reset/reconcile path this field is retained for: that
path is write-only with respect to this response — it recomputes the defaults
from the manifest declaration and writes them back — and no UI reads
`defaultsJson` at all.
Rebased onto master after PEN-2852 (BLO-33407) landed in this same file and
the merge queue ejected the branch as conflicting. Two notes from that rebase:
Composition with PEN-2852. The two workspace-runtime exits here now read
`viewer.revealRuntimeConfig ? raw : mask(...)`. `defaultsJson` is masked
unconditionally instead, because `workspace_runtime:read` is scoped to
workspace runtime config and this is plugin-manifest material with a different
audience — gating it on that flag would disclose plugin defaults to every
holder of an unrelated entitlement, widening the grant while looking like a
narrowing. There is also no entitled consumer to serve, so the gate's
true-branch would be empty. Rationale recorded at the call site.
The test is a sibling of the PEN-2852 block, not a case inside it. That block's
local `beforeEach` denies `workspace_runtime:read`; running there would model a
denied viewer, under which a future entitlement-gated implementation would also
return nothing and the test would stay green without measuring the entitled
path. At file scope the caller is maximally entitled, which is the hardest case
for an unconditional mask. Verified by mutation: restoring the verbatim
passthrough fails the test on the secret-value assertion.
Refs PEN-3114
Signed-off-by: Security Engineer <security-engineer@paperclip.blockcast.net>
67834c4 to
cb53e44
Compare
|
@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: cb53e44
Looks good. The change is correct, minimal, and complete for its stated scope; every load-bearing claim in the PR body was re-verified against the tree at this head rather than taken on trust.
Critical Issues (0)
Important Issues (0)
Suggestions (1)
- [gstack/review]
server/src/routes/workspace-response.ts:253— the same plugin-authoreddefaultsJsonthis PR masks still crosses verbatim on the project endpoints, so the credential in the threat model stays reachable by a same-company caller through a sibling route.publicProjectis a spread (...projectat :259) that re-projects onlyworkspaces/primaryWorkspace;managedByPluginis not enumerated, and theif (viewer.revealRuntimeConfig) return projectearly return at :257 is scoped to runtime config, so the non-entitled branch spreads it raw too. Reached fromroutes/projects.ts:150(GET /projects/:id),:287, and:142viapublicProjects(GET /companies/:companyId/projects); the value is the unmasked column, hydrated atservices/projects.ts:317and attached at:379.- This is not a defect in this diff and should not hold the PR — the PEN-2370 series is explicitly door-by-door, and the PR body correctly scopes
GET /projects/:idas "a route this PR does not touch." Raising it because that sentence cites the route as where the UI readspluginDisplayNamefrom, without noting it also discloses the rawdefaultsJsonbeing masked here. - I checked whether it is already tracked:
q=defaultsJsonreturns nothing, andq=publicProjectreturns only BLO-33568 (PEN-2852 residual —cleanupCommand/plannedActions[].command, a different group of fields) and its parent BLO-33407. So this residual appears untracked. Recommend filing it as the next door rather than widening this PR.
- This is not a defect in this diff and should not hold the PR — the PEN-2370 series is explicitly door-by-door, and the PR body correctly scopes
Strengths
- The enumeration is genuinely exhaustive: all nine fields of
ProjectManagedByPlugin(packages/shared/src/types/project.ts:82-92) are covered, none dropped, so a field added later has to be considered here rather than crossing silently. - Delegating to
maskWorkspaceRuntimeForReadinstead of copying a walk is the right call, and the contract matches what is claimed —null/undefinedpass through at top level and at any depth (redaction.ts:840,:843), so thecolor: nullassertion is correct; non-objects mask outright (:846,:869); depth is capped fail-closed (:844). That last property is what makes this safe over ajsonbcolumn. - The "identity carve-out is inert here" claim is accurate, and I verified it end to end rather than accepting it: the carve-out only activates for a top-level
commands/services/jobsarray (redaction.ts:873;identityScopeisfalseon every recursive call at:864), andbuildManagedProjectDefaults(services/projects.ts:534-543) fixes the top-level keys toprojectKey/displayName/description/status/color/settings. Plugin-controlled data lands undersettings, i.e. depth 1, where the carve-out cannot reach it. - The test asserts the credential leaf by value rather than by key presence, which is the assertion that actually fails against a verbatim pass-through — and the
regioncase pins mask-by-default rather than a key-name denylist. The distinct fixture secret and the documented mutation check close the "test passes on unfixed code" failure mode this series has hit before. - Test placement as a sibling of the entitlement-denying block is correct, and the comment explains why: it exercises the maximally entitled caller, which is the hardest case for an unconditional mask. The
...(await mockProjectService.getById())pattern matches existing usage in the file (:326,:826).
Recommended Action
- No Critical or Important issues — safe to merge from the reviewed code perspective.
- File the
publicProjectsibling exposure as the next door in the series; it appears untracked today.
|
Re-enqueued once after the 22:19Z ejection (kkroo session, not the author). The merge-group failure was |
|
Merge-queue status note (kkroo sweep, not the author): this PR has now been ejected three times at head That test is unrelated to this PR's change (issue-projection masking) and passed in neighbouring merge-group runs (e.g. #1846's), so it reads as a timing flake under the current runner-starvation (every ARC pool at its ceiling, unschedulable runner pods, shards at 1.5 to 2x baseline). I am not re-enqueueing: the sweep's rule is one re-enqueue per PR after a flake ejection, and the 09-17 23:12Z enqueue already spent it. Ally's 0/0 at 🤖 Generated with Claude Code |
|
This PR is clean at its current head but still has an outstanding code-owner review request (allyblockcast). GitHub does not enforce CODEOWNERS on this repository, so the landing routine holds it here rather than enqueuing it. |
Thinking Path
Linked Issues or Issue Description
Tracked as PEN-3114 (door #15 of the PEN-2370 series) in Paperclip, not as a GitHub issue, so per template path (B) the problem is described in full below and in the commit body.
The problem.
compactIssueProject()inserver/src/routes/issues.tsemittedmanagedByPlugin: project.managedByPlugin ?? nullverbatim. The chain, traced link by link onmaster:PluginManagedProjectDeclaration.settings?: Record<string, unknown>— "Optional plugin-specific defaults retained for reset/reconcile UI"packages/shared/src/types/plugin.ts:288-289buildManagedProjectDefaults(declaration)setssettings: declaration.settings ?? {}server/src/services/projects.tspluginManagedResources.defaultsJson(jsonb, so the runtime value is arbitrary regardless of the TS type)packages/db/src/schema/plugin_managed_resources.ts:19ProjectManagedByPlugin.defaultsJson(non-optionalRecord<string, unknown>)packages/shared/src/types/project.ts:83compactIssueProjectserver/src/routes/issues.tsSeverity is Low, deliberately: the type is open and the value crossed unmasked, but no in-tree shipped plugin populates
settingstoday, so this is latent-by-type rather than demonstrated-in-tree. It is fixed anyway because the shape is the class PEN-2370 exists to close. One in-tree corroboration that the chain is live end to end:server/src/__tests__/plugin-orchestration-apis.test.tsdeclares a manifest withsettings: { surface, upgraded }and asserts it lands indefaultsJson. That is a test manifest, not a shipped plugin, so it confirms the mechanism without raising the rating.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— introducedmaskWorkspaceRuntimeForRead, the walk this PR delegates to.Refs #1581— extractedwithholdAgentConfigKeys; evaluated and rejected here (see below).Refs #1670(PEN-3033, door fix(plugin-tool-dispatcher): pass pluginDbId through registerPluginTools — fixes tools/execute 502 #17 — open) — also touchesserver/src/routes/issues.ts, but at thementionedProjectscall site ~900 lines away and for plain project bindings only. No overlapping hunk.Refs #1574,Refs #1583— the array-shaped and JSON-string bypasses that make "do not copy the walk" a hard rule here.server/src/__tests__/issues-goal-context-routes.test.ts, so this does not collide with the sibling test-hardening work tracked as PEN-3130.What Changed
server/src/routes/issues.ts— newcompactIssueManagedByPlugin()helper, used bycompactIssueProject:defaultsJsonis passed throughmaskWorkspaceRuntimeForRead. Values are masked to***REDACTED***, every key name survives, non-objects are masked outright, and the walk is depth-capped fail-closed — which matters because the column isjsonb.managedByPluginis now enumerated rather than spread, so a field added toProjectManagedByPluginlater must be considered here instead of crossing silently. All nine current fields of the type are covered; none is dropped.server/src/__tests__/issues-goal-context-routes.test.ts— one test asserting the mask end to end throughGET /issues/:id.Why
maskWorkspaceRuntimeForReadand not something else — judged before delegating, as the ticket asked:commands/services/jobsidentity carve-out is inert here — it only applies to entries sitting directly inside an array under one of those three names, and the platform writesprojectKey/displayName/description/status/color/settings. Verified by reading the walk, not assumed. The residual if a manifest ever wrote a top-levelservicesarray is bounded: onlyid/name/label/titlestrings survive, which that walk already discloses in the strictly more sensitive workspace-runtime position.withholdAgentConfigKeys(security(skills): withhold agent config from skill test-run snapshots (PEN-2839) #1581) does not fit — it is keyed on the literal namesadapterConfig/runtimeConfig, and it blanks its target to{}, erasing the key names ask 1 requires be kept.Why masking is safe, not a compatibility break.
defaultsJson's own doc comment says it is retained for the reset/reconcile UI, so this was checked rather than assumed, and it resolves safe for two independent reasons: (1) that path is write-only with respect to this response — it recomputes the defaults from the manifest declaration and writes them back, never reading this projection; (2) no UI readsdefaultsJsonat all — swept the complete tree (1,245 trackedui/files), and the onlyui/hit is a test fixture (ProjectDetail.test.tsx:151,defaultsJson: {}).ProjectDetail.tsxreadspluginDisplayName,pluginKeyandresourceKey, all of which survive untouched, and reads them fromGET /projects/:id— a route this PR does not touch.Verification
All run locally on this branch, rebased onto
masterc3d1c11b7, in a freshpnpm install --offline— deliberately not a sibling worktree'snode_modules, and the first-party links were confirmed to resolve into this worktree (node_modules/@paperclipai/sharedpoints atpen3114-run2/packages/shared) so the suite loaded the tree under test.Mutation check — the test fails on unfixed code. A passing test proves nothing on its own, and "test passes on unfixed code" is a repeat failure mode in this series, so the single line was reverted to
managedByPlugin: project.managedByPlugin ?? nulland the test re-run:The failure body is the finding itself — the explicitly withheld field and the plugin-authored credential in the same object:
The fix was then restored and the tree confirmed byte-identical to the commit (
git statusclean).Test-quality notes, both deliberate:
.toBe("***REDACTED***")), not by key-set membership. A regression passingsettingsthrough verbatim keeps the key set byte-identical, so anObject.keys(...)assertion alone would stay green against it — that exact weakness in the door fix(ui): drop stale expand-control assertions from SidebarAgents test (v513 fallout) #12/#12b tests was tracked as PEN-3130, and its lesson is applied here rather than its diff. (Since this PR was opened,master'sf46251a24replaced those key-set assertions withtoBeNull()as part of PEN-2852 — strictly stronger, and it cannot pass on a verbatim passthrough — so PEN-3130's original target no longer exists. The principle still governs this PR's own new test.)not.toContain, and the test would pass against the unfixed projection. No real endpoint was called to produce it, and no plugin-managed project was read to reproduce the finding — it was confirmed from source, because reading it is the exposure.regionis asserted masked too, documenting that this walk is mask-by-default rather than a credential-name denylist — the property that makes it cover a manifest key nobody enumerated in advance.Rebase update — 2026-09-15
mastermoved under this PR. PEN-2852 / BLO-33407 landed in this same file and the same test file, and on 2026-09-14T18:27:04Zgithub-merge-queue[bot]ejected this branch from the queue asCONFLICTING. Rebasedc6a1a55a0→c3d1c11b7and re-verified from scratch;MERGEABLEis restored.The conflict was not textual only, so two judgements were made rather than auto-resolved:
1. Composition — why
defaultsJsonis masked unconditionally while the two runtime exits beside it are now entitlement-gated. PEN-2852 changed the workspace-runtime exits here toviewer.revealRuntimeConfig ? raw : mask(...). This call site deliberately does not follow that shape, becauseworkspace_runtime:readis scoped to workspace runtime config anddefaultsJsonis plugin-manifest material with a different audience — gating it on that flag would disclose plugin defaults to every holder of an unrelated entitlement, widening the grant while looking like a narrowing. There is also no entitled consumer to serve (swept above: no UI reads the field), so the gate's true-branch would be empty. If a consumer ever needs rawdefaultsJson, it should arrive with its own entitlement. Rationale is recorded at the call site, not just here.2. Test placement is load-bearing. The auto-merge put the new test inside PEN-2852's
describe, whose localbeforeEachdeniesworkspace_runtime:read. It was moved out to a sibling block. Under a denied viewer a future entitlement-gated implementation would also return nothing, so the test would stay green without ever measuring the entitled path — the "test passes on unfixed code" shape this series keeps hitting. At file scope the caller is maximally entitled, which is the hardest case for an unconditional mask to satisfy.Re-verification after the rebase (not carried over from the previous head):
The mutation check was re-run at the new head, not assumed to still hold: restoring
managedByPlugin: project.managedByPlugin ?? nullfails the test on the secret-value assertion (expected '{"id":"11111111-…' not to contain 'invented-plugin-defaults-fixture-value'), and the fix was then restored.One observation that is not mine and is not fixed here:
server/src/services/plugin-host-services.ts:1343failstscagainst a stalepackages/plugins/sdkbuild (params.ifMatch). It reproduces on pristineorigin/masterin a file this PR does not touch, and clears after rebuilding that package — a stale-artifact symptom, reported rather than silently worked around.Risks
Low. One projection in one handler; no schema, migration, or write path is touched, and no consumer of the masked field exists (swept above).
Three things a reviewer should weigh rather than take on trust:
Behavioural change for any agent reading
project.managedByPlugin.defaultsJsonoffGET /issues/:id: keys and structure are unchanged, values are now***REDACTED***. Intended, and that response is the exposure surface — but it is a real change to a response body.This fix inherits
maskWorkspaceRuntimeForRead's behaviour by design. That is the point — a finding against the walk fixes this call site too — but it also means the open PRs editingserver/src/redaction.ts(fix(redaction): mask five vendor credential shapes on the run-log free-text path (PEN-3139) #1736, feat(security): scope run-transcript reads to own-run + chain + operators + grant (PEN-3142) #1741, fix(security): secret-scrub resultJson and error before persistence (PEN-3153) #1746, fix(redaction): stop a composite gh token leaking 2 of 3 segments into transcripts (BLO-29553) #1683) change this call site's masking. None of them conflicts textually; this PR does not modifyredaction.ts.A sibling on this same response is still not covered by this PR — but it is narrower than this PR originally claimed, and the original claim is retracted. Running PEN-2370's method clause against my own fix, the first version of this description said
mentionedProjectsis emitted entirely unprojected. That is now false. PEN-2852 (BLO-33407) landedmentionedProjects: publicProjects(mentionedProjects, runtimeViewer)onmasterwhile this PR sat in the merge queue, so the workspace-runtime half of that sibling finding is closed and the rawmetadata/runtimeConfigno longer cross there. Corrected here rather than left standing.What remains uncovered is the narrower half:
publicProjectsgates workspace runtime material, not the plugin binding, somentionedProjects[].managedByPlugin.defaultsJsonstill crosses verbatim — the same leaf this PR masks on the projected path, one key over. That is tracked as its own door (PEN-3210) rather than widened into here, because it needs its own severity and its own call site. Deliberately scoped out, not overlooked.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. The change, the test, the mutation check and this description were produced in that harness; the source claims above were each read from the tree at the stated commit rather than recalled.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template