@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.
- package/README.md +25 -3
- package/dist/codex-plugin-pins.js +12 -12
- package/dist/hook-contract.d.ts +2 -2
- package/dist/hook-contract.d.ts.map +1 -1
- package/dist/hook-contract.js +8 -4
- package/dist/hook-contract.js.map +1 -1
- package/dist/hook-pre-action.d.ts +6 -1
- package/dist/hook-pre-action.d.ts.map +1 -1
- package/dist/hook-pre-action.js +276 -12
- package/dist/hook-pre-action.js.map +1 -1
- package/dist/hook-tool-policy.d.ts +13 -0
- package/dist/hook-tool-policy.d.ts.map +1 -1
- package/dist/hook-tool-policy.js +68 -4
- package/dist/hook-tool-policy.js.map +1 -1
- package/dist/host-approval-state.d.ts +17 -0
- package/dist/host-approval-state.d.ts.map +1 -1
- package/dist/host-approval-state.js +53 -0
- package/dist/host-approval-state.js.map +1 -1
- package/dist/normalized-action.d.ts +9 -3
- package/dist/normalized-action.d.ts.map +1 -1
- package/dist/normalized-action.js +241 -40
- package/dist/normalized-action.js.map +1 -1
- package/dist/own-server-identity.d.ts.map +1 -1
- package/dist/own-server-identity.js +10 -4
- package/dist/own-server-identity.js.map +1 -1
- package/dist/seatbelt/env-dump.d.ts +22 -1
- package/dist/seatbelt/env-dump.d.ts.map +1 -1
- package/dist/seatbelt/env-dump.js +27 -2
- package/dist/seatbelt/env-dump.js.map +1 -1
- package/dist/seatbelt/env-names.d.ts +22 -0
- package/dist/seatbelt/env-names.d.ts.map +1 -0
- package/dist/seatbelt/env-names.js +298 -0
- package/dist/seatbelt/env-names.js.map +1 -0
- package/dist/seatbelt/mapper.d.ts.map +1 -1
- package/dist/seatbelt/mapper.js +1247 -73
- package/dist/seatbelt/mapper.js.map +1 -1
- package/dist/seatbelt/tables.d.ts.map +1 -1
- package/dist/seatbelt/tables.js +4 -1
- package/dist/seatbelt/tables.js.map +1 -1
- package/dist/secret-paths.d.ts +23 -0
- package/dist/secret-paths.d.ts.map +1 -1
- package/dist/secret-paths.js +29 -2
- package/dist/secret-paths.js.map +1 -1
- package/dist/session-loop-guard.d.ts.map +1 -1
- package/dist/session-loop-guard.js +10 -1
- package/dist/session-loop-guard.js.map +1 -1
- package/package.json +1 -1
- 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
|
-
|
|
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
|
-
|
|
1109
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
1559
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
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
|
|
1849
|
+
return inputAction({ tool_kind: 'edit', tool_name: hostToolName, paths }, event.tool_input, state);
|
|
1649
1850
|
}
|
|
1650
|
-
return
|
|
1851
|
+
return inputAction({ tool_kind: 'other', tool_name: hostToolName }, event.tool_input, state);
|
|
1651
1852
|
}
|
|
1652
1853
|
//# sourceMappingURL=normalized-action.js.map
|