@haposoft/cafekit 0.16.0-rc.3 → 0.16.0-rc.5

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 (98) hide show
  1. package/bin/__tests__/change-firewall.semantic-firewall.test.js +2134 -0
  2. package/bin/__tests__/codex-hooks.test.js +531 -29
  3. package/bin/__tests__/codex-native.test.js +228 -7
  4. package/bin/__tests__/develop-contract.test.js +653 -185
  5. package/bin/__tests__/finalizer-pass-gate.semantic-firewall.test.js +342 -0
  6. package/bin/__tests__/installer-safety.test.js +39 -0
  7. package/bin/__tests__/opencode-spec-gate.test.js +24 -16
  8. package/bin/__tests__/package-inventory.test.js +705 -13
  9. package/bin/__tests__/release-preflight.semantic-firewall.test.js +578 -0
  10. package/bin/__tests__/review-receipt-c2.semantic-firewall.test.js +378 -0
  11. package/bin/__tests__/specs-v2-execution-closeout.test.js +468 -0
  12. package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +665 -0
  13. package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +1132 -0
  14. package/bin/__tests__/test-runtime-dependency-closure.cjs +182 -0
  15. package/bin/__tests__/validator-grounding.test.js +2013 -221
  16. package/bin/lib/managed-writer.js +12 -3
  17. package/bin/phases/codex-runtime.js +17 -1
  18. package/package.json +5 -2
  19. package/src/claude/CLAUDE.md +6 -0
  20. package/src/claude/agents/code-auditor.md +20 -0
  21. package/src/claude/agents/spec-maker.md +143 -99
  22. package/src/claude/hooks/__tests__/completion-authority.test.js +321 -58
  23. package/src/claude/hooks/__tests__/privacy-block.test.js +254 -0
  24. package/src/claude/hooks/__tests__/semantic-review-authority.test.js +257 -0
  25. package/src/claude/hooks/__tests__/spec-gate.test.js +115 -40
  26. package/src/claude/hooks/completion-authority-check.cjs +37 -288
  27. package/src/claude/hooks/completion-authority-state.cjs +10 -8
  28. package/src/claude/hooks/completion-authority.cjs +13 -7
  29. package/src/claude/hooks/docs-sync.cjs +5 -2
  30. package/src/claude/hooks/lib/privacy-command-analysis.cjs +393 -0
  31. package/src/claude/hooks/lib/runtime-path-safety.cjs +119 -0
  32. package/src/claude/hooks/privacy-block.cjs +99 -28
  33. package/src/claude/hooks/semantic-review-authority.cjs +152 -0
  34. package/src/claude/hooks/spec-gate.cjs +47 -117
  35. package/src/claude/hooks/spec-state.cjs +8 -2
  36. package/src/claude/migration-manifest.json +9 -1
  37. package/src/claude/rules/state-sync.md +52 -21
  38. package/src/claude/rules/workflow.md +17 -9
  39. package/src/claude/scripts/change-firewall.cjs +1748 -0
  40. package/src/claude/scripts/spec-authoring-digest.cjs +151 -0
  41. package/src/claude/scripts/spec-authoring-validation.cjs +126 -0
  42. package/src/claude/scripts/spec-final-state.cjs +290 -0
  43. package/src/claude/scripts/spec-ground.cjs +571 -173
  44. package/src/claude/scripts/spec-readiness.cjs +425 -0
  45. package/src/claude/scripts/spec-receipt.cjs +162 -0
  46. package/src/claude/scripts/spec-resolver.cjs +67 -16
  47. package/src/claude/scripts/spec-scaffold.cjs +1594 -139
  48. package/src/claude/scripts/spec-semantic-model.cjs +396 -0
  49. package/src/claude/scripts/validate-spec-output.cjs +3110 -218
  50. package/src/claude/scripts/workflow-policy.cjs +501 -183
  51. package/src/claude/settings/settings.json +4 -0
  52. package/src/claude/skills/code-review/SKILL.md +12 -3
  53. package/src/claude/skills/code-review/references/spec-compliance-review.md +10 -2
  54. package/src/claude/skills/code-review/references/verification-gate.md +13 -0
  55. package/src/claude/skills/develop/SKILL.md +47 -60
  56. package/src/claude/skills/develop/references/parallel-waves.md +9 -6
  57. package/src/claude/skills/develop/references/quality-gate.md +19 -12
  58. package/src/claude/skills/specs/SKILL.md +183 -177
  59. package/src/claude/skills/specs/references/ask-user-question-gates.md +27 -18
  60. package/src/claude/skills/specs/references/cross-spec-dependency.md +8 -4
  61. package/src/claude/skills/specs/references/research-strategy.md +4 -1
  62. package/src/claude/skills/specs/references/review.md +214 -262
  63. package/src/claude/skills/specs/references/scope-inquiry.md +18 -5
  64. package/src/claude/skills/specs/rules/design-discovery-full.md +33 -81
  65. package/src/claude/skills/specs/rules/design-discovery-light.md +21 -42
  66. package/src/claude/skills/specs/rules/design-principles.md +136 -180
  67. package/src/claude/skills/specs/rules/design-review.md +42 -97
  68. package/src/claude/skills/specs/rules/ears-format.md +42 -50
  69. package/src/claude/skills/specs/rules/phase-decision-matrix.md +42 -41
  70. package/src/claude/skills/specs/rules/task-scoring-rubric.md +2 -1
  71. package/src/claude/skills/specs/rules/tasks-generation.md +189 -300
  72. package/src/claude/skills/specs/templates/design.md +49 -263
  73. package/src/claude/skills/specs/templates/requirements.md +26 -36
  74. package/src/claude/skills/specs/templates/research.md +9 -98
  75. package/src/claude/skills/specs/templates/spec-state.json +35 -85
  76. package/src/claude/skills/specs/templates/task.md +30 -102
  77. package/src/claude/skills/sync/SKILL.md +33 -10
  78. package/src/claude/skills/sync/references/sync-protocols.md +31 -13
  79. package/src/claude/skills/test/SKILL.md +17 -7
  80. package/src/codex/AGENTS.md +6 -0
  81. package/src/codex/hooks/completion-authority-check.cjs +43 -240
  82. package/src/codex/hooks/completion-authority-state.cjs +10 -8
  83. package/src/codex/hooks/completion-authority.cjs +13 -7
  84. package/src/codex/hooks/lib/hook-context.cjs +16 -2
  85. package/src/codex/hooks/lib/privacy-command-analysis.cjs +3 -0
  86. package/src/codex/hooks/lib/privacy-state.cjs +61 -38
  87. package/src/codex/hooks/lib/runtime-path-safety.cjs +3 -0
  88. package/src/codex/hooks/lib/spec-receipt.cjs +72 -134
  89. package/src/codex/hooks/lib/spec-utils.cjs +56 -5
  90. package/src/codex/hooks/privacy-approval.cjs +6 -2
  91. package/src/codex/hooks/privacy-block.cjs +94 -15
  92. package/src/codex/hooks/semantic-review-authority.cjs +130 -0
  93. package/src/codex/hooks/spec-gate.cjs +49 -34
  94. package/src/codex/hooks/spec-state.cjs +10 -3
  95. package/src/codex/hooks/state.cjs +2 -0
  96. package/src/codex/hooks.json +6 -1
  97. package/src/codex/rules/state-sync.md +47 -27
  98. package/src/common/AGENTS.md +1 -1
@@ -0,0 +1,393 @@
1
+ 'use strict';
2
+
3
+ const DYNAMIC_READ_PATH = '<dynamic-read-path>';
4
+ const MAX_DEPTH = 8;
5
+
6
+ const SIMPLE_READERS = new Set([
7
+ 'cat', 'less', 'more', 'head', 'tail', 'nl', 'strings', 'tac', 'bat',
8
+ 'od', 'hexdump', 'xxd', 'base64', 'wc', 'cut', 'sort', 'uniq', 'tr',
9
+ 'fold', 'fmt', 'diff', 'cmp', 'comm', 'file', 'stat', 'du', 'md5',
10
+ 'md5sum', 'shasum', 'sha1sum', 'sha256sum', 'open',
11
+ ]);
12
+ const GREP_READERS = new Set(['grep', 'egrep', 'fgrep', 'rg', 'ag']);
13
+ const AWK_READERS = new Set(['awk', 'gawk', 'mawk']);
14
+ const SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'fish']);
15
+ const INLINE_RUNTIMES = new Set(['python', 'python3', 'node', 'perl', 'ruby', 'php']);
16
+ const WRAPPERS = new Set(['command', 'builtin', 'exec', 'env', 'nice', 'nohup', 'sudo', 'doas', 'timeout']);
17
+ const WRAPPER_OPTION_VALUE = new Set([
18
+ '-n', '--lines', '-c', '--bytes', '-m', '--max-count', '-A', '-B', '-C',
19
+ '--after-context', '--before-context', '--context', '-d', '--delimiter',
20
+ '-f', '--fields', '-k', '--key', '-t', '--field-separator', '-e',
21
+ '--regexp', '-I', '--include', '--exclude', '--exclude-dir', '-s',
22
+ '--signal', '--kill-after', '-u', '--user', '-g', '--group', '-C',
23
+ '--chdir', '-p', '--priority', '--adjustment',
24
+ ]);
25
+ const SIMPLE_OPTION_VALUES = new Map([
26
+ ['head', new Set(['-n', '--lines', '-c', '--bytes'])],
27
+ ['tail', new Set(['-n', '--lines', '-c', '--bytes', '-s', '--sleep-interval'])],
28
+ ['cut', new Set(['-b', '--bytes', '-c', '--characters', '-d', '--delimiter', '-f', '--fields'])],
29
+ ['sort', new Set(['-k', '--key', '-t', '--field-separator', '-S', '--buffer-size', '-T', '--temporary-directory'])],
30
+ ['tr', new Set()],
31
+ ['fold', new Set(['-w', '--width'])],
32
+ ]);
33
+ const GREP_OPTION_VALUE = new Set(['-e', '--regexp', '-f', '--file', '-m', '--max-count', '-A', '-B', '-C', '--after-context', '--before-context', '--context']);
34
+ const SED_OPTION_VALUE = new Set(['-e', '--expression', '-f', '--file']);
35
+ const AWK_OPTION_VALUE = new Set(['-f', '--file', '-v', '--assign', '-F', '--field-separator']);
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
+
38
+ function expansionAt(source, index) {
39
+ const next = source[index + 1];
40
+ return Boolean(next && (next === '{' || next === '('
41
+ || '*@#$?!-0123456789'.includes(next) || /[A-Za-z_]/.test(next)));
42
+ }
43
+
44
+ function matchingParen(source, start) {
45
+ let depth = 1;
46
+ let quote = null;
47
+ let escaped = false;
48
+ for (let index = start + 1; index < source.length; index += 1) {
49
+ const ch = source[index];
50
+ if (escaped) { escaped = false; continue; }
51
+ if (quote === "'") { if (ch === "'") quote = null; continue; }
52
+ if (quote === '"') {
53
+ if (ch === '\\') { escaped = true; continue; }
54
+ if (ch === '"') quote = null;
55
+ continue;
56
+ }
57
+ if (ch === '\\') { escaped = true; continue; }
58
+ if (ch === "'" || ch === '"') { quote = ch; continue; }
59
+ if (ch === '(') depth += 1;
60
+ if (ch === ')' && --depth === 0) return index;
61
+ }
62
+ return -1;
63
+ }
64
+
65
+ function matchingBacktick(source, start) {
66
+ let escaped = false;
67
+ for (let index = start + 1; index < source.length; index += 1) {
68
+ const ch = source[index];
69
+ if (escaped) { escaped = false; continue; }
70
+ if (ch === '\\') { escaped = true; continue; }
71
+ if (ch === '\x60') return index;
72
+ }
73
+ return -1;
74
+ }
75
+
76
+ function decodeAnsiQuote(source, start) {
77
+ let value = '';
78
+ for (let index = start + 2; index < source.length; index += 1) {
79
+ const ch = source[index];
80
+ if (ch === "'") return { value, end: index };
81
+ if (ch !== '\\') { value += ch; continue; }
82
+ const next = source[index + 1];
83
+ if (!next) return null;
84
+ if (next === 'x') {
85
+ const match = source.slice(index + 2).match(/^[0-9a-fA-F]{1,2}/);
86
+ if (match) { value += String.fromCodePoint(parseInt(match[0], 16)); index += 1 + match[0].length; continue; }
87
+ }
88
+ if (next === 'u' || next === 'U') {
89
+ const width = next === 'u' ? 4 : 8;
90
+ const digits = source.slice(index + 2, index + 2 + width);
91
+ if (new RegExp(`^[0-9a-fA-F]{${width}}$`).test(digits)) {
92
+ value += String.fromCodePoint(parseInt(digits, 16));
93
+ index += 1 + width;
94
+ continue;
95
+ }
96
+ }
97
+ const octal = source.slice(index + 1).match(/^[0-7]{1,3}/);
98
+ if (octal) { value += String.fromCodePoint(parseInt(octal[0], 8)); index += octal[0].length; continue; }
99
+ const escapes = { a: '\x07', b: '\b', e: '\x1b', E: '\x1b', f: '\f', n: '\n', r: '\r', t: '\t', v: '\v', '\\': '\\', "'": "'", '"': '"' };
100
+ value += Object.prototype.hasOwnProperty.call(escapes, next) ? escapes[next] : next;
101
+ index += 1;
102
+ }
103
+ return null;
104
+ }
105
+
106
+ function scanShell(command) {
107
+ const source = String(command).replace(/\\\r?\n/g, '');
108
+ const segments = [];
109
+ const nested = [];
110
+ let words = [];
111
+ let word = { text: '', activeExpansion: false, started: false };
112
+ let quote = null;
113
+ let escaped = false;
114
+ let malformed = false;
115
+ const append = (value) => { word.text += value; word.started = true; };
116
+ const flushWord = () => {
117
+ if (word.started || word.text) words.push(word);
118
+ word = { text: '', activeExpansion: false, started: false };
119
+ };
120
+ const flushSegment = () => { flushWord(); if (words.length) segments.push(words); words = []; };
121
+
122
+ for (let index = 0; index < source.length; index += 1) {
123
+ const ch = source[index];
124
+ if (quote === "'") { if (ch === "'") quote = null; else append(ch); continue; }
125
+ if (escaped) { append(ch); escaped = false; continue; }
126
+ if (ch === '\\') { escaped = true; word.started = true; continue; }
127
+ if (quote === '"') {
128
+ if (ch === '"') { quote = null; continue; }
129
+ if (ch === '$' && expansionAt(source, index)) {
130
+ word.activeExpansion = true;
131
+ append('$');
132
+ if (source[index + 1] === '(') {
133
+ const end = matchingParen(source, index + 1);
134
+ if (end < 0) { malformed = true; continue; }
135
+ nested.push(source.slice(index + 2, end));
136
+ append('()');
137
+ index = end;
138
+ }
139
+ continue;
140
+ }
141
+ if (ch === '\x60') {
142
+ word.activeExpansion = true;
143
+ const end = matchingBacktick(source, index);
144
+ if (end < 0) { malformed = true; continue; }
145
+ nested.push(source.slice(index + 1, end));
146
+ append('\x60\x60');
147
+ index = end;
148
+ continue;
149
+ }
150
+ append(ch);
151
+ continue;
152
+ }
153
+ if (ch === '$' && source[index + 1] === "'") {
154
+ const decoded = decodeAnsiQuote(source, index);
155
+ if (!decoded) { malformed = true; append(ch); continue; }
156
+ append(decoded.value);
157
+ index = decoded.end;
158
+ continue;
159
+ }
160
+ if (ch === '"' || ch === "'") { quote = ch; word.started = true; continue; }
161
+ if (ch === '$' && expansionAt(source, index)) {
162
+ word.activeExpansion = true;
163
+ append('$');
164
+ if (source[index + 1] === '(') {
165
+ const end = matchingParen(source, index + 1);
166
+ if (end < 0) { malformed = true; continue; }
167
+ nested.push(source.slice(index + 2, end));
168
+ append('()');
169
+ index = end;
170
+ }
171
+ continue;
172
+ }
173
+ if (ch === '\x60') {
174
+ word.activeExpansion = true;
175
+ const end = matchingBacktick(source, index);
176
+ if (end < 0) { malformed = true; continue; }
177
+ nested.push(source.slice(index + 1, end));
178
+ append('\x60\x60');
179
+ index = end;
180
+ continue;
181
+ }
182
+ if ((ch === '<' || ch === '>') && source[index + 1] === '(') {
183
+ word.activeExpansion = true;
184
+ const end = matchingParen(source, index + 1);
185
+ if (end < 0) { malformed = true; continue; }
186
+ nested.push(source.slice(index + 2, end));
187
+ append(`${ch}()`);
188
+ index = end;
189
+ continue;
190
+ }
191
+ if ((ch === '~' && !word.text) || '*?{['.includes(ch)) { word.activeExpansion = true; append(ch); continue; }
192
+ if (';|&\n()'.includes(ch)) { flushSegment(); continue; }
193
+ if (ch === '<') { flushWord(); words.push({ text: '<', activeExpansion: false, started: true }); continue; }
194
+ if (ch === '>') { flushWord(); continue; }
195
+ if (' \t\r'.includes(ch)) { flushWord(); continue; }
196
+ append(ch);
197
+ }
198
+ if (quote || escaped) malformed = true;
199
+ flushSegment();
200
+ return { segments, nested, malformed };
201
+ }
202
+
203
+ function commandName(word) {
204
+ return String(word?.text || '').replace(/^[([{]+|[)},;]+$/g, '').split(/[\\/]/).pop().toLowerCase();
205
+ }
206
+
207
+ function assignment(word) {
208
+ return /^[A-Za-z_][A-Za-z0-9_]*=/.test(String(word?.text || ''));
209
+ }
210
+
211
+ function skipOption(words, index, valueOptions = WRAPPER_OPTION_VALUE) {
212
+ const value = words[index]?.text || '';
213
+ if (value === '--') return index + 1;
214
+ if (!value.startsWith('-') || value === '-') return index;
215
+ if (value.includes('=') || /^-[A-Za-z]\S+/.test(value)) return index + 1;
216
+ return valueOptions.has(value) ? index + 2 : index + 1;
217
+ }
218
+
219
+ function unwrap(words) {
220
+ let index = 0;
221
+ while (index < words.length && assignment(words[index])) index += 1;
222
+ for (let depth = 0; depth < 12 && index < words.length; depth += 1) {
223
+ const name = commandName(words[index]);
224
+ if (!WRAPPERS.has(name)) return { index, name };
225
+ index += 1;
226
+ if (name === 'command' && ['-v', '-V'].includes(words[index]?.text)) return { index: -1, name: '' };
227
+ if (name === 'timeout') {
228
+ while (index < words.length && words[index].text.startsWith('-')) index = skipOption(words, index);
229
+ index += 1;
230
+ } else if (name === 'env') {
231
+ while (index < words.length) {
232
+ if (assignment(words[index])) { index += 1; continue; }
233
+ if (!words[index].text.startsWith('-') || words[index].text === '-') break;
234
+ index = skipOption(words, index);
235
+ }
236
+ } else {
237
+ while (index < words.length && words[index].text.startsWith('-')) index = skipOption(words, index);
238
+ }
239
+ while (index < words.length && assignment(words[index])) index += 1;
240
+ }
241
+ return index < words.length ? { index, name: commandName(words[index]) } : { index: -1, name: '' };
242
+ }
243
+
244
+ function addOperand(result, word) {
245
+ if (!word) return;
246
+ if (word.activeExpansion) result.dynamic = true;
247
+ else if (word.text && word.text !== '-') result.paths.add(word.text);
248
+ }
249
+
250
+ function genericOperands(words, start, result, {
251
+ skipFirst = 0,
252
+ pathOptions = new Set(),
253
+ valueOptions = new Set(),
254
+ } = {}) {
255
+ let positional = 0;
256
+ for (let index = start; index < words.length;) {
257
+ const word = words[index];
258
+ if (word.text === '--') { index += 1; while (index < words.length) addOperand(result, words[index++]); break; }
259
+ if (word.text.startsWith('-') && word.text !== '-') {
260
+ const option = word.text.split('=')[0];
261
+ if (pathOptions.has(option)) {
262
+ if (word.text.includes('=')) addOperand(result, { text: word.text.slice(word.text.indexOf('=') + 1), activeExpansion: word.activeExpansion });
263
+ else addOperand(result, words[index + 1]);
264
+ }
265
+ index = skipOption(words, index, valueOptions);
266
+ continue;
267
+ }
268
+ if (positional++ >= skipFirst) addOperand(result, word);
269
+ index += 1;
270
+ }
271
+ }
272
+
273
+ function merge(target, source) {
274
+ source.paths.forEach((value) => target.paths.add(value));
275
+ target.dynamic ||= source.dynamic;
276
+ target.malformed ||= source.malformed;
277
+ }
278
+
279
+ function analyzeWords(words, result, depth) {
280
+ for (let index = 0; index + 1 < words.length; index += 1) {
281
+ if (words[index].text === '<') addOperand(result, words[index + 1]);
282
+ }
283
+ const command = unwrap(words);
284
+ if (command.index < 0) return;
285
+ const start = command.index + 1;
286
+ const name = command.name;
287
+ if (SIMPLE_READERS.has(name)) {
288
+ genericOperands(words, start, result, { valueOptions: SIMPLE_OPTION_VALUES.get(name) || new Set() });
289
+ return;
290
+ }
291
+ if (GREP_READERS.has(name)) {
292
+ const patternOption = words.slice(start).some((word) => ['-e', '--regexp', '-f', '--file'].includes(word.text.split('=')[0]));
293
+ genericOperands(words, start, result, {
294
+ skipFirst: patternOption ? 0 : 1,
295
+ pathOptions: new Set(['-f', '--file']),
296
+ valueOptions: GREP_OPTION_VALUE,
297
+ });
298
+ return;
299
+ }
300
+ if (name === 'sed') {
301
+ const scriptOption = words.slice(start).some((word) => ['-e', '--expression', '-f', '--file'].includes(word.text.split('=')[0]));
302
+ genericOperands(words, start, result, {
303
+ skipFirst: scriptOption ? 0 : 1,
304
+ pathOptions: new Set(['-f', '--file']),
305
+ valueOptions: SED_OPTION_VALUE,
306
+ });
307
+ return;
308
+ }
309
+ if (AWK_READERS.has(name)) {
310
+ const programFile = words.slice(start).some((word) => ['-f', '--file'].includes(word.text.split('=')[0]));
311
+ genericOperands(words, start, result, {
312
+ skipFirst: programFile ? 0 : 1,
313
+ pathOptions: new Set(['-f', '--file']),
314
+ valueOptions: AWK_OPTION_VALUE,
315
+ });
316
+ return;
317
+ }
318
+ if (name === 'source' || name === '.') { genericOperands(words, start, result); return; }
319
+ if (SHELLS.has(name)) {
320
+ for (let index = start; index + 1 < words.length; index += 1) {
321
+ if (words[index].text === '-c' || words[index].text === '--command') {
322
+ merge(result, analyzeCommand(words[index + 1].text, depth + 1));
323
+ return;
324
+ }
325
+ }
326
+ genericOperands(words, start, result);
327
+ return;
328
+ }
329
+ if (name === 'eval') {
330
+ merge(result, analyzeCommand(words.slice(start).map((word) => word.text).join(' '), depth + 1));
331
+ return;
332
+ }
333
+ if (name === 'find') {
334
+ for (let index = start; index < words.length; index += 1) {
335
+ if (words[index].text !== '-exec' && words[index].text !== '-execdir') continue;
336
+ const nested = [];
337
+ for (index += 1; index < words.length && ![';', '+'].includes(words[index].text); index += 1) nested.push(words[index]);
338
+ analyzeWords(nested, result, depth + 1);
339
+ }
340
+ return;
341
+ }
342
+ if (name === 'xargs') {
343
+ let nestedStart = start;
344
+ for (let index = start; index < words.length;) {
345
+ const option = words[index].text.split('=')[0];
346
+ if (option === '-a' || option === '--arg-file') {
347
+ if (words[index].text.includes('=')) addOperand(result, { text: words[index].text.slice(words[index].text.indexOf('=') + 1), activeExpansion: words[index].activeExpansion });
348
+ else addOperand(result, words[index + 1]);
349
+ }
350
+ if (!words[index].text.startsWith('-')) { nestedStart = index; break; }
351
+ index = skipOption(words, index, XARGS_OPTION_VALUE);
352
+ nestedStart = index;
353
+ }
354
+ if (nestedStart < words.length) analyzeWords(words.slice(nestedStart), result, depth + 1);
355
+ return;
356
+ }
357
+ if (INLINE_RUNTIMES.has(name)) {
358
+ for (let index = start; index + 1 < words.length; index += 1) {
359
+ if (!['-c', '-e', '--eval'].includes(words[index].text)) continue;
360
+ const code = words[index + 1].text;
361
+ const reads = /\b(?:open|readFile(?:Sync)?|read_text|read_bytes|File\.read|IO\.read|file_get_contents)\s*\(/.test(code);
362
+ if (!reads) return;
363
+ for (const match of code.matchAll(/(['"])([^'"\r\n]+)\1/g)) result.paths.add(match[2]);
364
+ if (/\b(?:process\.env|process\.argv|os\.environ|sys\.argv|getenv|ENV\s*\[|ARGV\b)/.test(code)) result.dynamic = true;
365
+ return;
366
+ }
367
+ return;
368
+ }
369
+ if (name === 'openssl') genericOperands(words, start, result, { pathOptions: new Set(['-in', '-key', '-cert', '-config']) });
370
+ }
371
+
372
+ function analyzeCommand(command, depth = 0) {
373
+ const result = { paths: new Set(), dynamic: false, malformed: false };
374
+ if (depth > MAX_DEPTH) { result.dynamic = true; return result; }
375
+ const parsed = scanShell(command);
376
+ 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));
379
+ if (result.malformed && parsed.segments.some((words) => {
380
+ const commandInfo = unwrap(words);
381
+ return SIMPLE_READERS.has(commandInfo.name) || GREP_READERS.has(commandInfo.name)
382
+ || AWK_READERS.has(commandInfo.name) || SHELLS.has(commandInfo.name)
383
+ || INLINE_RUNTIMES.has(commandInfo.name) || ['sed', 'source', '.', 'eval', 'xargs'].includes(commandInfo.name);
384
+ })) result.dynamic = true;
385
+ return result;
386
+ }
387
+
388
+ function commandAccess(command) {
389
+ const result = analyzeCommand(command);
390
+ return { paths: [...result.paths], dynamic: result.dynamic, malformed: result.malformed };
391
+ }
392
+
393
+ module.exports = { DYNAMIC_READ_PATH, commandAccess };
@@ -0,0 +1,119 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const lstatSync = fs.lstatSync.bind(fs);
6
+ const readFileSync = fs.readFileSync.bind(fs);
7
+ const realpathSync = fs.realpathSync.bind(fs);
8
+
9
+ function inside(root, target) {
10
+ const relative = path.relative(path.resolve(root), path.resolve(target));
11
+ return relative === '' || (relative !== '..'
12
+ && !relative.startsWith(`..${path.sep}`)
13
+ && !path.isAbsolute(relative));
14
+ }
15
+
16
+ function canonicalDirectory(directory, label = 'runtime root') {
17
+ const requested = path.resolve(directory);
18
+ let stat;
19
+ try { stat = lstatSync(requested); }
20
+ catch (error) { throw new Error(`${label} cannot be inspected (${error.code || error.message})`); }
21
+ if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`${label} must be a non-symlink directory`);
22
+ return realpathSync(requested);
23
+ }
24
+
25
+ function assertComponentPath(root, target, { allowMissing = false } = {}) {
26
+ const requestedRoot = path.resolve(root);
27
+ const requestedTarget = path.resolve(target);
28
+ if (!inside(requestedRoot, requestedTarget)) throw new Error(`path escapes trusted root: ${requestedTarget}`);
29
+ let cursor = requestedRoot;
30
+ for (const part of path.relative(requestedRoot, requestedTarget).split(path.sep).filter(Boolean)) {
31
+ cursor = path.join(cursor, part);
32
+ try {
33
+ if (lstatSync(cursor).isSymbolicLink()) throw new Error(`symlink component rejected: ${cursor}`);
34
+ } catch (error) {
35
+ if (allowMissing && error.code === 'ENOENT') return null;
36
+ throw error;
37
+ }
38
+ }
39
+ return requestedTarget;
40
+ }
41
+
42
+ function canonicalRegularFile(root, target, label = 'runtime file') {
43
+ const canonicalRoot = canonicalDirectory(root);
44
+ const requested = assertComponentPath(root, target);
45
+ const stat = lstatSync(requested);
46
+ if (stat.isSymbolicLink() || !stat.isFile()) throw new Error(`${label} must be a non-symlink regular file`);
47
+ const canonical = realpathSync(requested);
48
+ if (!inside(canonicalRoot, canonical)) throw new Error(`${label} escapes the trusted root`);
49
+ return canonical;
50
+ }
51
+
52
+ function resolveLocalDependency(root, sourceFile, request) {
53
+ const unresolved = path.resolve(path.dirname(sourceFile), request);
54
+ if (!inside(root, unresolved)) throw new Error(`runtime dependency escapes root: ${sourceFile} -> ${request}`);
55
+ const candidates = path.extname(unresolved)
56
+ ? [unresolved]
57
+ : [unresolved, `${unresolved}.cjs`, `${unresolved}.js`, `${unresolved}.json`,
58
+ path.join(unresolved, 'index.cjs'), path.join(unresolved, 'index.js')];
59
+ for (const candidate of candidates) {
60
+ try { return canonicalRegularFile(root, candidate, 'runtime dependency'); }
61
+ catch (error) {
62
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR'
63
+ || /cannot be inspected \((?:ENOENT|ENOTDIR)\)/.test(error.message)) continue;
64
+ throw error;
65
+ }
66
+ }
67
+ throw new Error(`runtime dependency is missing: ${sourceFile} -> ${request}`);
68
+ }
69
+
70
+ function relativeRequires(sourceFile) {
71
+ if (path.extname(sourceFile) === '.json') return [];
72
+ const source = readFileSync(sourceFile, 'utf8');
73
+ if (/\brequire\s*\(\s*path\.(?:join|resolve)\s*\(\s*__dirname\b/.test(source)) {
74
+ throw new Error(`dynamic local require is not allowed: ${sourceFile}`);
75
+ }
76
+ const localBindings = new Set();
77
+ for (const match of source.matchAll(
78
+ /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:String\s*\(\s*)?(['"])(\.\.?\/[^'"]*)\2\s*\)?/g,
79
+ )) localBindings.add(match[1]);
80
+ const requests = new Set();
81
+ for (const match of source.matchAll(/\brequire\s*\(\s*([^()\r\n]+?)\s*\)/g)) {
82
+ const expression = match[1].trim();
83
+ const literal = expression.match(/^(['"])([^'"]+)\1$/);
84
+ if (literal) {
85
+ if (literal[2].startsWith('.')) requests.add(literal[2]);
86
+ continue;
87
+ }
88
+ const identifier = expression.match(/^[A-Za-z_$][\w$]*$/)?.[0];
89
+ const visiblyLocal = /^(['"])(\.\.?\/)/.test(expression)
90
+ || /^`\.\.?\//.test(expression)
91
+ || (identifier && localBindings.has(identifier));
92
+ if (visiblyLocal) throw new Error(`dynamic local require is not allowed: ${sourceFile}`);
93
+ }
94
+ return [...requests].sort();
95
+ }
96
+
97
+ function verifyCommonJsClosure(root, entrypoint) {
98
+ const canonicalRoot = canonicalDirectory(root);
99
+ const pending = [canonicalRegularFile(root, entrypoint, 'runtime entrypoint')];
100
+ const verified = new Set();
101
+ while (pending.length) {
102
+ const sourceFile = pending.shift();
103
+ if (verified.has(sourceFile)) continue;
104
+ verified.add(sourceFile);
105
+ for (const request of relativeRequires(sourceFile)) {
106
+ const dependency = resolveLocalDependency(canonicalRoot, sourceFile, request);
107
+ if (!verified.has(dependency)) pending.push(dependency);
108
+ }
109
+ }
110
+ return [...verified].sort();
111
+ }
112
+
113
+ module.exports = {
114
+ assertComponentPath,
115
+ canonicalDirectory,
116
+ canonicalRegularFile,
117
+ inside,
118
+ verifyCommonJsClosure,
119
+ };
@@ -17,6 +17,7 @@
17
17
  try {
18
18
  const fs = require('fs');
19
19
  const path = require('path');
20
+ const { commandAccess } = require('./lib/privacy-command-analysis.cjs');
20
21
 
21
22
  const RESTRICTED_PATTERNS = [
22
23
  /^\.env(?:[.\[*?{]|$)/i,
@@ -63,7 +64,7 @@ try {
63
64
  * Resolve a symlink to its real target so a harmless-looking name that points
64
65
  * at a sensitive file cannot slip through the basename check. Returns null
65
66
  * when the path cannot be resolved (missing file, broken link) — fail-open to
66
- * the original-path check in that case.
67
+ * the original-path check in that case. See isSymlinkToSensitive for dangling handling.
67
68
  */
68
69
  function resolveTarget(filePath, cwd) {
69
70
  try {
@@ -74,18 +75,91 @@ try {
74
75
  }
75
76
  }
76
77
 
77
- function extractBashPaths(command) {
78
- const paths = new Set();
79
- // Quotes are delimiters rather than grouped tokens so nested shell
80
- // commands such as `bash -c 'cat .env'` cannot hide the sensitive path.
81
- const tokens = command.split(/[\s|;&<>"']+/).filter(Boolean);
82
- for (const token of tokens) {
83
- const cleaned = token.replace(/^["'()`]+|["'(),]+$/g, '');
84
- for (const candidate of cleaned.split('=')) {
85
- const value = candidate.replace(/^["'()`]+|["'(),]+$/g, '');
86
- if (value && (isSensitive(value) || isSafe(value))) paths.add(value);
78
+ /**
79
+ * When realpath fails, check whether the requested path is a symlink (or chain)
80
+ * whose lexical target is sensitive. Dangling link to protected target -> true (fail-closed ask);
81
+ * dangling benign link or link to allowed example/template -> false (allow).
82
+ * Limit hops to avoid loops; malformed/looping -> fail closed.
83
+ * TOCTOU note: check and use are not atomic; target could be swapped between this check and the actual read.
84
+ * This is a best-effort guardrail, not an arbitrary-shell security boundary. For strong isolation use OS sandbox.
85
+ */
86
+ function isSymlinkToSensitive(filePath, cwd) {
87
+ try {
88
+ const requested = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
89
+ let current = requested;
90
+ let hops = 0;
91
+ const MAX_HOPS = 8;
92
+ while (hops < MAX_HOPS) {
93
+ let stat;
94
+ try {
95
+ stat = fs.lstatSync(current);
96
+ } catch {
97
+ return false;
98
+ }
99
+ if (!stat.isSymbolicLink()) return false;
100
+ let linkTarget;
101
+ try {
102
+ linkTarget = fs.readlinkSync(current);
103
+ } catch {
104
+ return true;
105
+ }
106
+ if (isSensitive(linkTarget) && !isSafe(linkTarget)) return true;
107
+ if (isSafe(linkTarget)) return false;
108
+ const next = path.isAbsolute(linkTarget) ? linkTarget : path.resolve(path.dirname(current), linkTarget);
109
+ if (next === current) return true;
110
+ try {
111
+ const real = fs.realpathSync(next);
112
+ if (isSensitive(real) && !isSafe(real)) return true;
113
+ if (isSafe(real)) return false;
114
+ return false;
115
+ } catch {
116
+ current = next;
117
+ hops += 1;
118
+ continue;
119
+ }
87
120
  }
121
+ return true;
122
+ } catch {
123
+ return true;
124
+ }
125
+ }
126
+
127
+ function extractBashPaths(command) {
128
+ // The analyzer parses the command and returns the operands of every reading
129
+ // command it recognises, nested shells and `find -exec` included. Treating
130
+ // every unresolved expansion as sensitive, as this did before, asked on
131
+ // `cat $FILE`, `wc -l *.cjs` and `cat ~/.zshrc` alike. Noise is what trains
132
+ // people to click through the prompt that matters.
133
+ // A heredoc whose delimiter is quoted (<<'BODY') is inert: the shell
134
+ // expands nothing inside it, so no word in the body is ever read. The
135
+ // analyzer has no notion of heredocs and parses the body as shell, so prose
136
+ // that quotes a command -- a PR body carrying `cat .env` in backticks --
137
+ // came back as a real command substitution. Blank those bodies out first.
138
+ // An unquoted delimiter (<<BODY) does expand, so it is left to the
139
+ // analyzer.
140
+ const scrubbed = String(command).replace(
141
+ /(<<-?[ \t]*)(['"])([A-Za-z_]\w*)\2[^\n]*\n[\s\S]*?\n[ \t]*\3\b/g,
142
+ '$1$2$3$2'
143
+ );
144
+
145
+ const paths = new Set(commandAccess(scrubbed).paths);
146
+
147
+ // Two shapes the analyzer cannot return. It drops any word carrying an
148
+ // expansion, so `~/.ssh/id_rsa` and `certs/*.pem` still have to be matched
149
+ // by name. And it only walks commands it recognises, so an unknown tool
150
+ // handed a secret through a file-shaped option, such as
151
+ // `docker compose --env-file .env`, is invisible to it.
152
+ const FILE_OPTION = /^--?[\w-]*(?:file|config|key|cert|identity)$/i;
153
+ const tokens = scrubbed.split(/[\s|;&<>"'()`]+/);
154
+ for (let index = 0; index < tokens.length; index += 1) {
155
+ const token = tokens[index];
156
+ const shaped = /^~|[*?[{]/.test(token);
157
+ const optionFed = index > 0 && FILE_OPTION.test(tokens[index - 1]);
158
+ if (!shaped && !optionFed) continue;
159
+ const value = token.replace(/^~/, '').replace(/^["'()`]+|["'(),]+$/g, '');
160
+ if (value && isSensitive(value) && !isSafe(value)) paths.add(value);
88
161
  }
162
+
89
163
  return [...paths];
90
164
  }
91
165
 
@@ -129,7 +203,7 @@ try {
129
203
  const data = JSON.parse(stdin);
130
204
  const toolName = data.tool_name || '';
131
205
  const toolInput = data.tool_input || {};
132
- const cwd = data.cwd || process.cwd();
206
+ const cwd = typeof data.cwd === 'string' && data.cwd.trim() ? data.cwd : process.cwd();
133
207
  const runtime = readRuntime(cwd);
134
208
 
135
209
  if (runtime.privacyBlock === false) process.exit(0);
@@ -148,6 +222,11 @@ try {
148
222
  process.exit(0);
149
223
  }
150
224
  if (isSafe(target)) continue;
225
+ } else {
226
+ if (isSymlinkToSensitive(filePath, cwd)) {
227
+ askForPermission(filePath);
228
+ process.exit(0);
229
+ }
151
230
  }
152
231
 
153
232
  if (isSafe(filePath) || !isSensitive(filePath)) continue;
@@ -156,21 +235,13 @@ try {
156
235
  }
157
236
 
158
237
  process.exit(0);
159
- } catch (error) {
160
- try {
161
- const fs = require('fs');
162
- const path = require('path');
163
- const logDir = path.join(__dirname, '.logs');
164
- if (!fs.existsSync(logDir)) fs.mkdirSync(logDir, { recursive: true });
165
- fs.appendFileSync(
166
- path.join(logDir, 'hook-log.jsonl'),
167
- JSON.stringify({
168
- ts: new Date().toISOString(),
169
- hook: 'privacy-block',
170
- status: 'crash',
171
- error: error.message
172
- }) + '\n'
173
- );
174
- } catch (_) {}
238
+ } catch (_) {
239
+ process.stdout.write(JSON.stringify({
240
+ hookSpecificOutput: {
241
+ hookEventName: 'PreToolUse',
242
+ permissionDecision: 'ask',
243
+ permissionDecisionReason: 'Sensitive access could not be evaluated safely.'
244
+ }
245
+ }) + '\n');
175
246
  process.exit(0);
176
247
  }