@devrik-tools/claude-gates 0.7.2 → 0.8.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 (78) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.es.md +69 -9
  3. package/README.md +68 -7
  4. package/cli/doctor.mjs +132 -0
  5. package/cli/evidence.mjs +78 -0
  6. package/cli/hooks-manifest.mjs +89 -0
  7. package/cli/index.mjs +124 -6
  8. package/cli/init.mjs +97 -3
  9. package/cli/log.mjs +70 -0
  10. package/cli/materialize.mjs +36 -2
  11. package/cli/registry.mjs +11 -1
  12. package/cli/selection.mjs +18 -0
  13. package/cli/smoke-fixtures.json +114 -22
  14. package/cli/task.mjs +36 -8
  15. package/cli/toggle.mjs +125 -0
  16. package/package.json +1 -1
  17. package/plugins/gates/.claude-plugin/plugin.json +1 -1
  18. package/plugins/gates/hooks/doctor.mjs +47 -1
  19. package/plugins/gates/hooks/gates/atomic-commit/index.mjs +92 -119
  20. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +101 -66
  21. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +8 -8
  22. package/plugins/gates/hooks/gates/autonomous-mode/stop.mjs +13 -64
  23. package/plugins/gates/hooks/gates/bash-commands/index.mjs +186 -163
  24. package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +100 -101
  25. package/plugins/gates/hooks/gates/brief-approved/index.mjs +71 -140
  26. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +56 -137
  27. package/plugins/gates/hooks/gates/capability-map/index.mjs +280 -506
  28. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +98 -212
  29. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +127 -46
  30. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +81 -19
  31. package/plugins/gates/hooks/gates/engram-first/index.mjs +41 -0
  32. package/plugins/gates/hooks/gates/engram-first/session-start.mjs +64 -0
  33. package/plugins/gates/hooks/gates/engram-first/shared.mjs +90 -0
  34. package/plugins/gates/hooks/gates/engram-first/stop.mjs +23 -0
  35. package/plugins/gates/hooks/gates/engram-first/track.mjs +97 -0
  36. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +156 -50
  37. package/plugins/gates/hooks/gates/force-parallel/index.mjs +51 -92
  38. package/plugins/gates/hooks/gates/forge-flow/index.mjs +51 -83
  39. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +40 -80
  40. package/plugins/gates/hooks/gates/intent-flow/index.mjs +23 -149
  41. package/plugins/gates/hooks/gates/library-docs/index.mjs +262 -0
  42. package/plugins/gates/hooks/gates/library-docs/track.mjs +95 -0
  43. package/plugins/gates/hooks/gates/lint-commit/index.mjs +88 -97
  44. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +48 -95
  45. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +69 -31
  46. package/plugins/gates/hooks/gates/never-assume/index.mjs +23 -18
  47. package/plugins/gates/hooks/gates/no-blocking/index.mjs +127 -86
  48. package/plugins/gates/hooks/gates/no-coauthor/index.mjs +108 -88
  49. package/plugins/gates/hooks/gates/no-explanatory-comments/index.mjs +281 -0
  50. package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +170 -110
  51. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +46 -77
  52. package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +85 -47
  53. package/plugins/gates/hooks/gates/protected-paths/index.mjs +137 -90
  54. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +95 -51
  55. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +72 -216
  56. package/plugins/gates/hooks/gates/risk-level/index.mjs +51 -204
  57. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +34 -17
  58. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +160 -95
  59. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +196 -97
  60. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +90 -143
  61. package/plugins/gates/hooks/gates/staged-lint/index.mjs +98 -106
  62. package/plugins/gates/hooks/gates/stop-pending/index.mjs +47 -148
  63. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +78 -68
  64. package/plugins/gates/hooks/gates/test-matrix/index.mjs +53 -88
  65. package/plugins/gates/hooks/gates/tool-map/index.mjs +80 -110
  66. package/plugins/gates/hooks/hooks.json +112 -42
  67. package/plugins/gates/hooks/lib/config.mjs +6 -3
  68. package/plugins/gates/hooks/lib/delegation.mjs +352 -0
  69. package/plugins/gates/hooks/lib/gate-log.mjs +141 -0
  70. package/plugins/gates/hooks/lib/git.mjs +262 -0
  71. package/plugins/gates/hooks/lib/hook-io.mjs +448 -83
  72. package/plugins/gates/hooks/lib/session-state.mjs +150 -0
  73. package/plugins/gates/hooks/lib/signals.mjs +18 -13
  74. package/plugins/gates/hooks/lib/testing.mjs +137 -0
  75. package/plugins/gates/hooks/lib/tools.mjs +144 -0
  76. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  77. package/plugins/tasks/hooks/lib/task-store.mjs +6 -6
  78. package/registry.json +204 -36
@@ -1,23 +1,42 @@
1
+ // root-cause-first — a "patch it later" marker without a diagnosis in the same content is
2
+ // denied. Tests and docs are skipped: a test names the marker it checks and a doc describes
3
+ // the practice; neither defers a fix.
4
+
1
5
  import {
2
6
  runGate,
3
7
  deny,
4
8
  toolInGroups,
5
9
  writtenContentOf,
10
+ writtenPathOf,
6
11
  delegationPromptOf,
12
+ compileRegexList,
7
13
  } from '../../lib/hook-io.mjs';
14
+ import { isTestPath } from '../../lib/tools.mjs';
8
15
 
9
16
  const GATE_ID = 'root-cause-first';
10
17
  const CONFIG_KEY = 'requireRootCauseBeforePatch';
11
18
 
12
- const DEFAULT_PATCH_MARKER_PATTERNS = ['//\\s*todo:?\\s*fix\\s+later\\s+patch'];
19
+ const DEFAULT_PATCH_MARKER_PATTERNS = [
20
+ 'todo:? fix later',
21
+ 'fixme:? patch',
22
+ 'hotfix',
23
+ 'quick fix',
24
+ 'workaround',
25
+ 'temporary (patch|fix)',
26
+ 'parche temporal',
27
+ 'arreglo r[aá]pido',
28
+ 'apa[ñn]o',
29
+ ];
30
+
31
+ const DIAGNOSIS_PATTERN =
32
+ /root cause:|causa ra[ií]z:|diagnosis:|diagn[oó]stico:/i;
33
+ const DOCUMENT_EXTENSION_PATTERN = /\.(?:md|mdx|txt)$/i;
13
34
 
14
- // The text to scan: a delegation's brief, or the content a write puts on disk. writtenContentOf
15
- // covers every native and MCP write shape (Write's content, Edit's new_string, NotebookEdit's
16
- // new_source, replace_file_content's new_content) — the old reader missed new_source, so a
17
- // deferral marker written via NotebookEdit was never caught by this DENY gate.
18
35
  function textToScan(toolName, toolInput) {
19
36
  if (toolInGroups(toolName, ['delegation']))
20
37
  return delegationPromptOf(toolInput);
38
+ const path = writtenPathOf(toolInput);
39
+ if (isTestPath(path) || DOCUMENT_EXTENSION_PATTERN.test(path)) return '';
21
40
  return writtenContentOf(toolInput);
22
41
  }
23
42
 
@@ -35,23 +54,21 @@ runGate(
35
54
 
36
55
  const content = textToScan(toolName, toolInput);
37
56
  if (!content) return;
57
+ if (DIAGNOSIS_PATTERN.test(content)) return;
38
58
 
39
- const patterns = parameters.patchMarkerPatterns.map(
40
- (source) => new RegExp(source, 'i'),
41
- );
42
- const matched = patterns.find((pattern) => pattern.test(content));
59
+ const { patterns } = compileRegexList(parameters.patchMarkerPatterns);
60
+ const matched = patterns
61
+ .map((pattern) => pattern.exec(content))
62
+ .find((match) => match !== null);
43
63
  if (!matched) return;
44
64
 
45
65
  deny(
46
66
  CONFIG_KEY,
47
- `Content matches a patch-without-diagnosis marker (${matched.source}). Identify and fix the root cause before patching; do not defer with a "fix later" marker.`,
67
+ `Content contains a patch-without-diagnosis marker ("${matched[0]}"). Identify the ` +
68
+ 'root cause before patching: state it in the same content with a line starting ' +
69
+ '"root cause:" (or "causa raíz:", "diagnosis:", "diagnóstico:"), then retry the ' +
70
+ 'same write. To change the markers, set patchMarkerPatterns for ' +
71
+ `${CONFIG_KEY} in .ai/config.json.`,
48
72
  );
49
73
  },
50
74
  );
51
-
52
- // Simplified vs. the source guard (guard-root-cause-first.mjs): the original
53
- // used an auxiliary lib/embedded-content-detection.mjs module to tell real
54
- // file content apart from a quoted example inside markdown. That module does
55
- // not exist in this repo, so this gate matches directly against the new
56
- // content/prompt. Distinguishing markdown quotes from real code is future
57
- // work if false positives on quoted examples become a problem.
@@ -1,25 +1,21 @@
1
- // root-whitelist — denies creating a new file or folder at the project's ROOT unless it
2
- // is on the declared whitelist. Migrated from ~/.claude/hooks/guard-root-whitelist.mjs.
3
- // A root grows orphaned files silently: nothing else stops a stray file from landing next
4
- // to package.json. Only the top level of the project is governedanything nested is
5
- // this gate's business only insofar as its first path segment is the new thing at the root.
6
- //
7
- // ── What a project can configure (params) ───────────────────────────────────────────
8
- // rootFilesWhitelist root-level file names allowed to be created. Replaces the
9
- // built-in list wholesale.
10
- // rootFoldersWhitelist root-level folder names allowed to be created. Replaces the
11
- // built-in list wholesale.
12
- // The defaults live here, in the source, so a project reads them and knows exactly what
13
- // its override replaces. A dotfile/dotfolder (.gitignore, .claude) is always allowed —
14
- // dotfiles are a different, well-known convention this gate does not police.
15
-
16
- import { basename, isAbsolute, resolve, sep } from 'node:path';
1
+ // root-whitelist — denies CREATING a file or folder at the project's root unless its name is
2
+ // whitelisted. Only the top level is governed, and only new entries: an edit to a file that
3
+ // already exists (registry.json, CHANGELOG.md) is not this gate's business, nor is a path
4
+ // outside the project (a scratchpad, /tmp). Dotfiles and dotfolders are always allowed a
5
+ // different, well-known convention. The root is the nearest .git/.ai ancestor of the cwd, so a
6
+ // tool call from a subfolder is judged against the same root. A path built from a variable is
7
+ // not resolved.
8
+
9
+ import { existsSync } from 'node:fs';
10
+ import { isAbsolute, relative, resolve, sep } from 'node:path';
11
+ import { projectRootOf } from '../../lib/config.mjs';
17
12
  import {
18
- runGate,
19
13
  deny,
14
+ runGate,
15
+ shellCommandOf,
16
+ shellWrittenPaths,
20
17
  toolInGroups,
21
18
  writtenPathOf,
22
- shellWrittenPaths,
23
19
  } from '../../lib/hook-io.mjs';
24
20
 
25
21
  const GATE_ID = 'root-whitelist';
@@ -47,16 +43,9 @@ const DEFAULT_ROOT_FILES_WHITELIST = [
47
43
  '.env.example',
48
44
  ];
49
45
 
50
- // Root folders that real projects legitimately create. The old list (src/tests/docs/
51
- // scripts/plugins) was calibrated to THIS repo and wrongly blocked the root folders every
52
- // common framework creates — app (Next/Nuxt/Laravel/Expo), lib, public, dist, components,
53
- // pages, api, and so on. A root-level folder is a deliberate, structural choice; the value
54
- // of this gate is catching a stray FILE dropped next to package.json, not second-guessing a
55
- // project's directory layout. So the folder whitelist is broad (real framework/tooling
56
- // conventions) while the file whitelist stays the strict part. A project still overrides
57
- // either list wholesale via config.
46
+ // Broad on purpose: a root folder is a deliberate structural choice every framework makes
47
+ // differently; the value of this gate is the stray FILE next to package.json.
58
48
  const DEFAULT_ROOT_FOLDERS_WHITELIST = [
59
- // source / app code
60
49
  'src',
61
50
  'app',
62
51
  'lib',
@@ -74,7 +63,6 @@ const DEFAULT_ROOT_FOLDERS_WHITELIST = [
74
63
  'core',
75
64
  'modules',
76
65
  'features',
77
- // web / framework conventions
78
66
  'public',
79
67
  'static',
80
68
  'assets',
@@ -95,7 +83,6 @@ const DEFAULT_ROOT_FOLDERS_WHITELIST = [
95
83
  'configs',
96
84
  'i18n',
97
85
  'locales',
98
- // tests / docs / tooling
99
86
  'tests',
100
87
  'test',
101
88
  '__tests__',
@@ -108,7 +95,6 @@ const DEFAULT_ROOT_FOLDERS_WHITELIST = [
108
95
  'scripts',
109
96
  'tools',
110
97
  'bin',
111
- // build / generated (present in many repos, not worth blocking)
112
98
  'dist',
113
99
  'build',
114
100
  'out',
@@ -116,7 +102,6 @@ const DEFAULT_ROOT_FOLDERS_WHITELIST = [
116
102
  'node_modules',
117
103
  'vendor',
118
104
  'target',
119
- // infra / ops
120
105
  'migrations',
121
106
  'prisma',
122
107
  'db',
@@ -125,42 +110,133 @@ const DEFAULT_ROOT_FOLDERS_WHITELIST = [
125
110
  '.github',
126
111
  ];
127
112
 
128
- // The whitelist verdict for one target path. Returns a deny reason string when the path is a
129
- // non-whitelisted new thing at the project root, or null when it is allowed (outside the root,
130
- // a dotfile, or whitelisted). Shared by the write-tool path and every shell-created path, so a
131
- // `printf x > basura.txt` is judged by the same rule as a Write to basura.txt.
132
- function rootWhitelistViolation(target, filesWhitelist, foldersWhitelist) {
133
- if (!target) return null;
134
-
135
- // Some tools send a path relative to cwd rather than absolute. resolve() leaves an
136
- // already-absolute path merely normalized ('..' collapsed, separators fixed), and resolves
137
- // a relative one against process.cwd() — so either shape lands on the same absolute path.
138
- const normalized = isAbsolute(target)
139
- ? resolve(target)
140
- : resolve(process.cwd(), target);
141
- const projectRoot = process.cwd() + sep;
142
-
143
- // A path outside the project (scratchpad, temp, another repo) is not at its root — not this
144
- // rule's business. Judged AFTER resolving, so a relative path is judged by where it lands.
145
- if (!normalized.startsWith(projectRoot)) return null;
146
-
147
- const relativePath = normalized.slice(projectRoot.length);
148
-
149
- if (!relativePath.includes(sep)) {
150
- const fileName = basename(relativePath);
151
- if (fileName.startsWith('.') || filesWhitelist.has(fileName.toLowerCase()))
152
- return null;
153
- return `'${fileName}' at the project root is not on the whitelist (${[...filesWhitelist].join(', ')}).`;
154
- }
113
+ const IS_WINDOWS = process.platform === 'win32';
114
+ const GIT_BASH_DRIVE_PATTERN = /^\/([a-z])(?=\/|$)/i;
115
+
116
+ // Git Bash spells C:\Users as /c/Users; on Windows that form is otherwise resolved to C:\c\.
117
+ function toNativePath(path) {
118
+ if (!IS_WINDOWS) return path;
119
+ return path.replace(
120
+ GIT_BASH_DRIVE_PATTERN,
121
+ (_, drive) => `${drive.toUpperCase()}:`,
122
+ );
123
+ }
155
124
 
156
- const topDirectory = relativePath.split(sep)[0];
125
+ function comparable(path) {
126
+ return IS_WINDOWS ? path.toLowerCase() : path;
127
+ }
128
+
129
+ // The first path segment under the root, or null when the path is outside the project.
130
+ function rootEntryOf(target, cwd, root) {
131
+ const absolute = resolve(cwd, toNativePath(target));
132
+ const relativePath = relative(comparable(root), comparable(absolute));
157
133
  if (
158
- topDirectory.startsWith('.') ||
159
- foldersWhitelist.has(topDirectory.toLowerCase())
160
- ) {
134
+ !relativePath ||
135
+ relativePath.startsWith('..') ||
136
+ isAbsolute(relativePath)
137
+ )
138
+ return null;
139
+ const [name] = relativePath.split(sep);
140
+ return {
141
+ name,
142
+ isNested: relativePath.includes(sep),
143
+ path: resolve(root, name),
144
+ };
145
+ }
146
+
147
+ function whitelistViolation({ target, isFolder }, context) {
148
+ const entry = rootEntryOf(target, context.cwd, context.root);
149
+ if (!entry || entry.name.startsWith('.') || existsSync(entry.path))
161
150
  return null;
151
+ const judgedAsFolder = isFolder || entry.isNested;
152
+ const whitelist = judgedAsFolder
153
+ ? context.foldersWhitelist
154
+ : context.filesWhitelist;
155
+ if (whitelist.has(entry.name.toLowerCase())) return null;
156
+ const kind = judgedAsFolder ? 'Folder' : 'File';
157
+ const parameter = judgedAsFolder
158
+ ? 'rootFoldersWhitelist'
159
+ : 'rootFilesWhitelist';
160
+ return (
161
+ `${kind} '${entry.name}' would be created at the project root and is not on the ` +
162
+ `whitelist (${[...whitelist].join(', ')}). Put it inside an existing folder, or add ` +
163
+ `it to ${parameter} under ${CONFIG_KEY} in .ai/config.json.`
164
+ );
165
+ }
166
+
167
+ // ── Shell targets ───────────────────────────────────────────────────────────────────
168
+ const SEGMENT_SEPARATOR = /;|&&|\|\||\||\n/;
169
+ const ARGUMENT_PATTERN = /"([^"]*)"|'([^']*)'|(\S+)/g;
170
+ const MKDIR_PREFIX = /^mkdir(?=\s)/i;
171
+ const GIT_CLONE_PREFIX = /^git\s+clone(?=\s)/i;
172
+ const NEW_ITEM_PREFIX = /^New-Item(?=\s)/i;
173
+ const DIRECTORY_ITEM_TYPE = /-ItemType\s+Directory\b/i;
174
+ const NAMED_PATH_PARAMETER =
175
+ /-(?:Path|Name|LiteralPath)\s+("[^"]*"|'[^']*'|\S+)/i;
176
+
177
+ function nonFlagArguments(text) {
178
+ const found = [];
179
+ for (const match of text.matchAll(ARGUMENT_PATTERN)) {
180
+ const token = match[1] ?? match[2] ?? match[3];
181
+ if (!token.startsWith('-')) found.push(token);
162
182
  }
163
- return `Folder '${topDirectory}' at the project root is not on the whitelist (${[...foldersWhitelist].join(', ')}).`;
183
+ return found;
184
+ }
185
+
186
+ function argumentsAfter(prefix, segment) {
187
+ const match = prefix.exec(segment);
188
+ return match ? segment.slice(match[0].length) : null;
189
+ }
190
+
191
+ function newDirectoryItemTarget(segment) {
192
+ if (!NEW_ITEM_PREFIX.test(segment) || !DIRECTORY_ITEM_TYPE.test(segment))
193
+ return null;
194
+ const remainder = segment.replace(DIRECTORY_ITEM_TYPE, '');
195
+ const named = NAMED_PATH_PARAMETER.exec(remainder);
196
+ if (named) return named[1].replace(/^["']|["']$/g, '');
197
+ return (
198
+ nonFlagArguments(argumentsAfter(NEW_ITEM_PREFIX, remainder))[0] ?? null
199
+ );
200
+ }
201
+
202
+ function cloneDestination(argumentText) {
203
+ const [url, destination] = nonFlagArguments(argumentText);
204
+ if (destination) return destination;
205
+ const base = String(url ?? '')
206
+ .split(/[\\/]/)
207
+ .filter(Boolean)
208
+ .at(-1);
209
+ return base ? base.replace(/\.git$/i, '') : null;
210
+ }
211
+
212
+ function folderTargetsOf(command) {
213
+ const folders = new Set();
214
+ for (const rawSegment of String(command).split(SEGMENT_SEPARATOR)) {
215
+ const segment = rawSegment.trim();
216
+ const mkdirArguments = argumentsAfter(MKDIR_PREFIX, segment);
217
+ if (mkdirArguments !== null)
218
+ nonFlagArguments(mkdirArguments).forEach((path) => folders.add(path));
219
+ const directoryItem = newDirectoryItemTarget(segment);
220
+ if (directoryItem) folders.add(directoryItem);
221
+ const cloneArguments = argumentsAfter(GIT_CLONE_PREFIX, segment);
222
+ const destination =
223
+ cloneArguments === null ? null : cloneDestination(cloneArguments);
224
+ if (destination) folders.add(destination);
225
+ }
226
+ return folders;
227
+ }
228
+
229
+ function shellTargetsOf(command) {
230
+ const folders = folderTargetsOf(command);
231
+ const targets = shellWrittenPaths(command).map((path) => ({
232
+ target: path,
233
+ isFolder: folders.has(path) || /[\\/]$/.test(path),
234
+ }));
235
+ for (const folder of folders) {
236
+ if (!targets.some((entry) => entry.target === folder))
237
+ targets.push({ target: folder, isFolder: true });
238
+ }
239
+ return targets.filter((entry) => !/[$`%]/.test(entry.target));
164
240
  }
165
241
 
166
242
  runGate(
@@ -173,38 +249,27 @@ runGate(
173
249
  rootFoldersWhitelist: DEFAULT_ROOT_FOLDERS_WHITELIST,
174
250
  },
175
251
  },
176
- ({ toolName, toolInput, parameters }) => {
177
- const isWrite = toolInGroups(toolName, ['write']);
178
- const isShell = toolInGroups(toolName, ['shell']);
179
- if (!isWrite && !isShell) return;
180
-
181
- // Windows filesystems are case-insensitive: 'Package.json' and 'package.json' are the same
182
- // file. Comparing lowercase on both sides avoids a false positive on a case difference.
183
- const filesWhitelist = new Set(
184
- (parameters.rootFilesWhitelist ?? []).map((name) => name.toLowerCase()),
185
- );
186
- const foldersWhitelist = new Set(
187
- (parameters.rootFoldersWhitelist ?? []).map((name) => name.toLowerCase()),
188
- );
189
-
190
- // The paths to judge: a write tool's target, OR every path a shell command creates via a
191
- // redirection / touch / tee / cp / mv. The latter closes the hole where `> basura.txt`
192
- // created a root file that a Write to the same name would have been denied. This does not
193
- // resolve a dynamically built path (`> "$f"`) — that honest limitation is the boundary of
194
- // a regex, and is why the write-tool path (which carries a concrete file_path) stays the
195
- // primary, most reliable surface.
196
- const targets = isShell
197
- ? shellWrittenPaths(
198
- String(toolInput?.command ?? toolInput?.CommandLine ?? ''),
199
- )
200
- : [writtenPathOf(toolInput)];
201
-
202
- for (const target of targets) {
203
- const reason = rootWhitelistViolation(
204
- target,
205
- filesWhitelist,
206
- foldersWhitelist,
207
- );
252
+ ({ toolName, toolInput, parameters, cwd }) => {
253
+ let targets;
254
+ if (toolInGroups(toolName, ['write'])) {
255
+ const path = writtenPathOf(toolInput);
256
+ targets = path ? [{ target: path, isFolder: false }] : [];
257
+ } else if (toolInGroups(toolName, ['shell'])) {
258
+ targets = shellTargetsOf(shellCommandOf(toolInput));
259
+ } else {
260
+ return;
261
+ }
262
+ if (targets.length === 0) return;
263
+
264
+ const lowered = (names) => new Set(names.map((name) => name.toLowerCase()));
265
+ const context = {
266
+ cwd,
267
+ root: projectRootOf(cwd) ?? cwd,
268
+ filesWhitelist: lowered(parameters.rootFilesWhitelist),
269
+ foldersWhitelist: lowered(parameters.rootFoldersWhitelist),
270
+ };
271
+ for (const entry of targets) {
272
+ const reason = whitelistViolation(entry, context);
208
273
  if (reason) deny(CONFIG_KEY, reason);
209
274
  }
210
275
  },