@getmarrow/mcp 3.9.98 → 3.9.100

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