@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,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
- });