@getmarrow/mcp 3.9.96 → 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,9 +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;
6
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;
7
14
  exports.localControlAllowOutput = localControlAllowOutput;
8
15
  exports.localLoopGuardDenyOutput = localLoopGuardDenyOutput;
9
16
  exports.classifyTool = classifyTool;
@@ -13,8 +20,10 @@ exports.windsurfPreActionDecision = windsurfPreActionDecision;
13
20
  exports.geminiPreActionHookOutput = geminiPreActionHookOutput;
14
21
  exports.grokPreActionHookOutput = grokPreActionHookOutput;
15
22
  exports.preActionHookOutput = preActionHookOutput;
23
+ exports.closeDeniedDecision = closeDeniedDecision;
16
24
  exports.installPreActionHook = installPreActionHook;
17
25
  exports.runPreActionHookCommand = runPreActionHookCommand;
26
+ const node_crypto_1 = require("node:crypto");
18
27
  const index_1 = require("./index");
19
28
  const request_reliability_1 = require("./request-reliability");
20
29
  const lifecycle_spool_1 = require("./lifecycle-spool");
@@ -24,7 +33,9 @@ const session_loop_guard_1 = require("./session-loop-guard");
24
33
  const hook_tool_policy_1 = require("./hook-tool-policy");
25
34
  const hook_contract_1 = require("./hook-contract");
26
35
  Object.defineProperty(exports, "MARROW_OUTAGE_WARNING", { enumerable: true, get: function () { return hook_contract_1.MARROW_OUTAGE_WARNING; } });
27
- 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;
28
39
  // Cold auth may already use 900ms plus a 1600ms in-flight grace before think
29
40
  // and enforcement. Keep this above that budget so a slow store is not aborted
30
41
  // and misread as an outage.
@@ -72,9 +83,21 @@ function isMarrowOutage(result) {
72
83
  return result.outage === true;
73
84
  }
74
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
+ }
75
98
  // Names a reached control failure by HTTP status and stable failure code only, so the
76
99
  // denial is diagnosable without echoing private service text into the agent transcript.
77
- function controlRejectionMessage(error) {
100
+ function controlRejectionMessage(error, agentId) {
78
101
  const detail = [];
79
102
  if (error instanceof request_reliability_1.MarrowRequestError) {
80
103
  if (typeof error.status === 'number' && Number.isInteger(error.status))
@@ -83,7 +106,155 @@ function controlRejectionMessage(error) {
83
106
  if (code)
84
107
  detail.push(code);
85
108
  }
86
- return `Marrow rejected this protected action${detail.length ? ` (${detail.join(' ')})` : ''}. Restore trusted governance before retrying.`;
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);
87
258
  }
88
259
  const SAFE_DECISION_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/;
89
260
  function localControlAllowOutput(harness) {
@@ -129,19 +300,31 @@ async function readStdin() {
129
300
  for await (const chunk of process.stdin) {
130
301
  const buffer = Buffer.from(chunk);
131
302
  bytes += buffer.length;
132
- if (bytes > MAX_INPUT_BYTES)
133
- throw new Error('pre-action hook input exceeds byte limit');
134
- 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;
135
315
  }
136
- return Buffer.concat(chunks).toString('utf8');
137
316
  }
138
317
  function classifyTool(event) {
139
318
  const tool = String(event.tool_name || 'tool').slice(0, 64);
140
319
  const normalizedTool = (0, hook_tool_policy_1.normalizeHookToolName)(tool);
141
- const command = (0, hook_tool_policy_1.hookToolCommand)(event);
142
- 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();
143
325
  const readOnly = (0, hook_tool_policy_1.isReadOnlyToolEvent)(event);
144
- 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);
145
328
  const infrastructureDeployment = /\b(?:kubectl|terraform|pulumi|helm)\b/.test(command.toLowerCase())
146
329
  && protectedShellCommand;
147
330
  let type = 'process';
@@ -153,7 +336,7 @@ function classifyTool(event) {
153
336
  type = 'review';
154
337
  else if (/\b(?:migration|schema|database|d1)\b/.test(input))
155
338
  type = 'migration';
156
- else if (/\b(?:secret|credential|token|key|permission)\b/.test(input))
339
+ else if (secretAccess || /\b(?:secret|credential|token|key|permission)\b/.test(input))
157
340
  type = 'audit';
158
341
  else if (/\b(?:payment|refund|charge|invoice|stripe|financial)\b/.test(input))
159
342
  type = 'financial';
@@ -161,11 +344,12 @@ function classifyTool(event) {
161
344
  /\b(?:deploy|release|production|prod|wrangler)\b/.test(input) || infrastructureDeployment ? 'production' : '',
162
345
  /\b(?:git|github|merge|pull request|push)\b/.test(input) ? 'github' : '',
163
346
  /\b(?:npm|package|publish)\b/.test(input) ? 'npm' : '',
164
- /\b(?:secret|credential|token|key)\b/.test(input) ? 'secrets' : '',
347
+ secretAccess || /\b(?:secret|credential|token|key)\b/.test(input) ? 'secrets' : '',
165
348
  /\b(?:migration|schema|database|d1)\b/.test(input) ? 'database' : '',
166
349
  /\b(?:payment|refund|charge|invoice|stripe|financial)\b/.test(input) ? 'financial' : '',
167
350
  ].filter(Boolean);
168
- 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))
169
353
  || protectedShellCommand
170
354
  || ((0, hook_tool_policy_1.isMcpHookTool)(event.tool_name) && !(0, hook_tool_policy_1.isOfficialMarrowMcpTool)(event.tool_name))
171
355
  || (['use_mcp_tool', 'use_tool'].includes(normalizedTool) && !(0, hook_tool_policy_1.isOfficialMarrowMcpEvent)(event)));
@@ -193,52 +377,45 @@ function cursorPreActionHookOutput(result) {
193
377
  return { permission: 'allow', user_message: hook_contract_1.MARROW_OUTAGE_WARNING, agent_message: hook_contract_1.MARROW_OUTAGE_WARNING };
194
378
  }
195
379
  const { runtime, permit, protectedRisk } = result;
196
- const message = (value) => String(value || 'Marrow denied this action.')
197
- .replace(/[\u0000-\u001f\u007f]/g, ' ')
198
- .replace(/\s+/g, ' ')
199
- .trim()
200
- .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
+ }
201
385
  if (protectedRisk && (!runtime || !permit?.verified)) {
202
- 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);
203
387
  return {
204
388
  permission: 'deny',
205
389
  user_message: denial,
206
390
  agent_message: denial,
207
391
  };
208
392
  }
209
- const gate = runtime?.risk_gate;
210
- if (!gate)
211
- return { permission: 'allow' };
212
- const reason = runtime?.exact_next_action
213
- || gate.reasons?.[0]?.message
214
- || 'Marrow requires additional proof or operator review before this action.';
215
- if (gate.decision === 'review_required' || gate.decision === 'block' || gate.allow === false) {
216
- const denial = message(reason);
217
- return { permission: 'deny', user_message: denial, agent_message: denial };
218
- }
219
- return { permission: 'allow' };
393
+ const advisory = advisoryGateNotice(runtime);
394
+ return advisory ? { permission: 'allow', user_message: advisory, agent_message: advisory } : { permission: 'allow' };
220
395
  }
221
396
  function clinePreActionHookOutput(result) {
222
397
  if (isMarrowOutage(result))
223
398
  return { cancel: false };
224
- if (result.protectedRisk && (!result.runtime || !result.permit?.verified)) {
225
- const credentialsUnavailable = /credentials are unavailable/i.test(String(result.enforcementError || ''));
399
+ const verdict = runtimeGateVerdict(result.runtime);
400
+ if (verdict) {
226
401
  return {
227
402
  cancel: true,
228
- errorMessage: credentialsUnavailable
229
- ? 'Marrow credentials are unavailable for this protected action. Restore the configured agent key and retry.'
230
- : '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.',
231
406
  };
232
407
  }
233
- const gate = result.runtime?.risk_gate;
234
- if (!gate)
235
- return { cancel: false };
236
- 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 || ''));
237
410
  return {
238
411
  cancel: true,
239
- errorMessage: gate.decision === 'review_required'
240
- ? 'Marrow requires operator review before this protected action.'
241
- : '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.',
242
419
  };
243
420
  }
244
421
  return { cancel: false };
@@ -247,11 +424,7 @@ function windsurfPreActionDecision(result) {
247
424
  if (isMarrowOutage(result))
248
425
  return { exitCode: 0, stderr: `${hook_contract_1.MARROW_OUTAGE_WARNING}\n` };
249
426
  const unavailable = result.protectedRisk && (!result.runtime || !result.permit?.verified);
250
- const gate = result.runtime?.risk_gate;
251
- const denied = unavailable
252
- || gate?.decision === 'review_required'
253
- || gate?.decision === 'block'
254
- || gate?.allow === false;
427
+ const denied = unavailable || runtimeGateVerdict(result.runtime) !== null;
255
428
  return denied
256
429
  ? {
257
430
  exitCode: 2,
@@ -263,11 +436,7 @@ function geminiPreActionHookOutput(result) {
263
436
  if (isMarrowOutage(result))
264
437
  return { decision: 'allow' };
265
438
  const unavailable = result.protectedRisk && (!result.runtime || !result.permit?.verified);
266
- const gate = result.runtime?.risk_gate;
267
- const denied = unavailable
268
- || gate?.decision === 'review_required'
269
- || gate?.decision === 'block'
270
- || gate?.allow === false;
439
+ const denied = unavailable || runtimeGateVerdict(result.runtime) !== null;
271
440
  return denied
272
441
  ? {
273
442
  decision: 'deny',
@@ -279,16 +448,12 @@ function grokPreActionHookOutput(result) {
279
448
  if (isMarrowOutage(result))
280
449
  return { decision: 'allow' };
281
450
  const unavailable = result.protectedRisk && (!result.runtime || !result.permit?.verified);
282
- const gate = result.runtime?.risk_gate;
283
- const denied = unavailable
284
- || gate?.decision === 'review_required'
285
- || gate?.decision === 'block'
286
- || gate?.allow === false;
451
+ const denied = unavailable || runtimeGateVerdict(result.runtime) !== null;
287
452
  return denied
288
453
  ? { decision: 'deny', reason: 'Marrow blocked this protected action.' }
289
454
  : { decision: 'allow' };
290
455
  }
291
- function preActionHookOutput(result, harness = 'claude-code') {
456
+ function preActionHookOutput(result, harness = 'claude-code', prompt = NO_OWNER_PROMPT) {
292
457
  if (harness === 'cursor')
293
458
  return cursorPreActionHookOutput(result);
294
459
  if (harness === 'cline')
@@ -306,6 +471,28 @@ function preActionHookOutput(result, harness = 'claude-code') {
306
471
  };
307
472
  }
308
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
+ }
309
496
  if (protectedRisk && (!runtime || !permit?.verified)) {
310
497
  return {
311
498
  hookSpecificOutput: {
@@ -318,39 +505,74 @@ function preActionHookOutput(result, harness = 'claude-code') {
318
505
  if (!runtime?.risk_gate) {
319
506
  return {};
320
507
  }
321
- const gate = runtime.risk_gate;
322
- const reason = runtime.exact_next_action
323
- || gate.reasons?.[0]?.message
324
- || 'Marrow requires additional proof or operator review before this action.';
325
- const permissionDecision = gate.decision === 'review_required'
326
- ? harness === 'codex' ? 'deny' : 'ask'
327
- : gate.decision === 'block' || gate.allow === false
328
- ? 'deny'
329
- : null;
330
508
  return {
331
509
  hookSpecificOutput: {
332
510
  hookEventName: 'PreToolUse',
333
- ...(permissionDecision ? { permissionDecision, permissionDecisionReason: reason } : {}),
334
- ...(runtime.before_you_act || permit?.permit_id ? {
335
- additionalContext: [
336
- runtime.before_you_act,
337
- permit?.permit_id ? `Marrow action permit verified: ${permit.permit_id}. Evidence and outcome closure remain required.` : null,
338
- ].filter(Boolean).join('\n'),
339
- } : {}),
511
+ ...context,
340
512
  },
341
513
  };
342
514
  }
343
- function emitDecision(result, harness = 'claude-code') {
515
+ function emitDecision(result, harness = 'claude-code', prompt = NO_OWNER_PROMPT) {
344
516
  if (harness === 'windsurf') {
345
517
  const decision = windsurfPreActionDecision(result);
346
518
  process.exitCode = decision.exitCode;
347
519
  if (decision.stderr)
348
520
  process.stderr.write(decision.stderr);
349
- return;
521
+ return { denied: decision.exitCode === 2, reason: decision.stderr.trim() };
350
522
  }
351
523
  if (result.outage)
352
524
  process.stderr.write(`${hook_contract_1.MARROW_OUTAGE_WARNING}\n`);
353
- 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
+ }
354
576
  }
355
577
  async function withTimeout(operation) {
356
578
  const controller = new AbortController();
@@ -387,19 +609,36 @@ async function runPreActionHookCommand(input) {
387
609
  return;
388
610
  const identity = (0, hook_contract_1.resolveNativeHookIdentity)(process.argv[2]);
389
611
  let event = input;
612
+ let inputFailure = null;
390
613
  if (event === undefined) {
391
614
  try {
392
- const raw = (await readStdin()).trim();
393
- 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
+ }
394
623
  }
395
624
  catch {
396
- emitDecision({ runtime: null, permit: null, protectedRisk: true, enforcementError: 'Marrow rejected malformed or oversized pre-action input.' }, identity.harness);
397
- return;
625
+ inputFailure = 'Marrow rejected malformed pre-action input.';
398
626
  }
399
627
  }
400
- const source = asRecord((0, hook_contract_1.normalizeHookEventPayload)(event));
401
- if (!source?.tool_name) {
402
- 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);
403
642
  return;
404
643
  }
405
644
  if ((0, hook_tool_policy_1.isOfficialMarrowMcpEvent)(source)) {
@@ -548,6 +787,8 @@ async function runPreActionHookCommand(input) {
548
787
  outcome_state: 'pending',
549
788
  },
550
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 };
551
792
  const control = async (signal) => {
552
793
  const runtime = await (0, index_1.marrowAgentRuntime)(resolved.apiKey, baseUrl, {
553
794
  action: classified.action,
@@ -559,8 +800,18 @@ async function runPreActionHookCommand(input) {
559
800
  // non-durable fast_gate receipt can never back an action permit.
560
801
  ...(enforcementRequired ? { risk_level: classified.risk } : {}),
561
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;
562
807
  const gate = runtime.risk_gate;
563
- 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)) {
564
815
  return { runtime, permit: null, protectedRisk: enforcementRequired };
565
816
  }
566
817
  // Only enforced actions need a decision and permit; creating them for every
@@ -589,6 +840,9 @@ async function runPreActionHookCommand(input) {
589
840
  ...(identity.harness !== 'mcp-client' ? { source_meta: { client: identity.harness } } : {}),
590
841
  }, sessionId, agentId, signal);
591
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;
592
846
  }
593
847
  if (!decisionId) {
594
848
  return {
@@ -616,6 +870,8 @@ async function runPreActionHookCommand(input) {
616
870
  }
617
871
  const verified = await (0, index_1.marrowEnforcement)(resolved.apiKey, baseUrl, {
618
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,
619
875
  permit: issued.permit,
620
876
  action: classified.action,
621
877
  action_type: classified.type,
@@ -646,10 +902,14 @@ async function runPreActionHookCommand(input) {
646
902
  runtime: null,
647
903
  permit: null,
648
904
  protectedRisk: enforcementRequired,
649
- enforcementError: controlRejectionMessage(error),
905
+ enforcementError: controlRejectionMessage(error, agentId),
906
+ ...(controlFailureKind(error) ? { failure: controlFailureKind(error) } : {}),
650
907
  })),
651
908
  lifecycle,
652
909
  ]);
653
- 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
+ }
654
914
  }
655
915
  //# sourceMappingURL=hook-pre-action.js.map