@haposoft/cafekit 0.18.1 → 0.18.2

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 (93) hide show
  1. package/README.md +12 -1
  2. package/bin/__tests__/claude-settings.test.js +123 -0
  3. package/bin/__tests__/codex-ask-guard.test.js +123 -0
  4. package/bin/__tests__/codex-ask-projection.test.js +51 -0
  5. package/bin/__tests__/codex-hooks-ownership.test.js +80 -1
  6. package/bin/__tests__/codex-hooks.test.js +774 -6
  7. package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
  8. package/bin/__tests__/codex-native.test.js +192 -59
  9. package/bin/__tests__/codex-projection-parity.test.js +314 -0
  10. package/bin/__tests__/codex-quiet-hooks.test.js +170 -0
  11. package/bin/__tests__/codex-research-projection.test.js +70 -0
  12. package/bin/__tests__/codex-specs-projection.test.js +76 -0
  13. package/bin/__tests__/develop-contract.test.js +1 -1
  14. package/bin/__tests__/docs-sync-on-demand.test.js +25 -20
  15. package/bin/__tests__/inspect-paths.test.js +183 -0
  16. package/bin/__tests__/installer-safety.test.js +335 -0
  17. package/bin/__tests__/legacy-hooks-retired.test.js +18 -8
  18. package/bin/__tests__/omp-bridge.test.js +66 -12
  19. package/bin/__tests__/omp-hooks.test.js +11 -5
  20. package/bin/__tests__/orca-session.test.js +7 -0
  21. package/bin/__tests__/privacy-paths.test.js +317 -0
  22. package/bin/__tests__/usage-hook-retired.test.js +5 -3
  23. package/bin/install.js +25 -0
  24. package/bin/lib/codex-install.js +134 -12
  25. package/bin/lib/context.js +1 -1
  26. package/bin/phases/claude-runtime.js +91 -25
  27. package/bin/phases/claude-settings.js +22 -1
  28. package/bin/phases/codex-hooks.js +96 -33
  29. package/bin/phases/codex-runtime.js +45 -52
  30. package/bin/phases/copy-payload.js +1 -3
  31. package/package.json +1 -1
  32. package/src/claude/CLAUDE.md +4 -0
  33. package/src/claude/gitignore +3 -0
  34. package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
  35. package/src/claude/hooks/__tests__/precompact.test.js +206 -10
  36. package/src/claude/hooks/__tests__/privacy-block.test.js +27 -0
  37. package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
  38. package/src/claude/hooks/__tests__/spec-gate.test.js +141 -10
  39. package/src/claude/hooks/__tests__/spec-state-touched.test.js +204 -8
  40. package/src/claude/hooks/__tests__/state.test.js +207 -7
  41. package/src/claude/hooks/__tests__/update-check.test.js +88 -0
  42. package/src/claude/hooks/agent.cjs +7 -6
  43. package/src/claude/hooks/inspect-block.cjs +15 -63
  44. package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
  45. package/src/claude/hooks/lib/parser.cjs +1 -1
  46. package/src/claude/hooks/lib/privacy-command-analysis.cjs +191 -17
  47. package/src/claude/hooks/lib/privacy-paths.cjs +313 -0
  48. package/src/claude/hooks/lib/project-root.cjs +15 -0
  49. package/src/claude/hooks/lib/rules-reservation.cjs +36 -0
  50. package/src/claude/hooks/lib/spec-touch.cjs +67 -0
  51. package/src/claude/hooks/lib/update-check.cjs +77 -0
  52. package/src/claude/hooks/precompact.cjs +8 -15
  53. package/src/claude/hooks/privacy-block.cjs +23 -205
  54. package/src/claude/hooks/rules.cjs +6 -28
  55. package/src/claude/hooks/session.cjs +34 -117
  56. package/src/claude/hooks/spec-gate.cjs +9 -26
  57. package/src/claude/hooks/spec-state.cjs +43 -59
  58. package/src/claude/hooks/state.cjs +96 -51
  59. package/src/claude/migration-manifest.json +8 -1
  60. package/src/claude/rules/hook-protocols.md +1 -1
  61. package/src/claude/runtime.json +0 -3
  62. package/src/claude/scripts/generate-skill-catalog.cjs +2 -2
  63. package/src/claude/scripts/provenance.cjs +14 -0
  64. package/src/claude/settings/settings.json +1 -1
  65. package/src/claude/skills/ai-multimodal/scripts/check_setup.py +8 -68
  66. package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +6 -28
  67. package/src/claude/skills/git/SKILL.md +1 -1
  68. package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
  69. package/src/codex/AGENTS.md +8 -0
  70. package/src/codex/hooks/ask-guard.cjs +54 -0
  71. package/src/codex/hooks/inspect-block.cjs +11 -7
  72. package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
  73. package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
  74. package/src/codex/hooks/lib/privacy-state.cjs +98 -16
  75. package/src/codex/hooks/lib/rules-reservation.cjs +38 -0
  76. package/src/codex/hooks/lib/secret-keywords.cjs +1 -0
  77. package/src/codex/hooks/lib/spec-session-touch.cjs +100 -0
  78. package/src/codex/hooks/lib/spec-touch.cjs +3 -0
  79. package/src/codex/hooks/lib/update-check.cjs +1 -0
  80. package/src/codex/hooks/precompact.cjs +39 -0
  81. package/src/codex/hooks/privacy-approval.cjs +28 -17
  82. package/src/codex/hooks/privacy-block.cjs +9 -215
  83. package/src/codex/hooks/rules.cjs +4 -30
  84. package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
  85. package/src/codex/hooks/session.cjs +47 -11
  86. package/src/codex/hooks/spec-gate.cjs +21 -6
  87. package/src/codex/hooks/spec-state.cjs +32 -6
  88. package/src/codex/hooks/state.cjs +25 -4
  89. package/src/codex/hooks.json +39 -7
  90. package/src/codex/rules/hook-protocols.md +13 -6
  91. package/src/omp/extensions/cafekit-bridge.mjs +12 -4
  92. package/src/omp/hooks/privacy-block.cjs +24 -206
  93. package/src/codex/rules/state-sync.md +0 -60
@@ -0,0 +1,304 @@
1
+ 'use strict';
2
+ const fs = require('fs');
3
+ const os = require('os');
4
+ const path = require('path');
5
+ const BLOCKED_DIRS = new Set([
6
+ 'node_modules', 'dist', 'build', '.next', '.nuxt', '.output',
7
+ '__pycache__', '.venv', 'venv', 'vendor', 'target',
8
+ '.git', 'coverage', '.nyc_output'
9
+ ]);
10
+ const BROAD_GLOBS = [/^\*\*\/\*$/, /^\*\*\.\w+$/, /^\*\*\/\*\.\w+$/];
11
+ const PATH_KEYS = /^(?:file_?paths?|paths?|search_?paths?|notebook_path|(?:source|target|destination)_?paths?|dirs?|director(?:y|ies)|folders?|cwd|root|locations?|uris?)$/i;
12
+ const WRITE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'apply_patch']);
13
+ const READERS = new Set(['cat', 'head', 'tail', 'nl', 'less', 'more', 'bat', 'ls', 'tree', 'find', 'rg', 'grep', 'sed', 'awk', 'wc', 'du', 'file', 'stat']);
14
+ const clean = (value) => String(value || '').trim();
15
+ const nameOf = (value) => String(value || '').replace(/\\/g, '/').split('/').pop();
16
+ function real(value) { try { return fs.realpathSync(value); } catch { return value; } }
17
+ function relativeInside(root, candidate) {
18
+ const relative = path.relative(root, candidate);
19
+ return relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative) ? relative : null;
20
+ }
21
+ function blockedSegment(value, { cwd, projectRoot }) {
22
+ let candidate = clean(value).replace(/\\/g, '/');
23
+ if (candidate === '~' || candidate.startsWith('~/')) candidate = os.homedir() + candidate.slice(1);
24
+ if (path.isAbsolute(candidate)) {
25
+ // Prefer project root over a nested session cwd. Compare lexical and canonical
26
+ // roots so /var and /private/var (or a symlink install) have the same boundary.
27
+ for (const base of [projectRoot, cwd].filter(base => typeof base === 'string' && base)) {
28
+ const root = path.resolve(base);
29
+ const relative = relativeInside(root, candidate) ?? relativeInside(real(root), candidate);
30
+ if (relative !== null) { candidate = relative; break; }
31
+ }
32
+ } else candidate = path.normalize(candidate);
33
+ return candidate.split('/').find((segment) => BLOCKED_DIRS.has(segment));
34
+ }
35
+ function narrowPath(value, context) {
36
+ if (context.cwd !== undefined && typeof context.cwd !== 'string') return false;
37
+ let requested = clean(value).replace(/\\/g, '/');
38
+ if (!requested) return false;
39
+ if (requested === '~' || requested.startsWith('~/')) requested = os.homedir() + requested.slice(1);
40
+ const root = path.resolve(context.projectRoot || context.cwd || process.cwd());
41
+ const candidate = path.resolve(context.cwd || root, requested);
42
+ const relative = relativeInside(root, candidate) ?? relativeInside(real(root), candidate);
43
+ // Both boundaries must hold: a symlink cannot turn an outside read into a
44
+ // narrow scope, nor point a lexical child at the project root or beyond it.
45
+ const canonical = relative === null ? candidate : real(path.resolve(real(root), relative));
46
+ return !!relative && !!relativeInside(real(root), canonical)
47
+ && !blockedSegment(candidate, context) && !blockedSegment(canonical, context);
48
+ }
49
+ function collectStructured(value, key, toolName, paths, globs) {
50
+ if (typeof value === 'string') {
51
+ if (PATH_KEYS.test(key)) paths.push(value);
52
+ if ((toolName === 'Glob' && key === 'pattern') || /^(?:glob|globs)$/i.test(key)) globs.push(value);
53
+ } else if (Array.isArray(value)) {
54
+ for (const child of value) collectStructured(child, key, toolName, paths, globs);
55
+ } else if (value && typeof value === 'object') {
56
+ for (const [childKey, child] of Object.entries(value)) collectStructured(child, childKey, toolName, paths, globs);
57
+ }
58
+ }
59
+
60
+ // A small shell lexer, not an executor: quoted prose is data, while substitutions
61
+ // contribute their own command groups. Heredoc bodies are data, never commands.
62
+ function commandGroups(command) {
63
+ function scan(start, closing = '') {
64
+ const groups = []; let group = []; let token = ''; let quote = ''; let index = start;
65
+ const heredocs = [];
66
+ const flush = () => { if (token) group.push(token); token = ''; };
67
+ const boundary = () => { flush(); if (group.length) groups.push(group); group = []; };
68
+ for (; index < command.length; index += 1) {
69
+ const char = command[index];
70
+ if (!quote && closing && char === closing) { boundary(); return { groups, index }; }
71
+ if (char === '\\' && quote !== "'") {
72
+ if (command[index + 1] !== '\n') token += command[index + 1] || '';
73
+ index += 1; continue;
74
+ }
75
+ if (quote === "'") { if (char === "'") quote = ''; else token += char; continue; }
76
+ if (char === '$' && command[index + 1] === '(') {
77
+ const sub = scan(index + 2, ')'); groups.push(...sub.groups);
78
+ token += '__substitution__'; index = sub.index; continue;
79
+ }
80
+ if (char === '`') {
81
+ const sub = scan(index + 1, '`'); groups.push(...sub.groups);
82
+ token += '__substitution__'; index = sub.index; continue;
83
+ }
84
+ if (quote) { if (char === quote) quote = ''; else token += char; continue; }
85
+ if (char === '"' || char === "'") { quote = char; continue; }
86
+ if (char === '(') {
87
+ boundary(); const sub = scan(index + 1, ')');
88
+ groups.push(...sub.groups); index = sub.index; continue;
89
+ }
90
+ if (command.startsWith('<<', index) && !command.startsWith('<<<', index)) {
91
+ const match = command.slice(index).match(/^<<(-?)[ \t]*((?:'[^']*'|"[^"]*"|\\[^\n]|[^\s;|&<>'"\\])+)/);
92
+ if (match) {
93
+ const delimiter = match[2].replace(/'([^']*)'|"([^"]*)"|\\(.)/g, (_, single, double, escaped) => single ?? double ?? escaped);
94
+ flush(); heredocs.push({ delimiter, tabs: !!match[1] });
95
+ index += match[0].length - 1; continue;
96
+ }
97
+ }
98
+ if (char === '>' || char === '<') {
99
+ // Preserve operator identity (quoted > remains ordinary text). A numeric
100
+ // token touching the operator is its fd, not a positional argument.
101
+ if (/^\d+$/.test(token)) token = '';
102
+ flush();
103
+ const operator = command.slice(index).match(/^(?:>>|>&|>|<)/)[0];
104
+ group.push({ redirect: operator }); index += operator.length - 1; continue;
105
+ }
106
+ if (char === '\n') {
107
+ boundary();
108
+ for (const here of heredocs.splice(0)) {
109
+ while (index < command.length) {
110
+ const end = command.indexOf('\n', index + 1);
111
+ const stop = end < 0 ? command.length : end;
112
+ const line = command.slice(index + 1, stop);
113
+ index = stop;
114
+ if ((here.tabs ? line.replace(/^\t+/, '') : line) === here.delimiter) break;
115
+ }
116
+ }
117
+ } else if (';&|'.includes(char)) {
118
+ boundary();
119
+ if (command[index + 1] === char) index += 1;
120
+ } else if (/\s/.test(char)) flush();
121
+ else token += char;
122
+ }
123
+ boundary(); return { groups, index };
124
+ }
125
+ return scan(0).groups;
126
+ }
127
+
128
+ const WRAPPER_OPTIONS = {
129
+ sudo: ['-u', '-g', '-h', '-p', '-C', '-T', '-R', '-D', '--user', '--group', '--host', '--prompt', '--chdir', '--chroot'],
130
+ env: ['-u', '--unset', '-C', '--chdir', '-S', '--split-string'],
131
+ time: ['-f', '--format', '-o', '--output'], command: [], builtin: [],
132
+ nice: ['-n', '--adjustment'], nohup: [],
133
+ xargs: ['-I', '-i', '-n', '-L', '-P', '-s', '-d', '-E', '-a', '--replace', '--max-args', '--max-lines', '--max-procs', '--arg-file', '--delimiter', '--eof'],
134
+ npm: ['-p', '--package', '-c', '--call', '--prefix', '--workspace', '-w'],
135
+ npx: ['-p', '--package', '-c', '--call', '--cache'],
136
+ pnpm: ['--dir', '-C', '--filter'], pnpx: ['-p', '--package'],
137
+ yarn: ['--cwd'], bunx: ['-p', '--package'],
138
+ };
139
+ function unwrap(group, state) {
140
+ let index = 0;
141
+ while (index < group.length) {
142
+ while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(group[index] || '')) index += 1;
143
+ const name = nameOf(group[index]);
144
+ if (!Object.hasOwn(WRAPPER_OPTIONS, name)) return group.slice(index);
145
+ if (name === 'xargs') state.unknownCwd = true;
146
+ index += 1;
147
+ // npm/pnpm/yarn only wrap a reader in exec mode; run/build remain opaque.
148
+ let exec = !['npm', 'pnpm', 'yarn'].includes(name);
149
+ while (index < group.length) {
150
+ const token = group[index];
151
+ const cwdOptions = { env: ['-C', '--chdir'], sudo: ['-D', '--chdir'],
152
+ npm: ['--prefix'], pnpm: ['-C', '--dir'], yarn: ['--cwd'] };
153
+ if (cwdOptions[name]?.some(option => String(token).split('=')[0] === option
154
+ || (option.length === 2 && String(token).startsWith(option)))) state.unknownCwd = true;
155
+ if (name === 'env' && /^-[^-]*C/.test(token)) state.unknownCwd = true;
156
+ if (token === 'exec' && !exec) { exec = true; index += 1; continue; }
157
+ if (token === '--') { index += 1; break; }
158
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) { index += 1; continue; }
159
+ const commandOption = name === 'env' ? /^(?:-S|--split-string)(?:=|$)/ : ['npm', 'npx'].includes(name) ? /^(?:-c|--call)(?:=|$)/ : null;
160
+ if (commandOption?.test(token)) {
161
+ const equals = token.indexOf('=');
162
+ return { command: equals < 0 ? group[index + 1] || '' : token.slice(equals + 1),
163
+ rest: name === 'env' ? group.slice(index + (equals < 0 ? 2 : 1)) : [] };
164
+ }
165
+ if (!token.startsWith('-')) break;
166
+ index += WRAPPER_OPTIONS[name].includes(token) ? 2 : 1;
167
+ }
168
+ if (!exec) return [];
169
+ }
170
+ return [];
171
+ }
172
+ function positional(tokens, options) {
173
+ const values = []; let afterDash = false;
174
+ for (let index = 1; index < tokens.length; index += 1) {
175
+ const token = tokens[index];
176
+ if (!afterDash && token === '--') { afterDash = true; continue; }
177
+ if (!afterDash && options.has(token)) { index += 1; continue; }
178
+ if (afterDash || !token.startsWith('-') || token === '-') values.push(token);
179
+ }
180
+ return values;
181
+ }
182
+ function knownSearchOptions(tokens, options, name) {
183
+ const booleans = new Set(['-n', '-i', '-v', '-w', '-x', '-F', '-P', '-l', '-L', '-q', '-s', '-h', '-H', '-o', '-c', '-a', '-u', '-U', '-z',
184
+ '--line-number', '--ignore-case', '--invert-match', '--word-regexp', '--line-regexp', '--fixed-strings', '--pcre2',
185
+ '--files-with-matches', '--files-without-match', '--quiet', '--no-messages', '--no-filename', '--with-filename',
186
+ '--only-matching', '--count', '--text', '--multiline', '--null', '--hidden', '--files', '--stats']);
187
+ if (name === 'grep') for (const flag of ['-r', '-R', '--recursive', '--dereference-recursive']) booleans.add(flag);
188
+ if (name === 'rg') for (const flag of ['--json', '-S', '--smart-case', '-N', '--no-heading', '--heading',
189
+ '--column', '--vimgrep', '--follow', '--no-ignore', '--no-ignore-dot', '--no-ignore-exclude',
190
+ '--no-ignore-files', '--no-ignore-global', '--no-ignore-parent', '--no-ignore-vcs', '--no-ignore-messages',
191
+ '--no-ignore-file-case-insensitive', '--count-matches', '-b', '--trim']) booleans.add(flag);
192
+ for (let index = 1; index < tokens.length; index += 1) {
193
+ const token = tokens[index];
194
+ if (token === '--') return true;
195
+ if (!token.startsWith('-') || token === '-') continue;
196
+ if (options.has(token)) { index += 1; continue; }
197
+ if (/^-[ABCmMjdt]\S+/.test(token) && options.has(token.slice(0, 2))) continue;
198
+ if (token.startsWith('--') && options.has(token.split('=')[0]) && token.includes('=')) continue;
199
+ if (booleans.has(token)) continue;
200
+ if (!token.startsWith('--') && [...token.slice(1)].every(flag => booleans.has(`-${flag}`))) continue;
201
+ return false;
202
+ }
203
+ return true;
204
+ }
205
+ function commandPaths(group, globs, scopes, state) {
206
+ const inputs = []; const args = [];
207
+ for (let index = 0; index < group.length; index += 1) {
208
+ const token = group[index];
209
+ if (token.redirect) {
210
+ const operand = group[index += 1];
211
+ if (token.redirect === '<' && typeof operand === 'string') inputs.push(operand);
212
+ } else args.push(token);
213
+ }
214
+ return [...inputs, ...readerPaths(args, globs, scopes, state)];
215
+ }
216
+ function readerPaths(group, globs, scopes, state) {
217
+ const tokens = unwrap(group, state);
218
+ if (tokens.command !== undefined) {
219
+ const groups = commandGroups(tokens.command);
220
+ if (groups.length) groups[groups.length - 1].push(...tokens.rest);
221
+ return groups.flatMap((nested) => commandPaths(nested, globs, scopes, state));
222
+ }
223
+ const name = nameOf(tokens[0]);
224
+ if (['cd', 'pushd', 'popd', 'eval', 'source', '.'].includes(name)) state.unknownCwd = true;
225
+ if (!READERS.has(name)) return []; // Executables and non-reader arguments are never paths.
226
+ if (name === 'find') {
227
+ let index = 1;
228
+ while (['-H', '-L', '-P'].includes(tokens[index]) || /^-O\d+$/.test(tokens[index] || '')) index += 1;
229
+ if (tokens[index] === '-D') index += 2;
230
+ if (tokens[index] === '--') index += 1;
231
+ const roots = [];
232
+ for (; index < tokens.length && !/^(?:-|!|\(|\))/.test(tokens[index]); index += 1) roots.push(tokens[index]);
233
+ return roots; // Expressions such as -path ... -prune are not read roots.
234
+ }
235
+ if (['rg', 'grep'].includes(name)) {
236
+ const firstGlob = globs.length;
237
+ for (let index = 1; index < tokens.length; index += 1) {
238
+ const token = tokens[index];
239
+ if (['-g', '--glob', '--iglob'].includes(token)) globs.push(tokens[index + 1] || '');
240
+ else if (/^--i?glob=/.test(token)) globs.push(token.slice(token.indexOf('=') + 1));
241
+ }
242
+ const options = new Set(['-e', '--regexp', '-f', '--file', '-g', '--glob', '--iglob', '-t', '--type', '-T', '--type-not', '--exclude', '--exclude-dir', '--include', '-m', '--max-count', '-A', '-B', '-C', '--after-context', '--before-context', '--context', '--encoding', '--color',
243
+ '--max-depth', '-d', '-j', '--threads', '-M', '--max-columns', '--sort', '--sortr', '--replace', '--max-filesize', '--pre', '--path-separator', '--dfa-size-limit', '--regex-size-limit', '--directories', '-D', '--devices', '--label']);
244
+ // rg -r takes replacement text; grep -r is a recursive flag.
245
+ if (name === 'rg') options.add('-r');
246
+ const values = positional(tokens, options);
247
+ const explicit = tokens.some((token) => /^(?:-[ef]|--(?:regexp|file)(?:=|$))/.test(token)) || tokens.includes('--files');
248
+ const paths = explicit ? values : values.slice(1);
249
+ scopes.push({ paths: state.unknownCwd || !knownSearchOptions(tokens, options, name) ? [] : paths, globs: globs.slice(firstGlob) });
250
+ return paths;
251
+ }
252
+ if (['sed', 'awk'].includes(name)) {
253
+ const options = new Set(['-e', '--expression', '-f', '--file', '-F', '-v']);
254
+ const values = positional(tokens, options);
255
+ return tokens.some((token) => /^(?:-[ef]|--(?:expression|file)(?:=|$))/.test(token)) ? values : values.slice(1);
256
+ }
257
+ // A flag's meaning belongs to its reader: cat -n and wc -c are booleans,
258
+ // whereas head -n consumes a number. Never swallow the following read path.
259
+ const options = {
260
+ head: ['-n', '--lines', '-c', '--bytes'], tail: ['-n', '--lines', '-c', '--bytes', '--pid', '-s', '--sleep-interval'],
261
+ nl: ['-b', '-d', '-f', '-h', '-i', '-l', '-n', '-s', '-v', '-w'],
262
+ tree: ['-I', '-P', '-L', '-o', '--filelimit', '--sort'],
263
+ ls: ['-I', '--ignore', '--hide', '--format', '--sort', '--time', '--block-size', '--quoting-style', '-w', '--width'],
264
+ du: ['-d', '--max-depth', '--exclude', '-X', '--exclude-from', '-B', '--block-size'],
265
+ stat: ['-c', '--format', '--printf'], file: ['-m', '--magic-file', '-f', '--files-from', '-F', '--separator'],
266
+ bat: ['--color', '--style', '--language', '--paging', '--theme', '--line-range', '-r', '--tabs'],
267
+ less: ['-x', '--tabs', '-j', '-p', '-P', '-z'],
268
+ };
269
+ return positional(tokens, new Set(options[name] || []));
270
+ }
271
+ function broadGlobFor(paths, globs, context) {
272
+ if (paths.length > 0 && paths.every(requested => narrowPath(requested, context))) return undefined;
273
+ return globs.find(glob => !glob.startsWith('!') && BROAD_GLOBS.some(rule => rule.test(clean(glob))));
274
+ }
275
+ function inspectInput(toolName, toolInput, context = {}) {
276
+ if (WRITE_TOOLS.has(toolName)) return {};
277
+ const paths = []; const globs = [];
278
+ collectStructured(toolInput, '', toolName, paths, globs);
279
+ let broadGlob = broadGlobFor(paths, globs, context);
280
+ const scopes = [];
281
+ const commandContext = { ...context };
282
+ let unknownCwd = context.cwd !== undefined && typeof context.cwd !== 'string';
283
+ const overrides = ['cwd', 'workdir'].filter(key => Object.hasOwn(toolInput || {}, key));
284
+ for (const key of overrides) {
285
+ if (typeof toolInput[key] !== 'string' || !toolInput[key].trim()) unknownCwd = true;
286
+ else commandContext.cwd = path.resolve(typeof context.cwd === 'string' ? context.cwd : process.cwd(), toolInput[key]);
287
+ }
288
+ if (overrides.length > 1 && toolInput.cwd !== toolInput.workdir) unknownCwd = true;
289
+ for (const key of ['command', 'cmd']) {
290
+ if (typeof toolInput?.[key] === 'string') {
291
+ const state = { unknownCwd };
292
+ for (const group of commandGroups(toolInput[key])) paths.push(...commandPaths(group, globs, scopes, state));
293
+ }
294
+ }
295
+ const included = globs.filter((glob) => !glob.startsWith('!'));
296
+ for (const scope of scopes) broadGlob ||= broadGlobFor(scope.paths, scope.globs, commandContext);
297
+ paths.push(...included);
298
+ return {
299
+ broadGlob,
300
+ blockedPath: paths.map((requested) => ({ requested, blocked: blockedSegment(requested, context) })).find((result) => result.blocked),
301
+ };
302
+ }
303
+
304
+ module.exports = { BLOCKED_DIRS, inspectInput };
@@ -80,7 +80,7 @@ function processEntry(entry, toolMap, agentMap, latestTodos, result) {
80
80
  for (const block of content) {
81
81
  // Handle tool_use blocks
82
82
  if (block.type === 'tool_use' && block.id && block.name) {
83
- if (block.name === 'Task') {
83
+ if (block.name === 'Task' || block.name === 'Agent') {
84
84
  // Agent spawn
85
85
  agentMap.set(block.id, {
86
86
  id: block.id,
@@ -107,6 +107,7 @@ function scanShell(command) {
107
107
  const source = String(command).replace(/\\\r?\n/g, '');
108
108
  const segments = [];
109
109
  const nested = [];
110
+ const recordNested = command => nested.push({ command, segment: segments.length });
110
111
  let words = [];
111
112
  let word = { text: '', activeExpansion: false, started: false };
112
113
  let quote = null;
@@ -132,7 +133,7 @@ function scanShell(command) {
132
133
  if (source[index + 1] === '(') {
133
134
  const end = matchingParen(source, index + 1);
134
135
  if (end < 0) { malformed = true; continue; }
135
- nested.push(source.slice(index + 2, end));
136
+ recordNested(source.slice(index + 2, end));
136
137
  append('()');
137
138
  index = end;
138
139
  }
@@ -142,7 +143,7 @@ function scanShell(command) {
142
143
  word.activeExpansion = true;
143
144
  const end = matchingBacktick(source, index);
144
145
  if (end < 0) { malformed = true; continue; }
145
- nested.push(source.slice(index + 1, end));
146
+ recordNested(source.slice(index + 1, end));
146
147
  append('\x60\x60');
147
148
  index = end;
148
149
  continue;
@@ -164,7 +165,7 @@ function scanShell(command) {
164
165
  if (source[index + 1] === '(') {
165
166
  const end = matchingParen(source, index + 1);
166
167
  if (end < 0) { malformed = true; continue; }
167
- nested.push(source.slice(index + 2, end));
168
+ recordNested(source.slice(index + 2, end));
168
169
  append('()');
169
170
  index = end;
170
171
  }
@@ -174,7 +175,7 @@ function scanShell(command) {
174
175
  word.activeExpansion = true;
175
176
  const end = matchingBacktick(source, index);
176
177
  if (end < 0) { malformed = true; continue; }
177
- nested.push(source.slice(index + 1, end));
178
+ recordNested(source.slice(index + 1, end));
178
179
  append('\x60\x60');
179
180
  index = end;
180
181
  continue;
@@ -183,15 +184,31 @@ function scanShell(command) {
183
184
  word.activeExpansion = true;
184
185
  const end = matchingParen(source, index + 1);
185
186
  if (end < 0) { malformed = true; continue; }
186
- nested.push(source.slice(index + 2, end));
187
+ recordNested(source.slice(index + 2, end));
187
188
  append(`${ch}()`);
188
189
  index = end;
189
190
  continue;
190
191
  }
191
192
  if ((ch === '~' && !word.text) || '*?{['.includes(ch)) { word.activeExpansion = true; append(ch); continue; }
192
193
  if (';|&\n()'.includes(ch)) { flushSegment(); continue; }
193
- if (ch === '<') { flushWord(); words.push({ text: '<', activeExpansion: false, started: true }); continue; }
194
- if (ch === '>') { flushWord(); continue; }
194
+ if (ch === '<') {
195
+ const descriptor = /^[0-9]+$/.test(word.text);
196
+ flushWord();
197
+ if (descriptor) (words.redirectFds ||= []).push(words.length - 1);
198
+ (words.inputRedirects ||= []).push(words.length, words.length + 1);
199
+ words.push({ text: '<', activeExpansion: false, started: true });
200
+ continue;
201
+ }
202
+ if (ch === '>') {
203
+ const descriptor = /^[0-9]+$/.test(word.text);
204
+ flushWord();
205
+ if (descriptor) (words.redirectFds ||= []).push(words.length - 1);
206
+ (words.redirects ||= []).push(words.length);
207
+ if (source[index + 1] === '>') index += 1;
208
+ if (source[index + 1] === '&') index += 1;
209
+ if (source[index + 1] === '|') index += 1;
210
+ continue;
211
+ }
195
212
  if (' \t\r'.includes(ch)) { flushWord(); continue; }
196
213
  append(ch);
197
214
  }
@@ -272,11 +289,150 @@ function genericOperands(words, start, result, {
272
289
 
273
290
  function merge(target, source) {
274
291
  source.paths.forEach((value) => target.paths.add(value));
292
+ target.searches.push(...source.searches);
293
+ source.writes.forEach(value => target.writes.add(value));
294
+ source.unknownWrites.forEach(value => target.unknownWrites.add(value));
275
295
  target.dynamic ||= source.dynamic;
276
296
  target.malformed ||= source.malformed;
277
297
  }
278
298
 
279
- function analyzeWords(words, result, depth) {
299
+ // Describe searches separately so the existing read operands stay unchanged.
300
+ function searchDescription(words, start, name) {
301
+ const grep = ['grep', 'egrep', 'fgrep'].includes(name);
302
+ const search = { recursive: !grep, hidden: grep, includes: [], excludes: [], roots: [] };
303
+ const values = new Set([...GREP_OPTION_VALUE, '-g', '--glob', '--iglob', '--include', '--exclude',
304
+ '--exclude-dir', '-d', '--directories', '-t', '--type', '-T', '--type-not',
305
+ '--max-depth', '-j', '--threads', '-M', '--max-columns', '--sort', '--sortr',
306
+ '--max-filesize', '--pre', '--path-separator', '--dfa-size-limit', '--regex-size-limit']);
307
+ let pattern = !grep && words.slice(start).some(word => word.text === '--files');
308
+ let unrestricted = 0;
309
+ let options = true;
310
+ const positional = [];
311
+ for (let index = start; index < words.length; index += 1) {
312
+ const word = words[index];
313
+ const text = word.text;
314
+ if (options && text === '--') { options = false; continue; }
315
+ if (options && text.startsWith('-') && text !== '-') {
316
+ let option = text.split('=')[0];
317
+ let value = text.includes('=') ? text.slice(text.indexOf('=') + 1) : null;
318
+ let shortFlags = /^-[^-]/.test(text) ? text.slice(1) : '';
319
+ const attached = text.match(/^-([^-]*?)([gef])(.*)$/);
320
+ if (attached) { shortFlags = attached[1]; option = `-${attached[2]}`; value = attached[3] || null; }
321
+ if (values.has(option) && value === null) value = words[++index]?.text || '';
322
+ if (['-e', '--regexp', '-f', '--file'].includes(option)) pattern = true;
323
+ if (['-g', '--glob', '--include'].includes(option)) search.includes.push(value || '');
324
+ if (['--exclude', '--exclude-dir'].includes(option)) search.excludes.push(value || '');
325
+ if (grep && (['--recursive', '--dereference-recursive'].includes(option)
326
+ || /[rR]/.test(shortFlags)
327
+ || (['-d', '--directories'].includes(option) && value === 'recurse'))) search.recursive = true;
328
+ if (!grep) {
329
+ if (option === '--hidden' || shortFlags.includes('.')) search.hidden = true;
330
+ unrestricted += (shortFlags.match(/u/g) || []).length + (option === '--unrestricted' ? 1 : 0);
331
+ }
332
+ continue;
333
+ }
334
+ positional.push(word);
335
+ }
336
+ if (!pattern) positional.shift();
337
+ search.roots = positional.filter(word => !word.activeExpansion && word.text !== '-').map(word => word.text);
338
+ if (!search.roots.length) search.roots.push('.');
339
+ if (unrestricted >= (name === 'ag' ? 1 : 2)) search.hidden = true;
340
+ // Case-insensitive globs are not evidence that a differently cased child is excluded.
341
+ if (words.slice(start).some(word => ['--glob-case-insensitive', '--iglob'].includes(word.text.split('=')[0]))) search.includes = [];
342
+ return search;
343
+ }
344
+
345
+ function writeTarget(word, result, directory) {
346
+ if (!word?.text || word.activeExpansion || word.text === '-') return;
347
+ if (directory === null && !word.text.startsWith('/')) {
348
+ result.unknownWrites.add(word.text);
349
+ return;
350
+ }
351
+ result.writes.add(word.text.startsWith('/') || directory === '.' ? word.text : `${directory}/${word.text}`);
352
+ }
353
+
354
+ function commandWrites(words, start, name, result, directory) {
355
+ const redirected = new Set([...(words.redirects || []), ...(words.redirectFds || []), ...(words.inputRedirects || [])]);
356
+ if (name === 'tee') {
357
+ for (let index = start; index < words.length; index += 1) {
358
+ if (!redirected.has(index) && !words[index].text.startsWith('-')) writeTarget(words[index], result, directory);
359
+ }
360
+ }
361
+ if (['cp', 'mv', 'install', 'ln'].includes(name)) {
362
+ const operands = [];
363
+ let targetDirectory = false;
364
+ const values = new Set(['-t', '--target-directory', '-S', '--suffix', '-m', '--mode', '-o', '--owner', '-g', '--group']);
365
+ let options = true;
366
+ for (let index = start; index < words.length; index += 1) {
367
+ if (redirected.has(index)) continue;
368
+ const word = words[index];
369
+ if (options && word.text === '--') { options = false; continue; }
370
+ if (options && word.text.startsWith('-')) {
371
+ let option = word.text.split('=')[0];
372
+ let value = word.text.includes('=')
373
+ ? { text: word.text.slice(word.text.indexOf('=') + 1), activeExpansion: word.activeExpansion }
374
+ : values.has(option) ? words[++index] : null;
375
+ if (!word.text.startsWith('--') && !values.has(option)) {
376
+ for (let flag = 1; flag < word.text.length; flag += 1) {
377
+ const candidate = `-${word.text[flag]}`;
378
+ if (!values.has(candidate)) continue;
379
+ option = candidate;
380
+ value = flag + 1 < word.text.length
381
+ ? { text: word.text.slice(flag + 1), activeExpansion: word.activeExpansion } : words[++index];
382
+ break;
383
+ }
384
+ }
385
+ if (['-t', '--target-directory'].includes(option)) {
386
+ targetDirectory = true;
387
+ writeTarget(value, result, directory);
388
+ }
389
+ } else operands.push(word);
390
+ }
391
+ if (!targetDirectory) writeTarget(operands.at(-1), result, directory);
392
+ if (name === 'cp' || name === 'mv') {
393
+ const sources = targetDirectory ? operands : operands.slice(0, -1);
394
+ for (const source of sources) {
395
+ const basename = source.text.replace(/\\/g, '/').replace(/\/+$/, '').split('/').at(-1).toLowerCase();
396
+ if (!['.claude', '.codex', '.omp', '.privacy'].includes(basename)) continue;
397
+ if (source.activeExpansion) result.unknownWrites.add(basename);
398
+ else writeTarget(source, result, directory);
399
+ }
400
+ }
401
+ }
402
+ if (name === 'sed') {
403
+ let inPlace = false;
404
+ let scriptOption = false;
405
+ const operands = [];
406
+ let options = true;
407
+ for (let index = start; index < words.length; index += 1) {
408
+ if (redirected.has(index)) continue;
409
+ const word = words[index];
410
+ if (options && word.text === '--') { options = false; continue; }
411
+ if (options && word.text.startsWith('-')) {
412
+ const option = word.text.split('=')[0];
413
+ if (option === '--in-place') inPlace = true;
414
+ if (['--expression', '--file'].includes(option)) {
415
+ scriptOption = true;
416
+ if (!word.text.includes('=')) index += 1;
417
+ } else if (!word.text.startsWith('--')) {
418
+ for (let flag = 1; flag < word.text.length; flag += 1) {
419
+ if (word.text[flag] === 'i') { inPlace = true; break; }
420
+ if (word.text[flag] === 'e' || word.text[flag] === 'f') {
421
+ scriptOption = true;
422
+ if (flag + 1 === word.text.length) index += 1;
423
+ break;
424
+ }
425
+ }
426
+ }
427
+ } else operands.push(word);
428
+ }
429
+ if (operands[0]?.text === '') operands.shift();
430
+ if (inPlace) for (const word of operands.slice(scriptOption ? 0 : 1)) writeTarget(word, result, directory);
431
+ }
432
+ }
433
+
434
+ function analyzeWords(words, result, depth, directory = '.') {
435
+ for (const index of words.redirects || []) writeTarget(words[index], result, directory);
280
436
  for (let index = 0; index + 1 < words.length; index += 1) {
281
437
  if (words[index].text === '<') addOperand(result, words[index + 1]);
282
438
  }
@@ -284,11 +440,13 @@ function analyzeWords(words, result, depth) {
284
440
  if (command.index < 0) return;
285
441
  const start = command.index + 1;
286
442
  const name = command.name;
443
+ commandWrites(words, start, name, result, directory);
287
444
  if (SIMPLE_READERS.has(name)) {
288
445
  genericOperands(words, start, result, { valueOptions: SIMPLE_OPTION_VALUES.get(name) || new Set() });
289
446
  return;
290
447
  }
291
448
  if (GREP_READERS.has(name)) {
449
+ result.searches.push(searchDescription(words, start, name));
292
450
  const patternOption = words.slice(start).some((word) => ['-e', '--regexp', '-f', '--file'].includes(word.text.split('=')[0]));
293
451
  genericOperands(words, start, result, {
294
452
  skipFirst: patternOption ? 0 : 1,
@@ -319,7 +477,7 @@ function analyzeWords(words, result, depth) {
319
477
  if (SHELLS.has(name)) {
320
478
  for (let index = start; index + 1 < words.length; index += 1) {
321
479
  if (words[index].text === '-c' || words[index].text === '--command') {
322
- merge(result, analyzeCommand(words[index + 1].text, depth + 1));
480
+ merge(result, analyzeCommand(words[index + 1].text, depth + 1, directory));
323
481
  return;
324
482
  }
325
483
  }
@@ -327,7 +485,7 @@ function analyzeWords(words, result, depth) {
327
485
  return;
328
486
  }
329
487
  if (name === 'eval') {
330
- merge(result, analyzeCommand(words.slice(start).map((word) => word.text).join(' '), depth + 1));
488
+ merge(result, analyzeCommand(words.slice(start).map((word) => word.text).join(' '), depth + 1, directory));
331
489
  return;
332
490
  }
333
491
  if (name === 'find') {
@@ -335,7 +493,7 @@ function analyzeWords(words, result, depth) {
335
493
  if (words[index].text !== '-exec' && words[index].text !== '-execdir') continue;
336
494
  const nested = [];
337
495
  for (index += 1; index < words.length && ![';', '+'].includes(words[index].text); index += 1) nested.push(words[index]);
338
- analyzeWords(nested, result, depth + 1);
496
+ analyzeWords(nested, result, depth + 1, directory);
339
497
  }
340
498
  return;
341
499
  }
@@ -351,7 +509,7 @@ function analyzeWords(words, result, depth) {
351
509
  index = skipOption(words, index, XARGS_OPTION_VALUE);
352
510
  nestedStart = index;
353
511
  }
354
- if (nestedStart < words.length) analyzeWords(words.slice(nestedStart), result, depth + 1);
512
+ if (nestedStart < words.length) analyzeWords(words.slice(nestedStart), result, depth + 1, directory);
355
513
  return;
356
514
  }
357
515
  if (INLINE_RUNTIMES.has(name)) {
@@ -369,13 +527,28 @@ function analyzeWords(words, result, depth) {
369
527
  if (name === 'openssl') genericOperands(words, start, result, { pathOptions: new Set(['-in', '-key', '-cert', '-config']) });
370
528
  }
371
529
 
372
- function analyzeCommand(command, depth = 0) {
373
- const result = { paths: new Set(), dynamic: false, malformed: false };
530
+ function analyzeCommand(command, depth = 0, directory = '.') {
531
+ const result = { paths: new Set(), dynamic: false, malformed: false, searches: [], writes: new Set(), unknownWrites: new Set() };
374
532
  if (depth > MAX_DEPTH) { result.dynamic = true; return result; }
375
533
  const parsed = scanShell(command);
376
534
  result.malformed = parsed.malformed;
377
- for (const words of parsed.segments) analyzeWords(words, result, depth);
378
- for (const nested of parsed.nested) merge(result, analyzeCommand(nested, depth + 1));
535
+ let currentDirectory = directory;
536
+ const directories = [];
537
+ for (const words of parsed.segments) {
538
+ directories.push(currentDirectory);
539
+ analyzeWords(words, result, depth, currentDirectory);
540
+ const info = unwrap(words);
541
+ if (['cd', 'pushd'].includes(info.name)) {
542
+ const target = words.slice(info.index + 1).find(word => word.text && !word.text.startsWith('-'));
543
+ if (!target || target.activeExpansion) currentDirectory = null;
544
+ else if (target.text.startsWith('/')) currentDirectory = target.text;
545
+ else if (currentDirectory !== null) currentDirectory = `${currentDirectory}/${target.text}`;
546
+ }
547
+ }
548
+ for (const nested of parsed.nested) {
549
+ const nestedDirectory = directories[nested.segment] === undefined ? directory : directories[nested.segment];
550
+ merge(result, analyzeCommand(nested.command, depth + 1, nestedDirectory));
551
+ }
379
552
  if (result.malformed && parsed.segments.some((words) => {
380
553
  const commandInfo = unwrap(words);
381
554
  return SIMPLE_READERS.has(commandInfo.name) || GREP_READERS.has(commandInfo.name)
@@ -387,7 +560,8 @@ function analyzeCommand(command, depth = 0) {
387
560
 
388
561
  function commandAccess(command) {
389
562
  const result = analyzeCommand(command);
390
- return { paths: [...result.paths], dynamic: result.dynamic, malformed: result.malformed };
563
+ return { paths: [...result.paths], dynamic: result.dynamic, malformed: result.malformed, searches: result.searches,
564
+ writes: [...result.writes], unknownWrites: [...result.unknownWrites] };
391
565
  }
392
566
 
393
567
  module.exports = { DYNAMIC_READ_PATH, commandAccess };