@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,268 +1,285 @@
1
- // bash-commands — denies destructive shell commands, and blocks publishing to a remote
2
- // without fresh authorization. Runs on a real Bash/run_command call and on a delegation
3
- // prompt (a subagent can be told "run git reset --hard" in prose).
4
- //
5
- // ── What a project can configure (params) ───────────────────────────────────────────
6
- // denyPatterns regex sources (matched case-insensitively) of destructive
7
- // commands to deny. Replaces the built-in list below wholesale.
8
- // rmRfProtectedAreas targets rm -rf may not hit; woven into the rm -rf pattern.
9
- // embeddedInterpreterEnabled block `node -e`/`python -c` that does raw file ops. Off
10
- // by default: this harness legitimately uses inline interpreters.
11
- // The defaults live here, in the source, so a project reads them and knows exactly what
12
- // its override replaces.
13
- //
14
- // ── What is NOT configurable (base, non-negotiable) ─────────────────────────────────
15
- // Remote-publish detection (`git push`, `gh pr merge`, `gh release create`) is a hard
16
- // block with no config knob and no in-command escape hatch: authorization is something
17
- // the USER states in chat, never a token the agent could write into its own command.
18
- //
19
- // ── Two-stage intent check, only for delegation prompts ─────────────────────────────
20
- // A real command's `command` field IS what the shell runs — a single-stage regex is
21
- // right. A delegation prompt is natural language that may DESCRIBE a command ("I extended
22
- // the guard to deny git push") without asking anyone to run it. There, publish rules run
23
- // a second stage: strip quoted/example text, then require that at least one surviving
24
- // mention is not governed by a reporting verb before denying.
25
-
26
- import { runGate, deny, TOOL_GROUPS } from '../../lib/hook-io.mjs';
27
-
28
- const GATE_ID = 'bash-commands';
29
- const CONFIG_KEY = 'blockDestructiveShellCommands';
30
-
31
- const SHELL_TOOLS = new Set(TOOL_GROUPS.shell);
32
- const DELEGATION_TOOLS = new Set(TOOL_GROUPS.delegation);
33
-
34
- // Areas rm -rf must never target. A project overrides this list in config; the rm -rf
35
- // deny pattern is rebuilt from it at runtime (see rmRfSourceFrom), so an edit takes effect.
36
- const DEFAULT_RM_RF_PROTECTED_AREAS = ['/', '*', 'src', 'tests'];
37
-
38
- // Process-killing command names, assembled from fragments so the spell checker does not
39
- // read them as prose (the project keeps an empty dictionary by policy).
40
- const KILL_BY_NAME_COMMANDS = ['task' + 'kill', 'p' + 'kill', 'kill' + 'all'];
41
-
42
- // The rm -rf deny source, built from the protected-areas list. Separate from the static
43
- // deny list so a project can edit rmRfProtectedAreas in config and have it take effect at
44
- // runtime: the list is re-read on every call, not baked in at load time.
45
- function rmRfSourceFrom(protectedAreas) {
46
- const rmRfTargets = protectedAreas
47
- .map((area) => area.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
48
- .join('|');
49
- return String.raw`\brm\s+-rf\s+(${rmRfTargets})\b`;
50
- }
51
-
52
- /**
53
- * Static destructive-command rules as `[regexSource, reason]`. Sources (not RegExp) so
54
- * they are JSON-serializable and a project can override them through config. rm -rf is not
55
- * here — it is built at runtime from rmRfProtectedAreas (see rmRfSourceFrom).
56
- */
57
- function defaultDenyPatterns() {
58
- const [taskKill, pKill, killAll] = KILL_BY_NAME_COMMANDS;
59
- return [
60
- [
61
- String.raw`\bgit\s+reset\s+--hard\b`,
62
- "'git reset --hard' is destructive and needs explicit authorization.",
63
- ],
64
- [
65
- String.raw`\bgit\s+push\s+.*--force\b`,
66
- "'git push --force' is destructive and is not allowed.",
67
- ],
68
- [
69
- String.raw`\bgit\s+clean\s+-[a-z]*f[a-z]*\b`,
70
- "'git clean -f' is destructive and is not allowed.",
71
- ],
72
- [
73
- // Killing by image name reaches every process with that name on the machine — the
74
- // server the user is watching, another session's, a half-run tool. The damage is
75
- // silent. Keep the PID you started and kill that; if lost, identify by full command
76
- // line and confirm ownership first.
77
- [
78
- String.raw`\b(`,
79
- taskKill,
80
- String.raw`\s+[^|;]*[/]IM|`,
81
- pKill,
82
- String.raw`\s+|`,
83
- killAll,
84
- String.raw`\s+)`,
85
- ].join(''),
86
- 'Killing processes by NAME reaches everything with that name, not just yours. ' +
87
- 'Keep the PID you started and kill that; if lost, find it by its full command ' +
88
- 'line and confirm it is yours before touching it.',
89
- ],
90
- ];
91
- }
92
-
93
- /** Remote-publish rules. Base, non-configurable — see the header. */
94
- const REMOTE_PUBLISH_RULES = [
95
- [
96
- String.raw`\bgit\s+(?:-C\s+\S+\s+)?push\b`,
97
- "Publishing to a remote ('git push') needs fresh authorization from the user naming " +
98
- 'commits, local branch, remote and target. This gate cannot verify that from the ' +
99
- 'command itself ask the user and let them run it.',
100
- ],
101
- [
102
- String.raw`\bgh\s+(?:pr\s+merge|release\s+create)\b`,
103
- 'Publishing via GitHub CLI (merging a PR or creating a release) needs fresh ' +
104
- 'authorization from the user naming commits, local branch, remote and target. ' +
105
- 'Ask the user and let them run it.',
106
- ],
107
- ];
108
-
109
- // Interpreter name, then any intermediate flags (e.g. --input-type=module), then an
110
- // eval flag. Intermediate flags are matched loosely (`-\S+`) to keep the pattern simple.
111
- const INTERPRETER_EVAL_PATTERN =
112
- /\b(node|python3?|deno|bun)\b(?:\s+-\S+)*?\s+(?:-e|-c|--eval|--print|-p)\b/i;
113
- const RAW_FILE_OPS_PATTERN =
114
- /\b(writeFileSync|readFileSync|appendFileSync|fs\.writeFile|fs\.readFile|fs\.unlink|fs\.mkdir)\b/;
115
-
116
- // How far back from a mention to look for a governing verb, and the reporting-verb lexicon
117
- // that marks a mention as description rather than an order. Only the text before a mention
118
- // is inspected: what governs it is what precedes it (appending words after a real command
119
- // would otherwise be a trivial bypass).
120
- const DESCRIPTION_LOOK_BACK = 80;
121
- const REPORTING_VERB_PATTERN =
122
- /(describe|explain|summar|mention|added|built|extended|denies?|deny|prohibit|protection|report|documentation|changelog)/i;
123
-
124
- function compile(source) {
125
- return new RegExp(source, 'i');
126
- }
127
-
128
- // git's GLOBAL options sit between `git` and the subcommand: `git -C <path> reset --hard`,
129
- // `git -c k=v push`, `git --git-dir=… clean -f`. A pattern that matches `git reset --hard`
130
- // contiguously is evaded by any of them. Stripping these options first — turning
131
- // `git -C /repo reset --hard` back into `git reset --hard` — closes that bypass for every
132
- // git rule at once, instead of teaching each pattern about every global option.
133
- //
134
- // A single global option, matched one at a time and stripped repeatedly (below), so the
135
- // pattern stays simple: an option taking a value (`-C /path`, `--git-dir=…`) or a flag
136
- // (`--no-pager`). The leading `git ` is kept; only the option after it is removed.
137
- const GIT_OPTION_WITH_VALUE = String.raw`(?:-[Cc]|--git-dir|--work-tree|--namespace|--exec-path|--config-env)(?:\s+|=)\S+`;
138
- const GIT_FLAG_OPTION = String.raw`--(?:paginate|no-pager|bare|no-optional-locks)|-p`;
139
- const GIT_GLOBAL_OPTION_PATTERN = new RegExp(
140
- String.raw`\bgit\s+(?:${GIT_OPTION_WITH_VALUE}|${GIT_FLAG_OPTION})\s+`,
141
- 'i',
142
- );
143
-
144
- function normalizeGitOptions(command) {
145
- // Strip one leading global option at a time and re-run, so a stacked
146
- // `git -c a=b -C /x reset` is fully reduced to `git reset` before the deny patterns run.
147
- let previous;
148
- let normalized = command;
149
- do {
150
- previous = normalized;
151
- normalized = normalized.replace(GIT_GLOBAL_OPTION_PATTERN, 'git ');
152
- } while (normalized !== previous);
153
- return normalized;
154
- }
155
-
156
- function stripQuoted(text) {
157
- return text
158
- .replace(/```[\s\S]*?```/g, ' ')
159
- .replace(/"[^"\n]{0,300}"/g, ' ')
160
- .replace(/'[^'\n]{0,300}'/g, ' ');
161
- }
162
-
163
- /** True when a surviving, non-quoted mention is not governed by a reporting verb. */
164
- function hasRealPublishIntent(text, pattern) {
165
- if (!pattern.test(text)) return false;
166
- const cleaned = stripQuoted(text);
167
- const global = new RegExp(
168
- pattern.source,
169
- `${pattern.flags.replace('g', '')}g`,
170
- );
171
- const matches = [...cleaned.matchAll(global)];
172
- if (matches.length === 0) return false;
173
- return matches.some((match) => {
174
- const from = Math.max(0, match.index - DESCRIPTION_LOOK_BACK);
175
- return !REPORTING_VERB_PATTERN.test(cleaned.slice(from, match.index));
176
- });
177
- }
178
-
179
- function checkEmbeddedInterpreter(command) {
180
- const match = INTERPRETER_EVAL_PATTERN.exec(command);
181
- if (!match) return null;
182
- if (match[1].toLowerCase().startsWith('python')) {
183
- return 'Inline Python is not allowed in this harness: write the script to a file with Write and run that.';
184
- }
185
- const inline = command.slice(match.index + match[0].length);
186
- if (RAW_FILE_OPS_PATTERN.test(inline)) {
187
- return 'Inline interpreter doing a raw file operation (read/write/delete/mkdir). Use Write/Edit/Read instead.';
188
- }
189
- return null; // import/require or fetch: logic the native tools do not cover — allowed
190
- }
191
-
192
- /** The text to inspect: a real command's command line, or the delegation prompt. */
193
- function commandTextFrom(toolName, toolInput) {
194
- if (SHELL_TOOLS.has(toolName)) {
195
- return String(toolInput.CommandLine ?? toolInput.command ?? '');
196
- }
197
- return String(toolInput.prompt ?? toolInput.description ?? '');
198
- }
199
-
200
- /** Static deny rules + the runtime rm -rf rule, both read from config params. */
201
- function checkDestructive(command, parameters) {
202
- // Strip git's global options so `git -C /repo reset --hard` cannot slip past a pattern
203
- // written for `git reset --hard`. Non-git commands are unaffected.
204
- const normalized = normalizeGitOptions(command);
205
-
206
- // denyPatterns may be a flat list of sources or [source, reason] pairs; normalize.
207
- const denyPairs = (parameters.denyPatterns ?? []).map((entry) =>
208
- Array.isArray(entry)
209
- ? entry
210
- : [entry, 'Destructive command is not allowed.'],
211
- );
212
- for (const [source, reason] of denyPairs) {
213
- if (compile(source).test(normalized)) deny(GATE_ID, reason);
214
- }
215
-
216
- // rm -rf over a protected area: areas read from config at runtime, so editing
217
- // rmRfProtectedAreas takes effect without touching denyPatterns.
218
- const rmRfAreas =
219
- parameters.rmRfProtectedAreas ?? DEFAULT_RM_RF_PROTECTED_AREAS;
220
- if (
221
- rmRfAreas.length > 0 &&
222
- compile(rmRfSourceFrom(rmRfAreas)).test(command)
223
- ) {
224
- deny(GATE_ID, "'rm -rf' over a protected area is not allowed.");
225
- }
226
-
227
- if (parameters.embeddedInterpreterEnabled) {
228
- const reason = checkEmbeddedInterpreter(command);
229
- if (reason) deny(GATE_ID, reason);
230
- }
231
- }
232
-
233
- /** Remote-publish rules: a real command is checked literally; a delegation prompt by intent. */
234
- function checkRemotePublish(command, isShell) {
235
- // For a real shell command, normalize git's global options first (same bypass as above).
236
- // For a delegation prompt (free text), the intent check runs on the raw text.
237
- const shellCommand = normalizeGitOptions(command);
238
- for (const [source, reason] of REMOTE_PUBLISH_RULES) {
239
- const pattern = compile(source);
240
- if (isShell) {
241
- if (pattern.test(shellCommand)) deny(GATE_ID, reason);
242
- } else if (hasRealPublishIntent(command, pattern)) {
243
- deny(GATE_ID, reason);
244
- }
245
- }
246
- }
247
-
248
- runGate(
249
- {
250
- id: GATE_ID,
251
- configKey: CONFIG_KEY,
252
- enabledByDefault: true,
253
- defaultParams: {
254
- denyPatterns: defaultDenyPatterns(),
255
- rmRfProtectedAreas: DEFAULT_RM_RF_PROTECTED_AREAS,
256
- embeddedInterpreterEnabled: false,
257
- },
258
- },
259
- ({ toolName, toolInput, parameters }) => {
260
- const isShell = SHELL_TOOLS.has(toolName);
261
- const isDelegation = DELEGATION_TOOLS.has(toolName);
262
- if (!isShell && !isDelegation) return;
263
-
264
- const command = commandTextFrom(toolName, toolInput);
265
- checkDestructive(command, parameters);
266
- checkRemotePublish(command, isShell);
267
- },
268
- );
1
+ // bash-commands — denies destructive shell commands, and blocks publishing to a remote
2
+ // without fresh authorization. Runs on a real Bash/run_command call and on a delegation
3
+ // prompt (a subagent can be told "run git reset --hard" in prose).
4
+ //
5
+ // ── What a project can configure (params) ───────────────────────────────────────────
6
+ // denyPatterns regex sources (matched case-insensitively) of destructive
7
+ // commands to deny. Replaces the built-in list below wholesale.
8
+ // rmRfProtectedAreas targets rm -rf may not hit; woven into the rm -rf pattern.
9
+ // embeddedInterpreterEnabled block `node -e`/`python -c` that does raw file ops. Off
10
+ // by default: this harness legitimately uses inline interpreters.
11
+ // The defaults live here, in the source, so a project reads them and knows exactly what
12
+ // its override replaces.
13
+ //
14
+ // ── What is NOT configurable (base, non-negotiable) ─────────────────────────────────
15
+ // Remote-publish detection (`git push`, `gh pr merge`, `gh release create`) is a hard
16
+ // block with no config knob and no in-command escape hatch: authorization is something
17
+ // the USER states in chat, never a token the agent could write into its own command.
18
+ //
19
+ // ── Two-stage intent check, only for delegation prompts ─────────────────────────────
20
+ // A real command's `command` field IS what the shell runs — a single-stage regex is
21
+ // right. A delegation prompt is natural language that may DESCRIBE a command ("I extended
22
+ // the guard to deny git push") without asking anyone to run it. There, publish rules run
23
+ // a second stage: strip quoted/example text, then require that at least one surviving
24
+ // mention is not governed by a reporting verb before denying.
25
+
26
+ import {
27
+ runGate,
28
+ deny,
29
+ toolInGroups,
30
+ delegationPromptOf,
31
+ } from '../../lib/hook-io.mjs';
32
+
33
+ const GATE_ID = 'bash-commands';
34
+ const CONFIG_KEY = 'blockDestructiveShellCommands';
35
+
36
+ // Areas rm -rf must never target. A project overrides this list in config; the rm -rf
37
+ // deny pattern is rebuilt from it at runtime (see rmRfSourceFrom), so an edit takes effect.
38
+ const DEFAULT_RM_RF_PROTECTED_AREAS = ['/', '*', 'src', 'tests'];
39
+
40
+ // Process-killing command names, assembled from fragments so the spell checker does not
41
+ // read them as prose (the project keeps an empty dictionary by policy).
42
+ const KILL_BY_NAME_COMMANDS = ['task' + 'kill', 'p' + 'kill', 'kill' + 'all'];
43
+
44
+ // PowerShell's kill-by-name form: `Stop-Process -Name node` (and its Get-Process pipe). On
45
+ // Windows this reaches every process of that name exactly like taskkill /IM, so it belongs
46
+ // in the same block. Matched separately because its shape (a -Name flag) differs from the
47
+ // unix commands above.
48
+ const STOP_PROCESS_BY_NAME_SOURCE = String.raw`\bStop-Process\b[^|;\n]*\s-Name\b`;
49
+
50
+ // The rm -rf deny source, built from the protected-areas list. Separate from the static
51
+ // deny list so a project can edit rmRfProtectedAreas in config and have it take effect at
52
+ // runtime: the list is re-read on every call, not baked in at load time.
53
+ function rmRfSourceFrom(protectedAreas) {
54
+ const rmRfTargets = protectedAreas
55
+ .map((area) => area.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
56
+ .join('|');
57
+ // Allow an optional `./` (or `.\`) prefix before the target, so `rm -rf ./src` and
58
+ // `rm -rf .\src` are caught, not just the bare `rm -rf src`. Without this, a leading
59
+ // `./` sat between the required whitespace and the target's word boundary and slipped past.
60
+ return String.raw`\brm\s+-rf\s+(?:\.[\\/])?(${rmRfTargets})\b`;
61
+ }
62
+
63
+ /**
64
+ * Static destructive-command rules as `[regexSource, reason]`. Sources (not RegExp) so
65
+ * they are JSON-serializable and a project can override them through config. rm -rf is not
66
+ * here — it is built at runtime from rmRfProtectedAreas (see rmRfSourceFrom).
67
+ */
68
+ function defaultDenyPatterns() {
69
+ const [taskKill, pKill, killAll] = KILL_BY_NAME_COMMANDS;
70
+ return [
71
+ [
72
+ String.raw`\bgit\s+reset\s+--hard\b`,
73
+ "'git reset --hard' is destructive and needs explicit authorization.",
74
+ ],
75
+ [
76
+ String.raw`\bgit\s+push\s+.*--force\b`,
77
+ "'git push --force' is destructive and is not allowed.",
78
+ ],
79
+ [
80
+ String.raw`\bgit\s+clean\s+-[a-z]*f[a-z]*\b`,
81
+ "'git clean -f' is destructive and is not allowed.",
82
+ ],
83
+ [
84
+ // Killing by image name reaches every process with that name on the machine — the
85
+ // server the user is watching, another session's, a half-run tool. The damage is
86
+ // silent. Keep the PID you started and kill that; if lost, identify by full command
87
+ // line and confirm ownership first.
88
+ [
89
+ String.raw`\b(`,
90
+ taskKill,
91
+ String.raw`\s+[^|;]*[/]IM|`,
92
+ pKill,
93
+ String.raw`\s+|`,
94
+ killAll,
95
+ String.raw`\s+)`,
96
+ ].join(''),
97
+ 'Killing processes by NAME reaches everything with that name, not just yours. ' +
98
+ 'Keep the PID you started and kill that; if lost, find it by its full command ' +
99
+ 'line and confirm it is yours before touching it.',
100
+ ],
101
+ [
102
+ STOP_PROCESS_BY_NAME_SOURCE,
103
+ 'Stop-Process -Name kills every process of that name on the machine, not just yours. ' +
104
+ 'Stop the specific process by its Id (the PID you started); if lost, identify it by ' +
105
+ 'its full command line and confirm ownership first.',
106
+ ],
107
+ ];
108
+ }
109
+
110
+ /** Remote-publish rules. Base, non-configurable see the header. */
111
+ const REMOTE_PUBLISH_RULES = [
112
+ [
113
+ String.raw`\bgit\s+(?:-C\s+\S+\s+)?push\b`,
114
+ "Publishing to a remote ('git push') needs fresh authorization from the user naming " +
115
+ 'commits, local branch, remote and target. This gate cannot verify that from the ' +
116
+ 'command itself ask the user and let them run it.',
117
+ ],
118
+ [
119
+ String.raw`\bgh\s+(?:pr\s+merge|release\s+create)\b`,
120
+ 'Publishing via GitHub CLI (merging a PR or creating a release) needs fresh ' +
121
+ 'authorization from the user naming commits, local branch, remote and target. ' +
122
+ 'Ask the user and let them run it.',
123
+ ],
124
+ ];
125
+
126
+ // Interpreter name, then any intermediate flags (e.g. --input-type=module), then an
127
+ // eval flag. Intermediate flags are matched loosely (`-\S+`) to keep the pattern simple.
128
+ const INTERPRETER_EVAL_PATTERN =
129
+ /\b(node|python3?|deno|bun)\b(?:\s+-\S+)*?\s+(?:-e|-c|--eval|--print|-p)\b/i;
130
+ const RAW_FILE_OPS_PATTERN =
131
+ /\b(writeFileSync|readFileSync|appendFileSync|fs\.writeFile|fs\.readFile|fs\.unlink|fs\.mkdir)\b/;
132
+
133
+ // How far back from a mention to look for a governing verb, and the reporting-verb lexicon
134
+ // that marks a mention as description rather than an order. Only the text before a mention
135
+ // is inspected: what governs it is what precedes it (appending words after a real command
136
+ // would otherwise be a trivial bypass).
137
+ const DESCRIPTION_LOOK_BACK = 80;
138
+ const REPORTING_VERB_PATTERN =
139
+ /(describe|explain|summar|mention|added|built|extended|denies?|deny|prohibit|protection|report|documentation|changelog)/i;
140
+
141
+ function compile(source) {
142
+ return new RegExp(source, 'i');
143
+ }
144
+
145
+ // git's GLOBAL options sit between `git` and the subcommand: `git -C <path> reset --hard`,
146
+ // `git -c k=v push`, `git --git-dir=… clean -f`. A pattern that matches `git reset --hard`
147
+ // contiguously is evaded by any of them. Stripping these options first — turning
148
+ // `git -C /repo reset --hard` back into `git reset --hard` — closes that bypass for every
149
+ // git rule at once, instead of teaching each pattern about every global option.
150
+ //
151
+ // A single global option, matched one at a time and stripped repeatedly (below), so the
152
+ // pattern stays simple: an option taking a value (`-C /path`, `--git-dir=…`) or a flag
153
+ // (`--no-pager`). The leading `git ` is kept; only the option after it is removed.
154
+ const GIT_OPTION_WITH_VALUE = String.raw`(?:-[Cc]|--git-dir|--work-tree|--namespace|--exec-path|--config-env)(?:\s+|=)\S+`;
155
+ const GIT_FLAG_OPTION = String.raw`--(?:paginate|no-pager|bare|no-optional-locks)|-p`;
156
+ const GIT_GLOBAL_OPTION_PATTERN = new RegExp(
157
+ String.raw`\bgit\s+(?:${GIT_OPTION_WITH_VALUE}|${GIT_FLAG_OPTION})\s+`,
158
+ 'i',
159
+ );
160
+
161
+ function normalizeGitOptions(command) {
162
+ // Strip one leading global option at a time and re-run, so a stacked
163
+ // `git -c a=b -C /x reset` is fully reduced to `git reset` before the deny patterns run.
164
+ let previous;
165
+ let normalized = command;
166
+ do {
167
+ previous = normalized;
168
+ normalized = normalized.replace(GIT_GLOBAL_OPTION_PATTERN, 'git ');
169
+ } while (normalized !== previous);
170
+ return normalized;
171
+ }
172
+
173
+ function stripQuoted(text) {
174
+ return text
175
+ .replace(/```[\s\S]*?```/g, ' ')
176
+ .replace(/"[^"\n]{0,300}"/g, ' ')
177
+ .replace(/'[^'\n]{0,300}'/g, ' ');
178
+ }
179
+
180
+ /** True when a surviving, non-quoted mention is not governed by a reporting verb. */
181
+ function hasRealPublishIntent(text, pattern) {
182
+ if (!pattern.test(text)) return false;
183
+ const cleaned = stripQuoted(text);
184
+ const global = new RegExp(
185
+ pattern.source,
186
+ `${pattern.flags.replace('g', '')}g`,
187
+ );
188
+ const matches = [...cleaned.matchAll(global)];
189
+ if (matches.length === 0) return false;
190
+ return matches.some((match) => {
191
+ const from = Math.max(0, match.index - DESCRIPTION_LOOK_BACK);
192
+ return !REPORTING_VERB_PATTERN.test(cleaned.slice(from, match.index));
193
+ });
194
+ }
195
+
196
+ function checkEmbeddedInterpreter(command) {
197
+ const match = INTERPRETER_EVAL_PATTERN.exec(command);
198
+ if (!match) return null;
199
+ if (match[1].toLowerCase().startsWith('python')) {
200
+ return 'Inline Python is not allowed in this harness: write the script to a file with Write and run that.';
201
+ }
202
+ const inline = command.slice(match.index + match[0].length);
203
+ if (RAW_FILE_OPS_PATTERN.test(inline)) {
204
+ return 'Inline interpreter doing a raw file operation (read/write/delete/mkdir). Use Write/Edit/Read instead.';
205
+ }
206
+ return null; // import/require or fetch: logic the native tools do not cover allowed
207
+ }
208
+
209
+ /** The text to inspect: a real command's command line, or the delegation prompt. */
210
+ function commandTextFrom(toolName, toolInput) {
211
+ if (toolInGroups(toolName, ['shell'])) {
212
+ return String(toolInput.CommandLine ?? toolInput.command ?? '');
213
+ }
214
+ return delegationPromptOf(toolInput);
215
+ }
216
+
217
+ /** Static deny rules + the runtime rm -rf rule, both read from config params. */
218
+ function checkDestructive(command, parameters) {
219
+ // Strip git's global options so `git -C /repo reset --hard` cannot slip past a pattern
220
+ // written for `git reset --hard`. Non-git commands are unaffected.
221
+ const normalized = normalizeGitOptions(command);
222
+
223
+ // denyPatterns may be a flat list of sources or [source, reason] pairs; normalize.
224
+ const denyPairs = (parameters.denyPatterns ?? []).map((entry) =>
225
+ Array.isArray(entry)
226
+ ? entry
227
+ : [entry, 'Destructive command is not allowed.'],
228
+ );
229
+ for (const [source, reason] of denyPairs) {
230
+ if (compile(source).test(normalized)) deny(GATE_ID, reason);
231
+ }
232
+
233
+ // rm -rf over a protected area: areas read from config at runtime, so editing
234
+ // rmRfProtectedAreas takes effect without touching denyPatterns.
235
+ const rmRfAreas =
236
+ parameters.rmRfProtectedAreas ?? DEFAULT_RM_RF_PROTECTED_AREAS;
237
+ if (
238
+ rmRfAreas.length > 0 &&
239
+ compile(rmRfSourceFrom(rmRfAreas)).test(command)
240
+ ) {
241
+ deny(GATE_ID, "'rm -rf' over a protected area is not allowed.");
242
+ }
243
+
244
+ if (parameters.embeddedInterpreterEnabled) {
245
+ const reason = checkEmbeddedInterpreter(command);
246
+ if (reason) deny(GATE_ID, reason);
247
+ }
248
+ }
249
+
250
+ /** Remote-publish rules: a real command is checked literally; a delegation prompt by intent. */
251
+ function checkRemotePublish(command, isShell) {
252
+ // For a real shell command, normalize git's global options first (same bypass as above).
253
+ // For a delegation prompt (free text), the intent check runs on the raw text.
254
+ const shellCommand = normalizeGitOptions(command);
255
+ for (const [source, reason] of REMOTE_PUBLISH_RULES) {
256
+ const pattern = compile(source);
257
+ if (isShell) {
258
+ if (pattern.test(shellCommand)) deny(GATE_ID, reason);
259
+ } else if (hasRealPublishIntent(command, pattern)) {
260
+ deny(GATE_ID, reason);
261
+ }
262
+ }
263
+ }
264
+
265
+ runGate(
266
+ {
267
+ id: GATE_ID,
268
+ configKey: CONFIG_KEY,
269
+ enabledByDefault: true,
270
+ defaultParams: {
271
+ denyPatterns: defaultDenyPatterns(),
272
+ rmRfProtectedAreas: DEFAULT_RM_RF_PROTECTED_AREAS,
273
+ embeddedInterpreterEnabled: false,
274
+ },
275
+ },
276
+ ({ toolName, toolInput, parameters }) => {
277
+ const isShell = toolInGroups(toolName, ['shell']);
278
+ const isDelegation = toolInGroups(toolName, ['delegation']);
279
+ if (!isShell && !isDelegation) return;
280
+
281
+ const command = commandTextFrom(toolName, toolInput);
282
+ checkDestructive(command, parameters);
283
+ checkRemotePublish(command, isShell);
284
+ },
285
+ );