@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,108 +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
|
-
// Runs the gate as its own process (it calls process.exit), feeding a payload on stdin
|
|
12
|
-
// from inside a temp project so the gate's config lookup is isolated. Returns the parsed
|
|
13
|
-
// stdout, or null when the gate allowed (no output).
|
|
14
|
-
function runGate(payload, { config } = {}) {
|
|
15
|
-
const project = mkdtempSync(join(tmpdir(), 'no-blocking-'));
|
|
16
|
-
mkdirSync(join(project, '.git'));
|
|
17
|
-
if (config) {
|
|
18
|
-
mkdirSync(join(project, '.ai'));
|
|
19
|
-
writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
|
|
20
|
-
}
|
|
21
|
-
const out = execFileSync(process.execPath, [GATE], {
|
|
22
|
-
input: JSON.stringify(payload),
|
|
23
|
-
encoding: 'utf8',
|
|
24
|
-
cwd: project,
|
|
25
|
-
// Isolate from the user's real global config: point homedir() at the temp
|
|
26
|
-
// project so the global-config fallback finds nothing (registry default).
|
|
27
|
-
env: { ...process.env, HOME: project, USERPROFILE: project },
|
|
28
|
-
});
|
|
29
|
-
return out.trim() ? JSON.parse(out.trim()) : null;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const ENABLED = { gates: { blockWaitingCommands: true } };
|
|
33
|
-
|
|
34
|
-
function bash(command, extra = {}) {
|
|
35
|
-
return { tool_name: 'Bash', tool_input: { command, ...extra } };
|
|
36
|
-
}
|
|
37
|
-
function delegate(prompt) {
|
|
38
|
-
return { tool_name: 'Agent', tool_input: { prompt } };
|
|
39
|
-
}
|
|
40
|
-
function isDeny(result) {
|
|
41
|
-
return result?.hookSpecificOutput?.permissionDecision === 'deny';
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
test('denies sleep in foreground, a dev server, and a polling loop', () => {
|
|
45
|
-
assert.ok(isDeny(runGate(bash('sleep 30'), { config: ENABLED })));
|
|
46
|
-
assert.ok(isDeny(runGate(bash('npm run dev'), { config: ENABLED })));
|
|
47
|
-
assert.ok(
|
|
48
|
-
isDeny(
|
|
49
|
-
runGate(bash('until curl -sf localhost:3000; do sleep 2; done'), {
|
|
50
|
-
config: ENABLED,
|
|
51
|
-
}),
|
|
52
|
-
),
|
|
53
|
-
);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
test('allows an innocuous command and a backgrounded long-runner', () => {
|
|
57
|
-
assert.equal(runGate(bash('git status'), { config: ENABLED }), null);
|
|
58
|
-
assert.equal(
|
|
59
|
-
runGate(bash('npm run dev', { run_in_background: true }), {
|
|
60
|
-
config: ENABLED,
|
|
61
|
-
}),
|
|
62
|
-
null,
|
|
63
|
-
);
|
|
64
|
-
assert.equal(runGate(bash('sleep 30 &'), { config: ENABLED }), null);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
test('disabled by default: the gate does not run without opting in', () => {
|
|
68
|
-
assert.equal(runGate(bash('sleep 30')), null);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
test('a delegation prompt telling the subagent to sleep-wait is denied', () => {
|
|
72
|
-
assert.ok(
|
|
73
|
-
isDeny(
|
|
74
|
-
runGate(
|
|
75
|
-
delegate('Please then sleep 60 seconds until the server is ready.'),
|
|
76
|
-
{
|
|
77
|
-
config: ENABLED,
|
|
78
|
-
},
|
|
79
|
-
),
|
|
80
|
-
),
|
|
81
|
-
);
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
test('the wait-justified marker with a reason escapes the block', () => {
|
|
85
|
-
assert.equal(
|
|
86
|
-
runGate(
|
|
87
|
-
bash('sleep 30 # WAIT-JUSTIFIED: waiting for migration lock release'),
|
|
88
|
-
{
|
|
89
|
-
config: ENABLED,
|
|
90
|
-
},
|
|
91
|
-
),
|
|
92
|
-
null,
|
|
93
|
-
);
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
test('project blockingPatterns replaces the built-in list', () => {
|
|
97
|
-
const config = {
|
|
98
|
-
gates: {
|
|
99
|
-
blockWaitingCommands: {
|
|
100
|
-
enabled: true,
|
|
101
|
-
blockingPatterns: [String.raw`\bcustom-block\b`],
|
|
102
|
-
},
|
|
103
|
-
},
|
|
104
|
-
};
|
|
105
|
-
// sleep no longer denied under the override
|
|
106
|
-
assert.equal(runGate(bash('sleep 30'), { config }), null);
|
|
107
|
-
assert.ok(isDeny(runGate(bash('custom-block now'), { config })));
|
|
108
|
-
});
|
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
// Edge-case audit for no-memory-dependency. This gate only warns, never denies, so a
|
|
2
|
-
// "bypass" here means the warning silently fails to fire for a prompt that really does
|
|
3
|
-
// depend on the model's memory, not that anything unsafe was blocked.
|
|
4
|
-
// Run: node --test no-memory-dependency.edge.test.mjs
|
|
5
|
-
import assert from 'node:assert/strict';
|
|
6
|
-
import { execFileSync } from 'node:child_process';
|
|
7
|
-
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
8
|
-
import { tmpdir } from 'node:os';
|
|
9
|
-
import { dirname, join } from 'node:path';
|
|
10
|
-
import { test } from 'node:test';
|
|
11
|
-
import { fileURLToPath } from 'node:url';
|
|
12
|
-
|
|
13
|
-
const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
|
|
14
|
-
|
|
15
|
-
function runGate(payload, { config } = {}) {
|
|
16
|
-
const project = mkdtempSync(join(tmpdir(), 'no-memory-dependency-edge-'));
|
|
17
|
-
mkdirSync(join(project, '.git'));
|
|
18
|
-
if (config) {
|
|
19
|
-
mkdirSync(join(project, '.ai'));
|
|
20
|
-
writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
|
|
21
|
-
}
|
|
22
|
-
const out = execFileSync(process.execPath, [GATE], {
|
|
23
|
-
input: JSON.stringify(payload),
|
|
24
|
-
encoding: 'utf8',
|
|
25
|
-
cwd: project,
|
|
26
|
-
env: { ...process.env, HOME: project, USERPROFILE: project },
|
|
27
|
-
});
|
|
28
|
-
return out.trim() ? JSON.parse(out.trim()) : null;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function isWarn(result) {
|
|
32
|
-
return typeof result?.hookSpecificOutput?.additionalContext === 'string';
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const ENABLED = { config: { gates: { warnMemoryDependencyInBrief: true } } };
|
|
36
|
-
const MEMORY_PROMPT = 'Acordate de lo que hablamos antes y aplica el mismo criterio.';
|
|
37
|
-
|
|
38
|
-
test('FIXED: a delegation tool name outside the native list is now caught via toolInGroups (MCP delegation signal)', () => {
|
|
39
|
-
const result = runGate(
|
|
40
|
-
{ tool_name: 'mcp__orchestrator__spawn_agent', tool_input: { prompt: MEMORY_PROMPT } },
|
|
41
|
-
ENABLED,
|
|
42
|
-
);
|
|
43
|
-
assert.ok(isWarn(result), 'gate now warns for the memory-dependency prompt under the MCP tool name too');
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
test('FIXED: prompt carried in a field other than prompt/description/task is now read via delegationPromptOf', () => {
|
|
47
|
-
const result = runGate(
|
|
48
|
-
{ tool_name: 'Agent', tool_input: { instructions: MEMORY_PROMPT } },
|
|
49
|
-
ENABLED,
|
|
50
|
-
);
|
|
51
|
-
assert.ok(isWarn(result), 'gate now sees the prompt under "instructions" and warns');
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
test('FIXED: a persistence noun with no verb no longer suppresses the warning', () => {
|
|
55
|
-
// hasPersistenceInstructionNearby now requires an imperative persistence VERB nearby,
|
|
56
|
-
// not merely a noun. A prompt that mentions an unrelated file's name within the window
|
|
57
|
-
// (with no verb instructing anything be saved) no longer suppresses the warning.
|
|
58
|
-
const prompt =
|
|
59
|
-
'Acordate de lo que dijimos del login (el reporte esta en incident.md) y aplica el ' +
|
|
60
|
-
'mismo criterio de siempre.';
|
|
61
|
-
const result = runGate({ tool_name: 'Agent', tool_input: { prompt } }, ENABLED);
|
|
62
|
-
assert.ok(
|
|
63
|
-
isWarn(result),
|
|
64
|
-
'gate now warns: "incident.md" nearby with no persistence verb is not a real persistence instruction',
|
|
65
|
-
);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
test('OK: a genuine memory-dependency phrase via Agent is warned', () => {
|
|
69
|
-
assert.ok(isWarn(runGate({ tool_name: 'Agent', tool_input: { prompt: MEMORY_PROMPT } }, ENABLED)));
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
test('OK: a memory phrase paired with a real persistence verb still suppresses the warning', () => {
|
|
73
|
-
const result = runGate(
|
|
74
|
-
{
|
|
75
|
-
tool_name: 'Agent',
|
|
76
|
-
tool_input: {
|
|
77
|
-
prompt: 'No te olvides de guardar la decision en .ai/decision.md antes de continuar.',
|
|
78
|
-
},
|
|
79
|
-
},
|
|
80
|
-
ENABLED,
|
|
81
|
-
);
|
|
82
|
-
assert.equal(result, null);
|
|
83
|
-
});
|
|
@@ -1,106 +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(), 'no-memory-dependency-'));
|
|
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) {
|
|
30
|
-
return { tool_name: 'Agent', tool_input: { prompt } };
|
|
31
|
-
}
|
|
32
|
-
function isWarn(result) {
|
|
33
|
-
return typeof result?.hookSpecificOutput?.additionalContext === 'string';
|
|
34
|
-
}
|
|
35
|
-
function isDeny(result) {
|
|
36
|
-
return result?.hookSpecificOutput?.permissionDecision === 'deny';
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const ENABLED = { config: { gates: { warnMemoryDependencyInBrief: true } } };
|
|
40
|
-
|
|
41
|
-
test('warns (never denies) when the brief leans on remembered context', () => {
|
|
42
|
-
const result = runGate(
|
|
43
|
-
delegate('Acordate de lo que hablamos antes y aplica el mismo criterio.'),
|
|
44
|
-
ENABLED,
|
|
45
|
-
);
|
|
46
|
-
assert.ok(isWarn(result));
|
|
47
|
-
assert.ok(!isDeny(result));
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
test('allows a brief with no memory-dependency phrase', () => {
|
|
51
|
-
assert.equal(
|
|
52
|
-
runGate(
|
|
53
|
-
delegate('Implementa el endpoint de login segun el contrato adjunto.'),
|
|
54
|
-
ENABLED,
|
|
55
|
-
),
|
|
56
|
-
null,
|
|
57
|
-
);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
test('allows a memory phrase paired with a deterministic persistence instruction', () => {
|
|
61
|
-
assert.equal(
|
|
62
|
-
runGate(
|
|
63
|
-
delegate(
|
|
64
|
-
'No te olvides de guardar la decision en .ai/decision.md antes de continuar.',
|
|
65
|
-
),
|
|
66
|
-
ENABLED,
|
|
67
|
-
),
|
|
68
|
-
null,
|
|
69
|
-
);
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
test('disabled by config: the gate does not run', () => {
|
|
73
|
-
assert.equal(
|
|
74
|
-
runGate(
|
|
75
|
-
delegate('Acordate de lo que hablamos antes y aplica el mismo criterio.'),
|
|
76
|
-
{
|
|
77
|
-
config: { gates: { warnMemoryDependencyInBrief: false } },
|
|
78
|
-
},
|
|
79
|
-
),
|
|
80
|
-
null,
|
|
81
|
-
);
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
test('project memoryDependencyPatterns override replaces the built-in list', () => {
|
|
85
|
-
const config = {
|
|
86
|
-
gates: {
|
|
87
|
-
warnMemoryDependencyInBrief: {
|
|
88
|
-
enabled: true,
|
|
89
|
-
memoryDependencyPatterns: ['como quedamos'],
|
|
90
|
-
},
|
|
91
|
-
},
|
|
92
|
-
};
|
|
93
|
-
// No longer matches the built-in "acordate de" phrase.
|
|
94
|
-
assert.equal(
|
|
95
|
-
runGate(
|
|
96
|
-
delegate('Acordate de lo que hablamos antes y aplica el mismo criterio.'),
|
|
97
|
-
{ config },
|
|
98
|
-
),
|
|
99
|
-
null,
|
|
100
|
-
);
|
|
101
|
-
assert.ok(
|
|
102
|
-
isWarn(
|
|
103
|
-
runGate(delegate('Hacelo como quedamos la ultima vez.'), { config }),
|
|
104
|
-
),
|
|
105
|
-
);
|
|
106
|
-
});
|
|
@@ -1,91 +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 newProject({ config } = {}) {
|
|
12
|
-
const project = mkdtempSync(join(tmpdir(), 'no-reconfirm-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
|
-
return project;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function writeTranscript(project, events) {
|
|
22
|
-
const transcriptPath = join(project, 'transcript.jsonl');
|
|
23
|
-
writeFileSync(transcriptPath, events.map((e) => JSON.stringify(e)).join('\n'));
|
|
24
|
-
return transcriptPath;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function humanTurn(text) {
|
|
28
|
-
return { type: 'user', userType: 'external', message: { content: text } };
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function askQuestion(transcriptPath, question) {
|
|
32
|
-
return {
|
|
33
|
-
tool_name: 'AskUserQuestion',
|
|
34
|
-
transcript_path: transcriptPath,
|
|
35
|
-
tool_input: { questions: [question] },
|
|
36
|
-
};
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function runGateIn(project, payload) {
|
|
40
|
-
const out = execFileSync(process.execPath, [GATE], {
|
|
41
|
-
input: JSON.stringify(payload),
|
|
42
|
-
encoding: 'utf8',
|
|
43
|
-
cwd: project,
|
|
44
|
-
env: { ...process.env, HOME: project, USERPROFILE: project },
|
|
45
|
-
});
|
|
46
|
-
return out.trim() ? JSON.parse(out.trim()) : null;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function isWarn(result) {
|
|
50
|
-
return result?.hookSpecificOutput?.additionalContext !== undefined;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// EDGE CASE (BUG, confirmed via manual calculation before writing this test): the overlap
|
|
54
|
-
// check has no notion of WHICH entity the action targets — it only counts shared
|
|
55
|
-
// significant (>=5 char) words between the approving turn and the new question's topic
|
|
56
|
-
// text (header+question+option labels). Approving "migrate the STAGING database schema"
|
|
57
|
-
// shares 3 of 4 topic words (migrate/database/schema) with an unrelated question about
|
|
58
|
-
// migrating the BILLING database schema — a materially different target — and clears both
|
|
59
|
-
// MIN_SHARED_WORDS (2) and the default overlapThreshold (0.34): shared=3, fraction=0.75.
|
|
60
|
-
test('BUG: approving one migration is treated as approval for an unrelated migration of a different entity (staging vs billing)', () => {
|
|
61
|
-
const project = newProject();
|
|
62
|
-
const transcriptPath = writeTranscript(project, [
|
|
63
|
-
humanTurn('go ahead, migrate the staging database schema now'),
|
|
64
|
-
]);
|
|
65
|
-
const question = {
|
|
66
|
-
header: '',
|
|
67
|
-
question: 'Should I migrate the billing database schema?',
|
|
68
|
-
options: [],
|
|
69
|
-
};
|
|
70
|
-
const result = runGateIn(project, askQuestion(transcriptPath, question));
|
|
71
|
-
// Documents the false positive: the gate claims this was "already approved" even though
|
|
72
|
-
// the user approved a migration of a DIFFERENT database (staging, not billing).
|
|
73
|
-
assert.ok(isWarn(result), 'expected the (buggy) false-positive warn to fire: no entity/target check, only generic word overlap');
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
// EDGE CASE (BUG candidate): false negative via negation. The APPROVAL_PATTERN matches
|
|
77
|
-
// literal phrases like "go ahead" / "do it" without checking for a preceding negation.
|
|
78
|
-
// "don't go ahead" or "no, don't do it yet" still matches the raw pattern.
|
|
79
|
-
test('BUG: a NEGATED approval phrase ("do not go ahead") is still treated as approval', () => {
|
|
80
|
-
const project = newProject();
|
|
81
|
-
const transcriptPath = writeTranscript(project, [
|
|
82
|
-
humanTurn('do not go ahead with migrating the authentication database schema'),
|
|
83
|
-
]);
|
|
84
|
-
const question = {
|
|
85
|
-
header: 'Migration',
|
|
86
|
-
question: 'Should I migrate the authentication database schema?',
|
|
87
|
-
options: [],
|
|
88
|
-
};
|
|
89
|
-
const result = runGateIn(project, askQuestion(transcriptPath, question));
|
|
90
|
-
assert.ok(isWarn(result), 'expected the (buggy) warn to fire even though the user said NOT to go ahead — APPROVAL_PATTERN has no negation check');
|
|
91
|
-
});
|
|
@@ -1,131 +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 newProject({ config } = {}) {
|
|
12
|
-
const project = mkdtempSync(join(tmpdir(), 'no-reconfirm-'));
|
|
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
|
-
return project;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function writeTranscript(project, events) {
|
|
22
|
-
const transcriptPath = join(project, 'transcript.jsonl');
|
|
23
|
-
writeFileSync(
|
|
24
|
-
transcriptPath,
|
|
25
|
-
events.map((event) => JSON.stringify(event)).join('\n'),
|
|
26
|
-
);
|
|
27
|
-
return transcriptPath;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function humanTurn(text) {
|
|
31
|
-
return { type: 'user', userType: 'external', message: { content: text } };
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function askQuestion(transcriptPath, question) {
|
|
35
|
-
return {
|
|
36
|
-
tool_name: 'AskUserQuestion',
|
|
37
|
-
transcript_path: transcriptPath,
|
|
38
|
-
tool_input: { questions: [question] },
|
|
39
|
-
};
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function runGateIn(project, payload) {
|
|
43
|
-
const out = execFileSync(process.execPath, [GATE], {
|
|
44
|
-
input: JSON.stringify(payload),
|
|
45
|
-
encoding: 'utf8',
|
|
46
|
-
cwd: project,
|
|
47
|
-
// Isolate from the user's real global config: point homedir() at the temp
|
|
48
|
-
// project so the global-config fallback finds nothing (registry default).
|
|
49
|
-
env: { ...process.env, HOME: project, USERPROFILE: project },
|
|
50
|
-
});
|
|
51
|
-
return out.trim() ? JSON.parse(out.trim()) : null;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function isWarn(result) {
|
|
55
|
-
return result?.hookSpecificOutput?.additionalContext !== undefined;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
test('warns when re-asking a topic the user already approved', () => {
|
|
59
|
-
const project = newProject();
|
|
60
|
-
const transcriptPath = writeTranscript(project, [
|
|
61
|
-
humanTurn('go ahead, migrate the authentication database schema now'),
|
|
62
|
-
]);
|
|
63
|
-
const question = {
|
|
64
|
-
header: 'Migration',
|
|
65
|
-
question: 'Should I migrate the authentication database schema?',
|
|
66
|
-
options: [],
|
|
67
|
-
};
|
|
68
|
-
assert.ok(isWarn(runGateIn(project, askQuestion(transcriptPath, question))));
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
test('allows a genuinely new question with no prior approval overlap', () => {
|
|
72
|
-
const project = newProject();
|
|
73
|
-
const transcriptPath = writeTranscript(project, [humanTurn('hello there')]);
|
|
74
|
-
const question = {
|
|
75
|
-
header: 'Deploy',
|
|
76
|
-
question: 'Should I deploy to production now?',
|
|
77
|
-
options: [],
|
|
78
|
-
};
|
|
79
|
-
assert.equal(runGateIn(project, askQuestion(transcriptPath, question)), null);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
test('disabled by config: the gate does not run', () => {
|
|
83
|
-
const project = newProject({
|
|
84
|
-
config: { gates: { requireNoReconfirmOfApproved: false } },
|
|
85
|
-
});
|
|
86
|
-
const transcriptPath = writeTranscript(project, [
|
|
87
|
-
humanTurn('go ahead, migrate the authentication database schema now'),
|
|
88
|
-
]);
|
|
89
|
-
const question = {
|
|
90
|
-
header: 'Migration',
|
|
91
|
-
question: 'Should I migrate the authentication database schema?',
|
|
92
|
-
options: [],
|
|
93
|
-
};
|
|
94
|
-
assert.equal(runGateIn(project, askQuestion(transcriptPath, question)), null);
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
test('degrades to allow when transcript_path is missing or unreadable', () => {
|
|
98
|
-
const project = newProject();
|
|
99
|
-
const question = {
|
|
100
|
-
header: 'Migration',
|
|
101
|
-
question: 'Should I migrate now?',
|
|
102
|
-
options: [],
|
|
103
|
-
};
|
|
104
|
-
assert.equal(
|
|
105
|
-
runGateIn(project, {
|
|
106
|
-
tool_name: 'AskUserQuestion',
|
|
107
|
-
tool_input: { questions: [question] },
|
|
108
|
-
}),
|
|
109
|
-
null,
|
|
110
|
-
);
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
test('overlapThreshold override changes sensitivity', () => {
|
|
114
|
-
const project = newProject({
|
|
115
|
-
config: {
|
|
116
|
-
gates: {
|
|
117
|
-
requireNoReconfirmOfApproved: { enabled: true, overlapThreshold: 0.99 },
|
|
118
|
-
},
|
|
119
|
-
},
|
|
120
|
-
});
|
|
121
|
-
const transcriptPath = writeTranscript(project, [
|
|
122
|
-
humanTurn('go ahead, migrate the authentication schema'),
|
|
123
|
-
]);
|
|
124
|
-
const question = {
|
|
125
|
-
header: 'Migration',
|
|
126
|
-
question:
|
|
127
|
-
'Should I migrate the authentication database schema now with backups?',
|
|
128
|
-
options: [],
|
|
129
|
-
};
|
|
130
|
-
assert.equal(runGateIn(project, askQuestion(transcriptPath, question)), null);
|
|
131
|
-
});
|
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
// Edge-case audit for protected-paths: bypasses and false positives.
|
|
2
|
-
import assert from 'node:assert/strict';
|
|
3
|
-
import { execFileSync } from 'node:child_process';
|
|
4
|
-
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
5
|
-
import { tmpdir } from 'node:os';
|
|
6
|
-
import { dirname, join } from 'node:path';
|
|
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
|
-
|
|
12
|
-
function runGate(payload, { config } = {}) {
|
|
13
|
-
const project = mkdtempSync(join(tmpdir(), 'protected-paths-edge-'));
|
|
14
|
-
mkdirSync(join(project, '.git'));
|
|
15
|
-
if (config) {
|
|
16
|
-
mkdirSync(join(project, '.ai'));
|
|
17
|
-
writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
|
|
18
|
-
}
|
|
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 write(filePath) {
|
|
29
|
-
return { tool_name: 'Write', tool_input: { file_path: filePath } };
|
|
30
|
-
}
|
|
31
|
-
function edit(filePath) {
|
|
32
|
-
return { tool_name: 'Edit', tool_input: { file_path: filePath } };
|
|
33
|
-
}
|
|
34
|
-
function bash(command) {
|
|
35
|
-
return { tool_name: 'Bash', tool_input: { command } };
|
|
36
|
-
}
|
|
37
|
-
function mcpTool(name, input) {
|
|
38
|
-
return { tool_name: name, tool_input: input };
|
|
39
|
-
}
|
|
40
|
-
function isDeny(result) {
|
|
41
|
-
return result?.hookSpecificOutput?.permissionDecision === 'deny';
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// ── FIXED: toolInGroups classifies an MCP tool by its action segment, so an MCP write
|
|
45
|
-
// tool is now recognized regardless of its exact name.
|
|
46
|
-
test('FIXED: mcp filesystem write_file targeting .env is now recognized as a write tool', () => {
|
|
47
|
-
const result = runGate(
|
|
48
|
-
mcpTool('mcp__filesystem__write_file', { path: '/repo/.env', content: 'X=1' }),
|
|
49
|
-
);
|
|
50
|
-
assert.ok(isDeny(result));
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
test('FIXED: mcp filesystem edit_file targeting package-lock.json is now recognized', () => {
|
|
54
|
-
const result = runGate(
|
|
55
|
-
mcpTool('mcp__filesystem__edit_file', {
|
|
56
|
-
file_path: '/repo/package-lock.json',
|
|
57
|
-
diff: '...',
|
|
58
|
-
}),
|
|
59
|
-
);
|
|
60
|
-
assert.ok(isDeny(result));
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
// ── FIXED: an mcp shell-equivalent tool with a mutating command against .env is now
|
|
64
|
-
// recognized too, via toolInGroups' shell signal.
|
|
65
|
-
test('FIXED: mcp shell-equivalent tool removing .env is now recognized as a shell tool', () => {
|
|
66
|
-
const result = runGate(mcpTool('mcp__shell__run', { command: 'rm .env' }));
|
|
67
|
-
assert.ok(isDeny(result));
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
// ── BUG candidate: chained command where the mutating verb and the protected path are
|
|
71
|
-
// split across a chain, e.g. `echo hi && rm .env` — mutatingCommand.test and
|
|
72
|
-
// protectedTarget.test both run against the FULL command string independently (not
|
|
73
|
-
// requiring adjacency), so this should still be caught. Confirm.
|
|
74
|
-
test('OK: mutating command chained after a harmless one is still caught', () => {
|
|
75
|
-
assert.ok(isDeny(runGate(bash('echo hi && rm .env'))));
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
// ── BUG candidate: redirect-to-protected pattern `>>?\s*\S*(protected)` requires the
|
|
79
|
-
// protected fragment to appear immediately after the redirect target token (no space
|
|
80
|
-
// before it beyond \s*, and \S* is greedy so it consumes up to whitespace). A redirect
|
|
81
|
-
// with a relative path prefix like `echo x > ./.env` should still match since '.env' is
|
|
82
|
-
// a substring inside './.env'. Confirm.
|
|
83
|
-
test('OK: redirect to a protected path via a relative ./ prefix is still caught', () => {
|
|
84
|
-
assert.ok(isDeny(runGate(bash('echo "X=1" > ./.env'))));
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
// ── BUG: the redirect pattern anchors on `>>?\s*\S*(protected)` — but it does not
|
|
88
|
-
// require a mutating command AT ALL; a bare redirect from an allowed "reading" command
|
|
89
|
-
// like `cat foo > .env` is denied correctly. But what about protected-path matches
|
|
90
|
-
// inside an UNQUOTED argument to a non-mutating, non-redirecting command that merely
|
|
91
|
-
// happens to reference the filename without any real risk, e.g. `node --check .env`?
|
|
92
|
-
// mutatingCommand list doesn't include 'node', and there's no redirect, so
|
|
93
|
-
// targetsProtected is false — correctly allowed. Confirm to make sure the "read/lint a
|
|
94
|
-
// protected path" case is not a false positive.
|
|
95
|
-
test('OK: a non-mutating command that only references a protected filename is allowed', () => {
|
|
96
|
-
assert.equal(runGate(bash('node --check .env')), null);
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
// ── BUG: case sensitivity / path separator on Windows. protectedTarget building lower-
|
|
100
|
-
// cases the path fragment comparison for isProtectedPath (writes) via .toLowerCase(), but
|
|
101
|
-
// for the SHELL branch, buildCommandPatterns compiles patterns with the 'i' flag, so case
|
|
102
|
-
// should be handled there too. Confirm mixed-case doesn't bypass either branch.
|
|
103
|
-
test('OK: mixed-case protected path does not bypass either the write or shell branch', () => {
|
|
104
|
-
assert.ok(isDeny(runGate(write('/repo/.ENV'))));
|
|
105
|
-
assert.ok(isDeny(runGate(bash('rm .ENV'))));
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
// ── FIXED: the write and shell branches normalize backslashes to forward slashes before
|
|
109
|
-
// comparing, so a Windows-style absolute path under hooks\ now matches the 'hooks/'
|
|
110
|
-
// protected fragment.
|
|
111
|
-
test('FIXED: windows backslash path under hooks\\ now matches the "hooks/" protected fragment', () => {
|
|
112
|
-
const result = runGate(write('C:\\repo\\hooks\\gates\\evil.mjs'));
|
|
113
|
-
assert.ok(isDeny(result));
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
// ── BUG: sed -i is in mutatingCommands as the two-token regex source `sed\\s+-i` joined
|
|
117
|
-
// into one big alternation with \b...\b around the WHOLE alternation, not each term. This
|
|
118
|
-
// means the outer \b applies to the first alternative's boundary only for single-word
|
|
119
|
-
// terms; for the multi-word 'sed\\s+-i' term, \b anchors immediately before 's' and after
|
|
120
|
-
// the LAST alternative in the group, not after 'sed\s+-i' locally — but since it's inside
|
|
121
|
-
// one non-capturing pattern (`\b(a|b|sed\s+-i|c)\b`), the trailing \b applies right after
|
|
122
|
-
// whatever alternative matched, i.e. after "-i", which IS a word boundary. Confirm sed -i
|
|
123
|
-
// against a protected path is still caught (not a bug) before moving on.
|
|
124
|
-
test('OK: sed -i against a protected path is still caught', () => {
|
|
125
|
-
assert.ok(isDeny(runGate(bash('sed -i "s/a/b/" .env'))));
|
|
126
|
-
});
|