Skip to content

fix(tool): subagent inherits the caller's tool denies (privilege escalation)#1132

Merged
Astro-Han merged 1 commit into
devfrom
claude/l4-26597-subagent-deny
Jun 3, 2026
Merged

fix(tool): subagent inherits the caller's tool denies (privilege escalation)#1132
Astro-Han merged 1 commit into
devfrom
claude/l4-26597-subagent-deny

Conversation

@Astro-Han

@Astro-Han Astro-Han commented Jun 3, 2026

Copy link
Copy Markdown
Owner

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.permission ruleset)

At dispatch the agent tool forwards the caller's deny rules onto the child session.permission — patterns intact — so scoped denies (edit on 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 evaluate merge(subagentAgent.permission, session.permission):

  • availabilityPermission.disabled hides whole-tool denies from the model;
  • ask — the runtime/subtask ask gate evaluates the same merged ruleset.

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_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. For an agent-tool child the rebuild carries forward the caller's inherited rules the map can't regenerate:

  • external_directory rules (workspace scoping),
  • scoped (non-"*") denies — e.g. edit on one path,
  • 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).

Caller resolution. ctx.extra.callerAgent ?? ctx.agent: on a normal LLM dispatch ctx.agent is the caller, but 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; upstream reads parent.agent). edit covers edit/write/apply_patch (all ask under the single "edit" key).

Resume. subagent_session_id resume 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.

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 tools map 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 on session.permission already 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 "*": deny agent that also allows read) are not preserved, so its subagent loses those tools too. This errs toward deny and matches upstream's deriveSubagentSessionPermission. 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); the tools map is availability-only
  • packages/opencode/src/session/prompt.tshandleSubtask passes callerAgent; the permission rebuild carries external_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 tests

Verification

  • 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 (explicit edit/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.

@Astro-Han Astro-Han added bug Something isn't working P2 Medium priority upstream Tracked upstream or vendor behavior harness Model harness, prompts, tool descriptions, and session mechanics labels Jun 3, 2026
@coderabbitai

coderabbitai Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@Astro-Han, we couldn't start this review because you've reached your PR review rate limit.

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 @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: bf5fde61-40a5-47a9-85c0-58afa7c26d5e

📥 Commits

Reviewing files that changed from the base of the PR and between 837d8ac and e7bdee8.

📒 Files selected for processing (4)
  • packages/opencode/src/session/prompt.ts
  • packages/opencode/src/tool/agent.ts
  • packages/opencode/test/session/prompt.test.ts
  • packages/opencode/test/tool/agent.test.ts
📝 Walkthrough

Walkthrough

Dispatch 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.

Changes

Subagent permission rebuild & caller deny forwarding

Layer / File(s) Summary
Pass caller agent through dispatch
packages/opencode/src/session/prompt.ts
handleSubtask now includes callerAgent: lastUser.agent in the dispatch extra context.
Load caller agent and forward deny rules
packages/opencode/src/tool/agent.ts
Derives caller from ctx.extra.callerAgent or ctx.agent, loads caller agent definition (fail-tolerant), and prepends caller deny rules into the new subagent session's permission array; adds comment clarifying tools map is availability-only.
SessionPrompt permission merge for agent-tool sessions
packages/opencode/src/session/prompt.ts
When session.createdByAgentTool is true, preserves selected existing permission entries (e.g., external_directory, forwarded denies), merges them with newly built denies from input.tools, and writes the combined permissions; non-agent-tool sessions retain previous replacement behavior.
Session.prompt permission rebuild tests
packages/opencode/test/session/prompt.test.ts
Imports Permission and adds a regression suite validating preservation of scoped denies and external_directory, regeneration of whole-tool denies from the boolean tools map, wildcard-carry behavior, and non-agent-tool replacement semantics.
Agent dispatch tests capturing tools map and forwarded denies
packages/opencode/test/tool/agent.test.ts
Imports Permission, adds dispatchChild helper to capture SessionPrompt.prompt's tools map, and multiple it.live tests verifying permission precedence, wildcard deny handling, caller-session denies, and that scoped denies are forwarded into the child session permissions.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐇 I passed the caller's rules along the trail,
Subagents now heed the parent's fail or hail,
Scopes preserved, wildcards kept in sight,
Tools stay available unless denied outright,
A rabbit's tidy merge—permissions prevail.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely identifies the main fix: subagent privilege escalation via inherited caller's tool denies. It accurately represents the primary change without vague language or noise.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The PR description is comprehensive and well-structured, covering all major template sections with detailed technical context and implementation rationale.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/l4-26597-subagent-deny

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested priority: P2 (includes non-doc, non-test paths outside the low-risk bucket).

P1/P0 are reserved for maintainer confirmation. Please relabel manually if this is a release blocker, security issue, data-loss risk, or updater/runtime failure.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/opencode/src/tool/agent.ts Outdated
@Astro-Han Astro-Han force-pushed the claude/l4-26597-subagent-deny branch from e04c0fb to 2857711 Compare June 3, 2026 10:22
@Astro-Han Astro-Han changed the title fix(tool): forward edit-restricted parent agent's deny into subagent sessions fix(tool): deny edit to subagents dispatched by an edit-restricted parent agent Jun 3, 2026
@Astro-Han Astro-Han force-pushed the claude/l4-26597-subagent-deny branch from 2857711 to 7d7a595 Compare June 3, 2026 10:46
@Astro-Han Astro-Han changed the title fix(tool): deny edit to subagents dispatched by an edit-restricted parent agent fix(tool): deny edit to subagents dispatched by an edit-restricted caller Jun 3, 2026
@Astro-Han Astro-Han force-pushed the claude/l4-26597-subagent-deny branch from 7d7a595 to 69d9a95 Compare June 3, 2026 11:11
@Astro-Han Astro-Han changed the title fix(tool): deny edit to subagents dispatched by an edit-restricted caller fix(tool): subagent inherits the caller's tool denies (privilege escalation) Jun 3, 2026
@Astro-Han Astro-Han force-pushed the claude/l4-26597-subagent-deny branch 4 times, most recently from 2b6dba6 to 837d8ac Compare June 3, 2026 13:01

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 7d7a595 and 837d8ac.

📒 Files selected for processing (4)
  • packages/opencode/src/session/prompt.ts
  • packages/opencode/src/tool/agent.ts
  • packages/opencode/test/session/prompt.test.ts
  • packages/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

Comment thread packages/opencode/src/tool/agent.ts Outdated
@Astro-Han Astro-Han force-pushed the claude/l4-26597-subagent-deny branch 2 times, most recently from adf0115 to 42e6774 Compare June 3, 2026 13:26
…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.
@Astro-Han Astro-Han force-pushed the claude/l4-26597-subagent-deny branch from 42e6774 to e7bdee8 Compare June 3, 2026 13:45
@Astro-Han Astro-Han merged commit 7a4473c into dev Jun 3, 2026
33 checks passed
@Astro-Han Astro-Han deleted the claude/l4-26597-subagent-deny branch June 3, 2026 14:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working harness Model harness, prompts, tool descriptions, and session mechanics P2 Medium priority upstream Tracked upstream or vendor behavior

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant