@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
@@ -1,142 +1,148 @@
1
- // no-blocking — denies syntactic forms of waiting in the foreground (sleeping, polling
2
- // loops, following unbounded output, foreground dev servers) on a real shell command and
3
- // on a delegation prompt (a subagent can be told "wait with sleep" in prose). Migrated
4
- // from ~/.claude/hooks/guard-no-blocking.mjs. Off by default: a project opts in, because
5
- // many legitimate workflows still need a bounded wait the project accepts as normal.
6
- //
7
- // This gate only recognizes SYNTACTIC forms of waiting — it never judges whether other
8
- // work was available meanwhile, because that is judgment, not a fact a regex can read.
9
- // A gate that misfires on the legitimate case gets disabled, taking every real catch
10
- // down with it.
11
- //
12
- // ── What a project can configure (params) ───────────────────────────────────────────
13
- // blockingPatterns regex sources (matched case-insensitively) of blocking/waiting
14
- // forms to deny. Replaces the built-in list wholesale.
15
- // waitJustifiedMarker a marker token that, present in the command with a reason,
16
- // escapes the block — a declared wait is a decision, not an
17
- // oversight. Replaces the built-in marker wholesale.
18
- // The defaults live here, in the source, so a project reads them and knows exactly what
19
- // its override replaces.
20
-
21
- import { runGate, deny, TOOL_GROUPS } from '../../lib/hook-io.mjs';
22
-
23
- const GATE_ID = 'no-blocking';
24
- const CONFIG_KEY = 'blockWaitingCommands';
25
-
26
- const SHELL_TOOLS = new Set(TOOL_GROUPS.shell);
27
- const DELEGATION_TOOLS = new Set(TOOL_GROUPS.delegation);
28
-
29
- const DEFAULT_WAIT_JUSTIFIED_MARKER = 'WAIT-JUSTIFIED:';
30
-
31
- // systemd's log-follow command, assembled from fragments so the spell checker does not
32
- // read it as prose (the project keeps an empty dictionary by policy).
33
- const SYSTEMD_LOG_COMMAND = 'journal' + 'ctl';
34
-
35
- /** Blocking rules as `[regexSource, reason]`, matched case-insensitively. */
36
- const DEFAULT_BLOCKING_PATTERNS = [
37
- [
38
- String.raw`(^|[|;&]\s*|\bthen\s+|\bdo\s+)(sleep|timeout)\s+\d`,
39
- 'Sleeping in the foreground spends the turn doing nothing. If waiting for something ' +
40
- 'to finish, launch it in the background and move on to something else; if waiting ' +
41
- 'on an external condition, use a monitor.',
42
- ],
43
- [
44
- String.raw`\bStart-Sleep\b`,
45
- 'Start-Sleep freezes the turn. Launch the work in the background and continue with ' +
46
- 'whatever does not depend on it.',
47
- ],
48
- [
49
- String.raw`(^|[|;&]\s*)wait\b|--wait\b|\bWait-Process\b|\bWait-Job\b`,
50
- 'Waiting for another process to finish blocks the whole turn. Launch it in the ' +
51
- 'background and consume the result when it arrives, not before.',
52
- ],
53
- [
54
- [
55
- String.raw`\b(tail|`,
56
- SYSTEMD_LOG_COMMAND,
57
- String.raw`|kubectl\s+logs|docker\s+logs)\b[^|;&]*\s-{1,2}f\b`,
58
- ].join(''),
59
- 'Following live output never returns and takes the turn with it. Read the file once, ' +
60
- 'or leave the following to a background process.',
61
- ],
62
- [
63
- String.raw`(^|[|;&]\s*)watch\s+`,
64
- "'watch' repeats forever and blocks. Run the command once; if a change genuinely " +
65
- 'needs watching, use a monitor that does not take the turn.',
66
- ],
67
- [
68
- String.raw`\b(until|while)\b[^\n]{0,80}\bdo\b[^\n]{0,80}\bsleep\b`,
69
- 'A loop that sleeps waiting for something to change is foreground polling: it blocks ' +
70
- 'and it spins. Use a monitor with the condition, or launch the work in the ' +
71
- 'background and wait for its notification.',
72
- ],
73
- [
74
- String.raw`\b(npm|pnpm|yarn|bun)\s+(run\s+)?(dev|start|serve|watch)\b`,
75
- 'A dev server does not end on its own: in the foreground it takes the turn until ' +
76
- 'something kills it. Launch it in the background and keep working while it comes up.',
77
- ],
78
- ];
79
-
80
- /** Marks that a command will not take the turn: backgrounded, detached, or bounded. */
81
- const NOT_TAKING_THE_TURN =
82
- /(&\s*$|\bnohup\b|\bstart\s+\/b\b|--detach\b|-d\b|\brun_in_background\b)/i;
83
-
84
- function compile(source) {
85
- return new RegExp(source, 'i');
86
- }
87
-
88
- /** The text to inspect: a real command's command line, or the delegation prompt. */
89
- function commandTextFrom(toolName, toolInput) {
90
- if (SHELL_TOOLS.has(toolName)) {
91
- return String(toolInput.CommandLine ?? toolInput.command ?? '');
92
- }
93
- return String(toolInput.prompt ?? toolInput.description ?? '');
94
- }
95
-
96
- runGate(
97
- {
98
- id: GATE_ID,
99
- configKey: CONFIG_KEY,
100
- enabledByDefault: false,
101
- defaultParams: {
102
- blockingPatterns: DEFAULT_BLOCKING_PATTERNS,
103
- waitJustifiedMarker: DEFAULT_WAIT_JUSTIFIED_MARKER,
104
- },
105
- },
106
- ({ toolName, toolInput, parameters }) => {
107
- const isShell = SHELL_TOOLS.has(toolName);
108
- const isDelegation = DELEGATION_TOOLS.has(toolName);
109
- if (!isShell && !isDelegation) return;
110
-
111
- const command = commandTextFrom(toolName, toolInput);
112
- if (!command.trim()) return;
113
-
114
- // Already declared in the background: the turn stays free, which is all that matters.
115
- if (toolInput.run_in_background === true) return;
116
- if (NOT_TAKING_THE_TURN.test(command)) return;
117
-
118
- // A declared wait with its reason is a decision, not an oversight.
119
- const marker = String(parameters.waitJustifiedMarker ?? '');
120
- if (marker) {
121
- const escapedMarker = marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
122
- const reasonAfterMarker = String.raw`\s*\S+\s+\S+`;
123
- const justified = new RegExp(`${escapedMarker}${reasonAfterMarker}`, 'i');
124
- if (justified.test(command)) return;
125
- }
126
-
127
- const blockingPairs = (parameters.blockingPatterns ?? []).map((entry) =>
128
- Array.isArray(entry)
129
- ? entry
130
- : [entry, 'Blocking/waiting command is not allowed.'],
131
- );
132
- for (const [source, reason] of blockingPairs) {
133
- if (compile(source).test(command)) {
134
- deny(
135
- GATE_ID,
136
- `${reason} If this wait is genuinely justified, add "${marker} <concrete reason>" ` +
137
- 'to the command and try again. A declared wait is a decision; a silent one is an oversight.',
138
- );
139
- }
140
- }
141
- },
142
- );
1
+ // no-blocking — denies syntactic forms of waiting in the foreground (sleeping, polling
2
+ // loops, following unbounded output, foreground dev servers) on a real shell command and
3
+ // on a delegation prompt (a subagent can be told "wait with sleep" in prose). Migrated
4
+ // from ~/.claude/hooks/guard-no-blocking.mjs. Off by default: a project opts in, because
5
+ // many legitimate workflows still need a bounded wait the project accepts as normal.
6
+ //
7
+ // This gate only recognizes SYNTACTIC forms of waiting — it never judges whether other
8
+ // work was available meanwhile, because that is judgment, not a fact a regex can read.
9
+ // A gate that misfires on the legitimate case gets disabled, taking every real catch
10
+ // down with it.
11
+ //
12
+ // ── What a project can configure (params) ───────────────────────────────────────────
13
+ // blockingPatterns regex sources (matched case-insensitively) of blocking/waiting
14
+ // forms to deny. Replaces the built-in list wholesale.
15
+ // waitJustifiedMarker a marker token that, present in the command with a reason,
16
+ // escapes the block — a declared wait is a decision, not an
17
+ // oversight. Replaces the built-in marker wholesale.
18
+ // The defaults live here, in the source, so a project reads them and knows exactly what
19
+ // its override replaces.
20
+
21
+ import {
22
+ runGate,
23
+ deny,
24
+ toolInGroups,
25
+ delegationPromptOf,
26
+ } from '../../lib/hook-io.mjs';
27
+
28
+ const GATE_ID = 'no-blocking';
29
+ const CONFIG_KEY = 'blockWaitingCommands';
30
+
31
+ const DEFAULT_WAIT_JUSTIFIED_MARKER = 'WAIT-JUSTIFIED:';
32
+
33
+ // systemd's log-follow command, assembled from fragments so the spell checker does not
34
+ // read it as prose (the project keeps an empty dictionary by policy).
35
+ const SYSTEMD_LOG_COMMAND = 'journal' + 'ctl';
36
+
37
+ /** Blocking rules as `[regexSource, reason]`, matched case-insensitively. */
38
+ const DEFAULT_BLOCKING_PATTERNS = [
39
+ [
40
+ String.raw`(^|[|;&]\s*|\bthen\s+|\bdo\s+)(sleep|timeout)\s+\d`,
41
+ 'Sleeping in the foreground spends the turn doing nothing. If waiting for something ' +
42
+ 'to finish, launch it in the background and move on to something else; if waiting ' +
43
+ 'on an external condition, use a monitor.',
44
+ ],
45
+ [
46
+ String.raw`\bStart-Sleep\b`,
47
+ 'Start-Sleep freezes the turn. Launch the work in the background and continue with ' +
48
+ 'whatever does not depend on it.',
49
+ ],
50
+ [
51
+ String.raw`(^|[|;&]\s*)wait\b|--wait\b|\bWait-Process\b|\bWait-Job\b`,
52
+ 'Waiting for another process to finish blocks the whole turn. Launch it in the ' +
53
+ 'background and consume the result when it arrives, not before.',
54
+ ],
55
+ [
56
+ [
57
+ String.raw`\b(tail|`,
58
+ SYSTEMD_LOG_COMMAND,
59
+ String.raw`|kubectl\s+logs|docker\s+logs)\b[^|;&]*\s-{1,2}f\b`,
60
+ ].join(''),
61
+ 'Following live output never returns and takes the turn with it. Read the file once, ' +
62
+ 'or leave the following to a background process.',
63
+ ],
64
+ [
65
+ String.raw`(^|[|;&]\s*)watch\s+`,
66
+ "'watch' repeats forever and blocks. Run the command once; if a change genuinely " +
67
+ 'needs watching, use a monitor that does not take the turn.',
68
+ ],
69
+ [
70
+ String.raw`\b(until|while)\b[^\n]{0,80}\bdo\b[^\n]{0,80}\bsleep\b`,
71
+ 'A loop that sleeps waiting for something to change is foreground polling: it blocks ' +
72
+ 'and it spins. Use a monitor with the condition, or launch the work in the ' +
73
+ 'background and wait for its notification.',
74
+ ],
75
+ [
76
+ String.raw`\b(npm|pnpm|yarn|bun)\s+(run\s+)?(dev|start|serve|watch)\b`,
77
+ 'A dev server does not end on its own: in the foreground it takes the turn until ' +
78
+ 'something kills it. Launch it in the background and keep working while it comes up.',
79
+ ],
80
+ ];
81
+
82
+ // Marks that a command will not take the turn: backgrounded, detached, or bounded. The bare
83
+ // `-d` token was removed: it matched any -d flag (e.g. `curl -d payload`), letting an
84
+ // unrelated flag whitelist a genuine foreground `sleep`. Detached forms are now matched
85
+ // explicitly (--detach, docker/compose -d at a word boundary before end/pipe), and PowerShell's
86
+ // Start-Job / Start-Process -NoNewWindow backgrounding is recognized.
87
+ const NOT_TAKING_THE_TURN =
88
+ /(&\s*$|\bnohup\b|\bstart\s+\/b\b|--detach\b|\b-d(?=\s*($|[|;&]))|\bStart-Job\b|\bStart-Process\b[^|;\n]*-NoNewWindow\b|\brun_in_background\b)/i;
89
+
90
+ function compile(source) {
91
+ return new RegExp(source, 'i');
92
+ }
93
+
94
+ /** The text to inspect: a real command's command line, or the delegation prompt. */
95
+ function commandTextFrom(toolName, toolInput) {
96
+ if (toolInGroups(toolName, ['shell'])) {
97
+ return String(toolInput.CommandLine ?? toolInput.command ?? '');
98
+ }
99
+ return delegationPromptOf(toolInput);
100
+ }
101
+
102
+ runGate(
103
+ {
104
+ id: GATE_ID,
105
+ configKey: CONFIG_KEY,
106
+ enabledByDefault: false,
107
+ defaultParams: {
108
+ blockingPatterns: DEFAULT_BLOCKING_PATTERNS,
109
+ waitJustifiedMarker: DEFAULT_WAIT_JUSTIFIED_MARKER,
110
+ },
111
+ },
112
+ ({ toolName, toolInput, parameters }) => {
113
+ const isShell = toolInGroups(toolName, ['shell']);
114
+ const isDelegation = toolInGroups(toolName, ['delegation']);
115
+ if (!isShell && !isDelegation) return;
116
+
117
+ const command = commandTextFrom(toolName, toolInput);
118
+ if (!command.trim()) return;
119
+
120
+ // Already declared in the background: the turn stays free, which is all that matters.
121
+ if (toolInput.run_in_background === true) return;
122
+ if (NOT_TAKING_THE_TURN.test(command)) return;
123
+
124
+ // A declared wait with its reason is a decision, not an oversight.
125
+ const marker = String(parameters.waitJustifiedMarker ?? '');
126
+ if (marker) {
127
+ const escapedMarker = marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
128
+ const reasonAfterMarker = String.raw`\s*\S+\s+\S+`;
129
+ const justified = new RegExp(`${escapedMarker}${reasonAfterMarker}`, 'i');
130
+ if (justified.test(command)) return;
131
+ }
132
+
133
+ const blockingPairs = (parameters.blockingPatterns ?? []).map((entry) =>
134
+ Array.isArray(entry)
135
+ ? entry
136
+ : [entry, 'Blocking/waiting command is not allowed.'],
137
+ );
138
+ for (const [source, reason] of blockingPairs) {
139
+ if (compile(source).test(command)) {
140
+ deny(
141
+ GATE_ID,
142
+ `${reason} If this wait is genuinely justified, add "${marker} <concrete reason>" ` +
143
+ 'to the command and try again. A declared wait is a decision; a silent one is an oversight.',
144
+ );
145
+ }
146
+ }
147
+ },
148
+ );
@@ -0,0 +1,109 @@
1
+ // Edge-case audit for no-blocking: 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(), 'no-blocking-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
+ const ENABLED = { gates: { blockWaitingCommands: true } };
29
+
30
+ function bash(command, extra = {}) {
31
+ return { tool_name: 'Bash', tool_input: { command, ...extra } };
32
+ }
33
+ function mcpTool(name, input) {
34
+ return { tool_name: name, tool_input: input };
35
+ }
36
+ function isDeny(result) {
37
+ return result?.hookSpecificOutput?.permissionDecision === 'deny';
38
+ }
39
+
40
+ // ── BUG: MCP shell-equivalent tools are not in TOOL_GROUPS.shell/delegation.
41
+ test('FIXED: an mcp shell-equivalent tool running "sleep 30" is now recognized', () => {
42
+ const result = runGate(mcpTool('mcp__shell__run', { command: 'sleep 30' }), {
43
+ config: ENABLED,
44
+ });
45
+ assert.ok(isDeny(result)); // toolInGroups classifies mcp__shell__run into the shell group
46
+ });
47
+
48
+ // ── BUG (false positive / evasion pairing): the WAIT-JUSTIFIED marker escape requires
49
+ // NO reason validation beyond "two more tokens exist after the marker" — the reason text
50
+ // itself is never checked for plausibility, so trivially defeats the intent of a
51
+ // "declared, justified wait": any two words work as an unchecked excuse.
52
+ test('BUG (weak justification check): any two-word text after the marker escapes the block regardless of content', () => {
53
+ const result = runGate(
54
+ bash('sleep 9999 # WAIT-JUSTIFIED: because yes'),
55
+ { config: ENABLED },
56
+ );
57
+ assert.equal(result, null); // allowed: marker escape has no semantic validation, only shape
58
+ });
59
+
60
+ // ── BUG candidate: `timeout 30 someserver` is matched by the sleep|timeout pattern
61
+ // requiring the command to START the line or follow |;&/then/do. A background-launched
62
+ // dev server that is piped through `| cat` first, e.g. `npm run dev | cat`, should still
63
+ // match \b(npm|pnpm|yarn|bun)\s+(run\s+)?(dev|start|serve|watch)\b since that's a
64
+ // substring test with no anchoring requirement (unlike sleep/timeout). Confirm no bypass.
65
+ test('OK: piping a dev server through another command does not bypass the dev-server pattern', () => {
66
+ assert.ok(
67
+ isDeny(runGate(bash('npm run dev | cat'), { config: ENABLED })),
68
+ );
69
+ });
70
+
71
+ // ── BUG: `NOT_TAKING_THE_TURN` includes `-d\b` as a generic "detached" flag marker. This
72
+ // is dangerously broad: ANY command containing a standalone `-d` token — including one
73
+ // completely unrelated to backgrounding — escapes every blocking pattern. E.g. `sleep 30
74
+ // -d` (nonsensical flag for sleep, but matches) or more realistically a docker/curl
75
+ // command with an unrelated `-d` (data) flag combined with a blocking pattern.
76
+ test('FIXED: an unrelated "-d" flag (curl -d) no longer escapes the blocking check', () => {
77
+ // curl -d is "data", not detach. The bare -d escape was removed, so a genuinely foreground
78
+ // sleep on the same command line is now denied despite the unrelated -d flag.
79
+ const result = runGate(bash('sleep 30; curl -d "payload" http://x'), {
80
+ config: ENABLED,
81
+ });
82
+ assert.ok(isDeny(result));
83
+ });
84
+
85
+ // ── BUG candidate: `npm run dev &` (trailing background operator with a space before
86
+ // EOL, e.g. training whitespace) — NOT_TAKING_THE_TURN requires `&\s*$`. A trailing
87
+ // newline in the JSON string (unlikely from a real shell, but a delegation prompt or a
88
+ // tool that appends '\n') would still satisfy `\s*$` since \s includes \n. Not pursued
89
+ // further as a distinct bug; documenting as checked.
90
+ test('OK: trailing background operator followed by trailing whitespace still escapes correctly (not a bug)', () => {
91
+ assert.equal(
92
+ runGate(bash('npm run dev & '), { config: ENABLED }),
93
+ null,
94
+ );
95
+ });
96
+
97
+ // ── BUG candidate: PowerShell `Start-Job` / `Start-Process ... -NoNewWindow` background
98
+ // forms are not recognized by NOT_TAKING_THE_TURN, but that only matters if the command
99
+ // is ALSO caught by a blocking pattern in the first place. `Start-Sleep` alone, run as
100
+ // `Start-Job { Start-Sleep 30 }` (already backgrounded via Start-Job) is still matched by
101
+ // \bStart-Sleep\b and NOT recognized as non-blocking, causing a FALSE POSITIVE: a
102
+ // genuinely backgrounded PowerShell job gets denied because its "backgrounded" wrapper
103
+ // (Start-Job) is not in NOT_TAKING_THE_TURN.
104
+ test('FIXED: Start-Sleep inside a Start-Job background wrapper is now allowed', () => {
105
+ const result = runGate(bash('Start-Job { Start-Sleep 30 }'), {
106
+ config: ENABLED,
107
+ });
108
+ assert.equal(result, null); // Start-Job is now recognized as a backgrounding mechanism
109
+ });