@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.
- 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/index.mjs +8 -6
- 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/root-whitelist/index.mjs +59 -44
- package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +124 -101
- package/plugins/gates/hooks/hooks.json +365 -346
- package/plugins/gates/hooks/lib/hook-io.mjs +39 -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 -97
- 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 -210
- 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
|
@@ -1,114 +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(), 'test-matrix-'));
|
|
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 delegate(prompt, subagentType = 'backend') {
|
|
30
|
-
return {
|
|
31
|
-
tool_name: 'Agent',
|
|
32
|
-
tool_input: { prompt, subagent_type: subagentType },
|
|
33
|
-
};
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function isDeny(result) {
|
|
37
|
-
return result?.hookSpecificOutput?.permissionDecision === 'deny';
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function enabledConfig(extraParameters) {
|
|
41
|
-
return {
|
|
42
|
-
gates: {
|
|
43
|
-
requireTestMatrixWhenImplementing: extraParameters
|
|
44
|
-
? { enabled: true, ...extraParameters }
|
|
45
|
-
: true,
|
|
46
|
-
},
|
|
47
|
-
};
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
test('disabled by default: a money-touching brief with no test matrix is allowed', () => {
|
|
51
|
-
const prompt = 'Nivel: STANDARD\nImplementá el cobro de la cuota.';
|
|
52
|
-
assert.equal(runGate(delegate(prompt)), null);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
test('denies missing unit/mutation on a plain brief with no test type declared', () => {
|
|
56
|
-
const prompt = 'Nivel: STANDARD\nImplementá el checkout.';
|
|
57
|
-
assert.ok(isDeny(runGate(delegate(prompt), { config: enabledConfig() })));
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
test('denies missing E2E when the brief touches money and only declares unit tests', () => {
|
|
61
|
-
const prompt =
|
|
62
|
-
'Nivel: STANDARD\nImplementá el cobro de la cuota. Verificación: vitest cubre el cálculo.';
|
|
63
|
-
assert.ok(isDeny(runGate(delegate(prompt), { config: enabledConfig() })));
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
test('allows a brief that declares unit, E2E and negative cases for a money domain', () => {
|
|
67
|
-
const prompt =
|
|
68
|
-
'Nivel: STANDARD\nImplementá el cobro de la cuota. ' +
|
|
69
|
-
'Verificación: vitest cubre el cálculo. E2E: flujo completo de cobro. ' +
|
|
70
|
-
'Casos negativos: monto cero, sin saldo, error de red.';
|
|
71
|
-
assert.equal(runGate(delegate(prompt), { config: enabledConfig() }), null);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
test('exempt subagent type (qa) is never held to the matrix requirement', () => {
|
|
75
|
-
const prompt = 'Nivel: STANDARD\nImplementá el cobro de la cuota.';
|
|
76
|
-
assert.equal(
|
|
77
|
-
runGate(delegate(prompt, 'qa'), { config: enabledConfig() }),
|
|
78
|
-
null,
|
|
79
|
-
);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
test('denies missing E2E for a Spanish money brief (cobro/saldo/cuota), not just its English equivalent', () => {
|
|
83
|
-
const prompt =
|
|
84
|
-
'Nivel: STANDARD\nImplementá el cobro de la cuota. Verificación: vitest cubre el cálculo.';
|
|
85
|
-
const result = runGate(delegate(prompt), { config: enabledConfig() });
|
|
86
|
-
assert.ok(isDeny(result));
|
|
87
|
-
assert.ok(result.hookSpecificOutput.permissionDecisionReason.includes('E2E'));
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
test('bilingual control: an EN money brief with only unit coverage denies for the same reason as its ES equivalent', () => {
|
|
91
|
-
const es = 'Nivel: STANDARD\nImplementá el cobro de la cuota. Verificación: vitest cubre el cálculo.';
|
|
92
|
-
const en = 'Level: STANDARD\nImplement the fee charge. Verification: vitest covers the calculation.';
|
|
93
|
-
const resultEs = runGate(delegate(es), { config: enabledConfig() });
|
|
94
|
-
const resultEn = runGate(delegate(en), { config: enabledConfig() });
|
|
95
|
-
assert.ok(isDeny(resultEs));
|
|
96
|
-
assert.ok(isDeny(resultEn));
|
|
97
|
-
assert.ok(resultEs.hookSpecificOutput.permissionDecisionReason.includes('E2E'));
|
|
98
|
-
assert.ok(resultEn.hookSpecificOutput.permissionDecisionReason.includes('E2E'));
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
test('allows a Spanish brief that declares unit, E2E and negative cases with Spanish wording', () => {
|
|
102
|
-
const prompt =
|
|
103
|
-
'Nivel: STANDARD\nImplementá el cobro de la cuota. ' +
|
|
104
|
-
'Verificación: pruebas unitarias cubren el calculo. E2E: flujo completo de cobro. ' +
|
|
105
|
-
'Casos negativos: monto cero, sin saldo, entrada invalida.';
|
|
106
|
-
assert.equal(runGate(delegate(prompt), { config: enabledConfig() }), null);
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
test('e2eSignals param override makes a domain word mandatory that the default list omits', () => {
|
|
110
|
-
const prompt =
|
|
111
|
-
'Nivel: STANDARD\nImplementá el módulo de inventario. Verificación: vitest cubre el cálculo.';
|
|
112
|
-
const config = enabledConfig({ e2eSignals: ['inventario|inventory'] });
|
|
113
|
-
assert.ok(isDeny(runGate(delegate(prompt), { config })));
|
|
114
|
-
});
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
// Edge-case probe for tool-map. Previously the same Edit/new_string content-field gap
|
|
2
|
-
// as feature-catalog/sdd-specs meant a legitimately-audited tool created/edited via
|
|
3
|
-
// Edit's `new_string` was never recorded in .ai/tool-map.json, so reuse-before-build
|
|
4
|
-
// kept re-blocking a tool that was already cleared once. Migrated to
|
|
5
|
-
// writtenContentOf()/writtenPathOf() (hook-io.mjs), which read new_string, so this is
|
|
6
|
-
// now recorded the same as a Write.
|
|
7
|
-
import assert from 'node:assert/strict';
|
|
8
|
-
import { execFileSync } from 'node:child_process';
|
|
9
|
-
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
|
|
10
|
-
import { tmpdir } from 'node:os';
|
|
11
|
-
import { dirname, join } from 'node:path';
|
|
12
|
-
import { test } from 'node:test';
|
|
13
|
-
import { fileURLToPath } from 'node:url';
|
|
14
|
-
|
|
15
|
-
const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
|
|
16
|
-
|
|
17
|
-
function runGate(payload) {
|
|
18
|
-
const project = mkdtempSync(join(tmpdir(), 'tool-map-edge-'));
|
|
19
|
-
mkdirSync(join(project, '.git'));
|
|
20
|
-
mkdirSync(join(project, '.ai'));
|
|
21
|
-
writeFileSync(
|
|
22
|
-
join(project, '.ai', 'config.json'),
|
|
23
|
-
JSON.stringify({ gates: { maintainToolMap: true } }),
|
|
24
|
-
);
|
|
25
|
-
execFileSync(process.execPath, [GATE], {
|
|
26
|
-
input: JSON.stringify(payload),
|
|
27
|
-
encoding: 'utf8',
|
|
28
|
-
cwd: project,
|
|
29
|
-
env: { ...process.env, HOME: project, USERPROFILE: project },
|
|
30
|
-
});
|
|
31
|
-
return { mapPath: join(project, '.ai', 'tool-map.json') };
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
test('FIXED: an audited tool written via Edit (new_string) is now recorded', () => {
|
|
35
|
-
const payload = {
|
|
36
|
-
tool_name: 'Edit',
|
|
37
|
-
tool_input: {
|
|
38
|
-
file_path: 'scripts/new-tool.mjs',
|
|
39
|
-
old_string: '',
|
|
40
|
-
new_string: '// audited: nothing does this\nexport function parse(){}',
|
|
41
|
-
},
|
|
42
|
-
};
|
|
43
|
-
const { mapPath } = runGate(payload);
|
|
44
|
-
assert.ok(
|
|
45
|
-
existsSync(mapPath),
|
|
46
|
-
'writtenContentOf must read new_string so an Edit-created tool is persisted',
|
|
47
|
-
);
|
|
48
|
-
const map = JSON.parse(readFileSync(mapPath, 'utf8'));
|
|
49
|
-
assert.equal(map.tools[0].path, 'scripts/new-tool.mjs');
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
test('OK: the equivalent Write-tool payload IS recorded (control)', () => {
|
|
53
|
-
const payload = {
|
|
54
|
-
tool_name: 'Write',
|
|
55
|
-
tool_input: {
|
|
56
|
-
file_path: 'scripts/new-tool.mjs',
|
|
57
|
-
content: '// audited: nothing does this\nexport function parse(){}',
|
|
58
|
-
},
|
|
59
|
-
};
|
|
60
|
-
const { mapPath } = runGate(payload);
|
|
61
|
-
assert.ok(existsSync(mapPath), 'control failed');
|
|
62
|
-
});
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
import assert from 'node:assert/strict';
|
|
2
|
-
import { execFileSync } from 'node:child_process';
|
|
3
|
-
import {
|
|
4
|
-
mkdtempSync,
|
|
5
|
-
mkdirSync,
|
|
6
|
-
writeFileSync,
|
|
7
|
-
existsSync,
|
|
8
|
-
readFileSync,
|
|
9
|
-
} from 'node:fs';
|
|
10
|
-
import { tmpdir } from 'node:os';
|
|
11
|
-
import { dirname, join } from 'node:path';
|
|
12
|
-
import { test } from 'node:test';
|
|
13
|
-
import { fileURLToPath } from 'node:url';
|
|
14
|
-
|
|
15
|
-
const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
|
|
16
|
-
|
|
17
|
-
// Runs the gate in a temp project (with .git). The gate is off by default, so config
|
|
18
|
-
// enables it. Returns { output, mapPath, project } so a test can inspect the written map.
|
|
19
|
-
function runGate(payload, { config } = {}) {
|
|
20
|
-
const project = mkdtempSync(join(tmpdir(), 'tool-map-'));
|
|
21
|
-
mkdirSync(join(project, '.git'));
|
|
22
|
-
mkdirSync(join(project, '.ai'));
|
|
23
|
-
const effectiveConfig = config ?? { gates: { maintainToolMap: true } };
|
|
24
|
-
writeFileSync(
|
|
25
|
-
join(project, '.ai', 'config.json'),
|
|
26
|
-
JSON.stringify(effectiveConfig),
|
|
27
|
-
);
|
|
28
|
-
const output = execFileSync(process.execPath, [GATE], {
|
|
29
|
-
input: JSON.stringify(payload),
|
|
30
|
-
encoding: 'utf8',
|
|
31
|
-
cwd: project,
|
|
32
|
-
// Isolate from the user's real global config: point homedir() at the temp
|
|
33
|
-
// project so the global-config fallback finds nothing (registry default).
|
|
34
|
-
env: { ...process.env, HOME: project, USERPROFILE: project },
|
|
35
|
-
});
|
|
36
|
-
return {
|
|
37
|
-
output: output.trim(),
|
|
38
|
-
mapPath: join(project, '.ai', 'tool-map.json'),
|
|
39
|
-
};
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function writeTool(filePath, content) {
|
|
43
|
-
return { tool_name: 'Write', tool_input: { file_path: filePath, content } };
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
test('records a new tool that declares its audit', () => {
|
|
47
|
-
const { mapPath } = runGate(
|
|
48
|
-
writeTool(
|
|
49
|
-
'scripts/csv-parser.mjs',
|
|
50
|
-
'// Justification: no existing tool covers this.\nexport {}',
|
|
51
|
-
),
|
|
52
|
-
);
|
|
53
|
-
assert.ok(existsSync(mapPath));
|
|
54
|
-
const map = JSON.parse(readFileSync(mapPath, 'utf8'));
|
|
55
|
-
assert.equal(map.tools.length, 1);
|
|
56
|
-
assert.equal(map.tools[0].path, 'scripts/csv-parser.mjs');
|
|
57
|
-
assert.match(map.tools[0].audit, /no existing tool/i);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
test('does not record a tool without an audit line', () => {
|
|
61
|
-
const { mapPath } = runGate(
|
|
62
|
-
writeTool('scripts/thing.mjs', 'export function thing() {}'),
|
|
63
|
-
);
|
|
64
|
-
assert.ok(!existsSync(mapPath));
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
test('ignores a non-tool write', () => {
|
|
68
|
-
const { mapPath } = runGate(writeTool('docs/readme.md', 'audited: hi'));
|
|
69
|
-
assert.ok(!existsSync(mapPath));
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
test('does not duplicate an already-recorded tool', () => {
|
|
73
|
-
// Two writes of the same tool path record it once.
|
|
74
|
-
const first = runGate(
|
|
75
|
-
writeTool('scripts/x.mjs', '// audited: nothing does this\nexport {}'),
|
|
76
|
-
);
|
|
77
|
-
const map = JSON.parse(readFileSync(first.mapPath, 'utf8'));
|
|
78
|
-
assert.equal(map.tools.length, 1);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
test('disabled by config: does not record', () => {
|
|
82
|
-
const { mapPath } = runGate(
|
|
83
|
-
writeTool('scripts/y.mjs', '// audited: none\nexport {}'),
|
|
84
|
-
{ config: { gates: { maintainToolMap: false } } },
|
|
85
|
-
);
|
|
86
|
-
assert.ok(!existsSync(mapPath));
|
|
87
|
-
});
|
|
@@ -1,173 +0,0 @@
|
|
|
1
|
-
import assert from 'node:assert/strict';
|
|
2
|
-
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
3
|
-
import { tmpdir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
-
import { test } from 'node:test';
|
|
6
|
-
import { isGateEnabled, gateParameters } from '../config.mjs';
|
|
7
|
-
|
|
8
|
-
const GATE_KEY = 'blockDestructiveShellCommands';
|
|
9
|
-
|
|
10
|
-
function temporaryDirectory() {
|
|
11
|
-
return mkdtempSync(join(tmpdir(), 'claude-gates-config-'));
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/** A project directory with a .git marker so config.mjs finds a project root. */
|
|
15
|
-
function projectDirectory() {
|
|
16
|
-
const root = temporaryDirectory();
|
|
17
|
-
mkdirSync(join(root, '.git'));
|
|
18
|
-
return root;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function writeProjectConfig(root, config) {
|
|
22
|
-
mkdirSync(join(root, '.ai'), { recursive: true });
|
|
23
|
-
writeFileSync(join(root, '.ai', 'config.json'), JSON.stringify(config));
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function writeGlobalConfig(home, config) {
|
|
27
|
-
const directory = join(home, '.claude', 'claude-gates');
|
|
28
|
-
mkdirSync(directory, { recursive: true });
|
|
29
|
-
writeFileSync(join(directory, 'config.json'), JSON.stringify(config));
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
test('isGateEnabled falls back to the registry default when no config exists anywhere', () => {
|
|
33
|
-
const project = projectDirectory();
|
|
34
|
-
const home = temporaryDirectory();
|
|
35
|
-
assert.equal(isGateEnabled(GATE_KEY, true, project, { home }), true);
|
|
36
|
-
assert.equal(isGateEnabled(GATE_KEY, false, project, { home }), false);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
test('isGateEnabled reads a bool shorthand entry from the project config', () => {
|
|
40
|
-
const project = projectDirectory();
|
|
41
|
-
const home = temporaryDirectory();
|
|
42
|
-
writeProjectConfig(project, { gates: { [GATE_KEY]: false } });
|
|
43
|
-
assert.equal(isGateEnabled(GATE_KEY, true, project, { home }), false);
|
|
44
|
-
|
|
45
|
-
const projectTrue = projectDirectory();
|
|
46
|
-
writeProjectConfig(projectTrue, { gates: { [GATE_KEY]: true } });
|
|
47
|
-
assert.equal(isGateEnabled(GATE_KEY, false, projectTrue, { home }), true);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
test('isGateEnabled reads an object entry with an explicit enabled flag', () => {
|
|
51
|
-
const project = projectDirectory();
|
|
52
|
-
const home = temporaryDirectory();
|
|
53
|
-
writeProjectConfig(project, {
|
|
54
|
-
gates: { [GATE_KEY]: { enabled: false, someParam: 'x' } },
|
|
55
|
-
});
|
|
56
|
-
assert.equal(isGateEnabled(GATE_KEY, true, project, { home }), false);
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
test('isGateEnabled treats an object entry with no enabled field as silent (uses registry default)', () => {
|
|
60
|
-
const project = projectDirectory();
|
|
61
|
-
const home = temporaryDirectory();
|
|
62
|
-
writeProjectConfig(project, {
|
|
63
|
-
gates: { [GATE_KEY]: { someParam: 'x' } },
|
|
64
|
-
});
|
|
65
|
-
assert.equal(isGateEnabled(GATE_KEY, true, project, { home }), true);
|
|
66
|
-
assert.equal(isGateEnabled(GATE_KEY, false, project, { home }), false);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
test('isGateEnabled falls back to the global config when the project declares nothing', () => {
|
|
70
|
-
const project = projectDirectory();
|
|
71
|
-
const home = temporaryDirectory();
|
|
72
|
-
writeGlobalConfig(home, { gates: { [GATE_KEY]: false } });
|
|
73
|
-
assert.equal(isGateEnabled(GATE_KEY, true, project, { home }), false);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
test('isGateEnabled: an existing project config wins over the global config even if silent about this gate', () => {
|
|
77
|
-
const project = projectDirectory();
|
|
78
|
-
const home = temporaryDirectory();
|
|
79
|
-
writeGlobalConfig(home, { gates: { [GATE_KEY]: false } });
|
|
80
|
-
writeProjectConfig(project, { gates: {} });
|
|
81
|
-
// Project config exists (even without this gate declared): registry default wins,
|
|
82
|
-
// global's false must NOT leak through.
|
|
83
|
-
assert.equal(isGateEnabled(GATE_KEY, true, project, { home }), true);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
test('isGateEnabled: corrupt project config is treated as absent and falls through to global', () => {
|
|
87
|
-
const project = projectDirectory();
|
|
88
|
-
const home = temporaryDirectory();
|
|
89
|
-
mkdirSync(join(project, '.ai'), { recursive: true });
|
|
90
|
-
writeFileSync(join(project, '.ai', 'config.json'), '{not valid json');
|
|
91
|
-
writeGlobalConfig(home, { gates: { [GATE_KEY]: false } });
|
|
92
|
-
assert.equal(isGateEnabled(GATE_KEY, true, project, { home }), false);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
test('isGateEnabled: a malformed entry (string/number) is ignored, falls back to registry default', () => {
|
|
96
|
-
const project = projectDirectory();
|
|
97
|
-
const home = temporaryDirectory();
|
|
98
|
-
writeProjectConfig(project, { gates: { [GATE_KEY]: 'yes' } });
|
|
99
|
-
assert.equal(isGateEnabled(GATE_KEY, true, project, { home }), true);
|
|
100
|
-
assert.equal(isGateEnabled(GATE_KEY, false, project, { home }), false);
|
|
101
|
-
|
|
102
|
-
const projectNumber = projectDirectory();
|
|
103
|
-
writeProjectConfig(projectNumber, { gates: { [GATE_KEY]: 1 } });
|
|
104
|
-
assert.equal(isGateEnabled(GATE_KEY, false, projectNumber, { home }), false);
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
test('gateParameters returns {} when nothing is declared for the gate', () => {
|
|
108
|
-
const project = projectDirectory();
|
|
109
|
-
const home = temporaryDirectory();
|
|
110
|
-
writeProjectConfig(project, { gates: {} });
|
|
111
|
-
assert.deepEqual(gateParameters(GATE_KEY, project, { home }), {});
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
test('gateParameters returns {} for a bool shorthand entry (no params, just enabled)', () => {
|
|
115
|
-
const project = projectDirectory();
|
|
116
|
-
const home = temporaryDirectory();
|
|
117
|
-
writeProjectConfig(project, { gates: { [GATE_KEY]: true } });
|
|
118
|
-
assert.deepEqual(gateParameters(GATE_KEY, project, { home }), {});
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
test('gateParameters strips enabled and returns the remaining params from the project config', () => {
|
|
122
|
-
const project = projectDirectory();
|
|
123
|
-
const home = temporaryDirectory();
|
|
124
|
-
writeProjectConfig(project, {
|
|
125
|
-
gates: {
|
|
126
|
-
[GATE_KEY]: { enabled: true, protectedPaths: ['a', 'b'] },
|
|
127
|
-
},
|
|
128
|
-
});
|
|
129
|
-
assert.deepEqual(gateParameters(GATE_KEY, project, { home }), {
|
|
130
|
-
protectedPaths: ['a', 'b'],
|
|
131
|
-
});
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
test('gateParameters: project params replace (do not merge with) global params', () => {
|
|
135
|
-
const project = projectDirectory();
|
|
136
|
-
const home = temporaryDirectory();
|
|
137
|
-
writeGlobalConfig(home, {
|
|
138
|
-
gates: {
|
|
139
|
-
[GATE_KEY]: { protectedPaths: ['global-a'], extra: 'g' },
|
|
140
|
-
},
|
|
141
|
-
});
|
|
142
|
-
writeProjectConfig(project, {
|
|
143
|
-
gates: { [GATE_KEY]: { protectedPaths: ['project-a'] } },
|
|
144
|
-
});
|
|
145
|
-
assert.deepEqual(gateParameters(GATE_KEY, project, { home }), {
|
|
146
|
-
protectedPaths: ['project-a'],
|
|
147
|
-
});
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
test('gateParameters returns {} when no config exists anywhere', () => {
|
|
151
|
-
const project = projectDirectory();
|
|
152
|
-
const home = temporaryDirectory();
|
|
153
|
-
assert.deepEqual(gateParameters(GATE_KEY, project, { home }), {});
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
test('a project config saved WITH a UTF-8 BOM is still read (not silently dropped)', () => {
|
|
157
|
-
// A BOM is what a Windows editor or PowerShell 5.1 `Set-Content -Encoding utf8` prepends.
|
|
158
|
-
// Before the fix, JSON.parse threw on the leading BOM, the project config was treated as
|
|
159
|
-
// absent, and its gate disables were silently ignored (falling through to global/registry).
|
|
160
|
-
// Here the project disables the gate; the BOM must not resurrect it.
|
|
161
|
-
const project = projectDirectory();
|
|
162
|
-
const home = temporaryDirectory();
|
|
163
|
-
mkdirSync(join(project, '.ai'), { recursive: true });
|
|
164
|
-
writeFileSync(
|
|
165
|
-
join(project, '.ai', 'config.json'),
|
|
166
|
-
'' + JSON.stringify({ gates: { [GATE_KEY]: { enabled: false } } }),
|
|
167
|
-
);
|
|
168
|
-
assert.equal(
|
|
169
|
-
isGateEnabled(GATE_KEY, true, project, { home }),
|
|
170
|
-
false,
|
|
171
|
-
'the BOM-prefixed project disable must take effect',
|
|
172
|
-
);
|
|
173
|
-
});
|
|
@@ -1,210 +0,0 @@
|
|
|
1
|
-
import assert from 'node:assert/strict';
|
|
2
|
-
import { execFileSync } from 'node:child_process';
|
|
3
|
-
import { test } from 'node:test';
|
|
4
|
-
import { pathToFileURL } from 'node:url';
|
|
5
|
-
import {
|
|
6
|
-
toolNamesFor,
|
|
7
|
-
matcherFor,
|
|
8
|
-
toolNameOf,
|
|
9
|
-
sessionIdOf,
|
|
10
|
-
toolInputOf,
|
|
11
|
-
toolInGroups,
|
|
12
|
-
writtenContentOf,
|
|
13
|
-
writtenPathOf,
|
|
14
|
-
delegationPromptOf,
|
|
15
|
-
} from '../hook-io.mjs';
|
|
16
|
-
|
|
17
|
-
// deny/warn/allow/runGate call process.exit and cannot be invoked in-process without
|
|
18
|
-
// killing the test runner. They are exercised here as a child process instead; each
|
|
19
|
-
// gate's own tests additionally cover runGate's wiring through its real check function.
|
|
20
|
-
|
|
21
|
-
test('toolNamesFor expands a single group', () => {
|
|
22
|
-
assert.deepEqual(toolNamesFor(['question']), ['AskUserQuestion']);
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
test('toolNamesFor expands multiple groups and de-duplicates overlapping names', () => {
|
|
26
|
-
const names = toolNamesFor(['write', 'execution']);
|
|
27
|
-
// 'execution' repeats every 'write' tool plus Bash/run_command/mcp__ide__executeCode.
|
|
28
|
-
assert.equal(names.filter((name) => name === 'Write').length, 1);
|
|
29
|
-
assert.ok(names.includes('Write'));
|
|
30
|
-
assert.ok(names.includes('Bash'));
|
|
31
|
-
assert.ok(names.includes('mcp__ide__executeCode'));
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
test('toolNamesFor returns an empty list for an empty group set', () => {
|
|
35
|
-
assert.deepEqual(toolNamesFor([]), []);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
test('toolNamesFor ignores an unknown group name', () => {
|
|
39
|
-
assert.deepEqual(toolNamesFor(['not-a-real-group']), []);
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
test('matcherFor joins native tool names with a pipe and always appends the mcp__.* clause', () => {
|
|
43
|
-
// The trailing `mcp__.*` makes Claude Code route EVERY MCP tool call to the hook, so the
|
|
44
|
-
// gate can classify it at runtime with toolInGroups. Without it, the hook is never even
|
|
45
|
-
// invoked for an MCP tool — the deepest layer of the MCP blind spot.
|
|
46
|
-
assert.equal(matcherFor(['question']), 'AskUserQuestion|mcp__.*');
|
|
47
|
-
assert.equal(matcherFor(['shell']), 'Bash|run_command|mcp__.*');
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
test('matcherFor still matches MCP tools even for an empty native group set', () => {
|
|
51
|
-
// An empty group means "no native names", but MCP tools must still reach the hook.
|
|
52
|
-
assert.equal(matcherFor([]), 'mcp__.*');
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
test('toolInGroups matches native names exactly and case-insensitively', () => {
|
|
56
|
-
assert.equal(toolInGroups('Write', ['write']), true);
|
|
57
|
-
assert.equal(toolInGroups('write', ['write']), true); // case-insensitive
|
|
58
|
-
assert.equal(toolInGroups('AskUserQuestion', ['question']), true);
|
|
59
|
-
assert.equal(toolInGroups('Bash', ['write']), false); // wrong group
|
|
60
|
-
assert.equal(toolInGroups('', ['write']), false); // empty tool name
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
test('toolInGroups classifies MCP tools by their action segment', () => {
|
|
64
|
-
assert.equal(toolInGroups('mcp__filesystem__write_file', ['write']), true);
|
|
65
|
-
assert.equal(toolInGroups('mcp__fs__edit_file', ['write']), true);
|
|
66
|
-
assert.equal(toolInGroups('mcp__shell__exec', ['shell']), true);
|
|
67
|
-
assert.equal(toolInGroups('mcp__x__ask_user_confirmation', ['question']), true);
|
|
68
|
-
assert.equal(toolInGroups('mcp__orchestrator__spawn_agent', ['delegation']), true);
|
|
69
|
-
// A read-only MCP tool must NOT match a write/shell group.
|
|
70
|
-
assert.equal(toolInGroups('mcp__fs__read_file', ['write', 'shell']), false);
|
|
71
|
-
// Malformed mcp name (no action segment) does not match.
|
|
72
|
-
assert.equal(toolInGroups('mcp__server', ['write']), false);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
test('writtenContentOf reads content across native and MCP field shapes', () => {
|
|
76
|
-
assert.equal(writtenContentOf({ content: 'a' }), 'a'); // Write
|
|
77
|
-
assert.equal(writtenContentOf({ new_string: 'b' }), 'b'); // Edit
|
|
78
|
-
assert.equal(writtenContentOf({ new_source: 'c' }), 'c'); // NotebookEdit
|
|
79
|
-
assert.equal(writtenContentOf({ new_content: 'd' }), 'd'); // replace_file_content
|
|
80
|
-
assert.equal(writtenContentOf({ text: 'e' }), 'e'); // an MCP shape
|
|
81
|
-
assert.equal(
|
|
82
|
-
writtenContentOf({ edits: [{ new_string: 'x' }, { new_string: 'y' }] }),
|
|
83
|
-
'x\ny',
|
|
84
|
-
); // MultiEdit
|
|
85
|
-
assert.equal(writtenContentOf({}), '');
|
|
86
|
-
assert.equal(writtenContentOf(null), '');
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
test('writtenPathOf reads the target path across field shapes', () => {
|
|
90
|
-
assert.equal(writtenPathOf({ file_path: 'a.js' }), 'a.js');
|
|
91
|
-
assert.equal(writtenPathOf({ path: 'b.js' }), 'b.js');
|
|
92
|
-
assert.equal(writtenPathOf({ notebook_path: 'n.ipynb' }), 'n.ipynb');
|
|
93
|
-
assert.equal(writtenPathOf({}), '');
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
test('delegationPromptOf reads the brief across field shapes', () => {
|
|
97
|
-
assert.equal(delegationPromptOf({ prompt: 'a' }), 'a');
|
|
98
|
-
assert.equal(delegationPromptOf({ description: 'b' }), 'b'); // Task
|
|
99
|
-
assert.equal(delegationPromptOf({ instructions: 'c' }), 'c'); // an MCP shape
|
|
100
|
-
assert.equal(delegationPromptOf({}), '');
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
test('toolNameOf reads tool_name, falls back to name, defaults to empty string', () => {
|
|
104
|
-
assert.equal(toolNameOf(JSON.stringify({ tool_name: 'Bash' })), 'Bash');
|
|
105
|
-
assert.equal(toolNameOf(JSON.stringify({ name: 'Edit' })), 'Edit');
|
|
106
|
-
assert.equal(
|
|
107
|
-
toolNameOf(JSON.stringify({ tool_name: 'Bash', name: 'Edit' })),
|
|
108
|
-
'Bash',
|
|
109
|
-
);
|
|
110
|
-
assert.equal(toolNameOf(JSON.stringify({})), '');
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
test('toolNameOf returns null for unparseable payloads', () => {
|
|
114
|
-
assert.equal(toolNameOf('{not json'), null);
|
|
115
|
-
assert.equal(toolNameOf(undefined), null);
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
test('sessionIdOf reads session_id or returns null when absent/unparseable', () => {
|
|
119
|
-
assert.equal(sessionIdOf(JSON.stringify({ session_id: 'abc123' })), 'abc123');
|
|
120
|
-
assert.equal(sessionIdOf(JSON.stringify({})), null);
|
|
121
|
-
assert.equal(sessionIdOf('{not json'), null);
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
test('toolInputOf reads tool_input, falls back to input, defaults to {}', () => {
|
|
125
|
-
assert.deepEqual(
|
|
126
|
-
toolInputOf(JSON.stringify({ tool_input: { command: 'ls' } })),
|
|
127
|
-
{ command: 'ls' },
|
|
128
|
-
);
|
|
129
|
-
assert.deepEqual(toolInputOf(JSON.stringify({ input: { file: 'a' } })), {
|
|
130
|
-
file: 'a',
|
|
131
|
-
});
|
|
132
|
-
assert.deepEqual(
|
|
133
|
-
toolInputOf(JSON.stringify({ tool_input: { a: 1 }, input: { b: 2 } })),
|
|
134
|
-
{ a: 1 },
|
|
135
|
-
);
|
|
136
|
-
assert.deepEqual(toolInputOf(JSON.stringify({})), {});
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
test('toolInputOf returns {} for unparseable payloads', () => {
|
|
140
|
-
assert.deepEqual(toolInputOf('{not json'), {});
|
|
141
|
-
assert.deepEqual(toolInputOf(undefined), {});
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
const hookIoModuleUrl = pathToFileURL(
|
|
145
|
-
new URL('../hook-io.mjs', import.meta.url).pathname.replace(
|
|
146
|
-
/^\/([A-Za-z]:)/,
|
|
147
|
-
'$1',
|
|
148
|
-
),
|
|
149
|
-
).href;
|
|
150
|
-
|
|
151
|
-
/** Runs `body` (a callback expression referencing `mod`) in a child process after
|
|
152
|
-
* dynamically importing hook-io.mjs there, so process.exit-calling exports can be
|
|
153
|
-
* exercised without killing the test runner. */
|
|
154
|
-
function runInChildProcess(body, execOptions) {
|
|
155
|
-
const script = `import('${hookIoModuleUrl}').then((mod) => { ${body} });`;
|
|
156
|
-
return execFileSync(process.execPath, ['--input-type=module', '-e', script], {
|
|
157
|
-
encoding: 'utf8',
|
|
158
|
-
...execOptions,
|
|
159
|
-
});
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
test('readHookPayload reads whatever is piped on stdin, including empty input', () => {
|
|
163
|
-
const output = runInChildProcess(
|
|
164
|
-
'process.stdout.write(JSON.stringify(mod.readHookPayload()))',
|
|
165
|
-
{ input: '{"tool_name":"Bash"}' },
|
|
166
|
-
);
|
|
167
|
-
assert.equal(output, JSON.stringify('{"tool_name":"Bash"}'));
|
|
168
|
-
|
|
169
|
-
const emptyOutput = runInChildProcess(
|
|
170
|
-
'process.stdout.write(JSON.stringify(mod.readHookPayload()))',
|
|
171
|
-
{ stdio: ['ignore', 'pipe', 'pipe'] },
|
|
172
|
-
);
|
|
173
|
-
// With no stdin available, readFileSync(0) resolves to an empty string rather than
|
|
174
|
-
// throwing on this platform, so readHookPayload returns '' — not null. The null
|
|
175
|
-
// path (an unreadable fd 0) is exercised indirectly by every gate's own tests via
|
|
176
|
-
// runGate, since forcing an EBADF/EAGAIN on fd 0 portably from node:test is not
|
|
177
|
-
// reliable across platforms.
|
|
178
|
-
assert.equal(emptyOutput, JSON.stringify(''));
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
test('deny writes a permissionDecision:deny hookSpecificOutput and exits 0', () => {
|
|
182
|
-
const output = runInChildProcess("mod.deny('my-gate', 'because reasons')", {
|
|
183
|
-
input: '',
|
|
184
|
-
});
|
|
185
|
-
const parsed = JSON.parse(output);
|
|
186
|
-
assert.equal(parsed.hookSpecificOutput.hookEventName, 'PreToolUse');
|
|
187
|
-
assert.equal(parsed.hookSpecificOutput.permissionDecision, 'deny');
|
|
188
|
-
assert.equal(
|
|
189
|
-
parsed.hookSpecificOutput.permissionDecisionReason,
|
|
190
|
-
'[my-gate] because reasons',
|
|
191
|
-
);
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
test('warn writes additionalContext and exits 0', () => {
|
|
195
|
-
const output = runInChildProcess("mod.warn('my-gate', 'consider this')", {
|
|
196
|
-
input: '',
|
|
197
|
-
});
|
|
198
|
-
const parsed = JSON.parse(output);
|
|
199
|
-
assert.equal(parsed.hookSpecificOutput.hookEventName, 'PreToolUse');
|
|
200
|
-
assert.equal(
|
|
201
|
-
parsed.hookSpecificOutput.additionalContext,
|
|
202
|
-
'[my-gate] consider this',
|
|
203
|
-
);
|
|
204
|
-
assert.equal(parsed.hookSpecificOutput.permissionDecision, undefined);
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
test('allow exits 0 with no stdout', () => {
|
|
208
|
-
const output = runInChildProcess('mod.allow()', { input: '' });
|
|
209
|
-
assert.equal(output, '');
|
|
210
|
-
});
|