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