@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,26 +1,43 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.isOfficialMarrowMcpTool = exports.isOfficialMarrowMcpEvent = void 0;
3
+ exports.SHELL_DIALECT = exports.isOfficialMarrowMcpTool = exports.isOfficialMarrowMcpEvent = void 0;
4
4
  exports.normalizeHookToolName = normalizeHookToolName;
5
5
  exports.isMcpHookTool = isMcpHookTool;
6
6
  exports.isProtectedShellMutation = isProtectedShellMutation;
7
7
  exports.hookToolCommand = hookToolCommand;
8
8
  exports.parseShellSegments = parseShellSegments;
9
9
  exports.normalizedToolPath = normalizedToolPath;
10
+ exports.secretPatternRegex = secretPatternRegex;
10
11
  exports.isSecretPath = isSecretPath;
11
12
  exports.isProtectedWriteTarget = isProtectedWriteTarget;
12
13
  exports.isSecretVariableName = isSecretVariableName;
13
14
  exports.shellSegmentVerdict = shellSegmentVerdict;
15
+ exports.policyReadsAllWords = policyReadsAllWords;
14
16
  exports.toolTargetPaths = toolTargetPaths;
15
17
  exports.isSecretMaterialAccess = isSecretMaterialAccess;
18
+ exports.isSecretMaterialAccessAsNamed = isSecretMaterialAccessAsNamed;
16
19
  exports.toolClassificationText = toolClassificationText;
20
+ exports.isShellTextTool = isShellTextTool;
17
21
  exports.isShellGovernedTool = isShellGovernedTool;
18
22
  exports.isReadOnlyToolEvent = isReadOnlyToolEvent;
23
+ exports.isReadOnlyToolEventAsNamed = isReadOnlyToolEventAsNamed;
24
+ exports.toolPolicyReading = toolPolicyReading;
25
+ exports.readEquivalent = readEquivalent;
26
+ exports.shellCommandToolText = shellCommandToolText;
27
+ exports.shellCommandToolRunOptions = shellCommandToolRunOptions;
28
+ exports.isWholeInputNativeTool = isWholeInputNativeTool;
29
+ const node_fs_1 = require("node:fs");
19
30
  const node_os_1 = require("node:os");
20
31
  const node_path_1 = require("node:path");
21
32
  const own_tool_1 = require("./own-tool");
22
33
  Object.defineProperty(exports, "isOfficialMarrowMcpEvent", { enumerable: true, get: function () { return own_tool_1.isOfficialMarrowMcpEvent; } });
23
34
  Object.defineProperty(exports, "isOfficialMarrowMcpTool", { enumerable: true, get: function () { return own_tool_1.isOfficialMarrowMcpTool; } });
35
+ const secret_paths_1 = require("./secret-paths");
36
+ /**
37
+ * Marks the Bash twin of a PowerShell call (toolPolicyReading): its text is PowerShell, not Bash. A symbol,
38
+ * so no field of a host event or of a tool input (JSON has no symbols) can set or clear it.
39
+ */
40
+ exports.SHELL_DIALECT = Symbol('marrow.shellDialect');
24
41
  const READ_ONLY_TOOLS = new Set([
25
42
  'read',
26
43
  'read_file',
@@ -83,7 +100,9 @@ const SECRET_STORE_DIRECTORY = /^(?:\.ssh|\.gnupg|\.aws|\.azure|\.kube|\.docker|
83
100
  // A plainly named secrets/credentials directory holds secret material, but source code may live in one too.
84
101
  const NAMED_SECRET_DIRECTORY = /^(?:secrets?|credentials?)$/i;
85
102
  const SECRET_FILE = /^(?:\.env(?:\..+)?|.+\.env|\.dev\.vars|\.git-credentials|\.netrc|_netrc|\.npmrc|\.yarnrc\.ya?ml|\.pypirc|\.pgpass|\.my\.cnf|\.htpasswd|\.?credentials\.(?:json|ya?ml|toml|ini|csv|txt|xml)|secrets?\.(?:json|ya?ml|toml|env|txt)|id_(?:rsa|dsa|ecdsa|ed25519)(?:_[A-Za-z0-9]+)?|.+\.(?:pem|key|p12|pfx|jks|keystore|kdbx|ppk))$/i;
86
- const ENV_TEMPLATE_FILE = /(?:^|\.)env\.(?:example|sample|template|dist|defaults?)$/i;
103
+ // One list with the keyless seatbelt (ENV_TEMPLATE_WORDS, src/secret-paths.ts): a `.env` template is `.example`, `.sample` or `.template` and nothing else, so
104
+ // `.env.dist`, `.env.defaults` and `.env.default` are secret files in both modes. A test keeps this list equal to the keyless table's.
105
+ const ENV_TEMPLATE_FILE = new RegExp(`(?:^|\\.)env\\.(?:${secret_paths_1.ENV_TEMPLATE_WORDS.join('|')})$`, 'i');
87
106
  const SECRET_KEYWORD_FILE = /(?:^|[._-])(?:secrets?|credentials?|tokens?|api[_-]?keys?|passwords?|private[_-]?keys?)(?:[._-]|$)/i;
88
107
  const SOURCE_OR_DOC_FILE = /\.(?:[cm]?[jt]sx?|md|mdx|rst|py|rb|go|rs|java|kt|swift|c|cc|cpp|h|hpp|cs|php|sh|bash|zsh|ps1|sql|html|css|scss|vue|svelte|map|snap|lock)$/i;
89
108
  const SECRET_VARIABLE_PART = new Set(['KEY', 'APIKEY', 'TOKEN', 'SECRET', 'PASSWORD', 'PASSWD', 'PAT', 'CREDENTIAL', 'CREDENTIALS', 'AUTH']);
@@ -94,6 +113,9 @@ const MAX_SHELL_ANALYSIS_BYTES = 8192;
94
113
  // or line separators as word characters, so splitting on them could hide one
95
114
  // command inside another; such input takes the conservative whole-command path.
96
115
  const UNMODELED_SHELL_CHARACTER = /[\u0000-\u0008\u000b-\u001f\u007f-\u00a0\u00ad\u061c\u1680\u180e\u2000-\u200f\u2028-\u202f\u205f-\u206f\u3000\ufeff\ufff9-\ufffb]/;
116
+ // PowerShell reads the typographic single and double quotation marks as quotes; Bash does not, so
117
+ // a PowerShell text holding one has quoting the splitter cannot follow.
118
+ const POWERSHELL_QUOTE_CHARACTER = /[\u2018-\u201f]/;
97
119
  // Programs whose options can execute, write, or change what runs; an expanded
98
120
  // variable could supply such an option, so they are not read-only with one.
99
121
  const OPTION_SENSITIVE_PROGRAMS = new Set(['find', 'rg', 'sort', 'git', 'gh', 'date', 'env', 'file', 'npm', 'pnpm', 'yarn', 'node', 'npx',
@@ -186,9 +208,12 @@ function hookToolCommand(event) {
186
208
  * the words alone (command or process substitution, subshells, heredocs,
187
209
  * complex parameter expansion); callers then fall back to whole-command rules.
188
210
  */
189
- function parseShellSegments(command) {
211
+ function parseShellSegments(command, dialect) {
190
212
  if (command.length > MAX_SHELL_ANALYSIS_BYTES || UNMODELED_SHELL_CHARACTER.test(command))
191
213
  return null;
214
+ const powershell = dialect === 'powershell';
215
+ if (powershell && POWERSHELL_QUOTE_CHARACTER.test(command))
216
+ return null;
192
217
  const segments = [];
193
218
  let segment = { words: [], outputs: [], inputs: [], expansions: [] };
194
219
  let word = '';
@@ -271,7 +296,7 @@ function parseShellSegments(command) {
271
296
  }
272
297
  if (inner === '`')
273
298
  return null;
274
- if (inner === '\\' && index + 1 < command.length && '$`"\\\n'.includes(command[index + 1])) {
299
+ if (!powershell && inner === '\\' && index + 1 < command.length && '$`"\\\n'.includes(command[index + 1])) {
275
300
  if (command[index + 1] !== '\n')
276
301
  word += command[index + 1];
277
302
  index += 2;
@@ -291,7 +316,8 @@ function parseShellSegments(command) {
291
316
  return null;
292
317
  continue;
293
318
  }
294
- if (char === '\\') {
319
+ // PowerShell's escape is the backtick (unmodeled); a backslash there is a path separator.
320
+ if (char === '\\' && !powershell) {
295
321
  if (next === undefined)
296
322
  return null;
297
323
  if (next !== '\n') {
@@ -304,6 +330,9 @@ function parseShellSegments(command) {
304
330
  // Braces expand or group commands, so the words would not be what runs.
305
331
  if (char === '`' || char === '(' || char === ')' || char === '{')
306
332
  return null;
333
+ // A PowerShell here-string (@' or @") holds lines of text up to a closing quote at a line start: not words.
334
+ if (powershell && char === '@' && (next === "'" || next === '"'))
335
+ return null;
307
336
  if (char === '$') {
308
337
  const consumed = expansion(index);
309
338
  if (consumed < 0)
@@ -423,7 +452,7 @@ function secretPath(value, base) {
423
452
  return true;
424
453
  if (!SOURCE_OR_DOC_FILE.test(name) && directories.some((part) => NAMED_SECRET_DIRECTORY.test(part)))
425
454
  return true;
426
- if (/^\/proc\/[^/]+\/environ$/.test(path) || /^\/etc\/g?shadow$/.test(path))
455
+ if (secret_paths_1.PROC_ENVIRON.test(path) || /^\/etc\/g?shadow$/.test(path))
427
456
  return true;
428
457
  // Claude Code local settings can carry agent keys in their env block.
429
458
  if (name === 'settings.local.json' && parts[parts.length - 2] === '.claude')
@@ -434,8 +463,53 @@ function secretPath(value, base) {
434
463
  return false;
435
464
  if (SECRET_FILE.test(name))
436
465
  return true;
466
+ // One table: every path the keyless seatbelt stops as a secret (S4) is a secret here too.
467
+ // Only the last 4096 characters (PATH_MAX) are read: the patterns end at `$`, and a two-`**`
468
+ // pattern backtracks quadratically, so an unbounded path could stall the hook past its timeout.
469
+ const tail = path.length > SECRET_PATH_TABLE_CHARS ? path.slice(-SECRET_PATH_TABLE_CHARS) : path;
470
+ if (SEATBELT_SECRET_PATHS.some((pattern) => pattern.test(tail)) || secret_paths_1.APPROVAL_STATE_TREE.test(tail))
471
+ return true;
437
472
  return SECRET_KEYWORD_FILE.test(name) && !SOURCE_OR_DOC_FILE.test(name);
438
473
  }
474
+ /**
475
+ * A SECRET_PATTERNS glob (src/secret-paths.ts) as a regex, the way the vendored classifier's globToRegex reads
476
+ * it (the table holds only `**`/`*` globs, home-agnostic `**`/ prefixes and absolute paths).
477
+ */
478
+ function secretPatternRegex(pattern) {
479
+ let p = pattern;
480
+ let anchored = true;
481
+ if (p.startsWith('**/')) {
482
+ anchored = false;
483
+ p = p.slice(3);
484
+ }
485
+ let out = '';
486
+ for (let i = 0; i < p.length; i += 1) {
487
+ const ch = p[i];
488
+ if (ch === '*' && p[i + 1] === '*') {
489
+ if (p[i + 2] === '/') {
490
+ out += '(?:.*/)?';
491
+ i += 2;
492
+ }
493
+ else if (out.endsWith('/') && i + 2 === p.length) {
494
+ out = `${out.slice(0, -1)}(?:/.*)?`;
495
+ i += 1;
496
+ }
497
+ else {
498
+ out += '.*';
499
+ i += 1;
500
+ }
501
+ }
502
+ else if (ch === '*')
503
+ out += '[^/]*';
504
+ else if (ch === '?')
505
+ out += '[^/]';
506
+ else
507
+ out += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&');
508
+ }
509
+ return new RegExp(`${anchored ? '^' : '(?:^|/)'}${out}$`);
510
+ }
511
+ const SEATBELT_SECRET_PATHS = secret_paths_1.SECRET_PATTERNS.map(secretPatternRegex);
512
+ const SECRET_PATH_TABLE_CHARS = 4096;
439
513
  // A glob such as .env* or *.pem names secret files without spelling one out.
440
514
  function globVariants(value) {
441
515
  if (!/[*?[]/.test(value))
@@ -447,6 +521,37 @@ function globVariants(value) {
447
521
  function isSecretPath(value, base = process.cwd()) {
448
522
  return pathCandidates(String(value || '')).flatMap(globVariants).some((candidate) => secretPath(candidate, base));
449
523
  }
524
+ /** The host-approvals folder, or ~/.marrow that holds it: a search tool that walks either reads the state file. */
525
+ function isApprovalStateFolder(value, base = process.cwd()) {
526
+ const folder = node_path_1.posix.join(homeDirectory(), '.marrow', 'host-approvals').toLowerCase();
527
+ return pathCandidates(String(value || '')).some((candidate) => {
528
+ const walked = normalizedToolPath(candidate, base).replace(/\/+$/, '').toLowerCase();
529
+ return walked === folder || walked === node_path_1.posix.dirname(folder);
530
+ });
531
+ }
532
+ /** A process folder (`/proc`, `/proc/self/task`): a search tool that walks it reads the `environ` of every process under it. */
533
+ function isProcessFolder(value, base = process.cwd()) {
534
+ return pathCandidates(String(value || '')).some((candidate) => secret_paths_1.PROC_PROCESS_TREE.test(normalizedToolPath(candidate, base)));
535
+ }
536
+ /**
537
+ * True when a path the tool names is a symbolic link, or goes through one, to a secret file (or, for a write, to a protected one): a read or an edit
538
+ * through it is of the file it points at, whatever the link is called. Only a path that exists and resolves elsewhere is read; nothing else changes.
539
+ */
540
+ function secretThroughLink(value, write, base = process.cwd()) {
541
+ return pathCandidates(String(value || '')).some((candidate) => {
542
+ let real;
543
+ try {
544
+ const lexical = normalizedToolPath(candidate, base);
545
+ real = (0, node_fs_1.realpathSync)(lexical);
546
+ if (real === lexical)
547
+ return false;
548
+ }
549
+ catch {
550
+ return false;
551
+ }
552
+ return write ? isProtectedWriteTarget(real) : isSecretPath(real);
553
+ });
554
+ }
450
555
  /** True for a secret file or Marrow's own local control state. */
451
556
  function isProtectedWriteTarget(value, base = process.cwd()) {
452
557
  return pathCandidates(String(value || '')).flatMap(globVariants).some((candidate) => secretPath(candidate, base)
@@ -577,8 +682,15 @@ function readOnlyProgramInvocation(program, args) {
577
682
  return !args.some((arg) => /^-[A-Za-z]*C/.test(arg) || longOptionPrefix(arg, ['compile']));
578
683
  return true;
579
684
  }
580
- /** Classifies one simple command: a read-only inspection, a secret access, or anything else. */
581
- function shellSegmentVerdict(segment, cwd = process.cwd()) {
685
+ /**
686
+ * Classifies one simple command: a read-only inspection, a secret access, or anything else.
687
+ * In the PowerShell dialect no command is a read-only inspection and no program is exempt from
688
+ * the content-read check (Bash's allowlists name Bash programs; `type` and `cat` read files in
689
+ * PowerShell, and `ls env:` and `echo $env:X` print the environment): every word is judged as a
690
+ * file a command may read.
691
+ */
692
+ function shellSegmentVerdict(segment, cwd = process.cwd(), dialect) {
693
+ const powershell = dialect === 'powershell';
582
694
  // After a cd Marrow cannot follow, relative paths are unknown, so nothing that follows is read-only.
583
695
  const base = cwd ?? process.cwd();
584
696
  const secret = (value) => isSecretPath(value, base);
@@ -587,7 +699,7 @@ function shellSegmentVerdict(segment, cwd = process.cwd()) {
587
699
  if (segment.inputs.some(secret) || writes.some(protectedTarget))
588
700
  return 'secret';
589
701
  if (!segment.words.length)
590
- return writes.length || cwd === null ? 'other' : 'read';
702
+ return writes.length || cwd === null || powershell ? 'other' : 'read';
591
703
  // A leading assignment can redirect a pager or helper into arbitrary code.
592
704
  const program = /^[A-Za-z_][A-Za-z0-9_]*=/.test(segment.words[0]) ? null : programName(segment.words[0]);
593
705
  const args = segment.words.slice(1);
@@ -607,12 +719,15 @@ function shellSegmentVerdict(segment, cwd = process.cwd()) {
607
719
  return 'secret';
608
720
  if (program === 'cd' && args.some(secret))
609
721
  return 'secret';
610
- if (!NON_CONTENT_BASH_COMMANDS.has(program)) {
722
+ if (powershell || !NON_CONTENT_BASH_COMMANDS.has(program)) {
611
723
  const targets = ['grep', 'egrep', 'fgrep', 'rg', 'jq'].includes(program) ? contentReadTargets(program, args) : args.filter((arg) => !arg.startsWith('-') || arg.includes('='));
612
724
  if (targets.some(secret))
613
725
  return 'secret';
614
726
  }
615
- return writes.length || cwd === null ? 'other' : 'read';
727
+ // A PowerShell parameter binds its value in the same word (-Path:.env).
728
+ if (powershell && segment.words.some(protectedTarget))
729
+ return 'secret';
730
+ return writes.length || cwd === null || powershell ? 'other' : 'read';
616
731
  }
617
732
  return segment.words.some(protectedTarget) ? 'secret' : 'other';
618
733
  }
@@ -627,18 +742,36 @@ function nextWorkingDirectory(segment, cwd) {
627
742
  return null;
628
743
  return normalizedToolPath(targets[0], cwd);
629
744
  }
630
- function shellAnalysis(command) {
631
- const segments = parseShellSegments(command);
745
+ /** `parse`: the dialect the words are split in; `dialect`: the dialect the simple commands are judged in (default: the same). */
746
+ function shellAnalysis(command, dialect, parse = dialect) {
747
+ const segments = parseShellSegments(command, parse === 'bash' ? undefined : parse);
632
748
  if (!segments)
633
749
  return null;
634
750
  let cwd = process.cwd();
635
751
  const verdicts = segments.map((segment) => {
636
- const verdict = shellSegmentVerdict(segment, cwd);
752
+ const verdict = shellSegmentVerdict(segment, cwd, dialect);
637
753
  cwd = nextWorkingDirectory(segment, cwd);
638
754
  return verdict;
639
755
  });
640
756
  return { segments, verdicts };
641
757
  }
758
+ /**
759
+ * True when the policy's own reading is the whole answer for a shell command: it parses, every simple command in it is a recognised read-only
760
+ * program (a `cat`, `grep`, `ls`, `jq`, `ps`, never a wrapper such as `bash -c`, `eval`, `sudo`, `ssh`, `xargs` or a program that writes), and no word
761
+ * holds a glob, a brace or a variable (those can spell a name the text does not hold). Such a command names its files in the words the policy already
762
+ * judges, so a second reading of it by the mapper could only add the mapper's own over-stops (the pattern or filter operand of `grep .env README.md`).
763
+ */
764
+ function policyReadsAllWords(command) {
765
+ try {
766
+ const analysis = shellAnalysis(command);
767
+ if (!analysis?.segments.length || !analysis.verdicts.every((verdict) => verdict === 'read'))
768
+ return false;
769
+ return analysis.segments.every((segment) => segment.expansions.length === 0 && ![...segment.words, ...segment.inputs, ...segment.outputs].some((word) => /[*?[{]/.test(word)));
770
+ }
771
+ catch {
772
+ return false;
773
+ }
774
+ }
642
775
  function stringLeaves(value, depth = 0, output = []) {
643
776
  if (output.length >= 64 || depth > 4)
644
777
  return output;
@@ -684,18 +817,44 @@ function shellCommandText(event) {
684
817
  /**
685
818
  * True when a tool reads secret or credential material, or writes a secret file
686
819
  * or Marrow's local control state. Unparseable shell falls back to a token scan.
820
+ * A Monitor command, and any shell text another tool carries, is read as Bash
821
+ * reads its command; a PowerShell command in the PowerShell dialect, which
822
+ * reads every word as a file the command may read (toolPolicyReading).
687
823
  */
688
- function isSecretMaterialAccess(event) {
824
+ function isSecretMaterialAccess(event, printsEnvironment) {
825
+ const reading = toolPolicyReading(event);
826
+ if (reading.shell.some((twin) => isSecretMaterialAccessAsNamed(twin, printsEnvironment)))
827
+ return true;
828
+ if (reading.exact)
829
+ return false;
830
+ if (reading.base === 'read')
831
+ return isSecretMaterialAccessAsNamed(readEquivalent(event), printsEnvironment);
832
+ return reading.base === 'named' && isSecretMaterialAccessAsNamed(event, printsEnvironment);
833
+ }
834
+ /** isSecretMaterialAccess for the call's own tool name only (every tool before the built-ins were hooked). */
835
+ function isSecretMaterialAccessAsNamed(event, printsEnvironment) {
689
836
  const tool = normalizeHookToolName(event.tool_name);
690
837
  if (CONTENT_READ_TOOLS.has(tool))
691
- return toolTargetPaths(event).some((path) => isSecretPath(path));
838
+ return toolTargetPaths(event).some((path) => isSecretPath(path) || secretThroughLink(path, false) || (tool === 'grep' && (isProcessFolder(path) || isApprovalStateFolder(path))));
692
839
  if (FILE_EDIT_TOOLS.has(tool))
693
- return toolTargetPaths(event).some((path) => isProtectedWriteTarget(path));
840
+ return toolTargetPaths(event).some((path) => isProtectedWriteTarget(path) || secretThroughLink(path, true));
694
841
  if (!SHELL_TOOLS.has(tool))
695
842
  return false;
696
843
  const command = shellCommandText(event);
697
- const analysis = shellAnalysis(command);
698
- if (analysis)
844
+ if (printsEnvironment && event[exports.SHELL_DIALECT] !== 'powershell' && printsEnvironment(command))
845
+ return true;
846
+ const analysis = shellAnalysis(command, event[exports.SHELL_DIALECT]);
847
+ if (event[exports.SHELL_DIALECT] === 'powershell') {
848
+ // The words are split as PowerShell splits them; text whose quoting Bash splits differently (an escaped quote,
849
+ // a typographic quote) is also split as Bash splits it, judged with no allowlist: a secret either finds is one.
850
+ // Where either split cannot be modelled, every token of the text is judged as a path.
851
+ const asBash = shellAnalysis(command, 'powershell', 'bash');
852
+ if (analysis?.verdicts.includes('secret') || asBash?.verdicts.includes('secret'))
853
+ return true;
854
+ if (analysis && asBash)
855
+ return false;
856
+ }
857
+ else if (analysis)
699
858
  return analysis.verdicts.includes('secret');
700
859
  return command.split(/[\s'"`;&|<>(){}]+/).some((token) => isProtectedWriteTarget(token));
701
860
  }
@@ -712,7 +871,7 @@ function toolClassificationText(event) {
712
871
  return [tool, ...toolTargetPaths(event)].join(' ');
713
872
  const command = hookToolCommand(event);
714
873
  if (SHELL_TOOLS.has(tool)) {
715
- const analysis = shellAnalysis(shellCommandText(event));
874
+ const analysis = shellAnalysis(shellCommandText(event), event[exports.SHELL_DIALECT]);
716
875
  if (analysis) {
717
876
  return [tool, ...analysis.segments
718
877
  .filter((_, index) => analysis.verdicts[index] !== 'read')
@@ -728,6 +887,17 @@ function toolClassificationText(event) {
728
887
  }
729
888
  return `${tool} ${command} ${serialized}`;
730
889
  }
890
+ /**
891
+ * Whether a tool runs its text as a shell command: Bash and the other terminal tools (Codex exec, Cursor Shell, ...),
892
+ * Monitor and PowerShell. Every other tool whose text is read as shell words (ToolSearch, a prompt, a path, an unknown
893
+ * tool's `command`) only borrows the parser, so no word of it is known to be a program.
894
+ */
895
+ function isShellTextTool(event) {
896
+ if (isMcpHookTool(event.tool_name))
897
+ return false;
898
+ const tool = normalizeHookToolName(event.tool_name);
899
+ return SHELL_TOOLS.has(tool) || SHELL_COMMAND_TOOLS.has(tool);
900
+ }
731
901
  /** Editing and task tools are judged by their target, not by shell-like text in their input. */
732
902
  function isShellGovernedTool(event) {
733
903
  const tool = normalizeHookToolName(event.tool_name);
@@ -742,14 +912,37 @@ function pathOnlyInput(value) {
742
912
  && keys.every((key) => ['path', 'file_path', 'filename', 'target_file'].includes(key))
743
913
  && Object.values(input).every((item) => typeof item === 'string' && item.trim().length > 0);
744
914
  }
915
+ /**
916
+ * Whether a call only reads. A shell reading (Monitor, a native git
917
+ * equivalent) is read-only exactly when Bash running its text is; a PowerShell
918
+ * call never is (Bash's read-only commands are Bash's); any other
919
+ * call carrying shell text is read-only only when both its own reading and
920
+ * Bash running the text are (never more than before).
921
+ */
745
922
  function isReadOnlyToolEvent(event) {
923
+ const reading = toolPolicyReading(event);
924
+ if (reading.dialect === 'powershell')
925
+ return false;
926
+ const shellReadOnly = reading.shell.every((twin) => isReadOnlyToolEventAsNamed(twin));
927
+ if (reading.exact)
928
+ return reading.shell.length > 0 && shellReadOnly;
929
+ const own = reading.base === 'read' ? isReadOnlyToolEventAsNamed(readEquivalent(event))
930
+ : reading.base === 'non_acting' ? false
931
+ : isReadOnlyToolEventAsNamed(event);
932
+ return own && shellReadOnly;
933
+ }
934
+ /** isReadOnlyToolEvent for the call's own tool name only (every tool before the built-ins were hooked). */
935
+ function isReadOnlyToolEventAsNamed(event, printsEnvironment) {
746
936
  const tool = normalizeHookToolName(event.tool_name);
747
937
  if (!tool)
748
938
  return false;
939
+ // Nothing PowerShell runs is a read-only inspection: the Bash twin of a PowerShell call never is.
940
+ if (event[exports.SHELL_DIALECT] === 'powershell')
941
+ return false;
749
942
  if (LOCAL_TASK_TOOLS.has(tool))
750
943
  return true;
751
944
  if (READ_ONLY_TOOLS.has(tool))
752
- return !isSecretMaterialAccess(event);
945
+ return !isSecretMaterialAccess(event, printsEnvironment);
753
946
  if (['edit', 'write', 'multiedit', 'search_replace', 'run_terminal_command', 'spawn_subagent'].includes(tool))
754
947
  return false;
755
948
  if (MUTATION_TOOL_VERB.test(tool))
@@ -758,6 +951,9 @@ function isReadOnlyToolEvent(event) {
758
951
  return true;
759
952
  const command = hookToolCommand(event);
760
953
  if (tool === 'bash' && command) {
954
+ // Printing the environment is never a read-only inspection.
955
+ if (printsEnvironment && printsEnvironment(shellCommandText(event)))
956
+ return false;
761
957
  // A compound command is read-only only when every simple command in it is.
762
958
  const analysis = shellAnalysis(shellCommandText(event));
763
959
  return Boolean(analysis?.segments.length && analysis.verdicts.every((verdict) => verdict === 'read'));
@@ -766,4 +962,141 @@ function isReadOnlyToolEvent(event) {
766
962
  && pathOnlyInput(event.tool_input)
767
963
  && !toolTargetPaths(event).some((path) => isSecretPath(path));
768
964
  }
965
+ // ---------------------------------------------------------------------------
966
+ // How a call is read for classification. Keys always keep the call's
967
+ // own tool name and input (normalized-action, hook-contract).
968
+ // ---------------------------------------------------------------------------
969
+ // Claude Code tools that run their `command` (or `script`) as a shell command: Monitor in the same
970
+ // shell environment as Bash, PowerShell in PowerShell. Any case or padding of the name. Monitor's
971
+ // text goes through Bash's rules exactly. PowerShell's does not: Bash's allowlists name Bash
972
+ // programs (`type`, `cat`, `find`, `ls`, `echo` read or print different things in PowerShell), so a
973
+ // PowerShell call is never read-only and is classified as the stricter of its own (named) reading
974
+ // and Bash's reading of its text with no allowlist applied (the PowerShell dialect of the splitter).
975
+ const SHELL_COMMAND_TOOLS = new Set(['monitor', 'powershell']);
976
+ // How long a Monitor or PowerShell call may run and whether it runs in the background: they are
977
+ // part of what is approved (a short-timeout approval is not a long-timeout one).
978
+ const SHELL_COMMAND_TOOL_RUN_FIELDS = ['timeout', 'timeout_ms', 'run_in_background'];
979
+ // The fields a Monitor or PowerShell call may carry besides its shell text without changing what runs.
980
+ const SHELL_COMMAND_TOOL_FIELDS = new Set(['description', ...SHELL_COMMAND_TOOL_RUN_FIELDS]);
981
+ // Tools whose `command` names something other than shell text (a slash command, a skill, an editor
982
+ // operation), and Workflow, whose `script` is JavaScript that only starts sub-agents.
983
+ const NON_SHELL_COMMAND_TOOLS = new Set(['slashcommand', 'skill', 'str_replace_editor', 'str_replace_based_edit_tool', 'workflow']);
984
+ // Claude Code built-ins that run a fixed local git command (exact host label): read as Bash running it.
985
+ const NATIVE_SHELL_EQUIVALENTS = new Map([
986
+ ['EnterWorktree', 'git worktree add'],
987
+ ['ExitWorktree', 'git worktree remove'],
988
+ ]);
989
+ // Claude Code built-ins no released Marrow matcher hooked that never act themselves (exact host
990
+ // label): what they lead to (a sub-agent's tools, a scheduled prompt, the agent's next step after a
991
+ // fetch, a message or a question) reaches the hook on its own. As for task bookkeeping, words in
992
+ // their prompt, message, plan or URL are not performed by the call, so they never make it
993
+ // protected; the call is still reported to Marrow (never read-only). Monitor is here only without
994
+ // shell text (its WebSocket source). The tools that only report, notify, structure output, render or
995
+ // hand text back, and what each one does:
996
+ // Artifact, ArtifactCheck, ArtifactComments, ArtifactData: the host's artifact pane (render a page
997
+ // for the user, check it, comment on it, give it data); no command, no local file.
998
+ // ListAgents: lists the agents SendMessage can reach.
999
+ // PushNotification: a desktop or phone notification with a one-line `message`.
1000
+ // ReportFindings: hands a code review's findings to the host UI to render.
1001
+ // StructuredOutput: hands the final structured result back to the caller.
1002
+ // SubagentHandback: hands a sub-agent's text back to the agent that started it.
1003
+ // Workflow: its `script` (JavaScript) only starts sub-agents, whose own tools reach the hook.
1004
+ // Tools a released matcher already hooked keep their reading (BashOutput, NotebookRead,
1005
+ // ReadMcpResourceTool, Task*, TodoWrite, ToolSearch); RemoteTrigger and DesignSync act on remote
1006
+ // services and stay as named.
1007
+ const NON_ACTING_NATIVE_TOOLS = new Set(['Agent', 'SendMessage', 'WebFetch', 'AskUserQuestion', 'EnterPlanMode', 'ExitPlanMode',
1008
+ 'SlashCommand', 'CronCreate', 'CronDelete', 'CronList', 'ScheduleWakeup', 'KillShell', 'Monitor',
1009
+ 'Artifact', 'ArtifactCheck', 'ArtifactComments', 'ArtifactData', 'ListAgents', 'PushNotification', 'ReportFindings',
1010
+ 'StructuredOutput', 'SubagentHandback', 'Workflow']);
1011
+ // Claude Code's Skill loads a skill's instructions (exact host label): read as Read.
1012
+ const READ_EQUIVALENT_NATIVE_TOOLS = new Set(['Skill']);
1013
+ function bashTwin(command) {
1014
+ return { tool_name: 'Bash', tool_input: { command } };
1015
+ }
1016
+ function powerShellTwin(command) {
1017
+ return { tool_name: 'Bash', tool_input: { command }, [exports.SHELL_DIALECT]: 'powershell' };
1018
+ }
1019
+ /** The string `command` and `script` texts of a call that are not blank (both when both are set). */
1020
+ function shellTexts(event) {
1021
+ const input = asRecord(event.tool_input);
1022
+ if (!input)
1023
+ return [];
1024
+ const texts = [];
1025
+ for (const key of ['command', 'script']) {
1026
+ const value = input[key];
1027
+ if (typeof value === 'string' && value.trim() && !texts.includes(value))
1028
+ texts.push(value);
1029
+ }
1030
+ return texts;
1031
+ }
1032
+ function toolPolicyReading(event) {
1033
+ const named = { base: 'named', shell: [], exact: false, bashLabel: false };
1034
+ const tool = normalizeHookToolName(event.tool_name);
1035
+ // Bash and the other shell tools are read as they always were; Marrow's own and MCP calls keep their reading plus any shell text.
1036
+ if (!tool || SHELL_TOOLS.has(tool))
1037
+ return named;
1038
+ const label = typeof event.tool_name === 'string' ? event.tool_name : '';
1039
+ const texts = NON_SHELL_COMMAND_TOOLS.has(tool) ? [] : shellTexts(event);
1040
+ const equivalent = isMcpHookTool(event.tool_name) ? undefined : NATIVE_SHELL_EQUIVALENTS.get(label);
1041
+ if (equivalent !== undefined)
1042
+ return { base: 'named', shell: [equivalent, ...texts].map(bashTwin), exact: true, bashLabel: false };
1043
+ if (tool === 'monitor' && texts.length && !isMcpHookTool(event.tool_name)) {
1044
+ return { base: 'named', shell: texts.map(bashTwin), exact: true, bashLabel: true };
1045
+ }
1046
+ // PowerShell: the stricter of its named reading and Bash's reading of the text in the PowerShell dialect.
1047
+ if (tool === 'powershell' && texts.length && !isMcpHookTool(event.tool_name)) {
1048
+ return { base: 'named', shell: texts.map(powerShellTwin), exact: false, bashLabel: false, dialect: 'powershell' };
1049
+ }
1050
+ const base = isMcpHookTool(event.tool_name) ? 'named'
1051
+ : READ_EQUIVALENT_NATIVE_TOOLS.has(label) ? 'read'
1052
+ : NON_ACTING_NATIVE_TOOLS.has(label) ? 'non_acting'
1053
+ : 'named';
1054
+ return { base, shell: texts.map(bashTwin), exact: false, bashLabel: false };
1055
+ }
1056
+ /** The Read call a Read-equivalent tool is read as (same input). */
1057
+ function readEquivalent(event) {
1058
+ return { tool_name: 'Read', tool_input: event.tool_input };
1059
+ }
1060
+ /**
1061
+ * The shell text of a plain Monitor or PowerShell call (one command or script string, no field
1062
+ * that changes what runs besides it), else null. Only such a call is keyed as shell text.
1063
+ */
1064
+ function shellCommandToolText(event) {
1065
+ if (isMcpHookTool(event.tool_name) || !SHELL_COMMAND_TOOLS.has(normalizeHookToolName(event.tool_name)))
1066
+ return null;
1067
+ const input = asRecord(event.tool_input);
1068
+ if (!input)
1069
+ return null;
1070
+ const texts = Object.keys(input).filter((key) => !SHELL_COMMAND_TOOL_FIELDS.has(key));
1071
+ if (texts.length !== 1 || !['command', 'script'].includes(texts[0]))
1072
+ return null;
1073
+ const value = input[texts[0]];
1074
+ return typeof value === 'string' && value.trim() ? value : null;
1075
+ }
1076
+ /**
1077
+ * The run options (`timeout`, `timeout_ms`, `run_in_background`) a Monitor or PowerShell call carries, in a
1078
+ * fixed order and as sent, or null for any other call or when it carries none. They are part of the key
1079
+ * of the call (normalized-action, hook-contract exactHookTarget); `description` is not.
1080
+ */
1081
+ function shellCommandToolRunOptions(event) {
1082
+ if (isMcpHookTool(event.tool_name) || !SHELL_COMMAND_TOOLS.has(normalizeHookToolName(event.tool_name)))
1083
+ return null;
1084
+ const input = asRecord(event.tool_input);
1085
+ if (!input)
1086
+ return null;
1087
+ const options = {};
1088
+ for (const key of SHELL_COMMAND_TOOL_RUN_FIELDS) {
1089
+ if (Object.prototype.hasOwnProperty.call(input, key))
1090
+ options[key] = input[key];
1091
+ }
1092
+ return Object.keys(options).length ? options : null;
1093
+ }
1094
+ /** A Claude Code built-in keyed by its whole input (a native git equivalent, Skill, or a call whose words are not actions). */
1095
+ function isWholeInputNativeTool(event) {
1096
+ const reading = toolPolicyReading(event);
1097
+ if ((reading.exact && !reading.bashLabel) || reading.base !== 'named')
1098
+ return true;
1099
+ // A Monitor or PowerShell call is keyed as shell text only when it is a plain one.
1100
+ return ((reading.exact && reading.bashLabel) || reading.dialect === 'powershell') && shellCommandToolText(event) === null;
1101
+ }
769
1102
  //# sourceMappingURL=hook-tool-policy.js.map