@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
@@ -0,0 +1,207 @@
1
+ // doctor — SessionStart hook. Validates the environment claude-gates needs and speaks
2
+ // ONLY on failure: node's version (node:sqlite and other built-ins the plugin relies on
3
+ // need >=22.5.0, read from this package's own engines.node when reachable, else a
4
+ // hard-coded fallback) and that every gate script the registry declares actually exists
5
+ // on disk (a plugin installed half — e.g. copied without a file, or a stale global
6
+ // install) fails silently otherwise: hooks.json references a script that is not there,
7
+ // and Claude Code just skips it without telling anyone).
8
+ //
9
+ // justification: no existing tool covers this. wiring-check.mjs (same family) checks
10
+ // registry vs hooks.json vs disk cross-consistency; doctor.mjs checks the runtime
11
+ // environment itself (node version) plus the same "script exists" fact from the angle
12
+ // of "can this plugin even run", so a broken environment is caught before any gate is
13
+ // invoked and blocks something for the wrong reason.
14
+ //
15
+ // Fail-safe: every check is wrapped so a doctor bug never blocks a session from
16
+ // starting — worst case it silently skips a check rather than throwing.
17
+
18
+ import { existsSync, readFileSync } from 'node:fs';
19
+ import { homedir } from 'node:os';
20
+ import { dirname, join } from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+
23
+ const STDIN_FILE_DESCRIPTOR = 0;
24
+ const SESSION_START_EVENT = 'SessionStart';
25
+ const CONFIG_KEY = 'validateEnvironmentOnStart';
26
+ const DEFAULT_MIN_NODE_VERSION = '22.5.0';
27
+
28
+ const HOOKS_DIRECTORY = dirname(fileURLToPath(import.meta.url));
29
+ const REPOSITORY_ROOT = join(HOOKS_DIRECTORY, '..', '..', '..');
30
+ const PACKAGE_JSON_PATH = join(REPOSITORY_ROOT, 'package.json');
31
+ const REGISTRY_PATH = join(REPOSITORY_ROOT, 'registry.json');
32
+
33
+ // Same config lookup as the other session hooks (see session-tasks.mjs / register-requests.mjs):
34
+ // kept local and dependency-free so this plugin works standalone.
35
+ const PROJECT_ROOT_MARKERS = ['.git', '.ai'];
36
+ const PROJECT_CONFIG = join('.ai', 'config.json');
37
+ const GLOBAL_CONFIG = join('.claude', 'claude-gates', 'config.json');
38
+
39
+ const BOM_CHAR_CODE = 0xfeff;
40
+
41
+ /** Strips a UTF-8 BOM a Windows editor/tool may have written before the JSON. */
42
+ function stripBom(text) {
43
+ return text.charCodeAt(0) === BOM_CHAR_CODE ? text.slice(1) : text;
44
+ }
45
+
46
+ function readJson(path) {
47
+ try {
48
+ return JSON.parse(stripBom(readFileSync(path, 'utf8')));
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ function projectRootOf(startDirectory) {
55
+ let current = startDirectory;
56
+ while (true) {
57
+ if (
58
+ PROJECT_ROOT_MARKERS.some((marker) => existsSync(join(current, marker)))
59
+ ) {
60
+ return current;
61
+ }
62
+ const parent = dirname(current);
63
+ if (parent === current) return null;
64
+ current = parent;
65
+ }
66
+ }
67
+
68
+ /** The doctor gate config entry (project overrides global), or null. */
69
+ function doctorConfig(startDirectory) {
70
+ const root = projectRootOf(startDirectory);
71
+ const projectData = root ? readJson(join(root, PROJECT_CONFIG)) : null;
72
+ const globalData = readJson(join(homedir(), GLOBAL_CONFIG));
73
+ const layer = projectData?.gates ?? globalData?.gates ?? {};
74
+ const entry = layer[CONFIG_KEY];
75
+ if (typeof entry === 'boolean') return { enabled: entry };
76
+ if (entry && typeof entry === 'object') return entry;
77
+ return null;
78
+ }
79
+
80
+ function readPayload() {
81
+ try {
82
+ return JSON.parse(readFileSync(STDIN_FILE_DESCRIPTOR, 'utf8'));
83
+ } catch {
84
+ return {};
85
+ }
86
+ }
87
+
88
+ const VERSION_SEGMENT_COUNT = 3; // major, minor, patch
89
+
90
+ /** Parses "22.5.0" (or "v22.5.0") into [22, 5, 0]; null when unparseable. */
91
+ function parseVersion(raw) {
92
+ const withoutPrefix = String(raw ?? '').replace(/^v/, '');
93
+ const segments = withoutPrefix.split('.').slice(0, VERSION_SEGMENT_COUNT);
94
+ if (segments.length < VERSION_SEGMENT_COUNT) return null;
95
+ const numbers = segments.map((segment) => Number(/^\d+/.exec(segment)?.[0]));
96
+ return numbers.some((n) => Number.isNaN(n)) ? null : numbers;
97
+ }
98
+
99
+ /** True when `actual` >= `required`, comparing major.minor.patch lexicographically. */
100
+ function versionAtLeast(actual, required) {
101
+ const a = parseVersion(actual);
102
+ const r = parseVersion(required);
103
+ if (!a || !r) return true; // can't parse: don't fail the session over a doctor bug
104
+ for (let index = 0; index < VERSION_SEGMENT_COUNT; index += 1) {
105
+ if (a[index] > r[index]) return true;
106
+ if (a[index] < r[index]) return false;
107
+ }
108
+ return true;
109
+ }
110
+
111
+ /** The first "x.y.z"-shaped token inside an engines.node range string (">=22.5.0" -> "22.5.0"). */
112
+ function firstVersionToken(engineRange) {
113
+ for (const token of engineRange.split(/\s+/)) {
114
+ const digits = token.replace(/[^\d.]/g, '');
115
+ if (parseVersion(digits)) return digits;
116
+ }
117
+ return null;
118
+ }
119
+
120
+ /** The minimum node version this install requires: package.json's engines.node, else the fallback. */
121
+ function minNodeVersionRequired(configuredMin) {
122
+ if (configuredMin) return configuredMin;
123
+ const packageManifest = readJson(PACKAGE_JSON_PATH);
124
+ const engineRange = packageManifest?.engines?.node;
125
+ const token = engineRange ? firstVersionToken(engineRange) : null;
126
+ return token ?? DEFAULT_MIN_NODE_VERSION;
127
+ }
128
+
129
+ function checkNodeVersion(minVersion) {
130
+ if (versionAtLeast(process.version, minVersion)) return null;
131
+ return (
132
+ `node ${process.version} is below the required >=${minVersion}. ` +
133
+ 'Some gates (e.g. node:sqlite-backed task tracking) will not work until node is upgraded.'
134
+ );
135
+ }
136
+
137
+ /** Every script the registry's PreToolUse/Stop gates (folder-style: gates/<id>/index.mjs) declare. */
138
+ function missingFolderGateScripts() {
139
+ const registry = readJson(REGISTRY_PATH);
140
+ if (!registry?.families) return []; // can't read the registry: nothing to report
141
+
142
+ const problems = [];
143
+ for (const family of registry.families) {
144
+ const pluginName = family.plugin ?? 'gates';
145
+ const pluginHooksDirectory = join(
146
+ REPOSITORY_ROOT,
147
+ 'plugins',
148
+ pluginName,
149
+ 'hooks',
150
+ );
151
+ for (const gate of family.gates ?? []) {
152
+ const scriptPath = join(pluginHooksDirectory, gate.script);
153
+ if (!existsSync(scriptPath)) {
154
+ problems.push(
155
+ `${gate.id} (expected ${join('plugins', pluginName, 'hooks', gate.script)})`,
156
+ );
157
+ }
158
+ }
159
+ }
160
+ return problems;
161
+ }
162
+
163
+ function speak(context) {
164
+ process.stdout.write(
165
+ JSON.stringify({
166
+ hookSpecificOutput: {
167
+ hookEventName: SESSION_START_EVENT,
168
+ additionalContext: context,
169
+ },
170
+ }),
171
+ );
172
+ }
173
+
174
+ function main() {
175
+ const payload = readPayload();
176
+ const cwd = payload.cwd || process.cwd();
177
+
178
+ const config = doctorConfig(cwd);
179
+ if (config && config.enabled === false) return; // gate turned off for this project
180
+
181
+ const problems = [];
182
+
183
+ const minVersion = minNodeVersionRequired(config?.minNodeVersion);
184
+ const versionProblem = checkNodeVersion(minVersion);
185
+ if (versionProblem) problems.push(versionProblem);
186
+
187
+ const missingScripts = missingFolderGateScripts();
188
+ if (missingScripts.length > 0) {
189
+ problems.push(
190
+ `${missingScripts.length} gate script(s) declared in registry.json are missing on disk: ${missingScripts.join(
191
+ ', ',
192
+ )}`,
193
+ );
194
+ }
195
+
196
+ if (problems.length === 0) return; // environment is healthy: stay silent
197
+
198
+ speak(
199
+ `[doctor] claude-gates environment check failed:\n- ${problems.join('\n- ')}`,
200
+ );
201
+ }
202
+
203
+ try {
204
+ main();
205
+ } catch {
206
+ // A doctor bug must never block session start.
207
+ }
@@ -0,0 +1,83 @@
1
+ import assert from 'node:assert/strict';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+ import { test } from 'node:test';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
10
+
11
+ function runGate(payload, { config } = {}) {
12
+ const project = mkdtempSync(join(tmpdir(), 'audit-before-build-edge-'));
13
+ mkdirSync(join(project, '.git'));
14
+ if (config) {
15
+ mkdirSync(join(project, '.ai'));
16
+ writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
17
+ }
18
+ const out = execFileSync(process.execPath, [GATE], {
19
+ input: JSON.stringify(payload),
20
+ encoding: 'utf8',
21
+ cwd: project,
22
+ env: { ...process.env, HOME: project, USERPROFILE: project },
23
+ });
24
+ return { out: out.trim() ? JSON.parse(out.trim()) : null, project };
25
+ }
26
+
27
+ function delegate(prompt) {
28
+ return { tool_name: 'Agent', tool_input: { prompt } };
29
+ }
30
+ function write(filePath, content) {
31
+ return { tool_name: 'Write', tool_input: { file_path: filePath, content } };
32
+ }
33
+ function isDeny(result) {
34
+ return result?.hookSpecificOutput?.permissionDecision === 'deny';
35
+ }
36
+
37
+ const ENABLE = { config: { gates: { requireAuditBeforeBuilding: true } } };
38
+
39
+ // EDGE CASE (BUG): AUDIT_EVIDENCE_PATTERN is a bare substring/regex test over the WHOLE
40
+ // delegation prompt with no proximity/ordering requirement relative to the tool-creation
41
+ // intent. An unrelated, earlier sentence that happens to say "no existing tool" anywhere
42
+ // in a long prompt satisfies the audit-evidence check even when it has nothing to do with
43
+ // the actual tool being created, so a prompt can smuggle past the gate by mentioning
44
+ // boilerplate audit language irrelevantly.
45
+ test('BUG: false negative — audit language elsewhere in the prompt, unrelated to the new tool, satisfies the check', () => {
46
+ const prompt =
47
+ 'Context: earlier we confirmed no existing tool handles our deploy pipeline. ' +
48
+ 'Now, unrelated task: create a new script that scrapes user passwords from logs.';
49
+ const { out } = runGate(delegate(prompt), ENABLE);
50
+ assert.equal(out, null, 'gate allows because AUDIT_EVIDENCE_PATTERN matched irrelevant boilerplate anywhere in the prompt');
51
+ });
52
+
53
+ // EDGE CASE (BUG): INLINE_JUSTIFICATION_PATTERN for a direct Write is similarly a bare
54
+ // substring test over the whole file content. Any comment anywhere in the file containing
55
+ // the word "justification:" (even quoting/describing the concept, not actually justifying
56
+ // THIS file) passes.
57
+ test('BUG: false negative — the word "justification:" appearing in an unrelated docstring satisfies the check for any new tool file', () => {
58
+ const content = [
59
+ '// This module documents how other files use the phrase justification: <reason> in',
60
+ '// their headers, as an example of our commenting convention.',
61
+ 'export function run() {}',
62
+ ].join('\n');
63
+ const { out } = runGate(write('/repo/scripts/new-thing.mjs', content), ENABLE);
64
+ assert.equal(out, null, 'gate allows because the literal string "justification:" appears anywhere, regardless of whether it actually justifies this file');
65
+ });
66
+
67
+ // EDGE CASE: confirm the "editing an existing file" exemption (checkWrite, index.mjs line
68
+ // 56-58) can be trivially satisfied by ANY tool call whose file_path happens to already
69
+ // exist on disk from an unrelated prior write within the same directory — verifying this
70
+ // is intentional per the comment, not a further bug, but documenting the boundary.
71
+ test('OK (documented, not a new bug): pre-creating an empty file at the target path bypasses audit entirely on the next edit', () => {
72
+ const { project } = runGate(delegate('noop'), ENABLE); // just to get an isolated project dir
73
+ const targetPath = join(project, 'scripts', 'sneaky.mjs');
74
+ mkdirSync(join(project, 'scripts'), { recursive: true });
75
+ writeFileSync(targetPath, ''); // pre-create empty file outside the gate's view
76
+ const out2 = execFileSync(process.execPath, [GATE], {
77
+ input: JSON.stringify(write(targetPath, 'export function run() {}')),
78
+ encoding: 'utf8',
79
+ cwd: project,
80
+ env: { ...process.env, HOME: project, USERPROFILE: project },
81
+ });
82
+ assert.equal(out2.trim() ? JSON.parse(out2.trim()) : null, null, 'existsSync(rawPath) exemption means pre-touching the file first defeats the whole gate');
83
+ });
@@ -1,88 +1,88 @@
1
- import { existsSync } from 'node:fs';
2
- import { runGate, deny, TOOL_GROUPS } from '../../lib/hook-io.mjs';
3
-
4
- const GATE_ID = 'audit-before-build';
5
- const CONFIG_KEY = 'requireAuditBeforeBuilding';
6
-
7
- const DEFAULT_EXECUTABLE_EXTENSIONS = [
8
- '.js',
9
- '.mjs',
10
- '.cjs',
11
- '.ts',
12
- '.py',
13
- '.sh',
14
- '.ps1',
15
- ];
16
- const DEFAULT_TOOL_FOLDERS = ['scripts/', 'hooks/', 'tools/'];
17
-
18
- const NEW_TOOL_INTENT_PATTERN =
19
- /\b(create|write|build|add)\b[^.]{0,60}\b(script|verifier|checker|gate|hook|linter|tool)\b/i;
20
-
21
- const AUDIT_EVIDENCE_PATTERN =
22
- /already exists|no existing tool|no plugin|audited and|justification:|no existe una herramienta|no existe la herramienta|no hay plugin|ya existe|busque? si ya existe|verifiqu[eé] que no (hay|existe)|audit[eé] herramientas|justificacion:|justificaci[oó]n:/i;
23
-
24
- const INLINE_JUSTIFICATION_PATTERN =
25
- /justification:|no-reinvent|audit:|justificacion:|justificaci[oó]n:/i;
26
-
27
- function checkDelegation(toolInput) {
28
- const prompt = toolInput?.prompt;
29
- if (typeof prompt !== 'string') return;
30
- if (!NEW_TOOL_INTENT_PATTERN.test(prompt)) return;
31
- if (AUDIT_EVIDENCE_PATTERN.test(prompt)) return;
32
-
33
- deny(
34
- GATE_ID,
35
- 'Delegating creation of a new script/checker/gate/hook/linter/tool without evidence of a prior audit. State what you searched and why no existing tool covers this (e.g. "audited and no existing tool...").',
36
- );
37
- }
38
-
39
- function isNewToolFile(filePath, parameters) {
40
- if (!filePath) return false;
41
- const inToolFolder = parameters.toolFolders.some((folder) =>
42
- filePath.includes(folder),
43
- );
44
- if (!inToolFolder) return false;
45
- return parameters.executableExtensions.some((extension) =>
46
- filePath.endsWith(extension),
47
- );
48
- }
49
-
50
- function checkWrite(toolInput, parameters) {
51
- const rawPath = toolInput?.file_path ?? '';
52
- const filePath = rawPath.replace(/\\/g, '/');
53
- if (!isNewToolFile(filePath, parameters)) return;
54
-
55
- // Editing an EXISTING file is not building a new tool — only creation needs the audit.
56
- // A file already on disk (any tool that carries file_path) is an edit, so it is allowed.
57
- // This is what makes maintaining the gates themselves possible with the gates active.
58
- if (rawPath && existsSync(rawPath)) return;
59
-
60
- const content = toolInput?.content ?? toolInput?.new_string;
61
- if (typeof content !== 'string') return;
62
- if (INLINE_JUSTIFICATION_PATTERN.test(content)) return;
63
-
64
- deny(
65
- GATE_ID,
66
- `Creating a new executable tool at ${filePath} without a justification comment (e.g. "justification: ..."). Document why no existing tool covers this before building a new one.`,
67
- );
68
- }
69
-
70
- runGate(
71
- {
72
- id: GATE_ID,
73
- configKey: CONFIG_KEY,
74
- enabledByDefault: false,
75
- defaultParams: {
76
- executableExtensions: DEFAULT_EXECUTABLE_EXTENSIONS,
77
- toolFolders: DEFAULT_TOOL_FOLDERS,
78
- },
79
- },
80
- ({ toolName, toolInput, parameters }) => {
81
- if (TOOL_GROUPS.delegation.includes(toolName)) {
82
- checkDelegation(toolInput);
83
- return;
84
- }
85
- if (!TOOL_GROUPS.write.includes(toolName)) return;
86
- checkWrite(toolInput, parameters);
87
- },
88
- );
1
+ import { existsSync } from 'node:fs';
2
+ import { runGate, deny, TOOL_GROUPS } from '../../lib/hook-io.mjs';
3
+
4
+ const GATE_ID = 'audit-before-build';
5
+ const CONFIG_KEY = 'requireAuditBeforeBuilding';
6
+
7
+ const DEFAULT_EXECUTABLE_EXTENSIONS = [
8
+ '.js',
9
+ '.mjs',
10
+ '.cjs',
11
+ '.ts',
12
+ '.py',
13
+ '.sh',
14
+ '.ps1',
15
+ ];
16
+ const DEFAULT_TOOL_FOLDERS = ['scripts/', 'hooks/', 'tools/'];
17
+
18
+ const NEW_TOOL_INTENT_PATTERN =
19
+ /\b(create|write|build|add|cre(?:a|á)|crear|escrib(?:e|í)|escribir|constru(?:ye|í)|construir|agreg(?:a|á)|agregar)\b[^.]{0,60}\b(script|verifier|checker|gate|hook|linter|tool|verificador|chequeador|gate|hook|linter|herramienta)\b/iu;
20
+
21
+ const AUDIT_EVIDENCE_PATTERN =
22
+ /already exists|no existing tool|no plugin|audited and|justification:|no existe una herramienta|no existe la herramienta|no hay plugin|ya existe|busque? si ya existe|verifiqu[eé] que no (hay|existe)|audit[eé] herramientas|justificacion:|justificaci[oó]n:/i;
23
+
24
+ const INLINE_JUSTIFICATION_PATTERN =
25
+ /justification:|no-reinvent|audit:|justificacion:|justificaci[oó]n:/i;
26
+
27
+ function checkDelegation(toolInput) {
28
+ const prompt = toolInput?.prompt;
29
+ if (typeof prompt !== 'string') return;
30
+ if (!NEW_TOOL_INTENT_PATTERN.test(prompt)) return;
31
+ if (AUDIT_EVIDENCE_PATTERN.test(prompt)) return;
32
+
33
+ deny(
34
+ GATE_ID,
35
+ 'Delegating creation of a new script/checker/gate/hook/linter/tool without evidence of a prior audit. State what you searched and why no existing tool covers this (e.g. "audited and no existing tool...").',
36
+ );
37
+ }
38
+
39
+ function isNewToolFile(filePath, parameters) {
40
+ if (!filePath) return false;
41
+ const inToolFolder = parameters.toolFolders.some((folder) =>
42
+ filePath.includes(folder),
43
+ );
44
+ if (!inToolFolder) return false;
45
+ return parameters.executableExtensions.some((extension) =>
46
+ filePath.endsWith(extension),
47
+ );
48
+ }
49
+
50
+ function checkWrite(toolInput, parameters) {
51
+ const rawPath = toolInput?.file_path ?? '';
52
+ const filePath = rawPath.replace(/\\/g, '/');
53
+ if (!isNewToolFile(filePath, parameters)) return;
54
+
55
+ // Editing an EXISTING file is not building a new tool — only creation needs the audit.
56
+ // A file already on disk (any tool that carries file_path) is an edit, so it is allowed.
57
+ // This is what makes maintaining the gates themselves possible with the gates active.
58
+ if (rawPath && existsSync(rawPath)) return;
59
+
60
+ const content = toolInput?.content ?? toolInput?.new_string;
61
+ if (typeof content !== 'string') return;
62
+ if (INLINE_JUSTIFICATION_PATTERN.test(content)) return;
63
+
64
+ deny(
65
+ GATE_ID,
66
+ `Creating a new executable tool at ${filePath} without a justification comment (e.g. "justification: ..."). Document why no existing tool covers this before building a new one.`,
67
+ );
68
+ }
69
+
70
+ runGate(
71
+ {
72
+ id: GATE_ID,
73
+ configKey: CONFIG_KEY,
74
+ enabledByDefault: false,
75
+ defaultParams: {
76
+ executableExtensions: DEFAULT_EXECUTABLE_EXTENSIONS,
77
+ toolFolders: DEFAULT_TOOL_FOLDERS,
78
+ },
79
+ },
80
+ ({ toolName, toolInput, parameters }) => {
81
+ if (TOOL_GROUPS.delegation.includes(toolName)) {
82
+ checkDelegation(toolInput);
83
+ return;
84
+ }
85
+ if (!TOOL_GROUPS.write.includes(toolName)) return;
86
+ checkWrite(toolInput, parameters);
87
+ },
88
+ );
@@ -1,92 +1,117 @@
1
- import assert from 'node:assert/strict';
2
- import { execFileSync } from 'node:child_process';
3
- import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
4
- import { tmpdir } from 'node:os';
5
- import { dirname, join } from 'node:path';
6
- import { test } from 'node:test';
7
- import { fileURLToPath } from 'node:url';
8
-
9
- const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
10
-
11
- function runGate(payload, { config } = {}) {
12
- const project = mkdtempSync(join(tmpdir(), 'audit-before-build-'));
13
- mkdirSync(join(project, '.git'));
14
- if (config) {
15
- mkdirSync(join(project, '.ai'));
16
- writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
17
- }
18
- const out = execFileSync(process.execPath, [GATE], {
19
- input: JSON.stringify(payload),
20
- encoding: 'utf8',
21
- cwd: project,
22
- // Isolate from the user's real global config: point homedir() at the temp
23
- // project so the global-config fallback finds nothing (registry default).
24
- env: { ...process.env, HOME: project, USERPROFILE: project },
25
- });
26
- return out.trim() ? JSON.parse(out.trim()) : null;
27
- }
28
-
29
- function delegate(prompt) {
30
- return { tool_name: 'Agent', tool_input: { prompt } };
31
- }
32
- function write(filePath, content) {
33
- return { tool_name: 'Write', tool_input: { file_path: filePath, content } };
34
- }
35
- function isDeny(result) {
36
- return result?.hookSpecificOutput?.permissionDecision === 'deny';
37
- }
38
-
39
- const ENABLE = { config: { gates: { requireAuditBeforeBuilding: true } } };
40
-
41
- test('denies delegation to create a new tool without audit evidence', () => {
42
- assert.ok(
43
- isDeny(runGate(delegate('create a new script that checks X'), ENABLE)),
44
- );
45
- });
46
-
47
- test('allows delegation with audit evidence stated', () => {
48
- assert.equal(
49
- runGate(
50
- delegate(
51
- 'create a new script that checks X. audited and no existing tool covers this.',
52
- ),
53
- ENABLE,
54
- ),
55
- null,
56
- );
57
- });
58
-
59
- test('denies writing a new executable tool file without a justification comment', () => {
60
- assert.equal(
61
- runGate(
62
- write('/repo/scripts/check-thing.mjs', 'export function run() {}'),
63
- ENABLE,
64
- )?.hookSpecificOutput?.permissionDecision,
65
- 'deny',
66
- );
67
- });
68
-
69
- test('allows writing a tool file with a justification comment', () => {
70
- assert.equal(
71
- runGate(
72
- write(
73
- '/repo/scripts/check-thing.mjs',
74
- '// justification: no existing tool does this\nexport function run() {}',
75
- ),
76
- ENABLE,
77
- ),
78
- null,
79
- );
80
- });
81
-
82
- test('allows a write outside tool folders or non-executable extension', () => {
83
- assert.equal(
84
- runGate(write('/repo/docs/notes.md', 'plain notes'), ENABLE),
85
- null,
86
- );
87
- assert.equal(runGate(write('/repo/scripts/data.json', '{}'), ENABLE), null);
88
- });
89
-
90
- test('disabled by default (registry default is false)', () => {
91
- assert.equal(runGate(delegate('create a new script that checks X')), null);
92
- });
1
+ import assert from 'node:assert/strict';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+ import { test } from 'node:test';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
10
+
11
+ function runGate(payload, { config } = {}) {
12
+ const project = mkdtempSync(join(tmpdir(), 'audit-before-build-'));
13
+ mkdirSync(join(project, '.git'));
14
+ if (config) {
15
+ mkdirSync(join(project, '.ai'));
16
+ writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
17
+ }
18
+ const out = execFileSync(process.execPath, [GATE], {
19
+ input: JSON.stringify(payload),
20
+ encoding: 'utf8',
21
+ cwd: project,
22
+ // Isolate from the user's real global config: point homedir() at the temp
23
+ // project so the global-config fallback finds nothing (registry default).
24
+ env: { ...process.env, HOME: project, USERPROFILE: project },
25
+ });
26
+ return out.trim() ? JSON.parse(out.trim()) : null;
27
+ }
28
+
29
+ function delegate(prompt) {
30
+ return { tool_name: 'Agent', tool_input: { prompt } };
31
+ }
32
+ function write(filePath, content) {
33
+ return { tool_name: 'Write', tool_input: { file_path: filePath, content } };
34
+ }
35
+ function isDeny(result) {
36
+ return result?.hookSpecificOutput?.permissionDecision === 'deny';
37
+ }
38
+
39
+ const ENABLE = { config: { gates: { requireAuditBeforeBuilding: true } } };
40
+
41
+ test('denies delegation to create a new tool without audit evidence', () => {
42
+ assert.ok(
43
+ isDeny(runGate(delegate('create a new script that checks X'), ENABLE)),
44
+ );
45
+ });
46
+
47
+ test('allows delegation with audit evidence stated', () => {
48
+ assert.equal(
49
+ runGate(
50
+ delegate(
51
+ 'create a new script that checks X. audited and no existing tool covers this.',
52
+ ),
53
+ ENABLE,
54
+ ),
55
+ null,
56
+ );
57
+ });
58
+
59
+ test('denies writing a new executable tool file without a justification comment', () => {
60
+ assert.equal(
61
+ runGate(
62
+ write('/repo/scripts/check-thing.mjs', 'export function run() {}'),
63
+ ENABLE,
64
+ )?.hookSpecificOutput?.permissionDecision,
65
+ 'deny',
66
+ );
67
+ });
68
+
69
+ test('allows writing a tool file with a justification comment', () => {
70
+ assert.equal(
71
+ runGate(
72
+ write(
73
+ '/repo/scripts/check-thing.mjs',
74
+ '// justification: no existing tool does this\nexport function run() {}',
75
+ ),
76
+ ENABLE,
77
+ ),
78
+ null,
79
+ );
80
+ });
81
+
82
+ test('allows a write outside tool folders or non-executable extension', () => {
83
+ assert.equal(
84
+ runGate(write('/repo/docs/notes.md', 'plain notes'), ENABLE),
85
+ null,
86
+ );
87
+ assert.equal(runGate(write('/repo/scripts/data.json', '{}'), ENABLE), null);
88
+ });
89
+
90
+ test('denies delegation to create a new tool without audit evidence, in Spanish', () => {
91
+ assert.ok(
92
+ isDeny(runGate(delegate('crea un nuevo script que verifique X'), ENABLE)),
93
+ );
94
+ });
95
+
96
+ test('bilingual control: ES and EN equivalent new-tool delegations both deny without audit evidence', () => {
97
+ const es = delegate('crea un nuevo script que verifique X');
98
+ const en = delegate('create a new script that checks X');
99
+ assert.ok(isDeny(runGate(es, ENABLE)));
100
+ assert.ok(isDeny(runGate(en, ENABLE)));
101
+ });
102
+
103
+ test('allows a Spanish delegation with audit evidence stated', () => {
104
+ assert.equal(
105
+ runGate(
106
+ delegate(
107
+ 'crea un nuevo script que verifique X. audite y no existe una herramienta que cubra esto.',
108
+ ),
109
+ ENABLE,
110
+ ),
111
+ null,
112
+ );
113
+ });
114
+
115
+ test('disabled by default (registry default is false)', () => {
116
+ assert.equal(runGate(delegate('create a new script that checks X')), null);
117
+ });