@haposoft/cafekit 0.18.0 → 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.
- package/README.md +12 -1
- package/bin/__tests__/claude-settings.test.js +123 -0
- package/bin/__tests__/codex-ask-guard.test.js +123 -0
- package/bin/__tests__/codex-ask-projection.test.js +51 -0
- package/bin/__tests__/codex-hooks-ownership.test.js +80 -1
- package/bin/__tests__/codex-hooks.test.js +938 -237
- package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
- package/bin/__tests__/codex-native.test.js +222 -149
- package/bin/__tests__/codex-projection-parity.test.js +314 -0
- package/bin/__tests__/codex-quiet-hooks.test.js +170 -0
- package/bin/__tests__/codex-research-projection.test.js +70 -0
- package/bin/__tests__/codex-specs-projection.test.js +76 -0
- package/bin/__tests__/develop-contract.test.js +102 -1362
- package/bin/__tests__/docs-sync-on-demand.test.js +25 -20
- package/bin/__tests__/inspect-paths.test.js +183 -0
- package/bin/__tests__/installer-safety.test.js +335 -0
- package/bin/__tests__/legacy-authority-decoupled.test.js +14 -19
- package/bin/__tests__/legacy-hooks-retired.test.js +18 -8
- package/bin/__tests__/omp-bridge.test.js +66 -12
- package/bin/__tests__/omp-hooks.test.js +11 -5
- package/bin/__tests__/orca-session.test.js +7 -0
- package/bin/__tests__/package-inventory.test.js +1 -395
- package/bin/__tests__/privacy-paths.test.js +317 -0
- package/bin/__tests__/spec-narrowing.test.js +30 -134
- package/bin/__tests__/specs-v2-execution-closeout.test.js +0 -372
- package/bin/__tests__/test-runtime-dependency-closure.cjs +1 -2
- package/bin/__tests__/usage-hook-retired.test.js +5 -3
- package/bin/__tests__/validator-grounding.test.js +17 -2929
- package/bin/install.js +25 -0
- package/bin/lib/codex-install.js +134 -12
- package/bin/lib/context.js +1 -1
- package/bin/lib/managed-writer.js +2 -3
- package/bin/phases/claude-runtime.js +91 -25
- package/bin/phases/claude-settings.js +22 -1
- package/bin/phases/codex-hooks.js +96 -33
- package/bin/phases/codex-runtime.js +45 -49
- package/bin/phases/copy-payload.js +11 -28
- package/bin/phases/omp-runtime.js +6 -3
- package/package.json +2 -3
- package/src/claude/CLAUDE.md +4 -0
- package/src/claude/agents/code-auditor.md +9 -6
- package/src/claude/agents/debugger.md +3 -9
- package/src/claude/agents/docs-keeper.md +4 -7
- package/src/claude/agents/git-ops.md +2 -4
- package/src/claude/agents/implementer.md +6 -7
- package/src/claude/agents/inspector.md +2 -0
- package/src/claude/agents/project-manager.md +2 -2
- package/src/claude/agents/spec-maker.md +4 -4
- package/src/claude/agents/test-runner.md +8 -11
- package/src/claude/agents/ui-ux-designer.md +0 -1
- package/src/claude/gitignore +3 -0
- package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
- package/src/claude/hooks/__tests__/precompact.test.js +206 -10
- package/src/claude/hooks/__tests__/privacy-block.test.js +27 -0
- package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +311 -340
- package/src/claude/hooks/__tests__/spec-state-touched.test.js +230 -8
- package/src/claude/hooks/__tests__/state.test.js +207 -7
- package/src/claude/hooks/__tests__/update-check.test.js +88 -0
- package/src/claude/hooks/agent.cjs +7 -6
- package/src/claude/hooks/inspect-block.cjs +15 -63
- package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
- package/src/claude/hooks/lib/parser.cjs +1 -1
- package/src/claude/hooks/lib/privacy-command-analysis.cjs +191 -17
- package/src/claude/hooks/lib/privacy-paths.cjs +313 -0
- package/src/claude/hooks/lib/project-root.cjs +15 -0
- package/src/claude/hooks/lib/rules-reservation.cjs +36 -0
- package/src/claude/hooks/lib/spec-touch.cjs +67 -0
- package/src/claude/hooks/lib/update-check.cjs +77 -0
- package/src/claude/hooks/precompact.cjs +8 -15
- package/src/claude/hooks/privacy-block.cjs +23 -205
- package/src/claude/hooks/rules.cjs +6 -28
- package/src/claude/hooks/session.cjs +34 -117
- package/src/claude/hooks/spec-gate.cjs +17 -89
- package/src/claude/hooks/spec-state.cjs +89 -96
- package/src/claude/hooks/state.cjs +96 -51
- package/src/claude/migration-manifest.json +20 -8
- package/src/claude/output-styles/coding-level-0-eli5.md +9 -9
- package/src/claude/output-styles/coding-level-1-junior.md +3 -3
- package/src/claude/output-styles/coding-level-2-mid.md +3 -3
- package/src/claude/output-styles/coding-level-3-senior.md +2 -2
- package/src/claude/output-styles/coding-level-4-lead.md +5 -5
- package/src/claude/output-styles/coding-level-5-god.md +6 -6
- package/src/claude/rules/ai-dev-rules.md +1 -2
- package/src/claude/rules/hook-protocols.md +1 -1
- package/src/claude/rules/workflow.md +1 -1
- package/src/claude/runtime.json +0 -3
- package/src/claude/scripts/generate-skill-catalog.cjs +2 -2
- package/src/claude/scripts/provenance.cjs +14 -0
- package/src/claude/scripts/spec-receipt.cjs +0 -114
- package/src/claude/scripts/spec-resolver.cjs +41 -457
- package/src/claude/scripts/workflow-policy.cjs +3 -1801
- package/src/claude/settings/settings.json +1 -1
- package/src/claude/skills/agent-browser/SKILL.md +1 -1
- package/src/claude/skills/ai-multimodal/scripts/check_setup.py +8 -68
- package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +6 -28
- package/src/claude/skills/chrome-devtools/SKILL.md +14 -31
- package/src/claude/skills/code-review/SKILL.md +25 -108
- package/src/claude/skills/code-review/references/spec-compliance-review.md +4 -7
- package/src/claude/skills/develop/SKILL.md +22 -70
- package/src/claude/skills/develop/references/subagent-patterns.md +0 -6
- package/src/claude/skills/git/SKILL.md +1 -1
- package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
- package/src/claude/skills/research/SKILL.md +24 -55
- package/src/claude/skills/sync/SKILL.md +12 -34
- package/src/claude/skills/sync/references/rebind-and-audit.md +2 -2
- package/src/claude/skills/test/SKILL.md +39 -123
- package/src/claude/skills/test/references/execution-strategy.md +11 -22
- package/src/claude/skills/ui-ux/SKILL.md +1 -1
- package/src/claude/skills/ui-ux-pro-max/SKILL.md +24 -79
- package/src/claude/skills/web-testing/SKILL.md +2 -20
- package/src/codex/AGENTS.md +8 -0
- package/src/codex/hooks/ask-guard.cjs +54 -0
- package/src/codex/hooks/inspect-block.cjs +11 -7
- package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
- package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
- package/src/codex/hooks/lib/privacy-state.cjs +98 -16
- package/src/codex/hooks/lib/rules-reservation.cjs +38 -0
- package/src/codex/hooks/lib/secret-keywords.cjs +1 -0
- package/src/codex/hooks/lib/spec-receipt.cjs +2 -30
- package/src/codex/hooks/lib/spec-session-touch.cjs +100 -0
- package/src/codex/hooks/lib/spec-touch.cjs +3 -0
- package/src/codex/hooks/lib/spec-utils.cjs +6 -45
- package/src/codex/hooks/lib/update-check.cjs +1 -0
- package/src/codex/hooks/precompact.cjs +39 -0
- package/src/codex/hooks/privacy-approval.cjs +28 -17
- package/src/codex/hooks/privacy-block.cjs +9 -215
- package/src/codex/hooks/rules.cjs +4 -30
- package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
- package/src/codex/hooks/session.cjs +47 -11
- package/src/codex/hooks/spec-gate.cjs +32 -74
- package/src/codex/hooks/spec-state.cjs +68 -46
- package/src/codex/hooks/state.cjs +25 -4
- package/src/codex/hooks.json +39 -7
- package/src/codex/rules/hook-protocols.md +13 -6
- package/src/common/AGENTS.md +4 -10
- package/src/omp/extensions/cafekit-bridge.mjs +12 -4
- package/src/omp/hooks/privacy-block.cjs +24 -206
- package/bin/__tests__/change-firewall.semantic-firewall.test.js +0 -2133
- package/bin/__tests__/finalizer-pass-gate.semantic-firewall.test.js +0 -342
- package/bin/__tests__/release-preflight.semantic-firewall.test.js +0 -586
- package/bin/__tests__/review-receipt-c2.semantic-firewall.test.js +0 -378
- package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +0 -665
- package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +0 -1132
- package/src/claude/hooks/lib/context.cjs +0 -527
- package/src/claude/hooks/lib/detect.cjs +0 -474
- package/src/claude/scripts/change-firewall.cjs +0 -1748
- package/src/claude/scripts/spec-authoring-digest.cjs +0 -151
- package/src/claude/scripts/spec-authoring-validation.cjs +0 -126
- package/src/claude/scripts/spec-final-state.cjs +0 -290
- package/src/claude/scripts/spec-ground.cjs +0 -650
- package/src/claude/scripts/spec-readiness.cjs +0 -425
- package/src/claude/scripts/spec-scaffold.cjs +0 -2317
- package/src/claude/scripts/spec-semantic-model.cjs +0 -396
- package/src/claude/scripts/validate-spec-output.cjs +0 -3914
- package/src/claude/skills/code-review/references/verification-gate.md +0 -41
- package/src/claude/skills/specs/templates/design.md +0 -82
- package/src/claude/skills/specs/templates/requirements-init.md +0 -8
- package/src/claude/skills/specs/templates/requirements.md +0 -42
- package/src/claude/skills/specs/templates/spec-state.json +0 -56
- package/src/claude/skills/specs/templates/task.md +0 -47
- package/src/codex/rules/state-sync.md +0 -60
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 === '<') {
|
|
194
|
-
|
|
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
|
-
|
|
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
|
-
|
|
378
|
-
|
|
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 };
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { commandAccess } = require('./privacy-command-analysis.cjs');
|
|
6
|
+
|
|
7
|
+
const RESTRICTED = [
|
|
8
|
+
/^\.env(?:[.\[*?{]|$)/i,
|
|
9
|
+
/^credentials/i,
|
|
10
|
+
/secrets?\.(ya?ml|json)$/i,
|
|
11
|
+
/\.pem$/i,
|
|
12
|
+
/\.key$/i,
|
|
13
|
+
/\.p12$/i,
|
|
14
|
+
/\.pfx$/i,
|
|
15
|
+
/^id_(rsa|ed25519|ecdsa|dsa)$/i,
|
|
16
|
+
/\.netrc$/i,
|
|
17
|
+
/\.pgpass$/i,
|
|
18
|
+
/kubeconfig/i,
|
|
19
|
+
/\.keystore$/i,
|
|
20
|
+
/\.jks$/i,
|
|
21
|
+
/auth\.json$/i,
|
|
22
|
+
/token(s)?\.json$/i
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const EXEMPT = [/\.env\.(example|sample|template|test)$/i];
|
|
26
|
+
|
|
27
|
+
function matchesAny(filePath, rules) {
|
|
28
|
+
const base = path.basename(filePath);
|
|
29
|
+
return rules.some((rule) => rule.test(base) || rule.test(filePath));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isSafe(filePath) {
|
|
33
|
+
return matchesAny(filePath, EXEMPT);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isSensitive(filePath) {
|
|
37
|
+
return matchesAny(filePath, RESTRICTED);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function resolveTarget(filePath, cwd) {
|
|
41
|
+
try {
|
|
42
|
+
const requested = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
|
|
43
|
+
return fs.realpathSync(requested);
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* When realpath fails, check whether the requested path is a symlink (or chain)
|
|
51
|
+
* whose lexical target is sensitive. Dangling link to protected target -> true (fail-closed deny);
|
|
52
|
+
* dangling benign link or link to allowed example/template -> false (allow).
|
|
53
|
+
* Limit hops to avoid loops; malformed/looping -> fail closed.
|
|
54
|
+
* TOCTOU note: check and use are not atomic; target could be swapped between this check and the actual read.
|
|
55
|
+
* This is a best-effort guardrail, not an arbitrary-shell security boundary. For strong isolation use OS sandbox.
|
|
56
|
+
*/
|
|
57
|
+
function isSymlinkToSensitive(filePath, cwd) {
|
|
58
|
+
try {
|
|
59
|
+
const requested = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
|
|
60
|
+
let current = requested;
|
|
61
|
+
let hops = 0;
|
|
62
|
+
const MAX_HOPS = 8;
|
|
63
|
+
while (hops < MAX_HOPS) {
|
|
64
|
+
let stat;
|
|
65
|
+
try {
|
|
66
|
+
stat = fs.lstatSync(current);
|
|
67
|
+
} catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
if (!stat.isSymbolicLink()) return false;
|
|
71
|
+
let linkTarget;
|
|
72
|
+
try {
|
|
73
|
+
linkTarget = fs.readlinkSync(current);
|
|
74
|
+
} catch {
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
if (isSensitive(linkTarget) && !isSafe(linkTarget)) return true;
|
|
78
|
+
if (isSafe(linkTarget)) return false;
|
|
79
|
+
const next = path.isAbsolute(linkTarget) ? linkTarget : path.resolve(path.dirname(current), linkTarget);
|
|
80
|
+
if (next === current) return true;
|
|
81
|
+
try {
|
|
82
|
+
const real = fs.realpathSync(next);
|
|
83
|
+
if (isSensitive(real) && !isSafe(real)) return true;
|
|
84
|
+
if (isSafe(real)) return false;
|
|
85
|
+
return false;
|
|
86
|
+
} catch {
|
|
87
|
+
current = next;
|
|
88
|
+
hops += 1;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return true;
|
|
93
|
+
} catch {
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function scrubCommand(command) {
|
|
99
|
+
return String(command).replace(
|
|
100
|
+
/(<<-?[ \t]*)(['"])([A-Za-z_]\w*)\2([^\n]*)\n[\s\S]*?\n[ \t]*\3\b/g,
|
|
101
|
+
'$1$2$3$2$4'
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function extractCommandPaths(command) {
|
|
106
|
+
// The analyzer parses the command and returns the operands of every reading
|
|
107
|
+
// command it recognises, nested shells and `find -exec` included. Treating
|
|
108
|
+
// every unresolved expansion as sensitive, as this did before, denied
|
|
109
|
+
// `cat $FILE`, `wc -l *.cjs` and `cat ~/.zshrc` alike -- and every denial here
|
|
110
|
+
// costs a round trip through a one-shot approval prompt.
|
|
111
|
+
// A heredoc whose delimiter is quoted (<<'BODY') is inert: the shell expands
|
|
112
|
+
// nothing inside it, so no word in the body is ever read. The analyzer has no
|
|
113
|
+
// notion of heredocs and parses the body as shell, so prose that quotes a
|
|
114
|
+
// command -- a PR body carrying `cat .env` in backticks -- came back as a real
|
|
115
|
+
// command substitution. Blank those bodies out first. An unquoted delimiter
|
|
116
|
+
// (<<BODY) does expand, so it is left to the analyzer.
|
|
117
|
+
const scrubbed = scrubCommand(command);
|
|
118
|
+
|
|
119
|
+
const paths = new Set(commandAccess(scrubbed).paths);
|
|
120
|
+
|
|
121
|
+
// Two shapes the analyzer cannot return. It drops any word carrying an
|
|
122
|
+
// expansion, so `~/.ssh/id_rsa` and `certs/*.pem` still have to be matched by
|
|
123
|
+
// name. And it only walks commands it recognises, so an unknown tool handed a
|
|
124
|
+
// secret through a file-shaped option, such as
|
|
125
|
+
// `docker compose --env-file .env`, is invisible to it.
|
|
126
|
+
const FILE_OPTION = /^--?[\w-]*(?:file|config|key|cert|identity)$/i;
|
|
127
|
+
const tokens = scrubbed.split(/[\s|;&<>"'()`]+/);
|
|
128
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
129
|
+
const token = tokens[index];
|
|
130
|
+
const shaped = /^~|[*?[{]/.test(token);
|
|
131
|
+
const optionFed = index > 0 && FILE_OPTION.test(tokens[index - 1]);
|
|
132
|
+
if (!shaped && !optionFed) continue;
|
|
133
|
+
const value = token.replace(/^~/, '').replace(/^["'()`]+|["'(),]+$/g, '');
|
|
134
|
+
if (value && isSensitive(value) && !isSafe(value)) paths.add(value);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return [...paths];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function extractPatchPaths(patch) {
|
|
141
|
+
return [...String(patch).matchAll(
|
|
142
|
+
/^\*\*\* (?:(?:Add|Update|Delete) File:|Move to:)\s+(.+)$/gm
|
|
143
|
+
)]
|
|
144
|
+
.map((match) => match[1].trim())
|
|
145
|
+
.filter(Boolean);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
function shellInput(input) {
|
|
150
|
+
const value = input?.command ?? input?.cmd;
|
|
151
|
+
if (typeof value === 'string') return value;
|
|
152
|
+
if (!Array.isArray(value)) return '';
|
|
153
|
+
const index = value.findIndex(word => word === '-c' || word === '-lc');
|
|
154
|
+
return index >= 0 ? String(value[index + 1] || '') : value.join(' ');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function patchInput(input) {
|
|
158
|
+
if (typeof input === 'string') return input;
|
|
159
|
+
for (const key of ['command', 'patch', 'input']) {
|
|
160
|
+
if (typeof input?.[key] === 'string') return input[key];
|
|
161
|
+
}
|
|
162
|
+
return '';
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function isPathKey(key, mcp) {
|
|
166
|
+
return /^(?:file_?paths?|paths?|search_?paths?|director(?:y|ies)|cwd|root|locations?|notebook_path|globs?)$/i.test(key)
|
|
167
|
+
|| (mcp && /path|file|dir|uri|source|dest|target/i.test(key));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function collectPathValues(value, key, out, mcp) {
|
|
171
|
+
if (typeof value === 'string' && isPathKey(key, mcp)) {
|
|
172
|
+
const filePath = value.trim().replace(/^file:\/\//i, '');
|
|
173
|
+
if (filePath) out.push(filePath);
|
|
174
|
+
} else if (Array.isArray(value)) {
|
|
175
|
+
for (const item of value) collectPathValues(item, key, out, mcp);
|
|
176
|
+
} else if (value && typeof value === 'object') {
|
|
177
|
+
for (const [childKey, childValue] of Object.entries(value)) {
|
|
178
|
+
collectPathValues(childValue, childKey, out, mcp);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function extractPaths(toolName, input) {
|
|
184
|
+
const paths = [];
|
|
185
|
+
const tool = String(toolName).toLowerCase();
|
|
186
|
+
collectPathValues(input, '', paths, tool.startsWith('mcp__'));
|
|
187
|
+
if (['bash', 'exec_command', 'shell'].includes(tool)) {
|
|
188
|
+
paths.push(...extractCommandPaths(shellInput(input)));
|
|
189
|
+
} else if (['apply_patch', 'edit', 'write'].includes(tool)) {
|
|
190
|
+
paths.push(...extractPatchPaths(patchInput(input)));
|
|
191
|
+
}
|
|
192
|
+
return [...new Set(paths)];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function sensitiveRequest(filePath, cwd) {
|
|
196
|
+
const target = resolveTarget(filePath, cwd);
|
|
197
|
+
if (target) {
|
|
198
|
+
if (isSensitive(target) && !isSafe(target)) return true;
|
|
199
|
+
if (isSafe(target)) return false;
|
|
200
|
+
} else {
|
|
201
|
+
if (isSymlinkToSensitive(filePath, cwd)) return true;
|
|
202
|
+
}
|
|
203
|
+
return !isSafe(filePath) && isSensitive(filePath);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function protectedLocation(filePath) {
|
|
207
|
+
const normalized = path.posix.normalize(filePath).toLowerCase();
|
|
208
|
+
const parts = normalized.split('/');
|
|
209
|
+
const runtimes = new Set(['.claude', '.codex', '.omp']);
|
|
210
|
+
if (runtimes.has(parts.at(-1)) || parts.at(-1) === '.privacy') return true;
|
|
211
|
+
if (parts.at(-1) === 'runtime.json' && runtimes.has(parts.at(-2))) return true;
|
|
212
|
+
for (let index = 0; index + 2 < parts.length; index += 1) {
|
|
213
|
+
if (parts[index] === '.codex' && parts[index + 1] === 'hooks' && parts[index + 2] === '.privacy') return true;
|
|
214
|
+
}
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function protectedWrite(filePath, cwd) {
|
|
219
|
+
const absolute = path.posix.resolve(cwd.replace(/\\/g, '/'), filePath.replace(/\\/g, '/'));
|
|
220
|
+
if (protectedLocation(absolute)) return true;
|
|
221
|
+
let current = absolute;
|
|
222
|
+
const suffix = [];
|
|
223
|
+
const links = new Set();
|
|
224
|
+
for (;;) {
|
|
225
|
+
try {
|
|
226
|
+
if (fs.lstatSync(current).isSymbolicLink()) {
|
|
227
|
+
if (links.has(current) || links.size >= 8) return true;
|
|
228
|
+
links.add(current);
|
|
229
|
+
const target = path.posix.resolve(path.posix.dirname(current), fs.readlinkSync(current));
|
|
230
|
+
if (protectedLocation(path.posix.join(target, ...suffix.slice().reverse()))) return true;
|
|
231
|
+
current = target;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const real = fs.realpathSync(current);
|
|
235
|
+
return protectedLocation(path.posix.join(real, ...suffix.reverse()));
|
|
236
|
+
} catch (error) {
|
|
237
|
+
if (error.code === 'ELOOP') return true;
|
|
238
|
+
if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') return false;
|
|
239
|
+
}
|
|
240
|
+
const parent = path.posix.dirname(current);
|
|
241
|
+
if (parent === current) return false;
|
|
242
|
+
suffix.push(path.posix.basename(current));
|
|
243
|
+
current = parent;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function extractWritePaths(tool, input) {
|
|
248
|
+
if (['bash', 'exec_command', 'shell'].includes(tool)) return commandAccess(scrubCommand(shellInput(input)));
|
|
249
|
+
const paths = [];
|
|
250
|
+
if (['write', 'edit', 'multiedit', 'notebookedit'].includes(tool) || tool.startsWith('mcp__')) {
|
|
251
|
+
collectPathValues(input, '', paths, tool.startsWith('mcp__'));
|
|
252
|
+
}
|
|
253
|
+
if (['apply_patch', 'edit', 'write'].includes(tool)) paths.push(...extractPatchPaths(patchInput(input)));
|
|
254
|
+
return { writes: paths, unknownWrites: [] };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function protectedUnknownWrite(filePath) {
|
|
258
|
+
const parts = path.posix.normalize(filePath.replace(/\\/g, '/')).toLowerCase().split('/');
|
|
259
|
+
return parts.at(-1) === 'runtime.json' || parts.includes('.privacy')
|
|
260
|
+
|| ['.claude', '.codex', '.omp'].includes(parts.at(-1));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function sensitivePaths(toolName, input, cwd) {
|
|
264
|
+
const paths = extractPaths(toolName, input).filter(filePath => sensitiveRequest(filePath, cwd));
|
|
265
|
+
const tool = String(toolName).toLowerCase();
|
|
266
|
+
const access = extractWritePaths(tool, input);
|
|
267
|
+
paths.push(...access.writes.filter(filePath => protectedWrite(filePath, cwd)),
|
|
268
|
+
...access.unknownWrites.filter(protectedUnknownWrite));
|
|
269
|
+
const searches = tool === 'grep'
|
|
270
|
+
? [{ recursive: true, hidden: true,
|
|
271
|
+
roots: [typeof input?.path === 'string' && input.path.trim() ? input.path : cwd],
|
|
272
|
+
includes: [input?.glob, ...(Array.isArray(input?.globs) ? input.globs : [])]
|
|
273
|
+
.filter(glob => typeof glob === 'string' && glob),
|
|
274
|
+
}]
|
|
275
|
+
: ['bash', 'exec_command', 'shell'].includes(tool)
|
|
276
|
+
? commandAccess(scrubCommand(shellInput(input))).searches : [];
|
|
277
|
+
for (const search of searches) {
|
|
278
|
+
if (!search.recursive) continue;
|
|
279
|
+
// Negative globs and exclusion/type flags cannot prove a sensitive child is omitted.
|
|
280
|
+
const includes = search.includes.filter(glob => glob && !glob.startsWith('!'));
|
|
281
|
+
for (const root of search.roots) {
|
|
282
|
+
const directory = path.resolve(cwd, root);
|
|
283
|
+
let entries;
|
|
284
|
+
try { entries = fs.readdirSync(directory, { withFileTypes: true }); } catch { continue; }
|
|
285
|
+
for (const entry of entries) {
|
|
286
|
+
if (entry.isDirectory() || (!search.hidden && entry.name.startsWith('.'))) continue;
|
|
287
|
+
if (includes.length && !includes.some(glob => matchesInclude(entry.name, glob))) continue;
|
|
288
|
+
const filePath = path.join(directory, entry.name);
|
|
289
|
+
if (sensitiveRequest(filePath, cwd)) paths.push(filePath);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return [...new Set(paths)];
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function matchesInclude(name, glob) {
|
|
297
|
+
if (typeof path.matchesGlob !== 'function') return true;
|
|
298
|
+
glob = glob.replace(/^\/+/, '');
|
|
299
|
+
if (glob.length > 256 || (glob.match(/[*?]/g) || []).length > 4 || /\{[^}]*\.\.[^}]*\}/.test(glob)
|
|
300
|
+
|| !/^[\w.\-\/]*(?:[*?][\w.\-\/]*){0,4}(?:\{[\w.\-*,]*\}[\w.\-\/]*)?$/.test(glob)) return true;
|
|
301
|
+
// Prefixing both sides removes Node's implicit leading-dot exclusion for a basename.
|
|
302
|
+
return path.matchesGlob(name, glob)
|
|
303
|
+
|| (name.startsWith('.') && path.matchesGlob(`x${name}`, `x${glob.replace(/^(?:\*\*\/)+/, '')}`));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function displayNames(filePaths) {
|
|
307
|
+
const names = [...new Set(filePaths.map((filePath) => path.basename(filePath)))].sort();
|
|
308
|
+
return names.length <= 3
|
|
309
|
+
? names.join(', ')
|
|
310
|
+
: `${names.slice(0, 3).join(', ')} (+${names.length - 3} more)`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
module.exports = { isSensitive, isSafe, sensitiveRequest, extractPaths, sensitivePaths, displayNames };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const runtimeRoot = path.resolve(__dirname, '../..');
|
|
6
|
+
|
|
7
|
+
function projectRoot(payload = {}) {
|
|
8
|
+
const installed = path.basename(runtimeRoot).startsWith('.') ? path.dirname(runtimeRoot) : '';
|
|
9
|
+
const configured = typeof process.env.CLAUDE_PROJECT_DIR === 'string' ? process.env.CLAUDE_PROJECT_DIR.trim() : '';
|
|
10
|
+
const cwd = typeof payload.cwd === 'string' ? payload.cwd.trim() : '';
|
|
11
|
+
const root = path.resolve(installed || configured || cwd || process.cwd());
|
|
12
|
+
try { return fs.realpathSync(root); } catch { return root; }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
module.exports = { projectRoot };
|