@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,111 +1,116 @@
1
- // root-whitelist — denies creating a new file or folder at the project's ROOT unless it
2
- // is on the declared whitelist. Migrated from ~/.claude/hooks/guard-root-whitelist.mjs.
3
- // A root grows orphaned files silently: nothing else stops a stray file from landing next
4
- // to package.json. Only the top level of the project is governed — anything nested is
5
- // this gate's business only insofar as its first path segment is the new thing at the root.
6
- //
7
- // ── What a project can configure (params) ───────────────────────────────────────────
8
- // rootFilesWhitelist root-level file names allowed to be created. Replaces the
9
- // built-in list wholesale.
10
- // rootFoldersWhitelist root-level folder names allowed to be created. Replaces the
11
- // built-in list wholesale.
12
- // The defaults live here, in the source, so a project reads them and knows exactly what
13
- // its override replaces. A dotfile/dotfolder (.gitignore, .claude) is always allowed —
14
- // dotfiles are a different, well-known convention this gate does not police.
15
-
16
- import { basename, normalize, sep } from 'node:path';
17
- import { runGate, deny, TOOL_GROUPS } from '../../lib/hook-io.mjs';
18
-
19
- const GATE_ID = 'root-whitelist';
20
- const CONFIG_KEY = 'blockPathsOutsideRootWhitelist';
21
-
22
- const WRITE_TOOLS = new Set(TOOL_GROUPS.write);
23
-
24
- const DEFAULT_ROOT_FILES_WHITELIST = [
25
- 'AGENTS.md',
26
- 'CLAUDE.md',
27
- 'README.md',
28
- 'LICENSE',
29
- 'package.json',
30
- 'package-lock.json',
31
- 'tsconfig.json',
32
- 'eslint.config.mjs',
33
- 'eslint.config.js',
34
- 'eslint.config.cjs',
35
- '.prettierrc',
36
- '.prettierrc.json',
37
- '.prettierignore',
38
- '.eslintignore',
39
- 'cspell.json',
40
- '.cspell.json',
41
- '.gitignore',
42
- '.env',
43
- '.env.example',
44
- ];
45
-
46
- const DEFAULT_ROOT_FOLDERS_WHITELIST = [
47
- 'src',
48
- 'tests',
49
- 'docs',
50
- 'scripts',
51
- 'plugins',
52
- ];
53
-
54
- /** The path a write tool targets, across the field names different tools use. */
55
- function writeTargetFrom(toolInput) {
56
- return String(
57
- toolInput.TargetFile ??
58
- toolInput.target_file ??
59
- toolInput.file_path ??
60
- toolInput.path ??
61
- '',
62
- );
63
- }
64
-
65
- runGate(
66
- {
67
- id: GATE_ID,
68
- configKey: CONFIG_KEY,
69
- enabledByDefault: true,
70
- defaultParams: {
71
- rootFilesWhitelist: DEFAULT_ROOT_FILES_WHITELIST,
72
- rootFoldersWhitelist: DEFAULT_ROOT_FOLDERS_WHITELIST,
73
- },
74
- },
75
- ({ toolName, toolInput, parameters }) => {
76
- if (!WRITE_TOOLS.has(toolName)) return;
77
-
78
- const target = writeTargetFrom(toolInput);
79
- if (!target) return;
80
-
81
- const normalized = normalize(target);
82
- const projectRoot = process.cwd() + sep;
83
-
84
- // The whitelist describes the project's ROOT. A file outside the project (session
85
- // scratchpad, system temp, another repo) is not at its root, and this rule does not
86
- // govern it: letting it through is correct, not an exception.
87
- if (!normalized.startsWith(projectRoot)) return;
88
-
89
- const relativePath = normalized.slice(projectRoot.length);
90
- const filesWhitelist = new Set(parameters.rootFilesWhitelist ?? []);
91
- const foldersWhitelist = new Set(parameters.rootFoldersWhitelist ?? []);
92
-
93
- if (!relativePath.includes(sep)) {
94
- const fileName = basename(relativePath);
95
- if (fileName.startsWith('.') || filesWhitelist.has(fileName)) return;
96
- deny(
97
- GATE_ID,
98
- `'${fileName}' at the project root is not on the whitelist (${[...filesWhitelist].join(', ')}).`,
99
- );
100
- return;
101
- }
102
-
103
- const topDirectory = relativePath.split(sep)[0];
104
- if (topDirectory.startsWith('.') || foldersWhitelist.has(topDirectory))
105
- return;
106
- deny(
107
- GATE_ID,
108
- `Folder '${topDirectory}' at the project root is not on the whitelist (${[...foldersWhitelist].join(', ')}).`,
109
- );
110
- },
111
- );
1
+ // root-whitelist — denies creating a new file or folder at the project's ROOT unless it
2
+ // is on the declared whitelist. Migrated from ~/.claude/hooks/guard-root-whitelist.mjs.
3
+ // A root grows orphaned files silently: nothing else stops a stray file from landing next
4
+ // to package.json. Only the top level of the project is governed — anything nested is
5
+ // this gate's business only insofar as its first path segment is the new thing at the root.
6
+ //
7
+ // ── What a project can configure (params) ───────────────────────────────────────────
8
+ // rootFilesWhitelist root-level file names allowed to be created. Replaces the
9
+ // built-in list wholesale.
10
+ // rootFoldersWhitelist root-level folder names allowed to be created. Replaces the
11
+ // built-in list wholesale.
12
+ // The defaults live here, in the source, so a project reads them and knows exactly what
13
+ // its override replaces. A dotfile/dotfolder (.gitignore, .claude) is always allowed —
14
+ // dotfiles are a different, well-known convention this gate does not police.
15
+
16
+ import { basename, isAbsolute, resolve, sep } from 'node:path';
17
+ import { runGate, deny, toolInGroups, writtenPathOf } from '../../lib/hook-io.mjs';
18
+
19
+ const GATE_ID = 'root-whitelist';
20
+ const CONFIG_KEY = 'blockPathsOutsideRootWhitelist';
21
+
22
+ const DEFAULT_ROOT_FILES_WHITELIST = [
23
+ 'AGENTS.md',
24
+ 'CLAUDE.md',
25
+ 'README.md',
26
+ 'LICENSE',
27
+ 'package.json',
28
+ 'package-lock.json',
29
+ 'tsconfig.json',
30
+ 'eslint.config.mjs',
31
+ 'eslint.config.js',
32
+ 'eslint.config.cjs',
33
+ '.prettierrc',
34
+ '.prettierrc.json',
35
+ '.prettierignore',
36
+ '.eslintignore',
37
+ 'cspell.json',
38
+ '.cspell.json',
39
+ '.gitignore',
40
+ '.env',
41
+ '.env.example',
42
+ ];
43
+
44
+ const DEFAULT_ROOT_FOLDERS_WHITELIST = [
45
+ 'src',
46
+ 'tests',
47
+ 'docs',
48
+ 'scripts',
49
+ 'plugins',
50
+ ];
51
+
52
+ runGate(
53
+ {
54
+ id: GATE_ID,
55
+ configKey: CONFIG_KEY,
56
+ enabledByDefault: true,
57
+ defaultParams: {
58
+ rootFilesWhitelist: DEFAULT_ROOT_FILES_WHITELIST,
59
+ rootFoldersWhitelist: DEFAULT_ROOT_FOLDERS_WHITELIST,
60
+ },
61
+ },
62
+ ({ toolName, toolInput, parameters }) => {
63
+ if (!toolInGroups(toolName, ['write'])) return;
64
+
65
+ const target = writtenPathOf(toolInput);
66
+ if (!target) return;
67
+
68
+ // Some tools send a path relative to cwd rather than absolute. resolve() leaves an
69
+ // already-absolute path merely normalized ('..' collapsed, separators fixed), and
70
+ // resolves a relative one against process.cwd() — so either shape lands on the same
71
+ // absolute path the root check below expects.
72
+ const normalized = isAbsolute(target)
73
+ ? resolve(target)
74
+ : resolve(process.cwd(), target);
75
+ const projectRoot = process.cwd() + sep;
76
+
77
+ // The whitelist describes the project's ROOT. A file outside the project (session
78
+ // scratchpad, system temp, another repo) is not at its root, and this rule does not
79
+ // govern it: letting it through is correct, not an exception. This check runs AFTER
80
+ // resolving relative paths, so a relative path is judged by where it actually lands.
81
+ if (!normalized.startsWith(projectRoot)) return;
82
+
83
+ const relativePath = normalized.slice(projectRoot.length);
84
+ // Windows filesystems are case-insensitive: 'Package.json' and 'package.json' are the
85
+ // same file. Comparing lowercase on both sides avoids denying a whitelisted name that
86
+ // merely differs in case (a false positive, not a real gap).
87
+ const filesWhitelist = new Set(
88
+ (parameters.rootFilesWhitelist ?? []).map((name) => name.toLowerCase()),
89
+ );
90
+ const foldersWhitelist = new Set(
91
+ (parameters.rootFoldersWhitelist ?? []).map((name) => name.toLowerCase()),
92
+ );
93
+
94
+ if (!relativePath.includes(sep)) {
95
+ const fileName = basename(relativePath);
96
+ if (fileName.startsWith('.') || filesWhitelist.has(fileName.toLowerCase()))
97
+ return;
98
+ deny(
99
+ GATE_ID,
100
+ `'${fileName}' at the project root is not on the whitelist (${[...filesWhitelist].join(', ')}).`,
101
+ );
102
+ return;
103
+ }
104
+
105
+ const topDirectory = relativePath.split(sep)[0];
106
+ if (
107
+ topDirectory.startsWith('.') ||
108
+ foldersWhitelist.has(topDirectory.toLowerCase())
109
+ )
110
+ return;
111
+ deny(
112
+ GATE_ID,
113
+ `Folder '${topDirectory}' at the project root is not on the whitelist (${[...foldersWhitelist].join(', ')}).`,
114
+ );
115
+ },
116
+ );
@@ -0,0 +1,97 @@
1
+ // Edge-case audit for root-whitelist: bypasses and false positives.
2
+ import assert from 'node:assert/strict';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { dirname, join, sep } from 'node:path';
7
+ import { test } from 'node:test';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
11
+
12
+ function project({ config } = {}) {
13
+ const root = mkdtempSync(join(tmpdir(), 'root-whitelist-edge-'));
14
+ mkdirSync(join(root, '.git'));
15
+ if (config) {
16
+ mkdirSync(join(root, '.ai'));
17
+ writeFileSync(join(root, '.ai', 'config.json'), JSON.stringify(config));
18
+ }
19
+ return {
20
+ root,
21
+ run(payload) {
22
+ const out = execFileSync(process.execPath, [GATE], {
23
+ input: JSON.stringify(payload),
24
+ encoding: 'utf8',
25
+ cwd: root,
26
+ env: { ...process.env, HOME: root, USERPROFILE: root },
27
+ });
28
+ return out.trim() ? JSON.parse(out.trim()) : null;
29
+ },
30
+ };
31
+ }
32
+
33
+ function write(filePath) {
34
+ return { tool_name: 'Write', tool_input: { file_path: filePath } };
35
+ }
36
+ function mcpTool(name, input) {
37
+ return { tool_name: name, tool_input: input };
38
+ }
39
+ function isDeny(result) {
40
+ return result?.hookSpecificOutput?.permissionDecision === 'deny';
41
+ }
42
+
43
+ // ── FIXED: toolInGroups classifies an MCP tool by its action segment, so an MCP write
44
+ // tool creating an orphan root file is now recognized.
45
+ test('FIXED: mcp filesystem write_file creating an orphan root file is now recognized', () => {
46
+ const { root, run } = project();
47
+ const result = run(
48
+ mcpTool('mcp__filesystem__write_file', {
49
+ path: join(root, 'orphan-mcp.txt'),
50
+ content: 'x',
51
+ }),
52
+ );
53
+ assert.ok(isDeny(result));
54
+ });
55
+
56
+ // ── FIXED: a relative (non-absolute) file_path is now resolved against process.cwd()
57
+ // (the test runs the gate with cwd = root) before the root-prefix check, so it is judged
58
+ // by where it actually lands instead of skipping the check entirely.
59
+ test('FIXED: a relative file_path targeting the root is now caught by resolving it against cwd', () => {
60
+ const { run } = project();
61
+ const result = run(write('orphan-relative.txt'));
62
+ assert.ok(isDeny(result));
63
+ });
64
+
65
+ // ── BUG candidate: path traversal via ../ that resolves back into the root from a
66
+ // nested absolute path, e.g. '<root>/src/../orphan.txt'. normalize() collapses '..'
67
+ // segments, so this SHOULD reduce to '<root>/orphan.txt' and be caught. Confirm.
68
+ test('OK: a traversal path that normalizes back to the root is still caught', () => {
69
+ const { root, run } = project();
70
+ const result = run(write(join(root, 'src', '..', 'orphan-traversal.txt')));
71
+ assert.ok(isDeny(result));
72
+ });
73
+
74
+ // ── BUG candidate: a whitelisted root FOLDER name used as a FILE at the root, e.g.
75
+ // creating a file literally named 'src' (no extension) at the root. relativePath has no
76
+ // sep, so it goes through the FILE branch and checks filesWhitelist, not
77
+ // foldersWhitelist — 'src' is not in rootFilesWhitelist, so this is correctly denied,
78
+ // not a bug. Confirm.
79
+ test('OK: a root-level file named identically to a whitelisted folder is still denied under the file whitelist', () => {
80
+ const { root, run } = project();
81
+ const result = run(write(join(root, 'src')));
82
+ assert.ok(isDeny(result));
83
+ });
84
+
85
+ // ── FIXED: case-sensitivity. rootFilesWhitelist/rootFoldersWhitelist membership is now
86
+ // compared lowercase on both sides, so 'Package.json' is recognized as the same entry as
87
+ // the whitelisted 'package.json' — no more false positive on case-insensitive filesystems.
88
+ test('FIXED: a root file matching the whitelist except for letter case is now allowed', () => {
89
+ const { root, run } = project();
90
+ const result = run(write(join(root, 'Package.json')));
91
+ assert.equal(result, null);
92
+ });
93
+
94
+ // ── BUG candidate: an MCP "write" tool with a `path` field only (not `file_path`,
95
+ // `TargetFile`, or `target_file`) — writeTargetFrom does not recognize `path` alone
96
+ // unless the tool is also in WRITE_TOOLS. Since MCP tools already fail the toolName
97
+ // check, this compounds the same root bug; not a separate one worth a distinct test.
@@ -1,99 +1,184 @@
1
- import { execFileSync } from 'node:child_process';
2
- import { existsSync, readdirSync } from 'node:fs';
3
- import { join, relative } from 'node:path';
4
- import { runGate, deny, TOOL_GROUPS } from '../../lib/hook-io.mjs';
5
-
6
- const GATE_ID = 'rule-skill-autodiscovery';
7
- const CONFIG_KEY = 'autodiscoverRulesAndSkills';
8
-
9
- const DEFAULT_RULES_DIR = 'rules';
10
- const DEFAULT_GATE_FILE_NAMES = [
11
- 'gate.mjs',
12
- 'rules.mjs',
13
- 'check.mjs',
14
- 'verify.mjs',
15
- ];
16
-
17
- function isGateFile(fileName, gateFileNames) {
18
- return gateFileNames.includes(fileName) || fileName.endsWith('.gate.mjs');
19
- }
20
-
21
- function findScriptsIn(scriptsDirectory, gateFileNames) {
22
- if (!existsSync(scriptsDirectory)) return [];
23
- let entries;
24
- try {
25
- entries = readdirSync(scriptsDirectory, { withFileTypes: true });
26
- } catch {
27
- return [];
28
- }
29
- return entries
30
- .filter((entry) => entry.isFile())
31
- .filter((entry) => /\.(mjs|js)$/.test(entry.name))
32
- .filter((entry) => isGateFile(entry.name, gateFileNames))
33
- .map((entry) => join(scriptsDirectory, entry.name));
34
- }
35
-
36
- function discoverScripts(projectRoot, rulesDirectoryName, gateFileNames) {
37
- const scripts = [
38
- ...findScriptsIn(join(projectRoot, rulesDirectoryName), gateFileNames),
39
- ];
40
-
41
- const skillsRoot = join(projectRoot, '.claude', 'skills');
42
- if (existsSync(skillsRoot)) {
43
- let skillDirectories;
44
- try {
45
- skillDirectories = readdirSync(skillsRoot, {
46
- withFileTypes: true,
47
- }).filter((entry) => entry.isDirectory());
48
- } catch {
49
- skillDirectories = [];
50
- }
51
- for (const skillDirectory of skillDirectories) {
52
- scripts.push(
53
- ...findScriptsIn(join(skillsRoot, skillDirectory.name), gateFileNames),
54
- );
55
- }
56
- }
57
-
58
- return scripts;
59
- }
60
-
61
- runGate(
62
- {
63
- id: GATE_ID,
64
- configKey: CONFIG_KEY,
65
- enabledByDefault: false,
66
- defaultParams: {
67
- rulesDir: DEFAULT_RULES_DIR,
68
- gateFileNames: DEFAULT_GATE_FILE_NAMES,
69
- },
70
- },
71
- ({ toolName, parameters }) => {
72
- const isExecution = TOOL_GROUPS.execution.includes(toolName);
73
- const isDelegation = TOOL_GROUPS.delegation.includes(toolName);
74
- if (!isExecution && !isDelegation) return;
75
-
76
- const projectRoot = process.cwd();
77
- const scripts = discoverScripts(
78
- projectRoot,
79
- parameters.rulesDir,
80
- parameters.gateFileNames,
81
- );
82
- if (scripts.length === 0) return;
83
-
84
- for (const script of scripts) {
85
- try {
86
- execFileSync(process.execPath, [script], {
87
- stdio: 'ignore',
88
- timeout: 5000,
89
- });
90
- } catch {
91
- deny(
92
- GATE_ID,
93
- `Sub-gate failed: ${relative(projectRoot, script)}. Fix it before continuing.`,
94
- );
95
- return;
96
- }
97
- }
98
- },
99
- );
1
+ import { execFileSync } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import {
4
+ existsSync,
5
+ readdirSync,
6
+ readFileSync,
7
+ writeFileSync,
8
+ unlinkSync,
9
+ } from 'node:fs';
10
+ import { homedir } from 'node:os';
11
+ import { join, relative } from 'node:path';
12
+ import { runGate, deny, toolInGroups } from '../../lib/hook-io.mjs';
13
+
14
+ const GATE_ID = 'rule-skill-autodiscovery';
15
+ const CONFIG_KEY = 'autodiscoverRulesAndSkills';
16
+
17
+ const DEFAULT_RULES_DIR = 'rules';
18
+ const DEFAULT_GATE_FILE_NAMES = [
19
+ 'gate.mjs',
20
+ 'rules.mjs',
21
+ 'check.mjs',
22
+ 'verify.mjs',
23
+ ];
24
+
25
+ function isGateFile(fileName, gateFileNames) {
26
+ return gateFileNames.includes(fileName) || fileName.endsWith('.gate.mjs');
27
+ }
28
+
29
+ function findScriptsIn(scriptsDirectory, gateFileNames) {
30
+ if (!existsSync(scriptsDirectory)) return [];
31
+ let entries;
32
+ try {
33
+ entries = readdirSync(scriptsDirectory, { withFileTypes: true });
34
+ } catch {
35
+ return [];
36
+ }
37
+ return entries
38
+ .filter((entry) => entry.isFile())
39
+ .filter((entry) => /\.(mjs|js)$/.test(entry.name))
40
+ .filter((entry) => isGateFile(entry.name, gateFileNames))
41
+ .map((entry) => join(scriptsDirectory, entry.name));
42
+ }
43
+
44
+ function discoverScripts(projectRoot, rulesDirectoryName, gateFileNames) {
45
+ const scripts = [
46
+ ...findScriptsIn(join(projectRoot, rulesDirectoryName), gateFileNames),
47
+ ];
48
+
49
+ const skillsRoot = join(projectRoot, '.claude', 'skills');
50
+ if (existsSync(skillsRoot)) {
51
+ let skillDirectories;
52
+ try {
53
+ skillDirectories = readdirSync(skillsRoot, {
54
+ withFileTypes: true,
55
+ }).filter((entry) => entry.isDirectory());
56
+ } catch {
57
+ skillDirectories = [];
58
+ }
59
+ for (const skillDirectory of skillDirectories) {
60
+ scripts.push(
61
+ ...findScriptsIn(join(skillsRoot, skillDirectory.name), gateFileNames),
62
+ );
63
+ }
64
+ }
65
+
66
+ return scripts;
67
+ }
68
+
69
+ // ── Security-surface protection ─────────────────────────────────────────────────────
70
+ // A discovered script runs with full Node privileges (no sandbox is realistically
71
+ // enforceable via execFileSync alone). The one privilege it must NEVER have is the
72
+ // power to mutate the gate configuration that governs whether it (or any other gate)
73
+ // runs at all — otherwise a discovered script can disable e.g.
74
+ // `blockDestructiveShellCommands` in the same tool call it was meant to be gated by,
75
+ // before any later gate could react. This is enforced deterministically: snapshot a
76
+ // hash of both config files (project + global) before executing, and after every
77
+ // script runs, compare. Any change is reverted immediately and the call is denied —
78
+ // regardless of whether the script itself exited 0.
79
+ const PROJECT_CONFIG_RELATIVE_PATH = join('.ai', 'config.json');
80
+
81
+ function globalConfigPath() {
82
+ return join(homedir(), '.claude', 'claude-gates', 'config.json');
83
+ }
84
+
85
+ function securityConfigPaths(projectRoot) {
86
+ return [
87
+ join(projectRoot, PROJECT_CONFIG_RELATIVE_PATH),
88
+ globalConfigPath(),
89
+ ];
90
+ }
91
+
92
+ /** Reads raw bytes (or null if absent) and their hash, per watched config path. */
93
+ function snapshotConfigs(paths) {
94
+ return paths.map((path) => {
95
+ let content = null;
96
+ try {
97
+ if (existsSync(path)) content = readFileSync(path);
98
+ } catch {
99
+ content = null;
100
+ }
101
+ const hash = content
102
+ ? createHash('sha256').update(content).digest('hex')
103
+ : null;
104
+ return { path, content, hash };
105
+ });
106
+ }
107
+
108
+ /** Restores every watched config file to its pre-execution content, best-effort. */
109
+ function revertConfigs(snapshots) {
110
+ for (const snapshot of snapshots) {
111
+ try {
112
+ if (snapshot.content === null) {
113
+ if (existsSync(snapshot.path)) unlinkSync(snapshot.path);
114
+ } else {
115
+ writeFileSync(snapshot.path, snapshot.content);
116
+ }
117
+ } catch {
118
+ // Best-effort revert: if this fails there is nothing more this gate can do
119
+ // beyond having already denied the call.
120
+ }
121
+ }
122
+ }
123
+
124
+ /** Whether any watched config file's content changed since `before`. */
125
+ function configsTampered(before, projectRoot) {
126
+ const after = snapshotConfigs(before.map((snapshot) => snapshot.path));
127
+ return before.some((snapshot, index) => snapshot.hash !== after[index].hash);
128
+ }
129
+
130
+ runGate(
131
+ {
132
+ id: GATE_ID,
133
+ configKey: CONFIG_KEY,
134
+ enabledByDefault: false,
135
+ defaultParams: {
136
+ rulesDir: DEFAULT_RULES_DIR,
137
+ gateFileNames: DEFAULT_GATE_FILE_NAMES,
138
+ },
139
+ },
140
+ ({ toolName, parameters }) => {
141
+ if (!toolInGroups(toolName, ['execution', 'delegation'])) return;
142
+
143
+ const projectRoot = process.cwd();
144
+ const scripts = discoverScripts(
145
+ projectRoot,
146
+ parameters.rulesDir,
147
+ parameters.gateFileNames,
148
+ );
149
+ if (scripts.length === 0) return;
150
+
151
+ const watchedConfigPaths = securityConfigPaths(projectRoot);
152
+
153
+ for (const script of scripts) {
154
+ const before = snapshotConfigs(watchedConfigPaths);
155
+
156
+ let failed = false;
157
+ try {
158
+ execFileSync(process.execPath, [script], {
159
+ stdio: 'ignore',
160
+ timeout: 5000,
161
+ });
162
+ } catch {
163
+ failed = true;
164
+ }
165
+
166
+ if (configsTampered(before, projectRoot)) {
167
+ revertConfigs(before);
168
+ deny(
169
+ GATE_ID,
170
+ `Discovered rule/skill script ${relative(projectRoot, script)} attempted to modify gate security configuration (.ai/config.json or the global config). The change was reverted and the action is blocked.`,
171
+ );
172
+ return;
173
+ }
174
+
175
+ if (failed) {
176
+ deny(
177
+ GATE_ID,
178
+ `Sub-gate failed: ${relative(projectRoot, script)}. Fix it before continuing.`,
179
+ );
180
+ return;
181
+ }
182
+ }
183
+ },
184
+ );