@getmarrow/mcp 3.9.102 → 3.9.106
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 +71 -24
- package/dist/approval-reply.d.ts +2 -0
- package/dist/approval-reply.d.ts.map +1 -0
- package/dist/approval-reply.js +18 -0
- package/dist/approval-reply.js.map +1 -0
- package/dist/cli.js +1716 -1644
- package/dist/cli.js.map +1 -1
- package/dist/codex-plugin-pins.d.ts +19 -0
- package/dist/codex-plugin-pins.d.ts.map +1 -0
- package/dist/codex-plugin-pins.js +61 -0
- package/dist/codex-plugin-pins.js.map +1 -0
- package/dist/codex-plugins.d.ts +34 -0
- package/dist/codex-plugins.d.ts.map +1 -0
- package/dist/codex-plugins.js +240 -0
- package/dist/codex-plugins.js.map +1 -0
- package/dist/env.d.ts +8 -0
- package/dist/env.d.ts.map +1 -1
- package/dist/env.js +83 -4
- package/dist/env.js.map +1 -1
- package/dist/hook-context.d.ts.map +1 -1
- package/dist/hook-context.js +25 -0
- package/dist/hook-context.js.map +1 -1
- package/dist/hook-contract.d.ts +4 -3
- package/dist/hook-contract.d.ts.map +1 -1
- package/dist/hook-contract.js +43 -5
- package/dist/hook-contract.js.map +1 -1
- package/dist/hook-pre-action.d.ts +80 -3
- package/dist/hook-pre-action.d.ts.map +1 -1
- package/dist/hook-pre-action.js +435 -29
- package/dist/hook-pre-action.js.map +1 -1
- package/dist/hook-tool-policy.d.ts +73 -4
- package/dist/hook-tool-policy.d.ts.map +1 -1
- package/dist/hook-tool-policy.js +287 -18
- package/dist/hook-tool-policy.js.map +1 -1
- package/dist/hook.d.ts +1 -1
- package/dist/hook.d.ts.map +1 -1
- package/dist/hook.js +15 -7
- package/dist/hook.js.map +1 -1
- package/dist/host-approval-state.d.ts +7 -0
- package/dist/host-approval-state.d.ts.map +1 -1
- package/dist/host-approval-state.js +15 -0
- package/dist/host-approval-state.js.map +1 -1
- package/dist/host-approval.d.ts +22 -1
- package/dist/host-approval.d.ts.map +1 -1
- package/dist/host-approval.js +65 -4
- package/dist/host-approval.js.map +1 -1
- package/dist/host-session.d.ts +11 -0
- package/dist/host-session.d.ts.map +1 -1
- package/dist/host-session.js +49 -0
- package/dist/host-session.js.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +23 -11
- package/dist/index.js.map +1 -1
- package/dist/keyless-server.d.ts +53 -0
- package/dist/keyless-server.d.ts.map +1 -0
- package/dist/keyless-server.js +230 -0
- package/dist/keyless-server.js.map +1 -0
- package/dist/normalized-action.d.ts +31 -6
- package/dist/normalized-action.d.ts.map +1 -1
- package/dist/normalized-action.js +550 -105
- package/dist/normalized-action.js.map +1 -1
- package/dist/own-server-identity.d.ts +142 -0
- package/dist/own-server-identity.d.ts.map +1 -0
- package/dist/own-server-identity.js +1123 -0
- package/dist/own-server-identity.js.map +1 -0
- package/dist/own-tool.d.ts +21 -9
- package/dist/own-tool.d.ts.map +1 -1
- package/dist/own-tool.js +25 -10
- package/dist/own-tool.js.map +1 -1
- package/dist/redact.d.ts +23 -0
- package/dist/redact.d.ts.map +1 -1
- package/dist/redact.js +181 -8
- package/dist/redact.js.map +1 -1
- package/dist/seatbelt/classifier-pin.d.ts +15 -0
- package/dist/seatbelt/classifier-pin.d.ts.map +1 -0
- package/dist/seatbelt/classifier-pin.js +26 -0
- package/dist/seatbelt/classifier-pin.js.map +1 -0
- package/dist/seatbelt/env-dump.d.ts +45 -0
- package/dist/seatbelt/env-dump.d.ts.map +1 -0
- package/dist/seatbelt/env-dump.js +308 -0
- package/dist/seatbelt/env-dump.js.map +1 -0
- package/dist/seatbelt/fallback-copy.d.ts +16 -0
- package/dist/seatbelt/fallback-copy.d.ts.map +1 -0
- package/dist/seatbelt/fallback-copy.js +68 -0
- package/dist/seatbelt/fallback-copy.js.map +1 -0
- package/dist/seatbelt/guards.d.ts +10 -0
- package/dist/seatbelt/guards.d.ts.map +1 -0
- package/dist/seatbelt/guards.js +509 -0
- package/dist/seatbelt/guards.js.map +1 -0
- package/dist/seatbelt/hook.d.ts +91 -0
- package/dist/seatbelt/hook.d.ts.map +1 -0
- package/dist/seatbelt/hook.js +396 -0
- package/dist/seatbelt/hook.js.map +1 -0
- package/dist/seatbelt/longopts.d.ts +68 -0
- package/dist/seatbelt/longopts.d.ts.map +1 -0
- package/dist/seatbelt/longopts.js +377 -0
- package/dist/seatbelt/longopts.js.map +1 -0
- package/dist/seatbelt/mapper.d.ts +83 -0
- package/dist/seatbelt/mapper.d.ts.map +1 -0
- package/dist/seatbelt/mapper.js +10581 -0
- package/dist/seatbelt/mapper.js.map +1 -0
- package/dist/seatbelt/record.d.ts +94 -0
- package/dist/seatbelt/record.d.ts.map +1 -0
- package/dist/seatbelt/record.js +776 -0
- package/dist/seatbelt/record.js.map +1 -0
- package/dist/seatbelt/tables.d.ts +82 -0
- package/dist/seatbelt/tables.d.ts.map +1 -0
- package/dist/seatbelt/tables.js +352 -0
- package/dist/seatbelt/tables.js.map +1 -0
- package/dist/seatbelt-deploy.d.ts +100 -0
- package/dist/seatbelt-deploy.d.ts.map +1 -0
- package/dist/seatbelt-deploy.js +393 -0
- package/dist/seatbelt-deploy.js.map +1 -0
- package/dist/seatbelt-fs.d.ts +30 -0
- package/dist/seatbelt-fs.d.ts.map +1 -0
- package/dist/seatbelt-fs.js +94 -0
- package/dist/seatbelt-fs.js.map +1 -0
- package/dist/seatbelt-git.d.ts +212 -0
- package/dist/seatbelt-git.d.ts.map +1 -0
- package/dist/seatbelt-git.js +844 -0
- package/dist/seatbelt-git.js.map +1 -0
- package/dist/seatbelt-mode.d.ts +77 -0
- package/dist/seatbelt-mode.d.ts.map +1 -0
- package/dist/seatbelt-mode.js +496 -0
- package/dist/seatbelt-mode.js.map +1 -0
- package/dist/seatbelt-path.d.ts +123 -0
- package/dist/seatbelt-path.d.ts.map +1 -0
- package/dist/seatbelt-path.js +482 -0
- package/dist/seatbelt-path.js.map +1 -0
- package/dist/seatbelt-scripts.d.ts +109 -0
- package/dist/seatbelt-scripts.d.ts.map +1 -0
- package/dist/seatbelt-scripts.js +1060 -0
- package/dist/seatbelt-scripts.js.map +1 -0
- package/dist/secret-paths.d.ts +17 -0
- package/dist/secret-paths.d.ts.map +1 -0
- package/dist/secret-paths.js +32 -0
- package/dist/secret-paths.js.map +1 -0
- package/dist/session-loop-guard.d.ts.map +1 -1
- package/dist/session-loop-guard.js +15 -5
- package/dist/session-loop-guard.js.map +1 -1
- package/dist/tool-catalog.js +2 -2
- package/dist/tool-catalog.js.map +1 -1
- package/dist/types.d.ts +2 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -2
- package/server.json +3 -3
- package/vendor/classifier/MANIFEST.json +25 -0
- package/vendor/classifier/classifier.js +1356 -0
- package/vendor/classifier/policy-baseline.js +187 -0
- package/vendor/classifier/redact.js +59 -0
- package/vendor/classifier/shell-parse.js +455 -0
|
@@ -5,12 +5,13 @@ exports.looksLikeKey = looksLikeKey;
|
|
|
5
5
|
exports.classifySecretName = classifySecretName;
|
|
6
6
|
exports.normalizeShellCommand = normalizeShellCommand;
|
|
7
7
|
exports.deployCommandText = deployCommandText;
|
|
8
|
+
exports.shellToolRunOptionsKey = shellToolRunOptionsKey;
|
|
8
9
|
exports.normalizedHookAction = normalizedHookAction;
|
|
9
10
|
const node_crypto_1 = require("node:crypto");
|
|
10
11
|
const node_os_1 = require("node:os");
|
|
11
12
|
const hook_tool_policy_1 = require("./hook-tool-policy");
|
|
12
13
|
const SECRET = '[secret]';
|
|
13
|
-
const HASH_VERSION = 'marrow-normalized-action-
|
|
14
|
+
const HASH_VERSION = 'marrow-normalized-action-v3';
|
|
14
15
|
const EDIT_TOOLS = /^(?:edit|write|multiedit|apply_patch|notebookedit|replace|write_file|edit_file|delete_file|search_replace|delete|create_file)$/i;
|
|
15
16
|
const MAX_DEPTH = 3;
|
|
16
17
|
/** A word, data text or input string longer than this is not scanned: it is withheld and the action marked truncated. */
|
|
@@ -27,8 +28,16 @@ const DEFAULT_NORMALIZE_MS = 1_000;
|
|
|
27
28
|
/** Thrown when normalization runs out of its budget: the caller sends a truncated placeholder instead. */
|
|
28
29
|
class NormalizationLimit extends Error {
|
|
29
30
|
}
|
|
30
|
-
function newExactness(deadline = Date.now() + DEFAULT_NORMALIZE_MS) {
|
|
31
|
-
return { truncated: false, budget: { scanned: 0, deadline } };
|
|
31
|
+
function newExactness(deadline = Date.now() + DEFAULT_NORMALIZE_MS, exact = false) {
|
|
32
|
+
return { truncated: false, budget: { scanned: 0, deadline }, exact, sigs: [] };
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* A value withheld only because it MAY be a secret (it may also be an id, a commit or a name that decides the
|
|
36
|
+
* action): replaced and the action marked truncated; the exact pass keeps it as it is.
|
|
37
|
+
*/
|
|
38
|
+
function withheld(state, original, replacement = SECRET) {
|
|
39
|
+
state.truncated = true; // exact: kept
|
|
40
|
+
return state.exact ? original : replacement;
|
|
32
41
|
}
|
|
33
42
|
/** Counts text about to be scanned against the shared budget; past it, normalization stops. */
|
|
34
43
|
function spend(state, chars) {
|
|
@@ -44,6 +53,8 @@ function sha256(text) {
|
|
|
44
53
|
// ---------------------------------------------------------------------------
|
|
45
54
|
const KNOWN_KEY_PREFIX = /^(?:AKIA|ASIA|AGPA|AIDA|AROA)[0-9A-Z]{12,}$|^(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}$|^github_pat_[A-Za-z0-9_]{20,}$|^(?:sk|pk|rk)[-_](?:live|test|proj|ant)?[-_]?[A-Za-z0-9_-]{12,}$|^xox[abprs]-[A-Za-z0-9-]{10,}$|^AIza[0-9A-Za-z_-]{30,}$|^glpat-[A-Za-z0-9_-]{16,}$|^npm_[A-Za-z0-9]{20,}$|^mrw_[A-Za-z0-9_-]{8,}$|^cfut_[A-Za-z0-9_-]{12,}$|^eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}$/;
|
|
46
55
|
const PEM = /-----BEGIN [A-Z ]*(?:PRIVATE KEY|CERTIFICATE)-----/;
|
|
56
|
+
/** Known formats that only name a key or are public (AWS key, user, group and role ids; publishable keys): the exact pass keeps them. */
|
|
57
|
+
const PUBLIC_KNOWN_KEY = /^(?:AKIA|ASIA|AGPA|AIDA|AROA)[0-9A-Z]{12,}$|^pk[-_]/;
|
|
47
58
|
/** A value shaped like a key, token or password hash (mixed-case random text, long hex, known prefixes). */
|
|
48
59
|
function looksLikeKey(value) {
|
|
49
60
|
const text = value.trim();
|
|
@@ -72,18 +83,19 @@ function redactKeyShapes(text, state) {
|
|
|
72
83
|
const whole = text.trim();
|
|
73
84
|
if (whole && !/\s/.test(whole) && looksLikeKey(whole)) {
|
|
74
85
|
if (!KNOWN_KEY_PREFIX.test(whole) && !PEM.test(whole))
|
|
75
|
-
state
|
|
76
|
-
return SECRET;
|
|
86
|
+
return withheld(state, text);
|
|
87
|
+
return state.exact && PUBLIC_KNOWN_KEY.test(whole) ? text : SECRET;
|
|
77
88
|
}
|
|
78
89
|
if (PEM.test(text)) {
|
|
79
|
-
|
|
90
|
+
// The exact pass replaces only a private key block; a certificate or a public key block is kept.
|
|
91
|
+
return text.replace(/-----BEGIN ([A-Z ]*)-----[\s\S]*?(?:-----END [A-Z ]*-----|$)/g, (block, label) => (state.exact && !/PRIVATE/.test(label) ? block : SECRET));
|
|
80
92
|
}
|
|
81
93
|
return text.split(KEY_SPLIT).map((part) => {
|
|
82
94
|
if (!looksLikeKey(part))
|
|
83
95
|
return part;
|
|
84
96
|
if (!KNOWN_KEY_PREFIX.test(part))
|
|
85
|
-
state
|
|
86
|
-
return SECRET;
|
|
97
|
+
return withheld(state, part);
|
|
98
|
+
return state.exact && PUBLIC_KNOWN_KEY.test(part) ? part : SECRET;
|
|
87
99
|
}).join('');
|
|
88
100
|
}
|
|
89
101
|
// ---------------------------------------------------------------------------
|
|
@@ -129,6 +141,24 @@ function holdsSecret(name) {
|
|
|
129
141
|
const kind = classifySecretName(name);
|
|
130
142
|
return kind === 'credential' || kind === 'ambiguous';
|
|
131
143
|
}
|
|
144
|
+
/** A name part that ends in a credential word (DB_PASSWORD, PGPASSWORD, GITHUB_TOKEN, ADMINPASS), not one that only starts with it. */
|
|
145
|
+
const CERTAIN_END = /(?:password|passwd|passphrase|passcode|secret|token|apikey|accesskey|privatekey|signingkey|masterkey|encryptionkey|accountkey|credential|creds|bearer|cookie|authorization|requirepass|masterauth|storepass|keypass|newpass|oldpass|pwd)$/;
|
|
146
|
+
/**
|
|
147
|
+
* classifySecretName, except that the exact pass (exact_hmac, never sent) reads a credential name as certain only
|
|
148
|
+
* when a part ends in a credential word, is one (otp, pat, jwt), or the name holds a pair (api_key): a value under
|
|
149
|
+
* `passwordless`, `SECRETNAME`, `TOKENID`, `max_tokens` or `credentials` may name or count something that decides
|
|
150
|
+
* the action, so the exact pass keeps it (as an ambiguous value). Pass 1 is unchanged.
|
|
151
|
+
*/
|
|
152
|
+
function nameClass(name, state) {
|
|
153
|
+
const kind = classifySecretName(name);
|
|
154
|
+
if (!state.exact || kind !== 'credential')
|
|
155
|
+
return kind;
|
|
156
|
+
const parts = nameParts(name);
|
|
157
|
+
const joined = parts.join('_');
|
|
158
|
+
const pair = CREDENTIAL_PAIRS.some((item) => joined === item || joined.startsWith(`${item}_`) || joined.endsWith(`_${item}`) || joined.includes(`_${item}_`));
|
|
159
|
+
const certain = pair || parts.some((part) => CREDENTIAL_WORDS.has(part) || CERTAIN_END.test(part) || (holdsPass(part) && part.endsWith('pass')));
|
|
160
|
+
return certain ? 'credential' : 'ambiguous';
|
|
161
|
+
}
|
|
132
162
|
// ---------------------------------------------------------------------------
|
|
133
163
|
// Secrets inside a text
|
|
134
164
|
// ---------------------------------------------------------------------------
|
|
@@ -195,10 +225,10 @@ function cutCredential(value, state) {
|
|
|
195
225
|
const at = credentialValueEnd(value, 0, true);
|
|
196
226
|
if (at >= value.length) {
|
|
197
227
|
if (holdsMoreThanCredential(value))
|
|
198
|
-
state
|
|
228
|
+
return withheld(state, value);
|
|
199
229
|
return SECRET;
|
|
200
230
|
}
|
|
201
|
-
state.truncated = true;
|
|
231
|
+
state.truncated = true; // exact: certain (the credential before the field; the rest is scanned in the same pass)
|
|
202
232
|
return SECRET + redactText(value.slice(at), state);
|
|
203
233
|
}
|
|
204
234
|
/** One-value auth header (`Authorization: Bearer X`, `X-Api-Key: K`): the whole value is the credential. */
|
|
@@ -209,21 +239,29 @@ const SINGLE_VALUE_HEADER = /^[^:\s]{1,128}:[ \t]*(?:(?:Bearer|Basic|Token|Bot)[
|
|
|
209
239
|
* names mark the action truncated) and key-shaped values.
|
|
210
240
|
*/
|
|
211
241
|
function redactText(text, state) {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
return
|
|
215
|
-
}
|
|
242
|
+
// Not scanned (the exact pass keeps it as it is).
|
|
243
|
+
if (text.length > exports.MAX_SCAN_CHARS)
|
|
244
|
+
return withheld(state, text);
|
|
216
245
|
spend(state, text.length);
|
|
217
246
|
// A short word without a separator cannot hold any of these (keys need 12 or more characters).
|
|
218
247
|
if (text.length < 12 && !/[:=?&@\s]/.test(text))
|
|
219
248
|
return text;
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
249
|
+
// M2-R1: a `#` or `?` in the password ends the URL authority for URL parsers (`https://deploy:443#@host`
|
|
250
|
+
// goes to host `deploy`), while libpq, mysql and redis clients take the whole span as the password. It is
|
|
251
|
+
// hidden either way, but it is not a certain credential: the action is marked truncated and the exact pass
|
|
252
|
+
// keeps the span, so it never shares a key with `https://deploy:<pw>@host`.
|
|
253
|
+
let out = text.replace(URL_CREDENTIALS, (_match, scheme, user, password) => (`${scheme}${user}:${/[#?]/.test(password) ? withheld(state, password) : SECRET}@`));
|
|
254
|
+
// The exact pass keeps a `Token`/`Bot` value that is not key-shaped (`git commit -m "Token rotation"`).
|
|
255
|
+
out = replaceValuesAfter(out, AUTH_SCHEME, state.exact ? (scheme, value) => /^(?:Token|Bot)$/.test(scheme) && !looksLikeKey(value) : null);
|
|
256
|
+
// The exact pass keeps a URL `code` value: it may be an OAuth code, but it may as well be a country or product code.
|
|
257
|
+
out = replaceValuesAfter(out, URL_CODE, state.exact ? () => true : null);
|
|
223
258
|
return redactKeyShapes(redactAssignments(out, state), state);
|
|
224
259
|
}
|
|
225
|
-
/**
|
|
226
|
-
|
|
260
|
+
/**
|
|
261
|
+
* Replaces the value after each match of `pattern` (an auth scheme, an OAuth code), ended by the shared rule.
|
|
262
|
+
* `keep` (exact pass only): a value it returns true for stays as it is.
|
|
263
|
+
*/
|
|
264
|
+
function replaceValuesAfter(text, pattern, keep) {
|
|
227
265
|
pattern.lastIndex = 0;
|
|
228
266
|
let out = '';
|
|
229
267
|
let copied = 0;
|
|
@@ -234,6 +272,10 @@ function replaceValuesAfter(text, pattern) {
|
|
|
234
272
|
pattern.lastIndex = start + 1;
|
|
235
273
|
continue;
|
|
236
274
|
}
|
|
275
|
+
if (keep && keep(match[1], text.slice(start, end))) {
|
|
276
|
+
pattern.lastIndex = end;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
237
279
|
out += text.slice(copied, start) + SECRET;
|
|
238
280
|
copied = end;
|
|
239
281
|
pattern.lastIndex = end;
|
|
@@ -254,11 +296,10 @@ function redactAssignments(text, state) {
|
|
|
254
296
|
let matches = 0;
|
|
255
297
|
for (let match = pattern.exec(text); match; match = pattern.exec(text)) {
|
|
256
298
|
matches += 1;
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
return
|
|
260
|
-
|
|
261
|
-
const kind = classifySecretName(match[2]);
|
|
299
|
+
// Too many names to scan: withheld (the exact pass keeps the text as it is).
|
|
300
|
+
if (matches > MAX_ASSIGNMENTS)
|
|
301
|
+
return withheld(state, text);
|
|
302
|
+
const kind = nameClass(match[2], state);
|
|
262
303
|
if (kind !== 'credential' && kind !== 'ambiguous')
|
|
263
304
|
continue;
|
|
264
305
|
let start = match.index + match[0].length;
|
|
@@ -275,34 +316,63 @@ function redactAssignments(text, state) {
|
|
|
275
316
|
continue;
|
|
276
317
|
const value = text.slice(start, end);
|
|
277
318
|
if (!value.startsWith('[secret') && !value.startsWith('[data:')) {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
out += text.slice(copied, start) + SECRET;
|
|
319
|
+
const replacement = kind === 'ambiguous' || holdsMoreThanCredential(value) ? withheld(state, value) : SECRET;
|
|
320
|
+
out += text.slice(copied, start) + replacement;
|
|
281
321
|
copied = end;
|
|
282
322
|
}
|
|
283
323
|
pattern.lastIndex = end;
|
|
284
324
|
}
|
|
285
325
|
return out + text.slice(copied);
|
|
286
326
|
}
|
|
287
|
-
/** Whether a text carries anything secret-shaped (it is then withheld, never hashed). */
|
|
288
|
-
function secretShaped(text, state) {
|
|
289
|
-
const probe = { truncated: false, budget: state.budget };
|
|
290
|
-
return redactText(text, probe) !== text;
|
|
291
|
-
}
|
|
292
327
|
/**
|
|
293
|
-
* Data a command reads: its SHA-256 in the hashed form
|
|
294
|
-
* truncated when it feeds a secrets command or file
|
|
328
|
+
* Data a command reads: its SHA-256 in the hashed form; `[secret]` and
|
|
329
|
+
* truncated when it feeds a secrets command or file (all of it is the
|
|
330
|
+
* secret); the SHA-256 of its redacted form and truncated when it looks secret.
|
|
295
331
|
*/
|
|
296
|
-
function dataMarker(text, feedsSecrets, state) {
|
|
297
|
-
if (
|
|
298
|
-
|
|
332
|
+
function dataMarker(text, feedsSecrets, state, words = null) {
|
|
333
|
+
if (state.exact) {
|
|
334
|
+
// The exact pass: only the certain credentials inside the data are replaced and the rest is hashed, also for
|
|
335
|
+
// data a secrets command or file reads (a vault policy, an authorized_keys line, a kubeconfig differ outside
|
|
336
|
+
// any secret), so data that differs outside a certain credential never shares exact material.
|
|
337
|
+
const probe = { truncated: false, budget: state.budget, exact: true, sigs: [] };
|
|
338
|
+
return `[data:${sha256(`${HASH_VERSION}\nexact-data\n${redactText(text, probe)}`)}]`;
|
|
339
|
+
}
|
|
340
|
+
if (feedsSecrets) {
|
|
341
|
+
state.truncated = true; // exact: kept (hashed above, certain credentials replaced)
|
|
299
342
|
return SECRET;
|
|
300
343
|
}
|
|
301
|
-
|
|
344
|
+
// Secret-shaped data (`'{"api_key":"S","env":"production"}'`): the secrets are
|
|
345
|
+
// replaced and the rest is hashed, so data that differs outside a secret gives
|
|
346
|
+
// a different key, data that differs only in a secret the same one. Withheld:
|
|
347
|
+
// the action is truncated and never binds an approval.
|
|
348
|
+
const probe = { truncated: false, budget: state.budget, exact: false, sigs: [] };
|
|
349
|
+
const redacted = redactText(text, probe);
|
|
350
|
+
if (redacted !== text) {
|
|
351
|
+
state.truncated = true; // exact: kept (hashed above, certain credentials replaced)
|
|
352
|
+
return `[secret-data:${sha256(`${HASH_VERSION}\nsecret-data\n${redacted}`)}]`;
|
|
353
|
+
}
|
|
354
|
+
// Printed words (`words`): hashed as their list, so word boundaries count.
|
|
355
|
+
return words === null ? `[data:${sha256(`${HASH_VERSION}\ndata\n${text}`)}]` : `[data:${sha256(`${HASH_VERSION}\nwords\n${JSON.stringify(words)}`)}]`;
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Text echoed or printed into a pipe or a file: probed for secrets as the shell joins it (one space), as
|
|
359
|
+
* before; hashed as the list of printed words, so word boundaries and flags count (`echo -n X` is not
|
|
360
|
+
* `echo "-n X"`, `printf '%s\n' a b` is not `printf '%s\n' 'a b'`). The exact pass replaces the certain
|
|
361
|
+
* credentials word by word and keeps the boundaries too.
|
|
362
|
+
*/
|
|
363
|
+
function printedData(words, feedsSecrets, state) {
|
|
364
|
+
if (state.exact) {
|
|
365
|
+
const probe = { truncated: false, budget: state.budget, exact: true, sigs: [] };
|
|
366
|
+
return `[data:${sha256(`${HASH_VERSION}\nexact-words\n${JSON.stringify(words.map((word) => redactText(word, probe)))}`)}]`;
|
|
367
|
+
}
|
|
368
|
+
return dataMarker(words.join(' '), feedsSecrets, state, words);
|
|
302
369
|
}
|
|
303
370
|
const OPERATORS = ['<<<', '&&', '||', '|&', '>>', '<<', ';;', '|', '&', ';', '<', '>', '(', ')', '\n'];
|
|
304
|
-
/**
|
|
305
|
-
|
|
371
|
+
/**
|
|
372
|
+
* Splits a command into words and operators, honoring quotes and escapes (an approximation of the shell).
|
|
373
|
+
* `crIsWord` (the exact pass): a CR is a word character, as it is to the shell.
|
|
374
|
+
*/
|
|
375
|
+
function tokenize(command, crIsWord = false) {
|
|
306
376
|
const tokens = [];
|
|
307
377
|
let current = '';
|
|
308
378
|
let started = false;
|
|
@@ -328,7 +398,7 @@ function tokenize(command) {
|
|
|
328
398
|
i = end < 0 ? command.length : end;
|
|
329
399
|
continue;
|
|
330
400
|
}
|
|
331
|
-
if (ch === ' ' || ch === '\t' || ch === '\r') {
|
|
401
|
+
if (ch === ' ' || ch === '\t' || (ch === '\r' && !crIsWord)) {
|
|
332
402
|
push();
|
|
333
403
|
continue;
|
|
334
404
|
}
|
|
@@ -489,10 +559,10 @@ function takesSecrets(words, parsed) {
|
|
|
489
559
|
return parsed.targets.some(isSecretFile);
|
|
490
560
|
}
|
|
491
561
|
function assignedValue(name, value, state) {
|
|
492
|
-
const kind =
|
|
562
|
+
const kind = nameClass(name, state);
|
|
493
563
|
if (kind === 'credential' || kind === 'ambiguous') {
|
|
494
564
|
if (kind === 'ambiguous' || holdsMoreThanCredential(value))
|
|
495
|
-
state.
|
|
565
|
+
return `${name}=${withheld(state, state.exact ? dataMarker(value, false, state) : value)}`;
|
|
496
566
|
return `${name}=${SECRET}`;
|
|
497
567
|
}
|
|
498
568
|
return value ? `${name}=${dataMarker(value, false, state)}` : `${name}=`;
|
|
@@ -539,7 +609,7 @@ function redactSegment(words, ctx) {
|
|
|
539
609
|
if ((program === 'echo' || program === 'printf') && (ctx.pipedOut || targets.length)) {
|
|
540
610
|
const printed = parsed.plain;
|
|
541
611
|
if (printed.length) {
|
|
542
|
-
out[printed[0]] =
|
|
612
|
+
out[printed[0]] = printedData(printed.map((i) => out[i]), downstreamTakes || secretTarget, state);
|
|
543
613
|
handled.add(printed[0]);
|
|
544
614
|
for (const i of printed.slice(1))
|
|
545
615
|
removed.add(i);
|
|
@@ -560,8 +630,7 @@ function redactSegment(words, ctx) {
|
|
|
560
630
|
// Commands inside the command: `bash -c CMD`, `ssh HOST CMD …`, `eval …`, `su -c CMD`.
|
|
561
631
|
const embed = (at, text, also = []) => {
|
|
562
632
|
if (ctx.depth >= MAX_DEPTH) {
|
|
563
|
-
out[at] =
|
|
564
|
-
state.truncated = true;
|
|
633
|
+
out[at] = withheld(state, text);
|
|
565
634
|
}
|
|
566
635
|
else {
|
|
567
636
|
out[at] = normalizeInner(text, state, ctx.depth + 1).text;
|
|
@@ -620,23 +689,31 @@ function redactSegment(words, ctx) {
|
|
|
620
689
|
const envStore = storeContext(lowered.slice(0, 4));
|
|
621
690
|
const stem = programStem(program);
|
|
622
691
|
const redactAt = (i, ambiguous = false) => {
|
|
623
|
-
|
|
624
|
-
state.truncated = true;
|
|
625
|
-
out[i] = SECRET;
|
|
692
|
+
out[i] = ambiguous || holdsMoreThanCredential(out[i]) ? withheld(state, out[i]) : SECRET;
|
|
626
693
|
handled.add(i);
|
|
627
694
|
};
|
|
628
695
|
// An auth header: a one-value header is the credential; any other (`Cookie: a=1; env=prod`)
|
|
629
696
|
// keeps its other fields, with the credentials inside replaced.
|
|
630
697
|
const redactHeaderValue = (header) => {
|
|
631
698
|
if (SINGLE_VALUE_HEADER.test(header)) {
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
699
|
+
const colon = header.indexOf(':');
|
|
700
|
+
const value = header.slice(colon + 1);
|
|
701
|
+
if (holdsMoreThanCredential(value))
|
|
702
|
+
return withheld(state, header);
|
|
703
|
+
if (!state.exact)
|
|
704
|
+
return SECRET;
|
|
705
|
+
// The exact pass: the value of a credential header (or a Bearer/Basic value) is certain; any other
|
|
706
|
+
// one-value header (`X-Idempotency-Key: k`) and the header name are kept.
|
|
707
|
+
const name = header.slice(0, colon);
|
|
708
|
+
if (nameClass(name, state) !== 'credential' && !/^[ \t]*(?:Bearer|Basic)[ \t]/i.test(value))
|
|
709
|
+
return header;
|
|
710
|
+
// Basic USER:PASSWORD names who acts, as `curl -u` does: only the password is certain.
|
|
711
|
+
const basic = /^[ \t]*Basic[ \t]+([A-Za-z0-9+/]+={0,2})[ \t]*$/i.exec(value);
|
|
712
|
+
const decoded = basic ? Buffer.from(basic[1], 'base64').toString('latin1') : '';
|
|
713
|
+
return /^[\x20-\x7e]*:/.test(decoded) ? `${name}:Basic ${JSON.stringify(decoded.slice(0, decoded.indexOf(':')))}:${SECRET}` : `${name}:${SECRET}`;
|
|
635
714
|
}
|
|
636
715
|
const redacted = redactText(header, state);
|
|
637
|
-
|
|
638
|
-
state.truncated = true;
|
|
639
|
-
return redacted === header ? SECRET : redacted;
|
|
716
|
+
return redacted === header ? withheld(state, header) : redacted;
|
|
640
717
|
};
|
|
641
718
|
// curl -b/--cookie NAME=VALUE[; …] is a Cookie header (without `=` it names a cookie file).
|
|
642
719
|
const redactCookieValue = (value) => {
|
|
@@ -651,7 +728,11 @@ function redactSegment(words, ctx) {
|
|
|
651
728
|
return redactText(`${name}=${value}`, state);
|
|
652
729
|
return `${name}=${cutCredential(value, state)}`;
|
|
653
730
|
};
|
|
654
|
-
//
|
|
731
|
+
// The exact pass: `curl -u USER:PASSWORD` names who acts; only the password is certain.
|
|
732
|
+
const exactUser = (value) => (value.includes(':') ? `${value.slice(0, value.indexOf(':'))}:${SECRET}` : value);
|
|
733
|
+
// The exact pass: openssl `pass:X` and a bare value are the password; `env:`, `file:`, `fd:` name where it is.
|
|
734
|
+
const exactOpensslPass = (value) => (/^(?:env|file|fd):/.test(value) ? value : /^pass:/.test(value) ? `pass:${SECRET}` : SECRET);
|
|
735
|
+
// A program named for a secret (set-password.sh): its arguments cannot be told apart (the exact pass keeps them).
|
|
655
736
|
if (!SECRET_PROGRAMS.has(stem) && classifySecretName(stem) === 'credential') {
|
|
656
737
|
for (const i of positionals)
|
|
657
738
|
redactAt(i, true);
|
|
@@ -666,12 +747,14 @@ function redactSegment(words, ctx) {
|
|
|
666
747
|
const j = args[k + 1];
|
|
667
748
|
return j !== undefined && !handled.has(j) && !out[j].startsWith('-') ? j : null;
|
|
668
749
|
};
|
|
669
|
-
|
|
750
|
+
// `exactForm` (exact pass only): the certain part of a value that also names something (a user, a file).
|
|
751
|
+
const next = (ambiguous = false, exactForm = null) => {
|
|
670
752
|
const j = valueAt();
|
|
671
753
|
if (j !== null) {
|
|
672
754
|
if (ambiguous)
|
|
673
|
-
state.
|
|
674
|
-
|
|
755
|
+
out[j] = state.exact ? withheld(state, out[j]) : withheld(state, out[j], cutCredential(out[j], state));
|
|
756
|
+
else
|
|
757
|
+
out[j] = state.exact && exactForm ? exactForm(out[j]) : cutCredential(out[j], state);
|
|
675
758
|
handled.add(j);
|
|
676
759
|
k += 1;
|
|
677
760
|
}
|
|
@@ -686,7 +769,7 @@ function redactSegment(words, ctx) {
|
|
|
686
769
|
}
|
|
687
770
|
};
|
|
688
771
|
const byName = (name) => {
|
|
689
|
-
const kind =
|
|
772
|
+
const kind = nameClass(name, state);
|
|
690
773
|
if (kind === 'credential')
|
|
691
774
|
next();
|
|
692
775
|
else if (kind === 'ambiguous')
|
|
@@ -697,13 +780,12 @@ function redactSegment(words, ctx) {
|
|
|
697
780
|
if (long) {
|
|
698
781
|
const name = long[1].replace(/^-+/, '');
|
|
699
782
|
if (name === 'from-literal' || name === 'from-env-literal') {
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
out[i] = `${long[1]}=${long[2].replace(/^([^=]*)=.*$/s, `$1=${SECRET}`)}`;
|
|
783
|
+
const replaced = `${long[1]}=${long[2].replace(/^([^=]*)=.*$/s, `$1=${SECRET}`)}`;
|
|
784
|
+
out[i] = holdsMoreThanCredential(long[2].replace(/^[^=]*=/, '')) ? withheld(state, arg, replaced) : replaced;
|
|
703
785
|
handled.add(i);
|
|
704
786
|
}
|
|
705
787
|
else if (CURL_FAMILY.has(program) && /^(?:u|user|proxy-user)$/.test(name)) {
|
|
706
|
-
out[i] = `${long[1]}=${SECRET}`;
|
|
788
|
+
out[i] = `${long[1]}=${state.exact ? exactUser(long[2]) : SECRET}`;
|
|
707
789
|
handled.add(i);
|
|
708
790
|
}
|
|
709
791
|
else if (CURL_FAMILY.has(program) && name === 'header' && AUTH_HEADER.test(long[2])) {
|
|
@@ -715,9 +797,11 @@ function redactSegment(words, ctx) {
|
|
|
715
797
|
handled.add(i);
|
|
716
798
|
}
|
|
717
799
|
else if (holdsSecret(name)) {
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
800
|
+
const kind = nameClass(name, state);
|
|
801
|
+
if (kind === 'ambiguous')
|
|
802
|
+
out[i] = withheld(state, arg, `${long[1]}=${cutCredential(long[2], state)}`);
|
|
803
|
+
else if (kind === 'credential')
|
|
804
|
+
out[i] = `${long[1]}=${cutCredential(long[2], state)}`;
|
|
721
805
|
handled.add(i);
|
|
722
806
|
}
|
|
723
807
|
continue;
|
|
@@ -726,7 +810,7 @@ function redactSegment(words, ctx) {
|
|
|
726
810
|
if (/^--[A-Za-z0-9]/.test(arg)) {
|
|
727
811
|
const name = arg.slice(2);
|
|
728
812
|
if (CURL_FAMILY.has(program) && /^(?:user|proxy-user)$/.test(name))
|
|
729
|
-
next();
|
|
813
|
+
next(false, exactUser);
|
|
730
814
|
else if (CURL_FAMILY.has(program) && name === 'header') {
|
|
731
815
|
const j = valueAt();
|
|
732
816
|
if (j !== null && AUTH_HEADER.test(out[j])) {
|
|
@@ -764,7 +848,7 @@ function redactSegment(words, ctx) {
|
|
|
764
848
|
else if (REDIS_FAMILY.has(program) && /^-a/.test(arg))
|
|
765
849
|
attachedOrNext('-a');
|
|
766
850
|
else if (CURL_FAMILY.has(program) && (arg === '-u' || arg === '-U'))
|
|
767
|
-
next();
|
|
851
|
+
next(false, exactUser);
|
|
768
852
|
else if (CURL_FAMILY.has(program) && arg === '-H') {
|
|
769
853
|
const j = valueAt();
|
|
770
854
|
if (j !== null && AUTH_HEADER.test(out[j])) {
|
|
@@ -782,7 +866,7 @@ function redactSegment(words, ctx) {
|
|
|
782
866
|
}
|
|
783
867
|
}
|
|
784
868
|
else if (program === 'openssl' && OPENSSL_PASS_FLAGS.has(arg))
|
|
785
|
-
next();
|
|
869
|
+
next(false, arg === '-kfile' ? (value) => value : exactOpensslPass);
|
|
786
870
|
else if (program === 'gh' && secretContext && arg === '-b')
|
|
787
871
|
next();
|
|
788
872
|
else if (/^-[A-Za-z][A-Za-z0-9_-]{2,}$/.test(arg))
|
|
@@ -795,15 +879,17 @@ function redactSegment(words, ctx) {
|
|
|
795
879
|
const [, name, value] = assignment;
|
|
796
880
|
if (secretContext) {
|
|
797
881
|
// In a secrets command every value is a secret: withheld whole, and truncated if it held more fields.
|
|
798
|
-
|
|
799
|
-
state.truncated = true;
|
|
800
|
-
out[i] = `${name}=${SECRET}`;
|
|
882
|
+
out[i] = /[&#]/.test(value) || holdsMoreThanCredential(value) ? withheld(state, arg, `${name}=${SECRET}`) : `${name}=${SECRET}`;
|
|
801
883
|
handled.add(i);
|
|
802
884
|
}
|
|
803
885
|
else if (holdsSecret(name)) {
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
886
|
+
const kind = nameClass(name, state);
|
|
887
|
+
if (kind === 'ambiguous')
|
|
888
|
+
out[i] = withheld(state, arg, credentialArgument(name, value));
|
|
889
|
+
else if (kind === 'credential')
|
|
890
|
+
out[i] = credentialArgument(name, value);
|
|
891
|
+
else
|
|
892
|
+
out[i] = redactText(arg, state);
|
|
807
893
|
handled.add(i);
|
|
808
894
|
}
|
|
809
895
|
else if (envStore) {
|
|
@@ -813,7 +899,7 @@ function redactSegment(words, ctx) {
|
|
|
813
899
|
continue;
|
|
814
900
|
}
|
|
815
901
|
if (program === 'openssl' && /^(?:pass|env|file|fd):/.test(arg)) {
|
|
816
|
-
out[i] = arg.replace(/:.*/s, `:${SECRET}`);
|
|
902
|
+
out[i] = state.exact ? exactOpensslPass(arg) : arg.replace(/:.*/s, `:${SECRET}`);
|
|
817
903
|
handled.add(i);
|
|
818
904
|
continue;
|
|
819
905
|
}
|
|
@@ -830,7 +916,7 @@ function redactSegment(words, ctx) {
|
|
|
830
916
|
if (envStore && position > 0 && /^(?:env|config|variables?|vars?):(?:set|add|push|import|create|update|put)$|^(?:set|add|put|push|import|create|update)$/.test(previous)) {
|
|
831
917
|
const j = valueAt();
|
|
832
918
|
if (j !== null) {
|
|
833
|
-
const kind =
|
|
919
|
+
const kind = nameClass(arg, state);
|
|
834
920
|
if (kind === 'credential')
|
|
835
921
|
redactAt(j);
|
|
836
922
|
else if (kind === 'ambiguous')
|
|
@@ -880,12 +966,95 @@ function redactSegment(words, ctx) {
|
|
|
880
966
|
function quoteWord(word) {
|
|
881
967
|
return /[\s'"\\|&;<>()$`*?]/.test(word) ? `'${word.replace(/'/g, `'\\''`)}'` : word;
|
|
882
968
|
}
|
|
969
|
+
const SIG_SPECIAL = '$`*?[{~';
|
|
970
|
+
const HEREDOC_OPEN = /(?<!<)<<(-?)[ \t]*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\2/g;
|
|
971
|
+
/**
|
|
972
|
+
* What quoting does to the characters the shell expands (`$`, backquote, `*`, `?`, `[`, `{`, `~`), in order: `A`
|
|
973
|
+
* when the shell acts on it, `L` when it is literal (single quotes, a backslash, double quotes for all but `$` and
|
|
974
|
+
* backquote, a quoted heredoc). The words the normalizer reads are unquoted, so `rm -rf '*'` and `rm -rf *`,
|
|
975
|
+
* `'$HOME'` and `$HOME`, `<<'EOF'` and `<<EOF` read alike; this signature keeps them apart. `'*'`, `"*"` and `\*`
|
|
976
|
+
* mean the same and sign the same.
|
|
977
|
+
*/
|
|
978
|
+
function quoteSignature(command) {
|
|
979
|
+
const parts = [];
|
|
980
|
+
const lines = command.split('\n');
|
|
981
|
+
const pending = [];
|
|
982
|
+
let quote = '';
|
|
983
|
+
for (let n = 0; n < lines.length; n += 1) {
|
|
984
|
+
if (pending.length && !quote) {
|
|
985
|
+
const doc = pending.shift();
|
|
986
|
+
for (; n < lines.length; n += 1) {
|
|
987
|
+
const line = lines[n];
|
|
988
|
+
if (line.replace(/^[ \t]+|[ \t]+$/g, '') === doc.tag)
|
|
989
|
+
break;
|
|
990
|
+
for (let i = 0; i < line.length; i += 1) {
|
|
991
|
+
const c = line[i];
|
|
992
|
+
if (!doc.quoted && c === '\\' && i + 1 < line.length && '$`\\'.includes(line[i + 1])) {
|
|
993
|
+
if (SIG_SPECIAL.includes(line[i + 1]))
|
|
994
|
+
parts.push(`L${line[i + 1]}`);
|
|
995
|
+
i += 1;
|
|
996
|
+
}
|
|
997
|
+
else if (SIG_SPECIAL.includes(c))
|
|
998
|
+
parts.push(`${!doc.quoted && (c === '$' || c === '`') ? 'A' : 'L'}${c}`);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
parts.push('|');
|
|
1002
|
+
continue;
|
|
1003
|
+
}
|
|
1004
|
+
const line = lines[n];
|
|
1005
|
+
if (!quote)
|
|
1006
|
+
for (const match of line.matchAll(HEREDOC_OPEN))
|
|
1007
|
+
pending.push({ tag: match[3], quoted: match[2] !== '' });
|
|
1008
|
+
for (let i = 0; i < line.length; i += 1) {
|
|
1009
|
+
const c = line[i];
|
|
1010
|
+
if (quote === "'") {
|
|
1011
|
+
if (c === "'")
|
|
1012
|
+
quote = '';
|
|
1013
|
+
else if (SIG_SPECIAL.includes(c))
|
|
1014
|
+
parts.push(`L${c}`);
|
|
1015
|
+
continue;
|
|
1016
|
+
}
|
|
1017
|
+
if (c === '\\' && i + 1 < line.length) {
|
|
1018
|
+
const next = line[i + 1];
|
|
1019
|
+
if (SIG_SPECIAL.includes(next))
|
|
1020
|
+
parts.push(`L${next}`);
|
|
1021
|
+
i += 1;
|
|
1022
|
+
continue;
|
|
1023
|
+
}
|
|
1024
|
+
if (quote === '"') {
|
|
1025
|
+
if (c === '"')
|
|
1026
|
+
quote = '';
|
|
1027
|
+
else if (SIG_SPECIAL.includes(c))
|
|
1028
|
+
parts.push(`${c === '$' || c === '`' ? 'A' : 'L'}${c}`);
|
|
1029
|
+
continue;
|
|
1030
|
+
}
|
|
1031
|
+
if (c === "'" || c === '"') {
|
|
1032
|
+
quote = c;
|
|
1033
|
+
continue;
|
|
1034
|
+
}
|
|
1035
|
+
if (SIG_SPECIAL.includes(c))
|
|
1036
|
+
parts.push(`A${c}`);
|
|
1037
|
+
}
|
|
1038
|
+
if (quote)
|
|
1039
|
+
parts.push('/');
|
|
1040
|
+
}
|
|
1041
|
+
return parts.join('');
|
|
1042
|
+
}
|
|
883
1043
|
function normalizeInner(command, state, depth) {
|
|
884
1044
|
if (command.length > MAX_COMMAND_CHARS)
|
|
885
1045
|
throw new NormalizationLimit();
|
|
886
1046
|
spend(state, 0);
|
|
1047
|
+
// Quoting of the characters the shell expands (kept apart from the unquoted words, see quoteSignature).
|
|
1048
|
+
const signature = quoteSignature(command);
|
|
1049
|
+
if (signature)
|
|
1050
|
+
state.sigs.push(`${depth}:${signature}`);
|
|
1051
|
+
// A lone CR (not before LF) is a word character to the shell; read as a line end here, so the action is
|
|
1052
|
+
// truncated and the exact pass keeps it (`main<CR>` is not `main`, `build<CR>dist` is not `build ; dist`).
|
|
1053
|
+
if (/\r(?!\n)/.test(command))
|
|
1054
|
+
state.truncated = true; // exact: kept (crIsWord)
|
|
887
1055
|
const data = [];
|
|
888
|
-
const
|
|
1056
|
+
const lines = state.exact ? command.replace(/\r\n/g, '\n') : command.replace(/\r\n?/g, '\n');
|
|
1057
|
+
const tokens = tokenize(extractHeredocs(lines, data), state.exact);
|
|
889
1058
|
if (tokens.length > MAX_TOKENS)
|
|
890
1059
|
throw new NormalizationLimit();
|
|
891
1060
|
const segments = [];
|
|
@@ -953,10 +1122,13 @@ function normalizeInner(command, state, depth) {
|
|
|
953
1122
|
* never sent. `truncated` is true when something that may change what the
|
|
954
1123
|
* command does had to be withheld. Exported for tests.
|
|
955
1124
|
*/
|
|
956
|
-
function normalizeShellCommand(command, deadline) {
|
|
957
|
-
const state = newExactness(deadline);
|
|
1125
|
+
function normalizeShellCommand(command, deadline, exact = false) {
|
|
1126
|
+
const state = newExactness(deadline, exact);
|
|
958
1127
|
try {
|
|
959
1128
|
const result = normalizeInner(command, state, 0);
|
|
1129
|
+
// The exact pass: the form exact_hmac keys (the words, the shell references and the quoting).
|
|
1130
|
+
if (exact)
|
|
1131
|
+
return { ...result, text: exactShellForm(result.text, command, state), truncated: state.truncated };
|
|
960
1132
|
return { ...result, truncated: state.truncated };
|
|
961
1133
|
}
|
|
962
1134
|
catch (error) {
|
|
@@ -975,28 +1147,37 @@ function sortedValue(value, depth = 0) {
|
|
|
975
1147
|
}
|
|
976
1148
|
/** A tool input with credentials and key-shaped values replaced; hashed, never sent. */
|
|
977
1149
|
function redactedInput(value, state, depth = 0) {
|
|
1150
|
+
// Past the depth or entry bounds the exact pass stops; exact_hmac then keys the raw canonical input.
|
|
978
1151
|
if (depth > 6) {
|
|
979
|
-
state.
|
|
1152
|
+
if (state.exact)
|
|
1153
|
+
throw new NormalizationLimit();
|
|
1154
|
+
state.truncated = true; // exact: raw (canonical input)
|
|
980
1155
|
return SECRET;
|
|
981
1156
|
}
|
|
982
1157
|
if (typeof value === 'string')
|
|
983
1158
|
return redactText(value, state);
|
|
984
1159
|
if (Array.isArray(value)) {
|
|
1160
|
+
if (value.length > 64 && state.exact)
|
|
1161
|
+
throw new NormalizationLimit();
|
|
985
1162
|
if (value.length > 64)
|
|
986
|
-
state.truncated = true;
|
|
1163
|
+
state.truncated = true; // exact: raw (canonical input)
|
|
987
1164
|
return value.slice(0, 64).map((item) => redactedInput(item, state, depth + 1));
|
|
988
1165
|
}
|
|
989
1166
|
if (value && typeof value === 'object') {
|
|
990
1167
|
const entries = Object.entries(value);
|
|
1168
|
+
if (entries.length > 64 && state.exact)
|
|
1169
|
+
throw new NormalizationLimit();
|
|
991
1170
|
if (entries.length > 64)
|
|
992
|
-
state.truncated = true;
|
|
1171
|
+
state.truncated = true; // exact: raw (canonical input)
|
|
993
1172
|
const out = {};
|
|
994
1173
|
for (const [key, item] of entries.slice(0, 64)) {
|
|
995
|
-
const kind =
|
|
1174
|
+
const kind = nameClass(key, state);
|
|
996
1175
|
if (kind === 'credential' || kind === 'ambiguous') {
|
|
1176
|
+
// The exact pass keeps an ambiguous value, and looks inside an object or array under a credential
|
|
1177
|
+
// name (`credentials: {user, role, password}`): only a value itself is certain.
|
|
997
1178
|
if (kind === 'ambiguous')
|
|
998
|
-
state.truncated = true;
|
|
999
|
-
out[key] = SECRET;
|
|
1179
|
+
state.truncated = true; // exact: kept (below)
|
|
1180
|
+
out[key] = state.exact && (kind === 'ambiguous' || (item !== null && typeof item === 'object')) ? redactedInput(item, state, depth + 1) : SECRET;
|
|
1000
1181
|
}
|
|
1001
1182
|
else {
|
|
1002
1183
|
out[key] = redactedInput(item, state, depth + 1);
|
|
@@ -1006,12 +1187,35 @@ function redactedInput(value, state, depth = 0) {
|
|
|
1006
1187
|
}
|
|
1007
1188
|
return value;
|
|
1008
1189
|
}
|
|
1190
|
+
/**
|
|
1191
|
+
* The host's own shell text when trimming changed what bash runs, else null. The command is read trimmed, but a
|
|
1192
|
+
* lone CR, a vertical tab, a form feed or a non-ASCII space at either end is part of a word in bash (`main<CR>` is
|
|
1193
|
+
* another ref than `main`). ASCII spaces, tabs, newlines and CRLF line ends at the ends change nothing.
|
|
1194
|
+
*/
|
|
1195
|
+
function untrimmedShellText(event, trimmed) {
|
|
1196
|
+
const input = event.tool_input && typeof event.tool_input === 'object' && !Array.isArray(event.tool_input)
|
|
1197
|
+
? event.tool_input : null;
|
|
1198
|
+
const host = (0, hook_tool_policy_1.shellCommandToolText)(event)
|
|
1199
|
+
?? (typeof event.tool_input === 'string' ? event.tool_input
|
|
1200
|
+
: typeof input?.command === 'string' ? input.command : typeof input?.cmd === 'string' ? input.cmd : null);
|
|
1201
|
+
if (host === null || host === trimmed)
|
|
1202
|
+
return null;
|
|
1203
|
+
const at = host.indexOf(trimmed);
|
|
1204
|
+
if (at < 0)
|
|
1205
|
+
return null;
|
|
1206
|
+
const edges = `${host.slice(0, at)}${host.slice(at + trimmed.length)}`.replace(/\r\n/g, '\n');
|
|
1207
|
+
return /[^ \t\n]/.test(edges) ? host : null;
|
|
1208
|
+
}
|
|
1009
1209
|
/**
|
|
1010
1210
|
* The command a shell tool runs. Codex may send an argument vector
|
|
1011
1211
|
* (["bash", "-lc", "<command>"]): its command is the same text a string form
|
|
1012
1212
|
* would carry, so both give the same normalized action.
|
|
1013
1213
|
*/
|
|
1014
1214
|
function shellCommand(event) {
|
|
1215
|
+
// A plain Monitor or PowerShell call: its one command or script text, read as Bash's command.
|
|
1216
|
+
const toolText = (0, hook_tool_policy_1.shellCommandToolText)(event);
|
|
1217
|
+
if (toolText !== null)
|
|
1218
|
+
return toolText.trim();
|
|
1015
1219
|
const input = event.tool_input && typeof event.tool_input === 'object' && !Array.isArray(event.tool_input)
|
|
1016
1220
|
? event.tool_input : null;
|
|
1017
1221
|
const argv = Array.isArray(input?.command) ? input.command : Array.isArray(input?.cmd) ? input.cmd : null;
|
|
@@ -1019,7 +1223,8 @@ function shellCommand(event) {
|
|
|
1019
1223
|
const words = argv;
|
|
1020
1224
|
if (words.length === 3 && /^(?:.*\/)?(?:ba|z|da|k)?sh$/.test(words[0]) && /^-[a-z]*c$/.test(words[1]))
|
|
1021
1225
|
return words[2];
|
|
1022
|
-
|
|
1226
|
+
// An argument vector runs without a shell: every word is literal, so any character the shell reads is quoted.
|
|
1227
|
+
return words.map((word) => (/[\s'"\\$`*?[\]{}~|&;<>()!#]/.test(word) ? `'${word.replace(/'/g, `'\\''`)}'` : word)).join(' ');
|
|
1023
1228
|
}
|
|
1024
1229
|
return (0, hook_tool_policy_1.hookToolCommand)(event);
|
|
1025
1230
|
}
|
|
@@ -1035,7 +1240,7 @@ function withoutUserName(path, state) {
|
|
|
1035
1240
|
const other = /^(\/(?:home|Users))\/[^/]+(?=\/|$)/.exec(path);
|
|
1036
1241
|
if (!other)
|
|
1037
1242
|
return path;
|
|
1038
|
-
state.truncated = true;
|
|
1243
|
+
state.truncated = true; // exact: n/a (paths are not exact material; the whole input is)
|
|
1039
1244
|
return `${other[1]}/[user]${path.slice(other[0].length)}`;
|
|
1040
1245
|
}
|
|
1041
1246
|
// ---------------------------------------------------------------------------
|
|
@@ -1127,12 +1332,216 @@ function inputHash(input, state) {
|
|
|
1127
1332
|
return {};
|
|
1128
1333
|
const text = JSON.stringify(sortedValue(redactedInput(input, state)));
|
|
1129
1334
|
if (text.includes(SECRET))
|
|
1130
|
-
state.truncated = true;
|
|
1335
|
+
state.truncated = true; // exact: n/a (pass 1 only; exactMaterial is the exact form)
|
|
1131
1336
|
return { input_sha256: sha256(`${HASH_VERSION}\ninput\n${text}`) };
|
|
1132
1337
|
}
|
|
1338
|
+
/**
|
|
1339
|
+
* The run options of a Monitor or PowerShell call (timeout, timeout_ms, run_in_background) as key material:
|
|
1340
|
+
* the hash of their canonical JSON with credentials replaced, as any tool input is hashed (so a replaced
|
|
1341
|
+
* credential marks the action truncated here too), or null when the call sets none (and for any other tool).
|
|
1342
|
+
* They decide how long and where the command runs, so an approval of one set is not an approval of another;
|
|
1343
|
+
* `description` is not key material.
|
|
1344
|
+
*/
|
|
1345
|
+
function runOptionsKey(event, state) {
|
|
1346
|
+
const options = (0, hook_tool_policy_1.shellCommandToolRunOptions)(event);
|
|
1347
|
+
if (options === null)
|
|
1348
|
+
return null;
|
|
1349
|
+
return inputHash(options, state).input_sha256;
|
|
1350
|
+
}
|
|
1351
|
+
/** runOptionsKey as a standalone identity (no deadline), for the exact target of a call (hook-contract exactHookTarget). */
|
|
1352
|
+
function shellToolRunOptionsKey(event) {
|
|
1353
|
+
return runOptionsKey(event, newExactness(Number.POSITIVE_INFINITY));
|
|
1354
|
+
}
|
|
1355
|
+
/** The exact form of the run options (only certain credentials replaced), for the exact material of a truncated action. */
|
|
1356
|
+
function exactRunOptions(event, state) {
|
|
1357
|
+
const options = (0, hook_tool_policy_1.shellCommandToolRunOptions)(event);
|
|
1358
|
+
return options === null ? null : JSON.stringify(sortedValue(redactedInput(options, state)));
|
|
1359
|
+
}
|
|
1133
1360
|
function withExactness(action, state) {
|
|
1134
1361
|
return state.truncated ? { ...action, truncated: true } : action;
|
|
1135
1362
|
}
|
|
1363
|
+
// ---------------------------------------------------------------------------
|
|
1364
|
+
// Exact material for a truncated action (exact_hmac; M2)
|
|
1365
|
+
// ---------------------------------------------------------------------------
|
|
1366
|
+
const EXACT_DOMAIN = 'marrow-exact-action-v1';
|
|
1367
|
+
/** Raw canonical input past this is never keyed: a nonce instead (it matches nothing). */
|
|
1368
|
+
const MAX_RAW_EXACT_CHARS = 1_048_576;
|
|
1369
|
+
const EXACT_SALT_NOTE = 'Marrow: the private device key for exact action matching is unavailable or unsafe on this machine, so a held action that hides a value is asked again instead of resumed on retry. Owner: run the Marrow installer doctor command in a terminal.';
|
|
1370
|
+
/** Canonical JSON (keys sorted at every depth), or null past 1 MB, past 64 levels, or when it cannot be serialized. */
|
|
1371
|
+
function canonicalJson(value) {
|
|
1372
|
+
const parts = [];
|
|
1373
|
+
let size = 0;
|
|
1374
|
+
const push = (text) => {
|
|
1375
|
+
size += text.length;
|
|
1376
|
+
if (size > MAX_RAW_EXACT_CHARS)
|
|
1377
|
+
throw new NormalizationLimit();
|
|
1378
|
+
parts.push(text);
|
|
1379
|
+
};
|
|
1380
|
+
const walk = (item, depth) => {
|
|
1381
|
+
if (depth > 64)
|
|
1382
|
+
throw new NormalizationLimit();
|
|
1383
|
+
if (Array.isArray(item)) {
|
|
1384
|
+
if (item.length > MAX_RAW_EXACT_CHARS / 2)
|
|
1385
|
+
throw new NormalizationLimit();
|
|
1386
|
+
push('[');
|
|
1387
|
+
item.forEach((entry, index) => { if (index)
|
|
1388
|
+
push(','); walk(entry, depth + 1); });
|
|
1389
|
+
push(']');
|
|
1390
|
+
}
|
|
1391
|
+
else if (item !== null && typeof item === 'object') {
|
|
1392
|
+
const keys = Object.keys(item);
|
|
1393
|
+
if (keys.length > MAX_RAW_EXACT_CHARS / 4)
|
|
1394
|
+
throw new NormalizationLimit();
|
|
1395
|
+
push('{');
|
|
1396
|
+
keys.sort().forEach((key, index) => {
|
|
1397
|
+
if (index)
|
|
1398
|
+
push(',');
|
|
1399
|
+
push(`${JSON.stringify(key)}:`);
|
|
1400
|
+
walk(item[key], depth + 1);
|
|
1401
|
+
});
|
|
1402
|
+
push('}');
|
|
1403
|
+
}
|
|
1404
|
+
else {
|
|
1405
|
+
push(JSON.stringify(item) ?? 'null');
|
|
1406
|
+
}
|
|
1407
|
+
};
|
|
1408
|
+
try {
|
|
1409
|
+
walk(value, 0);
|
|
1410
|
+
return parts.join('');
|
|
1411
|
+
}
|
|
1412
|
+
catch {
|
|
1413
|
+
return null;
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
/**
|
|
1417
|
+
* Every shell reference in a command, in order: `$NAME`, `${...}`, `$(...)` (balanced) and backquoted text,
|
|
1418
|
+
* quotes ignored (so a quoted literal `$` counts as well: stricter). A reference is never a credential value
|
|
1419
|
+
* itself: `API_KEY=$READONLY_KEY` and `API_KEY=$ADMIN_KEY`, or `mysql -p"$(cat ro.pw)"` and `"$(cat admin.pw)"`,
|
|
1420
|
+
* name different credentials. The exact pass replaces a credential position whole, so the references are
|
|
1421
|
+
* added to the exact material; two commands that differ only in a literal credential still match.
|
|
1422
|
+
*/
|
|
1423
|
+
function shellReferences(command) {
|
|
1424
|
+
const refs = [];
|
|
1425
|
+
for (let i = 0; i < command.length; i += 1) {
|
|
1426
|
+
const ch = command[i];
|
|
1427
|
+
if (ch === '`') {
|
|
1428
|
+
const end = command.indexOf('`', i + 1);
|
|
1429
|
+
const stop = end < 0 ? command.length : end + 1;
|
|
1430
|
+
refs.push(command.slice(i, stop));
|
|
1431
|
+
i = stop - 1;
|
|
1432
|
+
}
|
|
1433
|
+
else if (ch === '$' && (command[i + 1] === '(' || command[i + 1] === '{')) {
|
|
1434
|
+
const open = command[i + 1];
|
|
1435
|
+
const close = open === '(' ? ')' : '}';
|
|
1436
|
+
let depth = 0;
|
|
1437
|
+
let j = i + 1;
|
|
1438
|
+
for (; j < command.length; j += 1) {
|
|
1439
|
+
if (command[j] === open)
|
|
1440
|
+
depth += 1;
|
|
1441
|
+
else if (command[j] === close) {
|
|
1442
|
+
depth -= 1;
|
|
1443
|
+
if (depth === 0)
|
|
1444
|
+
break;
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
refs.push(command.slice(i, Math.min(j + 1, command.length)));
|
|
1448
|
+
i = Math.min(j, command.length);
|
|
1449
|
+
}
|
|
1450
|
+
else if (ch === '$') {
|
|
1451
|
+
const name = /^\$(?:[A-Za-z_][A-Za-z0-9_]*|[0-9@*#?$!-])/.exec(command.slice(i, i + 256));
|
|
1452
|
+
if (name) {
|
|
1453
|
+
refs.push(name[0]);
|
|
1454
|
+
i += name[0].length - 1;
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
return refs;
|
|
1459
|
+
}
|
|
1460
|
+
/** The exact form of a command: its words (only certain credentials replaced), its shell references and its quoting. */
|
|
1461
|
+
function exactShellForm(text, command, state) {
|
|
1462
|
+
return `${text}\n${JSON.stringify(shellReferences(command))}\n${JSON.stringify(state.sigs)}`;
|
|
1463
|
+
}
|
|
1464
|
+
/** The raw shell text as material; a plain Monitor or PowerShell call also carries its run options (raw). */
|
|
1465
|
+
function rawShellMaterial(event, command) {
|
|
1466
|
+
const options = (0, hook_tool_policy_1.shellCommandToolText)(event) === null
|
|
1467
|
+
? null : (0, hook_tool_policy_1.shellCommandToolRunOptions)(event);
|
|
1468
|
+
if (options === null)
|
|
1469
|
+
return `raw-shell\n${command}`;
|
|
1470
|
+
const json = canonicalJson(options);
|
|
1471
|
+
return json === null ? null : `raw-shell+run\n${json}\n${command}`;
|
|
1472
|
+
}
|
|
1473
|
+
/** The raw input as material (a placeholder, or an exact pass that hit a limit): the command, or the canonical input. */
|
|
1474
|
+
function rawExactMaterial(event, kind) {
|
|
1475
|
+
if (kind === 'shell') {
|
|
1476
|
+
try {
|
|
1477
|
+
const trimmed = shellCommand(event);
|
|
1478
|
+
const command = untrimmedShellText(event, trimmed) ?? trimmed;
|
|
1479
|
+
return typeof command === 'string' && command.length <= MAX_RAW_EXACT_CHARS ? rawShellMaterial(event, command) : null;
|
|
1480
|
+
}
|
|
1481
|
+
catch {
|
|
1482
|
+
return null;
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
const json = canonicalJson(event.tool_input ?? null);
|
|
1486
|
+
return json === null ? null : `raw-input\n${json}`;
|
|
1487
|
+
}
|
|
1488
|
+
/**
|
|
1489
|
+
* The exact material of an action: the same normalization with `exact` set (only a certain credential replaced,
|
|
1490
|
+
* every other value kept), or the raw input when that pass hits a limit. null: no material (a nonce instead).
|
|
1491
|
+
*/
|
|
1492
|
+
function exactMaterial(event, kind, deadline) {
|
|
1493
|
+
const state = newExactness(deadline, true);
|
|
1494
|
+
// Over 1 MB of material: a nonce (asked again), as for raw material.
|
|
1495
|
+
const bounded = (material) => (material.length <= MAX_RAW_EXACT_CHARS + 16 ? material : null);
|
|
1496
|
+
try {
|
|
1497
|
+
if (kind === 'shell') {
|
|
1498
|
+
const trimmed = shellCommand(event);
|
|
1499
|
+
const host = untrimmedShellText(event, trimmed);
|
|
1500
|
+
if (host !== null) {
|
|
1501
|
+
const raw = rawShellMaterial(event, host);
|
|
1502
|
+
return raw === null ? null : bounded(raw);
|
|
1503
|
+
}
|
|
1504
|
+
const { text } = normalizeInner(trimmed, state, 0);
|
|
1505
|
+
const runOptions = (0, hook_tool_policy_1.shellCommandToolText)(event) !== null
|
|
1506
|
+
? exactRunOptions(event, newExactness(deadline, true)) : null;
|
|
1507
|
+
return bounded(`exact-shell${runOptions === null ? '' : `+run\n${runOptions}`}\n${exactShellForm(text, trimmed, state)}`);
|
|
1508
|
+
}
|
|
1509
|
+
const input = event.tool_input;
|
|
1510
|
+
const text = input === undefined || input === null ? 'null' : JSON.stringify(sortedValue(redactedInput(input, state)));
|
|
1511
|
+
return bounded(`exact-input\n${text}`);
|
|
1512
|
+
}
|
|
1513
|
+
catch (error) {
|
|
1514
|
+
return error instanceof NormalizationLimit ? rawExactMaterial(event, kind) : null;
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
let saltNoted = false;
|
|
1518
|
+
/** The device salt (seatbelt store, same checks), or null with one stderr line per process (no path, no value). */
|
|
1519
|
+
function exactSalt() {
|
|
1520
|
+
let salt = null;
|
|
1521
|
+
try {
|
|
1522
|
+
salt = require('./seatbelt/record').deviceSalt();
|
|
1523
|
+
}
|
|
1524
|
+
catch {
|
|
1525
|
+
salt = null;
|
|
1526
|
+
}
|
|
1527
|
+
if (!salt && !saltNoted) {
|
|
1528
|
+
saltNoted = true;
|
|
1529
|
+
try {
|
|
1530
|
+
process.stderr.write(`${EXACT_SALT_NOTE}\n`);
|
|
1531
|
+
}
|
|
1532
|
+
catch { /* best effort */ }
|
|
1533
|
+
}
|
|
1534
|
+
return salt;
|
|
1535
|
+
}
|
|
1536
|
+
/**
|
|
1537
|
+
* exact_hmac: HMAC-SHA256(device salt, domain, kind, the host's full tool name, material), 128 bits. Without a
|
|
1538
|
+
* salt or material it is a fresh random nonce, which matches nothing: never unsalted, never the merged form.
|
|
1539
|
+
*/
|
|
1540
|
+
function exactHmac(salt, kind, toolName, material) {
|
|
1541
|
+
if (!salt || salt.length < 16 || material === null)
|
|
1542
|
+
return (0, node_crypto_1.randomBytes)(16).toString('hex');
|
|
1543
|
+
return (0, node_crypto_1.createHmac)('sha256', salt).update(`${EXACT_DOMAIN}\n${kind}\n${JSON.stringify(toolName)}\n`).update(material).digest('hex').slice(0, 32);
|
|
1544
|
+
}
|
|
1136
1545
|
/**
|
|
1137
1546
|
* The normalized action of a hook event. `deadlineAt` bounds the time it may
|
|
1138
1547
|
* take (default one second from now): past it, or past the size limits, the
|
|
@@ -1140,30 +1549,53 @@ function withExactness(action, state) {
|
|
|
1140
1549
|
* service never binds, so the hook still answers within its host's budget.
|
|
1141
1550
|
* An event whose input a host adapter cut or dropped (`input_truncated`) is
|
|
1142
1551
|
* marked truncated as well.
|
|
1552
|
+
*
|
|
1553
|
+
* A truncated action also carries `tool_input.exact_hmac`: a keyed hash (the
|
|
1554
|
+
* device salt never leaves the machine) of the action with only certain
|
|
1555
|
+
* credentials replaced, so two actions that differ anywhere else never share a
|
|
1556
|
+
* correlation, hold or key, and a retry that differs only in a certain
|
|
1557
|
+
* credential (an OTP) still does. A placeholder keys the raw input (up to
|
|
1558
|
+
* 1 MB). Input a host adapter cut or dropped, input over 1 MB and a missing or
|
|
1559
|
+
* unsafe salt give a random nonce: the action matches nothing (it is asked
|
|
1560
|
+
* again). `salt` is a test seam (undefined: the device salt).
|
|
1143
1561
|
*/
|
|
1144
1562
|
function normalizedHookAction(event, options = {}) {
|
|
1145
|
-
const
|
|
1563
|
+
const deadline = options.deadlineAt ?? Date.now() + DEFAULT_NORMALIZE_MS;
|
|
1564
|
+
const state = newExactness(deadline);
|
|
1146
1565
|
const toolName = (0, hook_tool_policy_1.normalizeHookToolName)(event.tool_name) || 'tool';
|
|
1147
1566
|
// The host's own tool name, exactly as sent (stable across retries); the policy name decides the kind.
|
|
1148
1567
|
// Not trimmed, so `X ` and `X` get different actions; a name past 128 characters is never bound.
|
|
1149
1568
|
const rawToolName = typeof event.tool_name === 'string' && event.tool_name ? event.tool_name : toolName;
|
|
1150
1569
|
const hostToolName = rawToolName.slice(0, 128);
|
|
1151
1570
|
if (rawToolName.length > 128)
|
|
1152
|
-
state.truncated = true;
|
|
1571
|
+
state.truncated = true; // exact: kept (the full name is in exact_hmac)
|
|
1572
|
+
// Claude Code built-ins first hooked in 3.9.105 that are not plain shell calls (a native git
|
|
1573
|
+
// equivalent, Skill, a call whose words are not actions, an irregular Monitor or PowerShell call)
|
|
1574
|
+
// are keyed by their whole input, never by one field of it.
|
|
1153
1575
|
const kind = (0, hook_tool_policy_1.isMcpHookTool)(event.tool_name) || /^MCP:/i.test(hostToolName) ? 'mcp'
|
|
1154
|
-
: (0, hook_tool_policy_1.
|
|
1155
|
-
:
|
|
1156
|
-
: '
|
|
1157
|
-
|
|
1158
|
-
|
|
1576
|
+
: (0, hook_tool_policy_1.isWholeInputNativeTool)(event) ? 'other'
|
|
1577
|
+
: (0, hook_tool_policy_1.isShellGovernedTool)(event) ? 'shell'
|
|
1578
|
+
: EDIT_TOOLS.test(toolName) ? 'edit'
|
|
1579
|
+
: 'other';
|
|
1580
|
+
const inputCut = event.input_truncated === true;
|
|
1581
|
+
if (inputCut)
|
|
1582
|
+
state.truncated = true; // exact: nonce (the cut or dropped input is all that is left)
|
|
1583
|
+
let action;
|
|
1584
|
+
let placeholder = false;
|
|
1159
1585
|
try {
|
|
1160
|
-
|
|
1586
|
+
action = normalizedOfKind(event, kind, hostToolName, state);
|
|
1161
1587
|
}
|
|
1162
1588
|
catch (error) {
|
|
1163
|
-
if (error instanceof NormalizationLimit)
|
|
1164
|
-
|
|
1165
|
-
|
|
1589
|
+
if (!(error instanceof NormalizationLimit))
|
|
1590
|
+
throw error;
|
|
1591
|
+
action = { tool_kind: kind, tool_name: hostToolName, tool_input: {}, truncated: true };
|
|
1592
|
+
placeholder = true;
|
|
1166
1593
|
}
|
|
1594
|
+
if (action.truncated !== true)
|
|
1595
|
+
return action;
|
|
1596
|
+
const material = inputCut ? null : placeholder ? rawExactMaterial(event, kind) : exactMaterial(event, kind, deadline);
|
|
1597
|
+
const salt = options.salt !== undefined ? options.salt : material === null ? null : exactSalt();
|
|
1598
|
+
return { ...action, tool_input: { ...action.tool_input, exact_hmac: exactHmac(salt, kind, rawToolName, material) } };
|
|
1167
1599
|
}
|
|
1168
1600
|
function normalizedOfKind(event, kind, hostToolName, state) {
|
|
1169
1601
|
if (kind === 'mcp') {
|
|
@@ -1171,21 +1603,34 @@ function normalizedOfKind(event, kind, hostToolName, state) {
|
|
|
1171
1603
|
}
|
|
1172
1604
|
if (kind === 'shell') {
|
|
1173
1605
|
const raw = shellCommand(event);
|
|
1606
|
+
if (untrimmedShellText(event, raw) !== null)
|
|
1607
|
+
state.truncated = true; // exact: raw (the host's untrimmed text)
|
|
1174
1608
|
const { text, programs } = normalizeInner(raw, state, 0);
|
|
1175
1609
|
// A secret was replaced: the action is never bound or reused (it asks each time).
|
|
1176
1610
|
if (text.includes(SECRET))
|
|
1177
|
-
state.truncated = true;
|
|
1611
|
+
state.truncated = true; // exact: n/a (pass 1 only; the exact pass decides each value)
|
|
1178
1612
|
// The command text itself only for a deploy the service can judge word for word (see deployCommandText).
|
|
1179
1613
|
// Judged on the host's exact text (a trimmed non-ASCII space must not slip through).
|
|
1180
1614
|
const input = event.tool_input && typeof event.tool_input === 'object' && !Array.isArray(event.tool_input) ? event.tool_input : null;
|
|
1181
1615
|
const exact = typeof input?.command === 'string' ? input.command : typeof input?.cmd === 'string' ? input.cmd : raw;
|
|
1182
1616
|
const deployText = state.truncated ? null : deployCommandText(exact);
|
|
1617
|
+
// A Monitor or PowerShell command carries its host tool name, so it never shares a hash with the
|
|
1618
|
+
// same Bash command (or another tool's): the line after the version is `shell` only for every
|
|
1619
|
+
// other shell call, whose hash is unchanged.
|
|
1620
|
+
// Its run options (timeout, timeout_ms, run_in_background), when it sets any, are in the hash too: a
|
|
1621
|
+
// different line after `shell-tool+run`, so no command text can stand for them.
|
|
1622
|
+
const plainShellTool = (0, hook_tool_policy_1.shellCommandToolText)(event) !== null;
|
|
1623
|
+
const runOptions = plainShellTool ? runOptionsKey(event, state) : null;
|
|
1624
|
+
const domain = !plainShellTool ? 'shell'
|
|
1625
|
+
: runOptions === null ? `shell-tool\n${JSON.stringify(hostToolName)}`
|
|
1626
|
+
: `shell-tool+run\n${JSON.stringify(hostToolName)}\n${runOptions}`;
|
|
1183
1627
|
return withExactness({
|
|
1184
1628
|
tool_kind: 'shell',
|
|
1185
1629
|
tool_name: hostToolName,
|
|
1186
1630
|
programs,
|
|
1187
1631
|
tool_input: {
|
|
1188
|
-
|
|
1632
|
+
// An exact action also hashes its quoting (never for a truncated one: a secret's characters stay out of it).
|
|
1633
|
+
command_sha256: sha256(`${HASH_VERSION}\n${domain}\n${state.truncated || !state.sigs.length ? text : `${text}\n${JSON.stringify(state.sigs)}`}`),
|
|
1189
1634
|
...(deployText !== null && deployText === text ? { command: deployText } : {}),
|
|
1190
1635
|
},
|
|
1191
1636
|
}, state);
|
|
@@ -1193,11 +1638,11 @@ function normalizedOfKind(event, kind, hostToolName, state) {
|
|
|
1193
1638
|
if (kind === 'edit') {
|
|
1194
1639
|
const targets = (0, hook_tool_policy_1.toolTargetPaths)(event);
|
|
1195
1640
|
if (targets.length > 64)
|
|
1196
|
-
state.truncated = true;
|
|
1641
|
+
state.truncated = true; // exact: n/a (paths are not exact material; the whole input is)
|
|
1197
1642
|
const paths = targets.slice(0, 64).map((path) => {
|
|
1198
1643
|
const redacted = redactText(withoutUserName(path, state), state);
|
|
1199
1644
|
if (redacted.length > 512 || redacted.includes(SECRET))
|
|
1200
|
-
state.truncated = true;
|
|
1645
|
+
state.truncated = true; // exact: n/a (as above)
|
|
1201
1646
|
return redacted.slice(0, 512);
|
|
1202
1647
|
});
|
|
1203
1648
|
return withExactness({ tool_kind: 'edit', tool_name: hostToolName, paths, tool_input: inputHash(event.tool_input, state) }, state);
|