@haposoft/cafekit 0.16.0-rc.3 → 0.16.0-rc.4
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/bin/__tests__/codex-hooks.test.js +444 -14
- package/bin/__tests__/codex-native.test.js +228 -7
- package/bin/__tests__/develop-contract.test.js +615 -185
- package/bin/__tests__/installer-safety.test.js +39 -0
- package/bin/__tests__/opencode-spec-gate.test.js +24 -16
- package/bin/__tests__/package-inventory.test.js +687 -13
- package/bin/__tests__/specs-v2-execution-closeout.test.js +421 -0
- package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +601 -0
- package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +952 -0
- package/bin/__tests__/test-runtime-dependency-closure.cjs +182 -0
- package/bin/__tests__/validator-grounding.test.js +1914 -221
- package/bin/lib/managed-writer.js +12 -3
- package/bin/phases/codex-runtime.js +17 -1
- package/package.json +1 -1
- package/src/claude/CLAUDE.md +6 -0
- package/src/claude/agents/code-auditor.md +20 -0
- package/src/claude/agents/spec-maker.md +143 -99
- package/src/claude/hooks/__tests__/completion-authority.test.js +291 -58
- package/src/claude/hooks/__tests__/privacy-block.test.js +172 -0
- package/src/claude/hooks/__tests__/semantic-review-authority.test.js +257 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +115 -40
- package/src/claude/hooks/completion-authority-check.cjs +37 -288
- package/src/claude/hooks/completion-authority-state.cjs +10 -8
- package/src/claude/hooks/completion-authority.cjs +13 -7
- package/src/claude/hooks/docs-sync.cjs +5 -2
- package/src/claude/hooks/lib/privacy-command-analysis.cjs +393 -0
- package/src/claude/hooks/lib/runtime-path-safety.cjs +119 -0
- package/src/claude/hooks/privacy-block.cjs +77 -30
- package/src/claude/hooks/semantic-review-authority.cjs +152 -0
- package/src/claude/hooks/spec-gate.cjs +47 -117
- package/src/claude/hooks/spec-state.cjs +8 -2
- package/src/claude/migration-manifest.json +9 -1
- package/src/claude/rules/state-sync.md +52 -21
- package/src/claude/rules/workflow.md +17 -9
- package/src/claude/scripts/spec-final-state.cjs +244 -0
- package/src/claude/scripts/spec-ground.cjs +535 -173
- package/src/claude/scripts/spec-readiness.cjs +118 -0
- package/src/claude/scripts/spec-receipt.cjs +162 -0
- package/src/claude/scripts/spec-resolver.cjs +67 -16
- package/src/claude/scripts/spec-scaffold.cjs +1354 -139
- package/src/claude/scripts/spec-semantic-model.cjs +396 -0
- package/src/claude/scripts/validate-spec-output.cjs +2782 -142
- package/src/claude/scripts/workflow-policy.cjs +501 -183
- package/src/claude/settings/settings.json +4 -0
- package/src/claude/skills/code-review/SKILL.md +12 -3
- package/src/claude/skills/code-review/references/spec-compliance-review.md +10 -2
- package/src/claude/skills/code-review/references/verification-gate.md +13 -0
- package/src/claude/skills/develop/SKILL.md +47 -60
- package/src/claude/skills/develop/references/parallel-waves.md +9 -6
- package/src/claude/skills/develop/references/quality-gate.md +19 -12
- package/src/claude/skills/specs/SKILL.md +183 -177
- package/src/claude/skills/specs/references/ask-user-question-gates.md +27 -18
- package/src/claude/skills/specs/references/cross-spec-dependency.md +8 -4
- package/src/claude/skills/specs/references/research-strategy.md +4 -1
- package/src/claude/skills/specs/references/review.md +200 -260
- package/src/claude/skills/specs/references/scope-inquiry.md +18 -5
- package/src/claude/skills/specs/rules/design-discovery-full.md +33 -81
- package/src/claude/skills/specs/rules/design-discovery-light.md +21 -42
- package/src/claude/skills/specs/rules/design-principles.md +136 -180
- package/src/claude/skills/specs/rules/design-review.md +42 -97
- package/src/claude/skills/specs/rules/ears-format.md +42 -50
- package/src/claude/skills/specs/rules/phase-decision-matrix.md +42 -41
- package/src/claude/skills/specs/rules/task-scoring-rubric.md +2 -1
- package/src/claude/skills/specs/rules/tasks-generation.md +189 -300
- package/src/claude/skills/specs/templates/design.md +49 -263
- package/src/claude/skills/specs/templates/requirements.md +26 -36
- package/src/claude/skills/specs/templates/research.md +9 -98
- package/src/claude/skills/specs/templates/spec-state.json +23 -85
- package/src/claude/skills/specs/templates/task.md +30 -102
- package/src/claude/skills/sync/SKILL.md +33 -10
- package/src/claude/skills/sync/references/sync-protocols.md +31 -13
- package/src/claude/skills/test/SKILL.md +17 -7
- package/src/codex/AGENTS.md +6 -0
- package/src/codex/hooks/completion-authority-check.cjs +43 -240
- package/src/codex/hooks/completion-authority-state.cjs +10 -8
- package/src/codex/hooks/completion-authority.cjs +13 -7
- package/src/codex/hooks/lib/hook-context.cjs +16 -2
- package/src/codex/hooks/lib/privacy-command-analysis.cjs +3 -0
- package/src/codex/hooks/lib/privacy-state.cjs +38 -16
- package/src/codex/hooks/lib/runtime-path-safety.cjs +3 -0
- package/src/codex/hooks/lib/spec-receipt.cjs +72 -134
- package/src/codex/hooks/lib/spec-utils.cjs +56 -5
- package/src/codex/hooks/privacy-approval.cjs +1 -1
- package/src/codex/hooks/privacy-block.cjs +67 -11
- package/src/codex/hooks/semantic-review-authority.cjs +130 -0
- package/src/codex/hooks/spec-gate.cjs +49 -34
- package/src/codex/hooks/spec-state.cjs +10 -3
- package/src/codex/hooks/state.cjs +2 -0
- package/src/codex/hooks.json +6 -1
- package/src/codex/rules/state-sync.md +47 -27
- 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,10 @@
|
|
|
17
17
|
try {
|
|
18
18
|
const fs = require('fs');
|
|
19
19
|
const path = require('path');
|
|
20
|
+
const {
|
|
21
|
+
DYNAMIC_READ_PATH,
|
|
22
|
+
commandAccess,
|
|
23
|
+
} = require('./lib/privacy-command-analysis.cjs');
|
|
20
24
|
|
|
21
25
|
const RESTRICTED_PATTERNS = [
|
|
22
26
|
/^\.env(?:[.\[*?{]|$)/i,
|
|
@@ -33,7 +37,13 @@ try {
|
|
|
33
37
|
/\.keystore$/i,
|
|
34
38
|
/\.jks$/i,
|
|
35
39
|
/auth\.json$/i,
|
|
36
|
-
/token(s)?\.json$/i
|
|
40
|
+
/token(s)?\.json$/i,
|
|
41
|
+
/\.cafekit/i,
|
|
42
|
+
/hmac\.key/i,
|
|
43
|
+
/completion-authority/i,
|
|
44
|
+
/session-state/i,
|
|
45
|
+
/hook-log/i,
|
|
46
|
+
/sessions/i
|
|
37
47
|
];
|
|
38
48
|
|
|
39
49
|
const ALLOWED_EXEMPTIONS = [
|
|
@@ -55,6 +65,7 @@ try {
|
|
|
55
65
|
}
|
|
56
66
|
|
|
57
67
|
function isSensitive(filePath) {
|
|
68
|
+
if (filePath === DYNAMIC_READ_PATH) return true;
|
|
58
69
|
const base = path.basename(filePath);
|
|
59
70
|
return RESTRICTED_PATTERNS.some((rule) => rule.test(base) || rule.test(filePath));
|
|
60
71
|
}
|
|
@@ -63,7 +74,7 @@ try {
|
|
|
63
74
|
* Resolve a symlink to its real target so a harmless-looking name that points
|
|
64
75
|
* at a sensitive file cannot slip through the basename check. Returns null
|
|
65
76
|
* when the path cannot be resolved (missing file, broken link) — fail-open to
|
|
66
|
-
* the original-path check in that case.
|
|
77
|
+
* the original-path check in that case. See isSymlinkToSensitive for dangling handling.
|
|
67
78
|
*/
|
|
68
79
|
function resolveTarget(filePath, cwd) {
|
|
69
80
|
try {
|
|
@@ -74,19 +85,58 @@ try {
|
|
|
74
85
|
}
|
|
75
86
|
}
|
|
76
87
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
88
|
+
/**
|
|
89
|
+
* When realpath fails, check whether the requested path is a symlink (or chain)
|
|
90
|
+
* whose lexical target is sensitive. Dangling link to protected target -> true (fail-closed ask);
|
|
91
|
+
* dangling benign link or link to allowed example/template -> false (allow).
|
|
92
|
+
* Limit hops to avoid loops; malformed/looping -> fail closed.
|
|
93
|
+
* TOCTOU note: check and use are not atomic; target could be swapped between this check and the actual read.
|
|
94
|
+
* This is a best-effort guardrail, not an arbitrary-shell security boundary. For strong isolation use OS sandbox.
|
|
95
|
+
*/
|
|
96
|
+
function isSymlinkToSensitive(filePath, cwd) {
|
|
97
|
+
try {
|
|
98
|
+
const requested = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
|
|
99
|
+
let current = requested;
|
|
100
|
+
let hops = 0;
|
|
101
|
+
const MAX_HOPS = 8;
|
|
102
|
+
while (hops < MAX_HOPS) {
|
|
103
|
+
let stat;
|
|
104
|
+
try {
|
|
105
|
+
stat = fs.lstatSync(current);
|
|
106
|
+
} catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
if (!stat.isSymbolicLink()) return false;
|
|
110
|
+
let linkTarget;
|
|
111
|
+
try {
|
|
112
|
+
linkTarget = fs.readlinkSync(current);
|
|
113
|
+
} catch {
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
if (isSensitive(linkTarget) && !isSafe(linkTarget)) return true;
|
|
117
|
+
if (isSafe(linkTarget)) return false;
|
|
118
|
+
const next = path.isAbsolute(linkTarget) ? linkTarget : path.resolve(path.dirname(current), linkTarget);
|
|
119
|
+
if (next === current) return true;
|
|
120
|
+
try {
|
|
121
|
+
const real = fs.realpathSync(next);
|
|
122
|
+
if (isSensitive(real) && !isSafe(real)) return true;
|
|
123
|
+
if (isSafe(real)) return false;
|
|
124
|
+
return false;
|
|
125
|
+
} catch {
|
|
126
|
+
current = next;
|
|
127
|
+
hops += 1;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
87
130
|
}
|
|
131
|
+
return true;
|
|
132
|
+
} catch {
|
|
133
|
+
return true;
|
|
88
134
|
}
|
|
89
|
-
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function extractBashPaths(command) {
|
|
138
|
+
const access = commandAccess(command);
|
|
139
|
+
return access.dynamic ? [...access.paths, DYNAMIC_READ_PATH] : access.paths;
|
|
90
140
|
}
|
|
91
141
|
|
|
92
142
|
function extractPaths(toolName, input) {
|
|
@@ -129,7 +179,7 @@ try {
|
|
|
129
179
|
const data = JSON.parse(stdin);
|
|
130
180
|
const toolName = data.tool_name || '';
|
|
131
181
|
const toolInput = data.tool_input || {};
|
|
132
|
-
const cwd = data.cwd
|
|
182
|
+
const cwd = typeof data.cwd === 'string' && data.cwd.trim() ? data.cwd : process.cwd();
|
|
133
183
|
const runtime = readRuntime(cwd);
|
|
134
184
|
|
|
135
185
|
if (runtime.privacyBlock === false) process.exit(0);
|
|
@@ -148,6 +198,11 @@ try {
|
|
|
148
198
|
process.exit(0);
|
|
149
199
|
}
|
|
150
200
|
if (isSafe(target)) continue;
|
|
201
|
+
} else {
|
|
202
|
+
if (isSymlinkToSensitive(filePath, cwd)) {
|
|
203
|
+
askForPermission(filePath);
|
|
204
|
+
process.exit(0);
|
|
205
|
+
}
|
|
151
206
|
}
|
|
152
207
|
|
|
153
208
|
if (isSafe(filePath) || !isSensitive(filePath)) continue;
|
|
@@ -156,21 +211,13 @@ try {
|
|
|
156
211
|
}
|
|
157
212
|
|
|
158
213
|
process.exit(0);
|
|
159
|
-
} catch (
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
JSON.stringify({
|
|
168
|
-
ts: new Date().toISOString(),
|
|
169
|
-
hook: 'privacy-block',
|
|
170
|
-
status: 'crash',
|
|
171
|
-
error: error.message
|
|
172
|
-
}) + '\n'
|
|
173
|
-
);
|
|
174
|
-
} catch (_) {}
|
|
214
|
+
} catch (_) {
|
|
215
|
+
process.stdout.write(JSON.stringify({
|
|
216
|
+
hookSpecificOutput: {
|
|
217
|
+
hookEventName: 'PreToolUse',
|
|
218
|
+
permissionDecision: 'ask',
|
|
219
|
+
permissionDecisionReason: 'Sensitive access could not be evaluated safely.'
|
|
220
|
+
}
|
|
221
|
+
}) + '\n');
|
|
175
222
|
process.exit(0);
|
|
176
223
|
}
|