@devrik-tools/claude-gates 0.2.1 → 0.3.1

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 (97) 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/stop.mjs +78 -0
  10. package/plugins/gates/hooks/gates/bash-commands/index.mjs +11 -81
  11. package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +140 -0
  12. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +130 -126
  13. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +104 -79
  14. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +138 -123
  15. package/plugins/gates/hooks/gates/stop-pending/index.mjs +6 -1
  16. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +124 -101
  17. package/plugins/gates/hooks/hooks.json +19 -0
  18. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  19. package/plugins/tasks/hooks/register-requests.mjs +7 -3
  20. package/registry.json +738 -793
  21. package/cli/__tests__/config.test.mjs +0 -113
  22. package/cli/__tests__/init-flags.test.mjs +0 -111
  23. package/cli/__tests__/install.test.mjs +0 -102
  24. package/cli/__tests__/materialize.test.mjs +0 -95
  25. package/cli/__tests__/registry-gates-consistency.test.mjs +0 -52
  26. package/cli/__tests__/selection.test.mjs +0 -200
  27. package/cli/__tests__/task.test.mjs +0 -124
  28. package/cli/__tests__/version-consistency.test.mjs +0 -44
  29. package/plugins/gates/hooks/__tests__/ask-adoption.test.mjs +0 -83
  30. package/plugins/gates/hooks/__tests__/doctor.test.mjs +0 -85
  31. package/plugins/gates/hooks/__tests__/wiring-check.test.mjs +0 -65
  32. package/plugins/gates/hooks/gates/audit-before-build/audit-before-build.edge.test.mjs +0 -83
  33. package/plugins/gates/hooks/gates/audit-before-build/test.mjs +0 -117
  34. package/plugins/gates/hooks/gates/autonomous-mode/autonomous.edge.test.mjs +0 -104
  35. package/plugins/gates/hooks/gates/autonomous-mode/test.mjs +0 -58
  36. package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +0 -165
  37. package/plugins/gates/hooks/gates/bash-commands/test.mjs +0 -126
  38. package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +0 -151
  39. package/plugins/gates/hooks/gates/brief-before-delegate/test.mjs +0 -111
  40. package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +0 -207
  41. package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +0 -161
  42. package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +0 -69
  43. package/plugins/gates/hooks/gates/dependency-skills/test.mjs +0 -109
  44. package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +0 -68
  45. package/plugins/gates/hooks/gates/diagnosis-before-patch/test.mjs +0 -68
  46. package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +0 -83
  47. package/plugins/gates/hooks/gates/feature-catalog/test.mjs +0 -97
  48. package/plugins/gates/hooks/gates/force-parallel/test.mjs +0 -88
  49. package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +0 -155
  50. package/plugins/gates/hooks/gates/forge-flow/test.mjs +0 -135
  51. package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +0 -66
  52. package/plugins/gates/hooks/gates/implementation-pipeline/test.mjs +0 -86
  53. package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +0 -96
  54. package/plugins/gates/hooks/gates/intent-flow/test.mjs +0 -165
  55. package/plugins/gates/hooks/gates/lint-commit/test.mjs +0 -85
  56. package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +0 -100
  57. package/plugins/gates/hooks/gates/mandatory-flow/test.mjs +0 -119
  58. package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +0 -80
  59. package/plugins/gates/hooks/gates/neutral-spanish/test.mjs +0 -65
  60. package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +0 -71
  61. package/plugins/gates/hooks/gates/never-assume/test.mjs +0 -95
  62. package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +0 -109
  63. package/plugins/gates/hooks/gates/no-blocking/test.mjs +0 -108
  64. package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +0 -83
  65. package/plugins/gates/hooks/gates/no-memory-dependency/test.mjs +0 -106
  66. package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +0 -91
  67. package/plugins/gates/hooks/gates/no-reconfirm/test.mjs +0 -131
  68. package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +0 -126
  69. package/plugins/gates/hooks/gates/protected-paths/test.mjs +0 -75
  70. package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +0 -102
  71. package/plugins/gates/hooks/gates/recurrence-lock/test.mjs +0 -99
  72. package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +0 -94
  73. package/plugins/gates/hooks/gates/reuse-before-build/test.mjs +0 -101
  74. package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +0 -116
  75. package/plugins/gates/hooks/gates/risk-level/test.mjs +0 -153
  76. package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +0 -58
  77. package/plugins/gates/hooks/gates/root-cause-first/test.mjs +0 -73
  78. package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +0 -124
  79. package/plugins/gates/hooks/gates/root-whitelist/test.mjs +0 -80
  80. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +0 -126
  81. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/test.mjs +0 -78
  82. package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +0 -130
  83. package/plugins/gates/hooks/gates/sdd-specs/test.mjs +0 -163
  84. package/plugins/gates/hooks/gates/stop-pending/test.mjs +0 -114
  85. package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +0 -72
  86. package/plugins/gates/hooks/gates/test-after-implementation/test.mjs +0 -81
  87. package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +0 -63
  88. package/plugins/gates/hooks/gates/test-matrix/test.mjs +0 -114
  89. package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +0 -62
  90. package/plugins/gates/hooks/gates/tool-map/test.mjs +0 -87
  91. package/plugins/gates/hooks/lib/__tests__/config.test.mjs +0 -173
  92. package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +0 -223
  93. package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +0 -114
  94. package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +0 -189
  95. package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +0 -100
  96. package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +0 -95
  97. package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +0 -180
@@ -1,75 +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
- // Runs the gate as its own process (it calls process.exit), feeding a payload on stdin
12
- // from inside a temp project so the gate's config lookup is isolated. Returns the parsed
13
- // stdout, or null when the gate allowed (no output).
14
- function runGate(payload, { config } = {}) {
15
- const project = mkdtempSync(join(tmpdir(), 'protected-paths-'));
16
- mkdirSync(join(project, '.git'));
17
- if (config) {
18
- mkdirSync(join(project, '.ai'));
19
- writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
20
- }
21
- const out = execFileSync(process.execPath, [GATE], {
22
- input: JSON.stringify(payload),
23
- encoding: 'utf8',
24
- cwd: project,
25
- // Isolate from the user's real global config: point homedir() at the temp
26
- // project so the global-config fallback finds nothing (registry default).
27
- env: { ...process.env, HOME: project, USERPROFILE: project },
28
- });
29
- return out.trim() ? JSON.parse(out.trim()) : null;
30
- }
31
-
32
- function write(filePath) {
33
- return { tool_name: 'Write', tool_input: { file_path: filePath } };
34
- }
35
- function bash(command) {
36
- return { tool_name: 'Bash', tool_input: { command } };
37
- }
38
- function isDeny(result) {
39
- return result?.hookSpecificOutput?.permissionDecision === 'deny';
40
- }
41
-
42
- test('denies writing to .env and a mutating shell command targeting it', () => {
43
- assert.ok(isDeny(runGate(write('/repo/.env'))));
44
- assert.ok(isDeny(runGate(bash('rm .env'))));
45
- assert.ok(isDeny(runGate(bash('touch package-lock.json'))));
46
- });
47
-
48
- test('allows an innocuous write and a read-only command mentioning a protected path', () => {
49
- assert.equal(runGate(write('/repo/src/index.js')), null);
50
- assert.equal(runGate(bash('echo "reading .env for debugging"')), null);
51
- assert.equal(runGate(bash('cat .env')), null);
52
- });
53
-
54
- test('disabled by config: the gate does not run', () => {
55
- assert.equal(
56
- runGate(write('/repo/.env'), {
57
- config: { gates: { blockWritesToProtectedPaths: false } },
58
- }),
59
- null,
60
- );
61
- });
62
-
63
- test('project protectedPaths replaces the built-in list', () => {
64
- const config = {
65
- gates: {
66
- blockWritesToProtectedPaths: {
67
- enabled: true,
68
- protectedPaths: ['secrets.yaml'],
69
- },
70
- },
71
- };
72
- // .env no longer protected under the override
73
- assert.equal(runGate(write('/repo/.env'), { config }), null);
74
- assert.ok(isDeny(runGate(write('/repo/secrets.yaml'), { config })));
75
- });
@@ -1,102 +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 newProject({ config } = {}) {
12
- const project = mkdtempSync(join(tmpdir(), 'recurrence-lock-edge-'));
13
- mkdirSync(join(project, '.git'));
14
- mkdirSync(join(project, '.ai'), { recursive: true });
15
- if (config) {
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
- // EDGE CASE (BUG): mcp__ide__executeCode is part of TOOL_GROUPS.execution (lib/hook-io.mjs
39
- // line 37-46) and this gate checks TOOL_GROUPS.execution.includes(toolName), so this should
40
- // actually be covered. Verify it is (defensive check — not assumed).
41
- test('OK: mcp__ide__executeCode is covered because it is in TOOL_GROUPS.execution', () => {
42
- const project = newProject();
43
- writeFileSync(
44
- join(project, '.ai', 'reincidencias.json'),
45
- JSON.stringify({ classes: [{ class: 'x', occurrences: [1, 2], status: 'open' }] }),
46
- );
47
- assert.ok(isDeny(runGateIn(project, { tool_name: 'mcp__ide__executeCode', tool_input: {} })));
48
- });
49
-
50
- // FIXED (was BUG): status is now trimmed before lowercasing, so a padded/capitalized status
51
- // like "Closed " (trailing space) is correctly recognized as closed and no longer sticks the
52
- // lock on a legitimately-resolved recurrence. A genuine typo like "closd" must still NOT be
53
- // recognized (no typo-tolerance) and stays open-blocking, as expected.
54
- test('FIXED: a status of "Closed " (capitalized, trailing space) is trimmed and recognized as closed, unblocking the recurrence', () => {
55
- const project = newProject();
56
- writeFileSync(
57
- join(project, '.ai', 'reincidencias.json'),
58
- JSON.stringify({ classes: [{ class: 'x', occurrences: [1, 2], status: 'Closed ' }] }),
59
- );
60
- assert.equal(runGateIn(project, exec()), null, 'a padded/capitalized "Closed " status must be trimmed and recognized as closed, allowing the tool call through');
61
- });
62
-
63
- test('OK: a genuine typo status like "closd" is NOT recognized as closed and stays open-blocking (no typo-tolerance)', () => {
64
- const project = newProject();
65
- writeFileSync(
66
- join(project, '.ai', 'reincidencias.json'),
67
- JSON.stringify({ classes: [{ class: 'x', occurrences: [1, 2], status: 'closd' }] }),
68
- );
69
- assert.ok(isDeny(runGateIn(project, exec())), 'a typo status must not be silently treated as closed');
70
- });
71
-
72
- // FIXED (was BUG): occurrences are now deduplicated (by id/hash, or by value for bare
73
- // scalars) before counting against the threshold. `occurrences: [1, 1]` is the same
74
- // occurrence logged twice (e.g. by a race or a bug in the writer) and must no longer count
75
- // as two distinct occurrences.
76
- test('FIXED: duplicate identical entries in occurrences[] are deduplicated before counting, so [1, 1] does not reach a threshold of 2', () => {
77
- const project = newProject();
78
- writeFileSync(
79
- join(project, '.ai', 'reincidencias.json'),
80
- JSON.stringify({ classes: [{ class: 'dup', occurrences: [1, 1], status: 'open' }] }),
81
- );
82
- assert.equal(runGateIn(project, exec()), null, 'duplicate occurrence entries must be deduplicated before comparing against thresholdAppearances');
83
- });
84
-
85
- // OK: genuinely distinct occurrences (even with the same shape) must still count normally —
86
- // dedup must not under-count real, distinct recurrences.
87
- test('OK: distinct occurrence entries (identified by id) still count toward the threshold normally', () => {
88
- const project = newProject();
89
- writeFileSync(
90
- join(project, '.ai', 'reincidencias.json'),
91
- JSON.stringify({
92
- classes: [
93
- {
94
- class: 'distinct',
95
- occurrences: [{ id: 'a' }, { id: 'b' }],
96
- status: 'open',
97
- },
98
- ],
99
- }),
100
- );
101
- assert.ok(isDeny(runGateIn(project, exec())), 'two genuinely distinct occurrences (different ids) must still trip the threshold');
102
- });
@@ -1,99 +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 newProject({ config } = {}) {
12
- const project = mkdtempSync(join(tmpdir(), 'recurrence-lock-'));
13
- mkdirSync(join(project, '.git'));
14
- mkdirSync(join(project, '.ai'), { recursive: true });
15
- if (config) {
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
- // Isolate from the user's real global config: point homedir() at the temp
27
- // project so the global-config fallback finds nothing (registry default).
28
- env: { ...process.env, HOME: project, USERPROFILE: project },
29
- });
30
- return out.trim() ? JSON.parse(out.trim()) : null;
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
- test('denies when an open recurrence class is at/above threshold', () => {
41
- const project = newProject();
42
- writeFileSync(
43
- join(project, '.ai', 'reincidencias.json'),
44
- JSON.stringify({
45
- classes: [
46
- { class: 'duplicate-catch', occurrences: [1, 2], status: 'open' },
47
- ],
48
- }),
49
- );
50
- assert.ok(isDeny(runGateIn(project, exec())));
51
- });
52
-
53
- test('allows when the recurrence class is closed', () => {
54
- const project = newProject();
55
- writeFileSync(
56
- join(project, '.ai', 'reincidencias.json'),
57
- JSON.stringify({
58
- classes: [
59
- { class: 'duplicate-catch', occurrences: [1, 2], status: 'closed' },
60
- ],
61
- }),
62
- );
63
- assert.equal(runGateIn(project, exec()), null);
64
- });
65
-
66
- test('degrades to allow when the recurrence file does not exist', () => {
67
- const project = newProject();
68
- assert.equal(runGateIn(project, exec()), null);
69
- });
70
-
71
- test('disabled by config: the gate does not run', () => {
72
- const project = newProject({
73
- config: { gates: { blockRegisteredRecurrences: false } },
74
- });
75
- writeFileSync(
76
- join(project, '.ai', 'reincidencias.json'),
77
- JSON.stringify({
78
- classes: [{ class: 'x', occurrences: [1, 2], status: 'open' }],
79
- }),
80
- );
81
- assert.equal(runGateIn(project, exec()), null);
82
- });
83
-
84
- test('thresholdAppearances override changes the trigger point', () => {
85
- const project = newProject({
86
- config: {
87
- gates: {
88
- blockRegisteredRecurrences: { enabled: true, thresholdAppearances: 3 },
89
- },
90
- },
91
- });
92
- writeFileSync(
93
- join(project, '.ai', 'reincidencias.json'),
94
- JSON.stringify({
95
- classes: [{ class: 'x', occurrences: [1, 2], status: 'open' }],
96
- }),
97
- );
98
- assert.equal(runGateIn(project, exec()), null);
99
- });
@@ -1,94 +0,0 @@
1
- // Edge-case probes for reuse-before-build. Previously: (1) TOOL_FOLDERS matching was a
2
- // bare substring test on the normalized path, so a legitimate folder like
3
- // "src/mytools/thing.mjs" was misclassified as a tool build (false positive) because
4
- // it contains the substring "tools/"; (2) checkDelegation read the prompt only from
5
- // toolInput.prompt/description, so a delegation using another field name (e.g. `task`)
6
- // was invisible; (3) an MCP-style write tool name (not in the old TOOL_GROUPS.write
7
- // Set) bypassed the gate entirely. All three are now fixed: isExecutableToolPath
8
- // compares whole path SEGMENTS (not substrings), delegationPromptOf() reads
9
- // task/instructions/message/input too, and toolInGroups() classifies any mcp__* tool
10
- // by its action segment so an unrecognized MCP write name is still caught.
11
- import assert from 'node:assert/strict';
12
- import { execFileSync } from 'node:child_process';
13
- import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
14
- import { tmpdir } from 'node:os';
15
- import { dirname, join } from 'node:path';
16
- import { test } from 'node:test';
17
- import { fileURLToPath } from 'node:url';
18
-
19
- const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
20
-
21
- function runGate(payload, { config } = {}) {
22
- const project = mkdtempSync(join(tmpdir(), 'reuse-before-build-edge-'));
23
- mkdirSync(join(project, '.git'));
24
- mkdirSync(join(project, '.ai'));
25
- const effectiveConfig = config ?? {
26
- gates: { requireReuseCheckBeforeBuilding: true },
27
- };
28
- writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(effectiveConfig));
29
- const out = execFileSync(process.execPath, [GATE], {
30
- input: JSON.stringify(payload),
31
- encoding: 'utf8',
32
- cwd: project,
33
- env: { ...process.env, HOME: project, USERPROFILE: project },
34
- });
35
- return out.trim() ? JSON.parse(out.trim()) : null;
36
- }
37
-
38
- function isDeny(result) {
39
- return result?.hookSpecificOutput?.permissionDecision === 'deny';
40
- }
41
-
42
- test('FIXED: a legitimate "mytools/" folder is no longer misclassified as a tool build', () => {
43
- const payload = {
44
- tool_name: 'Write',
45
- tool_input: { file_path: 'src/mytools/thing.mjs', content: 'export function thing(){}' },
46
- };
47
- assert.equal(
48
- isDeny(runGate(payload)),
49
- false,
50
- 'isExecutableToolPath must compare whole path segments, not substrings: "mytools" ' +
51
- 'is not the segment "tools"',
52
- );
53
- });
54
-
55
- test('FIXED: a real "tools/" folder is still caught (control for the segment fix)', () => {
56
- const payload = {
57
- tool_name: 'Write',
58
- tool_input: { file_path: 'tools/thing.mjs', content: 'export function thing(){}' },
59
- };
60
- assert.ok(
61
- isDeny(runGate(payload)),
62
- 'a genuine tools/ segment must still trigger the gate',
63
- );
64
- });
65
-
66
- test('FIXED: an MCP-style write tool name is no longer invisible to the gate', () => {
67
- const payload = {
68
- tool_name: 'mcp__filesystem__write_file',
69
- tool_input: { file_path: 'scripts/new-tool.mjs', content: 'export function parse(){}' },
70
- };
71
- assert.ok(
72
- isDeny(runGate(payload)),
73
- 'toolInGroups must classify an MCP write tool by its action segment (write_file)',
74
- );
75
- });
76
-
77
- test('FIXED: a delegation prompt carried in `task` (not prompt/description) is now checked', () => {
78
- const payload = {
79
- tool_name: 'Agent',
80
- tool_input: { task: 'Please write a new script that parses CSV files.' },
81
- };
82
- assert.ok(
83
- isDeny(runGate(payload)),
84
- 'delegationPromptOf must read the `task` field',
85
- );
86
- });
87
-
88
- test('OK: the equivalent Write-tool payload IS caught (control)', () => {
89
- const payload = {
90
- tool_name: 'Write',
91
- tool_input: { file_path: 'scripts/new-tool.mjs', content: 'export function parse(){}' },
92
- };
93
- assert.ok(isDeny(runGate(payload)), 'control failed');
94
- });
@@ -1,101 +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
- // Runs the gate as a child process inside a temp project (with .git so config/root resolve).
12
- // The gate is off by default, so config enables it unless a test overrides that. An optional
13
- // tool map is seeded. Returns parsed stdout or null when it allowed.
14
- function runGate(payload, { config, toolMap } = {}) {
15
- const project = mkdtempSync(join(tmpdir(), 'reuse-before-build-'));
16
- mkdirSync(join(project, '.git'));
17
- mkdirSync(join(project, '.ai'));
18
- const effectiveConfig = config ?? {
19
- gates: { requireReuseCheckBeforeBuilding: true },
20
- };
21
- writeFileSync(
22
- join(project, '.ai', 'config.json'),
23
- JSON.stringify(effectiveConfig),
24
- );
25
- if (toolMap) {
26
- writeFileSync(
27
- join(project, '.ai', 'tool-map.json'),
28
- JSON.stringify(toolMap),
29
- );
30
- }
31
- const out = execFileSync(process.execPath, [GATE], {
32
- input: JSON.stringify(payload),
33
- encoding: 'utf8',
34
- cwd: project,
35
- // Isolate from the user's real global config: point homedir() at the temp
36
- // project so the global-config fallback finds nothing (registry default).
37
- env: { ...process.env, HOME: project, USERPROFILE: project },
38
- });
39
- return out.trim() ? JSON.parse(out.trim()) : null;
40
- }
41
-
42
- function writeTool(content) {
43
- return {
44
- tool_name: 'Write',
45
- tool_input: { file_path: 'scripts/csv-parser.mjs', content },
46
- };
47
- }
48
- function delegate(prompt) {
49
- return { tool_name: 'Agent', tool_input: { prompt } };
50
- }
51
- function isDeny(result) {
52
- return result?.hookSpecificOutput?.permissionDecision === 'deny';
53
- }
54
-
55
- test('denies building a new tool without an audit', () => {
56
- assert.ok(isDeny(runGate(writeTool('export function parse() {}'))));
57
- assert.ok(
58
- isDeny(
59
- runGate(delegate('Please write a new script that parses CSV files.')),
60
- ),
61
- );
62
- });
63
-
64
- test('allows when the text declares an audit was done', () => {
65
- assert.equal(
66
- runGate(
67
- writeTool('// Justification: no existing tool covers this.\nexport {}'),
68
- ),
69
- null,
70
- );
71
- assert.equal(
72
- runGate(
73
- delegate('Write a new script for CSV — I audited and nothing does this.'),
74
- ),
75
- null,
76
- );
77
- });
78
-
79
- test('allows when the need is already recorded in the tool map', () => {
80
- const toolMap = { tools: [{ path: 'scripts/csv-parser.mjs', audit: 'x' }] };
81
- assert.equal(runGate(writeTool('export {}'), { toolMap }), null);
82
- });
83
-
84
- test('ignores non-tool writes (a plain markdown file)', () => {
85
- assert.equal(
86
- runGate({
87
- tool_name: 'Write',
88
- tool_input: { file_path: 'docs/readme.md', content: 'hello' },
89
- }),
90
- null,
91
- );
92
- });
93
-
94
- test('disabled by config: the gate does not run', () => {
95
- assert.equal(
96
- runGate(writeTool('export {}'), {
97
- config: { gates: { requireReuseCheckBeforeBuilding: false } },
98
- }),
99
- null,
100
- );
101
- });
@@ -1,116 +0,0 @@
1
- // Edge-case audit for risk-level. Each test demonstrates a confirmed BUG or an OK.
2
- // Run: node --test risk-level.edge.test.mjs
3
- import assert from 'node:assert/strict';
4
- import { execFileSync } from 'node:child_process';
5
- import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
6
- import { tmpdir } from 'node:os';
7
- import { dirname, join } from 'node:path';
8
- import { test } from 'node:test';
9
- import { fileURLToPath } from 'node:url';
10
-
11
- const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
12
-
13
- function runGate(payload, { config } = {}) {
14
- const project = mkdtempSync(join(tmpdir(), 'risk-level-edge-'));
15
- mkdirSync(join(project, '.git'));
16
- if (config) {
17
- mkdirSync(join(project, '.ai'));
18
- writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
19
- }
20
- const out = execFileSync(process.execPath, [GATE], {
21
- input: JSON.stringify(payload),
22
- encoding: 'utf8',
23
- cwd: project,
24
- env: { ...process.env, HOME: project, USERPROFILE: project },
25
- });
26
- return out.trim() ? JSON.parse(out.trim()) : null;
27
- }
28
-
29
- function isDeny(result) {
30
- return result?.hookSpecificOutput?.permissionDecision === 'deny';
31
- }
32
-
33
- const ENABLED = { config: { gates: { requireDeclaredRiskLevel: true } } };
34
-
35
- const SENSITIVE_NO_LEVEL =
36
- 'Implementa el cobro del pago con la nueva pasarela de dinero para el checkout.';
37
-
38
- test('FIXED: a delegation tool name outside the native list is now caught via toolInGroups (MCP delegation signal)', () => {
39
- const result = runGate(
40
- { tool_name: 'mcp__orchestrator__spawn_agent', tool_input: { prompt: SENSITIVE_NO_LEVEL } },
41
- ENABLED,
42
- );
43
- assert.ok(isDeny(result), 'gate now denies the no-level prompt under the MCP tool name too');
44
- });
45
-
46
- test('FIXED: prompt carried in a field other than prompt/description/task is now read via delegationPromptOf', () => {
47
- const result = runGate(
48
- { tool_name: 'Agent', tool_input: { instructions: SENSITIVE_NO_LEVEL } },
49
- ENABLED,
50
- );
51
- assert.ok(isDeny(result), 'gate now sees the prompt under "instructions" and denies it for missing LEVEL');
52
- });
53
-
54
- test('FIXED: a mutation-risk signal in the prompt overrides a whitelisted read-only subagent name', () => {
55
- const result = runGate(
56
- { tool_name: 'Agent', tool_input: { prompt: SENSITIVE_NO_LEVEL, subagent_type: 'plan' } },
57
- ENABLED,
58
- );
59
- assert.ok(
60
- isDeny(result),
61
- 'gate no longer exempts a real money-mutation delegation solely because subagent_type says "plan"',
62
- );
63
- });
64
-
65
- test('FIXED: the LAST level declaration governs, not the first (a decoy early mention no longer wins)', () => {
66
- // Previously DECLARED_LEVEL_PATTERN.exec(prompt) (no /g) took only the first match, so
67
- // a decoy HIGH-RISK mention near the top let a real MICRO-declared money mutation
68
- // through. operativeLevel() now takes the LAST declaration as operative: MICRO governs
69
- // here and the real money-mutation signal contradicts it, so the gate denies.
70
- const prompt =
71
- 'NIVEL: HIGH-RISK (nota: eso era para la tarea anterior, ignorar). Para esta tarea: NIVEL: MICRO. ' +
72
- 'Implementa el cobro del pago con la nueva pasarela de dinero para el checkout.';
73
- const result = runGate({ tool_name: 'Agent', tool_input: { prompt } }, ENABLED);
74
- assert.ok(
75
- isDeny(result),
76
- 'gate now denies: the operative (last) declaration is MICRO, which contradicts the real money-mutation signal',
77
- );
78
- });
79
-
80
- test('FIXED: two DIFFERENT levels that both look operative are denied as ambiguous, not silently resolved', () => {
81
- const prompt =
82
- 'NIVEL: MICRO. Mas adelante, NIVEL: HIGH-RISK. Arregla el boton que no cambia de color en la pagina.';
83
- const result = runGate({ tool_name: 'Agent', tool_input: { prompt } }, ENABLED);
84
- assert.ok(
85
- isDeny(result),
86
- 'gate denies and asks for a single unambiguous LEVEL when multiple different levels are declared',
87
- );
88
- });
89
-
90
- test('OK: repeating the SAME level twice is not treated as ambiguous', () => {
91
- const result = runGate(
92
- {
93
- tool_name: 'Agent',
94
- tool_input: {
95
- prompt:
96
- 'NIVEL: STANDARD. Como se declaro, NIVEL: STANDARD. Arregla el boton que no cambia de color.',
97
- },
98
- },
99
- ENABLED,
100
- );
101
- assert.equal(result, null);
102
- });
103
-
104
- test('OK: same sensitive prompt without a read-only subagent name and no level is denied', () => {
105
- const result = runGate({ tool_name: 'Agent', tool_input: { prompt: SENSITIVE_NO_LEVEL } }, ENABLED);
106
- assert.ok(isDeny(result));
107
- });
108
-
109
- test('OK: Task and invoke_subagent tool names are both covered', () => {
110
- assert.ok(isDeny(runGate({ tool_name: 'Task', tool_input: { prompt: SENSITIVE_NO_LEVEL } }, ENABLED)));
111
- assert.ok(
112
- isDeny(
113
- runGate({ tool_name: 'invoke_subagent', tool_input: { prompt: SENSITIVE_NO_LEVEL } }, ENABLED),
114
- ),
115
- );
116
- });