@devrik-tools/claude-gates 0.2.1 → 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 (96) 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/test-after-implementation/index.mjs +124 -101
  16. package/plugins/gates/hooks/hooks.json +19 -0
  17. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  18. package/plugins/tasks/hooks/register-requests.mjs +7 -3
  19. package/registry.json +738 -793
  20. package/cli/__tests__/config.test.mjs +0 -113
  21. package/cli/__tests__/init-flags.test.mjs +0 -111
  22. package/cli/__tests__/install.test.mjs +0 -102
  23. package/cli/__tests__/materialize.test.mjs +0 -95
  24. package/cli/__tests__/registry-gates-consistency.test.mjs +0 -52
  25. package/cli/__tests__/selection.test.mjs +0 -200
  26. package/cli/__tests__/task.test.mjs +0 -124
  27. package/cli/__tests__/version-consistency.test.mjs +0 -44
  28. package/plugins/gates/hooks/__tests__/ask-adoption.test.mjs +0 -83
  29. package/plugins/gates/hooks/__tests__/doctor.test.mjs +0 -85
  30. package/plugins/gates/hooks/__tests__/wiring-check.test.mjs +0 -65
  31. package/plugins/gates/hooks/gates/audit-before-build/audit-before-build.edge.test.mjs +0 -83
  32. package/plugins/gates/hooks/gates/audit-before-build/test.mjs +0 -117
  33. package/plugins/gates/hooks/gates/autonomous-mode/autonomous.edge.test.mjs +0 -104
  34. package/plugins/gates/hooks/gates/autonomous-mode/test.mjs +0 -58
  35. package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +0 -165
  36. package/plugins/gates/hooks/gates/bash-commands/test.mjs +0 -126
  37. package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +0 -151
  38. package/plugins/gates/hooks/gates/brief-before-delegate/test.mjs +0 -111
  39. package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +0 -207
  40. package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +0 -161
  41. package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +0 -69
  42. package/plugins/gates/hooks/gates/dependency-skills/test.mjs +0 -109
  43. package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +0 -68
  44. package/plugins/gates/hooks/gates/diagnosis-before-patch/test.mjs +0 -68
  45. package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +0 -83
  46. package/plugins/gates/hooks/gates/feature-catalog/test.mjs +0 -97
  47. package/plugins/gates/hooks/gates/force-parallel/test.mjs +0 -88
  48. package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +0 -155
  49. package/plugins/gates/hooks/gates/forge-flow/test.mjs +0 -135
  50. package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +0 -66
  51. package/plugins/gates/hooks/gates/implementation-pipeline/test.mjs +0 -86
  52. package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +0 -96
  53. package/plugins/gates/hooks/gates/intent-flow/test.mjs +0 -165
  54. package/plugins/gates/hooks/gates/lint-commit/test.mjs +0 -85
  55. package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +0 -100
  56. package/plugins/gates/hooks/gates/mandatory-flow/test.mjs +0 -119
  57. package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +0 -80
  58. package/plugins/gates/hooks/gates/neutral-spanish/test.mjs +0 -65
  59. package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +0 -71
  60. package/plugins/gates/hooks/gates/never-assume/test.mjs +0 -95
  61. package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +0 -109
  62. package/plugins/gates/hooks/gates/no-blocking/test.mjs +0 -108
  63. package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +0 -83
  64. package/plugins/gates/hooks/gates/no-memory-dependency/test.mjs +0 -106
  65. package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +0 -91
  66. package/plugins/gates/hooks/gates/no-reconfirm/test.mjs +0 -131
  67. package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +0 -126
  68. package/plugins/gates/hooks/gates/protected-paths/test.mjs +0 -75
  69. package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +0 -102
  70. package/plugins/gates/hooks/gates/recurrence-lock/test.mjs +0 -99
  71. package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +0 -94
  72. package/plugins/gates/hooks/gates/reuse-before-build/test.mjs +0 -101
  73. package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +0 -116
  74. package/plugins/gates/hooks/gates/risk-level/test.mjs +0 -153
  75. package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +0 -58
  76. package/plugins/gates/hooks/gates/root-cause-first/test.mjs +0 -73
  77. package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +0 -124
  78. package/plugins/gates/hooks/gates/root-whitelist/test.mjs +0 -80
  79. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +0 -126
  80. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/test.mjs +0 -78
  81. package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +0 -130
  82. package/plugins/gates/hooks/gates/sdd-specs/test.mjs +0 -163
  83. package/plugins/gates/hooks/gates/stop-pending/test.mjs +0 -114
  84. package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +0 -72
  85. package/plugins/gates/hooks/gates/test-after-implementation/test.mjs +0 -81
  86. package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +0 -63
  87. package/plugins/gates/hooks/gates/test-matrix/test.mjs +0 -114
  88. package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +0 -62
  89. package/plugins/gates/hooks/gates/tool-map/test.mjs +0 -87
  90. package/plugins/gates/hooks/lib/__tests__/config.test.mjs +0 -173
  91. package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +0 -223
  92. package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +0 -114
  93. package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +0 -189
  94. package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +0 -100
  95. package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +0 -95
  96. 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
- });