@devrik-tools/claude-gates 0.1.1 → 0.1.3

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 (105) hide show
  1. package/.claude-plugin/marketplace.json +23 -17
  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/config.mjs +124 -113
  8. package/cli/index.mjs +100 -97
  9. package/cli/init.mjs +259 -256
  10. package/cli/install.mjs +150 -80
  11. package/cli/materialize.mjs +102 -51
  12. package/cli/registry.mjs +136 -127
  13. package/cli/task.mjs +140 -0
  14. package/package.json +74 -73
  15. package/plugins/gates/hooks/__tests__/ask-adoption.test.mjs +83 -0
  16. package/plugins/gates/hooks/__tests__/doctor.test.mjs +85 -0
  17. package/plugins/gates/hooks/__tests__/wiring-check.test.mjs +65 -0
  18. package/plugins/gates/hooks/ask-adoption.mjs +147 -0
  19. package/plugins/gates/hooks/doctor.mjs +207 -0
  20. package/plugins/gates/hooks/gates/audit-before-build/audit-before-build.edge.test.mjs +83 -0
  21. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +88 -88
  22. package/plugins/gates/hooks/gates/audit-before-build/test.mjs +117 -92
  23. package/plugins/gates/hooks/gates/autonomous-mode/autonomous.edge.test.mjs +104 -0
  24. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +48 -0
  25. package/plugins/gates/hooks/gates/autonomous-mode/test.mjs +58 -0
  26. package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +165 -0
  27. package/plugins/gates/hooks/gates/bash-commands/index.mjs +285 -268
  28. package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +151 -0
  29. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +265 -177
  30. package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +207 -0
  31. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +501 -456
  32. package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +161 -143
  33. package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +69 -0
  34. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +126 -118
  35. package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +68 -0
  36. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +43 -49
  37. package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +83 -0
  38. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -100
  39. package/plugins/gates/hooks/gates/force-parallel/index.mjs +119 -0
  40. package/plugins/gates/hooks/gates/force-parallel/test.mjs +88 -0
  41. package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +155 -0
  42. package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -112
  43. package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +66 -0
  44. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -186
  45. package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -238
  46. package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +96 -0
  47. package/plugins/gates/hooks/gates/intent-flow/test.mjs +165 -136
  48. package/plugins/gates/hooks/gates/lint-commit/index.mjs +149 -0
  49. package/plugins/gates/hooks/gates/lint-commit/test.mjs +85 -0
  50. package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +100 -0
  51. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -166
  52. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +79 -85
  53. package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +80 -0
  54. package/plugins/gates/hooks/gates/never-assume/index.mjs +58 -55
  55. package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +71 -0
  56. package/plugins/gates/hooks/gates/never-assume/test.mjs +95 -78
  57. package/plugins/gates/hooks/gates/no-blocking/index.mjs +148 -142
  58. package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +109 -0
  59. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +123 -120
  60. package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +83 -0
  61. package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +91 -0
  62. package/plugins/gates/hooks/gates/protected-paths/index.mjs +144 -147
  63. package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +126 -0
  64. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +89 -64
  65. package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +102 -0
  66. package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +94 -0
  67. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +159 -150
  68. package/plugins/gates/hooks/gates/risk-level/index.mjs +263 -203
  69. package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +116 -0
  70. package/plugins/gates/hooks/gates/risk-level/test.mjs +153 -125
  71. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +56 -50
  72. package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +58 -0
  73. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +116 -111
  74. package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +97 -0
  75. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +184 -99
  76. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +126 -0
  77. package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +130 -0
  78. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -251
  79. package/plugins/gates/hooks/gates/stop-pending/index.mjs +159 -0
  80. package/plugins/gates/hooks/gates/stop-pending/test.mjs +114 -0
  81. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +101 -101
  82. package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +72 -0
  83. package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +63 -0
  84. package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -181
  85. package/plugins/gates/hooks/gates/test-matrix/test.mjs +114 -87
  86. package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +62 -0
  87. package/plugins/gates/hooks/gates/tool-map/index.mjs +143 -140
  88. package/plugins/gates/hooks/hooks.json +346 -266
  89. package/plugins/gates/hooks/lib/__tests__/config.test.mjs +173 -154
  90. package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +210 -154
  91. package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +114 -0
  92. package/plugins/gates/hooks/lib/config.mjs +172 -165
  93. package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +189 -0
  94. package/plugins/gates/hooks/lib/hook-io.mjs +318 -208
  95. package/plugins/gates/hooks/lib/signals.mjs +127 -0
  96. package/plugins/gates/hooks/wiring-check.mjs +227 -0
  97. package/plugins/tasks/.claude-plugin/plugin.json +9 -0
  98. package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +100 -0
  99. package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +95 -0
  100. package/plugins/tasks/hooks/hooks.json +26 -0
  101. package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +180 -132
  102. package/plugins/tasks/hooks/lib/task-store.mjs +197 -159
  103. package/plugins/tasks/hooks/register-requests.mjs +141 -108
  104. package/plugins/tasks/hooks/session-tasks.mjs +108 -0
  105. package/registry.json +793 -668
@@ -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
+ });