@devrik-tools/claude-gates 0.1.2 → 0.2.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 (106) hide show
  1. package/.claude-plugin/marketplace.json +7 -1
  2. package/cli/__tests__/config.test.mjs +113 -101
  3. package/cli/__tests__/install.test.mjs +102 -0
  4. package/cli/__tests__/materialize.test.mjs +95 -0
  5. package/cli/__tests__/registry-gates-consistency.test.mjs +52 -44
  6. package/cli/__tests__/task.test.mjs +124 -0
  7. package/cli/__tests__/version-consistency.test.mjs +44 -0
  8. package/cli/config.mjs +124 -113
  9. package/cli/index.mjs +100 -97
  10. package/cli/init.mjs +259 -256
  11. package/cli/install.mjs +150 -80
  12. package/cli/materialize.mjs +102 -51
  13. package/cli/registry.mjs +136 -127
  14. package/cli/task.mjs +140 -0
  15. package/package.json +2 -1
  16. package/plugins/gates/.claude-plugin/plugin.json +8 -2
  17. package/plugins/gates/hooks/__tests__/ask-adoption.test.mjs +83 -0
  18. package/plugins/gates/hooks/__tests__/doctor.test.mjs +85 -0
  19. package/plugins/gates/hooks/__tests__/wiring-check.test.mjs +65 -0
  20. package/plugins/gates/hooks/ask-adoption.mjs +147 -0
  21. package/plugins/gates/hooks/doctor.mjs +207 -0
  22. package/plugins/gates/hooks/gates/audit-before-build/audit-before-build.edge.test.mjs +83 -0
  23. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +88 -88
  24. package/plugins/gates/hooks/gates/audit-before-build/test.mjs +117 -92
  25. package/plugins/gates/hooks/gates/autonomous-mode/autonomous.edge.test.mjs +104 -0
  26. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +48 -45
  27. package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +165 -0
  28. package/plugins/gates/hooks/gates/bash-commands/index.mjs +285 -268
  29. package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +151 -0
  30. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +265 -177
  31. package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +207 -0
  32. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +501 -456
  33. package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +161 -143
  34. package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +69 -0
  35. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +126 -118
  36. package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +68 -0
  37. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +43 -49
  38. package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +83 -0
  39. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -100
  40. package/plugins/gates/hooks/gates/force-parallel/index.mjs +119 -0
  41. package/plugins/gates/hooks/gates/force-parallel/test.mjs +88 -0
  42. package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +155 -0
  43. package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -112
  44. package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +66 -0
  45. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -186
  46. package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -238
  47. package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +96 -0
  48. package/plugins/gates/hooks/gates/intent-flow/test.mjs +165 -136
  49. package/plugins/gates/hooks/gates/lint-commit/index.mjs +149 -0
  50. package/plugins/gates/hooks/gates/lint-commit/test.mjs +85 -0
  51. package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +100 -0
  52. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -166
  53. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +79 -85
  54. package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +80 -0
  55. package/plugins/gates/hooks/gates/never-assume/index.mjs +58 -55
  56. package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +71 -0
  57. package/plugins/gates/hooks/gates/never-assume/test.mjs +95 -78
  58. package/plugins/gates/hooks/gates/no-blocking/index.mjs +148 -142
  59. package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +109 -0
  60. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +123 -120
  61. package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +83 -0
  62. package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +91 -0
  63. package/plugins/gates/hooks/gates/protected-paths/index.mjs +144 -147
  64. package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +126 -0
  65. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +89 -64
  66. package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +102 -0
  67. package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +94 -0
  68. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +159 -150
  69. package/plugins/gates/hooks/gates/risk-level/index.mjs +263 -203
  70. package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +116 -0
  71. package/plugins/gates/hooks/gates/risk-level/test.mjs +153 -125
  72. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +56 -50
  73. package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +58 -0
  74. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +116 -111
  75. package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +97 -0
  76. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +184 -99
  77. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +126 -0
  78. package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +130 -0
  79. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -251
  80. package/plugins/gates/hooks/gates/stop-pending/index.mjs +159 -0
  81. package/plugins/gates/hooks/gates/stop-pending/test.mjs +114 -0
  82. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +101 -101
  83. package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +72 -0
  84. package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +63 -0
  85. package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -181
  86. package/plugins/gates/hooks/gates/test-matrix/test.mjs +114 -87
  87. package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +62 -0
  88. package/plugins/gates/hooks/gates/tool-map/index.mjs +143 -140
  89. package/plugins/gates/hooks/hooks.json +346 -286
  90. package/plugins/gates/hooks/lib/__tests__/config.test.mjs +173 -154
  91. package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +210 -154
  92. package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +114 -0
  93. package/plugins/gates/hooks/lib/config.mjs +172 -165
  94. package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +189 -0
  95. package/plugins/gates/hooks/lib/hook-io.mjs +318 -208
  96. package/plugins/gates/hooks/lib/signals.mjs +127 -0
  97. package/plugins/gates/hooks/wiring-check.mjs +227 -0
  98. package/plugins/tasks/.claude-plugin/plugin.json +14 -0
  99. package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +100 -0
  100. package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +95 -0
  101. package/plugins/tasks/hooks/hooks.json +26 -0
  102. package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +180 -132
  103. package/plugins/tasks/hooks/lib/task-store.mjs +197 -159
  104. package/plugins/tasks/hooks/register-requests.mjs +141 -108
  105. package/plugins/tasks/hooks/session-tasks.mjs +108 -0
  106. package/registry.json +793 -686
@@ -1,165 +1,172 @@
1
- // Runtime gate configuration for the hooks. Self-contained: Node built-ins only, no npm,
2
- // so it works when the plugin is installed on its own.
3
- //
4
- // This is the READER the gates use at runtime. The CLI (`cli/config.mjs`) is the WRITER;
5
- // the two never import each other. Both agree on the file: `<project root>/.ai/config.json`,
6
- // with `~/.claude/claude-gates/config.json` as the global fallback.
7
- //
8
- // ── Two things a project controls per gate ──────────────────────────────────────────
9
- // 1. enabled — is the gate on? A gate absent from config keeps the registry default.
10
- // 2. params — how the gate behaves: its whitelist, its patterns, its watched paths.
11
- // A param the project declares REPLACES the gate's built-in default wholesale (it
12
- // does not merge). The gate ships its defaults in its own source so the user can
13
- // read them and know exactly what to override.
14
- //
15
- // ── Config shape a project may write ────────────────────────────────────────────────
16
- // "gates": {
17
- // "blockDestructiveShellCommands": true, // shorthand: enabled only
18
- // "blockWritesToProtectedPaths": { "enabled": true, "protectedPaths": ["...","..."] }
19
- // }
20
- // A bare boolean is the enabled-only shorthand the CLI writes today; an object carries
21
- // enabled plus any params. Both are accepted so an old plain config keeps working.
22
-
23
- import { existsSync, readFileSync } from 'node:fs';
24
- import { homedir } from 'node:os';
25
- import { dirname, join } from 'node:path';
26
-
27
- const CLAUDE_USER_DIRECTORY = '.claude';
28
- const GLOBAL_STATE_DIRECTORY = 'claude-gates';
29
- const PROJECT_STATE_DIRECTORY = '.ai';
30
- const CONFIG_FILE = 'config.json';
31
- // Markers that identify a project root while climbing. Both `.git` AND `.ai/` count — the
32
- // same set the CLI uses — so a repo-less project (no git yet) still gets its project config
33
- // read. Anchoring on `.git` alone silently dropped the project layer in such repos.
34
- const PROJECT_ROOT_MARKERS = ['.git', PROJECT_STATE_DIRECTORY];
35
-
36
- function readJsonOrNull(path) {
37
- if (!existsSync(path)) return null;
38
- try {
39
- return JSON.parse(readFileSync(path, 'utf8'));
40
- } catch {
41
- // A corrupt project config must not silently disable protection: treat it as absent,
42
- // which falls through to the global config and then to the registry defaults.
43
- return null;
44
- }
45
- }
46
-
47
- /** Climbs to the nearest project root (a dir holding `.git` or `.ai/`); null when none. */
48
- function projectRootOf(startDirectory) {
49
- let current = startDirectory;
50
- while (true) {
51
- if (
52
- PROJECT_ROOT_MARKERS.some((marker) => existsSync(join(current, marker)))
53
- ) {
54
- return current;
55
- }
56
- const parent = dirname(current);
57
- if (parent === current) return null;
58
- current = parent;
59
- }
60
- }
61
-
62
- function projectConfigPath(startDirectory) {
63
- const root = projectRootOf(startDirectory);
64
- if (!root) return null;
65
- return join(root, PROJECT_STATE_DIRECTORY, CONFIG_FILE);
66
- }
67
-
68
- function globalConfigPath(home) {
69
- return join(home, CLAUDE_USER_DIRECTORY, GLOBAL_STATE_DIRECTORY, CONFIG_FILE);
70
- }
71
-
72
- /**
73
- * The `gates` object from the first config that exists: project first, then global.
74
- * A project config that exists but lacks a `gates` key still wins (an empty object) —
75
- * declaring a config is a deliberate act, and falling through to global would silently
76
- * re-enable what the project meant to turn off.
77
- */
78
- function gatesLayerFor(startDirectory, home) {
79
- const projectPath = projectConfigPath(startDirectory);
80
- const projectData = projectPath ? readJsonOrNull(projectPath) : null;
81
- if (projectData) return projectData.gates ?? {};
82
-
83
- const globalData = readJsonOrNull(globalConfigPath(home));
84
- if (globalData) return globalData.gates ?? {};
85
-
86
- return null; // nothing declared anywhere: gates fall back to registry defaults
87
- }
88
-
89
- /** Normalizes a gate's config entry (bool shorthand or object) to a plain object. */
90
- function gateEntryOf(gatesLayer, configKey) {
91
- if (!gatesLayer) return null;
92
- const entry = gatesLayer[configKey];
93
- if (entry === undefined) return null;
94
- if (typeof entry === 'boolean') return { enabled: entry };
95
- if (entry && typeof entry === 'object') return entry;
96
- return null; // malformed (string/number/null): ignore, fall back to default
97
- }
98
-
99
- /**
100
- * Reads the gates layer once per dispatcher invocation and hands back a lookup the
101
- * dispatcher and gates share. Reading once matters: N gates in the same tool call must
102
- * not each re-read and re-parse the file.
103
- *
104
- * @param {string} startDirectory usually process.cwd()
105
- * @returns {{ isEnabled(configKey, registryDefault): boolean,
106
- * paramsFor(configKey): object }}
107
- */
108
- export function loadGateConfig(startDirectory, { home = homedir() } = {}) {
109
- const gatesLayer = gatesLayerFor(startDirectory, home);
110
-
111
- return {
112
- /**
113
- * A gate runs unless a config explicitly turns it off. `enabled` absent means "use
114
- * the registry default"; only an explicit `false` disables. When no config exists
115
- * anywhere, the registry default decides.
116
- */
117
- isEnabled(configKey, registryDefault) {
118
- const entry = gateEntryOf(gatesLayer, configKey);
119
- if (!entry || entry.enabled === undefined) return registryDefault;
120
- return entry.enabled !== false;
121
- },
122
-
123
- /**
124
- * The project's params for a gate (everything except `enabled`), or an empty object
125
- * when none are declared. The gate merges these over its own built-in defaults —
126
- * a declared param replaces the corresponding default wholesale.
127
- */
128
- paramsFor(configKey) {
129
- const entry = gateEntryOf(gatesLayer, configKey);
130
- if (!entry) return {};
131
- const parameters = { ...entry };
132
- delete parameters.enabled;
133
- return parameters;
134
- },
135
- };
136
- }
137
-
138
- /**
139
- * Whether a gate should run, standalone. `registryDefault` decides when the project (and
140
- * global) config is silent about this gate; only an explicit `enabled: false` turns a gate
141
- * off. Used by `runGate` at each gate's start.
142
- */
143
- export function isGateEnabled(
144
- configKey,
145
- registryDefault,
146
- startDirectory,
147
- { home = homedir() } = {},
148
- ) {
149
- return loadGateConfig(startDirectory, { home }).isEnabled(
150
- configKey,
151
- registryDefault,
152
- );
153
- }
154
-
155
- /**
156
- * A single gate's project params (its whitelist/patterns), standalone. Returns {} when
157
- * nothing is declared, so the gate falls back to its own built-in defaults.
158
- */
159
- export function gateParameters(
160
- configKey,
161
- startDirectory,
162
- { home = homedir() } = {},
163
- ) {
164
- return loadGateConfig(startDirectory, { home }).paramsFor(configKey);
165
- }
1
+ // Runtime gate configuration for the hooks. Self-contained: Node built-ins only, no npm,
2
+ // so it works when the plugin is installed on its own.
3
+ //
4
+ // This is the READER the gates use at runtime. The CLI (`cli/config.mjs`) is the WRITER;
5
+ // the two never import each other. Both agree on the file: `<project root>/.ai/config.json`,
6
+ // with `~/.claude/claude-gates/config.json` as the global fallback.
7
+ //
8
+ // ── Two things a project controls per gate ──────────────────────────────────────────
9
+ // 1. enabled — is the gate on? A gate absent from config keeps the registry default.
10
+ // 2. params — how the gate behaves: its whitelist, its patterns, its watched paths.
11
+ // A param the project declares REPLACES the gate's built-in default wholesale (it
12
+ // does not merge). The gate ships its defaults in its own source so the user can
13
+ // read them and know exactly what to override.
14
+ //
15
+ // ── Config shape a project may write ────────────────────────────────────────────────
16
+ // "gates": {
17
+ // "blockDestructiveShellCommands": true, // shorthand: enabled only
18
+ // "blockWritesToProtectedPaths": { "enabled": true, "protectedPaths": ["...","..."] }
19
+ // }
20
+ // A bare boolean is the enabled-only shorthand the CLI writes today; an object carries
21
+ // enabled plus any params. Both are accepted so an old plain config keeps working.
22
+
23
+ import { existsSync, readFileSync } from 'node:fs';
24
+ import { homedir } from 'node:os';
25
+ import { dirname, join } from 'node:path';
26
+
27
+ const CLAUDE_USER_DIRECTORY = '.claude';
28
+ const GLOBAL_STATE_DIRECTORY = 'claude-gates';
29
+ const PROJECT_STATE_DIRECTORY = '.ai';
30
+ const CONFIG_FILE = 'config.json';
31
+ // Markers that identify a project root while climbing. Both `.git` AND `.ai/` count — the
32
+ // same set the CLI uses — so a repo-less project (no git yet) still gets its project config
33
+ // read. Anchoring on `.git` alone silently dropped the project layer in such repos.
34
+ const PROJECT_ROOT_MARKERS = ['.git', PROJECT_STATE_DIRECTORY];
35
+
36
+ function readJsonOrNull(path) {
37
+ if (!existsSync(path)) return null;
38
+ try {
39
+ // Strip a UTF-8 BOM () before parsing. `readFileSync(path,'utf8')` does NOT remove
40
+ // it, and JSON.parse throws on a leading BOM — so a config saved by a Windows editor or by
41
+ // PowerShell 5.1's `Set-Content -Encoding utf8` (which prepends a BOM) would be read as
42
+ // unparseable, treated as absent, and SILENTLY DROP every gate disable in it. That is the
43
+ // opposite of safe: it re-enables protections the project turned off, and (worse for the
44
+ // symmetric case) means a project can't be trusted to have been read at all. Removing the
45
+ // BOM makes the common Windows round-trip parse correctly.
46
+ return JSON.parse(readFileSync(path, 'utf8').replace(/^/, ''));
47
+ } catch {
48
+ // A genuinely corrupt project config must not silently disable protection: treat it as
49
+ // absent, which falls through to the global config and then to the registry defaults.
50
+ return null;
51
+ }
52
+ }
53
+
54
+ /** Climbs to the nearest project root (a dir holding `.git` or `.ai/`); null when none. */
55
+ function projectRootOf(startDirectory) {
56
+ let current = startDirectory;
57
+ while (true) {
58
+ if (
59
+ PROJECT_ROOT_MARKERS.some((marker) => existsSync(join(current, marker)))
60
+ ) {
61
+ return current;
62
+ }
63
+ const parent = dirname(current);
64
+ if (parent === current) return null;
65
+ current = parent;
66
+ }
67
+ }
68
+
69
+ function projectConfigPath(startDirectory) {
70
+ const root = projectRootOf(startDirectory);
71
+ if (!root) return null;
72
+ return join(root, PROJECT_STATE_DIRECTORY, CONFIG_FILE);
73
+ }
74
+
75
+ function globalConfigPath(home) {
76
+ return join(home, CLAUDE_USER_DIRECTORY, GLOBAL_STATE_DIRECTORY, CONFIG_FILE);
77
+ }
78
+
79
+ /**
80
+ * The `gates` object from the first config that exists: project first, then global.
81
+ * A project config that exists but lacks a `gates` key still wins (an empty object) —
82
+ * declaring a config is a deliberate act, and falling through to global would silently
83
+ * re-enable what the project meant to turn off.
84
+ */
85
+ function gatesLayerFor(startDirectory, home) {
86
+ const projectPath = projectConfigPath(startDirectory);
87
+ const projectData = projectPath ? readJsonOrNull(projectPath) : null;
88
+ if (projectData) return projectData.gates ?? {};
89
+
90
+ const globalData = readJsonOrNull(globalConfigPath(home));
91
+ if (globalData) return globalData.gates ?? {};
92
+
93
+ return null; // nothing declared anywhere: gates fall back to registry defaults
94
+ }
95
+
96
+ /** Normalizes a gate's config entry (bool shorthand or object) to a plain object. */
97
+ function gateEntryOf(gatesLayer, configKey) {
98
+ if (!gatesLayer) return null;
99
+ const entry = gatesLayer[configKey];
100
+ if (entry === undefined) return null;
101
+ if (typeof entry === 'boolean') return { enabled: entry };
102
+ if (entry && typeof entry === 'object') return entry;
103
+ return null; // malformed (string/number/null): ignore, fall back to default
104
+ }
105
+
106
+ /**
107
+ * Reads the gates layer once per dispatcher invocation and hands back a lookup the
108
+ * dispatcher and gates share. Reading once matters: N gates in the same tool call must
109
+ * not each re-read and re-parse the file.
110
+ *
111
+ * @param {string} startDirectory usually process.cwd()
112
+ * @returns {{ isEnabled(configKey, registryDefault): boolean,
113
+ * paramsFor(configKey): object }}
114
+ */
115
+ export function loadGateConfig(startDirectory, { home = homedir() } = {}) {
116
+ const gatesLayer = gatesLayerFor(startDirectory, home);
117
+
118
+ return {
119
+ /**
120
+ * A gate runs unless a config explicitly turns it off. `enabled` absent means "use
121
+ * the registry default"; only an explicit `false` disables. When no config exists
122
+ * anywhere, the registry default decides.
123
+ */
124
+ isEnabled(configKey, registryDefault) {
125
+ const entry = gateEntryOf(gatesLayer, configKey);
126
+ if (!entry || entry.enabled === undefined) return registryDefault;
127
+ return entry.enabled !== false;
128
+ },
129
+
130
+ /**
131
+ * The project's params for a gate (everything except `enabled`), or an empty object
132
+ * when none are declared. The gate merges these over its own built-in defaults —
133
+ * a declared param replaces the corresponding default wholesale.
134
+ */
135
+ paramsFor(configKey) {
136
+ const entry = gateEntryOf(gatesLayer, configKey);
137
+ if (!entry) return {};
138
+ const parameters = { ...entry };
139
+ delete parameters.enabled;
140
+ return parameters;
141
+ },
142
+ };
143
+ }
144
+
145
+ /**
146
+ * Whether a gate should run, standalone. `registryDefault` decides when the project (and
147
+ * global) config is silent about this gate; only an explicit `enabled: false` turns a gate
148
+ * off. Used by `runGate` at each gate's start.
149
+ */
150
+ export function isGateEnabled(
151
+ configKey,
152
+ registryDefault,
153
+ startDirectory,
154
+ { home = homedir() } = {},
155
+ ) {
156
+ return loadGateConfig(startDirectory, { home }).isEnabled(
157
+ configKey,
158
+ registryDefault,
159
+ );
160
+ }
161
+
162
+ /**
163
+ * A single gate's project params (its whitelist/patterns), standalone. Returns {} when
164
+ * nothing is declared, so the gate falls back to its own built-in defaults.
165
+ */
166
+ export function gateParameters(
167
+ configKey,
168
+ startDirectory,
169
+ { home = homedir() } = {},
170
+ ) {
171
+ return loadGateConfig(startDirectory, { home }).paramsFor(configKey);
172
+ }
@@ -0,0 +1,189 @@
1
+ // Edge audit for the common scaffolding (hook-io.mjs, config.mjs) that every gate shares.
2
+ // Uses a minimal real gate built on runGate() so behavior is exercised end to end rather
3
+ // than by reading source and guessing.
4
+ //
5
+ // Findings:
6
+ // 1. Config precedence: a project config that EXISTS but omits a given gate key falls
7
+ // through to the REGISTRY DEFAULT passed by the gate (not to the global config) --
8
+ // confirmed by gatesLayerFor's early return the moment projectData is truthy
9
+ // (config.mjs line 80-81: `if (projectData) return projectData.gates ?? {};`). A
10
+ // global config with the key enabled is never consulted once a project config file
11
+ // exists at all, even if it says nothing about this particular gate.
12
+ // 2. A corrupted project config.json does NOT silently disable protection: readJsonOrNull
13
+ // returns null on parse failure, gatesLayerFor then falls through to the global config
14
+ // layer, and if that too is corrupt/absent, isGateEnabled falls back to the gate's own
15
+ // registryDefault. Verified with a gate whose registryDefault is `true` (deny-by-default)
16
+ // to prove corruption doesn't fail open when the default is protective.
17
+ // 3. toolNameOf/runGate coalescing (payload.tool_name ?? payload.name ?? '' -> '' on
18
+ // unparseable JSON) is exercised directly here against the shared helpers, independent
19
+ // of any specific gate's matcher set.
20
+
21
+ import assert from 'node:assert/strict';
22
+ import { execFileSync } from 'node:child_process';
23
+ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
24
+ import { tmpdir } from 'node:os';
25
+ import { dirname, join } from 'node:path';
26
+ import { test } from 'node:test';
27
+ import { fileURLToPath, pathToFileURL } from 'node:url';
28
+
29
+ const HERE = dirname(fileURLToPath(import.meta.url));
30
+
31
+ // A minimal probe gate: denies unconditionally whenever it runs and is enabled, with a
32
+ // registryDefault of true (protective/deny-by-default), so we can distinguish "ran and
33
+ // allowed via toolName mismatch" from "did not run because disabled".
34
+ const PROBE_GATE_SOURCE = `
35
+ import { runGate, deny } from ${JSON.stringify(pathToFileURL(join(HERE, 'hook-io.mjs')).href)};
36
+ runGate(
37
+ { id: 'probe-gate', configKey: 'probeGate', enabledByDefault: true },
38
+ ({ toolName }) => {
39
+ deny('probe-gate', 'toolName=' + JSON.stringify(toolName));
40
+ },
41
+ );
42
+ `;
43
+
44
+ function writeProbeGate(directory) {
45
+ const gatePath = join(directory, 'probe-gate.mjs');
46
+ writeFileSync(gatePath, PROBE_GATE_SOURCE);
47
+ return gatePath;
48
+ }
49
+
50
+ function runProbe(gatePath, rawInput, project) {
51
+ const out = execFileSync(process.execPath, [gatePath], {
52
+ input: rawInput,
53
+ encoding: 'utf8',
54
+ cwd: project,
55
+ env: { ...process.env, HOME: project, USERPROFILE: project },
56
+ });
57
+ return out.trim() ? JSON.parse(out.trim()) : null;
58
+ }
59
+
60
+ function isDeny(result) {
61
+ return result?.hookSpecificOutput?.permissionDecision === 'deny';
62
+ }
63
+
64
+ test('toolNameOf/runGate: unparseable JSON payload coalesces toolName to empty string, not null', () => {
65
+ const scratch = mkdtempSync(join(tmpdir(), 'hookio-edge-'));
66
+ const project = mkdtempSync(join(tmpdir(), 'hookio-proj-'));
67
+ mkdirSync(join(project, '.git'));
68
+ const gatePath = writeProbeGate(scratch);
69
+ // registryDefault=true means the probe gate is ON with no config at all; it always denies
70
+ // when it runs, and the deny message embeds the resolved toolName so we can inspect it.
71
+ const result = runProbe(gatePath, 'not json at all {{{', project);
72
+ assert.ok(isDeny(result));
73
+ assert.match(
74
+ result.hookSpecificOutput.permissionDecisionReason,
75
+ /toolName=""/,
76
+ 'confirms toolNameOf(...) ?? \'\' resolves corrupt payloads to the empty string, ' +
77
+ 'the exact value that fails to match any TOOL_GROUPS Set',
78
+ );
79
+ });
80
+
81
+ test('config precedence: project config exists but omits the gate key -> registryDefault wins, NOT global config', () => {
82
+ const project = mkdtempSync(join(tmpdir(), 'hookio-proj-'));
83
+ mkdirSync(join(project, '.git'));
84
+ mkdirSync(join(project, '.ai'));
85
+ // Project config exists, and declares gates, but never mentions 'probeGate'.
86
+ writeFileSync(
87
+ join(project, '.ai', 'config.json'),
88
+ JSON.stringify({ gates: { someOtherGate: true } }),
89
+ );
90
+ // Global config (same HOME as project, since HOME=project below) explicitly disables it.
91
+ // If precedence were "most specific explicit setting wins", global's false should apply.
92
+ // If precedence is "project file existing short-circuits global entirely", the probe's
93
+ // own registryDefault (true) should apply instead, ignoring global.
94
+ const home = project; // isolate HOME to a dir we control, distinct from project root marker use
95
+ mkdirSync(join(home, '.claude', 'claude-gates'), { recursive: true });
96
+ writeFileSync(
97
+ join(home, '.claude', 'claude-gates', 'config.json'),
98
+ JSON.stringify({ gates: { probeGate: false } }),
99
+ );
100
+ const scratch = mkdtempSync(join(tmpdir(), 'hookio-edge-'));
101
+ const gatePath = writeProbeGate(scratch);
102
+ const out = execFileSync(process.execPath, [gatePath], {
103
+ input: JSON.stringify({ tool_name: 'Write', tool_input: {} }),
104
+ encoding: 'utf8',
105
+ cwd: project,
106
+ env: { ...process.env, HOME: home, USERPROFILE: home },
107
+ });
108
+ const result = out.trim() ? JSON.parse(out.trim()) : null;
109
+ assert.ok(
110
+ isDeny(result),
111
+ 'PASA(confirmed): project config existing makes gatesLayerFor return early with {} ' +
112
+ 'for gates it does not mention, so registryDefault (true/deny) wins over the global ' +
113
+ 'config saying false -- global is never consulted once ANY project config.json exists',
114
+ );
115
+ });
116
+
117
+ test('config precedence: NO project config at all -> global config value is honored', () => {
118
+ const project = mkdtempSync(join(tmpdir(), 'hookio-proj-'));
119
+ mkdirSync(join(project, '.git'));
120
+ // No .ai/config.json in the project at all.
121
+ const home = mkdtempSync(join(tmpdir(), 'hookio-home-'));
122
+ mkdirSync(join(home, '.claude', 'claude-gates'), { recursive: true });
123
+ writeFileSync(
124
+ join(home, '.claude', 'claude-gates', 'config.json'),
125
+ JSON.stringify({ gates: { probeGate: false } }),
126
+ );
127
+ const scratch = mkdtempSync(join(tmpdir(), 'hookio-edge-'));
128
+ const gatePath = writeProbeGate(scratch);
129
+ const out = execFileSync(process.execPath, [gatePath], {
130
+ input: JSON.stringify({ tool_name: 'Write', tool_input: {} }),
131
+ encoding: 'utf8',
132
+ cwd: project,
133
+ env: { ...process.env, HOME: home, USERPROFILE: home },
134
+ });
135
+ const result = out.trim() ? JSON.parse(out.trim()) : null;
136
+ assert.equal(
137
+ result,
138
+ null,
139
+ 'control: with no project config file present at all, global config IS consulted and ' +
140
+ 'its false correctly disables the gate -- confirms the precedence bug above is ' +
141
+ 'specifically "project file exists" short-circuiting, not "global never works"',
142
+ );
143
+ });
144
+
145
+ test('corrupted project config.json does not silently disable a protective (default-on) gate', () => {
146
+ const project = mkdtempSync(join(tmpdir(), 'hookio-proj-'));
147
+ mkdirSync(join(project, '.git'));
148
+ mkdirSync(join(project, '.ai'));
149
+ writeFileSync(join(project, '.ai', 'config.json'), '{ this is not valid json ][');
150
+ const home = mkdtempSync(join(tmpdir(), 'hookio-home-'));
151
+ const scratch = mkdtempSync(join(tmpdir(), 'hookio-edge-'));
152
+ const gatePath = writeProbeGate(scratch);
153
+ const out = execFileSync(process.execPath, [gatePath], {
154
+ input: JSON.stringify({ tool_name: 'Write', tool_input: {} }),
155
+ encoding: 'utf8',
156
+ cwd: project,
157
+ env: { ...process.env, HOME: home, USERPROFILE: home },
158
+ });
159
+ const result = out.trim() ? JSON.parse(out.trim()) : null;
160
+ assert.ok(
161
+ isDeny(result),
162
+ 'FALLA(ok): corrupt project config treated as absent -> falls through to global (none) ' +
163
+ '-> registryDefault (true) -> still protected, matching config.mjs\'s documented intent',
164
+ );
165
+ });
166
+
167
+ test('corrupted GLOBAL config.json (no project config) does not silently disable a protective gate', () => {
168
+ const project = mkdtempSync(join(tmpdir(), 'hookio-proj-'));
169
+ mkdirSync(join(project, '.git'));
170
+ const home = mkdtempSync(join(tmpdir(), 'hookio-home-'));
171
+ mkdirSync(join(home, '.claude', 'claude-gates'), { recursive: true });
172
+ writeFileSync(
173
+ join(home, '.claude', 'claude-gates', 'config.json'),
174
+ '{ not valid json at all',
175
+ );
176
+ const scratch = mkdtempSync(join(tmpdir(), 'hookio-edge-'));
177
+ const gatePath = writeProbeGate(scratch);
178
+ const out = execFileSync(process.execPath, [gatePath], {
179
+ input: JSON.stringify({ tool_name: 'Write', tool_input: {} }),
180
+ encoding: 'utf8',
181
+ cwd: project,
182
+ env: { ...process.env, HOME: home, USERPROFILE: home },
183
+ });
184
+ const result = out.trim() ? JSON.parse(out.trim()) : null;
185
+ assert.ok(
186
+ isDeny(result),
187
+ 'FALLA(ok): corrupt global config also treated as absent -> registryDefault wins',
188
+ );
189
+ });