@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.
- package/.claude-plugin/marketplace.json +7 -1
- package/cli/__tests__/config.test.mjs +113 -101
- package/cli/__tests__/install.test.mjs +102 -0
- package/cli/__tests__/materialize.test.mjs +95 -0
- package/cli/__tests__/registry-gates-consistency.test.mjs +52 -44
- package/cli/__tests__/task.test.mjs +124 -0
- package/cli/__tests__/version-consistency.test.mjs +44 -0
- package/cli/config.mjs +124 -113
- package/cli/index.mjs +100 -97
- package/cli/init.mjs +259 -256
- package/cli/install.mjs +150 -80
- package/cli/materialize.mjs +102 -51
- package/cli/registry.mjs +136 -127
- package/cli/task.mjs +140 -0
- package/package.json +2 -1
- package/plugins/gates/.claude-plugin/plugin.json +8 -2
- package/plugins/gates/hooks/__tests__/ask-adoption.test.mjs +83 -0
- package/plugins/gates/hooks/__tests__/doctor.test.mjs +85 -0
- package/plugins/gates/hooks/__tests__/wiring-check.test.mjs +65 -0
- package/plugins/gates/hooks/ask-adoption.mjs +147 -0
- package/plugins/gates/hooks/doctor.mjs +207 -0
- package/plugins/gates/hooks/gates/audit-before-build/audit-before-build.edge.test.mjs +83 -0
- package/plugins/gates/hooks/gates/audit-before-build/index.mjs +88 -88
- package/plugins/gates/hooks/gates/audit-before-build/test.mjs +117 -92
- package/plugins/gates/hooks/gates/autonomous-mode/autonomous.edge.test.mjs +104 -0
- package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +48 -45
- package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +165 -0
- package/plugins/gates/hooks/gates/bash-commands/index.mjs +285 -268
- package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +151 -0
- package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +265 -177
- package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +207 -0
- package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +501 -456
- package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +161 -143
- package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +69 -0
- package/plugins/gates/hooks/gates/dependency-skills/index.mjs +126 -118
- package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +68 -0
- package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +43 -49
- package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +83 -0
- package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -100
- package/plugins/gates/hooks/gates/force-parallel/index.mjs +119 -0
- package/plugins/gates/hooks/gates/force-parallel/test.mjs +88 -0
- package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +155 -0
- package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -112
- package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +66 -0
- package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -186
- package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -238
- package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +96 -0
- package/plugins/gates/hooks/gates/intent-flow/test.mjs +165 -136
- package/plugins/gates/hooks/gates/lint-commit/index.mjs +149 -0
- package/plugins/gates/hooks/gates/lint-commit/test.mjs +85 -0
- package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +100 -0
- package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -166
- package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +79 -85
- package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +80 -0
- package/plugins/gates/hooks/gates/never-assume/index.mjs +58 -55
- package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +71 -0
- package/plugins/gates/hooks/gates/never-assume/test.mjs +95 -78
- package/plugins/gates/hooks/gates/no-blocking/index.mjs +148 -142
- package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +109 -0
- package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +123 -120
- package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +83 -0
- package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +91 -0
- package/plugins/gates/hooks/gates/protected-paths/index.mjs +144 -147
- package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +126 -0
- package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +89 -64
- package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +102 -0
- package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +94 -0
- package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +159 -150
- package/plugins/gates/hooks/gates/risk-level/index.mjs +263 -203
- package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +116 -0
- package/plugins/gates/hooks/gates/risk-level/test.mjs +153 -125
- package/plugins/gates/hooks/gates/root-cause-first/index.mjs +56 -50
- package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +58 -0
- package/plugins/gates/hooks/gates/root-whitelist/index.mjs +116 -111
- package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +97 -0
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +184 -99
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +126 -0
- package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +130 -0
- package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -251
- package/plugins/gates/hooks/gates/stop-pending/index.mjs +159 -0
- package/plugins/gates/hooks/gates/stop-pending/test.mjs +114 -0
- package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +101 -101
- package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +72 -0
- package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +63 -0
- package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -181
- package/plugins/gates/hooks/gates/test-matrix/test.mjs +114 -87
- package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +62 -0
- package/plugins/gates/hooks/gates/tool-map/index.mjs +143 -140
- package/plugins/gates/hooks/hooks.json +346 -286
- package/plugins/gates/hooks/lib/__tests__/config.test.mjs +173 -154
- package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +210 -154
- package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +114 -0
- package/plugins/gates/hooks/lib/config.mjs +172 -165
- package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +189 -0
- package/plugins/gates/hooks/lib/hook-io.mjs +318 -208
- package/plugins/gates/hooks/lib/signals.mjs +127 -0
- package/plugins/gates/hooks/wiring-check.mjs +227 -0
- package/plugins/tasks/.claude-plugin/plugin.json +14 -0
- package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +100 -0
- package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +95 -0
- package/plugins/tasks/hooks/hooks.json +26 -0
- package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +180 -132
- package/plugins/tasks/hooks/lib/task-store.mjs +197 -159
- package/plugins/tasks/hooks/register-requests.mjs +141 -108
- package/plugins/tasks/hooks/session-tasks.mjs +108 -0
- 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,
|
|
17
|
-
import { runGate, deny,
|
|
18
|
-
|
|
19
|
-
const GATE_ID = 'root-whitelist';
|
|
20
|
-
const CONFIG_KEY = 'blockPathsOutsideRootWhitelist';
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
'
|
|
26
|
-
'
|
|
27
|
-
'
|
|
28
|
-
'
|
|
29
|
-
'
|
|
30
|
-
'
|
|
31
|
-
'
|
|
32
|
-
'eslint.config.
|
|
33
|
-
'
|
|
34
|
-
'
|
|
35
|
-
'.
|
|
36
|
-
'.
|
|
37
|
-
'.
|
|
38
|
-
'.
|
|
39
|
-
'
|
|
40
|
-
'.
|
|
41
|
-
'.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
'
|
|
48
|
-
'
|
|
49
|
-
'
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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 {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
+
);
|