Browse Source

refactor(compat): require an authored tools list and drop the unenforced ledger

Every agent targeting claude-code must now author oac.overrides.claude-code.tools;
the adapter no longer derives the list from the permission: block, even when a
projection would have been exact. The unenforced: justification record is gone —
each widening is stated in a YAML comment beside the grant instead, where a
reader actually looks. lossyCapabilities and the cross-check machinery go with it.
contextscout now declares its tools explicitly like the other six.
darrenhinde 2 weeks ago
parent
commit
9b4e807843

+ 1 - 1
.oac/build-manifest.json

@@ -182,7 +182,7 @@
       "root": ".tmp/oac-build/plugins/claude-code/agents"
     },
     ".tmp/oac-build/plugins/claude-code/agents/context-scout.md": {
-      "sha256": "34e97512b3007d994c1c705d796842b493d54cdc0d63bf42b394d87a8f2185de",
+      "sha256": "0656a63f76e70431a16a797d398007498fb41fdbc1c0fbcf8d0f88a311bb6056",
       "target": "claude-code",
       "root": ".tmp/oac-build/plugins/claude-code/agents"
     },

+ 8 - 12
content/agents/subagents/code/coder-agent.md

@@ -36,19 +36,15 @@ oac:
   overrides:
     claude-code:
       model: sonnet
-      # Matches what ships today. Bash and Task are scoped in canonical and are DENIED here
-      # rather than widened — this agent cannot run the task router on Claude Code and cannot
-      # delegate to the scouts; it reports back to the caller instead.
+      # Matches what ships today. Bash and Task are scoped in canonical and denied here: this
+      # agent cannot run the task router on Claude Code and cannot delegate to the scouts.
+      #
+      # WIDENING — Edit: canonical denies edits to *.env*, *.key, *.secret, node_modules/**
+      # and .git/**. Claude Code applies none of them (Edit is on or off, and rules that could
+      # express the paths only live session-wide in settings.json, which a plugin cannot ship).
+      # Accepted because a coding agent that cannot edit is not an agent. The globs still hold
+      # on OpenCode, and a user's own settings.json can reinstate them.
       tools: [Read, Write, Edit, Glob, Grep]
-      unenforced:
-        edit: >-
-          Canonical denies edits to *.env*, *.key, *.secret, node_modules/** and .git/**.
-          Claude Code applies none of them: its Edit is on or off, and permission rules that
-          could express the paths only exist session-wide in settings.json, which a plugin
-          cannot ship. Accepted because a coding agent that cannot edit is not an agent, and
-          the alternative — denying Edit — makes it useless. The user's own settings.json is
-          where these denies can be reinstated, and the secret globs remain enforced on
-          OpenCode.
 ---
 
 # CoderAgent

+ 5 - 7
content/agents/subagents/code/reviewer.md

@@ -33,14 +33,12 @@ oac:
     claude-code:
       name: code-reviewer
       model: sonnet
-      # Read-only, exactly as canonical intends and exactly as this agent ships today. Every
-      # scoped capability (write, edit, task) is DENIED rather than widened, so there is no
-      # `unenforced:` block: nothing is granted that Claude Code cannot enforce.
+      # Read-only, matching canonical and what ships today. Everything canonical scopes
+      # (write, edit, task) is denied here rather than widened.
       #
-      # The one real loss is delegation — canonical lets this agent call `contextscout`, and
-      # Claude Code's Task tool is all-or-nothing, so allowing it would let this agent invoke
-      # ANY agent. A reviewer that cannot delegate is a fair trade for one that cannot be
-      # turned into a general-purpose executor; it already ships this way.
+      # The real loss is delegation: canonical lets this agent call contextscout, but Claude's
+      # Task tool is all-or-nothing — granting it would let the reviewer invoke ANY agent. A
+      # reviewer that cannot delegate beats one that can be turned into a general executor.
       tools: [Read, Glob, Grep]
 ---
 

+ 9 - 16
content/agents/subagents/code/test-engineer.md

@@ -45,24 +45,17 @@ oac:
     claude-code:
       name: test-engineer
       model: sonnet
-      # Matches what ships today. Task is scoped in canonical and DENIED here.
+      # Matches what ships today. Task is scoped in canonical and denied here. Glob and Grep
+      # are NOT granted though canonical leaves them unconstrained — this agent has shipped
+      # without them and does not need them to author tests against files it is pointed at.
       #
-      # Glob and Grep are NOT granted, though canonical leaves them unconstrained: this agent
-      # has shipped without them and they are not needed to author tests against files it is
-      # pointed at. Kept narrow deliberately rather than widened for symmetry.
+      # WIDENING — Bash: canonical allows ten test-runner prefixes (npx vitest, pytest, go
+      # test, ...), asks on "rm -rf *", and denies sudo and all else. Claude Code collapses
+      # that to unrestricted shell: this is the only place the rm -rf ask and the sudo deny
+      # disappear. Accepted because running tests is the whole point of the agent.
+      # WIDENING — Edit: canonical denies *.env*, *.key, *.secret; Claude applies none. Same
+      # trade as coder-agent — authoring tests requires Edit, and the globs hold on OpenCode.
       tools: [Read, Write, Edit, Bash]
-      unenforced:
-        bash: >-
-          Canonical allows ten test-runner prefixes (npx vitest, pytest, go test, …), asks on
-          "rm -rf *", and denies sudo and everything else. Claude Code collapses all of that to
-          unrestricted shell. Accepted because running tests is the entire point of this agent
-          and Claude Code cannot scope Bash per-agent — but note this is the only place the
-          "rm -rf" ask and the sudo deny disappear, so on Claude Code this agent is trusted with
-          the shell rather than restricted to it.
-        edit: >-
-          Canonical denies edits to *.env*, *.key and *.secret; Claude Code applies none of
-          them. Accepted for the same reason as coder-agent: authoring tests requires Edit, and
-          the globs still hold on OpenCode.
 ---
 
 # TestEngineer

+ 9 - 16
content/agents/subagents/core/context-manager.md

@@ -49,23 +49,16 @@ oac:
   overrides:
     claude-code:
       model: sonnet
-      # Matches what ships today. Edit and Task are scoped in canonical and are DENIED here
-      # rather than widened; this agent writes context files rather than editing them in place
-      # on Claude Code, and cannot delegate.
+      # Matches what ships today. Edit and Task are scoped in canonical and denied here; this
+      # agent writes context files rather than editing them in place on Claude Code.
+      #
+      # WIDENING — Write: canonical confines writes to .opencode/context/**/*.{md,json} and
+      # denies *.env*, *.key, *.secret. Claude enforces none of it. Accepted because managing
+      # context files IS this agent's only job. The confinement holds on OpenCode.
+      # WIDENING — Bash: canonical allows exactly four shapes (find/ls/mkdir/mv under
+      # .opencode/context) and denies everything else; on Claude Code this is unrestricted
+      # shell. The widest grant in this corpus — revisit here first.
       tools: [Read, Write, Glob, Grep, Bash]
-      unenforced:
-        write: >-
-          Canonical confines writes to .opencode/context/**/*.{md,json} and denies *.env*,
-          *.key and *.secret. Claude Code enforces none of that — Write is on or off. Accepted
-          because managing context files IS this agent's only job, so denying Write would leave
-          nothing for it to do. The confinement still holds on OpenCode.
-        bash: >-
-          Canonical allows exactly four shapes (find/ls/mkdir/mv under .opencode/context) and
-          denies everything else. On Claude Code this becomes unrestricted shell, which is the
-          widest grant any of these seven agents receives. Accepted only because the four
-          allowed commands are how it creates and moves context directories, and Claude Code
-          offers no way to scope them for a single agent. Revisit first if this list is ever
-          tightened: this is the weakest link.
 ---
 
 # ContextManager

+ 3 - 4
content/agents/subagents/core/contextscout.md

@@ -44,12 +44,11 @@ oac:
     - claude-code
   overrides:
     claude-code:
-      # Name only — this agent's rules project EXACTLY onto Claude Code's flat lists (every
-      # capability is a uniform allow or a uniform deny, so nothing is scoped and nothing is
-      # lost). It is the only one of the seven that needs no security decision here, so it
-      # deliberately declares no `tools:`: the projection is authoritative.
       name: context-scout
       model: haiku
+      # Read-only discovery. Nothing here is scoped in canonical (every capability is a
+      # blanket allow or deny), so this list says exactly what the permission block says.
+      tools: [Read, Glob, Grep]
 ---
 
 # ContextScout

+ 13 - 19
content/agents/subagents/core/externalscout.md

@@ -40,26 +40,20 @@ oac:
       model: haiku
       # Matches what ships today. Task is denied (canonical denies it outright).
       #
-      # This agent is why per-target overrides exist. Canonical scopes its `read` to two
-      # directories, so a fail-closed projection emits `disallowedTools: Read` — a
-      # documentation scout that cannot read. Widening was the only other option an adapter
-      # had, and it is what silently happened here before. Now it is a decision, in writing.
+      # This agent is why overrides exist: canonical scopes its `read` to two directories, and
+      # Claude Code cannot express that, so neither automatic answer works — denying Read
+      # leaves a documentation scout that cannot read, and granting it is a widening. Hence a
+      # decision, in writing:
+      #
+      # WIDENING — Read: canonical confines reads to .opencode/skills/context7/** and
+      # .tmp/external-context/** (its own skill and cache); on Claude Code it can read the
+      # whole repo. Accepted as the lesser of the two widenings, but it is a real loss.
+      # WIDENING — Bash: canonical allows only `curl -s https://context7.com/*` and `jq *`.
+      # On Claude Code this is unrestricted shell for an agent whose job is fetching untrusted
+      # documentation off the internet — the worst combination here. Accepted ONLY because the
+      # shipped agent already works this way. Fix first: WebFetch (already granted) covers the
+      # curl half natively, so Bash may be removable once the prompt stops instructing curl.
       tools: [Read, Write, Bash, WebFetch]
-      unenforced:
-        read: >-
-          Canonical confines reads to .opencode/skills/context7/** and .tmp/external-context/**
-          — i.e. its own skill and its own cache. Claude Code grants unscoped Read instead:
-          this agent can read the whole repo. Accepted because Read is the least dangerous of
-          the two widenings here and the agent is otherwise inert, but it is a real loss of
-          confinement, not a formality.
-        bash: >-
-          Canonical allows exactly `curl -s https://context7.com/*` and `jq *`, denying all
-          else. Claude Code cannot scope Bash per-agent, so this becomes unrestricted shell for
-          an agent whose whole job is fetching untrusted documentation off the internet — the
-          worst combination in this corpus. Accepted ONLY because the shipped agent already
-          works this way and this change is deliberately behaviour-neutral. This is the first
-          thing to fix: Claude Code's WebFetch (already granted) covers the curl half natively,
-          so Bash may well be removable outright once the prompt body stops instructing curl.
 ---
 
 

+ 6 - 7
content/agents/subagents/core/task-manager.md

@@ -41,14 +41,13 @@ oac:
   overrides:
     claude-code:
       model: sonnet
-      # Matches what ships today. Bash, Edit and Task are all scoped in canonical and are all
-      # DENIED here rather than widened, so no `unenforced:` block is needed.
+      # Matches what ships today. Bash, Edit and Task are all scoped in canonical and all
+      # denied here rather than widened.
       #
-      # The cost is real and accepted: canonical allows this agent `npx ts-node*task-cli*`, so
-      # on Claude Code it cannot drive the task CLI itself and must hand those commands back to
-      # the caller. Granting Bash to buy that back would mean unrestricted shell for a planning
-      # agent — a far worse trade than a planner that writes task JSON and delegates execution.
-      # Write is granted and unscoped in canonical too, so nothing is lost there.
+      # The cost is real and accepted: canonical allows `npx ts-node*task-cli*`, so on Claude
+      # Code this agent cannot drive the task CLI and must hand those commands back to the
+      # caller. Granting Bash to buy that back would mean unrestricted shell for a planning
+      # agent — a far worse trade than a planner that writes task JSON and delegates.
       tools: [Read, Write, Glob, Grep]
 ---
 

+ 46 - 166
packages/compatibility-layer/src/adapters/ClaudeAdapter.ts

@@ -1,12 +1,7 @@
 import matter from "gray-matter";
 import { dump } from "js-yaml";
 import { BaseAdapter } from "./BaseAdapter.js";
-import {
-  lossyCapabilities,
-  projectToFlatTools,
-  rulesFor,
-  type ToolBinding,
-} from "../core/Capabilities.js";
+import { projectToFlatTools, rulesFor, type ToolBinding } from "../core/Capabilities.js";
 import { getToolCapabilities } from "../core/CapabilityMatrix.js";
 import {
   CanonicalAgentSchema,
@@ -44,30 +39,28 @@ import {
  * real target is the plugin tree that already ships in this repo. `plugins/claude-code/`
  * is therefore the only output root; `.claude/` appears nowhere in what this adapter emits.
  *
- * ## Permissions are NOT decided here
- *
- * Claude Code's frontmatter carries two flat lists and no scoping: no ordered globs, no
- * `ask`, no last-match-wins. Canonical agents rely on all three. Collapsing that safely is
- * a security decision, so it lives in exactly one place — {@link projectToFlatTools} in
- * `core/Capabilities.ts`, which fails CLOSED. This class contributes tool NAMING and
- * ORDERING only.
+ * ## Tools are AUTHORED, not derived
  *
- * Concretely, `PermissionMapper.mapPermissionsFromOAC` must never be used here: it defaults
- * to `strategy="permissive"` (`hasAllow || !hasDeny`), which answers `bash: true` for
- * coder-agent's deny-all-then-allowlist block and would hand Claude Code unrestricted Bash.
+ * `oac.overrides.claude-code.tools` is required for every agent targeting Claude Code, and it
+ * is taken at its word.
  *
- * ## When there is no honest answer, this adapter REFUSES
+ * This class used to DERIVE the tool list from the canonical `permission:` block, degrading it
+ * fail-closed. That was a mistake, and it was the source of most of this file's former
+ * complexity. Claude Code cannot enforce a per-agent scope by any route — flat frontmatter
+ * lists, session-wide `settings.json` rules that a plugin may not even ship, and category-based
+ * precedence that cannot express last-match-wins. An ordered rule set therefore has no Claude
+ * Code equivalent at all, and every automatic answer is wrong in one of two directions:
+ * fail-closed cripples the agent (`externalscout` scopes `read`, so it lost Read outright — a
+ * documentation scout that cannot read), and widening hands over the tool with none of the
+ * scoping, which is how the shipped agents came to leak.
  *
- * For an agent whose canonical rules are scoped, neither available emission is right:
- * fail-closed denies the tool outright (a documentation scout that cannot read), and widening
- * hands over the tool with none of the scoping — which is how the shipped agents came to leak.
- * The adapter cannot choose between those; the question is "how much access should this agent
- * really have on a target that cannot enforce scopes", and only a human can answer it.
+ * There is no computation to get right here, so there is none. A human decides once, in the
+ * source, beside the permission block it trades against, with a comment saying why.
  *
- * So it throws, naming the agent and the capabilities, and points at
- * `oac.overrides.claude-code`. A build error is the correct output here: it costs one authored
- * decision, once, recorded in the source and visible in a diff — versus a guess that silently
- * ships. See {@link TargetOverride}.
+ * `PermissionMapper.mapPermissionsFromOAC` must still never be used on this path: it defaults
+ * to `strategy="permissive"` (`hasAllow || !hasDeny`), which answers `bash: true` for
+ * coder-agent's deny-all-then-allowlist block. {@link projectToFlatTools} survives for
+ * {@link ClaudeAdapter.fromOAC} only — see the note there.
  *
  * @see https://code.claude.com/docs/en/sub-agents
  */
@@ -113,6 +106,8 @@ export class ClaudeAdapter extends BaseAdapter {
     const body = matter(source).content.trim();
     const override = agent.oac.overrides["claude-code"];
 
+    if (override?.tools === undefined) throw new Error(toolsRequired(agent.oac.id));
+
     // `oac.id` is machine identity; the Claude Code component name is a separate, authored
     // fact (`reviewer` ships as `code-reviewer`). Without the override the two silently
     // diverge and a rebuild ADDS a file rather than replacing the one on disk.
@@ -130,21 +125,10 @@ export class ClaudeAdapter extends BaseAdapter {
         maxSteps: agent.maxSteps,
         permission: agent.permission,
         override,
-        refuseOnLoss: true,
       },
       warnings
     );
 
-    // Surface every accepted-but-unenforceable semantic on every build, rather than only in
-    // the source. An override records the loss; it does not make the loss stop existing.
-    warnings.push(
-      ...Object.entries(override?.unenforced ?? {}).map(
-        ([capability, reason]) =>
-          `⚠️  Claude Code will not enforce '${capability}' scoping for "${name}" — granted ` +
-          `unscoped by an authored override: ${reason}`
-      )
-    );
-
     return {
       path: this.agentPath(name),
       content: `---\n${frontmatter}---\n\n${body}\n`,
@@ -330,16 +314,12 @@ export class ClaudeAdapter extends BaseAdapter {
   /**
    * Decide which Claude Code tools an agent gets, and which it is explicitly denied.
    *
-   * Three cases, in order:
-   *
-   * 1. **An authored override** — the human already answered. Use it verbatim.
-   * 2. **A permission block that projects EXACTLY** — no scoping to lose, so
-   *    {@link projectToFlatTools} is faithful and needs no human.
-   * 3. **A permission block that does not** — refuse. See the class docblock.
+   * Two cases:
    *
-   * The allow/deny decision in case 2 is entirely {@link projectToFlatTools}'s; this method
-   * only picks which tools are in play and orders the two lists by
-   * {@link CLAUDE_TOOL_BINDINGS}.
+   * 1. **An authored override** ({@link ClaudeAdapter.fromCanonical}) — used verbatim.
+   * 2. **No override, but an authored `tools:` on/off map** ({@link ClaudeAdapter.fromOAC}) —
+   *    a foreign agent being imported. It carries no scoping to lose, so it needs no
+   *    projection; a permission map on that path is degraded by {@link projectToFlatTools}.
    */
   private resolveTools(
     input: ClaudeAgentInput,
@@ -350,20 +330,12 @@ export class ClaudeAdapter extends BaseAdapter {
     }
 
     if (input.override?.tools) {
-      return this.applyOverride(input.override, input.permission, input.agentId);
+      return this.applyOverride(input.override.tools, input.agentId);
     }
 
+    // fromOAC only: no `oac:` block exists, so no override can have been authored. Degrade
+    // fail-closed and warn — see {@link ClaudeAdapter.fromOAC}.
     if (input.permission) {
-      if (input.refuseOnLoss) {
-        const lossy = lossyCapabilities(input.permission, CLAUDE_TOOL_BINDINGS, {
-          target: "Claude Code",
-        });
-
-        if (lossy.length > 0) {
-          throw new Error(overrideRequired(input.agentId, input.permission, lossy));
-        }
-      }
-
       const bindings = CLAUDE_TOOL_BINDINGS.filter(
         (binding) => rulesFor(input.permission!, binding.capability).length > 0
       );
@@ -395,28 +367,19 @@ export class ClaudeAdapter extends BaseAdapter {
   }
 
   /**
-   * Apply an authored `oac.overrides.claude-code` grant, and hold it to its own justification.
+   * Apply an authored `oac.overrides.claude-code.tools` grant.
    *
    * Every bound tool lands in exactly one of the two lists — the granted ones in `tools:`,
    * every other in `disallowedTools:`. Nothing is omitted, because an omitted tool reads as
-   * "Claude Code's default" and that is precisely the ambiguity an override exists to end.
+   * "Claude Code's default", and an override exists precisely to end that ambiguity.
    *
-   * The `unenforced` cross-check is what makes an override a decision rather than a mute.
-   * Granting a tool whose canonical rules are scoped IS a widening; the author must say why it
-   * is acceptable, and the reverse must hold too — a justification for something no longer
-   * granted (or no longer scoped) is stale, and stale security notes are worse than none,
-   * because they read as current. Both directions are enforced so the file cannot quietly
-   * drift away from what it claims.
-   *
-   * @throws {Error} on an unknown tool name (a typo like `Reed` would silently fail open), an
-   * unjustified widening, or a stale justification.
+   * @throws {Error} on a tool name Claude Code does not have — a typo here fails OPEN (`Reed`
+   * would silently just not be granted), so it must never be tolerated.
    */
   private applyOverride(
-    override: TargetOverride,
-    permission: GranularPermission | undefined,
+    granted: readonly string[],
     agentId: string
   ): { tools: string[]; disallowedTools: string[] } {
-    const granted = override.tools ?? [];
     const known = new Map(CLAUDE_TOOL_BINDINGS.map((binding) => [binding.tool, binding]));
     const unknown = granted.filter((tool) => !known.has(tool));
 
@@ -429,47 +392,6 @@ export class ClaudeAdapter extends BaseAdapter {
     }
 
     const grant = new Set(granted);
-    const lossy = new Set(
-      lossyCapabilities(permission ?? [], CLAUDE_TOOL_BINDINGS, { target: "Claude Code" })
-    );
-
-    // A widening: granted here, scoped in canonical, unenforceable there.
-    const needsReason = CLAUDE_TOOL_BINDINGS.filter(
-      (binding) => grant.has(binding.tool) && lossy.has(binding.capability)
-    ).map((binding) => binding.capability);
-
-    const justified = new Set(Object.keys(override.unenforced));
-    const missing = needsReason.filter((capability) => !justified.has(capability));
-    const stale = [...justified].filter((capability) => !needsReason.includes(capability));
-
-    if (missing.length > 0) {
-      throw new Error(
-        `ClaudeAdapter: agent "${agentId}" grants ${missing
-          .map((c) => `"${CAPABILITY_TO_TOOL.get(c) ?? c}"`)
-          .join(", ")} on claude-code, but canonical scopes ${missing
-          .map((c) => `"${c}"`)
-          .join(", ")} with ordered rules that Claude Code cannot enforce.\n\n` +
-          `  That is a widening: the tool is handed over with none of the scoping. It may well ` +
-          `be the right call — but it has to be stated, not assumed.\n\n` +
-          `  Add to ${agentId}'s oac.overrides.claude-code:\n\n` +
-          `    unenforced:\n` +
-          missing.map((c) => `      ${c}: "<why granting this unscoped is acceptable>"\n`).join("")
-      );
-    }
-
-    if (stale.length > 0) {
-      throw new Error(
-        `ClaudeAdapter: agent "${agentId}" justifies ${stale
-          .map((c) => `"${c}"`)
-          .join(", ")} under oac.overrides.claude-code.unenforced, but ${
-          stale.length === 1 ? "that capability is" : "those capabilities are"
-        } not a widening: ${
-          stale.length === 1 ? "it is" : "they are"
-        } either not granted on claude-code, or not scoped in canonical.\n\n` +
-          `  Remove the entr${stale.length === 1 ? "y" : "ies"} — a justification for a risk ` +
-          `that no longer exists reads as current and hides the ones that do.`
-      );
-    }
 
     return {
       tools: CLAUDE_TOOL_BINDINGS.filter((b) => grant.has(b.tool)).map((b) => b.tool),
@@ -739,64 +661,27 @@ interface ClaudeAgentInput {
   permission?: GranularPermission;
   tools?: Record<string, boolean | undefined>;
   override?: TargetOverride;
-  /**
-   * Refuse to emit rather than degrade a scoped rule set. See {@link overrideRequired}.
-   *
-   * True only on the {@link ClaudeAdapter.fromCanonical} (`oac build`) path, and the asymmetry
-   * is the point. `oac build` compiles OUR corpus: the source is in this repo, an override can
-   * be authored in it, and a wrong guess ships to users — so it must not guess.
-   * {@link ClaudeAdapter.fromOAC} converts an agent someone already has, from a format with no
-   * `oac:` block and therefore nowhere to record an answer. There is no human to ask and no
-   * file to ask them to edit, so it degrades fail-closed and warns, which is the best available
-   * behaviour rather than a lesser standard.
-   */
-  refuseOnLoss?: boolean;
 }
 
 /**
- * The message for an agent whose scoped rules Claude Code cannot express and whose author has
- * not yet said what to do about it.
+ * The message for an agent that targets Claude Code without saying what it may do there.
  *
- * It is deliberately long. This error is the entire mechanism by which a security decision
- * reaches a human, and it fires at most once per agent, so it must carry everything needed to
- * decide: which capabilities are lossy, what the author actually wrote, and the exact shape of
- * the answer. A terse "cannot project permissions" would just get worked around.
+ * Not derivable from `permission:` — see the class docblock. Claude Code cannot enforce a
+ * per-agent scope, so every automatic answer is wrong in one of two directions, and no error
+ * text can talk someone out of that. It just asks for the decision.
  */
-function overrideRequired(
-  agentId: string,
-  permission: GranularPermission,
-  lossy: readonly string[]
-): string {
-  const detail = lossy
-    .map((capability) => {
-      const rules = rulesFor(permission, capability)
-        .map((rule) => `"${rule.pattern}": ${rule.action}`)
-        .join(", ");
-      return `    ${capability}: ${rules}`;
-    })
-    .join("\n");
-
-  const suggestion = CLAUDE_TOOL_BINDINGS.filter((b) => lossy.includes(b.capability))
-    .map((b) => b.tool)
-    .join(", ");
-
+function toolsRequired(agentId: string): string {
   return (
-    `ClaudeAdapter: agent "${agentId}" cannot be emitted for claude-code without an ` +
-    `explicit override.\n\n` +
-    `  These capabilities are scoped by ordered rules:\n${detail}\n\n` +
-    `  Claude Code cannot enforce them. Its subagent frontmatter carries only flat ` +
-    `tools:/disallowedTools:; its permission rules live in settings.json and apply to the whole ` +
-    `session, not one subagent; and a plugin cannot ship permission rules at all.\n\n` +
-    `  There is no emission that is both faithful and useful, so this build will not guess: ` +
-    `denying [${suggestion}] outright may leave the agent unable to do its job, and granting ` +
-    `them hands over the tools with none of the scoping.\n\n` +
-    `  Decide in the source — in ${agentId}'s oac: block:\n\n` +
+    `ClaudeAdapter: agent "${agentId}" targets claude-code but declares no ` +
+    `oac.overrides.claude-code.tools.\n\n` +
+    `  Claude Code cannot enforce a per-agent permission scope, so an agent's tools there are ` +
+    `authored, not derived from its canonical permission: block. State what it gets:\n\n` +
     `    oac:\n` +
     `      overrides:\n` +
     `        claude-code:\n` +
-    `          tools: [Read, Glob, Grep]     # what this agent gets on Claude Code\n` +
-    `          unenforced:\n` +
-    `            - "<which canonical scope Claude Code will not enforce, and why that is ok>"\n`
+    `          # why this differs from the permission: block above\n` +
+    `          tools: [Read, Glob, Grep]\n\n` +
+    `  Or drop "claude-code" from targets if it should not ship there.\n`
   );
 }
 
@@ -825,11 +710,6 @@ const CLAUDE_TOOL_BINDINGS: readonly ToolBinding[] = [
 /** Capabilities that bind to a Claude Code tool. Anything else cannot be carried. */
 const MAPPED_CAPABILITIES = new Set(CLAUDE_TOOL_BINDINGS.map((binding) => binding.capability));
 
-/** Canonical capability -> the Claude Code tool it governs, for diagnostics. */
-const CAPABILITY_TO_TOOL = new Map(
-  CLAUDE_TOOL_BINDINGS.map((binding) => [binding.capability, binding.tool])
-);
-
 /**
  * Warn for each authored capability Claude Code has no tool for.
  *

+ 0 - 21
packages/compatibility-layer/src/core/Capabilities.ts

@@ -320,27 +320,6 @@ export function degradeToBinary(
   return { allowed: false, warnings };
 }
 
-/**
- * The bound capabilities whose rules have NO faithful flat on/off equivalent.
- *
- * Defined as "degrading it loses something" rather than by re-deriving the shape, so this can
- * never disagree with {@link projectToFlatTools} about which capabilities are lossy — they
- * consult the same function.
- *
- * This is what lets a caller distinguish the two honest outcomes from the dishonest one: an
- * exact projection (emit it), versus a projection that silently drops scoping (refuse, and
- * make a human author the loss). Empty means the projection is exact.
- */
-export function lossyCapabilities(
-  permissions: GranularPermission,
-  bindings: readonly ToolBinding[],
-  options: DegradationOptions = {}
-): string[] {
-  return bindings
-    .filter((binding) => degradeToBinary(permissions, binding.capability, options).warnings.length > 0)
-    .map((binding) => binding.capability);
-}
-
 /**
  * Project a canonical permission spec onto a target's flat `tools` / `disallowedTools` lists.
  *

+ 11 - 24
packages/compatibility-layer/src/types.ts

@@ -434,11 +434,11 @@ export const DependencyRefInputSchema = z.union([
 /**
  * What one target may override about this component, authored by a human.
  *
- * ## Why overrides exist at all
+ * ## Why overrides exist: some things are not derivable
  *
- * A canonical `permission:` block is an *enforcement* spec: ordered globs, last-match-wins.
- * Some targets cannot enforce that. Claude Code is the live example — verified against its
- * docs on 2026-07-15:
+ * A canonical `permission:` block is OpenCode's own field, and on OpenCode it is *enforcement*:
+ * ordered globs, last-match-wins, applied per agent. Other targets cannot enforce that. Claude
+ * Code is the live example — verified against its docs on 2026-07-15:
  *
  * - subagent frontmatter carries `tools:`/`disallowedTools:` and nothing else
  *   (`sub-agents.md`, "Supported frontmatter fields");
@@ -449,12 +449,13 @@ export const DependencyRefInputSchema = z.union([
  * - precedence is category-based (deny → ask → allow, specificity-blind, `permissions.md`),
  *   which cannot express last-match-wins even in principle.
  *
- * So for an agent whose canonical rules are scoped, *no emission is both faithful and useful*.
- * Fail-closed yields a documentation scout that cannot read; widening is how the shipped
- * agents came to leak. That is not a question an adapter can answer — it is a security
- * decision. This block is where a human answers it, once, in the source, visible in a diff.
+ * So "what should this agent be allowed to do on Claude Code?" **cannot be computed** from the
+ * permission block. Deriving it and fixing up the failures was tried and was a mistake: every
+ * answer is either a crippled agent (fail-closed denies `externalscout` the Read it exists to
+ * do) or a silent widening (which is how the shipped agents came to leak). It is a judgement
+ * call, so a human writes it down here, and the adapter does as it is told.
  *
- * @see {@link TargetOverridesSchema} for the rule that makes a widening un-silenceable.
+ * Say WHY in a YAML comment next to the grant. That is where a reader looks anyway.
  */
 export const TargetOverrideSchema = z
   .object({
@@ -464,26 +465,12 @@ export const TargetOverrideSchema = z
     model: z.string().min(1).optional(),
     /**
      * The tools this component is granted on this target, in the target's own vocabulary.
+     *
      * Deliberately `string[]`, not an enum: each target names its tools differently, and the
      * adapter that owns those names validates them. A schema-level enum here would make
      * `types.ts` know about every target's tool list.
      */
     tools: z.array(z.string().min(1)).optional(),
-    /**
-     * Why it is acceptable that this target will not enforce a capability's canonical scope,
-     * keyed by capability (`bash`, `edit`, …).
-     *
-     * This is the honest field, and the one that keeps the whole mechanism from rotting. When
-     * an override grants a tool whose canonical rules are scoped, the scope is simply not
-     * applied on the target — it survives as prompt text at best. That is a real widening, and
-     * the author is asserting it is acceptable.
-     *
-     * Keyed rather than free-form prose **so the adapter can check it**: every granted-but-
-     * scoped capability must have an entry, and every entry must correspond to one. A prose
-     * blob would decay into a rubber stamp that nothing verifies; this cannot silently fall
-     * out of date, because the build fails when it does.
-     */
-    unenforced: z.record(z.string().min(1), z.string().min(1)).default({}),
   })
   .strict();
 

+ 1 - 1
packages/compatibility-layer/tests/golden/expected/claude-code/fixture-reviewer.md

@@ -2,7 +2,7 @@
 name: fixture-reviewer
 description: Reviews code for correctness. A golden-file fixture, not a shipped agent.
 tools: Read, Glob, Grep
-disallowedTools: Write, Edit, Bash, Task
+disallowedTools: Write, Edit, Bash, WebFetch, Task
 model: haiku
 ---
 

+ 3 - 3
packages/compatibility-layer/tests/golden/fixtures/fixture-planner.md

@@ -34,9 +34,9 @@ oac:
   overrides:
     claude-code:
       # Bash is deliberately NOT granted. Its canonical block is deny-all-then-allowlist, and
-      # Claude Code cannot express that, so the adapter refuses to choose — this is where the
-      # choice is made. Denying is not a widening, so no `unenforced:` entry is owed: the
-      # `git status` / `git log*` allowances are simply unavailable on this target.
+      # Claude Code cannot express that, so the choice is made here, not derived. Denying is
+      # a tightening: the `git status` / `git log*` allowances are simply unavailable on this
+      # target.
       tools: [Read]
 ---
 

+ 5 - 0
packages/compatibility-layer/tests/golden/fixtures/fixture-reviewer.md

@@ -34,6 +34,11 @@ oac:
   targets:
     - opencode
     - claude-code
+  overrides:
+    claude-code:
+      # Read-only. Matches this fixture's canonical permission block exactly — it scopes
+      # nothing, so there is no trade-off to record here.
+      tools: [Read, Glob, Grep]
 ---
 
 # FixtureReviewer

+ 20 - 17
packages/compatibility-layer/tests/golden/golden-files.test.ts

@@ -176,41 +176,44 @@ describe.each(TARGETS)("%s adapter goldens", (target) => {
 describe("claude-code adapter against the live corpus", () => {
   const OWED_BY = "subtasks 07 + 09 (ClaudeAdapter + content/agents/)";
 
-  it("refuses an ordered bash allowlist that no override has ruled on", async () => {
-    // This used to assert that degrading an ordered rule list emitted a warning. It no longer
-    // degrades: Claude Code cannot enforce a per-agent scope by any route (flat frontmatter
-    // lists; session-wide settings.json rules; plugins barred from shipping rules at all), so
-    // neither available emission is right — fail-closed cripples the agent, widening leaks —
-    // and choosing between them is a security decision the adapter has no standing to make.
-    // It refuses, and a human rules on it in `oac.overrides.claude-code`. A warning would be
-    // the wrong instrument: warnings are advisory, and this must not be possible to ignore.
+  it("refuses to emit an agent that never says what it may do on Claude Code", async () => {
+    // This once asserted that an ordered rule list degraded fail-closed with a warning. It no
+    // longer degrades, and the tool list is no longer derived from `permission:` at all.
+    // Claude Code cannot enforce a per-agent scope by any route (flat frontmatter lists;
+    // session-wide settings.json rules; plugins barred from shipping rules), so there is
+    // nothing to compute: every automatic answer is either a crippled agent or a silent
+    // widening. The tools are authored, and an agent that omits them is incomplete — which is
+    // an error, not a cue to pick a default.
     const adapter = await adapterFor(
       "claude-code",
-      "an ordered rule list Claude Code cannot enforce is refused, not silently degraded"
+      "an agent targeting claude-code without an authored tools list is refused, not defaulted"
     );
 
-    // fixture-planner itself now carries an override, so build the un-ruled-on case here.
+    // fixture-planner carries an override, so build the un-authored case out of it.
     const unruled = fixture("fixture-planner").replace(
-      /  overrides:\n    claude-code:\n(?:      .*\n|      #.*\n)*/,
+      /  overrides:\n    claude-code:\n(?:      .*\n)*/,
       ""
     );
 
     expect(unruled, "the override block must actually be gone").not.toContain("overrides:");
 
-    await expect(adapter.fromCanonical(unruled)).rejects.toThrow(/bash/i);
+    await expect(adapter.fromCanonical(unruled)).rejects.toThrow(
+      /declares no oac\.overrides\.claude-code\.tools/
+    );
   });
 
-  it("emits fixture-planner once its override rules on that bash block", async () => {
+  it("honours an authored override verbatim", async () => {
     const adapter = await adapterFor(
       "claude-code",
-      "an authored override unblocks emission and is honoured verbatim"
+      "an authored override is honoured verbatim, with no projection involved"
     );
 
     const { content, warnings } = await adapter.fromCanonical(fixture("fixture-planner"));
 
-    // The override denies Bash. Denying is a tightening, not a widening, so no permission
-    // warning is owed. (The fixture's `temperature: 0.3` still warns — Claude Code's agent
-    // frontmatter has no temperature — which is a real and unrelated loss.)
+    // fixture-planner's canonical bash block is deny-all-then-allowlist; its override grants
+    // only Read, so Bash lands in disallowedTools because the AUTHOR said so — not because a
+    // projection worked it out.
+    expect(content).toMatch(/^tools: Read$/m);
     expect(content).toMatch(/^disallowedTools:.*\bBash\b/m);
     expect((warnings ?? []).filter((w) => /permission|enforce/i.test(w))).toEqual([]);
   });

+ 103 - 180
packages/compatibility-layer/tests/unit/adapters/ClaudeAdapter.test.ts

@@ -26,6 +26,12 @@ import { ClaudeAdapter } from "../../../src/adapters/ClaudeAdapter";
 import { packagePath } from "../../support/pending.js";
 import type { OpenAgent, AgentFrontmatter, HookDefinition } from "../../../src/types";
 
+/** Claude Code's tools in the adapter's emit order. The union of both lists is always this. */
+const CLAUDE_TOOLS = ["Read", "Write", "Edit", "Glob", "Grep", "Bash", "WebFetch", "Task"] as const;
+
+/** The same list, rendered for a YAML `tools: [...]` override. */
+const CLAUDE_TOOLS_LITERAL = CLAUDE_TOOLS.join(", ");
+
 const FIXTURE_REVIEWER = packagePath("tests/golden/fixtures/fixture-reviewer.md");
 const FIXTURE_PLANNER = packagePath("tests/golden/fixtures/fixture-planner.md");
 
@@ -33,8 +39,14 @@ function fixture(path: string): string {
   return readFileSync(path, "utf-8");
 }
 
-/** A canonical agent file built around one permission block, for targeted projection tests. */
-function canonical(permission: string, extra = "", override = ""): string {
+/**
+ * A canonical agent file built around one permission block.
+ *
+ * The override defaults to a valid one: every agent targeting claude-code MUST author
+ * `tools`, so an agent without one is an error, not a baseline. Tests that care about the
+ * tool list pass their own via {@link override}; the "refuses without tools" case passes "".
+ */
+function canonical(permission: string, extra = "", override = OVERRIDE_DEFAULT): string {
   return `---
 name: ProbeAgent
 description: A probe agent.
@@ -61,6 +73,9 @@ function override(body: string): string {
   return `  overrides:\n    claude-code:\n${body}`;
 }
 
+/** The default override for probes that are not about the tool list itself. */
+const OVERRIDE_DEFAULT = override(`      tools: [Read]\n`);
+
 /**
  * An in-memory OpenAgent carrying an authored permission map — the `fromOAC` (legacy
  * `oac-compat convert`) path.
@@ -196,7 +211,7 @@ describe("ClaudeAdapter", () => {
         `---\nname: fixture-reviewer\n` +
           `description: Reviews code for correctness. A golden-file fixture, not a shipped agent.\n` +
           `tools: Read, Glob, Grep\n` +
-          `disallowedTools: Write, Edit, Bash, Task\n` +
+          `disallowedTools: Write, Edit, Bash, WebFetch, Task\n` +
           `model: haiku\n`
       );
     });
@@ -217,16 +232,26 @@ describe("ClaudeAdapter", () => {
 
     it("omits an empty tools list rather than emitting a bare key", async () => {
       // `tools:` with no value means something different to Claude Code than an absent key.
-      const { content } = await adapter.fromCanonical(canonical(`  bash:\n    "*": "deny"\n`));
+      const { content } = await adapter.fromCanonical(
+        canonical(`  bash:\n    "*": "deny"\n`, "", override(`      tools: []\n`))
+      );
 
       expect(content).not.toMatch(/^tools:\s*$/m);
-      expect(content).toMatch(/^disallowedTools: Bash$/m);
+      expect(content).toMatch(/^disallowedTools: Read, Write/m);
     });
 
     it("omits an empty disallowedTools list", async () => {
-      const { content } = await adapter.fromCanonical(canonical(`  read:\n    "*": "allow"\n`));
+      // Only reachable when every bound tool is granted — an authored override is exhaustive,
+      // so this is the one shape that leaves the deny list empty.
+      const { content } = await adapter.fromCanonical(
+        canonical(
+          `  read:\n    "*": "allow"\n`,
+          "",
+          override(`      tools: [${CLAUDE_TOOLS_LITERAL}]\n`)
+        )
+      );
 
-      expect(content).toMatch(/^tools: Read$/m);
+      expect(content).toMatch(/^tools: Read, Write, Edit, Glob, Grep, Bash, WebFetch, Task$/m);
       expect(content).not.toMatch(/^disallowedTools:/m);
     });
 
@@ -269,210 +294,106 @@ describe("ClaudeAdapter", () => {
 
   describe("tool ordering", () => {
     it("emits tools in the canonical Read, Write, Edit, Glob, Grep, Bash, WebFetch, Task order", async () => {
-      // Recovered from the 7 committed agents: all 10 of their lists fit this order and it
-      // is the only total order that does. Alphabetical is refuted by context-manager.md
-      // (`Read, Write, Glob, Grep, Bash`); so is ToolAccessSchema field order.
+      // Recovered from the 7 committed agents — the only total order all 10 of their lists
+      // agree with. Emitting any other order fails to reproduce every shipped file.
       const { content } = await adapter.fromCanonical(
         canonical(
-          `  task:\n    "*": "allow"\n` +
-            `  bash:\n    "*": "allow"\n` +
-            `  grep:\n    "*": "allow"\n` +
-            `  glob:\n    "*": "allow"\n` +
-            `  edit:\n    "*": "allow"\n` +
-            `  write:\n    "*": "allow"\n` +
-            `  read:\n    "*": "allow"\n` +
-            `  webfetch:\n    "*": "allow"\n`
+          `  read:\n    "*": "allow"\n`,
+          "",
+          override(`      tools: [Task, Bash, Read, Grep, Write, Glob, Edit, WebFetch]\n`)
         )
       );
 
-      expect(content).toMatch(
-        /^tools: Read, Write, Edit, Glob, Grep, Bash, WebFetch, Task$/m
+      expect(/^tools: (.*)$/m.exec(content)?.[1]).toBe(
+        "Read, Write, Edit, Glob, Grep, Bash, WebFetch, Task"
       );
     });
 
     it("orders disallowedTools by the same rule", async () => {
       const { content } = await adapter.fromCanonical(
-        canonical(
-          `  task:\n    "*": "deny"\n` +
-            `  bash:\n    "*": "deny"\n` +
-            `  edit:\n    "*": "deny"\n` +
-            `  write:\n    "*": "deny"\n`
-        )
+        canonical(`  read:\n    "*": "allow"\n`, "", override(`      tools: [Read]\n`))
       );
 
-      expect(content).toMatch(/^disallowedTools: Write, Edit, Bash, Task$/m);
-    });
-
-    it("does not emit a tool for a capability the source never mentions", async () => {
-      // Ratified rule (02 §1.2.5 case 1): an absent capability means "the target's own
-      // default", so naming it in either list would invent an intent the author never had.
-      const { content } = await adapter.fromCanonical(canonical(`  read:\n    "*": "allow"\n`));
-
-      for (const tool of ["Write", "Edit", "Glob", "Grep", "Bash", "WebFetch", "Task"]) {
-        expect(content, `${tool} was invented from silence`).not.toContain(tool);
-      }
-    });
-  });
-
-  // ============================================================================
-  // PERMISSION PROJECTION — fails closed
-  // ============================================================================
-
-  describe("permission projection", () => {
-    it("carries a provably uniform allow through as a grant", async () => {
-      const { content, warnings } = await adapter.fromCanonical(
-        canonical(`  read:\n    "*": "allow"\n`)
+      expect(/^disallowedTools: (.*)$/m.exec(content)?.[1]).toBe(
+        "Write, Edit, Glob, Grep, Bash, WebFetch, Task"
       );
-
-      expect(content).toMatch(/^tools: Read$/m);
-      expect(warnings).toEqual([]);
     });
 
-    it("carries a provably uniform deny through as a denial, silently", async () => {
-      // An exact projection loses nothing, so it must not warn — warnings mean loss, and
-      // noise here would train readers to ignore the real ones.
-      const { content, warnings } = await adapter.fromCanonical(
-        canonical(`  bash:\n    "*": "deny"\n`)
+    it("puts every bound tool in exactly one list, leaving nothing to a default", async () => {
+      // An omitted tool reads as "Claude Code's default" to Claude Code, which is the
+      // ambiguity an authored override exists to end. Union == every bound tool, always.
+      const { content } = await adapter.fromCanonical(
+        canonical(`  read:\n    "*": "allow"\n`, "", override(`      tools: [Read, Glob]\n`))
       );
 
-      expect(content).toMatch(/^disallowedTools: Bash$/m);
-      expect(warnings).toEqual([]);
-    });
+      const tools = /^tools: (.*)$/m.exec(content)?.[1]?.split(", ") ?? [];
+      const denied = /^disallowedTools: (.*)$/m.exec(content)?.[1]?.split(", ") ?? [];
 
+      expect([...tools, ...denied].sort()).toEqual([...CLAUDE_TOOLS].sort());
+    });
   });
 
   // ============================================================================
-  // REFUSAL — the canonical path will not guess at a security decision
+  // AUTHORED TOOLS — the whole contract
   // ============================================================================
 
-  describe("refuses rather than guessing", () => {
-    // Claude Code cannot enforce a scoped rule per-agent (frontmatter is two flat lists;
-    // permission rules are session-wide in settings.json; a plugin cannot ship them at all).
-    // So for a scoped capability there is no emission that is both faithful and useful:
-    // fail-closed yields a scout that cannot read, and widening is the leak this whole module
-    // exists to prevent. That is a product decision, not a projection — so `oac build` stops
-    // and makes a human author it. These tests pin THAT, because a future "helpful" default
-    // here would silently ship one of the two wrong answers.
-
-    it("refuses a deny-all-then-allowlist bash block with no override", async () => {
-      // The live shape: `bash: {"*": deny, "git log*": allow}`. Answering `tools: Bash`
-      // because "an allow rule exists" would hand over unrestricted shell — the precise
-      // failure PermissionMapper's permissive default produces.
-      await expect(
-        adapter.fromCanonical(
-          canonical(`  bash:\n    "*": "deny"\n    "git status": "allow"\n`)
-        )
-      ).rejects.toThrow(/cannot be emitted for claude-code without an explicit override/);
-    });
+  describe("authored tools", () => {
+    // Claude Code cannot enforce a per-agent permission scope by any route, so an agent's
+    // tools there are NOT derivable from its canonical permission: block. Deriving them was
+    // tried and was wrong in both directions — fail-closed crippled `externalscout` (it scopes
+    // `read`, so it lost Read outright), and widening is how the shipped agents leaked. So the
+    // list is authored, taken at its word, and its absence is an error rather than a cue to
+    // guess. These tests pin exactly that.
 
-    it("names the offending capability and its rules, so the error is actionable", async () => {
+    it("refuses an agent that targets claude-code without authoring tools", async () => {
       await expect(
-        adapter.fromCanonical(canonical(`  bash:\n    "*": "deny"\n    "git log*": "allow"\n`))
-      ).rejects.toThrow(/bash: "\*": deny, "git log\*": allow/);
+        adapter.fromCanonical(canonical(`  read:\n    "*": "allow"\n`, "", ""))
+      ).rejects.toThrow(/declares no oac\.overrides\.claude-code\.tools/);
     });
 
-    it("points at the exact place the decision belongs", async () => {
+    it("refuses even when the permission block would have projected cleanly", async () => {
+      // The tempting case: `read: {"*": allow}` is unambiguous, so a projection WOULD be
+      // exact. It is still refused — "we could have guessed correctly this time" is how a
+      // derivation creeps back in, and the next agent is the one that scopes something.
       await expect(
-        adapter.fromCanonical(canonical(`  bash:\n    "*": "deny"\n    "git log*": "allow"\n`))
-      ).rejects.toThrow(/oac:\s*\n\s*overrides:\s*\n\s*claude-code:/);
-    });
-
-    it("refuses every mixed shape in the live corpus", async () => {
-      // Property check: no scoped shape may slip through to an emission either way.
-      const shapes = [
-        `  bash:\n    "*": "deny"\n    "git log*": "allow"\n`,
-        `  bash:\n    "git log*": "allow"\n    "*": "deny"\n`,
-        `  bash:\n    "*": "ask"\n`,
-        `  edit:\n    "*": "allow"\n    "**/*.env*": "deny"\n`,
-        `  edit:\n    "**/*.env*": "deny"\n    "**/*.key": "deny"\n`,
-        `  read:\n    "**/*": "deny"\n    ".tmp/**": "allow"\n`,
-      ];
-
-      for (const shape of shapes) {
-        await expect(
-          adapter.fromCanonical(canonical(shape)),
-          `${shape} was emitted instead of refused`
-        ).rejects.toThrow(/without an explicit override/);
-      }
+        adapter.fromCanonical(canonical(`  read:\n    "*": "allow"\n`, "", ""))
+      ).rejects.toThrow(/declares no oac\.overrides\.claude-code\.tools/);
     });
 
-    it("emits without complaint once a scoped capability is denied by an override", async () => {
-      // Denying is not a widening, so it needs no justification — the tools list says it.
-      const { content, warnings } = await adapter.fromCanonical(
-        canonical(
-          `  bash:\n    "*": "deny"\n    "git log*": "allow"\n`,
-          "",
-          override(`      tools: [Read]\n`)
-        )
-      );
-
-      expect(content).toMatch(/^tools: Read$/m);
-      expect(content).toMatch(/^disallowedTools:.*\bBash\b/m);
-      expect(warnings).toEqual([]);
+    it("says how to fix it, and that dropping the target is a valid answer", async () => {
+      await expect(
+        adapter.fromCanonical(canonical(`  read:\n    "*": "allow"\n`, "", ""))
+      ).rejects.toThrow(/Or drop "claude-code" from targets/);
     });
-  });
 
-  // ============================================================================
-  // OVERRIDES — an authored decision, held to its own justification
-  // ============================================================================
-
-  describe("per-target overrides", () => {
-    it("grants a scoped tool when the widening is justified", async () => {
+    it("grants a tool the canonical permission block scopes, when authored to", async () => {
+      // The widening case, stated out loud: canonical scopes bash to `git log*`; Claude Code
+      // cannot express that; the author grants Bash anyway. No warning, no refusal — a human
+      // decided, and the adapter does as it is told.
       const { content } = await adapter.fromCanonical(
         canonical(
           `  bash:\n    "*": "deny"\n    "git log*": "allow"\n`,
           "",
-          override(`      tools: [Read, Bash]\n      unenforced:\n        bash: "why not"\n`)
+          override(`      tools: [Read, Bash]\n`)
         )
       );
 
-      expect(content).toMatch(/^tools: Read, Bash$/m);
+      expect(/^tools: (.*)$/m.exec(content)?.[1]).toBe("Read, Bash");
     });
 
-    it("warns on every build about a widening, not just in the source", async () => {
-      // An override records the loss; it does not make the loss stop existing. A reader of
-      // the build output must see it too, or the decision is invisible outside the file.
-      const { warnings } = await adapter.fromCanonical(
-        canonical(
-          `  bash:\n    "*": "deny"\n    "git log*": "allow"\n`,
-          "",
-          override(`      tools: [Bash]\n      unenforced:\n        bash: "needs the shell"\n`)
-        )
+    it("denies a tool the canonical permission block allows, when authored to", async () => {
+      // The reverse: canonical allows bash outright, the author withholds it on this target.
+      const { content } = await adapter.fromCanonical(
+        canonical(`  bash:\n    "*": "allow"\n`, "", override(`      tools: [Read]\n`))
       );
 
-      expect(warnings).toHaveLength(1);
-      expect(warnings[0]).toMatch(/will not enforce 'bash'/);
-      expect(warnings[0]).toMatch(/needs the shell/);
-    });
-
-    it("refuses a widening with no justification", async () => {
-      await expect(
-        adapter.fromCanonical(
-          canonical(
-            `  bash:\n    "*": "deny"\n    "git log*": "allow"\n`,
-            "",
-            override(`      tools: [Read, Bash]\n`)
-          )
-        )
-      ).rejects.toThrow(/grants "Bash" on claude-code, but canonical scopes "bash"/);
-    });
-
-    it("refuses a justification for something that is not a widening", async () => {
-      // Stale security notes are worse than none: they read as current and mask the real
-      // ones. Bash is denied here, so justifying it is describing a risk that does not exist.
-      await expect(
-        adapter.fromCanonical(
-          canonical(
-            `  bash:\n    "*": "deny"\n    "git log*": "allow"\n`,
-            "",
-            override(`      tools: [Read]\n      unenforced:\n        bash: "stale"\n`)
-          )
-        )
-      ).rejects.toThrow(/not a widening/);
+      expect(/^tools: (.*)$/m.exec(content)?.[1]).toBe("Read");
+      expect(content).toMatch(/^disallowedTools:.*\bBash\b/m);
     });
 
     it("refuses a tool name Claude Code does not have", async () => {
-      // A typo fails OPEN — `Reed` would silently just not be granted — so it must not pass.
+      // A typo fails OPEN — `Reed` would silently just not be granted, quietly shipping an
+      // agent with less access than intended and no error anywhere.
       await expect(
         adapter.fromCanonical(
           canonical(`  read:\n    "*": "allow"\n`, "", override(`      tools: [Reed]\n`))
@@ -480,9 +401,22 @@ describe("ClaudeAdapter", () => {
       ).rejects.toThrow(/Claude Code has no such tool for/);
     });
 
+    it("accepts an empty grant: an agent may be authored to have no tools at all", async () => {
+      const { content } = await adapter.fromCanonical(
+        canonical(`  read:\n    "*": "allow"\n`, "", override(`      tools: []\n`))
+      );
+
+      expect(content).not.toMatch(/^tools:/m);
+      expect(/^disallowedTools: (.*)$/m.exec(content)?.[1]).toBe(CLAUDE_TOOLS.join(", "));
+    });
+
     it("emits the override name, and keys the path on it", async () => {
       const { path, content } = await adapter.fromCanonical(
-        canonical(`  read:\n    "*": "allow"\n`, "", override(`      name: probe-scout\n`))
+        canonical(
+          `  read:\n    "*": "allow"\n`,
+          "",
+          override(`      name: probe-scout\n      tools: [Read]\n`)
+        )
       );
 
       expect(path).toBe("plugins/claude-code/agents/probe-scout.md");
@@ -490,29 +424,18 @@ describe("ClaudeAdapter", () => {
     });
 
     it("prefers the override model over the canonical one", async () => {
+      // Claude Code names models in its own vocabulary (`haiku`); canonical `model:` is
+      // OpenCode's.
       const { content } = await adapter.fromCanonical(
         canonical(
           `  read:\n    "*": "allow"\n`,
           "model: claude-sonnet-4\n",
-          override(`      model: haiku\n`)
+          override(`      model: haiku\n      tools: [Read]\n`)
         )
       );
 
       expect(content).toMatch(/^model: haiku$/m);
     });
-
-    it("puts every bound tool in exactly one list, leaving nothing to a default", async () => {
-      // An omitted tool reads as "Claude Code's default", which is the ambiguity an override
-      // exists to end.
-      const { content } = await adapter.fromCanonical(
-        canonical(`  read:\n    "*": "allow"\n`, "", override(`      tools: [Read, Glob]\n`))
-      );
-
-      expect(/^tools: (.*)$/m.exec(content)?.[1]).toBe("Read, Glob");
-      expect(/^disallowedTools: (.*)$/m.exec(content)?.[1]).toBe(
-        "Write, Edit, Grep, Bash, WebFetch, Task"
-      );
-    });
   });
 
   // ============================================================================

+ 25 - 12
packages/compatibility-layer/tests/unit/build/build-pipeline.test.ts

@@ -40,7 +40,19 @@ const REPO = repoPath();
 let root: string;
 
 /** A minimal canonical agent. `targets` is the knob most of these tests turn. */
+/**
+ * A canonical fixture agent.
+ *
+ * A claude-code target automatically gets an authored `tools` override, because every agent
+ * targeting Claude Code must have one — the adapter refuses to derive it (Claude Code cannot
+ * enforce a per-agent scope, so there is no honest derivation). An agent without one is
+ * incomplete, so it cannot be this helper's default shape; tests that want that case strip it.
+ */
 function canonicalAgent(id: string, targets: readonly string[]): string {
+  const overrides = targets.includes("claude-code")
+    ? ["  overrides:", "    claude-code:", "      tools: [Read]"]
+    : [];
+
   return [
     "---",
     `name: ${id}`,
@@ -58,6 +70,7 @@ function canonicalAgent(id: string, targets: readonly string[]): string {
     "  author: opencode",
     "  targets:",
     ...targets.map((target) => `    - ${target}`),
+    ...overrides,
     "---",
     "",
     `# ${id}`,
@@ -318,32 +331,32 @@ describe("failure handling", () => {
     await expect(plan({ root })).rejects.toThrow();
   });
 
-  it("fails the build rather than silently widening when a target cannot carry a scoped rule", async () => {
-    // Previously this asserted a warning. A warning was the wrong instrument: it is advisory,
-    // and the two things it could be advising are both wrong (fail-closed cripples the agent,
-    // widening leaks the tool). Claude Code cannot enforce a per-agent scope by any route, so
-    // there is no correct emission to warn ABOUT — a human has to rule on it in
-    // `oac.overrides.claude-code`, and a build that keeps going does not get one.
+  it("fails the build when an agent targets claude-code without authoring its tools", async () => {
+    // Previously this asserted a warning, then a refusal-on-scoped-rules. Both were downstream
+    // of deriving the tool list from `permission:`, which is not possible — Claude Code cannot
+    // enforce a per-agent scope, so every derived answer is either a crippled agent or a silent
+    // widening. The list is authored; an agent that omits it is incomplete, and the build says
+    // so rather than picking a default.
     put(
       "content/agents/subagents/test/gamma.md",
       canonicalAgent("gamma", ["claude-code"]).replace(
-        '  read:\n    "*": "allow"',
-        '  bash:\n    "*": "deny"\n    "git status": "allow"',
+        "  overrides:\n    claude-code:\n      tools: [Read]\n",
+        "",
       ),
     );
 
     await expect(plan({ root, targets: ["claude-code"], skipRegistry: true })).rejects.toThrow(
-      /without an explicit override/,
+      /declares no oac\.overrides\.claude-code\.tools/,
     );
   });
 
   it("names the source file in a refusal, not just the agent id", async () => {
-    // The adapter only knows `gamma`. The person who has to make the decision needs the path.
+    // The adapter only knows `gamma`. Whoever has to make the decision needs the path.
     put(
       "content/agents/subagents/test/gamma.md",
       canonicalAgent("gamma", ["claude-code"]).replace(
-        '  read:\n    "*": "allow"',
-        '  bash:\n    "*": "deny"\n    "git status": "allow"',
+        "  overrides:\n    claude-code:\n      tools: [Read]\n",
+        "",
       ),
     );
 

+ 2 - 3
packages/compatibility-layer/tests/unit/types/OacBlock.test.ts

@@ -131,7 +131,6 @@ describe("OacBlockSchema", () => {
             name: "code-reviewer",
             model: "sonnet",
             tools: ["Read", "Glob", "Grep"],
-            unenforced: { bash: "Claude Code cannot scope Bash per-agent" },
           },
         },
       });
@@ -139,14 +138,14 @@ describe("OacBlockSchema", () => {
       expect(result.success ? [] : result.error.issues).toEqual([]);
     });
 
-    it("defaults unenforced to an empty record", () => {
+    it("accepts a name-only override, for an agent whose tools need no restating", () => {
       const result = OacBlockSchema.parse({
         ...VALID_BLOCK,
         targets: ["opencode", "claude-code"],
         overrides: { "claude-code": { name: "code-reviewer" } },
       });
 
-      expect(result.overrides["claude-code"]?.unenforced).toEqual({});
+      expect(result.overrides["claude-code"]).toEqual({ name: "code-reviewer" });
     });
 
     it("rejects an unknown key inside an override (strict)", () => {