@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.
- 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 -1
- package/dist/hook-pre-action.d.ts.map +1 -1
- package/dist/hook-pre-action.js +371 -91
- 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 +17 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/server.json +2 -2
package/dist/hook-pre-action.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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
|
|
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 && (
|
|
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
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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 =
|
|
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
|
|
195
|
-
|
|
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
|
-
|
|
210
|
-
|
|
399
|
+
const verdict = runtimeGateVerdict(result.runtime);
|
|
400
|
+
if (verdict) {
|
|
211
401
|
return {
|
|
212
402
|
cancel: true,
|
|
213
|
-
errorMessage:
|
|
214
|
-
? 'Marrow
|
|
215
|
-
: '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.',
|
|
216
406
|
};
|
|
217
407
|
}
|
|
218
|
-
|
|
219
|
-
|
|
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:
|
|
225
|
-
? 'Marrow
|
|
226
|
-
:
|
|
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
|
|
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
|
|
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
|
|
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
|
-
...
|
|
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
|
-
|
|
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
|
|
378
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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'
|
|
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
|
-
|
|
567
|
-
|
|
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:
|
|
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
|