fix(tool): subagent inherits the caller's tool denies (privilege escalation)#1132
Conversation
|
Warning Review limit reached
More reviews will be available in 16 minutes and 17 seconds. Learn how PR review limits work. Your organization has run out of usage credits. Purchase more in the billing tab. ⌛ How to resolve this issue?After more reviews become available, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available. Please see our Fair Usage Limits Policy for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (4)
📝 WalkthroughWalkthroughDispatch now passes the real caller agent to subagents; the caller agent's deny rules are loaded and prepended into the child session's permissions. For sessions created by agent tools, prompt() preserves caller-specific permission entries and merges them with regenerated tool-deny rules before persisting. ChangesSubagent permission rebuild & caller deny forwarding
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Code Review
This pull request addresses a privilege escalation vulnerability (#26597) by forwarding an edit-restricted parent agent's edit deny rules into the subagent session, appending them to the end of the permissions list to ensure they take precedence. Unit tests have been added to verify this behavior. The review feedback points out a critical security concern where catching all errors when fetching the parent agent and falling back to undefined creates a fail-open risk; it is recommended to let these failures propagate to ensure a fail-closed security boundary.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
e04c0fb to
2857711
Compare
2857711 to
7d7a595
Compare
7d7a595 to
69d9a95
Compare
2b6dba6 to
837d8ac
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/opencode/src/tool/agent.ts`:
- Around line 309-310: The current code swallows all failures from
agent.get(callerAgentName) by using Effect.catchCause, which masks real
lookup/storage/decoding errors; change the handling so that
agent.get(callerAgentName) is allowed to fail (propagate) for real errors but
still treat a missing caller as undefined: call agent.get(callerAgentName)
without a blanket catch, then explicitly map a successful NotFound/missing
result to undefined (or use Effect.catchSome/catchTag to only catch the specific
"not found" case), and remove the unconditional Effect.catchCause =>
Effect.succeed(undefined) so only the intended missing-caller case falls back to
undefined while other errors abort dispatch; refer to callerAgentName,
agent.get, Effect.catchCause and ctx.extra?.callerAgent/ctx.agent to locate and
update the logic.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: f7d26a0c-6436-4c7d-b44e-6d6cf9ad6410
📒 Files selected for processing (4)
packages/opencode/src/session/prompt.tspackages/opencode/src/tool/agent.tspackages/opencode/test/session/prompt.test.tspackages/opencode/test/tool/agent.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- packages/opencode/test/tool/agent.test.ts
adf0115 to
42e6774
Compare
…lation) A restricted agent's tool restrictions live on its agent ruleset, but a dispatched subagent ran with its own (often broader) permissions — so a read-only or Plan-Mode caller could escalate by spawning a more-capable subagent that edits files, runs bash, or reaches the network. Semantic port of anomalyco/opencode#26597 ("subagent inherits parent agent's deny rules"), adapted to PawWork's permission model. The subagent's session.permission ruleset is the single source of truth for what it inherits. At dispatch the agent tool forwards the caller's deny rules onto the child session — patterns intact — so scoped denies (edit on one path), whole-tool denies, and wildcard ("*") denies all reach the child as real rules. Both the availability gate (Permission.disabled hides whole-tool denies from the model) and the ask gate evaluate merge(subagentAgent.permission, session.permission), so a forwarded deny binds the child the same way it bound the caller. The caller agent's restrictions live on its agent ruleset, not the session, so they are forwarded explicitly; session/per-prompt denies and external_directory rules carry over too. SessionPrompt.prompt still rebuilds session.permission from the boolean tools map the agent tool passes, but that map is now availability-only — it lists the subagent's structural constraints (no nested dispatch, no worktree switching, no todos/primary-only tools), not caller-inherited authorization. So for an agent-tool child the rebuild carries forward the caller's inherited rules the map can't regenerate: external_directory rules, scoped (non-"*") denies, and whole-tool denies for keys the map doesn't list (the wildcard "*" and unlisted tools — automate, MCP, custom). Whole-tool denies for keys the map DOES list are regenerated from the map each turn, so the ruleset stays stable instead of accumulating; non-agent sessions still replace wholesale (pre-existing behavior). Like upstream, forwarding is deny-only: a wildcard caller's allow-exceptions (e.g. a read-only "*": deny agent that also allows read) are not preserved, so its subagent loses those tools too — erring toward deny. The caller is resolved as ctx.extra.callerAgent ?? ctx.agent: on a normal LLM dispatch ctx.agent is the caller, but on a subtask command SessionPrompt.handleSubtask runs the agent tool as the child, so it threads the real caller through ctx.extra (PawWork sessions don't store their agent). edit covers edit/write/apply_patch (all ask under the single "edit" key). Resume (subagent_session_id) is covered too: it skips sessions.create, so the inherited permission is recomputed from the current caller and re-applied onto the existing child before it runs. Otherwise a caller that became more restrictive after the child was created — e.g. switched to Plan Mode — could resume it and regain the denied tools, since the child still carried its original creator's permission.
42e6774 to
e7bdee8
Compare
What
A restricted agent's tool restrictions live on its agent ruleset, but a dispatched subagent ran with its own (often broader) permissions — so a read-only or Plan-Mode caller could escalate by spawning a more-capable subagent that edits files, runs
bash, or reaches the network.Why
Semantic port of anomalyco/opencode#26597 — "subagent inherits parent agent's deny rules (Plan Mode security bypass)" — adapted to PawWork's permission model.
How (single source of truth: the child's
session.permissionruleset)At dispatch the agent tool forwards the caller's deny rules onto the child
session.permission— patterns intact — so scoped denies (editon one path), whole-tool denies, and wildcard ("*") denies all reach the child as real rules, not a lossy boolean map. Both gates that bind a tool already evaluatemerge(subagentAgent.permission, session.permission):Permission.disabledhides whole-tool denies from the model;So a forwarded deny binds the child the same way it bound the caller. The caller agent's restrictions live on its agent ruleset (not the session), so they are forwarded explicitly; the caller session/per-prompt denies and
external_directoryrules carry over too.SessionPrompt.promptstill rebuildssession.permissionfrom the booleantoolsmap the agent tool passes, but that map is now availability-only — it lists the subagent's structural constraints (no nested dispatch, no worktree switching, no todos/primary-only tools), not caller-inherited authorization. For an agent-tool child the rebuild carries forward the caller's inherited rules the map can't regenerate:external_directoryrules (workspace scoping),"*") denies — e.g.editon one path,"*"and unlisted tools (automate, MCP, custom).Whole-tool denies for keys the map does list are regenerated from the map each turn, so the ruleset stays stable instead of accumulating; non-agent sessions still replace wholesale (pre-existing behavior).
Caller resolution.
ctx.extra.callerAgent ?? ctx.agent: on a normal LLM dispatchctx.agentis the caller, butSessionPrompt.handleSubtaskruns the agent tool as the child, so it threads the real caller throughctx.extra(PawWork sessions don't store their agent; upstream readsparent.agent).editcovers edit/write/apply_patch (all ask under the single"edit"key).Resume.
subagent_session_idresume skipssessions.create, so the inherited permission is recomputed from the current caller and re-applied onto the existing child before it runs — otherwise a caller that became more restrictive after the child was created (e.g. switched to Plan Mode) could resume it and regain the denied tools, since the child still carried its original creator's permission.Architecture note (why one channel, not two)
An earlier revision bound the subagent in two places — a fixed guarded-tool list evaluated into the boolean
toolsmap and the forwarded ruleset — and that two-channel design produced a string of review regressions (scoped denies dropped, unlisted/MCP tools dropped, wildcard handling) because the boolean map is lossy: it can only express a per-tool"*"rule. A first-principles review (independently confirmed) showed the two channels are redundant: the forwarded ruleset onsession.permissionalready drives both the availability gate and the ask gate, so the boolean channel never added coverage. This revision collapses to the single ruleset channel — the boolean map reverts to availability-only — which is what the model can actually represent and what the rest of the permission system already evaluates.Tradeoff (matches upstream)
Forwarding is deny-only: a wildcard caller's allow-exceptions (e.g. a read-only
"*": denyagent that also allowsread) are not preserved, so its subagent loses those tools too. This errs toward deny and matches upstream'sderiveSubagentSessionPermission. The linear allow/deny/last-wins ruleset can't express "intersect with the caller" without enumerating every tool (including dynamic MCP/custom ones), which isn't possible at dispatch — so deny-only is the faithful, safe choice.Boundary
packages/opencode/src/tool/agent.ts— resolve the real caller and forward its deny rules onto the child session at create (the single source of truth); thetoolsmap is availability-onlypackages/opencode/src/session/prompt.ts—handleSubtaskpassescallerAgent; the permission rebuild carriesexternal_directory+ scoped + unlisted/wildcard denies forward for agent-tool children (core-rebuild change authorized for a fully-correct fix)packages/opencode/test/tool/agent.test.ts,packages/opencode/test/session/prompt.test.ts— regression testsVerification
bun test test/tool/agent.test.ts+bun test test/session/prompt.test.ts -t "subagent permission rebuild": the child's effective ruleset denies a tool the caller denies (explicitedit/bash, wildcard, caller session deny, subtask-caller-override), no over-restriction when the caller can use the tool, caller-agent scoped deny forwarded at create, resume re-forwards the current caller's deny onto an existing child; the rebuild carries scoped +external_directory+ unlisted/wildcard denies forward for agent-tool children, regenerates listed whole-tool denies without double-carrying, and replaces wholesale for non-agent sessions.bun test(full opencode suite) — 3661 pass / 0 fail.bun run typecheck— clean.Scope / severity
Privilege escalation reachable via a non-default config: an agent that denies
edit/bash/a scoped path/"*". Default agents don't hit it. Labeled P2 on that basis.