Skip to content

Subagent description ignores session permissions #48106

Open

Description

@mengwEX

Description

The model-facing subagent list ignores session permissions in V2. The description hook filters targets with only the selected agent's permissions, while tool snapshots and execution merge agent and session rules. A session-denied target stays advertised; a session-allowed target remains absent.

OpenCode version

V2 2.0.32, dev at ba06ff9e0bf2dcefbbbc3a1d6094f3572d77c84e.

Steps to reproduce

  1. Define a visible reviewer subagent.
  2. Give the parent agent subagent:reviewer:allow and create a session with permissions: [{ action: "subagent", resource: "reviewer", effect: "deny" }].
  3. Inspect the subagent description sent to the model: it still lists reviewer. Calling that target is correctly denied.
  4. Reverse the rules: an agent-level deny followed by a session-level allow or ask permits the target, but the description omits it.

The mismatch occurs in primary, compaction, and generate requests. This concerns the advertised target list; runtime permission enforcement works.

Code: packages/core/src/tool/plugin/subagent.ts, in the shared session hook. Compare Permission.configured and SessionContext.select, which merge the session rules.

Earlier report

This issue originally tracked the same omission in legacy describeTask. #48107 targeted that implementation and was closed by automated cleanup. The legacy packages/opencode package has since been removed, so the V2 fix belongs in packages/core.

The capability-summary enhancement discussed in the comments is separate from this session-permission mismatch.

Activity

  1. github-actions commented on Sep 9, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of or closely related to existing issues. Please check:

    If this is intentionally a focused, narrower fix scoped to describeTask only (as mentioned regarding #41100), please clarify that distinction so maintainers can track it separately from the above.

  2. feelxbloom commented on Sep 30, 2026

    @feelxbloom

    Corroboration on 2.0.18: our lanes pin the subagent tool with deny-all plus per-target allows, and the per-target filtering does work — a lane allowed only explore sees only that target. The gap we hit is the one this issue names from the other side: the tool's description carries purpose but no capability information, so after a permission denial a model has no way to discover that a child lane holds the tool it was denied — it retries or substitutes the nearest allowed tool instead of delegating. A capability summary per advertised target would fix the routing half; the catalog/description drift this issue tracks is the other half.

  3. changed the title [-]Task tool description ignores session permissions[/-] [+]Subagent description ignores session permissions[/+] on Oct 19, 2026
  4. opencodejr commented on Oct 28, 2026

    @opencodejr
    Contributor

    Thanks for the detailed report. I reproduced this on 2.0.32 and it still happens on the latest dev (d1bd20f).

    With a visible reviewer subagent, the simulated model's requests showed:

    • Agent build allows reviewer, session denies it: the subagent tool description still lists reviewer. When the model calls it, it is correctly rejected with Permission denied: subagent.
    • Agent lead denies reviewer, session allows it: the description leaves reviewer out, although the merged rules allow it.

    So enforcement uses both the agent and session rules, but the advertised list only uses the agent's. The script below reproduces it; it sets the session rules through the SDK and inspects the model request bodies.

    Related: open PR #56979 ("fix(core): merge session permissions into subagent descriptions") looks like it targets exactly this. #48107 was the earlier legacy fix.

    Reproduction script (drive.ts)
    // Repro for #48106: the `subagent` tool description ignores session permissions.
    // Run: OPENCODE_DRIVE_MEDIA_DIR=/workspace/media opencode-drive run drive.ts
    import { Effect, Stream } from "effect"
    import { Llm, OpenCodeDriver } from "opencode-drive"
    
    const listed = (body: any): string => {
      const tools: any[] = body?.tools ?? []
      const tool = tools.find((t) => (t.function?.name ?? t.name) === "subagent")
      const desc: string = tool?.function?.description ?? tool?.description ?? ""
      const idx = desc.indexOf("Available subagents:")
      if (!tool) return "(no subagent tool)"
      if (idx < 0) return "(no 'Available subagents' list)"
      return desc.slice(idx).split("\n").slice(1).map((l) => l.split(":")[0].replace("- ", "")).join(", ")
    }
    
    export default OpenCodeDriver.use(
      {
        ...(process.env.OC_DEV ? { opencode: { dev: process.env.OC_DEV } } : {}),
        config: {
          agents: {
            reviewer: { mode: "subagent", description: "Reviews code" },
            // primary agent that allows reviewer
            build: { permissions: [{ action: "subagent", resource: "reviewer", effect: "allow" }] },
            // primary agent that denies reviewer
            lead: {
              mode: "primary",
              description: "lead",
              permissions: [{ action: "subagent", resource: "reviewer", effect: "deny" }],
            },
          },
        } as any,
      },
      ({ ui, llm, opencode }) =>
        Effect.gen(function* () {
          const bodies: any[] = []
          let n = 0
          yield* llm.serve((request) => {
            // Title-generation requests carry no tools; answer them without counting.
            if (!Array.isArray((request.body as any)?.tools)) return Stream.make(Llm.text("Title"))
            bodies.push(request.body)
            n++
            // request 2: model tries to call the denied reviewer target
            if (n === 2)
              return Stream.make(
                Llm.toolCall({ index: 0, id: "call_rev", name: "subagent", input: { agent: "reviewer", description: "review", prompt: "review" } }),
                Llm.finish("tool-calls"),
              )
            const k = n <= 1 ? 1 : n - 1
            return Stream.make(Llm.text(`ok ${k}`))
          })
    
          // 1. baseline request (agent build allows reviewer, no session rules)
          yield* Effect.sleep(5000) // let the TUI finish starting
          yield* ui.submit("first")
          yield* ui.waitFor("ok 1", { timeout: 30000 }).pipe(Effect.tapError(() => Effect.flatMap(ui.capture(), (f: any) => Effect.sync(() => console.log("SCREEN:", JSON.stringify(f).slice(0, 3000))))))
          const sessions: any = yield* opencode.session.list({} as any)
          const items: any[] = sessions.data ?? sessions.items ?? sessions
          const sessionID = items[0].id
          console.log("baseline (build, no session rules):", listed(bodies[0]))
    
          // 2. session-level deny on reviewer
          yield* opencode.session.update({
            sessionID,
            permissions: [{ action: "subagent", resource: "reviewer", effect: "deny" }],
          } as any)
          yield* ui.submit("second")
          yield* ui.waitFor("ok 2", { timeout: 30000 })
          const stored: any = yield* opencode.session.get({ sessionID } as any)
          console.log("stored session permissions:", JSON.stringify((stored.data ?? stored).permissions ?? (stored.data ?? stored).permission))
          console.log("build + session deny reviewer (expected: reviewer NOT listed):", listed(bodies[1]))
          const toolResult = JSON.stringify(bodies[2]?.messages ?? bodies[2]?.input ?? bodies[2]).match(/[^"]{0,40}call_rev[^]{0,300}/)?.[0]
          console.log("runtime result of calling reviewer:", toolResult)
    
          // 3. agent-level deny (lead) + session-level allow
          yield* opencode.session.switchAgent({ sessionID, agent: "lead" } as any)
          yield* opencode.session.update({
            sessionID,
            permissions: [{ action: "subagent", resource: "reviewer", effect: "allow" }],
          } as any)
          yield* ui.submit("third")
          yield* ui.waitFor("ok 3", { timeout: 30000 })
          console.log("lead (deny) + session allow reviewer (expected: reviewer listed):", listed(bodies[3]))
        }),
    )

    Run it with opencode-drive: opencode-drive run ./drive.ts. It drives opencode from your PATH; set OC_DEV to the path of an OpenCode checkout to run that from source instead.

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions