@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/README.es.md +7 -6
- package/README.md +16 -7
- package/cli/index.mjs +4 -0
- package/cli/init.mjs +18 -1
- package/cli/install.mjs +26 -1
- package/package.json +5 -2
- package/plugins/gates/.claude-plugin/plugin.json +1 -1
- package/plugins/gates/hooks/gates/autonomous-mode/stop.mjs +78 -0
- package/plugins/gates/hooks/gates/bash-commands/index.mjs +11 -81
- package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +140 -0
- package/plugins/gates/hooks/gates/dependency-skills/index.mjs +130 -126
- package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +104 -79
- package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +138 -123
- package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +124 -101
- package/plugins/gates/hooks/hooks.json +19 -0
- package/plugins/tasks/.claude-plugin/plugin.json +1 -1
- package/plugins/tasks/hooks/register-requests.mjs +7 -3
- package/registry.json +738 -793
- package/cli/__tests__/config.test.mjs +0 -113
- package/cli/__tests__/init-flags.test.mjs +0 -111
- package/cli/__tests__/install.test.mjs +0 -102
- package/cli/__tests__/materialize.test.mjs +0 -95
- package/cli/__tests__/registry-gates-consistency.test.mjs +0 -52
- package/cli/__tests__/selection.test.mjs +0 -200
- package/cli/__tests__/task.test.mjs +0 -124
- package/cli/__tests__/version-consistency.test.mjs +0 -44
- package/plugins/gates/hooks/__tests__/ask-adoption.test.mjs +0 -83
- package/plugins/gates/hooks/__tests__/doctor.test.mjs +0 -85
- package/plugins/gates/hooks/__tests__/wiring-check.test.mjs +0 -65
- package/plugins/gates/hooks/gates/audit-before-build/audit-before-build.edge.test.mjs +0 -83
- package/plugins/gates/hooks/gates/audit-before-build/test.mjs +0 -117
- package/plugins/gates/hooks/gates/autonomous-mode/autonomous.edge.test.mjs +0 -104
- package/plugins/gates/hooks/gates/autonomous-mode/test.mjs +0 -58
- package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +0 -165
- package/plugins/gates/hooks/gates/bash-commands/test.mjs +0 -126
- package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +0 -151
- package/plugins/gates/hooks/gates/brief-before-delegate/test.mjs +0 -111
- package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +0 -207
- package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +0 -161
- package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +0 -69
- package/plugins/gates/hooks/gates/dependency-skills/test.mjs +0 -109
- package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +0 -68
- package/plugins/gates/hooks/gates/diagnosis-before-patch/test.mjs +0 -68
- package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +0 -83
- package/plugins/gates/hooks/gates/feature-catalog/test.mjs +0 -97
- package/plugins/gates/hooks/gates/force-parallel/test.mjs +0 -88
- package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +0 -155
- package/plugins/gates/hooks/gates/forge-flow/test.mjs +0 -135
- package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +0 -66
- package/plugins/gates/hooks/gates/implementation-pipeline/test.mjs +0 -86
- package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +0 -96
- package/plugins/gates/hooks/gates/intent-flow/test.mjs +0 -165
- package/plugins/gates/hooks/gates/lint-commit/test.mjs +0 -85
- package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +0 -100
- package/plugins/gates/hooks/gates/mandatory-flow/test.mjs +0 -119
- package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +0 -80
- package/plugins/gates/hooks/gates/neutral-spanish/test.mjs +0 -65
- package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +0 -71
- package/plugins/gates/hooks/gates/never-assume/test.mjs +0 -95
- package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +0 -109
- package/plugins/gates/hooks/gates/no-blocking/test.mjs +0 -108
- package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +0 -83
- package/plugins/gates/hooks/gates/no-memory-dependency/test.mjs +0 -106
- package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +0 -91
- package/plugins/gates/hooks/gates/no-reconfirm/test.mjs +0 -131
- package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +0 -126
- package/plugins/gates/hooks/gates/protected-paths/test.mjs +0 -75
- package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +0 -102
- package/plugins/gates/hooks/gates/recurrence-lock/test.mjs +0 -99
- package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +0 -94
- package/plugins/gates/hooks/gates/reuse-before-build/test.mjs +0 -101
- package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +0 -116
- package/plugins/gates/hooks/gates/risk-level/test.mjs +0 -153
- package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +0 -58
- package/plugins/gates/hooks/gates/root-cause-first/test.mjs +0 -73
- package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +0 -124
- package/plugins/gates/hooks/gates/root-whitelist/test.mjs +0 -80
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +0 -126
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/test.mjs +0 -78
- package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +0 -130
- package/plugins/gates/hooks/gates/sdd-specs/test.mjs +0 -163
- package/plugins/gates/hooks/gates/stop-pending/test.mjs +0 -114
- package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +0 -72
- package/plugins/gates/hooks/gates/test-after-implementation/test.mjs +0 -81
- package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +0 -63
- package/plugins/gates/hooks/gates/test-matrix/test.mjs +0 -114
- package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +0 -62
- package/plugins/gates/hooks/gates/tool-map/test.mjs +0 -87
- package/plugins/gates/hooks/lib/__tests__/config.test.mjs +0 -173
- package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +0 -223
- package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +0 -114
- package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +0 -189
- package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +0 -100
- package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +0 -95
- package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +0 -180
package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs
DELETED
|
@@ -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
|
-
});
|