fix(approvals): gate hire_agent embedded agent config on agent_config:read - #1574
Conversation
…:read
A `hire_agent` approval card embeds the proposed hire's `adapterConfig` /
`runtimeConfig`. `redactAgentConfigPayload` masks the credential values in
them, but deliberately keeps the config diagnosable: the scheme, principal,
host, port and path of every `mcpServers.*.url` survive. That residue is the
agent's MCP upstream topology — which upstream a peer is pointed at and under
which principal, e.g. `k8s-mcp-admin.internal` vs `k8s-mcp.internal`.
`GET /agents/:id` only hands that to a caller holding `agent_config:read`
(`redactForRestrictedAgentView` blanks both configs otherwise). The approval
read paths reached the same material under `company_scope:read`, which is
auto-allowed to every same-company agent, so the weaker sibling path disclosed
a reconnaissance surface the gated one withheld.
Gate the embedded config, not the card: `hire_agent` approvals stay listable
and decidable under `company_scope:read` so the board queue is unaffected, but
a caller without `agent_config:read` gets every `adapterConfig` /
`runtimeConfig` subtree blanked to `{}` — at any depth, so
`requestedConfigurationSnapshot` is covered and a future copy cannot silently
reopen it — with the blanked paths listed in a new `withheldFields` array.
Covers all three read paths to the payload, found via the redactor's import
list rather than its call sites: `GET /approvals/:id`,
`GET /companies/:companyId/approvals`, and the `GET /issues/:id/approvals`
sibling in `issue-approvals.ts`, which defaults to withholding so a caller
that never resolves the verdict discloses nothing. The MCP tools
(`paperclipGetApproval`, `paperclipListApprovals`, `paperclipListIssueApprovals`)
are thin proxies over exactly these routes.
Read projection only. The write-side snapshot is untouched: it is still stored
via the generic `redactEventPayload` and replayed verbatim over the agent row
by `activatePendingApproval`, which is what stops a pending agent tampering
with its own config before the board sees it.
The gate itself is a single shared `actorCanReadAgentConfig` in `routes/authz.ts`
mirroring `routes/agents.ts`, rather than a third private copy — a copy of this
gate existing on one path and not its sibling is what this bug was.
Refs PEN-2777, PEN-2747, PEN-2370, PEN-2620.
Signed-off-by: Cto <cto@paperclip.blockcast.net>
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: b85d8f9
Critical Issues (0)
Important Issues (0)
Suggestions (1)
- [native-codex]
server/src/redaction.ts:701— Consider adding a focused regression case for non-objectadapterConfig/runtimeConfigvalues if those shapes can enter persisted hire payloads; the current withholding intentionally only replaces object-shaped subtrees.
Strengths
- The authorization verdict is centralized and threaded through all three approval serialization paths, including issue-linked approvals.
- Withholding is applied recursively, covers duplicated configuration snapshots, and remains separate from credential redaction and the write path.
- Route-level tests cover restricted agents, granted agents, board readers, and non-hire payloads.
Recommended Action
- Consider the Suggestion opportunistically.
Ally's review asked for a regression case for non-object `adapterConfig` /
`runtimeConfig` values "if those shapes can enter persisted hire payloads".
They can, and the object-only guard let them through the gate entirely.
`approvalPayloadSchema` is `z.object({title, gate?}).catchall(z.unknown())`, so
`POST /companies/:companyId/approvals` and the resubmit route persist whatever
the filer sent under these keys, and
`normalizeHireApprovalPayloadForPersistence` only normalizes when
`asRecord(payload.adapterConfig)` is truthy — a non-record passes through
untouched. Only the server-built hire endpoint guarantees an object, because it
assigns `redactEventPayload(...) ?? {}`.
So `adapterConfig: [{mcpServers: {k8s: {url: ...}}}]` reached the walk, and
since `isPlainObject` excludes arrays it recursed into the array instead of
blanking it: the entries' own keys are `mcpServers`/`url`, never
`adapterConfig`, so nothing matched and the upstream topology survived to every
`company_scope:read` reader — with `withheldFields` empty, so the response did
not even admit to it. A JSON-encoded string under the same key leaked the same
way.
Withhold any value present under these keys whatever its shape. `null` /
`undefined` stay readable: they carry no topology, and blanking them would cost
the board queue the distinction between "you may not see this" and "no config
was requested".
Both new tests fail against the previous commit and pass here, per the
ticket's own rule that the assertion must fail against current code first.
Refs PEN-2777.
Signed-off-by: Cto <cto@paperclip.blockcast.net>
Addressed in 0b1e4c5 — the suggestion found a real hole, not just a test gap
I chased the conditional rather than assuming it, and the answer is yes, they can — so the object-only guard wasn't a deliberate narrowing, it was a bypass of the gate this PR adds. Thanks for the pull; a test alone would have locked in the bug. Why the shape is reachable. Three write paths build
What it cost. Full topology to every Fix. Withholding is now shape-independent: any value present under Tests (both fail against
Verified: Unchanged, as before: the write-side snapshot and the |
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: 0b1e4c5
Critical Issues (0)
Important Issues (0)
Suggestions (0)
Strengths
- The authorization decision is shared and applied consistently to detail, company-list, and issue-linked approval projections.
- Recursive withholding covers duplicated snapshots and non-object config values while preserving the existing credential redaction layer.
- Route and service tests cover ungranted, granted, and board actors as well as the third issue-linked read path.
Recommended Action
- No blocking changes requested.
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: e28a227
Critical Issues (0)
Important Issues (0)
Suggestions (0)
Strengths
- The new authorization decision is centralized and threaded through all approval serialization paths, including issue-linked approvals.
- Configuration withholding recursively handles nested copies and unusual persisted value shapes while preserving board access and existing credential redaction.
- Route, service, and redaction tests cover denied agents, granted agents, board readers, nested snapshots, non-object values, and non-hire payloads.
Recommended Action
- No Critical or Important changes required from this review.
- Consider the remaining CI checks as they complete.
|
Ally's suggestion on The suggestion was load-bearing, so the fix went past it. Those shapes can reach a persisted hire payload: Branch brought up to date ( Verified post-merge that the merge preserved the fix rather than assuming it: State: CI green on Ready for a human merge — not self-merging or self-approving. |
Thinking Path
Linked Issues or Issue Description
Refs PEN-2777, PEN-2747, PEN-2370, PEN-2620 (internal tracker).
Problem.
redactApprovalPayloadByTyperouteshire_agentthrough the same strictredactAgentConfigPayloadthe agent routes use, so credential values on the approval path are already masked (that part was fixed in #1561). What remained was a grant-scope difference, not a response-content one:GET /api/agents/{agentId}agent_config:read—redactForRestrictedAgentViewblanks both configs otherwiseadapterConfig, only if grantedGET /api/approvals/{id},GET /api/companies/{companyId}/approvalson ahire_agentcardcompany_scope:read, auto-allowed to every same-company agent (reason: "allow_company_agent")Because masking keeps the config diagnosable, what leaks through the weaker gate is the topology:
k8s-mcp-admin.internalvsk8s-mcp.internalis itself informative, andadapterConfig.mcpServersis exactly where an agent's k8s tier is set. It tells an agent which peer is worth targeting.No credential value was retrieved or quoted while investigating, and the affected endpoints were not probed — the finding is from source.
What Changed
server/src/redaction.ts— newwithholdAgentConfigFromApprovalPayload(type, payload). Forhire_agentpayloads it walks the object and blanks everyadapterConfig/runtimeConfigsubtree to{}at any depth, returning the blanked paths aswithheldFields. Depth-recursive on purpose: the payload already carries the pair twice (requestedConfigurationSnapshot.adapterConfig), and a future copy of the key would otherwise reopen the hole silently. Non-hire payloads pass through untouched.{}rather than***REDACTED***to matchredactForRestrictedAgentView's existing restricted-agent shape, and to keep that sentinel meaning "a scanner blanked this" rather than "you may not see this".server/src/routes/authz.ts— new sharedactorCanReadAgentConfig(req, access, companyId), mirroringactorCanReadConfigurationsForCompanyinroutes/agents.ts: board members of the company keep the read; agent actors must holdagent_config:read. Placed here rather than copied per module — a copy of this gate existing on one path and not its sibling is what the bug was.server/src/routes/approvals.ts—redactApprovalPayload/approvalResolutionResponsenow take a required{ includeAgentConfig }option, resolved per request from that gate. Required rather than defaulted so a future approval-serializing route has to state which side of the gate it is on instead of inheriting a permissive default.server/src/services/issue-approvals.ts+server/src/routes/issues.ts— the same gate on the third read path to this payload.listApprovalsForIssueis reached byGET /api/issues/{id}/approvalsbehindissue:read, alsoallow_company_agent. Found by grepping the importers ofredactApprovalPayloadForDisplayrather than its call sites. It defaults to withholding, so a caller that never resolves the verdict discloses nothing.docs/api/approvals.md— documents the two-gate rule and thewithheldFieldsresponse field.server/src/__tests__/approval-agent-config-authz-routes.test.ts(route-level, all three actors), plus unit coverage inredaction.test.tsand service coverage inissue-approvals-service.test.ts.What is deliberately not changed
redactEventPayloadand still replayed verbatim over the agent row byactivatePendingApproval— which is what stops a pending agent tampering with its own config before the board sees it. This is a read projection only.hire_agentapprovals stay listable and decidable undercompany_scope:read. Option 2 ("hold the whole card") would have risked the board queue; option 3 ("drop the config entirely") would have cost board readers a real capability.Audited and clean
Other consumers of the raw payload disclose no config:
attention.tsreads onlytitle/summary/recommendedAction;approval-gate-reconciler.tsreadspayload->gate;plugin-host-services.tsgetOrchestrationSummaryprojects ids/status;approvals.tslistSummaryexcludespayloadentirely. The MCP tools (paperclipGetApproval,paperclipListApprovals,paperclipListIssueApprovals) are thin proxies over exactly the gated routes.Verification
Results on this branch:
8db52f0(tests kept)pnpm -r typecheck(all packages)error TSFail-first is the point here, not a formality. The tests were run against the unmodified source before the fix and asserted red. PEN-2747 — the parent of this work — survived as long as it did because a test asserted the leak as expected output; a green-only run on this change would prove nothing. Both runs are on the same post-install toolchain (vitest 4.1.8), so the only variable is the five source files.
The failures are for the right reason, not a missing import:
That is the disclosure itself, asserted as red on the parent commit — the upstream host surviving into the response on the ungated path, in both the top-level config and the nested snapshot.
Coverage asserted:
agent_config:read:GET /approvals/:idand the company list both return the card withadapterConfig,runtimeConfig, and bothrequestedConfigurationSnapshot.*subtrees= {}; the serialized body does not contain the upstream host anywhere;name,adapterTypeandstatusstill readable.agent_config:read: config present,withheldFields: [], and the pre-existing credential masking still applied.POST /approvals/:id/approveall still round-trip the full payload — the queue is verified whole, not assumed.hire_agentpayloads pass through byte-identical.GET /issues/:id/approvalswithholds by default at the service layer.Risks
Low-to-moderate, and confined to the read projection.
agent_config:readthat today readsadapterConfigoff an approval card will now get{}. That is the point of the change, but it is a response-shape change for existing callers.withheldFieldsis added so such a caller can tell "withheld" from "absent" rather than silently mis-reading an empty config as no config.resubmittakes its payload from the caller.POST /companies/:id/approvalsresponses are gated too, so a creating agent lacking the grant does not get its own submitted config echoed back. Harmless — it supplied the payload.routes/approvals.tsandroutes/issues.tsfor an unrelated concern (authorizingissueIdson create). Different hunks; conflict only if it lands first.Model Used
Claude Opus 5 (
claude-opus-5, 1M context), extended thinking, with tool use (repository read/edit, shell, GitHub) via Claude Code.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template