@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
|
@@ -0,0 +1,1417 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CLAUDE_CODE_NOT_A_DECISION_PREFIXES = exports.CLAUDE_CODE_USER_REJECTED_WITH_FEEDBACK = exports.CLAUDE_CODE_USER_REJECTED = exports.LATE_MARKER_WAIT_MS = exports.HELD_FOR_YOU_TEXT = exports.HOST_PROMPT_NOT_OBSERVED = exports.PASS_THROUGH_HOSTS = exports.TYPED_REPLY_MARKER = exports.OWNER_LINK_ON_REQUEST_TEXT = exports.HELD_UNREACHABLE_TEXT = exports.LEGACY_SERVICE_TEXT = exports.OWNER_APPROVAL_REQUEST_TEXT = exports.HOST_APPROVAL_REQUEST_TIMEOUT_MS = exports.CURSOR_ASK_EVENTS = exports.HEADLESS_CLAUDE_ENTRYPOINTS = exports.HOST_LABEL = void 0;
|
|
4
|
+
exports.approvalHostFor = approvalHostFor;
|
|
5
|
+
exports.claudeCodeHeadless = claudeCodeHeadless;
|
|
6
|
+
exports.preToolBudgetMs = preToolBudgetMs;
|
|
7
|
+
exports.preToolDeadline = preToolDeadline;
|
|
8
|
+
exports.remainingMs = remainingMs;
|
|
9
|
+
exports.stepTimeoutMs = stepTimeoutMs;
|
|
10
|
+
exports.settleBudgetMs = settleBudgetMs;
|
|
11
|
+
exports.hostSessionIdFor = hostSessionIdFor;
|
|
12
|
+
exports.ownerRequestText = ownerRequestText;
|
|
13
|
+
exports.ownerLinkSentText = ownerLinkSentText;
|
|
14
|
+
exports.finalizeOwnerRequest = finalizeOwnerRequest;
|
|
15
|
+
exports.planHeldAction = planHeldAction;
|
|
16
|
+
exports.planArbitrationHold = planArbitrationHold;
|
|
17
|
+
exports.typedReplyUserText = typedReplyUserText;
|
|
18
|
+
exports.arbitrationHoldGuidance = arbitrationHoldGuidance;
|
|
19
|
+
exports.rememberHold = rememberHold;
|
|
20
|
+
exports.rememberProtection = rememberProtection;
|
|
21
|
+
exports.protectedAmong = protectedAmong;
|
|
22
|
+
exports.forPerson = forPerson;
|
|
23
|
+
exports.personAgentName = personAgentName;
|
|
24
|
+
exports.requestOwnerLink = requestOwnerLink;
|
|
25
|
+
exports.heldActionsNotice = heldActionsNotice;
|
|
26
|
+
exports.noteDialogShown = noteDialogShown;
|
|
27
|
+
exports.permissionMarkerHookPresent = permissionMarkerHookPresent;
|
|
28
|
+
exports.approvalSentence = approvalSentence;
|
|
29
|
+
exports.resumeWaitingHold = resumeWaitingHold;
|
|
30
|
+
exports.deliverHold = deliverHold;
|
|
31
|
+
exports.flushHoldOutbox = flushHoldOutbox;
|
|
32
|
+
exports.holdSessionForReceipt = holdSessionForReceipt;
|
|
33
|
+
exports.proofFieldsForReceipt = proofFieldsForReceipt;
|
|
34
|
+
exports.deliverQueuedForReceipt = deliverQueuedForReceipt;
|
|
35
|
+
exports.settleAfterTool = settleAfterTool;
|
|
36
|
+
exports.toolResultText = toolResultText;
|
|
37
|
+
exports.classifyClaudeToolResult = classifyClaudeToolResult;
|
|
38
|
+
exports.transcriptToolResults = transcriptToolResults;
|
|
39
|
+
exports.settleToolBatch = settleToolBatch;
|
|
40
|
+
exports.settleAtPrompt = settleAtPrompt;
|
|
41
|
+
exports.parseTypedReply = parseTypedReply;
|
|
42
|
+
exports.noteCursorSession = noteCursorSession;
|
|
43
|
+
exports.cursorSessionEvidence = cursorSessionEvidence;
|
|
44
|
+
exports.typedReplyAvailable = typedReplyAvailable;
|
|
45
|
+
exports.settleTypedReply = settleTypedReply;
|
|
46
|
+
const node_crypto_1 = require("node:crypto");
|
|
47
|
+
const node_fs_1 = require("node:fs");
|
|
48
|
+
const node_os_1 = require("node:os");
|
|
49
|
+
const node_path_1 = require("node:path");
|
|
50
|
+
const index_1 = require("./index");
|
|
51
|
+
const hook_tool_policy_1 = require("./hook-tool-policy");
|
|
52
|
+
const hook_contract_1 = require("./hook-contract");
|
|
53
|
+
const runtime_contract_1 = require("./runtime-contract");
|
|
54
|
+
const host_approval_state_1 = require("./host-approval-state");
|
|
55
|
+
const host_session_1 = require("./host-session");
|
|
56
|
+
const normalized_action_1 = require("./normalized-action");
|
|
57
|
+
exports.HOST_LABEL = {
|
|
58
|
+
'claude-code': 'Claude Code',
|
|
59
|
+
codex: 'Codex',
|
|
60
|
+
cursor: 'Cursor',
|
|
61
|
+
cline: 'Cline',
|
|
62
|
+
windsurf: 'Windsurf',
|
|
63
|
+
gemini: 'Gemini CLI',
|
|
64
|
+
grok: 'Grok',
|
|
65
|
+
other: 'the host',
|
|
66
|
+
};
|
|
67
|
+
function approvalHostFor(harness, env = process.env) {
|
|
68
|
+
if (harness === 'claude-code' || (harness === 'mcp-client' && env.CLAUDE_CODE_CHILD_SESSION === '1'))
|
|
69
|
+
return 'claude-code';
|
|
70
|
+
if (harness === 'mcp-client')
|
|
71
|
+
return 'other';
|
|
72
|
+
return harness;
|
|
73
|
+
}
|
|
74
|
+
/** Claude Code entrypoints with no one at a dialog (claude -p, the Agent SDKs, its GitHub Action); hooks inherit it. */
|
|
75
|
+
exports.HEADLESS_CLAUDE_ENTRYPOINTS = new Set(['sdk-cli', 'sdk-ts', 'sdk-py', 'claude-code-github-action']);
|
|
76
|
+
function claudeCodeHeadless(env = process.env) {
|
|
77
|
+
return exports.HEADLESS_CLAUDE_ENTRYPOINTS.has(String(env.CLAUDE_CODE_ENTRYPOINT || ''));
|
|
78
|
+
}
|
|
79
|
+
/** Cursor events on which a hook "ask" is enforced (never preToolUse). */
|
|
80
|
+
exports.CURSOR_ASK_EVENTS = new Set(['beforeShellExecution', 'beforeMCPExecution']);
|
|
81
|
+
const HOST_SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/;
|
|
82
|
+
exports.HOST_APPROVAL_REQUEST_TIMEOUT_MS = 4_000;
|
|
83
|
+
/** The status read is one key lookup. */
|
|
84
|
+
const STATUS_READ_TIMEOUT_MS = 2_500;
|
|
85
|
+
/** Time kept back for writing the hook's answer. */
|
|
86
|
+
const OUTPUT_RESERVE_MS = 400;
|
|
87
|
+
/** Below this, a network step is not started before the answer is written. */
|
|
88
|
+
const MIN_STEP_MS = 600;
|
|
89
|
+
/**
|
|
90
|
+
* One total time budget for a pre-tool hook, counted from the start of the
|
|
91
|
+
* hook process and set from each host's real kill timeout as
|
|
92
|
+
* @getmarrow/install configures it: Codex 5 s (it then lets the call run),
|
|
93
|
+
* Cursor 5 s with failClosed, the installer's Grok guard 5 s (it then blocks),
|
|
94
|
+
* Gemini CLI 5 s. The margin covers npx start-up before the process starts.
|
|
95
|
+
* Cold auth can take 900 ms plus a 1.6 s grace, so the control path keeps
|
|
96
|
+
* room for a slow but healthy Marrow; when the budget still runs out, an
|
|
97
|
+
* action that can be held stays held (never an outage allow).
|
|
98
|
+
*/
|
|
99
|
+
function preToolBudgetMs(host) {
|
|
100
|
+
// Codex: 5 s kill, then it runs the call. npx start-up before this process
|
|
101
|
+
// takes about 0.5-1.3 s and more under load, so the answer comes within 2 s.
|
|
102
|
+
if (host === 'codex')
|
|
103
|
+
return 2_000;
|
|
104
|
+
if (host === 'cursor' || host === 'grok' || host === 'gemini')
|
|
105
|
+
return 4_000;
|
|
106
|
+
return 14_000;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* The hook process's deadline for its pre-tool answer and any follow-up work.
|
|
110
|
+
* Codex's kill clock starts when it spawns the hook command, so the time npx
|
|
111
|
+
* (or a shell) took to start this process comes out of the budget: the answer
|
|
112
|
+
* comes about 2.6 s after spawn, with at least 1 s for this process itself.
|
|
113
|
+
*/
|
|
114
|
+
function preToolDeadline(host, headStartMs = host_session_1.hookLauncherHeadStartMs) {
|
|
115
|
+
const start = Date.now() - Math.round(process.uptime() * 1000);
|
|
116
|
+
if (host !== 'codex')
|
|
117
|
+
return start + preToolBudgetMs(host);
|
|
118
|
+
let headStart = 0;
|
|
119
|
+
try {
|
|
120
|
+
headStart = headStartMs();
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
headStart = 0;
|
|
124
|
+
}
|
|
125
|
+
return start + Math.max(1_000, Math.min(preToolBudgetMs(host), 2_600 - headStart));
|
|
126
|
+
}
|
|
127
|
+
/** Time left before the hook must have written its answer (Infinity without a deadline). */
|
|
128
|
+
function remainingMs(ctx) {
|
|
129
|
+
return ctx.deadlineAt ? ctx.deadlineAt - Date.now() : Number.POSITIVE_INFINITY;
|
|
130
|
+
}
|
|
131
|
+
/** A step's timeout inside the budget, or null when there is no time for it before the answer. */
|
|
132
|
+
function stepTimeoutMs(ctx, max, reserve = OUTPUT_RESERVE_MS) {
|
|
133
|
+
const left = remainingMs(ctx) - reserve;
|
|
134
|
+
if (left < MIN_STEP_MS)
|
|
135
|
+
return null;
|
|
136
|
+
return Math.min(max, left);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Work a post-tool hook does for a held call. Codex and Cursor run these hooks
|
|
140
|
+
* with a 5-second timeout (as @getmarrow/install configures them); a hook cut
|
|
141
|
+
* short keeps its queued report, which a later hook resends.
|
|
142
|
+
*/
|
|
143
|
+
function settleBudgetMs(host) {
|
|
144
|
+
return host === 'codex' || host === 'cursor' ? 4_000 : 9_000;
|
|
145
|
+
}
|
|
146
|
+
function hostSessionIdFor(candidates, fallback) {
|
|
147
|
+
for (const candidate of candidates) {
|
|
148
|
+
if (typeof candidate === 'string' && HOST_SESSION_ID.test(candidate.trim()))
|
|
149
|
+
return candidate.trim();
|
|
150
|
+
}
|
|
151
|
+
return HOST_SESSION_ID.test(fallback) ? fallback : `session-${(0, node_crypto_1.createHash)('sha256').update(fallback).digest('hex').slice(0, 32)}`;
|
|
152
|
+
}
|
|
153
|
+
function scopeOf(ctx) {
|
|
154
|
+
return { apiKey: ctx.apiKey, baseUrl: ctx.baseUrl, agentId: ctx.agentId || null };
|
|
155
|
+
}
|
|
156
|
+
function bounded(value, limit) {
|
|
157
|
+
return value.replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, limit);
|
|
158
|
+
}
|
|
159
|
+
/** Why the operator's own approval does not count for this hold (the server's refusal). */
|
|
160
|
+
function ownerOnlyReason(guidance) {
|
|
161
|
+
if (!guidance.hostApprovalSupported)
|
|
162
|
+
return exports.LEGACY_SERVICE_TEXT;
|
|
163
|
+
if (guidance.hostApprovalRefusal === 'owner_decline_stands') {
|
|
164
|
+
return `The account owner declined this action${guidance.ownerDeclinedAt ? ` at ${guidance.ownerDeclinedAt}` : ' earlier'}.`;
|
|
165
|
+
}
|
|
166
|
+
if (guidance.hostApprovalRefusal === 'approval_state_unavailable') {
|
|
167
|
+
return 'Marrow could not check how this hold can be approved right now.';
|
|
168
|
+
}
|
|
169
|
+
if (guidance.verifiedApprovalRequired === true) {
|
|
170
|
+
return `The account owner approves ${guidance.verifiedApprovalCategories.join(', ') || 'these'} actions personally.`;
|
|
171
|
+
}
|
|
172
|
+
if (guidance.verifiedApprovalRequired === null) {
|
|
173
|
+
return 'Marrow could not read the account approval settings.';
|
|
174
|
+
}
|
|
175
|
+
return 'An operator approval is not available for this hold.';
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Stands in a plan's text for what happened to the owner's one-tap link; it is
|
|
179
|
+
* always replaced (finalizeOwnerRequest) before anyone sees the text, so no
|
|
180
|
+
* text says a request was sent when none was.
|
|
181
|
+
*/
|
|
182
|
+
exports.OWNER_APPROVAL_REQUEST_TEXT = '[owner-request]';
|
|
183
|
+
/** An older Marrow service: no host approvals and no links; only the account owner can approve. */
|
|
184
|
+
exports.LEGACY_SERVICE_TEXT = 'This Marrow service does not support chat or terminal approvals yet, so only the account owner can approve it.';
|
|
185
|
+
/** Marrow could not be read while this action waits for an approval: it stays held. */
|
|
186
|
+
exports.HELD_UNREACHABLE_TEXT = 'Marrow could not confirm the owner\'s approval; this action stays held. Retry when Marrow is reachable.';
|
|
187
|
+
/** The sentence for what happened to the owner's link. Never claims a send that did not happen. */
|
|
188
|
+
function ownerRequestText(outcome) {
|
|
189
|
+
switch (outcome.kind) {
|
|
190
|
+
case 'sent': return ownerLinkSentText(outcome.channel);
|
|
191
|
+
case 'already_sent': return outcome.channel
|
|
192
|
+
? `The account owner already has a one-tap approval link (${outcome.channel}${outcome.expiresAt ? `, until ${outcome.expiresAt}` : ''}); wait for their answer.`
|
|
193
|
+
: 'An approval link was sent to the account owner.';
|
|
194
|
+
case 'not_sent': return 'Nothing was sent to anyone; it waits quietly until a person approves it.';
|
|
195
|
+
// Deferred: tried right after this answer is written; until a retry confirms it, it is not "sent".
|
|
196
|
+
case 'deferred':
|
|
197
|
+
case 'retryable': return 'Marrow could not send the account owner an approval link yet; retrying this exact action tries again.';
|
|
198
|
+
case 'failed': return `Marrow could not send the account owner an approval link${outcome.code ? ` (${outcome.code})` : ''}; tell the operator this action is waiting for the account owner's approval.`;
|
|
199
|
+
default: return 'Tell the operator this action is waiting for the account owner\'s approval.';
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/** Shown when the owner's link is sent only if the operator asks for it. */
|
|
203
|
+
exports.OWNER_LINK_ON_REQUEST_TEXT = 'To ask the account owner, retry this exact action; Marrow then sends the owner a one-tap approval link.';
|
|
204
|
+
/** The sentence that replaces OWNER_APPROVAL_REQUEST_TEXT once Marrow sent the owner a one-tap link. */
|
|
205
|
+
function ownerLinkSentText(channel) {
|
|
206
|
+
return `An approval link was sent to the account owner (${channel}).`;
|
|
207
|
+
}
|
|
208
|
+
/** Puts what happened to the owner's link into the plan's text (always, before output). */
|
|
209
|
+
function finalizeOwnerRequest(plan, outcome) {
|
|
210
|
+
if (plan.kind !== 'deny')
|
|
211
|
+
return plan;
|
|
212
|
+
const text = ownerRequestText(outcome);
|
|
213
|
+
return { ...plan, agentText: plan.agentText.replace(exports.OWNER_APPROVAL_REQUEST_TEXT, text), userText: plan.userText.replace(exports.OWNER_APPROVAL_REQUEST_TEXT, text) };
|
|
214
|
+
}
|
|
215
|
+
/** Hosts whose typed reply is a person-only marker, and that marker (backend OPERATOR_MARKER_BY_HOST). */
|
|
216
|
+
exports.TYPED_REPLY_MARKER = {
|
|
217
|
+
codex: 'UserPromptSubmit',
|
|
218
|
+
gemini: 'BeforeAgent',
|
|
219
|
+
cursor: 'beforeSubmitPrompt',
|
|
220
|
+
};
|
|
221
|
+
/**
|
|
222
|
+
* Decides how a hook answers an ordinary held action. The operator approves
|
|
223
|
+
* where they work: the host's own dialog (Claude Code, Cursor shell and MCP
|
|
224
|
+
* calls), or a typed reply in a local interactive session of a host without a
|
|
225
|
+
* dialog (Codex, Gemini CLI, Cursor otherwise). The approval code and its
|
|
226
|
+
* prompt go only to a user-only channel; model-facing text never contains it.
|
|
227
|
+
* When no one can answer here, the action waits quietly (the owner's link only
|
|
228
|
+
* for owner-locked categories, the owner's own decline when the operator asks,
|
|
229
|
+
* or unattended runs with the owner's pings on).
|
|
230
|
+
* No text makes a dashboard login the step to take.
|
|
231
|
+
*/
|
|
232
|
+
function planHeldAction(input) {
|
|
233
|
+
const { guidance, host } = input;
|
|
234
|
+
const unattended = input.unattended === true;
|
|
235
|
+
const reason = input.reason ? ` Reason: ${bounded(input.reason, 200)}` : '';
|
|
236
|
+
const id = guidance.gateReceiptId;
|
|
237
|
+
// Shown in the host's own prompt (to the operator, never to the agent).
|
|
238
|
+
const notice = guidance.operatorNotice ? ` Note: ${guidance.operatorNotice}` : '';
|
|
239
|
+
const held = `Marrow is holding this action for approval (gate receipt ${id}), so it did not run.${reason}`;
|
|
240
|
+
const tail = ' When it is approved, retry this exact action; Marrow checks the approval then. Do not report or claim an approval yourself.';
|
|
241
|
+
// Text a person reads names no receipt ids; the agent's text keeps them (it closes the action with them).
|
|
242
|
+
const claudePrompt = bounded(`Marrow holds this action for your approval. Approve only if you authorize this exact action; Marrow records your answer.${notice}${reason}`, 500);
|
|
243
|
+
// Owner rule: the owner's link only for (a) an owner-locked category, (b) the
|
|
244
|
+
// owner's standing decline once the operator asks, (c) an unattended run with pings on.
|
|
245
|
+
const policy = (0, runtime_contract_1.ownerLinkPolicy)(guidance, { unattended });
|
|
246
|
+
if (guidance.hostApprovalAccepted) {
|
|
247
|
+
if (host === 'claude-code' && input.claudePrompt?.available) {
|
|
248
|
+
return { kind: 'ask', promptText: claudePrompt };
|
|
249
|
+
}
|
|
250
|
+
// After an operator decline only a marked answer counts; Cursor's dialog carries no marker.
|
|
251
|
+
if (host === 'cursor' && exports.CURSOR_ASK_EVENTS.has(input.hookEvent) && input.cursorInteractive === true && !guidance.operatorOnly) {
|
|
252
|
+
return {
|
|
253
|
+
kind: 'ask',
|
|
254
|
+
promptText: bounded(`Marrow holds this action for your approval. Approve only if you authorize this exact action.${notice}${reason}`, 500),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
if (input.typedReply && exports.TYPED_REPLY_MARKER[host] && !unattended) {
|
|
258
|
+
return {
|
|
259
|
+
kind: 'deny',
|
|
260
|
+
agentText: bounded(`${held} The operator was asked to approve it here.${tail}`, 500),
|
|
261
|
+
userText: bounded(`Marrow holds this action for your approval.${notice}${reason}`, 400),
|
|
262
|
+
code: true,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (!guidance.hostApprovalSupported) {
|
|
267
|
+
// An older service: no host approvals and no links. The hold waits, and
|
|
268
|
+
// the retried action reads its status, so an owner's approval still counts.
|
|
269
|
+
const agentText = bounded(`${held} ${exports.LEGACY_SERVICE_TEXT} Retry this exact action after the owner approves it; Marrow checks the approval then. Do not report or claim an approval yourself.`, 500);
|
|
270
|
+
return { kind: 'deny', agentText, userText: forPerson(agentText), code: false };
|
|
271
|
+
}
|
|
272
|
+
if (!guidance.hostApprovalAccepted) {
|
|
273
|
+
if (guidance.hostApprovalRefusal === 'approval_state_unavailable'
|
|
274
|
+
|| (guidance.hostApprovalRefusal === null && guidance.verifiedApprovalRequired === null)) {
|
|
275
|
+
// Nothing to wait for yet: the next attempt asks Marrow again.
|
|
276
|
+
const agentText = bounded(`${held} ${ownerOnlyReason(guidance)} Retry this exact action in a moment. Do not report or claim an approval yourself.`, 500);
|
|
277
|
+
return { kind: 'deny', agentText, userText: forPerson(agentText), code: false, retryFresh: true };
|
|
278
|
+
}
|
|
279
|
+
if (guidance.hostApprovalRefusal === 'owner_decline_stands') {
|
|
280
|
+
// The owner just said no: the owner is asked again only when the operator asks.
|
|
281
|
+
const why = `${ownerOnlyReason(guidance)} Only the account owner can reverse that.`;
|
|
282
|
+
const ask = policy === 'on_request' ? ` ${exports.OWNER_LINK_ON_REQUEST_TEXT}` : '';
|
|
283
|
+
const agentText = bounded(`${held} ${why}${ask} Retry it only if the operator asks you to; otherwise carry on with other work. Do not report or claim an approval yourself.`, 500);
|
|
284
|
+
return { kind: 'deny', agentText, userText: forPerson(agentText), code: false, ...(policy === 'on_request' ? { ownerLink: 'on_request' } : {}) };
|
|
285
|
+
}
|
|
286
|
+
// An owner-locked category: the owner's one-tap link is how it is approved.
|
|
287
|
+
const request = policy === 'now' ? exports.OWNER_APPROVAL_REQUEST_TEXT : ownerRequestText({ kind: 'none' });
|
|
288
|
+
const agentText = bounded(`${held} ${ownerOnlyReason(guidance)} ${request} Carry on with other work meanwhile.${tail}`, 500);
|
|
289
|
+
return { kind: 'deny', agentText, userText: forPerson(agentText), code: false, ...(policy === 'now' ? { ownerLink: 'now' } : {}) };
|
|
290
|
+
}
|
|
291
|
+
if (host === 'claude-code' && !input.claudePrompt?.headless && !unattended) {
|
|
292
|
+
// The operator is present but this session shows no dialog: switching to a
|
|
293
|
+
// mode with the dialog approves it here (the host's own prompt, one click).
|
|
294
|
+
const agentText = bounded(`${held} Claude Code shows no approval dialog in this session (${input.claudePrompt?.unavailableReason || 'it cannot prompt'}). To approve it here, switch Claude Code to its default permission mode and retry this exact action; Claude Code then asks you. Until then carry on with other work. Do not report or claim an approval yourself.`, 500);
|
|
295
|
+
return { kind: 'deny', agentText, userText: forPerson(agentText), code: false, dialogLater: true, laterPrompt: claudePrompt };
|
|
296
|
+
}
|
|
297
|
+
if (unattended || (host === 'claude-code' && input.claudePrompt?.headless)) {
|
|
298
|
+
// Unattended: the action waits quietly and the agent carries on. The person
|
|
299
|
+
// sees it at their next interactive session; the owner is pinged only on opt-in.
|
|
300
|
+
const pinged = policy === 'now';
|
|
301
|
+
const agentText = pinged
|
|
302
|
+
? bounded(`${held} Nobody can approve it in this run. ${exports.OWNER_APPROVAL_REQUEST_TEXT} If it is approved, retrying this exact action runs it once; meanwhile carry on with other work. Do not report or claim an approval yourself.`, 500)
|
|
303
|
+
: bounded(`${held} Nobody can approve it in this run, so it waits quietly; nothing was sent to anyone. Carry on with other work and do not retry it in this run. A person sees it at their next interactive session and approves it there by retrying it where the host's prompt asks. Do not report or claim an approval yourself.`, 500);
|
|
304
|
+
return { kind: 'deny', agentText, userText: forPerson(agentText), code: false, quiet: 'unattended', ...(pinged ? { ownerLink: 'now' } : {}) };
|
|
305
|
+
}
|
|
306
|
+
// A person is here, but Marrow can neither ask in this host nor observe the
|
|
307
|
+
// answer: the host's own approval step decides (owner rule). Not after an
|
|
308
|
+
// operator decline (only a marked answer counts then), and not without
|
|
309
|
+
// positive evidence that a person is here.
|
|
310
|
+
if (input.attendedConfirmed === true && input.hostPromptOff !== true && !guidance.operatorOnly && exports.PASS_THROUGH_HOSTS.has(host)) {
|
|
311
|
+
return {
|
|
312
|
+
kind: 'pass',
|
|
313
|
+
contextText: bounded(`Marrow did not block this held action (gate receipt ${id}). ${exports.HOST_LABEL[host].charAt(0).toUpperCase()}${exports.HOST_LABEL[host].slice(1)}'s own approval step decides; Marrow cannot ask here and does not observe that answer. If it runs, Marrow records it as approved through the host's own prompt (client-attested, not an operator answer). Do not report or claim an approval yourself.${reason}`, 500),
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
// Otherwise: hold quietly.
|
|
317
|
+
const label = `${exports.HOST_LABEL[host].charAt(0).toUpperCase()}${exports.HOST_LABEL[host].slice(1)}`;
|
|
318
|
+
const why = host === 'cursor'
|
|
319
|
+
? 'Cursor asks for approval only for shell and MCP calls in a local session.'
|
|
320
|
+
: input.hostPromptOff === true
|
|
321
|
+
? `${label} runs with its own approval prompt turned off in this session (approval bypass, never-ask, automatic review or full access), so nothing asks for approval here.`
|
|
322
|
+
: `${label} cannot ask for approval in this session.`;
|
|
323
|
+
const agentText = bounded(`${held} ${why} It stays held until the operator approves it: tell them it is held, and that they approve it by retrying it in a session with Marrow's prompt (a host permission dialog, or a typed reply). Carry on with other work. Do not report or claim an approval yourself.`, 500);
|
|
324
|
+
return { kind: 'deny', agentText, userText: exports.HELD_FOR_YOU_TEXT, code: false, quiet: 'attended' };
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Hosts where an ordinary hold Marrow cannot ask about is left to the host's
|
|
328
|
+
* own approval step: those whose hook can answer neutrally (no decision), so
|
|
329
|
+
* the host's normal permission flow runs. A hook never emits an explicit
|
|
330
|
+
* allow for a held action. Gemini CLI and Grok are not here (their installed
|
|
331
|
+
* guards accept only an explicit allow or a fixed denial), nor is Cursor
|
|
332
|
+
* (preToolUse has no neutral answer and "ask" is not enforced there): those
|
|
333
|
+
* hold quietly.
|
|
334
|
+
*/
|
|
335
|
+
exports.PASS_THROUGH_HOSTS = new Set(['codex', 'cline', 'windsurf']);
|
|
336
|
+
/** The hook_event of an answer given in a host prompt Marrow did not observe (labelled an allow rule). */
|
|
337
|
+
exports.HOST_PROMPT_NOT_OBSERVED = 'host_prompt_not_observed';
|
|
338
|
+
/** What the person sees when this host cannot ask them: the action waits for them. */
|
|
339
|
+
exports.HELD_FOR_YOU_TEXT = 'This action is held until you approve it. Approve it by retrying it in a session with Marrow\'s prompt.';
|
|
340
|
+
/**
|
|
341
|
+
* Arbitration review_required with the server's one-tap path: the owner picks
|
|
342
|
+
* and approves one proposal. The hook denies, asks Marrow to send the owner a
|
|
343
|
+
* link, and the retried action reads the status. Nobody is told to log in.
|
|
344
|
+
*/
|
|
345
|
+
function planArbitrationHold(guidance, options = {}) {
|
|
346
|
+
const held = `Marrow is holding this action for arbitration review (gate receipt ${guidance.gateReceiptId}), so it did not run. The account owner picks and approves one proposal.`;
|
|
347
|
+
if (options.unattended === true) {
|
|
348
|
+
// Nobody here can ask: the service sends the owner a link only if they turned on unattended pings.
|
|
349
|
+
const agentText = bounded(`${held} ${exports.OWNER_APPROVAL_REQUEST_TEXT} Carry on with other work and do not retry it in this run. Do not report or claim an approval yourself.`, 500);
|
|
350
|
+
return { kind: 'deny', agentText, userText: forPerson(agentText), code: false, ownerLink: 'now', quiet: 'unattended' };
|
|
351
|
+
}
|
|
352
|
+
// A person is here: the owner is asked only when they ask for it, by retrying this action.
|
|
353
|
+
const agentText = bounded(`${held} Nothing was sent to the owner yet. If the operator wants the owner asked now, retry this exact action once; Marrow then sends the owner a one-tap link. Otherwise carry on with other work. When the owner has answered, retry this exact action; Marrow checks the answer then. Do not report or claim an approval yourself.`, 500);
|
|
354
|
+
return { kind: 'deny', agentText, userText: forPerson(agentText), code: false, ownerLink: 'on_request' };
|
|
355
|
+
}
|
|
356
|
+
/** User-only text with the typed-reply code (Cursor user_message, Codex and Gemini systemMessage). */
|
|
357
|
+
function typedReplyUserText(userText, code) {
|
|
358
|
+
return bounded(`${userText} To approve it, type: marrow approve ${code} (or: marrow decline ${code}). Then let the agent retry it.`, 600);
|
|
359
|
+
}
|
|
360
|
+
function arbitrationHoldGuidance(guidance) {
|
|
361
|
+
return {
|
|
362
|
+
gateReceiptId: guidance.gateReceiptId,
|
|
363
|
+
decisionId: guidance.decisionId,
|
|
364
|
+
proofRequired: guidance.proofRequired,
|
|
365
|
+
proofFields: guidance.proofFields,
|
|
366
|
+
expiresAt: guidance.expiresAt,
|
|
367
|
+
approvalLinkPath: guidance.linkPath,
|
|
368
|
+
hostApprovalSupported: true,
|
|
369
|
+
arbitrationReceiptId: guidance.arbitrationReceiptId,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
const HOOK_EVENT_NAME = /^[A-Za-z][A-Za-z0-9_.:-]{0,63}$/;
|
|
373
|
+
const BOUNDED_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
374
|
+
function rememberHold(ctx, input) {
|
|
375
|
+
return (0, host_approval_state_1.recordHold)(scopeOf(ctx), {
|
|
376
|
+
host: ctx.host,
|
|
377
|
+
harness: ctx.harness,
|
|
378
|
+
// The same bound buildHeaders applies to X-Marrow-Session-Id.
|
|
379
|
+
session_id: (0, host_approval_state_1.boundSessionId)(ctx.sessionId),
|
|
380
|
+
host_session_id: ctx.hostSessionId,
|
|
381
|
+
agent_id: ctx.agentId && ctx.agentId.length <= 128 ? ctx.agentId : null,
|
|
382
|
+
correlation: input.correlation,
|
|
383
|
+
tool_use_id: input.toolUseId && BOUNDED_ID.test(input.toolUseId) ? input.toolUseId : null,
|
|
384
|
+
generation_id: input.generationId && BOUNDED_ID.test(input.generationId) ? input.generationId : null,
|
|
385
|
+
tool_name: input.toolName.slice(0, 256),
|
|
386
|
+
hook_event: HOOK_EVENT_NAME.test(input.hookEvent) ? input.hookEvent : 'PreToolUse',
|
|
387
|
+
mode: input.mode,
|
|
388
|
+
gate_receipt_id: input.guidance.gateReceiptId,
|
|
389
|
+
decision_id: input.guidance.decisionId,
|
|
390
|
+
asked_at: new Date().toISOString(),
|
|
391
|
+
pre_action_event_id: input.preActionEventId,
|
|
392
|
+
proof_required: input.guidance.proofRequired,
|
|
393
|
+
proof_fields: input.guidance.proofFields,
|
|
394
|
+
expires_at: input.guidance.expiresAt,
|
|
395
|
+
action: {
|
|
396
|
+
action: input.action.action.slice(0, 512),
|
|
397
|
+
target: input.action.target.slice(0, 256),
|
|
398
|
+
type: input.action.type.slice(0, 64),
|
|
399
|
+
surfaces: input.action.surfaces.slice(0, 16),
|
|
400
|
+
},
|
|
401
|
+
withCode: input.withCode,
|
|
402
|
+
owner_link: !input.guidance.approvalLinkPath ? null
|
|
403
|
+
: input.ownerLink === 'now' ? 'unsent'
|
|
404
|
+
: input.ownerLink === 'on_request' ? 'on_request'
|
|
405
|
+
: null,
|
|
406
|
+
link_attempts: 0,
|
|
407
|
+
dialog_later: input.dialogLater === true,
|
|
408
|
+
ask_text: input.dialogLater && input.laterPrompt ? bounded(input.laterPrompt, 500) : null,
|
|
409
|
+
legacy_service: input.guidance.hostApprovalSupported === false,
|
|
410
|
+
arbitration_receipt_id: input.guidance.arbitrationReceiptId && BOUNDED_ID.test(input.guidance.arbitrationReceiptId) ? input.guidance.arbitrationReceiptId : null,
|
|
411
|
+
quiet: input.quiet ?? null,
|
|
412
|
+
not_observed: input.notObserved === true,
|
|
413
|
+
...(input.normalizedAction && JSON.stringify(input.normalizedAction).length <= 16_384 ? { normalized_action: input.normalizedAction } : {}),
|
|
414
|
+
...(typeof input.personPresent === 'boolean' ? { person_present: input.personPresent } : {}),
|
|
415
|
+
}, ctx.home);
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Remembers the categories the account owner protects, from a hold's guidance,
|
|
419
|
+
* so an owner-protected action stays held while Marrow cannot be reached.
|
|
420
|
+
*/
|
|
421
|
+
function rememberProtection(ctx, guidance) {
|
|
422
|
+
try {
|
|
423
|
+
if (guidance.verifiedApprovalRequired === true) {
|
|
424
|
+
(0, host_approval_state_1.noteProtectedCategories)(scopeOf(ctx), guidance.verifiedApprovalCategories, [], ctx.home);
|
|
425
|
+
}
|
|
426
|
+
else if (guidance.verifiedApprovalRequired === false) {
|
|
427
|
+
(0, host_approval_state_1.noteProtectedCategories)(scopeOf(ctx), [], guidance.approvalCategories, ctx.home);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
catch { /* a convenience for outages; the server stays the authority */ }
|
|
431
|
+
}
|
|
432
|
+
/** Of these categories, the ones this key last saw the account owner protect. */
|
|
433
|
+
function protectedAmong(ctx, categories) {
|
|
434
|
+
return (0, host_approval_state_1.protectedCategoriesAmong)(scopeOf(ctx), categories, ctx.home);
|
|
435
|
+
}
|
|
436
|
+
const OWNER_LINK_TIMEOUT_MS = 2_000;
|
|
437
|
+
/** Text for a person: the agent's text without receipt ids (the agent keeps them to close the action). */
|
|
438
|
+
function forPerson(text) {
|
|
439
|
+
return text.replace(/ ?\(gate receipt [^)]{1,200}\)/g, '').replace(/ on gate receipt [A-Za-z0-9_.:-]{1,128}/g, '');
|
|
440
|
+
}
|
|
441
|
+
/** An agent id worth showing a person: not an automatic seat or key-derived fallback id. */
|
|
442
|
+
function personAgentName(agent) {
|
|
443
|
+
if (!agent)
|
|
444
|
+
return null;
|
|
445
|
+
return /^(?:free-seat|seat|api-key|key|fallback)-/i.test(agent) ? null : agent;
|
|
446
|
+
}
|
|
447
|
+
/** The server allows three links per gate receipt. */
|
|
448
|
+
const MAX_LINK_ATTEMPTS = 3;
|
|
449
|
+
/** Link refusals that another request cannot change. */
|
|
450
|
+
const FINAL_LINK_CODES = new Set([
|
|
451
|
+
'MARROW_APPROVAL_CHANNEL_UNAVAILABLE',
|
|
452
|
+
'MARROW_APPROVAL_LINK_LIMITED',
|
|
453
|
+
'MARROW_APPROVAL_LINK_NOT_HELD',
|
|
454
|
+
'MARROW_APPROVAL_LINK_SCOPE_MISMATCH',
|
|
455
|
+
'MARROW_APPROVAL_LINK_INVALID',
|
|
456
|
+
'MARROW_APPROVAL_LINK_DECISION_REQUIRED',
|
|
457
|
+
'MARROW_PRE_ACTION_GATE_USED',
|
|
458
|
+
'MARROW_PRE_ACTION_GATE_EXPIRED',
|
|
459
|
+
'MARROW_OWNER_APPROVAL_ALREADY_DECIDED',
|
|
460
|
+
'MARROW_GATE_RECEIPT_NOT_FOUND',
|
|
461
|
+
]);
|
|
462
|
+
/**
|
|
463
|
+
* Asks Marrow to send the account owner a one-tap approval link for this hold.
|
|
464
|
+
* A retryable failure (network, rate limit, undelivered) is tried again on a
|
|
465
|
+
* later attempt, up to the server's per-receipt limit; a final refusal is not.
|
|
466
|
+
* Returns what happened. The link itself never reaches this client or the agent.
|
|
467
|
+
*/
|
|
468
|
+
async function requestOwnerLink(ctx, hold, reserve = OUTPUT_RESERVE_MS) {
|
|
469
|
+
if (hold.owner_link === 'sent')
|
|
470
|
+
return { kind: 'already_sent' };
|
|
471
|
+
if (hold.owner_link === 'failed')
|
|
472
|
+
return { kind: 'failed', code: null };
|
|
473
|
+
if (hold.owner_link !== 'unsent' && hold.owner_link !== 'on_request')
|
|
474
|
+
return { kind: 'none' };
|
|
475
|
+
if ((hold.link_attempts || 0) >= MAX_LINK_ATTEMPTS)
|
|
476
|
+
return { kind: 'failed', code: 'MARROW_APPROVAL_LINK_LIMITED' };
|
|
477
|
+
const budget = stepTimeoutMs(ctx, OWNER_LINK_TIMEOUT_MS, reserve);
|
|
478
|
+
if (budget === null)
|
|
479
|
+
return { kind: 'deferred' };
|
|
480
|
+
const timeout = statusTimeout(budget);
|
|
481
|
+
let outcome;
|
|
482
|
+
try {
|
|
483
|
+
// Arbitration links carry whether a person at this session asked (client-attested): an
|
|
484
|
+
// attended hold's link is requested on the operator's retry; an unattended one never claims a person.
|
|
485
|
+
const arbitration = Boolean(hold.arbitration_receipt_id);
|
|
486
|
+
const result = await (0, index_1.marrowRequestApprovalLink)(ctx.apiKey, ctx.baseUrl, hold.gate_receipt_id, hold.decision_id, hold.session_id, hold.agent_id || undefined, timeout.signal, arbitration ? { personPresent: hold.person_present === true && hold.owner_link === 'on_request' } : {});
|
|
487
|
+
outcome = result.ok && result.alreadySent ? { kind: 'already_sent', channel: result.link.channel, expiresAt: result.link.expires_at ?? null }
|
|
488
|
+
: result.ok ? { kind: 'sent', channel: result.link.channel }
|
|
489
|
+
: result.notSent ? { kind: 'not_sent' }
|
|
490
|
+
: !result.retryable && result.code && FINAL_LINK_CODES.has(result.code) ? { kind: 'failed', code: result.code }
|
|
491
|
+
: result.retryable ? { kind: 'retryable' }
|
|
492
|
+
: { kind: 'failed', code: result.code };
|
|
493
|
+
}
|
|
494
|
+
catch {
|
|
495
|
+
outcome = { kind: 'retryable' };
|
|
496
|
+
}
|
|
497
|
+
finally {
|
|
498
|
+
timeout.cancel();
|
|
499
|
+
}
|
|
500
|
+
const attempts = (hold.link_attempts || 0) + 1;
|
|
501
|
+
const state = outcome.kind === 'sent' || outcome.kind === 'already_sent' ? 'sent'
|
|
502
|
+
: outcome.kind === 'not_sent' ? null
|
|
503
|
+
: outcome.kind === 'failed' || attempts >= MAX_LINK_ATTEMPTS ? 'failed'
|
|
504
|
+
: 'unsent';
|
|
505
|
+
try {
|
|
506
|
+
(0, host_approval_state_1.updateHold)(scopeOf(ctx), hold.id, (current) => ({ ...current, owner_link: state, link_attempts: attempts }), ctx.home);
|
|
507
|
+
}
|
|
508
|
+
catch { /* the link state is a convenience; the hold stands */ }
|
|
509
|
+
if (outcome.kind === 'retryable' && state === 'failed')
|
|
510
|
+
return { kind: 'failed', code: null };
|
|
511
|
+
return outcome;
|
|
512
|
+
}
|
|
513
|
+
/** The categories an owner can lock (risk-categories.ts on the service). */
|
|
514
|
+
const OWNER_LOCKABLE_CATEGORIES = ['production_deploy', 'package_publish', 'secrets_security', 'data_migration', 'billing_access', 'destructive_action', 'source_control'];
|
|
515
|
+
/**
|
|
516
|
+
* Once per interactive host session (its first prompt): tells the person how
|
|
517
|
+
* many held actions are waiting for them, with the action type and agent only,
|
|
518
|
+
* and refreshes this machine's copy of the owner-locked categories (so they
|
|
519
|
+
* stay held during an outage on a fresh machine). Returns user-only text, or null.
|
|
520
|
+
*/
|
|
521
|
+
async function heldActionsNotice(ctx, budgetMs = 1_500) {
|
|
522
|
+
const scope = scopeOf(ctx);
|
|
523
|
+
const marker = `held-actions-surfaced:${ctx.hostSessionId}`;
|
|
524
|
+
try {
|
|
525
|
+
if ((0, host_approval_state_1.sessionMarker)('prompt_hook', scope, marker, ctx.home) === true)
|
|
526
|
+
return null;
|
|
527
|
+
(0, host_approval_state_1.setSessionMarker)('prompt_hook', scope, marker, true, ctx.home);
|
|
528
|
+
}
|
|
529
|
+
catch {
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
532
|
+
const timeout = statusTimeout(budgetMs);
|
|
533
|
+
let items = null;
|
|
534
|
+
let total = null;
|
|
535
|
+
try {
|
|
536
|
+
const [held, settings] = await Promise.all([
|
|
537
|
+
(0, index_1.marrowHeldActions)(ctx.apiKey, ctx.baseUrl, { scope: 'agent', limit: 20 }, ctx.sessionId, ctx.agentId, timeout.signal).catch(() => undefined),
|
|
538
|
+
(0, index_1.marrowAgentApprovalSettings)(ctx.apiKey, ctx.baseUrl, ctx.sessionId, ctx.agentId, timeout.signal).catch(() => undefined),
|
|
539
|
+
]);
|
|
540
|
+
if (settings) {
|
|
541
|
+
(0, host_approval_state_1.noteProtectedCategories)(scope, settings.verified_approval_categories, OWNER_LOCKABLE_CATEGORIES.filter((category) => !settings.verified_approval_categories.includes(category)), ctx.home);
|
|
542
|
+
}
|
|
543
|
+
if (held) {
|
|
544
|
+
items = held.holds.map((hold) => ({ type: hold.decision_type || 'action', agent: hold.agent_id }));
|
|
545
|
+
total = { n: held.count, capped: held.countCapped };
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
finally {
|
|
549
|
+
timeout.cancel();
|
|
550
|
+
}
|
|
551
|
+
if (!items) {
|
|
552
|
+
// A service without the read: this machine's own waiting holds.
|
|
553
|
+
try {
|
|
554
|
+
const now = Date.now();
|
|
555
|
+
items = (0, host_approval_state_1.findHolds)(scope, { mode: 'wait', states: ['open'] }, ctx.home)
|
|
556
|
+
.filter((hold) => !hold.expires_at || Date.parse(hold.expires_at) > now)
|
|
557
|
+
.map((hold) => ({ type: hold.action.type || 'action', agent: hold.agent_id }));
|
|
558
|
+
}
|
|
559
|
+
catch {
|
|
560
|
+
items = [];
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
if (!items.length)
|
|
564
|
+
return null;
|
|
565
|
+
const groups = new Map();
|
|
566
|
+
for (const item of items) {
|
|
567
|
+
const agent = personAgentName(item.agent);
|
|
568
|
+
const label = `${bounded(item.type, 40)}${agent ? ` by agent ${bounded(agent, 64)}` : ''}`;
|
|
569
|
+
groups.set(label, (groups.get(label) || 0) + 1);
|
|
570
|
+
}
|
|
571
|
+
const list = [...groups.entries()].slice(0, 5).map(([label, count]) => (count > 1 ? `${label} (${count})` : label)).join('; ');
|
|
572
|
+
const n = total?.n ?? items.length;
|
|
573
|
+
const plus = total?.capped ? '+' : '';
|
|
574
|
+
return bounded(`Marrow: ${n}${plus} held action${n === 1 && !plus ? ' is' : 's are'} waiting for you: ${list}. Nothing ran. To approve one, retry it here and answer Marrow's prompt.`, 500);
|
|
575
|
+
}
|
|
576
|
+
/** PermissionRequest (pass-through): the host is about to show its own dialog for an asked call. */
|
|
577
|
+
function noteDialogShown(ctx, correlation) {
|
|
578
|
+
try {
|
|
579
|
+
// This machine runs the marker hook: a post-tool hook may wait for a late marker.
|
|
580
|
+
(0, host_approval_state_1.setSessionMarker)('prompt_hook', scopeOf(ctx), PERMISSION_HOOK_SEEN, true, ctx.home);
|
|
581
|
+
}
|
|
582
|
+
catch { /* a convenience; the marker below is what counts */ }
|
|
583
|
+
return (0, host_approval_state_1.markDialogShown)(scopeOf(ctx), { correlation, sessionId: ctx.sessionId }, new Date().toISOString(), ctx.home);
|
|
584
|
+
}
|
|
585
|
+
const PERMISSION_HOOK_SEEN = 'claude-permission-request-hook-seen';
|
|
586
|
+
const NORMALIZED_ACTION_REFUSED = 'host-route-refuses-normalized-action';
|
|
587
|
+
/**
|
|
588
|
+
* The exact action the operator answered for, carried with the report (it is
|
|
589
|
+
* kept with the queued report so a resend is byte-identical, secrets removed),
|
|
590
|
+
* unless this service refused the field recently.
|
|
591
|
+
*/
|
|
592
|
+
function reportAction(ctx, normalizedAction) {
|
|
593
|
+
if (!normalizedAction)
|
|
594
|
+
return {};
|
|
595
|
+
try {
|
|
596
|
+
if ((0, host_approval_state_1.sessionMarker)('prompt_hook', scopeOf(ctx), NORMALIZED_ACTION_REFUSED, ctx.home) === true)
|
|
597
|
+
return {};
|
|
598
|
+
}
|
|
599
|
+
catch {
|
|
600
|
+
return {};
|
|
601
|
+
}
|
|
602
|
+
return { normalized_action: normalizedAction };
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Whether Claude Code runs Marrow's pass-through PermissionRequest hook here:
|
|
606
|
+
* seen on this machine for this key, or configured in the user's or the
|
|
607
|
+
* project's Claude Code settings. Without it there is no marker to wait for.
|
|
608
|
+
*/
|
|
609
|
+
function permissionMarkerHookPresent(ctx, cwd = process.env.CLAUDE_PROJECT_DIR || process.cwd()) {
|
|
610
|
+
try {
|
|
611
|
+
if ((0, host_approval_state_1.sessionMarker)('prompt_hook', scopeOf(ctx), PERMISSION_HOOK_SEEN, ctx.home) === true)
|
|
612
|
+
return true;
|
|
613
|
+
}
|
|
614
|
+
catch { /* fall through to the settings */ }
|
|
615
|
+
const home = ctx.home || process.env.HOME || (0, node_os_1.homedir)();
|
|
616
|
+
for (const path of [(0, node_path_1.join)(home, '.claude', 'settings.json'), (0, node_path_1.join)(cwd, '.claude', 'settings.json')]) {
|
|
617
|
+
try {
|
|
618
|
+
const stat = (0, node_fs_1.lstatSync)(path);
|
|
619
|
+
if (!stat.isFile() || stat.size > 1_048_576)
|
|
620
|
+
continue;
|
|
621
|
+
const hooks = JSON.parse((0, node_fs_1.readFileSync)(path, 'utf8'))?.hooks?.PermissionRequest;
|
|
622
|
+
if (Array.isArray(hooks) && JSON.stringify(hooks).includes('claude-permission-request-hook'))
|
|
623
|
+
return true;
|
|
624
|
+
}
|
|
625
|
+
catch { /* unreadable or absent: no evidence */ }
|
|
626
|
+
}
|
|
627
|
+
return false;
|
|
628
|
+
}
|
|
629
|
+
function statusTimeout(ms = exports.HOST_APPROVAL_REQUEST_TIMEOUT_MS) {
|
|
630
|
+
const controller = new AbortController();
|
|
631
|
+
const timer = setTimeout(() => controller.abort(), ms);
|
|
632
|
+
timer.unref?.();
|
|
633
|
+
return { signal: controller.signal, cancel: () => clearTimeout(timer) };
|
|
634
|
+
}
|
|
635
|
+
async function readStatus(ctx, hold) {
|
|
636
|
+
const budget = stepTimeoutMs(ctx, STATUS_READ_TIMEOUT_MS);
|
|
637
|
+
if (budget === null)
|
|
638
|
+
return { status: null, notFound: false, failed: true };
|
|
639
|
+
const timeout = statusTimeout(budget);
|
|
640
|
+
try {
|
|
641
|
+
const result = await (0, index_1.marrowOwnerApprovalStatus)(ctx.apiKey, ctx.baseUrl, hold.gate_receipt_id, hold.session_id, hold.agent_id || undefined, timeout.signal);
|
|
642
|
+
return result.kind === 'not_found' ? { status: null, notFound: true, failed: false } : { status: result.status, notFound: false, failed: false };
|
|
643
|
+
}
|
|
644
|
+
catch {
|
|
645
|
+
return { status: null, notFound: false, failed: true };
|
|
646
|
+
}
|
|
647
|
+
finally {
|
|
648
|
+
timeout.cancel();
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* Who approved, as the status says it. "The account owner" only for the
|
|
653
|
+
* owner's verified approval; anything else is named for what it is.
|
|
654
|
+
*/
|
|
655
|
+
function approvalSentence(status) {
|
|
656
|
+
if (status.approval_source === 'host_prompt') {
|
|
657
|
+
const who = status.approval_answered_by === 'host_operator' ? 'The operator approved'
|
|
658
|
+
: status.approval_answered_by === 'owner_chat_preapproval' ? 'The account owner\'s chat pre-approval approved'
|
|
659
|
+
: 'An allow rule in the host approved';
|
|
660
|
+
return `${who} this held action (client-attested)`;
|
|
661
|
+
}
|
|
662
|
+
if (status.approval_trust === 'verified'
|
|
663
|
+
&& (status.approval_answered_by === 'account_owner' || status.approval_source === 'dashboard' || status.approval_source === 'one_tap')) {
|
|
664
|
+
return 'The account owner approved this held action';
|
|
665
|
+
}
|
|
666
|
+
return 'Marrow recorded an approval for this held action';
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* The same action, retried after a hold that waited (denied while it waited
|
|
670
|
+
* for an approval). Reads the hold's status first: approved allows it once on
|
|
671
|
+
* the same gate receipt (compare-and-set: a second identical call is denied);
|
|
672
|
+
* pending denies again without a new hold; a status Marrow cannot give keeps
|
|
673
|
+
* it held; declined, expired or used fall back to the normal flow (null).
|
|
674
|
+
*/
|
|
675
|
+
async function resumeWaitingHold(ctx, input) {
|
|
676
|
+
const scope = scopeOf(ctx);
|
|
677
|
+
let holds;
|
|
678
|
+
try {
|
|
679
|
+
holds = (0, host_approval_state_1.findHolds)(scope, { correlation: input.correlation, sessionId: ctx.sessionId, mode: 'wait', states: ['open'] }, ctx.home);
|
|
680
|
+
}
|
|
681
|
+
catch {
|
|
682
|
+
return null;
|
|
683
|
+
}
|
|
684
|
+
const hold = holds[holds.length - 1];
|
|
685
|
+
if (!hold)
|
|
686
|
+
return null;
|
|
687
|
+
const read = await readStatus(ctx, hold);
|
|
688
|
+
if (read.failed || read.status?.state === 'unavailable') {
|
|
689
|
+
// This exact action is waiting for an approval: it stays held until Marrow answers.
|
|
690
|
+
return { kind: 'deny', hold, agentText: exports.HELD_UNREACHABLE_TEXT, userText: exports.HELD_UNREACHABLE_TEXT };
|
|
691
|
+
}
|
|
692
|
+
const status = read.status;
|
|
693
|
+
if (!status || read.notFound) {
|
|
694
|
+
(0, host_approval_state_1.updateHold)(scope, hold.id, () => null, ctx.home);
|
|
695
|
+
return null;
|
|
696
|
+
}
|
|
697
|
+
if (status.state === 'approved') {
|
|
698
|
+
const claimed = (0, host_approval_state_1.claimHold)(scope, hold.id, (current) => ({
|
|
699
|
+
...current,
|
|
700
|
+
state: 'allowed',
|
|
701
|
+
tool_use_id: input.toolUseId,
|
|
702
|
+
generation_id: input.generationId,
|
|
703
|
+
decision_id: current.decision_id || status.decision_id,
|
|
704
|
+
}), ctx.home);
|
|
705
|
+
if (!claimed) {
|
|
706
|
+
const text = bounded(`Marrow approved this held action once (gate receipt ${hold.gate_receipt_id}), and an identical call is already running on that approval, so this repeat did not run. If it is still needed, retry it after that call finishes. Do not report or claim an approval yourself.`, 500);
|
|
707
|
+
return { kind: 'deny', hold, agentText: text, userText: forPerson(text) };
|
|
708
|
+
}
|
|
709
|
+
const decision = claimed.decision_id ? `decision_id ${claimed.decision_id}, ` : '';
|
|
710
|
+
const contextText = claimed.arbitration_receipt_id
|
|
711
|
+
? `Marrow: the account owner approved one proposal for this arbitrated action (gate receipt ${hold.gate_receipt_id}). Run only the approved proposal, then close it with marrow_commit: ${decision}gate_receipt_id ${hold.gate_receipt_id}, arbitration_receipt_id ${claimed.arbitration_receipt_id}${status.owner_approval_receipt_id ? `, owner_approval_receipt_id ${status.owner_approval_receipt_id}` : ''}, the real success and outcome${claimed.proof_required ? `, and proof with ${claimed.proof_fields.join(', ') || 'the required fields'}` : ''}.`
|
|
712
|
+
: `${approvalSentence(status).startsWith('Marrow ') ? '' : 'Marrow: '}${approvalSentence(status)} (gate receipt ${hold.gate_receipt_id}). Run only this exact action; Marrow records its outcome on that receipt.`;
|
|
713
|
+
return { kind: 'allow', hold: claimed, contextText: bounded(contextText, 600) };
|
|
714
|
+
}
|
|
715
|
+
const waitingStates = new Set(['pending', 'arbitration_review']);
|
|
716
|
+
if (waitingStates.has(status.state) && hold.dialog_later && input.dialogAvailable === true && ctx.host === 'claude-code') {
|
|
717
|
+
// The operator switched to a mode with the dialog: ask now, on the same gate receipt.
|
|
718
|
+
const asked = (0, host_approval_state_1.claimHold)(scope, hold.id, (current) => ({
|
|
719
|
+
...current,
|
|
720
|
+
mode: 'ask',
|
|
721
|
+
dialog_later: false,
|
|
722
|
+
tool_use_id: input.toolUseId,
|
|
723
|
+
generation_id: input.generationId,
|
|
724
|
+
asked_at: new Date().toISOString(),
|
|
725
|
+
dialog_at: null,
|
|
726
|
+
}), ctx.home);
|
|
727
|
+
if (!asked)
|
|
728
|
+
return null;
|
|
729
|
+
return {
|
|
730
|
+
kind: 'ask',
|
|
731
|
+
hold: asked,
|
|
732
|
+
promptText: hold.ask_text || 'Marrow holds this action for your approval. Approve only if you authorize this exact action; Marrow records your answer.',
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
if (waitingStates.has(status.state)) {
|
|
736
|
+
// A retry is how the operator asks for the owner's link (sent once per hold;
|
|
737
|
+
// a retryable failure is tried again within the server's per-receipt limit).
|
|
738
|
+
const link = !hold.code && hold.owner_link ? await requestOwnerLink(ctx, hold) : { kind: 'none' };
|
|
739
|
+
const waiting = hold.code ? 'The operator was asked to approve it here.'
|
|
740
|
+
: hold.legacy_service ? `${exports.LEGACY_SERVICE_TEXT} The account owner has not approved it yet.`
|
|
741
|
+
: hold.owner_link ? ownerRequestText(link)
|
|
742
|
+
: hold.dialog_later ? 'Claude Code shows no approval dialog in this session; switch to its default permission mode and retry this exact action.'
|
|
743
|
+
: hold.quiet === 'unattended' ? 'Nobody can approve it in this run, so it waits quietly. Carry on with other work; a person approves it at their next interactive session.'
|
|
744
|
+
: hold.quiet === 'attended' ? 'It stays held until the operator approves it by retrying it in a session with Marrow\'s prompt. Carry on with other work.'
|
|
745
|
+
: ownerRequestText({ kind: 'none' });
|
|
746
|
+
const expires = status.expires_at || hold.expires_at;
|
|
747
|
+
const what = hold.arbitration_receipt_id ? 'for arbitration review' : 'for approval';
|
|
748
|
+
const text = bounded(`Marrow is still holding this action ${what} (gate receipt ${hold.gate_receipt_id}), so it did not run. ${waiting} When it is approved${expires ? ` (before ${expires})` : ''}, retry this exact action. Do not report or claim an approval yourself.`, 500);
|
|
749
|
+
return {
|
|
750
|
+
kind: 'deny',
|
|
751
|
+
hold,
|
|
752
|
+
agentText: text,
|
|
753
|
+
userText: hold.code ? 'Marrow still holds this action for your approval.'
|
|
754
|
+
: hold.quiet === 'attended' ? exports.HELD_FOR_YOU_TEXT
|
|
755
|
+
: forPerson(text),
|
|
756
|
+
...(link.kind === 'deferred' ? { deferredLink: true } : {}),
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
if (status.state === 'declined') {
|
|
760
|
+
const who = status.approval_source === 'host_prompt' ? `the operator declined it in ${exports.HOST_LABEL[ctx.host]}` : 'the account owner declined it';
|
|
761
|
+
// The action did not run: close it as a denial after the hook answers (queued, resent if needed).
|
|
762
|
+
(0, host_approval_state_1.updateHold)(scope, hold.id, (current) => ({
|
|
763
|
+
...current,
|
|
764
|
+
state: 'resolved',
|
|
765
|
+
outbox: { report: null, commit: { success: false, outcome: bounded(`Denied by Marrow pre-action gate: ${who} (gate receipt ${hold.gate_receipt_id}); the action did not run.`, 480) }, attempts: 0, next_at: 0 },
|
|
766
|
+
}), ctx.home);
|
|
767
|
+
return null;
|
|
768
|
+
}
|
|
769
|
+
(0, host_approval_state_1.updateHold)(scope, hold.id, () => null, ctx.home);
|
|
770
|
+
return null;
|
|
771
|
+
}
|
|
772
|
+
function idempotencyFor(hold, kind) {
|
|
773
|
+
return `mcp-host-approval:${(0, node_crypto_1.createHash)('sha256').update(`${kind}\n${hold.gate_receipt_id}`).digest('hex').slice(0, 40)}`;
|
|
774
|
+
}
|
|
775
|
+
async function ensureDecision(ctx, hold, signal) {
|
|
776
|
+
if (hold.decision_id)
|
|
777
|
+
return hold.decision_id;
|
|
778
|
+
try {
|
|
779
|
+
const decision = await (0, index_1.marrowThink)(ctx.apiKey, ctx.baseUrl, {
|
|
780
|
+
action: hold.action.action,
|
|
781
|
+
target: hold.action.target,
|
|
782
|
+
surfaces: hold.action.surfaces,
|
|
783
|
+
type: hold.action.type,
|
|
784
|
+
source_kind: 'integration',
|
|
785
|
+
...(hold.harness !== 'mcp-client' ? { source_meta: { client: hold.harness } } : {}),
|
|
786
|
+
}, hold.session_id, hold.agent_id || undefined, signal);
|
|
787
|
+
const decisionId = typeof decision.decision_id === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:-]{2,199}$/.test(decision.decision_id) ? decision.decision_id : null;
|
|
788
|
+
if (decisionId)
|
|
789
|
+
(0, host_approval_state_1.updateHold)(scopeOf(ctx), hold.id, (current) => ({ ...current, decision_id: decisionId }), ctx.home);
|
|
790
|
+
return decisionId;
|
|
791
|
+
}
|
|
792
|
+
catch {
|
|
793
|
+
return null;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
async function commitHold(ctx, hold, commit, signal) {
|
|
797
|
+
const decisionId = hold.decision_id || await ensureDecision(ctx, hold, signal);
|
|
798
|
+
if (!decisionId)
|
|
799
|
+
return 'failed';
|
|
800
|
+
try {
|
|
801
|
+
const result = await (0, index_1.marrowCommit)(ctx.apiKey, ctx.baseUrl, {
|
|
802
|
+
decision_id: decisionId,
|
|
803
|
+
success: commit.success,
|
|
804
|
+
outcome: commit.outcome,
|
|
805
|
+
gate_receipt_id: hold.gate_receipt_id,
|
|
806
|
+
auto_gate: false,
|
|
807
|
+
}, hold.session_id, hold.agent_id || undefined, signal, idempotencyFor(hold, commit.success ? 'commit' : 'denial'));
|
|
808
|
+
return result.committed === true ? 'committed' : 'unverified';
|
|
809
|
+
}
|
|
810
|
+
catch {
|
|
811
|
+
return 'failed';
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
const REFUSED_AFTER_RUN = new Set([
|
|
815
|
+
'MARROW_OWNER_APPROVAL_DECLINED',
|
|
816
|
+
'MARROW_OWNER_DECLINE_STANDS',
|
|
817
|
+
// An allow rule or automatic approval after the operator declined this action.
|
|
818
|
+
'MARROW_EARLIER_DECLINE_STANDS',
|
|
819
|
+
'MARROW_VERIFIED_OWNER_APPROVAL_REQUIRED',
|
|
820
|
+
// The answer was reported for a different normalized action than the held one.
|
|
821
|
+
'MARROW_HOST_APPROVAL_ACTION_MISMATCH',
|
|
822
|
+
'MARROW_PRE_ACTION_GATE_EXPIRED',
|
|
823
|
+
'MARROW_PRE_ACTION_GATE_USED',
|
|
824
|
+
'MARROW_ARBITRATION_OWNER_APPROVAL_REQUIRED',
|
|
825
|
+
]);
|
|
826
|
+
function backoffMs(attempts, retryAfterMs) {
|
|
827
|
+
const exponential = Math.min(60_000, 1_000 * 2 ** Math.min(attempts, 6));
|
|
828
|
+
return Math.max(exponential, retryAfterMs ?? 0);
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* Sends a hold's queued report (and the commit that follows it) once. A
|
|
832
|
+
* retryable failure (network, 409 STATE_UNAVAILABLE, 429, 5xx) keeps the
|
|
833
|
+
* identical body queued with backoff: the operator's answer is never dropped
|
|
834
|
+
* while its gate receipt can still accept it.
|
|
835
|
+
*/
|
|
836
|
+
async function deliverHold(ctx, holdId, deadline = Date.now() + settleBudgetMs(ctx.host)) {
|
|
837
|
+
const scope = scopeOf(ctx);
|
|
838
|
+
let hold = (0, host_approval_state_1.findHolds)(scope, { id: holdId }, ctx.home)[0];
|
|
839
|
+
if (!hold?.outbox)
|
|
840
|
+
return null;
|
|
841
|
+
const outbox = hold.outbox;
|
|
842
|
+
const remaining = () => Math.max(250, deadline - Date.now());
|
|
843
|
+
const commitAfter = async (current) => {
|
|
844
|
+
if (!outbox.commit)
|
|
845
|
+
return 'skipped';
|
|
846
|
+
const timeout = statusTimeout(remaining());
|
|
847
|
+
try {
|
|
848
|
+
return await commitHold(ctx, current, outbox.commit, timeout.signal);
|
|
849
|
+
}
|
|
850
|
+
finally {
|
|
851
|
+
timeout.cancel();
|
|
852
|
+
}
|
|
853
|
+
};
|
|
854
|
+
// A typed approval of a waiting hold keeps it waiting: the retried action reads the status and runs once.
|
|
855
|
+
const settledState = hold.mode === 'wait' && outbox.report?.verdict === 'approved' && !outbox.commit ? 'open' : 'resolved';
|
|
856
|
+
const finish = (state = settledState) => (0, host_approval_state_1.updateHold)(scope, hold.id, (current) => ({ ...current, outbox: null, state }), ctx.home);
|
|
857
|
+
if (!outbox.report) {
|
|
858
|
+
const committed = await commitAfter(hold);
|
|
859
|
+
if (committed === 'failed') {
|
|
860
|
+
(0, host_approval_state_1.updateHold)(scope, hold.id, (current) => ({ ...current, outbox: current.outbox && { ...current.outbox, attempts: current.outbox.attempts + 1, next_at: Date.now() + backoffMs(current.outbox.attempts, null) } }), ctx.home);
|
|
861
|
+
return { kind: 'queued' };
|
|
862
|
+
}
|
|
863
|
+
finish();
|
|
864
|
+
return { kind: 'already_approved', committed };
|
|
865
|
+
}
|
|
866
|
+
let report = outbox.report;
|
|
867
|
+
if (report.verdict === 'approved' && !report.decision_id) {
|
|
868
|
+
const timeout = statusTimeout(remaining());
|
|
869
|
+
const decisionId = await ensureDecision(ctx, hold, timeout.signal).finally(timeout.cancel);
|
|
870
|
+
if (!decisionId) {
|
|
871
|
+
(0, host_approval_state_1.updateHold)(scope, hold.id, (current) => ({ ...current, outbox: current.outbox && { ...current.outbox, attempts: current.outbox.attempts + 1, next_at: Date.now() + backoffMs(current.outbox.attempts, null) } }), ctx.home);
|
|
872
|
+
return { kind: 'queued' };
|
|
873
|
+
}
|
|
874
|
+
report = { ...report, decision_id: decisionId };
|
|
875
|
+
hold = (0, host_approval_state_1.updateHold)(scope, hold.id, (current) => ({ ...current, decision_id: decisionId, outbox: current.outbox && { ...current.outbox, report } }), ctx.home) || hold;
|
|
876
|
+
}
|
|
877
|
+
const timeout = statusTimeout(Math.min(exports.HOST_APPROVAL_REQUEST_TIMEOUT_MS, remaining()));
|
|
878
|
+
let result;
|
|
879
|
+
try {
|
|
880
|
+
result = await (0, index_1.marrowHostApproval)(ctx.apiKey, ctx.baseUrl, hold.gate_receipt_id, report, hold.session_id, hold.agent_id || undefined, timeout.signal);
|
|
881
|
+
// A service that does not take normalized_action yet rejected the report
|
|
882
|
+
// before recording it: store it without the field (remembered for a day) and send that.
|
|
883
|
+
if (report.normalized_action && result && !result.ok && result.status === 400 && result.fields?.includes('normalized_action')) {
|
|
884
|
+
try {
|
|
885
|
+
(0, host_approval_state_1.setSessionMarker)('prompt_hook', scope, NORMALIZED_ACTION_REFUSED, true, ctx.home);
|
|
886
|
+
}
|
|
887
|
+
catch { /* resent below either way */ }
|
|
888
|
+
const { normalized_action: _dropped, ...plain } = report;
|
|
889
|
+
report = plain;
|
|
890
|
+
hold = (0, host_approval_state_1.updateHold)(scope, hold.id, (current) => ({ ...current, outbox: current.outbox && { ...current.outbox, report: plain } }), ctx.home) || hold;
|
|
891
|
+
result = await (0, index_1.marrowHostApproval)(ctx.apiKey, ctx.baseUrl, hold.gate_receipt_id, report, hold.session_id, hold.agent_id || undefined, timeout.signal);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
catch {
|
|
895
|
+
result = null;
|
|
896
|
+
}
|
|
897
|
+
finally {
|
|
898
|
+
timeout.cancel();
|
|
899
|
+
}
|
|
900
|
+
if (!result || (!result.ok && (result.retryable || result.code === 'MARROW_OWNER_APPROVAL_STATE_UNAVAILABLE'))) {
|
|
901
|
+
const retryAfter = result && !result.ok ? result.retryAfterMs : null;
|
|
902
|
+
(0, host_approval_state_1.updateHold)(scope, hold.id, (current) => ({
|
|
903
|
+
...current,
|
|
904
|
+
outbox: current.outbox && { ...current.outbox, attempts: current.outbox.attempts + 1, next_at: Date.now() + backoffMs(current.outbox.attempts, retryAfter) },
|
|
905
|
+
}), ctx.home);
|
|
906
|
+
return { kind: 'queued' };
|
|
907
|
+
}
|
|
908
|
+
if (result.ok) {
|
|
909
|
+
const recorded = (0, host_approval_state_1.updateHold)(scope, hold.id, (current) => ({ ...current, decision_id: current.decision_id || result.receipt.decision_id, outbox: current.outbox && { ...current.outbox, report: null } }), ctx.home) || hold;
|
|
910
|
+
const committed = await commitAfter(recorded);
|
|
911
|
+
if (committed === 'failed') {
|
|
912
|
+
(0, host_approval_state_1.updateHold)(scope, hold.id, (current) => ({ ...current, outbox: current.outbox && { ...current.outbox, attempts: current.outbox.attempts + 1, next_at: Date.now() + backoffMs(current.outbox.attempts, null) } }), ctx.home);
|
|
913
|
+
}
|
|
914
|
+
else {
|
|
915
|
+
finish();
|
|
916
|
+
}
|
|
917
|
+
return { kind: 'recorded', answeredBy: result.receipt.answered_by, verdict: result.receipt.verdict, committed };
|
|
918
|
+
}
|
|
919
|
+
if (result.code === 'MARROW_OWNER_APPROVAL_ALREADY_DECIDED' && result.existingVerdict === report.verdict) {
|
|
920
|
+
const committed = await commitAfter(hold);
|
|
921
|
+
finish();
|
|
922
|
+
return { kind: 'already_approved', committed };
|
|
923
|
+
}
|
|
924
|
+
if (result.code && REFUSED_AFTER_RUN.has(result.code) || result.code === 'MARROW_OWNER_APPROVAL_ALREADY_DECIDED') {
|
|
925
|
+
// Nothing was recorded. When the action already ran, its real outcome is
|
|
926
|
+
// still committed; it stays unverified, which is correct.
|
|
927
|
+
const committed = report.verdict === 'approved' && outbox.commit ? await commitAfter(hold) : 'skipped';
|
|
928
|
+
finish();
|
|
929
|
+
return { kind: 'refused', code: result.code, committed };
|
|
930
|
+
}
|
|
931
|
+
// 400 invalid, 403/409 scope mismatch, not held, decision closed: a hook bug or a
|
|
932
|
+
// receipt that is not waiting. Never retried.
|
|
933
|
+
finish();
|
|
934
|
+
return { kind: 'dropped', code: result.code };
|
|
935
|
+
}
|
|
936
|
+
/** Delivers due queued reports for this key (bounded); called at the start of later hooks. */
|
|
937
|
+
async function flushHoldOutbox(ctx, limit = 2, budgetMs = 4_000) {
|
|
938
|
+
let due;
|
|
939
|
+
try {
|
|
940
|
+
due = (0, host_approval_state_1.findHolds)(scopeOf(ctx), {}, ctx.home).filter((hold) => hold.outbox && hold.outbox.next_at <= Date.now()).slice(0, limit);
|
|
941
|
+
}
|
|
942
|
+
catch {
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
const deadline = Date.now() + budgetMs;
|
|
946
|
+
for (const hold of due) {
|
|
947
|
+
if (Date.now() >= deadline)
|
|
948
|
+
break;
|
|
949
|
+
await deliverHold(ctx, hold.id, deadline).catch(() => null);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
/**
|
|
953
|
+
* marrow_commit from the MCP server: the hooks bound this gate receipt to the
|
|
954
|
+
* host session (for example Claude Code's session_id), which the MCP server
|
|
955
|
+
* process does not know. The receipt stays the authority; the session only has
|
|
956
|
+
* to match it.
|
|
957
|
+
*/
|
|
958
|
+
function holdSessionForReceipt(ctx, gateReceiptId) {
|
|
959
|
+
try {
|
|
960
|
+
const holds = (0, host_approval_state_1.findHolds)(scopeOf(ctx), {}, ctx.home).filter((hold) => hold.gate_receipt_id === gateReceiptId);
|
|
961
|
+
return holds.length ? holds[holds.length - 1].session_id : null;
|
|
962
|
+
}
|
|
963
|
+
catch {
|
|
964
|
+
return null;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
/** The proof fields this machine's hold for a receipt asked for (empty when unknown). */
|
|
968
|
+
function proofFieldsForReceipt(ctx, gateReceiptId) {
|
|
969
|
+
try {
|
|
970
|
+
const holds = (0, host_approval_state_1.findHolds)(scopeOf(ctx), {}, ctx.home).filter((hold) => hold.gate_receipt_id === gateReceiptId);
|
|
971
|
+
return holds.length && holds[holds.length - 1].proof_required ? [...holds[holds.length - 1].proof_fields] : [];
|
|
972
|
+
}
|
|
973
|
+
catch {
|
|
974
|
+
return [];
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
/** marrow_commit: send a queued host approval for this receipt before the agent's own commit. */
|
|
978
|
+
async function deliverQueuedForReceipt(ctx, gateReceiptId) {
|
|
979
|
+
let holds;
|
|
980
|
+
try {
|
|
981
|
+
holds = (0, host_approval_state_1.findHolds)(scopeOf(ctx), {}, ctx.home).filter((hold) => hold.gate_receipt_id === gateReceiptId && hold.outbox?.report);
|
|
982
|
+
}
|
|
983
|
+
catch {
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
for (const hold of holds)
|
|
987
|
+
await deliverHold(ctx, hold.id, Date.now() + 5_000).catch(() => null);
|
|
988
|
+
}
|
|
989
|
+
function outcomeText(hold, success, host) {
|
|
990
|
+
return bounded(`${hold.action.action} ${success ? 'completed' : 'failed'} in ${exports.HOST_LABEL[host]} after it was approved (gate receipt ${hold.gate_receipt_id}).`, 480);
|
|
991
|
+
}
|
|
992
|
+
function handoffText(hold, delivery) {
|
|
993
|
+
const decision = hold.decision_id ? `decision_id ${hold.decision_id}, ` : '';
|
|
994
|
+
const proof = hold.proof_fields.length ? hold.proof_fields.join(', ') : 'the required fields';
|
|
995
|
+
if (!delivery)
|
|
996
|
+
return null;
|
|
997
|
+
if (delivery.kind === 'queued') {
|
|
998
|
+
return bounded(`Marrow is recording the approval of this held action (gate receipt ${hold.gate_receipt_id}); the report is queued and retried automatically. Close it with marrow_commit as usual: ${decision}gate_receipt_id ${hold.gate_receipt_id}, the real success and outcome${hold.proof_required ? `, and proof with ${proof}` : ''}. Marrow sends the queued approval first.`, 600);
|
|
999
|
+
}
|
|
1000
|
+
if (delivery.kind === 'refused' && delivery.code === 'MARROW_HOST_APPROVAL_ACTION_MISMATCH') {
|
|
1001
|
+
return bounded(`Marrow could not record an approval for this held action: the action that ran is not the one Marrow held (${delivery.code}), so its outcome stays unverified. Do not retry it to get approval; a new attempt is held and asked again.`, 400);
|
|
1002
|
+
}
|
|
1003
|
+
if (delivery.kind === 'refused') {
|
|
1004
|
+
return bounded(`Marrow could not record an approval for this held action (${delivery.code || 'refused'}), so its outcome stays unverified. Do not retry it to get approval; the account owner sees it with its receipts in Marrow.`, 400);
|
|
1005
|
+
}
|
|
1006
|
+
if (delivery.kind === 'dropped')
|
|
1007
|
+
return null;
|
|
1008
|
+
const approved = delivery.kind === 'already_approved' || (delivery.kind === 'recorded' && delivery.verdict === 'approved');
|
|
1009
|
+
if (!approved)
|
|
1010
|
+
return null;
|
|
1011
|
+
// A waited hold was approved on the server (by the owner, or a typed reply):
|
|
1012
|
+
// only an answer from this host's own prompt is the hook's client-attested record.
|
|
1013
|
+
const recorded = hold.not_observed
|
|
1014
|
+
? 'Marrow recorded this held action as approved through the host\'s own prompt, which Marrow did not observe (client-attested, not an operator answer).'
|
|
1015
|
+
: hold.mode === 'ask'
|
|
1016
|
+
? 'Marrow recorded the approval of this held action (client-attested).'
|
|
1017
|
+
: `This held action was approved (gate receipt ${hold.gate_receipt_id}).`;
|
|
1018
|
+
if (delivery.committed === 'committed') {
|
|
1019
|
+
if (hold.not_observed) {
|
|
1020
|
+
return bounded(`Marrow recorded this held action as approved through the host's own prompt, which Marrow did not observe (client-attested), and closed it on gate receipt ${hold.gate_receipt_id}.`, 300);
|
|
1021
|
+
}
|
|
1022
|
+
return bounded(`${hold.mode === 'ask' ? 'Marrow recorded the approval (client-attested) and closed' : 'Marrow closed'} this held action on gate receipt ${hold.gate_receipt_id}.`, 300);
|
|
1023
|
+
}
|
|
1024
|
+
const arbitration = hold.arbitration_receipt_id ? `, arbitration_receipt_id ${hold.arbitration_receipt_id} and the owner_approval_receipt_id Marrow gave when it allowed the action` : '';
|
|
1025
|
+
if (hold.proof_required) {
|
|
1026
|
+
return bounded(`${recorded} Close it with marrow_commit: ${decision}gate_receipt_id ${hold.gate_receipt_id}${arbitration}, the real success and outcome, and proof with ${proof}. Do not call marrow_agent_runtime or marrow_think again for it.`, 600);
|
|
1027
|
+
}
|
|
1028
|
+
return bounded(`${recorded} Close it with marrow_commit: ${decision}gate_receipt_id ${hold.gate_receipt_id}${arbitration}, and the real success and outcome.`, 500);
|
|
1029
|
+
}
|
|
1030
|
+
/** How long a post-tool hook waits for Claude Code's async PermissionRequest marker. */
|
|
1031
|
+
exports.LATE_MARKER_WAIT_MS = 1_500;
|
|
1032
|
+
/**
|
|
1033
|
+
* After the tool ran (PostToolUse/PostToolUseFailure, Cursor after*Execution):
|
|
1034
|
+
* the operator allowed an asked call, or a waited hold was approved and retried.
|
|
1035
|
+
* Reports the approval (asked calls only), then commits the real outcome when
|
|
1036
|
+
* no proof is required and the host reported it; otherwise tells the agent how
|
|
1037
|
+
* to close it.
|
|
1038
|
+
*/
|
|
1039
|
+
async function settleAfterTool(ctx, input) {
|
|
1040
|
+
const scope = scopeOf(ctx);
|
|
1041
|
+
let holds;
|
|
1042
|
+
try {
|
|
1043
|
+
holds = (0, host_approval_state_1.findHolds)(scope, {
|
|
1044
|
+
correlation: input.correlation,
|
|
1045
|
+
sessionId: ctx.sessionId,
|
|
1046
|
+
states: ['open', 'allowed'],
|
|
1047
|
+
...(input.toolUseId ? { toolUseId: input.toolUseId } : {}),
|
|
1048
|
+
...(input.generationId ? { generationId: input.generationId } : {}),
|
|
1049
|
+
}, ctx.home).filter((hold) => hold.mode === 'ask' ? hold.state === 'open' : hold.state === 'allowed');
|
|
1050
|
+
}
|
|
1051
|
+
catch {
|
|
1052
|
+
return null;
|
|
1053
|
+
}
|
|
1054
|
+
let hold = holds[0];
|
|
1055
|
+
if (!hold || hold.outbox)
|
|
1056
|
+
return null;
|
|
1057
|
+
const answeredAt = new Date().toISOString();
|
|
1058
|
+
let lateMarker = false;
|
|
1059
|
+
if (hold.mode === 'ask' && hold.host === 'claude-code' && !hold.dialog_at && permissionMarkerHookPresent(ctx)) {
|
|
1060
|
+
// The marker hook runs async: after a fast click it can land just after
|
|
1061
|
+
// this hook starts. Wait briefly so a real click is not labelled an allow rule.
|
|
1062
|
+
const waitUntil = Date.now() + (input.markerWaitMs ?? exports.LATE_MARKER_WAIT_MS);
|
|
1063
|
+
while (Date.now() < waitUntil) {
|
|
1064
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
1065
|
+
const latest = (0, host_approval_state_1.findHolds)(scope, { id: hold.id }, ctx.home)[0];
|
|
1066
|
+
if (!latest || latest.state !== 'open' || latest.outbox)
|
|
1067
|
+
break;
|
|
1068
|
+
hold = latest;
|
|
1069
|
+
if (latest.dialog_at) {
|
|
1070
|
+
lateMarker = true;
|
|
1071
|
+
break;
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
// An outcome the host does not report (null) is never committed as a success or a failure.
|
|
1076
|
+
const commit = hold.proof_required || input.success === null ? null : { success: input.success, outcome: outcomeText(hold, input.success, ctx.host) };
|
|
1077
|
+
const report = hold.mode === 'ask'
|
|
1078
|
+
? {
|
|
1079
|
+
verdict: 'approved',
|
|
1080
|
+
host: hold.host,
|
|
1081
|
+
host_session_id: hold.host_session_id,
|
|
1082
|
+
// Claude Code's operator marker is the pass-through PermissionRequest
|
|
1083
|
+
// hook only. Without it the answer is reported as the event that asked
|
|
1084
|
+
// and the server labels it an allow rule.
|
|
1085
|
+
hook_event: hold.not_observed ? exports.HOST_PROMPT_NOT_OBSERVED
|
|
1086
|
+
: hold.host === 'claude-code' ? (hold.dialog_at ? 'PermissionRequest' : 'PreToolUse') : hold.hook_event,
|
|
1087
|
+
pre_action_event_id: hold.pre_action_event_id,
|
|
1088
|
+
// A marker that landed after the call ran was written late; the dialog was
|
|
1089
|
+
// shown before the click, so the time the hook asked is the honest bound.
|
|
1090
|
+
asked_at: lateMarker ? hold.asked_at : (hold.dialog_at || hold.asked_at),
|
|
1091
|
+
answered_at: answeredAt,
|
|
1092
|
+
...(hold.decision_id ? { decision_id: hold.decision_id } : {}),
|
|
1093
|
+
...reportAction(ctx, hold.normalized_action ?? input.normalizedAction),
|
|
1094
|
+
}
|
|
1095
|
+
: null;
|
|
1096
|
+
if (!report && !commit) {
|
|
1097
|
+
(0, host_approval_state_1.updateHold)(scope, hold.id, (current) => ({ ...current, state: 'resolved' }), ctx.home);
|
|
1098
|
+
return handoffText(hold, { kind: 'already_approved', committed: 'skipped' });
|
|
1099
|
+
}
|
|
1100
|
+
(0, host_approval_state_1.updateHold)(scope, hold.id, (current) => ({ ...current, state: 'resolved', outbox: { report, commit, attempts: 0, next_at: 0 } }), ctx.home);
|
|
1101
|
+
const delivery = await deliverHold(ctx, hold.id);
|
|
1102
|
+
const latest = (0, host_approval_state_1.findHolds)(scope, { id: hold.id }, ctx.home)[0] || hold;
|
|
1103
|
+
return handoffText({ ...latest, decision_id: latest.decision_id || hold.decision_id }, delivery);
|
|
1104
|
+
}
|
|
1105
|
+
// ---------------------------------------------------------------------------
|
|
1106
|
+
// Claude Code declines: a permission rejection, never an interruption.
|
|
1107
|
+
// Texts pinned from the Claude Code 2.1.289 bundle (see test/host-approvals-claude-texts).
|
|
1108
|
+
// ---------------------------------------------------------------------------
|
|
1109
|
+
exports.CLAUDE_CODE_USER_REJECTED = "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.";
|
|
1110
|
+
exports.CLAUDE_CODE_USER_REJECTED_WITH_FEEDBACK = "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). To tell you how to proceed, the user said:\n";
|
|
1111
|
+
/** Interruptions, cancellations and unanswered prompts: never a decline. */
|
|
1112
|
+
exports.CLAUDE_CODE_NOT_A_DECISION_PREFIXES = [
|
|
1113
|
+
'[Request interrupted by user',
|
|
1114
|
+
'[Tool call did not complete:',
|
|
1115
|
+
'[Tool call interrupted:',
|
|
1116
|
+
'[Tool call skipped:',
|
|
1117
|
+
'[Tool call not completed:',
|
|
1118
|
+
'[Tool call result not in this copy:',
|
|
1119
|
+
"The user doesn't want to take this action right now.",
|
|
1120
|
+
];
|
|
1121
|
+
/** Transcript toolDenialKind values that mean the call never got an operator answer. */
|
|
1122
|
+
const NOT_A_DECISION_KINDS = new Set(['cancelled', 'interrupted']);
|
|
1123
|
+
function toolResultText(value) {
|
|
1124
|
+
if (typeof value === 'string')
|
|
1125
|
+
return value;
|
|
1126
|
+
if (Array.isArray(value)) {
|
|
1127
|
+
const text = value.find((block) => block && typeof block === 'object' && block.type === 'text'
|
|
1128
|
+
&& typeof block.text === 'string');
|
|
1129
|
+
return text ? String(text.text) : null;
|
|
1130
|
+
}
|
|
1131
|
+
return null;
|
|
1132
|
+
}
|
|
1133
|
+
/**
|
|
1134
|
+
* A structured transcript denial kind wins. Free text is used only for
|
|
1135
|
+
* built-in tools: an MCP tool authors its own result text and could imitate a
|
|
1136
|
+
* rejection, so for MCP tools only the structured kind counts.
|
|
1137
|
+
*/
|
|
1138
|
+
function classifyClaudeToolResult(input) {
|
|
1139
|
+
if (input.denialKind === 'user-rejected')
|
|
1140
|
+
return 'declined';
|
|
1141
|
+
if ((input.denialKind && NOT_A_DECISION_KINDS.has(input.denialKind)) || input.unanswered)
|
|
1142
|
+
return 'interrupted';
|
|
1143
|
+
if (input.denialKind)
|
|
1144
|
+
return 'unknown';
|
|
1145
|
+
if ((0, hook_tool_policy_1.isMcpHookTool)(input.toolName) || input.text === null)
|
|
1146
|
+
return 'unknown';
|
|
1147
|
+
const text = input.text.replace(/\s+$/, '');
|
|
1148
|
+
if (text === exports.CLAUDE_CODE_USER_REJECTED || input.text.startsWith(exports.CLAUDE_CODE_USER_REJECTED_WITH_FEEDBACK))
|
|
1149
|
+
return 'declined';
|
|
1150
|
+
if (exports.CLAUDE_CODE_NOT_A_DECISION_PREFIXES.some((prefix) => text.startsWith(prefix)))
|
|
1151
|
+
return 'interrupted';
|
|
1152
|
+
return 'unknown';
|
|
1153
|
+
}
|
|
1154
|
+
const TRANSCRIPT_TAIL_BYTES = 512 * 1024;
|
|
1155
|
+
/** Reads only a bounded tail of the host's own transcript, and only the tool results asked for. */
|
|
1156
|
+
function transcriptToolResults(path, toolUseIds) {
|
|
1157
|
+
const found = new Map();
|
|
1158
|
+
if (typeof path !== 'string' || path.length > 4096 || !(0, node_path_1.isAbsolute)(path) || !path.endsWith('.jsonl') || toolUseIds.size === 0)
|
|
1159
|
+
return found;
|
|
1160
|
+
let fd = -1;
|
|
1161
|
+
try {
|
|
1162
|
+
const stat = (0, node_fs_1.lstatSync)(path);
|
|
1163
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : null;
|
|
1164
|
+
if (!stat.isFile() || stat.isSymbolicLink() || (uid !== null && stat.uid !== uid))
|
|
1165
|
+
return found;
|
|
1166
|
+
fd = (0, node_fs_1.openSync)(path, node_fs_1.constants.O_RDONLY | (node_fs_1.constants.O_NOFOLLOW || 0));
|
|
1167
|
+
const opened = (0, node_fs_1.fstatSync)(fd);
|
|
1168
|
+
if (opened.ino !== stat.ino)
|
|
1169
|
+
return found;
|
|
1170
|
+
const length = Math.min(opened.size, TRANSCRIPT_TAIL_BYTES);
|
|
1171
|
+
const buffer = Buffer.alloc(length);
|
|
1172
|
+
(0, node_fs_1.readSync)(fd, buffer, 0, length, opened.size - length);
|
|
1173
|
+
const lines = buffer.toString('utf8').split('\n');
|
|
1174
|
+
if (opened.size > length)
|
|
1175
|
+
lines.shift();
|
|
1176
|
+
for (const line of lines) {
|
|
1177
|
+
if (!line.includes('tool_result'))
|
|
1178
|
+
continue;
|
|
1179
|
+
let entry;
|
|
1180
|
+
try {
|
|
1181
|
+
entry = JSON.parse(line);
|
|
1182
|
+
}
|
|
1183
|
+
catch {
|
|
1184
|
+
continue;
|
|
1185
|
+
}
|
|
1186
|
+
const message = entry.message && typeof entry.message === 'object' ? entry.message : null;
|
|
1187
|
+
if (entry.type !== 'user' || !Array.isArray(message?.content))
|
|
1188
|
+
continue;
|
|
1189
|
+
for (const block of message.content) {
|
|
1190
|
+
if (!block || block.type !== 'tool_result' || typeof block.tool_use_id !== 'string' || !toolUseIds.has(block.tool_use_id))
|
|
1191
|
+
continue;
|
|
1192
|
+
found.set(block.tool_use_id, {
|
|
1193
|
+
text: toolResultText(block.content),
|
|
1194
|
+
denialKind: typeof entry.toolDenialKind === 'string' ? entry.toolDenialKind.slice(0, 64) : null,
|
|
1195
|
+
unanswered: typeof entry.toolDenialUnanswered === 'string' ? entry.toolDenialUnanswered.slice(0, 64) : null,
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
catch {
|
|
1201
|
+
return found;
|
|
1202
|
+
}
|
|
1203
|
+
finally {
|
|
1204
|
+
if (fd >= 0)
|
|
1205
|
+
(0, node_fs_1.closeSync)(fd);
|
|
1206
|
+
}
|
|
1207
|
+
return found;
|
|
1208
|
+
}
|
|
1209
|
+
async function settleClaudeResolution(ctx, hold, resolution, hookEvent, normalizedAction) {
|
|
1210
|
+
const scope = scopeOf(ctx);
|
|
1211
|
+
if (resolution === 'unknown')
|
|
1212
|
+
return;
|
|
1213
|
+
if (resolution === 'declined') {
|
|
1214
|
+
const report = {
|
|
1215
|
+
verdict: 'declined',
|
|
1216
|
+
host: hold.host,
|
|
1217
|
+
host_session_id: hold.host_session_id,
|
|
1218
|
+
hook_event: hookEvent,
|
|
1219
|
+
pre_action_event_id: hold.pre_action_event_id,
|
|
1220
|
+
asked_at: hold.dialog_at || hold.asked_at,
|
|
1221
|
+
answered_at: new Date().toISOString(),
|
|
1222
|
+
...(hold.decision_id ? { decision_id: hold.decision_id } : {}),
|
|
1223
|
+
...reportAction(ctx, hold.normalized_action ?? normalizedAction),
|
|
1224
|
+
};
|
|
1225
|
+
const commit = {
|
|
1226
|
+
success: false,
|
|
1227
|
+
outcome: `Denied by Marrow pre-action gate: the operator declined in ${exports.HOST_LABEL[ctx.host]} (gate receipt ${hold.gate_receipt_id}).`,
|
|
1228
|
+
};
|
|
1229
|
+
(0, host_approval_state_1.updateHold)(scope, hold.id, (current) => ({ ...current, state: 'resolved', outbox: { report, commit, attempts: 0, next_at: 0 } }), ctx.home);
|
|
1230
|
+
await deliverHold(ctx, hold.id);
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
// Interrupted, cancelled or left unanswered: not a decline and not an approval.
|
|
1234
|
+
// The observed outcome is committed without an approval and stays unverified.
|
|
1235
|
+
const commit = {
|
|
1236
|
+
success: false,
|
|
1237
|
+
outcome: `Not completed: the call was interrupted, cancelled or left unanswered in ${exports.HOST_LABEL[ctx.host]} before Marrow recorded an answer (gate receipt ${hold.gate_receipt_id}); no approval or decline was given.`,
|
|
1238
|
+
};
|
|
1239
|
+
(0, host_approval_state_1.updateHold)(scope, hold.id, (current) => ({ ...current, state: 'resolved', outbox: { report: null, commit, attempts: 0, next_at: 0 } }), ctx.home);
|
|
1240
|
+
await deliverHold(ctx, hold.id);
|
|
1241
|
+
}
|
|
1242
|
+
/** Claude Code PostToolBatch: decide each still-open asked call of the batch per tool_use_id. */
|
|
1243
|
+
async function settleToolBatch(ctx, input) {
|
|
1244
|
+
if (!Array.isArray(input.toolCalls))
|
|
1245
|
+
return 0;
|
|
1246
|
+
const scope = scopeOf(ctx);
|
|
1247
|
+
let open;
|
|
1248
|
+
try {
|
|
1249
|
+
open = (0, host_approval_state_1.findHolds)(scope, { sessionId: ctx.sessionId, mode: 'ask', states: ['open'] }, ctx.home).filter((hold) => !hold.outbox);
|
|
1250
|
+
}
|
|
1251
|
+
catch {
|
|
1252
|
+
return 0;
|
|
1253
|
+
}
|
|
1254
|
+
if (open.length === 0)
|
|
1255
|
+
return 0;
|
|
1256
|
+
const calls = input.toolCalls.slice(0, 64).filter((call) => Boolean(call) && typeof call === 'object');
|
|
1257
|
+
const ids = new Set(open.map((hold) => hold.tool_use_id).filter((id) => Boolean(id)));
|
|
1258
|
+
const transcript = transcriptToolResults(input.transcriptPath, ids);
|
|
1259
|
+
let settled = 0;
|
|
1260
|
+
for (const call of calls) {
|
|
1261
|
+
const toolUseId = typeof call.tool_use_id === 'string' ? call.tool_use_id : null;
|
|
1262
|
+
const toolName = typeof call.tool_name === 'string' ? call.tool_name : '';
|
|
1263
|
+
const correlation = (0, hook_contract_1.stableToolCorrelation)({ session_id: ctx.sessionId, tool_name: toolName, tool_input: call.tool_input });
|
|
1264
|
+
const hold = open.find((candidate) => candidate.correlation === correlation && (!toolUseId || !candidate.tool_use_id || candidate.tool_use_id === toolUseId));
|
|
1265
|
+
if (!hold)
|
|
1266
|
+
continue;
|
|
1267
|
+
const fromTranscript = toolUseId ? transcript.get(toolUseId) : undefined;
|
|
1268
|
+
const resolution = classifyClaudeToolResult({
|
|
1269
|
+
toolName,
|
|
1270
|
+
text: toolResultText(call.tool_response),
|
|
1271
|
+
denialKind: fromTranscript?.denialKind,
|
|
1272
|
+
unanswered: fromTranscript?.unanswered,
|
|
1273
|
+
});
|
|
1274
|
+
if (resolution === 'unknown')
|
|
1275
|
+
continue;
|
|
1276
|
+
await settleClaudeResolution(ctx, hold, resolution, 'PostToolBatch', (0, normalized_action_1.normalizedHookAction)({ tool_name: toolName, tool_input: call.tool_input }));
|
|
1277
|
+
settled += 1;
|
|
1278
|
+
}
|
|
1279
|
+
return settled;
|
|
1280
|
+
}
|
|
1281
|
+
/** Claude Code UserPromptSubmit fallback: a rejected dialog can end the turn before PostToolBatch. */
|
|
1282
|
+
async function settleAtPrompt(ctx, transcriptPath) {
|
|
1283
|
+
const scope = scopeOf(ctx);
|
|
1284
|
+
let open;
|
|
1285
|
+
try {
|
|
1286
|
+
open = (0, host_approval_state_1.findHolds)(scope, { sessionId: ctx.sessionId, mode: 'ask', states: ['open'] }, ctx.home)
|
|
1287
|
+
.filter((hold) => !hold.outbox && hold.tool_use_id);
|
|
1288
|
+
}
|
|
1289
|
+
catch {
|
|
1290
|
+
return 0;
|
|
1291
|
+
}
|
|
1292
|
+
if (open.length === 0)
|
|
1293
|
+
return 0;
|
|
1294
|
+
const results = transcriptToolResults(transcriptPath, new Set(open.map((hold) => hold.tool_use_id)));
|
|
1295
|
+
let settled = 0;
|
|
1296
|
+
for (const hold of open) {
|
|
1297
|
+
const found = results.get(hold.tool_use_id);
|
|
1298
|
+
if (!found)
|
|
1299
|
+
continue;
|
|
1300
|
+
const resolution = classifyClaudeToolResult({ toolName: hold.tool_name, text: found.text, denialKind: found.denialKind, unanswered: found.unanswered });
|
|
1301
|
+
if (resolution === 'unknown')
|
|
1302
|
+
continue;
|
|
1303
|
+
await settleClaudeResolution(ctx, hold, resolution, 'UserPromptSubmit');
|
|
1304
|
+
settled += 1;
|
|
1305
|
+
}
|
|
1306
|
+
return settled;
|
|
1307
|
+
}
|
|
1308
|
+
// ---------------------------------------------------------------------------
|
|
1309
|
+
// Typed replies: local interactive Codex, Gemini CLI and Cursor sessions only
|
|
1310
|
+
// (never Claude Code, Cursor cloud or background agents, codex exec or gemini -p).
|
|
1311
|
+
// ---------------------------------------------------------------------------
|
|
1312
|
+
const TYPED_REPLY = /^\s*marrow\s+(approve|decline|deny)\s+([A-Za-z0-9]{6})\s*$/i;
|
|
1313
|
+
function parseTypedReply(prompt) {
|
|
1314
|
+
if (typeof prompt !== 'string' || prompt.length > 64)
|
|
1315
|
+
return null;
|
|
1316
|
+
const match = prompt.match(TYPED_REPLY);
|
|
1317
|
+
if (!match)
|
|
1318
|
+
return null;
|
|
1319
|
+
const code = match[2].toUpperCase();
|
|
1320
|
+
if (!host_approval_state_1.APPROVAL_CODE.test(code))
|
|
1321
|
+
return null;
|
|
1322
|
+
return { verdict: match[1].toLowerCase() === 'approve' ? 'approved' : 'declined', code };
|
|
1323
|
+
}
|
|
1324
|
+
function noteCursorSession(ctx, input) {
|
|
1325
|
+
(0, host_approval_state_1.setSessionMarker)('interactive', scopeOf(ctx), ctx.hostSessionId, input.isBackgroundAgent === false, ctx.home);
|
|
1326
|
+
}
|
|
1327
|
+
function cursorSessionEvidence(ctx) {
|
|
1328
|
+
try {
|
|
1329
|
+
return {
|
|
1330
|
+
interactive: (0, host_approval_state_1.sessionMarker)('interactive', scopeOf(ctx), ctx.hostSessionId, ctx.home),
|
|
1331
|
+
promptHook: (0, host_approval_state_1.sessionMarker)('prompt_hook', scopeOf(ctx), ctx.hostSessionId, ctx.home),
|
|
1332
|
+
};
|
|
1333
|
+
}
|
|
1334
|
+
catch {
|
|
1335
|
+
return { interactive: null, promptHook: null };
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
/**
|
|
1339
|
+
* A typed reply counts only from a person: the session must be local and
|
|
1340
|
+
* interactive (Codex and Gemini CLI: the host process has a terminal and no
|
|
1341
|
+
* scripted subcommand or prompt flag), the host must still ask before it runs
|
|
1342
|
+
* a tool (otherwise the agent could run the prompt hook itself with a code it
|
|
1343
|
+
* read from this user's files: Codex with its approval prompt off, Gemini CLI
|
|
1344
|
+
* in YOLO mode, and Cursor, whose auto-run cannot be seen, get no typed reply
|
|
1345
|
+
* and no code), and its prompt hook must already have run, so the reply can
|
|
1346
|
+
* reach Marrow at all.
|
|
1347
|
+
*/
|
|
1348
|
+
function typedReplyAvailable(ctx, interactive = host_session_1.localInteractiveSession, promptOff = host_session_1.typedReplyPromptOff) {
|
|
1349
|
+
if (!exports.TYPED_REPLY_MARKER[ctx.host] || ctx.host === 'cursor')
|
|
1350
|
+
return false;
|
|
1351
|
+
const evidence = cursorSessionEvidence(ctx);
|
|
1352
|
+
if (evidence.promptHook !== true)
|
|
1353
|
+
return false;
|
|
1354
|
+
if (interactive(ctx.host) !== true)
|
|
1355
|
+
return false;
|
|
1356
|
+
return promptOff(ctx.host) === false;
|
|
1357
|
+
}
|
|
1358
|
+
/**
|
|
1359
|
+
* The host's prompt hook (Codex UserPromptSubmit, Gemini BeforeAgent, Cursor
|
|
1360
|
+
* beforeSubmitPrompt): records that the hook runs for this session, and records
|
|
1361
|
+
* "marrow approve CODE" / "marrow decline CODE" typed by the operator.
|
|
1362
|
+
*/
|
|
1363
|
+
async function settleTypedReply(ctx, prompt, interactive = host_session_1.localInteractiveSession) {
|
|
1364
|
+
const scope = scopeOf(ctx);
|
|
1365
|
+
try {
|
|
1366
|
+
(0, host_approval_state_1.setSessionMarker)('prompt_hook', scope, ctx.hostSessionId, true, ctx.home);
|
|
1367
|
+
}
|
|
1368
|
+
catch { /* evidence is best effort */ }
|
|
1369
|
+
const typed = parseTypedReply(prompt);
|
|
1370
|
+
const marker = exports.TYPED_REPLY_MARKER[ctx.host];
|
|
1371
|
+
if (!typed || !marker || !typedReplyAvailable(ctx, interactive))
|
|
1372
|
+
return null;
|
|
1373
|
+
let hold;
|
|
1374
|
+
try {
|
|
1375
|
+
hold = (0, host_approval_state_1.findHolds)(scope, { code: typed.code, hostSessionId: ctx.hostSessionId, mode: 'wait', states: ['open'] }, ctx.home)[0];
|
|
1376
|
+
}
|
|
1377
|
+
catch {
|
|
1378
|
+
return null;
|
|
1379
|
+
}
|
|
1380
|
+
if (!hold || hold.outbox)
|
|
1381
|
+
return null;
|
|
1382
|
+
const report = {
|
|
1383
|
+
verdict: typed.verdict,
|
|
1384
|
+
host: hold.host,
|
|
1385
|
+
host_session_id: hold.host_session_id,
|
|
1386
|
+
hook_event: marker,
|
|
1387
|
+
pre_action_event_id: hold.pre_action_event_id,
|
|
1388
|
+
asked_at: hold.asked_at,
|
|
1389
|
+
answered_at: new Date().toISOString(),
|
|
1390
|
+
...(hold.decision_id ? { decision_id: hold.decision_id } : {}),
|
|
1391
|
+
};
|
|
1392
|
+
const commit = typed.verdict === 'declined'
|
|
1393
|
+
? { success: false, outcome: `Denied by Marrow pre-action gate: the operator declined in ${exports.HOST_LABEL[ctx.host]} (gate receipt ${hold.gate_receipt_id}).` }
|
|
1394
|
+
: null;
|
|
1395
|
+
// An approval keeps the hold waiting: the retried action reads the status and runs once.
|
|
1396
|
+
(0, host_approval_state_1.updateHold)(scope, hold.id, (current) => ({ ...current, state: typed.verdict === 'declined' ? 'resolved' : 'open', outbox: { report, commit, attempts: 0, next_at: 0 } }), ctx.home);
|
|
1397
|
+
const delivery = await deliverHold(ctx, hold.id);
|
|
1398
|
+
const approved = typed.verdict === 'approved';
|
|
1399
|
+
if (!delivery || delivery.kind === 'queued') {
|
|
1400
|
+
return {
|
|
1401
|
+
ok: true, verdict: typed.verdict,
|
|
1402
|
+
userText: 'Marrow is recording your answer; the report is queued and resent automatically.',
|
|
1403
|
+
agentText: approved ? `The operator approved the held action (gate receipt ${hold.gate_receipt_id}); Marrow is recording it. Retry that exact action now.` : null,
|
|
1404
|
+
};
|
|
1405
|
+
}
|
|
1406
|
+
if (delivery.kind === 'recorded' || delivery.kind === 'already_approved') {
|
|
1407
|
+
return approved
|
|
1408
|
+
? { ok: true, verdict: 'approved', userText: 'Marrow recorded your approval (client-attested).', agentText: `The operator approved the held action (gate receipt ${hold.gate_receipt_id}). Retry that exact action now; Marrow lets it run once.` }
|
|
1409
|
+
: { ok: true, verdict: 'declined', userText: 'Marrow recorded your decline. The held action will not run.', agentText: `The operator declined the held action (gate receipt ${hold.gate_receipt_id}). Do not run it.` };
|
|
1410
|
+
}
|
|
1411
|
+
return {
|
|
1412
|
+
ok: false, verdict: typed.verdict,
|
|
1413
|
+
userText: `Marrow could not record your answer (${'code' in delivery && delivery.code ? delivery.code : 'refused'}). ${exports.OWNER_APPROVAL_REQUEST_TEXT}`,
|
|
1414
|
+
agentText: null,
|
|
1415
|
+
};
|
|
1416
|
+
}
|
|
1417
|
+
//# sourceMappingURL=host-approval.js.map
|