@getmarrow/mcp 3.9.102 → 3.9.106
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 +71 -24
- package/dist/approval-reply.d.ts +2 -0
- package/dist/approval-reply.d.ts.map +1 -0
- package/dist/approval-reply.js +18 -0
- package/dist/approval-reply.js.map +1 -0
- package/dist/cli.js +1716 -1644
- package/dist/cli.js.map +1 -1
- package/dist/codex-plugin-pins.d.ts +19 -0
- package/dist/codex-plugin-pins.d.ts.map +1 -0
- package/dist/codex-plugin-pins.js +61 -0
- package/dist/codex-plugin-pins.js.map +1 -0
- package/dist/codex-plugins.d.ts +34 -0
- package/dist/codex-plugins.d.ts.map +1 -0
- package/dist/codex-plugins.js +240 -0
- package/dist/codex-plugins.js.map +1 -0
- package/dist/env.d.ts +8 -0
- package/dist/env.d.ts.map +1 -1
- package/dist/env.js +83 -4
- package/dist/env.js.map +1 -1
- package/dist/hook-context.d.ts.map +1 -1
- package/dist/hook-context.js +25 -0
- package/dist/hook-context.js.map +1 -1
- package/dist/hook-contract.d.ts +4 -3
- package/dist/hook-contract.d.ts.map +1 -1
- package/dist/hook-contract.js +43 -5
- package/dist/hook-contract.js.map +1 -1
- package/dist/hook-pre-action.d.ts +80 -3
- package/dist/hook-pre-action.d.ts.map +1 -1
- package/dist/hook-pre-action.js +435 -29
- package/dist/hook-pre-action.js.map +1 -1
- package/dist/hook-tool-policy.d.ts +73 -4
- package/dist/hook-tool-policy.d.ts.map +1 -1
- package/dist/hook-tool-policy.js +287 -18
- package/dist/hook-tool-policy.js.map +1 -1
- package/dist/hook.d.ts +1 -1
- package/dist/hook.d.ts.map +1 -1
- package/dist/hook.js +15 -7
- package/dist/hook.js.map +1 -1
- package/dist/host-approval-state.d.ts +7 -0
- package/dist/host-approval-state.d.ts.map +1 -1
- package/dist/host-approval-state.js +15 -0
- package/dist/host-approval-state.js.map +1 -1
- package/dist/host-approval.d.ts +22 -1
- package/dist/host-approval.d.ts.map +1 -1
- package/dist/host-approval.js +65 -4
- package/dist/host-approval.js.map +1 -1
- package/dist/host-session.d.ts +11 -0
- package/dist/host-session.d.ts.map +1 -1
- package/dist/host-session.js +49 -0
- package/dist/host-session.js.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +23 -11
- package/dist/index.js.map +1 -1
- package/dist/keyless-server.d.ts +53 -0
- package/dist/keyless-server.d.ts.map +1 -0
- package/dist/keyless-server.js +230 -0
- package/dist/keyless-server.js.map +1 -0
- package/dist/normalized-action.d.ts +31 -6
- package/dist/normalized-action.d.ts.map +1 -1
- package/dist/normalized-action.js +550 -105
- package/dist/normalized-action.js.map +1 -1
- package/dist/own-server-identity.d.ts +142 -0
- package/dist/own-server-identity.d.ts.map +1 -0
- package/dist/own-server-identity.js +1123 -0
- package/dist/own-server-identity.js.map +1 -0
- package/dist/own-tool.d.ts +21 -9
- package/dist/own-tool.d.ts.map +1 -1
- package/dist/own-tool.js +25 -10
- package/dist/own-tool.js.map +1 -1
- package/dist/redact.d.ts +23 -0
- package/dist/redact.d.ts.map +1 -1
- package/dist/redact.js +181 -8
- package/dist/redact.js.map +1 -1
- package/dist/seatbelt/classifier-pin.d.ts +15 -0
- package/dist/seatbelt/classifier-pin.d.ts.map +1 -0
- package/dist/seatbelt/classifier-pin.js +26 -0
- package/dist/seatbelt/classifier-pin.js.map +1 -0
- package/dist/seatbelt/env-dump.d.ts +45 -0
- package/dist/seatbelt/env-dump.d.ts.map +1 -0
- package/dist/seatbelt/env-dump.js +308 -0
- package/dist/seatbelt/env-dump.js.map +1 -0
- package/dist/seatbelt/fallback-copy.d.ts +16 -0
- package/dist/seatbelt/fallback-copy.d.ts.map +1 -0
- package/dist/seatbelt/fallback-copy.js +68 -0
- package/dist/seatbelt/fallback-copy.js.map +1 -0
- package/dist/seatbelt/guards.d.ts +10 -0
- package/dist/seatbelt/guards.d.ts.map +1 -0
- package/dist/seatbelt/guards.js +509 -0
- package/dist/seatbelt/guards.js.map +1 -0
- package/dist/seatbelt/hook.d.ts +91 -0
- package/dist/seatbelt/hook.d.ts.map +1 -0
- package/dist/seatbelt/hook.js +396 -0
- package/dist/seatbelt/hook.js.map +1 -0
- package/dist/seatbelt/longopts.d.ts +68 -0
- package/dist/seatbelt/longopts.d.ts.map +1 -0
- package/dist/seatbelt/longopts.js +377 -0
- package/dist/seatbelt/longopts.js.map +1 -0
- package/dist/seatbelt/mapper.d.ts +83 -0
- package/dist/seatbelt/mapper.d.ts.map +1 -0
- package/dist/seatbelt/mapper.js +10581 -0
- package/dist/seatbelt/mapper.js.map +1 -0
- package/dist/seatbelt/record.d.ts +94 -0
- package/dist/seatbelt/record.d.ts.map +1 -0
- package/dist/seatbelt/record.js +776 -0
- package/dist/seatbelt/record.js.map +1 -0
- package/dist/seatbelt/tables.d.ts +82 -0
- package/dist/seatbelt/tables.d.ts.map +1 -0
- package/dist/seatbelt/tables.js +352 -0
- package/dist/seatbelt/tables.js.map +1 -0
- package/dist/seatbelt-deploy.d.ts +100 -0
- package/dist/seatbelt-deploy.d.ts.map +1 -0
- package/dist/seatbelt-deploy.js +393 -0
- package/dist/seatbelt-deploy.js.map +1 -0
- package/dist/seatbelt-fs.d.ts +30 -0
- package/dist/seatbelt-fs.d.ts.map +1 -0
- package/dist/seatbelt-fs.js +94 -0
- package/dist/seatbelt-fs.js.map +1 -0
- package/dist/seatbelt-git.d.ts +212 -0
- package/dist/seatbelt-git.d.ts.map +1 -0
- package/dist/seatbelt-git.js +844 -0
- package/dist/seatbelt-git.js.map +1 -0
- package/dist/seatbelt-mode.d.ts +77 -0
- package/dist/seatbelt-mode.d.ts.map +1 -0
- package/dist/seatbelt-mode.js +496 -0
- package/dist/seatbelt-mode.js.map +1 -0
- package/dist/seatbelt-path.d.ts +123 -0
- package/dist/seatbelt-path.d.ts.map +1 -0
- package/dist/seatbelt-path.js +482 -0
- package/dist/seatbelt-path.js.map +1 -0
- package/dist/seatbelt-scripts.d.ts +109 -0
- package/dist/seatbelt-scripts.d.ts.map +1 -0
- package/dist/seatbelt-scripts.js +1060 -0
- package/dist/seatbelt-scripts.js.map +1 -0
- package/dist/secret-paths.d.ts +17 -0
- package/dist/secret-paths.d.ts.map +1 -0
- package/dist/secret-paths.js +32 -0
- package/dist/secret-paths.js.map +1 -0
- package/dist/session-loop-guard.d.ts.map +1 -1
- package/dist/session-loop-guard.js +15 -5
- package/dist/session-loop-guard.js.map +1 -1
- package/dist/tool-catalog.js +2 -2
- package/dist/tool-catalog.js.map +1 -1
- package/dist/types.d.ts +2 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -2
- package/server.json +3 -3
- package/vendor/classifier/MANIFEST.json +25 -0
- package/vendor/classifier/classifier.js +1356 -0
- package/vendor/classifier/policy-baseline.js +187 -0
- package/vendor/classifier/redact.js +59 -0
- package/vendor/classifier/shell-parse.js +455 -0
package/dist/hook-pre-action.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.DENIED_DECISION_CLOSE_TIMEOUT_MS = exports.GROK_FIXED_DENIAL = exports.HELD_SLOW_TEXT = exports.HOLD_OWNER_DENIAL = exports.PreActionControlTimeoutError = exports.PRE_ACTION_CONTROL_TIMEOUT_MS = exports.MAX_PRE_ACTION_INPUT_BYTES = exports.MARROW_OUTAGE_WARNING = exports.NO_KEY_TEXT = void 0;
|
|
3
|
+
exports.DENIED_DECISION_CLOSE_TIMEOUT_MS = exports.ENV_FILTER_SECRET_NAME = exports.SEATBELT_CURSOR_ASK_AGENT_TEXT = exports.SEATBELT_CURSOR_ASK_USER_TEXT = exports.SEATBELT_HELD_TEXT = exports.GROK_FIXED_DENIAL = exports.HELD_OFFLINE_TEXT = exports.HELD_SLOW_TEXT = exports.HOLD_OWNER_DENIAL = exports.PreActionControlTimeoutError = exports.PRE_ACTION_CONTROL_TIMEOUT_MS = exports.MAX_PRE_ACTION_INPUT_BYTES = exports.MARROW_OUTAGE_WARNING = exports.SETUP_UNCONFIRMED_TEXT = exports.CLAIMED_NO_KEY_TEXT = exports.NO_KEY_TEXT = void 0;
|
|
4
4
|
exports.isMarrowControlTimeout = isMarrowControlTimeout;
|
|
5
5
|
exports.isMarrowControlOutage = isMarrowControlOutage;
|
|
6
6
|
exports.heldActionHookOutput = heldActionHookOutput;
|
|
@@ -16,8 +16,10 @@ exports.advisoryGateNotice = advisoryGateNotice;
|
|
|
16
16
|
exports.runtimeGateVerdict = runtimeGateVerdict;
|
|
17
17
|
exports.gateDecisionMessage = gateDecisionMessage;
|
|
18
18
|
exports.localControlAllowOutput = localControlAllowOutput;
|
|
19
|
+
exports.seatbeltPassOutput = seatbeltPassOutput;
|
|
19
20
|
exports.localLoopGuardDenyOutput = localLoopGuardDenyOutput;
|
|
20
21
|
exports.classifyTool = classifyTool;
|
|
22
|
+
exports.mayPrintEnvironment = mayPrintEnvironment;
|
|
21
23
|
exports.cursorPreActionHookOutput = cursorPreActionHookOutput;
|
|
22
24
|
exports.clinePreActionHookOutput = clinePreActionHookOutput;
|
|
23
25
|
exports.windsurfPreActionDecision = windsurfPreActionDecision;
|
|
@@ -27,8 +29,11 @@ exports.preActionHookOutput = preActionHookOutput;
|
|
|
27
29
|
exports.closeDeniedDecision = closeDeniedDecision;
|
|
28
30
|
exports.localApprovalCategories = localApprovalCategories;
|
|
29
31
|
exports.installPreActionHook = installPreActionHook;
|
|
32
|
+
exports.runSeatbeltStatusCommand = runSeatbeltStatusCommand;
|
|
33
|
+
exports.outageHolds = outageHolds;
|
|
30
34
|
exports.runPreActionHookCommand = runPreActionHookCommand;
|
|
31
35
|
const node_crypto_1 = require("node:crypto");
|
|
36
|
+
const node_os_1 = require("node:os");
|
|
32
37
|
const index_1 = require("./index");
|
|
33
38
|
const request_reliability_1 = require("./request-reliability");
|
|
34
39
|
const lifecycle_spool_1 = require("./lifecycle-spool");
|
|
@@ -36,13 +41,66 @@ const control_state_1 = require("./control-state");
|
|
|
36
41
|
const runtime_contract_1 = require("./runtime-contract");
|
|
37
42
|
const host_approval_1 = require("./host-approval");
|
|
38
43
|
const host_session_1 = require("./host-session");
|
|
44
|
+
const own_server_identity_1 = require("./own-server-identity");
|
|
39
45
|
const normalized_action_1 = require("./normalized-action");
|
|
40
46
|
const session_loop_guard_1 = require("./session-loop-guard");
|
|
41
47
|
const hook_tool_policy_1 = require("./hook-tool-policy");
|
|
42
48
|
const hook_contract_1 = require("./hook-contract");
|
|
43
49
|
Object.defineProperty(exports, "MARROW_OUTAGE_WARNING", { enumerable: true, get: function () { return hook_contract_1.MARROW_OUTAGE_WARNING; } });
|
|
44
50
|
/** A protected action with no Marrow key anywhere this machine keeps one (MARROW_API_KEY, ~/.marrow/env.local or ~/.marrow/env, owner-only). */
|
|
45
|
-
exports.NO_KEY_TEXT = 'Marrow has no API key on this machine.
|
|
51
|
+
exports.NO_KEY_TEXT = 'Marrow has no API key on this machine, so it held this action. To approve actions like this, create a key at getmarrow.ai/signup and add it: getmarrow.ai/docs#start-here (Claude Code plugin: put MARROW_API_KEY in ~/.marrow/env, chmod 600, then /reload-plugins). Agent: do not retry this action.';
|
|
52
|
+
/** Seatbelt mode `claimed_no_key` (spec §1.1): account evidence on the machine but no usable key. Same text as src/seatbelt/hook.ts. */
|
|
53
|
+
exports.CLAIMED_NO_KEY_TEXT = 'Marrow is linked to an account but has no working key; owner: run `npx -y @getmarrow/install@latest doctor`.';
|
|
54
|
+
/**
|
|
55
|
+
* `claimed_no_key` without account evidence (a mode fallback): the same holds, never an account claim.
|
|
56
|
+
* This constant is the generic wording; the hold names its reason (setupUnconfirmedText). Same text as src/seatbelt/hook.ts.
|
|
57
|
+
*/
|
|
58
|
+
exports.SETUP_UNCONFIRMED_TEXT = 'Marrow stopped this action: there is no Marrow key on this machine, and Marrow uses its stricter no-key mode here because it could not confirm this machine\'s setup. Fix (for the person at this machine, not the agent): run npx -y @getmarrow/install@latest doctor, or add a Marrow key (getmarrow.ai/docs#start-here). Agent: do not retry it another way and do not edit Marrow settings; tell the user why it was stopped. Code: setup_unconfirmed.';
|
|
59
|
+
/** The seatbelt path itself could not run (its modules did not load): a stop (spec §2.1.4). */
|
|
60
|
+
const SEATBELT_FAILED_TEXT = 'MARROW SEATBELT stopped this action: a safety check that could not finish. If you (the person at this machine) meant it, run it yourself in your own terminal. Agent: do not retry it another way and do not edit Marrow settings; tell the user it was stopped. If this keeps happening, run npx -y @getmarrow/install@latest doctor. Code: U5.fail_closed/hook_error.';
|
|
61
|
+
/**
|
|
62
|
+
* Seatbelt stop and keyless hold texts end with their code, so they get room for it: the longest real stop
|
|
63
|
+
* (a fallback reason row with the longest label and codes) is about 850 characters. It is our own cap, not a host's:
|
|
64
|
+
* Claude Code, Codex, Cursor and Windsurf document no limit on the text of a denial and their installed hooks pass it on unchanged.
|
|
65
|
+
*/
|
|
66
|
+
const STOP_TEXT_LIMIT = 880;
|
|
67
|
+
/** The installed Gemini CLI guard (@getmarrow/install, src/installer.js) turns a deny reason over 500 characters into its launch failure. */
|
|
68
|
+
const GEMINI_REASON_LIMIT = 500;
|
|
69
|
+
/** The installed Cline PreToolUse hook (@getmarrow/install, src/installer.js clinePreToolUseHookSource) replaces an errorMessage over 500 characters with its generic "did not return a valid decision" text. */
|
|
70
|
+
const CLINE_ERROR_LIMIT = 500;
|
|
71
|
+
/** The most characters of a stop or hold text this host takes without losing the code at its end. */
|
|
72
|
+
function hostTextLimit(harness) {
|
|
73
|
+
return harness === 'gemini' ? GEMINI_REASON_LIMIT : harness === 'cline' ? CLINE_ERROR_LIMIT : STOP_TEXT_LIMIT;
|
|
74
|
+
}
|
|
75
|
+
/** The Cursor deny text of every enforcementError that is not one of the fixed keyless texts (as on 3.9.104). */
|
|
76
|
+
const CURSOR_ENFORCEMENT_LIMIT = 500;
|
|
77
|
+
/** Claude Code plugin only; a host with a tight limit drops it before it would lose the stop's code at the end. */
|
|
78
|
+
const PLUGIN_KEY_HINT = ' (Claude Code plugin: put MARROW_API_KEY in ~/.marrow/env, chmod 600, then /reload-plugins)';
|
|
79
|
+
/** The person's own way out; after the plugin hint it is the next thing a tight limit gives up. The agent's instruction stays. */
|
|
80
|
+
const PERSON_ADVICE = ' If you (the person at this machine) meant it, run it yourself in your own terminal.';
|
|
81
|
+
/**
|
|
82
|
+
* One line of hook text within a host's limit. A text over it first gives up the plugin hint, then the
|
|
83
|
+
* person's advice; if it is still over, the end of the explanation is cut at a sentence and the stop's
|
|
84
|
+
* code (the last "Code: ..." part) is kept. Texts that fit are untouched.
|
|
85
|
+
*/
|
|
86
|
+
function fitHookText(value, limit) {
|
|
87
|
+
let text = value.replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
88
|
+
for (const part of [PLUGIN_KEY_HINT, PERSON_ADVICE]) {
|
|
89
|
+
if (text.length <= limit)
|
|
90
|
+
break;
|
|
91
|
+
text = text.replace(part, '');
|
|
92
|
+
}
|
|
93
|
+
if (text.length <= limit)
|
|
94
|
+
return text;
|
|
95
|
+
const at = text.lastIndexOf(' Code: ');
|
|
96
|
+
if (at > 0 && limit - (text.length - at) > 40) {
|
|
97
|
+
const tail = text.slice(at);
|
|
98
|
+
const head = text.slice(0, limit - tail.length);
|
|
99
|
+
const cut = head.lastIndexOf('. ');
|
|
100
|
+
return `${cut > 0 ? head.slice(0, cut + 1) : head}${tail}`.slice(0, limit);
|
|
101
|
+
}
|
|
102
|
+
return text.slice(0, limit);
|
|
103
|
+
}
|
|
46
104
|
// Claude Code sends the whole tool input, and a Write of a long document grows
|
|
47
105
|
// further once JSON-escaped, so tens of kilobytes of markdown must still parse.
|
|
48
106
|
exports.MAX_PRE_ACTION_INPUT_BYTES = 4 * 1024 * 1024;
|
|
@@ -107,6 +165,8 @@ function isMarrowControlOutage(error) {
|
|
|
107
165
|
exports.HOLD_OWNER_DENIAL = 'Marrow is holding this action for approval. Retry it after approval.';
|
|
108
166
|
/** Marrow did not answer within the hook's time limit: an action that can be held stays held. */
|
|
109
167
|
exports.HELD_SLOW_TEXT = 'Marrow did not answer in time, so this action is held. Retry it in a moment.';
|
|
168
|
+
/** Marrow cannot be reached (a real outage): a high-risk action fails closed; routine actions keep the outage allow. */
|
|
169
|
+
exports.HELD_OFFLINE_TEXT = 'Marrow cannot be reached right now, so this high-risk action is held; routine actions keep running. Retry it when Marrow is back.';
|
|
110
170
|
/** The only denial @getmarrow/install's Grok guard passes through (any other output blocks with a launch failure). */
|
|
111
171
|
exports.GROK_FIXED_DENIAL = 'Marrow blocked this protected action.';
|
|
112
172
|
/**
|
|
@@ -146,6 +206,9 @@ function heldActionHookOutput(harness, plan, code = null) {
|
|
|
146
206
|
return {
|
|
147
207
|
hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: plan.agentText },
|
|
148
208
|
...(harness === 'codex' && code ? { systemMessage: (0, host_approval_1.typedReplyUserText)(plan.userText, code) } : {}),
|
|
209
|
+
// Claude Code, a hold nobody can approve in chat (no dialog here, or nobody in this run): one user-only
|
|
210
|
+
// line says so. Output only: the decision above is the same deny, and the line carries no code or id.
|
|
211
|
+
...(harness === 'claude-code' && plan.userNotice ? { systemMessage: plan.userNotice } : {}),
|
|
149
212
|
};
|
|
150
213
|
}
|
|
151
214
|
/**
|
|
@@ -231,7 +294,7 @@ function plainControlFix(error) {
|
|
|
231
294
|
}
|
|
232
295
|
// Names a reached control failure by HTTP status and stable failure code, with a plain
|
|
233
296
|
// fix (see plainControlFix); private service text is never echoed into the agent transcript.
|
|
234
|
-
function controlRejectionMessage(error, agentId) {
|
|
297
|
+
function controlRejectionMessage(error, agentId, kind = controlFailureKind(error)) {
|
|
235
298
|
const detail = [];
|
|
236
299
|
if (error instanceof request_reliability_1.MarrowRequestError) {
|
|
237
300
|
if (typeof error.status === 'number' && Number.isInteger(error.status))
|
|
@@ -241,7 +304,6 @@ function controlRejectionMessage(error, agentId) {
|
|
|
241
304
|
detail.push(code);
|
|
242
305
|
}
|
|
243
306
|
const suffix = detail.length ? ` (${detail.join(' ')})` : '';
|
|
244
|
-
const kind = controlFailureKind(error);
|
|
245
307
|
if (kind === 'credential_scope') {
|
|
246
308
|
const agent = agentId && SAFE_AGENT_ID.test(agentId)
|
|
247
309
|
? `agent "${agentId}"`
|
|
@@ -410,22 +472,65 @@ function localControlAllowOutput(harness) {
|
|
|
410
472
|
return { decision: 'allow' };
|
|
411
473
|
return {};
|
|
412
474
|
}
|
|
475
|
+
/** The Cursor events where Cursor enforces "ask" with its own approval prompt (the installer's execution hooks). */
|
|
476
|
+
const CURSOR_ASK_EVENTS = new Set(['beforeShellExecution', 'beforeMCPExecution']);
|
|
477
|
+
/** Keyless seatbelt: a host that cannot hand this action to its own approval prompt holds it (no account to approve it). */
|
|
478
|
+
exports.SEATBELT_HELD_TEXT = 'MARROW SEATBELT held this action: this host cannot show its own approval prompt here, and no Marrow account is linked to approve it. If you (the person at this machine) meant it, run it yourself in your own terminal. Agent: do not retry it another way and do not edit Marrow settings; tell the user it was held. For approval prompts, add a Marrow key: getmarrow.ai/docs#start-here.';
|
|
479
|
+
exports.SEATBELT_CURSOR_ASK_USER_TEXT = 'Marrow seatbelt (no account linked): approve this action here only if you expected it.';
|
|
480
|
+
exports.SEATBELT_CURSOR_ASK_AGENT_TEXT = 'Cursor is asking the user to approve this action (Marrow seatbelt, no account linked). Wait for the answer; do not retry it another way.';
|
|
481
|
+
/**
|
|
482
|
+
* The keyless seatbelt's answer for an action it does not stop (spec §1.3 "pass": Marrow adds no
|
|
483
|
+
* decision and the host's own permission flow runs). `heldBefore`: the 3.9.103 keyless hook held this
|
|
484
|
+
* action (protected, or not read-only on Windsurf, Gemini or Grok). `null` means exit 0, no output.
|
|
485
|
+
* - Not held before: exactly the answer 3.9.103 gave (its read-only and unprotected passes).
|
|
486
|
+
* - Held before: never an explicit allow that could skip the host's own approval step.
|
|
487
|
+
* Claude Code and Codex `{}` (no permissionDecision), Cline `{cancel:false}`, Windsurf no output:
|
|
488
|
+
* the hosts' neutral answers (see PASS_THROUGH_HOSTS), so their own approval step runs.
|
|
489
|
+
* Gemini CLI `{"decision":"allow"}`: the installed guard passes only that or a fixed denial (no
|
|
490
|
+
* output is a launch failure that blocks), and Gemini applies its own policy and confirmation
|
|
491
|
+
* after the BeforeTool hook whatever an "allow" says, so it is Gemini's neutral answer.
|
|
492
|
+
* Cursor beforeShellExecution / beforeMCPExecution: "ask", so Cursor's own approval prompt decides.
|
|
493
|
+
* Cursor preToolUse (no neutral answer, "ask" not enforced) and Grok (its guard passes only an
|
|
494
|
+
* explicit allow or the fixed denial, and nothing shows an allow keeps Grok's own approval): held,
|
|
495
|
+
* as 3.9.103 did. Any other or missing Cursor event name is treated as preToolUse.
|
|
496
|
+
*/
|
|
497
|
+
function seatbeltPassOutput(harness, hookEventName, heldBefore) {
|
|
498
|
+
if (!heldBefore)
|
|
499
|
+
return localControlAllowOutput(harness);
|
|
500
|
+
if (harness === 'windsurf')
|
|
501
|
+
return null;
|
|
502
|
+
if (harness === 'cline')
|
|
503
|
+
return { cancel: false };
|
|
504
|
+
if (harness === 'gemini')
|
|
505
|
+
return { decision: 'allow' };
|
|
506
|
+
if (harness === 'grok')
|
|
507
|
+
return { decision: 'deny', reason: exports.GROK_FIXED_DENIAL };
|
|
508
|
+
if (harness === 'cursor') {
|
|
509
|
+
return typeof hookEventName === 'string' && CURSOR_ASK_EVENTS.has(hookEventName)
|
|
510
|
+
? { permission: 'ask', user_message: exports.SEATBELT_CURSOR_ASK_USER_TEXT, agent_message: exports.SEATBELT_CURSOR_ASK_AGENT_TEXT }
|
|
511
|
+
: { permission: 'deny', user_message: exports.SEATBELT_HELD_TEXT, agent_message: exports.SEATBELT_HELD_TEXT };
|
|
512
|
+
}
|
|
513
|
+
return {};
|
|
514
|
+
}
|
|
413
515
|
function localLoopGuardDenyOutput(harness, reason) {
|
|
414
|
-
const bounded = reason
|
|
516
|
+
const bounded = fitHookText(reason, hostTextLimit(harness));
|
|
415
517
|
if (harness === 'windsurf')
|
|
416
518
|
return null;
|
|
417
519
|
if (harness === 'cursor')
|
|
418
520
|
return { permission: 'deny', user_message: bounded, agent_message: bounded };
|
|
419
521
|
if (harness === 'cline')
|
|
420
522
|
return { cancel: true, errorMessage: bounded };
|
|
421
|
-
|
|
523
|
+
// The installed Grok guard passes only an allow or this fixed denial; any other text is its launch failure (audit M1).
|
|
524
|
+
if (harness === 'grok')
|
|
525
|
+
return { decision: 'deny', reason: exports.GROK_FIXED_DENIAL };
|
|
526
|
+
if (harness === 'gemini')
|
|
422
527
|
return { decision: 'deny', reason: bounded };
|
|
423
528
|
return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: bounded } };
|
|
424
529
|
}
|
|
425
530
|
function emitLoopGuardDenial(harness, reason) {
|
|
426
531
|
if (harness === 'windsurf') {
|
|
427
532
|
process.exitCode = 2;
|
|
428
|
-
process.stderr.write(`${reason
|
|
533
|
+
process.stderr.write(`${fitHookText(reason, hostTextLimit(harness))}\n`);
|
|
429
534
|
return;
|
|
430
535
|
}
|
|
431
536
|
process.stdout.write(JSON.stringify(localLoopGuardDenyOutput(harness, reason)));
|
|
@@ -456,7 +561,139 @@ function localControlDisabled() {
|
|
|
456
561
|
return false;
|
|
457
562
|
}
|
|
458
563
|
}
|
|
459
|
-
|
|
564
|
+
// The order classifyTool picks a type and lists surfaces in; earlier types are the more specific (stricter) ones.
|
|
565
|
+
const CLASSIFICATION_TYPES = ['publish', 'deploy', 'review', 'migration', 'audit', 'financial', 'process'];
|
|
566
|
+
const CLASSIFICATION_SURFACES = ['production', 'github', 'npm', 'secrets', 'database', 'financial'];
|
|
567
|
+
/**
|
|
568
|
+
* The classification of one tool call. A Monitor command (and a native git
|
|
569
|
+
* equivalent such as EnterWorktree) is classified exactly as Bash running it;
|
|
570
|
+
* any other call carrying a command or script string gets the stricter of its
|
|
571
|
+
* own classification and Bash's for that text (its own classification,
|
|
572
|
+
* unchanged, when that is already at least as strict). A PowerShell call is
|
|
573
|
+
* one of those, with Bash's reading in the PowerShell dialect (no read-only
|
|
574
|
+
* allowlist) and never read-only. See toolPolicyReading.
|
|
575
|
+
*
|
|
576
|
+
* `postTool`: the PostToolUse hook (hook.ts) classifies a call that already ran, for the labels of its lifecycle event and the read-only
|
|
577
|
+
* flag of the local loop guard; no approval, redaction or stored output reads the result. That path must add ~no latency, so it asks no
|
|
578
|
+
* environment-print check and never loads the seatbelt mapper: its labels are the ones 3.9.103 gave (the secret-path table it shares with the
|
|
579
|
+
* seatbelt is the one stricter difference). Only the pre-action hook decides (and holds) on what prints the environment.
|
|
580
|
+
*/
|
|
581
|
+
function classifyTool(event, options = {}) {
|
|
582
|
+
const reading = (0, hook_tool_policy_1.toolPolicyReading)(event);
|
|
583
|
+
if (!reading.shell.length && reading.base === 'named')
|
|
584
|
+
return classificationFrom(classifyAsNamed(event, options));
|
|
585
|
+
const shell = reading.shell.map((twin) => classifyAsNamed(twin, options));
|
|
586
|
+
if (reading.exact) {
|
|
587
|
+
const merged = shell.reduce(stricterParts);
|
|
588
|
+
return classificationFrom(reading.bashLabel ? merged : { ...merged, tool: ownToolLabel(event) });
|
|
589
|
+
}
|
|
590
|
+
const own = reading.base === 'read' ? { ...classifyAsNamed((0, hook_tool_policy_1.readEquivalent)(event), options), tool: ownToolLabel(event) }
|
|
591
|
+
: reading.base === 'non_acting' ? nonActingParts(event)
|
|
592
|
+
: classifyAsNamed(event, options);
|
|
593
|
+
const merged = shell.reduce(stricterParts, own);
|
|
594
|
+
return classificationFrom(reading.dialect === 'powershell' ? { ...merged, readOnly: false } : merged);
|
|
595
|
+
}
|
|
596
|
+
function ownToolLabel(event) {
|
|
597
|
+
return String(event.tool_name || 'tool').slice(0, 64);
|
|
598
|
+
}
|
|
599
|
+
// A call whose words are not actions (toolPolicyReading): reported, never read-only, never protected by its text.
|
|
600
|
+
function nonActingParts(event) {
|
|
601
|
+
return { tool: ownToolLabel(event), type: 'process', surfaces: [], readOnly: false, protected: false };
|
|
602
|
+
}
|
|
603
|
+
/** `a`, unchanged, when it is at least as strict as `b` on every axis; otherwise the stricter of each. */
|
|
604
|
+
function stricterParts(a, b) {
|
|
605
|
+
const rank = (type) => {
|
|
606
|
+
const index = CLASSIFICATION_TYPES.indexOf(type);
|
|
607
|
+
return index < 0 ? CLASSIFICATION_TYPES.length : index;
|
|
608
|
+
};
|
|
609
|
+
const covers = (!a.readOnly || b.readOnly) && (a.protected || !b.protected) && rank(a.type) <= rank(b.type)
|
|
610
|
+
&& b.surfaces.every((surface) => a.surfaces.includes(surface));
|
|
611
|
+
if (covers)
|
|
612
|
+
return a;
|
|
613
|
+
const surfaces = new Set([...a.surfaces, ...b.surfaces]);
|
|
614
|
+
return {
|
|
615
|
+
tool: a.tool,
|
|
616
|
+
type: rank(a.type) <= rank(b.type) ? a.type : b.type,
|
|
617
|
+
surfaces: [...CLASSIFICATION_SURFACES.filter((surface) => surfaces.has(surface)), ...[...surfaces].filter((surface) => !CLASSIFICATION_SURFACES.includes(surface))],
|
|
618
|
+
readOnly: a.readOnly && b.readOnly,
|
|
619
|
+
protected: a.protected || b.protected,
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
let envDumpTools;
|
|
623
|
+
let lastEnvDumpAnswer = null;
|
|
624
|
+
// Every form that prints the environment holds one of these words (env also covers environ, getenv, printenv, ENV and every wrapper's
|
|
625
|
+
// last word). The mapper (about 100 ms to load and run once) is asked only for a command that holds one. Four words are read more closely
|
|
626
|
+
// (what the mapper needs, and nothing less), each unless an expansion could spell it:
|
|
627
|
+
// set only a bare `set` (the mapper's rule is no operand: `set -e` and the word in a message are not);
|
|
628
|
+
// ps only with an `e` or `E` after it (BSD `ps e`, `-E`); `ps aux` and `docker ps` are not;
|
|
629
|
+
// jq only with `env` in the text (a filter reads the environment by that word);
|
|
630
|
+
// proc only as `/proc`, or `process[...]` (a computed name); `process.env` holds `env`.
|
|
631
|
+
// A literal read of one non-secret name (`process.env.HOME`, the mapper's own rule) and a word that only joins `env` to a branch name in a
|
|
632
|
+
// plain `git` command (`git checkout -b env-fix`) hold nothing. A `$`, a backtick or a brace can spell any of these words, so a command that
|
|
633
|
+
// holds one of them and a narrowed word is read exactly as before.
|
|
634
|
+
exports.ENV_FILTER_SECRET_NAME = /KEY|TOKEN|SECRET|PASS|CRED/i; // the mapper's SECRET_VAR (src/seatbelt/tables.ts); a test keeps the two equal
|
|
635
|
+
const ENV_DUMP_WORDS = /env|export|declare|typeset|readonly|compgen|tmux|systemctl|strace|php|awk|global|\bproc\b|\bprocess\s*(?:\?\.\s*)?\[|\bset\b(?=[^\S\n]*(?:$|[\n\r;&|)}<>#]|\d+[<>]))/i;
|
|
636
|
+
const NARROWED_WORDS = /\bjq\b|\bps\b|\bset\b|proc/i;
|
|
637
|
+
const EXPANSION = /[$`{]/;
|
|
638
|
+
const ONE_NAME_READ = /\bprocess\.env\.(?!(?:items|keys|copy)\b)([A-Za-z_]\w*)/g;
|
|
639
|
+
const PLAIN_GIT = /^\s*git\s[^$`;&|<>(){}\\\n]*$/;
|
|
640
|
+
/** False for a command that cannot print the environment: the keyed hook then never loads the mapper for it. */
|
|
641
|
+
function mayPrintEnvironment(command) {
|
|
642
|
+
// An ANSI-C quote can spell a word in escapes.
|
|
643
|
+
if (command.includes("$'") || command.includes('$"'))
|
|
644
|
+
return true;
|
|
645
|
+
// Quotes and backslashes split a word without changing it (`e'n'v`).
|
|
646
|
+
const text = command.replace(/['"\\]/g, '');
|
|
647
|
+
// The word `env` only hyphen-joined in a plain git command with no secret-looking word (data to git, never a program or a name).
|
|
648
|
+
let words = PLAIN_GIT.test(command) && !exports.ENV_FILTER_SECRET_NAME.test(command) ? text.replace(/-env(?![\w])|env-/gi, '') : text;
|
|
649
|
+
words = words.replace(ONE_NAME_READ, (read, name) => (exports.ENV_FILTER_SECRET_NAME.test(name) ? read : ''));
|
|
650
|
+
if (ENV_DUMP_WORDS.test(words))
|
|
651
|
+
return true;
|
|
652
|
+
// `ps` with an `e` or `E` anywhere after the first `ps` (an `e` after a later `ps` is after the first one too: one scan, no backtracking).
|
|
653
|
+
const ps = /\bps\b/i.exec(words);
|
|
654
|
+
if (ps && /[eE]/.test(words.slice(ps.index + 2)))
|
|
655
|
+
return true;
|
|
656
|
+
return EXPANSION.test(text) && NARROWED_WORDS.test(text);
|
|
657
|
+
}
|
|
658
|
+
const MAPPER_UNSURE = /^(?:mapper_error|budget_)/;
|
|
659
|
+
function shellPrintsEnvironment(command) {
|
|
660
|
+
if (lastEnvDumpAnswer && lastEnvDumpAnswer.command === command)
|
|
661
|
+
return lastEnvDumpAnswer.dumps;
|
|
662
|
+
if (!mayPrintEnvironment(command))
|
|
663
|
+
return false;
|
|
664
|
+
let dumps;
|
|
665
|
+
try {
|
|
666
|
+
if (envDumpTools === undefined) {
|
|
667
|
+
try {
|
|
668
|
+
envDumpTools = { mapper: require('./seatbelt/mapper'), reasons: require('./seatbelt/env-dump').ENV_DUMP_REASONS };
|
|
669
|
+
}
|
|
670
|
+
catch {
|
|
671
|
+
envDumpTools = null;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
if (!envDumpTools)
|
|
675
|
+
dumps = ENV_DUMP_WORDS.test(command);
|
|
676
|
+
else {
|
|
677
|
+
const { mapper, reasons } = envDumpTools;
|
|
678
|
+
const decision = mapper.evaluateSeatbeltShell(command, {
|
|
679
|
+
cwd: process.cwd(),
|
|
680
|
+
home: process.env.HOME || (0, node_os_1.homedir)(),
|
|
681
|
+
tmpdir: '/tmp',
|
|
682
|
+
platform: process.platform,
|
|
683
|
+
fs: { readFile: () => null, realpath: (path) => path },
|
|
684
|
+
git: { toplevel: () => null, getAll: () => [], currentBranch: () => null, remoteHead: () => null },
|
|
685
|
+
});
|
|
686
|
+
dumps = decision.hits.some((hit) => (hit.category === 'S4' && reasons.has(hit.reason)) || (MAPPER_UNSURE.test(hit.reason) && ENV_DUMP_WORDS.test(command)));
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
catch {
|
|
690
|
+
dumps = ENV_DUMP_WORDS.test(command);
|
|
691
|
+
}
|
|
692
|
+
lastEnvDumpAnswer = { command, dumps };
|
|
693
|
+
return dumps;
|
|
694
|
+
}
|
|
695
|
+
/** The classification of a call under its own tool name only (every tool before the built-ins were hooked). */
|
|
696
|
+
function classifyAsNamed(event, options) {
|
|
460
697
|
const tool = String(event.tool_name || 'tool').slice(0, 64);
|
|
461
698
|
const normalizedTool = (0, hook_tool_policy_1.normalizeHookToolName)(tool);
|
|
462
699
|
// Editing and task tools are judged by their target; shell-like words in the
|
|
@@ -464,8 +701,11 @@ function classifyTool(event) {
|
|
|
464
701
|
const shellGoverned = (0, hook_tool_policy_1.isShellGovernedTool)(event);
|
|
465
702
|
const command = shellGoverned ? (0, hook_tool_policy_1.hookToolCommand)(event) : '';
|
|
466
703
|
const input = (0, hook_tool_policy_1.toolClassificationText)(event).toLowerCase();
|
|
467
|
-
|
|
468
|
-
|
|
704
|
+
// A look-alike of Marrow's own tool is never read-only, whatever its verb.
|
|
705
|
+
// The PostToolUse path passes no check (options.postTool): the mapper is never loaded for it.
|
|
706
|
+
const printsEnvironment = options.postTool === true ? undefined : shellPrintsEnvironment;
|
|
707
|
+
const readOnly = options.ownUnproven !== true && (0, hook_tool_policy_1.isReadOnlyToolEventAsNamed)(event, printsEnvironment);
|
|
708
|
+
const secretAccess = (0, hook_tool_policy_1.isSecretMaterialAccessAsNamed)(event, printsEnvironment);
|
|
469
709
|
const protectedShellCommand = shellGoverned && (0, hook_tool_policy_1.isProtectedShellMutation)(command);
|
|
470
710
|
const infrastructureDeployment = /\b(?:kubectl|terraform|pulumi|helm)\b/.test(command.toLowerCase())
|
|
471
711
|
&& protectedShellCommand;
|
|
@@ -493,8 +733,13 @@ function classifyTool(event) {
|
|
|
493
733
|
const protectedAction = !readOnly && (secretAccess
|
|
494
734
|
|| (shellGoverned && /\b(?:deploy|release|publish|git\s+push|git\s+merge|gh\s+pr\s+merge|migration|migrate|secret|credential|rotate|revoke|payment|refund|charge|invoice|production|prod)\b/.test(input))
|
|
495
735
|
|| protectedShellCommand
|
|
496
|
-
|| ((0, hook_tool_policy_1.isMcpHookTool)(event.tool_name) && !(0, hook_tool_policy_1.isOfficialMarrowMcpTool)(event.tool_name))
|
|
736
|
+
|| ((0, hook_tool_policy_1.isMcpHookTool)(event.tool_name) && (options.ownUnproven === true || !(0, hook_tool_policy_1.isOfficialMarrowMcpTool)(event.tool_name)))
|
|
497
737
|
|| (['use_mcp_tool', 'use_tool'].includes(normalizedTool) && !(0, hook_tool_policy_1.isOfficialMarrowMcpEvent)(event)));
|
|
738
|
+
return { tool, type, surfaces, readOnly, protected: protectedAction };
|
|
739
|
+
}
|
|
740
|
+
function classificationFrom(parts) {
|
|
741
|
+
const { tool, type, surfaces, readOnly } = parts;
|
|
742
|
+
const protectedAction = parts.protected;
|
|
498
743
|
const risk = readOnly ? 'low' : protectedAction ? 'high' : 'medium';
|
|
499
744
|
const target = surfaces.includes('npm') ? `npm:${type}`
|
|
500
745
|
: surfaces.includes('github') ? `github:${type}`
|
|
@@ -525,7 +770,8 @@ function cursorPreActionHookOutput(result) {
|
|
|
525
770
|
return { permission: 'deny', user_message: denial, agent_message: denial };
|
|
526
771
|
}
|
|
527
772
|
if (protectedRisk && (!runtime || !permit?.verified)) {
|
|
528
|
-
|
|
773
|
+
// Room for the code at the end only for the fixed keyless texts; every other text (it can carry a server error, an agent id) keeps the base bound.
|
|
774
|
+
const denial = boundedText(result.enforcementError || 'Marrow could not verify the required action permit. Retry after governance is available.', isKeylessHoldText(result.enforcementError) ? STOP_TEXT_LIMIT : CURSOR_ENFORCEMENT_LIMIT);
|
|
529
775
|
return {
|
|
530
776
|
permission: 'deny',
|
|
531
777
|
user_message: denial,
|
|
@@ -548,11 +794,10 @@ function clinePreActionHookOutput(result) {
|
|
|
548
794
|
};
|
|
549
795
|
}
|
|
550
796
|
if (result.protectedRisk && (!result.runtime || !result.permit?.verified)) {
|
|
551
|
-
const credentialsUnavailable = result.enforcementError === exports.NO_KEY_TEXT;
|
|
552
797
|
return {
|
|
553
798
|
cancel: true,
|
|
554
|
-
errorMessage: credentialsUnavailable
|
|
555
|
-
?
|
|
799
|
+
errorMessage: result.credentialsUnavailable === true
|
|
800
|
+
? fitHookText(result.enforcementError, CLINE_ERROR_LIMIT)
|
|
556
801
|
: result.failure === 'credential_scope'
|
|
557
802
|
? 'This Marrow API key is not authorized to obtain action permits for this agent. Use the API key issued to this agent and retry.'
|
|
558
803
|
: result.failure === 'unavailable'
|
|
@@ -828,11 +1073,97 @@ function installPreActionHook(startDir = process.cwd()) {
|
|
|
828
1073
|
fs.writeFileSync(path, JSON.stringify(settings, null, 2) + '\n');
|
|
829
1074
|
return { settingsPath: path, installed: reconciled.changed };
|
|
830
1075
|
}
|
|
1076
|
+
/**
|
|
1077
|
+
* `marrow-mcp seatbelt-status`: exactly one line of JSON, exit 0. Read-only. On any error the unknown line.
|
|
1078
|
+
* The installer's doctor reads it; it is not a user feature.
|
|
1079
|
+
*/
|
|
1080
|
+
function runSeatbeltStatusCommand() {
|
|
1081
|
+
let status = { mode: 'unknown', reasons: [] };
|
|
1082
|
+
try {
|
|
1083
|
+
status = require('./seatbelt/hook').seatbeltStatus();
|
|
1084
|
+
}
|
|
1085
|
+
catch { /* the unknown line */ }
|
|
1086
|
+
process.stdout.write(`${JSON.stringify({ mode: status.mode, reasons: status.reasons })}\n`);
|
|
1087
|
+
}
|
|
1088
|
+
/**
|
|
1089
|
+
* I3: whether a real outage (the gate never answered) holds this action. Never looser than master,
|
|
1090
|
+
* which allowed every action in an outage. A shell command is read by the seatbelt mapper: held for an
|
|
1091
|
+
* S0-S5/S-x hit or a program on the outage list (git push, publish, deploy, remote delete), allowed
|
|
1092
|
+
* when the mapper read all of it and found neither (words in a message, a path or a comment never hold
|
|
1093
|
+
* it). Anything else, or a mapper that could not finish, keeps `classifiedHighRisk` (F2).
|
|
1094
|
+
*/
|
|
1095
|
+
function outageHolds(classifiedHighRisk, read) {
|
|
1096
|
+
let reading = { shell: false, risk: 'unknown' };
|
|
1097
|
+
try {
|
|
1098
|
+
reading = read();
|
|
1099
|
+
}
|
|
1100
|
+
catch { /* unknown: the classifier's answer */ }
|
|
1101
|
+
if (reading.risk === 'high')
|
|
1102
|
+
return true;
|
|
1103
|
+
if (reading.shell && reading.risk === 'not_high')
|
|
1104
|
+
return false;
|
|
1105
|
+
return classifiedHighRisk;
|
|
1106
|
+
}
|
|
1107
|
+
/**
|
|
1108
|
+
* The hold text of the keyless fallback, naming its reason. The words live in the seatbelt module (a lazy
|
|
1109
|
+
* require, like the rest of the keyless path); if it cannot load the generic wording stands.
|
|
1110
|
+
*/
|
|
1111
|
+
function setupUnconfirmedHold(modeReason) {
|
|
1112
|
+
try {
|
|
1113
|
+
return require('./seatbelt/fallback-copy').setupUnconfirmedText(modeReason);
|
|
1114
|
+
}
|
|
1115
|
+
catch {
|
|
1116
|
+
return exports.SETUP_UNCONFIRMED_TEXT;
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
let keylessHoldTexts = null;
|
|
1120
|
+
/**
|
|
1121
|
+
* True only for the fixed texts the keyless path sets as enforcementError (NO_KEY_TEXT, CLAIMED_NO_KEY_TEXT, or one
|
|
1122
|
+
* setupUnconfirmedHold text per row of the fallback table). An exact match against that generated set: never a prefix,
|
|
1123
|
+
* a pattern or a flag, so no other text, whatever it starts with, is taken for one of them.
|
|
1124
|
+
*/
|
|
1125
|
+
function isKeylessHoldText(text) {
|
|
1126
|
+
if (typeof text !== 'string')
|
|
1127
|
+
return false;
|
|
1128
|
+
if (text === exports.NO_KEY_TEXT || text === exports.CLAIMED_NO_KEY_TEXT || text === exports.SETUP_UNCONFIRMED_TEXT)
|
|
1129
|
+
return true;
|
|
1130
|
+
try {
|
|
1131
|
+
keylessHoldTexts ??= new Set(require('./seatbelt/fallback-copy').setupUnconfirmedTexts());
|
|
1132
|
+
return keylessHoldTexts.has(text);
|
|
1133
|
+
}
|
|
1134
|
+
catch {
|
|
1135
|
+
return false;
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
function seatbeltEvent(source, rawEvent) {
|
|
1139
|
+
const raw = asRecord(rawEvent) || {};
|
|
1140
|
+
const event = source;
|
|
1141
|
+
return {
|
|
1142
|
+
tool_name: source.tool_name, tool_input: source.tool_input, cwd: event.cwd ?? raw.cwd, truncated: event.input_truncated === true,
|
|
1143
|
+
session: source.session_id || source.conversation_id || source.task_id || undefined,
|
|
1144
|
+
};
|
|
1145
|
+
}
|
|
831
1146
|
async function runPreActionHookCommand(input) {
|
|
1147
|
+
// Account mode: the S0-S5 floor in watch runs after the answer is written, for the record only.
|
|
1148
|
+
const watch = {};
|
|
1149
|
+
try {
|
|
1150
|
+
await runPreActionHook(input, watch);
|
|
1151
|
+
}
|
|
1152
|
+
finally {
|
|
1153
|
+
if (watch.harness && watch.event) {
|
|
1154
|
+
try {
|
|
1155
|
+
require('./seatbelt/hook').accountFloor(watch.harness, watch.event, (0, host_approval_1.preToolDeadline)((0, host_approval_1.approvalHostFor)(watch.harness)));
|
|
1156
|
+
}
|
|
1157
|
+
catch { /* watch never changes the answer */ }
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
async function runPreActionHook(input, watch) {
|
|
832
1162
|
if (process.env.MARROW_AUTO_HOOK === 'false')
|
|
833
1163
|
return;
|
|
834
1164
|
const identity = (0, hook_contract_1.resolveNativeHookIdentity)(process.argv[2]);
|
|
835
1165
|
let event = input;
|
|
1166
|
+
let rawEvent = input;
|
|
836
1167
|
let inputFailure = null;
|
|
837
1168
|
if (event === undefined) {
|
|
838
1169
|
try {
|
|
@@ -842,7 +1173,8 @@ async function runPreActionHookCommand(input) {
|
|
|
842
1173
|
}
|
|
843
1174
|
else {
|
|
844
1175
|
const raw = stdin.raw.trim();
|
|
845
|
-
|
|
1176
|
+
rawEvent = raw ? JSON.parse(raw) : {};
|
|
1177
|
+
event = (0, hook_contract_1.normalizeHookEventPayload)(rawEvent);
|
|
846
1178
|
}
|
|
847
1179
|
}
|
|
848
1180
|
catch {
|
|
@@ -865,7 +1197,7 @@ async function runPreActionHookCommand(input) {
|
|
|
865
1197
|
emitDecision({ runtime: null, permit: null, protectedRisk: true, enforcementError: inputFailure || 'Marrow could not classify this mutation-capable tool request.' }, identity.harness);
|
|
866
1198
|
return;
|
|
867
1199
|
}
|
|
868
|
-
if ((0,
|
|
1200
|
+
if ((0, own_server_identity_1.isOfficialMarrowMcpEventOnHost)(identity.harness, source, rawEvent)) {
|
|
869
1201
|
if (identity.harness === 'windsurf') {
|
|
870
1202
|
process.exitCode = 0;
|
|
871
1203
|
return;
|
|
@@ -876,7 +1208,7 @@ async function runPreActionHookCommand(input) {
|
|
|
876
1208
|
: {}));
|
|
877
1209
|
return;
|
|
878
1210
|
}
|
|
879
|
-
const classified = classifyTool(source);
|
|
1211
|
+
const classified = classifyTool(source, { ownUnproven: (0, hook_tool_policy_1.isOfficialMarrowMcpEvent)(source) });
|
|
880
1212
|
let localControl;
|
|
881
1213
|
try {
|
|
882
1214
|
localControl = (0, control_state_1.readLocalControlState)();
|
|
@@ -962,6 +1294,58 @@ async function runPreActionHookCommand(input) {
|
|
|
962
1294
|
emitLoopGuardDenial(identity.harness, loopDecision.reason || `Marrow local loop guard blocked this unchanged repeat. Receipt: ${loopDecision.receipt}.`);
|
|
963
1295
|
return;
|
|
964
1296
|
}
|
|
1297
|
+
// No usable key: the seatbelt decides first (spec §1.1), before the read-only pass so S4 reads stop.
|
|
1298
|
+
// `seatbelt` answers here with no network call; `claimed_no_key` stops S0-S5 here and keeps the holds below.
|
|
1299
|
+
// Whether a keyless hold may say "linked to an account" (only with account evidence, never for a mode fallback).
|
|
1300
|
+
let keylessAccountLinked = false;
|
|
1301
|
+
// The keyless hold text when the seatbelt does not answer: master's no-key text in seatbelt mode (a
|
|
1302
|
+
// tool the mapper does not recognise, audit H1), the claimed_no_key texts otherwise.
|
|
1303
|
+
let keylessSeatbeltMode = false;
|
|
1304
|
+
// Why the stricter fallback is on (words only, read from the mode resolution; the mode is decided elsewhere).
|
|
1305
|
+
let keylessModeReason = null;
|
|
1306
|
+
if (!resolved.apiKey) {
|
|
1307
|
+
let keyless;
|
|
1308
|
+
try {
|
|
1309
|
+
keyless = require('./seatbelt/hook').keylessSeatbelt(identity.harness, seatbeltEvent(source, rawEvent));
|
|
1310
|
+
}
|
|
1311
|
+
catch {
|
|
1312
|
+
keyless = { mode: 'claimed_no_key', stopText: SEATBELT_FAILED_TEXT };
|
|
1313
|
+
}
|
|
1314
|
+
keylessAccountLinked = keyless.accountLinked === true;
|
|
1315
|
+
keylessSeatbeltMode = keyless.mode === 'seatbelt';
|
|
1316
|
+
keylessModeReason = typeof keyless.modeReason === 'string' ? keyless.modeReason : null;
|
|
1317
|
+
if (keyless.stopText) {
|
|
1318
|
+
emitLoopGuardDenial(identity.harness, keyless.stopText);
|
|
1319
|
+
return;
|
|
1320
|
+
}
|
|
1321
|
+
// A tool the mapper does not recognise never gets the seatbelt pass: master's answer below.
|
|
1322
|
+
if (keyless.mode === 'seatbelt' && keyless.recognized === true) {
|
|
1323
|
+
// Never an explicit allow for an action 3.9.103 held: the host's own approval step decides.
|
|
1324
|
+
const answer = seatbeltPassOutput(identity.harness, source.hook_event_name, !classified.readOnly && enforcementRequired);
|
|
1325
|
+
if (answer === null)
|
|
1326
|
+
process.exitCode = 0;
|
|
1327
|
+
else
|
|
1328
|
+
process.stdout.write(JSON.stringify(answer));
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
else {
|
|
1333
|
+
watch.harness = identity.harness;
|
|
1334
|
+
watch.event = seatbeltEvent(source, rawEvent);
|
|
1335
|
+
let floor = null;
|
|
1336
|
+
try {
|
|
1337
|
+
floor = require('./seatbelt/hook');
|
|
1338
|
+
}
|
|
1339
|
+
catch { /* watch only: a missing module never changes the account flow */ }
|
|
1340
|
+
if (floor && floor.SEATBELT_ROLLOUT.account_floor === 'on') {
|
|
1341
|
+
watch.harness = undefined;
|
|
1342
|
+
const stopText = floor.accountFloor(identity.harness, watch.event, (0, host_approval_1.preToolDeadline)((0, host_approval_1.approvalHostFor)(identity.harness)));
|
|
1343
|
+
if (stopText) {
|
|
1344
|
+
emitLoopGuardDenial(identity.harness, stopText);
|
|
1345
|
+
return;
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
965
1349
|
if (classified.readOnly) {
|
|
966
1350
|
const allow = localControlAllowOutput(identity.harness);
|
|
967
1351
|
if (allow === null)
|
|
@@ -988,7 +1372,8 @@ async function runPreActionHookCommand(input) {
|
|
|
988
1372
|
runtime: null,
|
|
989
1373
|
permit: null,
|
|
990
1374
|
protectedRisk: enforcementRequired,
|
|
991
|
-
enforcementError: exports.NO_KEY_TEXT,
|
|
1375
|
+
enforcementError: keylessSeatbeltMode ? exports.NO_KEY_TEXT : keylessAccountLinked ? exports.CLAIMED_NO_KEY_TEXT : setupUnconfirmedHold(keylessModeReason),
|
|
1376
|
+
credentialsUnavailable: true,
|
|
992
1377
|
}, identity.harness);
|
|
993
1378
|
return;
|
|
994
1379
|
}
|
|
@@ -1031,7 +1416,9 @@ async function runPreActionHookCommand(input) {
|
|
|
1031
1416
|
return;
|
|
1032
1417
|
}
|
|
1033
1418
|
if (waited?.kind === 'deny') {
|
|
1034
|
-
|
|
1419
|
+
// The retry of a hold that waits for an approval nobody can give in chat: the same user-only line again (not for a denial that has another cause: see holdNotice).
|
|
1420
|
+
const userNotice = identity.harness === 'claude-code' ? (0, host_approval_1.holdNotice)(waited.hold, (0, host_approval_1.heldKindLabel)(localApprovalCategories(classified)), waited.waiting === true) : null;
|
|
1421
|
+
emitHeldPlan(identity.harness, { kind: 'deny', agentText: waited.agentText, userText: waited.userText, code: Boolean(waited.hold.code), ...(userNotice ? { userNotice } : {}) }, waited.hold.code);
|
|
1035
1422
|
await afterAnswer(holdContext, { link: waited.deferredLink ? waited.hold : null });
|
|
1036
1423
|
return;
|
|
1037
1424
|
}
|
|
@@ -1058,6 +1445,11 @@ async function runPreActionHookCommand(input) {
|
|
|
1058
1445
|
});
|
|
1059
1446
|
// The decision and gate receipt this control path holds, so a denial can close them.
|
|
1060
1447
|
const held = { decisionId: null, gateReceiptId: null };
|
|
1448
|
+
// Set once the runtime gate has answered. A later network or 5xx failure (decision, permit issue
|
|
1449
|
+
// or verify) on a high-risk action is a reached answer that no permit can be had: a denial, never
|
|
1450
|
+
// "offline, allowed". Routine actions keep the outage policy (failure_mode low_risk_behavior).
|
|
1451
|
+
const reach = { gateAnswered: false };
|
|
1452
|
+
const highRisk = classified.protected || classified.risk === 'high';
|
|
1061
1453
|
const control = async (signal) => {
|
|
1062
1454
|
const runtime = await (0, index_1.marrowAgentRuntime)(resolved.apiKey, baseUrl, {
|
|
1063
1455
|
action: classified.action,
|
|
@@ -1072,6 +1464,7 @@ async function runPreActionHookCommand(input) {
|
|
|
1072
1464
|
// non-durable fast_gate receipt can never back an action permit.
|
|
1073
1465
|
...(enforcementRequired ? { risk_level: classified.risk } : {}),
|
|
1074
1466
|
}, sessionId, agentId, signal);
|
|
1467
|
+
reach.gateAnswered = true;
|
|
1075
1468
|
const heldIds = [...new Set([runtime.decision_id, runtime.completion_contract?.decision_id, runtime.runtime_authorization?.decision_id]
|
|
1076
1469
|
.filter((value) => typeof value === 'string' && SAFE_DECISION_ID.test(value)))];
|
|
1077
1470
|
held.decisionId = heldIds.length === 1 ? heldIds[0] : null;
|
|
@@ -1173,7 +1566,7 @@ async function runPreActionHookCommand(input) {
|
|
|
1173
1566
|
timedOut: true,
|
|
1174
1567
|
enforcementError: hook_contract_1.MARROW_OUTAGE_WARNING,
|
|
1175
1568
|
}
|
|
1176
|
-
: isMarrowControlOutage(error)
|
|
1569
|
+
: isMarrowControlOutage(error) && !(reach.gateAnswered && highRisk)
|
|
1177
1570
|
? {
|
|
1178
1571
|
runtime: null,
|
|
1179
1572
|
permit: null,
|
|
@@ -1181,13 +1574,21 @@ async function runPreActionHookCommand(input) {
|
|
|
1181
1574
|
outage: true,
|
|
1182
1575
|
enforcementError: hook_contract_1.MARROW_OUTAGE_WARNING,
|
|
1183
1576
|
}
|
|
1184
|
-
:
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1577
|
+
: isMarrowControlOutage(error)
|
|
1578
|
+
? {
|
|
1579
|
+
runtime: null,
|
|
1580
|
+
permit: null,
|
|
1581
|
+
protectedRisk: enforcementRequired,
|
|
1582
|
+
enforcementError: controlRejectionMessage(error, agentId, 'unavailable'),
|
|
1583
|
+
failure: 'unavailable',
|
|
1584
|
+
}
|
|
1585
|
+
: {
|
|
1586
|
+
runtime: null,
|
|
1587
|
+
permit: null,
|
|
1588
|
+
protectedRisk: enforcementRequired,
|
|
1589
|
+
enforcementError: controlRejectionMessage(error, agentId),
|
|
1590
|
+
...(controlFailureKind(error) ? { failure: controlFailureKind(error) } : {}),
|
|
1591
|
+
})),
|
|
1191
1592
|
lifecycle,
|
|
1192
1593
|
]);
|
|
1193
1594
|
const claudePrompt = ownerApprovalPrompt(identity.harness, source);
|
|
@@ -1205,12 +1606,17 @@ async function runPreActionHookCommand(input) {
|
|
|
1205
1606
|
}
|
|
1206
1607
|
if (result.outage) {
|
|
1207
1608
|
// Marrow cannot be reached. An action in a category the account owner
|
|
1208
|
-
// protects stays held on every host;
|
|
1609
|
+
// protects stays held on every host; a high-risk action fails closed (high-risk
|
|
1610
|
+
// work needs a fresh gate); routine actions keep the outage policy.
|
|
1209
1611
|
const protectedNow = (0, host_approval_1.protectedAmong)(holdContext, localApprovalCategories(classified));
|
|
1210
1612
|
if (protectedNow.length) {
|
|
1211
1613
|
emitHeldPlan(identity.harness, { kind: 'deny', agentText: host_approval_1.HELD_UNREACHABLE_TEXT, userText: host_approval_1.HELD_UNREACHABLE_TEXT, code: false });
|
|
1212
1614
|
return;
|
|
1213
1615
|
}
|
|
1616
|
+
if (outageHolds(highRisk, () => require('./seatbelt/hook').outageRisk(seatbeltEvent(source, rawEvent), holdContext.deadlineAt ?? Number.POSITIVE_INFINITY))) {
|
|
1617
|
+
emitHeldPlan(identity.harness, { kind: 'deny', agentText: exports.HELD_OFFLINE_TEXT, userText: exports.HELD_OFFLINE_TEXT, code: false });
|
|
1618
|
+
return;
|
|
1619
|
+
}
|
|
1214
1620
|
}
|
|
1215
1621
|
const verdict = result.outage ? null : runtimeGateVerdict(result.runtime);
|
|
1216
1622
|
const guidance = verdict?.kind === 'review' ? (0, runtime_contract_1.ordinaryApprovalGuidance)(result.runtime) : null;
|