@devrik-tools/claude-gates 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,13 +11,13 @@
11
11
  "name": "gates",
12
12
  "source": "./plugins/gates",
13
13
  "description": "PreToolUse gates: destructive-command blocks, protected paths, delegation/spec/quality rules, tool-discovery and forge-pipeline enforcement. Which gates run is decided by .ai/config.json (project) or ~/.claude/claude-gates/config.json (global).",
14
- "version": "0.2.0"
14
+ "version": "0.2.1"
15
15
  },
16
16
  {
17
17
  "name": "tasks",
18
18
  "source": "./plugins/tasks",
19
19
  "description": "Deterministic task tracking: the model registers tasks via the CLI, a UserPromptSubmit hook reminds of open tasks every few messages, and a SessionStart hook lists active tasks when a session opens.",
20
- "version": "0.2.0"
20
+ "version": "0.2.1"
21
21
  }
22
22
  ]
23
23
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devrik-tools/claude-gates",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Installable, deterministic gates (hooks) for Claude Code: block destructive commands, protected paths, and enforce delegation/spec/quality rules. Configurable per project.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gates",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Deterministic gates for Claude Code: destructive-command blocks, protected paths, delegation briefs, spec-driven flow and session-start validation. Selection lives in config, not in code.",
5
5
  "author": {
6
6
  "name": "Devrik"
@@ -28,12 +28,14 @@ const CONFIG_KEY = 'autonomousMode';
28
28
  const QUESTION_GROUPS = ['question'];
29
29
 
30
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.';
31
+ 'Autonomous mode is ON for this project: do not ask the user, decide instead. The question ' +
32
+ 'popup was intercepted and never shown. Now, in your VISIBLE reply, leave a one-line trace ' +
33
+ 'so the user sees what was suppressed and what you chose e.g. "[autonomous] I was going ' +
34
+ 'to ask <the question>; I decided <choice>, assuming <reversible assumption>." Then take ' +
35
+ 'that best, goal-aligned decision and proceed. Only a genuinely irreversible or dangerous ' +
36
+ 'choice (deleting data, money, production) justifies stopping — and then stop in prose ' +
37
+ 'saying exactly what and why, do not reach for the popup again. To let questions through, ' +
38
+ 'set "autonomousMode": false in .ai/config.json.';
37
39
 
38
40
  runGate(
39
41
  {
@@ -14,7 +14,13 @@
14
14
  // dotfiles are a different, well-known convention this gate does not police.
15
15
 
16
16
  import { basename, isAbsolute, resolve, sep } from 'node:path';
17
- import { runGate, deny, toolInGroups, writtenPathOf } from '../../lib/hook-io.mjs';
17
+ import {
18
+ runGate,
19
+ deny,
20
+ toolInGroups,
21
+ writtenPathOf,
22
+ shellWrittenPaths,
23
+ } from '../../lib/hook-io.mjs';
18
24
 
19
25
  const GATE_ID = 'root-whitelist';
20
26
  const CONFIG_KEY = 'blockPathsOutsideRootWhitelist';
@@ -49,6 +55,40 @@ const DEFAULT_ROOT_FOLDERS_WHITELIST = [
49
55
  'plugins',
50
56
  ];
51
57
 
58
+ // The whitelist verdict for one target path. Returns a deny reason string when the path is a
59
+ // non-whitelisted new thing at the project root, or null when it is allowed (outside the root,
60
+ // a dotfile, or whitelisted). Shared by the write-tool path and every shell-created path, so a
61
+ // `printf x > basura.txt` is judged by the same rule as a Write to basura.txt.
62
+ function rootWhitelistViolation(target, filesWhitelist, foldersWhitelist) {
63
+ if (!target) return null;
64
+
65
+ // Some tools send a path relative to cwd rather than absolute. resolve() leaves an
66
+ // already-absolute path merely normalized ('..' collapsed, separators fixed), and resolves
67
+ // a relative one against process.cwd() — so either shape lands on the same absolute path.
68
+ const normalized = isAbsolute(target)
69
+ ? resolve(target)
70
+ : resolve(process.cwd(), target);
71
+ const projectRoot = process.cwd() + sep;
72
+
73
+ // A path outside the project (scratchpad, temp, another repo) is not at its root — not this
74
+ // rule's business. Judged AFTER resolving, so a relative path is judged by where it lands.
75
+ if (!normalized.startsWith(projectRoot)) return null;
76
+
77
+ const relativePath = normalized.slice(projectRoot.length);
78
+
79
+ if (!relativePath.includes(sep)) {
80
+ const fileName = basename(relativePath);
81
+ if (fileName.startsWith('.') || filesWhitelist.has(fileName.toLowerCase())) return null;
82
+ return `'${fileName}' at the project root is not on the whitelist (${[...filesWhitelist].join(', ')}).`;
83
+ }
84
+
85
+ const topDirectory = relativePath.split(sep)[0];
86
+ if (topDirectory.startsWith('.') || foldersWhitelist.has(topDirectory.toLowerCase())) {
87
+ return null;
88
+ }
89
+ return `Folder '${topDirectory}' at the project root is not on the whitelist (${[...foldersWhitelist].join(', ')}).`;
90
+ }
91
+
52
92
  runGate(
53
93
  {
54
94
  id: GATE_ID,
@@ -60,30 +100,12 @@ runGate(
60
100
  },
61
101
  },
62
102
  ({ toolName, toolInput, parameters }) => {
63
- if (!toolInGroups(toolName, ['write'])) return;
103
+ const isWrite = toolInGroups(toolName, ['write']);
104
+ const isShell = toolInGroups(toolName, ['shell']);
105
+ if (!isWrite && !isShell) return;
64
106
 
65
- const target = writtenPathOf(toolInput);
66
- if (!target) return;
67
-
68
- // Some tools send a path relative to cwd rather than absolute. resolve() leaves an
69
- // already-absolute path merely normalized ('..' collapsed, separators fixed), and
70
- // resolves a relative one against process.cwd() — so either shape lands on the same
71
- // absolute path the root check below expects.
72
- const normalized = isAbsolute(target)
73
- ? resolve(target)
74
- : resolve(process.cwd(), target);
75
- const projectRoot = process.cwd() + sep;
76
-
77
- // The whitelist describes the project's ROOT. A file outside the project (session
78
- // scratchpad, system temp, another repo) is not at its root, and this rule does not
79
- // govern it: letting it through is correct, not an exception. This check runs AFTER
80
- // resolving relative paths, so a relative path is judged by where it actually lands.
81
- if (!normalized.startsWith(projectRoot)) return;
82
-
83
- const relativePath = normalized.slice(projectRoot.length);
84
- // Windows filesystems are case-insensitive: 'Package.json' and 'package.json' are the
85
- // same file. Comparing lowercase on both sides avoids denying a whitelisted name that
86
- // merely differs in case (a false positive, not a real gap).
107
+ // Windows filesystems are case-insensitive: 'Package.json' and 'package.json' are the same
108
+ // file. Comparing lowercase on both sides avoids a false positive on a case difference.
87
109
  const filesWhitelist = new Set(
88
110
  (parameters.rootFilesWhitelist ?? []).map((name) => name.toLowerCase()),
89
111
  );
@@ -91,26 +113,19 @@ runGate(
91
113
  (parameters.rootFoldersWhitelist ?? []).map((name) => name.toLowerCase()),
92
114
  );
93
115
 
94
- if (!relativePath.includes(sep)) {
95
- const fileName = basename(relativePath);
96
- if (fileName.startsWith('.') || filesWhitelist.has(fileName.toLowerCase()))
97
- return;
98
- deny(
99
- GATE_ID,
100
- `'${fileName}' at the project root is not on the whitelist (${[...filesWhitelist].join(', ')}).`,
101
- );
102
- return;
103
- }
116
+ // The paths to judge: a write tool's target, OR every path a shell command creates via a
117
+ // redirection / touch / tee / cp / mv. The latter closes the hole where `> basura.txt`
118
+ // created a root file that a Write to the same name would have been denied. This does not
119
+ // resolve a dynamically built path (`> "$f"`) — that honest limitation is the boundary of
120
+ // a regex, and is why the write-tool path (which carries a concrete file_path) stays the
121
+ // primary, most reliable surface.
122
+ const targets = isShell
123
+ ? shellWrittenPaths(String(toolInput?.command ?? toolInput?.CommandLine ?? ''))
124
+ : [writtenPathOf(toolInput)];
104
125
 
105
- const topDirectory = relativePath.split(sep)[0];
106
- if (
107
- topDirectory.startsWith('.') ||
108
- foldersWhitelist.has(topDirectory.toLowerCase())
109
- )
110
- return;
111
- deny(
112
- GATE_ID,
113
- `Folder '${topDirectory}' at the project root is not on the whitelist (${[...foldersWhitelist].join(', ')}).`,
114
- );
126
+ for (const target of targets) {
127
+ const reason = rootWhitelistViolation(target, filesWhitelist, foldersWhitelist);
128
+ if (reason) deny(GATE_ID, reason);
129
+ }
115
130
  },
116
131
  );
@@ -95,3 +95,30 @@ test('FIXED: a root file matching the whitelist except for letter case is now al
95
95
  // `TargetFile`, or `target_file`) — writeTargetFrom does not recognize `path` alone
96
96
  // unless the tool is also in WRITE_TOOLS. Since MCP tools already fail the toolName
97
97
  // check, this compounds the same root bug; not a separate one worth a distinct test.
98
+
99
+ // ── FIXED (the VPS bug): a shell REDIRECTION creating a root file evaded the gate, because
100
+ // root-whitelist only watched write tools, not Bash. It now also inspects the paths a shell
101
+ // command creates (redirection / touch / tee / cp / mv), matching protected-paths' behavior.
102
+ function bash(command) {
103
+ return { tool_name: 'Bash', tool_input: { command } };
104
+ }
105
+
106
+ test('FIXED: a shell redirection creating an orphan root file is now denied', () => {
107
+ const p = project();
108
+ assert.ok(isDeny(p.run(bash('printf "x" > basura.txt'))), 'printf > basura.txt must be denied');
109
+ });
110
+
111
+ test('FIXED: touch of an orphan root file via Bash is now denied', () => {
112
+ const p = project();
113
+ assert.ok(isDeny(p.run(bash('touch orphan.js'))), 'touch orphan.js must be denied');
114
+ });
115
+
116
+ test('OK: a shell redirection into a whitelisted folder is allowed', () => {
117
+ const p = project();
118
+ assert.equal(p.run(bash('echo x > src/ok.js')), null, 'src/ is whitelisted');
119
+ });
120
+
121
+ test('OK: a shell command that creates nothing (git status) is allowed', () => {
122
+ const p = project();
123
+ assert.equal(p.run(bash('git status')), null);
124
+ });
@@ -1,346 +1,346 @@
1
- {
2
- "hooks": {
3
- "PreToolUse": [
4
- {
5
- "matcher": "Bash|run_command|Agent|Task|invoke_subagent|mcp__.*",
6
- "hooks": [
7
- {
8
- "type": "command",
9
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/bash-commands/index.mjs\"",
10
- "timeout": 10
11
- }
12
- ]
13
- },
14
- {
15
- "matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content|Bash|run_command|mcp__.*",
16
- "hooks": [
17
- {
18
- "type": "command",
19
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/protected-paths/index.mjs\"",
20
- "timeout": 10
21
- }
22
- ]
23
- },
24
- {
25
- "matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content|mcp__.*",
26
- "hooks": [
27
- {
28
- "type": "command",
29
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/root-whitelist/index.mjs\"",
30
- "timeout": 10
31
- }
32
- ]
33
- },
34
- {
35
- "matcher": "Bash|run_command|Agent|Task|invoke_subagent|mcp__.*",
36
- "hooks": [
37
- {
38
- "type": "command",
39
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/no-blocking/index.mjs\"",
40
- "timeout": 10
41
- }
42
- ]
43
- },
44
- {
45
- "matcher": "Agent|Task|invoke_subagent|mcp__.*",
46
- "hooks": [
47
- {
48
- "type": "command",
49
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/brief-before-delegate/index.mjs\"",
50
- "timeout": 10
51
- }
52
- ]
53
- },
54
- {
55
- "matcher": "Agent|Task|invoke_subagent|mcp__.*",
56
- "hooks": [
57
- {
58
- "type": "command",
59
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/intent-flow/index.mjs\"",
60
- "timeout": 10
61
- }
62
- ]
63
- },
64
- {
65
- "matcher": "Agent|Task|invoke_subagent|mcp__.*",
66
- "hooks": [
67
- {
68
- "type": "command",
69
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/risk-level/index.mjs\"",
70
- "timeout": 10
71
- }
72
- ]
73
- },
74
- {
75
- "matcher": "Agent|Task|invoke_subagent|mcp__.*",
76
- "hooks": [
77
- {
78
- "type": "command",
79
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/circuit-breaker/index.mjs\"",
80
- "timeout": 10
81
- }
82
- ]
83
- },
84
- {
85
- "matcher": "Agent|Task|invoke_subagent|mcp__.*",
86
- "hooks": [
87
- {
88
- "type": "command",
89
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/no-memory-dependency/index.mjs\"",
90
- "timeout": 10
91
- }
92
- ]
93
- },
94
- {
95
- "matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content|mcp__.*",
96
- "hooks": [
97
- {
98
- "type": "command",
99
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/feature-catalog/index.mjs\"",
100
- "timeout": 10
101
- }
102
- ]
103
- },
104
- {
105
- "matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content|Agent|Task|invoke_subagent|mcp__.*",
106
- "hooks": [
107
- {
108
- "type": "command",
109
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/sdd-specs/index.mjs\"",
110
- "timeout": 10
111
- }
112
- ]
113
- },
114
- {
115
- "matcher": "Agent|Task|invoke_subagent|mcp__.*",
116
- "hooks": [
117
- {
118
- "type": "command",
119
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/implementation-pipeline/index.mjs\"",
120
- "timeout": 10
121
- }
122
- ]
123
- },
124
- {
125
- "matcher": "Agent|Task|invoke_subagent|mcp__.*",
126
- "hooks": [
127
- {
128
- "type": "command",
129
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/mandatory-flow/index.mjs\"",
130
- "timeout": 10
131
- }
132
- ]
133
- },
134
- {
135
- "matcher": "Agent|Task|invoke_subagent|mcp__.*",
136
- "hooks": [
137
- {
138
- "type": "command",
139
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/test-matrix/index.mjs\"",
140
- "timeout": 10
141
- }
142
- ]
143
- },
144
- {
145
- "matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content|mcp__.*",
146
- "hooks": [
147
- {
148
- "type": "command",
149
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/dependency-skills/index.mjs\"",
150
- "timeout": 10
151
- }
152
- ]
153
- },
154
- {
155
- "matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content|Agent|Task|invoke_subagent|mcp__.*",
156
- "hooks": [
157
- {
158
- "type": "command",
159
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/root-cause-first/index.mjs\"",
160
- "timeout": 10
161
- }
162
- ]
163
- },
164
- {
165
- "matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content|Agent|Task|invoke_subagent|mcp__.*",
166
- "hooks": [
167
- {
168
- "type": "command",
169
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/audit-before-build/index.mjs\"",
170
- "timeout": 10
171
- }
172
- ]
173
- },
174
- {
175
- "matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content|Agent|Task|invoke_subagent|mcp__.*",
176
- "hooks": [
177
- {
178
- "type": "command",
179
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/never-assume/index.mjs\"",
180
- "timeout": 10
181
- }
182
- ]
183
- },
184
- {
185
- "matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content|Bash|run_command|mcp__ide__executeCode|Agent|Task|invoke_subagent|mcp__.*",
186
- "hooks": [
187
- {
188
- "type": "command",
189
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/rule-skill-autodiscovery/index.mjs\"",
190
- "timeout": 10
191
- }
192
- ]
193
- },
194
- {
195
- "matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content|Bash|run_command|mcp__ide__executeCode|Agent|Task|invoke_subagent|mcp__.*",
196
- "hooks": [
197
- {
198
- "type": "command",
199
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/recurrence-lock/index.mjs\"",
200
- "timeout": 10
201
- }
202
- ]
203
- },
204
- {
205
- "matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content|mcp__.*",
206
- "hooks": [
207
- {
208
- "type": "command",
209
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/test-after-implementation/index.mjs\"",
210
- "timeout": 10
211
- }
212
- ]
213
- },
214
- {
215
- "matcher": "AskUserQuestion|mcp__.*",
216
- "hooks": [
217
- {
218
- "type": "command",
219
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/no-reconfirm/index.mjs\"",
220
- "timeout": 10
221
- }
222
- ]
223
- },
224
- {
225
- "matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content|mcp__.*",
226
- "hooks": [
227
- {
228
- "type": "command",
229
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/neutral-spanish/index.mjs\"",
230
- "timeout": 10
231
- }
232
- ]
233
- },
234
- {
235
- "matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content|mcp__.*",
236
- "hooks": [
237
- {
238
- "type": "command",
239
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/diagnosis-before-patch/index.mjs\"",
240
- "timeout": 10
241
- }
242
- ]
243
- },
244
- {
245
- "matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content|Agent|Task|invoke_subagent|mcp__.*",
246
- "hooks": [
247
- {
248
- "type": "command",
249
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/reuse-before-build/index.mjs\"",
250
- "timeout": 10
251
- }
252
- ]
253
- },
254
- {
255
- "matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content|mcp__.*",
256
- "hooks": [
257
- {
258
- "type": "command",
259
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/tool-map/index.mjs\"",
260
- "timeout": 10
261
- }
262
- ]
263
- },
264
- {
265
- "matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content|Bash|run_command|mcp__.*",
266
- "hooks": [
267
- {
268
- "type": "command",
269
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/forge-flow/index.mjs\"",
270
- "timeout": 10
271
- }
272
- ]
273
- },
274
- {
275
- "matcher": "AskUserQuestion|mcp__.*",
276
- "hooks": [
277
- {
278
- "type": "command",
279
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/autonomous-mode/index.mjs\"",
280
- "timeout": 10
281
- }
282
- ]
283
- },
284
- {
285
- "matcher": "Bash|run_command|mcp__.*",
286
- "hooks": [
287
- {
288
- "type": "command",
289
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/lint-commit/index.mjs\"",
290
- "timeout": 65
291
- }
292
- ]
293
- },
294
- {
295
- "matcher": "Agent|Task|invoke_subagent|mcp__.*",
296
- "hooks": [
297
- {
298
- "type": "command",
299
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/force-parallel/index.mjs\"",
300
- "timeout": 10
301
- }
302
- ]
303
- }
304
- ],
305
- "Stop": [
306
- {
307
- "hooks": [
308
- {
309
- "type": "command",
310
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/stop-pending/index.mjs\"",
311
- "timeout": 10
312
- }
313
- ]
314
- }
315
- ],
316
- "SessionStart": [
317
- {
318
- "hooks": [
319
- {
320
- "type": "command",
321
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/doctor.mjs\"",
322
- "timeout": 10
323
- }
324
- ]
325
- },
326
- {
327
- "hooks": [
328
- {
329
- "type": "command",
330
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/ask-adoption.mjs\"",
331
- "timeout": 10
332
- }
333
- ]
334
- },
335
- {
336
- "hooks": [
337
- {
338
- "type": "command",
339
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/wiring-check.mjs\"",
340
- "timeout": 10
341
- }
342
- ]
343
- }
344
- ]
345
- }
346
- }
1
+ {
2
+ "hooks": {
3
+ "PreToolUse": [
4
+ {
5
+ "matcher": "Bash|run_command|Agent|Task|invoke_subagent|mcp__.*",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/bash-commands/index.mjs\"",
10
+ "timeout": 30
11
+ }
12
+ ]
13
+ },
14
+ {
15
+ "matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content|Bash|run_command|mcp__.*",
16
+ "hooks": [
17
+ {
18
+ "type": "command",
19
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/protected-paths/index.mjs\"",
20
+ "timeout": 30
21
+ }
22
+ ]
23
+ },
24
+ {
25
+ "matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content|Bash|run_command|mcp__.*",
26
+ "hooks": [
27
+ {
28
+ "type": "command",
29
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/root-whitelist/index.mjs\"",
30
+ "timeout": 30
31
+ }
32
+ ]
33
+ },
34
+ {
35
+ "matcher": "Bash|run_command|Agent|Task|invoke_subagent|mcp__.*",
36
+ "hooks": [
37
+ {
38
+ "type": "command",
39
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/no-blocking/index.mjs\"",
40
+ "timeout": 30
41
+ }
42
+ ]
43
+ },
44
+ {
45
+ "matcher": "Agent|Task|invoke_subagent|mcp__.*",
46
+ "hooks": [
47
+ {
48
+ "type": "command",
49
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/brief-before-delegate/index.mjs\"",
50
+ "timeout": 30
51
+ }
52
+ ]
53
+ },
54
+ {
55
+ "matcher": "Agent|Task|invoke_subagent|mcp__.*",
56
+ "hooks": [
57
+ {
58
+ "type": "command",
59
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/intent-flow/index.mjs\"",
60
+ "timeout": 30
61
+ }
62
+ ]
63
+ },
64
+ {
65
+ "matcher": "Agent|Task|invoke_subagent|mcp__.*",
66
+ "hooks": [
67
+ {
68
+ "type": "command",
69
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/risk-level/index.mjs\"",
70
+ "timeout": 30
71
+ }
72
+ ]
73
+ },
74
+ {
75
+ "matcher": "Agent|Task|invoke_subagent|mcp__.*",
76
+ "hooks": [
77
+ {
78
+ "type": "command",
79
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/circuit-breaker/index.mjs\"",
80
+ "timeout": 30
81
+ }
82
+ ]
83
+ },
84
+ {
85
+ "matcher": "Agent|Task|invoke_subagent|mcp__.*",
86
+ "hooks": [
87
+ {
88
+ "type": "command",
89
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/no-memory-dependency/index.mjs\"",
90
+ "timeout": 30
91
+ }
92
+ ]
93
+ },
94
+ {
95
+ "matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content|mcp__.*",
96
+ "hooks": [
97
+ {
98
+ "type": "command",
99
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/feature-catalog/index.mjs\"",
100
+ "timeout": 30
101
+ }
102
+ ]
103
+ },
104
+ {
105
+ "matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content|Agent|Task|invoke_subagent|mcp__.*",
106
+ "hooks": [
107
+ {
108
+ "type": "command",
109
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/sdd-specs/index.mjs\"",
110
+ "timeout": 30
111
+ }
112
+ ]
113
+ },
114
+ {
115
+ "matcher": "Agent|Task|invoke_subagent|mcp__.*",
116
+ "hooks": [
117
+ {
118
+ "type": "command",
119
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/implementation-pipeline/index.mjs\"",
120
+ "timeout": 30
121
+ }
122
+ ]
123
+ },
124
+ {
125
+ "matcher": "Agent|Task|invoke_subagent|mcp__.*",
126
+ "hooks": [
127
+ {
128
+ "type": "command",
129
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/mandatory-flow/index.mjs\"",
130
+ "timeout": 30
131
+ }
132
+ ]
133
+ },
134
+ {
135
+ "matcher": "Agent|Task|invoke_subagent|mcp__.*",
136
+ "hooks": [
137
+ {
138
+ "type": "command",
139
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/test-matrix/index.mjs\"",
140
+ "timeout": 30
141
+ }
142
+ ]
143
+ },
144
+ {
145
+ "matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content|mcp__.*",
146
+ "hooks": [
147
+ {
148
+ "type": "command",
149
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/dependency-skills/index.mjs\"",
150
+ "timeout": 30
151
+ }
152
+ ]
153
+ },
154
+ {
155
+ "matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content|Agent|Task|invoke_subagent|mcp__.*",
156
+ "hooks": [
157
+ {
158
+ "type": "command",
159
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/root-cause-first/index.mjs\"",
160
+ "timeout": 30
161
+ }
162
+ ]
163
+ },
164
+ {
165
+ "matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content|Agent|Task|invoke_subagent|mcp__.*",
166
+ "hooks": [
167
+ {
168
+ "type": "command",
169
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/audit-before-build/index.mjs\"",
170
+ "timeout": 30
171
+ }
172
+ ]
173
+ },
174
+ {
175
+ "matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content|Agent|Task|invoke_subagent|mcp__.*",
176
+ "hooks": [
177
+ {
178
+ "type": "command",
179
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/never-assume/index.mjs\"",
180
+ "timeout": 30
181
+ }
182
+ ]
183
+ },
184
+ {
185
+ "matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content|Bash|run_command|mcp__ide__executeCode|Agent|Task|invoke_subagent|mcp__.*",
186
+ "hooks": [
187
+ {
188
+ "type": "command",
189
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/rule-skill-autodiscovery/index.mjs\"",
190
+ "timeout": 30
191
+ }
192
+ ]
193
+ },
194
+ {
195
+ "matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content|Bash|run_command|mcp__ide__executeCode|Agent|Task|invoke_subagent|mcp__.*",
196
+ "hooks": [
197
+ {
198
+ "type": "command",
199
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/recurrence-lock/index.mjs\"",
200
+ "timeout": 30
201
+ }
202
+ ]
203
+ },
204
+ {
205
+ "matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content|mcp__.*",
206
+ "hooks": [
207
+ {
208
+ "type": "command",
209
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/test-after-implementation/index.mjs\"",
210
+ "timeout": 30
211
+ }
212
+ ]
213
+ },
214
+ {
215
+ "matcher": "AskUserQuestion|mcp__.*",
216
+ "hooks": [
217
+ {
218
+ "type": "command",
219
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/no-reconfirm/index.mjs\"",
220
+ "timeout": 30
221
+ }
222
+ ]
223
+ },
224
+ {
225
+ "matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content|mcp__.*",
226
+ "hooks": [
227
+ {
228
+ "type": "command",
229
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/neutral-spanish/index.mjs\"",
230
+ "timeout": 30
231
+ }
232
+ ]
233
+ },
234
+ {
235
+ "matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content|mcp__.*",
236
+ "hooks": [
237
+ {
238
+ "type": "command",
239
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/diagnosis-before-patch/index.mjs\"",
240
+ "timeout": 30
241
+ }
242
+ ]
243
+ },
244
+ {
245
+ "matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content|Agent|Task|invoke_subagent|mcp__.*",
246
+ "hooks": [
247
+ {
248
+ "type": "command",
249
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/reuse-before-build/index.mjs\"",
250
+ "timeout": 30
251
+ }
252
+ ]
253
+ },
254
+ {
255
+ "matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content|mcp__.*",
256
+ "hooks": [
257
+ {
258
+ "type": "command",
259
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/tool-map/index.mjs\"",
260
+ "timeout": 30
261
+ }
262
+ ]
263
+ },
264
+ {
265
+ "matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content|Bash|run_command|mcp__.*",
266
+ "hooks": [
267
+ {
268
+ "type": "command",
269
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/forge-flow/index.mjs\"",
270
+ "timeout": 30
271
+ }
272
+ ]
273
+ },
274
+ {
275
+ "matcher": "AskUserQuestion|mcp__.*",
276
+ "hooks": [
277
+ {
278
+ "type": "command",
279
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/autonomous-mode/index.mjs\"",
280
+ "timeout": 30
281
+ }
282
+ ]
283
+ },
284
+ {
285
+ "matcher": "Bash|run_command|mcp__.*",
286
+ "hooks": [
287
+ {
288
+ "type": "command",
289
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/lint-commit/index.mjs\"",
290
+ "timeout": 65
291
+ }
292
+ ]
293
+ },
294
+ {
295
+ "matcher": "Agent|Task|invoke_subagent|mcp__.*",
296
+ "hooks": [
297
+ {
298
+ "type": "command",
299
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/force-parallel/index.mjs\"",
300
+ "timeout": 30
301
+ }
302
+ ]
303
+ }
304
+ ],
305
+ "Stop": [
306
+ {
307
+ "hooks": [
308
+ {
309
+ "type": "command",
310
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/stop-pending/index.mjs\"",
311
+ "timeout": 30
312
+ }
313
+ ]
314
+ }
315
+ ],
316
+ "SessionStart": [
317
+ {
318
+ "hooks": [
319
+ {
320
+ "type": "command",
321
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/doctor.mjs\"",
322
+ "timeout": 30
323
+ }
324
+ ]
325
+ },
326
+ {
327
+ "hooks": [
328
+ {
329
+ "type": "command",
330
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/ask-adoption.mjs\"",
331
+ "timeout": 30
332
+ }
333
+ ]
334
+ },
335
+ {
336
+ "hooks": [
337
+ {
338
+ "type": "command",
339
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/wiring-check.mjs\"",
340
+ "timeout": 30
341
+ }
342
+ ]
343
+ }
344
+ ]
345
+ }
346
+ }
@@ -11,7 +11,8 @@ import {
11
11
  toolInGroups,
12
12
  writtenContentOf,
13
13
  writtenPathOf,
14
- delegationPromptOf,
14
+ delegationPromptOf,
15
+ shellWrittenPaths,
15
16
  } from '../hook-io.mjs';
16
17
 
17
18
  // deny/warn/allow/runGate call process.exit and cannot be invoked in-process without
@@ -208,3 +209,15 @@ test('allow exits 0 with no stdout', () => {
208
209
  const output = runInChildProcess('mod.allow()', { input: '' });
209
210
  assert.equal(output, '');
210
211
  });
212
+
213
+ test('shellWrittenPaths extracts paths a shell command creates', () => {
214
+ assert.deepEqual(shellWrittenPaths('printf x > basura.txt'), ['basura.txt']);
215
+ assert.deepEqual(shellWrittenPaths('echo hi >> log.txt'), ['log.txt']);
216
+ assert.deepEqual(shellWrittenPaths('touch nuevo.js'), ['nuevo.js']);
217
+ assert.deepEqual(shellWrittenPaths('tee out.txt'), ['out.txt']);
218
+ // creates nothing -> empty
219
+ assert.deepEqual(shellWrittenPaths('git status'), []);
220
+ assert.deepEqual(shellWrittenPaths('cat archivo.txt'), []);
221
+ // a dynamically built path is NOT extracted (documented limitation)
222
+ assert.deepEqual(shellWrittenPaths('printf x > "$f"'), []);
223
+ });
@@ -155,6 +155,45 @@ export function writtenPathOf(toolInput) {
155
155
  return String(path);
156
156
  }
157
157
 
158
+ // Shell forms that CREATE or write a file at a named path, so a gate protecting a path can
159
+ // see a `printf ... > basura.txt` the same way it sees a Write. Each pattern captures the
160
+ // target path. Conservative by design: it over-detects (a gate then finds the path benign)
161
+ // rather than under-detects (a silent hole). It does NOT resolve variables, command
162
+ // substitution, or subshells — a path built dynamically (`> "$f"`) is not extracted; that
163
+ // limitation is documented on the gates that use this, the honest boundary of a regex.
164
+ const SHELL_WRITE_PATTERNS = [
165
+ // redirection: `> file`, `>> file`, `1> file`, `&> file` (not `2>` alone — stderr)
166
+ /(?:^|\s|;|&&|\|\|)(?:[0-9]*|&)>>?\s*(['"]?)([^\s'"|;&<>]+)\1/g,
167
+ // touch / tee target(s)
168
+ /\b(?:touch|tee)\s+(?:-\S+\s+)*(['"]?)([^\s'"|;&<>]+)\1/g,
169
+ // cp / mv / install destination is the LAST path; capture the first arg after the command
170
+ // as a cheap proxy (over-detects the source too, which is acceptable — a gate re-checks).
171
+ /\b(?:cp|mv|install)\s+(?:-\S+\s+)*(['"]?)([^\s'"|;&<>]+)\1/g,
172
+ ];
173
+
174
+ /**
175
+ * Every filesystem path a shell command appears to create or write to (redirections, touch,
176
+ * tee, cp/mv destinations). Returns a de-duplicated list, empty when none is found. A gate
177
+ * that protects paths should check these IN ADDITION to writtenPathOf, or a shell redirection
178
+ * slips past it (the exact hole that let `printf x > basura.txt` evade root-whitelist while a
179
+ * Write to the same path was blocked).
180
+ */
181
+ export function shellWrittenPaths(command) {
182
+ const text = String(command ?? '');
183
+ const found = new Set();
184
+ for (const pattern of SHELL_WRITE_PATTERNS) {
185
+ for (const match of text.matchAll(pattern)) {
186
+ const path = match[2];
187
+ // Skip a dynamically built target (a variable/substitution): we cannot resolve `$f`,
188
+ // `${x}` or `$(…)` to a real path, and guessing would only add false positives. This is
189
+ // the documented limitation — the write-tool surface, which carries a concrete path,
190
+ // stays the reliable one.
191
+ if (path && !/[$`]/.test(path)) found.add(path);
192
+ }
193
+ }
194
+ return [...found];
195
+ }
196
+
158
197
  /** The brief/prompt a delegation carries, across native and MCP field shapes. */
159
198
  export function delegationPromptOf(toolInput) {
160
199
  if (!toolInput || typeof toolInput !== 'object') return '';
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tasks",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Deterministic task tracking for Claude Code: persists tasks the model registers via the CLI, reminds of open tasks on a message counter, and lists active tasks on session start.",
5
5
  "author": {
6
6
  "name": "Devrik"