@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.
Files changed (152) hide show
  1. package/README.md +71 -24
  2. package/dist/approval-reply.d.ts +2 -0
  3. package/dist/approval-reply.d.ts.map +1 -0
  4. package/dist/approval-reply.js +18 -0
  5. package/dist/approval-reply.js.map +1 -0
  6. package/dist/cli.js +1716 -1644
  7. package/dist/cli.js.map +1 -1
  8. package/dist/codex-plugin-pins.d.ts +19 -0
  9. package/dist/codex-plugin-pins.d.ts.map +1 -0
  10. package/dist/codex-plugin-pins.js +61 -0
  11. package/dist/codex-plugin-pins.js.map +1 -0
  12. package/dist/codex-plugins.d.ts +34 -0
  13. package/dist/codex-plugins.d.ts.map +1 -0
  14. package/dist/codex-plugins.js +240 -0
  15. package/dist/codex-plugins.js.map +1 -0
  16. package/dist/env.d.ts +8 -0
  17. package/dist/env.d.ts.map +1 -1
  18. package/dist/env.js +83 -4
  19. package/dist/env.js.map +1 -1
  20. package/dist/hook-context.d.ts.map +1 -1
  21. package/dist/hook-context.js +25 -0
  22. package/dist/hook-context.js.map +1 -1
  23. package/dist/hook-contract.d.ts +4 -3
  24. package/dist/hook-contract.d.ts.map +1 -1
  25. package/dist/hook-contract.js +43 -5
  26. package/dist/hook-contract.js.map +1 -1
  27. package/dist/hook-pre-action.d.ts +80 -3
  28. package/dist/hook-pre-action.d.ts.map +1 -1
  29. package/dist/hook-pre-action.js +435 -29
  30. package/dist/hook-pre-action.js.map +1 -1
  31. package/dist/hook-tool-policy.d.ts +73 -4
  32. package/dist/hook-tool-policy.d.ts.map +1 -1
  33. package/dist/hook-tool-policy.js +287 -18
  34. package/dist/hook-tool-policy.js.map +1 -1
  35. package/dist/hook.d.ts +1 -1
  36. package/dist/hook.d.ts.map +1 -1
  37. package/dist/hook.js +15 -7
  38. package/dist/hook.js.map +1 -1
  39. package/dist/host-approval-state.d.ts +7 -0
  40. package/dist/host-approval-state.d.ts.map +1 -1
  41. package/dist/host-approval-state.js +15 -0
  42. package/dist/host-approval-state.js.map +1 -1
  43. package/dist/host-approval.d.ts +22 -1
  44. package/dist/host-approval.d.ts.map +1 -1
  45. package/dist/host-approval.js +65 -4
  46. package/dist/host-approval.js.map +1 -1
  47. package/dist/host-session.d.ts +11 -0
  48. package/dist/host-session.d.ts.map +1 -1
  49. package/dist/host-session.js +49 -0
  50. package/dist/host-session.js.map +1 -1
  51. package/dist/index.d.ts +5 -1
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +23 -11
  54. package/dist/index.js.map +1 -1
  55. package/dist/keyless-server.d.ts +53 -0
  56. package/dist/keyless-server.d.ts.map +1 -0
  57. package/dist/keyless-server.js +230 -0
  58. package/dist/keyless-server.js.map +1 -0
  59. package/dist/normalized-action.d.ts +31 -6
  60. package/dist/normalized-action.d.ts.map +1 -1
  61. package/dist/normalized-action.js +550 -105
  62. package/dist/normalized-action.js.map +1 -1
  63. package/dist/own-server-identity.d.ts +142 -0
  64. package/dist/own-server-identity.d.ts.map +1 -0
  65. package/dist/own-server-identity.js +1123 -0
  66. package/dist/own-server-identity.js.map +1 -0
  67. package/dist/own-tool.d.ts +21 -9
  68. package/dist/own-tool.d.ts.map +1 -1
  69. package/dist/own-tool.js +25 -10
  70. package/dist/own-tool.js.map +1 -1
  71. package/dist/redact.d.ts +23 -0
  72. package/dist/redact.d.ts.map +1 -1
  73. package/dist/redact.js +181 -8
  74. package/dist/redact.js.map +1 -1
  75. package/dist/seatbelt/classifier-pin.d.ts +15 -0
  76. package/dist/seatbelt/classifier-pin.d.ts.map +1 -0
  77. package/dist/seatbelt/classifier-pin.js +26 -0
  78. package/dist/seatbelt/classifier-pin.js.map +1 -0
  79. package/dist/seatbelt/env-dump.d.ts +45 -0
  80. package/dist/seatbelt/env-dump.d.ts.map +1 -0
  81. package/dist/seatbelt/env-dump.js +308 -0
  82. package/dist/seatbelt/env-dump.js.map +1 -0
  83. package/dist/seatbelt/fallback-copy.d.ts +16 -0
  84. package/dist/seatbelt/fallback-copy.d.ts.map +1 -0
  85. package/dist/seatbelt/fallback-copy.js +68 -0
  86. package/dist/seatbelt/fallback-copy.js.map +1 -0
  87. package/dist/seatbelt/guards.d.ts +10 -0
  88. package/dist/seatbelt/guards.d.ts.map +1 -0
  89. package/dist/seatbelt/guards.js +509 -0
  90. package/dist/seatbelt/guards.js.map +1 -0
  91. package/dist/seatbelt/hook.d.ts +91 -0
  92. package/dist/seatbelt/hook.d.ts.map +1 -0
  93. package/dist/seatbelt/hook.js +396 -0
  94. package/dist/seatbelt/hook.js.map +1 -0
  95. package/dist/seatbelt/longopts.d.ts +68 -0
  96. package/dist/seatbelt/longopts.d.ts.map +1 -0
  97. package/dist/seatbelt/longopts.js +377 -0
  98. package/dist/seatbelt/longopts.js.map +1 -0
  99. package/dist/seatbelt/mapper.d.ts +83 -0
  100. package/dist/seatbelt/mapper.d.ts.map +1 -0
  101. package/dist/seatbelt/mapper.js +10581 -0
  102. package/dist/seatbelt/mapper.js.map +1 -0
  103. package/dist/seatbelt/record.d.ts +94 -0
  104. package/dist/seatbelt/record.d.ts.map +1 -0
  105. package/dist/seatbelt/record.js +776 -0
  106. package/dist/seatbelt/record.js.map +1 -0
  107. package/dist/seatbelt/tables.d.ts +82 -0
  108. package/dist/seatbelt/tables.d.ts.map +1 -0
  109. package/dist/seatbelt/tables.js +352 -0
  110. package/dist/seatbelt/tables.js.map +1 -0
  111. package/dist/seatbelt-deploy.d.ts +100 -0
  112. package/dist/seatbelt-deploy.d.ts.map +1 -0
  113. package/dist/seatbelt-deploy.js +393 -0
  114. package/dist/seatbelt-deploy.js.map +1 -0
  115. package/dist/seatbelt-fs.d.ts +30 -0
  116. package/dist/seatbelt-fs.d.ts.map +1 -0
  117. package/dist/seatbelt-fs.js +94 -0
  118. package/dist/seatbelt-fs.js.map +1 -0
  119. package/dist/seatbelt-git.d.ts +212 -0
  120. package/dist/seatbelt-git.d.ts.map +1 -0
  121. package/dist/seatbelt-git.js +844 -0
  122. package/dist/seatbelt-git.js.map +1 -0
  123. package/dist/seatbelt-mode.d.ts +77 -0
  124. package/dist/seatbelt-mode.d.ts.map +1 -0
  125. package/dist/seatbelt-mode.js +496 -0
  126. package/dist/seatbelt-mode.js.map +1 -0
  127. package/dist/seatbelt-path.d.ts +123 -0
  128. package/dist/seatbelt-path.d.ts.map +1 -0
  129. package/dist/seatbelt-path.js +482 -0
  130. package/dist/seatbelt-path.js.map +1 -0
  131. package/dist/seatbelt-scripts.d.ts +109 -0
  132. package/dist/seatbelt-scripts.d.ts.map +1 -0
  133. package/dist/seatbelt-scripts.js +1060 -0
  134. package/dist/seatbelt-scripts.js.map +1 -0
  135. package/dist/secret-paths.d.ts +17 -0
  136. package/dist/secret-paths.d.ts.map +1 -0
  137. package/dist/secret-paths.js +32 -0
  138. package/dist/secret-paths.js.map +1 -0
  139. package/dist/session-loop-guard.d.ts.map +1 -1
  140. package/dist/session-loop-guard.js +15 -5
  141. package/dist/session-loop-guard.js.map +1 -1
  142. package/dist/tool-catalog.js +2 -2
  143. package/dist/tool-catalog.js.map +1 -1
  144. package/dist/types.d.ts +2 -1
  145. package/dist/types.d.ts.map +1 -1
  146. package/package.json +3 -2
  147. package/server.json +3 -3
  148. package/vendor/classifier/MANIFEST.json +25 -0
  149. package/vendor/classifier/classifier.js +1356 -0
  150. package/vendor/classifier/policy-baseline.js +187 -0
  151. package/vendor/classifier/redact.js +59 -0
  152. 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-v2';
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.truncated = true;
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
- return text.replace(/-----BEGIN [A-Z ]*-----[\s\S]*?(?:-----END [A-Z ]*-----|$)/g, SECRET);
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.truncated = true;
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.truncated = true;
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
- if (text.length > exports.MAX_SCAN_CHARS) {
213
- state.truncated = true;
214
- return SECRET;
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
- let out = text.replace(URL_CREDENTIALS, (_match, scheme, user) => `${scheme}${user}:${SECRET}@`);
221
- out = replaceValuesAfter(out, AUTH_SCHEME);
222
- out = replaceValuesAfter(out, URL_CODE);
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
- /** Replaces the value after each match of `pattern` (an auth scheme, an OAuth code), ended by the shared rule. */
226
- function replaceValuesAfter(text, pattern) {
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
- if (matches > MAX_ASSIGNMENTS) {
258
- state.truncated = true;
259
- return SECRET;
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
- if (kind === 'ambiguous' || holdsMoreThanCredential(value))
279
- state.truncated = true;
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, or `[secret]` and
294
- * truncated when it feeds a secrets command or file or looks secret.
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 (feedsSecrets || secretShaped(text, state)) {
298
- state.truncated = true;
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
- return `[data:${sha256(`${HASH_VERSION}\ndata\n${text}`)}]`;
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
- /** Splits a command into words and operators, honoring quotes and escapes (an approximation of the shell). */
305
- function tokenize(command) {
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 = classifySecretName(name);
562
+ const kind = nameClass(name, state);
493
563
  if (kind === 'credential' || kind === 'ambiguous') {
494
564
  if (kind === 'ambiguous' || holdsMoreThanCredential(value))
495
- state.truncated = true;
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]] = dataMarker(printed.map((i) => out[i]).join(' '), downstreamTakes || secretTarget, state);
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] = SECRET;
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
- if (ambiguous || holdsMoreThanCredential(out[i]))
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
- if (holdsMoreThanCredential(header.replace(/^[^:]*:/, '')))
633
- state.truncated = true;
634
- return SECRET;
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
- if (redacted === header)
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
- // A program named for a secret (set-password.sh): its arguments cannot be told apart.
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
- const next = (ambiguous = false) => {
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.truncated = true;
674
- out[j] = cutCredential(out[j], state);
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 = classifySecretName(name);
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
- if (holdsMoreThanCredential(long[2].replace(/^[^=]*=/, '')))
701
- state.truncated = true;
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
- if (classifySecretName(name) === 'ambiguous')
719
- state.truncated = true;
720
- out[i] = `${long[1]}=${cutCredential(long[2], state)}`;
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
- if (/[&#]/.test(value) || holdsMoreThanCredential(value))
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
- if (classifySecretName(name) === 'ambiguous')
805
- state.truncated = true;
806
- out[i] = credentialArgument(name, value);
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 = classifySecretName(arg);
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 tokens = tokenize(extractHeredocs(command.replace(/\r\n?/g, '\n'), data));
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.truncated = true;
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 = classifySecretName(key);
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
- return words.map((word) => (/[\s'"]/.test(word) ? `'${word.replace(/'/g, `'\\''`)}'` : word)).join(' ');
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 state = newExactness(options.deadlineAt);
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.isShellGovernedTool)(event) ? 'shell'
1155
- : EDIT_TOOLS.test(toolName) ? 'edit'
1156
- : 'other';
1157
- if (event.input_truncated === true)
1158
- state.truncated = true;
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
- return normalizedOfKind(event, kind, hostToolName, state);
1586
+ action = normalizedOfKind(event, kind, hostToolName, state);
1161
1587
  }
1162
1588
  catch (error) {
1163
- if (error instanceof NormalizationLimit)
1164
- return { tool_kind: kind, tool_name: hostToolName, tool_input: {}, truncated: true };
1165
- throw error;
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
- command_sha256: sha256(`${HASH_VERSION}\nshell\n${text}`),
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);