@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,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,9 @@ exports.localControlAllowOutput = localControlAllowOutput;
19
19
  exports.seatbeltPassOutput = seatbeltPassOutput;
20
20
  exports.localLoopGuardDenyOutput = localLoopGuardDenyOutput;
21
21
  exports.classifyTool = classifyTool;
22
+ exports.mayPrintEnvironment = mayPrintEnvironment;
23
+ exports.mayReadApprovalState = mayReadApprovalState;
24
+ exports.mayReadSecret = mayReadSecret;
22
25
  exports.cursorPreActionHookOutput = cursorPreActionHookOutput;
23
26
  exports.clinePreActionHookOutput = clinePreActionHookOutput;
24
27
  exports.windsurfPreActionDecision = windsurfPreActionDecision;
@@ -28,30 +31,82 @@ exports.preActionHookOutput = preActionHookOutput;
28
31
  exports.closeDeniedDecision = closeDeniedDecision;
29
32
  exports.localApprovalCategories = localApprovalCategories;
30
33
  exports.installPreActionHook = installPreActionHook;
34
+ exports.runSeatbeltStatusCommand = runSeatbeltStatusCommand;
31
35
  exports.outageHolds = outageHolds;
32
36
  exports.runPreActionHookCommand = runPreActionHookCommand;
33
37
  const node_crypto_1 = require("node:crypto");
38
+ const node_fs_1 = require("node:fs");
39
+ const node_os_1 = require("node:os");
40
+ const node_path_1 = require("node:path");
34
41
  const index_1 = require("./index");
35
42
  const request_reliability_1 = require("./request-reliability");
36
43
  const lifecycle_spool_1 = require("./lifecycle-spool");
37
44
  const control_state_1 = require("./control-state");
38
45
  const runtime_contract_1 = require("./runtime-contract");
39
46
  const host_approval_1 = require("./host-approval");
47
+ const host_approval_state_1 = require("./host-approval-state");
40
48
  const host_session_1 = require("./host-session");
41
49
  const own_server_identity_1 = require("./own-server-identity");
42
50
  const normalized_action_1 = require("./normalized-action");
51
+ const secret_paths_1 = require("./secret-paths");
43
52
  const session_loop_guard_1 = require("./session-loop-guard");
44
53
  const hook_tool_policy_1 = require("./hook-tool-policy");
45
54
  const hook_contract_1 = require("./hook-contract");
46
55
  Object.defineProperty(exports, "MARROW_OUTAGE_WARNING", { enumerable: true, get: function () { return hook_contract_1.MARROW_OUTAGE_WARNING; } });
47
56
  /** 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.';
57
+ 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
58
  /** 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
59
  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`.';
60
+ /**
61
+ * `claimed_no_key` without account evidence (a mode fallback): the same holds, never an account claim.
62
+ * This constant is the generic wording; the hold names its reason (setupUnconfirmedText). Same text as src/seatbelt/hook.ts.
63
+ */
64
+ 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
65
  /** 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.';
66
+ 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.';
67
+ /**
68
+ * Seatbelt stop and keyless hold texts end with their code, so they get room for it: the longest real stop
69
+ * (a fallback reason row with the longest label and codes) is about 850 characters. It is our own cap, not a host's:
70
+ * Claude Code, Codex, Cursor and Windsurf document no limit on the text of a denial and their installed hooks pass it on unchanged.
71
+ */
72
+ const STOP_TEXT_LIMIT = 880;
73
+ /** The installed Gemini CLI guard (@getmarrow/install, src/installer.js) turns a deny reason over 500 characters into its launch failure. */
74
+ const GEMINI_REASON_LIMIT = 500;
75
+ /** 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. */
76
+ const CLINE_ERROR_LIMIT = 500;
77
+ /** The most characters of a stop or hold text this host takes without losing the code at its end. */
78
+ function hostTextLimit(harness) {
79
+ return harness === 'gemini' ? GEMINI_REASON_LIMIT : harness === 'cline' ? CLINE_ERROR_LIMIT : STOP_TEXT_LIMIT;
80
+ }
81
+ /** The Cursor deny text of every enforcementError that is not one of the fixed keyless texts (as on 3.9.104). */
82
+ const CURSOR_ENFORCEMENT_LIMIT = 500;
83
+ /** Claude Code plugin only; a host with a tight limit drops it before it would lose the stop's code at the end. */
84
+ const PLUGIN_KEY_HINT = ' (Claude Code plugin: put MARROW_API_KEY in ~/.marrow/env, chmod 600, then /reload-plugins)';
85
+ /** 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. */
86
+ const PERSON_ADVICE = ' If you (the person at this machine) meant it, run it yourself in your own terminal.';
87
+ /**
88
+ * One line of hook text within a host's limit. A text over it first gives up the plugin hint, then the
89
+ * person's advice; if it is still over, the end of the explanation is cut at a sentence and the stop's
90
+ * code (the last "Code: ..." part) is kept. Texts that fit are untouched.
91
+ */
92
+ function fitHookText(value, limit) {
93
+ let text = value.replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim();
94
+ for (const part of [PLUGIN_KEY_HINT, PERSON_ADVICE]) {
95
+ if (text.length <= limit)
96
+ break;
97
+ text = text.replace(part, '');
98
+ }
99
+ if (text.length <= limit)
100
+ return text;
101
+ const at = text.lastIndexOf(' Code: ');
102
+ if (at > 0 && limit - (text.length - at) > 40) {
103
+ const tail = text.slice(at);
104
+ const head = text.slice(0, limit - tail.length);
105
+ const cut = head.lastIndexOf('. ');
106
+ return `${cut > 0 ? head.slice(0, cut + 1) : head}${tail}`.slice(0, limit);
107
+ }
108
+ return text.slice(0, limit);
109
+ }
55
110
  // Claude Code sends the whole tool input, and a Write of a long document grows
56
111
  // further once JSON-escaped, so tens of kilobytes of markdown must still parse.
57
112
  exports.MAX_PRE_ACTION_INPUT_BYTES = 4 * 1024 * 1024;
@@ -157,6 +212,9 @@ function heldActionHookOutput(harness, plan, code = null) {
157
212
  return {
158
213
  hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: plan.agentText },
159
214
  ...(harness === 'codex' && code ? { systemMessage: (0, host_approval_1.typedReplyUserText)(plan.userText, code) } : {}),
215
+ // Claude Code, a hold nobody can approve in chat (no dialog here, or nobody in this run): one user-only
216
+ // line says so. Output only: the decision above is the same deny, and the line carries no code or id.
217
+ ...(harness === 'claude-code' && plan.userNotice ? { systemMessage: plan.userNotice } : {}),
160
218
  };
161
219
  }
162
220
  /**
@@ -423,7 +481,7 @@ function localControlAllowOutput(harness) {
423
481
  /** The Cursor events where Cursor enforces "ask" with its own approval prompt (the installer's execution hooks). */
424
482
  const CURSOR_ASK_EVENTS = new Set(['beforeShellExecution', 'beforeMCPExecution']);
425
483
  /** 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.';
484
+ 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
485
  exports.SEATBELT_CURSOR_ASK_USER_TEXT = 'Marrow seatbelt (no account linked): approve this action here only if you expected it.';
428
486
  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
487
  /**
@@ -461,7 +519,7 @@ function seatbeltPassOutput(harness, hookEventName, heldBefore) {
461
519
  return {};
462
520
  }
463
521
  function localLoopGuardDenyOutput(harness, reason) {
464
- const bounded = reason.replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 500);
522
+ const bounded = fitHookText(reason, hostTextLimit(harness));
465
523
  if (harness === 'windsurf')
466
524
  return null;
467
525
  if (harness === 'cursor')
@@ -478,7 +536,7 @@ function localLoopGuardDenyOutput(harness, reason) {
478
536
  function emitLoopGuardDenial(harness, reason) {
479
537
  if (harness === 'windsurf') {
480
538
  process.exitCode = 2;
481
- process.stderr.write(`${reason.replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 500)}\n`);
539
+ process.stderr.write(`${fitHookText(reason, hostTextLimit(harness))}\n`);
482
540
  return;
483
541
  }
484
542
  process.stdout.write(JSON.stringify(localLoopGuardDenyOutput(harness, reason)));
@@ -509,13 +567,394 @@ function localControlDisabled() {
509
567
  return false;
510
568
  }
511
569
  }
570
+ // The order classifyTool picks a type and lists surfaces in; earlier types are the more specific (stricter) ones.
571
+ const CLASSIFICATION_TYPES = ['publish', 'deploy', 'review', 'migration', 'audit', 'financial', 'process'];
572
+ const CLASSIFICATION_SURFACES = ['production', 'github', 'npm', 'secrets', 'database', 'financial'];
512
573
  /**
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).
574
+ * The classification of one tool call. A Monitor command (and a native git
575
+ * equivalent such as EnterWorktree) is classified exactly as Bash running it;
576
+ * any other call carrying a command or script string gets the stricter of its
577
+ * own classification and Bash's for that text (its own classification,
578
+ * unchanged, when that is already at least as strict). A PowerShell call is
579
+ * one of those, with Bash's reading in the PowerShell dialect (no read-only
580
+ * allowlist) and never read-only. See toolPolicyReading.
581
+ *
582
+ * `postTool`: the PostToolUse hook (hook.ts) classifies a call that already ran, for the labels of its lifecycle event and the read-only
583
+ * 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
584
+ * 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
585
+ * seatbelt is the one stricter difference). Only the pre-action hook decides (and holds) on what prints the environment.
517
586
  */
518
587
  function classifyTool(event, options = {}) {
588
+ const cwd = absoluteFolder(options.cwd) ?? absoluteFolder(event.cwd);
589
+ if (cwd !== options.cwd)
590
+ options = { ...options, cwd };
591
+ const reading = (0, hook_tool_policy_1.toolPolicyReading)(event);
592
+ if (!reading.shell.length && reading.base === 'named')
593
+ return classificationFrom(classifyAsNamed(event, options));
594
+ const shell = reading.shell.map((twin) => classifyAsNamed(twin, options));
595
+ if (reading.exact) {
596
+ const merged = shell.reduce(stricterParts);
597
+ return classificationFrom(reading.bashLabel ? merged : { ...merged, tool: ownToolLabel(event) });
598
+ }
599
+ const own = reading.base === 'read' ? { ...classifyAsNamed((0, hook_tool_policy_1.readEquivalent)(event), options), tool: ownToolLabel(event) }
600
+ : reading.base === 'non_acting' ? nonActingParts(event)
601
+ : classifyAsNamed(event, options);
602
+ const merged = shell.reduce(stricterParts, own);
603
+ return classificationFrom(reading.dialect === 'powershell' ? { ...merged, readOnly: false } : merged);
604
+ }
605
+ function ownToolLabel(event) {
606
+ return String(event.tool_name || 'tool').slice(0, 64);
607
+ }
608
+ // A call whose words are not actions (toolPolicyReading): reported, never read-only, never protected by its text.
609
+ function nonActingParts(event) {
610
+ return { tool: ownToolLabel(event), type: 'process', surfaces: [], readOnly: false, protected: false };
611
+ }
612
+ /** `a`, unchanged, when it is at least as strict as `b` on every axis; otherwise the stricter of each. */
613
+ function stricterParts(a, b) {
614
+ const rank = (type) => {
615
+ const index = CLASSIFICATION_TYPES.indexOf(type);
616
+ return index < 0 ? CLASSIFICATION_TYPES.length : index;
617
+ };
618
+ const covers = (!a.readOnly || b.readOnly) && (a.protected || !b.protected) && rank(a.type) <= rank(b.type)
619
+ && b.surfaces.every((surface) => a.surfaces.includes(surface));
620
+ if (covers)
621
+ return a;
622
+ const surfaces = new Set([...a.surfaces, ...b.surfaces]);
623
+ return {
624
+ tool: a.tool,
625
+ type: rank(a.type) <= rank(b.type) ? a.type : b.type,
626
+ surfaces: [...CLASSIFICATION_SURFACES.filter((surface) => surfaces.has(surface)), ...[...surfaces].filter((surface) => !CLASSIFICATION_SURFACES.includes(surface))],
627
+ readOnly: a.readOnly && b.readOnly,
628
+ protected: a.protected || b.protected,
629
+ };
630
+ }
631
+ let envDumpTools;
632
+ let lastEnvDumpAnswer = null;
633
+ // Every form that prints the environment holds one of these words (env also covers environ, getenv, printenv, ENV and every wrapper's
634
+ // 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
635
+ // (what the mapper needs, and nothing less), each unless an expansion could spell it:
636
+ // set only a bare `set` (the mapper's rule is no operand: `set -e` and the word in a message are not);
637
+ // ps only with an `e` or `E` after it (BSD `ps e`, `-E`); `ps aux` and `docker ps` are not;
638
+ // jq only with `env` in the text (a filter reads the environment by that word);
639
+ // proc only as `/proc`, or `process[...]` (a computed name); `process.env` holds `env`.
640
+ // 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
641
+ // 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
642
+ // holds one of them and a narrowed word is read exactly as before.
643
+ 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
644
+ 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;
645
+ const NARROWED_WORDS = /\bjq\b|\bps\b|\bset\b|proc/i;
646
+ const EXPANSION = /[$`{]/;
647
+ const ONE_NAME_READ = /\bprocess\.env\.(?!(?:items|keys|copy)\b)([A-Za-z_]\w*)/g;
648
+ const PLAIN_GIT = /^\s*git\s[^$`;&|<>(){}\\\n]*$/;
649
+ /** False for a command that cannot print the environment: the keyed hook then never loads the mapper for it. */
650
+ function mayPrintEnvironment(command) {
651
+ // An ANSI-C quote can spell a word in escapes.
652
+ if (command.includes("$'") || command.includes('$"'))
653
+ return true;
654
+ // Quotes and backslashes split a word without changing it (`e'n'v`).
655
+ const text = command.replace(/['"\\]/g, '');
656
+ // 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).
657
+ let words = PLAIN_GIT.test(command) && !exports.ENV_FILTER_SECRET_NAME.test(command) ? text.replace(/-env(?![\w])|env-/gi, '') : text;
658
+ words = words.replace(ONE_NAME_READ, (read, name) => (exports.ENV_FILTER_SECRET_NAME.test(name) ? read : ''));
659
+ if (ENV_DUMP_WORDS.test(words))
660
+ return true;
661
+ if (globInFolderPart(words))
662
+ return true;
663
+ // `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).
664
+ const ps = /\bps\b/i.exec(words);
665
+ if (ps && /[eE]/.test(words.slice(ps.index + 2)))
666
+ return true;
667
+ return EXPANSION.test(text) && NARROWED_WORDS.test(text);
668
+ }
669
+ /**
670
+ * A path whose folder part holds a glob character can spell `/proc/self/task/1/environ` with neither word in it (`/p*\/self/e*`, `/???c/s*\/e*`,
671
+ * `/p[r]oc/self/en[v]iron`). A word that starts like a path (`/`, `~`, `.`, `$`), or any word after a `cd` (or when the hook itself runs in `/` or
672
+ * under `/proc`), is asked of the mapper when a glob sits in a folder of it. One pass over the words, no backtracking.
673
+ */
674
+ const WORD_BREAK = /[\s=:<>(;&|]+/;
675
+ const GLOB_CHAR = /[*?[]/;
676
+ const PATH_START = /^[/~.$]/;
677
+ const CHANGES_FOLDER = /\b(?:cd|pushd|popd)\b/;
678
+ function runsAtFilesystemRoot() {
679
+ try {
680
+ const cwd = process.cwd();
681
+ return cwd === '/' || cwd === '/proc' || cwd.startsWith('/proc/');
682
+ }
683
+ catch {
684
+ return false;
685
+ }
686
+ }
687
+ function globInFolderPart(words) {
688
+ const anywhere = CHANGES_FOLDER.test(words) || runsAtFilesystemRoot();
689
+ for (const word of words.split(WORD_BREAK)) {
690
+ const slash = word.lastIndexOf('/');
691
+ if (slash > 0 && (anywhere || PATH_START.test(word)) && GLOB_CHAR.test(word.slice(0, slash)))
692
+ return true;
693
+ }
694
+ return false;
695
+ }
696
+ const MAPPER_UNSURE = /^(?:mapper_error|budget_)/;
697
+ /**
698
+ * False for a command that cannot read the host-approvals state file: the keyed hook then does not load the mapper for it on that account. A command that
699
+ * can spell the folder, or a folder that holds it, holds the word `approvals` or `marrow` (quotes and backslashes split a word without changing it), an
700
+ * ANSI-C quote, a glob in a folder part of a path that starts at the home (`~/.m*\/h*\/state.json`; `cd ~/.marrow; cat h*\/s*` holds `marrow`), or a tree walker (grep, rg, find, tar, cp, ...) given
701
+ * the home or the root, or given `.`/`..`/`*` while the hook runs in the home or the root. A path that only a computed name spells
702
+ * (`cd ~; cd .m*; cd h*; cat s*`) is read by the keyless mapper but not asked of it here.
703
+ */
704
+ const TREE_WALKERS = /\b(?:grep|egrep|fgrep|zgrep|rg|ag|ack|find|tar|zip|rsync|cp|scp|xargs|7z|bsdtar|cpio|pax)\b/;
705
+ const HOME_OR_ROOT_OPERAND = /(?:^|[\s=:'"])(?:~|\$HOME|\$\{HOME\}|\/home|\/Users|\/)\/?(?=[\s'";&|)]|$)/;
706
+ const CWD_RELATIVE_OPERAND = /(?:^|\s)(?:\.\.?|\*|\.\/\*|\.\*)\/?(?=\s|$)/;
707
+ function runsInHomeOrAbove() {
708
+ try {
709
+ const home = (process.env.HOME || (0, node_os_1.homedir)()).replace(/\/+$/, '');
710
+ const cwd = process.cwd().replace(/\/+$/, '') || '/';
711
+ return cwd === '/' || cwd === home || home.startsWith(`${cwd}/`);
712
+ }
713
+ catch {
714
+ return false;
715
+ }
716
+ }
717
+ const HOME_PATH_START = /^(?:~|\$HOME|\$\{HOME\}|\.|\/home\/|\/Users\/|\/root\/)/;
718
+ /** A glob in a folder part of a path that starts at the home or in the working folder (the env-dump filter keeps its own glob rule). */
719
+ function homeGlobInFolderPart(words) {
720
+ for (const word of words.split(WORD_BREAK)) {
721
+ const slash = word.lastIndexOf('/');
722
+ if (slash > 0 && GLOB_CHAR.test(word.slice(0, slash)) && HOME_PATH_START.test(word))
723
+ return true;
724
+ }
725
+ return false;
726
+ }
727
+ function mayReadApprovalState(command) {
728
+ if (command.includes("$'") || command.includes('$"'))
729
+ return true;
730
+ const text = command.replace(/['"\\]/g, '').toLowerCase();
731
+ if (text.includes('approvals') || text.includes('marrow') || homeGlobInFolderPart(text))
732
+ return true;
733
+ if (!TREE_WALKERS.test(text))
734
+ return false;
735
+ return HOME_OR_ROOT_OPERAND.test(command) || (CWD_RELATIVE_OPERAND.test(command) && runsInHomeOrAbove());
736
+ }
737
+ /**
738
+ * False for a command that cannot read a secret FILE inside code the keyed policy does not parse (a quoted `bash -c`, `eval`, `sudo sh -c`, `ssh host` or
739
+ * `xargs` string is one word to it). A command that can spell one holds a word of the secret-path table (src/secret-paths.ts: `.env`, `.npmrc`, `.ssh`, ...)
740
+ * or of the policy's secret file names, a word that starts with a dot and holds a glob or an expansion (`.e*`, `.e$X`), a variable set to a dot
741
+ * word beside any expansion (`A=.e; B=nv; cat $A$B`), or an expansion of a secret-looking variable (`$GITHUB_TOKEN`: ENV_FILTER_SECRET_NAME). Env and
742
+ * process words are not here (mayPrintEnvironment has them), nor are the programs that print a credential, nor an ANSI-C quote: the same text-only
743
+ * answer as before holds for those. A name that variables spell with no dot word in an assignment (`A=x.e`) is read by the keyless mapper but not asked
744
+ * of it here, as for the state file above. A dot word that only a brace spells (`cat .en{v,x}`) is not asked either: test/pre-action-hook.test.js pins it as
745
+ * not protected, and that expectation is left as it is.
746
+ */
747
+ const SECRET_FILE_WORDS = /(?<!process)\.env(?![a-z0-9_])|\.(?:ssh|aws|azure|kube|docker|gnupg|config|claude|marrow)|npmrc|netrc|pypirc|pgpass|git-credentials|dev\.vars|id_(?:rsa|dsa|ecdsa|ed25519)|hosts\.yml|settings\.local|htpasswd|my\.cnf|yarnrc|\.(?:pem|key|p12|pfx|jks|keystore|kdbx|ppk)\b/;
748
+ // A word (split at white space and `/`) with a dot that starts it, or follows one of `=:(;&|<>`, and a glob character or an expansion after that dot (`.e*`,
749
+ // `a=.e?v`, `.e$X`). One pass over the words and the first such dot only (a later dot's rest is part of its rest): linear, where one regular expression
750
+ // for it took seconds on a 100 KB command of `a=.b` repeated.
751
+ const DOT_LEAD = '=:(;&|<>';
752
+ function dotWordSpelled(text) {
753
+ for (const word of text.split(/[\s/]+/)) {
754
+ let dot = word.indexOf('.');
755
+ while (dot > 0 && !DOT_LEAD.includes(word[dot - 1]))
756
+ dot = word.indexOf('.', dot + 1);
757
+ if (dot !== -1 && /[*?[$`]/.test(word.slice(dot + 1)))
758
+ return true;
759
+ }
760
+ return false;
761
+ }
762
+ // A variable set to a dot word (`A=.e; B=nv; cat $A$B`) and an expansion anywhere: the name may be spelled by joining them.
763
+ const ASSIGNED_DOT_WORD = /(?:^|[\s;&|(])[a-z_]\w*=\.[^\s/;&|)]*/;
764
+ const SECRET_VARIABLE_USE = new RegExp(`\\$\\{?!?\\w*(?:${exports.ENV_FILTER_SECRET_NAME.source.toLowerCase()})`);
765
+ function mayReadSecret(command) {
766
+ const text = command.replace(/['"\\]/g, '').toLowerCase();
767
+ return SECRET_FILE_WORDS.test(text) || dotWordSpelled(text) || (text.includes('$') && ASSIGNED_DOT_WORD.test(text)) || SECRET_VARIABLE_USE.test(text);
768
+ }
769
+ /**
770
+ * `link` when a word of the command is a symbolic link, or a path through one (it reads as what it points at, whatever its name; the text alone cannot say);
771
+ * with `onlySecret` (a command the policy reads whole), only a link whose target is a secret path. `unread` when the command has more words than the limit.
772
+ * A word is read as a path from the working folder, the home (`~`, `$HOME`) and any folder the command names that exists (a `cd` target); words that hold an
773
+ * expansion or a glob are not paths yet. The mapper is then given the real `realpath` for this command, and only for it. A command with more words than the
774
+ * limit is asked of the mapper, so an unread word is never a pass.
775
+ */
776
+ const LINK_WORD_LIMIT = 2048;
777
+ // A subshell `(cat x)`, a substitution `$(cat x)` and a backtick pair `cat x` end a word with `)` or a backtick, so the words are read split there too
778
+ // (WORD_BREAK has neither). A name that holds one of those characters is still read whole: a word is a path in either spelling.
779
+ const LINK_WORD_BREAK = /[\s=:<>(;&|)`}]+/;
780
+ /** `value` when it is an absolute path (the keyless seatbelt's own test for an event's cwd, and no NUL byte), else undefined. */
781
+ function absoluteFolder(value) {
782
+ return typeof value === 'string' && value.startsWith('/') && !value.includes('\0') ? value : undefined;
783
+ }
784
+ function passwdHome() {
785
+ try {
786
+ const home = (0, node_os_1.userInfo)().homedir;
787
+ return typeof home === 'string' && home.startsWith('/') ? home : null;
788
+ }
789
+ catch {
790
+ return null;
791
+ }
792
+ }
793
+ /**
794
+ * The folders a command's words are read in. The hook process's own (its working folder and $HOME) are always one: that is the reading every earlier build
795
+ * gave, so no answer is looser than it. An event that names an absolute working folder adds the keyless seatbelt's reading of it (that folder and the passwd home),
796
+ * so a host that spawns the hook in another folder is read as the seatbelt reads it, a relative link name included. Any of them can hold the command.
797
+ */
798
+ function wordContexts(eventCwd) {
799
+ const contexts = [];
800
+ try {
801
+ contexts.push({ cwd: process.cwd(), home: process.env.HOME || (0, node_os_1.homedir)() });
802
+ }
803
+ catch { /* the hook's own folder is gone: the event's is read alone */ }
804
+ const eventContext = eventCwd === undefined ? null : { cwd: eventCwd, home: passwdHome() || '/nonexistent-home' };
805
+ if (eventContext && !contexts.some((own) => own.cwd === eventContext.cwd && own.home === eventContext.home))
806
+ contexts.push(eventContext);
807
+ return contexts;
808
+ }
809
+ function namesSymlink(command, onlySecret, contexts) {
810
+ const text = command.replace(/['"\\]/g, '');
811
+ const readable = (word) => word.length > 0 && word.length <= 4096 && !word.startsWith('-') && !/[*?[{$`]/.test(word.replace(/^\$\{?HOME\}?(?=\/|$)/, ''));
812
+ const whole = text.split(WORD_BREAK).filter(readable);
813
+ const known = new Set(whole);
814
+ const words = whole.concat(text.split(LINK_WORD_BREAK).filter((word) => readable(word) && !known.has(word)));
815
+ let unread = false;
816
+ for (const where of contexts) {
817
+ const answer = namesSymlinkIn(command, words, onlySecret, where);
818
+ if (answer === 'link')
819
+ return 'link';
820
+ if (answer === 'unread')
821
+ unread = true;
822
+ }
823
+ return unread ? 'unread' : 'none';
824
+ }
825
+ function namesSymlinkIn(command, words, onlySecret, where) {
826
+ const cwd = where.cwd;
827
+ const home = where.home.replace(/\/+$/, '');
828
+ const bases = [cwd];
829
+ const moved = (word, base) => {
830
+ const lexical = word === '~' || word.startsWith('~/') ? (0, node_path_1.resolve)(home, word.slice(2)) : word.startsWith('$HOME') || word.startsWith('${HOME}') ? (0, node_path_1.resolve)(home, word.replace(/^\$\{?HOME\}?\/?/, '')) : (0, node_path_1.resolve)(base, word);
831
+ // `/proc/self` and `/proc/thread-self` are links the kernel makes; a word under /proc is read by the env-dump words, not as a link.
832
+ if (lexical === '/proc' || lexical.startsWith('/proc/'))
833
+ return false;
834
+ try {
835
+ const real = (0, node_fs_1.realpathSync)(lexical);
836
+ return real !== lexical && (!onlySecret || (0, hook_tool_policy_1.isSecretPath)(real));
837
+ }
838
+ catch {
839
+ return false;
840
+ }
841
+ };
842
+ let reads = 0;
843
+ if (CHANGES_FOLDER.test(command)) {
844
+ for (const word of words) {
845
+ if (bases.length >= 9)
846
+ break;
847
+ const lexical = word.startsWith('/') ? word : (0, node_path_1.resolve)(cwd, word);
848
+ try {
849
+ if ((0, node_fs_1.statSync)(lexical).isDirectory())
850
+ bases.push(lexical);
851
+ }
852
+ catch { /* not a folder that exists */ }
853
+ }
854
+ }
855
+ for (const word of words) {
856
+ for (const base of bases) {
857
+ reads += 1;
858
+ if (reads > LINK_WORD_LIMIT)
859
+ return 'unread';
860
+ if (moved(word, base))
861
+ return 'link';
862
+ }
863
+ }
864
+ return 'none';
865
+ }
866
+ const fsGone = (error) => ['ENOENT', 'ENOTDIR'].includes(error?.code || '');
867
+ const fsDenied = (error) => ['EACCES', 'EPERM'].includes(error?.code || '');
868
+ const REAL_FS_VIEW = {
869
+ readFile: () => null,
870
+ realpath(path) { try {
871
+ return (0, node_fs_1.realpathSync)(path);
872
+ }
873
+ catch (error) {
874
+ if (fsGone(error))
875
+ return null;
876
+ throw error;
877
+ } },
878
+ readdir(path) { try {
879
+ return (0, node_fs_1.readdirSync)(path);
880
+ }
881
+ catch (error) {
882
+ if (fsGone(error) || fsDenied(error))
883
+ return null;
884
+ throw error;
885
+ } },
886
+ };
887
+ const TEXT_ONLY_FS_VIEW = { readFile: () => null, realpath: (path) => path };
888
+ // What the second run may add to the first: an S4 hit on a PATH (a secret file named, a link to one, a glob or pattern that can spell one, a secret folder
889
+ // walked). Never `ps_environment_loose` or a credential-printing command (`gh auth token`, `aws sts get-session-token`: not a read of a file), and never a
890
+ // reason the mapper only gives a word that is no path (the pattern of `grep .env README.md`) in a command the policy reads whole (policyReadsAllWords).
891
+ const SECOND_RUN_REASONS = /_secret_(?:path|pattern|dir)$|_secret_name$|_unknown_link$|^copy_secret_directory$|^tool_grep_glob_secret$|^tripwire_env_file$/;
892
+ // `eventCwd` comes first so classifyAsNamed can bind it and hand the policy a one-argument check.
893
+ function shellPrintsEnvironment(eventCwd, command) {
894
+ const contexts = wordContexts(eventCwd);
895
+ const where = contexts.map((context) => `${context.cwd}\0${context.home}`).join('\n');
896
+ if (lastEnvDumpAnswer && lastEnvDumpAnswer.command === command && lastEnvDumpAnswer.where === where)
897
+ return lastEnvDumpAnswer.dumps;
898
+ const secretNamed = mayReadSecret(command);
899
+ const readsWhole = (0, hook_tool_policy_1.policyReadsAllWords)(command);
900
+ const links = namesSymlink(command, readsWhole, contexts);
901
+ // A command the policy reads whole is held when a link in it points at a secret path (the policy judges the target as it judges any path); any other
902
+ // command that names a link, or has too many words to read, is asked of the mapper.
903
+ const linkedSecret = readsWhole && links === 'link';
904
+ const linked = linkedSecret || (readsWhole ? links === 'unread' : links !== 'none');
905
+ if (!mayPrintEnvironment(command) && !mayReadApprovalState(command) && !secretNamed && !linked)
906
+ return false;
907
+ if (linkedSecret) {
908
+ lastEnvDumpAnswer = { command, where, dumps: true };
909
+ return true;
910
+ }
911
+ let dumps;
912
+ try {
913
+ if (envDumpTools === undefined) {
914
+ try {
915
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
916
+ envDumpTools = { mapper: require('./seatbelt/mapper'), reasons: require('./seatbelt/env-dump').ENV_DUMP_REASONS };
917
+ }
918
+ catch {
919
+ envDumpTools = null;
920
+ }
921
+ }
922
+ if (!envDumpTools)
923
+ dumps = ENV_DUMP_WORDS.test(command) || mayReadApprovalState(command) || secretNamed || linked;
924
+ else {
925
+ const { mapper, reasons } = envDumpTools;
926
+ const namesMarrow = /approvals|\.marrow/i.test(command.replace(/['"\\]/g, ''));
927
+ const ask = (fs, at) => mapper.evaluateSeatbeltShell(command, {
928
+ cwd: at.cwd,
929
+ home: at.home,
930
+ tmpdir: '/tmp',
931
+ platform: process.platform,
932
+ fs,
933
+ git: { toplevel: () => null, getAll: () => [], currentBranch: () => null, remoteHead: () => null },
934
+ });
935
+ // The same answer holds both reads: the env-dump reasons, and the host-approvals state file: the reason the mapper adds to a hit on it, or
936
+ // any S4 or S0 stop of a command that names the folder or ~/.marrow (a wrapper, a copy, a walk that reaches it); and a mapper that cannot decide
937
+ // (an error, an exhausted budget) on a command that may read a secret.
938
+ const unsure = ENV_DUMP_WORDS.test(command) || mayReadApprovalState(command) || secretNamed || linked;
939
+ const holds = (decision, paths) => decision.hits.some((hit) => (hit.category === 'S4' && (reasons.has(hit.reason) || hit.reason === secret_paths_1.APPROVAL_STATE_REASON || (paths && SECOND_RUN_REASONS.test(hit.reason)))) || ((hit.category === 'S4' || hit.category === 'S0') && namesMarrow)
940
+ || (MAPPER_UNSURE.test(hit.reason) && unsure));
941
+ // The text alone is read first, exactly as before. A command that can name a secret file inside code the policy does not read (a wrapper, a glob, a
942
+ // variable, a name it cannot parse), or names a link, is then read again with the real `realpath` and `readdir` (no file contents): a word is a secret
943
+ // path only where the file is there (`process.env` in `grep -rn process.env src` names no file; the text alone cannot say), a link reads as its target
944
+ // and a glob as the names it expands to. Either run can hold the command, so no answer is looser than the text's.
945
+ if (!contexts.length)
946
+ throw new Error('no working folder');
947
+ dumps = contexts.some((at) => holds(ask(TEXT_ONLY_FS_VIEW, at), false) || (((secretNamed && !readsWhole) || linked) && holds(ask(REAL_FS_VIEW, at), true)));
948
+ }
949
+ }
950
+ catch {
951
+ dumps = ENV_DUMP_WORDS.test(command) || mayReadApprovalState(command) || secretNamed || linked;
952
+ }
953
+ lastEnvDumpAnswer = { command, where, dumps };
954
+ return dumps;
955
+ }
956
+ /** The classification of a call under its own tool name only (every tool before the built-ins were hooked). */
957
+ function classifyAsNamed(event, options) {
519
958
  const tool = String(event.tool_name || 'tool').slice(0, 64);
520
959
  const normalizedTool = (0, hook_tool_policy_1.normalizeHookToolName)(tool);
521
960
  // Editing and task tools are judged by their target; shell-like words in the
@@ -524,8 +963,10 @@ function classifyTool(event, options = {}) {
524
963
  const command = shellGoverned ? (0, hook_tool_policy_1.hookToolCommand)(event) : '';
525
964
  const input = (0, hook_tool_policy_1.toolClassificationText)(event).toLowerCase();
526
965
  // 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);
966
+ // The PostToolUse path passes no check (options.postTool): the mapper is never loaded for it.
967
+ const printsEnvironment = options.postTool === true ? undefined : shellPrintsEnvironment.bind(null, options.cwd);
968
+ const readOnly = options.ownUnproven !== true && (0, hook_tool_policy_1.isReadOnlyToolEventAsNamed)(event, printsEnvironment);
969
+ const secretAccess = (0, hook_tool_policy_1.isSecretMaterialAccessAsNamed)(event, printsEnvironment);
529
970
  const protectedShellCommand = shellGoverned && (0, hook_tool_policy_1.isProtectedShellMutation)(command);
530
971
  const infrastructureDeployment = /\b(?:kubectl|terraform|pulumi|helm)\b/.test(command.toLowerCase())
531
972
  && protectedShellCommand;
@@ -555,6 +996,11 @@ function classifyTool(event, options = {}) {
555
996
  || protectedShellCommand
556
997
  || ((0, hook_tool_policy_1.isMcpHookTool)(event.tool_name) && (options.ownUnproven === true || !(0, hook_tool_policy_1.isOfficialMarrowMcpTool)(event.tool_name)))
557
998
  || (['use_mcp_tool', 'use_tool'].includes(normalizedTool) && !(0, hook_tool_policy_1.isOfficialMarrowMcpEvent)(event)));
999
+ return { tool, type, surfaces, readOnly, protected: protectedAction };
1000
+ }
1001
+ function classificationFrom(parts) {
1002
+ const { tool, type, surfaces, readOnly } = parts;
1003
+ const protectedAction = parts.protected;
558
1004
  const risk = readOnly ? 'low' : protectedAction ? 'high' : 'medium';
559
1005
  const target = surfaces.includes('npm') ? `npm:${type}`
560
1006
  : surfaces.includes('github') ? `github:${type}`
@@ -585,7 +1031,8 @@ function cursorPreActionHookOutput(result) {
585
1031
  return { permission: 'deny', user_message: denial, agent_message: denial };
586
1032
  }
587
1033
  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);
1034
+ // 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.
1035
+ 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
1036
  return {
590
1037
  permission: 'deny',
591
1038
  user_message: denial,
@@ -608,12 +1055,10 @@ function clinePreActionHookOutput(result) {
608
1055
  };
609
1056
  }
610
1057
  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
1058
  return {
614
1059
  cancel: true,
615
- errorMessage: credentialsUnavailable
616
- ? result.enforcementError
1060
+ errorMessage: result.credentialsUnavailable === true
1061
+ ? fitHookText(result.enforcementError, CLINE_ERROR_LIMIT)
617
1062
  : result.failure === 'credential_scope'
618
1063
  ? '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
1064
  : result.failure === 'unavailable'
@@ -889,6 +1334,18 @@ function installPreActionHook(startDir = process.cwd()) {
889
1334
  fs.writeFileSync(path, JSON.stringify(settings, null, 2) + '\n');
890
1335
  return { settingsPath: path, installed: reconciled.changed };
891
1336
  }
1337
+ /**
1338
+ * `marrow-mcp seatbelt-status`: exactly one line of JSON, exit 0. Read-only. On any error the unknown line.
1339
+ * The installer's doctor reads it; it is not a user feature.
1340
+ */
1341
+ function runSeatbeltStatusCommand() {
1342
+ let status = { mode: 'unknown', reasons: [] };
1343
+ try {
1344
+ status = require('./seatbelt/hook').seatbeltStatus();
1345
+ }
1346
+ catch { /* the unknown line */ }
1347
+ process.stdout.write(`${JSON.stringify({ mode: status.mode, reasons: status.reasons })}\n`);
1348
+ }
892
1349
  /**
893
1350
  * I3: whether a real outage (the gate never answered) holds this action. Never looser than master,
894
1351
  * which allowed every action in an outage. A shell command is read by the seatbelt mapper: held for an
@@ -908,6 +1365,37 @@ function outageHolds(classifiedHighRisk, read) {
908
1365
  return false;
909
1366
  return classifiedHighRisk;
910
1367
  }
1368
+ /**
1369
+ * The hold text of the keyless fallback, naming its reason. The words live in the seatbelt module (a lazy
1370
+ * require, like the rest of the keyless path); if it cannot load the generic wording stands.
1371
+ */
1372
+ function setupUnconfirmedHold(modeReason) {
1373
+ try {
1374
+ return require('./seatbelt/fallback-copy').setupUnconfirmedText(modeReason);
1375
+ }
1376
+ catch {
1377
+ return exports.SETUP_UNCONFIRMED_TEXT;
1378
+ }
1379
+ }
1380
+ let keylessHoldTexts = null;
1381
+ /**
1382
+ * True only for the fixed texts the keyless path sets as enforcementError (NO_KEY_TEXT, CLAIMED_NO_KEY_TEXT, or one
1383
+ * setupUnconfirmedHold text per row of the fallback table). An exact match against that generated set: never a prefix,
1384
+ * a pattern or a flag, so no other text, whatever it starts with, is taken for one of them.
1385
+ */
1386
+ function isKeylessHoldText(text) {
1387
+ if (typeof text !== 'string')
1388
+ return false;
1389
+ if (text === exports.NO_KEY_TEXT || text === exports.CLAIMED_NO_KEY_TEXT || text === exports.SETUP_UNCONFIRMED_TEXT)
1390
+ return true;
1391
+ try {
1392
+ keylessHoldTexts ??= new Set(require('./seatbelt/fallback-copy').setupUnconfirmedTexts());
1393
+ return keylessHoldTexts.has(text);
1394
+ }
1395
+ catch {
1396
+ return false;
1397
+ }
1398
+ }
911
1399
  function seatbeltEvent(source, rawEvent) {
912
1400
  const raw = asRecord(rawEvent) || {};
913
1401
  const event = source;
@@ -1025,6 +1513,9 @@ async function runPreActionHook(input, watch) {
1025
1513
  const sessionId = resolved.sessionId || source.session_id || source.conversation_id || source.task_id
1026
1514
  || (0, hook_contract_1.stableSessionWorkflowId)(undefined, [identity.harness, process.cwd()]);
1027
1515
  const agentId = identity.agent_id;
1516
+ // With a resolved key, the no-device-key fallback may make its approval state (nokeyFallbackKey); with none it never does.
1517
+ if (resolved.apiKey)
1518
+ (0, host_approval_state_1.allowNokeyFallbackCreate)();
1028
1519
  const correlation = (0, hook_contract_1.stableToolCorrelation)({ ...source, session_id: sessionId });
1029
1520
  const loopOperation = {
1030
1521
  sessionId,
@@ -1074,6 +1565,8 @@ async function runPreActionHook(input, watch) {
1074
1565
  // The keyless hold text when the seatbelt does not answer: master's no-key text in seatbelt mode (a
1075
1566
  // tool the mapper does not recognise, audit H1), the claimed_no_key texts otherwise.
1076
1567
  let keylessSeatbeltMode = false;
1568
+ // Why the stricter fallback is on (words only, read from the mode resolution; the mode is decided elsewhere).
1569
+ let keylessModeReason = null;
1077
1570
  if (!resolved.apiKey) {
1078
1571
  let keyless;
1079
1572
  try {
@@ -1084,6 +1577,7 @@ async function runPreActionHook(input, watch) {
1084
1577
  }
1085
1578
  keylessAccountLinked = keyless.accountLinked === true;
1086
1579
  keylessSeatbeltMode = keyless.mode === 'seatbelt';
1580
+ keylessModeReason = typeof keyless.modeReason === 'string' ? keyless.modeReason : null;
1087
1581
  if (keyless.stopText) {
1088
1582
  emitLoopGuardDenial(identity.harness, keyless.stopText);
1089
1583
  return;
@@ -1142,7 +1636,8 @@ async function runPreActionHook(input, watch) {
1142
1636
  runtime: null,
1143
1637
  permit: null,
1144
1638
  protectedRisk: enforcementRequired,
1145
- enforcementError: keylessSeatbeltMode ? exports.NO_KEY_TEXT : keylessAccountLinked ? exports.CLAIMED_NO_KEY_TEXT : exports.SETUP_UNCONFIRMED_TEXT,
1639
+ enforcementError: keylessSeatbeltMode ? exports.NO_KEY_TEXT : keylessAccountLinked ? exports.CLAIMED_NO_KEY_TEXT : setupUnconfirmedHold(keylessModeReason),
1640
+ credentialsUnavailable: true,
1146
1641
  }, identity.harness);
1147
1642
  return;
1148
1643
  }
@@ -1185,7 +1680,9 @@ async function runPreActionHook(input, watch) {
1185
1680
  return;
1186
1681
  }
1187
1682
  if (waited?.kind === 'deny') {
1188
- emitHeldPlan(identity.harness, { kind: 'deny', agentText: waited.agentText, userText: waited.userText, code: Boolean(waited.hold.code) }, waited.hold.code);
1683
+ // 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).
1684
+ const userNotice = identity.harness === 'claude-code' ? (0, host_approval_1.holdNotice)(waited.hold, (0, host_approval_1.heldKindLabel)(localApprovalCategories(classified)), waited.waiting === true) : null;
1685
+ emitHeldPlan(identity.harness, { kind: 'deny', agentText: waited.agentText, userText: waited.userText, code: Boolean(waited.hold.code), ...(userNotice ? { userNotice } : {}) }, waited.hold.code);
1189
1686
  await afterAnswer(holdContext, { link: waited.deferredLink ? waited.hold : null });
1190
1687
  return;
1191
1688
  }