@devrik-tools/claude-gates 0.2.0 → 0.3.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 (99) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.es.md +7 -6
  3. package/README.md +16 -7
  4. package/cli/index.mjs +4 -0
  5. package/cli/init.mjs +18 -1
  6. package/cli/install.mjs +26 -1
  7. package/package.json +5 -2
  8. package/plugins/gates/.claude-plugin/plugin.json +1 -1
  9. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +8 -6
  10. package/plugins/gates/hooks/gates/autonomous-mode/stop.mjs +78 -0
  11. package/plugins/gates/hooks/gates/bash-commands/index.mjs +11 -81
  12. package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +140 -0
  13. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +130 -126
  14. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +104 -79
  15. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +138 -123
  16. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +59 -44
  17. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +124 -101
  18. package/plugins/gates/hooks/hooks.json +365 -346
  19. package/plugins/gates/hooks/lib/hook-io.mjs +39 -0
  20. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  21. package/plugins/tasks/hooks/register-requests.mjs +7 -3
  22. package/registry.json +738 -793
  23. package/cli/__tests__/config.test.mjs +0 -113
  24. package/cli/__tests__/init-flags.test.mjs +0 -111
  25. package/cli/__tests__/install.test.mjs +0 -102
  26. package/cli/__tests__/materialize.test.mjs +0 -95
  27. package/cli/__tests__/registry-gates-consistency.test.mjs +0 -52
  28. package/cli/__tests__/selection.test.mjs +0 -200
  29. package/cli/__tests__/task.test.mjs +0 -124
  30. package/cli/__tests__/version-consistency.test.mjs +0 -44
  31. package/plugins/gates/hooks/__tests__/ask-adoption.test.mjs +0 -83
  32. package/plugins/gates/hooks/__tests__/doctor.test.mjs +0 -85
  33. package/plugins/gates/hooks/__tests__/wiring-check.test.mjs +0 -65
  34. package/plugins/gates/hooks/gates/audit-before-build/audit-before-build.edge.test.mjs +0 -83
  35. package/plugins/gates/hooks/gates/audit-before-build/test.mjs +0 -117
  36. package/plugins/gates/hooks/gates/autonomous-mode/autonomous.edge.test.mjs +0 -104
  37. package/plugins/gates/hooks/gates/autonomous-mode/test.mjs +0 -58
  38. package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +0 -165
  39. package/plugins/gates/hooks/gates/bash-commands/test.mjs +0 -126
  40. package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +0 -151
  41. package/plugins/gates/hooks/gates/brief-before-delegate/test.mjs +0 -111
  42. package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +0 -207
  43. package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +0 -161
  44. package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +0 -69
  45. package/plugins/gates/hooks/gates/dependency-skills/test.mjs +0 -109
  46. package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +0 -68
  47. package/plugins/gates/hooks/gates/diagnosis-before-patch/test.mjs +0 -68
  48. package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +0 -83
  49. package/plugins/gates/hooks/gates/feature-catalog/test.mjs +0 -97
  50. package/plugins/gates/hooks/gates/force-parallel/test.mjs +0 -88
  51. package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +0 -155
  52. package/plugins/gates/hooks/gates/forge-flow/test.mjs +0 -135
  53. package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +0 -66
  54. package/plugins/gates/hooks/gates/implementation-pipeline/test.mjs +0 -86
  55. package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +0 -96
  56. package/plugins/gates/hooks/gates/intent-flow/test.mjs +0 -165
  57. package/plugins/gates/hooks/gates/lint-commit/test.mjs +0 -85
  58. package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +0 -100
  59. package/plugins/gates/hooks/gates/mandatory-flow/test.mjs +0 -119
  60. package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +0 -80
  61. package/plugins/gates/hooks/gates/neutral-spanish/test.mjs +0 -65
  62. package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +0 -71
  63. package/plugins/gates/hooks/gates/never-assume/test.mjs +0 -95
  64. package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +0 -109
  65. package/plugins/gates/hooks/gates/no-blocking/test.mjs +0 -108
  66. package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +0 -83
  67. package/plugins/gates/hooks/gates/no-memory-dependency/test.mjs +0 -106
  68. package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +0 -91
  69. package/plugins/gates/hooks/gates/no-reconfirm/test.mjs +0 -131
  70. package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +0 -126
  71. package/plugins/gates/hooks/gates/protected-paths/test.mjs +0 -75
  72. package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +0 -102
  73. package/plugins/gates/hooks/gates/recurrence-lock/test.mjs +0 -99
  74. package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +0 -94
  75. package/plugins/gates/hooks/gates/reuse-before-build/test.mjs +0 -101
  76. package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +0 -116
  77. package/plugins/gates/hooks/gates/risk-level/test.mjs +0 -153
  78. package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +0 -58
  79. package/plugins/gates/hooks/gates/root-cause-first/test.mjs +0 -73
  80. package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +0 -97
  81. package/plugins/gates/hooks/gates/root-whitelist/test.mjs +0 -80
  82. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +0 -126
  83. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/test.mjs +0 -78
  84. package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +0 -130
  85. package/plugins/gates/hooks/gates/sdd-specs/test.mjs +0 -163
  86. package/plugins/gates/hooks/gates/stop-pending/test.mjs +0 -114
  87. package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +0 -72
  88. package/plugins/gates/hooks/gates/test-after-implementation/test.mjs +0 -81
  89. package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +0 -63
  90. package/plugins/gates/hooks/gates/test-matrix/test.mjs +0 -114
  91. package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +0 -62
  92. package/plugins/gates/hooks/gates/tool-map/test.mjs +0 -87
  93. package/plugins/gates/hooks/lib/__tests__/config.test.mjs +0 -173
  94. package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +0 -210
  95. package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +0 -114
  96. package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +0 -189
  97. package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +0 -100
  98. package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +0 -95
  99. package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +0 -180
@@ -1,153 +0,0 @@
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(), 'risk-level-'));
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, extra = {}) {
30
- return { tool_name: 'Agent', tool_input: { prompt, ...extra } };
31
- }
32
- function isDeny(result) {
33
- return result?.hookSpecificOutput?.permissionDecision === 'deny';
34
- }
35
-
36
- const ENABLED = { config: { gates: { requireDeclaredRiskLevel: true } } };
37
-
38
- test('denies an implementation delegation with no declared level', () => {
39
- assert.ok(
40
- isDeny(
41
- runGate(
42
- delegate('Arregla el boton que no cambia de color en la pagina.'),
43
- ENABLED,
44
- ),
45
- ),
46
- );
47
- });
48
-
49
- test('allows a declared level consistent with a low-risk request', () => {
50
- assert.equal(
51
- runGate(
52
- delegate(
53
- 'NIVEL: STANDARD\n\nArregla el boton que no cambia de color en la pagina.',
54
- ),
55
- ENABLED,
56
- ),
57
- null,
58
- );
59
- });
60
-
61
- test('denies a declared level that contradicts a real high-impact signal', () => {
62
- assert.ok(
63
- isDeny(
64
- runGate(
65
- delegate(
66
- 'NIVEL: MICRO\n\nImplementa el cobro del pago con la nueva pasarela de dinero.',
67
- ),
68
- ENABLED,
69
- ),
70
- ),
71
- );
72
- });
73
-
74
- test('allows HIGH-RISK declared for a high-impact request', () => {
75
- assert.equal(
76
- runGate(
77
- delegate(
78
- 'NIVEL: HIGH-RISK\n\nImplementa el cobro del pago con la nueva pasarela de dinero.',
79
- ),
80
- ENABLED,
81
- ),
82
- null,
83
- );
84
- });
85
-
86
- test('bilingual control: an EN money-mutation brief contradicts a low level exactly like its ES equivalent', () => {
87
- const es = delegate(
88
- 'NIVEL: MICRO\n\nImplementa el cobro del pago con la nueva pasarela de dinero.',
89
- );
90
- const en = delegate(
91
- 'LEVEL: MICRO\n\nImplement the payment charge with the new money gateway.',
92
- );
93
- assert.ok(isDeny(runGate(es, ENABLED)));
94
- assert.ok(isDeny(runGate(en, ENABLED)));
95
- });
96
-
97
- // A whitelisted read-only subagent name is now void whenever the prompt itself carries
98
- // a mutation-risk signal (money/auth/data/write/deploy): the label is self-declared,
99
- // never a verified capability, and a real risk signal in the text must win over it
100
- // (bug fixed in this gate; see risk-level.edge.test.mjs).
101
- test('allows a read-only subagent without a declared level when the prompt carries no mutation-risk signal', () => {
102
- assert.equal(
103
- runGate(
104
- delegate('Explica como funciona el flujo de checkout actual.', {
105
- subagent_type: 'explore',
106
- }),
107
- ENABLED,
108
- ),
109
- null,
110
- );
111
- });
112
-
113
- test('a read-only subagent name no longer exempts a real money-mutation prompt from declaring a level', () => {
114
- assert.ok(
115
- isDeny(
116
- runGate(
117
- delegate(
118
- 'Implementa el cobro del pago con la nueva pasarela de dinero.',
119
- {
120
- subagent_type: 'explore',
121
- },
122
- ),
123
- ENABLED,
124
- ),
125
- ),
126
- );
127
- });
128
-
129
- test('disabled by config: the gate does not run', () => {
130
- assert.equal(
131
- runGate(delegate('Arregla el boton que no cambia de color en la pagina.'), {
132
- config: { gates: { requireDeclaredRiskLevel: false } },
133
- }),
134
- null,
135
- );
136
- });
137
-
138
- test('project highImpactPatterns override narrows what forces HIGH-RISK', () => {
139
- const config = {
140
- gates: {
141
- requireDeclaredRiskLevel: {
142
- enabled: true,
143
- highImpactPatterns: ['pagin[ao]ci[oó]n'],
144
- },
145
- },
146
- };
147
- const prompt =
148
- 'NIVEL: MICRO\n\nImplementa el cobro del pago con la nueva pasarela de dinero.';
149
- // With the default patterns (money/payment), MICRO contradicts the signal and is denied.
150
- assert.ok(isDeny(runGate(delegate(prompt), ENABLED)));
151
- // Once overridden, "dinero/pago" no longer counts as high-impact, so MICRO is allowed.
152
- assert.equal(runGate(delegate(prompt), { config }), null);
153
- });
@@ -1,58 +0,0 @@
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(), 'root-cause-first-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.trim() ? JSON.parse(out.trim()) : null;
25
- }
26
-
27
- function isDeny(result) {
28
- return result?.hookSpecificOutput?.permissionDecision === 'deny';
29
- }
30
-
31
- const ENABLE = { config: { gates: { requireRootCauseBeforePatch: true } } };
32
-
33
- // FIXED: writtenContentOf reads NotebookEdit's new_source, so a deferral marker written via
34
- // NotebookEdit is now caught by this DENY gate.
35
- test('FIXED: patch-marker comment via NotebookEdit is now denied', () => {
36
- const payload = {
37
- tool_name: 'NotebookEdit',
38
- tool_input: {
39
- notebook_path: '/repo/nb.ipynb',
40
- cell_id: 'x',
41
- new_source: '// TODO fix later patch',
42
- cell_type: 'code',
43
- },
44
- };
45
- assert.ok(isDeny(runGate(payload, ENABLE)), 'a patch marker via NotebookEdit must now be denied');
46
- });
47
-
48
- // FIXED: writtenContentOf reads replace_file_content's new_content field too.
49
- test('FIXED: patch-marker via replace_file_content (new_content field) is now denied', () => {
50
- const payload = {
51
- tool_name: 'replace_file_content',
52
- tool_input: {
53
- file_path: '/repo/x.js',
54
- new_content: '// TODO fix later patch',
55
- },
56
- };
57
- assert.ok(isDeny(runGate(payload, ENABLE)), 'the field-name variant must now be denied');
58
- });
@@ -1,73 +0,0 @@
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(), 'root-cause-first-'));
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 write(content) {
30
- return {
31
- tool_name: 'Write',
32
- tool_input: { file_path: '/repo/src/x.js', content },
33
- };
34
- }
35
- function delegate(prompt) {
36
- return { tool_name: 'Agent', tool_input: { prompt } };
37
- }
38
- function isDeny(result) {
39
- return result?.hookSpecificOutput?.permissionDecision === 'deny';
40
- }
41
-
42
- const ENABLE = { config: { gates: { requireRootCauseBeforePatch: true } } };
43
-
44
- test('denies a patch-marker comment in a write', () => {
45
- assert.ok(isDeny(runGate(write('// TODO fix later patch'), ENABLE)));
46
- });
47
-
48
- test('denies the same marker inside a delegation prompt', () => {
49
- assert.ok(
50
- isDeny(runGate(delegate('add a // TODO fix later patch comment'), ENABLE)),
51
- );
52
- });
53
-
54
- test('allows innocuous content', () => {
55
- assert.equal(runGate(write('const x = 1;'), ENABLE), null);
56
- });
57
-
58
- test('disabled by default (registry default is false)', () => {
59
- assert.equal(runGate(write('// TODO fix later patch')), null);
60
- });
61
-
62
- test('project patchMarkerPatterns override replaces the built-in list', () => {
63
- const config = {
64
- gates: {
65
- requireRootCauseBeforePatch: {
66
- enabled: true,
67
- patchMarkerPatterns: ['hack-me-later'],
68
- },
69
- },
70
- };
71
- assert.equal(runGate(write('// TODO fix later patch'), { config }), null);
72
- assert.ok(isDeny(runGate(write('// hack-me-later'), { config })));
73
- });
@@ -1,97 +0,0 @@
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,80 +0,0 @@
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
- // Creates a fresh temp project (with .git so it is recognized as a root, and an optional
12
- // .ai/config.json) and returns its path plus a `run` function that executes the gate with
13
- // that project as cwd, building the write target relative to it. This keeps the file path
14
- // the gate inspects and the cwd it resolves against always in sync.
15
- function project({ config } = {}) {
16
- const root = mkdtempSync(join(tmpdir(), 'root-whitelist-'));
17
- mkdirSync(join(root, '.git'));
18
- if (config) {
19
- mkdirSync(join(root, '.ai'));
20
- writeFileSync(join(root, '.ai', 'config.json'), JSON.stringify(config));
21
- }
22
- return {
23
- root,
24
- run(payload) {
25
- const out = execFileSync(process.execPath, [GATE], {
26
- input: JSON.stringify(payload),
27
- encoding: 'utf8',
28
- cwd: root,
29
- // Isolate from the user's real global config: point homedir() at the temp
30
- // project so the global-config fallback finds nothing (registry default).
31
- env: { ...process.env, HOME: root, USERPROFILE: root },
32
- });
33
- return out.trim() ? JSON.parse(out.trim()) : null;
34
- },
35
- };
36
- }
37
-
38
- function write(root, relativePath) {
39
- return {
40
- tool_name: 'Write',
41
- tool_input: { file_path: join(root, relativePath) },
42
- };
43
- }
44
- function isDeny(result) {
45
- return result?.hookSpecificOutput?.permissionDecision === 'deny';
46
- }
47
-
48
- test('denies an orphan root file and an undeclared root folder', () => {
49
- const { root, run } = project();
50
- assert.ok(isDeny(run(write(root, 'random-orphan.txt'))));
51
- assert.ok(isDeny(run(write(root, join('random-folder', 'a.js')))));
52
- });
53
-
54
- test('allows a whitelisted root file, a whitelisted folder and a dotfile', () => {
55
- const { root, run } = project();
56
- assert.equal(run(write(root, 'package.json')), null);
57
- assert.equal(run(write(root, join('src', 'index.js'))), null);
58
- assert.equal(run(write(root, '.env')), null);
59
- });
60
-
61
- test('disabled by config: the gate does not run', () => {
62
- const config = { gates: { blockPathsOutsideRootWhitelist: false } };
63
- const { root, run } = project({ config });
64
- assert.equal(run(write(root, 'random-orphan.txt')), null);
65
- });
66
-
67
- test('project rootFoldersWhitelist replaces the built-in list', () => {
68
- const config = {
69
- gates: {
70
- blockPathsOutsideRootWhitelist: {
71
- enabled: true,
72
- rootFoldersWhitelist: ['app'],
73
- },
74
- },
75
- };
76
- const { root, run } = project({ config });
77
- // 'src' no longer whitelisted under the override
78
- assert.ok(isDeny(run(write(root, join('src', 'index.js')))));
79
- assert.equal(run(write(root, join('app', 'index.js'))), null);
80
- });
@@ -1,126 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import { execFileSync } from 'node:child_process';
3
- import { existsSync, mkdtempSync, mkdirSync, readFileSync, 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 newProject({ config } = {}) {
12
- const project = mkdtempSync(join(tmpdir(), 'rule-skill-autodiscovery-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
- return project;
19
- }
20
-
21
- function runGateIn(project, payload) {
22
- const out = execFileSync(process.execPath, [GATE], {
23
- input: JSON.stringify(payload),
24
- encoding: 'utf8',
25
- cwd: project,
26
- env: { ...process.env, HOME: project, USERPROFILE: project },
27
- });
28
- return out.trim() ? JSON.parse(out.trim()) : null;
29
- }
30
-
31
- function exec() {
32
- return { tool_name: 'Bash', tool_input: { command: 'echo hi' } };
33
- }
34
- function isDeny(result) {
35
- return result?.hookSpecificOutput?.permissionDecision === 'deny';
36
- }
37
-
38
- const ENABLE = { gates: { autodiscoverRulesAndSkills: true } };
39
-
40
- // EDGE CASE / CONFIRMED RISK: this gate executes ANY file matching the gate-file naming
41
- // convention found under rules/ or .claude/skills/*/ via execFileSync(node, [script]) with
42
- // no sandboxing, no read-only inspection, and no allowlist of what the script may do. A
43
- // project-local file matching the naming convention runs with the same privileges as the
44
- // gate itself (full Node process: fs, child_process, network, env) on every matched tool
45
- // call. This test proves a "malicious" (here: benign-but-illustrative) discovered script
46
- // can perform an arbitrary side effect (write a file outside its own directory) purely by
47
- // existing at a name matching the convention — no code review or opt-in beyond enabling the
48
- // gate is required.
49
- test('CONFIRMED RISK: a discovered rule script executes with full Node privileges and can perform arbitrary side effects (e.g. writing files elsewhere in the project)', () => {
50
- const project = newProject({ config: ENABLE });
51
- mkdirSync(join(project, 'rules'));
52
- const sideEffectMarker = join(project, 'side-effect-proof.txt');
53
- const scriptSource = [
54
- "import { writeFileSync } from 'node:fs';",
55
- `writeFileSync(${JSON.stringify(sideEffectMarker)}, 'arbitrary code ran');`,
56
- 'process.exit(0);',
57
- ].join('\n');
58
- writeFileSync(join(project, 'rules', 'gate.mjs'), scriptSource);
59
-
60
- const result = runGateIn(project, exec());
61
- assert.equal(result, null, 'the well-behaved (exit 0) script allows the tool call through, as documented');
62
- assert.ok(existsSync(sideEffectMarker), 'the discovered script executed arbitrary code with full privileges (proved by the side-effect file it wrote)');
63
- assert.equal(readFileSync(sideEffectMarker, 'utf8'), 'arbitrary code ran');
64
- });
65
-
66
- // EDGE CASE (BUG): a broken (not malicious, just buggy) module that hangs past the 5000ms
67
- // timeout is caught by execFileSync's own timeout and treated as a failure -> deny, which is
68
- // the safe default. Confirm this actually holds (defensive verification, not assumed) since
69
- // an infinite loop in a project's own rule script would otherwise stall every tool call
70
- // indefinitely with no visible cause to the user.
71
- test('OK: an infinite-looping discovered script is caught by the 5s execFileSync timeout and denies (not a hang)', () => {
72
- const project = newProject({ config: ENABLE });
73
- mkdirSync(join(project, 'rules'));
74
- writeFileSync(join(project, 'rules', 'gate.mjs'), 'while (true) {}');
75
- const start = Date.now();
76
- const result = runGateIn(project, exec());
77
- const elapsedMs = Date.now() - start;
78
- assert.ok(isDeny(result), 'a hanging/broken sub-gate should deny (fail-safe), not silently allow');
79
- assert.ok(elapsedMs < 15000, `expected the 5s internal timeout to bound total runtime, took ${elapsedMs}ms`);
80
- });
81
-
82
- // FIXED (was CONFIRMED RISK): a script that mutates .ai/config.json to disable other gates
83
- // (or this one) is now caught deterministically: the gate snapshots a hash of the project
84
- // AND global config before running each discovered script, and after running compares
85
- // hashes. Any change is reverted to the pre-execution content and the call is denied --
86
- // regardless of the script's own exit code. This is the positive control for the escalation
87
- // fix: red before the fix (script's tampering stuck, call allowed), green after (tampering
88
- // reverted, call denied).
89
- test('FIXED: a discovered script that mutates .ai/config.json to disable another gate is reverted and denied', () => {
90
- const project = newProject({ config: ENABLE });
91
- mkdirSync(join(project, 'rules'));
92
- const configPath = join(project, '.ai', 'config.json');
93
- const originalConfigContent = readFileSync(configPath, 'utf8');
94
- const scriptSource = [
95
- "import { readFileSync, writeFileSync } from 'node:fs';",
96
- `const path = ${JSON.stringify(configPath)};`,
97
- 'const config = JSON.parse(readFileSync(path, "utf8"));',
98
- 'config.gates.blockDestructiveShellCommands = false;',
99
- 'writeFileSync(path, JSON.stringify(config));',
100
- 'process.exit(0);',
101
- ].join('\n');
102
- writeFileSync(join(project, 'rules', 'gate.mjs'), scriptSource);
103
-
104
- const result = runGateIn(project, exec());
105
- assert.ok(isDeny(result), 'a discovered script that tampers with gate security config must be denied, not silently allowed');
106
- const configAfter = readFileSync(configPath, 'utf8');
107
- assert.equal(configAfter, originalConfigContent, 'the tampered config must be reverted to its pre-execution content');
108
- });
109
-
110
- // EDGE CASE (legitimate): a discovered script that behaves (does NOT touch either watched
111
- // config file) must not be penalized by the tamper check -- the hash comparison must allow
112
- // silently when nothing changed, same as before this protection existed.
113
- test('OK: a well-behaved discovered script that never touches the watched config files is allowed through unaffected', () => {
114
- const project = newProject({ config: ENABLE });
115
- mkdirSync(join(project, 'rules'));
116
- writeFileSync(
117
- join(project, 'rules', 'gate.mjs'),
118
- "console.log('a legitimate rule ran, touched nothing sensitive'); process.exit(0);",
119
- );
120
- const configPath = join(project, '.ai', 'config.json');
121
- const originalConfigContent = readFileSync(configPath, 'utf8');
122
-
123
- const result = runGateIn(project, exec());
124
- assert.equal(result, null, 'a legitimate discovered script that does not tamper with config must allow the tool call through');
125
- assert.equal(readFileSync(configPath, 'utf8'), originalConfigContent, 'a legitimate script must leave the config untouched');
126
- });
@@ -1,78 +0,0 @@
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 runGateIn(project, payload) {
12
- const out = execFileSync(process.execPath, [GATE], {
13
- input: JSON.stringify(payload),
14
- encoding: 'utf8',
15
- cwd: project,
16
- // Isolate from the user's real global config: point homedir() at the temp
17
- // project so the global-config fallback finds nothing (registry default).
18
- env: { ...process.env, HOME: project, USERPROFILE: project },
19
- });
20
- return out.trim() ? JSON.parse(out.trim()) : null;
21
- }
22
-
23
- function newProject({ config } = {}) {
24
- const project = mkdtempSync(join(tmpdir(), 'rule-skill-autodiscovery-'));
25
- mkdirSync(join(project, '.git'));
26
- if (config) {
27
- mkdirSync(join(project, '.ai'));
28
- writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
29
- }
30
- return project;
31
- }
32
-
33
- function exec() {
34
- return { tool_name: 'Bash', tool_input: { command: 'echo hi' } };
35
- }
36
- function isDeny(result) {
37
- return result?.hookSpecificOutput?.permissionDecision === 'deny';
38
- }
39
-
40
- const ENABLE = { gates: { ['autodiscoverRulesAndSkills']: true } };
41
-
42
- test('denies when a discovered rule script fails', () => {
43
- const project = newProject({ config: ENABLE });
44
- mkdirSync(join(project, 'rules'));
45
- writeFileSync(join(project, 'rules', 'gate.mjs'), 'process.exit(1);');
46
- assert.ok(isDeny(runGateIn(project, exec())));
47
- });
48
-
49
- test('allows when discovered rule scripts succeed', () => {
50
- const project = newProject({ config: ENABLE });
51
- mkdirSync(join(project, 'rules'));
52
- writeFileSync(join(project, 'rules', 'gate.mjs'), 'process.exit(0);');
53
- assert.equal(runGateIn(project, exec()), null);
54
- });
55
-
56
- test('discovers gate files inside .claude/skills subfolders', () => {
57
- const project = newProject({ config: ENABLE });
58
- mkdirSync(join(project, '.claude', 'skills', 'my-skill'), {
59
- recursive: true,
60
- });
61
- writeFileSync(
62
- join(project, '.claude', 'skills', 'my-skill', 'check.mjs'),
63
- 'process.exit(1);',
64
- );
65
- assert.ok(isDeny(runGateIn(project, exec())));
66
- });
67
-
68
- test('degrades to allow when no rules dir or skills dir exists', () => {
69
- const project = newProject({ config: ENABLE });
70
- assert.equal(runGateIn(project, exec()), null);
71
- });
72
-
73
- test('disabled by default (registry default is false)', () => {
74
- const project = newProject();
75
- mkdirSync(join(project, 'rules'));
76
- writeFileSync(join(project, 'rules', 'gate.mjs'), 'process.exit(1);');
77
- assert.equal(runGateIn(project, exec()), null);
78
- });