@getmarrow/mcp 3.9.97 → 3.9.99

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 (52) hide show
  1. package/README.md +57 -338
  2. package/dist/cli.js +249 -47
  3. package/dist/cli.js.map +1 -1
  4. package/dist/hook-context.d.ts.map +1 -1
  5. package/dist/hook-context.js +135 -9
  6. package/dist/hook-context.js.map +1 -1
  7. package/dist/hook-contract.d.ts +26 -5
  8. package/dist/hook-contract.d.ts.map +1 -1
  9. package/dist/hook-contract.js +144 -10
  10. package/dist/hook-contract.js.map +1 -1
  11. package/dist/hook-pre-action.d.ts +40 -1
  12. package/dist/hook-pre-action.d.ts.map +1 -1
  13. package/dist/hook-pre-action.js +429 -35
  14. package/dist/hook-pre-action.js.map +1 -1
  15. package/dist/hook-session.d.ts.map +1 -1
  16. package/dist/hook-session.js +32 -0
  17. package/dist/hook-session.js.map +1 -1
  18. package/dist/hook.d.ts +12 -0
  19. package/dist/hook.d.ts.map +1 -1
  20. package/dist/hook.js +149 -5
  21. package/dist/hook.js.map +1 -1
  22. package/dist/host-approval-state.d.ts +137 -0
  23. package/dist/host-approval-state.d.ts.map +1 -0
  24. package/dist/host-approval-state.js +510 -0
  25. package/dist/host-approval-state.js.map +1 -0
  26. package/dist/host-approval.d.ts +436 -0
  27. package/dist/host-approval.d.ts.map +1 -0
  28. package/dist/host-approval.js +1417 -0
  29. package/dist/host-approval.js.map +1 -0
  30. package/dist/host-session.d.ts +82 -0
  31. package/dist/host-session.d.ts.map +1 -0
  32. package/dist/host-session.js +570 -0
  33. package/dist/host-session.js.map +1 -0
  34. package/dist/index.d.ts +150 -2
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +748 -33
  37. package/dist/index.js.map +1 -1
  38. package/dist/lifecycle-spool.d.ts.map +1 -1
  39. package/dist/lifecycle-spool.js +6 -5
  40. package/dist/lifecycle-spool.js.map +1 -1
  41. package/dist/normalized-action.d.ts +120 -0
  42. package/dist/normalized-action.d.ts.map +1 -0
  43. package/dist/normalized-action.js +1109 -0
  44. package/dist/normalized-action.js.map +1 -0
  45. package/dist/runtime-contract.d.ts +108 -4
  46. package/dist/runtime-contract.d.ts.map +1 -1
  47. package/dist/runtime-contract.js +178 -35
  48. package/dist/runtime-contract.js.map +1 -1
  49. package/dist/types.d.ts +128 -0
  50. package/dist/types.d.ts.map +1 -1
  51. package/package.json +21 -25
  52. package/server.json +15 -4
@@ -1,7 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.DENIED_DECISION_CLOSE_TIMEOUT_MS = exports.PreActionControlTimeoutError = exports.PRE_ACTION_CONTROL_TIMEOUT_MS = exports.MAX_PRE_ACTION_INPUT_BYTES = exports.MARROW_OUTAGE_WARNING = void 0;
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 = void 0;
4
+ exports.isMarrowControlTimeout = isMarrowControlTimeout;
4
5
  exports.isMarrowControlOutage = isMarrowControlOutage;
6
+ exports.heldActionHookOutput = heldActionHookOutput;
7
+ exports.passHookOutput = passHookOutput;
8
+ exports.approvedHoldHookOutput = approvedHoldHookOutput;
5
9
  exports.isMarrowOutage = isMarrowOutage;
6
10
  exports.controlFailureKind = controlFailureKind;
7
11
  exports.controlRejectionMessage = controlRejectionMessage;
@@ -21,6 +25,7 @@ exports.geminiPreActionHookOutput = geminiPreActionHookOutput;
21
25
  exports.grokPreActionHookOutput = grokPreActionHookOutput;
22
26
  exports.preActionHookOutput = preActionHookOutput;
23
27
  exports.closeDeniedDecision = closeDeniedDecision;
28
+ exports.localApprovalCategories = localApprovalCategories;
24
29
  exports.installPreActionHook = installPreActionHook;
25
30
  exports.runPreActionHookCommand = runPreActionHookCommand;
26
31
  const node_crypto_1 = require("node:crypto");
@@ -29,6 +34,9 @@ const request_reliability_1 = require("./request-reliability");
29
34
  const lifecycle_spool_1 = require("./lifecycle-spool");
30
35
  const control_state_1 = require("./control-state");
31
36
  const runtime_contract_1 = require("./runtime-contract");
37
+ const host_approval_1 = require("./host-approval");
38
+ const host_session_1 = require("./host-session");
39
+ const normalized_action_1 = require("./normalized-action");
32
40
  const session_loop_guard_1 = require("./session-loop-guard");
33
41
  const hook_tool_policy_1 = require("./hook-tool-policy");
34
42
  const hook_contract_1 = require("./hook-contract");
@@ -65,6 +73,20 @@ class PreActionControlTimeoutError extends Error {
65
73
  }
66
74
  }
67
75
  exports.PreActionControlTimeoutError = PreActionControlTimeoutError;
76
+ /**
77
+ * A timeout (the hook's own budget, or a request that did not answer in time):
78
+ * Marrow is slow, not known to be down. It is never treated as an outage.
79
+ */
80
+ function isMarrowControlTimeout(error) {
81
+ if (error instanceof PreActionControlTimeoutError)
82
+ return true;
83
+ if (error instanceof request_reliability_1.MarrowRequestError)
84
+ return error.code === 'request_timeout';
85
+ if (!error || typeof error !== 'object')
86
+ return false;
87
+ const named = error;
88
+ return named.name === 'AbortError' || named.name === 'TimeoutError';
89
+ }
68
90
  function isMarrowControlOutage(error) {
69
91
  if (error instanceof PreActionControlTimeoutError)
70
92
  return true;
@@ -79,6 +101,89 @@ function isMarrowControlOutage(error) {
79
101
  return true;
80
102
  return error instanceof TypeError && /fetch|network|getaddrinfo/i.test(String(named.message || ''));
81
103
  }
104
+ /** Fixed, privacy-preserving hold texts for hosts whose adapters accept only fixed strings. */
105
+ exports.HOLD_OWNER_DENIAL = 'Marrow is holding this action for approval. Retry it after approval.';
106
+ /** Marrow did not answer within the hook's time limit: an action that can be held stays held. */
107
+ exports.HELD_SLOW_TEXT = 'Marrow did not answer in time, so this action is held. Retry it in a moment.';
108
+ /** The only denial @getmarrow/install's Grok guard passes through (any other output blocks with a launch failure). */
109
+ exports.GROK_FIXED_DENIAL = 'Marrow blocked this protected action.';
110
+ /**
111
+ * The hook's answer for an ordinary held action, per host. A Claude Code "ask"
112
+ * reason is shown to the user only; a Cursor user_message is shown only in the
113
+ * client; neither ever reaches the agent with an approval code.
114
+ */
115
+ function heldActionHookOutput(harness, plan, code = null) {
116
+ if (harness === 'windsurf')
117
+ return null;
118
+ if (plan.kind === 'pass')
119
+ return passHookOutput(harness, plan.contextText);
120
+ if (plan.kind === 'ask') {
121
+ if (harness === 'cursor')
122
+ return { permission: 'ask', user_message: plan.promptText, agent_message: 'Marrow asked the user to approve this held action in Cursor.' };
123
+ return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'ask', permissionDecisionReason: plan.promptText } };
124
+ }
125
+ // The approval code goes only to a user-only channel: Cursor user_message,
126
+ // Codex and Gemini CLI systemMessage. The agent's text never contains it.
127
+ if (harness === 'cursor') {
128
+ return {
129
+ permission: 'deny',
130
+ user_message: code ? (0, host_approval_1.typedReplyUserText)(plan.userText, code) : plan.userText,
131
+ agent_message: plan.agentText,
132
+ };
133
+ }
134
+ if (harness === 'cline')
135
+ return { cancel: true, errorMessage: plan.agentText };
136
+ if (harness === 'gemini') {
137
+ // Without a typed reply the Gemini adapter's fixed denial text is kept.
138
+ return code
139
+ ? { decision: 'deny', reason: plan.agentText, systemMessage: (0, host_approval_1.typedReplyUserText)(plan.userText, code) }
140
+ : { decision: 'deny', reason: 'Marrow blocked this action because required governance approval or proof is unavailable.' };
141
+ }
142
+ if (harness === 'grok')
143
+ return { decision: 'deny', reason: exports.GROK_FIXED_DENIAL };
144
+ return {
145
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: plan.agentText },
146
+ ...(harness === 'codex' && code ? { systemMessage: (0, host_approval_1.typedReplyUserText)(plan.userText, code) } : {}),
147
+ };
148
+ }
149
+ /**
150
+ * The hook's answer when the host's own approval step decides (owner rule):
151
+ * neutral, never an explicit allow, so the host's normal permission flow runs.
152
+ * Codex: no permissionDecision (only context for the agent); Cline: not
153
+ * cancelled; Windsurf: exit 0 (emitted by the caller). Any other host has no
154
+ * neutral answer here and never gets a pass plan.
155
+ */
156
+ function passHookOutput(harness, contextText) {
157
+ if (harness === 'windsurf')
158
+ return null;
159
+ if (harness === 'cline')
160
+ return { cancel: false };
161
+ if (harness === 'codex')
162
+ return { hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: contextText } };
163
+ throw new Error(`no neutral pre-tool answer for ${harness}`);
164
+ }
165
+ /** The hook's answer when a waited hold was approved and the same action is retried. */
166
+ function approvedHoldHookOutput(harness, contextText) {
167
+ if (harness === 'windsurf')
168
+ return null;
169
+ if (harness === 'cursor')
170
+ return { permission: 'allow' };
171
+ if (harness === 'cline')
172
+ return { cancel: false };
173
+ if (harness === 'gemini' || harness === 'grok')
174
+ return { decision: 'allow' };
175
+ return { hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: contextText } };
176
+ }
177
+ function emitHookOutput(harness, output, deniedText) {
178
+ if (harness === 'windsurf') {
179
+ process.exitCode = deniedText ? 2 : 0;
180
+ if (deniedText)
181
+ process.stderr.write(`${deniedText}\n`);
182
+ return;
183
+ }
184
+ if (output)
185
+ process.stdout.write(JSON.stringify(output));
186
+ }
82
187
  function isMarrowOutage(result) {
83
188
  return result.outage === true;
84
189
  }
@@ -95,8 +200,35 @@ function controlFailureKind(error) {
95
200
  return 'credential_scope';
96
201
  return CONTROL_UNAVAILABLE_CODES.has(error.code) ? 'unavailable' : undefined;
97
202
  }
98
- // Names a reached control failure by HTTP status and stable failure code only, so the
99
- // denial is diagnosable without echoing private service text into the agent transcript.
203
+ const DOCTOR_FIX = 'Run `npx -y @getmarrow/install@latest doctor` in this terminal to check this machine\'s Marrow setup, then retry.';
204
+ /**
205
+ * A plain fix for a reached control failure: a known code's own fix, or the
206
+ * service's exact_fix when it is plain (no API calls, dashboard steps or
207
+ * links), else the doctor command. Service text other than exact_fix is never
208
+ * echoed into the agent transcript.
209
+ */
210
+ function plainControlFix(error) {
211
+ if (!(error instanceof request_reliability_1.MarrowRequestError))
212
+ return DOCTOR_FIX;
213
+ const code = String(error.backendCode || '');
214
+ if (code === 'AGENT_NOT_REGISTERED' || code === 'MARROW_AGENT_NOT_REGISTERED') {
215
+ if (/agent limit/i.test(error.message)) {
216
+ return 'Marrow could not add this agent: this account has reached its plan\'s agent limit. The account owner can archive an unused agent or change the plan in Marrow, then retry.';
217
+ }
218
+ if (/not active/i.test(error.message)) {
219
+ return 'This agent is turned off in this Marrow account. The account owner can turn it back on in Marrow, then retry.';
220
+ }
221
+ return 'Marrow doesn\'t know this agent yet. Run `npx @getmarrow/install` again in this terminal, then retry.';
222
+ }
223
+ if (error.status === 429 || error.code === 'rate_limited')
224
+ return 'Marrow is busy right now. Retry in a minute.';
225
+ const fix = error.exactFix.replace(/\s+/g, ' ').trim();
226
+ const plain = fix.length > 0 && fix.length <= 240 && /[.!]$/.test(fix)
227
+ && !/\/v1\/|\b(?:POST|GET|PATCH|PUT|DELETE)\b|dashboard|https?:\/\/|X-Marrow|agent_id|api key|curl\b/i.test(fix);
228
+ return plain ? fix : DOCTOR_FIX;
229
+ }
230
+ // Names a reached control failure by HTTP status and stable failure code, with a plain
231
+ // fix (see plainControlFix); private service text is never echoed into the agent transcript.
100
232
  function controlRejectionMessage(error, agentId) {
101
233
  const detail = [];
102
234
  if (error instanceof request_reliability_1.MarrowRequestError) {
@@ -117,7 +249,7 @@ function controlRejectionMessage(error, agentId) {
117
249
  if (kind === 'unavailable') {
118
250
  return `Marrow is unavailable, so this protected action was denied${suffix}. Retry when Marrow is reachable.`;
119
251
  }
120
- return `Marrow rejected this protected action${suffix}. Restore trusted governance before retrying.`;
252
+ return `Marrow did not allow this protected action${suffix}. ${plainControlFix(error)}`;
121
253
  }
122
254
  // Claude Code permission modes in which a PreToolUse "ask" reaches a person.
123
255
  // bypassPermissions disables prompts, dontAsk auto-denies anything that would
@@ -125,6 +257,8 @@ function controlRejectionMessage(error, agentId) {
125
257
  // https://code.claude.com/docs/en/hooks#pretooluse-decision-control
126
258
  // https://code.claude.com/docs/en/permission-modes
127
259
  const OWNER_PROMPT_PERMISSION_MODES = new Set(['default', 'acceptEdits', 'auto']);
260
+ /** Claude Code entrypoints with no one at a dialog (claude -p and the Agent SDKs); hooks inherit it. */
261
+ const HEADLESS_CLAUDE_ENTRYPOINTS = host_approval_1.HEADLESS_CLAUDE_ENTRYPOINTS;
128
262
  const NO_OWNER_PROMPT = { available: false, unavailableReason: 'this agent host cannot prompt the owner' };
129
263
  /**
130
264
  * Whether a PreToolUse "ask" from this hook reaches a person who can approve.
@@ -138,6 +272,10 @@ function ownerApprovalPrompt(harness, event, env = process.env) {
138
272
  ? { available: false, unavailableReason: 'Codex hooks cannot prompt the owner' }
139
273
  : NO_OWNER_PROMPT;
140
274
  }
275
+ const entrypoint = typeof env.CLAUDE_CODE_ENTRYPOINT === 'string' ? env.CLAUDE_CODE_ENTRYPOINT : '';
276
+ if (HEADLESS_CLAUDE_ENTRYPOINTS.has(entrypoint)) {
277
+ return { available: false, unavailableReason: `${entrypoint}, no one sees a dialog`, headless: true };
278
+ }
141
279
  const mode = typeof event.permission_mode === 'string' && /^[A-Za-z]{1,32}$/.test(event.permission_mode) ? event.permission_mode : '';
142
280
  if (!OWNER_PROMPT_PERMISSION_MODES.has(mode)) {
143
281
  return { available: false, unavailableReason: `Claude Code permission mode ${mode || 'unknown'} cannot guarantee an owner prompt` };
@@ -160,14 +298,14 @@ function boundedText(value, limit) {
160
298
  function asOptionalRecord(value) {
161
299
  return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
162
300
  }
163
- // Arbitration review is satisfied only by an authenticated-dashboard receipt,
164
- // never by a host prompt, so any arbitration signal keeps the denial.
301
+ // Arbitration review is satisfied only by the account owner's choice of a
302
+ // proposal (one-tap link), never by a host prompt, so any arbitration signal keeps the denial.
165
303
  function arbitrationReview(runtime) {
166
304
  const completion = runtime.completion_contract;
167
305
  const approval = asOptionalRecord(completion?.owner_approval);
168
306
  return Boolean(runtime.arbitration)
169
307
  || completion?.arbitration_receipt_required === true
170
- || approval?.dashboard_receipt_required === true
308
+ || (0, runtime_contract_1.ownerReceiptRequired)(approval)
171
309
  || (approval?.mode !== undefined && approval.mode !== 'ordinary_non_arbitrated');
172
310
  }
173
311
  /**
@@ -209,10 +347,10 @@ function runtimeGateAdvisory(runtime) {
209
347
  function runtimeGateEnforced(runtime) {
210
348
  return !runtimeGateAdvisory(runtime);
211
349
  }
350
+ // Why the gate held or blocked the action. The runtime's exact_next_action is
351
+ // written for API clients (endpoints to call) and is never relayed by a hook.
212
352
  function gateReason(runtime) {
213
- const why = boundedText(runtime.risk_gate.reasons?.[0]?.message, 240);
214
- const next = boundedText(runtime.exact_next_action, 240);
215
- return why && next && why !== next ? `${why}${/[.!?]$/.test(why) ? '' : '.'} Next: ${next}` : why || next;
353
+ return boundedText(runtime.risk_gate.reasons?.[0]?.message, 240);
216
354
  }
217
355
  /** A warning for a non-allow gate the runtime does not enforce on this plan. */
218
356
  function advisoryGateNotice(runtime) {
@@ -248,12 +386,12 @@ function gateDecisionMessage(verdict, ask, prompt = NO_OWNER_PROMPT) {
248
386
  const headline = verdict.kind === 'block'
249
387
  ? 'Marrow blocked this action under the current policy.'
250
388
  : verdict.kind === 'arbitration_review'
251
- ? 'Marrow arbitration requires owner approval in the authenticated Marrow dashboard before this action.'
389
+ ? 'Marrow is holding this action for arbitration review: the account owner picks and approves one proposal before it runs. Do not run it yet.'
252
390
  : verdict.kind === 'denied'
253
391
  ? 'Marrow did not allow this action.'
254
392
  : ask
255
393
  ? 'Marrow requires owner review before this action. Approve only if you authorize this exact action.'
256
- : `Marrow requires owner review before this action, and no owner approval prompt is available (${prompt.unavailableReason}), so it was denied. Ask the owner to approve or run it.`;
394
+ : `Marrow requires owner review before this action, and no owner approval prompt is available (${prompt.unavailableReason}), so it was denied. Tell the operator it is waiting for the account owner's approval.`;
257
395
  return boundedText(verdict.reason ? `${headline} Reason: ${verdict.reason}` : headline, 500);
258
396
  }
259
397
  const SAFE_DECISION_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/;
@@ -415,7 +553,7 @@ function clinePreActionHookOutput(result) {
415
553
  ? 'This Marrow API key is not authorized to obtain action permits for this agent. Use the API key issued to this agent and retry.'
416
554
  : result.failure === 'unavailable'
417
555
  ? 'Marrow is unavailable, so this protected action was denied. Retry when Marrow is reachable.'
418
- : 'Marrow could not verify the required action permit. Restore trusted governance and retry.',
556
+ : `Marrow could not confirm a permit for this protected action. ${DOCTOR_FIX}`,
419
557
  };
420
558
  }
421
559
  return { cancel: false };
@@ -453,7 +591,9 @@ function grokPreActionHookOutput(result) {
453
591
  ? { decision: 'deny', reason: 'Marrow blocked this protected action.' }
454
592
  : { decision: 'allow' };
455
593
  }
456
- function preActionHookOutput(result, harness = 'claude-code', prompt = NO_OWNER_PROMPT) {
594
+ function preActionHookOutput(result, harness = 'claude-code', prompt = NO_OWNER_PROMPT,
595
+ /** An older service without host approvals: Claude Code asks in its dialog exactly as 3.9.98 did. */
596
+ legacyServiceAsk = false) {
457
597
  if (harness === 'cursor')
458
598
  return cursorPreActionHookOutput(result);
459
599
  if (harness === 'cline')
@@ -480,15 +620,28 @@ function preActionHookOutput(result, harness = 'claude-code', prompt = NO_OWNER_
480
620
  ].filter(Boolean).join('\n'),
481
621
  } : {};
482
622
  const verdict = runtimeGateVerdict(runtime);
623
+ if (verdict && legacyServiceAsk && verdict.kind === 'review' && prompt.available && harness !== 'codex') {
624
+ return {
625
+ hookSpecificOutput: {
626
+ hookEventName: 'PreToolUse',
627
+ permissionDecision: 'ask',
628
+ permissionDecisionReason: gateDecisionMessage(verdict, true, prompt),
629
+ ...context,
630
+ },
631
+ };
632
+ }
483
633
  if (verdict) {
484
- // Only an ordinary review in a session that shows the owner a prompt asks;
485
- // block, arbitration and every host where no person would see it deny.
486
- const ask = verdict.kind === 'review' && prompt.available && harness !== 'codex';
634
+ // An ordinary hold the server lets the host approve is answered by
635
+ // heldActionHookOutput. Here every gate denies: block, arbitration, and a
636
+ // review the server offered no chat or terminal approval for.
637
+ const reason = verdict.kind === 'review' && prompt.available && !legacyServiceAsk
638
+ ? { available: false, unavailableReason: 'this Marrow service did not offer a chat or terminal approval for this hold' }
639
+ : prompt;
487
640
  return {
488
641
  hookSpecificOutput: {
489
642
  hookEventName: 'PreToolUse',
490
- permissionDecision: ask ? 'ask' : 'deny',
491
- permissionDecisionReason: gateDecisionMessage(verdict, ask, prompt),
643
+ permissionDecision: 'deny',
644
+ permissionDecisionReason: gateDecisionMessage(verdict, false, reason),
492
645
  ...context,
493
646
  },
494
647
  };
@@ -512,7 +665,7 @@ function preActionHookOutput(result, harness = 'claude-code', prompt = NO_OWNER_
512
665
  },
513
666
  };
514
667
  }
515
- function emitDecision(result, harness = 'claude-code', prompt = NO_OWNER_PROMPT) {
668
+ function emitDecision(result, harness = 'claude-code', prompt = NO_OWNER_PROMPT, legacyServiceAsk = false) {
516
669
  if (harness === 'windsurf') {
517
670
  const decision = windsurfPreActionDecision(result);
518
671
  process.exitCode = decision.exitCode;
@@ -522,7 +675,7 @@ function emitDecision(result, harness = 'claude-code', prompt = NO_OWNER_PROMPT)
522
675
  }
523
676
  if (result.outage)
524
677
  process.stderr.write(`${hook_contract_1.MARROW_OUTAGE_WARNING}\n`);
525
- const output = preActionHookOutput(result, harness, prompt);
678
+ const output = preActionHookOutput(result, harness, prompt, legacyServiceAsk);
526
679
  process.stdout.write(JSON.stringify(output));
527
680
  const specific = asRecord(output.hookSpecificOutput);
528
681
  if (specific?.permissionDecision === 'deny')
@@ -574,7 +727,7 @@ async function closeDeniedDecision(apiKey, baseUrl, held, reason, sessionId, age
574
727
  clearTimeout(timer);
575
728
  }
576
729
  }
577
- async function withTimeout(operation) {
730
+ async function withTimeout(operation, timeoutMs = exports.PRE_ACTION_CONTROL_TIMEOUT_MS) {
578
731
  const controller = new AbortController();
579
732
  let timer;
580
733
  try {
@@ -584,7 +737,7 @@ async function withTimeout(operation) {
584
737
  timer = setTimeout(() => {
585
738
  controller.abort();
586
739
  reject(new PreActionControlTimeoutError());
587
- }, exports.PRE_ACTION_CONTROL_TIMEOUT_MS);
740
+ }, timeoutMs);
588
741
  }),
589
742
  ]);
590
743
  }
@@ -593,6 +746,72 @@ async function withTimeout(operation) {
593
746
  clearTimeout(timer);
594
747
  }
595
748
  }
749
+ /**
750
+ * The gate's risk categories for this classified action, as the server
751
+ * derives them from the same action, type and surfaces (risk-categories.ts).
752
+ * Used only while Marrow cannot be reached, to keep owner-protected actions held.
753
+ */
754
+ const LOCAL_CATEGORY_PATTERNS = [
755
+ ['production_deploy', /\b(deploy|release|rollback|rollout|production|prod)\b/i],
756
+ ['source_control', /\b(merge|pull request|pr|github|main|master)\b/i],
757
+ ['data_migration', /\b(migration|migrate|schema|database|sql|table|column)\b/i],
758
+ ['secrets_security', /\b(secret|token|key|credential|rotate|revoke|security|waf|auth)\b/i],
759
+ ['billing_access', /\b(billing|payment|invoice|subscription|plan|tier|permission|access)\b/i],
760
+ ['destructive_action', /\b(?:delete|drop|truncate|purge|destroy|remove all|wipe)\b/i],
761
+ ];
762
+ function localApprovalCategories(action) {
763
+ const text = `${action.action} ${action.type} ${action.surfaces.join(' ')}`;
764
+ const categories = LOCAL_CATEGORY_PATTERNS.filter(([, pattern]) => pattern.test(text)).map(([category]) => category);
765
+ // Broader than the server's package-publish check on purpose: it only keeps an action held.
766
+ if (action.type.toLowerCase() === 'publish' || /\bpublish\b/i.test(text))
767
+ categories.unshift('package_publish');
768
+ return categories;
769
+ }
770
+ /**
771
+ * Positive evidence that a person is at this host for a call Marrow cannot ask
772
+ * about: a local interactive Codex, Gemini CLI or Cline CLI process, Cline or
773
+ * Windsurf inside the editor app, or a local Cursor session (sessionStart).
774
+ * Missing evidence is not attended.
775
+ */
776
+ function attendedConfirmed(ctx, cursorInteractive) {
777
+ if (ctx.host === 'codex' || ctx.host === 'gemini' || ctx.host === 'cline' || ctx.host === 'windsurf')
778
+ return (0, host_session_1.localInteractiveSession)(ctx.host) === true;
779
+ if (ctx.host === 'cursor')
780
+ return cursorInteractive === true;
781
+ return false;
782
+ }
783
+ /**
784
+ * Nobody is in this run: headless Claude Code (sdk-*, its GitHub Action), a
785
+ * scripted Codex or Gemini CLI run (`codex exec`, `gemini -p`), or a Cursor
786
+ * background agent. Missing evidence is not unattended.
787
+ */
788
+ function unattendedRun(ctx, claudePrompt, cursorInteractive) {
789
+ if (ctx.host === 'claude-code')
790
+ return claudePrompt.headless === true;
791
+ if (ctx.host === 'codex' || ctx.host === 'gemini')
792
+ return (0, host_session_1.localInteractiveSession)(ctx.host) === false;
793
+ // Cursor: sessionStart reports local sessions; without it (cloud agents) or as a background agent, nobody is there.
794
+ if (ctx.host === 'cursor')
795
+ return cursorInteractive !== true;
796
+ // Grok, Cline, Windsurf: attended only with positive evidence of a person's session.
797
+ if (ctx.host === 'grok' || ctx.host === 'cline' || ctx.host === 'windsurf')
798
+ return (0, host_session_1.localInteractiveSession)(ctx.host) !== true;
799
+ return false;
800
+ }
801
+ /** The hook's answer for a held action on any host (Windsurf answers through its exit code). */
802
+ function emitHeldPlan(harness, plan, code = null) {
803
+ emitHookOutput(harness, heldActionHookOutput(harness, plan, code), plan.kind === 'deny' ? plan.agentText : undefined);
804
+ }
805
+ /** Work that does not decide the answer, done after it is written and inside the hook's budget. */
806
+ async function afterAnswer(ctx, work) {
807
+ try {
808
+ if (work.link)
809
+ await (0, host_approval_1.requestOwnerLink)(ctx, work.link, 0);
810
+ if ((0, host_approval_1.remainingMs)(ctx) > 800)
811
+ await (0, host_approval_1.flushHoldOutbox)(ctx, 2, Math.min(4_000, (0, host_approval_1.remainingMs)(ctx) - 200));
812
+ }
813
+ catch { /* best effort: a later hook resends queued work */ }
814
+ }
596
815
  function installPreActionHook(startDir = process.cwd()) {
597
816
  const fs = require('node:fs');
598
817
  const path = (0, hook_contract_1.findHookSettingsPath)(startDir);
@@ -757,7 +976,7 @@ async function runPreActionHookCommand(input) {
757
976
  runtime: null,
758
977
  permit: null,
759
978
  protectedRisk: enforcementRequired,
760
- enforcementError: 'Marrow enforcement configuration is unavailable. Restore the trusted configuration before retrying this protected action.',
979
+ enforcementError: 'The Marrow address in this machine\'s settings is not valid, so this protected action was held. Run `npx @getmarrow/install` again in this terminal, then retry.',
761
980
  }, identity.harness);
762
981
  return;
763
982
  }
@@ -770,11 +989,56 @@ async function runPreActionHookCommand(input) {
770
989
  }, identity.harness);
771
990
  return;
772
991
  }
992
+ const holdContext = {
993
+ apiKey: resolved.apiKey,
994
+ baseUrl,
995
+ sessionId,
996
+ agentId,
997
+ harness: identity.harness,
998
+ host: (0, host_approval_1.approvalHostFor)(identity.harness),
999
+ hostSessionId: (0, host_approval_1.hostSessionIdFor)([source.session_id, source.conversation_id, source.task_id], sessionId),
1000
+ // One total budget for this hook's answer and its follow-up work.
1001
+ deadlineAt: (0, host_approval_1.preToolDeadline)((0, host_approval_1.approvalHostFor)(identity.harness)),
1002
+ };
1003
+ const toolUseId = typeof source.tool_use_id === 'string' ? source.tool_use_id : null;
1004
+ const generationId = typeof source.generation_id === 'string' ? source.generation_id : null;
1005
+ // Cursor runs an MCP call through preToolUse and then beforeMCPExecution, the
1006
+ // only event where Cursor shows its own approval prompt. In a local
1007
+ // interactive session preToolUse defers to it; cloud agents never run
1008
+ // beforeMCPExecution, so there preToolUse is the gate and holds quietly.
1009
+ const cursorMcpPreToolUse = holdContext.host === 'cursor' && String(source.hook_event_name || '').toLowerCase() === 'pretooluse'
1010
+ && /^MCP:/i.test(String(source.tool_name || ''));
1011
+ if (cursorMcpPreToolUse && (0, host_approval_1.cursorSessionEvidence)(holdContext).interactive === true) {
1012
+ process.stdout.write(JSON.stringify({ permission: 'allow' }));
1013
+ return;
1014
+ }
1015
+ // The same action retried after a hold that waited for approval: its status decides.
1016
+ const dialogAvailable = holdContext.host === 'claude-code' && ownerApprovalPrompt(identity.harness, source).available;
1017
+ const waited = await (0, host_approval_1.resumeWaitingHold)(holdContext, { correlation, toolUseId, generationId, dialogAvailable }).catch(() => null);
1018
+ if (waited?.kind === 'allow') {
1019
+ emitHookOutput(identity.harness, approvedHoldHookOutput(identity.harness, waited.contextText));
1020
+ await afterAnswer(holdContext, {});
1021
+ return;
1022
+ }
1023
+ if (waited?.kind === 'ask') {
1024
+ emitHookOutput(identity.harness, heldActionHookOutput(identity.harness, { kind: 'ask', promptText: waited.promptText }, null));
1025
+ await afterAnswer(holdContext, {});
1026
+ return;
1027
+ }
1028
+ if (waited?.kind === 'deny') {
1029
+ emitHeldPlan(identity.harness, { kind: 'deny', agentText: waited.agentText, userText: waited.userText, code: Boolean(waited.hold.code) }, waited.hold.code);
1030
+ await afterAnswer(holdContext, { link: waited.deferredLink ? waited.hold : null });
1031
+ return;
1032
+ }
1033
+ // One lifecycle record per attempt: a retried action is a new attempt, and a
1034
+ // hold names the record of the attempt that created it.
1035
+ const attempt = (0, node_crypto_1.createHash)('sha256').update(toolUseId || generationId || (0, node_crypto_1.randomUUID)()).digest('hex').slice(0, 12);
1036
+ const preActionEventId = `pretool-${correlation}-${attempt}`;
773
1037
  const lifecycle = (0, lifecycle_spool_1.recordLifecycleEvent)({
774
1038
  apiKey: resolved.apiKey,
775
1039
  baseUrl,
776
1040
  event: {
777
- event_id: `pretool-${correlation}`,
1041
+ event_id: preActionEventId,
778
1042
  event_type: 'pre_action_checked',
779
1043
  ...(0, hook_contract_1.clientReportedHookLifecycleIdentity)(identity),
780
1044
  session_id: sessionId,
@@ -787,6 +1051,11 @@ async function runPreActionHookCommand(input) {
787
1051
  outcome_state: 'pending',
788
1052
  },
789
1053
  }).catch(() => null);
1054
+ // Normalizing has its own share of the host's budget: past it the action is
1055
+ // a truncated placeholder, so the hook still answers (deny first) in time.
1056
+ const normalizedAction = (0, normalized_action_1.normalizedHookAction)(source, {
1057
+ deadlineAt: Math.min(Date.now() + 750, (holdContext.deadlineAt ?? Number.POSITIVE_INFINITY) - 1_000),
1058
+ });
790
1059
  // The decision and gate receipt this control path holds, so a denial can close them.
791
1060
  const held = { decisionId: null, gateReceiptId: null };
792
1061
  const control = async (signal) => {
@@ -796,6 +1065,9 @@ async function runPreActionHookCommand(input) {
796
1065
  type: classified.type,
797
1066
  role: classified.role,
798
1067
  surfaces: classified.surfaces,
1068
+ // The exact command or tool call (secrets removed), so Marrow binds the
1069
+ // gate and any approval to this action, not to its coarse class.
1070
+ normalized_action: normalizedAction,
799
1071
  // Without a risk level the runtime may take its low-risk fast path, whose
800
1072
  // non-durable fast_gate receipt can never back an action permit.
801
1073
  ...(enforcementRequired ? { risk_level: classified.risk } : {}),
@@ -889,25 +1161,147 @@ async function runPreActionHookCommand(input) {
889
1161
  ...(verifiedExactly ? {} : { enforcementError: 'Marrow permit verification did not match the issued permit.' }),
890
1162
  };
891
1163
  };
1164
+ // Codex and Cursor stop the hook at 5 s (and Codex then lets the call run):
1165
+ // the control path ends inside the hook's budget, leaving time to answer.
1166
+ const controlTimeoutMs = Math.max(1_000, Math.min(exports.PRE_ACTION_CONTROL_TIMEOUT_MS, (0, host_approval_1.remainingMs)(holdContext) - 400));
892
1167
  const [result] = await Promise.all([
893
- withTimeout(control).catch((error) => (isMarrowControlOutage(error)
1168
+ withTimeout(control, controlTimeoutMs).catch((error) => (isMarrowControlTimeout(error)
894
1169
  ? {
895
1170
  runtime: null,
896
1171
  permit: null,
897
1172
  protectedRisk: enforcementRequired,
898
- outage: true,
1173
+ timedOut: true,
899
1174
  enforcementError: hook_contract_1.MARROW_OUTAGE_WARNING,
900
1175
  }
901
- : {
902
- runtime: null,
903
- permit: null,
904
- protectedRisk: enforcementRequired,
905
- enforcementError: controlRejectionMessage(error, agentId),
906
- ...(controlFailureKind(error) ? { failure: controlFailureKind(error) } : {}),
907
- })),
1176
+ : isMarrowControlOutage(error)
1177
+ ? {
1178
+ runtime: null,
1179
+ permit: null,
1180
+ protectedRisk: enforcementRequired,
1181
+ outage: true,
1182
+ enforcementError: hook_contract_1.MARROW_OUTAGE_WARNING,
1183
+ }
1184
+ : {
1185
+ runtime: null,
1186
+ permit: null,
1187
+ protectedRisk: enforcementRequired,
1188
+ enforcementError: controlRejectionMessage(error, agentId),
1189
+ ...(controlFailureKind(error) ? { failure: controlFailureKind(error) } : {}),
1190
+ })),
908
1191
  lifecycle,
909
1192
  ]);
910
- const emitted = emitDecision(result, identity.harness, ownerApprovalPrompt(identity.harness, source));
1193
+ const claudePrompt = ownerApprovalPrompt(identity.harness, source);
1194
+ if (result.timedOut) {
1195
+ // Marrow is slow, not down: a self-imposed budget is never an outage. An
1196
+ // action that can be held stays held; routine actions keep flowing.
1197
+ const holdable = classified.protected || classified.risk === 'high'
1198
+ || (0, host_approval_1.protectedAmong)(holdContext, localApprovalCategories(classified)).length > 0;
1199
+ if (holdable) {
1200
+ emitHeldPlan(identity.harness, { kind: 'deny', agentText: exports.HELD_SLOW_TEXT, userText: exports.HELD_SLOW_TEXT, code: false });
1201
+ return;
1202
+ }
1203
+ emitDecision({ ...result, outage: true, timedOut: false }, identity.harness, claudePrompt);
1204
+ return;
1205
+ }
1206
+ if (result.outage) {
1207
+ // 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.
1209
+ const protectedNow = (0, host_approval_1.protectedAmong)(holdContext, localApprovalCategories(classified));
1210
+ if (protectedNow.length) {
1211
+ emitHeldPlan(identity.harness, { kind: 'deny', agentText: host_approval_1.HELD_UNREACHABLE_TEXT, userText: host_approval_1.HELD_UNREACHABLE_TEXT, code: false });
1212
+ return;
1213
+ }
1214
+ }
1215
+ const verdict = result.outage ? null : runtimeGateVerdict(result.runtime);
1216
+ const guidance = verdict?.kind === 'review' ? (0, runtime_contract_1.ordinaryApprovalGuidance)(result.runtime) : null;
1217
+ const arbitration = verdict?.kind === 'arbitration_review' ? (0, runtime_contract_1.arbitrationApprovalGuidance)(result.runtime) : null;
1218
+ if (verdict && guidance && !guidance.hostApprovalSupported && holdContext.host === 'claude-code') {
1219
+ // An older service without host approvals: exactly as released 3.9.98 —
1220
+ // ask in Claude Code's dialog (the close stays unverified), never wait.
1221
+ const emitted = emitDecision(result, identity.harness, claudePrompt, true);
1222
+ if (emitted.denied)
1223
+ await closeDeniedDecision(resolved.apiKey, baseUrl, held, emitted.reason, sessionId, agentId);
1224
+ return;
1225
+ }
1226
+ if (verdict && (guidance || arbitration)) {
1227
+ // A hold: ask in the host's own prompt where it counts and is shown,
1228
+ // otherwise deny and wait for the approval. Never close the decision here;
1229
+ // that would spend the receipt the operator or owner is about to approve.
1230
+ const hookEvent = typeof source.hook_event_name === 'string' ? source.hook_event_name : 'PreToolUse';
1231
+ let plan;
1232
+ const cursor = holdContext.host === 'cursor' ? (0, host_approval_1.cursorSessionEvidence)(holdContext) : null;
1233
+ const nobodyHere = cursorMcpPreToolUse || unattendedRun(holdContext, claudePrompt, cursor?.interactive ?? null);
1234
+ if (guidance) {
1235
+ (0, host_approval_1.rememberProtection)(holdContext, guidance);
1236
+ plan = (0, host_approval_1.planHeldAction)({
1237
+ unattended: nobodyHere,
1238
+ attendedConfirmed: !cursorMcpPreToolUse && attendedConfirmed(holdContext, cursor?.interactive ?? null),
1239
+ hostPromptOff: (0, host_session_1.hostApprovalPromptOff)(holdContext.host),
1240
+ guidance,
1241
+ host: holdContext.host,
1242
+ hookEvent,
1243
+ // Only why it is held: the server's next-action text is written for API
1244
+ // clients and names endpoints, so it is never relayed.
1245
+ reason: boundedText(result.runtime?.risk_gate?.reasons?.[0]?.message, 240),
1246
+ claudePrompt,
1247
+ cursorInteractive: cursor?.interactive ?? null,
1248
+ typedReply: (0, host_approval_1.typedReplyAvailable)(holdContext),
1249
+ });
1250
+ }
1251
+ else {
1252
+ plan = (0, host_approval_1.planArbitrationHold)(arbitration, { unattended: nobodyHere });
1253
+ }
1254
+ let code = null;
1255
+ let effective = plan;
1256
+ let linkOutcome = { kind: 'none' };
1257
+ let laterLink = null;
1258
+ try {
1259
+ // An unreadable approval state leaves nothing to wait on: the next attempt starts over.
1260
+ const hold = plan.kind === 'deny' && plan.retryFresh ? null : (0, host_approval_1.rememberHold)(holdContext, {
1261
+ guidance: guidance || (0, host_approval_1.arbitrationHoldGuidance)(arbitration),
1262
+ correlation,
1263
+ toolUseId,
1264
+ generationId,
1265
+ toolName: String(source.tool_name || 'tool'),
1266
+ hookEvent,
1267
+ // ask and pass: the host decides now; deny: the hold waits for an approval.
1268
+ mode: plan.kind === 'deny' ? 'wait' : 'ask',
1269
+ withCode: plan.kind === 'deny' && plan.code,
1270
+ preActionEventId,
1271
+ action: { action: classified.action, target: classified.target, type: classified.type, surfaces: classified.surfaces },
1272
+ ...(plan.kind === 'deny' && plan.ownerLink ? { ownerLink: plan.ownerLink } : {}),
1273
+ ...(plan.kind === 'deny' && plan.dialogLater ? { dialogLater: true, laterPrompt: plan.laterPrompt } : {}),
1274
+ ...(plan.kind === 'deny' && plan.quiet ? { quiet: plan.quiet } : {}),
1275
+ ...(plan.kind === 'pass' ? { notObserved: true } : {}),
1276
+ normalizedAction,
1277
+ ...(guidance ? {} : { personPresent: !nobodyHere }),
1278
+ });
1279
+ code = hold?.code ?? null;
1280
+ if (hold && plan.kind === 'deny' && plan.ownerLink === 'now') {
1281
+ linkOutcome = await (0, host_approval_1.requestOwnerLink)(holdContext, hold);
1282
+ if (linkOutcome.kind === 'deferred')
1283
+ laterLink = hold;
1284
+ }
1285
+ }
1286
+ catch {
1287
+ // Without local state the answer could not be linked to this hold, so it is not asked for.
1288
+ if (plan.kind === 'ask' || plan.kind === 'pass') {
1289
+ const text = `Marrow is holding this action for approval (gate receipt ${guidance?.gateReceiptId || arbitration?.gateReceiptId}), so it did not run. Tell the operator it is waiting for approval; local approval state is unavailable on this machine.`;
1290
+ effective = { kind: 'deny', agentText: text, userText: (0, host_approval_1.forPerson)(text), code: false };
1291
+ }
1292
+ }
1293
+ effective = (0, host_approval_1.finalizeOwnerRequest)(effective, linkOutcome);
1294
+ if (effective.kind === 'pass') {
1295
+ // The hook does not block and does not allow: the host's own approval step decides.
1296
+ emitHookOutput(identity.harness, passHookOutput(identity.harness, effective.contextText));
1297
+ await afterAnswer(holdContext, {});
1298
+ return;
1299
+ }
1300
+ emitHeldPlan(identity.harness, effective, code);
1301
+ await afterAnswer(holdContext, { link: laterLink });
1302
+ return;
1303
+ }
1304
+ const emitted = emitDecision(result, identity.harness, claudePrompt);
911
1305
  if (emitted.denied && !result.outage) {
912
1306
  await closeDeniedDecision(resolved.apiKey, baseUrl, held, emitted.reason, sessionId, agentId);
913
1307
  }