Procházet zdrojové kódy

feat(canonical): author per-target overrides so the Claude adapter stops guessing

Claude Code cannot enforce a per-agent permission scope by any route. Verified against
its docs 2026-07-15: subagent frontmatter carries only flat tools:/disallowedTools:;
permission rules exist but live in settings.json and "apply to the entire session, not
only the plugin subagent"; a plugin's settings.json supports only the `agent` and
`subagentStatusLine` keys, so a plugin cannot ship rules at all; and precedence is
category-based (deny -> ask -> allow, specificity-blind), which cannot express
last-match-wins even in principle.

6 of our 7 Claude-targeted agents use scoped rules. For those there is no emission that
is both faithful and useful: fail-closed denies the tool outright (externalscout scopes
`read`, so it emitted `disallowedTools: Read` — a documentation scout that cannot read),
and widening hands over the tool with none of the scoping — which is how the 4 shipped
agents came to leak. That is a security decision, not a projection, so the adapter now
REFUSES rather than picking one, and a human rules on it in the source.

Adds `oac.overrides.<target>`:
  - `name`/`model`  — closes the id/filename gap (reviewer -> code-reviewer). A rebuild
                      now replaces the shipped file instead of adding a duplicate.
  - `tools`         — the authored grant. Every bound tool lands in exactly one list;
                      nothing is left to the target's default.
  - `unenforced`    — keyed by capability, and CHECKED: every granted-but-scoped
                      capability must carry a justification, and every justification must
                      correspond to one. Neither an unjustified widening nor a stale note
                      can survive a build.

All 7 overrides are authored to reproduce each agent's shipped `tools:` list exactly, so
this is behaviour-neutral: not one grant changes. What changes is that each loss is now
written down and re-warned on every build. The two worst are recorded in the source:
context-manager and externalscout receive unrestricted Bash where canonical allows four
and two commands respectively.

The refusal is scoped to `oac build` (`fromCanonical`), not the `oac-compat convert`
import path (`fromOAC`) — that converts an agent from a format with no `oac:` block, so
there is nowhere to record a decision and nobody to ask; fail-closed-and-warn remains the
best available answer there.

Also: build errors now name the source file, not just the agent id.

`plugins/claude-code/**` still stages rather than emitting in place — permissions no
longer block it, but running the emission surfaced a separate blocker: the shipped Claude
files are target-adapted, not stale. 4 of 7 carry `<example>` dispatch blocks in
`description:` that canonical has no field for, and their bodies are written against the
target's capabilities (Claude's code-reviewer cannot delegate, so its prompt says context
is pre-loaded; canonical's says "ALWAYS call ContextScout"). Emitting would silently
degrade all 7. Documented in build.ts; needs a design decision.

870 tests pass, 3 skipped; lint 0 errors; tsc clean; drift gate green.
darrenhinde před 2 týdny
rodič
revize
d643b4af76

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

@@ -166,38 +166,38 @@
       "target": "opencode",
       "root": ".opencode/agent"
     },
-    ".tmp/oac-build/plugins/claude-code/agents/coder-agent.md": {
-      "sha256": "c36053af5518c75748ea0d39e55fbc27e2c976339d960798ce5b62bb011560b2",
+    ".tmp/oac-build/plugins/claude-code/agents/code-reviewer.md": {
+      "sha256": "985896d8123330aa28ccb45fa23583de44c416a39f1cee09b282eb15b56b04f1",
       "target": "claude-code",
       "root": ".tmp/oac-build/plugins/claude-code/agents"
     },
-    ".tmp/oac-build/plugins/claude-code/agents/context-manager.md": {
-      "sha256": "c9903a9cc5b7a91c76ca725f6d1d1d92a31a2c0e9a53230ccb0202c2ea1af9e7",
+    ".tmp/oac-build/plugins/claude-code/agents/coder-agent.md": {
+      "sha256": "bc56a4e8868093d37ada634c2c0979b45813384621f60300f072a0cab11189e5",
       "target": "claude-code",
       "root": ".tmp/oac-build/plugins/claude-code/agents"
     },
-    ".tmp/oac-build/plugins/claude-code/agents/contextscout.md": {
-      "sha256": "2f604e2d2b531c81ec658d732505e95e1e4f9b29f8033c26ddd23225f55139a6",
+    ".tmp/oac-build/plugins/claude-code/agents/context-manager.md": {
+      "sha256": "61f07dd83ca55afb6d72cc58094f6753aa71dfb84505fe628343badfd4310b98",
       "target": "claude-code",
       "root": ".tmp/oac-build/plugins/claude-code/agents"
     },
-    ".tmp/oac-build/plugins/claude-code/agents/externalscout.md": {
-      "sha256": "3fbc39537ad6ca270f8bed8997e64c56527bc1cafc54d85b41f35689cafba6d7",
+    ".tmp/oac-build/plugins/claude-code/agents/context-scout.md": {
+      "sha256": "34e97512b3007d994c1c705d796842b493d54cdc0d63bf42b394d87a8f2185de",
       "target": "claude-code",
       "root": ".tmp/oac-build/plugins/claude-code/agents"
     },
-    ".tmp/oac-build/plugins/claude-code/agents/reviewer.md": {
-      "sha256": "840172fc68aed647dc65d666707d5d89e9cccdddba58520317de8725b154a2fa",
+    ".tmp/oac-build/plugins/claude-code/agents/external-scout.md": {
+      "sha256": "9656405d4fa8e81397f9b0228770abe8bae63f44cc9a5ff2d66999534d428b7a",
       "target": "claude-code",
       "root": ".tmp/oac-build/plugins/claude-code/agents"
     },
     ".tmp/oac-build/plugins/claude-code/agents/task-manager.md": {
-      "sha256": "33a52102790c7ccdae75269e095d44ccde9655b249ab77907a168c5b38c5e0f6",
+      "sha256": "b74bd22e51b9566752e5d4debc3a49cfe39a58debe3d0e1671e2556e17cf2281",
       "target": "claude-code",
       "root": ".tmp/oac-build/plugins/claude-code/agents"
     },
-    ".tmp/oac-build/plugins/claude-code/agents/tester.md": {
-      "sha256": "838e023fbbb06f75d37778751f76e37831af2f99586e63ac1bb3410ed3155990",
+    ".tmp/oac-build/plugins/claude-code/agents/test-engineer.md": {
+      "sha256": "01332fdd48dcf47008d150c42ffe794366b14ce5f05babf9fd94c0001d43d811",
       "target": "claude-code",
       "root": ".tmp/oac-build/plugins/claude-code/agents"
     },

+ 16 - 0
content/agents/subagents/code/coder-agent.md

@@ -33,6 +33,22 @@ oac:
   targets:
     - opencode
     - claude-code
+  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.
+      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

+ 13 - 0
content/agents/subagents/code/reviewer.md

@@ -29,6 +29,19 @@ oac:
   targets:
     - opencode
     - claude-code
+  overrides:
+    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.
+      #
+      # 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.
+      tools: [Read, Glob, Grep]
 ---
 
 # CodeReviewer

+ 22 - 0
content/agents/subagents/code/test-engineer.md

@@ -41,6 +41,28 @@ oac:
   targets:
     - opencode
     - claude-code
+  overrides:
+    claude-code:
+      name: test-engineer
+      model: sonnet
+      # 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 they are not needed to author tests against files it is
+      # pointed at. Kept narrow deliberately rather than widened for symmetry.
+      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

+ 20 - 0
content/agents/subagents/core/context-manager.md

@@ -46,6 +46,26 @@ oac:
   targets:
     - opencode
     - claude-code
+  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.
+      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

+ 8 - 0
content/agents/subagents/core/contextscout.md

@@ -42,6 +42,14 @@ oac:
   targets:
     - opencode
     - 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
 ---
 
 # ContextScout

+ 26 - 0
content/agents/subagents/core/externalscout.md

@@ -34,6 +34,32 @@ oac:
   targets:
     - opencode
     - claude-code
+  overrides:
+    claude-code:
+      name: external-scout
+      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.
+      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.
 ---
 
 

+ 12 - 0
content/agents/subagents/core/task-manager.md

@@ -38,6 +38,18 @@ oac:
   targets:
     - opencode
     - claude-code
+  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.
+      #
+      # 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.
+      tools: [Read, Write, Glob, Grep]
 ---
 
 <context>

+ 26 - 8
packages/cli/src/commands/build.ts

@@ -15,14 +15,32 @@
  * `.opencode/agent/**` and `registry.json` are emitted IN PLACE: they are build output, and
  * the subtask-11 CI gate rebuilds them and diffs.
  *
- * `plugins/claude-code/agents/**` is NOT. Regenerating it today would change what 4 shipped
- * agents are allowed to do — `coder-agent`, `context-manager`, `external-scout` and
- * `test-engineer` currently ship `Bash`/`Edit` unscoped, and the canonical sources project
- * those to a fail-closed deny (correctly: see `degradeToBinary`). That is a real security
- * tightening and a real behavioural change, and it is pending review rather than something a
- * build command should slip into a diff. So the target builds to {@link CLAUDE_STAGING_ROOT}
- * and the command REPORTS the comparison. There is deliberately no flag to emit it in place;
- * when the change is approved, the staging default is removed in one reviewed commit.
+ * `plugins/claude-code/agents/**` is NOT — and the original reason for that is now GONE, so
+ * do not remove this staging on the strength of the old comment. What used to block it was
+ * permissions: the canonical sources scope `Bash`/`Edit`, the shipped files granted them
+ * unscoped, and the adapter had to guess. `oac.overrides.claude-code` fixed exactly that.
+ * Every override reproduces its agent's shipped `tools:` list byte-for-byte; permissions no
+ * longer block anything.
+ *
+ * What blocks it now is PROMPT CONTENT, found 2026-07-15 by running the emission and reading
+ * the diff. The shipped Claude files are not stale copies of the canonical ones — they are
+ * target-adapted, in two ways a rebuild would silently destroy:
+ *
+ * 1. **`description:` carries agent-dispatch examples.** 4 of the 7 (`code-reviewer`,
+ *    `coder-agent`, `context-scout`, plus `context-manager`'s body) embed `<example>` blocks
+ *    that are how Claude Code SELECTS an agent. Canonical's `description` is OpenCode's — a
+ *    single line, with no examples anywhere in the corpus. Emitting would replace a dispatch
+ *    spec with a summary.
+ * 2. **The body is written against the target's capabilities.** Claude's `code-reviewer` has
+ *    `disallowedTools: Task`, so its prompt says context is "pre-loaded by the main agent".
+ *    The canonical body says "ALWAYS call ContextScout BEFORE reviewing any code" — an
+ *    instruction that agent cannot follow, because it cannot delegate.
+ *
+ * Both are the same shape as the permission problem: content that is a function of the target,
+ * with no canonical field to carry the variance. The fix is the same shape too — extend
+ * `oac.overrides` to the prompt — but it is a real design decision, not a default a build
+ * command should pick. So this target keeps staging to {@link CLAUDE_STAGING_ROOT} and
+ * REPORTING the comparison, and there is deliberately no flag to emit it in place.
  */
 
 import { type Command } from 'commander'

+ 230 - 16
packages/compatibility-layer/src/adapters/ClaudeAdapter.ts

@@ -1,7 +1,12 @@
 import matter from "gray-matter";
 import { dump } from "js-yaml";
 import { BaseAdapter } from "./BaseAdapter.js";
-import { projectToFlatTools, rulesFor, type ToolBinding } from "../core/Capabilities.js";
+import {
+  lossyCapabilities,
+  projectToFlatTools,
+  rulesFor,
+  type ToolBinding,
+} from "../core/Capabilities.js";
 import { getToolCapabilities } from "../core/CapabilityMatrix.js";
 import {
   CanonicalAgentSchema,
@@ -13,6 +18,7 @@ import {
   type HookEvent,
   type OpenAgent,
   type SkillReference,
+  type TargetOverride,
   type ToolCapabilities,
   type ToolConfig,
 } from "../types.js";
@@ -50,6 +56,19 @@ import {
  * 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.
  *
+ * ## When there is no honest answer, this adapter REFUSES
+ *
+ * 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.
+ *
+ * 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}.
+ *
  * @see https://code.claude.com/docs/en/sub-agents
  */
 export class ClaudeAdapter extends BaseAdapter {
@@ -92,36 +111,57 @@ export class ClaudeAdapter extends BaseAdapter {
     const agent = parsed.data;
     const warnings: string[] = [];
     const body = matter(source).content.trim();
+    const override = agent.oac.overrides["claude-code"];
+
+    // `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.
+    const name = override?.name ?? agent.oac.id;
 
     const frontmatter = this.buildAgentFrontmatter(
       {
-        name: agent.oac.id,
+        agentId: agent.oac.id,
+        name,
         description: agent.description,
-        model: agent.model,
+        // Claude Code names models in its own vocabulary (`sonnet`), so an override wins over
+        // the canonical `model:`, which is OpenCode's.
+        model: override?.model ?? agent.model,
         temperature: agent.temperature,
         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(agent.oac.id),
+      path: this.agentPath(name),
       content: `---\n${frontmatter}---\n\n${body}\n`,
       warnings,
     };
   }
 
   /**
-   * Where a canonical id lands in the plugin tree.
+   * Where a Claude Code component name lands in the plugin tree.
    *
-   * Keyed by `oac.id`, never by source filename or display name: the canonical ids and the
-   * Claude Code filenames genuinely differ (`contextscout` → `context-scout.md`,
-   * `reviewer` → `code-reviewer.md`, `tester` → `test-engineer.md`), and only the id is
-   * stable identity.
+   * Takes the emitted NAME, not `oac.id` — Claude Code requires an agent's `name` to match its
+   * filename, and the two genuinely differ from canonical ids (`reviewer` → `code-reviewer`,
+   * `tester` → `test-engineer`). {@link fromCanonical} resolves the name from
+   * `oac.overrides.claude-code.name`, falling back to the id.
    */
-  agentPath(id: string): string {
-    return `plugins/claude-code/agents/${id}.md`;
+  agentPath(name: string): string {
+    return `plugins/claude-code/agents/${name}.md`;
   }
 
   // ============================================================================
@@ -159,6 +199,7 @@ export class ClaudeAdapter extends BaseAdapter {
 
     const frontmatter = this.buildAgentFrontmatter(
       {
+        agentId: agent.frontmatter.name,
         name: agent.frontmatter.name,
         description: agent.frontmatter.description,
         model: agent.frontmatter.model,
@@ -289,21 +330,44 @@ export class ClaudeAdapter extends BaseAdapter {
   /**
    * Decide which Claude Code tools an agent gets, and which it is explicitly denied.
    *
-   * The allow/deny decision is entirely {@link projectToFlatTools}'s; this method only picks
-   * which tools are in play and hands back the two lists in {@link CLAUDE_TOOL_BINDINGS}
-   * order.
+   * 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.
+   *
+   * 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}.
    */
   private resolveTools(
     input: ClaudeAgentInput,
     warnings: string[]
   ): { tools: string[]; disallowedTools: string[] } {
     if (input.permission) {
+      warnings.push(...unmappableCapabilityWarnings(input.permission));
+    }
+
+    if (input.override?.tools) {
+      return this.applyOverride(input.override, input.permission, input.agentId);
+    }
+
+    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
       );
 
-      warnings.push(...unmappableCapabilityWarnings(input.permission));
-
       const projection = projectToFlatTools(input.permission, bindings, {
         target: "Claude Code",
       });
@@ -330,6 +394,89 @@ export class ClaudeAdapter extends BaseAdapter {
     return { tools: [], disallowedTools: [] };
   }
 
+  /**
+   * Apply an authored `oac.overrides.claude-code` grant, and hold it to its own justification.
+   *
+   * 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.
+   *
+   * 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.
+   */
+  private applyOverride(
+    override: TargetOverride,
+    permission: GranularPermission | undefined,
+    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));
+
+    if (unknown.length > 0) {
+      throw new Error(
+        `ClaudeAdapter: agent "${agentId}" overrides claude-code tools with ` +
+          `${unknown.map((tool) => `"${tool}"`).join(", ")}, which Claude Code has no such ` +
+          `tool for. Valid tools: ${[...known.keys()].join(", ")}.`
+      );
+    }
+
+    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),
+      disallowedTools: CLAUDE_TOOL_BINDINGS.filter((b) => !grant.has(b.tool)).map((b) => b.tool),
+    };
+  }
+
   /**
    * Generate Skills from OAC contexts.
    *
@@ -582,6 +729,8 @@ export interface ClaudeEmission {
 
 /** The frontmatter inputs both emit paths share. */
 interface ClaudeAgentInput {
+  /** Canonical `oac.id`. Used for diagnostics only — {@link ClaudeAgentInput.name} is emitted. */
+  agentId: string;
   name: string;
   description: string;
   model?: string;
@@ -589,6 +738,66 @@ interface ClaudeAgentInput {
   maxSteps?: number;
   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.
+ *
+ * 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.
+ */
+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(", ");
+
+  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` +
+    `    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`
+  );
 }
 
 /**
@@ -616,6 +825,11 @@ 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.
  *

+ 13 - 4
packages/compatibility-layer/src/core/BuildPipeline.ts

@@ -228,13 +228,22 @@ async function emitAgent(
   }
 
   const adapter = new ClaudeAdapter();
-  const { path, content, warnings } = await adapter.fromCanonical(source);
+
+  // The Claude Code adapter refuses (rather than guesses) when an agent's scoped rules have no
+  // honest flat equivalent. That error is how a security decision reaches a human, so it must
+  // name the file they have to open — the adapter only knows the agent id.
+  const emission = await adapter.fromCanonical(source).catch((cause: unknown) => {
+    throw new Error(`${sourcePath}: ${cause instanceof Error ? cause.message : String(cause)}`, {
+      cause,
+    });
+  });
+
   return {
-    path,
-    content,
+    path: emission.path,
+    content: emission.content,
     target,
     agentId: agent.oac.id,
-    warnings: warnings.map((reason) => ({ source: sourcePath, reason })),
+    warnings: emission.warnings.map((reason) => ({ source: sourcePath, reason })),
   };
 }
 

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

@@ -320,6 +320,27 @@ 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.
  *

+ 98 - 1
packages/compatibility-layer/src/types.ts

@@ -431,6 +431,77 @@ export const DependencyRefInputSchema = z.union([
   DependencyReferenceSchema,
 ]);
 
+/**
+ * What one target may override about this component, authored by a human.
+ *
+ * ## Why overrides exist at all
+ *
+ * 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:
+ *
+ * - subagent frontmatter carries `tools:`/`disallowedTools:` and nothing else
+ *   (`sub-agents.md`, "Supported frontmatter fields");
+ * - permission RULES exist, but only in `settings.json`, and they "apply to the entire
+ *   session, not only the plugin subagent" (`sub-agents.md`) — there is no per-agent scope;
+ * - a plugin's own `settings.json` supports only the `agent` and `subagentStatusLine` keys
+ *   (`plugins-reference.md`), so a plugin cannot ship permission rules even session-wide;
+ * - 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.
+ *
+ * @see {@link TargetOverridesSchema} for the rule that makes a widening un-silenceable.
+ */
+export const TargetOverrideSchema = z
+  .object({
+    /** Component name on this target. Defaults to {@link OacBlockSchema}'s `id`. */
+    name: z.string().min(1).optional(),
+    /** Target-native model id (e.g. Claude Code's `sonnet`), distinct from OpenCode's. */
+    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();
+
+/**
+ * Per-target overrides, keyed by target.
+ *
+ * A closed object rather than `z.record(BuildTargetSchema, …)` so a typo'd or unknown target
+ * key is a parse error instead of a silently-ignored block that never takes effect.
+ */
+export const TargetOverridesSchema = z
+  .object({
+    opencode: TargetOverrideSchema.optional(),
+    "claude-code": TargetOverrideSchema.optional(),
+    cursor: TargetOverrideSchema.optional(),
+    windsurf: TargetOverrideSchema.optional(),
+  })
+  .strict();
+
 /**
  * The canonical `oac:` frontmatter block — everything a component needs that OpenCode's
  * frontmatter schema rejects as an unknown field. This is precisely the content of
@@ -439,7 +510,7 @@ export const DependencyRefInputSchema = z.union([
  *
  * Strict: an unknown key is an error, never silently dropped.
  */
-export const OacBlockSchema = z
+export const OacBlockFieldsSchema = z
   .object({
     id: OacIdSchema,
     name: z.string().min(1),
@@ -450,9 +521,33 @@ export const OacBlockSchema = z
     tags: z.array(z.string()).default([]),
     dependencies: z.array(DependencyRefInputSchema).default([]),
     targets: BuildTargetsSchema,
+    overrides: TargetOverridesSchema.default({}),
   })
   .strict();
 
+/**
+ * The `oac:` block as parsed. {@link OacBlockFieldsSchema} plus the cross-field checks.
+ *
+ * This is a `ZodEffects`, so it has no `.shape`; reach for {@link OacBlockFieldsSchema} when
+ * you need the field list itself.
+ */
+export const OacBlockSchema = OacBlockFieldsSchema
+  .superRefine((oac, ctx) => {
+    // An override for a target this component does not emit to is dead config: it looks like
+    // it is doing something and never runs. Almost always a half-finished edit to `targets`.
+    for (const target of Object.keys(oac.overrides)) {
+      if (!oac.targets.includes(target as z.infer<typeof BuildTargetSchema>)) {
+        ctx.addIssue({
+          code: z.ZodIssueCode.custom,
+          path: ["overrides", target],
+          message:
+            `override declared for target "${target}", which is not in targets ` +
+            `[${oac.targets.join(", ")}] — it would never be applied`,
+        });
+      }
+    }
+  });
+
 // ============================================================================
 // Canonical Agent Schema
 // ============================================================================
@@ -566,6 +661,8 @@ export type AgentFrontmatter = z.infer<typeof AgentFrontmatterSchema>;
 export type OacId = z.infer<typeof OacIdSchema>;
 export type OacCategory = z.infer<typeof OacCategorySchema>;
 export type BuildTarget = z.infer<typeof BuildTargetSchema>;
+export type TargetOverride = z.infer<typeof TargetOverrideSchema>;
+export type TargetOverrides = z.infer<typeof TargetOverridesSchema>;
 export type OacBlock = z.infer<typeof OacBlockSchema>;
 /** Authored `oac:` block, before defaults are applied. */
 export type OacBlockInput = z.input<typeof OacBlockSchema>;

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

@@ -2,7 +2,7 @@
 name: fixture-planner
 description: Plans work before implementation. A golden-file fixture, not a shipped agent.
 tools: Read
-disallowedTools: Write, Edit, Bash
+disallowedTools: Write, Edit, Glob, Grep, Bash, WebFetch, Task
 model: sonnet
 ---
 

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

@@ -31,6 +31,13 @@ oac:
   targets:
     - opencode
     - claude-code
+  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.
+      tools: [Read]
 ---
 
 # FixturePlanner

+ 31 - 6
packages/compatibility-layer/tests/golden/golden-files.test.ts

@@ -176,18 +176,43 @@ 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("warns when it degrades an ordered bash allowlist to a binary deny", async () => {
+  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.
     const adapter = await adapterFor(
       "claude-code",
-      "degrading an ordered rule list to Claude Code's binary model emits a warning rather " +
-        "than silently dropping the allowlist"
+      "an ordered rule list Claude Code cannot enforce is refused, not silently degraded"
     );
 
-    const { warnings } = await adapter.fromCanonical(fixture("fixture-planner"));
+    // fixture-planner itself now carries an override, so build the un-ruled-on case here.
+    const unruled = fixture("fixture-planner").replace(
+      /  overrides:\n    claude-code:\n(?:      .*\n|      #.*\n)*/,
+      ""
+    );
+
+    expect(unruled, "the override block must actually be gone").not.toContain("overrides:");
+
+    await expect(adapter.fromCanonical(unruled)).rejects.toThrow(/bash/i);
+  });
 
-    expect(warnings ?? []).toEqual(
-      expect.arrayContaining([expect.stringMatching(/bash/i)])
+  it("emits fixture-planner once its override rules on that bash block", async () => {
+    const adapter = await adapterFor(
+      "claude-code",
+      "an authored override unblocks emission and is honoured verbatim"
     );
+
+    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.)
+    expect(content).toMatch(/^disallowedTools:.*\bBash\b/m);
+    expect((warnings ?? []).filter((w) => /permission|enforce/i.test(w))).toEqual([]);
   });
 
   // SKIPPED — blocked on a deferred product decision, not a bug. Two things must be settled

+ 222 - 57
packages/compatibility-layer/tests/unit/adapters/ClaudeAdapter.test.ts

@@ -34,7 +34,7 @@ function fixture(path: string): string {
 }
 
 /** A canonical agent file built around one permission block, for targeted projection tests. */
-function canonical(permission: string, extra = ""): string {
+function canonical(permission: string, extra = "", override = ""): string {
   return `---
 name: ProbeAgent
 description: A probe agent.
@@ -48,7 +48,7 @@ oac:
   type: subagent
   targets:
     - claude-code
----
+${override}---
 
 # ProbeAgent
 
@@ -56,6 +56,35 @@ Body.
 `;
 }
 
+/** An `oac.overrides.claude-code` block, indented to sit inside {@link canonical}'s oac block. */
+function override(body: string): string {
+  return `  overrides:\n    claude-code:\n${body}`;
+}
+
+/**
+ * An in-memory OpenAgent carrying an authored permission map — the `fromOAC` (legacy
+ * `oac-compat convert`) path.
+ *
+ * The degradation warnings live here rather than on the canonical path, and that is by design:
+ * `fromCanonical` refuses to degrade a scoped rule set at all (it demands an authored
+ * override), whereas `fromOAC` converts an agent from a format with no `oac:` block, so there
+ * is nowhere to record a decision and nobody to ask. Fail-closed-and-warn is the best it can
+ * do, so it is still exactly what these assert.
+ */
+function legacy(permission: Record<string, unknown>): OpenAgent {
+  return {
+    frontmatter: {
+      name: "probe-agent",
+      description: "A probe agent.",
+      mode: "subagent",
+      permission,
+    } as AgentFrontmatter,
+    metadata: { name: "probe-agent", category: "core", type: "subagent" },
+    systemPrompt: "Body.",
+    contexts: [],
+  };
+}
+
 describe("ClaudeAdapter", () => {
   let adapter: ClaudeAdapter;
 
@@ -290,32 +319,6 @@ describe("ClaudeAdapter", () => {
   // ============================================================================
 
   describe("permission projection", () => {
-    it("fails closed on a deny-all-then-allowlist bash block", async () => {
-      // The live shape: `bash: {"*": deny, "git log*": allow}`. Claude Code has no ordered
-      // -glob equivalent. Answering `tools: Bash` because "an allow rule exists" would hand
-      // it unrestricted shell — the precise failure PermissionMapper's permissive default
-      // produces, and the reason this adapter does not use it.
-      const { content } = await adapter.fromCanonical(fixture(FIXTURE_PLANNER));
-
-      expect(content).toMatch(/^disallowedTools:.*\bBash\b/m);
-      expect(content).not.toMatch(/^tools:.*\bBash\b/m);
-    });
-
-    it("degrades 'ask' to deny, never to allow", async () => {
-      const { content } = await adapter.fromCanonical(canonical(`  bash:\n    "*": "ask"\n`));
-
-      expect(content).toMatch(/^disallowedTools: Bash$/m);
-      expect(content).not.toMatch(/^tools:.*Bash/m);
-    });
-
-    it("does not treat an allow-with-exceptions as a plain allow", async () => {
-      const { content } = await adapter.fromCanonical(
-        canonical(`  edit:\n    "*": "allow"\n    "**/*.env*": "deny"\n`)
-      );
-
-      expect(content).toMatch(/^disallowedTools: Edit$/m);
-    });
-
     it("carries a provably uniform allow through as a grant", async () => {
       const { content, warnings } = await adapter.fromCanonical(
         canonical(`  read:\n    "*": "allow"\n`)
@@ -336,22 +339,180 @@ describe("ClaudeAdapter", () => {
       expect(warnings).toEqual([]);
     });
 
-    it("never grants a tool whose rules contain any deny", async () => {
-      // Property check over every mixed shape in the live corpus.
+  });
+
+  // ============================================================================
+  // REFUSAL — the canonical path will not guess at a security decision
+  // ============================================================================
+
+  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/);
+    });
+
+    it("names the offending capability and its rules, so the error is actionable", async () => {
+      await expect(
+        adapter.fromCanonical(canonical(`  bash:\n    "*": "deny"\n    "git log*": "allow"\n`))
+      ).rejects.toThrow(/bash: "\*": deny, "git log\*": allow/);
+    });
+
+    it("points at the exact place the decision belongs", async () => {
+      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) {
-        const { content } = await adapter.fromCanonical(canonical(shape));
-        const tools = /^tools: (.*)$/m.exec(content)?.[1] ?? "";
-
-        expect(tools, `${shape} leaked a grant`).toBe("");
+        await expect(
+          adapter.fromCanonical(canonical(shape)),
+          `${shape} was emitted instead of refused`
+        ).rejects.toThrow(/without an explicit override/);
       }
     });
+
+    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([]);
+    });
+  });
+
+  // ============================================================================
+  // OVERRIDES — an authored decision, held to its own justification
+  // ============================================================================
+
+  describe("per-target overrides", () => {
+    it("grants a scoped tool when the widening is justified", async () => {
+      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`)
+        )
+      );
+
+      expect(content).toMatch(/^tools: Read, Bash$/m);
+    });
+
+    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`)
+        )
+      );
+
+      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/);
+    });
+
+    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.
+      await expect(
+        adapter.fromCanonical(
+          canonical(`  read:\n    "*": "allow"\n`, "", override(`      tools: [Reed]\n`))
+        )
+      ).rejects.toThrow(/Claude Code has no such tool for/);
+    });
+
+    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`))
+      );
+
+      expect(path).toBe("plugins/claude-code/agents/probe-scout.md");
+      expect(content).toMatch(/^name: probe-scout$/m);
+    });
+
+    it("prefers the override model over the canonical one", async () => {
+      const { content } = await adapter.fromCanonical(
+        canonical(
+          `  read:\n    "*": "allow"\n`,
+          "model: claude-sonnet-4\n",
+          override(`      model: haiku\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"
+      );
+    });
   });
 
   // ============================================================================
@@ -359,51 +520,55 @@ describe("ClaudeAdapter", () => {
   // ============================================================================
 
   describe("warnings", () => {
+    // The degradation warnings below are asserted through `fromOAC`, not `fromCanonical`.
+    // That is not a workaround — after the override work, `fromCanonical` never degrades a
+    // scoped rule set at all (it refuses; see "refuses rather than guessing"), so there is no
+    // canonical-path behaviour left to assert. `fromOAC` still degrades, because it converts
+    // an agent from a format with no `oac:` block: no override can be authored and no human
+    // can be asked, so fail-closed-and-warn remains the best available answer there.
+
     it("emits exactly one warning for a single unrepresentable capability", async () => {
-      const { warnings } = await adapter.fromCanonical(
-        canonical(`  bash:\n    "*": "deny"\n    "git log*": "allow"\n`)
+      const { warnings } = await adapter.fromOAC(
+        legacy({ bash: { "*": "deny", "git log*": "allow" } })
       );
 
-      expect(warnings).toHaveLength(1);
-      expect(warnings[0]).toMatch(/bash/i);
-      expect(warnings[0]).toMatch(/fail-closed/);
+      expect(warnings.filter((w) => /has no equivalent/.test(w))).toHaveLength(1);
+      expect(warnings.some((w) => /'bash'/.test(w) && /fail-closed/.test(w))).toBe(true);
     });
 
     it("counts one warning per lossy capability, and none for the lossless ones", async () => {
       // read/glob are exact; bash and edit are not. Two losses, two warnings.
-      const { warnings } = await adapter.fromCanonical(
-        canonical(
-          `  read:\n    "*": "allow"\n` +
-            `  glob:\n    "*": "allow"\n` +
-            `  bash:\n    "*": "deny"\n    "git log*": "allow"\n` +
-            `  edit:\n    "*": "allow"\n    "**/*.key": "deny"\n`
-        )
+      const { warnings } = await adapter.fromOAC(
+        legacy({
+          read: { "*": "allow" },
+          glob: { "*": "allow" },
+          bash: { "*": "deny", "git log*": "allow" },
+          edit: { "*": "allow", "**/*.key": "deny" },
+        })
       );
 
-      expect(warnings).toHaveLength(2);
-      expect(warnings.filter((w) => /'bash'/.test(w))).toHaveLength(1);
-      expect(warnings.filter((w) => /'edit'/.test(w))).toHaveLength(1);
+      const lossy = warnings.filter((w) => /has no equivalent/.test(w));
+
+      expect(lossy).toHaveLength(2);
+      expect(lossy.filter((w) => /'bash'/.test(w))).toHaveLength(1);
+      expect(lossy.filter((w) => /'edit'/.test(w))).toHaveLength(1);
     });
 
     it("adds a second warning naming 'ask' when a mixed list contains one", async () => {
       // test-engineer's real block: a test-runner allowlist plus `rm -rf *: ask`.
-      const { warnings } = await adapter.fromCanonical(
-        canonical(`  bash:\n    "npx vitest *": "allow"\n    "rm -rf *": "ask"\n    "*": "deny"\n`)
+      const { warnings } = await adapter.fromOAC(
+        legacy({ bash: { "npx vitest *": "allow", "rm -rf *": "ask", "*": "deny" } })
       );
 
-      expect(warnings).toHaveLength(2);
       expect(warnings.some((w) => /cannot express/.test(w) && /ask/.test(w))).toBe(true);
     });
 
     it("warns when a rule list has no recoverable default", async () => {
       // context-manager's real `write` block: allow + deny with no "*" rule.
-      const { warnings } = await adapter.fromCanonical(
-        canonical(
-          `  write:\n    ".opencode/context/**/*.md": "allow"\n    "**/*.env*": "deny"\n`
-        )
+      const { warnings } = await adapter.fromOAC(
+        legacy({ write: { ".opencode/context/**/*.md": "allow", "**/*.env*": "deny" } })
       );
 
-      expect(warnings).toHaveLength(2);
       expect(warnings.some((w) => /ambiguous/.test(w))).toBe(true);
     });
 

+ 23 - 6
packages/compatibility-layer/tests/unit/build/build-pipeline.test.ts

@@ -318,7 +318,12 @@ describe("failure handling", () => {
     await expect(plan({ root })).rejects.toThrow();
   });
 
-  it("warns rather than silently widening when a target cannot carry a scoped rule", async () => {
+  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.
     put(
       "content/agents/subagents/test/gamma.md",
       canonicalAgent("gamma", ["claude-code"]).replace(
@@ -327,12 +332,24 @@ describe("failure handling", () => {
       ),
     );
 
-    const built = await plan({ root, targets: ["claude-code"], skipRegistry: true });
-    const emitted = built.files.find((file) => file.agentId === "gamma");
+    await expect(plan({ root, targets: ["claude-code"], skipRegistry: true })).rejects.toThrow(
+      /without an explicit override/,
+    );
+  });
 
-    expect(emitted?.content).toMatch(/^disallowedTools:.*\bBash\b/m);
-    expect(emitted?.content).not.toMatch(/^tools:.*\bBash\b/m);
-    expect(built.warnings.map((warning) => warning.reason).join("\n")).toMatch(/bash/i);
+  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.
+    put(
+      "content/agents/subagents/test/gamma.md",
+      canonicalAgent("gamma", ["claude-code"]).replace(
+        '  read:\n    "*": "allow"',
+        '  bash:\n    "*": "deny"\n    "git status": "allow"',
+      ),
+    );
+
+    await expect(plan({ root, targets: ["claude-code"], skipRegistry: true })).rejects.toThrow(
+      /content\/agents\/subagents\/test\/gamma\.md:/,
+    );
   });
 
   it("attaches the source path to every warning, so a warning is actionable", async () => {

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

@@ -14,6 +14,7 @@ import { readFileSync } from "node:fs";
 import { dirname, join } from "node:path";
 import { fileURLToPath } from "node:url";
 import {
+  OacBlockFieldsSchema,
   OacBlockSchema,
   CanonicalAgentSchema,
   OacCategorySchema,
@@ -95,7 +96,7 @@ describe("OacBlockSchema", () => {
       ]);
     });
 
-    it("defaults version, author, tags, dependencies and targets", () => {
+    it("defaults version, author, tags, dependencies, targets and overrides", () => {
       const result = OacBlockSchema.parse({
         id: "contextscout",
         name: "ContextScout",
@@ -113,10 +114,74 @@ describe("OacBlockSchema", () => {
         tags: [],
         dependencies: [],
         targets: ["opencode"],
+        // Overrides are opt-in: the common case is a component with nothing target-specific
+        // to say, and it must stay writable without an empty ceremonial block.
+        overrides: {},
       });
     });
   });
 
+  describe("per-target overrides", () => {
+    it("accepts a claude-code override", () => {
+      const result = OacBlockSchema.safeParse({
+        ...VALID_BLOCK,
+        targets: ["opencode", "claude-code"],
+        overrides: {
+          "claude-code": {
+            name: "code-reviewer",
+            model: "sonnet",
+            tools: ["Read", "Glob", "Grep"],
+            unenforced: { bash: "Claude Code cannot scope Bash per-agent" },
+          },
+        },
+      });
+
+      expect(result.success ? [] : result.error.issues).toEqual([]);
+    });
+
+    it("defaults unenforced to an empty record", () => {
+      const result = OacBlockSchema.parse({
+        ...VALID_BLOCK,
+        targets: ["opencode", "claude-code"],
+        overrides: { "claude-code": { name: "code-reviewer" } },
+      });
+
+      expect(result.overrides["claude-code"]?.unenforced).toEqual({});
+    });
+
+    it("rejects an unknown key inside an override (strict)", () => {
+      const result = OacBlockSchema.safeParse({
+        ...VALID_BLOCK,
+        targets: ["opencode", "claude-code"],
+        overrides: { "claude-code": { toolz: ["Read"] } },
+      });
+
+      expect(result.success).toBe(false);
+    });
+
+    it("rejects an override for an unknown target", () => {
+      const result = OacBlockSchema.safeParse({
+        ...VALID_BLOCK,
+        overrides: { emacs: { name: "whatever" } },
+      });
+
+      expect(result.success).toBe(false);
+    });
+
+    it("rejects an override for a target this component does not emit to", () => {
+      // Dead config: it looks like it is doing something and never runs. Almost always a
+      // half-finished edit to `targets`, and silently ignoring it is how that ships.
+      const result = OacBlockSchema.safeParse({
+        ...VALID_BLOCK,
+        targets: ["opencode"],
+        overrides: { "claude-code": { name: "code-reviewer" } },
+      });
+
+      expect(result.success).toBe(false);
+      expect(result.success ? "" : result.error.issues[0]?.message).toContain("not in targets");
+    });
+  });
+
   describe("rejected blocks", () => {
     it("rejects an unknown top-level key (strict)", () => {
       const result = OacBlockSchema.safeParse({ ...VALID_BLOCK, colour: "blue" });
@@ -192,7 +257,9 @@ describe("OacBlockSchema", () => {
 
     it("covers every field the sidecar uses, so nothing is lost dissolving it", () => {
       const used = new Set(Object.values(corpus()).flatMap((entry) => Object.keys(entry)));
-      const known = new Set(Object.keys(OacBlockSchema.shape));
+      // OacBlockSchema carries cross-field refinements, which makes it a ZodEffects with no
+      // `.shape`. The field list lives on the object it wraps.
+      const known = new Set(Object.keys(OacBlockFieldsSchema.shape));
 
       expect([...used].filter((field) => !known.has(field))).toEqual([]);
     });

+ 4 - 1
scripts/validation/check-build-drift.sh

@@ -31,7 +31,10 @@ cd "$ROOT"
 
 # The trees `oac build` emits IN PLACE, and therefore everything this gate watches.
 # plugins/claude-code/** is deliberately absent: it stages to .tmp/oac-build/ (gitignored)
-# and is compared only, never emitted. See packages/cli/src/commands/build.ts.
+# and is compared only, never emitted. Permissions no longer block emitting it in place --
+# oac.overrides.claude-code settled those -- but prompt content does: the shipped files carry
+# dispatch <example> blocks and capability-adapted bodies that canonical has no field for.
+# See the docblock in packages/cli/src/commands/build.ts.
 GENERATED=(.opencode/agent registry.json .oac/build-manifest.json)
 
 if [ ! -f packages/cli/dist/index.js ]; then