@devrik-tools/claude-gates 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.es.md +7 -6
  3. package/README.md +16 -7
  4. package/cli/index.mjs +4 -0
  5. package/cli/init.mjs +18 -1
  6. package/cli/install.mjs +26 -1
  7. package/package.json +5 -2
  8. package/plugins/gates/.claude-plugin/plugin.json +1 -1
  9. package/plugins/gates/hooks/gates/autonomous-mode/stop.mjs +78 -0
  10. package/plugins/gates/hooks/gates/bash-commands/index.mjs +11 -81
  11. package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +140 -0
  12. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +130 -126
  13. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +104 -79
  14. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +138 -123
  15. package/plugins/gates/hooks/gates/stop-pending/index.mjs +6 -1
  16. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +124 -101
  17. package/plugins/gates/hooks/hooks.json +19 -0
  18. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  19. package/plugins/tasks/hooks/register-requests.mjs +7 -3
  20. package/registry.json +738 -793
  21. package/cli/__tests__/config.test.mjs +0 -113
  22. package/cli/__tests__/init-flags.test.mjs +0 -111
  23. package/cli/__tests__/install.test.mjs +0 -102
  24. package/cli/__tests__/materialize.test.mjs +0 -95
  25. package/cli/__tests__/registry-gates-consistency.test.mjs +0 -52
  26. package/cli/__tests__/selection.test.mjs +0 -200
  27. package/cli/__tests__/task.test.mjs +0 -124
  28. package/cli/__tests__/version-consistency.test.mjs +0 -44
  29. package/plugins/gates/hooks/__tests__/ask-adoption.test.mjs +0 -83
  30. package/plugins/gates/hooks/__tests__/doctor.test.mjs +0 -85
  31. package/plugins/gates/hooks/__tests__/wiring-check.test.mjs +0 -65
  32. package/plugins/gates/hooks/gates/audit-before-build/audit-before-build.edge.test.mjs +0 -83
  33. package/plugins/gates/hooks/gates/audit-before-build/test.mjs +0 -117
  34. package/plugins/gates/hooks/gates/autonomous-mode/autonomous.edge.test.mjs +0 -104
  35. package/plugins/gates/hooks/gates/autonomous-mode/test.mjs +0 -58
  36. package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +0 -165
  37. package/plugins/gates/hooks/gates/bash-commands/test.mjs +0 -126
  38. package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +0 -151
  39. package/plugins/gates/hooks/gates/brief-before-delegate/test.mjs +0 -111
  40. package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +0 -207
  41. package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +0 -161
  42. package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +0 -69
  43. package/plugins/gates/hooks/gates/dependency-skills/test.mjs +0 -109
  44. package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +0 -68
  45. package/plugins/gates/hooks/gates/diagnosis-before-patch/test.mjs +0 -68
  46. package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +0 -83
  47. package/plugins/gates/hooks/gates/feature-catalog/test.mjs +0 -97
  48. package/plugins/gates/hooks/gates/force-parallel/test.mjs +0 -88
  49. package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +0 -155
  50. package/plugins/gates/hooks/gates/forge-flow/test.mjs +0 -135
  51. package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +0 -66
  52. package/plugins/gates/hooks/gates/implementation-pipeline/test.mjs +0 -86
  53. package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +0 -96
  54. package/plugins/gates/hooks/gates/intent-flow/test.mjs +0 -165
  55. package/plugins/gates/hooks/gates/lint-commit/test.mjs +0 -85
  56. package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +0 -100
  57. package/plugins/gates/hooks/gates/mandatory-flow/test.mjs +0 -119
  58. package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +0 -80
  59. package/plugins/gates/hooks/gates/neutral-spanish/test.mjs +0 -65
  60. package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +0 -71
  61. package/plugins/gates/hooks/gates/never-assume/test.mjs +0 -95
  62. package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +0 -109
  63. package/plugins/gates/hooks/gates/no-blocking/test.mjs +0 -108
  64. package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +0 -83
  65. package/plugins/gates/hooks/gates/no-memory-dependency/test.mjs +0 -106
  66. package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +0 -91
  67. package/plugins/gates/hooks/gates/no-reconfirm/test.mjs +0 -131
  68. package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +0 -126
  69. package/plugins/gates/hooks/gates/protected-paths/test.mjs +0 -75
  70. package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +0 -102
  71. package/plugins/gates/hooks/gates/recurrence-lock/test.mjs +0 -99
  72. package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +0 -94
  73. package/plugins/gates/hooks/gates/reuse-before-build/test.mjs +0 -101
  74. package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +0 -116
  75. package/plugins/gates/hooks/gates/risk-level/test.mjs +0 -153
  76. package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +0 -58
  77. package/plugins/gates/hooks/gates/root-cause-first/test.mjs +0 -73
  78. package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +0 -124
  79. package/plugins/gates/hooks/gates/root-whitelist/test.mjs +0 -80
  80. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +0 -126
  81. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/test.mjs +0 -78
  82. package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +0 -130
  83. package/plugins/gates/hooks/gates/sdd-specs/test.mjs +0 -163
  84. package/plugins/gates/hooks/gates/stop-pending/test.mjs +0 -114
  85. package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +0 -72
  86. package/plugins/gates/hooks/gates/test-after-implementation/test.mjs +0 -81
  87. package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +0 -63
  88. package/plugins/gates/hooks/gates/test-matrix/test.mjs +0 -114
  89. package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +0 -62
  90. package/plugins/gates/hooks/gates/tool-map/test.mjs +0 -87
  91. package/plugins/gates/hooks/lib/__tests__/config.test.mjs +0 -173
  92. package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +0 -223
  93. package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +0 -114
  94. package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +0 -189
  95. package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +0 -100
  96. package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +0 -95
  97. package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +0 -180
@@ -1,104 +0,0 @@
1
- // Edge audit for autonomous-mode: does it actually stop the agent from "asking" in every
2
- // surface that exists, or only the one tool it names?
3
- //
4
- // State after the MCP-hole fix (toolInGroups + mcp__.* matcher):
5
- // 1. A corrupted / unparseable stdin payload still makes toolNameOf() return '' and the
6
- // gate allows — kept intentionally: an unidentifiable tool is not a question to block,
7
- // and autonomous-mode's job is only to stop the ask surfaces. (A blocking security gate
8
- // makes the opposite choice; that is per-gate, not a shared default.)
9
- // 2. FIXED. The gate now classifies via toolInGroups: native names match case-insensitively
10
- // and any MCP ask/confirm/elicit surface (mcp__*__ask_*) hits the question signal, so the
11
- // differently-cased name and the MCP ask tool are now DENIED (tests below).
12
- // 3. Still true and inherent: a plain-prose question (no tool call) is invisible to any
13
- // PreToolUse hook. This is the residual limitation the gate's own header documents; it
14
- // is addressed by the injected reminder, not by the matcher.
15
-
16
- import assert from 'node:assert/strict';
17
- import { execFileSync } from 'node:child_process';
18
- import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
19
- import { tmpdir } from 'node:os';
20
- import { dirname, join } from 'node:path';
21
- import { test } from 'node:test';
22
- import { fileURLToPath } from 'node:url';
23
-
24
- const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
25
-
26
- function projectWithAutonomous(autonomous) {
27
- const project = mkdtempSync(join(tmpdir(), 'autonomous-edge-'));
28
- mkdirSync(join(project, '.git'));
29
- mkdirSync(join(project, '.ai'));
30
- writeFileSync(
31
- join(project, '.ai', 'config.json'),
32
- JSON.stringify({ gates: { autonomousMode: { enabled: Boolean(autonomous) } } }),
33
- );
34
- return project;
35
- }
36
-
37
- function runGateRaw(rawInput, project) {
38
- const out = execFileSync(process.execPath, [GATE], {
39
- input: rawInput,
40
- encoding: 'utf8',
41
- cwd: project,
42
- env: { ...process.env, HOME: project, USERPROFILE: project },
43
- });
44
- return out.trim() ? JSON.parse(out.trim()) : null;
45
- }
46
-
47
- function isDeny(result) {
48
- return result?.hookSpecificOutput?.permissionDecision === 'deny';
49
- }
50
-
51
- test('BYPASS: corrupted stdin payload (invalid JSON) is ALLOWED even in autonomous mode', () => {
52
- const project = projectWithAutonomous(true);
53
- // Not valid JSON at all -> JSON.parse throws inside toolNameOf -> returns null ->
54
- // runGate coalesces to '' -> '' not in QUESTION_TOOLS -> allow(). This is the tool_name
55
- // null->'' bypass named in the task brief, exercised end to end via the real binary.
56
- const result = runGateRaw('{not valid json', project);
57
- assert.equal(result, null, 'PASA(bug): corrupted payload bypasses the deny');
58
- });
59
-
60
- test('BYPASS: payload missing tool_name entirely is ALLOWED even in autonomous mode', () => {
61
- const project = projectWithAutonomous(true);
62
- // Valid JSON, but no tool_name/name field. toolNameOf returns payload?.tool_name ??
63
- // payload?.name ?? '' -> ''. Same bypass, reached via a well-formed but incomplete payload
64
- // (e.g. a future/unknown hook event shape) rather than malformed JSON.
65
- const result = runGateRaw(JSON.stringify({ tool_input: { questions: [] } }), project);
66
- assert.equal(result, null, 'PASA(bug): payload without tool_name bypasses the deny');
67
- });
68
-
69
- test('FIXED: a differently-cased tool name ("askuserquestion") is now DENIED', () => {
70
- const project = projectWithAutonomous(true);
71
- // toolInGroups matches native names case-insensitively, so a differently-cased spelling
72
- // can no longer dodge the question block.
73
- const result = runGateRaw(
74
- JSON.stringify({ tool_name: 'askuserquestion', tool_input: {} }),
75
- project,
76
- );
77
- assert.ok(isDeny(result), 'case/name variant must now be blocked');
78
- });
79
-
80
- test('FIXED: an MCP "ask" tool is now DENIED via the question signal match', () => {
81
- const project = projectWithAutonomous(true);
82
- // toolInGroups classifies mcp__* by its action segment; "ask_user_confirmation" hits the
83
- // question signal, so an MCP-exposed ask/confirm surface is blocked in autonomous mode too.
84
- const result = runGateRaw(
85
- JSON.stringify({
86
- tool_name: 'mcp__some-server__ask_user_confirmation',
87
- tool_input: { question: 'Proceed?' },
88
- }),
89
- project,
90
- );
91
- assert.ok(isDeny(result), 'MCP-exposed ask tool must now be blocked');
92
- });
93
-
94
- test('control: canonical AskUserQuestion with well-formed payload IS denied (sanity check)', () => {
95
- const project = projectWithAutonomous(true);
96
- const result = runGateRaw(
97
- JSON.stringify({
98
- tool_name: 'AskUserQuestion',
99
- tool_input: { questions: [{ question: 'A or B?' }] },
100
- }),
101
- project,
102
- );
103
- assert.ok(isDeny(result), 'FALLA(ok): the one exact name it knows is still blocked');
104
- });
@@ -1,58 +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 in a temp project with HOME isolated (so the real global config is not
12
- // read). `autonomous` writes the flag on. Returns the parsed deny output, or null.
13
- function runGate(payload, { autonomous } = {}) {
14
- const project = mkdtempSync(join(tmpdir(), 'autonomous-mode-'));
15
- mkdirSync(join(project, '.git'));
16
- mkdirSync(join(project, '.ai'));
17
- writeFileSync(
18
- join(project, '.ai', 'config.json'),
19
- JSON.stringify({
20
- gates: { autonomousMode: { enabled: Boolean(autonomous) } },
21
- }),
22
- );
23
- const out = execFileSync(process.execPath, [GATE], {
24
- input: JSON.stringify(payload),
25
- encoding: 'utf8',
26
- cwd: project,
27
- env: { ...process.env, HOME: project, USERPROFILE: project },
28
- });
29
- return out.trim() ? JSON.parse(out.trim()) : null;
30
- }
31
-
32
- function ask() {
33
- return {
34
- tool_name: 'AskUserQuestion',
35
- tool_input: { questions: [{ question: 'A or B?' }] },
36
- };
37
- }
38
- function isDeny(result) {
39
- return result?.hookSpecificOutput?.permissionDecision === 'deny';
40
- }
41
-
42
- test('autonomous mode ON: AskUserQuestion is denied', () => {
43
- assert.ok(isDeny(runGate(ask(), { autonomous: true })));
44
- });
45
-
46
- test('autonomous mode OFF (default): questions pass', () => {
47
- assert.equal(runGate(ask(), { autonomous: false }), null);
48
- });
49
-
50
- test('even ON, a non-question tool is never blocked', () => {
51
- assert.equal(
52
- runGate(
53
- { tool_name: 'Write', tool_input: { file_path: 'x.js', content: '' } },
54
- { autonomous: true },
55
- ),
56
- null,
57
- );
58
- });
@@ -1,165 +0,0 @@
1
- // Edge-case audit for bash-commands: bypasses (should deny but doesn't) and false
2
- // positives (denies something innocuous). Each test documents the payload and asserts
3
- // the ACTUAL observed behavior of the gate as it stands today — a green test here means
4
- // the described case is a confirmed bug (bypass) unless the test name says otherwise.
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(), 'bash-commands-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 bash(command) {
32
- return { tool_name: 'Bash', tool_input: { command } };
33
- }
34
- function mcpTool(name, input) {
35
- return { tool_name: name, tool_input: input };
36
- }
37
- function isDeny(result) {
38
- return result?.hookSpecificOutput?.permissionDecision === 'deny';
39
- }
40
-
41
- // ── BUG: MCP write/exec tools are not in TOOL_GROUPS.shell/write/delegation, so the
42
- // gate's own `toolName` check (SHELL_TOOLS.has / DELEGATION_TOOLS.has) never matches an
43
- // mcp__* tool even if the gate DID receive the call. hooks.json's `matcher` also never
44
- // includes any mcp__*__write/run pattern (only mcp__ide__executeCode, and only for
45
- // rule-skill-autodiscovery/recurrence-lock, not this gate) so Claude Code would not even
46
- // invoke this hook for such a tool call. This test proves the gate-level half of the bug:
47
- // even fed the payload directly, it does not recognize the tool and allows destructive
48
- // content that would be denied under Bash.
49
- test('BUG: mcp filesystem write tool carrying a destructive shell command is not recognized', () => {
50
- const result = runGate(
51
- mcpTool('mcp__filesystem__write_file', {
52
- path: '/repo/run.sh',
53
- content: 'git reset --hard HEAD~3',
54
- }),
55
- );
56
- assert.equal(result, null); // allowed: bypass confirmed
57
- });
58
-
59
- test('FIXED: a run_command-shaped MCP tool with a different name is now recognized', () => {
60
- // e.g. an MCP server that exposes its shell tool as mcp__shell__run instead of run_command.
61
- // toolInGroups classifies it into the shell group by its action segment, so the destructive
62
- // command it carries is now inspected and denied.
63
- const result = runGate(
64
- mcpTool('mcp__shell__run', { command: 'rm -rf src' }),
65
- );
66
- assert.ok(isDeny(result));
67
- });
68
-
69
- // ── BUG: command chaining. checkDestructive/checkRemotePublish test the WHOLE command
70
- // string with patterns that use \b word boundaries without anchoring to start-of-command,
71
- // so `a && git push` and `a; rm -rf src` SHOULD still match (the regex has no ^ anchor).
72
- // Confirm this actually still catches chaining (expected: denied, i.e. NOT a bug) so we
73
- // don't misreport it.
74
- test('OK: chained destructive command after && is still caught (no anchor bug here)', () => {
75
- assert.ok(isDeny(runGate(bash('echo hi && git reset --hard'))));
76
- assert.ok(isDeny(runGate(bash('echo hi ; rm -rf src'))));
77
- });
78
-
79
- // ── BUG: subshell / command substitution. The destructive pattern is a plain substring
80
- // regex, so it should still match text inside $(...) or backticks. Confirm.
81
- test('OK: destructive command inside a subshell is still caught', () => {
82
- assert.ok(isDeny(runGate(bash('echo $(git push origin main --force)'))));
83
- assert.ok(isDeny(runGate(bash('echo `rm -rf src`'))));
84
- });
85
-
86
- // ── BUG candidate: env-var prefixed command, e.g. `FOO=x git push origin main`. The
87
- // git push pattern is \bgit\s+(?:-C\s+\S+\s+)?push\b which does not care what precedes
88
- // "git", so this should still be denied. Confirm rather than assume.
89
- test('OK: env-var-prefixed git push is still caught', () => {
90
- assert.ok(isDeny(runGate(bash('FOO=bar git push origin main'))));
91
- });
92
-
93
- // ── BUG: `sh -c "..."` / `bash -lc "..."` wrapping. The outer command line still
94
- // literally contains the inner destructive text as a substring (it's quoted, not
95
- // encoded), so the plain regex against the raw command string should still match. But the
96
- // remote-publish description-detector's `stripQuoted` step only runs for delegation
97
- // prompts (isShell=true skips the intent check and matches literally) — so a real Bash
98
- // call wrapped in sh -c should NOT bypass this gate. If it did NOT bypass, that's OK, but
99
- // worth confirming since it's tempting to assume quoting evades a regex the way it does
100
- // in stripQuoted's quote-stripping heuristic used for delegation.
101
- test('OK: destructive command wrapped in sh -c is still caught (raw command string has no quote-stripping for shell tools)', () => {
102
- assert.ok(isDeny(runGate(bash('sh -c "git push origin main --force"'))));
103
- assert.ok(isDeny(runGate(bash('bash -lc \'rm -rf src\''))));
104
- });
105
-
106
- // ── BUG: rm -rf with a relative-but-not-listed path segment prefix, e.g. `rm -rf ./src`
107
- // or `rm -rf src/` (trailing slash) may not match `\brm\s+-rf\s+(area)\b` because of the
108
- // `./` prefix or trailing slash changing the \b boundary expectations.
109
- test('FIXED: rm -rf with a leading ./ on a protected area is now caught', () => {
110
- const result = runGate(bash('rm -rf ./src'));
111
- assert.ok(isDeny(result)); // the optional ./ prefix is now part of the rm -rf pattern
112
- });
113
-
114
- test('BUG: rm -rf with a trailing slash on a protected area bypasses the rm-rf pattern', () => {
115
- const result = runGate(bash('rm -rf src/'));
116
- // \bsrc\b: 'src/' still has a word boundary right after 'src' (before '/'), so this
117
- // actually still matches. Included to document the check, not assumed.
118
- assert.ok(isDeny(result));
119
- });
120
-
121
- // ── BUG: `git reset --hard` split across an alias / npx wrapper e.g.
122
- // `npx --yes git reset --hard` (unusual but plausible in some CI wrapper) is unaffected;
123
- // the pattern still matches the literal substring `git reset --hard` inside. Confirm no
124
- // bypass there; the interesting bypass is quoting INSIDE an argument that a shell would
125
- // interpret literally as separate tokens but which our regex still sees as one string —
126
- // there is no such distinct case; skip.
127
-
128
- // ── BUG: kill-by-name pattern only covers `taskkill ... /IM`, `pkill `, `killall `.
129
- // `Stop-Process -Name node` (PowerShell) is a kill-by-name equivalent not in the pattern.
130
- test('FIXED: PowerShell Stop-Process -Name (kill by name) is now denied', () => {
131
- const result = runGate(bash('Stop-Process -Name node -Force'));
132
- assert.ok(isDeny(result)); // Stop-Process -Name is now in the kill-by-name rules
133
- });
134
-
135
- // ── BUG: `taskkill` without the literal `/IM` flag before the process name, e.g.
136
- // `taskkill /F /PID 1234 /IM node.exe` still has /IM later, but `taskkill /F /T /IM *`
137
- // with wildcard, or simply reordered flags `taskkill /IM node.exe /F` — pattern requires
138
- // `taskkill\s+[^|;]*[/]IM` which does match flags-then-/IM in any order since [^|;]* is
139
- // greedy-any. Confirm both orders are caught (no bug) before asserting the wildcard case.
140
- test('OK: taskkill flag order does not evade the kill-by-name pattern', () => {
141
- assert.ok(isDeny(runGate(bash('taskkill /IM node.exe /F'))));
142
- });
143
-
144
- // ── BUG: `gh pr merge` and `gh release create` only check for a literal `gh` invocation;
145
- // GitHub CLI can also be invoked as `gh.exe` on Windows PATH resolution, or through
146
- // `command gh pr merge` (shell builtin wrapper) — the pattern \bgh\s+ still matches
147
- // 'command gh pr merge' since \b matches at the 'gh' boundary regardless of preceding
148
- // text. Confirm.
149
- test('OK: "command gh pr merge" wrapper does not evade the remote-publish pattern', () => {
150
- assert.ok(isDeny(runGate(bash('command gh pr merge 12'))));
151
- });
152
-
153
- // ── BUG: delegation prompt intent-detection reporting-verb bypass can be abused to slip
154
- // a REAL instruction past the two-stage check by simply prefixing it with a reporting verb
155
- // within DESCRIPTION_LOOK_BACK (80 chars), even though the actual intent is an order.
156
- test('BUG: prefixing an order with a reporting verb inside the lookback window defeats delegation intent detection', () => {
157
- const result = runGate({
158
- tool_name: 'Agent',
159
- tool_input: {
160
- prompt:
161
- 'The changelog mentions we should run git push origin main now to finish the release.',
162
- },
163
- });
164
- assert.equal(result, null); // allowed: bypass confirmed (reporting verb precedes mention)
165
- });
@@ -1,126 +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(), 'bash-commands-'));
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
- function bash(command) {
33
- return { tool_name: 'Bash', tool_input: { command } };
34
- }
35
- function delegate(prompt) {
36
- return { tool_name: 'Agent', tool_input: { prompt } };
37
- }
38
- function isDeny(result) {
39
- return result?.hookSpecificOutput?.permissionDecision === 'deny';
40
- }
41
-
42
- test('denies git reset --hard, rm -rf on a protected area, force push and kill-by-name', () => {
43
- assert.ok(isDeny(runGate(bash('git reset --hard HEAD~3'))));
44
- assert.ok(isDeny(runGate(bash('rm -rf src'))));
45
- assert.ok(isDeny(runGate(bash('git push origin main --force'))));
46
- assert.ok(isDeny(runGate(bash('taskkill /F /IM node.exe'))));
47
- });
48
-
49
- test('allows an innocuous command', () => {
50
- assert.equal(runGate(bash('git status')), null);
51
- assert.equal(runGate(bash('rm -rf ./build/cache')), null);
52
- });
53
-
54
- test("git's global options (-C, -c, --git-dir) cannot smuggle a destructive subcommand past", () => {
55
- // Regression: `git -C /path reset --hard` used to slip past the `git reset --hard` pattern
56
- // because the option sat between `git` and the subcommand. Normalization strips it first.
57
- assert.ok(isDeny(runGate(bash('git -C /home/code/orca reset --hard HEAD'))));
58
- assert.ok(isDeny(runGate(bash('git -c core.editor=vim reset --hard'))));
59
- assert.ok(isDeny(runGate(bash('git --git-dir=/x reset --hard'))));
60
- assert.ok(isDeny(runGate(bash('git -c a=b -C /x clean -fd'))));
61
- assert.ok(isDeny(runGate(bash('git -C /repo push origin main'))));
62
- // A global option on a harmless subcommand is still allowed.
63
- assert.equal(runGate(bash('git -C /repo status')), null);
64
- assert.equal(runGate(bash('git reset --soft HEAD')), null);
65
- });
66
-
67
- test('denies a plain git push (remote publish needs fresh authorization)', () => {
68
- assert.ok(isDeny(runGate(bash('git push origin main'))));
69
- assert.ok(isDeny(runGate(bash('gh pr merge 12'))));
70
- });
71
-
72
- test('in a delegation, denies a real order to publish but allows a description of one', () => {
73
- assert.ok(
74
- isDeny(runGate(delegate('Then run git push origin main to publish.'))),
75
- );
76
- assert.equal(
77
- runGate(
78
- delegate(
79
- 'I extended the guard so it denies "git push" without authorization.',
80
- ),
81
- ),
82
- null,
83
- );
84
- });
85
-
86
- test('disabled by config: the gate does not run', () => {
87
- assert.equal(
88
- runGate(bash('git reset --hard'), {
89
- config: { gates: { blockDestructiveShellCommands: false } },
90
- }),
91
- null,
92
- );
93
- });
94
-
95
- test('project denyPatterns replace the built-in list', () => {
96
- // A project that only bans `curl | sh` — git reset --hard is no longer denied.
97
- const config = {
98
- gates: {
99
- blockDestructiveShellCommands: {
100
- enabled: true,
101
- denyPatterns: [String.raw`curl\s+.*\|\s*sh`],
102
- },
103
- },
104
- };
105
- assert.equal(runGate(bash('git reset --hard'), { config }), null);
106
- assert.ok(isDeny(runGate(bash('curl http://x | sh'), { config })));
107
- });
108
-
109
- test('embedded interpreter is off by default, on when enabled', () => {
110
- const inline = bash("node -e \"require('fs').writeFileSync('x','y')\"");
111
- assert.equal(runGate(inline), null);
112
- assert.ok(
113
- isDeny(
114
- runGate(inline, {
115
- config: {
116
- gates: {
117
- blockDestructiveShellCommands: {
118
- enabled: true,
119
- embeddedInterpreterEnabled: true,
120
- },
121
- },
122
- },
123
- }),
124
- ),
125
- );
126
- });
@@ -1,151 +0,0 @@
1
- // Edge-case audit for brief-before-delegate. See gate comment header for the rule this
2
- // gate enforces. Each test below demonstrates either a confirmed BUG (asserted with a
3
- // comment explaining why the result is wrong) or an OK (confirms the gate handles the
4
- // case correctly). Run: node --test brief-before-delegate.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(), 'brief-before-delegate-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 isDeny(result) {
32
- return result?.hookSpecificOutput?.permissionDecision === 'deny';
33
- }
34
-
35
- const ENABLED = { config: { gates: { requireBriefBeforeDelegating: true } } };
36
-
37
- const NO_BRIEF_PROMPT = 'Fix the login bug please, thanks.';
38
-
39
- test('FIXED: a delegation tool name outside the native list is now caught via toolInGroups (MCP delegation signal)', () => {
40
- // toolInGroups matches an MCP tool name whose action segment carries a delegation
41
- // signal (agent/task/delegat/spawn/dispatch/orchestrat/worker), so a renamed
42
- // orchestration tool like mcp__orchestrator__spawn_agent is no longer invisible.
43
- const payload = {
44
- tool_name: 'mcp__orchestrator__spawn_agent',
45
- tool_input: { prompt: NO_BRIEF_PROMPT },
46
- };
47
- const result = runGate(payload, ENABLED);
48
- assert.ok(isDeny(result), 'gate now denies the brief-less prompt under the MCP tool name too');
49
- });
50
-
51
- test('FIXED: brief carried in a field other than prompt/description/task is now read via delegationPromptOf', () => {
52
- // delegationPromptOf also reads instructions/message/input/Prompt, so a payload that
53
- // carries the delegation text under "instructions" (as some MCP subagent tools do) is
54
- // no longer read as an empty prompt.
55
- const payload = {
56
- tool_name: 'Agent',
57
- tool_input: { instructions: NO_BRIEF_PROMPT, subagent_type: 'worker-senior' },
58
- };
59
- const result = runGate(payload, ENABLED);
60
- assert.ok(isDeny(result), 'gate now sees the brief under "instructions" and denies it for being brief-less');
61
- });
62
-
63
- test('FIXED: keyword-only compliance no longer passes with content that does not carry a real brief', () => {
64
- // missingSignals now requires each marker to be followed by substantive content
65
- // (markerHasSubstance / stepsHaveSubstance), not just the marker's presence. A prompt
66
- // that pastes GOAL/STEPS/CRITERION markers next to filler no longer clears the check.
67
- const prompt = [
68
- 'Objetivo: cosa.',
69
- '- paso',
70
- 'Criterio: listo cuando funcione bien y quede resuelto satisfactoriamente para todos.',
71
- 'Implementa el arreglo correspondiente segun corresponda en el sistema relevante.',
72
- ].join('\n');
73
- assert.ok(prompt.length >= 180, 'sanity: prompt clears the length floor');
74
- const result = runGate({ tool_name: 'Agent', tool_input: { prompt } }, ENABLED);
75
- assert.ok(
76
- isDeny(result),
77
- 'gate now denies a brief that is letter-compliant (has GOAL/STEPS/CRITERION markers) but has no real content',
78
- );
79
- });
80
-
81
- test('FIXED: a real brief with substantial content past each marker is still allowed', () => {
82
- const prompt = [
83
- 'Objetivo: fix the broken login redirect so users land on the correct dashboard page.',
84
- '',
85
- 'Haceres:',
86
- '- update src/auth/redirect.js to use the post-login route',
87
- '- add a regression test for the redirect',
88
- '',
89
- 'Criterio: se considera hecho cuando el test de regresion pasa y el login redirige correctamente.',
90
- ].join('\n');
91
- assert.equal(runGate({ tool_name: 'Agent', tool_input: { prompt } }, ENABLED), null);
92
- });
93
-
94
- test('FIXED: a mutation-risk signal in the prompt overrides a whitelisted read-only subagent name', () => {
95
- // isReadOnlySubagent now voids the name-based exemption whenever the prompt itself
96
- // carries a mutation-risk signal (money/auth/data/write/deploy): a subagent named
97
- // "explore" cannot exempt a real payment-data mutation just by using that label.
98
- const prompt =
99
- 'Implementa el guardado de datos de pago del usuario en la base y escribe el token de auth en el archivo de sesion.';
100
- const result = runGate(
101
- { tool_name: 'Agent', tool_input: { prompt, subagent_type: 'explore' } },
102
- ENABLED,
103
- );
104
- assert.ok(
105
- isDeny(result),
106
- 'gate no longer exempts a mutation-risk prompt solely because subagent_type says "explore"',
107
- );
108
- });
109
-
110
- test('bilingual control: an EN mutation-risk prompt voids the exemption exactly like its ES equivalent', () => {
111
- const es =
112
- 'Implementa el guardado de datos de pago del usuario en la base y escribe el token de auth en el archivo de sesion.';
113
- const en =
114
- 'Implement saving the user payment data to the database and write the auth token to the session file.';
115
- const resultEs = runGate(
116
- { tool_name: 'Agent', tool_input: { prompt: es, subagent_type: 'explore' } },
117
- ENABLED,
118
- );
119
- const resultEn = runGate(
120
- { tool_name: 'Agent', tool_input: { prompt: en, subagent_type: 'explore' } },
121
- ENABLED,
122
- );
123
- assert.ok(isDeny(resultEs));
124
- assert.ok(isDeny(resultEn));
125
- });
126
-
127
- test('OK: a whitelisted read-only subagent name with no mutation-risk signal is still exempt', () => {
128
- assert.equal(
129
- runGate(
130
- { tool_name: 'Agent', tool_input: { prompt: NO_BRIEF_PROMPT, subagent_type: 'explore' } },
131
- ENABLED,
132
- ),
133
- null,
134
- );
135
- });
136
-
137
- test('OK: a genuinely brief-less implementation delegation via Agent is denied', () => {
138
- const result = runGate({ tool_name: 'Agent', tool_input: { prompt: NO_BRIEF_PROMPT } }, ENABLED);
139
- assert.ok(isDeny(result));
140
- });
141
-
142
- test('OK: Task and invoke_subagent tool names are both covered (in TOOL_GROUPS.delegation)', () => {
143
- assert.ok(
144
- isDeny(runGate({ tool_name: 'Task', tool_input: { prompt: NO_BRIEF_PROMPT } }, ENABLED)),
145
- );
146
- assert.ok(
147
- isDeny(
148
- runGate({ tool_name: 'invoke_subagent', tool_input: { prompt: NO_BRIEF_PROMPT } }, ENABLED),
149
- ),
150
- );
151
- });