@getmarrow/mcp 3.9.103 → 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 +26 -5
- 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 +1677 -1636
- package/dist/cli.js.map +1 -1
- package/dist/codex-plugin-pins.d.ts.map +1 -1
- package/dist/codex-plugin-pins.js +18 -0
- package/dist/codex-plugin-pins.js.map +1 -1
- 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 +36 -3
- package/dist/hook-contract.js.map +1 -1
- package/dist/hook-pre-action.d.ts +38 -7
- package/dist/hook-pre-action.d.ts.map +1 -1
- package/dist/hook-pre-action.js +254 -21
- 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.map +1 -1
- package/dist/hook.js +5 -3
- 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/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- 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 +2 -0
- package/dist/normalized-action.d.ts.map +1 -1
- package/dist/normalized-action.js +65 -10
- package/dist/normalized-action.js.map +1 -1
- package/dist/own-server-identity.d.ts +22 -3
- package/dist/own-server-identity.d.ts.map +1 -1
- package/dist/own-server-identity.js +98 -27
- package/dist/own-server-identity.js.map +1 -1
- package/dist/own-tool.d.ts +14 -6
- package/dist/own-tool.d.ts.map +1 -1
- package/dist/own-tool.js +18 -7
- package/dist/own-tool.js.map +1 -1
- 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/hook.d.ts +21 -2
- package/dist/seatbelt/hook.d.ts.map +1 -1
- package/dist/seatbelt/hook.js +45 -11
- package/dist/seatbelt/hook.js.map +1 -1
- package/dist/seatbelt/mapper.d.ts.map +1 -1
- package/dist/seatbelt/mapper.js +360 -27
- package/dist/seatbelt/mapper.js.map +1 -1
- package/dist/seatbelt/tables.d.ts +1 -1
- package/dist/seatbelt/tables.d.ts.map +1 -1
- package/dist/seatbelt/tables.js +9 -16
- package/dist/seatbelt/tables.js.map +1 -1
- 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/package.json +2 -2
- package/server.json +2 -2
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.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;
|
|
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;
|
|
@@ -19,6 +19,7 @@ exports.localControlAllowOutput = localControlAllowOutput;
|
|
|
19
19
|
exports.seatbeltPassOutput = seatbeltPassOutput;
|
|
20
20
|
exports.localLoopGuardDenyOutput = localLoopGuardDenyOutput;
|
|
21
21
|
exports.classifyTool = classifyTool;
|
|
22
|
+
exports.mayPrintEnvironment = mayPrintEnvironment;
|
|
22
23
|
exports.cursorPreActionHookOutput = cursorPreActionHookOutput;
|
|
23
24
|
exports.clinePreActionHookOutput = clinePreActionHookOutput;
|
|
24
25
|
exports.windsurfPreActionDecision = windsurfPreActionDecision;
|
|
@@ -28,9 +29,11 @@ exports.preActionHookOutput = preActionHookOutput;
|
|
|
28
29
|
exports.closeDeniedDecision = closeDeniedDecision;
|
|
29
30
|
exports.localApprovalCategories = localApprovalCategories;
|
|
30
31
|
exports.installPreActionHook = installPreActionHook;
|
|
32
|
+
exports.runSeatbeltStatusCommand = runSeatbeltStatusCommand;
|
|
31
33
|
exports.outageHolds = outageHolds;
|
|
32
34
|
exports.runPreActionHookCommand = runPreActionHookCommand;
|
|
33
35
|
const node_crypto_1 = require("node:crypto");
|
|
36
|
+
const node_os_1 = require("node:os");
|
|
34
37
|
const index_1 = require("./index");
|
|
35
38
|
const request_reliability_1 = require("./request-reliability");
|
|
36
39
|
const lifecycle_spool_1 = require("./lifecycle-spool");
|
|
@@ -45,13 +48,59 @@ const hook_tool_policy_1 = require("./hook-tool-policy");
|
|
|
45
48
|
const hook_contract_1 = require("./hook-contract");
|
|
46
49
|
Object.defineProperty(exports, "MARROW_OUTAGE_WARNING", { enumerable: true, get: function () { return hook_contract_1.MARROW_OUTAGE_WARNING; } });
|
|
47
50
|
/** A protected action with no Marrow key anywhere this machine keeps one (MARROW_API_KEY, ~/.marrow/env.local or ~/.marrow/env, owner-only). */
|
|
48
|
-
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.';
|
|
49
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. */
|
|
50
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`.';
|
|
51
|
-
/**
|
|
52
|
-
|
|
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.';
|
|
53
59
|
/** The seatbelt path itself could not run (its modules did not load): a stop (spec §2.1.4). */
|
|
54
|
-
const SEATBELT_FAILED_TEXT = 'MARROW SEATBELT stopped this action: a safety check that could not finish (
|
|
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
|
+
}
|
|
55
104
|
// Claude Code sends the whole tool input, and a Write of a long document grows
|
|
56
105
|
// further once JSON-escaped, so tens of kilobytes of markdown must still parse.
|
|
57
106
|
exports.MAX_PRE_ACTION_INPUT_BYTES = 4 * 1024 * 1024;
|
|
@@ -157,6 +206,9 @@ function heldActionHookOutput(harness, plan, code = null) {
|
|
|
157
206
|
return {
|
|
158
207
|
hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: plan.agentText },
|
|
159
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 } : {}),
|
|
160
212
|
};
|
|
161
213
|
}
|
|
162
214
|
/**
|
|
@@ -423,7 +475,7 @@ function localControlAllowOutput(harness) {
|
|
|
423
475
|
/** The Cursor events where Cursor enforces "ask" with its own approval prompt (the installer's execution hooks). */
|
|
424
476
|
const CURSOR_ASK_EVENTS = new Set(['beforeShellExecution', 'beforeMCPExecution']);
|
|
425
477
|
/** Keyless seatbelt: a host that cannot hand this action to its own approval prompt holds it (no account to approve it). */
|
|
426
|
-
exports.SEATBELT_HELD_TEXT = 'MARROW SEATBELT held this action: this host cannot
|
|
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.';
|
|
427
479
|
exports.SEATBELT_CURSOR_ASK_USER_TEXT = 'Marrow seatbelt (no account linked): approve this action here only if you expected it.';
|
|
428
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.';
|
|
429
481
|
/**
|
|
@@ -461,7 +513,7 @@ function seatbeltPassOutput(harness, hookEventName, heldBefore) {
|
|
|
461
513
|
return {};
|
|
462
514
|
}
|
|
463
515
|
function localLoopGuardDenyOutput(harness, reason) {
|
|
464
|
-
const bounded = reason
|
|
516
|
+
const bounded = fitHookText(reason, hostTextLimit(harness));
|
|
465
517
|
if (harness === 'windsurf')
|
|
466
518
|
return null;
|
|
467
519
|
if (harness === 'cursor')
|
|
@@ -478,7 +530,7 @@ function localLoopGuardDenyOutput(harness, reason) {
|
|
|
478
530
|
function emitLoopGuardDenial(harness, reason) {
|
|
479
531
|
if (harness === 'windsurf') {
|
|
480
532
|
process.exitCode = 2;
|
|
481
|
-
process.stderr.write(`${reason
|
|
533
|
+
process.stderr.write(`${fitHookText(reason, hostTextLimit(harness))}\n`);
|
|
482
534
|
return;
|
|
483
535
|
}
|
|
484
536
|
process.stdout.write(JSON.stringify(localLoopGuardDenyOutput(harness, reason)));
|
|
@@ -509,13 +561,139 @@ function localControlDisabled() {
|
|
|
509
561
|
return false;
|
|
510
562
|
}
|
|
511
563
|
}
|
|
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'];
|
|
512
567
|
/**
|
|
513
|
-
*
|
|
514
|
-
*
|
|
515
|
-
*
|
|
516
|
-
*
|
|
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.
|
|
517
580
|
*/
|
|
518
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) {
|
|
519
697
|
const tool = String(event.tool_name || 'tool').slice(0, 64);
|
|
520
698
|
const normalizedTool = (0, hook_tool_policy_1.normalizeHookToolName)(tool);
|
|
521
699
|
// Editing and task tools are judged by their target; shell-like words in the
|
|
@@ -524,8 +702,10 @@ function classifyTool(event, options = {}) {
|
|
|
524
702
|
const command = shellGoverned ? (0, hook_tool_policy_1.hookToolCommand)(event) : '';
|
|
525
703
|
const input = (0, hook_tool_policy_1.toolClassificationText)(event).toLowerCase();
|
|
526
704
|
// A look-alike of Marrow's own tool is never read-only, whatever its verb.
|
|
527
|
-
|
|
528
|
-
const
|
|
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);
|
|
529
709
|
const protectedShellCommand = shellGoverned && (0, hook_tool_policy_1.isProtectedShellMutation)(command);
|
|
530
710
|
const infrastructureDeployment = /\b(?:kubectl|terraform|pulumi|helm)\b/.test(command.toLowerCase())
|
|
531
711
|
&& protectedShellCommand;
|
|
@@ -555,6 +735,11 @@ function classifyTool(event, options = {}) {
|
|
|
555
735
|
|| protectedShellCommand
|
|
556
736
|
|| ((0, hook_tool_policy_1.isMcpHookTool)(event.tool_name) && (options.ownUnproven === true || !(0, hook_tool_policy_1.isOfficialMarrowMcpTool)(event.tool_name)))
|
|
557
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;
|
|
558
743
|
const risk = readOnly ? 'low' : protectedAction ? 'high' : 'medium';
|
|
559
744
|
const target = surfaces.includes('npm') ? `npm:${type}`
|
|
560
745
|
: surfaces.includes('github') ? `github:${type}`
|
|
@@ -585,7 +770,8 @@ function cursorPreActionHookOutput(result) {
|
|
|
585
770
|
return { permission: 'deny', user_message: denial, agent_message: denial };
|
|
586
771
|
}
|
|
587
772
|
if (protectedRisk && (!runtime || !permit?.verified)) {
|
|
588
|
-
|
|
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);
|
|
589
775
|
return {
|
|
590
776
|
permission: 'deny',
|
|
591
777
|
user_message: denial,
|
|
@@ -608,12 +794,10 @@ function clinePreActionHookOutput(result) {
|
|
|
608
794
|
};
|
|
609
795
|
}
|
|
610
796
|
if (result.protectedRisk && (!result.runtime || !result.permit?.verified)) {
|
|
611
|
-
const credentialsUnavailable = result.enforcementError === exports.NO_KEY_TEXT || result.enforcementError === exports.CLAIMED_NO_KEY_TEXT
|
|
612
|
-
|| result.enforcementError === exports.SETUP_UNCONFIRMED_TEXT;
|
|
613
797
|
return {
|
|
614
798
|
cancel: true,
|
|
615
|
-
errorMessage: credentialsUnavailable
|
|
616
|
-
? result.enforcementError
|
|
799
|
+
errorMessage: result.credentialsUnavailable === true
|
|
800
|
+
? fitHookText(result.enforcementError, CLINE_ERROR_LIMIT)
|
|
617
801
|
: result.failure === 'credential_scope'
|
|
618
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.'
|
|
619
803
|
: result.failure === 'unavailable'
|
|
@@ -889,6 +1073,18 @@ function installPreActionHook(startDir = process.cwd()) {
|
|
|
889
1073
|
fs.writeFileSync(path, JSON.stringify(settings, null, 2) + '\n');
|
|
890
1074
|
return { settingsPath: path, installed: reconciled.changed };
|
|
891
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
|
+
}
|
|
892
1088
|
/**
|
|
893
1089
|
* I3: whether a real outage (the gate never answered) holds this action. Never looser than master,
|
|
894
1090
|
* which allowed every action in an outage. A shell command is read by the seatbelt mapper: held for an
|
|
@@ -908,6 +1104,37 @@ function outageHolds(classifiedHighRisk, read) {
|
|
|
908
1104
|
return false;
|
|
909
1105
|
return classifiedHighRisk;
|
|
910
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
|
+
}
|
|
911
1138
|
function seatbeltEvent(source, rawEvent) {
|
|
912
1139
|
const raw = asRecord(rawEvent) || {};
|
|
913
1140
|
const event = source;
|
|
@@ -1074,6 +1301,8 @@ async function runPreActionHook(input, watch) {
|
|
|
1074
1301
|
// The keyless hold text when the seatbelt does not answer: master's no-key text in seatbelt mode (a
|
|
1075
1302
|
// tool the mapper does not recognise, audit H1), the claimed_no_key texts otherwise.
|
|
1076
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;
|
|
1077
1306
|
if (!resolved.apiKey) {
|
|
1078
1307
|
let keyless;
|
|
1079
1308
|
try {
|
|
@@ -1084,6 +1313,7 @@ async function runPreActionHook(input, watch) {
|
|
|
1084
1313
|
}
|
|
1085
1314
|
keylessAccountLinked = keyless.accountLinked === true;
|
|
1086
1315
|
keylessSeatbeltMode = keyless.mode === 'seatbelt';
|
|
1316
|
+
keylessModeReason = typeof keyless.modeReason === 'string' ? keyless.modeReason : null;
|
|
1087
1317
|
if (keyless.stopText) {
|
|
1088
1318
|
emitLoopGuardDenial(identity.harness, keyless.stopText);
|
|
1089
1319
|
return;
|
|
@@ -1142,7 +1372,8 @@ async function runPreActionHook(input, watch) {
|
|
|
1142
1372
|
runtime: null,
|
|
1143
1373
|
permit: null,
|
|
1144
1374
|
protectedRisk: enforcementRequired,
|
|
1145
|
-
enforcementError: keylessSeatbeltMode ? exports.NO_KEY_TEXT : keylessAccountLinked ? exports.CLAIMED_NO_KEY_TEXT :
|
|
1375
|
+
enforcementError: keylessSeatbeltMode ? exports.NO_KEY_TEXT : keylessAccountLinked ? exports.CLAIMED_NO_KEY_TEXT : setupUnconfirmedHold(keylessModeReason),
|
|
1376
|
+
credentialsUnavailable: true,
|
|
1146
1377
|
}, identity.harness);
|
|
1147
1378
|
return;
|
|
1148
1379
|
}
|
|
@@ -1185,7 +1416,9 @@ async function runPreActionHook(input, watch) {
|
|
|
1185
1416
|
return;
|
|
1186
1417
|
}
|
|
1187
1418
|
if (waited?.kind === 'deny') {
|
|
1188
|
-
|
|
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);
|
|
1189
1422
|
await afterAnswer(holdContext, { link: waited.deferredLink ? waited.hold : null });
|
|
1190
1423
|
return;
|
|
1191
1424
|
}
|