@getmarrow/mcp 3.9.103 → 3.9.106

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +26 -5
  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 +36 -3
  21. package/dist/hook-contract.js.map +1 -1
  22. package/dist/hook-pre-action.d.ts +38 -7
  23. package/dist/hook-pre-action.d.ts.map +1 -1
  24. package/dist/hook-pre-action.js +254 -21
  25. package/dist/hook-pre-action.js.map +1 -1
  26. package/dist/hook-tool-policy.d.ts +73 -4
  27. package/dist/hook-tool-policy.d.ts.map +1 -1
  28. package/dist/hook-tool-policy.js +287 -18
  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 +7 -0
  34. package/dist/host-approval-state.d.ts.map +1 -1
  35. package/dist/host-approval-state.js +15 -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 +2 -0
  50. package/dist/normalized-action.d.ts.map +1 -1
  51. package/dist/normalized-action.js +65 -10
  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 +98 -27
  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 +45 -0
  62. package/dist/seatbelt/env-dump.d.ts.map +1 -0
  63. package/dist/seatbelt/env-dump.js +308 -0
  64. package/dist/seatbelt/env-dump.js.map +1 -0
  65. package/dist/seatbelt/fallback-copy.d.ts +16 -0
  66. package/dist/seatbelt/fallback-copy.d.ts.map +1 -0
  67. package/dist/seatbelt/fallback-copy.js +68 -0
  68. package/dist/seatbelt/fallback-copy.js.map +1 -0
  69. package/dist/seatbelt/hook.d.ts +21 -2
  70. package/dist/seatbelt/hook.d.ts.map +1 -1
  71. package/dist/seatbelt/hook.js +45 -11
  72. package/dist/seatbelt/hook.js.map +1 -1
  73. package/dist/seatbelt/mapper.d.ts.map +1 -1
  74. package/dist/seatbelt/mapper.js +360 -27
  75. package/dist/seatbelt/mapper.js.map +1 -1
  76. package/dist/seatbelt/tables.d.ts +1 -1
  77. package/dist/seatbelt/tables.d.ts.map +1 -1
  78. package/dist/seatbelt/tables.js +9 -16
  79. package/dist/seatbelt/tables.js.map +1 -1
  80. package/dist/secret-paths.d.ts +17 -0
  81. package/dist/secret-paths.d.ts.map +1 -0
  82. package/dist/secret-paths.js +32 -0
  83. package/dist/secret-paths.js.map +1 -0
  84. package/dist/session-loop-guard.d.ts.map +1 -1
  85. package/dist/session-loop-guard.js +15 -5
  86. package/dist/session-loop-guard.js.map +1 -1
  87. package/package.json +2 -2
  88. package/server.json +2 -2
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.DENIED_DECISION_CLOSE_TIMEOUT_MS = exports.SEATBELT_CURSOR_ASK_AGENT_TEXT = exports.SEATBELT_CURSOR_ASK_USER_TEXT = exports.SEATBELT_HELD_TEXT = exports.GROK_FIXED_DENIAL = exports.HELD_OFFLINE_TEXT = exports.HELD_SLOW_TEXT = exports.HOLD_OWNER_DENIAL = exports.PreActionControlTimeoutError = exports.PRE_ACTION_CONTROL_TIMEOUT_MS = exports.MAX_PRE_ACTION_INPUT_BYTES = exports.MARROW_OUTAGE_WARNING = exports.SETUP_UNCONFIRMED_TEXT = exports.CLAIMED_NO_KEY_TEXT = exports.NO_KEY_TEXT = void 0;
3
+ exports.DENIED_DECISION_CLOSE_TIMEOUT_MS = exports.ENV_FILTER_SECRET_NAME = exports.SEATBELT_CURSOR_ASK_AGENT_TEXT = exports.SEATBELT_CURSOR_ASK_USER_TEXT = exports.SEATBELT_HELD_TEXT = exports.GROK_FIXED_DENIAL = exports.HELD_OFFLINE_TEXT = exports.HELD_SLOW_TEXT = exports.HOLD_OWNER_DENIAL = exports.PreActionControlTimeoutError = exports.PRE_ACTION_CONTROL_TIMEOUT_MS = exports.MAX_PRE_ACTION_INPUT_BYTES = exports.MARROW_OUTAGE_WARNING = exports.SETUP_UNCONFIRMED_TEXT = exports.CLAIMED_NO_KEY_TEXT = exports.NO_KEY_TEXT = void 0;
4
4
  exports.isMarrowControlTimeout = isMarrowControlTimeout;
5
5
  exports.isMarrowControlOutage = isMarrowControlOutage;
6
6
  exports.heldActionHookOutput = heldActionHookOutput;
@@ -19,6 +19,7 @@ exports.localControlAllowOutput = localControlAllowOutput;
19
19
  exports.seatbeltPassOutput = seatbeltPassOutput;
20
20
  exports.localLoopGuardDenyOutput = localLoopGuardDenyOutput;
21
21
  exports.classifyTool = classifyTool;
22
+ exports.mayPrintEnvironment = mayPrintEnvironment;
22
23
  exports.cursorPreActionHookOutput = cursorPreActionHookOutput;
23
24
  exports.clinePreActionHookOutput = clinePreActionHookOutput;
24
25
  exports.windsurfPreActionDecision = windsurfPreActionDecision;
@@ -28,9 +29,11 @@ exports.preActionHookOutput = preActionHookOutput;
28
29
  exports.closeDeniedDecision = closeDeniedDecision;
29
30
  exports.localApprovalCategories = localApprovalCategories;
30
31
  exports.installPreActionHook = installPreActionHook;
32
+ exports.runSeatbeltStatusCommand = runSeatbeltStatusCommand;
31
33
  exports.outageHolds = outageHolds;
32
34
  exports.runPreActionHookCommand = runPreActionHookCommand;
33
35
  const node_crypto_1 = require("node:crypto");
36
+ const node_os_1 = require("node:os");
34
37
  const index_1 = require("./index");
35
38
  const request_reliability_1 = require("./request-reliability");
36
39
  const lifecycle_spool_1 = require("./lifecycle-spool");
@@ -45,13 +48,59 @@ const hook_tool_policy_1 = require("./hook-tool-policy");
45
48
  const hook_contract_1 = require("./hook-contract");
46
49
  Object.defineProperty(exports, "MARROW_OUTAGE_WARNING", { enumerable: true, get: function () { return hook_contract_1.MARROW_OUTAGE_WARNING; } });
47
50
  /** A protected action with no Marrow key anywhere this machine keeps one (MARROW_API_KEY, ~/.marrow/env.local or ~/.marrow/env, owner-only). */
48
- exports.NO_KEY_TEXT = 'Marrow has no API key on this machine. Owner: create one at getmarrow.ai/signup, then run the installer activate command in a terminal. Do not retry this action.';
51
+ exports.NO_KEY_TEXT = 'Marrow has no API key on this machine, so it held this action. To approve actions like this, create a key at getmarrow.ai/signup and add it: getmarrow.ai/docs#start-here (Claude Code plugin: put MARROW_API_KEY in ~/.marrow/env, chmod 600, then /reload-plugins). Agent: do not retry this action.';
49
52
  /** Seatbelt mode `claimed_no_key` (spec §1.1): account evidence on the machine but no usable key. Same text as src/seatbelt/hook.ts. */
50
53
  exports.CLAIMED_NO_KEY_TEXT = 'Marrow is linked to an account but has no working key; owner: run `npx -y @getmarrow/install@latest doctor`.';
51
- /** `claimed_no_key` without account evidence (a mode fallback): the same holds, never an account claim. Same text as src/seatbelt/hook.ts. */
52
- exports.SETUP_UNCONFIRMED_TEXT = 'Marrow has no working key on this machine and its keyless mode is off here (strict mode, or a home folder it cannot trust); owner: run `npx -y @getmarrow/install@latest doctor`.';
54
+ /**
55
+ * `claimed_no_key` without account evidence (a mode fallback): the same holds, never an account claim.
56
+ * This constant is the generic wording; the hold names its reason (setupUnconfirmedText). Same text as src/seatbelt/hook.ts.
57
+ */
58
+ exports.SETUP_UNCONFIRMED_TEXT = 'Marrow stopped this action: there is no Marrow key on this machine, and Marrow uses its stricter no-key mode here because it could not confirm this machine\'s setup. Fix (for the person at this machine, not the agent): run npx -y @getmarrow/install@latest doctor, or add a Marrow key (getmarrow.ai/docs#start-here). Agent: do not retry it another way and do not edit Marrow settings; tell the user why it was stopped. Code: setup_unconfirmed.';
53
59
  /** The seatbelt path itself could not run (its modules did not load): a stop (spec §2.1.4). */
54
- const SEATBELT_FAILED_TEXT = 'MARROW SEATBELT stopped this action: a safety check that could not finish (U5.fail_closed, hook_error). This needs the machine owner. Do not retry it another way or edit Marrow settings.';
60
+ const SEATBELT_FAILED_TEXT = 'MARROW SEATBELT stopped this action: a safety check that could not finish. If you (the person at this machine) meant it, run it yourself in your own terminal. Agent: do not retry it another way and do not edit Marrow settings; tell the user it was stopped. If this keeps happening, run npx -y @getmarrow/install@latest doctor. Code: U5.fail_closed/hook_error.';
61
+ /**
62
+ * Seatbelt stop and keyless hold texts end with their code, so they get room for it: the longest real stop
63
+ * (a fallback reason row with the longest label and codes) is about 850 characters. It is our own cap, not a host's:
64
+ * Claude Code, Codex, Cursor and Windsurf document no limit on the text of a denial and their installed hooks pass it on unchanged.
65
+ */
66
+ const STOP_TEXT_LIMIT = 880;
67
+ /** The installed Gemini CLI guard (@getmarrow/install, src/installer.js) turns a deny reason over 500 characters into its launch failure. */
68
+ const GEMINI_REASON_LIMIT = 500;
69
+ /** The installed Cline PreToolUse hook (@getmarrow/install, src/installer.js clinePreToolUseHookSource) replaces an errorMessage over 500 characters with its generic "did not return a valid decision" text. */
70
+ const CLINE_ERROR_LIMIT = 500;
71
+ /** The most characters of a stop or hold text this host takes without losing the code at its end. */
72
+ function hostTextLimit(harness) {
73
+ return harness === 'gemini' ? GEMINI_REASON_LIMIT : harness === 'cline' ? CLINE_ERROR_LIMIT : STOP_TEXT_LIMIT;
74
+ }
75
+ /** The Cursor deny text of every enforcementError that is not one of the fixed keyless texts (as on 3.9.104). */
76
+ const CURSOR_ENFORCEMENT_LIMIT = 500;
77
+ /** Claude Code plugin only; a host with a tight limit drops it before it would lose the stop's code at the end. */
78
+ const PLUGIN_KEY_HINT = ' (Claude Code plugin: put MARROW_API_KEY in ~/.marrow/env, chmod 600, then /reload-plugins)';
79
+ /** The person's own way out; after the plugin hint it is the next thing a tight limit gives up. The agent's instruction stays. */
80
+ const PERSON_ADVICE = ' If you (the person at this machine) meant it, run it yourself in your own terminal.';
81
+ /**
82
+ * One line of hook text within a host's limit. A text over it first gives up the plugin hint, then the
83
+ * person's advice; if it is still over, the end of the explanation is cut at a sentence and the stop's
84
+ * code (the last "Code: ..." part) is kept. Texts that fit are untouched.
85
+ */
86
+ function fitHookText(value, limit) {
87
+ let text = value.replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim();
88
+ for (const part of [PLUGIN_KEY_HINT, PERSON_ADVICE]) {
89
+ if (text.length <= limit)
90
+ break;
91
+ text = text.replace(part, '');
92
+ }
93
+ if (text.length <= limit)
94
+ return text;
95
+ const at = text.lastIndexOf(' Code: ');
96
+ if (at > 0 && limit - (text.length - at) > 40) {
97
+ const tail = text.slice(at);
98
+ const head = text.slice(0, limit - tail.length);
99
+ const cut = head.lastIndexOf('. ');
100
+ return `${cut > 0 ? head.slice(0, cut + 1) : head}${tail}`.slice(0, limit);
101
+ }
102
+ return text.slice(0, limit);
103
+ }
55
104
  // Claude Code sends the whole tool input, and a Write of a long document grows
56
105
  // further once JSON-escaped, so tens of kilobytes of markdown must still parse.
57
106
  exports.MAX_PRE_ACTION_INPUT_BYTES = 4 * 1024 * 1024;
@@ -157,6 +206,9 @@ function heldActionHookOutput(harness, plan, code = null) {
157
206
  return {
158
207
  hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: plan.agentText },
159
208
  ...(harness === 'codex' && code ? { systemMessage: (0, host_approval_1.typedReplyUserText)(plan.userText, code) } : {}),
209
+ // Claude Code, a hold nobody can approve in chat (no dialog here, or nobody in this run): one user-only
210
+ // line says so. Output only: the decision above is the same deny, and the line carries no code or id.
211
+ ...(harness === 'claude-code' && plan.userNotice ? { systemMessage: plan.userNotice } : {}),
160
212
  };
161
213
  }
162
214
  /**
@@ -423,7 +475,7 @@ function localControlAllowOutput(harness) {
423
475
  /** The Cursor events where Cursor enforces "ask" with its own approval prompt (the installer's execution hooks). */
424
476
  const CURSOR_ASK_EVENTS = new Set(['beforeShellExecution', 'beforeMCPExecution']);
425
477
  /** Keyless seatbelt: a host that cannot hand this action to its own approval prompt holds it (no account to approve it). */
426
- exports.SEATBELT_HELD_TEXT = 'MARROW SEATBELT held this action: this host cannot hand it to its own approval prompt here, and no Marrow account is linked to approve it. This needs the machine owner. Do not retry it another way or edit Marrow settings. Tell the owner: run it yourself in your own terminal, or link a Marrow account with "npx -y @getmarrow/install@latest activate" (it needs MARROW_API_KEY set first) so these become approval prompts.';
478
+ exports.SEATBELT_HELD_TEXT = 'MARROW SEATBELT held this action: this host cannot show its own approval prompt here, and no Marrow account is linked to approve it. If you (the person at this machine) meant it, run it yourself in your own terminal. Agent: do not retry it another way and do not edit Marrow settings; tell the user it was held. For approval prompts, add a Marrow key: getmarrow.ai/docs#start-here.';
427
479
  exports.SEATBELT_CURSOR_ASK_USER_TEXT = 'Marrow seatbelt (no account linked): approve this action here only if you expected it.';
428
480
  exports.SEATBELT_CURSOR_ASK_AGENT_TEXT = 'Cursor is asking the user to approve this action (Marrow seatbelt, no account linked). Wait for the answer; do not retry it another way.';
429
481
  /**
@@ -461,7 +513,7 @@ function seatbeltPassOutput(harness, hookEventName, heldBefore) {
461
513
  return {};
462
514
  }
463
515
  function localLoopGuardDenyOutput(harness, reason) {
464
- const bounded = reason.replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 500);
516
+ const bounded = fitHookText(reason, hostTextLimit(harness));
465
517
  if (harness === 'windsurf')
466
518
  return null;
467
519
  if (harness === 'cursor')
@@ -478,7 +530,7 @@ function localLoopGuardDenyOutput(harness, reason) {
478
530
  function emitLoopGuardDenial(harness, reason) {
479
531
  if (harness === 'windsurf') {
480
532
  process.exitCode = 2;
481
- process.stderr.write(`${reason.replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 500)}\n`);
533
+ process.stderr.write(`${fitHookText(reason, hostTextLimit(harness))}\n`);
482
534
  return;
483
535
  }
484
536
  process.stdout.write(JSON.stringify(localLoopGuardDenyOutput(harness, reason)));
@@ -509,13 +561,139 @@ function localControlDisabled() {
509
561
  return false;
510
562
  }
511
563
  }
564
+ // The order classifyTool picks a type and lists surfaces in; earlier types are the more specific (stricter) ones.
565
+ const CLASSIFICATION_TYPES = ['publish', 'deploy', 'review', 'migration', 'audit', 'financial', 'process'];
566
+ const CLASSIFICATION_SURFACES = ['production', 'github', 'npm', 'secrets', 'database', 'financial'];
512
567
  /**
513
- * `ownUnproven`: the label is one of Marrow's own, but the host's server
514
- * identity check did not prove the official server (Codex, Cursor). Such a
515
- * call is classified as a protected MCP tool of another server, never
516
- * read-only (it imitates Marrow's names, so a read verb proves nothing).
568
+ * The classification of one tool call. A Monitor command (and a native git
569
+ * equivalent such as EnterWorktree) is classified exactly as Bash running it;
570
+ * any other call carrying a command or script string gets the stricter of its
571
+ * own classification and Bash's for that text (its own classification,
572
+ * unchanged, when that is already at least as strict). A PowerShell call is
573
+ * one of those, with Bash's reading in the PowerShell dialect (no read-only
574
+ * allowlist) and never read-only. See toolPolicyReading.
575
+ *
576
+ * `postTool`: the PostToolUse hook (hook.ts) classifies a call that already ran, for the labels of its lifecycle event and the read-only
577
+ * flag of the local loop guard; no approval, redaction or stored output reads the result. That path must add ~no latency, so it asks no
578
+ * environment-print check and never loads the seatbelt mapper: its labels are the ones 3.9.103 gave (the secret-path table it shares with the
579
+ * seatbelt is the one stricter difference). Only the pre-action hook decides (and holds) on what prints the environment.
517
580
  */
518
581
  function classifyTool(event, options = {}) {
582
+ const reading = (0, hook_tool_policy_1.toolPolicyReading)(event);
583
+ if (!reading.shell.length && reading.base === 'named')
584
+ return classificationFrom(classifyAsNamed(event, options));
585
+ const shell = reading.shell.map((twin) => classifyAsNamed(twin, options));
586
+ if (reading.exact) {
587
+ const merged = shell.reduce(stricterParts);
588
+ return classificationFrom(reading.bashLabel ? merged : { ...merged, tool: ownToolLabel(event) });
589
+ }
590
+ const own = reading.base === 'read' ? { ...classifyAsNamed((0, hook_tool_policy_1.readEquivalent)(event), options), tool: ownToolLabel(event) }
591
+ : reading.base === 'non_acting' ? nonActingParts(event)
592
+ : classifyAsNamed(event, options);
593
+ const merged = shell.reduce(stricterParts, own);
594
+ return classificationFrom(reading.dialect === 'powershell' ? { ...merged, readOnly: false } : merged);
595
+ }
596
+ function ownToolLabel(event) {
597
+ return String(event.tool_name || 'tool').slice(0, 64);
598
+ }
599
+ // A call whose words are not actions (toolPolicyReading): reported, never read-only, never protected by its text.
600
+ function nonActingParts(event) {
601
+ return { tool: ownToolLabel(event), type: 'process', surfaces: [], readOnly: false, protected: false };
602
+ }
603
+ /** `a`, unchanged, when it is at least as strict as `b` on every axis; otherwise the stricter of each. */
604
+ function stricterParts(a, b) {
605
+ const rank = (type) => {
606
+ const index = CLASSIFICATION_TYPES.indexOf(type);
607
+ return index < 0 ? CLASSIFICATION_TYPES.length : index;
608
+ };
609
+ const covers = (!a.readOnly || b.readOnly) && (a.protected || !b.protected) && rank(a.type) <= rank(b.type)
610
+ && b.surfaces.every((surface) => a.surfaces.includes(surface));
611
+ if (covers)
612
+ return a;
613
+ const surfaces = new Set([...a.surfaces, ...b.surfaces]);
614
+ return {
615
+ tool: a.tool,
616
+ type: rank(a.type) <= rank(b.type) ? a.type : b.type,
617
+ surfaces: [...CLASSIFICATION_SURFACES.filter((surface) => surfaces.has(surface)), ...[...surfaces].filter((surface) => !CLASSIFICATION_SURFACES.includes(surface))],
618
+ readOnly: a.readOnly && b.readOnly,
619
+ protected: a.protected || b.protected,
620
+ };
621
+ }
622
+ let envDumpTools;
623
+ let lastEnvDumpAnswer = null;
624
+ // Every form that prints the environment holds one of these words (env also covers environ, getenv, printenv, ENV and every wrapper's
625
+ // last word). The mapper (about 100 ms to load and run once) is asked only for a command that holds one. Four words are read more closely
626
+ // (what the mapper needs, and nothing less), each unless an expansion could spell it:
627
+ // set only a bare `set` (the mapper's rule is no operand: `set -e` and the word in a message are not);
628
+ // ps only with an `e` or `E` after it (BSD `ps e`, `-E`); `ps aux` and `docker ps` are not;
629
+ // jq only with `env` in the text (a filter reads the environment by that word);
630
+ // proc only as `/proc`, or `process[...]` (a computed name); `process.env` holds `env`.
631
+ // A literal read of one non-secret name (`process.env.HOME`, the mapper's own rule) and a word that only joins `env` to a branch name in a
632
+ // plain `git` command (`git checkout -b env-fix`) hold nothing. A `$`, a backtick or a brace can spell any of these words, so a command that
633
+ // holds one of them and a narrowed word is read exactly as before.
634
+ exports.ENV_FILTER_SECRET_NAME = /KEY|TOKEN|SECRET|PASS|CRED/i; // the mapper's SECRET_VAR (src/seatbelt/tables.ts); a test keeps the two equal
635
+ const ENV_DUMP_WORDS = /env|export|declare|typeset|readonly|compgen|tmux|systemctl|strace|php|awk|global|\bproc\b|\bprocess\s*(?:\?\.\s*)?\[|\bset\b(?=[^\S\n]*(?:$|[\n\r;&|)}<>#]|\d+[<>]))/i;
636
+ const NARROWED_WORDS = /\bjq\b|\bps\b|\bset\b|proc/i;
637
+ const EXPANSION = /[$`{]/;
638
+ const ONE_NAME_READ = /\bprocess\.env\.(?!(?:items|keys|copy)\b)([A-Za-z_]\w*)/g;
639
+ const PLAIN_GIT = /^\s*git\s[^$`;&|<>(){}\\\n]*$/;
640
+ /** False for a command that cannot print the environment: the keyed hook then never loads the mapper for it. */
641
+ function mayPrintEnvironment(command) {
642
+ // An ANSI-C quote can spell a word in escapes.
643
+ if (command.includes("$'") || command.includes('$"'))
644
+ return true;
645
+ // Quotes and backslashes split a word without changing it (`e'n'v`).
646
+ const text = command.replace(/['"\\]/g, '');
647
+ // The word `env` only hyphen-joined in a plain git command with no secret-looking word (data to git, never a program or a name).
648
+ let words = PLAIN_GIT.test(command) && !exports.ENV_FILTER_SECRET_NAME.test(command) ? text.replace(/-env(?![\w])|env-/gi, '') : text;
649
+ words = words.replace(ONE_NAME_READ, (read, name) => (exports.ENV_FILTER_SECRET_NAME.test(name) ? read : ''));
650
+ if (ENV_DUMP_WORDS.test(words))
651
+ return true;
652
+ // `ps` with an `e` or `E` anywhere after the first `ps` (an `e` after a later `ps` is after the first one too: one scan, no backtracking).
653
+ const ps = /\bps\b/i.exec(words);
654
+ if (ps && /[eE]/.test(words.slice(ps.index + 2)))
655
+ return true;
656
+ return EXPANSION.test(text) && NARROWED_WORDS.test(text);
657
+ }
658
+ const MAPPER_UNSURE = /^(?:mapper_error|budget_)/;
659
+ function shellPrintsEnvironment(command) {
660
+ if (lastEnvDumpAnswer && lastEnvDumpAnswer.command === command)
661
+ return lastEnvDumpAnswer.dumps;
662
+ if (!mayPrintEnvironment(command))
663
+ return false;
664
+ let dumps;
665
+ try {
666
+ if (envDumpTools === undefined) {
667
+ try {
668
+ envDumpTools = { mapper: require('./seatbelt/mapper'), reasons: require('./seatbelt/env-dump').ENV_DUMP_REASONS };
669
+ }
670
+ catch {
671
+ envDumpTools = null;
672
+ }
673
+ }
674
+ if (!envDumpTools)
675
+ dumps = ENV_DUMP_WORDS.test(command);
676
+ else {
677
+ const { mapper, reasons } = envDumpTools;
678
+ const decision = mapper.evaluateSeatbeltShell(command, {
679
+ cwd: process.cwd(),
680
+ home: process.env.HOME || (0, node_os_1.homedir)(),
681
+ tmpdir: '/tmp',
682
+ platform: process.platform,
683
+ fs: { readFile: () => null, realpath: (path) => path },
684
+ git: { toplevel: () => null, getAll: () => [], currentBranch: () => null, remoteHead: () => null },
685
+ });
686
+ dumps = decision.hits.some((hit) => (hit.category === 'S4' && reasons.has(hit.reason)) || (MAPPER_UNSURE.test(hit.reason) && ENV_DUMP_WORDS.test(command)));
687
+ }
688
+ }
689
+ catch {
690
+ dumps = ENV_DUMP_WORDS.test(command);
691
+ }
692
+ lastEnvDumpAnswer = { command, dumps };
693
+ return dumps;
694
+ }
695
+ /** The classification of a call under its own tool name only (every tool before the built-ins were hooked). */
696
+ function classifyAsNamed(event, options) {
519
697
  const tool = String(event.tool_name || 'tool').slice(0, 64);
520
698
  const normalizedTool = (0, hook_tool_policy_1.normalizeHookToolName)(tool);
521
699
  // Editing and task tools are judged by their target; shell-like words in the
@@ -524,8 +702,10 @@ function classifyTool(event, options = {}) {
524
702
  const command = shellGoverned ? (0, hook_tool_policy_1.hookToolCommand)(event) : '';
525
703
  const input = (0, hook_tool_policy_1.toolClassificationText)(event).toLowerCase();
526
704
  // A look-alike of Marrow's own tool is never read-only, whatever its verb.
527
- const readOnly = options.ownUnproven !== true && (0, hook_tool_policy_1.isReadOnlyToolEvent)(event);
528
- const secretAccess = (0, hook_tool_policy_1.isSecretMaterialAccess)(event);
705
+ // The PostToolUse path passes no check (options.postTool): the mapper is never loaded for it.
706
+ const printsEnvironment = options.postTool === true ? undefined : shellPrintsEnvironment;
707
+ const readOnly = options.ownUnproven !== true && (0, hook_tool_policy_1.isReadOnlyToolEventAsNamed)(event, printsEnvironment);
708
+ const secretAccess = (0, hook_tool_policy_1.isSecretMaterialAccessAsNamed)(event, printsEnvironment);
529
709
  const protectedShellCommand = shellGoverned && (0, hook_tool_policy_1.isProtectedShellMutation)(command);
530
710
  const infrastructureDeployment = /\b(?:kubectl|terraform|pulumi|helm)\b/.test(command.toLowerCase())
531
711
  && protectedShellCommand;
@@ -555,6 +735,11 @@ function classifyTool(event, options = {}) {
555
735
  || protectedShellCommand
556
736
  || ((0, hook_tool_policy_1.isMcpHookTool)(event.tool_name) && (options.ownUnproven === true || !(0, hook_tool_policy_1.isOfficialMarrowMcpTool)(event.tool_name)))
557
737
  || (['use_mcp_tool', 'use_tool'].includes(normalizedTool) && !(0, hook_tool_policy_1.isOfficialMarrowMcpEvent)(event)));
738
+ return { tool, type, surfaces, readOnly, protected: protectedAction };
739
+ }
740
+ function classificationFrom(parts) {
741
+ const { tool, type, surfaces, readOnly } = parts;
742
+ const protectedAction = parts.protected;
558
743
  const risk = readOnly ? 'low' : protectedAction ? 'high' : 'medium';
559
744
  const target = surfaces.includes('npm') ? `npm:${type}`
560
745
  : surfaces.includes('github') ? `github:${type}`
@@ -585,7 +770,8 @@ function cursorPreActionHookOutput(result) {
585
770
  return { permission: 'deny', user_message: denial, agent_message: denial };
586
771
  }
587
772
  if (protectedRisk && (!runtime || !permit?.verified)) {
588
- const denial = boundedText(result.enforcementError || 'Marrow could not verify the required action permit. Retry after governance is available.', 500);
773
+ // Room for the code at the end only for the fixed keyless texts; every other text (it can carry a server error, an agent id) keeps the base bound.
774
+ const denial = boundedText(result.enforcementError || 'Marrow could not verify the required action permit. Retry after governance is available.', isKeylessHoldText(result.enforcementError) ? STOP_TEXT_LIMIT : CURSOR_ENFORCEMENT_LIMIT);
589
775
  return {
590
776
  permission: 'deny',
591
777
  user_message: denial,
@@ -608,12 +794,10 @@ function clinePreActionHookOutput(result) {
608
794
  };
609
795
  }
610
796
  if (result.protectedRisk && (!result.runtime || !result.permit?.verified)) {
611
- const credentialsUnavailable = result.enforcementError === exports.NO_KEY_TEXT || result.enforcementError === exports.CLAIMED_NO_KEY_TEXT
612
- || result.enforcementError === exports.SETUP_UNCONFIRMED_TEXT;
613
797
  return {
614
798
  cancel: true,
615
- errorMessage: credentialsUnavailable
616
- ? result.enforcementError
799
+ errorMessage: result.credentialsUnavailable === true
800
+ ? fitHookText(result.enforcementError, CLINE_ERROR_LIMIT)
617
801
  : result.failure === 'credential_scope'
618
802
  ? 'This Marrow API key is not authorized to obtain action permits for this agent. Use the API key issued to this agent and retry.'
619
803
  : result.failure === 'unavailable'
@@ -889,6 +1073,18 @@ function installPreActionHook(startDir = process.cwd()) {
889
1073
  fs.writeFileSync(path, JSON.stringify(settings, null, 2) + '\n');
890
1074
  return { settingsPath: path, installed: reconciled.changed };
891
1075
  }
1076
+ /**
1077
+ * `marrow-mcp seatbelt-status`: exactly one line of JSON, exit 0. Read-only. On any error the unknown line.
1078
+ * The installer's doctor reads it; it is not a user feature.
1079
+ */
1080
+ function runSeatbeltStatusCommand() {
1081
+ let status = { mode: 'unknown', reasons: [] };
1082
+ try {
1083
+ status = require('./seatbelt/hook').seatbeltStatus();
1084
+ }
1085
+ catch { /* the unknown line */ }
1086
+ process.stdout.write(`${JSON.stringify({ mode: status.mode, reasons: status.reasons })}\n`);
1087
+ }
892
1088
  /**
893
1089
  * I3: whether a real outage (the gate never answered) holds this action. Never looser than master,
894
1090
  * which allowed every action in an outage. A shell command is read by the seatbelt mapper: held for an
@@ -908,6 +1104,37 @@ function outageHolds(classifiedHighRisk, read) {
908
1104
  return false;
909
1105
  return classifiedHighRisk;
910
1106
  }
1107
+ /**
1108
+ * The hold text of the keyless fallback, naming its reason. The words live in the seatbelt module (a lazy
1109
+ * require, like the rest of the keyless path); if it cannot load the generic wording stands.
1110
+ */
1111
+ function setupUnconfirmedHold(modeReason) {
1112
+ try {
1113
+ return require('./seatbelt/fallback-copy').setupUnconfirmedText(modeReason);
1114
+ }
1115
+ catch {
1116
+ return exports.SETUP_UNCONFIRMED_TEXT;
1117
+ }
1118
+ }
1119
+ let keylessHoldTexts = null;
1120
+ /**
1121
+ * True only for the fixed texts the keyless path sets as enforcementError (NO_KEY_TEXT, CLAIMED_NO_KEY_TEXT, or one
1122
+ * setupUnconfirmedHold text per row of the fallback table). An exact match against that generated set: never a prefix,
1123
+ * a pattern or a flag, so no other text, whatever it starts with, is taken for one of them.
1124
+ */
1125
+ function isKeylessHoldText(text) {
1126
+ if (typeof text !== 'string')
1127
+ return false;
1128
+ if (text === exports.NO_KEY_TEXT || text === exports.CLAIMED_NO_KEY_TEXT || text === exports.SETUP_UNCONFIRMED_TEXT)
1129
+ return true;
1130
+ try {
1131
+ keylessHoldTexts ??= new Set(require('./seatbelt/fallback-copy').setupUnconfirmedTexts());
1132
+ return keylessHoldTexts.has(text);
1133
+ }
1134
+ catch {
1135
+ return false;
1136
+ }
1137
+ }
911
1138
  function seatbeltEvent(source, rawEvent) {
912
1139
  const raw = asRecord(rawEvent) || {};
913
1140
  const event = source;
@@ -1074,6 +1301,8 @@ async function runPreActionHook(input, watch) {
1074
1301
  // The keyless hold text when the seatbelt does not answer: master's no-key text in seatbelt mode (a
1075
1302
  // tool the mapper does not recognise, audit H1), the claimed_no_key texts otherwise.
1076
1303
  let keylessSeatbeltMode = false;
1304
+ // Why the stricter fallback is on (words only, read from the mode resolution; the mode is decided elsewhere).
1305
+ let keylessModeReason = null;
1077
1306
  if (!resolved.apiKey) {
1078
1307
  let keyless;
1079
1308
  try {
@@ -1084,6 +1313,7 @@ async function runPreActionHook(input, watch) {
1084
1313
  }
1085
1314
  keylessAccountLinked = keyless.accountLinked === true;
1086
1315
  keylessSeatbeltMode = keyless.mode === 'seatbelt';
1316
+ keylessModeReason = typeof keyless.modeReason === 'string' ? keyless.modeReason : null;
1087
1317
  if (keyless.stopText) {
1088
1318
  emitLoopGuardDenial(identity.harness, keyless.stopText);
1089
1319
  return;
@@ -1142,7 +1372,8 @@ async function runPreActionHook(input, watch) {
1142
1372
  runtime: null,
1143
1373
  permit: null,
1144
1374
  protectedRisk: enforcementRequired,
1145
- enforcementError: keylessSeatbeltMode ? exports.NO_KEY_TEXT : keylessAccountLinked ? exports.CLAIMED_NO_KEY_TEXT : exports.SETUP_UNCONFIRMED_TEXT,
1375
+ enforcementError: keylessSeatbeltMode ? exports.NO_KEY_TEXT : keylessAccountLinked ? exports.CLAIMED_NO_KEY_TEXT : setupUnconfirmedHold(keylessModeReason),
1376
+ credentialsUnavailable: true,
1146
1377
  }, identity.harness);
1147
1378
  return;
1148
1379
  }
@@ -1185,7 +1416,9 @@ async function runPreActionHook(input, watch) {
1185
1416
  return;
1186
1417
  }
1187
1418
  if (waited?.kind === 'deny') {
1188
- emitHeldPlan(identity.harness, { kind: 'deny', agentText: waited.agentText, userText: waited.userText, code: Boolean(waited.hold.code) }, waited.hold.code);
1419
+ // The retry of a hold that waits for an approval nobody can give in chat: the same user-only line again (not for a denial that has another cause: see holdNotice).
1420
+ const userNotice = identity.harness === 'claude-code' ? (0, host_approval_1.holdNotice)(waited.hold, (0, host_approval_1.heldKindLabel)(localApprovalCategories(classified)), waited.waiting === true) : null;
1421
+ emitHeldPlan(identity.harness, { kind: 'deny', agentText: waited.agentText, userText: waited.userText, code: Boolean(waited.hold.code), ...(userNotice ? { userNotice } : {}) }, waited.hold.code);
1189
1422
  await afterAnswer(holdContext, { link: waited.deferredLink ? waited.hold : null });
1190
1423
  return;
1191
1424
  }