@getmarrow/mcp 3.9.106 → 3.9.108

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 (48) hide show
  1. package/README.md +25 -3
  2. package/dist/codex-plugin-pins.js +12 -12
  3. package/dist/hook-contract.d.ts +2 -2
  4. package/dist/hook-contract.d.ts.map +1 -1
  5. package/dist/hook-contract.js +8 -4
  6. package/dist/hook-contract.js.map +1 -1
  7. package/dist/hook-pre-action.d.ts +6 -1
  8. package/dist/hook-pre-action.d.ts.map +1 -1
  9. package/dist/hook-pre-action.js +276 -12
  10. package/dist/hook-pre-action.js.map +1 -1
  11. package/dist/hook-tool-policy.d.ts +13 -0
  12. package/dist/hook-tool-policy.d.ts.map +1 -1
  13. package/dist/hook-tool-policy.js +68 -4
  14. package/dist/hook-tool-policy.js.map +1 -1
  15. package/dist/host-approval-state.d.ts +17 -0
  16. package/dist/host-approval-state.d.ts.map +1 -1
  17. package/dist/host-approval-state.js +53 -0
  18. package/dist/host-approval-state.js.map +1 -1
  19. package/dist/normalized-action.d.ts +9 -3
  20. package/dist/normalized-action.d.ts.map +1 -1
  21. package/dist/normalized-action.js +241 -40
  22. package/dist/normalized-action.js.map +1 -1
  23. package/dist/own-server-identity.d.ts.map +1 -1
  24. package/dist/own-server-identity.js +10 -4
  25. package/dist/own-server-identity.js.map +1 -1
  26. package/dist/seatbelt/env-dump.d.ts +22 -1
  27. package/dist/seatbelt/env-dump.d.ts.map +1 -1
  28. package/dist/seatbelt/env-dump.js +27 -2
  29. package/dist/seatbelt/env-dump.js.map +1 -1
  30. package/dist/seatbelt/env-names.d.ts +22 -0
  31. package/dist/seatbelt/env-names.d.ts.map +1 -0
  32. package/dist/seatbelt/env-names.js +298 -0
  33. package/dist/seatbelt/env-names.js.map +1 -0
  34. package/dist/seatbelt/mapper.d.ts.map +1 -1
  35. package/dist/seatbelt/mapper.js +1247 -73
  36. package/dist/seatbelt/mapper.js.map +1 -1
  37. package/dist/seatbelt/tables.d.ts.map +1 -1
  38. package/dist/seatbelt/tables.js +4 -1
  39. package/dist/seatbelt/tables.js.map +1 -1
  40. package/dist/secret-paths.d.ts +23 -0
  41. package/dist/secret-paths.d.ts.map +1 -1
  42. package/dist/secret-paths.js +29 -2
  43. package/dist/secret-paths.js.map +1 -1
  44. package/dist/session-loop-guard.d.ts.map +1 -1
  45. package/dist/session-loop-guard.js +10 -1
  46. package/dist/session-loop-guard.js.map +1 -1
  47. package/package.json +1 -1
  48. package/server.json +2 -2
@@ -1,11 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.MAX_SCAN_CHARS = void 0;
3
+ exports.KNOWN_KEY_PREFIX = exports.MAX_SCAN_CHARS = void 0;
4
4
  exports.looksLikeKey = looksLikeKey;
5
5
  exports.classifySecretName = classifySecretName;
6
6
  exports.normalizeShellCommand = normalizeShellCommand;
7
7
  exports.deployCommandText = deployCommandText;
8
8
  exports.shellToolRunOptionsKey = shellToolRunOptionsKey;
9
+ exports.keyedTargetHash = keyedTargetHash;
9
10
  exports.normalizedHookAction = normalizedHookAction;
10
11
  const node_crypto_1 = require("node:crypto");
11
12
  const node_os_1 = require("node:os");
@@ -29,7 +30,7 @@ const DEFAULT_NORMALIZE_MS = 1_000;
29
30
  class NormalizationLimit extends Error {
30
31
  }
31
32
  function newExactness(deadline = Date.now() + DEFAULT_NORMALIZE_MS, exact = false) {
32
- return { truncated: false, budget: { scanned: 0, deadline }, exact, sigs: [] };
33
+ return { truncated: false, budget: { scanned: 0, deadline }, exact, sigs: [], freeText: false };
33
34
  }
34
35
  /**
35
36
  * A value withheld only because it MAY be a secret (it may also be an id, a commit or a name that decides the
@@ -51,7 +52,16 @@ function sha256(text) {
51
52
  // ---------------------------------------------------------------------------
52
53
  // Key-shaped values
53
54
  // ---------------------------------------------------------------------------
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,}$/;
55
+ exports.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,}$/;
56
+ /**
57
+ * KNOWN_KEY_PREFIX as spans inside a longer text (an edit path): the same alternatives with their anchors cut off, so
58
+ * the two cannot drift apart, plus a hex run of 32 or more characters. A span starts at the start of the text or after
59
+ * any character that is not a letter or digit (`/`, `\`, `.`, `-`, `=`), and its greedy token class ends it at the first
60
+ * character the class does not hold (the `.` before an extension, `/`, `\`, `?`, `#`, the end).
61
+ */
62
+ const PATH_KEY_SPAN = new RegExp(`(?<![A-Za-z0-9])(?:${exports.KNOWN_KEY_PREFIX.source.split('|^').map((alt) => alt.replace(/^\^/, '').replace(/\$$/, '')).join('|')}|[0-9A-Fa-f]{32,})`, 'g');
63
+ /** An edit path is scanned for spans on its first characters only, twice the 512 that are sent: the work stays bounded. */
64
+ const PATH_SCAN_CHARS = 1024;
55
65
  const PEM = /-----BEGIN [A-Z ]*(?:PRIVATE KEY|CERTIFICATE)-----/;
56
66
  /** 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
67
  const PUBLIC_KNOWN_KEY = /^(?:AKIA|ASIA|AGPA|AIDA|AROA)[0-9A-Z]{12,}$|^pk[-_]/;
@@ -60,7 +70,7 @@ function looksLikeKey(value) {
60
70
  const text = value.trim();
61
71
  if (!text)
62
72
  return false;
63
- if (KNOWN_KEY_PREFIX.test(text))
73
+ if (exports.KNOWN_KEY_PREFIX.test(text))
64
74
  return true;
65
75
  if (PEM.test(text))
66
76
  return true;
@@ -82,7 +92,7 @@ const KEY_SPLIT = /([\s=:,;@/?&'"`()[\]{}<>|!*$])/;
82
92
  function redactKeyShapes(text, state) {
83
93
  const whole = text.trim();
84
94
  if (whole && !/\s/.test(whole) && looksLikeKey(whole)) {
85
- if (!KNOWN_KEY_PREFIX.test(whole) && !PEM.test(whole))
95
+ if (!exports.KNOWN_KEY_PREFIX.test(whole) && !PEM.test(whole))
86
96
  return withheld(state, text);
87
97
  return state.exact && PUBLIC_KNOWN_KEY.test(whole) ? text : SECRET;
88
98
  }
@@ -93,7 +103,7 @@ function redactKeyShapes(text, state) {
93
103
  return text.split(KEY_SPLIT).map((part) => {
94
104
  if (!looksLikeKey(part))
95
105
  return part;
96
- if (!KNOWN_KEY_PREFIX.test(part))
106
+ if (!exports.KNOWN_KEY_PREFIX.test(part))
97
107
  return withheld(state, part);
98
108
  return state.exact && PUBLIC_KNOWN_KEY.test(part) ? part : SECRET;
99
109
  }).join('');
@@ -334,7 +344,7 @@ function dataMarker(text, feedsSecrets, state, words = null) {
334
344
  // The exact pass: only the certain credentials inside the data are replaced and the rest is hashed, also for
335
345
  // data a secrets command or file reads (a vault policy, an authorized_keys line, a kubeconfig differ outside
336
346
  // 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: [] };
347
+ const probe = { truncated: false, budget: state.budget, exact: true, sigs: [], freeText: false };
338
348
  return `[data:${sha256(`${HASH_VERSION}\nexact-data\n${redactText(text, probe)}`)}]`;
339
349
  }
340
350
  if (feedsSecrets) {
@@ -345,7 +355,7 @@ function dataMarker(text, feedsSecrets, state, words = null) {
345
355
  // replaced and the rest is hashed, so data that differs outside a secret gives
346
356
  // a different key, data that differs only in a secret the same one. Withheld:
347
357
  // the action is truncated and never binds an approval.
348
- const probe = { truncated: false, budget: state.budget, exact: false, sigs: [] };
358
+ const probe = { truncated: false, budget: state.budget, exact: false, sigs: [], freeText: false };
349
359
  const redacted = redactText(text, probe);
350
360
  if (redacted !== text) {
351
361
  state.truncated = true; // exact: kept (hashed above, certain credentials replaced)
@@ -362,7 +372,7 @@ function dataMarker(text, feedsSecrets, state, words = null) {
362
372
  */
363
373
  function printedData(words, feedsSecrets, state) {
364
374
  if (state.exact) {
365
- const probe = { truncated: false, budget: state.budget, exact: true, sigs: [] };
375
+ const probe = { truncated: false, budget: state.budget, exact: true, sigs: [], freeText: false };
366
376
  return `[data:${sha256(`${HASH_VERSION}\nexact-words\n${JSON.stringify(words.map((word) => redactText(word, probe)))}`)}]`;
367
377
  }
368
378
  return dataMarker(words.join(' '), feedsSecrets, state, words);
@@ -1040,6 +1050,93 @@ function quoteSignature(command) {
1040
1050
  }
1041
1051
  return parts.join('');
1042
1052
  }
1053
+ /**
1054
+ * The program names a stage that feeds a password program may keep in `programs[]`: the seatbelt's compiled-in
1055
+ * KNOWN_COMMANDS and INERT_PROGRAMS (a copy: this module may not import the seatbelt, see test/seatbelt-vendor.test.js;
1056
+ * test/n5-redact-keyed.test.js fails when the tables gain a name this set lacks) and a few secret-store and
1057
+ * PowerShell feeders. Any other first word of such a stage may be the secret itself (`"pw" | smbpasswd -s`).
1058
+ */
1059
+ const KNOWN_FEEDER_PROGRAMS = new Set([
1060
+ '.', '[', '[[', 'ack', 'ag', 'alias', 'anaconda', 'apply_patch', 'applypatch', 'ash', 'awk', 'aws', 'az', 'b2sum',
1061
+ 'base64', 'basename', 'bash', 'bat', 'bc', 'break', 'bsdtar', 'builtin', 'bun', 'bunx', 'busybox', 'bw', 'bzcat',
1062
+ 'bzip2', 'cabal', 'caffeinate', 'cal', 'cargo', 'cat', 'cd', 'cdk', 'changeset', 'changesets', 'chgrp', 'chmod',
1063
+ 'chown', 'chrt', 'cksum', 'claude', 'clear', 'cmd', 'cmp', 'colordiff', 'column', 'comm', 'command',
1064
+ 'concurrently', 'conda', 'continue', 'convertto-securestring', 'corepack', 'cp', 'cpio', 'csh', 'curl', 'cut',
1065
+ 'dart', 'dash', 'date', 'dc', 'dd', 'declare', 'deno', 'df', 'diff', 'diff3', 'dig', 'dirname', 'doas', 'docker',
1066
+ 'dotnet', 'du', 'eb', 'echo', 'egrep', 'env', 'eslint', 'eval', 'exa', 'exec', 'exit', 'export', 'expr', 'eza',
1067
+ 'false', 'fd', 'fgrep', 'file', 'find', 'firebase', 'fish', 'flit', 'flock', 'flutter', 'fly', 'flyctl', 'fmt',
1068
+ 'fold', 'free', 'gawk', 'gcloud', 'gem', 'get-content', 'get-secret', 'getconf', 'getopts', 'gh', 'git', 'git-lfs',
1069
+ 'gmake', 'gopass', 'goreleaser', 'gpg', 'gpg2', 'gradle', 'gradlew', 'grep', 'gsed', 'gtar', 'gunzip', 'gzip',
1070
+ 'hash', 'hatch', 'head', 'helm', 'help', 'heroku', 'hexdump', 'history', 'host', 'hostname', 'htop', 'http',
1071
+ 'https', 'id', 'ifconfig', 'install', 'invoke', 'ionice', 'ip', 'ipython', 'irb', 'jest', 'jobs', 'join', 'jq',
1072
+ 'jsr', 'just', 'kill', 'ksh', 'kubectl', 'lein', 'lerna', 'less', 'let', 'ln', 'local', 'locale', 'logger',
1073
+ 'lpass', 'ls', 'lsof', 'lua', 'mage', 'make', 'mamba', 'man', 'mapfile', 'marrow', 'marrow-install', 'maturin',
1074
+ 'mawk', 'md5sum', 'mix', 'mkdir', 'mksh', 'mktemp', 'mocha', 'more', 'mv', 'mvn', 'mvnw', 'nawk', 'netlify',
1075
+ 'netstat', 'nice', 'nl', 'node', 'nodejs', 'nohup', 'notify-send', 'np', 'npm', 'npm-run-all', 'npm-run-all2',
1076
+ 'nproc', 'npx', 'nslookup', 'ntl', 'nuget', 'nx', 'od', 'op', 'opam', 'openssl', 'osascript', 'ovsx', 'parallel',
1077
+ 'pass', 'paste', 'patch', 'pax', 'pbcopy', 'pbpaste', 'pdm', 'perl', 'pgrep', 'php', 'ping', 'pkexec', 'pnpm',
1078
+ 'pod', 'podman', 'poetry', 'popd', 'powershell', 'prettier', 'print', 'printenv', 'printf', 'ps', 'pulumi',
1079
+ 'pushd', 'pwd', 'pwgen', 'pwsh', 'python', 'python2', 'python3', 'railway', 'rake', 'rbash', 'read', 'read-host',
1080
+ 'readarray', 'readlink', 'readonly', 'realpath', 'rebar3', 'release-it', 'retry', 'return', 'rev', 'rg', 'rimraf',
1081
+ 'rm', 'rmdir', 'rsync', 'ruby', 'run-p', 'run-s', 'runas', 'say', 'sbt', 'script', 'secret-tool', 'security',
1082
+ 'sed', 'semantic-release', 'seq', 'serverless', 'set', 'setsid', 'sh', 'sha1sum', 'sha256sum', 'sha512sum',
1083
+ 'shasum', 'shift', 'shred', 'sleep', 'sls', 'sort', 'source', 'ss', 'ssh', 'ssh-add', 'ssh-keygen', 'ssh-keyscan',
1084
+ 'sst', 'stack', 'stat', 'stdbuf', 'strings', 'stty', 'su', 'sudo', 'sync', 'tac', 'tail', 'tar', 'task', 'taskset',
1085
+ 'tclsh', 'tcsh', 'tee', 'terraform', 'terragrunt', 'test', 'time', 'timeout', 'tofu', 'top', 'touch', 'toybox',
1086
+ 'tput', 'tr', 'trap', 'tree', 'true', 'truncate', 'tsc', 'turbo', 'twine', 'type', 'typeset', 'ulimit', 'umask',
1087
+ 'uname', 'unbuffer', 'uniq', 'unlink', 'unset', 'unzip', 'uptime', 'uv', 'vault', 'vercel', 'vitest', 'vsce',
1088
+ 'wait', 'watch', 'wc', 'wget', 'whereis', 'which', 'whoami', 'wl-copy', 'wrangler', 'write-host', 'write-output',
1089
+ 'xargs', 'xclip', 'xxd', 'xz', 'xzcat', 'yarn', 'yash', 'yes', 'yq', 'zcat', 'zip', 'zsh', 'zstd',
1090
+ ]);
1091
+ /**
1092
+ * Program names the vendored classifier's policy table knows and the set above lacks (psql, mysql, crontab, iptables,
1093
+ * systemctl, ...): a name the classifier judges stays a name in `programs[]`, so withholding unknown words never drops one
1094
+ * (test/n5-full-scope.test.js fails when the table gains a name neither set has).
1095
+ */
1096
+ const CLASSIFIER_PROGRAMS = new Set([
1097
+ '7z', 'actionlint', 'ansible', 'ansible-playbook', 'astro', 'base32', 'biome', 'black', 'chpasswd', 'clang-format',
1098
+ 'clang_format', 'clickhouse-client', 'cloc', 'code', 'cqlsh', 'createdb', 'crontab', 'dir', 'doctl', 'dropdb',
1099
+ 'eksctl', 'emacs', 'enable', 'esbuild', 'fdisk', 'flake8', 'gatsby', 'go', 'gofmt', 'golangci-lint',
1100
+ 'golangci_lint', 'hadolint', 'halt', 'husky', 'init', 'iptables', 'isort', 'killall', 'launchctl', 'll',
1101
+ 'marrow-agentd', 'marrow-mcp', 'mkfs', 'mkfs.ext4', 'mkfs.xfs', 'mongo', 'mongosh', 'mosh', 'mount', 'mypy',
1102
+ 'mysql', 'nano', 'nc', 'ncat', 'next', 'nft', 'nox', 'nuxi', 'nuxt', 'nvim', 'open', 'parcel', 'parted', 'passwd',
1103
+ 'pg_dump', 'pg_restore', 'pip', 'pip3', 'pkill', 'pnpx', 'poweroff', 'psql', 'pylint', 'pytest', 'reboot',
1104
+ 'redis-cli', 'remix', 'rollup', 'ruff', 'rustfmt', 'scp', 'service', 'sftp', 'shellcheck', 'shopt', 'shutdown',
1105
+ 'socat', 'sqlite3', 'storybook', 'stripe', 'supabase', 'swapoff', 'systemctl', 'telnet', 'tokei', 'tox', 'tsup',
1106
+ 'ufw', 'umount', 'unxz', 'useradd', 'userdel', 'usermod', 'vim', 'visudo', 'vite', 'webpack', 'webpack-cli',
1107
+ 'wipefs', 'xdg-open', 'xh', 'yamllint',
1108
+ ]);
1109
+ /** A lone word (no argument, no redirection) is a program only when it is one of the stdin producers; a lone password is a lone word. */
1110
+ const LONE_FEEDER_PROGRAMS = new Set(['cat', 'yes', 'pbpaste']);
1111
+ /** Stands in `programs[]` for a withheld first word: constant, derived from nothing about the word. */
1112
+ const WITHHELD_FEEDER = 'unlisted-feeder';
1113
+ /** Stands in `programs[]` for a program word of a tool that does not run shell text, when it is not a known program. */
1114
+ const UNLISTED_PROGRAM = 'unlisted-program';
1115
+ /** Whether a program word names a known program (`git.exe`, `npm.cmd` and `wrangler.ps1` are the program they name). */
1116
+ function isKnownProgram(program) {
1117
+ const stem = programStem(program).replace(/\.(?:cmd|bat|com)$/, '');
1118
+ return KNOWN_FEEDER_PROGRAMS.has(stem) || CLASSIFIER_PROGRAMS.has(stem);
1119
+ }
1120
+ /**
1121
+ * Whether a word of a tool that does not run shell text may stay in `programs[]`: a known program, or one of the programs
1122
+ * that take credentials (the table `takesSecrets` reads, and the password program of the SEC1 cases). A name the seatbelt or
1123
+ * the classifier reasons about is never replaced by the marker; the feeder rule below stays on the narrower known set.
1124
+ */
1125
+ function isListableProgram(program) {
1126
+ if (isKnownProgram(program))
1127
+ return true;
1128
+ const stem = programStem(program).replace(/\.(?:cmd|bat|com)$/, '');
1129
+ return SECRET_PROGRAMS.has(stem) || stem === 'smbpasswd';
1130
+ }
1131
+ /** Whether the program of a stage that feeds a password program is a program name rather than possibly a value. */
1132
+ function feederNameKept(program, stage) {
1133
+ if (!isKnownProgram(program))
1134
+ return false;
1135
+ if (stage.length !== 1)
1136
+ return true;
1137
+ // Alone, only the exact lower-case name of a stdin producer: no path, no other case (`"Cat" | smbpasswd` is a value).
1138
+ return LONE_FEEDER_PROGRAMS.has(program) && stage[0] === program;
1139
+ }
1043
1140
  function normalizeInner(command, state, depth) {
1044
1141
  if (command.length > MAX_COMMAND_CHARS)
1045
1142
  throw new NormalizationLimit();
@@ -1105,9 +1202,24 @@ function normalizeInner(command, state, depth) {
1105
1202
  depth,
1106
1203
  });
1107
1204
  words.push(...result.words.map(quoteWord));
1108
- if (result.program && /^[a-z0-9][a-z0-9._+-]{0,63}$/.test(result.program) && !looksLikeKey(result.program) && !programs.includes(result.program)) {
1109
- programs.push(result.program);
1205
+ let program = result.program;
1206
+ // The first word of a stage that feeds a password program may itself be the secret (PowerShell's
1207
+ // `"pw" | smbpasswd -s` idiom, which the redactor reads as a program name). It is named only when it is a
1208
+ // known program (and, when it stands alone, one of the stdin producers); any other word is withheld as a
1209
+ // fixed marker and the action is never bound. The word stays in the hashed text (keyed for a truncated
1210
+ // action), so two different feeders never share a key.
1211
+ if (program && sinkAfter[k] && !feederNameKept(program, segment.words)) {
1212
+ program = WITHHELD_FEEDER;
1213
+ state.truncated = true; // exact: n/a (programs are not exact material; the hashed text keeps the word)
1110
1214
  }
1215
+ const listed = !!program && /^[a-z0-9][a-z0-9._+-]{0,63}$/.test(program) && !looksLikeKey(program);
1216
+ // A tool that does not run shell text (ToolSearch's query, a prompt, a path, an unknown tool's `command`) is read
1217
+ // as shell words only for classification: its first word is a value, not a program, unless it names a known one.
1218
+ // Not marked truncated: the text hash is keyed, so binding stays safe (see keyTextHash).
1219
+ if (listed && state.freeText && program !== WITHHELD_FEEDER && !isListableProgram(program))
1220
+ program = UNLISTED_PROGRAM;
1221
+ if (listed && !programs.includes(program))
1222
+ programs.push(program);
1111
1223
  }
1112
1224
  const op = segment.op;
1113
1225
  if (op && (op !== ';' || (words.length && words[words.length - 1] !== ';')))
@@ -1243,6 +1355,18 @@ function withoutUserName(path, state) {
1243
1355
  state.truncated = true; // exact: n/a (paths are not exact material; the whole input is)
1244
1356
  return `${other[1]}/[user]${path.slice(other[0].length)}`;
1245
1357
  }
1358
+ /**
1359
+ * An edit path with each known-format secret span (PATH_KEY_SPAN) replaced by `[secret]`: only the span, so the
1360
+ * extension, `.env.`, `backup-`, the directories and the Windows, `file://` and `~/` forms stay readable for the
1361
+ * service's path classifier. The caller marks the action truncated when a span was replaced or the path is longer than
1362
+ * the scan window (it never binds). Only the first 512 characters of a path are sent: a path longer than the window is
1363
+ * scanned on the window without its last run of token characters (a token that began there and was cut).
1364
+ */
1365
+ function redactPathSpans(path) {
1366
+ if (path.length <= PATH_SCAN_CHARS)
1367
+ return path.replace(PATH_KEY_SPAN, SECRET);
1368
+ return path.slice(0, PATH_SCAN_CHARS).replace(/[A-Za-z0-9_.-]+$/, '').replace(PATH_KEY_SPAN, SECRET);
1369
+ }
1246
1370
  // ---------------------------------------------------------------------------
1247
1371
  // Deploy command text (the only command text ever sent)
1248
1372
  // ---------------------------------------------------------------------------
@@ -1327,13 +1451,23 @@ function deployCommandText(command) {
1327
1451
  function normalizedWords(_toolWords, trimmed) {
1328
1452
  return trimmed.split(/[ \t]+/).join(' ');
1329
1453
  }
1330
- function inputHash(input, state) {
1454
+ /** What `input_sha256` hashes (null: no input). Sets `truncated` when a credential was replaced. */
1455
+ function inputPreimage(input, state) {
1331
1456
  if (input === undefined || input === null)
1332
- return {};
1457
+ return null;
1333
1458
  const text = JSON.stringify(sortedValue(redactedInput(input, state)));
1334
1459
  if (text.includes(SECRET))
1335
1460
  state.truncated = true; // exact: n/a (pass 1 only; exactMaterial is the exact form)
1336
- return { input_sha256: sha256(`${HASH_VERSION}\ninput\n${text}`) };
1461
+ return `${HASH_VERSION}\ninput\n${text}`;
1462
+ }
1463
+ function inputHash(input, state) {
1464
+ const preimage = inputPreimage(input, state);
1465
+ return preimage === null ? {} : { input_sha256: sha256(preimage) };
1466
+ }
1467
+ /** Whether every run option of a Monitor or PowerShell call is a number or a boolean (none: yes). */
1468
+ function runOptionsScalar(event) {
1469
+ const options = (0, hook_tool_policy_1.shellCommandToolRunOptions)(event);
1470
+ return options === null || Object.values(options).every((value) => typeof value === 'boolean' || (typeof value === 'number' && Number.isFinite(value)));
1337
1471
  }
1338
1472
  /**
1339
1473
  * The run options of a Monitor or PowerShell call (timeout, timeout_ms, run_in_background) as key material:
@@ -1363,10 +1497,14 @@ function withExactness(action, state) {
1363
1497
  // ---------------------------------------------------------------------------
1364
1498
  // Exact material for a truncated action (exact_hmac; M2)
1365
1499
  // ---------------------------------------------------------------------------
1366
- const EXACT_DOMAIN = 'marrow-exact-action-v1';
1500
+ /** The version tag is part of every MAC input: a value made under another version can never equal one made under this. */
1501
+ const EXACT_DOMAIN = 'marrow-exact-action-v2';
1502
+ const TEXT_KEY_LABEL = 'marrow-text-hmac-key-v2';
1503
+ const TEXT_MAC_DOMAIN = 'marrow-text-hmac-v2';
1504
+ const TARGET_MAC_DOMAIN = 'marrow-hook-target-v2';
1367
1505
  /** Raw canonical input past this is never keyed: a nonce instead (it matches nothing). */
1368
1506
  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.';
1507
+ const EXACT_SALT_NOTE = 'Marrow: neither the private device key nor the local approval state for exact action matching is usable on this machine, so a held action is asked again instead of resumed on retry. Owner: run the Marrow installer doctor command in a terminal.';
1370
1508
  /** Canonical JSON (keys sorted at every depth), or null past 1 MB, past 64 levels, or when it cannot be serialized. */
1371
1509
  function canonicalJson(value) {
1372
1510
  const parts = [];
@@ -1515,7 +1653,11 @@ function exactMaterial(event, kind, deadline) {
1515
1653
  }
1516
1654
  }
1517
1655
  let saltNoted = false;
1518
- /** The device salt (seatbelt store, same checks), or null with one stderr line per process (no path, no value). */
1656
+ /**
1657
+ * The device salt (the same checks as the receipts store). Without one, the fallback key of the host-approval state
1658
+ * (nokeyFallbackKey: where a hold can be made, the same hashes stay stable, so an approved action is still reported and
1659
+ * committed). null, with one stderr line per process (no path, no value), only when neither key is usable.
1660
+ */
1519
1661
  function exactSalt() {
1520
1662
  let salt = null;
1521
1663
  try {
@@ -1524,6 +1666,14 @@ function exactSalt() {
1524
1666
  catch {
1525
1667
  salt = null;
1526
1668
  }
1669
+ if (!salt) {
1670
+ try {
1671
+ salt = require('./host-approval-state').nokeyFallbackKey();
1672
+ }
1673
+ catch {
1674
+ salt = null;
1675
+ }
1676
+ }
1527
1677
  if (!salt && !saltNoted) {
1528
1678
  saltNoted = true;
1529
1679
  try {
@@ -1542,6 +1692,31 @@ function exactHmac(salt, kind, toolName, material) {
1542
1692
  return (0, node_crypto_1.randomBytes)(16).toString('hex');
1543
1693
  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
1694
  }
1695
+ /**
1696
+ * The hash of the text of an action (`command_sha256`, `input_sha256`; every action but a deploy sent in clear):
1697
+ * HMAC-SHA256 under a sub-key of the device salt, 64 hex (the plain hash had the same shape). The text may hold a
1698
+ * value the redactor did not recognise, so a plain hash of it could be brute-forced offline; the key never leaves
1699
+ * this machine. The kind and the host's tool
1700
+ * name are in the input, as for exact_hmac. Without a usable salt it is a random value that matches nothing (the
1701
+ * action is asked again): never the plain hash, never a fixed value.
1702
+ */
1703
+ function keyTextHash(salt, kind, toolName, preimage) {
1704
+ if (!salt || salt.length < 16)
1705
+ return (0, node_crypto_1.randomBytes)(32).toString('hex');
1706
+ const key = (0, node_crypto_1.createHmac)('sha256', salt).update(TEXT_KEY_LABEL).digest();
1707
+ return (0, node_crypto_1.createHmac)('sha256', key).update(`${TEXT_MAC_DOMAIN}\n${kind}\n${JSON.stringify(toolName)}\n`).update(preimage).digest('hex');
1708
+ }
1709
+ /**
1710
+ * The keyed hash of a hold's exact target (hook-contract exactHookTarget): the same sub-key as keyTextHash, its own
1711
+ * domain. null without a usable device key (the caller then makes a target nothing can match).
1712
+ */
1713
+ function keyedTargetHash(parts) {
1714
+ const salt = exactSalt();
1715
+ if (!salt || salt.length < 16)
1716
+ return null;
1717
+ const key = (0, node_crypto_1.createHmac)('sha256', salt).update(TEXT_KEY_LABEL).digest();
1718
+ return (0, node_crypto_1.createHmac)('sha256', key).update(`${TARGET_MAC_DOMAIN}\n`).update(JSON.stringify(parts)).digest('hex');
1719
+ }
1545
1720
  /**
1546
1721
  * The normalized action of a hook event. `deadlineAt` bounds the time it may
1547
1722
  * take (default one second from now): past it, or past the size limits, the
@@ -1555,9 +1730,9 @@ function exactHmac(salt, kind, toolName, material) {
1555
1730
  * credentials replaced, so two actions that differ anywhere else never share a
1556
1731
  * correlation, hold or key, and a retry that differs only in a certain
1557
1732
  * 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).
1733
+ * 1 MB). Input a host adapter cut or dropped, input over 1 MB and a machine with neither a
1734
+ * device salt nor a usable host-approval state (exactSalt) give a random nonce: the action matches
1735
+ * nothing (it is asked again). `salt` is a test seam (undefined: the device salt).
1561
1736
  */
1562
1737
  function normalizedHookAction(event, options = {}) {
1563
1738
  const deadline = options.deadlineAt ?? Date.now() + DEFAULT_NORMALIZE_MS;
@@ -1581,9 +1756,12 @@ function normalizedHookAction(event, options = {}) {
1581
1756
  if (inputCut)
1582
1757
  state.truncated = true; // exact: nonce (the cut or dropped input is all that is left)
1583
1758
  let action;
1759
+ let preimage = null;
1760
+ let hashField = 'input_sha256';
1761
+ let plain = false;
1584
1762
  let placeholder = false;
1585
1763
  try {
1586
- action = normalizedOfKind(event, kind, hostToolName, state);
1764
+ ({ action, preimage, hashField, plain } = normalizedOfKind(event, kind, hostToolName, state));
1587
1765
  }
1588
1766
  catch (error) {
1589
1767
  if (!(error instanceof NormalizationLimit))
@@ -1591,20 +1769,35 @@ function normalizedHookAction(event, options = {}) {
1591
1769
  action = { tool_kind: kind, tool_name: hostToolName, tool_input: {}, truncated: true };
1592
1770
  placeholder = true;
1593
1771
  }
1594
- if (action.truncated !== true)
1772
+ const cut = action.truncated === true;
1773
+ // Nothing to key: the deploy text sent in clear (plain hash, see BuiltAction), or an action with no text hash.
1774
+ if (!cut && (plain || preimage === null))
1595
1775
  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) } };
1776
+ const material = !cut || inputCut ? null : placeholder ? rawExactMaterial(event, kind) : exactMaterial(event, kind, deadline);
1777
+ const salt = options.salt !== undefined ? options.salt : cut && material === null && preimage === null ? null : exactSalt();
1778
+ // EVERY text hash is keyed, bound or not: the text of any tool field may hold a value the redactor did not see
1779
+ // (a prompt, a message, a plan, a query, a filter, an id, an unknown tool's `command`), and a plain hash of it could be
1780
+ // guessed offline. The key is per install and stable, so the same call on the same install still binds and resumes.
1781
+ const keyed = preimage === null ? {} : { [hashField]: keyTextHash(salt, kind, rawToolName, preimage) };
1782
+ if (!cut)
1783
+ return { ...action, tool_input: { ...action.tool_input, ...keyed } };
1784
+ return { ...action, tool_input: { ...action.tool_input, ...keyed, exact_hmac: exactHmac(salt, kind, rawToolName, material) } };
1785
+ }
1786
+ /** An action whose tool input is hashed whole (mcp, edit, other). */
1787
+ function inputAction(base, input, state) {
1788
+ const preimage = inputPreimage(input, state);
1789
+ const action = { ...base, tool_input: preimage === null ? {} : { input_sha256: sha256(preimage) } };
1790
+ return { action: withExactness(action, state), hashField: 'input_sha256', preimage, plain: false };
1599
1791
  }
1600
1792
  function normalizedOfKind(event, kind, hostToolName, state) {
1601
1793
  if (kind === 'mcp') {
1602
- return withExactness({ tool_kind: 'mcp', tool_name: hostToolName, tool_input: inputHash(event.tool_input, state) }, state);
1794
+ return inputAction({ tool_kind: 'mcp', tool_name: hostToolName }, event.tool_input, state);
1603
1795
  }
1604
1796
  if (kind === 'shell') {
1605
1797
  const raw = shellCommand(event);
1606
1798
  if (untrimmedShellText(event, raw) !== null)
1607
1799
  state.truncated = true; // exact: raw (the host's untrimmed text)
1800
+ state.freeText = !(0, hook_tool_policy_1.isShellTextTool)(event);
1608
1801
  const { text, programs } = normalizeInner(raw, state, 0);
1609
1802
  // A secret was replaced: the action is never bound or reused (it asks each time).
1610
1803
  if (text.includes(SECRET))
@@ -1624,29 +1817,37 @@ function normalizedOfKind(event, kind, hostToolName, state) {
1624
1817
  const domain = !plainShellTool ? 'shell'
1625
1818
  : runOptions === null ? `shell-tool\n${JSON.stringify(hostToolName)}`
1626
1819
  : `shell-tool+run\n${JSON.stringify(hostToolName)}\n${runOptions}`;
1627
- return withExactness({
1628
- tool_kind: 'shell',
1629
- tool_name: hostToolName,
1630
- programs,
1631
- tool_input: {
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)}`}`),
1634
- ...(deployText !== null && deployText === text ? { command: deployText } : {}),
1635
- },
1636
- }, state);
1820
+ // An exact action also hashes its quoting (never for a truncated one: a secret's characters stay out of it).
1821
+ const preimage = `${HASH_VERSION}\n${domain}\n${state.truncated || !state.sigs.length ? text : `${text}\n${JSON.stringify(state.sigs)}`}`;
1822
+ return {
1823
+ action: withExactness({
1824
+ tool_kind: 'shell',
1825
+ tool_name: hostToolName,
1826
+ programs,
1827
+ tool_input: {
1828
+ command_sha256: sha256(preimage),
1829
+ ...(deployText !== null && deployText === text ? { command: deployText } : {}),
1830
+ },
1831
+ }, state),
1832
+ hashField: 'command_sha256',
1833
+ preimage,
1834
+ // Only the grammar-bound deploy text is sent in clear, so only its hash may stay plain (the service binds a deploy
1835
+ // word for word). The run options in the preimage must be numbers or booleans: any other value takes the keyed form.
1836
+ plain: deployText !== null && deployText === text && !state.truncated && runOptionsScalar(event),
1837
+ };
1637
1838
  }
1638
1839
  if (kind === 'edit') {
1639
1840
  const targets = (0, hook_tool_policy_1.toolTargetPaths)(event);
1640
1841
  if (targets.length > 64)
1641
1842
  state.truncated = true; // exact: n/a (paths are not exact material; the whole input is)
1642
1843
  const paths = targets.slice(0, 64).map((path) => {
1643
- const redacted = redactText(withoutUserName(path, state), state);
1644
- if (redacted.length > 512 || redacted.includes(SECRET))
1844
+ const redacted = redactPathSpans(redactText(withoutUserName(path, state), state));
1845
+ if (redacted.length > 512 || redacted.includes(SECRET) || path.length > PATH_SCAN_CHARS)
1645
1846
  state.truncated = true; // exact: n/a (as above)
1646
1847
  return redacted.slice(0, 512);
1647
1848
  });
1648
- return withExactness({ tool_kind: 'edit', tool_name: hostToolName, paths, tool_input: inputHash(event.tool_input, state) }, state);
1849
+ return inputAction({ tool_kind: 'edit', tool_name: hostToolName, paths }, event.tool_input, state);
1649
1850
  }
1650
- return withExactness({ tool_kind: 'other', tool_name: hostToolName, tool_input: inputHash(event.tool_input, state) }, state);
1851
+ return inputAction({ tool_kind: 'other', tool_name: hostToolName }, event.tool_input, state);
1651
1852
  }
1652
1853
  //# sourceMappingURL=normalized-action.js.map