@getmarrow/mcp 3.9.95 → 3.9.97

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.
@@ -1,8 +1,16 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.PreActionControlTimeoutError = exports.PRE_ACTION_CONTROL_TIMEOUT_MS = exports.MARROW_OUTAGE_WARNING = void 0;
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;
4
4
  exports.isMarrowControlOutage = isMarrowControlOutage;
5
5
  exports.isMarrowOutage = isMarrowOutage;
6
+ exports.controlFailureKind = controlFailureKind;
7
+ exports.controlRejectionMessage = controlRejectionMessage;
8
+ exports.ownerApprovalPrompt = ownerApprovalPrompt;
9
+ exports.runtimeGateAdvisory = runtimeGateAdvisory;
10
+ exports.runtimeGateEnforced = runtimeGateEnforced;
11
+ exports.advisoryGateNotice = advisoryGateNotice;
12
+ exports.runtimeGateVerdict = runtimeGateVerdict;
13
+ exports.gateDecisionMessage = gateDecisionMessage;
6
14
  exports.localControlAllowOutput = localControlAllowOutput;
7
15
  exports.localLoopGuardDenyOutput = localLoopGuardDenyOutput;
8
16
  exports.classifyTool = classifyTool;
@@ -12,8 +20,10 @@ exports.windsurfPreActionDecision = windsurfPreActionDecision;
12
20
  exports.geminiPreActionHookOutput = geminiPreActionHookOutput;
13
21
  exports.grokPreActionHookOutput = grokPreActionHookOutput;
14
22
  exports.preActionHookOutput = preActionHookOutput;
23
+ exports.closeDeniedDecision = closeDeniedDecision;
15
24
  exports.installPreActionHook = installPreActionHook;
16
25
  exports.runPreActionHookCommand = runPreActionHookCommand;
26
+ const node_crypto_1 = require("node:crypto");
17
27
  const index_1 = require("./index");
18
28
  const request_reliability_1 = require("./request-reliability");
19
29
  const lifecycle_spool_1 = require("./lifecycle-spool");
@@ -23,7 +33,9 @@ const session_loop_guard_1 = require("./session-loop-guard");
23
33
  const hook_tool_policy_1 = require("./hook-tool-policy");
24
34
  const hook_contract_1 = require("./hook-contract");
25
35
  Object.defineProperty(exports, "MARROW_OUTAGE_WARNING", { enumerable: true, get: function () { return hook_contract_1.MARROW_OUTAGE_WARNING; } });
26
- const MAX_INPUT_BYTES = 64 * 1024;
36
+ // Claude Code sends the whole tool input, and a Write of a long document grows
37
+ // further once JSON-escaped, so tens of kilobytes of markdown must still parse.
38
+ exports.MAX_PRE_ACTION_INPUT_BYTES = 4 * 1024 * 1024;
27
39
  // Cold auth may already use 900ms plus a 1600ms in-flight grace before think
28
40
  // and enforcement. Keep this above that budget so a slow store is not aborted
29
41
  // and misread as an outage.
@@ -70,6 +82,180 @@ function isMarrowControlOutage(error) {
70
82
  function isMarrowOutage(result) {
71
83
  return result.outage === true;
72
84
  }
85
+ const SAFE_FAILURE_CODE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,79}$/;
86
+ const SAFE_AGENT_ID = /^[A-Za-z0-9._:-]{1,128}$/;
87
+ // The key is valid but not bound to the agent this hook acts as.
88
+ const AGENT_CREDENTIAL_SCOPE_CODES = new Set(['ACTION_PERMIT_AGENT_CREDENTIAL_SCOPE_INVALID', 'MARROW_AGENT_SCOPE_MISMATCH']);
89
+ // Marrow could not be used at all, as opposed to answering with a policy or credential decision.
90
+ const CONTROL_UNAVAILABLE_CODES = new Set(['tls_failure', 'invalid_response', 'edge_access_denied']);
91
+ function controlFailureKind(error) {
92
+ if (!(error instanceof request_reliability_1.MarrowRequestError))
93
+ return undefined;
94
+ if (error.status === 403 && AGENT_CREDENTIAL_SCOPE_CODES.has(error.backendCode || ''))
95
+ return 'credential_scope';
96
+ return CONTROL_UNAVAILABLE_CODES.has(error.code) ? 'unavailable' : undefined;
97
+ }
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.
100
+ function controlRejectionMessage(error, agentId) {
101
+ const detail = [];
102
+ if (error instanceof request_reliability_1.MarrowRequestError) {
103
+ if (typeof error.status === 'number' && Number.isInteger(error.status))
104
+ detail.push(`HTTP ${error.status}`);
105
+ const code = [error.backendCode, error.code].find((value) => typeof value === 'string' && SAFE_FAILURE_CODE.test(value));
106
+ if (code)
107
+ detail.push(code);
108
+ }
109
+ const suffix = detail.length ? ` (${detail.join(' ')})` : '';
110
+ const kind = controlFailureKind(error);
111
+ if (kind === 'credential_scope') {
112
+ const agent = agentId && SAFE_AGENT_ID.test(agentId)
113
+ ? `agent "${agentId}"`
114
+ : 'this hook\'s agent (no MARROW_FLEET_AGENT_ID or MARROW_AGENT_ID is set)';
115
+ return `This Marrow API key is not authorized to obtain action permits for ${agent}${suffix}. Use the API key issued to that agent, or set MARROW_FLEET_AGENT_ID (or MARROW_AGENT_ID) to the agent this key belongs to, then retry.`;
116
+ }
117
+ if (kind === 'unavailable') {
118
+ return `Marrow is unavailable, so this protected action was denied${suffix}. Retry when Marrow is reachable.`;
119
+ }
120
+ return `Marrow rejected this protected action${suffix}. Restore trusted governance before retrying.`;
121
+ }
122
+ // Claude Code permission modes in which a PreToolUse "ask" reaches a person.
123
+ // bypassPermissions disables prompts, dontAsk auto-denies anything that would
124
+ // prompt, and plan mode with bypass available runs edits without prompting.
125
+ // https://code.claude.com/docs/en/hooks#pretooluse-decision-control
126
+ // https://code.claude.com/docs/en/permission-modes
127
+ const OWNER_PROMPT_PERMISSION_MODES = new Set(['default', 'acceptEdits', 'auto']);
128
+ const NO_OWNER_PROMPT = { available: false, unavailableReason: 'this agent host cannot prompt the owner' };
129
+ /**
130
+ * Whether a PreToolUse "ask" from this hook reaches a person who can approve.
131
+ * Only Claude Code asks; the generic entrypoint counts as Claude Code only when
132
+ * Claude Code itself spawned the hook (CLAUDE_CODE_CHILD_SESSION, v2.1.172+).
133
+ */
134
+ function ownerApprovalPrompt(harness, event, env = process.env) {
135
+ const claudeCode = harness === 'claude-code' || (harness === 'mcp-client' && env.CLAUDE_CODE_CHILD_SESSION === '1');
136
+ if (!claudeCode) {
137
+ return harness === 'codex'
138
+ ? { available: false, unavailableReason: 'Codex hooks cannot prompt the owner' }
139
+ : NO_OWNER_PROMPT;
140
+ }
141
+ const mode = typeof event.permission_mode === 'string' && /^[A-Za-z]{1,32}$/.test(event.permission_mode) ? event.permission_mode : '';
142
+ if (!OWNER_PROMPT_PERMISSION_MODES.has(mode)) {
143
+ return { available: false, unavailableReason: `Claude Code permission mode ${mode || 'unknown'} cannot guarantee an owner prompt` };
144
+ }
145
+ // Before Claude Code v2.1.211 the auto-mode classifier could approve a Bash
146
+ // command outside the sandbox without the prompt a hook asked for. The hook
147
+ // input carries scratchpad_dir only from v2.1.257, so it proves a fixed version.
148
+ if (mode === 'auto' && !(typeof event.scratchpad_dir === 'string' && event.scratchpad_dir.trim())) {
149
+ return { available: false, unavailableReason: 'this auto-mode session does not prove a Claude Code version whose classifier honors hook prompts' };
150
+ }
151
+ return { available: true, unavailableReason: '' };
152
+ }
153
+ function boundedText(value, limit) {
154
+ return String(value ?? '')
155
+ .replace(/[\u0000-\u001f\u007f]/g, ' ')
156
+ .replace(/\s+/g, ' ')
157
+ .trim()
158
+ .slice(0, limit);
159
+ }
160
+ function asOptionalRecord(value) {
161
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
162
+ }
163
+ // Arbitration review is satisfied only by an authenticated-dashboard receipt,
164
+ // never by a host prompt, so any arbitration signal keeps the denial.
165
+ function arbitrationReview(runtime) {
166
+ const completion = runtime.completion_contract;
167
+ const approval = asOptionalRecord(completion?.owner_approval);
168
+ return Boolean(runtime.arbitration)
169
+ || completion?.arbitration_receipt_required === true
170
+ || approval?.dashboard_receipt_required === true
171
+ || (approval?.mode !== undefined && approval.mode !== 'ordinary_non_arbitrated');
172
+ }
173
+ /**
174
+ * True only for the runtime's positive advisory contract. On a plan without
175
+ * production_action_enforcement the backend gate (agent-runtime.service.ts,
176
+ * the hardGateEnforcement branch) carries enforced:false with
177
+ * enforcement_decision:'advisory', and gate_required, owner_approval_required
178
+ * and gate_receipt.required are false; the slim shape the MCP client receives
179
+ * carries risk_gate_enforced:false instead. Missing, malformed or conflicting
180
+ * enforcement fields are never advisory, so a protected action fails closed.
181
+ */
182
+ function runtimeGateAdvisory(runtime) {
183
+ const gate = runtime?.risk_gate;
184
+ if (!runtime || !gate)
185
+ return false;
186
+ const slim = runtime.response_mode === 'slim';
187
+ const completion = runtime.completion_contract;
188
+ const plan = runtime.plan_capability;
189
+ return gate.enforced === false
190
+ && gate.enforcement_decision === 'advisory'
191
+ && String(gate.decision) !== 'block'
192
+ // A slim response has no allow field; the client derives it from the decision.
193
+ && (slim ? runtime.risk_gate_enforced === false : gate.allow === true && runtime.risk_gate_enforced == null)
194
+ && (runtime.enforcement_decision == null || runtime.enforcement_decision === 'advisory')
195
+ && (runtime.authorization_state === undefined || runtime.authorization_state === 'advisory_only')
196
+ && runtime.hard_gate_obtained !== true
197
+ && gate.gate_required !== true
198
+ && gate.owner_approval_required !== true
199
+ && runtime.gate_receipt?.required !== true
200
+ && runtime.gate_receipt?.owner_approval_required !== true
201
+ && completion?.gate_receipt_required !== true
202
+ && completion?.owner_approval_required !== true
203
+ && completion?.owner_approval == null
204
+ && !arbitrationReview(runtime)
205
+ && plan?.production_enforcement_entitled !== true
206
+ && plan?.mode !== 'enforced';
207
+ }
208
+ /** Every gate is enforced unless the runtime positively declares it advisory. */
209
+ function runtimeGateEnforced(runtime) {
210
+ return !runtimeGateAdvisory(runtime);
211
+ }
212
+ 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;
216
+ }
217
+ /** A warning for a non-allow gate the runtime does not enforce on this plan. */
218
+ function advisoryGateNotice(runtime) {
219
+ const gate = runtime?.risk_gate;
220
+ if (!runtime || !gate || !runtimeGateAdvisory(runtime))
221
+ return null;
222
+ const decision = String(gate.decision || '');
223
+ if (gate.allow !== false && !['warn', 'review_required', 'owner_approval_required'].includes(decision))
224
+ return null;
225
+ const reason = gateReason(runtime);
226
+ return boundedText(`Marrow advisory: this plan does not enforce the pre-action gate, so the action is allowed. Gate decision: ${decision}.${reason ? ` Reason: ${reason}` : ''}`, 500);
227
+ }
228
+ function runtimeGateVerdict(runtime) {
229
+ const gate = runtime?.risk_gate;
230
+ if (!runtime || !gate)
231
+ return null;
232
+ const decision = String(gate.decision || '');
233
+ const review = decision === 'review_required' || decision === 'owner_approval_required';
234
+ if (decision !== 'block' && !review && gate.allow !== false)
235
+ return null;
236
+ // An advisory gate warns; only an enforced gate (or any block) stops the action.
237
+ if (decision !== 'block' && runtimeGateAdvisory(runtime))
238
+ return null;
239
+ const reason = gateReason(runtime);
240
+ if (decision === 'block')
241
+ return { kind: 'block', reason };
242
+ if (review)
243
+ return { kind: arbitrationReview(runtime) ? 'arbitration_review' : 'review', reason };
244
+ return { kind: 'denied', reason };
245
+ }
246
+ // Fixed wording leads so a long service reason can never truncate what happened.
247
+ function gateDecisionMessage(verdict, ask, prompt = NO_OWNER_PROMPT) {
248
+ const headline = verdict.kind === 'block'
249
+ ? 'Marrow blocked this action under the current policy.'
250
+ : verdict.kind === 'arbitration_review'
251
+ ? 'Marrow arbitration requires owner approval in the authenticated Marrow dashboard before this action.'
252
+ : verdict.kind === 'denied'
253
+ ? 'Marrow did not allow this action.'
254
+ : ask
255
+ ? '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.`;
257
+ return boundedText(verdict.reason ? `${headline} Reason: ${verdict.reason}` : headline, 500);
258
+ }
73
259
  const SAFE_DECISION_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/;
74
260
  function localControlAllowOutput(harness) {
75
261
  if (harness === 'windsurf')
@@ -114,19 +300,31 @@ async function readStdin() {
114
300
  for await (const chunk of process.stdin) {
115
301
  const buffer = Buffer.from(chunk);
116
302
  bytes += buffer.length;
117
- if (bytes > MAX_INPUT_BYTES)
118
- throw new Error('pre-action hook input exceeds byte limit');
119
- chunks.push(buffer);
303
+ // Keep draining so the host never sees a broken pipe, but stop buffering past the bound.
304
+ if (bytes <= exports.MAX_PRE_ACTION_INPUT_BYTES)
305
+ chunks.push(buffer);
306
+ }
307
+ return { raw: bytes > exports.MAX_PRE_ACTION_INPUT_BYTES ? '' : Buffer.concat(chunks).toString('utf8'), bytes };
308
+ }
309
+ function localControlDisabled() {
310
+ try {
311
+ return (0, control_state_1.readLocalControlState)().enabled === false;
312
+ }
313
+ catch {
314
+ return false;
120
315
  }
121
- return Buffer.concat(chunks).toString('utf8');
122
316
  }
123
317
  function classifyTool(event) {
124
318
  const tool = String(event.tool_name || 'tool').slice(0, 64);
125
319
  const normalizedTool = (0, hook_tool_policy_1.normalizeHookToolName)(tool);
126
- const command = (0, hook_tool_policy_1.hookToolCommand)(event);
127
- const input = `${normalizedTool} ${command} ${JSON.stringify(event.tool_input || {})}`.toLowerCase();
320
+ // Editing and task tools are judged by their target; shell-like words in the
321
+ // content they write or the subject they record are not actions.
322
+ const shellGoverned = (0, hook_tool_policy_1.isShellGovernedTool)(event);
323
+ const command = shellGoverned ? (0, hook_tool_policy_1.hookToolCommand)(event) : '';
324
+ const input = (0, hook_tool_policy_1.toolClassificationText)(event).toLowerCase();
128
325
  const readOnly = (0, hook_tool_policy_1.isReadOnlyToolEvent)(event);
129
- const protectedShellCommand = (0, hook_tool_policy_1.isProtectedShellMutation)(command);
326
+ const secretAccess = (0, hook_tool_policy_1.isSecretMaterialAccess)(event);
327
+ const protectedShellCommand = shellGoverned && (0, hook_tool_policy_1.isProtectedShellMutation)(command);
130
328
  const infrastructureDeployment = /\b(?:kubectl|terraform|pulumi|helm)\b/.test(command.toLowerCase())
131
329
  && protectedShellCommand;
132
330
  let type = 'process';
@@ -138,7 +336,7 @@ function classifyTool(event) {
138
336
  type = 'review';
139
337
  else if (/\b(?:migration|schema|database|d1)\b/.test(input))
140
338
  type = 'migration';
141
- else if (/\b(?:secret|credential|token|key|permission)\b/.test(input))
339
+ else if (secretAccess || /\b(?:secret|credential|token|key|permission)\b/.test(input))
142
340
  type = 'audit';
143
341
  else if (/\b(?:payment|refund|charge|invoice|stripe|financial)\b/.test(input))
144
342
  type = 'financial';
@@ -146,11 +344,12 @@ function classifyTool(event) {
146
344
  /\b(?:deploy|release|production|prod|wrangler)\b/.test(input) || infrastructureDeployment ? 'production' : '',
147
345
  /\b(?:git|github|merge|pull request|push)\b/.test(input) ? 'github' : '',
148
346
  /\b(?:npm|package|publish)\b/.test(input) ? 'npm' : '',
149
- /\b(?:secret|credential|token|key)\b/.test(input) ? 'secrets' : '',
347
+ secretAccess || /\b(?:secret|credential|token|key)\b/.test(input) ? 'secrets' : '',
150
348
  /\b(?:migration|schema|database|d1)\b/.test(input) ? 'database' : '',
151
349
  /\b(?:payment|refund|charge|invoice|stripe|financial)\b/.test(input) ? 'financial' : '',
152
350
  ].filter(Boolean);
153
- const protectedAction = !readOnly && (/\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)
351
+ const protectedAction = !readOnly && (secretAccess
352
+ || (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))
154
353
  || protectedShellCommand
155
354
  || ((0, hook_tool_policy_1.isMcpHookTool)(event.tool_name) && !(0, hook_tool_policy_1.isOfficialMarrowMcpTool)(event.tool_name))
156
355
  || (['use_mcp_tool', 'use_tool'].includes(normalizedTool) && !(0, hook_tool_policy_1.isOfficialMarrowMcpEvent)(event)));
@@ -178,52 +377,45 @@ function cursorPreActionHookOutput(result) {
178
377
  return { permission: 'allow', user_message: hook_contract_1.MARROW_OUTAGE_WARNING, agent_message: hook_contract_1.MARROW_OUTAGE_WARNING };
179
378
  }
180
379
  const { runtime, permit, protectedRisk } = result;
181
- const message = (value) => String(value || 'Marrow denied this action.')
182
- .replace(/[\u0000-\u001f\u007f]/g, ' ')
183
- .replace(/\s+/g, ' ')
184
- .trim()
185
- .slice(0, 500);
380
+ const verdict = runtimeGateVerdict(runtime);
381
+ if (verdict) {
382
+ const denial = gateDecisionMessage(verdict, false);
383
+ return { permission: 'deny', user_message: denial, agent_message: denial };
384
+ }
186
385
  if (protectedRisk && (!runtime || !permit?.verified)) {
187
- const denial = message(result.enforcementError || 'Marrow could not verify the required action permit. Retry after governance is available.');
386
+ const denial = boundedText(result.enforcementError || 'Marrow could not verify the required action permit. Retry after governance is available.', 500);
188
387
  return {
189
388
  permission: 'deny',
190
389
  user_message: denial,
191
390
  agent_message: denial,
192
391
  };
193
392
  }
194
- const gate = runtime?.risk_gate;
195
- if (!gate)
196
- return { permission: 'allow' };
197
- const reason = runtime?.exact_next_action
198
- || gate.reasons?.[0]?.message
199
- || 'Marrow requires additional proof or operator review before this action.';
200
- if (gate.decision === 'review_required' || gate.decision === 'block' || gate.allow === false) {
201
- const denial = message(reason);
202
- return { permission: 'deny', user_message: denial, agent_message: denial };
203
- }
204
- return { permission: 'allow' };
393
+ const advisory = advisoryGateNotice(runtime);
394
+ return advisory ? { permission: 'allow', user_message: advisory, agent_message: advisory } : { permission: 'allow' };
205
395
  }
206
396
  function clinePreActionHookOutput(result) {
207
397
  if (isMarrowOutage(result))
208
398
  return { cancel: false };
209
- if (result.protectedRisk && (!result.runtime || !result.permit?.verified)) {
210
- const credentialsUnavailable = /credentials are unavailable/i.test(String(result.enforcementError || ''));
399
+ const verdict = runtimeGateVerdict(result.runtime);
400
+ if (verdict) {
211
401
  return {
212
402
  cancel: true,
213
- errorMessage: credentialsUnavailable
214
- ? 'Marrow credentials are unavailable for this protected action. Restore the configured agent key and retry.'
215
- : 'Marrow could not verify the required action permit. Restore trusted governance and retry.',
403
+ errorMessage: verdict.kind === 'review' || verdict.kind === 'arbitration_review'
404
+ ? 'Marrow requires operator review before this protected action.'
405
+ : 'Marrow blocked this protected action under the current policy.',
216
406
  };
217
407
  }
218
- const gate = result.runtime?.risk_gate;
219
- if (!gate)
220
- return { cancel: false };
221
- if (gate.decision === 'review_required' || gate.decision === 'block' || gate.allow === false) {
408
+ if (result.protectedRisk && (!result.runtime || !result.permit?.verified)) {
409
+ const credentialsUnavailable = /credentials are unavailable/i.test(String(result.enforcementError || ''));
222
410
  return {
223
411
  cancel: true,
224
- errorMessage: gate.decision === 'review_required'
225
- ? 'Marrow requires operator review before this protected action.'
226
- : 'Marrow blocked this protected action under the current policy.',
412
+ errorMessage: credentialsUnavailable
413
+ ? 'Marrow credentials are unavailable for this protected action. Restore the configured agent key and retry.'
414
+ : result.failure === 'credential_scope'
415
+ ? '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
+ : result.failure === 'unavailable'
417
+ ? '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.',
227
419
  };
228
420
  }
229
421
  return { cancel: false };
@@ -232,11 +424,7 @@ function windsurfPreActionDecision(result) {
232
424
  if (isMarrowOutage(result))
233
425
  return { exitCode: 0, stderr: `${hook_contract_1.MARROW_OUTAGE_WARNING}\n` };
234
426
  const unavailable = result.protectedRisk && (!result.runtime || !result.permit?.verified);
235
- const gate = result.runtime?.risk_gate;
236
- const denied = unavailable
237
- || gate?.decision === 'review_required'
238
- || gate?.decision === 'block'
239
- || gate?.allow === false;
427
+ const denied = unavailable || runtimeGateVerdict(result.runtime) !== null;
240
428
  return denied
241
429
  ? {
242
430
  exitCode: 2,
@@ -248,11 +436,7 @@ function geminiPreActionHookOutput(result) {
248
436
  if (isMarrowOutage(result))
249
437
  return { decision: 'allow' };
250
438
  const unavailable = result.protectedRisk && (!result.runtime || !result.permit?.verified);
251
- const gate = result.runtime?.risk_gate;
252
- const denied = unavailable
253
- || gate?.decision === 'review_required'
254
- || gate?.decision === 'block'
255
- || gate?.allow === false;
439
+ const denied = unavailable || runtimeGateVerdict(result.runtime) !== null;
256
440
  return denied
257
441
  ? {
258
442
  decision: 'deny',
@@ -264,16 +448,12 @@ function grokPreActionHookOutput(result) {
264
448
  if (isMarrowOutage(result))
265
449
  return { decision: 'allow' };
266
450
  const unavailable = result.protectedRisk && (!result.runtime || !result.permit?.verified);
267
- const gate = result.runtime?.risk_gate;
268
- const denied = unavailable
269
- || gate?.decision === 'review_required'
270
- || gate?.decision === 'block'
271
- || gate?.allow === false;
451
+ const denied = unavailable || runtimeGateVerdict(result.runtime) !== null;
272
452
  return denied
273
453
  ? { decision: 'deny', reason: 'Marrow blocked this protected action.' }
274
454
  : { decision: 'allow' };
275
455
  }
276
- function preActionHookOutput(result, harness = 'claude-code') {
456
+ function preActionHookOutput(result, harness = 'claude-code', prompt = NO_OWNER_PROMPT) {
277
457
  if (harness === 'cursor')
278
458
  return cursorPreActionHookOutput(result);
279
459
  if (harness === 'cline')
@@ -291,6 +471,28 @@ function preActionHookOutput(result, harness = 'claude-code') {
291
471
  };
292
472
  }
293
473
  const { runtime, permit, protectedRisk } = result;
474
+ const advisory = advisoryGateNotice(runtime);
475
+ const context = runtime?.before_you_act || permit?.permit_id || advisory ? {
476
+ additionalContext: [
477
+ advisory,
478
+ runtime?.before_you_act,
479
+ permit?.permit_id ? `Marrow action permit verified: ${permit.permit_id}. Evidence and outcome closure remain required.` : null,
480
+ ].filter(Boolean).join('\n'),
481
+ } : {};
482
+ const verdict = runtimeGateVerdict(runtime);
483
+ 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';
487
+ return {
488
+ hookSpecificOutput: {
489
+ hookEventName: 'PreToolUse',
490
+ permissionDecision: ask ? 'ask' : 'deny',
491
+ permissionDecisionReason: gateDecisionMessage(verdict, ask, prompt),
492
+ ...context,
493
+ },
494
+ };
495
+ }
294
496
  if (protectedRisk && (!runtime || !permit?.verified)) {
295
497
  return {
296
498
  hookSpecificOutput: {
@@ -303,39 +505,74 @@ function preActionHookOutput(result, harness = 'claude-code') {
303
505
  if (!runtime?.risk_gate) {
304
506
  return {};
305
507
  }
306
- const gate = runtime.risk_gate;
307
- const reason = runtime.exact_next_action
308
- || gate.reasons?.[0]?.message
309
- || 'Marrow requires additional proof or operator review before this action.';
310
- const permissionDecision = gate.decision === 'review_required'
311
- ? harness === 'codex' ? 'deny' : 'ask'
312
- : gate.decision === 'block' || gate.allow === false
313
- ? 'deny'
314
- : null;
315
508
  return {
316
509
  hookSpecificOutput: {
317
510
  hookEventName: 'PreToolUse',
318
- ...(permissionDecision ? { permissionDecision, permissionDecisionReason: reason } : {}),
319
- ...(runtime.before_you_act || permit?.permit_id ? {
320
- additionalContext: [
321
- runtime.before_you_act,
322
- permit?.permit_id ? `Marrow action permit verified: ${permit.permit_id}. Evidence and outcome closure remain required.` : null,
323
- ].filter(Boolean).join('\n'),
324
- } : {}),
511
+ ...context,
325
512
  },
326
513
  };
327
514
  }
328
- function emitDecision(result, harness = 'claude-code') {
515
+ function emitDecision(result, harness = 'claude-code', prompt = NO_OWNER_PROMPT) {
329
516
  if (harness === 'windsurf') {
330
517
  const decision = windsurfPreActionDecision(result);
331
518
  process.exitCode = decision.exitCode;
332
519
  if (decision.stderr)
333
520
  process.stderr.write(decision.stderr);
334
- return;
521
+ return { denied: decision.exitCode === 2, reason: decision.stderr.trim() };
335
522
  }
336
523
  if (result.outage)
337
524
  process.stderr.write(`${hook_contract_1.MARROW_OUTAGE_WARNING}\n`);
338
- process.stdout.write(JSON.stringify(preActionHookOutput(result, harness)));
525
+ const output = preActionHookOutput(result, harness, prompt);
526
+ process.stdout.write(JSON.stringify(output));
527
+ const specific = asRecord(output.hookSpecificOutput);
528
+ if (specific?.permissionDecision === 'deny')
529
+ return { denied: true, reason: String(specific.permissionDecisionReason || '') };
530
+ if (output.permission === 'deny')
531
+ return { denied: true, reason: String(output.agent_message || '') };
532
+ if (output.cancel === true)
533
+ return { denied: true, reason: String(output.errorMessage || '') };
534
+ if (output.decision === 'deny')
535
+ return { denied: true, reason: String(output.reason || '') };
536
+ return { denied: false, reason: '' };
537
+ }
538
+ // Closing is best effort: the denial already stands, and the hook must not hang.
539
+ exports.DENIED_DECISION_CLOSE_TIMEOUT_MS = 2_500;
540
+ /**
541
+ * Records a decision the hook denied as a failed outcome, so it carries real
542
+ * outcome data instead of being swept to a NULL outcome later. Never called for
543
+ * an "ask": an approved prompt runs the action and its outcome is still open.
544
+ */
545
+ async function closeDeniedDecision(apiKey, baseUrl, held, reason, sessionId, agentId) {
546
+ if (!held.decisionId)
547
+ return false;
548
+ const outcome = boundedText(`denied by Marrow pre-action gate: ${reason || 'no reason was returned'}`, 500);
549
+ const controller = new AbortController();
550
+ let timer;
551
+ try {
552
+ const committed = await Promise.race([
553
+ (0, index_1.marrowCommit)(apiKey, baseUrl, {
554
+ decision_id: held.decisionId,
555
+ success: false,
556
+ outcome,
557
+ auto_gate: false,
558
+ ...(held.gateReceiptId ? { gate_receipt_id: held.gateReceiptId } : {}),
559
+ }, sessionId, agentId, controller.signal, `mcp-hook-deny:${(0, node_crypto_1.createHash)('sha256').update(`${held.decisionId}\n${outcome}`).digest('hex').slice(0, 40)}`),
560
+ new Promise((resolve) => {
561
+ timer = setTimeout(() => {
562
+ controller.abort();
563
+ resolve(null);
564
+ }, exports.DENIED_DECISION_CLOSE_TIMEOUT_MS);
565
+ }),
566
+ ]);
567
+ return committed?.committed === true;
568
+ }
569
+ catch {
570
+ return false;
571
+ }
572
+ finally {
573
+ if (timer)
574
+ clearTimeout(timer);
575
+ }
339
576
  }
340
577
  async function withTimeout(operation) {
341
578
  const controller = new AbortController();
@@ -372,19 +609,36 @@ async function runPreActionHookCommand(input) {
372
609
  return;
373
610
  const identity = (0, hook_contract_1.resolveNativeHookIdentity)(process.argv[2]);
374
611
  let event = input;
612
+ let inputFailure = null;
375
613
  if (event === undefined) {
376
614
  try {
377
- const raw = (await readStdin()).trim();
378
- event = raw ? (0, hook_contract_1.normalizeHookEventPayload)(JSON.parse(raw)) : {};
615
+ const stdin = await readStdin();
616
+ if (stdin.bytes > exports.MAX_PRE_ACTION_INPUT_BYTES) {
617
+ inputFailure = `Marrow did not check this action because its hook input is ${stdin.bytes} bytes, over the ${exports.MAX_PRE_ACTION_INPUT_BYTES}-byte pre-action limit, so it was denied. Split it into smaller tool calls and retry.`;
618
+ }
619
+ else {
620
+ const raw = stdin.raw.trim();
621
+ event = raw ? (0, hook_contract_1.normalizeHookEventPayload)(JSON.parse(raw)) : {};
622
+ }
379
623
  }
380
624
  catch {
381
- emitDecision({ runtime: null, permit: null, protectedRisk: true, enforcementError: 'Marrow rejected malformed or oversized pre-action input.' }, identity.harness);
382
- return;
625
+ inputFailure = 'Marrow rejected malformed pre-action input.';
383
626
  }
384
627
  }
385
- const source = asRecord((0, hook_contract_1.normalizeHookEventPayload)(event));
386
- if (!source?.tool_name) {
387
- emitDecision({ runtime: null, permit: null, protectedRisk: true, enforcementError: 'Marrow could not classify this mutation-capable tool request.' }, identity.harness);
628
+ const source = inputFailure ? null : asRecord((0, hook_contract_1.normalizeHookEventPayload)(event));
629
+ if (!inputFailure && !source?.tool_name)
630
+ inputFailure = 'Marrow could not classify this mutation-capable tool request.';
631
+ if (inputFailure || !source) {
632
+ // Owner-disabled local control allows every action, including input Marrow cannot read.
633
+ if (localControlDisabled()) {
634
+ const allow = localControlAllowOutput(identity.harness);
635
+ if (allow === null)
636
+ process.exitCode = 0;
637
+ else
638
+ process.stdout.write(JSON.stringify(allow));
639
+ return;
640
+ }
641
+ emitDecision({ runtime: null, permit: null, protectedRisk: true, enforcementError: inputFailure || 'Marrow could not classify this mutation-capable tool request.' }, identity.harness);
388
642
  return;
389
643
  }
390
644
  if ((0, hook_tool_policy_1.isOfficialMarrowMcpEvent)(source)) {
@@ -533,6 +787,8 @@ async function runPreActionHookCommand(input) {
533
787
  outcome_state: 'pending',
534
788
  },
535
789
  }).catch(() => null);
790
+ // The decision and gate receipt this control path holds, so a denial can close them.
791
+ const held = { decisionId: null, gateReceiptId: null };
536
792
  const control = async (signal) => {
537
793
  const runtime = await (0, index_1.marrowAgentRuntime)(resolved.apiKey, baseUrl, {
538
794
  action: classified.action,
@@ -540,11 +796,28 @@ async function runPreActionHookCommand(input) {
540
796
  type: classified.type,
541
797
  role: classified.role,
542
798
  surfaces: classified.surfaces,
799
+ // Without a risk level the runtime may take its low-risk fast path, whose
800
+ // non-durable fast_gate receipt can never back an action permit.
801
+ ...(enforcementRequired ? { risk_level: classified.risk } : {}),
543
802
  }, sessionId, agentId, signal);
803
+ const heldIds = [...new Set([runtime.decision_id, runtime.completion_contract?.decision_id, runtime.runtime_authorization?.decision_id]
804
+ .filter((value) => typeof value === 'string' && SAFE_DECISION_ID.test(value)))];
805
+ held.decisionId = heldIds.length === 1 ? heldIds[0] : null;
806
+ held.gateReceiptId = (0, runtime_contract_1.runtimeAuthorizationReceiptId)(runtime) || null;
544
807
  const gate = runtime.risk_gate;
545
- if (gate?.decision === 'block' || gate?.decision === 'review_required' || gate?.allow === false) {
808
+ if (gate?.decision === 'block')
809
+ return { runtime, permit: null, protectedRisk: enforcementRequired };
810
+ // Free and starter plans get a positively advisory gate that warns but never
811
+ // hard-stops; every other gate, including an unclear one, keeps enforcement.
812
+ if (runtimeGateAdvisory(runtime))
813
+ return { runtime, permit: null, protectedRisk: false };
814
+ if (runtimeGateVerdict(runtime)) {
546
815
  return { runtime, permit: null, protectedRisk: enforcementRequired };
547
816
  }
817
+ // Only enforced actions need a decision and permit; creating them for every
818
+ // unprotected tool call would record a decision per edit.
819
+ if (!enforcementRequired)
820
+ return { runtime, permit: null, protectedRisk: false };
548
821
  const gateReceiptId = (0, runtime_contract_1.runtimeAuthorizationReceiptId)(runtime);
549
822
  const runtimeIds = [runtime.decision_id, runtime.completion_contract?.decision_id, runtime.runtime_authorization?.decision_id]
550
823
  .filter((value) => typeof value === 'string' && SAFE_DECISION_ID.test(value));
@@ -562,13 +835,14 @@ async function runPreActionHookCommand(input) {
562
835
  surfaces: classified.surfaces,
563
836
  type: classified.type,
564
837
  source_kind: 'integration',
565
- source_meta: {
566
- harness: identity.harness,
567
- correlation_id: correlation,
568
- gate_receipt_id: gateReceiptId,
569
- },
838
+ // Think rejects any source_meta key outside channel, client, agent_id, task_depth and
839
+ // user_intent with HTTP 400. The gate receipt and correlation bind on the permit below.
840
+ ...(identity.harness !== 'mcp-client' ? { source_meta: { client: identity.harness } } : {}),
570
841
  }, sessionId, agentId, signal);
571
842
  decisionId = SAFE_DECISION_ID.test(decision.decision_id) ? decision.decision_id : null;
843
+ // The runtime receipt is not bound to a decision Think creates; closing with it is a scope mismatch.
844
+ held.decisionId = decisionId;
845
+ held.gateReceiptId = null;
572
846
  }
573
847
  if (!decisionId) {
574
848
  return {
@@ -596,6 +870,8 @@ async function runPreActionHookCommand(input) {
596
870
  }
597
871
  const verified = await (0, index_1.marrowEnforcement)(resolved.apiKey, baseUrl, {
598
872
  operation: 'verify',
873
+ // Verify must declare the permit's protocol; issue defaults to version 1.
874
+ protocol_version: issued.protocol_version === 2 ? 2 : 1,
599
875
  permit: issued.permit,
600
876
  action: classified.action,
601
877
  action_type: classified.type,
@@ -626,10 +902,14 @@ async function runPreActionHookCommand(input) {
626
902
  runtime: null,
627
903
  permit: null,
628
904
  protectedRisk: enforcementRequired,
629
- enforcementError: 'Marrow rejected this protected action. Restore trusted governance before retrying.',
905
+ enforcementError: controlRejectionMessage(error, agentId),
906
+ ...(controlFailureKind(error) ? { failure: controlFailureKind(error) } : {}),
630
907
  })),
631
908
  lifecycle,
632
909
  ]);
633
- emitDecision(result, identity.harness);
910
+ const emitted = emitDecision(result, identity.harness, ownerApprovalPrompt(identity.harness, source));
911
+ if (emitted.denied && !result.outage) {
912
+ await closeDeniedDecision(resolved.apiKey, baseUrl, held, emitted.reason, sessionId, agentId);
913
+ }
634
914
  }
635
915
  //# sourceMappingURL=hook-pre-action.js.map