@getmarrow/mcp 3.9.103 → 3.9.107

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 (92) hide show
  1. package/README.md +43 -6
  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 +1677 -1636
  7. package/dist/cli.js.map +1 -1
  8. package/dist/codex-plugin-pins.d.ts.map +1 -1
  9. package/dist/codex-plugin-pins.js +18 -0
  10. package/dist/codex-plugin-pins.js.map +1 -1
  11. package/dist/env.d.ts +8 -0
  12. package/dist/env.d.ts.map +1 -1
  13. package/dist/env.js +83 -4
  14. package/dist/env.js.map +1 -1
  15. package/dist/hook-context.d.ts.map +1 -1
  16. package/dist/hook-context.js +25 -0
  17. package/dist/hook-context.js.map +1 -1
  18. package/dist/hook-contract.d.ts +4 -3
  19. package/dist/hook-contract.d.ts.map +1 -1
  20. package/dist/hook-contract.js +43 -6
  21. package/dist/hook-contract.js.map +1 -1
  22. package/dist/hook-pre-action.d.ts +43 -7
  23. package/dist/hook-pre-action.d.ts.map +1 -1
  24. package/dist/hook-pre-action.js +518 -21
  25. package/dist/hook-pre-action.js.map +1 -1
  26. package/dist/hook-tool-policy.d.ts +86 -4
  27. package/dist/hook-tool-policy.d.ts.map +1 -1
  28. package/dist/hook-tool-policy.js +354 -21
  29. package/dist/hook-tool-policy.js.map +1 -1
  30. package/dist/hook.d.ts.map +1 -1
  31. package/dist/hook.js +5 -3
  32. package/dist/hook.js.map +1 -1
  33. package/dist/host-approval-state.d.ts +24 -0
  34. package/dist/host-approval-state.d.ts.map +1 -1
  35. package/dist/host-approval-state.js +68 -0
  36. package/dist/host-approval-state.js.map +1 -1
  37. package/dist/host-approval.d.ts +22 -1
  38. package/dist/host-approval.d.ts.map +1 -1
  39. package/dist/host-approval.js +65 -4
  40. package/dist/host-approval.js.map +1 -1
  41. package/dist/index.d.ts +5 -1
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +8 -1
  44. package/dist/index.js.map +1 -1
  45. package/dist/keyless-server.d.ts +53 -0
  46. package/dist/keyless-server.d.ts.map +1 -0
  47. package/dist/keyless-server.js +230 -0
  48. package/dist/keyless-server.js.map +1 -0
  49. package/dist/normalized-action.d.ts +11 -3
  50. package/dist/normalized-action.d.ts.map +1 -1
  51. package/dist/normalized-action.js +305 -49
  52. package/dist/normalized-action.js.map +1 -1
  53. package/dist/own-server-identity.d.ts +22 -3
  54. package/dist/own-server-identity.d.ts.map +1 -1
  55. package/dist/own-server-identity.js +108 -31
  56. package/dist/own-server-identity.js.map +1 -1
  57. package/dist/own-tool.d.ts +14 -6
  58. package/dist/own-tool.d.ts.map +1 -1
  59. package/dist/own-tool.js +18 -7
  60. package/dist/own-tool.js.map +1 -1
  61. package/dist/seatbelt/env-dump.d.ts +66 -0
  62. package/dist/seatbelt/env-dump.d.ts.map +1 -0
  63. package/dist/seatbelt/env-dump.js +333 -0
  64. package/dist/seatbelt/env-dump.js.map +1 -0
  65. package/dist/seatbelt/env-names.d.ts +22 -0
  66. package/dist/seatbelt/env-names.d.ts.map +1 -0
  67. package/dist/seatbelt/env-names.js +298 -0
  68. package/dist/seatbelt/env-names.js.map +1 -0
  69. package/dist/seatbelt/fallback-copy.d.ts +16 -0
  70. package/dist/seatbelt/fallback-copy.d.ts.map +1 -0
  71. package/dist/seatbelt/fallback-copy.js +68 -0
  72. package/dist/seatbelt/fallback-copy.js.map +1 -0
  73. package/dist/seatbelt/hook.d.ts +21 -2
  74. package/dist/seatbelt/hook.d.ts.map +1 -1
  75. package/dist/seatbelt/hook.js +45 -11
  76. package/dist/seatbelt/hook.js.map +1 -1
  77. package/dist/seatbelt/mapper.d.ts.map +1 -1
  78. package/dist/seatbelt/mapper.js +1592 -85
  79. package/dist/seatbelt/mapper.js.map +1 -1
  80. package/dist/seatbelt/tables.d.ts +1 -1
  81. package/dist/seatbelt/tables.d.ts.map +1 -1
  82. package/dist/seatbelt/tables.js +13 -17
  83. package/dist/seatbelt/tables.js.map +1 -1
  84. package/dist/secret-paths.d.ts +40 -0
  85. package/dist/secret-paths.d.ts.map +1 -0
  86. package/dist/secret-paths.js +59 -0
  87. package/dist/secret-paths.js.map +1 -0
  88. package/dist/session-loop-guard.d.ts.map +1 -1
  89. package/dist/session-loop-guard.js +25 -6
  90. package/dist/session-loop-guard.js.map +1 -1
  91. package/package.json +2 -2
  92. package/server.json +2 -2
@@ -1,10 +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
+ exports.shellToolRunOptionsKey = shellToolRunOptionsKey;
9
+ exports.keyedTargetHash = keyedTargetHash;
8
10
  exports.normalizedHookAction = normalizedHookAction;
9
11
  const node_crypto_1 = require("node:crypto");
10
12
  const node_os_1 = require("node:os");
@@ -28,7 +30,7 @@ const DEFAULT_NORMALIZE_MS = 1_000;
28
30
  class NormalizationLimit extends Error {
29
31
  }
30
32
  function newExactness(deadline = Date.now() + DEFAULT_NORMALIZE_MS, exact = false) {
31
- return { truncated: false, budget: { scanned: 0, deadline }, exact, sigs: [] };
33
+ return { truncated: false, budget: { scanned: 0, deadline }, exact, sigs: [], freeText: false };
32
34
  }
33
35
  /**
34
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
@@ -50,7 +52,16 @@ function sha256(text) {
50
52
  // ---------------------------------------------------------------------------
51
53
  // Key-shaped values
52
54
  // ---------------------------------------------------------------------------
53
- 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;
54
65
  const PEM = /-----BEGIN [A-Z ]*(?:PRIVATE KEY|CERTIFICATE)-----/;
55
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. */
56
67
  const PUBLIC_KNOWN_KEY = /^(?:AKIA|ASIA|AGPA|AIDA|AROA)[0-9A-Z]{12,}$|^pk[-_]/;
@@ -59,7 +70,7 @@ function looksLikeKey(value) {
59
70
  const text = value.trim();
60
71
  if (!text)
61
72
  return false;
62
- if (KNOWN_KEY_PREFIX.test(text))
73
+ if (exports.KNOWN_KEY_PREFIX.test(text))
63
74
  return true;
64
75
  if (PEM.test(text))
65
76
  return true;
@@ -81,7 +92,7 @@ const KEY_SPLIT = /([\s=:,;@/?&'"`()[\]{}<>|!*$])/;
81
92
  function redactKeyShapes(text, state) {
82
93
  const whole = text.trim();
83
94
  if (whole && !/\s/.test(whole) && looksLikeKey(whole)) {
84
- if (!KNOWN_KEY_PREFIX.test(whole) && !PEM.test(whole))
95
+ if (!exports.KNOWN_KEY_PREFIX.test(whole) && !PEM.test(whole))
85
96
  return withheld(state, text);
86
97
  return state.exact && PUBLIC_KNOWN_KEY.test(whole) ? text : SECRET;
87
98
  }
@@ -92,7 +103,7 @@ function redactKeyShapes(text, state) {
92
103
  return text.split(KEY_SPLIT).map((part) => {
93
104
  if (!looksLikeKey(part))
94
105
  return part;
95
- if (!KNOWN_KEY_PREFIX.test(part))
106
+ if (!exports.KNOWN_KEY_PREFIX.test(part))
96
107
  return withheld(state, part);
97
108
  return state.exact && PUBLIC_KNOWN_KEY.test(part) ? part : SECRET;
98
109
  }).join('');
@@ -333,7 +344,7 @@ function dataMarker(text, feedsSecrets, state, words = null) {
333
344
  // The exact pass: only the certain credentials inside the data are replaced and the rest is hashed, also for
334
345
  // data a secrets command or file reads (a vault policy, an authorized_keys line, a kubeconfig differ outside
335
346
  // any secret), so data that differs outside a certain credential never shares exact material.
336
- const probe = { truncated: false, budget: state.budget, exact: true, sigs: [] };
347
+ const probe = { truncated: false, budget: state.budget, exact: true, sigs: [], freeText: false };
337
348
  return `[data:${sha256(`${HASH_VERSION}\nexact-data\n${redactText(text, probe)}`)}]`;
338
349
  }
339
350
  if (feedsSecrets) {
@@ -344,7 +355,7 @@ function dataMarker(text, feedsSecrets, state, words = null) {
344
355
  // replaced and the rest is hashed, so data that differs outside a secret gives
345
356
  // a different key, data that differs only in a secret the same one. Withheld:
346
357
  // the action is truncated and never binds an approval.
347
- const probe = { truncated: false, budget: state.budget, exact: false, sigs: [] };
358
+ const probe = { truncated: false, budget: state.budget, exact: false, sigs: [], freeText: false };
348
359
  const redacted = redactText(text, probe);
349
360
  if (redacted !== text) {
350
361
  state.truncated = true; // exact: kept (hashed above, certain credentials replaced)
@@ -361,7 +372,7 @@ function dataMarker(text, feedsSecrets, state, words = null) {
361
372
  */
362
373
  function printedData(words, feedsSecrets, state) {
363
374
  if (state.exact) {
364
- const probe = { truncated: false, budget: state.budget, exact: true, sigs: [] };
375
+ const probe = { truncated: false, budget: state.budget, exact: true, sigs: [], freeText: false };
365
376
  return `[data:${sha256(`${HASH_VERSION}\nexact-words\n${JSON.stringify(words.map((word) => redactText(word, probe)))}`)}]`;
366
377
  }
367
378
  return dataMarker(words.join(' '), feedsSecrets, state, words);
@@ -1039,6 +1050,93 @@ function quoteSignature(command) {
1039
1050
  }
1040
1051
  return parts.join('');
1041
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
+ }
1042
1140
  function normalizeInner(command, state, depth) {
1043
1141
  if (command.length > MAX_COMMAND_CHARS)
1044
1142
  throw new NormalizationLimit();
@@ -1104,9 +1202,24 @@ function normalizeInner(command, state, depth) {
1104
1202
  depth,
1105
1203
  });
1106
1204
  words.push(...result.words.map(quoteWord));
1107
- if (result.program && /^[a-z0-9][a-z0-9._+-]{0,63}$/.test(result.program) && !looksLikeKey(result.program) && !programs.includes(result.program)) {
1108
- 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)
1109
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);
1110
1223
  }
1111
1224
  const op = segment.op;
1112
1225
  if (op && (op !== ';' || (words.length && words[words.length - 1] !== ';')))
@@ -1194,8 +1307,9 @@ function redactedInput(value, state, depth = 0) {
1194
1307
  function untrimmedShellText(event, trimmed) {
1195
1308
  const input = event.tool_input && typeof event.tool_input === 'object' && !Array.isArray(event.tool_input)
1196
1309
  ? event.tool_input : null;
1197
- const host = typeof event.tool_input === 'string' ? event.tool_input
1198
- : typeof input?.command === 'string' ? input.command : typeof input?.cmd === 'string' ? input.cmd : null;
1310
+ const host = (0, hook_tool_policy_1.shellCommandToolText)(event)
1311
+ ?? (typeof event.tool_input === 'string' ? event.tool_input
1312
+ : typeof input?.command === 'string' ? input.command : typeof input?.cmd === 'string' ? input.cmd : null);
1199
1313
  if (host === null || host === trimmed)
1200
1314
  return null;
1201
1315
  const at = host.indexOf(trimmed);
@@ -1210,6 +1324,10 @@ function untrimmedShellText(event, trimmed) {
1210
1324
  * would carry, so both give the same normalized action.
1211
1325
  */
1212
1326
  function shellCommand(event) {
1327
+ // A plain Monitor or PowerShell call: its one command or script text, read as Bash's command.
1328
+ const toolText = (0, hook_tool_policy_1.shellCommandToolText)(event);
1329
+ if (toolText !== null)
1330
+ return toolText.trim();
1213
1331
  const input = event.tool_input && typeof event.tool_input === 'object' && !Array.isArray(event.tool_input)
1214
1332
  ? event.tool_input : null;
1215
1333
  const argv = Array.isArray(input?.command) ? input.command : Array.isArray(input?.cmd) ? input.cmd : null;
@@ -1237,6 +1355,18 @@ function withoutUserName(path, state) {
1237
1355
  state.truncated = true; // exact: n/a (paths are not exact material; the whole input is)
1238
1356
  return `${other[1]}/[user]${path.slice(other[0].length)}`;
1239
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
+ }
1240
1370
  // ---------------------------------------------------------------------------
1241
1371
  // Deploy command text (the only command text ever sent)
1242
1372
  // ---------------------------------------------------------------------------
@@ -1321,13 +1451,45 @@ function deployCommandText(command) {
1321
1451
  function normalizedWords(_toolWords, trimmed) {
1322
1452
  return trimmed.split(/[ \t]+/).join(' ');
1323
1453
  }
1324
- function inputHash(input, state) {
1454
+ /** What `input_sha256` hashes (null: no input). Sets `truncated` when a credential was replaced. */
1455
+ function inputPreimage(input, state) {
1325
1456
  if (input === undefined || input === null)
1326
- return {};
1457
+ return null;
1327
1458
  const text = JSON.stringify(sortedValue(redactedInput(input, state)));
1328
1459
  if (text.includes(SECRET))
1329
1460
  state.truncated = true; // exact: n/a (pass 1 only; exactMaterial is the exact form)
1330
- 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)));
1471
+ }
1472
+ /**
1473
+ * The run options of a Monitor or PowerShell call (timeout, timeout_ms, run_in_background) as key material:
1474
+ * the hash of their canonical JSON with credentials replaced, as any tool input is hashed (so a replaced
1475
+ * credential marks the action truncated here too), or null when the call sets none (and for any other tool).
1476
+ * They decide how long and where the command runs, so an approval of one set is not an approval of another;
1477
+ * `description` is not key material.
1478
+ */
1479
+ function runOptionsKey(event, state) {
1480
+ const options = (0, hook_tool_policy_1.shellCommandToolRunOptions)(event);
1481
+ if (options === null)
1482
+ return null;
1483
+ return inputHash(options, state).input_sha256;
1484
+ }
1485
+ /** runOptionsKey as a standalone identity (no deadline), for the exact target of a call (hook-contract exactHookTarget). */
1486
+ function shellToolRunOptionsKey(event) {
1487
+ return runOptionsKey(event, newExactness(Number.POSITIVE_INFINITY));
1488
+ }
1489
+ /** The exact form of the run options (only certain credentials replaced), for the exact material of a truncated action. */
1490
+ function exactRunOptions(event, state) {
1491
+ const options = (0, hook_tool_policy_1.shellCommandToolRunOptions)(event);
1492
+ return options === null ? null : JSON.stringify(sortedValue(redactedInput(options, state)));
1331
1493
  }
1332
1494
  function withExactness(action, state) {
1333
1495
  return state.truncated ? { ...action, truncated: true } : action;
@@ -1335,10 +1497,14 @@ function withExactness(action, state) {
1335
1497
  // ---------------------------------------------------------------------------
1336
1498
  // Exact material for a truncated action (exact_hmac; M2)
1337
1499
  // ---------------------------------------------------------------------------
1338
- 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';
1339
1505
  /** Raw canonical input past this is never keyed: a nonce instead (it matches nothing). */
1340
1506
  const MAX_RAW_EXACT_CHARS = 1_048_576;
1341
- 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.';
1342
1508
  /** Canonical JSON (keys sorted at every depth), or null past 1 MB, past 64 levels, or when it cannot be serialized. */
1343
1509
  function canonicalJson(value) {
1344
1510
  const parts = [];
@@ -1433,13 +1599,22 @@ function shellReferences(command) {
1433
1599
  function exactShellForm(text, command, state) {
1434
1600
  return `${text}\n${JSON.stringify(shellReferences(command))}\n${JSON.stringify(state.sigs)}`;
1435
1601
  }
1602
+ /** The raw shell text as material; a plain Monitor or PowerShell call also carries its run options (raw). */
1603
+ function rawShellMaterial(event, command) {
1604
+ const options = (0, hook_tool_policy_1.shellCommandToolText)(event) === null
1605
+ ? null : (0, hook_tool_policy_1.shellCommandToolRunOptions)(event);
1606
+ if (options === null)
1607
+ return `raw-shell\n${command}`;
1608
+ const json = canonicalJson(options);
1609
+ return json === null ? null : `raw-shell+run\n${json}\n${command}`;
1610
+ }
1436
1611
  /** The raw input as material (a placeholder, or an exact pass that hit a limit): the command, or the canonical input. */
1437
1612
  function rawExactMaterial(event, kind) {
1438
1613
  if (kind === 'shell') {
1439
1614
  try {
1440
1615
  const trimmed = shellCommand(event);
1441
1616
  const command = untrimmedShellText(event, trimmed) ?? trimmed;
1442
- return typeof command === 'string' && command.length <= MAX_RAW_EXACT_CHARS ? `raw-shell\n${command}` : null;
1617
+ return typeof command === 'string' && command.length <= MAX_RAW_EXACT_CHARS ? rawShellMaterial(event, command) : null;
1443
1618
  }
1444
1619
  catch {
1445
1620
  return null;
@@ -1460,10 +1635,14 @@ function exactMaterial(event, kind, deadline) {
1460
1635
  if (kind === 'shell') {
1461
1636
  const trimmed = shellCommand(event);
1462
1637
  const host = untrimmedShellText(event, trimmed);
1463
- if (host !== null)
1464
- return bounded(`raw-shell\n${host}`);
1638
+ if (host !== null) {
1639
+ const raw = rawShellMaterial(event, host);
1640
+ return raw === null ? null : bounded(raw);
1641
+ }
1465
1642
  const { text } = normalizeInner(trimmed, state, 0);
1466
- return bounded(`exact-shell\n${exactShellForm(text, trimmed, state)}`);
1643
+ const runOptions = (0, hook_tool_policy_1.shellCommandToolText)(event) !== null
1644
+ ? exactRunOptions(event, newExactness(deadline, true)) : null;
1645
+ return bounded(`exact-shell${runOptions === null ? '' : `+run\n${runOptions}`}\n${exactShellForm(text, trimmed, state)}`);
1467
1646
  }
1468
1647
  const input = event.tool_input;
1469
1648
  const text = input === undefined || input === null ? 'null' : JSON.stringify(sortedValue(redactedInput(input, state)));
@@ -1474,7 +1653,11 @@ function exactMaterial(event, kind, deadline) {
1474
1653
  }
1475
1654
  }
1476
1655
  let saltNoted = false;
1477
- /** 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
+ */
1478
1661
  function exactSalt() {
1479
1662
  let salt = null;
1480
1663
  try {
@@ -1483,6 +1666,14 @@ function exactSalt() {
1483
1666
  catch {
1484
1667
  salt = null;
1485
1668
  }
1669
+ if (!salt) {
1670
+ try {
1671
+ salt = require('./host-approval-state').nokeyFallbackKey();
1672
+ }
1673
+ catch {
1674
+ salt = null;
1675
+ }
1676
+ }
1486
1677
  if (!salt && !saltNoted) {
1487
1678
  saltNoted = true;
1488
1679
  try {
@@ -1501,6 +1692,31 @@ function exactHmac(salt, kind, toolName, material) {
1501
1692
  return (0, node_crypto_1.randomBytes)(16).toString('hex');
1502
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);
1503
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
+ }
1504
1720
  /**
1505
1721
  * The normalized action of a hook event. `deadlineAt` bounds the time it may
1506
1722
  * take (default one second from now): past it, or past the size limits, the
@@ -1514,9 +1730,9 @@ function exactHmac(salt, kind, toolName, material) {
1514
1730
  * credentials replaced, so two actions that differ anywhere else never share a
1515
1731
  * correlation, hold or key, and a retry that differs only in a certain
1516
1732
  * credential (an OTP) still does. A placeholder keys the raw input (up to
1517
- * 1 MB). Input a host adapter cut or dropped, input over 1 MB and a missing or
1518
- * unsafe salt give a random nonce: the action matches nothing (it is asked
1519
- * 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).
1520
1736
  */
1521
1737
  function normalizedHookAction(event, options = {}) {
1522
1738
  const deadline = options.deadlineAt ?? Date.now() + DEFAULT_NORMALIZE_MS;
@@ -1528,17 +1744,24 @@ function normalizedHookAction(event, options = {}) {
1528
1744
  const hostToolName = rawToolName.slice(0, 128);
1529
1745
  if (rawToolName.length > 128)
1530
1746
  state.truncated = true; // exact: kept (the full name is in exact_hmac)
1747
+ // Claude Code built-ins first hooked in 3.9.105 that are not plain shell calls (a native git
1748
+ // equivalent, Skill, a call whose words are not actions, an irregular Monitor or PowerShell call)
1749
+ // are keyed by their whole input, never by one field of it.
1531
1750
  const kind = (0, hook_tool_policy_1.isMcpHookTool)(event.tool_name) || /^MCP:/i.test(hostToolName) ? 'mcp'
1532
- : (0, hook_tool_policy_1.isShellGovernedTool)(event) ? 'shell'
1533
- : EDIT_TOOLS.test(toolName) ? 'edit'
1534
- : 'other';
1751
+ : (0, hook_tool_policy_1.isWholeInputNativeTool)(event) ? 'other'
1752
+ : (0, hook_tool_policy_1.isShellGovernedTool)(event) ? 'shell'
1753
+ : EDIT_TOOLS.test(toolName) ? 'edit'
1754
+ : 'other';
1535
1755
  const inputCut = event.input_truncated === true;
1536
1756
  if (inputCut)
1537
1757
  state.truncated = true; // exact: nonce (the cut or dropped input is all that is left)
1538
1758
  let action;
1759
+ let preimage = null;
1760
+ let hashField = 'input_sha256';
1761
+ let plain = false;
1539
1762
  let placeholder = false;
1540
1763
  try {
1541
- action = normalizedOfKind(event, kind, hostToolName, state);
1764
+ ({ action, preimage, hashField, plain } = normalizedOfKind(event, kind, hostToolName, state));
1542
1765
  }
1543
1766
  catch (error) {
1544
1767
  if (!(error instanceof NormalizationLimit))
@@ -1546,20 +1769,35 @@ function normalizedHookAction(event, options = {}) {
1546
1769
  action = { tool_kind: kind, tool_name: hostToolName, tool_input: {}, truncated: true };
1547
1770
  placeholder = true;
1548
1771
  }
1549
- 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))
1550
1775
  return action;
1551
- const material = inputCut ? null : placeholder ? rawExactMaterial(event, kind) : exactMaterial(event, kind, deadline);
1552
- const salt = options.salt !== undefined ? options.salt : material === null ? null : exactSalt();
1553
- 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 };
1554
1791
  }
1555
1792
  function normalizedOfKind(event, kind, hostToolName, state) {
1556
1793
  if (kind === 'mcp') {
1557
- 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);
1558
1795
  }
1559
1796
  if (kind === 'shell') {
1560
1797
  const raw = shellCommand(event);
1561
1798
  if (untrimmedShellText(event, raw) !== null)
1562
1799
  state.truncated = true; // exact: raw (the host's untrimmed text)
1800
+ state.freeText = !(0, hook_tool_policy_1.isShellTextTool)(event);
1563
1801
  const { text, programs } = normalizeInner(raw, state, 0);
1564
1802
  // A secret was replaced: the action is never bound or reused (it asks each time).
1565
1803
  if (text.includes(SECRET))
@@ -1569,29 +1807,47 @@ function normalizedOfKind(event, kind, hostToolName, state) {
1569
1807
  const input = event.tool_input && typeof event.tool_input === 'object' && !Array.isArray(event.tool_input) ? event.tool_input : null;
1570
1808
  const exact = typeof input?.command === 'string' ? input.command : typeof input?.cmd === 'string' ? input.cmd : raw;
1571
1809
  const deployText = state.truncated ? null : deployCommandText(exact);
1572
- return withExactness({
1573
- tool_kind: 'shell',
1574
- tool_name: hostToolName,
1575
- programs,
1576
- tool_input: {
1577
- // An exact action also hashes its quoting (never for a truncated one: a secret's characters stay out of it).
1578
- command_sha256: sha256(`${HASH_VERSION}\nshell\n${state.truncated || !state.sigs.length ? text : `${text}\n${JSON.stringify(state.sigs)}`}`),
1579
- ...(deployText !== null && deployText === text ? { command: deployText } : {}),
1580
- },
1581
- }, state);
1810
+ // A Monitor or PowerShell command carries its host tool name, so it never shares a hash with the
1811
+ // same Bash command (or another tool's): the line after the version is `shell` only for every
1812
+ // other shell call, whose hash is unchanged.
1813
+ // Its run options (timeout, timeout_ms, run_in_background), when it sets any, are in the hash too: a
1814
+ // different line after `shell-tool+run`, so no command text can stand for them.
1815
+ const plainShellTool = (0, hook_tool_policy_1.shellCommandToolText)(event) !== null;
1816
+ const runOptions = plainShellTool ? runOptionsKey(event, state) : null;
1817
+ const domain = !plainShellTool ? 'shell'
1818
+ : runOptions === null ? `shell-tool\n${JSON.stringify(hostToolName)}`
1819
+ : `shell-tool+run\n${JSON.stringify(hostToolName)}\n${runOptions}`;
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
+ };
1582
1838
  }
1583
1839
  if (kind === 'edit') {
1584
1840
  const targets = (0, hook_tool_policy_1.toolTargetPaths)(event);
1585
1841
  if (targets.length > 64)
1586
1842
  state.truncated = true; // exact: n/a (paths are not exact material; the whole input is)
1587
1843
  const paths = targets.slice(0, 64).map((path) => {
1588
- const redacted = redactText(withoutUserName(path, state), state);
1589
- 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)
1590
1846
  state.truncated = true; // exact: n/a (as above)
1591
1847
  return redacted.slice(0, 512);
1592
1848
  });
1593
- 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);
1594
1850
  }
1595
- 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);
1596
1852
  }
1597
1853
  //# sourceMappingURL=normalized-action.js.map