@getmarrow/mcp 3.9.102 → 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 (152) hide show
  1. package/README.md +71 -24
  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 +1716 -1644
  7. package/dist/cli.js.map +1 -1
  8. package/dist/codex-plugin-pins.d.ts +19 -0
  9. package/dist/codex-plugin-pins.d.ts.map +1 -0
  10. package/dist/codex-plugin-pins.js +61 -0
  11. package/dist/codex-plugin-pins.js.map +1 -0
  12. package/dist/codex-plugins.d.ts +34 -0
  13. package/dist/codex-plugins.d.ts.map +1 -0
  14. package/dist/codex-plugins.js +240 -0
  15. package/dist/codex-plugins.js.map +1 -0
  16. package/dist/env.d.ts +8 -0
  17. package/dist/env.d.ts.map +1 -1
  18. package/dist/env.js +83 -4
  19. package/dist/env.js.map +1 -1
  20. package/dist/hook-context.d.ts.map +1 -1
  21. package/dist/hook-context.js +25 -0
  22. package/dist/hook-context.js.map +1 -1
  23. package/dist/hook-contract.d.ts +4 -3
  24. package/dist/hook-contract.d.ts.map +1 -1
  25. package/dist/hook-contract.js +43 -5
  26. package/dist/hook-contract.js.map +1 -1
  27. package/dist/hook-pre-action.d.ts +80 -3
  28. package/dist/hook-pre-action.d.ts.map +1 -1
  29. package/dist/hook-pre-action.js +435 -29
  30. package/dist/hook-pre-action.js.map +1 -1
  31. package/dist/hook-tool-policy.d.ts +73 -4
  32. package/dist/hook-tool-policy.d.ts.map +1 -1
  33. package/dist/hook-tool-policy.js +287 -18
  34. package/dist/hook-tool-policy.js.map +1 -1
  35. package/dist/hook.d.ts +1 -1
  36. package/dist/hook.d.ts.map +1 -1
  37. package/dist/hook.js +15 -7
  38. package/dist/hook.js.map +1 -1
  39. package/dist/host-approval-state.d.ts +7 -0
  40. package/dist/host-approval-state.d.ts.map +1 -1
  41. package/dist/host-approval-state.js +15 -0
  42. package/dist/host-approval-state.js.map +1 -1
  43. package/dist/host-approval.d.ts +22 -1
  44. package/dist/host-approval.d.ts.map +1 -1
  45. package/dist/host-approval.js +65 -4
  46. package/dist/host-approval.js.map +1 -1
  47. package/dist/host-session.d.ts +11 -0
  48. package/dist/host-session.d.ts.map +1 -1
  49. package/dist/host-session.js +49 -0
  50. package/dist/host-session.js.map +1 -1
  51. package/dist/index.d.ts +5 -1
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +23 -11
  54. package/dist/index.js.map +1 -1
  55. package/dist/keyless-server.d.ts +53 -0
  56. package/dist/keyless-server.d.ts.map +1 -0
  57. package/dist/keyless-server.js +230 -0
  58. package/dist/keyless-server.js.map +1 -0
  59. package/dist/normalized-action.d.ts +31 -6
  60. package/dist/normalized-action.d.ts.map +1 -1
  61. package/dist/normalized-action.js +550 -105
  62. package/dist/normalized-action.js.map +1 -1
  63. package/dist/own-server-identity.d.ts +142 -0
  64. package/dist/own-server-identity.d.ts.map +1 -0
  65. package/dist/own-server-identity.js +1123 -0
  66. package/dist/own-server-identity.js.map +1 -0
  67. package/dist/own-tool.d.ts +21 -9
  68. package/dist/own-tool.d.ts.map +1 -1
  69. package/dist/own-tool.js +25 -10
  70. package/dist/own-tool.js.map +1 -1
  71. package/dist/redact.d.ts +23 -0
  72. package/dist/redact.d.ts.map +1 -1
  73. package/dist/redact.js +181 -8
  74. package/dist/redact.js.map +1 -1
  75. package/dist/seatbelt/classifier-pin.d.ts +15 -0
  76. package/dist/seatbelt/classifier-pin.d.ts.map +1 -0
  77. package/dist/seatbelt/classifier-pin.js +26 -0
  78. package/dist/seatbelt/classifier-pin.js.map +1 -0
  79. package/dist/seatbelt/env-dump.d.ts +45 -0
  80. package/dist/seatbelt/env-dump.d.ts.map +1 -0
  81. package/dist/seatbelt/env-dump.js +308 -0
  82. package/dist/seatbelt/env-dump.js.map +1 -0
  83. package/dist/seatbelt/fallback-copy.d.ts +16 -0
  84. package/dist/seatbelt/fallback-copy.d.ts.map +1 -0
  85. package/dist/seatbelt/fallback-copy.js +68 -0
  86. package/dist/seatbelt/fallback-copy.js.map +1 -0
  87. package/dist/seatbelt/guards.d.ts +10 -0
  88. package/dist/seatbelt/guards.d.ts.map +1 -0
  89. package/dist/seatbelt/guards.js +509 -0
  90. package/dist/seatbelt/guards.js.map +1 -0
  91. package/dist/seatbelt/hook.d.ts +91 -0
  92. package/dist/seatbelt/hook.d.ts.map +1 -0
  93. package/dist/seatbelt/hook.js +396 -0
  94. package/dist/seatbelt/hook.js.map +1 -0
  95. package/dist/seatbelt/longopts.d.ts +68 -0
  96. package/dist/seatbelt/longopts.d.ts.map +1 -0
  97. package/dist/seatbelt/longopts.js +377 -0
  98. package/dist/seatbelt/longopts.js.map +1 -0
  99. package/dist/seatbelt/mapper.d.ts +83 -0
  100. package/dist/seatbelt/mapper.d.ts.map +1 -0
  101. package/dist/seatbelt/mapper.js +10581 -0
  102. package/dist/seatbelt/mapper.js.map +1 -0
  103. package/dist/seatbelt/record.d.ts +94 -0
  104. package/dist/seatbelt/record.d.ts.map +1 -0
  105. package/dist/seatbelt/record.js +776 -0
  106. package/dist/seatbelt/record.js.map +1 -0
  107. package/dist/seatbelt/tables.d.ts +82 -0
  108. package/dist/seatbelt/tables.d.ts.map +1 -0
  109. package/dist/seatbelt/tables.js +352 -0
  110. package/dist/seatbelt/tables.js.map +1 -0
  111. package/dist/seatbelt-deploy.d.ts +100 -0
  112. package/dist/seatbelt-deploy.d.ts.map +1 -0
  113. package/dist/seatbelt-deploy.js +393 -0
  114. package/dist/seatbelt-deploy.js.map +1 -0
  115. package/dist/seatbelt-fs.d.ts +30 -0
  116. package/dist/seatbelt-fs.d.ts.map +1 -0
  117. package/dist/seatbelt-fs.js +94 -0
  118. package/dist/seatbelt-fs.js.map +1 -0
  119. package/dist/seatbelt-git.d.ts +212 -0
  120. package/dist/seatbelt-git.d.ts.map +1 -0
  121. package/dist/seatbelt-git.js +844 -0
  122. package/dist/seatbelt-git.js.map +1 -0
  123. package/dist/seatbelt-mode.d.ts +77 -0
  124. package/dist/seatbelt-mode.d.ts.map +1 -0
  125. package/dist/seatbelt-mode.js +496 -0
  126. package/dist/seatbelt-mode.js.map +1 -0
  127. package/dist/seatbelt-path.d.ts +123 -0
  128. package/dist/seatbelt-path.d.ts.map +1 -0
  129. package/dist/seatbelt-path.js +482 -0
  130. package/dist/seatbelt-path.js.map +1 -0
  131. package/dist/seatbelt-scripts.d.ts +109 -0
  132. package/dist/seatbelt-scripts.d.ts.map +1 -0
  133. package/dist/seatbelt-scripts.js +1060 -0
  134. package/dist/seatbelt-scripts.js.map +1 -0
  135. package/dist/secret-paths.d.ts +17 -0
  136. package/dist/secret-paths.d.ts.map +1 -0
  137. package/dist/secret-paths.js +32 -0
  138. package/dist/secret-paths.js.map +1 -0
  139. package/dist/session-loop-guard.d.ts.map +1 -1
  140. package/dist/session-loop-guard.js +15 -5
  141. package/dist/session-loop-guard.js.map +1 -1
  142. package/dist/tool-catalog.js +2 -2
  143. package/dist/tool-catalog.js.map +1 -1
  144. package/dist/types.d.ts +2 -1
  145. package/dist/types.d.ts.map +1 -1
  146. package/package.json +3 -2
  147. package/server.json +3 -3
  148. package/vendor/classifier/MANIFEST.json +25 -0
  149. package/vendor/classifier/classifier.js +1356 -0
  150. package/vendor/classifier/policy-baseline.js +187 -0
  151. package/vendor/classifier/redact.js +59 -0
  152. package/vendor/classifier/shell-parse.js +455 -0
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.DENIED_DECISION_CLOSE_TIMEOUT_MS = exports.GROK_FIXED_DENIAL = 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.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;
@@ -16,8 +16,10 @@ exports.advisoryGateNotice = advisoryGateNotice;
16
16
  exports.runtimeGateVerdict = runtimeGateVerdict;
17
17
  exports.gateDecisionMessage = gateDecisionMessage;
18
18
  exports.localControlAllowOutput = localControlAllowOutput;
19
+ exports.seatbeltPassOutput = seatbeltPassOutput;
19
20
  exports.localLoopGuardDenyOutput = localLoopGuardDenyOutput;
20
21
  exports.classifyTool = classifyTool;
22
+ exports.mayPrintEnvironment = mayPrintEnvironment;
21
23
  exports.cursorPreActionHookOutput = cursorPreActionHookOutput;
22
24
  exports.clinePreActionHookOutput = clinePreActionHookOutput;
23
25
  exports.windsurfPreActionDecision = windsurfPreActionDecision;
@@ -27,8 +29,11 @@ exports.preActionHookOutput = preActionHookOutput;
27
29
  exports.closeDeniedDecision = closeDeniedDecision;
28
30
  exports.localApprovalCategories = localApprovalCategories;
29
31
  exports.installPreActionHook = installPreActionHook;
32
+ exports.runSeatbeltStatusCommand = runSeatbeltStatusCommand;
33
+ exports.outageHolds = outageHolds;
30
34
  exports.runPreActionHookCommand = runPreActionHookCommand;
31
35
  const node_crypto_1 = require("node:crypto");
36
+ const node_os_1 = require("node:os");
32
37
  const index_1 = require("./index");
33
38
  const request_reliability_1 = require("./request-reliability");
34
39
  const lifecycle_spool_1 = require("./lifecycle-spool");
@@ -36,13 +41,66 @@ const control_state_1 = require("./control-state");
36
41
  const runtime_contract_1 = require("./runtime-contract");
37
42
  const host_approval_1 = require("./host-approval");
38
43
  const host_session_1 = require("./host-session");
44
+ const own_server_identity_1 = require("./own-server-identity");
39
45
  const normalized_action_1 = require("./normalized-action");
40
46
  const session_loop_guard_1 = require("./session-loop-guard");
41
47
  const hook_tool_policy_1 = require("./hook-tool-policy");
42
48
  const hook_contract_1 = require("./hook-contract");
43
49
  Object.defineProperty(exports, "MARROW_OUTAGE_WARNING", { enumerable: true, get: function () { return hook_contract_1.MARROW_OUTAGE_WARNING; } });
44
50
  /** A protected action with no Marrow key anywhere this machine keeps one (MARROW_API_KEY, ~/.marrow/env.local or ~/.marrow/env, owner-only). */
45
- 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.';
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. */
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`.';
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.';
59
+ /** The seatbelt path itself could not run (its modules did not load): a stop (spec §2.1.4). */
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
+ }
46
104
  // Claude Code sends the whole tool input, and a Write of a long document grows
47
105
  // further once JSON-escaped, so tens of kilobytes of markdown must still parse.
48
106
  exports.MAX_PRE_ACTION_INPUT_BYTES = 4 * 1024 * 1024;
@@ -107,6 +165,8 @@ function isMarrowControlOutage(error) {
107
165
  exports.HOLD_OWNER_DENIAL = 'Marrow is holding this action for approval. Retry it after approval.';
108
166
  /** Marrow did not answer within the hook's time limit: an action that can be held stays held. */
109
167
  exports.HELD_SLOW_TEXT = 'Marrow did not answer in time, so this action is held. Retry it in a moment.';
168
+ /** Marrow cannot be reached (a real outage): a high-risk action fails closed; routine actions keep the outage allow. */
169
+ exports.HELD_OFFLINE_TEXT = 'Marrow cannot be reached right now, so this high-risk action is held; routine actions keep running. Retry it when Marrow is back.';
110
170
  /** The only denial @getmarrow/install's Grok guard passes through (any other output blocks with a launch failure). */
111
171
  exports.GROK_FIXED_DENIAL = 'Marrow blocked this protected action.';
112
172
  /**
@@ -146,6 +206,9 @@ function heldActionHookOutput(harness, plan, code = null) {
146
206
  return {
147
207
  hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: plan.agentText },
148
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 } : {}),
149
212
  };
150
213
  }
151
214
  /**
@@ -231,7 +294,7 @@ function plainControlFix(error) {
231
294
  }
232
295
  // Names a reached control failure by HTTP status and stable failure code, with a plain
233
296
  // fix (see plainControlFix); private service text is never echoed into the agent transcript.
234
- function controlRejectionMessage(error, agentId) {
297
+ function controlRejectionMessage(error, agentId, kind = controlFailureKind(error)) {
235
298
  const detail = [];
236
299
  if (error instanceof request_reliability_1.MarrowRequestError) {
237
300
  if (typeof error.status === 'number' && Number.isInteger(error.status))
@@ -241,7 +304,6 @@ function controlRejectionMessage(error, agentId) {
241
304
  detail.push(code);
242
305
  }
243
306
  const suffix = detail.length ? ` (${detail.join(' ')})` : '';
244
- const kind = controlFailureKind(error);
245
307
  if (kind === 'credential_scope') {
246
308
  const agent = agentId && SAFE_AGENT_ID.test(agentId)
247
309
  ? `agent "${agentId}"`
@@ -410,22 +472,65 @@ function localControlAllowOutput(harness) {
410
472
  return { decision: 'allow' };
411
473
  return {};
412
474
  }
475
+ /** The Cursor events where Cursor enforces "ask" with its own approval prompt (the installer's execution hooks). */
476
+ const CURSOR_ASK_EVENTS = new Set(['beforeShellExecution', 'beforeMCPExecution']);
477
+ /** Keyless seatbelt: a host that cannot hand this action to its own approval prompt holds it (no account to approve it). */
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.';
479
+ exports.SEATBELT_CURSOR_ASK_USER_TEXT = 'Marrow seatbelt (no account linked): approve this action here only if you expected it.';
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.';
481
+ /**
482
+ * The keyless seatbelt's answer for an action it does not stop (spec §1.3 "pass": Marrow adds no
483
+ * decision and the host's own permission flow runs). `heldBefore`: the 3.9.103 keyless hook held this
484
+ * action (protected, or not read-only on Windsurf, Gemini or Grok). `null` means exit 0, no output.
485
+ * - Not held before: exactly the answer 3.9.103 gave (its read-only and unprotected passes).
486
+ * - Held before: never an explicit allow that could skip the host's own approval step.
487
+ * Claude Code and Codex `{}` (no permissionDecision), Cline `{cancel:false}`, Windsurf no output:
488
+ * the hosts' neutral answers (see PASS_THROUGH_HOSTS), so their own approval step runs.
489
+ * Gemini CLI `{"decision":"allow"}`: the installed guard passes only that or a fixed denial (no
490
+ * output is a launch failure that blocks), and Gemini applies its own policy and confirmation
491
+ * after the BeforeTool hook whatever an "allow" says, so it is Gemini's neutral answer.
492
+ * Cursor beforeShellExecution / beforeMCPExecution: "ask", so Cursor's own approval prompt decides.
493
+ * Cursor preToolUse (no neutral answer, "ask" not enforced) and Grok (its guard passes only an
494
+ * explicit allow or the fixed denial, and nothing shows an allow keeps Grok's own approval): held,
495
+ * as 3.9.103 did. Any other or missing Cursor event name is treated as preToolUse.
496
+ */
497
+ function seatbeltPassOutput(harness, hookEventName, heldBefore) {
498
+ if (!heldBefore)
499
+ return localControlAllowOutput(harness);
500
+ if (harness === 'windsurf')
501
+ return null;
502
+ if (harness === 'cline')
503
+ return { cancel: false };
504
+ if (harness === 'gemini')
505
+ return { decision: 'allow' };
506
+ if (harness === 'grok')
507
+ return { decision: 'deny', reason: exports.GROK_FIXED_DENIAL };
508
+ if (harness === 'cursor') {
509
+ return typeof hookEventName === 'string' && CURSOR_ASK_EVENTS.has(hookEventName)
510
+ ? { permission: 'ask', user_message: exports.SEATBELT_CURSOR_ASK_USER_TEXT, agent_message: exports.SEATBELT_CURSOR_ASK_AGENT_TEXT }
511
+ : { permission: 'deny', user_message: exports.SEATBELT_HELD_TEXT, agent_message: exports.SEATBELT_HELD_TEXT };
512
+ }
513
+ return {};
514
+ }
413
515
  function localLoopGuardDenyOutput(harness, reason) {
414
- const bounded = reason.replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 500);
516
+ const bounded = fitHookText(reason, hostTextLimit(harness));
415
517
  if (harness === 'windsurf')
416
518
  return null;
417
519
  if (harness === 'cursor')
418
520
  return { permission: 'deny', user_message: bounded, agent_message: bounded };
419
521
  if (harness === 'cline')
420
522
  return { cancel: true, errorMessage: bounded };
421
- if (harness === 'gemini' || harness === 'grok')
523
+ // The installed Grok guard passes only an allow or this fixed denial; any other text is its launch failure (audit M1).
524
+ if (harness === 'grok')
525
+ return { decision: 'deny', reason: exports.GROK_FIXED_DENIAL };
526
+ if (harness === 'gemini')
422
527
  return { decision: 'deny', reason: bounded };
423
528
  return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: bounded } };
424
529
  }
425
530
  function emitLoopGuardDenial(harness, reason) {
426
531
  if (harness === 'windsurf') {
427
532
  process.exitCode = 2;
428
- 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`);
429
534
  return;
430
535
  }
431
536
  process.stdout.write(JSON.stringify(localLoopGuardDenyOutput(harness, reason)));
@@ -456,7 +561,139 @@ function localControlDisabled() {
456
561
  return false;
457
562
  }
458
563
  }
459
- function classifyTool(event) {
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'];
567
+ /**
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.
580
+ */
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) {
460
697
  const tool = String(event.tool_name || 'tool').slice(0, 64);
461
698
  const normalizedTool = (0, hook_tool_policy_1.normalizeHookToolName)(tool);
462
699
  // Editing and task tools are judged by their target; shell-like words in the
@@ -464,8 +701,11 @@ function classifyTool(event) {
464
701
  const shellGoverned = (0, hook_tool_policy_1.isShellGovernedTool)(event);
465
702
  const command = shellGoverned ? (0, hook_tool_policy_1.hookToolCommand)(event) : '';
466
703
  const input = (0, hook_tool_policy_1.toolClassificationText)(event).toLowerCase();
467
- const readOnly = (0, hook_tool_policy_1.isReadOnlyToolEvent)(event);
468
- const secretAccess = (0, hook_tool_policy_1.isSecretMaterialAccess)(event);
704
+ // A look-alike of Marrow's own tool is never read-only, whatever its verb.
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);
469
709
  const protectedShellCommand = shellGoverned && (0, hook_tool_policy_1.isProtectedShellMutation)(command);
470
710
  const infrastructureDeployment = /\b(?:kubectl|terraform|pulumi|helm)\b/.test(command.toLowerCase())
471
711
  && protectedShellCommand;
@@ -493,8 +733,13 @@ function classifyTool(event) {
493
733
  const protectedAction = !readOnly && (secretAccess
494
734
  || (shellGoverned && /\b(?:deploy|release|publish|git\s+push|git\s+merge|gh\s+pr\s+merge|migration|migrate|secret|credential|rotate|revoke|payment|refund|charge|invoice|production|prod)\b/.test(input))
495
735
  || protectedShellCommand
496
- || ((0, hook_tool_policy_1.isMcpHookTool)(event.tool_name) && !(0, hook_tool_policy_1.isOfficialMarrowMcpTool)(event.tool_name))
736
+ || ((0, hook_tool_policy_1.isMcpHookTool)(event.tool_name) && (options.ownUnproven === true || !(0, hook_tool_policy_1.isOfficialMarrowMcpTool)(event.tool_name)))
497
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;
498
743
  const risk = readOnly ? 'low' : protectedAction ? 'high' : 'medium';
499
744
  const target = surfaces.includes('npm') ? `npm:${type}`
500
745
  : surfaces.includes('github') ? `github:${type}`
@@ -525,7 +770,8 @@ function cursorPreActionHookOutput(result) {
525
770
  return { permission: 'deny', user_message: denial, agent_message: denial };
526
771
  }
527
772
  if (protectedRisk && (!runtime || !permit?.verified)) {
528
- 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);
529
775
  return {
530
776
  permission: 'deny',
531
777
  user_message: denial,
@@ -548,11 +794,10 @@ function clinePreActionHookOutput(result) {
548
794
  };
549
795
  }
550
796
  if (result.protectedRisk && (!result.runtime || !result.permit?.verified)) {
551
- const credentialsUnavailable = result.enforcementError === exports.NO_KEY_TEXT;
552
797
  return {
553
798
  cancel: true,
554
- errorMessage: credentialsUnavailable
555
- ? exports.NO_KEY_TEXT
799
+ errorMessage: result.credentialsUnavailable === true
800
+ ? fitHookText(result.enforcementError, CLINE_ERROR_LIMIT)
556
801
  : result.failure === 'credential_scope'
557
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.'
558
803
  : result.failure === 'unavailable'
@@ -828,11 +1073,97 @@ function installPreActionHook(startDir = process.cwd()) {
828
1073
  fs.writeFileSync(path, JSON.stringify(settings, null, 2) + '\n');
829
1074
  return { settingsPath: path, installed: reconciled.changed };
830
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
+ }
1088
+ /**
1089
+ * I3: whether a real outage (the gate never answered) holds this action. Never looser than master,
1090
+ * which allowed every action in an outage. A shell command is read by the seatbelt mapper: held for an
1091
+ * S0-S5/S-x hit or a program on the outage list (git push, publish, deploy, remote delete), allowed
1092
+ * when the mapper read all of it and found neither (words in a message, a path or a comment never hold
1093
+ * it). Anything else, or a mapper that could not finish, keeps `classifiedHighRisk` (F2).
1094
+ */
1095
+ function outageHolds(classifiedHighRisk, read) {
1096
+ let reading = { shell: false, risk: 'unknown' };
1097
+ try {
1098
+ reading = read();
1099
+ }
1100
+ catch { /* unknown: the classifier's answer */ }
1101
+ if (reading.risk === 'high')
1102
+ return true;
1103
+ if (reading.shell && reading.risk === 'not_high')
1104
+ return false;
1105
+ return classifiedHighRisk;
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
+ }
1138
+ function seatbeltEvent(source, rawEvent) {
1139
+ const raw = asRecord(rawEvent) || {};
1140
+ const event = source;
1141
+ return {
1142
+ tool_name: source.tool_name, tool_input: source.tool_input, cwd: event.cwd ?? raw.cwd, truncated: event.input_truncated === true,
1143
+ session: source.session_id || source.conversation_id || source.task_id || undefined,
1144
+ };
1145
+ }
831
1146
  async function runPreActionHookCommand(input) {
1147
+ // Account mode: the S0-S5 floor in watch runs after the answer is written, for the record only.
1148
+ const watch = {};
1149
+ try {
1150
+ await runPreActionHook(input, watch);
1151
+ }
1152
+ finally {
1153
+ if (watch.harness && watch.event) {
1154
+ try {
1155
+ require('./seatbelt/hook').accountFloor(watch.harness, watch.event, (0, host_approval_1.preToolDeadline)((0, host_approval_1.approvalHostFor)(watch.harness)));
1156
+ }
1157
+ catch { /* watch never changes the answer */ }
1158
+ }
1159
+ }
1160
+ }
1161
+ async function runPreActionHook(input, watch) {
832
1162
  if (process.env.MARROW_AUTO_HOOK === 'false')
833
1163
  return;
834
1164
  const identity = (0, hook_contract_1.resolveNativeHookIdentity)(process.argv[2]);
835
1165
  let event = input;
1166
+ let rawEvent = input;
836
1167
  let inputFailure = null;
837
1168
  if (event === undefined) {
838
1169
  try {
@@ -842,7 +1173,8 @@ async function runPreActionHookCommand(input) {
842
1173
  }
843
1174
  else {
844
1175
  const raw = stdin.raw.trim();
845
- event = raw ? (0, hook_contract_1.normalizeHookEventPayload)(JSON.parse(raw)) : {};
1176
+ rawEvent = raw ? JSON.parse(raw) : {};
1177
+ event = (0, hook_contract_1.normalizeHookEventPayload)(rawEvent);
846
1178
  }
847
1179
  }
848
1180
  catch {
@@ -865,7 +1197,7 @@ async function runPreActionHookCommand(input) {
865
1197
  emitDecision({ runtime: null, permit: null, protectedRisk: true, enforcementError: inputFailure || 'Marrow could not classify this mutation-capable tool request.' }, identity.harness);
866
1198
  return;
867
1199
  }
868
- if ((0, hook_tool_policy_1.isOfficialMarrowMcpEvent)(source)) {
1200
+ if ((0, own_server_identity_1.isOfficialMarrowMcpEventOnHost)(identity.harness, source, rawEvent)) {
869
1201
  if (identity.harness === 'windsurf') {
870
1202
  process.exitCode = 0;
871
1203
  return;
@@ -876,7 +1208,7 @@ async function runPreActionHookCommand(input) {
876
1208
  : {}));
877
1209
  return;
878
1210
  }
879
- const classified = classifyTool(source);
1211
+ const classified = classifyTool(source, { ownUnproven: (0, hook_tool_policy_1.isOfficialMarrowMcpEvent)(source) });
880
1212
  let localControl;
881
1213
  try {
882
1214
  localControl = (0, control_state_1.readLocalControlState)();
@@ -962,6 +1294,58 @@ async function runPreActionHookCommand(input) {
962
1294
  emitLoopGuardDenial(identity.harness, loopDecision.reason || `Marrow local loop guard blocked this unchanged repeat. Receipt: ${loopDecision.receipt}.`);
963
1295
  return;
964
1296
  }
1297
+ // No usable key: the seatbelt decides first (spec §1.1), before the read-only pass so S4 reads stop.
1298
+ // `seatbelt` answers here with no network call; `claimed_no_key` stops S0-S5 here and keeps the holds below.
1299
+ // Whether a keyless hold may say "linked to an account" (only with account evidence, never for a mode fallback).
1300
+ let keylessAccountLinked = false;
1301
+ // The keyless hold text when the seatbelt does not answer: master's no-key text in seatbelt mode (a
1302
+ // tool the mapper does not recognise, audit H1), the claimed_no_key texts otherwise.
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;
1306
+ if (!resolved.apiKey) {
1307
+ let keyless;
1308
+ try {
1309
+ keyless = require('./seatbelt/hook').keylessSeatbelt(identity.harness, seatbeltEvent(source, rawEvent));
1310
+ }
1311
+ catch {
1312
+ keyless = { mode: 'claimed_no_key', stopText: SEATBELT_FAILED_TEXT };
1313
+ }
1314
+ keylessAccountLinked = keyless.accountLinked === true;
1315
+ keylessSeatbeltMode = keyless.mode === 'seatbelt';
1316
+ keylessModeReason = typeof keyless.modeReason === 'string' ? keyless.modeReason : null;
1317
+ if (keyless.stopText) {
1318
+ emitLoopGuardDenial(identity.harness, keyless.stopText);
1319
+ return;
1320
+ }
1321
+ // A tool the mapper does not recognise never gets the seatbelt pass: master's answer below.
1322
+ if (keyless.mode === 'seatbelt' && keyless.recognized === true) {
1323
+ // Never an explicit allow for an action 3.9.103 held: the host's own approval step decides.
1324
+ const answer = seatbeltPassOutput(identity.harness, source.hook_event_name, !classified.readOnly && enforcementRequired);
1325
+ if (answer === null)
1326
+ process.exitCode = 0;
1327
+ else
1328
+ process.stdout.write(JSON.stringify(answer));
1329
+ return;
1330
+ }
1331
+ }
1332
+ else {
1333
+ watch.harness = identity.harness;
1334
+ watch.event = seatbeltEvent(source, rawEvent);
1335
+ let floor = null;
1336
+ try {
1337
+ floor = require('./seatbelt/hook');
1338
+ }
1339
+ catch { /* watch only: a missing module never changes the account flow */ }
1340
+ if (floor && floor.SEATBELT_ROLLOUT.account_floor === 'on') {
1341
+ watch.harness = undefined;
1342
+ const stopText = floor.accountFloor(identity.harness, watch.event, (0, host_approval_1.preToolDeadline)((0, host_approval_1.approvalHostFor)(identity.harness)));
1343
+ if (stopText) {
1344
+ emitLoopGuardDenial(identity.harness, stopText);
1345
+ return;
1346
+ }
1347
+ }
1348
+ }
965
1349
  if (classified.readOnly) {
966
1350
  const allow = localControlAllowOutput(identity.harness);
967
1351
  if (allow === null)
@@ -988,7 +1372,8 @@ async function runPreActionHookCommand(input) {
988
1372
  runtime: null,
989
1373
  permit: null,
990
1374
  protectedRisk: enforcementRequired,
991
- enforcementError: exports.NO_KEY_TEXT,
1375
+ enforcementError: keylessSeatbeltMode ? exports.NO_KEY_TEXT : keylessAccountLinked ? exports.CLAIMED_NO_KEY_TEXT : setupUnconfirmedHold(keylessModeReason),
1376
+ credentialsUnavailable: true,
992
1377
  }, identity.harness);
993
1378
  return;
994
1379
  }
@@ -1031,7 +1416,9 @@ async function runPreActionHookCommand(input) {
1031
1416
  return;
1032
1417
  }
1033
1418
  if (waited?.kind === 'deny') {
1034
- 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);
1035
1422
  await afterAnswer(holdContext, { link: waited.deferredLink ? waited.hold : null });
1036
1423
  return;
1037
1424
  }
@@ -1058,6 +1445,11 @@ async function runPreActionHookCommand(input) {
1058
1445
  });
1059
1446
  // The decision and gate receipt this control path holds, so a denial can close them.
1060
1447
  const held = { decisionId: null, gateReceiptId: null };
1448
+ // Set once the runtime gate has answered. A later network or 5xx failure (decision, permit issue
1449
+ // or verify) on a high-risk action is a reached answer that no permit can be had: a denial, never
1450
+ // "offline, allowed". Routine actions keep the outage policy (failure_mode low_risk_behavior).
1451
+ const reach = { gateAnswered: false };
1452
+ const highRisk = classified.protected || classified.risk === 'high';
1061
1453
  const control = async (signal) => {
1062
1454
  const runtime = await (0, index_1.marrowAgentRuntime)(resolved.apiKey, baseUrl, {
1063
1455
  action: classified.action,
@@ -1072,6 +1464,7 @@ async function runPreActionHookCommand(input) {
1072
1464
  // non-durable fast_gate receipt can never back an action permit.
1073
1465
  ...(enforcementRequired ? { risk_level: classified.risk } : {}),
1074
1466
  }, sessionId, agentId, signal);
1467
+ reach.gateAnswered = true;
1075
1468
  const heldIds = [...new Set([runtime.decision_id, runtime.completion_contract?.decision_id, runtime.runtime_authorization?.decision_id]
1076
1469
  .filter((value) => typeof value === 'string' && SAFE_DECISION_ID.test(value)))];
1077
1470
  held.decisionId = heldIds.length === 1 ? heldIds[0] : null;
@@ -1173,7 +1566,7 @@ async function runPreActionHookCommand(input) {
1173
1566
  timedOut: true,
1174
1567
  enforcementError: hook_contract_1.MARROW_OUTAGE_WARNING,
1175
1568
  }
1176
- : isMarrowControlOutage(error)
1569
+ : isMarrowControlOutage(error) && !(reach.gateAnswered && highRisk)
1177
1570
  ? {
1178
1571
  runtime: null,
1179
1572
  permit: null,
@@ -1181,13 +1574,21 @@ async function runPreActionHookCommand(input) {
1181
1574
  outage: true,
1182
1575
  enforcementError: hook_contract_1.MARROW_OUTAGE_WARNING,
1183
1576
  }
1184
- : {
1185
- runtime: null,
1186
- permit: null,
1187
- protectedRisk: enforcementRequired,
1188
- enforcementError: controlRejectionMessage(error, agentId),
1189
- ...(controlFailureKind(error) ? { failure: controlFailureKind(error) } : {}),
1190
- })),
1577
+ : isMarrowControlOutage(error)
1578
+ ? {
1579
+ runtime: null,
1580
+ permit: null,
1581
+ protectedRisk: enforcementRequired,
1582
+ enforcementError: controlRejectionMessage(error, agentId, 'unavailable'),
1583
+ failure: 'unavailable',
1584
+ }
1585
+ : {
1586
+ runtime: null,
1587
+ permit: null,
1588
+ protectedRisk: enforcementRequired,
1589
+ enforcementError: controlRejectionMessage(error, agentId),
1590
+ ...(controlFailureKind(error) ? { failure: controlFailureKind(error) } : {}),
1591
+ })),
1191
1592
  lifecycle,
1192
1593
  ]);
1193
1594
  const claudePrompt = ownerApprovalPrompt(identity.harness, source);
@@ -1205,12 +1606,17 @@ async function runPreActionHookCommand(input) {
1205
1606
  }
1206
1607
  if (result.outage) {
1207
1608
  // Marrow cannot be reached. An action in a category the account owner
1208
- // protects stays held on every host; everything else keeps the outage policy.
1609
+ // protects stays held on every host; a high-risk action fails closed (high-risk
1610
+ // work needs a fresh gate); routine actions keep the outage policy.
1209
1611
  const protectedNow = (0, host_approval_1.protectedAmong)(holdContext, localApprovalCategories(classified));
1210
1612
  if (protectedNow.length) {
1211
1613
  emitHeldPlan(identity.harness, { kind: 'deny', agentText: host_approval_1.HELD_UNREACHABLE_TEXT, userText: host_approval_1.HELD_UNREACHABLE_TEXT, code: false });
1212
1614
  return;
1213
1615
  }
1616
+ if (outageHolds(highRisk, () => require('./seatbelt/hook').outageRisk(seatbeltEvent(source, rawEvent), holdContext.deadlineAt ?? Number.POSITIVE_INFINITY))) {
1617
+ emitHeldPlan(identity.harness, { kind: 'deny', agentText: exports.HELD_OFFLINE_TEXT, userText: exports.HELD_OFFLINE_TEXT, code: false });
1618
+ return;
1619
+ }
1214
1620
  }
1215
1621
  const verdict = result.outage ? null : runtimeGateVerdict(result.runtime);
1216
1622
  const guidance = verdict?.kind === 'review' ? (0, runtime_contract_1.ordinaryApprovalGuidance)(result.runtime) : null;