@klarkxy/dsh-safe-auto 0.2.0-rc.3 → 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
+ }
@@ -1,6 +1,7 @@
1
1
  import { isAbsolute, relative, resolve } from 'node:path';
2
2
  import { createHash } from 'node:crypto';
3
3
  import { containsSecret, hardRisk } from './policy.js';
4
+ import { redactCredentialText } from './credential-text.js';
4
5
 
5
6
  const MAX_FILES = 8;
6
7
  const MAX_FILE_BYTES = 6144;
@@ -25,7 +26,7 @@ export async function collectExecutionEvidence(call, fs, signal) {
25
26
  if (!['bash', 'pwsh'].includes(call.tool)) return { data: undefined, verify: async () => true };
26
27
  if (!fs || ['resolve', 'contains', 'stat', 'readBytes', 'processPath'].some(key => typeof fs[key] !== 'function')) return unavailable();
27
28
  const rootPath = call.sandbox?.workspaceRoot;
28
- const workdir = call.args.workdir ?? call.cwd;
29
+ const workdir = call.reviewPaths?.workdir ?? call.args.workdir ?? call.cwd;
29
30
  if (!isAbsolute(rootPath ?? '') || !isAbsolute(workdir ?? '')) return unavailable();
30
31
  const root = await fs.resolve(rootPath, { signal });
31
32
  const cwd = await fs.resolve(workdir, { signal });
@@ -71,7 +72,7 @@ export async function collectExecutionEvidence(call, fs, signal) {
71
72
  // UTF-16/NUL and other binary/control encodings must not bypass secret
72
73
  // screening or make the model review a different source representation.
73
74
  if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(content)) { omissions.push('NON_TEXT_EVIDENCE_WITHHELD'); continue; }
74
- if (containsSecret(content)) { omissions.push('SENSITIVE_EVIDENCE_WITHHELD'); continue; }
75
+ if (containsSecret(content) || redactCredentialText(content, canonical).redacted) { omissions.push('SENSITIVE_EVIDENCE_WITHHELD'); continue; }
75
76
  // JSON keys can use escape sequences: screen their decoded form too.
76
77
  let manifest;
77
78
  if (path.endsWith('package.json')) {
@@ -0,0 +1,159 @@
1
+ import { lstatSync, realpathSync } from 'node:fs';
2
+ import { isAbsolute, normalize } from 'node:path';
3
+
4
+ const DEPENDENCIES_TOOL = 'load_workspace_dependencies';
5
+ const MAX_PATH = 4096;
6
+ const MAX_COMMAND = 32768;
7
+ const attestations = new WeakMap();
8
+ const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
9
+ const pathKey = value => process.platform === 'win32' ? normalize(value).toLowerCase() : normalize(value);
10
+ const absolutePath = value => typeof value === 'string' && value.length > 0 && value.length <= MAX_PATH &&
11
+ !/[\x00-\x1f]/.test(value) && isAbsolute(value) && !value.startsWith('\\\\') && !value.startsWith('//') &&
12
+ !value.split(/[\\/]/).includes('..');
13
+
14
+ /** Local host paths only. A symlink/junction alias is not an attested entry.
15
+ * This checks file identity, not executable safety, and grants no permission.
16
+ * No directory traversal or executable hashing is performed.
17
+ */
18
+ export function snapshotHostRuntimeFile(path) {
19
+ if (!absolutePath(path)) return undefined;
20
+ try {
21
+ const canonical = realpathSync.native(path);
22
+ const stat = lstatSync(path, { bigint: true });
23
+ if (!stat.isFile() || stat.isSymbolicLink() || pathKey(canonical) !== pathKey(path)) return undefined;
24
+ return Object.freeze({ path, identity: [canonical, stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs, stat.mode].join(':') });
25
+ } catch { return undefined; }
26
+ }
27
+
28
+ function dependencyPair(value) {
29
+ if (!record(value) || Object.keys(value).some(key => !['python', 'node', 'pnpm', 'pythonPackages', 'nodePackages', 'pythonDistributions'].includes(key))) return undefined;
30
+ if (![value.python, value.pythonPackages, value.node, value.pnpm].every(absolutePath) ||
31
+ value.nodePackages !== undefined && !absolutePath(value.nodePackages) || !record(value.pythonDistributions)) return undefined;
32
+ if (pathKey(value.node) === pathKey(value.pnpm)) return undefined;
33
+ return { node: value.node, pnpm: value.pnpm };
34
+ }
35
+
36
+ /** Observe the native tools pipeline, never transcript/content text or replay.
37
+ * begin is called from tools/pre-execute; observe from tools/result; get from
38
+ * the shell guard. tools.get(global) and tools.get(agent) must be the SAME
39
+ * pinned definition throughout. Scope shadows and registration changes fail
40
+ * closed. Host plugins themselves remain part of the trusted host boundary.
41
+ */
42
+ export function createHostRuntimeRegistry(tools, { snapshot = snapshotHostRuntimeFile } = {}) {
43
+ let owner = tools?.get?.(DEPENDENCIES_TOOL);
44
+ let disposed = false;
45
+ const pending = new Map(), sessions = new WeakMap();
46
+ const definitionMatches = agent => !disposed && !!owner && tools?.get?.(DEPENDENCIES_TOOL) === owner &&
47
+ tools?.get?.(DEPENDENCIES_TOOL, agent) === owner;
48
+ const forgetPending = token => {
49
+ const item = pending.get(token);
50
+ if (item) { pending.delete(token); item.signal?.removeEventListener('abort', item.abort); }
51
+ return item;
52
+ };
53
+ const valid = (agent, entry) => {
54
+ if (sessions.get(agent) !== entry || !definitionMatches(agent)) return false;
55
+ try {
56
+ return entry.files.every(file => snapshot(file.path)?.identity === file.identity);
57
+ } catch { return false; }
58
+ };
59
+ return {
60
+ begin(exec) {
61
+ if (disposed) return;
62
+ if (exec?.name !== DEPENDENCIES_TOOL || !record(exec.agent) || typeof exec.token !== 'symbol' || exec.signal?.aborted) return;
63
+ forgetPending(exec.token);
64
+ owner ??= tools?.get?.(DEPENDENCIES_TOOL);
65
+ if (!definitionMatches(exec.agent) || !record(exec.arguments) || Object.keys(exec.arguments).length) return;
66
+ // Bound interrupted-call retention without relying on a lifecycle event.
67
+ if (pending.size >= 64) forgetPending(pending.keys().next().value);
68
+ const item = { agent: exec.agent, signal: exec.signal, abort: () => forgetPending(exec.token) };
69
+ pending.set(exec.token, item);
70
+ exec.signal?.addEventListener('abort', item.abort, { once: true });
71
+ },
72
+ observe(exec, result) {
73
+ if (disposed) return;
74
+ if (exec?.name !== DEPENDENCIES_TOOL || !record(exec.agent)) return;
75
+ const started = forgetPending(exec.token);
76
+ sessions.delete(exec.agent);
77
+ if (!started || started.agent !== exec.agent || exec.signal?.aborted || result?.isError !== false || !definitionMatches(exec.agent)) return;
78
+ const pair = dependencyPair(result.value);
79
+ if (!pair) return;
80
+ let files;
81
+ try { files = [snapshot(pair.node), snapshot(pair.pnpm)]; } catch { return; }
82
+ if (files.some(file => !file || typeof file.identity !== 'string')) return;
83
+ const runtime = Object.freeze({ ...pair, identity: Object.freeze(files.map(file => file.identity)) });
84
+ const entry = { runtime, files };
85
+ sessions.set(exec.agent, entry);
86
+ attestations.set(runtime, () => valid(exec.agent, entry));
87
+ },
88
+ get(exec) {
89
+ if (disposed) return undefined;
90
+ if (!record(exec?.agent) || exec.signal?.aborted) return undefined;
91
+ const entry = sessions.get(exec.agent);
92
+ if (!entry) return undefined;
93
+ if (!valid(exec.agent, entry)) { sessions.delete(exec.agent); return undefined; }
94
+ return entry.runtime;
95
+ },
96
+ dispose() {
97
+ disposed = true;
98
+ owner = undefined;
99
+ for (const token of pending.keys()) forgetPending(token);
100
+ },
101
+ };
102
+ }
103
+
104
+ /** A deliberately small single-invocation grammar, not a general shell parser.
105
+ * No interpolation, escapes, concatenated quotes, wrappers, redirections,
106
+ * pipelines, assignments or additional commands can earn an exemption.
107
+ * Single-quoted content is literal in both shells. For PowerShell, doubled
108
+ * quotes are deliberately unsupported; unsupported syntax keeps full policy.
109
+ */
110
+ function literalInvocation(command, tool) {
111
+ if (typeof command !== 'string' || !command.length || command.length > MAX_COMMAND || /[\x00-\x1f]/.test(command)) return undefined;
112
+ const powershell = tool === 'pwsh';
113
+ if (!powershell && tool !== 'bash') return undefined;
114
+ let cursor = 0;
115
+ const spaces = () => { while (command[cursor] === ' ') cursor++; };
116
+ spaces();
117
+ let callOperator = false;
118
+ if (powershell && command[cursor] === '&') { callOperator = true; cursor++; spaces(); }
119
+ const tokens = [];
120
+ while (cursor < command.length) {
121
+ const start = cursor, quote = command[cursor];
122
+ let value;
123
+ if (quote === "'" || quote === '"') {
124
+ cursor++;
125
+ const from = cursor;
126
+ while (cursor < command.length && command[cursor] !== quote) cursor++;
127
+ if (cursor === command.length) return undefined;
128
+ value = command.slice(from, cursor++);
129
+ if (quote === '"' && (powershell ? /[$`]/ : /[$`\\]/).test(value)) return undefined;
130
+ if (powershell && tokens.length === 0 && !callOperator) return undefined;
131
+ if (cursor < command.length && command[cursor] !== ' ') return undefined;
132
+ } else {
133
+ while (cursor < command.length && command[cursor] !== ' ') cursor++;
134
+ value = command.slice(start, cursor);
135
+ if (!(powershell ? /^[A-Za-z0-9_.\/:\\=-]+$/ : /^[A-Za-z0-9_.\/:=-]+$/).test(value)) return undefined;
136
+ }
137
+ tokens.push({ start, end: cursor, value });
138
+ spaces();
139
+ }
140
+ return tokens.length >= 2 ? tokens : undefined;
141
+ }
142
+
143
+ /** Return a screening-only projection. NEVER execute it or send it for review.
144
+ * Only the attested Node argv[0] + pnpm argv[1] pair is removed; every data
145
+ * argument remains verbatim. This does not classify pnpm or its scripts safe.
146
+ */
147
+ export function commandForPolicy(call) {
148
+ const command = call?.args?.command;
149
+ const runtime = call?.trustedRuntime;
150
+ const validate = runtime && attestations.get(runtime);
151
+ if (!validate || !validate()) return command;
152
+ const tokens = literalInvocation(command, call.tool);
153
+ if (!tokens || tokens[0].value !== runtime.node || tokens[1].value !== runtime.pnpm) return command;
154
+ // The validated PowerShell call operator is omitted from this projection:
155
+ // the policy's refusal decoder parses POSIX commands and otherwise misses
156
+ // quoted sensitive operands after a leading '&'. The real call is intact.
157
+ return '__DSH_HOST_NODE__' +
158
+ command.slice(tokens[0].end, tokens[1].start) + '__DSH_HOST_PNPM__' + command.slice(tokens[1].end);
159
+ }
package/src/index.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks';
2
- import { isAbsolute } from 'node:path';
2
+ import { resolveReviewPaths } from './review-paths.js';
3
+ import { createHostRuntimeRegistry } from './host-runtime.js';
3
4
  import { isSubagentSession } from '@klarkxy/dsh-plugin-kit/contracts';
4
5
  import { parseConfig } from './config.js';
5
6
  import { hardRisk, containsSecret } from './policy.js';
6
- import { analyzeShell, isReadonly } from './shell-command.js';
7
+ import { analyzeShell, isReadonly, isSafeStaticExposure } from './shell-command.js';
8
+ import { createCredentialReads } from './credential-reads.js';
7
9
  import { assessApproval, bindingOf, escalationReason } from './approval-review.js';
8
10
  import { createGate } from './gate.js';
9
11
  import { resolveReviewRoute, sameRoute } from './model-route.js';
@@ -52,6 +54,8 @@ export function apply(ctx, raw = {}) {
52
54
  const decisions = new Map();
53
55
  const execution = new AsyncLocalStorage();
54
56
  const lifetime = new AbortController();
57
+ const hostRuntime = createHostRuntimeRegistry(ctx.tools);
58
+ const credentialReads = createCredentialReads(ctx, lifetime.signal);
55
59
  let llm;
56
60
  // Optional dependency: losing the model service must NOT unload the safety guards.
57
61
  if (typeof ctx.inject === 'function') ctx.inject(['llm'], scope => {
@@ -60,7 +64,7 @@ export function apply(ctx, raw = {}) {
60
64
  scope.effect(() => () => { if (llm === active) llm = undefined; });
61
65
  });
62
66
  const gate = createGate(config, { getLlm: () => llm, audit: row => ctx.logger.info('safe-auto %s', JSON.stringify(row)) });
63
- ctx.effect(() => () => { lifetime.abort(); humanApprovals.dispose(); gate.dispose(); decisions.clear(); execution.disable(); });
67
+ ctx.effect(() => () => { lifetime.abort(); hostRuntime.dispose(); humanApprovals.dispose(); gate.dispose(); decisions.clear(); execution.disable(); });
64
68
 
65
69
  const reviewable = exec => REVIEW_TOOLS.has(exec.name) && ESCALATION.has(exec.arguments?.sandbox_permissions);
66
70
 
@@ -72,13 +76,13 @@ export function apply(ctx, raw = {}) {
72
76
  cwd: session?.header?.cwd, sandbox, signal: exec.signal,
73
77
  subagent: isSubagentSession(session),
74
78
  nested: exec.parent !== undefined, ...info };
75
- if (reviewable(exec)) {
79
+ if (REVIEW_TOOLS.has(exec.name)) {
76
80
  // These are optional capability reads, not model assertions about its environment.
77
81
  const fs = ctx.get?.('fs');
78
82
  const shell = ctx.get?.('shell');
79
- const paths = [call.cwd, ...(['bash', 'pwsh'].includes(call.tool)
80
- ? (call.args.workdir === undefined ? [] : [call.args.workdir]) : [call.args.file_path])];
81
- call.localExecution = paths.every(p => typeof p === 'string' && isAbsolute(p) && fs?.processPathFromHostPath?.(p) === p);
83
+ call.reviewPaths = resolveReviewPaths(call, fs);
84
+ call.localExecution = call.reviewPaths.localExecution;
85
+ if (call.localExecution && ['bash', 'pwsh'].includes(call.tool)) call.trustedRuntime = hostRuntime.get(exec);
82
86
  call.jobsAvailable = typeof ctx.get !== 'function' || ctx.get('jobs') !== undefined;
83
87
  call.shellConfined = ['read-only', 'workspace-write', 'danger-full-access'].includes(shell?.sandboxMode);
84
88
  }
@@ -105,6 +109,7 @@ export function apply(ctx, raw = {}) {
105
109
  });
106
110
 
107
111
  ctx.on('tools/pre-execute', async (exec, next) => {
112
+ hostRuntime.begin(exec);
108
113
  const config = configFor(exec);
109
114
  if (!config.enabled) return next();
110
115
  if (exec.signal.aborted || lifetime.signal.aborted) return { kind: 'cancel' };
@@ -117,14 +122,16 @@ export function apply(ctx, raw = {}) {
117
122
  // Bind the approval to the active execution, never a process-wide visible callId cache.
118
123
  ctx.on('tools/execute', async (exec, next) => {
119
124
  const config = configFor(exec);
120
- if (!config.enabled || !reviewable(exec)) return next();
125
+ if (!config.enabled) return next();
126
+ const run = () => credentialReads.execute(exec, next);
127
+ if (!reviewable(exec)) return run();
121
128
  const call = callOf(exec, true);
122
129
  const saved = { kind: 'approval', binding: bindingOf(call, assessApproval(call, config)), signal: exec.signal, controlBinding: controlBinding(exec) };
123
- if (decisions.size >= 1024) return next();
130
+ if (decisions.size >= 1024) return run();
124
131
  decisions.set(exec.token, saved);
125
132
  const store = { exec, saved, active: true, claimed: false };
126
133
  try {
127
- const result = await execution.run(store, next);
134
+ const result = await execution.run(store, run);
128
135
  // Around-dispatch may return result content; tools/result is a read-only
129
136
  // observer in the pinned host. Expose recovery through the actual result.
130
137
  if (result?.isError && saved.feedback) return { ...result,
@@ -135,6 +142,7 @@ export function apply(ctx, raw = {}) {
135
142
  }, { prepend: true });
136
143
 
137
144
  ctx.on('tools/result', (exec, result) => {
145
+ hostRuntime.observe(exec, result);
138
146
  decisions.delete(exec.token);
139
147
  ctx.logger.info('safe-auto %s', JSON.stringify({ phase: 'result', tool: exec.name,
140
148
  callId: String(exec.callId), isError: result?.isError }));
@@ -202,12 +210,14 @@ export function apply(ctx, raw = {}) {
202
210
  // structural, secret and host constraints under the original live binding.
203
211
  const manualAssessment = assessment.code === 'AUTHORITY_CONTEXT_INCOMPLETE'
204
212
  ? assessApproval({ ...callOf(exec, true), authorityIncomplete: false }, config) : assessment;
205
- // Static proof, no model: a fully literal bash parse where every invocation
206
- // is provably read-only skips evidence and review entirely. pwsh and
207
- // unparseable commands always fall through to the reviewer.
213
+ // No mutation is only half the proof: content/environment dumps can expose
214
+ // credentials. Only metadata/literal output may skip evidence and review.
208
215
  let staticAllow = false;
209
216
  if (assessment.kind === 'review' && config.staticReadonly && exec.name === 'bash') {
210
- try { staticAllow = isReadonly(analyzeShell(exec.arguments?.command)); } catch { staticAllow = false; }
217
+ try {
218
+ const analysis = analyzeShell(exec.arguments?.command);
219
+ staticAllow = isReadonly(analysis) && isSafeStaticExposure(analysis);
220
+ } catch { staticAllow = false; }
211
221
  }
212
222
  try {
213
223
  const call = callOf(exec, true);