@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.
- package/README.md +25 -8
- package/README.zh-CN.md +25 -8
- package/docs/ADR-0006.md +12 -4
- package/dsh.plugin.json +1 -1
- package/lib/client.js +420 -49
- package/package.json +12 -8
- package/src/approval-review.js +6 -2
- package/src/control.js +3 -3
- package/src/credential-paths.js +29 -0
- package/src/credential-reads.js +227 -0
- package/src/credential-text.js +361 -0
- package/src/execution-evidence.js +3 -2
- package/src/host-runtime.js +159 -0
- package/src/index.js +24 -14
- package/src/policy.js +24 -8
- package/src/review-context.js +4 -0
- package/src/review-paths.js +53 -0
- package/src/shell-command.js +48 -1
- package/src/ui-host.js +19 -1
|
@@ -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 {
|
|
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 (
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
call.localExecution
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
//
|
|
206
|
-
//
|
|
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 {
|
|
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);
|