@getmarrow/mcp 3.9.103 → 3.9.107
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 +43 -6
- 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 +43 -6
- package/dist/hook-contract.js.map +1 -1
- package/dist/hook-pre-action.d.ts +43 -7
- package/dist/hook-pre-action.d.ts.map +1 -1
- package/dist/hook-pre-action.js +518 -21
- package/dist/hook-pre-action.js.map +1 -1
- package/dist/hook-tool-policy.d.ts +86 -4
- package/dist/hook-tool-policy.d.ts.map +1 -1
- package/dist/hook-tool-policy.js +354 -21
- 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 +24 -0
- package/dist/host-approval-state.d.ts.map +1 -1
- package/dist/host-approval-state.js +68 -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 +11 -3
- package/dist/normalized-action.d.ts.map +1 -1
- package/dist/normalized-action.js +305 -49
- 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 +108 -31
- 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 +66 -0
- package/dist/seatbelt/env-dump.d.ts.map +1 -0
- package/dist/seatbelt/env-dump.js +333 -0
- package/dist/seatbelt/env-dump.js.map +1 -0
- package/dist/seatbelt/env-names.d.ts +22 -0
- package/dist/seatbelt/env-names.d.ts.map +1 -0
- package/dist/seatbelt/env-names.js +298 -0
- package/dist/seatbelt/env-names.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 +1592 -85
- 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 +13 -17
- package/dist/seatbelt/tables.js.map +1 -1
- package/dist/secret-paths.d.ts +40 -0
- package/dist/secret-paths.d.ts.map +1 -0
- package/dist/secret-paths.js +59 -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 +25 -6
- 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,9 @@ exports.localControlAllowOutput = localControlAllowOutput;
|
|
|
19
19
|
exports.seatbeltPassOutput = seatbeltPassOutput;
|
|
20
20
|
exports.localLoopGuardDenyOutput = localLoopGuardDenyOutput;
|
|
21
21
|
exports.classifyTool = classifyTool;
|
|
22
|
+
exports.mayPrintEnvironment = mayPrintEnvironment;
|
|
23
|
+
exports.mayReadApprovalState = mayReadApprovalState;
|
|
24
|
+
exports.mayReadSecret = mayReadSecret;
|
|
22
25
|
exports.cursorPreActionHookOutput = cursorPreActionHookOutput;
|
|
23
26
|
exports.clinePreActionHookOutput = clinePreActionHookOutput;
|
|
24
27
|
exports.windsurfPreActionDecision = windsurfPreActionDecision;
|
|
@@ -28,30 +31,82 @@ exports.preActionHookOutput = preActionHookOutput;
|
|
|
28
31
|
exports.closeDeniedDecision = closeDeniedDecision;
|
|
29
32
|
exports.localApprovalCategories = localApprovalCategories;
|
|
30
33
|
exports.installPreActionHook = installPreActionHook;
|
|
34
|
+
exports.runSeatbeltStatusCommand = runSeatbeltStatusCommand;
|
|
31
35
|
exports.outageHolds = outageHolds;
|
|
32
36
|
exports.runPreActionHookCommand = runPreActionHookCommand;
|
|
33
37
|
const node_crypto_1 = require("node:crypto");
|
|
38
|
+
const node_fs_1 = require("node:fs");
|
|
39
|
+
const node_os_1 = require("node:os");
|
|
40
|
+
const node_path_1 = require("node:path");
|
|
34
41
|
const index_1 = require("./index");
|
|
35
42
|
const request_reliability_1 = require("./request-reliability");
|
|
36
43
|
const lifecycle_spool_1 = require("./lifecycle-spool");
|
|
37
44
|
const control_state_1 = require("./control-state");
|
|
38
45
|
const runtime_contract_1 = require("./runtime-contract");
|
|
39
46
|
const host_approval_1 = require("./host-approval");
|
|
47
|
+
const host_approval_state_1 = require("./host-approval-state");
|
|
40
48
|
const host_session_1 = require("./host-session");
|
|
41
49
|
const own_server_identity_1 = require("./own-server-identity");
|
|
42
50
|
const normalized_action_1 = require("./normalized-action");
|
|
51
|
+
const secret_paths_1 = require("./secret-paths");
|
|
43
52
|
const session_loop_guard_1 = require("./session-loop-guard");
|
|
44
53
|
const hook_tool_policy_1 = require("./hook-tool-policy");
|
|
45
54
|
const hook_contract_1 = require("./hook-contract");
|
|
46
55
|
Object.defineProperty(exports, "MARROW_OUTAGE_WARNING", { enumerable: true, get: function () { return hook_contract_1.MARROW_OUTAGE_WARNING; } });
|
|
47
56
|
/** 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.
|
|
57
|
+
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
58
|
/** 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
59
|
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
|
-
|
|
60
|
+
/**
|
|
61
|
+
* `claimed_no_key` without account evidence (a mode fallback): the same holds, never an account claim.
|
|
62
|
+
* This constant is the generic wording; the hold names its reason (setupUnconfirmedText). Same text as src/seatbelt/hook.ts.
|
|
63
|
+
*/
|
|
64
|
+
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
65
|
/** 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 (
|
|
66
|
+
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.';
|
|
67
|
+
/**
|
|
68
|
+
* Seatbelt stop and keyless hold texts end with their code, so they get room for it: the longest real stop
|
|
69
|
+
* (a fallback reason row with the longest label and codes) is about 850 characters. It is our own cap, not a host's:
|
|
70
|
+
* Claude Code, Codex, Cursor and Windsurf document no limit on the text of a denial and their installed hooks pass it on unchanged.
|
|
71
|
+
*/
|
|
72
|
+
const STOP_TEXT_LIMIT = 880;
|
|
73
|
+
/** The installed Gemini CLI guard (@getmarrow/install, src/installer.js) turns a deny reason over 500 characters into its launch failure. */
|
|
74
|
+
const GEMINI_REASON_LIMIT = 500;
|
|
75
|
+
/** 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. */
|
|
76
|
+
const CLINE_ERROR_LIMIT = 500;
|
|
77
|
+
/** The most characters of a stop or hold text this host takes without losing the code at its end. */
|
|
78
|
+
function hostTextLimit(harness) {
|
|
79
|
+
return harness === 'gemini' ? GEMINI_REASON_LIMIT : harness === 'cline' ? CLINE_ERROR_LIMIT : STOP_TEXT_LIMIT;
|
|
80
|
+
}
|
|
81
|
+
/** The Cursor deny text of every enforcementError that is not one of the fixed keyless texts (as on 3.9.104). */
|
|
82
|
+
const CURSOR_ENFORCEMENT_LIMIT = 500;
|
|
83
|
+
/** Claude Code plugin only; a host with a tight limit drops it before it would lose the stop's code at the end. */
|
|
84
|
+
const PLUGIN_KEY_HINT = ' (Claude Code plugin: put MARROW_API_KEY in ~/.marrow/env, chmod 600, then /reload-plugins)';
|
|
85
|
+
/** 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. */
|
|
86
|
+
const PERSON_ADVICE = ' If you (the person at this machine) meant it, run it yourself in your own terminal.';
|
|
87
|
+
/**
|
|
88
|
+
* One line of hook text within a host's limit. A text over it first gives up the plugin hint, then the
|
|
89
|
+
* person's advice; if it is still over, the end of the explanation is cut at a sentence and the stop's
|
|
90
|
+
* code (the last "Code: ..." part) is kept. Texts that fit are untouched.
|
|
91
|
+
*/
|
|
92
|
+
function fitHookText(value, limit) {
|
|
93
|
+
let text = value.replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
94
|
+
for (const part of [PLUGIN_KEY_HINT, PERSON_ADVICE]) {
|
|
95
|
+
if (text.length <= limit)
|
|
96
|
+
break;
|
|
97
|
+
text = text.replace(part, '');
|
|
98
|
+
}
|
|
99
|
+
if (text.length <= limit)
|
|
100
|
+
return text;
|
|
101
|
+
const at = text.lastIndexOf(' Code: ');
|
|
102
|
+
if (at > 0 && limit - (text.length - at) > 40) {
|
|
103
|
+
const tail = text.slice(at);
|
|
104
|
+
const head = text.slice(0, limit - tail.length);
|
|
105
|
+
const cut = head.lastIndexOf('. ');
|
|
106
|
+
return `${cut > 0 ? head.slice(0, cut + 1) : head}${tail}`.slice(0, limit);
|
|
107
|
+
}
|
|
108
|
+
return text.slice(0, limit);
|
|
109
|
+
}
|
|
55
110
|
// Claude Code sends the whole tool input, and a Write of a long document grows
|
|
56
111
|
// further once JSON-escaped, so tens of kilobytes of markdown must still parse.
|
|
57
112
|
exports.MAX_PRE_ACTION_INPUT_BYTES = 4 * 1024 * 1024;
|
|
@@ -157,6 +212,9 @@ function heldActionHookOutput(harness, plan, code = null) {
|
|
|
157
212
|
return {
|
|
158
213
|
hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: plan.agentText },
|
|
159
214
|
...(harness === 'codex' && code ? { systemMessage: (0, host_approval_1.typedReplyUserText)(plan.userText, code) } : {}),
|
|
215
|
+
// Claude Code, a hold nobody can approve in chat (no dialog here, or nobody in this run): one user-only
|
|
216
|
+
// line says so. Output only: the decision above is the same deny, and the line carries no code or id.
|
|
217
|
+
...(harness === 'claude-code' && plan.userNotice ? { systemMessage: plan.userNotice } : {}),
|
|
160
218
|
};
|
|
161
219
|
}
|
|
162
220
|
/**
|
|
@@ -423,7 +481,7 @@ function localControlAllowOutput(harness) {
|
|
|
423
481
|
/** The Cursor events where Cursor enforces "ask" with its own approval prompt (the installer's execution hooks). */
|
|
424
482
|
const CURSOR_ASK_EVENTS = new Set(['beforeShellExecution', 'beforeMCPExecution']);
|
|
425
483
|
/** 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
|
|
484
|
+
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
485
|
exports.SEATBELT_CURSOR_ASK_USER_TEXT = 'Marrow seatbelt (no account linked): approve this action here only if you expected it.';
|
|
428
486
|
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
487
|
/**
|
|
@@ -461,7 +519,7 @@ function seatbeltPassOutput(harness, hookEventName, heldBefore) {
|
|
|
461
519
|
return {};
|
|
462
520
|
}
|
|
463
521
|
function localLoopGuardDenyOutput(harness, reason) {
|
|
464
|
-
const bounded = reason
|
|
522
|
+
const bounded = fitHookText(reason, hostTextLimit(harness));
|
|
465
523
|
if (harness === 'windsurf')
|
|
466
524
|
return null;
|
|
467
525
|
if (harness === 'cursor')
|
|
@@ -478,7 +536,7 @@ function localLoopGuardDenyOutput(harness, reason) {
|
|
|
478
536
|
function emitLoopGuardDenial(harness, reason) {
|
|
479
537
|
if (harness === 'windsurf') {
|
|
480
538
|
process.exitCode = 2;
|
|
481
|
-
process.stderr.write(`${reason
|
|
539
|
+
process.stderr.write(`${fitHookText(reason, hostTextLimit(harness))}\n`);
|
|
482
540
|
return;
|
|
483
541
|
}
|
|
484
542
|
process.stdout.write(JSON.stringify(localLoopGuardDenyOutput(harness, reason)));
|
|
@@ -509,13 +567,394 @@ function localControlDisabled() {
|
|
|
509
567
|
return false;
|
|
510
568
|
}
|
|
511
569
|
}
|
|
570
|
+
// The order classifyTool picks a type and lists surfaces in; earlier types are the more specific (stricter) ones.
|
|
571
|
+
const CLASSIFICATION_TYPES = ['publish', 'deploy', 'review', 'migration', 'audit', 'financial', 'process'];
|
|
572
|
+
const CLASSIFICATION_SURFACES = ['production', 'github', 'npm', 'secrets', 'database', 'financial'];
|
|
512
573
|
/**
|
|
513
|
-
*
|
|
514
|
-
*
|
|
515
|
-
*
|
|
516
|
-
*
|
|
574
|
+
* The classification of one tool call. A Monitor command (and a native git
|
|
575
|
+
* equivalent such as EnterWorktree) is classified exactly as Bash running it;
|
|
576
|
+
* any other call carrying a command or script string gets the stricter of its
|
|
577
|
+
* own classification and Bash's for that text (its own classification,
|
|
578
|
+
* unchanged, when that is already at least as strict). A PowerShell call is
|
|
579
|
+
* one of those, with Bash's reading in the PowerShell dialect (no read-only
|
|
580
|
+
* allowlist) and never read-only. See toolPolicyReading.
|
|
581
|
+
*
|
|
582
|
+
* `postTool`: the PostToolUse hook (hook.ts) classifies a call that already ran, for the labels of its lifecycle event and the read-only
|
|
583
|
+
* 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
|
|
584
|
+
* 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
|
|
585
|
+
* seatbelt is the one stricter difference). Only the pre-action hook decides (and holds) on what prints the environment.
|
|
517
586
|
*/
|
|
518
587
|
function classifyTool(event, options = {}) {
|
|
588
|
+
const cwd = absoluteFolder(options.cwd) ?? absoluteFolder(event.cwd);
|
|
589
|
+
if (cwd !== options.cwd)
|
|
590
|
+
options = { ...options, cwd };
|
|
591
|
+
const reading = (0, hook_tool_policy_1.toolPolicyReading)(event);
|
|
592
|
+
if (!reading.shell.length && reading.base === 'named')
|
|
593
|
+
return classificationFrom(classifyAsNamed(event, options));
|
|
594
|
+
const shell = reading.shell.map((twin) => classifyAsNamed(twin, options));
|
|
595
|
+
if (reading.exact) {
|
|
596
|
+
const merged = shell.reduce(stricterParts);
|
|
597
|
+
return classificationFrom(reading.bashLabel ? merged : { ...merged, tool: ownToolLabel(event) });
|
|
598
|
+
}
|
|
599
|
+
const own = reading.base === 'read' ? { ...classifyAsNamed((0, hook_tool_policy_1.readEquivalent)(event), options), tool: ownToolLabel(event) }
|
|
600
|
+
: reading.base === 'non_acting' ? nonActingParts(event)
|
|
601
|
+
: classifyAsNamed(event, options);
|
|
602
|
+
const merged = shell.reduce(stricterParts, own);
|
|
603
|
+
return classificationFrom(reading.dialect === 'powershell' ? { ...merged, readOnly: false } : merged);
|
|
604
|
+
}
|
|
605
|
+
function ownToolLabel(event) {
|
|
606
|
+
return String(event.tool_name || 'tool').slice(0, 64);
|
|
607
|
+
}
|
|
608
|
+
// A call whose words are not actions (toolPolicyReading): reported, never read-only, never protected by its text.
|
|
609
|
+
function nonActingParts(event) {
|
|
610
|
+
return { tool: ownToolLabel(event), type: 'process', surfaces: [], readOnly: false, protected: false };
|
|
611
|
+
}
|
|
612
|
+
/** `a`, unchanged, when it is at least as strict as `b` on every axis; otherwise the stricter of each. */
|
|
613
|
+
function stricterParts(a, b) {
|
|
614
|
+
const rank = (type) => {
|
|
615
|
+
const index = CLASSIFICATION_TYPES.indexOf(type);
|
|
616
|
+
return index < 0 ? CLASSIFICATION_TYPES.length : index;
|
|
617
|
+
};
|
|
618
|
+
const covers = (!a.readOnly || b.readOnly) && (a.protected || !b.protected) && rank(a.type) <= rank(b.type)
|
|
619
|
+
&& b.surfaces.every((surface) => a.surfaces.includes(surface));
|
|
620
|
+
if (covers)
|
|
621
|
+
return a;
|
|
622
|
+
const surfaces = new Set([...a.surfaces, ...b.surfaces]);
|
|
623
|
+
return {
|
|
624
|
+
tool: a.tool,
|
|
625
|
+
type: rank(a.type) <= rank(b.type) ? a.type : b.type,
|
|
626
|
+
surfaces: [...CLASSIFICATION_SURFACES.filter((surface) => surfaces.has(surface)), ...[...surfaces].filter((surface) => !CLASSIFICATION_SURFACES.includes(surface))],
|
|
627
|
+
readOnly: a.readOnly && b.readOnly,
|
|
628
|
+
protected: a.protected || b.protected,
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
let envDumpTools;
|
|
632
|
+
let lastEnvDumpAnswer = null;
|
|
633
|
+
// Every form that prints the environment holds one of these words (env also covers environ, getenv, printenv, ENV and every wrapper's
|
|
634
|
+
// 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
|
|
635
|
+
// (what the mapper needs, and nothing less), each unless an expansion could spell it:
|
|
636
|
+
// set only a bare `set` (the mapper's rule is no operand: `set -e` and the word in a message are not);
|
|
637
|
+
// ps only with an `e` or `E` after it (BSD `ps e`, `-E`); `ps aux` and `docker ps` are not;
|
|
638
|
+
// jq only with `env` in the text (a filter reads the environment by that word);
|
|
639
|
+
// proc only as `/proc`, or `process[...]` (a computed name); `process.env` holds `env`.
|
|
640
|
+
// 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
|
|
641
|
+
// 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
|
|
642
|
+
// holds one of them and a narrowed word is read exactly as before.
|
|
643
|
+
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
|
|
644
|
+
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;
|
|
645
|
+
const NARROWED_WORDS = /\bjq\b|\bps\b|\bset\b|proc/i;
|
|
646
|
+
const EXPANSION = /[$`{]/;
|
|
647
|
+
const ONE_NAME_READ = /\bprocess\.env\.(?!(?:items|keys|copy)\b)([A-Za-z_]\w*)/g;
|
|
648
|
+
const PLAIN_GIT = /^\s*git\s[^$`;&|<>(){}\\\n]*$/;
|
|
649
|
+
/** False for a command that cannot print the environment: the keyed hook then never loads the mapper for it. */
|
|
650
|
+
function mayPrintEnvironment(command) {
|
|
651
|
+
// An ANSI-C quote can spell a word in escapes.
|
|
652
|
+
if (command.includes("$'") || command.includes('$"'))
|
|
653
|
+
return true;
|
|
654
|
+
// Quotes and backslashes split a word without changing it (`e'n'v`).
|
|
655
|
+
const text = command.replace(/['"\\]/g, '');
|
|
656
|
+
// 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).
|
|
657
|
+
let words = PLAIN_GIT.test(command) && !exports.ENV_FILTER_SECRET_NAME.test(command) ? text.replace(/-env(?![\w])|env-/gi, '') : text;
|
|
658
|
+
words = words.replace(ONE_NAME_READ, (read, name) => (exports.ENV_FILTER_SECRET_NAME.test(name) ? read : ''));
|
|
659
|
+
if (ENV_DUMP_WORDS.test(words))
|
|
660
|
+
return true;
|
|
661
|
+
if (globInFolderPart(words))
|
|
662
|
+
return true;
|
|
663
|
+
// `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).
|
|
664
|
+
const ps = /\bps\b/i.exec(words);
|
|
665
|
+
if (ps && /[eE]/.test(words.slice(ps.index + 2)))
|
|
666
|
+
return true;
|
|
667
|
+
return EXPANSION.test(text) && NARROWED_WORDS.test(text);
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* A path whose folder part holds a glob character can spell `/proc/self/task/1/environ` with neither word in it (`/p*\/self/e*`, `/???c/s*\/e*`,
|
|
671
|
+
* `/p[r]oc/self/en[v]iron`). A word that starts like a path (`/`, `~`, `.`, `$`), or any word after a `cd` (or when the hook itself runs in `/` or
|
|
672
|
+
* under `/proc`), is asked of the mapper when a glob sits in a folder of it. One pass over the words, no backtracking.
|
|
673
|
+
*/
|
|
674
|
+
const WORD_BREAK = /[\s=:<>(;&|]+/;
|
|
675
|
+
const GLOB_CHAR = /[*?[]/;
|
|
676
|
+
const PATH_START = /^[/~.$]/;
|
|
677
|
+
const CHANGES_FOLDER = /\b(?:cd|pushd|popd)\b/;
|
|
678
|
+
function runsAtFilesystemRoot() {
|
|
679
|
+
try {
|
|
680
|
+
const cwd = process.cwd();
|
|
681
|
+
return cwd === '/' || cwd === '/proc' || cwd.startsWith('/proc/');
|
|
682
|
+
}
|
|
683
|
+
catch {
|
|
684
|
+
return false;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
function globInFolderPart(words) {
|
|
688
|
+
const anywhere = CHANGES_FOLDER.test(words) || runsAtFilesystemRoot();
|
|
689
|
+
for (const word of words.split(WORD_BREAK)) {
|
|
690
|
+
const slash = word.lastIndexOf('/');
|
|
691
|
+
if (slash > 0 && (anywhere || PATH_START.test(word)) && GLOB_CHAR.test(word.slice(0, slash)))
|
|
692
|
+
return true;
|
|
693
|
+
}
|
|
694
|
+
return false;
|
|
695
|
+
}
|
|
696
|
+
const MAPPER_UNSURE = /^(?:mapper_error|budget_)/;
|
|
697
|
+
/**
|
|
698
|
+
* False for a command that cannot read the host-approvals state file: the keyed hook then does not load the mapper for it on that account. A command that
|
|
699
|
+
* can spell the folder, or a folder that holds it, holds the word `approvals` or `marrow` (quotes and backslashes split a word without changing it), an
|
|
700
|
+
* ANSI-C quote, a glob in a folder part of a path that starts at the home (`~/.m*\/h*\/state.json`; `cd ~/.marrow; cat h*\/s*` holds `marrow`), or a tree walker (grep, rg, find, tar, cp, ...) given
|
|
701
|
+
* the home or the root, or given `.`/`..`/`*` while the hook runs in the home or the root. A path that only a computed name spells
|
|
702
|
+
* (`cd ~; cd .m*; cd h*; cat s*`) is read by the keyless mapper but not asked of it here.
|
|
703
|
+
*/
|
|
704
|
+
const TREE_WALKERS = /\b(?:grep|egrep|fgrep|zgrep|rg|ag|ack|find|tar|zip|rsync|cp|scp|xargs|7z|bsdtar|cpio|pax)\b/;
|
|
705
|
+
const HOME_OR_ROOT_OPERAND = /(?:^|[\s=:'"])(?:~|\$HOME|\$\{HOME\}|\/home|\/Users|\/)\/?(?=[\s'";&|)]|$)/;
|
|
706
|
+
const CWD_RELATIVE_OPERAND = /(?:^|\s)(?:\.\.?|\*|\.\/\*|\.\*)\/?(?=\s|$)/;
|
|
707
|
+
function runsInHomeOrAbove() {
|
|
708
|
+
try {
|
|
709
|
+
const home = (process.env.HOME || (0, node_os_1.homedir)()).replace(/\/+$/, '');
|
|
710
|
+
const cwd = process.cwd().replace(/\/+$/, '') || '/';
|
|
711
|
+
return cwd === '/' || cwd === home || home.startsWith(`${cwd}/`);
|
|
712
|
+
}
|
|
713
|
+
catch {
|
|
714
|
+
return false;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
const HOME_PATH_START = /^(?:~|\$HOME|\$\{HOME\}|\.|\/home\/|\/Users\/|\/root\/)/;
|
|
718
|
+
/** A glob in a folder part of a path that starts at the home or in the working folder (the env-dump filter keeps its own glob rule). */
|
|
719
|
+
function homeGlobInFolderPart(words) {
|
|
720
|
+
for (const word of words.split(WORD_BREAK)) {
|
|
721
|
+
const slash = word.lastIndexOf('/');
|
|
722
|
+
if (slash > 0 && GLOB_CHAR.test(word.slice(0, slash)) && HOME_PATH_START.test(word))
|
|
723
|
+
return true;
|
|
724
|
+
}
|
|
725
|
+
return false;
|
|
726
|
+
}
|
|
727
|
+
function mayReadApprovalState(command) {
|
|
728
|
+
if (command.includes("$'") || command.includes('$"'))
|
|
729
|
+
return true;
|
|
730
|
+
const text = command.replace(/['"\\]/g, '').toLowerCase();
|
|
731
|
+
if (text.includes('approvals') || text.includes('marrow') || homeGlobInFolderPart(text))
|
|
732
|
+
return true;
|
|
733
|
+
if (!TREE_WALKERS.test(text))
|
|
734
|
+
return false;
|
|
735
|
+
return HOME_OR_ROOT_OPERAND.test(command) || (CWD_RELATIVE_OPERAND.test(command) && runsInHomeOrAbove());
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* False for a command that cannot read a secret FILE inside code the keyed policy does not parse (a quoted `bash -c`, `eval`, `sudo sh -c`, `ssh host` or
|
|
739
|
+
* `xargs` string is one word to it). A command that can spell one holds a word of the secret-path table (src/secret-paths.ts: `.env`, `.npmrc`, `.ssh`, ...)
|
|
740
|
+
* or of the policy's secret file names, a word that starts with a dot and holds a glob or an expansion (`.e*`, `.e$X`), a variable set to a dot
|
|
741
|
+
* word beside any expansion (`A=.e; B=nv; cat $A$B`), or an expansion of a secret-looking variable (`$GITHUB_TOKEN`: ENV_FILTER_SECRET_NAME). Env and
|
|
742
|
+
* process words are not here (mayPrintEnvironment has them), nor are the programs that print a credential, nor an ANSI-C quote: the same text-only
|
|
743
|
+
* answer as before holds for those. A name that variables spell with no dot word in an assignment (`A=x.e`) is read by the keyless mapper but not asked
|
|
744
|
+
* of it here, as for the state file above. A dot word that only a brace spells (`cat .en{v,x}`) is not asked either: test/pre-action-hook.test.js pins it as
|
|
745
|
+
* not protected, and that expectation is left as it is.
|
|
746
|
+
*/
|
|
747
|
+
const SECRET_FILE_WORDS = /(?<!process)\.env(?![a-z0-9_])|\.(?:ssh|aws|azure|kube|docker|gnupg|config|claude|marrow)|npmrc|netrc|pypirc|pgpass|git-credentials|dev\.vars|id_(?:rsa|dsa|ecdsa|ed25519)|hosts\.yml|settings\.local|htpasswd|my\.cnf|yarnrc|\.(?:pem|key|p12|pfx|jks|keystore|kdbx|ppk)\b/;
|
|
748
|
+
// A word (split at white space and `/`) with a dot that starts it, or follows one of `=:(;&|<>`, and a glob character or an expansion after that dot (`.e*`,
|
|
749
|
+
// `a=.e?v`, `.e$X`). One pass over the words and the first such dot only (a later dot's rest is part of its rest): linear, where one regular expression
|
|
750
|
+
// for it took seconds on a 100 KB command of `a=.b` repeated.
|
|
751
|
+
const DOT_LEAD = '=:(;&|<>';
|
|
752
|
+
function dotWordSpelled(text) {
|
|
753
|
+
for (const word of text.split(/[\s/]+/)) {
|
|
754
|
+
let dot = word.indexOf('.');
|
|
755
|
+
while (dot > 0 && !DOT_LEAD.includes(word[dot - 1]))
|
|
756
|
+
dot = word.indexOf('.', dot + 1);
|
|
757
|
+
if (dot !== -1 && /[*?[$`]/.test(word.slice(dot + 1)))
|
|
758
|
+
return true;
|
|
759
|
+
}
|
|
760
|
+
return false;
|
|
761
|
+
}
|
|
762
|
+
// A variable set to a dot word (`A=.e; B=nv; cat $A$B`) and an expansion anywhere: the name may be spelled by joining them.
|
|
763
|
+
const ASSIGNED_DOT_WORD = /(?:^|[\s;&|(])[a-z_]\w*=\.[^\s/;&|)]*/;
|
|
764
|
+
const SECRET_VARIABLE_USE = new RegExp(`\\$\\{?!?\\w*(?:${exports.ENV_FILTER_SECRET_NAME.source.toLowerCase()})`);
|
|
765
|
+
function mayReadSecret(command) {
|
|
766
|
+
const text = command.replace(/['"\\]/g, '').toLowerCase();
|
|
767
|
+
return SECRET_FILE_WORDS.test(text) || dotWordSpelled(text) || (text.includes('$') && ASSIGNED_DOT_WORD.test(text)) || SECRET_VARIABLE_USE.test(text);
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* `link` when a word of the command is a symbolic link, or a path through one (it reads as what it points at, whatever its name; the text alone cannot say);
|
|
771
|
+
* with `onlySecret` (a command the policy reads whole), only a link whose target is a secret path. `unread` when the command has more words than the limit.
|
|
772
|
+
* A word is read as a path from the working folder, the home (`~`, `$HOME`) and any folder the command names that exists (a `cd` target); words that hold an
|
|
773
|
+
* expansion or a glob are not paths yet. The mapper is then given the real `realpath` for this command, and only for it. A command with more words than the
|
|
774
|
+
* limit is asked of the mapper, so an unread word is never a pass.
|
|
775
|
+
*/
|
|
776
|
+
const LINK_WORD_LIMIT = 2048;
|
|
777
|
+
// A subshell `(cat x)`, a substitution `$(cat x)` and a backtick pair `cat x` end a word with `)` or a backtick, so the words are read split there too
|
|
778
|
+
// (WORD_BREAK has neither). A name that holds one of those characters is still read whole: a word is a path in either spelling.
|
|
779
|
+
const LINK_WORD_BREAK = /[\s=:<>(;&|)`}]+/;
|
|
780
|
+
/** `value` when it is an absolute path (the keyless seatbelt's own test for an event's cwd, and no NUL byte), else undefined. */
|
|
781
|
+
function absoluteFolder(value) {
|
|
782
|
+
return typeof value === 'string' && value.startsWith('/') && !value.includes('\0') ? value : undefined;
|
|
783
|
+
}
|
|
784
|
+
function passwdHome() {
|
|
785
|
+
try {
|
|
786
|
+
const home = (0, node_os_1.userInfo)().homedir;
|
|
787
|
+
return typeof home === 'string' && home.startsWith('/') ? home : null;
|
|
788
|
+
}
|
|
789
|
+
catch {
|
|
790
|
+
return null;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* The folders a command's words are read in. The hook process's own (its working folder and $HOME) are always one: that is the reading every earlier build
|
|
795
|
+
* gave, so no answer is looser than it. An event that names an absolute working folder adds the keyless seatbelt's reading of it (that folder and the passwd home),
|
|
796
|
+
* so a host that spawns the hook in another folder is read as the seatbelt reads it, a relative link name included. Any of them can hold the command.
|
|
797
|
+
*/
|
|
798
|
+
function wordContexts(eventCwd) {
|
|
799
|
+
const contexts = [];
|
|
800
|
+
try {
|
|
801
|
+
contexts.push({ cwd: process.cwd(), home: process.env.HOME || (0, node_os_1.homedir)() });
|
|
802
|
+
}
|
|
803
|
+
catch { /* the hook's own folder is gone: the event's is read alone */ }
|
|
804
|
+
const eventContext = eventCwd === undefined ? null : { cwd: eventCwd, home: passwdHome() || '/nonexistent-home' };
|
|
805
|
+
if (eventContext && !contexts.some((own) => own.cwd === eventContext.cwd && own.home === eventContext.home))
|
|
806
|
+
contexts.push(eventContext);
|
|
807
|
+
return contexts;
|
|
808
|
+
}
|
|
809
|
+
function namesSymlink(command, onlySecret, contexts) {
|
|
810
|
+
const text = command.replace(/['"\\]/g, '');
|
|
811
|
+
const readable = (word) => word.length > 0 && word.length <= 4096 && !word.startsWith('-') && !/[*?[{$`]/.test(word.replace(/^\$\{?HOME\}?(?=\/|$)/, ''));
|
|
812
|
+
const whole = text.split(WORD_BREAK).filter(readable);
|
|
813
|
+
const known = new Set(whole);
|
|
814
|
+
const words = whole.concat(text.split(LINK_WORD_BREAK).filter((word) => readable(word) && !known.has(word)));
|
|
815
|
+
let unread = false;
|
|
816
|
+
for (const where of contexts) {
|
|
817
|
+
const answer = namesSymlinkIn(command, words, onlySecret, where);
|
|
818
|
+
if (answer === 'link')
|
|
819
|
+
return 'link';
|
|
820
|
+
if (answer === 'unread')
|
|
821
|
+
unread = true;
|
|
822
|
+
}
|
|
823
|
+
return unread ? 'unread' : 'none';
|
|
824
|
+
}
|
|
825
|
+
function namesSymlinkIn(command, words, onlySecret, where) {
|
|
826
|
+
const cwd = where.cwd;
|
|
827
|
+
const home = where.home.replace(/\/+$/, '');
|
|
828
|
+
const bases = [cwd];
|
|
829
|
+
const moved = (word, base) => {
|
|
830
|
+
const lexical = word === '~' || word.startsWith('~/') ? (0, node_path_1.resolve)(home, word.slice(2)) : word.startsWith('$HOME') || word.startsWith('${HOME}') ? (0, node_path_1.resolve)(home, word.replace(/^\$\{?HOME\}?\/?/, '')) : (0, node_path_1.resolve)(base, word);
|
|
831
|
+
// `/proc/self` and `/proc/thread-self` are links the kernel makes; a word under /proc is read by the env-dump words, not as a link.
|
|
832
|
+
if (lexical === '/proc' || lexical.startsWith('/proc/'))
|
|
833
|
+
return false;
|
|
834
|
+
try {
|
|
835
|
+
const real = (0, node_fs_1.realpathSync)(lexical);
|
|
836
|
+
return real !== lexical && (!onlySecret || (0, hook_tool_policy_1.isSecretPath)(real));
|
|
837
|
+
}
|
|
838
|
+
catch {
|
|
839
|
+
return false;
|
|
840
|
+
}
|
|
841
|
+
};
|
|
842
|
+
let reads = 0;
|
|
843
|
+
if (CHANGES_FOLDER.test(command)) {
|
|
844
|
+
for (const word of words) {
|
|
845
|
+
if (bases.length >= 9)
|
|
846
|
+
break;
|
|
847
|
+
const lexical = word.startsWith('/') ? word : (0, node_path_1.resolve)(cwd, word);
|
|
848
|
+
try {
|
|
849
|
+
if ((0, node_fs_1.statSync)(lexical).isDirectory())
|
|
850
|
+
bases.push(lexical);
|
|
851
|
+
}
|
|
852
|
+
catch { /* not a folder that exists */ }
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
for (const word of words) {
|
|
856
|
+
for (const base of bases) {
|
|
857
|
+
reads += 1;
|
|
858
|
+
if (reads > LINK_WORD_LIMIT)
|
|
859
|
+
return 'unread';
|
|
860
|
+
if (moved(word, base))
|
|
861
|
+
return 'link';
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
return 'none';
|
|
865
|
+
}
|
|
866
|
+
const fsGone = (error) => ['ENOENT', 'ENOTDIR'].includes(error?.code || '');
|
|
867
|
+
const fsDenied = (error) => ['EACCES', 'EPERM'].includes(error?.code || '');
|
|
868
|
+
const REAL_FS_VIEW = {
|
|
869
|
+
readFile: () => null,
|
|
870
|
+
realpath(path) { try {
|
|
871
|
+
return (0, node_fs_1.realpathSync)(path);
|
|
872
|
+
}
|
|
873
|
+
catch (error) {
|
|
874
|
+
if (fsGone(error))
|
|
875
|
+
return null;
|
|
876
|
+
throw error;
|
|
877
|
+
} },
|
|
878
|
+
readdir(path) { try {
|
|
879
|
+
return (0, node_fs_1.readdirSync)(path);
|
|
880
|
+
}
|
|
881
|
+
catch (error) {
|
|
882
|
+
if (fsGone(error) || fsDenied(error))
|
|
883
|
+
return null;
|
|
884
|
+
throw error;
|
|
885
|
+
} },
|
|
886
|
+
};
|
|
887
|
+
const TEXT_ONLY_FS_VIEW = { readFile: () => null, realpath: (path) => path };
|
|
888
|
+
// What the second run may add to the first: an S4 hit on a PATH (a secret file named, a link to one, a glob or pattern that can spell one, a secret folder
|
|
889
|
+
// walked). Never `ps_environment_loose` or a credential-printing command (`gh auth token`, `aws sts get-session-token`: not a read of a file), and never a
|
|
890
|
+
// reason the mapper only gives a word that is no path (the pattern of `grep .env README.md`) in a command the policy reads whole (policyReadsAllWords).
|
|
891
|
+
const SECOND_RUN_REASONS = /_secret_(?:path|pattern|dir)$|_secret_name$|_unknown_link$|^copy_secret_directory$|^tool_grep_glob_secret$|^tripwire_env_file$/;
|
|
892
|
+
// `eventCwd` comes first so classifyAsNamed can bind it and hand the policy a one-argument check.
|
|
893
|
+
function shellPrintsEnvironment(eventCwd, command) {
|
|
894
|
+
const contexts = wordContexts(eventCwd);
|
|
895
|
+
const where = contexts.map((context) => `${context.cwd}\0${context.home}`).join('\n');
|
|
896
|
+
if (lastEnvDumpAnswer && lastEnvDumpAnswer.command === command && lastEnvDumpAnswer.where === where)
|
|
897
|
+
return lastEnvDumpAnswer.dumps;
|
|
898
|
+
const secretNamed = mayReadSecret(command);
|
|
899
|
+
const readsWhole = (0, hook_tool_policy_1.policyReadsAllWords)(command);
|
|
900
|
+
const links = namesSymlink(command, readsWhole, contexts);
|
|
901
|
+
// A command the policy reads whole is held when a link in it points at a secret path (the policy judges the target as it judges any path); any other
|
|
902
|
+
// command that names a link, or has too many words to read, is asked of the mapper.
|
|
903
|
+
const linkedSecret = readsWhole && links === 'link';
|
|
904
|
+
const linked = linkedSecret || (readsWhole ? links === 'unread' : links !== 'none');
|
|
905
|
+
if (!mayPrintEnvironment(command) && !mayReadApprovalState(command) && !secretNamed && !linked)
|
|
906
|
+
return false;
|
|
907
|
+
if (linkedSecret) {
|
|
908
|
+
lastEnvDumpAnswer = { command, where, dumps: true };
|
|
909
|
+
return true;
|
|
910
|
+
}
|
|
911
|
+
let dumps;
|
|
912
|
+
try {
|
|
913
|
+
if (envDumpTools === undefined) {
|
|
914
|
+
try {
|
|
915
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
916
|
+
envDumpTools = { mapper: require('./seatbelt/mapper'), reasons: require('./seatbelt/env-dump').ENV_DUMP_REASONS };
|
|
917
|
+
}
|
|
918
|
+
catch {
|
|
919
|
+
envDumpTools = null;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
if (!envDumpTools)
|
|
923
|
+
dumps = ENV_DUMP_WORDS.test(command) || mayReadApprovalState(command) || secretNamed || linked;
|
|
924
|
+
else {
|
|
925
|
+
const { mapper, reasons } = envDumpTools;
|
|
926
|
+
const namesMarrow = /approvals|\.marrow/i.test(command.replace(/['"\\]/g, ''));
|
|
927
|
+
const ask = (fs, at) => mapper.evaluateSeatbeltShell(command, {
|
|
928
|
+
cwd: at.cwd,
|
|
929
|
+
home: at.home,
|
|
930
|
+
tmpdir: '/tmp',
|
|
931
|
+
platform: process.platform,
|
|
932
|
+
fs,
|
|
933
|
+
git: { toplevel: () => null, getAll: () => [], currentBranch: () => null, remoteHead: () => null },
|
|
934
|
+
});
|
|
935
|
+
// The same answer holds both reads: the env-dump reasons, and the host-approvals state file: the reason the mapper adds to a hit on it, or
|
|
936
|
+
// any S4 or S0 stop of a command that names the folder or ~/.marrow (a wrapper, a copy, a walk that reaches it); and a mapper that cannot decide
|
|
937
|
+
// (an error, an exhausted budget) on a command that may read a secret.
|
|
938
|
+
const unsure = ENV_DUMP_WORDS.test(command) || mayReadApprovalState(command) || secretNamed || linked;
|
|
939
|
+
const holds = (decision, paths) => decision.hits.some((hit) => (hit.category === 'S4' && (reasons.has(hit.reason) || hit.reason === secret_paths_1.APPROVAL_STATE_REASON || (paths && SECOND_RUN_REASONS.test(hit.reason)))) || ((hit.category === 'S4' || hit.category === 'S0') && namesMarrow)
|
|
940
|
+
|| (MAPPER_UNSURE.test(hit.reason) && unsure));
|
|
941
|
+
// The text alone is read first, exactly as before. A command that can name a secret file inside code the policy does not read (a wrapper, a glob, a
|
|
942
|
+
// variable, a name it cannot parse), or names a link, is then read again with the real `realpath` and `readdir` (no file contents): a word is a secret
|
|
943
|
+
// path only where the file is there (`process.env` in `grep -rn process.env src` names no file; the text alone cannot say), a link reads as its target
|
|
944
|
+
// and a glob as the names it expands to. Either run can hold the command, so no answer is looser than the text's.
|
|
945
|
+
if (!contexts.length)
|
|
946
|
+
throw new Error('no working folder');
|
|
947
|
+
dumps = contexts.some((at) => holds(ask(TEXT_ONLY_FS_VIEW, at), false) || (((secretNamed && !readsWhole) || linked) && holds(ask(REAL_FS_VIEW, at), true)));
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
catch {
|
|
951
|
+
dumps = ENV_DUMP_WORDS.test(command) || mayReadApprovalState(command) || secretNamed || linked;
|
|
952
|
+
}
|
|
953
|
+
lastEnvDumpAnswer = { command, where, dumps };
|
|
954
|
+
return dumps;
|
|
955
|
+
}
|
|
956
|
+
/** The classification of a call under its own tool name only (every tool before the built-ins were hooked). */
|
|
957
|
+
function classifyAsNamed(event, options) {
|
|
519
958
|
const tool = String(event.tool_name || 'tool').slice(0, 64);
|
|
520
959
|
const normalizedTool = (0, hook_tool_policy_1.normalizeHookToolName)(tool);
|
|
521
960
|
// Editing and task tools are judged by their target; shell-like words in the
|
|
@@ -524,8 +963,10 @@ function classifyTool(event, options = {}) {
|
|
|
524
963
|
const command = shellGoverned ? (0, hook_tool_policy_1.hookToolCommand)(event) : '';
|
|
525
964
|
const input = (0, hook_tool_policy_1.toolClassificationText)(event).toLowerCase();
|
|
526
965
|
// A look-alike of Marrow's own tool is never read-only, whatever its verb.
|
|
527
|
-
|
|
528
|
-
const
|
|
966
|
+
// The PostToolUse path passes no check (options.postTool): the mapper is never loaded for it.
|
|
967
|
+
const printsEnvironment = options.postTool === true ? undefined : shellPrintsEnvironment.bind(null, options.cwd);
|
|
968
|
+
const readOnly = options.ownUnproven !== true && (0, hook_tool_policy_1.isReadOnlyToolEventAsNamed)(event, printsEnvironment);
|
|
969
|
+
const secretAccess = (0, hook_tool_policy_1.isSecretMaterialAccessAsNamed)(event, printsEnvironment);
|
|
529
970
|
const protectedShellCommand = shellGoverned && (0, hook_tool_policy_1.isProtectedShellMutation)(command);
|
|
530
971
|
const infrastructureDeployment = /\b(?:kubectl|terraform|pulumi|helm)\b/.test(command.toLowerCase())
|
|
531
972
|
&& protectedShellCommand;
|
|
@@ -555,6 +996,11 @@ function classifyTool(event, options = {}) {
|
|
|
555
996
|
|| protectedShellCommand
|
|
556
997
|
|| ((0, hook_tool_policy_1.isMcpHookTool)(event.tool_name) && (options.ownUnproven === true || !(0, hook_tool_policy_1.isOfficialMarrowMcpTool)(event.tool_name)))
|
|
557
998
|
|| (['use_mcp_tool', 'use_tool'].includes(normalizedTool) && !(0, hook_tool_policy_1.isOfficialMarrowMcpEvent)(event)));
|
|
999
|
+
return { tool, type, surfaces, readOnly, protected: protectedAction };
|
|
1000
|
+
}
|
|
1001
|
+
function classificationFrom(parts) {
|
|
1002
|
+
const { tool, type, surfaces, readOnly } = parts;
|
|
1003
|
+
const protectedAction = parts.protected;
|
|
558
1004
|
const risk = readOnly ? 'low' : protectedAction ? 'high' : 'medium';
|
|
559
1005
|
const target = surfaces.includes('npm') ? `npm:${type}`
|
|
560
1006
|
: surfaces.includes('github') ? `github:${type}`
|
|
@@ -585,7 +1031,8 @@ function cursorPreActionHookOutput(result) {
|
|
|
585
1031
|
return { permission: 'deny', user_message: denial, agent_message: denial };
|
|
586
1032
|
}
|
|
587
1033
|
if (protectedRisk && (!runtime || !permit?.verified)) {
|
|
588
|
-
|
|
1034
|
+
// 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.
|
|
1035
|
+
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
1036
|
return {
|
|
590
1037
|
permission: 'deny',
|
|
591
1038
|
user_message: denial,
|
|
@@ -608,12 +1055,10 @@ function clinePreActionHookOutput(result) {
|
|
|
608
1055
|
};
|
|
609
1056
|
}
|
|
610
1057
|
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
1058
|
return {
|
|
614
1059
|
cancel: true,
|
|
615
|
-
errorMessage: credentialsUnavailable
|
|
616
|
-
? result.enforcementError
|
|
1060
|
+
errorMessage: result.credentialsUnavailable === true
|
|
1061
|
+
? fitHookText(result.enforcementError, CLINE_ERROR_LIMIT)
|
|
617
1062
|
: result.failure === 'credential_scope'
|
|
618
1063
|
? '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
1064
|
: result.failure === 'unavailable'
|
|
@@ -889,6 +1334,18 @@ function installPreActionHook(startDir = process.cwd()) {
|
|
|
889
1334
|
fs.writeFileSync(path, JSON.stringify(settings, null, 2) + '\n');
|
|
890
1335
|
return { settingsPath: path, installed: reconciled.changed };
|
|
891
1336
|
}
|
|
1337
|
+
/**
|
|
1338
|
+
* `marrow-mcp seatbelt-status`: exactly one line of JSON, exit 0. Read-only. On any error the unknown line.
|
|
1339
|
+
* The installer's doctor reads it; it is not a user feature.
|
|
1340
|
+
*/
|
|
1341
|
+
function runSeatbeltStatusCommand() {
|
|
1342
|
+
let status = { mode: 'unknown', reasons: [] };
|
|
1343
|
+
try {
|
|
1344
|
+
status = require('./seatbelt/hook').seatbeltStatus();
|
|
1345
|
+
}
|
|
1346
|
+
catch { /* the unknown line */ }
|
|
1347
|
+
process.stdout.write(`${JSON.stringify({ mode: status.mode, reasons: status.reasons })}\n`);
|
|
1348
|
+
}
|
|
892
1349
|
/**
|
|
893
1350
|
* I3: whether a real outage (the gate never answered) holds this action. Never looser than master,
|
|
894
1351
|
* which allowed every action in an outage. A shell command is read by the seatbelt mapper: held for an
|
|
@@ -908,6 +1365,37 @@ function outageHolds(classifiedHighRisk, read) {
|
|
|
908
1365
|
return false;
|
|
909
1366
|
return classifiedHighRisk;
|
|
910
1367
|
}
|
|
1368
|
+
/**
|
|
1369
|
+
* The hold text of the keyless fallback, naming its reason. The words live in the seatbelt module (a lazy
|
|
1370
|
+
* require, like the rest of the keyless path); if it cannot load the generic wording stands.
|
|
1371
|
+
*/
|
|
1372
|
+
function setupUnconfirmedHold(modeReason) {
|
|
1373
|
+
try {
|
|
1374
|
+
return require('./seatbelt/fallback-copy').setupUnconfirmedText(modeReason);
|
|
1375
|
+
}
|
|
1376
|
+
catch {
|
|
1377
|
+
return exports.SETUP_UNCONFIRMED_TEXT;
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
let keylessHoldTexts = null;
|
|
1381
|
+
/**
|
|
1382
|
+
* True only for the fixed texts the keyless path sets as enforcementError (NO_KEY_TEXT, CLAIMED_NO_KEY_TEXT, or one
|
|
1383
|
+
* setupUnconfirmedHold text per row of the fallback table). An exact match against that generated set: never a prefix,
|
|
1384
|
+
* a pattern or a flag, so no other text, whatever it starts with, is taken for one of them.
|
|
1385
|
+
*/
|
|
1386
|
+
function isKeylessHoldText(text) {
|
|
1387
|
+
if (typeof text !== 'string')
|
|
1388
|
+
return false;
|
|
1389
|
+
if (text === exports.NO_KEY_TEXT || text === exports.CLAIMED_NO_KEY_TEXT || text === exports.SETUP_UNCONFIRMED_TEXT)
|
|
1390
|
+
return true;
|
|
1391
|
+
try {
|
|
1392
|
+
keylessHoldTexts ??= new Set(require('./seatbelt/fallback-copy').setupUnconfirmedTexts());
|
|
1393
|
+
return keylessHoldTexts.has(text);
|
|
1394
|
+
}
|
|
1395
|
+
catch {
|
|
1396
|
+
return false;
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
911
1399
|
function seatbeltEvent(source, rawEvent) {
|
|
912
1400
|
const raw = asRecord(rawEvent) || {};
|
|
913
1401
|
const event = source;
|
|
@@ -1025,6 +1513,9 @@ async function runPreActionHook(input, watch) {
|
|
|
1025
1513
|
const sessionId = resolved.sessionId || source.session_id || source.conversation_id || source.task_id
|
|
1026
1514
|
|| (0, hook_contract_1.stableSessionWorkflowId)(undefined, [identity.harness, process.cwd()]);
|
|
1027
1515
|
const agentId = identity.agent_id;
|
|
1516
|
+
// With a resolved key, the no-device-key fallback may make its approval state (nokeyFallbackKey); with none it never does.
|
|
1517
|
+
if (resolved.apiKey)
|
|
1518
|
+
(0, host_approval_state_1.allowNokeyFallbackCreate)();
|
|
1028
1519
|
const correlation = (0, hook_contract_1.stableToolCorrelation)({ ...source, session_id: sessionId });
|
|
1029
1520
|
const loopOperation = {
|
|
1030
1521
|
sessionId,
|
|
@@ -1074,6 +1565,8 @@ async function runPreActionHook(input, watch) {
|
|
|
1074
1565
|
// The keyless hold text when the seatbelt does not answer: master's no-key text in seatbelt mode (a
|
|
1075
1566
|
// tool the mapper does not recognise, audit H1), the claimed_no_key texts otherwise.
|
|
1076
1567
|
let keylessSeatbeltMode = false;
|
|
1568
|
+
// Why the stricter fallback is on (words only, read from the mode resolution; the mode is decided elsewhere).
|
|
1569
|
+
let keylessModeReason = null;
|
|
1077
1570
|
if (!resolved.apiKey) {
|
|
1078
1571
|
let keyless;
|
|
1079
1572
|
try {
|
|
@@ -1084,6 +1577,7 @@ async function runPreActionHook(input, watch) {
|
|
|
1084
1577
|
}
|
|
1085
1578
|
keylessAccountLinked = keyless.accountLinked === true;
|
|
1086
1579
|
keylessSeatbeltMode = keyless.mode === 'seatbelt';
|
|
1580
|
+
keylessModeReason = typeof keyless.modeReason === 'string' ? keyless.modeReason : null;
|
|
1087
1581
|
if (keyless.stopText) {
|
|
1088
1582
|
emitLoopGuardDenial(identity.harness, keyless.stopText);
|
|
1089
1583
|
return;
|
|
@@ -1142,7 +1636,8 @@ async function runPreActionHook(input, watch) {
|
|
|
1142
1636
|
runtime: null,
|
|
1143
1637
|
permit: null,
|
|
1144
1638
|
protectedRisk: enforcementRequired,
|
|
1145
|
-
enforcementError: keylessSeatbeltMode ? exports.NO_KEY_TEXT : keylessAccountLinked ? exports.CLAIMED_NO_KEY_TEXT :
|
|
1639
|
+
enforcementError: keylessSeatbeltMode ? exports.NO_KEY_TEXT : keylessAccountLinked ? exports.CLAIMED_NO_KEY_TEXT : setupUnconfirmedHold(keylessModeReason),
|
|
1640
|
+
credentialsUnavailable: true,
|
|
1146
1641
|
}, identity.harness);
|
|
1147
1642
|
return;
|
|
1148
1643
|
}
|
|
@@ -1185,7 +1680,9 @@ async function runPreActionHook(input, watch) {
|
|
|
1185
1680
|
return;
|
|
1186
1681
|
}
|
|
1187
1682
|
if (waited?.kind === 'deny') {
|
|
1188
|
-
|
|
1683
|
+
// 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).
|
|
1684
|
+
const userNotice = identity.harness === 'claude-code' ? (0, host_approval_1.holdNotice)(waited.hold, (0, host_approval_1.heldKindLabel)(localApprovalCategories(classified)), waited.waiting === true) : null;
|
|
1685
|
+
emitHeldPlan(identity.harness, { kind: 'deny', agentText: waited.agentText, userText: waited.userText, code: Boolean(waited.hold.code), ...(userNotice ? { userNotice } : {}) }, waited.hold.code);
|
|
1189
1686
|
await afterAnswer(holdContext, { link: waited.deferredLink ? waited.hold : null });
|
|
1190
1687
|
return;
|
|
1191
1688
|
}
|