@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,68 +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(), 'diagnosis-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 isWarn(result) {
28
- return result?.hookSpecificOutput?.additionalContext !== undefined;
29
- }
30
-
31
- // FIXED: extractText() now gates on toolInGroups(toolName, ['write']) and reads through
32
- // writtenContentOf() (lib/hook-io.mjs), which covers NotebookEdit's new_source field.
33
- test('FIXED: NotebookEdit changing a timeout value is detected', () => {
34
- const payload = {
35
- tool_name: 'NotebookEdit',
36
- tool_input: {
37
- notebook_path: '/repo/notebook.ipynb',
38
- cell_id: 'abc',
39
- new_source: 'REQUEST_TIMEOUT_MS = 30000',
40
- cell_type: 'code',
41
- },
42
- };
43
- assert.ok(isWarn(runGate(payload)), 'gate now detects a timeout change via NotebookEdit');
44
- });
45
-
46
- // FIXED: replace_file_content is recognized by toolInGroups(toolName, ['write']) (native
47
- // name in TOOL_GROUPS.write) and its `content` field is read through writtenContentOf().
48
- test('FIXED: replace_file_content changing a timeout value is detected', () => {
49
- const payload = {
50
- tool_name: 'replace_file_content',
51
- tool_input: {
52
- file_path: '/repo/src/config.js',
53
- content: 'const REQUEST_TIMEOUT_MS = 30000;',
54
- },
55
- };
56
- assert.ok(isWarn(runGate(payload)), 'gate now detects a timeout change via replace_file_content');
57
- });
58
-
59
- // EDGE CASE: confirm severity really is warn-only, never deny, for a real match (per WARN
60
- // family classification). This should hold given index.mjs only calls warn(), never deny.
61
- test('OK: matched content produces warn, never deny', () => {
62
- const result = runGate({
63
- tool_name: 'Write',
64
- tool_input: { file_path: '/repo/src/config.js', content: 'const REQUEST_TIMEOUT_MS = 30000;' },
65
- });
66
- assert.ok(isWarn(result));
67
- assert.notEqual(result?.hookSpecificOutput?.permissionDecision, 'deny');
68
- });
@@ -1,68 +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(), 'diagnosis-before-patch-'));
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/config.js', content },
33
- };
34
- }
35
- function isWarn(result) {
36
- return result?.hookSpecificOutput?.additionalContext !== undefined;
37
- }
38
-
39
- test('warns when a timeout value is being changed', () => {
40
- assert.ok(isWarn(runGate(write('const REQUEST_TIMEOUT_MS = 30000;'))));
41
- assert.ok(isWarn(runGate(write('max_retry: 5'))));
42
- });
43
-
44
- test('allows content that does not touch a timeout/retry key', () => {
45
- assert.equal(runGate(write('const x = 1;')), null);
46
- });
47
-
48
- test('disabled by config: the gate does not run', () => {
49
- assert.equal(
50
- runGate(write('TIMEOUT_MS = 5000'), {
51
- config: { gates: { warnTimeoutChangeWithoutDiagnosis: false } },
52
- }),
53
- null,
54
- );
55
- });
56
-
57
- test('project timeoutPatterns override replaces the built-in list', () => {
58
- const config = {
59
- gates: {
60
- warnTimeoutChangeWithoutDiagnosis: {
61
- enabled: true,
62
- timeoutPatterns: ['custom_limit'],
63
- },
64
- },
65
- };
66
- assert.equal(runGate(write('TIMEOUT_MS = 5000'), { config }), null);
67
- assert.ok(isWarn(runGate(write('custom_limit = 5'), { config })));
68
- });
@@ -1,83 +0,0 @@
1
- // Edge-case probes for feature-catalog. Previously an Edit (new_string) writing
2
- // status:done or an in_progress overrun to the catalog bypassed this gate because
3
- // writeContentFrom() read only CodeContent/ReplacementContent/content, never
4
- // new_string. Migrated to writtenContentOf()/writtenPathOf() (hook-io.mjs), which read
5
- // through every known field shape, so these are now caught the same as a Write.
6
- import assert from 'node:assert/strict';
7
- import { execFileSync } from 'node:child_process';
8
- import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
9
- import { tmpdir } from 'node:os';
10
- import { dirname, join } from 'node:path';
11
- import { test } from 'node:test';
12
- import { fileURLToPath } from 'node:url';
13
-
14
- const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
15
-
16
- function runGate(payload) {
17
- const project = mkdtempSync(join(tmpdir(), 'feature-catalog-edge-'));
18
- mkdirSync(join(project, '.git'));
19
- const out = execFileSync(process.execPath, [GATE], {
20
- input: JSON.stringify(payload),
21
- encoding: 'utf8',
22
- cwd: project,
23
- env: { ...process.env, HOME: project, USERPROFILE: project },
24
- });
25
- return out.trim() ? JSON.parse(out.trim()) : null;
26
- }
27
-
28
- function isDeny(result) {
29
- return result?.hookSpecificOutput?.permissionDecision === 'deny';
30
- }
31
-
32
- test('FIXED: an Edit (new_string) writing status:done to the catalog is now detected', () => {
33
- const payload = {
34
- tool_name: 'Edit',
35
- tool_input: {
36
- file_path: 'feature_list.json',
37
- old_string: 'x',
38
- new_string: JSON.stringify({
39
- features: [{ name: 'checkout', status: 'done' }],
40
- }),
41
- },
42
- };
43
- assert.ok(
44
- isDeny(runGate(payload)),
45
- 'writtenContentOf reads new_string and catches status:done via Edit',
46
- );
47
- });
48
-
49
- test('FIXED: an Edit (new_string) exceeding maxInProgress is now detected', () => {
50
- const payload = {
51
- tool_name: 'Edit',
52
- tool_input: {
53
- file_path: 'feature_list.json',
54
- old_string: 'x',
55
- new_string: JSON.stringify({
56
- features: [
57
- { name: 'a', status: 'in_progress' },
58
- { name: 'b', status: 'in_progress' },
59
- ],
60
- }),
61
- },
62
- };
63
- assert.ok(
64
- isDeny(runGate(payload)),
65
- 'writtenContentOf reads new_string and catches the maxInProgress overrun via Edit',
66
- );
67
- });
68
-
69
- test('OK: the equivalent Write-tool payload IS caught (control)', () => {
70
- const payload = {
71
- tool_name: 'Write',
72
- tool_input: {
73
- file_path: 'feature_list.json',
74
- content: JSON.stringify({
75
- features: [{ name: 'checkout', status: 'done' }],
76
- }),
77
- },
78
- };
79
- assert.ok(
80
- isDeny(runGate(payload)),
81
- 'control failed: Write-tool status:done should be denied',
82
- );
83
- });
@@ -1,97 +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(), 'feature-catalog-'));
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 writeCatalog(content, targetFile = 'feature_list.json') {
30
- return {
31
- tool_name: 'Write',
32
- tool_input: { file_path: targetFile, content },
33
- };
34
- }
35
-
36
- function isDeny(result) {
37
- return result?.hookSpecificOutput?.permissionDecision === 'deny';
38
- }
39
-
40
- test('denies writing status:done directly to the catalog', () => {
41
- const content = JSON.stringify({
42
- features: [{ name: 'checkout', status: 'done' }],
43
- });
44
- assert.ok(isDeny(runGate(writeCatalog(content))));
45
- });
46
-
47
- test('denies more than maxInProgress features in_progress', () => {
48
- const content = JSON.stringify({
49
- features: [
50
- { name: 'a', status: 'in_progress' },
51
- { name: 'b', status: 'in_progress' },
52
- ],
53
- });
54
- assert.ok(isDeny(runGate(writeCatalog(content))));
55
- });
56
-
57
- test('allows a write with one in_progress feature and no direct done', () => {
58
- const content = JSON.stringify({
59
- features: [
60
- { name: 'a', status: 'in_progress' },
61
- { name: 'b', status: 'spec_ready' },
62
- ],
63
- });
64
- assert.equal(runGate(writeCatalog(content)), null);
65
- });
66
-
67
- test('allows a write to an unrelated file (auto-off: no catalog targeted)', () => {
68
- const content = JSON.stringify({ status: 'done' });
69
- assert.equal(runGate(writeCatalog(content, 'notes.json')), null);
70
- });
71
-
72
- test('disabled by config: the gate does not run', () => {
73
- const content = JSON.stringify({
74
- features: [{ name: 'checkout', status: 'done' }],
75
- });
76
- assert.equal(
77
- runGate(writeCatalog(content), {
78
- config: { gates: { requireFeatureCatalog: false } },
79
- }),
80
- null,
81
- );
82
- });
83
-
84
- test('project param maxInProgress override raises/lowers the threshold', () => {
85
- const content = JSON.stringify({
86
- features: [
87
- { name: 'a', status: 'in_progress' },
88
- { name: 'b', status: 'in_progress' },
89
- ],
90
- });
91
- const config = {
92
- gates: {
93
- requireFeatureCatalog: { enabled: true, maxInProgress: 2 },
94
- },
95
- };
96
- assert.equal(runGate(writeCatalog(content), { config }), null);
97
- });
@@ -1,88 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import { execFileSync } from 'node:child_process';
3
- import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
4
- import { tmpdir } from 'node:os';
5
- import { dirname, join } from 'node:path';
6
- import { randomUUID } from 'node:crypto';
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
- const STATE_ROOT = join(tmpdir(), 'claude-gates', 'force-parallel');
12
-
13
- function delegationPayload(sessionId, prompt = 'do work') {
14
- return {
15
- tool_name: 'Task',
16
- session_id: sessionId,
17
- tool_input: { prompt },
18
- };
19
- }
20
-
21
- function isWarn(result) {
22
- return typeof result?.hookSpecificOutput?.additionalContext === 'string';
23
- }
24
-
25
- function runGate(project, payload) {
26
- mkdirSync(join(project, '.git'), { recursive: true });
27
- mkdirSync(join(project, '.ai'), { recursive: true });
28
- writeFileSync(
29
- join(project, '.ai', 'config.json'),
30
- JSON.stringify({ gates: { warnSequentialDelegations: { enabled: true } } }),
31
- );
32
- const out = execFileSync(process.execPath, [GATE], {
33
- input: JSON.stringify(payload),
34
- encoding: 'utf8',
35
- cwd: project,
36
- env: { ...process.env, HOME: project, USERPROFILE: project },
37
- });
38
- return out.trim() ? JSON.parse(out.trim()) : null;
39
- }
40
-
41
- test('3 sequential delegations in the same session: the 3rd warns', () => {
42
- const project = mkdtempSync(join(tmpdir(), 'force-parallel-'));
43
- const sessionId = `test-${randomUUID()}`;
44
- rmSync(join(STATE_ROOT, sessionId), { recursive: true, force: true });
45
-
46
- const first = runGate(project, delegationPayload(sessionId));
47
- const second = runGate(project, delegationPayload(sessionId));
48
- const third = runGate(project, delegationPayload(sessionId));
49
-
50
- assert.equal(first, null, 'first delegation should not warn');
51
- assert.equal(second, null, 'second delegation should not warn');
52
- assert.ok(isWarn(third), 'third consecutive delegation should warn');
53
- assert.match(third.hookSpecificOutput.additionalContext, /force-parallel/);
54
-
55
- rmSync(join(STATE_ROOT, sessionId), { recursive: true, force: true });
56
- });
57
-
58
- test('a marked SEQUENTIAL-JUSTIFIED prompt never warns, even at count 3', () => {
59
- const project = mkdtempSync(join(tmpdir(), 'force-parallel-'));
60
- const sessionId = `test-${randomUUID()}`;
61
- rmSync(join(STATE_ROOT, sessionId), { recursive: true, force: true });
62
-
63
- runGate(project, delegationPayload(sessionId));
64
- runGate(project, delegationPayload(sessionId));
65
- const third = runGate(
66
- project,
67
- delegationPayload(sessionId, 'needs prior result SEQUENTIAL-JUSTIFIED'),
68
- );
69
-
70
- assert.equal(third, null);
71
-
72
- rmSync(join(STATE_ROOT, sessionId), { recursive: true, force: true });
73
- });
74
-
75
- test('a non-delegation tool is never warned', () => {
76
- const project = mkdtempSync(join(tmpdir(), 'force-parallel-'));
77
- const sessionId = `test-${randomUUID()}`;
78
- rmSync(join(STATE_ROOT, sessionId), { recursive: true, force: true });
79
-
80
- const result = runGate(project, {
81
- tool_name: 'Bash',
82
- session_id: sessionId,
83
- tool_input: { command: 'ls' },
84
- });
85
- assert.equal(result, null);
86
-
87
- rmSync(join(STATE_ROOT, sessionId), { recursive: true, force: true });
88
- });
@@ -1,155 +0,0 @@
1
- // Edge audit for forge-flow: fail-open vs fail-closed behavior, and the MCP write/shell
2
- // matcher hole (same structural gap as autonomous-mode's question hole, applied here to
3
- // ACTING_TOOLS = TOOL_GROUPS.write + TOOL_GROUPS.shell).
4
-
5
- import assert from 'node:assert/strict';
6
- import { execFileSync } from 'node:child_process';
7
- import { mkdtempSync, mkdirSync, writeFileSync, chmodSync } from 'node:fs';
8
- import { tmpdir } from 'node:os';
9
- import { dirname, join } from 'node:path';
10
- import { DatabaseSync } from 'node:sqlite';
11
- import { test } from 'node:test';
12
- import { fileURLToPath } from 'node:url';
13
-
14
- const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
15
-
16
- function adoptedProject() {
17
- const project = mkdtempSync(join(tmpdir(), 'forge-edge-'));
18
- mkdirSync(join(project, '.git'));
19
- mkdirSync(join(project, '.ai'));
20
- writeFileSync(join(project, '.ai', 'forge.json'), '{}');
21
- return project;
22
- }
23
-
24
- function writeConfig(project, forgeDatabasePath, enabled = true) {
25
- const gateEntry = { enabled };
26
- if (forgeDatabasePath) gateEntry.forgeDatabasePath = forgeDatabasePath;
27
- writeFileSync(
28
- join(project, '.ai', 'config.json'),
29
- JSON.stringify({ gates: { requireForgeRunToEdit: gateEntry } }),
30
- );
31
- }
32
-
33
- function runGate(payload, project) {
34
- const out = execFileSync(process.execPath, [GATE], {
35
- input: JSON.stringify(payload),
36
- encoding: 'utf8',
37
- cwd: project,
38
- env: { ...process.env, HOME: project, USERPROFILE: project },
39
- });
40
- return out.trim() ? JSON.parse(out.trim()) : null;
41
- }
42
-
43
- function isDeny(result) {
44
- return result?.hookSpecificOutput?.permissionDecision === 'deny';
45
- }
46
-
47
- function isWarn(result) {
48
- const output = result?.hookSpecificOutput;
49
- return Boolean(output?.additionalContext) && output.permissionDecision !== 'deny';
50
- }
51
-
52
- function makeForgeDatabase(directory, activeCwd) {
53
- const databasePath = join(directory, 'forge.db');
54
- const database = new DatabaseSync(databasePath);
55
- database.exec(
56
- 'CREATE TABLE runs (id TEXT, cwd TEXT, current_phase TEXT, status TEXT)',
57
- );
58
- if (activeCwd) {
59
- database
60
- .prepare('INSERT INTO runs (id, cwd, current_phase, status) VALUES (?,?,?,?)')
61
- .run('r1', activeCwd, 'build', 'active');
62
- }
63
- database.close();
64
- return databasePath;
65
- }
66
-
67
- test('DEGRADED-WARN: a corrupt (non-SQLite) DB file allows the write but WARNS visibly', () => {
68
- const project = adoptedProject();
69
- const home = mkdtempSync(join(tmpdir(), 'forge-db-'));
70
- const databasePath = join(home, 'forge.db');
71
- writeFileSync(databasePath, 'this is not a sqlite file at all');
72
- writeConfig(project, databasePath);
73
- const result = runGate(
74
- { tool_name: 'Write', tool_input: { file_path: 'x.js', content: 'x' } },
75
- project,
76
- );
77
- // The catch-all no longer returns a silent "run present". A DB that cannot be read yields
78
- // state 'unknown' -> the gate ALLOWS (a broken lookup must not freeze all work) but emits a
79
- // warn, so degraded enforcement is never invisible. This is the fix for the old silent
80
- // fail-open that disabled the whole gate with no trace.
81
- assert.ok(isWarn(result), 'corrupt DB must allow-with-warning, not allow silently');
82
- });
83
-
84
- test('DEGRADED-WARN: DB exists but "runs" table is missing/wrong schema warns visibly', () => {
85
- const project = adoptedProject();
86
- const home = mkdtempSync(join(tmpdir(), 'forge-db-'));
87
- const databasePath = join(home, 'forge.db');
88
- const database = new DatabaseSync(databasePath);
89
- database.exec('CREATE TABLE not_runs (id TEXT)');
90
- database.close();
91
- writeConfig(project, databasePath);
92
- const result = runGate(
93
- { tool_name: 'Write', tool_input: { file_path: 'x.js', content: 'x' } },
94
- project,
95
- );
96
- assert.ok(isWarn(result), 'schema drift must allow-with-warning, not allow silently');
97
- });
98
-
99
- test('FAIL-CLOSED (deny): DB file plainly absent at the configured path denies the write', () => {
100
- const project = adoptedProject();
101
- writeConfig(project, join(tmpdir(), 'definitely-does-not-exist-' + Date.now(), 'forge.db'));
102
- const result = runGate(
103
- { tool_name: 'Write', tool_input: { file_path: 'x.js', content: 'x' } },
104
- project,
105
- );
106
- // existsSync(forgeDatabasePath) is false -> hasActiveForgeRun returns false directly
107
- // (line 72: "no DB yet -> no runs at all -> deny") -- this is the ONE path that is
108
- // fail-closed; every other failure mode (corrupt file, missing table, locked file,
109
- // node:sqlite unavailable) is fail-open via the catch block.
110
- assert.ok(isDeny(result), 'FALLA(ok): absent DB is the sole fail-closed path');
111
- });
112
-
113
- test('FIXED: an MCP filesystem write tool is now gated (denied with no active run)', () => {
114
- const project = adoptedProject();
115
- const home = mkdtempSync(join(tmpdir(), 'forge-db-'));
116
- const databasePath = makeForgeDatabase(home); // no active run: a real Write would be denied
117
- writeConfig(project, databasePath);
118
- const result = runGate(
119
- {
120
- tool_name: 'mcp__filesystem__write_file',
121
- tool_input: { path: 'x.js', content: 'x' },
122
- },
123
- project,
124
- );
125
- // ACTING_GROUPS = ['execution'] and toolInGroups matches mcp__*__write_file via the write
126
- // signal, so an MCP filesystem write is now subject to the active-run requirement exactly
127
- // like a native Write. No active run -> deny.
128
- assert.ok(isDeny(result), 'MCP write tool must now be gated like a native Write');
129
- });
130
-
131
- test('control: the same DB/config DOES deny a native Write (proves the MCP case above is a real gap, not a config issue)', () => {
132
- const project = adoptedProject();
133
- const home = mkdtempSync(join(tmpdir(), 'forge-db-'));
134
- const databasePath = makeForgeDatabase(home); // no active run
135
- writeConfig(project, databasePath);
136
- const result = runGate(
137
- { tool_name: 'Write', tool_input: { file_path: 'x.js', content: 'x' } },
138
- project,
139
- );
140
- assert.ok(isDeny(result), 'FALLA(ok): native Write is correctly denied under identical setup');
141
- });
142
-
143
- test('FIXED: an MCP shell/exec tool (mcp__ide__executeCode) is now gated', () => {
144
- const project = adoptedProject();
145
- const home = mkdtempSync(join(tmpdir(), 'forge-db-'));
146
- const databasePath = makeForgeDatabase(home); // no active run
147
- writeConfig(project, databasePath);
148
- const result = runGate(
149
- { tool_name: 'mcp__ide__executeCode', tool_input: { code: 'require("fs").writeFileSync("x.js","x")' } },
150
- project,
151
- );
152
- // forge-flow now consumes the 'execution' group, which includes mcp__ide__executeCode and,
153
- // via toolInGroups, any mcp__* exec/run surface. No active run -> deny.
154
- assert.ok(isDeny(result), 'mcp__ide__executeCode must now be gated by forge-flow');
155
- });