@devrik-tools/claude-gates 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.es.md +7 -6
  3. package/README.md +16 -7
  4. package/cli/index.mjs +4 -0
  5. package/cli/init.mjs +18 -1
  6. package/cli/install.mjs +26 -1
  7. package/package.json +5 -2
  8. package/plugins/gates/.claude-plugin/plugin.json +1 -1
  9. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +8 -6
  10. package/plugins/gates/hooks/gates/autonomous-mode/stop.mjs +78 -0
  11. package/plugins/gates/hooks/gates/bash-commands/index.mjs +11 -81
  12. package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +140 -0
  13. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +130 -126
  14. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +104 -79
  15. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +138 -123
  16. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +59 -44
  17. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +124 -101
  18. package/plugins/gates/hooks/hooks.json +365 -346
  19. package/plugins/gates/hooks/lib/hook-io.mjs +39 -0
  20. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  21. package/plugins/tasks/hooks/register-requests.mjs +7 -3
  22. package/registry.json +738 -793
  23. package/cli/__tests__/config.test.mjs +0 -113
  24. package/cli/__tests__/init-flags.test.mjs +0 -111
  25. package/cli/__tests__/install.test.mjs +0 -102
  26. package/cli/__tests__/materialize.test.mjs +0 -95
  27. package/cli/__tests__/registry-gates-consistency.test.mjs +0 -52
  28. package/cli/__tests__/selection.test.mjs +0 -200
  29. package/cli/__tests__/task.test.mjs +0 -124
  30. package/cli/__tests__/version-consistency.test.mjs +0 -44
  31. package/plugins/gates/hooks/__tests__/ask-adoption.test.mjs +0 -83
  32. package/plugins/gates/hooks/__tests__/doctor.test.mjs +0 -85
  33. package/plugins/gates/hooks/__tests__/wiring-check.test.mjs +0 -65
  34. package/plugins/gates/hooks/gates/audit-before-build/audit-before-build.edge.test.mjs +0 -83
  35. package/plugins/gates/hooks/gates/audit-before-build/test.mjs +0 -117
  36. package/plugins/gates/hooks/gates/autonomous-mode/autonomous.edge.test.mjs +0 -104
  37. package/plugins/gates/hooks/gates/autonomous-mode/test.mjs +0 -58
  38. package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +0 -165
  39. package/plugins/gates/hooks/gates/bash-commands/test.mjs +0 -126
  40. package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +0 -151
  41. package/plugins/gates/hooks/gates/brief-before-delegate/test.mjs +0 -111
  42. package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +0 -207
  43. package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +0 -161
  44. package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +0 -69
  45. package/plugins/gates/hooks/gates/dependency-skills/test.mjs +0 -109
  46. package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +0 -68
  47. package/plugins/gates/hooks/gates/diagnosis-before-patch/test.mjs +0 -68
  48. package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +0 -83
  49. package/plugins/gates/hooks/gates/feature-catalog/test.mjs +0 -97
  50. package/plugins/gates/hooks/gates/force-parallel/test.mjs +0 -88
  51. package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +0 -155
  52. package/plugins/gates/hooks/gates/forge-flow/test.mjs +0 -135
  53. package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +0 -66
  54. package/plugins/gates/hooks/gates/implementation-pipeline/test.mjs +0 -86
  55. package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +0 -96
  56. package/plugins/gates/hooks/gates/intent-flow/test.mjs +0 -165
  57. package/plugins/gates/hooks/gates/lint-commit/test.mjs +0 -85
  58. package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +0 -100
  59. package/plugins/gates/hooks/gates/mandatory-flow/test.mjs +0 -119
  60. package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +0 -80
  61. package/plugins/gates/hooks/gates/neutral-spanish/test.mjs +0 -65
  62. package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +0 -71
  63. package/plugins/gates/hooks/gates/never-assume/test.mjs +0 -95
  64. package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +0 -109
  65. package/plugins/gates/hooks/gates/no-blocking/test.mjs +0 -108
  66. package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +0 -83
  67. package/plugins/gates/hooks/gates/no-memory-dependency/test.mjs +0 -106
  68. package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +0 -91
  69. package/plugins/gates/hooks/gates/no-reconfirm/test.mjs +0 -131
  70. package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +0 -126
  71. package/plugins/gates/hooks/gates/protected-paths/test.mjs +0 -75
  72. package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +0 -102
  73. package/plugins/gates/hooks/gates/recurrence-lock/test.mjs +0 -99
  74. package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +0 -94
  75. package/plugins/gates/hooks/gates/reuse-before-build/test.mjs +0 -101
  76. package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +0 -116
  77. package/plugins/gates/hooks/gates/risk-level/test.mjs +0 -153
  78. package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +0 -58
  79. package/plugins/gates/hooks/gates/root-cause-first/test.mjs +0 -73
  80. package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +0 -97
  81. package/plugins/gates/hooks/gates/root-whitelist/test.mjs +0 -80
  82. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +0 -126
  83. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/test.mjs +0 -78
  84. package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +0 -130
  85. package/plugins/gates/hooks/gates/sdd-specs/test.mjs +0 -163
  86. package/plugins/gates/hooks/gates/stop-pending/test.mjs +0 -114
  87. package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +0 -72
  88. package/plugins/gates/hooks/gates/test-after-implementation/test.mjs +0 -81
  89. package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +0 -63
  90. package/plugins/gates/hooks/gates/test-matrix/test.mjs +0 -114
  91. package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +0 -62
  92. package/plugins/gates/hooks/gates/tool-map/test.mjs +0 -87
  93. package/plugins/gates/hooks/lib/__tests__/config.test.mjs +0 -173
  94. package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +0 -210
  95. package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +0 -114
  96. package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +0 -189
  97. package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +0 -100
  98. package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +0 -95
  99. package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +0 -180
@@ -1,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
- });