@getmarrow/mcp 3.9.97 → 3.9.99
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 +57 -338
- package/dist/cli.js +249 -47
- package/dist/cli.js.map +1 -1
- package/dist/hook-context.d.ts.map +1 -1
- package/dist/hook-context.js +135 -9
- package/dist/hook-context.js.map +1 -1
- package/dist/hook-contract.d.ts +26 -5
- package/dist/hook-contract.d.ts.map +1 -1
- package/dist/hook-contract.js +144 -10
- package/dist/hook-contract.js.map +1 -1
- package/dist/hook-pre-action.d.ts +40 -1
- package/dist/hook-pre-action.d.ts.map +1 -1
- package/dist/hook-pre-action.js +429 -35
- package/dist/hook-pre-action.js.map +1 -1
- package/dist/hook-session.d.ts.map +1 -1
- package/dist/hook-session.js +32 -0
- package/dist/hook-session.js.map +1 -1
- package/dist/hook.d.ts +12 -0
- package/dist/hook.d.ts.map +1 -1
- package/dist/hook.js +149 -5
- package/dist/hook.js.map +1 -1
- package/dist/host-approval-state.d.ts +137 -0
- package/dist/host-approval-state.d.ts.map +1 -0
- package/dist/host-approval-state.js +510 -0
- package/dist/host-approval-state.js.map +1 -0
- package/dist/host-approval.d.ts +436 -0
- package/dist/host-approval.d.ts.map +1 -0
- package/dist/host-approval.js +1417 -0
- package/dist/host-approval.js.map +1 -0
- package/dist/host-session.d.ts +82 -0
- package/dist/host-session.d.ts.map +1 -0
- package/dist/host-session.js +570 -0
- package/dist/host-session.js.map +1 -0
- package/dist/index.d.ts +150 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +748 -33
- package/dist/index.js.map +1 -1
- package/dist/lifecycle-spool.d.ts.map +1 -1
- package/dist/lifecycle-spool.js +6 -5
- package/dist/lifecycle-spool.js.map +1 -1
- package/dist/normalized-action.d.ts +120 -0
- package/dist/normalized-action.d.ts.map +1 -0
- package/dist/normalized-action.js +1109 -0
- package/dist/normalized-action.js.map +1 -0
- package/dist/runtime-contract.d.ts +108 -4
- package/dist/runtime-contract.d.ts.map +1 -1
- package/dist/runtime-contract.js +178 -35
- package/dist/runtime-contract.js.map +1 -1
- package/dist/types.d.ts +128 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +21 -25
- package/server.json +15 -4
|
@@ -0,0 +1,1109 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MAX_SCAN_CHARS = void 0;
|
|
4
|
+
exports.looksLikeKey = looksLikeKey;
|
|
5
|
+
exports.classifySecretName = classifySecretName;
|
|
6
|
+
exports.normalizeShellCommand = normalizeShellCommand;
|
|
7
|
+
exports.normalizedHookAction = normalizedHookAction;
|
|
8
|
+
const node_crypto_1 = require("node:crypto");
|
|
9
|
+
const node_os_1 = require("node:os");
|
|
10
|
+
const hook_tool_policy_1 = require("./hook-tool-policy");
|
|
11
|
+
const SECRET = '[secret]';
|
|
12
|
+
const HASH_VERSION = 'marrow-normalized-action-v2';
|
|
13
|
+
const EDIT_TOOLS = /^(?:edit|write|multiedit|apply_patch|notebookedit|replace|write_file|edit_file|delete_file|search_replace|delete|create_file)$/i;
|
|
14
|
+
const MAX_DEPTH = 3;
|
|
15
|
+
/** A word, data text or input string longer than this is not scanned: it is withheld and the action marked truncated. */
|
|
16
|
+
exports.MAX_SCAN_CHARS = 16_384;
|
|
17
|
+
/** A command longer than this, or more text scanned in total, gives up on exactness (truncated placeholder). */
|
|
18
|
+
const MAX_COMMAND_CHARS = 262_144;
|
|
19
|
+
const MAX_TOTAL_SCAN_CHARS = 524_288;
|
|
20
|
+
/** Words and operators in one command; more gives up on exactness (truncated placeholder). */
|
|
21
|
+
const MAX_TOKENS = 4_096;
|
|
22
|
+
/** Credential-literal matches scanned in one text before it is withheld as truncated. */
|
|
23
|
+
const MAX_ASSIGNMENTS = 2_048;
|
|
24
|
+
/** Wall-clock time normalization may take when the caller gives no deadline. */
|
|
25
|
+
const DEFAULT_NORMALIZE_MS = 1_000;
|
|
26
|
+
/** Thrown when normalization runs out of its budget: the caller sends a truncated placeholder instead. */
|
|
27
|
+
class NormalizationLimit extends Error {
|
|
28
|
+
}
|
|
29
|
+
function newExactness(deadline = Date.now() + DEFAULT_NORMALIZE_MS) {
|
|
30
|
+
return { truncated: false, budget: { scanned: 0, deadline } };
|
|
31
|
+
}
|
|
32
|
+
/** Counts text about to be scanned against the shared budget; past it, normalization stops. */
|
|
33
|
+
function spend(state, chars) {
|
|
34
|
+
state.budget.scanned += chars;
|
|
35
|
+
if (state.budget.scanned > MAX_TOTAL_SCAN_CHARS || Date.now() > state.budget.deadline)
|
|
36
|
+
throw new NormalizationLimit();
|
|
37
|
+
}
|
|
38
|
+
function sha256(text) {
|
|
39
|
+
return (0, node_crypto_1.createHash)('sha256').update(text).digest('hex');
|
|
40
|
+
}
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Key-shaped values
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
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,}$/;
|
|
45
|
+
const PEM = /-----BEGIN [A-Z ]*(?:PRIVATE KEY|CERTIFICATE)-----/;
|
|
46
|
+
/** A value shaped like a key, token or password hash (mixed-case random text, long hex, known prefixes). */
|
|
47
|
+
function looksLikeKey(value) {
|
|
48
|
+
const text = value.trim();
|
|
49
|
+
if (!text)
|
|
50
|
+
return false;
|
|
51
|
+
if (KNOWN_KEY_PREFIX.test(text))
|
|
52
|
+
return true;
|
|
53
|
+
if (PEM.test(text))
|
|
54
|
+
return true;
|
|
55
|
+
// A content digest (sha256:...) names content; it is not a secret.
|
|
56
|
+
if (/^(?:sha(?:1|256|384|512)):[0-9a-f]+$/i.test(text))
|
|
57
|
+
return false;
|
|
58
|
+
if (/^[0-9a-f]{32,}$/i.test(text))
|
|
59
|
+
return true;
|
|
60
|
+
if (text.length >= 20 && /^[A-Za-z0-9+/=_-]+$/.test(text) && /[a-z]/.test(text) && /[A-Z]/.test(text) && /[0-9]/.test(text))
|
|
61
|
+
return true;
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
const KEY_SPLIT = /([\s=:,;@/?&'"`()[\]{}<>|!*$])/;
|
|
65
|
+
/**
|
|
66
|
+
* Replaces key-shaped values in a text. A known key format is a credential;
|
|
67
|
+
* any other random-looking value may be an id or a commit, so it marks the
|
|
68
|
+
* action truncated.
|
|
69
|
+
*/
|
|
70
|
+
function redactKeyShapes(text, state) {
|
|
71
|
+
const whole = text.trim();
|
|
72
|
+
if (whole && !/\s/.test(whole) && looksLikeKey(whole)) {
|
|
73
|
+
if (!KNOWN_KEY_PREFIX.test(whole) && !PEM.test(whole))
|
|
74
|
+
state.truncated = true;
|
|
75
|
+
return SECRET;
|
|
76
|
+
}
|
|
77
|
+
if (PEM.test(text)) {
|
|
78
|
+
return text.replace(/-----BEGIN [A-Z ]*-----[\s\S]*?(?:-----END [A-Z ]*-----|$)/g, SECRET);
|
|
79
|
+
}
|
|
80
|
+
return text.split(KEY_SPLIT).map((part) => {
|
|
81
|
+
if (!looksLikeKey(part))
|
|
82
|
+
return part;
|
|
83
|
+
if (!KNOWN_KEY_PREFIX.test(part))
|
|
84
|
+
state.truncated = true;
|
|
85
|
+
return SECRET;
|
|
86
|
+
}).join('');
|
|
87
|
+
}
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Names
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
function nameParts(name) {
|
|
92
|
+
return name.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
|
93
|
+
}
|
|
94
|
+
const CREDENTIAL_STEM = /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/;
|
|
95
|
+
const CREDENTIAL_WORDS = new Set(['pass', 'pw', 'otp', 'totp', 'pat', 'jwt']);
|
|
96
|
+
/** A name part that contains `pass` names a password (DBPASS, ADMINPASS), except these ordinary words. */
|
|
97
|
+
const PASS_WORDS_NOT_SECRET = /bypass|compass|passage|passive|passeng|passport|passthr|surpass|overpass|underpass|trespass|passover|passable|passing|passed|passes/;
|
|
98
|
+
const holdsPass = (part) => part.includes('pass') && !PASS_WORDS_NOT_SECRET.test(part);
|
|
99
|
+
const CREDENTIAL_PAIRS = ['api_key', 'access_key', 'private_key', 'secret_key', 'signing_key', 'master_key', 'encryption_key', 'account_key', 'auth_key'];
|
|
100
|
+
const AMBIGUOUS_WORDS = new Set(['auth', 'oauth', 'key', 'keys', 'session', 'sessionid', 'sid', 'signature', 'sig', 'pin', 'cred', 'hmac', 'sk']);
|
|
101
|
+
const REFERENCE_LAST = new Set([
|
|
102
|
+
'file', 'files', 'path', 'paths', 'dir', 'name', 'names', 'id', 'ids', 'arn', 'ref', 'uri', 'url', 'type', 'mode',
|
|
103
|
+
'ttl', 'length', 'size', 'scope', 'scopes', 'expiry', 'expires', 'version', 'alias', 'env', 'region', 'policy',
|
|
104
|
+
'count', 'limit', 'fd', 'stdin', 'prompt', 'rotation', 'format', 'algorithm', 'alg', 'method', 'provider', 'store',
|
|
105
|
+
'helper', 'command', 'cmd', 'prefix', 'field', 'var', 'location', 'endpoint', 'host', 'port', 'user', 'username', 'email',
|
|
106
|
+
]);
|
|
107
|
+
/**
|
|
108
|
+
* What a value under this name is: a credential (replaced; the action stays
|
|
109
|
+
* exact), ambiguous (replaced; the action is marked truncated), a reference
|
|
110
|
+
* to a secret such as its file, name or id (kept), or plain (kept).
|
|
111
|
+
*/
|
|
112
|
+
function classifySecretName(name) {
|
|
113
|
+
const parts = nameParts(name);
|
|
114
|
+
if (!parts.length)
|
|
115
|
+
return 'plain';
|
|
116
|
+
const joined = parts.join('_');
|
|
117
|
+
const credential = parts.some((part) => CREDENTIAL_STEM.test(part) || CREDENTIAL_WORDS.has(part) || holdsPass(part))
|
|
118
|
+
|| CREDENTIAL_PAIRS.some((pair) => joined === pair || joined.startsWith(`${pair}_`) || joined.endsWith(`_${pair}`) || joined.includes(`_${pair}_`));
|
|
119
|
+
const ambiguous = !credential && parts.some((part) => AMBIGUOUS_WORDS.has(part));
|
|
120
|
+
if (!credential && !ambiguous)
|
|
121
|
+
return 'plain';
|
|
122
|
+
const last = parts[parts.length - 1];
|
|
123
|
+
if (REFERENCE_LAST.has(last))
|
|
124
|
+
return 'reference';
|
|
125
|
+
return credential ? 'credential' : 'ambiguous';
|
|
126
|
+
}
|
|
127
|
+
function holdsSecret(name) {
|
|
128
|
+
const kind = classifySecretName(name);
|
|
129
|
+
return kind === 'credential' || kind === 'ambiguous';
|
|
130
|
+
}
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Secrets inside a text
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// Every pattern starts at a boundary (lookbehind) and bounds its repeats, so
|
|
135
|
+
// a scan is linear in the text: no start inside a run, no unbounded backtracking.
|
|
136
|
+
const URL_CREDENTIALS = /(?<![a-z0-9+.-])([a-z][a-z0-9+.-]{0,31}:\/\/)([^\s/@:'"]{0,256}):([^\s/@'"]{1,1024})@/gi;
|
|
137
|
+
/** An auth scheme (any case for Bearer and Basic) followed by a token-shaped value; the value is read by hand. */
|
|
138
|
+
const AUTH_SCHEME = /\b([Bb][Ee][Aa][Rr][Ee][Rr]|[Bb][Aa][Ss][Ii][Cc]|BEARER|BASIC|Token|Bot)([ \t]{1,8})(?=[A-Za-z0-9._~+/=-]{8})/g;
|
|
139
|
+
const URL_CODE = /([?&])((?:auth(?:orization)?_)?code)=/gi;
|
|
140
|
+
/** A name and its separator (`name=`, `name: `, `"name": `); the value is read by hand after it. */
|
|
141
|
+
const NAME_SEPARATOR = /(["']?)(?<![A-Za-z0-9_.-])([A-Za-z_][A-Za-z0-9_.-]{0,127})\1([ \t]{0,8}[:=][ \t]{0,8})/g;
|
|
142
|
+
/** After a separator: an identifier-like field name and its own separator (`env=`, `env:`, `"env":`). */
|
|
143
|
+
const FIELD_AHEAD = /[ \t]{0,8}(["']?)[A-Za-z_][A-Za-z0-9_.-]{0,127}\1[ \t]{0,8}[=:]/y;
|
|
144
|
+
function fieldAhead(text, at) {
|
|
145
|
+
FIELD_AHEAD.lastIndex = at;
|
|
146
|
+
return FIELD_AHEAD.test(text);
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Where an unquoted credential value ends (one rule for every path): at
|
|
150
|
+
* whitespace or a quote; at `&`, `;` or `|` only when an identifier-like
|
|
151
|
+
* `name=`/`name:` field or a shell operator boundary (whitespace) follows; at
|
|
152
|
+
* `#` or `,` only when a `name=`/`name:` field follows. Anything else
|
|
153
|
+
* (`ab#cd`, `ab,cd`, `ab&cd`, `ab)cd`) is part of the value, so no part of a
|
|
154
|
+
* password stays in the normalized text. `wordOnly`: the value is one shell
|
|
155
|
+
* word, so whitespace and quotes inside it are part of it.
|
|
156
|
+
*/
|
|
157
|
+
function credentialValueEnd(text, start, wordOnly = false) {
|
|
158
|
+
let i = start;
|
|
159
|
+
while (i < text.length) {
|
|
160
|
+
const c = text[i];
|
|
161
|
+
if (!wordOnly && (c === '"' || c === '\'' || /\s/.test(c)))
|
|
162
|
+
return i;
|
|
163
|
+
if (c === '&' || c === ';' || c === '|') {
|
|
164
|
+
let j = i + 1;
|
|
165
|
+
while (j < text.length && (text[j] === '&' || text[j] === ';' || text[j] === '|'))
|
|
166
|
+
j += 1;
|
|
167
|
+
if ((!wordOnly && j < text.length && /\s/.test(text[j])) || fieldAhead(text, j))
|
|
168
|
+
return i;
|
|
169
|
+
i = j;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if ((c === '#' || c === ',') && fieldAhead(text, i + 1))
|
|
173
|
+
return i;
|
|
174
|
+
i += 1;
|
|
175
|
+
}
|
|
176
|
+
return i;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* A credential value that itself holds `name=value` (beyond base64 `=`
|
|
180
|
+
* padding) may have carried more than the credential: withholding it could
|
|
181
|
+
* merge different actions, so the action is marked truncated.
|
|
182
|
+
*/
|
|
183
|
+
function holdsMoreThanCredential(value) {
|
|
184
|
+
return /=(?!=*$)/.test(value);
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* A credential value followed by another field (`S,env=prod`, `S&env=prod`)
|
|
188
|
+
* is cut at that separator: the rest stays in the hashed form, so actions
|
|
189
|
+
* that differ after it stay different, and the action is marked truncated
|
|
190
|
+
* (never bound), since the separator may have been part of the credential.
|
|
191
|
+
* Without a following `name=` field the whole value is the credential.
|
|
192
|
+
*/
|
|
193
|
+
function cutCredential(value, state) {
|
|
194
|
+
const at = credentialValueEnd(value, 0, true);
|
|
195
|
+
if (at >= value.length) {
|
|
196
|
+
if (holdsMoreThanCredential(value))
|
|
197
|
+
state.truncated = true;
|
|
198
|
+
return SECRET;
|
|
199
|
+
}
|
|
200
|
+
state.truncated = true;
|
|
201
|
+
return SECRET + redactText(value.slice(at), state);
|
|
202
|
+
}
|
|
203
|
+
/** One-value auth header (`Authorization: Bearer X`, `X-Api-Key: K`): the whole value is the credential. */
|
|
204
|
+
const SINGLE_VALUE_HEADER = /^[^:\s]{1,128}:[ \t]*(?:(?:Bearer|Basic|Token|Bot)[ \t]+)?[^\s;,&]+[ \t]*$/i;
|
|
205
|
+
/**
|
|
206
|
+
* Replaces the secrets inside one word or text: URL passwords, auth header
|
|
207
|
+
* values, OAuth codes, credential-named literals (credentials; ambiguous
|
|
208
|
+
* names mark the action truncated) and key-shaped values.
|
|
209
|
+
*/
|
|
210
|
+
function redactText(text, state) {
|
|
211
|
+
if (text.length > exports.MAX_SCAN_CHARS) {
|
|
212
|
+
state.truncated = true;
|
|
213
|
+
return SECRET;
|
|
214
|
+
}
|
|
215
|
+
spend(state, text.length);
|
|
216
|
+
// A short word without a separator cannot hold any of these (keys need 12 or more characters).
|
|
217
|
+
if (text.length < 12 && !/[:=?&@\s]/.test(text))
|
|
218
|
+
return text;
|
|
219
|
+
let out = text.replace(URL_CREDENTIALS, (_match, scheme, user) => `${scheme}${user}:${SECRET}@`);
|
|
220
|
+
out = replaceValuesAfter(out, AUTH_SCHEME);
|
|
221
|
+
out = replaceValuesAfter(out, URL_CODE);
|
|
222
|
+
return redactKeyShapes(redactAssignments(out, state), state);
|
|
223
|
+
}
|
|
224
|
+
/** Replaces the value after each match of `pattern` (an auth scheme, an OAuth code), ended by the shared rule. */
|
|
225
|
+
function replaceValuesAfter(text, pattern) {
|
|
226
|
+
pattern.lastIndex = 0;
|
|
227
|
+
let out = '';
|
|
228
|
+
let copied = 0;
|
|
229
|
+
for (let match = pattern.exec(text); match; match = pattern.exec(text)) {
|
|
230
|
+
const start = match.index + match[0].length;
|
|
231
|
+
const end = credentialValueEnd(text, start);
|
|
232
|
+
if (end <= start) {
|
|
233
|
+
pattern.lastIndex = start + 1;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
out += text.slice(copied, start) + SECRET;
|
|
237
|
+
copied = end;
|
|
238
|
+
pattern.lastIndex = end;
|
|
239
|
+
}
|
|
240
|
+
return out + text.slice(copied);
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Credential-named literals (`name=value`, `name: value`, `"name": "value"`),
|
|
244
|
+
* also nested in a plain value (a URL query): one pass, no recursion. After a
|
|
245
|
+
* plain name the scan continues inside its value; after a credential name the
|
|
246
|
+
* value is replaced and skipped. Too many names in one text: it is withheld.
|
|
247
|
+
*/
|
|
248
|
+
function redactAssignments(text, state) {
|
|
249
|
+
const pattern = NAME_SEPARATOR;
|
|
250
|
+
pattern.lastIndex = 0;
|
|
251
|
+
let out = '';
|
|
252
|
+
let copied = 0;
|
|
253
|
+
let matches = 0;
|
|
254
|
+
for (let match = pattern.exec(text); match; match = pattern.exec(text)) {
|
|
255
|
+
matches += 1;
|
|
256
|
+
if (matches > MAX_ASSIGNMENTS) {
|
|
257
|
+
state.truncated = true;
|
|
258
|
+
return SECRET;
|
|
259
|
+
}
|
|
260
|
+
const kind = classifySecretName(match[2]);
|
|
261
|
+
if (kind !== 'credential' && kind !== 'ambiguous')
|
|
262
|
+
continue;
|
|
263
|
+
let start = match.index + match[0].length;
|
|
264
|
+
let end = start;
|
|
265
|
+
const quote = text[start] === '"' || text[start] === '\'' ? text[start] : '';
|
|
266
|
+
if (quote) {
|
|
267
|
+
start += 1;
|
|
268
|
+
const close = text.indexOf(quote, start);
|
|
269
|
+
end = close;
|
|
270
|
+
}
|
|
271
|
+
if (!quote || end < 0)
|
|
272
|
+
end = credentialValueEnd(text, start);
|
|
273
|
+
if (end <= start)
|
|
274
|
+
continue;
|
|
275
|
+
const value = text.slice(start, end);
|
|
276
|
+
if (!value.startsWith('[secret') && !value.startsWith('[data:')) {
|
|
277
|
+
if (kind === 'ambiguous' || holdsMoreThanCredential(value))
|
|
278
|
+
state.truncated = true;
|
|
279
|
+
out += text.slice(copied, start) + SECRET;
|
|
280
|
+
copied = end;
|
|
281
|
+
}
|
|
282
|
+
pattern.lastIndex = end;
|
|
283
|
+
}
|
|
284
|
+
return out + text.slice(copied);
|
|
285
|
+
}
|
|
286
|
+
/** Whether a text carries anything secret-shaped (it is then withheld, never hashed). */
|
|
287
|
+
function secretShaped(text, state) {
|
|
288
|
+
const probe = { truncated: false, budget: state.budget };
|
|
289
|
+
return redactText(text, probe) !== text;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Data a command reads: its SHA-256 in the hashed form, or `[secret]` and
|
|
293
|
+
* truncated when it feeds a secrets command or file or looks secret.
|
|
294
|
+
*/
|
|
295
|
+
function dataMarker(text, feedsSecrets, state) {
|
|
296
|
+
if (feedsSecrets || secretShaped(text, state)) {
|
|
297
|
+
state.truncated = true;
|
|
298
|
+
return SECRET;
|
|
299
|
+
}
|
|
300
|
+
return `[data:${sha256(`${HASH_VERSION}\ndata\n${text}`)}]`;
|
|
301
|
+
}
|
|
302
|
+
const OPERATORS = ['<<<', '&&', '||', '|&', '>>', '<<', ';;', '|', '&', ';', '<', '>', '(', ')', '\n'];
|
|
303
|
+
/** Splits a command into words and operators, honoring quotes and escapes (an approximation of the shell). */
|
|
304
|
+
function tokenize(command) {
|
|
305
|
+
const tokens = [];
|
|
306
|
+
let current = '';
|
|
307
|
+
let started = false;
|
|
308
|
+
const push = () => {
|
|
309
|
+
if (started)
|
|
310
|
+
tokens.push({ value: current, op: false });
|
|
311
|
+
current = '';
|
|
312
|
+
started = false;
|
|
313
|
+
};
|
|
314
|
+
for (let i = 0; i < command.length; i += 1) {
|
|
315
|
+
const ch = command[i];
|
|
316
|
+
if (ch === '\\' && i + 1 < command.length) {
|
|
317
|
+
current += command[i + 1];
|
|
318
|
+
started = true;
|
|
319
|
+
i += 1;
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
if (ch === '\'' || ch === '"') {
|
|
323
|
+
const end = command.indexOf(ch, i + 1);
|
|
324
|
+
const body = end < 0 ? command.slice(i + 1) : command.slice(i + 1, end);
|
|
325
|
+
current += ch === '"' ? body.replace(/\\(["\\$`])/g, '$1') : body;
|
|
326
|
+
started = true;
|
|
327
|
+
i = end < 0 ? command.length : end;
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (ch === ' ' || ch === '\t' || ch === '\r') {
|
|
331
|
+
push();
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
const op = OPERATORS.find((candidate) => command.startsWith(candidate, i));
|
|
335
|
+
if (op) {
|
|
336
|
+
push();
|
|
337
|
+
tokens.push({ value: op === '\n' ? ';' : op, op: true });
|
|
338
|
+
i += op.length - 1;
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
current += ch;
|
|
342
|
+
started = true;
|
|
343
|
+
}
|
|
344
|
+
push();
|
|
345
|
+
return tokens;
|
|
346
|
+
}
|
|
347
|
+
/** A placeholder word for a heredoc body or here-string, resolved per command. */
|
|
348
|
+
function slotWord(slot) {
|
|
349
|
+
return `\u0000${slot}\u0000`;
|
|
350
|
+
}
|
|
351
|
+
const SLOT = /^\u0000(\d+)\u0000$/;
|
|
352
|
+
/**
|
|
353
|
+
* Heredoc bodies are input to the command, never part of it: each is moved
|
|
354
|
+
* to `data` and its place marked, before tokenizing.
|
|
355
|
+
*/
|
|
356
|
+
function extractHeredocs(command, data) {
|
|
357
|
+
const lines = command.split('\n');
|
|
358
|
+
const out = [];
|
|
359
|
+
const pending = [];
|
|
360
|
+
for (let n = 0; n < lines.length; n += 1) {
|
|
361
|
+
if (pending.length) {
|
|
362
|
+
const doc = pending.shift();
|
|
363
|
+
const body = [];
|
|
364
|
+
let closed = false;
|
|
365
|
+
for (; n < lines.length; n += 1) {
|
|
366
|
+
if (lines[n].replace(/^[ \t]+|[ \t]+$/g, '') === doc.tag) {
|
|
367
|
+
closed = true;
|
|
368
|
+
break;
|
|
369
|
+
}
|
|
370
|
+
body.push(doc.dash ? lines[n].replace(/^\t+/, '') : lines[n]);
|
|
371
|
+
}
|
|
372
|
+
data[doc.slot] = body.join('\n');
|
|
373
|
+
if (!closed)
|
|
374
|
+
break;
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
out.push(lines[n].replace(/(?<!<)<<(-?)[ \t]*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\2/g, (_match, dash, _quote, tag) => {
|
|
378
|
+
data.push('');
|
|
379
|
+
const slot = data.length - 1;
|
|
380
|
+
pending.push({ tag, dash: dash === '-', slot });
|
|
381
|
+
return `<<${tag} ${slotWord(slot)} `;
|
|
382
|
+
}));
|
|
383
|
+
}
|
|
384
|
+
return out.join('\n');
|
|
385
|
+
}
|
|
386
|
+
const WRAPPERS = new Set(['sudo', 'doas', 'env', 'time', 'nohup', 'exec', 'command', 'nice', 'ionice', 'stdbuf', 'timeout', 'xargs', 'npx', 'bunx', 'pnpx']);
|
|
387
|
+
const SHELLS = /^(?:ba|z|da|k|fi|tc|c)?sh$/;
|
|
388
|
+
const MYSQL_FAMILY = new Set(['mysql', 'mariadb', 'mysqldump', 'mysqladmin', 'mysqlimport', 'mysqlshow', 'mysqlcheck', 'mysqlsh']);
|
|
389
|
+
const MONGO_FAMILY = new Set(['mongo', 'mongosh', 'mongodump', 'mongorestore', 'mongoexport', 'mongoimport', 'mongostat', 'mongotop', 'mongofiles']);
|
|
390
|
+
/** `-p` is always the password. */
|
|
391
|
+
const ALWAYS_P = new Set(['sshpass', 'twine', 'useradd', 'usermod', ...MONGO_FAMILY]);
|
|
392
|
+
/** `-p` is the password in a `login` subcommand. */
|
|
393
|
+
const LOGIN_P = new Set(['docker', 'podman', 'nerdctl', 'buildah', 'helm', 'oras', 'crane', 'skopeo', 'regctl', 'az', 'oc', 'cf']);
|
|
394
|
+
/** `-P` is the password. */
|
|
395
|
+
const UPPER_P = new Set(['sqlcmd', 'bcp', 'osql', 'isql']);
|
|
396
|
+
const REDIS_FAMILY = new Set(['redis-cli', 'keydb-cli', 'valkey-cli']);
|
|
397
|
+
const CURL_FAMILY = new Set(['curl', 'wget', 'http', 'https', 'xh']);
|
|
398
|
+
const OPENSSL_PASS_FLAGS = new Set(['-pass', '-passin', '-passout', '-k', '-kfile', '-password']);
|
|
399
|
+
const AUTH_HEADER = /authorization|token|key|secret|cookie|bearer|basic/i;
|
|
400
|
+
/** Flags of secrets commands whose value is a target, never the secret (`gh secret set N --repo org/app`). */
|
|
401
|
+
const SECRET_COMMAND_VALUE_FLAGS = new Set([
|
|
402
|
+
'repo', 'repos', 'env', 'environment', 'org', 'app', 'application', 'namespace', 'context', 'cluster', 'project',
|
|
403
|
+
'config', 'mount', 'visibility', 'type', 'scope', 'region', 'profile', 'account', 'team', 'target', 'stage',
|
|
404
|
+
'service', 'site', 'path', 'name', 'vault',
|
|
405
|
+
]);
|
|
406
|
+
const SECRET_COMMAND_SHORT_VALUE_FLAGS = {
|
|
407
|
+
gh: new Set(['-R', '-e', '-o', '-a', '-v']),
|
|
408
|
+
kubectl: new Set(['-n']),
|
|
409
|
+
oc: new Set(['-n']),
|
|
410
|
+
doppler: new Set(['-p', '-c']),
|
|
411
|
+
};
|
|
412
|
+
function secretCommandValueFlag(program, flag) {
|
|
413
|
+
if (flag.startsWith('--'))
|
|
414
|
+
return SECRET_COMMAND_VALUE_FLAGS.has(flag.slice(2).toLowerCase());
|
|
415
|
+
return SECRET_COMMAND_SHORT_VALUE_FLAGS[program]?.has(flag) ?? false;
|
|
416
|
+
}
|
|
417
|
+
const SSH_VALUE_FLAGS = new Set(['-b', '-c', '-D', '-E', '-e', '-F', '-I', '-i', '-J', '-L', '-l', '-m', '-O', '-o', '-p', '-Q', '-R', '-S', '-W', '-w']);
|
|
418
|
+
/** Programs that take secrets on their input. */
|
|
419
|
+
const SECRET_PROGRAMS = new Set([
|
|
420
|
+
'vault', 'op', 'pass', 'gopass', 'sops', 'age', 'kubeseal', 'ssh-add', 'sshpass', 'chpasswd', 'passwd', 'htpasswd',
|
|
421
|
+
'keytool', 'security', 'gpg', 'gpg2', 'systemd-creds', 'bw', 'lpass', 'doppler', 'infisical', 'dotenvx', 'secret-tool',
|
|
422
|
+
]);
|
|
423
|
+
const SECRET_FILE = /(?:^|\/)\.env(?:[.\w-]*)$|\.(?:pem|key|p12|pfx|jks|keystore|kdbx|gpg|asc|ppk)$|(?:^|\/)id_(?:rsa|dsa|ecdsa|ed25519)(?:\.pub)?$|(?:^|\/)\.(?:npmrc|netrc|pgpass|git-credentials|pypirc|dockercfg|htpasswd|my\.cnf)$|(?:^|\/)\.docker\/config\.json$|(?:^|\/)\.kube\/config$|kubeconfig|authorized_keys|credential|secret|token|passw|(?:^|[/._-])pass(?:$|[/._-])|(?:^|\/)shadow$/i;
|
|
424
|
+
const REDIRECTS = new Set(['>', '>>', '<', '<<', '<<<']);
|
|
425
|
+
function basename(word) {
|
|
426
|
+
return word.replace(/^.*\//, '');
|
|
427
|
+
}
|
|
428
|
+
const ASSIGNMENT = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/s;
|
|
429
|
+
function parseSegment(words) {
|
|
430
|
+
let index = 0;
|
|
431
|
+
while (index < words.length && ASSIGNMENT.test(words[index]))
|
|
432
|
+
index += 1;
|
|
433
|
+
while (index < words.length && WRAPPERS.has(basename(words[index]))) {
|
|
434
|
+
index += 1;
|
|
435
|
+
while (index < words.length && (words[index].startsWith('-') || ASSIGNMENT.test(words[index]) || /^\d+[smhd]?$/.test(words[index])))
|
|
436
|
+
index += 1;
|
|
437
|
+
}
|
|
438
|
+
const program = index < words.length ? basename(words[index]).toLowerCase() : null;
|
|
439
|
+
const plain = [];
|
|
440
|
+
const targets = [];
|
|
441
|
+
for (let i = index + 1; i < words.length; i += 1) {
|
|
442
|
+
const word = words[i];
|
|
443
|
+
if (REDIRECTS.has(word)) {
|
|
444
|
+
if ((word === '>' || word === '>>') && i + 1 < words.length)
|
|
445
|
+
targets.push(words[i + 1]);
|
|
446
|
+
// Skip the file, the heredoc tag (and its body slot) or the here-string slot.
|
|
447
|
+
i += 1;
|
|
448
|
+
if (word === '<<' && i + 1 < words.length && SLOT.test(words[i + 1]))
|
|
449
|
+
i += 1;
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
if (SLOT.test(word))
|
|
453
|
+
continue;
|
|
454
|
+
plain.push(i);
|
|
455
|
+
}
|
|
456
|
+
return { index, program, plain, targets };
|
|
457
|
+
}
|
|
458
|
+
function isSecretFile(path) {
|
|
459
|
+
return SECRET_FILE.test(path);
|
|
460
|
+
}
|
|
461
|
+
function programStem(program) {
|
|
462
|
+
return program.replace(/\.(?:sh|bash|py|js|mjs|ts|rb|pl|ps1|exe)$/i, '');
|
|
463
|
+
}
|
|
464
|
+
/** env stores: values set there may be secrets (`netlify env:set`, `heroku config:set`, `railway variables set`). */
|
|
465
|
+
function storeContext(positionals) {
|
|
466
|
+
return positionals.some((word, i) => /^(?:env|config|variables?|vars?|secrets?):(?:set|add|push|import|create|update|put)$/.test(word)
|
|
467
|
+
|| (/^(?:env|variables?|vars?)$/.test(word) && /^(?:set|add|push|import|put|create|update)$/.test(positionals[i + 1] ?? '')));
|
|
468
|
+
}
|
|
469
|
+
/** Whether a command takes secrets on its input (data fed to it is withheld). */
|
|
470
|
+
function takesSecrets(words, parsed) {
|
|
471
|
+
const { program } = parsed;
|
|
472
|
+
if (!program)
|
|
473
|
+
return false;
|
|
474
|
+
if (SECRET_PROGRAMS.has(programStem(program)) || classifySecretName(programStem(program)) === 'credential')
|
|
475
|
+
return true;
|
|
476
|
+
const args = parsed.plain.map((i) => words[i]);
|
|
477
|
+
const positionals = args.filter((word) => !word.startsWith('-')).slice(0, 4).map((word) => word.toLowerCase());
|
|
478
|
+
if (positionals.some((word) => /secret|credential|passw|token|login|keychain|keyring|keyvault/.test(word) || /^o?auth$/.test(word) || /:auth$/.test(word)))
|
|
479
|
+
return true;
|
|
480
|
+
if (program === 'aws' && positionals[0] === 'configure')
|
|
481
|
+
return true;
|
|
482
|
+
if (storeContext(positionals))
|
|
483
|
+
return true;
|
|
484
|
+
if (args.some((word) => /^--?with-token(?:=|$)/.test(word) || (/^--?[a-z-]+-(?:stdin|fd)(?:=|$)/i.test(word) && holdsSecret(word.replace(/^-+/, '').replace(/-(?:stdin|fd)(?:=.*)?$/i, '')))))
|
|
485
|
+
return true;
|
|
486
|
+
if (program === 'tee' && positionals.some(isSecretFile))
|
|
487
|
+
return true;
|
|
488
|
+
return parsed.targets.some(isSecretFile);
|
|
489
|
+
}
|
|
490
|
+
function assignedValue(name, value, state) {
|
|
491
|
+
const kind = classifySecretName(name);
|
|
492
|
+
if (kind === 'credential' || kind === 'ambiguous') {
|
|
493
|
+
if (kind === 'ambiguous' || holdsMoreThanCredential(value))
|
|
494
|
+
state.truncated = true;
|
|
495
|
+
return `${name}=${SECRET}`;
|
|
496
|
+
}
|
|
497
|
+
return value ? `${name}=${dataMarker(value, false, state)}` : `${name}=`;
|
|
498
|
+
}
|
|
499
|
+
/** Normalizes one simple command (a segment between operators); returns its words and program name. */
|
|
500
|
+
function redactSegment(words, ctx) {
|
|
501
|
+
const { state } = ctx;
|
|
502
|
+
const out = [...words];
|
|
503
|
+
const handled = new Set();
|
|
504
|
+
const removed = new Set();
|
|
505
|
+
const parsed = parseSegment(out);
|
|
506
|
+
const { index, program, targets } = parsed;
|
|
507
|
+
// Environment assignments before the program (and a wrapper's own, as in `env X=1 cmd`).
|
|
508
|
+
for (let i = 0; i < index; i += 1) {
|
|
509
|
+
const match = ASSIGNMENT.exec(out[i]);
|
|
510
|
+
if (match) {
|
|
511
|
+
out[i] = assignedValue(match[1], match[2], state);
|
|
512
|
+
handled.add(i);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
// Input data: heredoc bodies and here-strings go to this command and on through a pipe.
|
|
516
|
+
const downstreamTakes = ctx.pipedOut && ctx.downstreamTakes;
|
|
517
|
+
const secretTarget = targets.some(isSecretFile);
|
|
518
|
+
const feedsHere = takesSecrets(out, parsed) || downstreamTakes || secretTarget;
|
|
519
|
+
for (let i = 0; i < out.length; i += 1) {
|
|
520
|
+
const slot = SLOT.exec(out[i]);
|
|
521
|
+
if (!slot)
|
|
522
|
+
continue;
|
|
523
|
+
out[i] = dataMarker(ctx.data[Number(slot[1])] ?? '', feedsHere, state);
|
|
524
|
+
handled.add(i);
|
|
525
|
+
}
|
|
526
|
+
const finish = () => {
|
|
527
|
+
const result = [];
|
|
528
|
+
out.forEach((word, i) => {
|
|
529
|
+
if (removed.has(i))
|
|
530
|
+
return;
|
|
531
|
+
result.push(handled.has(i) ? word : redactText(word, state));
|
|
532
|
+
});
|
|
533
|
+
return { words: result, program };
|
|
534
|
+
};
|
|
535
|
+
if (!program)
|
|
536
|
+
return finish();
|
|
537
|
+
// Text echoed or printed into a pipe or a file is data.
|
|
538
|
+
if ((program === 'echo' || program === 'printf') && (ctx.pipedOut || targets.length)) {
|
|
539
|
+
const printed = parsed.plain;
|
|
540
|
+
if (printed.length) {
|
|
541
|
+
out[printed[0]] = dataMarker(printed.map((i) => out[i]).join(' '), downstreamTakes || secretTarget, state);
|
|
542
|
+
handled.add(printed[0]);
|
|
543
|
+
for (const i of printed.slice(1))
|
|
544
|
+
removed.add(i);
|
|
545
|
+
}
|
|
546
|
+
return finish();
|
|
547
|
+
}
|
|
548
|
+
// export / declare NAME=value: like a leading assignment.
|
|
549
|
+
if (['export', 'declare', 'typeset', 'local', 'readonly', 'set'].includes(program)) {
|
|
550
|
+
for (const i of parsed.plain) {
|
|
551
|
+
const match = ASSIGNMENT.exec(out[i]);
|
|
552
|
+
if (match) {
|
|
553
|
+
out[i] = assignedValue(match[1], match[2], state);
|
|
554
|
+
handled.add(i);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
return finish();
|
|
558
|
+
}
|
|
559
|
+
// Commands inside the command: `bash -c CMD`, `ssh HOST CMD …`, `eval …`, `su -c CMD`.
|
|
560
|
+
const embed = (at, text, also = []) => {
|
|
561
|
+
if (ctx.depth >= MAX_DEPTH) {
|
|
562
|
+
out[at] = SECRET;
|
|
563
|
+
state.truncated = true;
|
|
564
|
+
}
|
|
565
|
+
else {
|
|
566
|
+
out[at] = normalizeInner(text, state, ctx.depth + 1).text;
|
|
567
|
+
}
|
|
568
|
+
handled.add(at);
|
|
569
|
+
for (const i of also)
|
|
570
|
+
removed.add(i);
|
|
571
|
+
};
|
|
572
|
+
const plainWords = parsed.plain;
|
|
573
|
+
if (program === 'eval' && plainWords.length) {
|
|
574
|
+
embed(plainWords[0], plainWords.map((i) => out[i]).join(' '), plainWords.slice(1));
|
|
575
|
+
}
|
|
576
|
+
else if (program === 'ssh' || program === 'autossh') {
|
|
577
|
+
let k = 0;
|
|
578
|
+
while (k < plainWords.length && out[plainWords[k]].startsWith('-')) {
|
|
579
|
+
k += SSH_VALUE_FLAGS.has(out[plainWords[k]]) ? 2 : 1;
|
|
580
|
+
}
|
|
581
|
+
const remote = plainWords.slice(k + 1);
|
|
582
|
+
if (remote.length)
|
|
583
|
+
embed(remote[0], remote.map((i) => out[i]).join(' '), remote.slice(1));
|
|
584
|
+
}
|
|
585
|
+
else if (program === 'su') {
|
|
586
|
+
for (let k = 0; k < plainWords.length; k += 1) {
|
|
587
|
+
const word = out[plainWords[k]];
|
|
588
|
+
if ((word === '-c' || word === '--command') && k + 1 < plainWords.length) {
|
|
589
|
+
embed(plainWords[k + 1], out[plainWords[k + 1]]);
|
|
590
|
+
break;
|
|
591
|
+
}
|
|
592
|
+
if (word.startsWith('--command=')) {
|
|
593
|
+
const text = word.slice('--command='.length);
|
|
594
|
+
embed(plainWords[k], text);
|
|
595
|
+
out[plainWords[k]] = `--command=${out[plainWords[k]]}`;
|
|
596
|
+
break;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
const shellAt = [index, ...plainWords];
|
|
601
|
+
for (let k = 0; k + 2 < shellAt.length; k += 1) {
|
|
602
|
+
if (handled.has(shellAt[k + 2]) || removed.has(shellAt[k + 2]))
|
|
603
|
+
continue;
|
|
604
|
+
if (SHELLS.test(basename(out[shellAt[k]]).toLowerCase()) && /^-[a-zA-Z]*c$/.test(out[shellAt[k + 1]])) {
|
|
605
|
+
embed(shellAt[k + 2], out[shellAt[k + 2]]);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
const args = plainWords.filter((i) => !handled.has(i) && !removed.has(i));
|
|
609
|
+
const positionals = args.filter((i) => !out[i].startsWith('-'));
|
|
610
|
+
const positionOf = new Map(positionals.map((i, position) => [i, position]));
|
|
611
|
+
const lastArg = args[args.length - 1];
|
|
612
|
+
const htpasswdBatch = program === 'htpasswd' && args.some((value) => /^-[a-zA-Z]*b/.test(out[value]));
|
|
613
|
+
const subcommands = positionals.slice(0, 3).map((i) => out[i].toLowerCase());
|
|
614
|
+
const lowered = positionals.map((i) => out[i].toLowerCase());
|
|
615
|
+
// A secrets command is named by a bare subcommand word (`gh secret set`, `vault kv put secret/app`),
|
|
616
|
+
// never by a field inside a form body, query or JSON (`client_secret=K&path=/a`).
|
|
617
|
+
const secretContext = subcommands.some((word) => /secret/.test(word) && /^[a-z0-9][a-z0-9_.:\/-]*$/.test(word));
|
|
618
|
+
const loginContext = subcommands.includes('login');
|
|
619
|
+
const envStore = storeContext(lowered.slice(0, 4));
|
|
620
|
+
const stem = programStem(program);
|
|
621
|
+
const redactAt = (i, ambiguous = false) => {
|
|
622
|
+
if (ambiguous || holdsMoreThanCredential(out[i]))
|
|
623
|
+
state.truncated = true;
|
|
624
|
+
out[i] = SECRET;
|
|
625
|
+
handled.add(i);
|
|
626
|
+
};
|
|
627
|
+
// An auth header: a one-value header is the credential; any other (`Cookie: a=1; env=prod`)
|
|
628
|
+
// keeps its other fields, with the credentials inside replaced.
|
|
629
|
+
const redactHeaderValue = (header) => {
|
|
630
|
+
if (SINGLE_VALUE_HEADER.test(header)) {
|
|
631
|
+
if (holdsMoreThanCredential(header.replace(/^[^:]*:/, '')))
|
|
632
|
+
state.truncated = true;
|
|
633
|
+
return SECRET;
|
|
634
|
+
}
|
|
635
|
+
const redacted = redactText(header, state);
|
|
636
|
+
if (redacted === header)
|
|
637
|
+
state.truncated = true;
|
|
638
|
+
return redacted === header ? SECRET : redacted;
|
|
639
|
+
};
|
|
640
|
+
// curl -b/--cookie NAME=VALUE[; …] is a Cookie header (without `=` it names a cookie file).
|
|
641
|
+
const redactCookieValue = (value) => {
|
|
642
|
+
if (!value.includes('='))
|
|
643
|
+
return value;
|
|
644
|
+
const redacted = redactHeaderValue(`Cookie: ${value}`);
|
|
645
|
+
return redacted.startsWith('Cookie: ') ? redacted.slice('Cookie: '.length) : redacted;
|
|
646
|
+
};
|
|
647
|
+
// A credential `NAME=value` argument: a form body (`api_key=K&sql=DROP`) keeps its other fields.
|
|
648
|
+
const credentialArgument = (name, value) => {
|
|
649
|
+
if (/[&#]/.test(value))
|
|
650
|
+
return redactText(`${name}=${value}`, state);
|
|
651
|
+
return `${name}=${cutCredential(value, state)}`;
|
|
652
|
+
};
|
|
653
|
+
// A program named for a secret (set-password.sh): its arguments cannot be told apart.
|
|
654
|
+
if (!SECRET_PROGRAMS.has(stem) && classifySecretName(stem) === 'credential') {
|
|
655
|
+
for (const i of positionals)
|
|
656
|
+
redactAt(i, true);
|
|
657
|
+
}
|
|
658
|
+
let afterSecretWord = -1;
|
|
659
|
+
for (let k = 0; k < args.length; k += 1) {
|
|
660
|
+
const i = args[k];
|
|
661
|
+
if (handled.has(i))
|
|
662
|
+
continue;
|
|
663
|
+
const arg = out[i];
|
|
664
|
+
const valueAt = () => {
|
|
665
|
+
const j = args[k + 1];
|
|
666
|
+
return j !== undefined && !handled.has(j) && !out[j].startsWith('-') ? j : null;
|
|
667
|
+
};
|
|
668
|
+
const next = (ambiguous = false) => {
|
|
669
|
+
const j = valueAt();
|
|
670
|
+
if (j !== null) {
|
|
671
|
+
if (ambiguous)
|
|
672
|
+
state.truncated = true;
|
|
673
|
+
out[j] = cutCredential(out[j], state);
|
|
674
|
+
handled.add(j);
|
|
675
|
+
k += 1;
|
|
676
|
+
}
|
|
677
|
+
};
|
|
678
|
+
const attachedOrNext = (prefix) => {
|
|
679
|
+
if (arg.length > prefix.length) {
|
|
680
|
+
out[i] = `${prefix}${SECRET}`;
|
|
681
|
+
handled.add(i);
|
|
682
|
+
}
|
|
683
|
+
else {
|
|
684
|
+
next();
|
|
685
|
+
}
|
|
686
|
+
};
|
|
687
|
+
const byName = (name) => {
|
|
688
|
+
const kind = classifySecretName(name);
|
|
689
|
+
if (kind === 'credential')
|
|
690
|
+
next();
|
|
691
|
+
else if (kind === 'ambiguous')
|
|
692
|
+
next(true);
|
|
693
|
+
};
|
|
694
|
+
// --flag=value (or -flag=value)
|
|
695
|
+
const long = /^(--?[A-Za-z0-9][A-Za-z0-9_.-]*)=(.*)$/s.exec(arg);
|
|
696
|
+
if (long) {
|
|
697
|
+
const name = long[1].replace(/^-+/, '');
|
|
698
|
+
if (name === 'from-literal' || name === 'from-env-literal') {
|
|
699
|
+
if (holdsMoreThanCredential(long[2].replace(/^[^=]*=/, '')))
|
|
700
|
+
state.truncated = true;
|
|
701
|
+
out[i] = `${long[1]}=${long[2].replace(/^([^=]*)=.*$/s, `$1=${SECRET}`)}`;
|
|
702
|
+
handled.add(i);
|
|
703
|
+
}
|
|
704
|
+
else if (CURL_FAMILY.has(program) && /^(?:u|user|proxy-user)$/.test(name)) {
|
|
705
|
+
out[i] = `${long[1]}=${SECRET}`;
|
|
706
|
+
handled.add(i);
|
|
707
|
+
}
|
|
708
|
+
else if (CURL_FAMILY.has(program) && name === 'header' && AUTH_HEADER.test(long[2])) {
|
|
709
|
+
out[i] = `${long[1]}=${redactHeaderValue(long[2])}`;
|
|
710
|
+
handled.add(i);
|
|
711
|
+
}
|
|
712
|
+
else if (CURL_FAMILY.has(program) && name === 'cookie') {
|
|
713
|
+
out[i] = `${long[1]}=${redactCookieValue(long[2])}`;
|
|
714
|
+
handled.add(i);
|
|
715
|
+
}
|
|
716
|
+
else if (holdsSecret(name)) {
|
|
717
|
+
if (classifySecretName(name) === 'ambiguous')
|
|
718
|
+
state.truncated = true;
|
|
719
|
+
out[i] = `${long[1]}=${cutCredential(long[2], state)}`;
|
|
720
|
+
handled.add(i);
|
|
721
|
+
}
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
// --flag value
|
|
725
|
+
if (/^--[A-Za-z0-9]/.test(arg)) {
|
|
726
|
+
const name = arg.slice(2);
|
|
727
|
+
if (CURL_FAMILY.has(program) && /^(?:user|proxy-user)$/.test(name))
|
|
728
|
+
next();
|
|
729
|
+
else if (CURL_FAMILY.has(program) && name === 'header') {
|
|
730
|
+
const j = valueAt();
|
|
731
|
+
if (j !== null && AUTH_HEADER.test(out[j])) {
|
|
732
|
+
out[j] = redactHeaderValue(out[j]);
|
|
733
|
+
handled.add(j);
|
|
734
|
+
k += 1;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
else if (CURL_FAMILY.has(program) && name === 'cookie') {
|
|
738
|
+
const j = valueAt();
|
|
739
|
+
if (j !== null) {
|
|
740
|
+
out[j] = redactCookieValue(out[j]);
|
|
741
|
+
handled.add(j);
|
|
742
|
+
k += 1;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
else if (program === 'gh' && secretContext && name === 'body')
|
|
746
|
+
next();
|
|
747
|
+
else
|
|
748
|
+
byName(name);
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
// Short flags by program; single-dash long names (-password, -token) by name.
|
|
752
|
+
if (/^-[A-Za-z]/.test(arg)) {
|
|
753
|
+
if (MYSQL_FAMILY.has(program) && /^-p.+/.test(arg)) {
|
|
754
|
+
out[i] = `-p${SECRET}`;
|
|
755
|
+
handled.add(i);
|
|
756
|
+
}
|
|
757
|
+
else if (ALWAYS_P.has(program) && /^-p/.test(arg))
|
|
758
|
+
attachedOrNext('-p');
|
|
759
|
+
else if (LOGIN_P.has(program) && loginContext && /^-p/.test(arg))
|
|
760
|
+
attachedOrNext('-p');
|
|
761
|
+
else if (UPPER_P.has(program) && /^-P/.test(arg))
|
|
762
|
+
attachedOrNext('-P');
|
|
763
|
+
else if (REDIS_FAMILY.has(program) && /^-a/.test(arg))
|
|
764
|
+
attachedOrNext('-a');
|
|
765
|
+
else if (CURL_FAMILY.has(program) && (arg === '-u' || arg === '-U'))
|
|
766
|
+
next();
|
|
767
|
+
else if (CURL_FAMILY.has(program) && arg === '-H') {
|
|
768
|
+
const j = valueAt();
|
|
769
|
+
if (j !== null && AUTH_HEADER.test(out[j])) {
|
|
770
|
+
out[j] = redactHeaderValue(out[j]);
|
|
771
|
+
handled.add(j);
|
|
772
|
+
k += 1;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
else if (CURL_FAMILY.has(program) && arg === '-b') {
|
|
776
|
+
const j = valueAt();
|
|
777
|
+
if (j !== null) {
|
|
778
|
+
out[j] = redactCookieValue(out[j]);
|
|
779
|
+
handled.add(j);
|
|
780
|
+
k += 1;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
else if (program === 'openssl' && OPENSSL_PASS_FLAGS.has(arg))
|
|
784
|
+
next();
|
|
785
|
+
else if (program === 'gh' && secretContext && arg === '-b')
|
|
786
|
+
next();
|
|
787
|
+
else if (/^-[A-Za-z][A-Za-z0-9_-]{2,}$/.test(arg))
|
|
788
|
+
byName(arg.slice(1));
|
|
789
|
+
continue;
|
|
790
|
+
}
|
|
791
|
+
// NAME=value arguments: a credential name, any value in a secrets command, env-store values.
|
|
792
|
+
const assignment = ASSIGNMENT.exec(arg);
|
|
793
|
+
if (assignment) {
|
|
794
|
+
const [, name, value] = assignment;
|
|
795
|
+
if (secretContext) {
|
|
796
|
+
// In a secrets command every value is a secret: withheld whole, and truncated if it held more fields.
|
|
797
|
+
if (/[&#]/.test(value) || holdsMoreThanCredential(value))
|
|
798
|
+
state.truncated = true;
|
|
799
|
+
out[i] = `${name}=${SECRET}`;
|
|
800
|
+
handled.add(i);
|
|
801
|
+
}
|
|
802
|
+
else if (holdsSecret(name)) {
|
|
803
|
+
if (classifySecretName(name) === 'ambiguous')
|
|
804
|
+
state.truncated = true;
|
|
805
|
+
out[i] = credentialArgument(name, value);
|
|
806
|
+
handled.add(i);
|
|
807
|
+
}
|
|
808
|
+
else if (envStore) {
|
|
809
|
+
out[i] = assignedValue(name, value, state);
|
|
810
|
+
handled.add(i);
|
|
811
|
+
}
|
|
812
|
+
continue;
|
|
813
|
+
}
|
|
814
|
+
if (program === 'openssl' && /^(?:pass|env|file|fd):/.test(arg)) {
|
|
815
|
+
out[i] = arg.replace(/:.*/s, `:${SECRET}`);
|
|
816
|
+
handled.add(i);
|
|
817
|
+
continue;
|
|
818
|
+
}
|
|
819
|
+
const position = positionOf.get(i) ?? -1;
|
|
820
|
+
const previous = position > 0 ? out[positionals[position - 1]].toLowerCase() : '';
|
|
821
|
+
const beforePrevious = position > 1 ? out[positionals[position - 2]].toLowerCase() : '';
|
|
822
|
+
// aws configure set <name> <value>; npm|yarn|pnpm config set <name> <value>
|
|
823
|
+
if ((program === 'aws' && subcommands[0] === 'configure' && previous === 'set')
|
|
824
|
+
|| (['npm', 'yarn', 'pnpm'].includes(program) && beforePrevious === 'config' && previous === 'set')) {
|
|
825
|
+
byName(arg);
|
|
826
|
+
continue;
|
|
827
|
+
}
|
|
828
|
+
// env stores: `netlify env:set NAME VALUE`, `railway variables set NAME VALUE`
|
|
829
|
+
if (envStore && position > 0 && /^(?:env|config|variables?|vars?):(?:set|add|push|import|create|update|put)$|^(?:set|add|put|push|import|create|update)$/.test(previous)) {
|
|
830
|
+
const j = valueAt();
|
|
831
|
+
if (j !== null) {
|
|
832
|
+
const kind = classifySecretName(arg);
|
|
833
|
+
if (kind === 'credential')
|
|
834
|
+
redactAt(j);
|
|
835
|
+
else if (kind === 'ambiguous')
|
|
836
|
+
redactAt(j, true);
|
|
837
|
+
else {
|
|
838
|
+
out[j] = dataMarker(out[j], false, state);
|
|
839
|
+
handled.add(j);
|
|
840
|
+
}
|
|
841
|
+
k += 1;
|
|
842
|
+
}
|
|
843
|
+
continue;
|
|
844
|
+
}
|
|
845
|
+
// Known positional passwords.
|
|
846
|
+
if ((/^(?:password|passwd)$/i.test(arg) && program !== 'gh')
|
|
847
|
+
|| (REDIS_FAMILY.has(program) && /^(?:auth|requirepass|masterauth)$/i.test(arg))
|
|
848
|
+
|| (program === 'vault' && subcommands[0] === 'login' && arg.toLowerCase() === 'login')) {
|
|
849
|
+
next();
|
|
850
|
+
continue;
|
|
851
|
+
}
|
|
852
|
+
if (program === 'rabbitmqctl' && ['add_user', 'change_password'].includes(subcommands[0] ?? '') && i === positionals[positionals.length - 1] && positionals.length >= 3) {
|
|
853
|
+
redactAt(i);
|
|
854
|
+
continue;
|
|
855
|
+
}
|
|
856
|
+
// In a secrets command (gh secret set NAME, doppler secrets set NAME VALUE ...),
|
|
857
|
+
// the name stays; later positionals are values.
|
|
858
|
+
if (secretContext) {
|
|
859
|
+
const before = k > 0 ? out[args[k - 1]] : '';
|
|
860
|
+
// The value of a known non-secret flag (`--repo org/app`, `-n staging`) is part of the action, kept as is.
|
|
861
|
+
if (afterSecretWord >= 0 && secretCommandValueFlag(program, before))
|
|
862
|
+
continue;
|
|
863
|
+
if (afterSecretWord >= 0) {
|
|
864
|
+
afterSecretWord += 1;
|
|
865
|
+
// After an unknown flag the word may be that flag's value, not the secret: withheld, and the action never binds.
|
|
866
|
+
if (afterSecretWord > 2)
|
|
867
|
+
redactAt(i, /^-/.test(before) && !before.includes('='));
|
|
868
|
+
}
|
|
869
|
+
else if (/secret/i.test(arg)) {
|
|
870
|
+
afterSecretWord = 0;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
// htpasswd -b FILE USER PASSWORD: the last positional.
|
|
874
|
+
if (htpasswdBatch && i === lastArg)
|
|
875
|
+
redactAt(i);
|
|
876
|
+
}
|
|
877
|
+
return finish();
|
|
878
|
+
}
|
|
879
|
+
function quoteWord(word) {
|
|
880
|
+
return /[\s'"\\|&;<>()$`*?]/.test(word) ? `'${word.replace(/'/g, `'\\''`)}'` : word;
|
|
881
|
+
}
|
|
882
|
+
function normalizeInner(command, state, depth) {
|
|
883
|
+
if (command.length > MAX_COMMAND_CHARS)
|
|
884
|
+
throw new NormalizationLimit();
|
|
885
|
+
spend(state, 0);
|
|
886
|
+
const data = [];
|
|
887
|
+
const tokens = tokenize(extractHeredocs(command.replace(/\r\n?/g, '\n'), data));
|
|
888
|
+
if (tokens.length > MAX_TOKENS)
|
|
889
|
+
throw new NormalizationLimit();
|
|
890
|
+
const segments = [];
|
|
891
|
+
let current = [];
|
|
892
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
893
|
+
const token = tokens[i];
|
|
894
|
+
if (!token.op) {
|
|
895
|
+
current.push(token.value);
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
if (token.value === '<<<') {
|
|
899
|
+
// A here-string is the command's input.
|
|
900
|
+
current.push('<<<');
|
|
901
|
+
if (i + 1 < tokens.length && !tokens[i + 1].op) {
|
|
902
|
+
data.push(tokens[i + 1].value);
|
|
903
|
+
current.push(slotWord(data.length - 1));
|
|
904
|
+
i += 1;
|
|
905
|
+
}
|
|
906
|
+
continue;
|
|
907
|
+
}
|
|
908
|
+
if (['>', '>>', '<', '<<'].includes(token.value)) {
|
|
909
|
+
// A redirection (and a heredoc tag) belongs to the current command.
|
|
910
|
+
current.push(token.value);
|
|
911
|
+
continue;
|
|
912
|
+
}
|
|
913
|
+
segments.push({ words: current, op: token.value });
|
|
914
|
+
current = [];
|
|
915
|
+
}
|
|
916
|
+
segments.push({ words: current, op: null });
|
|
917
|
+
const words = [];
|
|
918
|
+
const programs = [];
|
|
919
|
+
// Whether anything later in each segment's pipeline takes secrets: one pass from the end.
|
|
920
|
+
const piped = (k) => segments[k].op === '|' || segments[k].op === '|&';
|
|
921
|
+
const sinkAfter = new Array(segments.length).fill(false);
|
|
922
|
+
for (let k = segments.length - 2; k >= 0; k -= 1) {
|
|
923
|
+
if (!piped(k))
|
|
924
|
+
continue;
|
|
925
|
+
const next = segments[k + 1].words;
|
|
926
|
+
sinkAfter[k] = (next.length > 0 && takesSecrets(next, parseSegment(next))) || sinkAfter[k + 1];
|
|
927
|
+
}
|
|
928
|
+
segments.forEach((segment, k) => {
|
|
929
|
+
if (segment.words.length) {
|
|
930
|
+
const result = redactSegment(segment.words, {
|
|
931
|
+
pipedOut: piped(k),
|
|
932
|
+
downstreamTakes: sinkAfter[k],
|
|
933
|
+
data,
|
|
934
|
+
state,
|
|
935
|
+
depth,
|
|
936
|
+
});
|
|
937
|
+
words.push(...result.words.map(quoteWord));
|
|
938
|
+
if (result.program && /^[a-z0-9][a-z0-9._+-]{0,63}$/.test(result.program) && !looksLikeKey(result.program) && !programs.includes(result.program)) {
|
|
939
|
+
programs.push(result.program);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
const op = segment.op;
|
|
943
|
+
if (op && (op !== ';' || (words.length && words[words.length - 1] !== ';')))
|
|
944
|
+
words.push(op);
|
|
945
|
+
});
|
|
946
|
+
while (words.length && words[words.length - 1] === ';')
|
|
947
|
+
words.pop();
|
|
948
|
+
return { text: words.join(' '), programs: programs.slice(0, 16) };
|
|
949
|
+
}
|
|
950
|
+
/**
|
|
951
|
+
* The normalized form of a shell command (see the module comment): hashed,
|
|
952
|
+
* never sent. `truncated` is true when something that may change what the
|
|
953
|
+
* command does had to be withheld. Exported for tests.
|
|
954
|
+
*/
|
|
955
|
+
function normalizeShellCommand(command, deadline) {
|
|
956
|
+
const state = newExactness(deadline);
|
|
957
|
+
try {
|
|
958
|
+
const result = normalizeInner(command, state, 0);
|
|
959
|
+
return { ...result, truncated: state.truncated };
|
|
960
|
+
}
|
|
961
|
+
catch (error) {
|
|
962
|
+
if (error instanceof NormalizationLimit)
|
|
963
|
+
return { text: '', programs: [], truncated: true };
|
|
964
|
+
throw error;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
function sortedValue(value, depth = 0) {
|
|
968
|
+
if (depth > 6 || !value || typeof value !== 'object')
|
|
969
|
+
return value;
|
|
970
|
+
if (Array.isArray(value))
|
|
971
|
+
return value.map((item) => sortedValue(item, depth + 1));
|
|
972
|
+
return Object.fromEntries(Object.keys(value).sort()
|
|
973
|
+
.map((key) => [key, sortedValue(value[key], depth + 1)]));
|
|
974
|
+
}
|
|
975
|
+
/** A tool input with credentials and key-shaped values replaced; hashed, never sent. */
|
|
976
|
+
function redactedInput(value, state, depth = 0) {
|
|
977
|
+
if (depth > 6) {
|
|
978
|
+
state.truncated = true;
|
|
979
|
+
return SECRET;
|
|
980
|
+
}
|
|
981
|
+
if (typeof value === 'string')
|
|
982
|
+
return redactText(value, state);
|
|
983
|
+
if (Array.isArray(value)) {
|
|
984
|
+
if (value.length > 64)
|
|
985
|
+
state.truncated = true;
|
|
986
|
+
return value.slice(0, 64).map((item) => redactedInput(item, state, depth + 1));
|
|
987
|
+
}
|
|
988
|
+
if (value && typeof value === 'object') {
|
|
989
|
+
const entries = Object.entries(value);
|
|
990
|
+
if (entries.length > 64)
|
|
991
|
+
state.truncated = true;
|
|
992
|
+
const out = {};
|
|
993
|
+
for (const [key, item] of entries.slice(0, 64)) {
|
|
994
|
+
const kind = classifySecretName(key);
|
|
995
|
+
if (kind === 'credential' || kind === 'ambiguous') {
|
|
996
|
+
if (kind === 'ambiguous')
|
|
997
|
+
state.truncated = true;
|
|
998
|
+
out[key] = SECRET;
|
|
999
|
+
}
|
|
1000
|
+
else {
|
|
1001
|
+
out[key] = redactedInput(item, state, depth + 1);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
return out;
|
|
1005
|
+
}
|
|
1006
|
+
return value;
|
|
1007
|
+
}
|
|
1008
|
+
/**
|
|
1009
|
+
* The command a shell tool runs. Codex may send an argument vector
|
|
1010
|
+
* (["bash", "-lc", "<command>"]): its command is the same text a string form
|
|
1011
|
+
* would carry, so both give the same normalized action.
|
|
1012
|
+
*/
|
|
1013
|
+
function shellCommand(event) {
|
|
1014
|
+
const input = event.tool_input && typeof event.tool_input === 'object' && !Array.isArray(event.tool_input)
|
|
1015
|
+
? event.tool_input : null;
|
|
1016
|
+
const argv = Array.isArray(input?.command) ? input.command : Array.isArray(input?.cmd) ? input.cmd : null;
|
|
1017
|
+
if (argv && argv.every((item) => typeof item === 'string')) {
|
|
1018
|
+
const words = argv;
|
|
1019
|
+
if (words.length === 3 && /^(?:.*\/)?(?:ba|z|da|k)?sh$/.test(words[0]) && /^-[a-z]*c$/.test(words[1]))
|
|
1020
|
+
return words[2];
|
|
1021
|
+
return words.map((word) => (/[\s'"]/.test(word) ? `'${word.replace(/'/g, `'\\''`)}'` : word)).join(' ');
|
|
1022
|
+
}
|
|
1023
|
+
return (0, hook_tool_policy_1.hookToolCommand)(event);
|
|
1024
|
+
}
|
|
1025
|
+
/**
|
|
1026
|
+
* An edit path as sent: the person's home directory as `~`, and another
|
|
1027
|
+
* user's home as `/home/[user]` (marked truncated, since it no longer names
|
|
1028
|
+
* one path), so no user name leaves the machine.
|
|
1029
|
+
*/
|
|
1030
|
+
function withoutUserName(path, state) {
|
|
1031
|
+
const home = (0, node_os_1.homedir)();
|
|
1032
|
+
if (home && home !== '/' && (path === home || path.startsWith(`${home}/`)))
|
|
1033
|
+
return `~${path.slice(home.length)}`;
|
|
1034
|
+
const other = /^(\/(?:home|Users))\/[^/]+(?=\/|$)/.exec(path);
|
|
1035
|
+
if (!other)
|
|
1036
|
+
return path;
|
|
1037
|
+
state.truncated = true;
|
|
1038
|
+
return `${other[1]}/[user]${path.slice(other[0].length)}`;
|
|
1039
|
+
}
|
|
1040
|
+
function inputHash(input, state) {
|
|
1041
|
+
if (input === undefined || input === null)
|
|
1042
|
+
return {};
|
|
1043
|
+
const text = JSON.stringify(sortedValue(redactedInput(input, state)));
|
|
1044
|
+
if (text.includes(SECRET))
|
|
1045
|
+
state.truncated = true;
|
|
1046
|
+
return { input_sha256: sha256(`${HASH_VERSION}\ninput\n${text}`) };
|
|
1047
|
+
}
|
|
1048
|
+
function withExactness(action, state) {
|
|
1049
|
+
return state.truncated ? { ...action, truncated: true } : action;
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* The normalized action of a hook event. `deadlineAt` bounds the time it may
|
|
1053
|
+
* take (default one second from now): past it, or past the size limits, the
|
|
1054
|
+
* action is a truncated placeholder (kind and tool name only), which the
|
|
1055
|
+
* service never binds, so the hook still answers within its host's budget.
|
|
1056
|
+
* An event whose input a host adapter cut or dropped (`input_truncated`) is
|
|
1057
|
+
* marked truncated as well.
|
|
1058
|
+
*/
|
|
1059
|
+
function normalizedHookAction(event, options = {}) {
|
|
1060
|
+
const state = newExactness(options.deadlineAt);
|
|
1061
|
+
const toolName = (0, hook_tool_policy_1.normalizeHookToolName)(event.tool_name) || 'tool';
|
|
1062
|
+
// The host's own tool name (stable across retries); the policy name decides the kind.
|
|
1063
|
+
const hostToolName = (typeof event.tool_name === 'string' && event.tool_name.trim() ? event.tool_name.trim() : toolName).slice(0, 128);
|
|
1064
|
+
const kind = (0, hook_tool_policy_1.isMcpHookTool)(event.tool_name) || /^MCP:/i.test(hostToolName) ? 'mcp'
|
|
1065
|
+
: (0, hook_tool_policy_1.isShellGovernedTool)(event) ? 'shell'
|
|
1066
|
+
: EDIT_TOOLS.test(toolName) ? 'edit'
|
|
1067
|
+
: 'other';
|
|
1068
|
+
if (event.input_truncated === true)
|
|
1069
|
+
state.truncated = true;
|
|
1070
|
+
try {
|
|
1071
|
+
return normalizedOfKind(event, kind, hostToolName, state);
|
|
1072
|
+
}
|
|
1073
|
+
catch (error) {
|
|
1074
|
+
if (error instanceof NormalizationLimit)
|
|
1075
|
+
return { tool_kind: kind, tool_name: hostToolName, tool_input: {}, truncated: true };
|
|
1076
|
+
throw error;
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
function normalizedOfKind(event, kind, hostToolName, state) {
|
|
1080
|
+
if (kind === 'mcp') {
|
|
1081
|
+
return withExactness({ tool_kind: 'mcp', tool_name: hostToolName, tool_input: inputHash(event.tool_input, state) }, state);
|
|
1082
|
+
}
|
|
1083
|
+
if (kind === 'shell') {
|
|
1084
|
+
const { text, programs } = normalizeInner(shellCommand(event), state, 0);
|
|
1085
|
+
// A secret was replaced: the action is never bound or reused (it asks each time).
|
|
1086
|
+
if (text.includes(SECRET))
|
|
1087
|
+
state.truncated = true;
|
|
1088
|
+
return withExactness({
|
|
1089
|
+
tool_kind: 'shell',
|
|
1090
|
+
tool_name: hostToolName,
|
|
1091
|
+
programs,
|
|
1092
|
+
tool_input: { command_sha256: sha256(`${HASH_VERSION}\nshell\n${text}`) },
|
|
1093
|
+
}, state);
|
|
1094
|
+
}
|
|
1095
|
+
if (kind === 'edit') {
|
|
1096
|
+
const targets = (0, hook_tool_policy_1.toolTargetPaths)(event);
|
|
1097
|
+
if (targets.length > 64)
|
|
1098
|
+
state.truncated = true;
|
|
1099
|
+
const paths = targets.slice(0, 64).map((path) => {
|
|
1100
|
+
const redacted = redactText(withoutUserName(path, state), state);
|
|
1101
|
+
if (redacted.length > 512 || redacted.includes(SECRET))
|
|
1102
|
+
state.truncated = true;
|
|
1103
|
+
return redacted.slice(0, 512);
|
|
1104
|
+
});
|
|
1105
|
+
return withExactness({ tool_kind: 'edit', tool_name: hostToolName, paths, tool_input: inputHash(event.tool_input, state) }, state);
|
|
1106
|
+
}
|
|
1107
|
+
return withExactness({ tool_kind: 'other', tool_name: hostToolName, tool_input: inputHash(event.tool_input, state) }, state);
|
|
1108
|
+
}
|
|
1109
|
+
//# sourceMappingURL=normalized-action.js.map
|