@getmarrow/mcp 3.9.97 → 3.9.99
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 +57 -338
- 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 +135 -9
- package/dist/hook-context.js.map +1 -1
- package/dist/hook-contract.d.ts +26 -5
- package/dist/hook-contract.d.ts.map +1 -1
- package/dist/hook-contract.js +144 -10
- package/dist/hook-contract.js.map +1 -1
- package/dist/hook-pre-action.d.ts +40 -1
- package/dist/hook-pre-action.d.ts.map +1 -1
- package/dist/hook-pre-action.js +429 -35
- package/dist/hook-pre-action.js.map +1 -1
- package/dist/hook-session.d.ts.map +1 -1
- package/dist/hook-session.js +32 -0
- 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 +149 -5
- 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 +82 -0
- package/dist/host-session.d.ts.map +1 -0
- package/dist/host-session.js +570 -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.map +1 -1
- package/dist/lifecycle-spool.js +6 -5
- package/dist/lifecycle-spool.js.map +1 -1
- package/dist/normalized-action.d.ts +120 -0
- package/dist/normalized-action.d.ts.map +1 -0
- package/dist/normalized-action.js +1109 -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 +21 -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 = 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,6 +34,9 @@ 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");
|
|
@@ -65,6 +73,20 @@ class PreActionControlTimeoutError extends Error {
|
|
|
65
73
|
}
|
|
66
74
|
}
|
|
67
75
|
exports.PreActionControlTimeoutError = PreActionControlTimeoutError;
|
|
76
|
+
/**
|
|
77
|
+
* A timeout (the hook's own budget, or a request that did not answer in time):
|
|
78
|
+
* Marrow is slow, not known to be down. It is never treated as an outage.
|
|
79
|
+
*/
|
|
80
|
+
function isMarrowControlTimeout(error) {
|
|
81
|
+
if (error instanceof PreActionControlTimeoutError)
|
|
82
|
+
return true;
|
|
83
|
+
if (error instanceof request_reliability_1.MarrowRequestError)
|
|
84
|
+
return error.code === 'request_timeout';
|
|
85
|
+
if (!error || typeof error !== 'object')
|
|
86
|
+
return false;
|
|
87
|
+
const named = error;
|
|
88
|
+
return named.name === 'AbortError' || named.name === 'TimeoutError';
|
|
89
|
+
}
|
|
68
90
|
function isMarrowControlOutage(error) {
|
|
69
91
|
if (error instanceof PreActionControlTimeoutError)
|
|
70
92
|
return true;
|
|
@@ -79,6 +101,89 @@ function isMarrowControlOutage(error) {
|
|
|
79
101
|
return true;
|
|
80
102
|
return error instanceof TypeError && /fetch|network|getaddrinfo/i.test(String(named.message || ''));
|
|
81
103
|
}
|
|
104
|
+
/** Fixed, privacy-preserving hold texts for hosts whose adapters accept only fixed strings. */
|
|
105
|
+
exports.HOLD_OWNER_DENIAL = 'Marrow is holding this action for approval. Retry it after approval.';
|
|
106
|
+
/** Marrow did not answer within the hook's time limit: an action that can be held stays held. */
|
|
107
|
+
exports.HELD_SLOW_TEXT = 'Marrow did not answer in time, so this action is held. Retry it in a moment.';
|
|
108
|
+
/** The only denial @getmarrow/install's Grok guard passes through (any other output blocks with a launch failure). */
|
|
109
|
+
exports.GROK_FIXED_DENIAL = 'Marrow blocked this protected action.';
|
|
110
|
+
/**
|
|
111
|
+
* The hook's answer for an ordinary held action, per host. A Claude Code "ask"
|
|
112
|
+
* reason is shown to the user only; a Cursor user_message is shown only in the
|
|
113
|
+
* client; neither ever reaches the agent with an approval code.
|
|
114
|
+
*/
|
|
115
|
+
function heldActionHookOutput(harness, plan, code = null) {
|
|
116
|
+
if (harness === 'windsurf')
|
|
117
|
+
return null;
|
|
118
|
+
if (plan.kind === 'pass')
|
|
119
|
+
return passHookOutput(harness, plan.contextText);
|
|
120
|
+
if (plan.kind === 'ask') {
|
|
121
|
+
if (harness === 'cursor')
|
|
122
|
+
return { permission: 'ask', user_message: plan.promptText, agent_message: 'Marrow asked the user to approve this held action in Cursor.' };
|
|
123
|
+
return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'ask', permissionDecisionReason: plan.promptText } };
|
|
124
|
+
}
|
|
125
|
+
// The approval code goes only to a user-only channel: Cursor user_message,
|
|
126
|
+
// Codex and Gemini CLI systemMessage. The agent's text never contains it.
|
|
127
|
+
if (harness === 'cursor') {
|
|
128
|
+
return {
|
|
129
|
+
permission: 'deny',
|
|
130
|
+
user_message: code ? (0, host_approval_1.typedReplyUserText)(plan.userText, code) : plan.userText,
|
|
131
|
+
agent_message: plan.agentText,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
if (harness === 'cline')
|
|
135
|
+
return { cancel: true, errorMessage: plan.agentText };
|
|
136
|
+
if (harness === 'gemini') {
|
|
137
|
+
// Without a typed reply the Gemini adapter's fixed denial text is kept.
|
|
138
|
+
return code
|
|
139
|
+
? { decision: 'deny', reason: plan.agentText, systemMessage: (0, host_approval_1.typedReplyUserText)(plan.userText, code) }
|
|
140
|
+
: { decision: 'deny', reason: 'Marrow blocked this action because required governance approval or proof is unavailable.' };
|
|
141
|
+
}
|
|
142
|
+
if (harness === 'grok')
|
|
143
|
+
return { decision: 'deny', reason: exports.GROK_FIXED_DENIAL };
|
|
144
|
+
return {
|
|
145
|
+
hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: plan.agentText },
|
|
146
|
+
...(harness === 'codex' && code ? { systemMessage: (0, host_approval_1.typedReplyUserText)(plan.userText, code) } : {}),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* The hook's answer when the host's own approval step decides (owner rule):
|
|
151
|
+
* neutral, never an explicit allow, so the host's normal permission flow runs.
|
|
152
|
+
* Codex: no permissionDecision (only context for the agent); Cline: not
|
|
153
|
+
* cancelled; Windsurf: exit 0 (emitted by the caller). Any other host has no
|
|
154
|
+
* neutral answer here and never gets a pass plan.
|
|
155
|
+
*/
|
|
156
|
+
function passHookOutput(harness, contextText) {
|
|
157
|
+
if (harness === 'windsurf')
|
|
158
|
+
return null;
|
|
159
|
+
if (harness === 'cline')
|
|
160
|
+
return { cancel: false };
|
|
161
|
+
if (harness === 'codex')
|
|
162
|
+
return { hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: contextText } };
|
|
163
|
+
throw new Error(`no neutral pre-tool answer for ${harness}`);
|
|
164
|
+
}
|
|
165
|
+
/** The hook's answer when a waited hold was approved and the same action is retried. */
|
|
166
|
+
function approvedHoldHookOutput(harness, contextText) {
|
|
167
|
+
if (harness === 'windsurf')
|
|
168
|
+
return null;
|
|
169
|
+
if (harness === 'cursor')
|
|
170
|
+
return { permission: 'allow' };
|
|
171
|
+
if (harness === 'cline')
|
|
172
|
+
return { cancel: false };
|
|
173
|
+
if (harness === 'gemini' || harness === 'grok')
|
|
174
|
+
return { decision: 'allow' };
|
|
175
|
+
return { hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: contextText } };
|
|
176
|
+
}
|
|
177
|
+
function emitHookOutput(harness, output, deniedText) {
|
|
178
|
+
if (harness === 'windsurf') {
|
|
179
|
+
process.exitCode = deniedText ? 2 : 0;
|
|
180
|
+
if (deniedText)
|
|
181
|
+
process.stderr.write(`${deniedText}\n`);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (output)
|
|
185
|
+
process.stdout.write(JSON.stringify(output));
|
|
186
|
+
}
|
|
82
187
|
function isMarrowOutage(result) {
|
|
83
188
|
return result.outage === true;
|
|
84
189
|
}
|
|
@@ -95,8 +200,35 @@ function controlFailureKind(error) {
|
|
|
95
200
|
return 'credential_scope';
|
|
96
201
|
return CONTROL_UNAVAILABLE_CODES.has(error.code) ? 'unavailable' : undefined;
|
|
97
202
|
}
|
|
98
|
-
|
|
99
|
-
|
|
203
|
+
const DOCTOR_FIX = 'Run `npx -y @getmarrow/install@latest doctor` in this terminal to check this machine\'s Marrow setup, then retry.';
|
|
204
|
+
/**
|
|
205
|
+
* A plain fix for a reached control failure: a known code's own fix, or the
|
|
206
|
+
* service's exact_fix when it is plain (no API calls, dashboard steps or
|
|
207
|
+
* links), else the doctor command. Service text other than exact_fix is never
|
|
208
|
+
* echoed into the agent transcript.
|
|
209
|
+
*/
|
|
210
|
+
function plainControlFix(error) {
|
|
211
|
+
if (!(error instanceof request_reliability_1.MarrowRequestError))
|
|
212
|
+
return DOCTOR_FIX;
|
|
213
|
+
const code = String(error.backendCode || '');
|
|
214
|
+
if (code === 'AGENT_NOT_REGISTERED' || code === 'MARROW_AGENT_NOT_REGISTERED') {
|
|
215
|
+
if (/agent limit/i.test(error.message)) {
|
|
216
|
+
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.';
|
|
217
|
+
}
|
|
218
|
+
if (/not active/i.test(error.message)) {
|
|
219
|
+
return 'This agent is turned off in this Marrow account. The account owner can turn it back on in Marrow, then retry.';
|
|
220
|
+
}
|
|
221
|
+
return 'Marrow doesn\'t know this agent yet. Run `npx @getmarrow/install` again in this terminal, then retry.';
|
|
222
|
+
}
|
|
223
|
+
if (error.status === 429 || error.code === 'rate_limited')
|
|
224
|
+
return 'Marrow is busy right now. Retry in a minute.';
|
|
225
|
+
const fix = error.exactFix.replace(/\s+/g, ' ').trim();
|
|
226
|
+
const plain = fix.length > 0 && fix.length <= 240 && /[.!]$/.test(fix)
|
|
227
|
+
&& !/\/v1\/|\b(?:POST|GET|PATCH|PUT|DELETE)\b|dashboard|https?:\/\/|X-Marrow|agent_id|api key|curl\b/i.test(fix);
|
|
228
|
+
return plain ? fix : DOCTOR_FIX;
|
|
229
|
+
}
|
|
230
|
+
// Names a reached control failure by HTTP status and stable failure code, with a plain
|
|
231
|
+
// fix (see plainControlFix); private service text is never echoed into the agent transcript.
|
|
100
232
|
function controlRejectionMessage(error, agentId) {
|
|
101
233
|
const detail = [];
|
|
102
234
|
if (error instanceof request_reliability_1.MarrowRequestError) {
|
|
@@ -117,7 +249,7 @@ function controlRejectionMessage(error, agentId) {
|
|
|
117
249
|
if (kind === 'unavailable') {
|
|
118
250
|
return `Marrow is unavailable, so this protected action was denied${suffix}. Retry when Marrow is reachable.`;
|
|
119
251
|
}
|
|
120
|
-
return `Marrow
|
|
252
|
+
return `Marrow did not allow this protected action${suffix}. ${plainControlFix(error)}`;
|
|
121
253
|
}
|
|
122
254
|
// Claude Code permission modes in which a PreToolUse "ask" reaches a person.
|
|
123
255
|
// bypassPermissions disables prompts, dontAsk auto-denies anything that would
|
|
@@ -125,6 +257,8 @@ function controlRejectionMessage(error, agentId) {
|
|
|
125
257
|
// https://code.claude.com/docs/en/hooks#pretooluse-decision-control
|
|
126
258
|
// https://code.claude.com/docs/en/permission-modes
|
|
127
259
|
const OWNER_PROMPT_PERMISSION_MODES = new Set(['default', 'acceptEdits', 'auto']);
|
|
260
|
+
/** Claude Code entrypoints with no one at a dialog (claude -p and the Agent SDKs); hooks inherit it. */
|
|
261
|
+
const HEADLESS_CLAUDE_ENTRYPOINTS = host_approval_1.HEADLESS_CLAUDE_ENTRYPOINTS;
|
|
128
262
|
const NO_OWNER_PROMPT = { available: false, unavailableReason: 'this agent host cannot prompt the owner' };
|
|
129
263
|
/**
|
|
130
264
|
* Whether a PreToolUse "ask" from this hook reaches a person who can approve.
|
|
@@ -138,6 +272,10 @@ function ownerApprovalPrompt(harness, event, env = process.env) {
|
|
|
138
272
|
? { available: false, unavailableReason: 'Codex hooks cannot prompt the owner' }
|
|
139
273
|
: NO_OWNER_PROMPT;
|
|
140
274
|
}
|
|
275
|
+
const entrypoint = typeof env.CLAUDE_CODE_ENTRYPOINT === 'string' ? env.CLAUDE_CODE_ENTRYPOINT : '';
|
|
276
|
+
if (HEADLESS_CLAUDE_ENTRYPOINTS.has(entrypoint)) {
|
|
277
|
+
return { available: false, unavailableReason: `${entrypoint}, no one sees a dialog`, headless: true };
|
|
278
|
+
}
|
|
141
279
|
const mode = typeof event.permission_mode === 'string' && /^[A-Za-z]{1,32}$/.test(event.permission_mode) ? event.permission_mode : '';
|
|
142
280
|
if (!OWNER_PROMPT_PERMISSION_MODES.has(mode)) {
|
|
143
281
|
return { available: false, unavailableReason: `Claude Code permission mode ${mode || 'unknown'} cannot guarantee an owner prompt` };
|
|
@@ -160,14 +298,14 @@ function boundedText(value, limit) {
|
|
|
160
298
|
function asOptionalRecord(value) {
|
|
161
299
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
162
300
|
}
|
|
163
|
-
// Arbitration review is satisfied only by
|
|
164
|
-
// never by a host prompt, so any arbitration signal keeps the denial.
|
|
301
|
+
// Arbitration review is satisfied only by the account owner's choice of a
|
|
302
|
+
// proposal (one-tap link), never by a host prompt, so any arbitration signal keeps the denial.
|
|
165
303
|
function arbitrationReview(runtime) {
|
|
166
304
|
const completion = runtime.completion_contract;
|
|
167
305
|
const approval = asOptionalRecord(completion?.owner_approval);
|
|
168
306
|
return Boolean(runtime.arbitration)
|
|
169
307
|
|| completion?.arbitration_receipt_required === true
|
|
170
|
-
|| approval
|
|
308
|
+
|| (0, runtime_contract_1.ownerReceiptRequired)(approval)
|
|
171
309
|
|| (approval?.mode !== undefined && approval.mode !== 'ordinary_non_arbitrated');
|
|
172
310
|
}
|
|
173
311
|
/**
|
|
@@ -209,10 +347,10 @@ function runtimeGateAdvisory(runtime) {
|
|
|
209
347
|
function runtimeGateEnforced(runtime) {
|
|
210
348
|
return !runtimeGateAdvisory(runtime);
|
|
211
349
|
}
|
|
350
|
+
// Why the gate held or blocked the action. The runtime's exact_next_action is
|
|
351
|
+
// written for API clients (endpoints to call) and is never relayed by a hook.
|
|
212
352
|
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;
|
|
353
|
+
return boundedText(runtime.risk_gate.reasons?.[0]?.message, 240);
|
|
216
354
|
}
|
|
217
355
|
/** A warning for a non-allow gate the runtime does not enforce on this plan. */
|
|
218
356
|
function advisoryGateNotice(runtime) {
|
|
@@ -248,12 +386,12 @@ function gateDecisionMessage(verdict, ask, prompt = NO_OWNER_PROMPT) {
|
|
|
248
386
|
const headline = verdict.kind === 'block'
|
|
249
387
|
? 'Marrow blocked this action under the current policy.'
|
|
250
388
|
: verdict.kind === 'arbitration_review'
|
|
251
|
-
? 'Marrow arbitration
|
|
389
|
+
? '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
390
|
: verdict.kind === 'denied'
|
|
253
391
|
? 'Marrow did not allow this action.'
|
|
254
392
|
: ask
|
|
255
393
|
? '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.
|
|
394
|
+
: `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
395
|
return boundedText(verdict.reason ? `${headline} Reason: ${verdict.reason}` : headline, 500);
|
|
258
396
|
}
|
|
259
397
|
const SAFE_DECISION_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/;
|
|
@@ -415,7 +553,7 @@ function clinePreActionHookOutput(result) {
|
|
|
415
553
|
? '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
554
|
: result.failure === 'unavailable'
|
|
417
555
|
? 'Marrow is unavailable, so this protected action was denied. Retry when Marrow is reachable.'
|
|
418
|
-
:
|
|
556
|
+
: `Marrow could not confirm a permit for this protected action. ${DOCTOR_FIX}`,
|
|
419
557
|
};
|
|
420
558
|
}
|
|
421
559
|
return { cancel: false };
|
|
@@ -453,7 +591,9 @@ function grokPreActionHookOutput(result) {
|
|
|
453
591
|
? { decision: 'deny', reason: 'Marrow blocked this protected action.' }
|
|
454
592
|
: { decision: 'allow' };
|
|
455
593
|
}
|
|
456
|
-
function preActionHookOutput(result, harness = 'claude-code', prompt = NO_OWNER_PROMPT
|
|
594
|
+
function preActionHookOutput(result, harness = 'claude-code', prompt = NO_OWNER_PROMPT,
|
|
595
|
+
/** An older service without host approvals: Claude Code asks in its dialog exactly as 3.9.98 did. */
|
|
596
|
+
legacyServiceAsk = false) {
|
|
457
597
|
if (harness === 'cursor')
|
|
458
598
|
return cursorPreActionHookOutput(result);
|
|
459
599
|
if (harness === 'cline')
|
|
@@ -480,15 +620,28 @@ function preActionHookOutput(result, harness = 'claude-code', prompt = NO_OWNER_
|
|
|
480
620
|
].filter(Boolean).join('\n'),
|
|
481
621
|
} : {};
|
|
482
622
|
const verdict = runtimeGateVerdict(runtime);
|
|
623
|
+
if (verdict && legacyServiceAsk && verdict.kind === 'review' && prompt.available && harness !== 'codex') {
|
|
624
|
+
return {
|
|
625
|
+
hookSpecificOutput: {
|
|
626
|
+
hookEventName: 'PreToolUse',
|
|
627
|
+
permissionDecision: 'ask',
|
|
628
|
+
permissionDecisionReason: gateDecisionMessage(verdict, true, prompt),
|
|
629
|
+
...context,
|
|
630
|
+
},
|
|
631
|
+
};
|
|
632
|
+
}
|
|
483
633
|
if (verdict) {
|
|
484
|
-
//
|
|
485
|
-
//
|
|
486
|
-
|
|
634
|
+
// An ordinary hold the server lets the host approve is answered by
|
|
635
|
+
// heldActionHookOutput. Here every gate denies: block, arbitration, and a
|
|
636
|
+
// review the server offered no chat or terminal approval for.
|
|
637
|
+
const reason = verdict.kind === 'review' && prompt.available && !legacyServiceAsk
|
|
638
|
+
? { available: false, unavailableReason: 'this Marrow service did not offer a chat or terminal approval for this hold' }
|
|
639
|
+
: prompt;
|
|
487
640
|
return {
|
|
488
641
|
hookSpecificOutput: {
|
|
489
642
|
hookEventName: 'PreToolUse',
|
|
490
|
-
permissionDecision:
|
|
491
|
-
permissionDecisionReason: gateDecisionMessage(verdict,
|
|
643
|
+
permissionDecision: 'deny',
|
|
644
|
+
permissionDecisionReason: gateDecisionMessage(verdict, false, reason),
|
|
492
645
|
...context,
|
|
493
646
|
},
|
|
494
647
|
};
|
|
@@ -512,7 +665,7 @@ function preActionHookOutput(result, harness = 'claude-code', prompt = NO_OWNER_
|
|
|
512
665
|
},
|
|
513
666
|
};
|
|
514
667
|
}
|
|
515
|
-
function emitDecision(result, harness = 'claude-code', prompt = NO_OWNER_PROMPT) {
|
|
668
|
+
function emitDecision(result, harness = 'claude-code', prompt = NO_OWNER_PROMPT, legacyServiceAsk = false) {
|
|
516
669
|
if (harness === 'windsurf') {
|
|
517
670
|
const decision = windsurfPreActionDecision(result);
|
|
518
671
|
process.exitCode = decision.exitCode;
|
|
@@ -522,7 +675,7 @@ function emitDecision(result, harness = 'claude-code', prompt = NO_OWNER_PROMPT)
|
|
|
522
675
|
}
|
|
523
676
|
if (result.outage)
|
|
524
677
|
process.stderr.write(`${hook_contract_1.MARROW_OUTAGE_WARNING}\n`);
|
|
525
|
-
const output = preActionHookOutput(result, harness, prompt);
|
|
678
|
+
const output = preActionHookOutput(result, harness, prompt, legacyServiceAsk);
|
|
526
679
|
process.stdout.write(JSON.stringify(output));
|
|
527
680
|
const specific = asRecord(output.hookSpecificOutput);
|
|
528
681
|
if (specific?.permissionDecision === 'deny')
|
|
@@ -574,7 +727,7 @@ async function closeDeniedDecision(apiKey, baseUrl, held, reason, sessionId, age
|
|
|
574
727
|
clearTimeout(timer);
|
|
575
728
|
}
|
|
576
729
|
}
|
|
577
|
-
async function withTimeout(operation) {
|
|
730
|
+
async function withTimeout(operation, timeoutMs = exports.PRE_ACTION_CONTROL_TIMEOUT_MS) {
|
|
578
731
|
const controller = new AbortController();
|
|
579
732
|
let timer;
|
|
580
733
|
try {
|
|
@@ -584,7 +737,7 @@ async function withTimeout(operation) {
|
|
|
584
737
|
timer = setTimeout(() => {
|
|
585
738
|
controller.abort();
|
|
586
739
|
reject(new PreActionControlTimeoutError());
|
|
587
|
-
},
|
|
740
|
+
}, timeoutMs);
|
|
588
741
|
}),
|
|
589
742
|
]);
|
|
590
743
|
}
|
|
@@ -593,6 +746,72 @@ async function withTimeout(operation) {
|
|
|
593
746
|
clearTimeout(timer);
|
|
594
747
|
}
|
|
595
748
|
}
|
|
749
|
+
/**
|
|
750
|
+
* The gate's risk categories for this classified action, as the server
|
|
751
|
+
* derives them from the same action, type and surfaces (risk-categories.ts).
|
|
752
|
+
* Used only while Marrow cannot be reached, to keep owner-protected actions held.
|
|
753
|
+
*/
|
|
754
|
+
const LOCAL_CATEGORY_PATTERNS = [
|
|
755
|
+
['production_deploy', /\b(deploy|release|rollback|rollout|production|prod)\b/i],
|
|
756
|
+
['source_control', /\b(merge|pull request|pr|github|main|master)\b/i],
|
|
757
|
+
['data_migration', /\b(migration|migrate|schema|database|sql|table|column)\b/i],
|
|
758
|
+
['secrets_security', /\b(secret|token|key|credential|rotate|revoke|security|waf|auth)\b/i],
|
|
759
|
+
['billing_access', /\b(billing|payment|invoice|subscription|plan|tier|permission|access)\b/i],
|
|
760
|
+
['destructive_action', /\b(?:delete|drop|truncate|purge|destroy|remove all|wipe)\b/i],
|
|
761
|
+
];
|
|
762
|
+
function localApprovalCategories(action) {
|
|
763
|
+
const text = `${action.action} ${action.type} ${action.surfaces.join(' ')}`;
|
|
764
|
+
const categories = LOCAL_CATEGORY_PATTERNS.filter(([, pattern]) => pattern.test(text)).map(([category]) => category);
|
|
765
|
+
// Broader than the server's package-publish check on purpose: it only keeps an action held.
|
|
766
|
+
if (action.type.toLowerCase() === 'publish' || /\bpublish\b/i.test(text))
|
|
767
|
+
categories.unshift('package_publish');
|
|
768
|
+
return categories;
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* Positive evidence that a person is at this host for a call Marrow cannot ask
|
|
772
|
+
* about: a local interactive Codex, Gemini CLI or Cline CLI process, Cline or
|
|
773
|
+
* Windsurf inside the editor app, or a local Cursor session (sessionStart).
|
|
774
|
+
* Missing evidence is not attended.
|
|
775
|
+
*/
|
|
776
|
+
function attendedConfirmed(ctx, cursorInteractive) {
|
|
777
|
+
if (ctx.host === 'codex' || ctx.host === 'gemini' || ctx.host === 'cline' || ctx.host === 'windsurf')
|
|
778
|
+
return (0, host_session_1.localInteractiveSession)(ctx.host) === true;
|
|
779
|
+
if (ctx.host === 'cursor')
|
|
780
|
+
return cursorInteractive === true;
|
|
781
|
+
return false;
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* Nobody is in this run: headless Claude Code (sdk-*, its GitHub Action), a
|
|
785
|
+
* scripted Codex or Gemini CLI run (`codex exec`, `gemini -p`), or a Cursor
|
|
786
|
+
* background agent. Missing evidence is not unattended.
|
|
787
|
+
*/
|
|
788
|
+
function unattendedRun(ctx, claudePrompt, cursorInteractive) {
|
|
789
|
+
if (ctx.host === 'claude-code')
|
|
790
|
+
return claudePrompt.headless === true;
|
|
791
|
+
if (ctx.host === 'codex' || ctx.host === 'gemini')
|
|
792
|
+
return (0, host_session_1.localInteractiveSession)(ctx.host) === false;
|
|
793
|
+
// Cursor: sessionStart reports local sessions; without it (cloud agents) or as a background agent, nobody is there.
|
|
794
|
+
if (ctx.host === 'cursor')
|
|
795
|
+
return cursorInteractive !== true;
|
|
796
|
+
// Grok, Cline, Windsurf: attended only with positive evidence of a person's session.
|
|
797
|
+
if (ctx.host === 'grok' || ctx.host === 'cline' || ctx.host === 'windsurf')
|
|
798
|
+
return (0, host_session_1.localInteractiveSession)(ctx.host) !== true;
|
|
799
|
+
return false;
|
|
800
|
+
}
|
|
801
|
+
/** The hook's answer for a held action on any host (Windsurf answers through its exit code). */
|
|
802
|
+
function emitHeldPlan(harness, plan, code = null) {
|
|
803
|
+
emitHookOutput(harness, heldActionHookOutput(harness, plan, code), plan.kind === 'deny' ? plan.agentText : undefined);
|
|
804
|
+
}
|
|
805
|
+
/** Work that does not decide the answer, done after it is written and inside the hook's budget. */
|
|
806
|
+
async function afterAnswer(ctx, work) {
|
|
807
|
+
try {
|
|
808
|
+
if (work.link)
|
|
809
|
+
await (0, host_approval_1.requestOwnerLink)(ctx, work.link, 0);
|
|
810
|
+
if ((0, host_approval_1.remainingMs)(ctx) > 800)
|
|
811
|
+
await (0, host_approval_1.flushHoldOutbox)(ctx, 2, Math.min(4_000, (0, host_approval_1.remainingMs)(ctx) - 200));
|
|
812
|
+
}
|
|
813
|
+
catch { /* best effort: a later hook resends queued work */ }
|
|
814
|
+
}
|
|
596
815
|
function installPreActionHook(startDir = process.cwd()) {
|
|
597
816
|
const fs = require('node:fs');
|
|
598
817
|
const path = (0, hook_contract_1.findHookSettingsPath)(startDir);
|
|
@@ -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
|
}
|
|
@@ -770,11 +989,56 @@ async function runPreActionHookCommand(input) {
|
|
|
770
989
|
}, identity.harness);
|
|
771
990
|
return;
|
|
772
991
|
}
|
|
992
|
+
const holdContext = {
|
|
993
|
+
apiKey: resolved.apiKey,
|
|
994
|
+
baseUrl,
|
|
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: a retried action is a new attempt, and a
|
|
1034
|
+
// hold names the record of the attempt that created it.
|
|
1035
|
+
const attempt = (0, node_crypto_1.createHash)('sha256').update(toolUseId || generationId || (0, node_crypto_1.randomUUID)()).digest('hex').slice(0, 12);
|
|
1036
|
+
const preActionEventId = `pretool-${correlation}-${attempt}`;
|
|
773
1037
|
const lifecycle = (0, lifecycle_spool_1.recordLifecycleEvent)({
|
|
774
1038
|
apiKey: resolved.apiKey,
|
|
775
1039
|
baseUrl,
|
|
776
1040
|
event: {
|
|
777
|
-
event_id:
|
|
1041
|
+
event_id: preActionEventId,
|
|
778
1042
|
event_type: 'pre_action_checked',
|
|
779
1043
|
...(0, hook_contract_1.clientReportedHookLifecycleIdentity)(identity),
|
|
780
1044
|
session_id: sessionId,
|
|
@@ -787,6 +1051,11 @@ async function runPreActionHookCommand(input) {
|
|
|
787
1051
|
outcome_state: 'pending',
|
|
788
1052
|
},
|
|
789
1053
|
}).catch(() => null);
|
|
1054
|
+
// Normalizing has its own share of the host's budget: past it the action is
|
|
1055
|
+
// a truncated placeholder, so the hook still answers (deny first) in time.
|
|
1056
|
+
const normalizedAction = (0, normalized_action_1.normalizedHookAction)(source, {
|
|
1057
|
+
deadlineAt: Math.min(Date.now() + 750, (holdContext.deadlineAt ?? Number.POSITIVE_INFINITY) - 1_000),
|
|
1058
|
+
});
|
|
790
1059
|
// The decision and gate receipt this control path holds, so a denial can close them.
|
|
791
1060
|
const held = { decisionId: null, gateReceiptId: null };
|
|
792
1061
|
const control = async (signal) => {
|
|
@@ -796,6 +1065,9 @@ async function runPreActionHookCommand(input) {
|
|
|
796
1065
|
type: classified.type,
|
|
797
1066
|
role: classified.role,
|
|
798
1067
|
surfaces: classified.surfaces,
|
|
1068
|
+
// The exact command or tool call (secrets removed), so Marrow binds the
|
|
1069
|
+
// gate and any approval to this action, not to its coarse class.
|
|
1070
|
+
normalized_action: normalizedAction,
|
|
799
1071
|
// Without a risk level the runtime may take its low-risk fast path, whose
|
|
800
1072
|
// non-durable fast_gate receipt can never back an action permit.
|
|
801
1073
|
...(enforcementRequired ? { risk_level: classified.risk } : {}),
|
|
@@ -889,25 +1161,147 @@ async function runPreActionHookCommand(input) {
|
|
|
889
1161
|
...(verifiedExactly ? {} : { enforcementError: 'Marrow permit verification did not match the issued permit.' }),
|
|
890
1162
|
};
|
|
891
1163
|
};
|
|
1164
|
+
// Codex and Cursor stop the hook at 5 s (and Codex then lets the call run):
|
|
1165
|
+
// the control path ends inside the hook's budget, leaving time to answer.
|
|
1166
|
+
const controlTimeoutMs = Math.max(1_000, Math.min(exports.PRE_ACTION_CONTROL_TIMEOUT_MS, (0, host_approval_1.remainingMs)(holdContext) - 400));
|
|
892
1167
|
const [result] = await Promise.all([
|
|
893
|
-
withTimeout(control).catch((error) => (
|
|
1168
|
+
withTimeout(control, controlTimeoutMs).catch((error) => (isMarrowControlTimeout(error)
|
|
894
1169
|
? {
|
|
895
1170
|
runtime: null,
|
|
896
1171
|
permit: null,
|
|
897
1172
|
protectedRisk: enforcementRequired,
|
|
898
|
-
|
|
1173
|
+
timedOut: true,
|
|
899
1174
|
enforcementError: hook_contract_1.MARROW_OUTAGE_WARNING,
|
|
900
1175
|
}
|
|
901
|
-
:
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
1176
|
+
: isMarrowControlOutage(error)
|
|
1177
|
+
? {
|
|
1178
|
+
runtime: null,
|
|
1179
|
+
permit: null,
|
|
1180
|
+
protectedRisk: enforcementRequired,
|
|
1181
|
+
outage: true,
|
|
1182
|
+
enforcementError: hook_contract_1.MARROW_OUTAGE_WARNING,
|
|
1183
|
+
}
|
|
1184
|
+
: {
|
|
1185
|
+
runtime: null,
|
|
1186
|
+
permit: null,
|
|
1187
|
+
protectedRisk: enforcementRequired,
|
|
1188
|
+
enforcementError: controlRejectionMessage(error, agentId),
|
|
1189
|
+
...(controlFailureKind(error) ? { failure: controlFailureKind(error) } : {}),
|
|
1190
|
+
})),
|
|
908
1191
|
lifecycle,
|
|
909
1192
|
]);
|
|
910
|
-
const
|
|
1193
|
+
const claudePrompt = ownerApprovalPrompt(identity.harness, source);
|
|
1194
|
+
if (result.timedOut) {
|
|
1195
|
+
// Marrow is slow, not down: a self-imposed budget is never an outage. An
|
|
1196
|
+
// action that can be held stays held; routine actions keep flowing.
|
|
1197
|
+
const holdable = classified.protected || classified.risk === 'high'
|
|
1198
|
+
|| (0, host_approval_1.protectedAmong)(holdContext, localApprovalCategories(classified)).length > 0;
|
|
1199
|
+
if (holdable) {
|
|
1200
|
+
emitHeldPlan(identity.harness, { kind: 'deny', agentText: exports.HELD_SLOW_TEXT, userText: exports.HELD_SLOW_TEXT, code: false });
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
emitDecision({ ...result, outage: true, timedOut: false }, identity.harness, claudePrompt);
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
if (result.outage) {
|
|
1207
|
+
// Marrow cannot be reached. An action in a category the account owner
|
|
1208
|
+
// protects stays held on every host; everything else keeps the outage policy.
|
|
1209
|
+
const protectedNow = (0, host_approval_1.protectedAmong)(holdContext, localApprovalCategories(classified));
|
|
1210
|
+
if (protectedNow.length) {
|
|
1211
|
+
emitHeldPlan(identity.harness, { kind: 'deny', agentText: host_approval_1.HELD_UNREACHABLE_TEXT, userText: host_approval_1.HELD_UNREACHABLE_TEXT, code: false });
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
const verdict = result.outage ? null : runtimeGateVerdict(result.runtime);
|
|
1216
|
+
const guidance = verdict?.kind === 'review' ? (0, runtime_contract_1.ordinaryApprovalGuidance)(result.runtime) : null;
|
|
1217
|
+
const arbitration = verdict?.kind === 'arbitration_review' ? (0, runtime_contract_1.arbitrationApprovalGuidance)(result.runtime) : null;
|
|
1218
|
+
if (verdict && guidance && !guidance.hostApprovalSupported && holdContext.host === 'claude-code') {
|
|
1219
|
+
// An older service without host approvals: exactly as released 3.9.98 —
|
|
1220
|
+
// ask in Claude Code's dialog (the close stays unverified), never wait.
|
|
1221
|
+
const emitted = emitDecision(result, identity.harness, claudePrompt, true);
|
|
1222
|
+
if (emitted.denied)
|
|
1223
|
+
await closeDeniedDecision(resolved.apiKey, baseUrl, held, emitted.reason, sessionId, agentId);
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
if (verdict && (guidance || arbitration)) {
|
|
1227
|
+
// A hold: ask in the host's own prompt where it counts and is shown,
|
|
1228
|
+
// otherwise deny and wait for the approval. Never close the decision here;
|
|
1229
|
+
// that would spend the receipt the operator or owner is about to approve.
|
|
1230
|
+
const hookEvent = typeof source.hook_event_name === 'string' ? source.hook_event_name : 'PreToolUse';
|
|
1231
|
+
let plan;
|
|
1232
|
+
const cursor = holdContext.host === 'cursor' ? (0, host_approval_1.cursorSessionEvidence)(holdContext) : null;
|
|
1233
|
+
const nobodyHere = cursorMcpPreToolUse || unattendedRun(holdContext, claudePrompt, cursor?.interactive ?? null);
|
|
1234
|
+
if (guidance) {
|
|
1235
|
+
(0, host_approval_1.rememberProtection)(holdContext, guidance);
|
|
1236
|
+
plan = (0, host_approval_1.planHeldAction)({
|
|
1237
|
+
unattended: nobodyHere,
|
|
1238
|
+
attendedConfirmed: !cursorMcpPreToolUse && attendedConfirmed(holdContext, cursor?.interactive ?? null),
|
|
1239
|
+
hostPromptOff: (0, host_session_1.hostApprovalPromptOff)(holdContext.host),
|
|
1240
|
+
guidance,
|
|
1241
|
+
host: holdContext.host,
|
|
1242
|
+
hookEvent,
|
|
1243
|
+
// Only why it is held: the server's next-action text is written for API
|
|
1244
|
+
// clients and names endpoints, so it is never relayed.
|
|
1245
|
+
reason: boundedText(result.runtime?.risk_gate?.reasons?.[0]?.message, 240),
|
|
1246
|
+
claudePrompt,
|
|
1247
|
+
cursorInteractive: cursor?.interactive ?? null,
|
|
1248
|
+
typedReply: (0, host_approval_1.typedReplyAvailable)(holdContext),
|
|
1249
|
+
});
|
|
1250
|
+
}
|
|
1251
|
+
else {
|
|
1252
|
+
plan = (0, host_approval_1.planArbitrationHold)(arbitration, { unattended: nobodyHere });
|
|
1253
|
+
}
|
|
1254
|
+
let code = null;
|
|
1255
|
+
let effective = plan;
|
|
1256
|
+
let linkOutcome = { kind: 'none' };
|
|
1257
|
+
let laterLink = null;
|
|
1258
|
+
try {
|
|
1259
|
+
// An unreadable approval state leaves nothing to wait on: the next attempt starts over.
|
|
1260
|
+
const hold = plan.kind === 'deny' && plan.retryFresh ? null : (0, host_approval_1.rememberHold)(holdContext, {
|
|
1261
|
+
guidance: guidance || (0, host_approval_1.arbitrationHoldGuidance)(arbitration),
|
|
1262
|
+
correlation,
|
|
1263
|
+
toolUseId,
|
|
1264
|
+
generationId,
|
|
1265
|
+
toolName: String(source.tool_name || 'tool'),
|
|
1266
|
+
hookEvent,
|
|
1267
|
+
// ask and pass: the host decides now; deny: the hold waits for an approval.
|
|
1268
|
+
mode: plan.kind === 'deny' ? 'wait' : 'ask',
|
|
1269
|
+
withCode: plan.kind === 'deny' && plan.code,
|
|
1270
|
+
preActionEventId,
|
|
1271
|
+
action: { action: classified.action, target: classified.target, type: classified.type, surfaces: classified.surfaces },
|
|
1272
|
+
...(plan.kind === 'deny' && plan.ownerLink ? { ownerLink: plan.ownerLink } : {}),
|
|
1273
|
+
...(plan.kind === 'deny' && plan.dialogLater ? { dialogLater: true, laterPrompt: plan.laterPrompt } : {}),
|
|
1274
|
+
...(plan.kind === 'deny' && plan.quiet ? { quiet: plan.quiet } : {}),
|
|
1275
|
+
...(plan.kind === 'pass' ? { notObserved: true } : {}),
|
|
1276
|
+
normalizedAction,
|
|
1277
|
+
...(guidance ? {} : { personPresent: !nobodyHere }),
|
|
1278
|
+
});
|
|
1279
|
+
code = hold?.code ?? null;
|
|
1280
|
+
if (hold && plan.kind === 'deny' && plan.ownerLink === 'now') {
|
|
1281
|
+
linkOutcome = await (0, host_approval_1.requestOwnerLink)(holdContext, hold);
|
|
1282
|
+
if (linkOutcome.kind === 'deferred')
|
|
1283
|
+
laterLink = hold;
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
catch {
|
|
1287
|
+
// Without local state the answer could not be linked to this hold, so it is not asked for.
|
|
1288
|
+
if (plan.kind === 'ask' || plan.kind === 'pass') {
|
|
1289
|
+
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.`;
|
|
1290
|
+
effective = { kind: 'deny', agentText: text, userText: (0, host_approval_1.forPerson)(text), code: false };
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
effective = (0, host_approval_1.finalizeOwnerRequest)(effective, linkOutcome);
|
|
1294
|
+
if (effective.kind === 'pass') {
|
|
1295
|
+
// The hook does not block and does not allow: the host's own approval step decides.
|
|
1296
|
+
emitHookOutput(identity.harness, passHookOutput(identity.harness, effective.contextText));
|
|
1297
|
+
await afterAnswer(holdContext, {});
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
emitHeldPlan(identity.harness, effective, code);
|
|
1301
|
+
await afterAnswer(holdContext, { link: laterLink });
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
const emitted = emitDecision(result, identity.harness, claudePrompt);
|
|
911
1305
|
if (emitted.denied && !result.outage) {
|
|
912
1306
|
await closeDeniedDecision(resolved.apiKey, baseUrl, held, emitted.reason, sessionId, agentId);
|
|
913
1307
|
}
|