@devrik-tools/claude-gates 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/.claude-plugin/marketplace.json +7 -1
  2. package/cli/__tests__/config.test.mjs +113 -101
  3. package/cli/__tests__/install.test.mjs +102 -0
  4. package/cli/__tests__/materialize.test.mjs +95 -0
  5. package/cli/__tests__/registry-gates-consistency.test.mjs +52 -44
  6. package/cli/__tests__/task.test.mjs +124 -0
  7. package/cli/__tests__/version-consistency.test.mjs +44 -0
  8. package/cli/config.mjs +124 -113
  9. package/cli/index.mjs +100 -97
  10. package/cli/init.mjs +259 -256
  11. package/cli/install.mjs +150 -80
  12. package/cli/materialize.mjs +102 -51
  13. package/cli/registry.mjs +136 -127
  14. package/cli/task.mjs +140 -0
  15. package/package.json +2 -1
  16. package/plugins/gates/.claude-plugin/plugin.json +8 -2
  17. package/plugins/gates/hooks/__tests__/ask-adoption.test.mjs +83 -0
  18. package/plugins/gates/hooks/__tests__/doctor.test.mjs +85 -0
  19. package/plugins/gates/hooks/__tests__/wiring-check.test.mjs +65 -0
  20. package/plugins/gates/hooks/ask-adoption.mjs +147 -0
  21. package/plugins/gates/hooks/doctor.mjs +207 -0
  22. package/plugins/gates/hooks/gates/audit-before-build/audit-before-build.edge.test.mjs +83 -0
  23. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +88 -88
  24. package/plugins/gates/hooks/gates/audit-before-build/test.mjs +117 -92
  25. package/plugins/gates/hooks/gates/autonomous-mode/autonomous.edge.test.mjs +104 -0
  26. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +48 -45
  27. package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +165 -0
  28. package/plugins/gates/hooks/gates/bash-commands/index.mjs +285 -268
  29. package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +151 -0
  30. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +265 -177
  31. package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +207 -0
  32. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +501 -456
  33. package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +161 -143
  34. package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +69 -0
  35. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +126 -118
  36. package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +68 -0
  37. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +43 -49
  38. package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +83 -0
  39. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -100
  40. package/plugins/gates/hooks/gates/force-parallel/index.mjs +119 -0
  41. package/plugins/gates/hooks/gates/force-parallel/test.mjs +88 -0
  42. package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +155 -0
  43. package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -112
  44. package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +66 -0
  45. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -186
  46. package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -238
  47. package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +96 -0
  48. package/plugins/gates/hooks/gates/intent-flow/test.mjs +165 -136
  49. package/plugins/gates/hooks/gates/lint-commit/index.mjs +149 -0
  50. package/plugins/gates/hooks/gates/lint-commit/test.mjs +85 -0
  51. package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +100 -0
  52. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -166
  53. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +79 -85
  54. package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +80 -0
  55. package/plugins/gates/hooks/gates/never-assume/index.mjs +58 -55
  56. package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +71 -0
  57. package/plugins/gates/hooks/gates/never-assume/test.mjs +95 -78
  58. package/plugins/gates/hooks/gates/no-blocking/index.mjs +148 -142
  59. package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +109 -0
  60. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +123 -120
  61. package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +83 -0
  62. package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +91 -0
  63. package/plugins/gates/hooks/gates/protected-paths/index.mjs +144 -147
  64. package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +126 -0
  65. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +89 -64
  66. package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +102 -0
  67. package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +94 -0
  68. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +159 -150
  69. package/plugins/gates/hooks/gates/risk-level/index.mjs +263 -203
  70. package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +116 -0
  71. package/plugins/gates/hooks/gates/risk-level/test.mjs +153 -125
  72. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +56 -50
  73. package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +58 -0
  74. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +116 -111
  75. package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +97 -0
  76. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +184 -99
  77. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +126 -0
  78. package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +130 -0
  79. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -251
  80. package/plugins/gates/hooks/gates/stop-pending/index.mjs +159 -0
  81. package/plugins/gates/hooks/gates/stop-pending/test.mjs +114 -0
  82. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +101 -101
  83. package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +72 -0
  84. package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +63 -0
  85. package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -181
  86. package/plugins/gates/hooks/gates/test-matrix/test.mjs +114 -87
  87. package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +62 -0
  88. package/plugins/gates/hooks/gates/tool-map/index.mjs +143 -140
  89. package/plugins/gates/hooks/hooks.json +346 -286
  90. package/plugins/gates/hooks/lib/__tests__/config.test.mjs +173 -154
  91. package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +210 -154
  92. package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +114 -0
  93. package/plugins/gates/hooks/lib/config.mjs +172 -165
  94. package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +189 -0
  95. package/plugins/gates/hooks/lib/hook-io.mjs +318 -208
  96. package/plugins/gates/hooks/lib/signals.mjs +127 -0
  97. package/plugins/gates/hooks/wiring-check.mjs +227 -0
  98. package/plugins/tasks/.claude-plugin/plugin.json +14 -0
  99. package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +100 -0
  100. package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +95 -0
  101. package/plugins/tasks/hooks/hooks.json +26 -0
  102. package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +180 -132
  103. package/plugins/tasks/hooks/lib/task-store.mjs +197 -159
  104. package/plugins/tasks/hooks/register-requests.mjs +141 -108
  105. package/plugins/tasks/hooks/session-tasks.mjs +108 -0
  106. package/registry.json +793 -686
@@ -0,0 +1,104 @@
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,45 +1,48 @@
1
- // autonomous-mode — when the project turns on autonomous mode, the assistant must stop
2
- // asking and decide. This gate denies the AskUserQuestion tool while the mode is on, so a
3
- // question popup cannot interrupt an unattended run. The assistant is expected to take the
4
- // best, aligned decision and state the reversible assumption instead of asking.
5
- //
6
- // justification: no existing tool covers this. It is the deterministic half of "run without
7
- // asking": a gate can block the question tool; the prose reminder (via the session-start
8
- // hook and this gate's message) covers the rest, which a hook cannot force.
9
- //
10
- // ── When it acts ────────────────────────────────────────────────────────────────────
11
- // Only when autonomousMode is enabled in .ai/config.json (project) or the global config.
12
- // Off by default. The user is also reminded at session start that the mode is on (see the
13
- // session hook), in case they forgot to turn it off.
14
- //
15
- // ── What it does NOT do ─────────────────────────────────────────────────────────────
16
- // It blocks the AskUserQuestion TOOL. It cannot stop the assistant from asking in plain
17
- // prose (no hook sees chat text) — the injected reminder pushes against that, but the only
18
- // deterministic lever is the question tool, and this gate pulls it.
19
-
20
- import { runGate, deny, TOOL_GROUPS } from '../../lib/hook-io.mjs';
21
-
22
- const GATE_ID = 'autonomous-mode';
23
- const CONFIG_KEY = 'autonomousMode';
24
-
25
- const QUESTION_TOOLS = new Set(TOOL_GROUPS.question);
26
-
27
- const DENY_MESSAGE =
28
- 'Autonomous mode is ON for this project: do not ask the user. Take the best decision ' +
29
- 'that is aligned with the goal and the project rules, state the reversible assumption you ' +
30
- 'made, and proceed. Only a genuinely irreversible or dangerous choice (deleting data, ' +
31
- 'money, production) would justify stopping and then say so in prose, do not use the ' +
32
- 'question popup. To let questions through again, set "autonomousMode": false in ' +
33
- '.ai/config.json.';
34
-
35
- runGate(
36
- {
37
- id: GATE_ID,
38
- configKey: CONFIG_KEY,
39
- enabledByDefault: false,
40
- },
41
- ({ toolName }) => {
42
- if (!QUESTION_TOOLS.has(toolName)) return;
43
- deny(GATE_ID, DENY_MESSAGE);
44
- },
45
- );
1
+ // autonomous-mode — when the project turns on autonomous mode, the assistant must stop
2
+ // asking and decide. This gate denies the AskUserQuestion tool while the mode is on, so a
3
+ // question popup cannot interrupt an unattended run. The assistant is expected to take the
4
+ // best, aligned decision and state the reversible assumption instead of asking.
5
+ //
6
+ // justification: no existing tool covers this. It is the deterministic half of "run without
7
+ // asking": a gate can block the question tool; the prose reminder (via the session-start
8
+ // hook and this gate's message) covers the rest, which a hook cannot force.
9
+ //
10
+ // ── When it acts ────────────────────────────────────────────────────────────────────
11
+ // Only when autonomousMode is enabled in .ai/config.json (project) or the global config.
12
+ // Off by default. The user is also reminded at session start that the mode is on (see the
13
+ // session hook), in case they forgot to turn it off.
14
+ //
15
+ // ── What it does NOT do ─────────────────────────────────────────────────────────────
16
+ // It blocks the AskUserQuestion TOOL. It cannot stop the assistant from asking in plain
17
+ // prose (no hook sees chat text) — the injected reminder pushes against that, but the only
18
+ // deterministic lever is the question tool, and this gate pulls it.
19
+
20
+ import { runGate, deny, toolInGroups } from '../../lib/hook-io.mjs';
21
+
22
+ const GATE_ID = 'autonomous-mode';
23
+ const CONFIG_KEY = 'autonomousMode';
24
+
25
+ // The tool groups whose members are "asking the user something". `toolInGroups` matches the
26
+ // native AskUserQuestion AND any MCP ask/confirm/elicit surface (mcp__*__ask_user_*), so an
27
+ // autonomous run can't be interrupted by a question routed through an MCP server either.
28
+ const QUESTION_GROUPS = ['question'];
29
+
30
+ const DENY_MESSAGE =
31
+ 'Autonomous mode is ON for this project: do not ask the user. Take the best decision ' +
32
+ 'that is aligned with the goal and the project rules, state the reversible assumption you ' +
33
+ 'made, and proceed. Only a genuinely irreversible or dangerous choice (deleting data, ' +
34
+ 'money, production) would justify stopping — and then say so in prose, do not use the ' +
35
+ 'question popup. To let questions through again, set "autonomousMode": false in ' +
36
+ '.ai/config.json.';
37
+
38
+ runGate(
39
+ {
40
+ id: GATE_ID,
41
+ configKey: CONFIG_KEY,
42
+ enabledByDefault: false,
43
+ },
44
+ ({ toolName }) => {
45
+ if (!toolInGroups(toolName, QUESTION_GROUPS)) return;
46
+ deny(GATE_ID, DENY_MESSAGE);
47
+ },
48
+ );
@@ -0,0 +1,165 @@
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
+ });