@getmarrow/mcp 3.9.96 → 3.9.98
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.
- package/README.md +11 -1
- package/dist/cli.js +64 -1
- package/dist/cli.js.map +1 -1
- package/dist/hook-contract.d.ts +2 -2
- package/dist/hook-contract.js +1 -1
- package/dist/hook-pre-action.d.ts +48 -2
- package/dist/hook-pre-action.d.ts.map +1 -1
- package/dist/hook-pre-action.js +348 -88
- package/dist/hook-pre-action.js.map +1 -1
- package/dist/hook-tool-policy.d.ts +39 -0
- package/dist/hook-tool-policy.d.ts.map +1 -1
- package/dist/hook-tool-policy.js +620 -23
- package/dist/hook-tool-policy.js.map +1 -1
- package/dist/hook.d.ts +2 -0
- package/dist/hook.d.ts.map +1 -1
- package/dist/hook.js +14 -1
- package/dist/hook.js.map +1 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +69 -16
- package/dist/index.js.map +1 -1
- package/dist/lifecycle-spool.d.ts +25 -0
- package/dist/lifecycle-spool.d.ts.map +1 -1
- package/dist/lifecycle-spool.js +117 -19
- package/dist/lifecycle-spool.js.map +1 -1
- package/dist/types.d.ts +16 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +4 -2
- package/server.json +2 -2
package/dist/hook-pre-action.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
142
|
-
|
|
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
|
|
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 && (
|
|
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
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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 =
|
|
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
|
|
210
|
-
|
|
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
|
-
|
|
225
|
-
|
|
399
|
+
const verdict = runtimeGateVerdict(result.runtime);
|
|
400
|
+
if (verdict) {
|
|
226
401
|
return {
|
|
227
402
|
cancel: true,
|
|
228
|
-
errorMessage:
|
|
229
|
-
? 'Marrow
|
|
230
|
-
: 'Marrow
|
|
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
|
-
|
|
234
|
-
|
|
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:
|
|
240
|
-
? 'Marrow
|
|
241
|
-
:
|
|
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
|
|
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
|
|
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
|
|
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
|
-
...
|
|
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
|
-
|
|
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
|
|
393
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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'
|
|
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
|