@haposoft/cafekit 0.18.1 → 0.18.3
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__/adv-skill.test.js +371 -0
- package/bin/__tests__/agent-authority.test.js +38 -0
- package/bin/__tests__/agent-instructions.test.js +54 -0
- package/bin/__tests__/claude-plugin-build.test.js +151 -0
- 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 +812 -12
- package/bin/__tests__/codex-implicit-policy.test.js +160 -0
- package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
- package/bin/__tests__/codex-native.test.js +198 -58
- package/bin/__tests__/codex-plugin-build.test.js +230 -0
- package/bin/__tests__/codex-plugin-context.test.js +135 -0
- package/bin/__tests__/codex-plugin-hooks.test.js +220 -0
- 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-runtime-layout.test.js +81 -0
- package/bin/__tests__/codex-setup-agents.test.js +216 -0
- package/bin/__tests__/codex-specs-projection.test.js +76 -0
- package/bin/__tests__/develop-contract.test.js +1 -1
- 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-hooks-retired.test.js +81 -21
- package/bin/__tests__/migrate-apply.test.js +373 -0
- package/bin/__tests__/migrate-cli.test.js +152 -0
- package/bin/__tests__/migrate-merged.test.js +269 -0
- package/bin/__tests__/migrate-plan.test.js +271 -0
- package/bin/__tests__/omp-bridge.test.js +147 -17
- package/bin/__tests__/omp-hooks.test.js +35 -5
- package/bin/__tests__/orca-session.test.js +7 -0
- package/bin/__tests__/plugin-safety-keys.test.js +121 -0
- package/bin/__tests__/privacy-paths.test.js +501 -0
- package/bin/__tests__/usage-hook-retired.test.js +29 -9
- package/bin/__tests__/vibe-run.test.js +215 -0
- package/bin/__tests__/vibe-skill.test.js +549 -0
- package/bin/install.js +124 -1
- package/bin/lib/backup.js +43 -24
- package/bin/lib/codex-install.js +141 -12
- package/bin/lib/context.js +6 -1
- package/bin/lib/lock.js +98 -18
- package/bin/lib/managed-writer.js +16 -10
- package/bin/lib/migrate-apply.js +202 -0
- package/bin/lib/migrate-merged.js +342 -0
- package/bin/lib/migrate-plan.js +419 -0
- package/bin/lib/plugin-inventory.js +33 -0
- package/bin/phases/claude-runtime.js +93 -25
- package/bin/phases/claude-settings.js +22 -1
- package/bin/phases/codex-hooks.js +134 -33
- package/bin/phases/codex-runtime.js +110 -85
- package/bin/phases/copy-payload.js +50 -4
- package/bin/phases/omp-runtime.js +4 -26
- package/bin/phases/root-config.js +5 -2
- package/package.json +1 -1
- package/src/claude/CLAUDE.md +5 -1
- package/src/claude/agents/advisor.md +55 -0
- package/src/claude/agents/debugger.md +2 -3
- package/src/claude/agents/deployer.md +2 -1
- package/src/claude/agents/docs-keeper.md +12 -0
- package/src/claude/agents/inspector.md +1 -1
- package/src/claude/agents/project-manager.md +2 -2
- package/src/claude/agents/researcher.md +0 -1
- package/src/claude/agents/spec-maker.md +4 -1
- package/src/claude/gitignore +3 -0
- package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
- package/src/claude/hooks/__tests__/plugin-context.test.js +96 -0
- package/src/claude/hooks/__tests__/plugin-options.test.js +141 -0
- package/src/claude/hooks/__tests__/plugin-state-isolation.test.js +125 -0
- package/src/claude/hooks/__tests__/precompact.test.js +213 -11
- package/src/claude/hooks/__tests__/privacy-block.test.js +60 -9
- package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
- package/src/claude/hooks/__tests__/setup-statusline.test.js +179 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +227 -11
- package/src/claude/hooks/__tests__/spec-state-touched.test.js +204 -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 +11 -7
- package/src/claude/hooks/inspect-block.cjs +20 -66
- package/src/claude/hooks/lib/hook-state-dir.cjs +19 -1
- package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
- package/src/claude/hooks/lib/parser.cjs +1 -1
- package/src/claude/hooks/lib/plugin-options.cjs +47 -0
- package/src/claude/hooks/lib/privacy-command-analysis.cjs +317 -24
- package/src/claude/hooks/lib/privacy-paths.cjs +673 -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 +80 -0
- package/src/claude/hooks/lib/update-check.cjs +77 -0
- package/src/claude/hooks/plugin-context.cjs +80 -0
- package/src/claude/hooks/precompact.cjs +8 -15
- package/src/claude/hooks/privacy-block.cjs +25 -206
- package/src/claude/hooks/rules.cjs +10 -29
- package/src/claude/hooks/session.cjs +37 -117
- package/src/claude/hooks/spec-gate.cjs +9 -26
- package/src/claude/hooks/spec-state.cjs +44 -59
- package/src/claude/hooks/state.cjs +101 -52
- package/src/claude/migration-manifest.json +14 -2
- package/src/claude/plugin/commands/setup-statusline.md +12 -0
- package/src/claude/plugin/core.md +71 -0
- package/src/claude/rules/hook-protocols.md +1 -1
- package/src/claude/rules/manage-docs.md +1 -2
- package/src/claude/rules/review-audit-self-decision.md +1 -1
- package/src/claude/rules/skill-workflow-routing.md +1 -1
- package/src/claude/rules/state-sync.md +14 -0
- package/src/claude/rules/workflow.md +1 -1
- package/src/claude/runtime.json +0 -3
- package/src/claude/scripts/generate-skill-catalog.cjs +7 -2
- package/src/claude/scripts/provenance.cjs +54 -16
- package/src/claude/scripts/setup-statusline.cjs +88 -0
- package/src/claude/settings/settings.json +1 -1
- package/src/claude/skills/adv/SKILL.md +139 -0
- package/src/claude/skills/adv/references/optional-modes.md +41 -0
- package/src/claude/skills/adv/references/ultra-mode.md +29 -0
- 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/brainstorm/SKILL.md +1 -1
- package/src/claude/skills/code-review/SKILL.md +4 -1
- package/src/claude/skills/code-review/references/pr-fix-reply.md +42 -0
- package/src/claude/skills/develop/SKILL.md +5 -3
- package/src/claude/skills/develop/references/quality-gate.md +1 -1
- package/src/claude/skills/fix/SKILL.md +2 -2
- package/src/claude/skills/git/SKILL.md +5 -3
- package/src/claude/skills/git/references/merge-pr.md +125 -0
- package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
- package/src/claude/skills/specs/SKILL.md +2 -2
- package/src/claude/skills/specs/references/review.md +1 -1
- package/src/claude/skills/specs/references/templates.md +2 -0
- package/src/claude/skills/vibe/SKILL.md +69 -0
- package/src/claude/skills/vibe/references/pipeline-skill-integration.md +34 -0
- package/src/claude/skills/vibe/references/report-templates.md +84 -0
- package/src/claude/skills/vibe/references/subagent-orchestration.md +23 -0
- package/src/codex/AGENTS.md +9 -1
- package/src/codex/hooks/ask-guard.cjs +54 -0
- package/src/codex/hooks/inspect-block.cjs +15 -8
- package/src/codex/hooks/lib/hook-context.cjs +118 -4
- package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
- package/src/codex/hooks/lib/plugin-options.cjs +2 -0
- package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
- package/src/codex/hooks/lib/privacy-state.cjs +116 -17
- 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-session-touch.cjs +100 -0
- package/src/codex/hooks/lib/spec-touch.cjs +3 -0
- package/src/codex/hooks/lib/state-store.cjs +5 -4
- package/src/codex/hooks/lib/update-check.cjs +1 -0
- package/src/codex/hooks/precompact.cjs +40 -0
- package/src/codex/hooks/privacy-approval.cjs +28 -17
- package/src/codex/hooks/privacy-block.cjs +11 -216
- package/src/codex/hooks/rules.cjs +14 -34
- package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
- package/src/codex/hooks/session.cjs +59 -11
- package/src/codex/hooks/spec-gate.cjs +21 -6
- package/src/codex/hooks/spec-state.cjs +32 -6
- package/src/codex/hooks/state.cjs +25 -4
- package/src/codex/hooks.json +39 -7
- package/src/codex/plugin/core.md +76 -0
- package/src/codex/plugin/hooks/plugin-context.cjs +63 -0
- package/src/codex/plugin/skills/setup-agents/SKILL.md +17 -0
- package/src/codex/rules/hook-protocols.md +13 -6
- package/src/codex/scripts/setup-agents.cjs +124 -0
- package/src/common/AGENTS.md +1 -1
- package/src/omp/extensions/{cafekit-bridge.mjs → cafekit-bridge.js} +85 -9
- package/src/omp/hooks/privacy-block.cjs +26 -207
- package/src/codex/rules/state-sync.md +0 -60
|
@@ -35,6 +35,8 @@ const SED_OPTION_VALUE = new Set(['-e', '--expression', '-f', '--file']);
|
|
|
35
35
|
const AWK_OPTION_VALUE = new Set(['-f', '--file', '-v', '--assign', '-F', '--field-separator']);
|
|
36
36
|
const XARGS_OPTION_VALUE = new Set(['-a', '--arg-file', '-d', '--delimiter', '-E', '--eof', '-I', '--replace', '-L', '--max-lines', '-n', '--max-args', '-P', '--max-procs', '-s', '--max-chars']);
|
|
37
37
|
|
|
38
|
+
const IDENTIFIER = /[A-Za-z_][A-Za-z0-9_]*/y;
|
|
39
|
+
|
|
38
40
|
function expansionAt(source, index) {
|
|
39
41
|
const next = source[index + 1];
|
|
40
42
|
return Boolean(next && (next === '{' || next === '('
|
|
@@ -107,17 +109,28 @@ function scanShell(command) {
|
|
|
107
109
|
const source = String(command).replace(/\\\r?\n/g, '');
|
|
108
110
|
const segments = [];
|
|
109
111
|
const nested = [];
|
|
112
|
+
// Each nested command is also kept on the word it sits in, so an operand built from $(...) carries its literals.
|
|
113
|
+
const recordNested = command => { nested.push({ command, segment: segments.length }); (word.nested ||= []).push(command); };
|
|
110
114
|
let words = [];
|
|
111
115
|
let word = { text: '', activeExpansion: false, started: false };
|
|
112
116
|
let quote = null;
|
|
113
117
|
let escaped = false;
|
|
114
118
|
let malformed = false;
|
|
115
119
|
const append = (value) => { word.text += value; word.started = true; };
|
|
120
|
+
// `$NAME` is written as `${NAME}` so a quote that ends the name (`.en"$Y"v`) still ends it after the quotes go.
|
|
121
|
+
const appendName = (index) => {
|
|
122
|
+
IDENTIFIER.lastIndex = index + 1;
|
|
123
|
+
const match = IDENTIFIER.exec(source);
|
|
124
|
+
if (!match) return index;
|
|
125
|
+
append(`{${match[0]}}`);
|
|
126
|
+
return index + match[0].length;
|
|
127
|
+
};
|
|
116
128
|
const flushWord = () => {
|
|
117
129
|
if (word.started || word.text) words.push(word);
|
|
118
130
|
word = { text: '', activeExpansion: false, started: false };
|
|
119
131
|
};
|
|
120
|
-
|
|
132
|
+
// `link` is how the segment ends: 'pipe' for a single |, anything else for ;, &&, ||, &, newline and parentheses.
|
|
133
|
+
const flushSegment = (link = 'end') => { flushWord(); if (words.length) { words.link = link; segments.push(words); } words = []; };
|
|
121
134
|
|
|
122
135
|
for (let index = 0; index < source.length; index += 1) {
|
|
123
136
|
const ch = source[index];
|
|
@@ -132,17 +145,17 @@ function scanShell(command) {
|
|
|
132
145
|
if (source[index + 1] === '(') {
|
|
133
146
|
const end = matchingParen(source, index + 1);
|
|
134
147
|
if (end < 0) { malformed = true; continue; }
|
|
135
|
-
|
|
148
|
+
recordNested(source.slice(index + 2, end));
|
|
136
149
|
append('()');
|
|
137
150
|
index = end;
|
|
138
|
-
}
|
|
151
|
+
} else index = appendName(index);
|
|
139
152
|
continue;
|
|
140
153
|
}
|
|
141
154
|
if (ch === '\x60') {
|
|
142
155
|
word.activeExpansion = true;
|
|
143
156
|
const end = matchingBacktick(source, index);
|
|
144
157
|
if (end < 0) { malformed = true; continue; }
|
|
145
|
-
|
|
158
|
+
recordNested(source.slice(index + 1, end));
|
|
146
159
|
append('\x60\x60');
|
|
147
160
|
index = end;
|
|
148
161
|
continue;
|
|
@@ -164,17 +177,17 @@ function scanShell(command) {
|
|
|
164
177
|
if (source[index + 1] === '(') {
|
|
165
178
|
const end = matchingParen(source, index + 1);
|
|
166
179
|
if (end < 0) { malformed = true; continue; }
|
|
167
|
-
|
|
180
|
+
recordNested(source.slice(index + 2, end));
|
|
168
181
|
append('()');
|
|
169
182
|
index = end;
|
|
170
|
-
}
|
|
183
|
+
} else index = appendName(index);
|
|
171
184
|
continue;
|
|
172
185
|
}
|
|
173
186
|
if (ch === '\x60') {
|
|
174
187
|
word.activeExpansion = true;
|
|
175
188
|
const end = matchingBacktick(source, index);
|
|
176
189
|
if (end < 0) { malformed = true; continue; }
|
|
177
|
-
|
|
190
|
+
recordNested(source.slice(index + 1, end));
|
|
178
191
|
append('\x60\x60');
|
|
179
192
|
index = end;
|
|
180
193
|
continue;
|
|
@@ -183,15 +196,35 @@ function scanShell(command) {
|
|
|
183
196
|
word.activeExpansion = true;
|
|
184
197
|
const end = matchingParen(source, index + 1);
|
|
185
198
|
if (end < 0) { malformed = true; continue; }
|
|
186
|
-
|
|
199
|
+
recordNested(source.slice(index + 2, end));
|
|
187
200
|
append(`${ch}()`);
|
|
188
201
|
index = end;
|
|
189
202
|
continue;
|
|
190
203
|
}
|
|
191
204
|
if ((ch === '~' && !word.text) || '*?{['.includes(ch)) { word.activeExpansion = true; append(ch); continue; }
|
|
192
|
-
if (';|&\n()'.includes(ch)) {
|
|
193
|
-
|
|
194
|
-
|
|
205
|
+
if (';|&\n()'.includes(ch)) {
|
|
206
|
+
const pipe = ch === '|' && source[index + 1] !== '|' && source[index - 1] !== '|' && source[index + 1] !== '&';
|
|
207
|
+
flushSegment(pipe ? 'pipe' : 'other');
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (ch === '<') {
|
|
211
|
+
const descriptor = /^[0-9]+$/.test(word.text);
|
|
212
|
+
flushWord();
|
|
213
|
+
if (descriptor) (words.redirectFds ||= []).push(words.length - 1);
|
|
214
|
+
(words.inputRedirects ||= []).push(words.length, words.length + 1);
|
|
215
|
+
words.push({ text: '<', activeExpansion: false, started: true });
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (ch === '>') {
|
|
219
|
+
const descriptor = /^[0-9]+$/.test(word.text);
|
|
220
|
+
flushWord();
|
|
221
|
+
if (descriptor) (words.redirectFds ||= []).push(words.length - 1);
|
|
222
|
+
(words.redirects ||= []).push(words.length);
|
|
223
|
+
if (source[index + 1] === '>') index += 1;
|
|
224
|
+
if (source[index + 1] === '&') index += 1;
|
|
225
|
+
if (source[index + 1] === '|') index += 1;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
195
228
|
if (' \t\r'.includes(ch)) { flushWord(); continue; }
|
|
196
229
|
append(ch);
|
|
197
230
|
}
|
|
@@ -216,9 +249,17 @@ function skipOption(words, index, valueOptions = WRAPPER_OPTION_VALUE) {
|
|
|
216
249
|
return valueOptions.has(value) ? index + 2 : index + 1;
|
|
217
250
|
}
|
|
218
251
|
|
|
252
|
+
// Words that open or continue a compound command; the command they lead into is still the one that runs.
|
|
253
|
+
const KEYWORDS = new Set(['if', 'then', 'elif', 'else', 'while', 'until', 'do', '{', '!', 'time', 'coproc', 'function']);
|
|
254
|
+
|
|
219
255
|
function unwrap(words) {
|
|
220
256
|
let index = 0;
|
|
221
|
-
while (index < words.length && assignment(words[index])
|
|
257
|
+
while (index < words.length && (assignment(words[index]) || KEYWORDS.has(words[index].text))) {
|
|
258
|
+
index += 1;
|
|
259
|
+
if (words[index - 1].text === 'time') while (words[index]?.text.startsWith('-')) index += 1;
|
|
260
|
+
// `function f { cat .env; }`: the word after `function` is the name being defined, not a command.
|
|
261
|
+
if (words[index - 1].text === 'function') index += 1;
|
|
262
|
+
}
|
|
222
263
|
for (let depth = 0; depth < 12 && index < words.length; depth += 1) {
|
|
223
264
|
const name = commandName(words[index]);
|
|
224
265
|
if (!WRAPPERS.has(name)) return { index, name };
|
|
@@ -243,8 +284,19 @@ function unwrap(words) {
|
|
|
243
284
|
|
|
244
285
|
function addOperand(result, word) {
|
|
245
286
|
if (!word) return;
|
|
246
|
-
if (word.activeExpansion)
|
|
247
|
-
|
|
287
|
+
if (word.activeExpansion) {
|
|
288
|
+
result.dynamic = true;
|
|
289
|
+
// Kept for the path resolver: the raw text, the directory a tracked `cd` left, and the literal words of every
|
|
290
|
+
// command substitution inside it. The assignments it can see are attached when its segment finishes.
|
|
291
|
+
result.expansions.push({ text: word.text, directory: result.directory === undefined ? '.' : result.directory,
|
|
292
|
+
literals: (word.nested || []).flatMap(literalWords) });
|
|
293
|
+
} else if (word.text && word.text !== '-') result.paths.add(word.text);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// The literal words of a command line: no expansion, no option.
|
|
297
|
+
function literalWords(command) {
|
|
298
|
+
return scanShell(command).segments.flat().filter((word) => !word.activeExpansion && word.text && !word.text.startsWith('-'))
|
|
299
|
+
.map((word) => word.text);
|
|
248
300
|
}
|
|
249
301
|
|
|
250
302
|
function genericOperands(words, start, result, {
|
|
@@ -272,11 +324,154 @@ function genericOperands(words, start, result, {
|
|
|
272
324
|
|
|
273
325
|
function merge(target, source) {
|
|
274
326
|
source.paths.forEach((value) => target.paths.add(value));
|
|
327
|
+
target.searches.push(...source.searches);
|
|
328
|
+
source.writes.forEach(value => target.writes.add(value));
|
|
329
|
+
source.unknownWrites.forEach(value => target.unknownWrites.add(value));
|
|
330
|
+
target.expansions.push(...source.expansions);
|
|
331
|
+
target.pipeNames.push(...source.pipeNames);
|
|
332
|
+
target.decodedRead ||= source.decodedRead;
|
|
275
333
|
target.dynamic ||= source.dynamic;
|
|
276
334
|
target.malformed ||= source.malformed;
|
|
277
335
|
}
|
|
278
336
|
|
|
279
|
-
|
|
337
|
+
// Describe searches separately so the existing read operands stay unchanged.
|
|
338
|
+
function searchDescription(words, start, name) {
|
|
339
|
+
const grep = ['grep', 'egrep', 'fgrep'].includes(name);
|
|
340
|
+
const search = { recursive: !grep, hidden: grep, includes: [], excludes: [], roots: [] };
|
|
341
|
+
const values = new Set([...GREP_OPTION_VALUE, '-g', '--glob', '--iglob', '--include', '--exclude',
|
|
342
|
+
'--exclude-dir', '-d', '--directories', '-t', '--type', '-T', '--type-not',
|
|
343
|
+
'--max-depth', '-j', '--threads', '-M', '--max-columns', '--sort', '--sortr',
|
|
344
|
+
'--max-filesize', '--pre', '--path-separator', '--dfa-size-limit', '--regex-size-limit']);
|
|
345
|
+
let pattern = !grep && words.slice(start).some(word => word.text === '--files');
|
|
346
|
+
let unrestricted = 0;
|
|
347
|
+
let options = true;
|
|
348
|
+
const positional = [];
|
|
349
|
+
for (let index = start; index < words.length; index += 1) {
|
|
350
|
+
const word = words[index];
|
|
351
|
+
const text = word.text;
|
|
352
|
+
if (options && text === '--') { options = false; continue; }
|
|
353
|
+
if (options && text.startsWith('-') && text !== '-') {
|
|
354
|
+
let option = text.split('=')[0];
|
|
355
|
+
let value = text.includes('=') ? text.slice(text.indexOf('=') + 1) : null;
|
|
356
|
+
let shortFlags = /^-[^-]/.test(text) ? text.slice(1) : '';
|
|
357
|
+
const attached = text.match(/^-([^-]*?)([gef])(.*)$/);
|
|
358
|
+
if (attached) { shortFlags = attached[1]; option = `-${attached[2]}`; value = attached[3] || null; }
|
|
359
|
+
if (values.has(option) && value === null) value = words[++index]?.text || '';
|
|
360
|
+
if (['-e', '--regexp', '-f', '--file'].includes(option)) pattern = true;
|
|
361
|
+
if (['-g', '--glob', '--include'].includes(option)) search.includes.push(value || '');
|
|
362
|
+
if (['--exclude', '--exclude-dir'].includes(option)) search.excludes.push(value || '');
|
|
363
|
+
if (grep && (['--recursive', '--dereference-recursive'].includes(option)
|
|
364
|
+
|| /[rR]/.test(shortFlags)
|
|
365
|
+
|| (['-d', '--directories'].includes(option) && value === 'recurse'))) search.recursive = true;
|
|
366
|
+
if (!grep) {
|
|
367
|
+
if (option === '--hidden' || shortFlags.includes('.')) search.hidden = true;
|
|
368
|
+
unrestricted += (shortFlags.match(/u/g) || []).length + (option === '--unrestricted' ? 1 : 0);
|
|
369
|
+
}
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
positional.push(word);
|
|
373
|
+
}
|
|
374
|
+
if (!pattern) positional.shift();
|
|
375
|
+
search.roots = positional.filter(word => !word.activeExpansion && word.text !== '-').map(word => word.text);
|
|
376
|
+
if (!search.roots.length) search.roots.push('.');
|
|
377
|
+
if (unrestricted >= (name === 'ag' ? 1 : 2)) search.hidden = true;
|
|
378
|
+
// Case-insensitive globs are not evidence that a differently cased child is excluded.
|
|
379
|
+
if (words.slice(start).some(word => ['--glob-case-insensitive', '--iglob'].includes(word.text.split('=')[0]))) search.includes = [];
|
|
380
|
+
return search;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function writeTarget(word, result, directory) {
|
|
384
|
+
if (!word?.text || word.activeExpansion || word.text === '-') return;
|
|
385
|
+
if (directory === null && !word.text.startsWith('/')) {
|
|
386
|
+
result.unknownWrites.add(word.text);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
result.writes.add(word.text.startsWith('/') || directory === '.' ? word.text : `${directory}/${word.text}`);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function commandWrites(words, start, name, result, directory) {
|
|
393
|
+
const redirected = new Set([...(words.redirects || []), ...(words.redirectFds || []), ...(words.inputRedirects || [])]);
|
|
394
|
+
if (name === 'tee') {
|
|
395
|
+
for (let index = start; index < words.length; index += 1) {
|
|
396
|
+
if (!redirected.has(index) && !words[index].text.startsWith('-')) writeTarget(words[index], result, directory);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
if (['cp', 'mv', 'install', 'ln'].includes(name)) {
|
|
400
|
+
const operands = [];
|
|
401
|
+
let targetDirectory = false;
|
|
402
|
+
const values = new Set(['-t', '--target-directory', '-S', '--suffix', '-m', '--mode', '-o', '--owner', '-g', '--group']);
|
|
403
|
+
let options = true;
|
|
404
|
+
for (let index = start; index < words.length; index += 1) {
|
|
405
|
+
if (redirected.has(index)) continue;
|
|
406
|
+
const word = words[index];
|
|
407
|
+
if (options && word.text === '--') { options = false; continue; }
|
|
408
|
+
if (options && word.text.startsWith('-')) {
|
|
409
|
+
let option = word.text.split('=')[0];
|
|
410
|
+
let value = word.text.includes('=')
|
|
411
|
+
? { text: word.text.slice(word.text.indexOf('=') + 1), activeExpansion: word.activeExpansion }
|
|
412
|
+
: values.has(option) ? words[++index] : null;
|
|
413
|
+
if (!word.text.startsWith('--') && !values.has(option)) {
|
|
414
|
+
for (let flag = 1; flag < word.text.length; flag += 1) {
|
|
415
|
+
const candidate = `-${word.text[flag]}`;
|
|
416
|
+
if (!values.has(candidate)) continue;
|
|
417
|
+
option = candidate;
|
|
418
|
+
value = flag + 1 < word.text.length
|
|
419
|
+
? { text: word.text.slice(flag + 1), activeExpansion: word.activeExpansion } : words[++index];
|
|
420
|
+
break;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
if (['-t', '--target-directory'].includes(option)) {
|
|
424
|
+
targetDirectory = true;
|
|
425
|
+
writeTarget(value, result, directory);
|
|
426
|
+
}
|
|
427
|
+
} else operands.push(word);
|
|
428
|
+
}
|
|
429
|
+
if (!targetDirectory) writeTarget(operands.at(-1), result, directory);
|
|
430
|
+
if (name === 'cp' || name === 'mv') {
|
|
431
|
+
const sources = targetDirectory ? operands : operands.slice(0, -1);
|
|
432
|
+
for (const source of sources) {
|
|
433
|
+
const basename = source.text.replace(/\\/g, '/').replace(/\/+$/, '').split('/').at(-1).toLowerCase();
|
|
434
|
+
if (!['.claude', '.codex', '.omp', '.privacy'].includes(basename)) continue;
|
|
435
|
+
if (source.activeExpansion) result.unknownWrites.add(basename);
|
|
436
|
+
else writeTarget(source, result, directory);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
if (name === 'sed') {
|
|
441
|
+
let inPlace = false;
|
|
442
|
+
let scriptOption = false;
|
|
443
|
+
const operands = [];
|
|
444
|
+
let options = true;
|
|
445
|
+
for (let index = start; index < words.length; index += 1) {
|
|
446
|
+
if (redirected.has(index)) continue;
|
|
447
|
+
const word = words[index];
|
|
448
|
+
if (options && word.text === '--') { options = false; continue; }
|
|
449
|
+
if (options && word.text.startsWith('-')) {
|
|
450
|
+
const option = word.text.split('=')[0];
|
|
451
|
+
if (option === '--in-place') inPlace = true;
|
|
452
|
+
if (['--expression', '--file'].includes(option)) {
|
|
453
|
+
scriptOption = true;
|
|
454
|
+
if (!word.text.includes('=')) index += 1;
|
|
455
|
+
} else if (!word.text.startsWith('--')) {
|
|
456
|
+
for (let flag = 1; flag < word.text.length; flag += 1) {
|
|
457
|
+
if (word.text[flag] === 'i') { inPlace = true; break; }
|
|
458
|
+
if (word.text[flag] === 'e' || word.text[flag] === 'f') {
|
|
459
|
+
scriptOption = true;
|
|
460
|
+
if (flag + 1 === word.text.length) index += 1;
|
|
461
|
+
break;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
} else operands.push(word);
|
|
466
|
+
}
|
|
467
|
+
if (operands[0]?.text === '') operands.shift();
|
|
468
|
+
if (inPlace) for (const word of operands.slice(scriptOption ? 0 : 1)) writeTarget(word, result, directory);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function analyzeWords(words, result, depth, directory = '.') {
|
|
473
|
+
result.directory = directory;
|
|
474
|
+
for (const index of words.redirects || []) writeTarget(words[index], result, directory);
|
|
280
475
|
for (let index = 0; index + 1 < words.length; index += 1) {
|
|
281
476
|
if (words[index].text === '<') addOperand(result, words[index + 1]);
|
|
282
477
|
}
|
|
@@ -284,11 +479,13 @@ function analyzeWords(words, result, depth) {
|
|
|
284
479
|
if (command.index < 0) return;
|
|
285
480
|
const start = command.index + 1;
|
|
286
481
|
const name = command.name;
|
|
482
|
+
commandWrites(words, start, name, result, directory);
|
|
287
483
|
if (SIMPLE_READERS.has(name)) {
|
|
288
484
|
genericOperands(words, start, result, { valueOptions: SIMPLE_OPTION_VALUES.get(name) || new Set() });
|
|
289
485
|
return;
|
|
290
486
|
}
|
|
291
487
|
if (GREP_READERS.has(name)) {
|
|
488
|
+
result.searches.push(searchDescription(words, start, name));
|
|
292
489
|
const patternOption = words.slice(start).some((word) => ['-e', '--regexp', '-f', '--file'].includes(word.text.split('=')[0]));
|
|
293
490
|
genericOperands(words, start, result, {
|
|
294
491
|
skipFirst: patternOption ? 0 : 1,
|
|
@@ -319,7 +516,7 @@ function analyzeWords(words, result, depth) {
|
|
|
319
516
|
if (SHELLS.has(name)) {
|
|
320
517
|
for (let index = start; index + 1 < words.length; index += 1) {
|
|
321
518
|
if (words[index].text === '-c' || words[index].text === '--command') {
|
|
322
|
-
merge(result, analyzeCommand(words[index + 1].text, depth + 1));
|
|
519
|
+
merge(result, analyzeCommand(words[index + 1].text, depth + 1, directory));
|
|
323
520
|
return;
|
|
324
521
|
}
|
|
325
522
|
}
|
|
@@ -327,15 +524,21 @@ function analyzeWords(words, result, depth) {
|
|
|
327
524
|
return;
|
|
328
525
|
}
|
|
329
526
|
if (name === 'eval') {
|
|
330
|
-
merge(result, analyzeCommand(words.slice(start).map((word) => word.text).join(' '), depth + 1));
|
|
527
|
+
merge(result, analyzeCommand(words.slice(start).map((word) => word.text).join(' '), depth + 1, directory));
|
|
331
528
|
return;
|
|
332
529
|
}
|
|
333
530
|
if (name === 'find') {
|
|
531
|
+
// A name test that selects a secret, handed to a reader through -exec, reads it.
|
|
532
|
+
const names = [];
|
|
533
|
+
for (let index = start; index + 1 < words.length; index += 1) {
|
|
534
|
+
if (['-name', '-iname', '-path', '-ipath', '-wholename', '-iwholename'].includes(words[index].text)) names.push(words[index + 1].text);
|
|
535
|
+
}
|
|
334
536
|
for (let index = start; index < words.length; index += 1) {
|
|
335
537
|
if (words[index].text !== '-exec' && words[index].text !== '-execdir') continue;
|
|
336
538
|
const nested = [];
|
|
337
539
|
for (index += 1; index < words.length && ![';', '+'].includes(words[index].text); index += 1) nested.push(words[index]);
|
|
338
|
-
|
|
540
|
+
if (READS.has(unwrap(nested).name)) result.pipeNames.push(...names);
|
|
541
|
+
analyzeWords(nested, result, depth + 1, directory);
|
|
339
542
|
}
|
|
340
543
|
return;
|
|
341
544
|
}
|
|
@@ -351,7 +554,7 @@ function analyzeWords(words, result, depth) {
|
|
|
351
554
|
index = skipOption(words, index, XARGS_OPTION_VALUE);
|
|
352
555
|
nestedStart = index;
|
|
353
556
|
}
|
|
354
|
-
if (nestedStart < words.length) analyzeWords(words.slice(nestedStart), result, depth + 1);
|
|
557
|
+
if (nestedStart < words.length) analyzeWords(words.slice(nestedStart), result, depth + 1, directory);
|
|
355
558
|
return;
|
|
356
559
|
}
|
|
357
560
|
if (INLINE_RUNTIMES.has(name)) {
|
|
@@ -369,13 +572,45 @@ function analyzeWords(words, result, depth) {
|
|
|
369
572
|
if (name === 'openssl') genericOperands(words, start, result, { pathOptions: new Set(['-in', '-key', '-cert', '-config']) });
|
|
370
573
|
}
|
|
371
574
|
|
|
372
|
-
function analyzeCommand(command, depth = 0) {
|
|
373
|
-
const result = { paths: new Set(), dynamic: false, malformed: false
|
|
575
|
+
function analyzeCommand(command, depth = 0, directory = '.') {
|
|
576
|
+
const result = { paths: new Set(), dynamic: false, malformed: false, searches: [], writes: new Set(), unknownWrites: new Set(),
|
|
577
|
+
expansions: [], pipeNames: [], decodedRead: false };
|
|
374
578
|
if (depth > MAX_DEPTH) { result.dynamic = true; return result; }
|
|
375
579
|
const parsed = scanShell(command);
|
|
376
580
|
result.malformed = parsed.malformed;
|
|
377
|
-
|
|
378
|
-
|
|
581
|
+
let currentDirectory = directory;
|
|
582
|
+
const directories = [];
|
|
583
|
+
const assignments = [];
|
|
584
|
+
const visibleAt = [];
|
|
585
|
+
// Every operand found while analysing a segment -- its own, or one from a nested eval, `bash -c` or $(...) -- sees the
|
|
586
|
+
// assignments of the earlier segments. `f=.env cat $f` does not: the shell expands $f before that prefix applies.
|
|
587
|
+
const scope = (from, segmentIndex) => {
|
|
588
|
+
for (const expansion of result.expansions.slice(from)) {
|
|
589
|
+
expansion.scopes = [{ list: assignments, count: visibleAt[segmentIndex] }, ...(expansion.scopes || [])];
|
|
590
|
+
}
|
|
591
|
+
};
|
|
592
|
+
for (const [segmentIndex, words] of parsed.segments.entries()) {
|
|
593
|
+
directories.push(currentDirectory);
|
|
594
|
+
visibleAt.push(assignments.length);
|
|
595
|
+
recordAssignments(words, assignments);
|
|
596
|
+
const from = result.expansions.length;
|
|
597
|
+
analyzeWords(words, result, depth, currentDirectory);
|
|
598
|
+
scope(from, segmentIndex);
|
|
599
|
+
const info = unwrap(words);
|
|
600
|
+
if (['cd', 'pushd'].includes(info.name)) {
|
|
601
|
+
const target = words.slice(info.index + 1).find(word => word.text && !word.text.startsWith('-'));
|
|
602
|
+
if (!target || target.activeExpansion) currentDirectory = null;
|
|
603
|
+
else if (target.text.startsWith('/')) currentDirectory = target.text;
|
|
604
|
+
else if (currentDirectory !== null) currentDirectory = `${currentDirectory}/${target.text}`;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
analyzePipelines(parsed.segments, result);
|
|
608
|
+
for (const nested of parsed.nested) {
|
|
609
|
+
const nestedDirectory = directories[nested.segment] === undefined ? directory : directories[nested.segment];
|
|
610
|
+
const from = result.expansions.length;
|
|
611
|
+
merge(result, analyzeCommand(nested.command, depth + 1, nestedDirectory));
|
|
612
|
+
if (visibleAt[nested.segment] !== undefined) scope(from, nested.segment);
|
|
613
|
+
}
|
|
379
614
|
if (result.malformed && parsed.segments.some((words) => {
|
|
380
615
|
const commandInfo = unwrap(words);
|
|
381
616
|
return SIMPLE_READERS.has(commandInfo.name) || GREP_READERS.has(commandInfo.name)
|
|
@@ -385,9 +620,67 @@ function analyzeCommand(command, depth = 0) {
|
|
|
385
620
|
return result;
|
|
386
621
|
}
|
|
387
622
|
|
|
623
|
+
const ASSIGNING = new Set(['export', 'declare', 'typeset', 'local', 'readonly']);
|
|
624
|
+
|
|
625
|
+
// A segment that only assigns (`f=.env`), or an export/declare/local/typeset/readonly. The value keeps its expansions
|
|
626
|
+
// (`f="$PWD/.env"`, `G=$F`); the resolver substitutes them in order.
|
|
627
|
+
function recordAssignments(words, out) {
|
|
628
|
+
const bare = words.every(assignment);
|
|
629
|
+
const builtin = ASSIGNING.has(commandName(words[0]));
|
|
630
|
+
if (!bare && !builtin) return;
|
|
631
|
+
for (const word of words.slice(bare ? 0 : 1)) {
|
|
632
|
+
if (!assignment(word)) continue;
|
|
633
|
+
const at = word.text.indexOf('=');
|
|
634
|
+
out.push({ name: word.text.slice(0, at), value: word.text.slice(at + 1) });
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const DECODERS = (words) => {
|
|
639
|
+
const name = commandName(words[unwrap(words).index]);
|
|
640
|
+
const flags = words.map((word) => word.text);
|
|
641
|
+
if (name === 'base64' || name === 'basenc') return flags.some((flag) => ['-d', '-D', '--decode'].includes(flag));
|
|
642
|
+
if (name === 'openssl') return flags.some((flag) => ['base64', 'enc'].includes(flag)) && flags.includes('-d');
|
|
643
|
+
if (name === 'xxd') return flags.includes('-r') || flags.includes('-revert');
|
|
644
|
+
if (name === 'gpg') return flags.some((flag) => ['-d', '--decrypt'].includes(flag));
|
|
645
|
+
return ['uudecode', 'b64decode'].includes(name);
|
|
646
|
+
};
|
|
647
|
+
const READS = new Set([...SIMPLE_READERS, ...GREP_READERS, ...AWK_READERS, 'sed', 'source', '.']);
|
|
648
|
+
|
|
649
|
+
// A pipeline whose decoder feeds a shell, eval or xargs runs what it decoded; xargs handing a reader the literal words of
|
|
650
|
+
// an earlier stage (`printf .env | xargs cat`, `find . -name .env | xargs cat`) reads those words. One pass: each stage
|
|
651
|
+
// is looked at once, and each earlier word is handed over at most once.
|
|
652
|
+
function analyzePipelines(segments, result) {
|
|
653
|
+
let decoded = false;
|
|
654
|
+
let first = true;
|
|
655
|
+
let words = [];
|
|
656
|
+
let handed = 0;
|
|
657
|
+
for (const stage of segments) {
|
|
658
|
+
const info = unwrap(stage);
|
|
659
|
+
if (info.index >= 0 && !first) {
|
|
660
|
+
if (decoded && (SHELLS.has(info.name) || ['eval', 'xargs', 'source', '.'].includes(info.name))) result.decodedRead = true;
|
|
661
|
+
if (info.name === 'xargs') {
|
|
662
|
+
let index = info.index + 1;
|
|
663
|
+
while (index < stage.length && stage[index].text.startsWith('-')) index = skipOption(stage, index, XARGS_OPTION_VALUE);
|
|
664
|
+
const reader = unwrap(stage.slice(index)).name;
|
|
665
|
+
if (READS.has(reader) || SHELLS.has(reader)) {
|
|
666
|
+
result.pipeNames.push(...words.slice(handed));
|
|
667
|
+
handed = words.length;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
if (DECODERS(stage)) decoded = true;
|
|
672
|
+
for (const word of stage.slice(info.index + 1)) if (word.text && !word.text.startsWith('-')) words.push(word.text);
|
|
673
|
+
first = stage.link !== 'pipe';
|
|
674
|
+
if (first) { decoded = false; words = []; handed = 0; }
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
388
678
|
function commandAccess(command) {
|
|
389
679
|
const result = analyzeCommand(command);
|
|
390
|
-
return { paths: [...result.paths], dynamic: result.dynamic, malformed: result.malformed
|
|
680
|
+
return { paths: [...result.paths], dynamic: result.dynamic, malformed: result.malformed, searches: result.searches,
|
|
681
|
+
writes: [...result.writes], unknownWrites: [...result.unknownWrites],
|
|
682
|
+
expansions: result.expansions.map(({ text, directory, literals, scopes }) => ({ text, directory, literals, scopes: scopes || [] })),
|
|
683
|
+
pipeNames: [...new Set(result.pipeNames)], decodedRead: result.decodedRead };
|
|
391
684
|
}
|
|
392
685
|
|
|
393
686
|
module.exports = { DYNAMIC_READ_PATH, commandAccess };
|