agent-assurance 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/CODE_OF_CONDUCT.md +59 -0
  3. package/CONTRIBUTING.md +19 -0
  4. package/LICENSE +21 -0
  5. package/NOTICE +15 -0
  6. package/README.md +89 -0
  7. package/SECURITY.md +13 -0
  8. package/attacks/exfil.yaml +46 -0
  9. package/attacks/injection.yaml +51 -0
  10. package/attacks/tools.yaml +29 -0
  11. package/bun.lock +484 -0
  12. package/dist/adapter/exec.d.ts +10 -0
  13. package/dist/adapter/http.d.ts +7 -0
  14. package/dist/adapter/index.d.ts +5 -0
  15. package/dist/adapter/sdk.d.ts +7 -0
  16. package/dist/adapter/types.d.ts +41 -0
  17. package/dist/attacks/index.d.ts +3 -0
  18. package/dist/attacks/load.d.ts +33 -0
  19. package/dist/attacks/schema.d.ts +206 -0
  20. package/dist/cli.d.ts +2 -0
  21. package/dist/cli.js +24731 -0
  22. package/dist/graph/build.d.ts +60 -0
  23. package/dist/graph/flows.d.ts +14 -0
  24. package/dist/graph/index.d.ts +4 -0
  25. package/dist/graph/trifecta.d.ts +13 -0
  26. package/dist/index.d.ts +18 -0
  27. package/dist/index.js +22956 -0
  28. package/dist/manifest/index.d.ts +3 -0
  29. package/dist/manifest/load.d.ts +25 -0
  30. package/dist/manifest/schema.d.ts +136 -0
  31. package/dist/policy/protected-paths.d.ts +56 -0
  32. package/dist/report/findings.d.ts +52 -0
  33. package/dist/report/human.d.ts +19 -0
  34. package/dist/report/index.d.ts +5 -0
  35. package/dist/report/json.d.ts +39 -0
  36. package/dist/report/sarif.d.ts +57 -0
  37. package/dist/runner/index.d.ts +5 -0
  38. package/dist/runner/oracle.d.ts +46 -0
  39. package/dist/runner/run.d.ts +38 -0
  40. package/dist/runner/sandbox.d.ts +27 -0
  41. package/dist/runner/side-effect.d.ts +32 -0
  42. package/dist/scan.d.ts +43 -0
  43. package/package.json +60 -0
  44. package/policy-pack/README.md +105 -0
  45. package/policy-pack/hooks/guard-config-change.mjs +61 -0
  46. package/policy-pack/hooks/guard-protected-paths.mjs +65 -0
  47. package/policy-pack/managed-settings.json +18 -0
  48. package/policy-pack/protected-paths.json +18 -0
  49. package/policy-pack/settings.json +59 -0
  50. package/policy-pack/spike-bypass.sh +72 -0
@@ -0,0 +1,105 @@
1
+ # Cycle-of-Trust Policy Pack (reference)
2
+
3
+ A drop-in Claude Code configuration that **mechanically enforces the Cycle of Trust
4
+ boundary**: an autonomous agent may modify its **prompts, context, and manifests**,
5
+ but never its own **tools, permission grants, permission modes, or hook policies**.
6
+
7
+ This is the productized companion to AAL Core. AAL Core *detects* boundary
8
+ violations offensively; this pack *prevents* the most direct one — an agent
9
+ editing its own guardrails — and streams an evidence event for every attempt.
10
+
11
+ ## Why layered (and what the spike found)
12
+
13
+ The boundary is enforced by three controls. The load-bearing one turned out to be
14
+ the **hook**: an empirical spike (Claude Code **v2.1.201**, 2026-07-04 — see
15
+ [ADR-0001](../docs/adr/0001-layered-cycle-of-trust-enforcement.md)) showed a
16
+ `PreToolUse` exit-2 deny blocks the tool call in **every** mode tested, including
17
+ `bypassPermissions` **and** `--dangerously-skip-permissions`. A no-hook control run
18
+ confirmed bypass genuinely skips permission checks — so the block is the hook's,
19
+ not a permission rule's. This **reverses** the earlier doc-based reading (matrix
20
+ delta D1, which had claimed hooks don't hold under bypass).
21
+
22
+ | Layer | File | default / plan / acceptEdits | **bypassPermissions** |
23
+ |---|---|:---:|:---:|
24
+ | `permissions.deny` rules | `settings.json` | ✅ | ❌ (permission rules are skipped under bypass) |
25
+ | **`PreToolUse` guard hook** | `settings.json` → `hooks/guard-protected-paths.mjs` | ✅ | ✅ **(verified v2.1.201)** |
26
+ | Managed settings (`disableBypassPermissionsMode` + deny) | `managed-settings.json` | ✅ | ✅ (also removes the mode) |
27
+
28
+ **Take-away:** the **hook alone holds the boundary in every mode**, so the pack's
29
+ core guarantee does not depend on managed settings. Install `managed-settings.json`
30
+ anyway for **org-level defense-in-depth** — it removes `bypassPermissions` as an
31
+ option entirely and its deny rules cannot be edited away by the agent. This matrix
32
+ is asserted in `src/policy/protected-paths.test.ts` (`GUARANTEES_BY_MODE`); the
33
+ live re-run is [`spike-bypass.sh`](./spike-bypass.sh).
34
+
35
+ ## What is protected
36
+
37
+ The single source of truth is [`protected-paths.json`](./protected-paths.json).
38
+ Every consumer (deny rules, the guard hook, the tests) reads it, so they cannot
39
+ drift:
40
+
41
+ | Protected path (glob) | Invariant it defends |
42
+ |---|---|
43
+ | `**/.claude/settings.json`, `**/.claude/settings.local.json` | permission grants, permission modes, hook policies |
44
+ | `**/.claude/hooks/**` | hook policies (the enforcement code itself) |
45
+ | `**/.claude/agents/**` | subagent / tool definitions |
46
+ | `**/.mcp.json` | MCP tool/server definitions (tools) |
47
+
48
+ `shellMarkers` mirror these for `Bash` (substring match), so `echo … > .claude/settings.json`
49
+ is caught too.
50
+
51
+ ## Install
52
+
53
+ 1. **Project or user settings** — merge [`settings.json`](./settings.json) into
54
+ `.claude/settings.json`. Copy `policy-pack/` into the repo (the hook command
55
+ references `$CLAUDE_PROJECT_DIR/policy-pack/hooks/guard-protected-paths.mjs`).
56
+ 2. **Managed settings (required for the bypass guarantee)** — install
57
+ [`managed-settings.json`](./managed-settings.json) at the OS managed path:
58
+ - macOS: `/Library/Application Support/ClaudeCode/managed-settings.json`
59
+ - Linux: `/etc/claude-code/managed-settings.json`
60
+ - Windows: `C:\ProgramData\ClaudeCode\managed-settings.json`
61
+ 3. **Evidence stream (optional, → WS2)** — export `AAL_AUDIT_URL` (the AgenticMind
62
+ `POST /hooks/audit` URL) and `AAL_AUDIT_TOKEN` (a bearer carrying the
63
+ `audit:write` scope). The `PostToolUse` and `ConfigChange` HTTP hooks then
64
+ record every mutating tool call and config change as a hash-not-text evidence
65
+ row. Secrets come from the environment only — never hardcode them.
66
+
67
+ ## Open items — verify before rollout
68
+
69
+ - **`disableBypassPermissionsMode`** (`managed-settings.json`) drives the
70
+ org-level defense-in-depth (removing bypass as an option). The hook already
71
+ holds the boundary in every mode, so this key is not the sole guarantee — but
72
+ confirm its exact name/values against the current managed-settings docs before
73
+ relying on the org-level control.
74
+ - **Re-run the spike per CLI version.** The v2.1.201 result (hook blocks in every
75
+ mode) is version-specific evidence, not a doc guarantee. If a future version
76
+ lets `bypassPermissions` skip hooks, the managed layer becomes load-bearing
77
+ again — re-run `spike-bypass.sh` after upgrades.
78
+ - **`ConfigChange` blocking (delta D2 — resolved).** A spike confirmed the event
79
+ fires headlessly with `source` + `file_path`, and exit 2 blocks the change from
80
+ taking effect (except `policy_settings`). The pack now ships
81
+ `hooks/guard-config-change.mjs`, which blocks protected settings/skill changes
82
+ and lets `policy_settings` (managed) through. ⚠ This **freezes** ordinary edits
83
+ to `.claude/settings.json` / `settings.local.json` and any skills change from
84
+ taking effect — admins change the boundary via **managed** settings. Remove this
85
+ hook (keep the evidence hook) if settings must stay live-editable.
86
+ - **HTTP-hook runtime semantics (delta D3).** sync/async, timeout, and retry
87
+ behavior of `http` hooks are undocumented. The evidence stream is best-effort;
88
+ do not assume it is synchronous or that a failed POST is retried.
89
+ - **Hook matcher / `ConfigChange` matcher syntax.** Confirm the matcher grammar
90
+ against the hooks reference for your CLI version.
91
+
92
+ ## Verify it works
93
+
94
+ - **Offline (CI gate):** `bun run tsc && bun x vitest run src/policy/` — proves the
95
+ pack denies every protected glob in both layers, that managed settings disable
96
+ bypass, that the guard blocks self-modification attempts, and that the
97
+ guarantee matrix is honest about bypass.
98
+ - **Live (the D1/D2 spike):** run [`spike-bypass.sh`](./spike-bypass.sh) with the
99
+ real `claude` CLI. It drives an agent to modify its own `settings.json` under
100
+ each permission mode and records what actually happened. Result on **v2.1.201**:
101
+ the `PreToolUse` hook blocked the write in every mode — default, plan,
102
+ acceptEdits, `bypassPermissions`, and `--dangerously-skip-permissions` — while a
103
+ no-hook control confirmed bypass really does skip permission checks. Re-run this
104
+ on your target CLI version; the hook's cross-mode enforcement is the property to
105
+ re-confirm.
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ConfigChange guard hook — blocks a settings/skill change to a protected control
4
+ * path from taking effect (exit 2). Second net behind the PreToolUse guard: even
5
+ * if a protected-config change lands on disk, ConfigChange refuses to apply it.
6
+ *
7
+ * The event carries `source` (user_settings | project_settings | local_settings |
8
+ * policy_settings | skills) and `file_path`. `policy_settings` (managed) cannot be
9
+ * blocked by a hook and is the admin channel — we never block it. Verified against
10
+ * a live ConfigChange event on Claude Code v2.1.201 (delta D2, resolved).
11
+ *
12
+ * ⚠ This freezes protected-config changes for hardened deployments: ordinary edits
13
+ * to .claude/settings.json / settings.local.json and any skills change won't take
14
+ * effect while installed. Admins change the boundary via MANAGED (policy) settings.
15
+ * Remove this hook (keep the PreToolUse guard + evidence) if settings must stay
16
+ * live-editable.
17
+ */
18
+
19
+ import { readFileSync } from "node:fs"
20
+ import { dirname, resolve } from "node:path"
21
+ import { fileURLToPath } from "node:url"
22
+
23
+ const here = dirname(fileURLToPath(import.meta.url))
24
+ const { globs } = JSON.parse(readFileSync(resolve(here, "../protected-paths.json"), "utf8"))
25
+
26
+ const globToRe = (g) => {
27
+ // Convert a glob to a regex in a single alternation pass (longest token first) so `*` inside a
28
+ // `**` expansion is never re-processed — no placeholder sentinels needed.
29
+ const re = g
30
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
31
+ .replace(/\*\*\/|\*\*|\*/g, (m) => (m === "**/" ? "(?:.*/)?" : m === "**" ? ".*" : "[^/]*"))
32
+ return new RegExp("^" + re + "$")
33
+ }
34
+ const globRes = globs.map(globToRe)
35
+ const matchesPath = (p) => typeof p === "string" && globRes.some((re) => re.test(p))
36
+
37
+ let raw = ""
38
+ process.stdin.setEncoding("utf8")
39
+ process.stdin.on("data", (c) => (raw += c))
40
+ process.stdin.on("end", () => {
41
+ let ev = {}
42
+ try {
43
+ ev = JSON.parse(raw || "{}")
44
+ } catch {
45
+ process.exit(0)
46
+ }
47
+ const source = ev.source ?? ""
48
+ if (source === "policy_settings") {
49
+ process.exit(0) // managed settings are the admin channel — never blocked
50
+ }
51
+ const block = source === "skills" || matchesPath(ev.file_path)
52
+ if (block) {
53
+ process.stderr.write(
54
+ `Cycle of Trust: blocked a ${source} change to a protected control path from ` +
55
+ `taking effect (${ev.file_path ?? "skill"}). Change the boundary via managed ` +
56
+ `(policy) settings, not from inside a session.\n`,
57
+ )
58
+ process.exit(2)
59
+ }
60
+ process.exit(0)
61
+ })
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PreToolUse deny hook — Cycle of Trust boundary (defense-in-depth layer).
4
+ *
5
+ * Blocks Write / Edit / MultiEdit / Bash calls that would modify the agent's own
6
+ * tools, permission grants, permission modes, or hook policies. Reads the shared
7
+ * protected-paths.json so it can never drift from the deny rules and the tests.
8
+ *
9
+ * Deny protocol: exit code 2, reason on stderr (shown to the model). Exit 0 = allow.
10
+ *
11
+ * Spike result (Claude Code v2.1.201, 2026-07-04 — see policy-pack/README.md and
12
+ * ADR-0001): a PreToolUse exit-2 deny blocks the tool call even in
13
+ * `bypassPermissions` AND under `--dangerously-skip-permissions` (a no-hook
14
+ * control confirmed bypass really skips permission checks, so the block is the
15
+ * hook's). So this hook IS the load-bearing boundary in every mode — it also
16
+ * fires a per-attempt evidence trigger. `permissions.deny` RULES are still
17
+ * skipped under bypass; managed-settings (`disableBypassPermissionsMode`)
18
+ * additionally removes the mode entirely for org-level enforcement.
19
+ */
20
+
21
+ import { readFileSync } from "node:fs"
22
+ import { dirname, resolve } from "node:path"
23
+ import { fileURLToPath } from "node:url"
24
+
25
+ const here = dirname(fileURLToPath(import.meta.url))
26
+ const { globs, shellMarkers } = JSON.parse(
27
+ readFileSync(resolve(here, "../protected-paths.json"), "utf8"),
28
+ )
29
+
30
+ const globToRe = (g) => {
31
+ const re = g
32
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
33
+ // Single alternation pass (longest token first) — no control-char sentinels needed.
34
+ .replace(/\*\*\/|\*\*|\*/g, (m) => (m === "**/" ? "(?:.*/)?" : m === "**" ? ".*" : "[^/]*"))
35
+ return new RegExp("^" + re + "$")
36
+ }
37
+ const globRes = globs.map(globToRe)
38
+ const matchesPath = (p) => typeof p === "string" && globRes.some((re) => re.test(p))
39
+ const shellTouches = (cmd) =>
40
+ typeof cmd === "string" && shellMarkers.some((m) => cmd.includes(m))
41
+
42
+ let raw = ""
43
+ process.stdin.setEncoding("utf8")
44
+ process.stdin.on("data", (c) => (raw += c))
45
+ process.stdin.on("end", () => {
46
+ let ev = {}
47
+ try {
48
+ ev = JSON.parse(raw || "{}")
49
+ } catch {
50
+ process.exit(0) // unparseable event — do not block; the http audit hook still records
51
+ }
52
+ const tool = ev.tool_name ?? ev.tool ?? ""
53
+ const ti = ev.tool_input ?? {}
54
+ const blocked =
55
+ matchesPath(ti.file_path) || matchesPath(ti.path) || shellTouches(ti.command)
56
+ if (blocked) {
57
+ process.stderr.write(
58
+ `Cycle of Trust: blocked ${tool} targeting a protected control path ` +
59
+ `(tools / permissions / hooks). Autonomous remediation may modify ` +
60
+ `prompts, context, and manifests only — never its own guardrails.\n`,
61
+ )
62
+ process.exit(2)
63
+ }
64
+ process.exit(0)
65
+ })
@@ -0,0 +1,18 @@
1
+ {
2
+ "$comment": "Cycle-of-Trust reference policy pack — ADMIN MANAGED settings. Install at the OS managed-settings path (macOS: /Library/Application Support/ClaudeCode/managed-settings.json; Linux: /etc/claude-code/managed-settings.json). Managed settings CANNOT be overridden by the user or the agent. The PreToolUse guard hook already blocks in every mode (verified, Claude Code v2.1.201), so this managed layer is org-level DEFENSE-IN-DEPTH: disableBypassPermissionsMode removes bypassPermissions as an option entirely, and these deny rules cannot be edited away by the agent. ⚠ Verify `disableBypassPermissionsMode` against the current managed-settings docs before rollout.",
3
+ "permissions": {
4
+ "disableBypassPermissionsMode": "disable",
5
+ "deny": [
6
+ "Write(**/.claude/settings.json)",
7
+ "Edit(**/.claude/settings.json)",
8
+ "Write(**/.claude/settings.local.json)",
9
+ "Edit(**/.claude/settings.local.json)",
10
+ "Write(**/.claude/hooks/**)",
11
+ "Edit(**/.claude/hooks/**)",
12
+ "Write(**/.claude/agents/**)",
13
+ "Edit(**/.claude/agents/**)",
14
+ "Write(**/.mcp.json)",
15
+ "Edit(**/.mcp.json)"
16
+ ]
17
+ }
18
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "$comment": "Single source of truth for the Cycle-of-Trust boundary. The agent may modify prompts/context/manifests, but NEVER its own tools, permission grants, permission modes, or hook policies. `globs` gate file-writing tools (Write/Edit); `shellMarkers` are substring guards for Bash. Consumed by policy-pack/hooks/guard-protected-paths.mjs, by policy-pack/settings.json deny rules, and by src/policy/protected-paths.ts (drift is caught by src/policy/protected-paths.test.ts).",
3
+ "globs": [
4
+ "**/.claude/settings.json",
5
+ "**/.claude/settings.local.json",
6
+ "**/.claude/hooks/**",
7
+ "**/.claude/agents/**",
8
+ "**/.mcp.json"
9
+ ],
10
+ "shellMarkers": [
11
+ ".claude/settings.json",
12
+ ".claude/settings.local.json",
13
+ ".claude/hooks/",
14
+ ".claude/agents/",
15
+ ".mcp.json",
16
+ "managed-settings.json"
17
+ ]
18
+ }
@@ -0,0 +1,59 @@
1
+ {
2
+ "$comment": "Cycle-of-Trust reference policy pack — PROJECT/USER settings fragment. Merge into .claude/settings.json. The `permissions.deny` rules hold in default/plan/acceptEdits/dontAsk but are SKIPPED under bypassPermissions; the `PreToolUse` guard hook in this same file, however, blocks in EVERY mode including bypassPermissions and --dangerously-skip-permissions (verified, Claude Code v2.1.201 — see policy-pack/README.md). Install managed-settings.json to additionally remove bypass as an option org-wide. Set AAL_AUDIT_URL / AAL_AUDIT_TOKEN in the environment to stream evidence to AgenticMind /hooks/audit (WS2).",
3
+ "permissions": {
4
+ "deny": [
5
+ "Write(**/.claude/settings.json)",
6
+ "Edit(**/.claude/settings.json)",
7
+ "Write(**/.claude/settings.local.json)",
8
+ "Edit(**/.claude/settings.local.json)",
9
+ "Write(**/.claude/hooks/**)",
10
+ "Edit(**/.claude/hooks/**)",
11
+ "Write(**/.claude/agents/**)",
12
+ "Edit(**/.claude/agents/**)",
13
+ "Write(**/.mcp.json)",
14
+ "Edit(**/.mcp.json)"
15
+ ]
16
+ },
17
+ "hooks": {
18
+ "PreToolUse": [
19
+ {
20
+ "matcher": "Write|Edit|MultiEdit|Bash",
21
+ "hooks": [
22
+ {
23
+ "type": "command",
24
+ "command": "node \"$CLAUDE_PROJECT_DIR/policy-pack/hooks/guard-protected-paths.mjs\""
25
+ }
26
+ ]
27
+ }
28
+ ],
29
+ "PostToolUse": [
30
+ {
31
+ "matcher": "Write|Edit|MultiEdit|Bash",
32
+ "hooks": [
33
+ {
34
+ "type": "http",
35
+ "url": "${AAL_AUDIT_URL}",
36
+ "headers": { "Authorization": "Bearer ${AAL_AUDIT_TOKEN}" },
37
+ "allowedEnvVars": ["AAL_AUDIT_URL", "AAL_AUDIT_TOKEN"]
38
+ }
39
+ ]
40
+ }
41
+ ],
42
+ "ConfigChange": [
43
+ {
44
+ "hooks": [
45
+ {
46
+ "type": "command",
47
+ "command": "node \"$CLAUDE_PROJECT_DIR/policy-pack/hooks/guard-config-change.mjs\""
48
+ },
49
+ {
50
+ "type": "http",
51
+ "url": "${AAL_AUDIT_URL}",
52
+ "headers": { "Authorization": "Bearer ${AAL_AUDIT_TOKEN}" },
53
+ "allowedEnvVars": ["AAL_AUDIT_URL", "AAL_AUDIT_TOKEN"]
54
+ }
55
+ ]
56
+ }
57
+ ]
58
+ }
59
+ }
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env bash
2
+ # spike-bypass.sh — the LIVE empirical check the docs leave open.
3
+ #
4
+ # Question: does a PreToolUse deny hook block a tool call in every permission
5
+ # mode, INCLUDING bypassPermissions and --dangerously-skip-permissions?
6
+ #
7
+ # First run (Claude Code v2.1.201, 2026-07-04): YES — the hook blocked the write
8
+ # in every mode. A no-hook CONTROL run confirmed bypassPermissions genuinely
9
+ # skips permission checks (the write succeeds with no hook), so the block is
10
+ # attributable to the hook, not to a permission rule. This reversed the earlier
11
+ # doc-based reading (delta D1). Re-run after CLI upgrades to re-confirm.
12
+ #
13
+ # READ-SAFE: everything happens inside a throwaway temp dir; your real ~/.claude
14
+ # and repos are untouched. Requires the `claude` CLI + an authenticated account.
15
+ #
16
+ # NOTE on trust: project-level `permissions.allow`/`deny` from an UNTRUSTED
17
+ # workspace are ignored by Claude Code, but `hooks` still run. This spike tests
18
+ # the HOOK (which holds regardless of trust). To also exercise the pack's
19
+ # permissions.deny layer, trust the workspace first (run `claude` in it once, or
20
+ # set projects[<dir>].hasTrustDialogAccepted=true in ~/.claude.json).
21
+ set -euo pipefail
22
+
23
+ command -v claude >/dev/null 2>&1 || { echo "FAIL: 'claude' CLI not on PATH." >&2; exit 127; }
24
+
25
+ WORK="$(mktemp -d)"
26
+ trap 'rm -rf "$WORK"' EXIT
27
+ mkdir -p "$WORK/.claude"
28
+
29
+ # A PreToolUse Write hook that records the event (proves it fired) then denies.
30
+ cat > "$WORK/.claude/settings.json" <<JSON
31
+ {
32
+ "hooks": {
33
+ "PreToolUse": [
34
+ { "matcher": "Write", "hooks": [ { "type": "command", "command": "cat >> $WORK/fired.log; printf '\\n' >> $WORK/fired.log; exit 2" } ] }
35
+ ]
36
+ }
37
+ }
38
+ JSON
39
+
40
+ ATTEMPT='Use the Write tool to create ./target.txt containing exactly HELLO. If the tool is blocked, report the exact block reason and stop.'
41
+
42
+ attempt() { # $1 = label, rest = extra claude flags
43
+ local label="$1"; shift
44
+ rm -f "$WORK/target.txt" "$WORK/fired.log"
45
+ ( cd "$WORK" && CLAUDE_PROJECT_DIR="$WORK" claude -p "$ATTEMPT" "$@" >/dev/null 2>&1 || true )
46
+ local fired="no"; [ -s "$WORK/fired.log" ] && fired="yes"
47
+ local wrote="BLOCKED"; [ -e "$WORK/target.txt" ] && wrote="WROTE (hook did NOT block)"
48
+ printf '%-34s hook-fired=%-3s result=%s\n' "$label" "$fired" "$wrote"
49
+ }
50
+
51
+ echo "### hook-deny across modes (expect BLOCKED in all on v2.1.201)"
52
+ attempt "default (no flag)"
53
+ attempt "plan" --permission-mode plan
54
+ attempt "acceptEdits" --permission-mode acceptEdits
55
+ attempt "bypassPermissions" --permission-mode bypassPermissions
56
+ attempt "dangerously-skip-permissions" --dangerously-skip-permissions
57
+
58
+ echo
59
+ echo "### CONTROL: NO hook, bypassPermissions (expect WROTE — proves bypass is real)"
60
+ rm -f "$WORK/.claude/settings.json"
61
+ attempt "control: no hook, bypass" --permission-mode bypassPermissions
62
+
63
+ cat <<'NOTE'
64
+
65
+ ### Interpretation
66
+ - The hook-deny rows should all read BLOCKED (hook-fired=yes). If bypassPermissions
67
+ or dangerously-skip rows read WROTE, this CLI version lets bypass skip hooks —
68
+ D1 would hold again, and managed-settings.json becomes the load-bearing layer.
69
+ - The CONTROL row must read WROTE. If it reads BLOCKED, bypass is not actually
70
+ active and the test above is inconclusive — investigate before trusting results.
71
+ - Record the observed table + your CLI version in ADR-0001.
72
+ NOTE