Skip to content

Subagent parent deny inheritance over-constrains delegated agents with explicit permissions #26700

Closed

Description

@nabilfreeman

Description

Regression introduced by the fix for #26514 / #26597.

PR #26597 correctly closed a Plan Mode security bypass: a read-only parent agent could spawn a subagent that retained edit/write permissions. However, the implementation now copies all parent agent deny rules into the child subagent session. For deny-by-default multi-agent configurations, this creates a new failure mode: a deliberately restricted primary agent can no longer delegate to a deliberately more-capable subagent, because the primary agent's deny rules are appended after the subagent's allow rules and override them.

This is not about restoring the Plan Mode bypass. The issue is that the new inheritance behavior treats every parent deny as a descendant-wide permission ceiling. That is valid for Plan Mode read-only restrictions, but it breaks controller/coordinator patterns where the primary agent is intentionally narrow and delegates execution to a subagent that has its own explicit allowlist.

Our configuration style is deny-by-default:

  • Start every agent with wildcard deny: "*": { "*": "deny" }
  • Add explicit per-tool allow rules for that agent
  • Give the primary/controller only task access to selected subagents
  • Give the execution subagent read/grep/glob/list/task/bash as needed
  • Give the execution subagent permission to delegate to a worker sub-sub-agent

This worked in 1.14.41. In 1.14.46, the executor subagent inherits the controller's deny rules, so it loses its own tools and cannot continue the delegation flow.

Relevant code path:

Observed effect:

  • read, grep, glob, list, task, todowrite, webfetch disappear from the subagent tool inventory
  • bash may remain visible, but every command is denied at runtime because inherited bash * -> deny wins over the subagent's bash * -> allow
  • The subagent reports that it only has bash, cannot use task, cannot read files, and cannot delegate further

Plugins

None required. This is reproducible with configuration only.

OpenCode version

Regresses in 1.14.46.

Known-good comparison: 1.14.41.

Steps to reproduce

Use a generic three-agent setup: controller -> executor -> worker. The names are arbitrary; the important part is that the primary/controller is intentionally restricted and the executor is explicitly whitelisted.

Minimal permission profile:

{
  "default_agent": "controller",
  "agent": {
    "controller": {
      "mode": "primary",
      "permission": {
        "*": { "*": "deny" },
        "read": { "*": "deny" },
        "bash": {
          "*": "deny",
          "git *": "allow",
          "npm *": "allow"
        },
        "task": {
          "*": "deny",
          "executor": "allow"
        },
        "edit": { "*": "deny" },
        "write": { "*": "deny" }
      },
      "prompt": "You are a restricted controller. Delegate implementation to executor."
    },
    "executor": {
      "mode": "subagent",
      "permission": {
        "*": { "*": "deny" },
        "read": { "*": "allow" },
        "grep": { "*": "allow" },
        "glob": { "*": "allow" },
        "list": { "*": "allow" },
        "bash": { "*": "allow" },
        "task": {
          "*": "deny",
          "worker": "allow"
        },
        "edit": { "*": "deny" },
        "write": { "*": "deny" }
      },
      "prompt": "You are an executor. Read files and delegate file edits to worker."
    },
    "worker": {
      "mode": "subagent",
      "permission": {
        "*": { "*": "deny" },
        "read": { "*": "allow" },
        "edit": { "*": "allow" },
        "write": { "*": "allow" },
        "bash": { "*": "allow" },
        "task": { "*": "deny" }
      },
      "prompt": "You are a terminal worker. Modify files when asked."
    }
  }
}

Then ask controller to delegate a simple task to executor, where executor must read a file and delegate a small edit to worker.

Expected behavior:

  1. controller can only spawn executor.
  2. executor receives its configured tools: read, grep, glob, list, bash, and task to worker.
  3. executor cannot edit directly, but can delegate to worker.
  4. worker receives its own configured edit/write tools.

Actual behavior in 1.14.46:

  1. executor inherits the controller's deny rules.
  2. The inherited * * -> deny, read * -> deny, bash * -> deny, and task * -> deny rules are evaluated after executor's own allows.
  3. Because last matching rule wins, executor's explicit allowlist is overridden.
  4. executor cannot read, cannot use task, and cannot run even allowlisted bash commands like git status or npm test.
  5. The delegated workflow stops before the worker can be spawned.

This is the effective rule conflict for executor after #26597:

executor permission: read * -> allow
inherited controller deny: read * -> deny
last match wins: read is denied

executor permission: task worker -> allow
inherited controller deny: task * -> deny
last match wins: task is denied

executor permission: bash * -> allow
inherited controller deny: bash * -> deny
last match wins: bash is denied

A possible fix direction would be to distinguish parent restrictions that are intended to bind descendants (for example Plan Mode read-only ceilings) from parent-local restrictions that only describe what the parent agent itself may do. Another option would be an explicit config flag for permission inheritance strategy. The current behavior makes any deny-by-default primary agent over-constrain its subagents.

Screenshot and/or share link

N/A. This is a config/runtime permission inheritance issue.

Operating System

Observed on macOS, but the regression is in the permission derivation code path and should be platform-independent.

Terminal

Observed through an OpenCode agent session. Terminal is not material to the bug.

Activity

  1. nabilfreeman commented on May 10, 2026

    @nabilfreeman
    ContributorAuthor

    Additional sanitized transcript excerpt from the real failure case. I’ve renamed our custom agent role to executor here to keep the example generic.

    Context: the primary/controller agent delegates to an executor subagent. The executor is configured with explicit permissions for read, grep, glob, task, and limited shell/git operations, and is expected to delegate implementation work to a worker subagent. After upgrading to 1.14.46, the executor reports that it has effectively lost all tools except a denied shell tool.

    Controller -> executor:
    Execute Phase 1 of the plan. Read the phase doc, delegate the implementation to the worker/code agent, verify, and report the commit SHA.
    
    executor:
    I cannot proceed. My only available tool is `bash`, but every bash invocation is being denied by the active permission rules, including read-only commands like `pwd` and `git rev-parse`.
    
    I also do not have `Read`, `Grep`, `Glob`, or `Task` tools available in this session — only `bash`.
    
    So I cannot:
    - Read the phase doc to learn what work is required
    - Inspect the target config file to make the fix
    - Delegate to a worker/code agent (no Task tool)
    - Run lint/tests or commit
    
    Status: Blocked — cannot execute Phase 1.
    

    A second fresh executor session produced the same failure:

    executor:
    Tool sanity check FAILED at the platform layer. Stopping as instructed.
    
    Probe 1: `pwd` (bash) — DENIED
    
    The effective rule set ends with `{ "permission": "bash", "pattern": "*", "action": "deny" }` — i.e. all bash commands are denied.
    
    Probe 3 (Read the phase doc) — the Read tool is not in my tool inventory. My available tools list contains only `bash`. There is no `Read`, `Grep`, `Glob`, `Edit`, `Write`, or `Task` tool exposed to this session.
    
    Diagnosis:
    1. Bash is denied by a catch-all rule.
    2. No file tools are exposed.
    
    I cannot read the phase doc, inspect files, delegate to a worker/code agent, or commit. No work has been attempted.
    

    And when explicitly instructed to delegate immediately rather than inspect files first:

    Controller -> executor:
    Your first action MUST be to call the task tool with subagent_type `worker`. Do not analyze your tool inventory first.
    
    executor:
    I have to stop and report a problem rather than proceed silently.
    
    My actual tool inventory in this session contains only the `bash` tool. There is no `task` tool available to me, despite what my executor role description says. I cannot call a function that isn't registered in my available functions.
    
    What I cannot do:
    - Delegate to the worker/code agent (no task tool exposed)
    - Perform the edit myself (executor is forbidden from editing files, and I only have bash anyway, with deny rules active)
    

    This behavior disappeared after downgrading back to 1.14.41, which is why this looks tied specifically to the #26597 permission inheritance change rather than to the agent prompt or the local role definition.

  2. 21pounder commented on May 11, 2026

    @21pounder
    Contributor

    I was able to reproduce this locally and traced the root cause through the code.

    How I reproduced it

    Minimal opencode.json with default_agent: controller:

    {
      "$schema": "https://opencode.ai/config.json",
      "default_agent": "controller",
      "agent": {
        "controller": {
          "mode": "primary",
          "permission": {
            "*": { "*": "deny" },
            "read": { "*": "deny" },
            "bash": { "*": "deny" },
            "task": { "*": "deny", "executor": "allow" },
            "edit": { "*": "deny" },
            "write": { "*": "deny" }
          }
        },
        "executor": {
          "mode": "subagent",
          "permission": {
            "*": { "*": "deny" },
            "read": { "*": "allow" },
            "bash": { "*": "allow" },
            "task": { "*": "deny" },
            "edit": { "*": "deny" },
            "write": { "*": "deny" }
          }
        }
      }
    }

    When controller spawns executor via the task tool, the server log shows the executor session being created with this permission array:

    permission=[
      {"permission":"*","pattern":"*","action":"deny"},
      {"permission":"read","pattern":"*","action":"deny"},
      {"permission":"bash","pattern":"*","action":"deny"},
      {"permission":"task","pattern":"*","action":"deny"},
      {"permission":"edit","pattern":"*","action":"deny"},
      {"permission":"write","pattern":"*","action":"deny"},
      {"permission":"todowrite","pattern":"*","action":"deny"}
    ]
    
    Image

    The executor then reports it cannot use the Read tool or any other tool.

    Image

    Root cause

    deriveSubagentSessionPermission() in src/agent/subagent-permissions.ts:24 appends all parent agent deny rules into the child session's permission array:

    const parentAgentDenies = input.parentAgent?.permission.filter((rule) => rule.action === "deny") ?? []
    return [
      ...parentAgentDenies,          // controller's denies go first
      ...input.parentSessionPermission.filter(...),
      ...defaults,
    ]

    The effective ruleset is then built in src/session/llm.ts:453 as:

    Permission.merge(executor.agent.permission, executor.session.permission)
    // = [...executor's own rules, ...inherited controller denies]

    Permission.disabled() uses findLast, so the inherited read * -> deny (appended at the end) wins over executor's own read * -> allow, causing the Read tool to be removed from the tool inventory before the LLM ever sees it.

    What I think the fix should be

    deriveSubagentSessionPermission should not blindly inherit all parent agent denies. The original intent of #26597 was to prevent Plan Mode bypass — i.e., stop a read-only parent from spawning a subagent that can edit files. That concern is specifically about edit/write permissions.

    The simplest targeted fix: only inherit parent agent denies for edit-class permissions (edit, write, apply_patch), not for every permission the parent happens to deny for its own operational reasons.

    Alternatively, the inherited denies could be prepended (before the subagent's own rules) rather than appended, so the subagent's explicit allows always win — though this would reopen the Plan Mode bypass for edit permissions.

    I'm happy to submit a PR if the approach looks right to you.

  3. nabilfreeman commented on May 11, 2026

    @nabilfreeman
    ContributorAuthor

    @21pounder good idea, but my 2 cents are that it feels to me like this is too opinionated a fix.

    The permissions system should be general and unopinionated. Actually the PR that caused this bug is an example of forcing a specific usage pattern that has broken the wider architecture.

    That's why I didn't open a PR, the maintainers need to think carefully about a general approach.

  4. VGoshev commented on May 11, 2026

    @VGoshev

    Alternatively, the inherited denies could be prepended (before the subagent's own rules) rather than appended, so the subagent's explicit allows always win — though this would reopen the Plan Mode bypass for edit permissions.

    To my opinion, inheriting denies of write actions will break some workflows as well (when we have coordinator with only task permissions and subagents for real work, including edits).

    So I suggest to think about allowing plan agent to dispatch only read-only subagents (i.e. explore and scout) and each user will redefine it's permissions if needed (to add custom subagents for example).

    Or at least add setting to not inherit such denies for users who need it in their workflows

  5. vikramsg commented on May 11, 2026

    @vikramsg

    Just came across this issue myself. Feels like this was a natural consequence of relying on inheritance over composition.

    Maybe the fix should be to have it as an optional flag, and make it required for the Plan agent but let users decide their own customization rather than breaking custom agent workflows.

  6. nabilfreeman commented on May 11, 2026

    @nabilfreeman
    ContributorAuthor

    @vikramsg You know, the Plan agent itself is actually the cause of a lot of headaches in this codebase. 😅

    There are things like prompt injections matching the plan magic string that you cannot disable without forking Opencode!

    So this could actually be one of the cleanest fixes, accept that Plan is already specially handled in a few places, make this permission thing Plan only.

    Small cognitive load and the technical debt of Plan has already been accrued for a later date anyway.

  7. vikramsg commented on May 11, 2026

    @vikramsg

    There are things like prompt injections matching the plan magic string that you cannot disable without forking Opencode!

    Really? Can you give any examples please. I would really like to see what is being done that does this kind of hardcoding.

  8. nabilfreeman commented on May 11, 2026

    @nabilfreeman
    ContributorAuthor

    @vikramsg check this out:

    Plan mode injects this hardcoded reminder that you can't turn off from config:

    https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/session/prompt.ts#L388-L410

    The actual reminder is here:

    https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/session/prompt/plan.txt#L1-L9

    It literally starts with:

    <system-reminder>
    # Plan Mode - System Reminder
    
    CRITICAL: Plan mode ACTIVE - you are in READ-ONLY phase.
    

    Then switching back to build is also just another hardcoded <system-reminder>:

    https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/session/prompt/build-switch.txt#L1-L5

    And the newer experimental plan mode path hardcodes an even bigger inline reminder here:

    https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/session/prompt.ts#L441-L510

    What it means

    So, you can configure a 100% identical primary agent to Plan's prompts, same permissions, but it will not behave the same as the special internal Plan mode.

    Similarly, if you want to extend plan mode to maybe persist its plans into source control or something or sync them to a company Obsidian vault, it's extremely hard because you are constantly fighting this read only directive (learned this the hard way)

  9. nabilfreeman commented on May 11, 2026

    @nabilfreeman
    ContributorAuthor

    So back on thread, basically deep root cause, Plan mode is the most opinionated part of Opencode, latent risk of trampling people building heavily customized harnesses, now delicate balance has been broken.

  10. 21pounder commented on May 12, 2026

    @21pounder
    Contributor

    @nabilfreeman agreed — the edit-class filter is still opinionated. The real fix is letting agents declare which denies are descendant ceilings vs self-restrictions. A separate inherit_permission field on the agent config would do this cleanly: deriveSubagentSessionPermission pulls from inherit_permission instead of filtering permission. No hardcoded lists, opt-in per agent. Happy to update the PR in that direction if maintainers agree.

  11. kitlangton commented on May 13, 2026

    @kitlangton
    Contributor

    This was fixed in #27201 and should be out in the next release.

    The fix keeps Plan Mode’s inherited edit restriction, but stops parent agent self-restrictions like read, bash, and task from erasing a subagent’s own explicit permissions.

    Thanks for the detailed repro and analysis here, it made this much easier to pin down.

  12. vikramsg commented on May 13, 2026

    @vikramsg

    This was fixed in #27201 and should be out in the next release.

    Sorry this does not in fact solve the following issue. I don't have reopening permissions, but feel like it was closed prematurely.

    The issue is that the new inheritance behavior treats every parent deny as a descendant-wide permission ceiling. That is valid for Plan Mode read-only restrictions, but it breaks controller/coordinator patterns where the primary agent is intentionally narrow and delegates execution to a subagent that has its own explicit allowlist.

  13. OrShmuel22 commented on May 15, 2026

    @OrShmuel22

    The deny-by-default multi-agent pattern from this issue is still partially broken after #27201. Parent edit: deny still overrides subagent edit: allow because edit-class denies are unconditionally inherited. Opened #27654 which adds a subagent self-declaration check — if the subagent explicitly allows edit, the parent's deny isn't injected into its session. Works for the controller→executor→worker pattern described here.

  14. 8 remaining items

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions