@klarkxy/dsh-safe-auto 0.1.0-rc.6 → 0.2.0

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.
@@ -0,0 +1,361 @@
1
+ import { createScanner, parseTree, SyntaxKind } from 'jsonc-parser';
2
+ import { isAlias, isMap, isScalar, isSeq, parseAllDocuments, Parser } from 'yaml';
3
+ import { parseTOML } from 'toml-eslint-parser';
4
+ import { Buffer } from 'node:buffer';
5
+ import { credentialSourcePath } from './credential-paths.js';
6
+
7
+ const SAFE_ERROR = 'Credential text could not be safely redacted.';
8
+ const fail = () => { throw new Error(SAFE_ERROR); };
9
+ const whitespace = character => /\s/u.test(character);
10
+
11
+ /** Match credential names, rather than words such as model, keybindings or token limits. */
12
+ export function isCredentialField(name) {
13
+ if (typeof name !== 'string') return false;
14
+ const normalized = name.replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
15
+ .replace(/([a-z\d])([A-Z])/g, '$1_$2').toLowerCase()
16
+ .replace(/[^a-z\d]+/g, '_').replace(/^_+|_+$/g, '');
17
+ return /(?:^|[/:])_auth$/i.test(name) || /(?:^|_)(?:api_key|apikey|auth_token|access_token|refresh_token|id_token|api_token|bearer_token|session_token|security_token|personal_access_token|client_secret|client_key_data|secret_key|secret_access_key|access_key_id|access_key|private_key|privatekey|password|passwd|passphrase|authorization|proxy_authorization|cookie|set_cookie|secret|token)$/.test(normalized);
18
+ }
19
+
20
+ function isReference(value) {
21
+ const text = value.trim();
22
+ const header = /^(?:Bearer|Basic)[ \t]+(.+)$/i.exec(text);
23
+ if (header) return isReference(header[1]);
24
+ return !text || /^(?:\*+|\.{3}|\[redacted\]|<redacted>|redacted|null|none|undefined)$/i.test(text)
25
+ || /^(?:\$\{(?:env:)?[\w.]+\}|\$(?:env:)?[\w.]+|%[\w]+%|\$?\{\{\s*(?:env|secrets)\.[\w.]+\s*\}\}|(?:env:|process\.env\.)[\w.]+)$/i.test(text)
26
+ || /^<(?:your[_ -]?)?(?:api[_ -]?key|token|password|secret|private[_ -]?key)>$/i.test(text)
27
+ || /^<[A-Z][A-Z\d_]*>$/.test(text)
28
+ || /^(?:(?:your|example|placeholder|dummy|fake|test)[_-])?(?:api[_-]?key|token|password|secret)(?:[_-](?:here|placeholder|example))?$/i.test(text)
29
+ || /^(?:replace[-_ ]?me|change[-_ ]?me|placeholder|dummy|example|not[-_ ]a[-_ ]real[-_ ](?:key|token|secret)|sk-(?:placeholder|your[-_]?(?:key|api[-_]?key)|example))$/i.test(text)
30
+ || /^(?:\.{1,2}[\\/]|~[\\/]|[A-Za-z]:[\\/]|file:\/\/)[^\r\n]+$/.test(text)
31
+ || /^(?:[\\/]|\.[\w-]+[\\/])[^\r\n]*\.(?:jsonc?|ya?ml|toml|pem|key|txt|env)$/i.test(text);
32
+ }
33
+
34
+ function addRange(ranges, start, end) {
35
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start) fail();
36
+ if (end > start) ranges.push([start, end]);
37
+ }
38
+
39
+ function literalRanges(text) {
40
+ const ranges = [];
41
+ // URL parsing validates the authority; source offsets preserve the original
42
+ // username, host, path and percent encoding without URL reserialization.
43
+ for (const match of text.matchAll(/\b[A-Za-z][A-Za-z\d+.-]*:\/\//g)) {
44
+ const start = match.index + match[0].length;
45
+ const tail = text.slice(start);
46
+ const authorityEnd = /[/?#\\\s"'`<>]/.exec(tail)?.index ?? tail.length;
47
+ const authority = tail.slice(0, authorityEnd);
48
+ const at = authority.lastIndexOf('@');
49
+ const colon = authority.indexOf(':');
50
+ if (at < 0 || colon < 0 || colon >= at || colon + 1 === at) continue;
51
+ try { new URL(match[0] + authority + '/'); } catch { fail(); }
52
+ const password = authority.slice(colon + 1, at);
53
+ let decoded = password;
54
+ try { decoded = decodeURIComponent(password); } catch { /* Invalid percent escapes are still password payload. */ }
55
+ if (!isReference(decoded)) addRange(ranges, start + colon + 1, start + at);
56
+ }
57
+ const token = /\b(?:sk-[A-Za-z\d_-]{8,}|gh[pousr]_[A-Za-z\d_]{8,}|github_pat_[A-Za-z\d_]{8,}|glpat-[A-Za-z\d_-]{8,}|xox[baprs]-[A-Za-z\d_-]{8,}|npm_[A-Za-z\d]{8,}|(?:AKIA|ASIA)[A-Z\d]{16}|AIza[A-Za-z\d_-]{20,}|SG\.[A-Za-z\d_-]{8,}\.[A-Za-z\d_-]{8,}|eyJ[A-Za-z\d_-]{8,}\.[A-Za-z\d_-]{8,}\.[A-Za-z\d_-]{8,})(?![A-Za-z\d_-])/g;
58
+ for (const match of text.matchAll(token)) {
59
+ if (!isReference(match[0])) addRange(ranges, match.index, match.index + match[0].length);
60
+ }
61
+ for (const match of text.matchAll(/\bBearer[ \t]+([^\s"'`,;<>]+)/gi)) {
62
+ if (!isReference(match[1])) {
63
+ const start = match.index + match[0].length - match[1].length;
64
+ addRange(ranges, start, start + match[1].length);
65
+ }
66
+ }
67
+ for (const match of text.matchAll(/\bBasic[ \t]+([A-Za-z\d+/]+={0,2})(?![A-Za-z\d+/=])/gi)) {
68
+ const payload = match[1];
69
+ const bytes = Buffer.from(payload, 'base64');
70
+ const colon = bytes.indexOf(0x3a);
71
+ // A Basic credential is base64(user:password), not any word after Basic.
72
+ if (bytes.toString('base64').replace(/=+$/, '') !== payload.replace(/=+$/, '')
73
+ || colon < 0 || colon + 1 >= bytes.length || bytes.some(byte => byte < 0x20 || byte === 0x7f)) continue;
74
+ const start = match.index + match[0].length - payload.length;
75
+ addRange(ranges, start, start + payload.length);
76
+ }
77
+ const pem = /-----BEGIN ((?:(?:RSA|EC|DSA|OPENSSH|ENCRYPTED) )?PRIVATE KEY)-----/g;
78
+ for (const match of text.matchAll(pem)) {
79
+ const start = match.index + match[0].length;
80
+ const end = text.indexOf(`-----END ${match[1]}-----`, start);
81
+ if (end < 0) fail();
82
+ addRange(ranges, start, end);
83
+ }
84
+ return ranges;
85
+ }
86
+
87
+ // ENV/INI have no shared grammar. This scanner only recognizes assignments to
88
+ // credential names; comments and all other keys retain their original bytes.
89
+ function assignmentRanges(text, mode = 'plain') {
90
+ const ranges = [];
91
+ const assignment = mode === 'config'
92
+ ? /^[ \t]*(?:[#;][ \t]*)?(?:export[ \t]+)?(?:((?:[A-Za-z_]|\/\/)[\w./:@-]*)|"([^"\r\n]+)"|'([^'\r\n]+)')[ \t]*[=:][ \t]*/gm
93
+ : /(?:^|[\s{,;])(?:((?:\$?(?:env:)?)[A-Za-z_][\w.-]*)|"((?:\\.|[^"\\\r\n])+)"|'([^'\r\n]+)')[ \t]*([=:])[ \t]*/gm;
94
+ for (const match of text.matchAll(assignment)) {
95
+ let name = match[1] ?? match[2] ?? match[3];
96
+ if (mode === 'plain' && match[2]) {
97
+ try { name = JSON.parse(`"${name}"`); } catch { continue; }
98
+ }
99
+ if (!isCredentialField(name)) continue;
100
+ const start = match.index + match[0].length;
101
+ // In ordinary prose, an authorization heading may describe permission.
102
+ if (mode === 'plain' && /(?:authorization|cookie)$/i.test(name) && match[4] === ':'
103
+ && !/^(?:["']|Bearer\b|Basic\b|[\w-]+=)/i.test(text.slice(start))) continue;
104
+ const quote = text[start];
105
+ let end, contentStart = start;
106
+ if (quote === '"' || quote === "'") {
107
+ contentStart++;
108
+ end = contentStart;
109
+ while (end < text.length) {
110
+ if (text[end] === quote) break;
111
+ if (text[end] === '\\' && quote === '"') end++;
112
+ end++;
113
+ }
114
+ if (end >= text.length) fail();
115
+ if (mode === 'config') {
116
+ const nextLine = text.indexOf('\n', end);
117
+ const remainder = text.slice(end + 1, nextLine < 0 ? text.length : nextLine).trim();
118
+ if (remainder && !/^[#;]/.test(remainder)) fail();
119
+ }
120
+ } else {
121
+ end = text.indexOf('\n', start);
122
+ if (end < 0) end = text.length;
123
+ const tail = text.slice(start, end);
124
+ if (mode === 'config') {
125
+ const stop = /[ \t]+[#;]/.exec(tail);
126
+ if (stop) end = start + stop.index;
127
+ } else if (!/(?:^|[_.-])cookie$/i.test(name)) {
128
+ const prefix = /^(?:(?:Bearer|Basic)[ \t]+)?(?:\$\{[^}\r\n]+\}|\{\{[^}\r\n]+\}\}|[^\s,;}]+)/i.exec(tail);
129
+ if (prefix) end = start + prefix[0].length;
130
+ }
131
+ }
132
+ const value = text.slice(contentStart, end).trimEnd();
133
+ let decoded = value;
134
+ if (quote === '"') {
135
+ try { decoded = JSON.parse(`"${value}"`); } catch { /* ENV permits literal newlines and non-JSON escapes. */ }
136
+ }
137
+ if (!isReference(decoded)) addRange(ranges, contentStart, contentStart + value.length);
138
+ }
139
+ return ranges;
140
+ }
141
+
142
+ // The parser owns the grammar and decoded value. This only maps decoded scalar
143
+ // characters back to source spans, so escaped literals can be masked in place.
144
+ function sourceUnits(text, start, end, style) {
145
+ const units = [];
146
+ const simple = style === 'yaml' ? { '0': '\0', a: '\x07', b: '\b', t: '\t', n: '\n', v: '\v', f: '\f', r: '\r', e: '\x1b', ' ': ' ', '"': '"', '/': '/', '\\': '\\', N: '\x85', _: '\xa0', L: '\u2028', P: '\u2029' }
147
+ : { b: '\b', t: '\t', n: '\n', f: '\f', r: '\r', '"': '"', '/': '/', '\\': '\\' };
148
+ for (let i = start; i < end; i++) {
149
+ const begin = i;
150
+ let decoded = text[i];
151
+ if (style === 'yaml-single' && text[i] === "'" && text[i + 1] === "'") i++;
152
+ else if (['json', 'toml', 'yaml'].includes(style) && text[i] === '\\') {
153
+ const escaped = text[++i];
154
+ const length = escaped === 'u' ? 4 : escaped === 'U' && style !== 'json' ? 8 : escaped === 'x' && style === 'yaml' ? 2 : 0;
155
+ if (length) {
156
+ const digits = text.slice(i + 1, i + length + 1);
157
+ if (digits.length !== length || !/^[\da-f]+$/i.test(digits)) fail();
158
+ const code = Number.parseInt(digits, 16);
159
+ if (code > 0x10ffff) fail();
160
+ decoded = String.fromCodePoint(code); i += length;
161
+ } else if (Object.hasOwn(simple, escaped)) decoded = simple[escaped];
162
+ else if (style !== 'json' && /\s/u.test(escaped)) {
163
+ while (i + 1 < end && /\s/u.test(text[i + 1])) i++;
164
+ continue;
165
+ } else fail();
166
+ }
167
+ for (let j = 0; j < decoded.length; j++) units.push({ character: decoded[j], start: begin, end: i + 1 });
168
+ }
169
+ return units;
170
+ }
171
+
172
+ function maskDecodedLiterals(text, decoded, start, end, style, ranges) {
173
+ const matches = [...literalRanges(decoded), ...assignmentRanges(decoded)];
174
+ if (!matches.length) return;
175
+ const units = sourceUnits(text, start, end, style).filter(unit => !whitespace(unit.character));
176
+ const map = new Map();
177
+ let position = 0;
178
+ for (let i = 0; i < decoded.length; i++) {
179
+ if (whitespace(decoded[i])) continue;
180
+ if (units[position]?.character !== decoded[i]) fail();
181
+ map.set(i, units[position++]);
182
+ }
183
+ if (position !== units.length) fail();
184
+ for (const [from, to] of matches) {
185
+ let first, last;
186
+ for (let i = from; i < to; i++) {
187
+ if (map.has(i)) { first ??= map.get(i); last = map.get(i); }
188
+ }
189
+ if (first) addRange(ranges, first.start, last.end);
190
+ }
191
+ }
192
+
193
+ function scalar(text, value, start, end, style, sensitive, ranges) {
194
+ if (sensitive && typeof value === 'string' && !isReference(value)) addRange(ranges, start, end);
195
+ else if (typeof value === 'string') maskDecodedLiterals(text, value, start, end, style, ranges);
196
+ else if (sensitive && value != null && typeof value !== 'boolean') addRange(ranges, start, end);
197
+ }
198
+
199
+ function jsonRanges(text, ranges, offset = 0) {
200
+ const errors = [];
201
+ const root = parseTree(text, errors, { allowTrailingComma: true, disallowComments: false, allowEmptyContent: true });
202
+ if (errors.length) fail();
203
+ const local = [];
204
+ const queue = root ? [[root, false, []]] : [];
205
+ while (queue.length) {
206
+ const [node, sensitive, path] = queue.pop();
207
+ if (node.type === 'object') {
208
+ for (const property of node.children ?? []) {
209
+ if (property.type !== 'property' || property.children?.length !== 2) fail();
210
+ const [key, value] = property.children;
211
+ // Docker's auths registry entries use an otherwise ordinary auth key.
212
+ const registryCredential = path.length === 2 && path[0] === 'auths'
213
+ && ['auth', 'identitytoken', 'registrytoken'].includes(key.value);
214
+ queue.push([value, sensitive || registryCredential || isCredentialField(key.value), [...path, key.value]]);
215
+ }
216
+ } else if (node.type === 'array') {
217
+ for (const [index, child] of (node.children ?? []).entries()) queue.push([child, sensitive, [...path, index]]);
218
+ } else {
219
+ const quoted = node.type === 'string';
220
+ scalar(text, node.value, node.offset + Number(quoted), node.offset + node.length - Number(quoted), 'json', sensitive, local);
221
+ }
222
+ }
223
+ const scanner = createScanner(text, false);
224
+ for (let kind = scanner.scan(); kind !== SyntaxKind.EOF; kind = scanner.scan()) {
225
+ if ([SyntaxKind.LineCommentTrivia, SyntaxKind.BlockCommentTrivia].includes(kind)) {
226
+ const start = scanner.getTokenOffset(), comment = text.slice(start, start + scanner.getTokenLength());
227
+ for (const [from, to] of literalRanges(comment)) addRange(local, start + from, start + to);
228
+ for (const [from, to] of assignmentRanges(comment)) addRange(local, start + from, start + to);
229
+ }
230
+ }
231
+ for (const [start, end] of local) addRange(ranges, start + offset, end + offset);
232
+ }
233
+
234
+ function yamlRanges(text, ranges) {
235
+ const docs = parseAllDocuments(text, { keepSourceTokens: true, strict: true, uniqueKeys: true });
236
+ for (const doc of docs) {
237
+ if (doc.errors.length || doc.warnings.length) fail();
238
+ const seen = new WeakMap();
239
+ const queue = doc.contents ? [[doc.contents, false]] : [];
240
+ const keyName = key => {
241
+ if (isAlias(key)) key = key.resolve(doc);
242
+ if (!isScalar(key)) fail();
243
+ return String(key.value ?? '');
244
+ };
245
+ while (queue.length) {
246
+ const [node, sensitive] = queue.pop();
247
+ if (!node) continue;
248
+ const bit = sensitive ? 2 : 1;
249
+ if ((seen.get(node) ?? 0) & bit) continue;
250
+ seen.set(node, (seen.get(node) ?? 0) | bit);
251
+ if (isAlias(node)) {
252
+ const origin = node.resolve(doc);
253
+ if (!origin) fail();
254
+ queue.push([origin, sensitive]);
255
+ } else if (isMap(node)) {
256
+ for (const pair of node.items) queue.push([pair.value, sensitive || isCredentialField(keyName(pair.key))]);
257
+ } else if (isSeq(node)) {
258
+ for (const item of node.items) queue.push([item, sensitive]);
259
+ } else if (isScalar(node)) {
260
+ if (!node.range) fail();
261
+ let [start, end] = node.range;
262
+ let style = 'literal';
263
+ if (node.type === 'QUOTE_DOUBLE' || node.type === 'QUOTE_SINGLE') {
264
+ start++; end--; style = node.type === 'QUOTE_DOUBLE' ? 'yaml' : 'yaml-single';
265
+ } else if (node.type === 'BLOCK_LITERAL' || node.type === 'BLOCK_FOLDED') {
266
+ if (node.srcToken?.type !== 'block-scalar') fail();
267
+ start = node.srcToken.offset + node.srcToken.props.reduce((total, prop) => total + prop.source.length, 0);
268
+ end = start + node.srcToken.source.length;
269
+ }
270
+ if (node.value !== null && !['string', 'number', 'boolean', 'undefined'].includes(typeof node.value)) fail();
271
+ scalar(text, node.value, start, end, style, sensitive, ranges);
272
+ } else fail();
273
+ }
274
+ }
275
+ // Comments are outside AST value ranges. Restrict recognition to comment text.
276
+ // A # within a quoted or block value must not be misread as a comment.
277
+ const comments = [];
278
+ const collect = token => {
279
+ if (!token || typeof token !== 'object') return;
280
+ if (token.type === 'comment') comments.push(token);
281
+ for (const [key, value] of Object.entries(token)) {
282
+ if (key === 'source') continue;
283
+ if (Array.isArray(value)) value.forEach(collect);
284
+ else if (value && typeof value === 'object') collect(value);
285
+ }
286
+ };
287
+ for (const token of new Parser().parse(text)) collect(token);
288
+ for (const token of comments) {
289
+ for (const [start, end] of [...literalRanges(token.source), ...assignmentRanges(token.source)]) {
290
+ addRange(ranges, token.offset + start, token.offset + end);
291
+ }
292
+ }
293
+ }
294
+
295
+ function tomlRanges(text, ranges) {
296
+ const root = parseTOML(text, { tomlVersion: '1.0' });
297
+ const queue = [[root, false]];
298
+ while (queue.length) {
299
+ const [node, sensitive] = queue.pop();
300
+ if (node.type === 'TOMLKeyValue') {
301
+ const names = node.key.keys.map(key => key.name ?? key.value);
302
+ queue.push([node.value, sensitive || names.some(isCredentialField)]);
303
+ } else if (node.type === 'TOMLTable') {
304
+ const inherited = sensitive || node.resolvedKey.some(isCredentialField);
305
+ for (const child of node.body) queue.push([child, inherited]);
306
+ } else if (['Program', 'TOMLTopLevelTable', 'TOMLInlineTable'].includes(node.type)) {
307
+ for (const child of node.body) queue.push([child, sensitive]);
308
+ } else if (node.type === 'TOMLArray') {
309
+ for (const child of node.elements) queue.push([child, sensitive]);
310
+ } else if (node.type === 'TOMLValue') {
311
+ let [start, end] = node.range;
312
+ let style = 'literal';
313
+ if (node.kind === 'string') {
314
+ const delimiter = node.multiline ? 3 : 1;
315
+ start += delimiter; end -= delimiter;
316
+ style = node.style === 'basic' ? 'toml' : 'literal';
317
+ }
318
+ scalar(text, node.value, start, end, style, sensitive, ranges);
319
+ } else fail();
320
+ }
321
+ for (const comment of root.comments) {
322
+ const source = text.slice(...comment.range);
323
+ for (const [start, end] of [...literalRanges(source), ...assignmentRanges(source)]) {
324
+ addRange(ranges, comment.range[0] + start, comment.range[0] + end);
325
+ }
326
+ }
327
+ }
328
+
329
+ /** Pure value redaction. Offsets are UTF-16 and every original newline survives. */
330
+ export function redactCredentialText(text, filename = '') {
331
+ try {
332
+ if (typeof text !== 'string' || typeof filename !== 'string' || /\0/.test(text)) fail();
333
+ const sourcePath = credentialSourcePath(filename);
334
+ const base = sourcePath.split('/').pop();
335
+ const ranges = [];
336
+ if (/\.(?:jsonl|ndjson)$/.test(base)) {
337
+ let offset = 0;
338
+ for (const line of text.split('\n')) { jsonRanges(line, ranges, offset); offset += line.length + 1; }
339
+ } else if (/\.jsonc?$/.test(base)) jsonRanges(text, ranges);
340
+ else if (/\.ya?ml$/.test(base) || /(?:^|\/)\.kube\/config$/.test(sourcePath)) yamlRanges(text, ranges);
341
+ else if (/\.toml$/.test(base)) tomlRanges(text, ranges);
342
+ else if (/\.(?:json5|xml|plist|hcl|tfvars|edn)$/.test(base)) fail();
343
+ else if (/^[\s\uFEFF]*(?:\{|\[\s*(?:\{|"))/.test(text)) jsonRanges(text, ranges);
344
+ else {
345
+ const config = /^(?:\.env(?:\..*)?|\.npmrc|\.gitconfig|\.pypirc|credentials|config)$/.test(base)
346
+ || /\.(?:env|ini|conf|cfg|properties)$/.test(base);
347
+ ranges.push(...literalRanges(text), ...assignmentRanges(text, config ? 'config' : 'plain'));
348
+ }
349
+ const masked = new Uint8Array(text.length);
350
+ for (const [start, end] of ranges) {
351
+ if (end > text.length) fail();
352
+ masked.fill(1, start, end);
353
+ }
354
+ let redacted = false;
355
+ const result = text.split('');
356
+ for (let i = 0; i < result.length; i++) {
357
+ if (masked[i] && !whitespace(result[i]) && result[i] !== '*') { result[i] = '*'; redacted = true; }
358
+ }
359
+ return { text: result.join(''), redacted };
360
+ } catch { throw new Error(SAFE_ERROR); }
361
+ }
@@ -0,0 +1,115 @@
1
+ import { isAbsolute, relative, resolve } from 'node:path';
2
+ import { createHash } from 'node:crypto';
3
+ import { containsSecret, hardRisk } from './policy.js';
4
+ import { redactCredentialText } from './credential-text.js';
5
+
6
+ const MAX_FILES = 8;
7
+ const MAX_FILE_BYTES = 6144;
8
+ const MAX_BYTES = 12288;
9
+ const digest = bytes => createHash('sha256').update(bytes).digest('hex');
10
+
11
+ // This only locates possible evidence. It never interprets a shell, determines
12
+ // permission, or constructs an executable command. Unsupported syntax stays in
13
+ // the exact action for the reviewer. POSIX shell parsers would misread pwsh.
14
+ function fileHints(text) {
15
+ return (text.match(/"[^"\r\n]*"|'[^'\r\n]*'|[^\s;&|<>]+/g) ?? [])
16
+ .map(value => value.replace(/^["']|["']$/g, ''))
17
+ .filter(value => /\.(?:[cm]?js|tsx?|ps1|sh)$/i.test(value) && !/[\x00-\x1f$`*?{}]/.test(value));
18
+ }
19
+
20
+ /** Read bounded local evidence through the host's canonical filesystem seam.
21
+ * Repository code is evidence, never authority. No subprocesses or network.
22
+ */
23
+ export async function collectExecutionEvidence(call, fs, signal) {
24
+ const files = [], omissions = [], snapshots = [];
25
+ const unavailable = () => ({ data: { files, omissions: ['FILESYSTEM_EVIDENCE_UNAVAILABLE'], coverage: 'partial' }, verify: async () => true });
26
+ if (!['bash', 'pwsh'].includes(call.tool)) return { data: undefined, verify: async () => true };
27
+ if (!fs || ['resolve', 'contains', 'stat', 'readBytes', 'processPath'].some(key => typeof fs[key] !== 'function')) return unavailable();
28
+ const rootPath = call.sandbox?.workspaceRoot;
29
+ const workdir = call.reviewPaths?.workdir ?? call.args.workdir ?? call.cwd;
30
+ if (!isAbsolute(rootPath ?? '') || !isAbsolute(workdir ?? '')) return unavailable();
31
+ const root = await fs.resolve(rootPath, { signal });
32
+ const cwd = await fs.resolve(workdir, { signal });
33
+ if (!fs.contains(root, cwd)) return { data: { files, omissions: ['WORKDIR_OUTSIDE_WORKSPACE'], coverage: 'partial' }, verify: async () => true };
34
+ const canonicalRoot = fs.processPath(root), canonicalCwd = fs.processPath(cwd);
35
+ let bytes = 0;
36
+ const queued = new Set();
37
+ const queue = [];
38
+ const enqueue = (path, base = canonicalCwd) => {
39
+ const absolute = resolve(base, path);
40
+ if (queued.has(absolute)) return;
41
+ queued.add(absolute); queue.push(absolute);
42
+ };
43
+ enqueue('package.json');
44
+ if (canonicalCwd !== canonicalRoot) enqueue('package.json', canonicalRoot);
45
+ for (const path of fileHints(call.args.command)) enqueue(path);
46
+ // Manifests expose lifecycle hooks as well as script definitions. Follow only
47
+ // local file references in relevant scripts; no transitive dependency audit.
48
+ const scriptNames = new Set(call.args.command.match(/[A-Za-z0-9_:-]+/g) ?? []);
49
+ if (scriptNames.has('install')) for (const name of ['preinstall', 'postinstall', 'prepare']) scriptNames.add(name);
50
+ for (let i = 0; i < queue.length; i++) {
51
+ signal.throwIfAborted();
52
+ const path = queue[i];
53
+ if (snapshots.length >= MAX_FILES || bytes >= MAX_BYTES) { omissions.push('EVIDENCE_LIMIT'); break; }
54
+ if (hardRisk({ tool: 'read', args: { file_path: path } })) { omissions.push('PROTECTED_EVIDENCE'); continue; }
55
+ try {
56
+ const target = await fs.resolve(path, { signal });
57
+ const canonical = fs.processPath(target);
58
+ if (!fs.contains(root, target) || hardRisk({ tool: 'read', args: { file_path: canonical } })) { omissions.push('EVIDENCE_OUTSIDE_WORKSPACE_OR_PROTECTED'); continue; }
59
+ const before = await fs.stat(target, signal);
60
+ if (!before) { snapshots.push({ path, canonical, absent: true }); continue; }
61
+ if (before.type !== 'file') { omissions.push('EVIDENCE_NOT_REGULAR_FILE'); continue; }
62
+ const remaining = Math.min(MAX_FILE_BYTES, MAX_BYTES - bytes);
63
+ const contentBytes = await fs.readBytes(target, signal, remaining);
64
+ if (!(contentBytes instanceof Uint8Array) || contentBytes.byteLength > remaining) throw new Error('EVIDENCE_PROVIDER_UNBOUNDED');
65
+ const after = await fs.stat(target, signal);
66
+ if (after?.type !== 'file' || before.version !== after.version) throw new Error('EVIDENCE_CHANGED');
67
+ const hash = digest(contentBytes);
68
+ snapshots.push({ path, canonical, version: after.version, hash });
69
+ let content;
70
+ try { content = new TextDecoder('utf-8', { fatal: true }).decode(contentBytes); }
71
+ catch { omissions.push('NON_TEXT_EVIDENCE_WITHHELD'); continue; }
72
+ // UTF-16/NUL and other binary/control encodings must not bypass secret
73
+ // screening or make the model review a different source representation.
74
+ if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(content)) { omissions.push('NON_TEXT_EVIDENCE_WITHHELD'); continue; }
75
+ if (containsSecret(content) || redactCredentialText(content, canonical).redacted) { omissions.push('SENSITIVE_EVIDENCE_WITHHELD'); continue; }
76
+ // JSON keys can use escape sequences: screen their decoded form too.
77
+ let manifest;
78
+ if (path.endsWith('package.json')) {
79
+ manifest = JSON.parse(content);
80
+ if (containsSecret(JSON.stringify(manifest))) { omissions.push('SENSITIVE_EVIDENCE_WITHHELD'); continue; }
81
+ }
82
+ bytes += Buffer.byteLength(content);
83
+ files.push({ path: relative(canonicalRoot, canonical), content, sha256: hash });
84
+ if (path.endsWith('package.json')) {
85
+ for (const [name, command] of Object.entries(manifest.scripts ?? {})) {
86
+ if (!scriptNames.has(name) || typeof command !== 'string') continue;
87
+ for (const hint of fileHints(command)) enqueue(hint, resolve(path, '..'));
88
+ }
89
+ }
90
+ } catch (error) {
91
+ signal.throwIfAborted();
92
+ if (['EVIDENCE_CHANGED', 'EVIDENCE_PROVIDER_UNBOUNDED'].includes(error.message)) throw error;
93
+ omissions.push(error.code === 'FS_TOO_LARGE' ? 'EVIDENCE_TOO_LARGE' : 'EVIDENCE_UNREADABLE');
94
+ }
95
+ }
96
+ const data = { files, omissions: [...new Set(omissions)], coverage: 'partial' };
97
+ return { data, async verify(signalForVerification = signal) {
98
+ const signal = signalForVerification;
99
+ signal.throwIfAborted();
100
+ // Re-resolve containment and reread digests, including previously absent
101
+ // manifests. Stat versions alone are not a content identity guarantee.
102
+ const nextRoot = await fs.resolve(rootPath, { signal });
103
+ const nextCwd = await fs.resolve(workdir, { signal });
104
+ if (fs.processPath(nextRoot) !== canonicalRoot || fs.processPath(nextCwd) !== canonicalCwd || !fs.contains(nextRoot, nextCwd)) return false;
105
+ for (const saved of snapshots) {
106
+ const target = await fs.resolve(saved.path, { signal });
107
+ if (fs.processPath(target) !== saved.canonical || !fs.contains(nextRoot, target)) return false;
108
+ const info = await fs.stat(target, signal);
109
+ if (saved.absent) { if (info) return false; continue; }
110
+ if (info?.type !== 'file' || info.version !== saved.version) return false;
111
+ if (digest(await fs.readBytes(target, signal, MAX_FILE_BYTES)) !== saved.hash) return false;
112
+ }
113
+ return true;
114
+ } };
115
+ }
package/src/gate.js CHANGED
@@ -1,73 +1,79 @@
1
- import { assess } from './policy.js';
2
- import { assessEscalation } from './escalation.js';
3
1
  import { assessApproval } from './approval-review.js';
4
2
  import { review } from './reviewer.js';
5
- import { resolveReviewRoutes, sameRoutes } from './model-route.js';
3
+ import { resolveReviewRoute, sameRoute } from './model-route.js';
4
+ const REVIEW_FAILURES = new Set(['REVIEW_TIMEOUT', 'REVIEW_CANCELLED', 'REVIEW_BUDGET_EXHAUSTED',
5
+ 'REVIEW_INPUT_TOO_LARGE', 'NATIVE_REVIEWER_UNAVAILABLE', 'CONVERSATION_MODEL_UNAVAILABLE',
6
+ 'MISSING_OR_SENSITIVE_AUTHORITY', 'SENSITIVE_REVIEW_CONTEXT', 'SENSITIVE_ACTION', 'SENSITIVE_REVIEWER_PROMPT']);
7
+ const failureCode = error => REVIEW_FAILURES.has(error?.message) ? error.message
8
+ : /^(?:INVALID_|MISSING_COMPLETION|UNEXPECTED_|INCONSISTENT_|DUPLICATE_|REVIEW_RESPONSE_TOO_LARGE)/.test(error?.message ?? '')
9
+ ? 'INVALID_REVIEW_RESPONSE' : 'REVIEW_UNAVAILABLE';
6
10
 
7
- /** Per-instance, per-session budgets. Model changes never reset the session budget. */
8
- export function createGate(config, { fetcher = globalThis.fetch, audit = () => {}, getLlm = () => undefined } = {}) {
11
+ /** Per-instance, per-session budgets. Model changes never reset the ledger. */
12
+ export function createGate(config, { audit = () => {}, getLlm = () => undefined } = {}) {
9
13
  const sessions = new WeakMap();
10
14
  const abort = new AbortController();
11
15
  function state(session, task) {
12
16
  let s = sessions.get(session);
13
17
  if (!s) {
14
- s = { task, fastCalls: 0, deepCalls: 0, units: 0, reportedTokens: 0, consecutive: 0, denials: 0 };
18
+ s = { task, reviews: 0, consecutive: 0, reportedTokens: 0 };
15
19
  sessions.set(session, s);
16
20
  }
21
+ // A new direct user message resets the per-task budget and the denial streak.
17
22
  if (s.task !== task) {
18
- s.task = task; s.fastCalls = 0; s.deepCalls = 0; s.consecutive = 0;
19
- // Neither the session budget nor the total-denial fuse is reset by a new user message.
23
+ s.task = task; s.reviews = 0; s.consecutive = 0;
20
24
  }
21
25
  return s;
22
26
  }
23
27
  return {
24
28
  dispose() { abort.abort(); },
25
- async decide(call, phase = 'preflight', currentConfig = config) {
29
+ humanAllowed(call) {
30
+ const s = sessions.get(call.session);
31
+ if (s?.task === call.task) s.consecutive = 0;
32
+ },
33
+ async decide(call, currentConfig = config) {
26
34
  // One ledger survives reviewer preference changes; settings edits do not refill budgets.
27
35
  const config = currentConfig;
28
36
  const start = Date.now();
29
- let result = phase === 'approval' ? assessApproval(call, config)
30
- : phase === 'escalation' ? assessEscalation(call, config) : assess(call, config);
37
+ let result = assessApproval(call, config);
31
38
  let s;
32
39
  if (result.kind === 'review') {
33
40
  if (!call.session || call.subagent) result = { kind: 'ask', code: 'NO_DIRECT_USER_AUTHORITY' };
34
41
  else {
35
42
  s = state(call.session, call.task);
36
- if (s.consecutive >= config.consecutiveDenials || s.denials >= config.totalDenials) {
43
+ if (s.consecutive >= config.consecutiveDenials) {
37
44
  result = { kind: 'ask', code: 'CIRCUIT_OPEN' };
38
45
  } else {
39
46
  const oldTask = s.task;
40
47
  try {
41
48
  const signal = AbortSignal.any([call.signal, abort.signal]);
42
- const routes = resolveReviewRoutes(config, call);
43
- const llm = routes.fast.transport === 'dsh' ? getLlm() : undefined;
44
- const verdict = await review(config, result.action, call.intent, s, signal, fetcher, { routes, llm, structured: phase === 'approval' });
45
- const kind = typeof verdict === 'string' ? verdict : verdict.decision;
46
- const details = typeof verdict === 'string' ? {} : {
47
- risk: verdict.risk, authorization: verdict.authorization, bounded: verdict.bounded, reason: verdict.reason,
48
- };
49
+ const route = resolveReviewRoute(config, call);
50
+ const llm = getLlm();
51
+ const verdict = await review(config, result.action, call.intent, s, signal, { route, llm,
52
+ authorizationContext: call.authorizationContext, executionEvidence: call.executionEvidence });
53
+ const details = { risk: verdict.risk, authorization: verdict.authorization, bounded: verdict.bounded, reason: verdict.reason };
49
54
  if (signal.aborted || s.task !== oldTask) result = { kind: 'deny', code: 'STALE_REVIEW' };
50
- else if (kind === 'deny') result = { kind: 'deny', code: 'MODEL_NOT_ALLOWED', ...details };
51
- else if (!sameRoutes(routes, resolveReviewRoutes(config, call)) || (llm && llm !== getLlm())) {
55
+ else if (verdict.decision === 'deny') result = { kind: 'deny', code: 'MODEL_NOT_ALLOWED', ...details };
56
+ else if (!sameRoute(route, resolveReviewRoute(config, call)) || llm !== getLlm()) {
52
57
  result = { kind: 'ask', code: 'REVIEW_MODEL_CHANGED' };
53
- } else result = { kind, code: kind === 'allow' ? 'MODEL_ALLOWED' : 'MODEL_NOT_ALLOWED', ...details,
54
- ...(kind === 'allow' ? { reviewRoutes: routes, reviewLlm: llm } : {}),
58
+ } else result = { kind: verdict.decision, code: verdict.decision === 'allow' ? 'MODEL_ALLOWED' : 'REVIEW_NEEDS_CONFIRMATION', ...details,
59
+ ...(verdict.decision === 'allow' ? { reviewRoute: route, reviewLlm: llm } : {}),
55
60
  };
56
- } catch { result = { kind: 'ask', code: 'REVIEW_UNAVAILABLE' }; }
57
- if (result.kind === 'allow') s.consecutive = 0;
58
- else { s.consecutive++; s.denials++; }
61
+ } catch (error) { result = { kind: 'ask', code: failureCode(error) }; }
62
+ // Missing evidence or a transport failure is not a semantic denial.
63
+ // Reservations remain charged, so repeated uncertainty is bounded.
64
+ if (result.kind === 'deny') s.consecutive++;
65
+ else s.consecutive = 0;
59
66
  }
60
67
  }
61
68
  }
62
69
  if (call.signal.aborted || abort.signal.aborted) result = { kind: 'cancel', code: 'CANCELLED' };
63
- if (config.mode === 'unattended' && result.kind === 'ask') result = { ...result, kind: 'deny' };
64
- // Audit failures cannot accidentally grant permission. Do not include arguments, intent, endpoint or errors.
65
- try { audit({ phase: 'assessment', gate: phase, tool: call.tool, callId: call.callId, decision: result.kind, code: result.code,
66
- mode: config.mode, durationMs: Date.now() - start,
70
+ // Audit failures cannot accidentally grant permission. Do not include arguments, intent or errors.
71
+ try { audit({ phase: 'assessment', tool: call.tool, callId: call.callId, decision: result.kind, code: result.code,
72
+ durationMs: Date.now() - start,
67
73
  // Rationale is kept in the result, not logs: a model can echo raw commands or secrets.
68
74
  ...(result.risk ? { risk: result.risk, authorization: result.authorization, bounded: result.bounded,
69
75
  reason: '[reviewer rationale withheld from audit]' } : {}),
70
- ...(s ? { reservedUnits: s.units, reportedTokens: s.reportedTokens, fastCalls: s.fastCalls, deepCalls: s.deepCalls } : {}),
76
+ ...(s ? { reviews: s.reviews, reportedTokens: s.reportedTokens } : {}),
71
77
  }); } catch { result = { kind: 'deny', code: 'AUDIT_UNAVAILABLE' }; }
72
78
  return result;
73
79
  },