@getmarrow/mcp 3.9.101 → 3.9.103
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 +63 -29
- package/dist/cli.js +64 -1005
- 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 +43 -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 +2 -0
- package/dist/env.d.ts.map +1 -1
- package/dist/env.js +229 -39
- package/dist/env.js.map +1 -1
- package/dist/hook-contract.d.ts +41 -3
- package/dist/hook-contract.d.ts.map +1 -1
- package/dist/hook-contract.js +126 -18
- package/dist/hook-contract.js.map +1 -1
- package/dist/hook-pre-action.d.ts +48 -2
- package/dist/hook-pre-action.d.ts.map +1 -1
- package/dist/hook-pre-action.js +199 -23
- package/dist/hook-pre-action.js.map +1 -1
- package/dist/hook-tool-policy.d.ts +3 -3
- package/dist/hook-tool-policy.d.ts.map +1 -1
- package/dist/hook-tool-policy.js +6 -22
- package/dist/hook-tool-policy.js.map +1 -1
- package/dist/hook.d.ts +2 -2
- package/dist/hook.d.ts.map +1 -1
- package/dist/hook.js +13 -6
- package/dist/hook.js.map +1 -1
- package/dist/host-approval-state.d.ts +9 -0
- package/dist/host-approval-state.d.ts.map +1 -1
- package/dist/host-approval-state.js +4 -0
- package/dist/host-approval-state.js.map +1 -1
- package/dist/host-approval.d.ts +6 -0
- package/dist/host-approval.d.ts.map +1 -1
- package/dist/host-approval.js +3 -1
- 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.map +1 -1
- package/dist/index.js +27 -19
- package/dist/index.js.map +1 -1
- package/dist/normalized-action.d.ts +29 -6
- package/dist/normalized-action.d.ts.map +1 -1
- package/dist/normalized-action.js +497 -103
- package/dist/normalized-action.js.map +1 -1
- package/dist/own-server-identity.d.ts +123 -0
- package/dist/own-server-identity.d.ts.map +1 -0
- package/dist/own-server-identity.js +1052 -0
- package/dist/own-server-identity.js.map +1 -0
- package/dist/own-tool.d.ts +87 -0
- package/dist/own-tool.d.ts.map +1 -0
- package/dist/own-tool.js +169 -0
- package/dist/own-tool.js.map +1 -0
- 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/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 +72 -0
- package/dist/seatbelt/hook.d.ts.map +1 -0
- package/dist/seatbelt/hook.js +362 -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 +10248 -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 +359 -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/tool-catalog.d.ts +6852 -0
- package/dist/tool-catalog.d.ts.map +1 -0
- package/dist/tool-catalog.js +999 -0
- package/dist/tool-catalog.js.map +1 -0
- package/dist/types.d.ts +2 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -1
- package/server.json +10 -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
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.FLAG_CODES = exports.RETENTION_DAYS = exports.MAX_TOTAL_BYTES = exports.MAX_RECEIPT_BYTES = void 0;
|
|
37
|
+
exports.validateReceipt = validateReceipt;
|
|
38
|
+
exports.writeSeatbeltReceipt = writeSeatbeltReceipt;
|
|
39
|
+
exports.deviceSalt = deviceSalt;
|
|
40
|
+
exports.recordSeatbeltDecision = recordSeatbeltDecision;
|
|
41
|
+
// Local receipts for seatbelt decisions (SEATBELT-SPEC.md §1.5, U6): a private, readable record that
|
|
42
|
+
// "Marrow stopped X", written on this machine only. U5 calls recordSeatbeltDecision() once per decision.
|
|
43
|
+
//
|
|
44
|
+
// Every receipt field is an enum, a bounded integer, a fixed-format id or a keyed hash (validateReceipt
|
|
45
|
+
// enforces it; a record that fails it is dropped, never written). There is no command text, argument,
|
|
46
|
+
// flag value, path, URL, host, MCP server or tool name, or branch name anywhere. The three keys are
|
|
47
|
+
// HMAC-SHA256(device_salt, ...) cut to 128 bits; the salt (32 random bytes, 0600) never leaves the machine.
|
|
48
|
+
//
|
|
49
|
+
// Receipts are evidence, never authority: nothing reads them to allow anything. A failure to write one
|
|
50
|
+
// (unsafe store, full disk, lock timeout, cap) never changes a decision: a stop stays a stop, a pass
|
|
51
|
+
// passes, and the failure is counted as `receipt_drop` in state.json. Nothing here touches the network.
|
|
52
|
+
const nodeFs = __importStar(require("node:fs"));
|
|
53
|
+
const node_crypto_1 = require("node:crypto");
|
|
54
|
+
const node_os_1 = require("node:os");
|
|
55
|
+
const node_path_1 = require("node:path");
|
|
56
|
+
const hook_contract_1 = require("../hook-contract");
|
|
57
|
+
const normalized_action_1 = require("../normalized-action");
|
|
58
|
+
const classifier_pin_1 = require("./classifier-pin");
|
|
59
|
+
const tables_1 = require("./tables");
|
|
60
|
+
exports.MAX_RECEIPT_BYTES = 16 * 1024;
|
|
61
|
+
exports.MAX_TOTAL_BYTES = 50 * 1024 * 1024;
|
|
62
|
+
exports.RETENTION_DAYS = 90;
|
|
63
|
+
const LOCK_WAIT_MS = 150;
|
|
64
|
+
/** A stop's receipt is the first-hour record, so it waits longer for the writer lock than a pass does. */
|
|
65
|
+
const STOP_LOCK_WAIT_MS = 600;
|
|
66
|
+
const LOCK_STALE_MS = 500;
|
|
67
|
+
const ZERO_PREV = '0'.repeat(64);
|
|
68
|
+
const FILE_NAME = /^\d{4}-\d{2}-\d{2}\.jsonl$/;
|
|
69
|
+
const DROP_REASONS = ['unsafe_store', 'lock_timeout', 'write_failed', 'cap_full', 'oversize', 'platform', 'error'];
|
|
70
|
+
class Refusal extends Error {
|
|
71
|
+
reason;
|
|
72
|
+
constructor(reason) {
|
|
73
|
+
super(reason);
|
|
74
|
+
this.reason = reason;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Closed vocabularies
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
const HARNESS_ENUM = {
|
|
81
|
+
'claude-code': 'claude_code', cline: 'cline', codex: 'codex', cursor: 'cursor', gemini: 'gemini', grok: 'grok', windsurf: 'windsurf', 'mcp-client': 'mcp_client',
|
|
82
|
+
};
|
|
83
|
+
const HARNESSES = new Set([...Object.values(HARNESS_ENUM), 'other']);
|
|
84
|
+
const READ_TOOLS = new Set(['read', 'notebookread', 'read_file', 'view', 'view_file', 'grep', 'grep_files', 'search', 'rg', 'glob', 'ls', 'list_dir', 'list_directory']);
|
|
85
|
+
const OTHER_TOOLS = ['webfetch', 'websearch', 'task', 'agent', 'todowrite', 'skill', 'askuserquestion', 'exitplanmode'];
|
|
86
|
+
const TOOL_NAMES = new Set([...tables_1.SHELL_TOOL_NAMES, ...tables_1.FILE_TOOL_NAMES, ...OTHER_TOOLS, 'mcp', 'other']);
|
|
87
|
+
const TOOL_KINDS = new Set(['shell', 'file_read', 'file_write', 'mcp', 'other']);
|
|
88
|
+
const MODES = new Set(['seatbelt', 'claimed_no_key', 'account']);
|
|
89
|
+
const ENFORCEMENTS = new Set(['enforce', 'watch']);
|
|
90
|
+
const DECISIONS = new Set(['pass', 'stop', 'ask']);
|
|
91
|
+
const CATEGORIES = new Set(['none', 'S0', 'S1', 'S2', 'S3', 'S4', 'S5', 'Sx', 'hook', 'unknown']);
|
|
92
|
+
const PARSES = new Set(['ok', 'dynamic', 'unparsed', 'too_large', 'unknown']);
|
|
93
|
+
/**
|
|
94
|
+
* The receipt schema accepts every class below, but this release writes only four: `protected_branch`
|
|
95
|
+
* (S1 on a protected branch, and a plain push to one), `prod_env` (S2 with the `prod` flag),
|
|
96
|
+
* `outside_workspace` (S5) and `unknown` for everything else. `other_branch`, `in_workspace`,
|
|
97
|
+
* `home_credential` and `project_env` need a target on each mapper hit (a later unit); read `unknown`
|
|
98
|
+
* as "not computed", never as "no target".
|
|
99
|
+
*/
|
|
100
|
+
const TARGET_CLASSES = new Set(['protected_branch', 'other_branch', 'in_workspace', 'outside_workspace', 'home_credential', 'project_env', 'prod_env', 'unknown']);
|
|
101
|
+
/** Flags the mapper emits (test/seatbelt-u6-receipts reads the source to prove it) plus the two the receipt adds for the account floor. */
|
|
102
|
+
exports.FLAG_CODES = new Set([
|
|
103
|
+
'depth_limit', 'dry_run_final_true', 'pages_branch_from_git', 'prod', 'protected_branch_push', 'remote_exec', 'tripwire_clear', 'delete', 'force',
|
|
104
|
+
'parse_dynamic', 'parse_unparsed', 'parse_too_large', 'floor_watch', 'floor_skipped',
|
|
105
|
+
]);
|
|
106
|
+
const PROGRAMS = new Set([...tables_1.KNOWN_COMMANDS, ...tables_1.INERT_PROGRAMS, 'other']);
|
|
107
|
+
const SUB_PROGRAMS = new Set(['git', 'gh', 'npm', 'pnpm', 'yarn', 'bun', 'npx', 'cargo', 'docker', 'podman', 'kubectl', 'helm', 'terraform', 'tofu', 'pulumi',
|
|
108
|
+
'wrangler', 'vercel', 'netlify', 'fly', 'flyctl', 'firebase', 'heroku', 'gcloud', 'aws', 'az', 'twine', 'gem', 'uv', 'poetry', 'make', 'marrow', 'marrow-install']);
|
|
109
|
+
const RISKY_SUBCOMMANDS = ['push', 'publish', 'deploy', 'release', 'delete', 'destroy', 'apply', 'rm', 'unpublish', 'deprecate', 'upload', 'login', 'token', 'secret'];
|
|
110
|
+
const SUBCOMMANDS = new Set([...RISKY_SUBCOMMANDS, 'commit', 'clean', 'reset', 'rebase', 'merge', 'checkout', 'branch', 'tag', 'status', 'diff', 'log',
|
|
111
|
+
'pull', 'fetch', 'clone', 'add', 'build', 'test', 'run', 'install', 'ci', 'pack', 'create', 'edit', 'view', 'list', 'get', 'set', 'config', 'plan', 'init', 'none', 'other']);
|
|
112
|
+
const REASON = /^[a-z][a-z0-9_]{0,63}$/;
|
|
113
|
+
const RULE = /^(?:none|unknown|S[0-5x]\.[a-z0-9_]{1,48}|U5\.[a-z_]{1,32})$/;
|
|
114
|
+
const HEX32 = /^[0-9a-f]{32}$/;
|
|
115
|
+
const HEX64 = /^[0-9a-f]{64}$/;
|
|
116
|
+
const FIELD_NAMES = ['v', 'id', 'seq', 'prev', 'ts', 'ts_label', 'mode', 'enforcement', 'harness', 'event', 'tool_kind', 'tool', 'decision', 'category', 'rule', 'reasons',
|
|
117
|
+
'programs', 'subcommand', 'flags', 'target_class', 'parse', 'action_key', 'workspace_key', 'session_key', 'engine', 'latency_ms'];
|
|
118
|
+
const ENGINE_FIELDS = ['pkg', 'contract', 'source_commit', 'rules', 'tables'];
|
|
119
|
+
const isInt = (value, min, max) => typeof value === 'number' && Number.isInteger(value) && value >= min && value <= max;
|
|
120
|
+
const isList = (value, max, ok) => Array.isArray(value) && value.length <= max && value.every((item) => typeof item === 'string' && ok(item));
|
|
121
|
+
const hasHexRun = (value) => /[0-9a-f]{20,}/.test(value);
|
|
122
|
+
/** The problems with a receipt object (an empty list means valid). Used by the writer and by every consumer of the fields. */
|
|
123
|
+
function validateReceipt(value) {
|
|
124
|
+
const bad = [];
|
|
125
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
126
|
+
return ['not_an_object'];
|
|
127
|
+
const r = value;
|
|
128
|
+
for (const name of Object.keys(r))
|
|
129
|
+
if (!FIELD_NAMES.includes(name))
|
|
130
|
+
bad.push(`unknown_field:${name.slice(0, 32).replace(/[^A-Za-z0-9_]/g, '_')}`);
|
|
131
|
+
for (const name of FIELD_NAMES)
|
|
132
|
+
if (!(name in r))
|
|
133
|
+
bad.push(`missing:${name}`);
|
|
134
|
+
const str = (name, ok) => { if (typeof r[name] !== 'string' || !ok(r[name]))
|
|
135
|
+
bad.push(`bad:${name}`); };
|
|
136
|
+
if (r.v !== 1)
|
|
137
|
+
bad.push('bad:v');
|
|
138
|
+
str('id', (s) => /^sbr_[0-9a-f]{32}$/.test(s));
|
|
139
|
+
if (!isInt(r.seq, 1, Number.MAX_SAFE_INTEGER))
|
|
140
|
+
bad.push('bad:seq');
|
|
141
|
+
str('prev', (s) => HEX64.test(s));
|
|
142
|
+
if (!isInt(r.ts, 0, 4_102_444_800))
|
|
143
|
+
bad.push('bad:ts');
|
|
144
|
+
if (r.ts_label !== 'client_time')
|
|
145
|
+
bad.push('bad:ts_label');
|
|
146
|
+
str('mode', (s) => MODES.has(s));
|
|
147
|
+
str('enforcement', (s) => ENFORCEMENTS.has(s));
|
|
148
|
+
str('harness', (s) => HARNESSES.has(s));
|
|
149
|
+
if (r.event !== 'pre_tool')
|
|
150
|
+
bad.push('bad:event');
|
|
151
|
+
str('tool_kind', (s) => TOOL_KINDS.has(s));
|
|
152
|
+
str('tool', (s) => TOOL_NAMES.has(s));
|
|
153
|
+
str('decision', (s) => DECISIONS.has(s));
|
|
154
|
+
str('category', (s) => CATEGORIES.has(s));
|
|
155
|
+
str('rule', (s) => RULE.test(s));
|
|
156
|
+
if (!isList(r.reasons, 16, (s) => REASON.test(s) && !hasHexRun(s)))
|
|
157
|
+
bad.push('bad:reasons');
|
|
158
|
+
if (!isList(r.programs, 8, (s) => PROGRAMS.has(s)))
|
|
159
|
+
bad.push('bad:programs');
|
|
160
|
+
str('subcommand', (s) => SUBCOMMANDS.has(s));
|
|
161
|
+
if (!isList(r.flags, 16, (s) => exports.FLAG_CODES.has(s)))
|
|
162
|
+
bad.push('bad:flags');
|
|
163
|
+
str('target_class', (s) => TARGET_CLASSES.has(s));
|
|
164
|
+
str('parse', (s) => PARSES.has(s));
|
|
165
|
+
for (const name of ['action_key', 'workspace_key', 'session_key'])
|
|
166
|
+
str(name, (s) => HEX32.test(s));
|
|
167
|
+
const engine = r.engine;
|
|
168
|
+
if (!engine || typeof engine !== 'object' || Array.isArray(engine))
|
|
169
|
+
bad.push('bad:engine');
|
|
170
|
+
else {
|
|
171
|
+
for (const name of Object.keys(engine))
|
|
172
|
+
if (!ENGINE_FIELDS.includes(name))
|
|
173
|
+
bad.push('bad:engine_field');
|
|
174
|
+
const part = (name, re) => { if (typeof engine[name] !== 'string' || !re.test(engine[name]))
|
|
175
|
+
bad.push(`bad:engine.${name}`); };
|
|
176
|
+
part('pkg', /^\d{1,4}\.\d{1,4}\.\d{1,4}(?:[-+][0-9A-Za-z.-]{1,32})?$/);
|
|
177
|
+
part('contract', /^marrow\.classifier\.v\d{1,3}$/);
|
|
178
|
+
part('source_commit', /^[0-9a-f]{40}$/);
|
|
179
|
+
part('rules', /^marrow\.seatbelt\.rules\.v\d{1,3}$/);
|
|
180
|
+
part('tables', /^(?:[0-9a-f]{16}|unknown)$/);
|
|
181
|
+
}
|
|
182
|
+
if (!isInt(r.latency_ms, 0, 600_000))
|
|
183
|
+
bad.push('bad:latency_ms');
|
|
184
|
+
return bad;
|
|
185
|
+
}
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// Deriving the fields
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
const hmacHex = (salt, label, material) => (0, node_crypto_1.createHmac)('sha256', salt).update(`${label}\n`).update(material).digest('hex').slice(0, 32);
|
|
190
|
+
const sha256Hex = (text) => (0, node_crypto_1.createHash)('sha256').update(text).digest('hex');
|
|
191
|
+
function sortedJson(value) {
|
|
192
|
+
const order = (item, level) => {
|
|
193
|
+
if (level > 8 || !item || typeof item !== 'object')
|
|
194
|
+
return item;
|
|
195
|
+
if (Array.isArray(item))
|
|
196
|
+
return item.map((entry) => order(entry, level + 1));
|
|
197
|
+
return Object.fromEntries(Object.keys(item).sort().map((key) => [key, order(item[key], level + 1)]));
|
|
198
|
+
};
|
|
199
|
+
return JSON.stringify(order(value, 0));
|
|
200
|
+
}
|
|
201
|
+
function toolOf(name) {
|
|
202
|
+
const text = typeof name === 'string' ? name.trim() : '';
|
|
203
|
+
const lower = text.toLowerCase();
|
|
204
|
+
if (/^(?:functions\.\s*)*(?:mcp__|mcp_|mcp:)/i.test(text))
|
|
205
|
+
return { tool: 'mcp', kind: 'mcp' };
|
|
206
|
+
if (tables_1.SHELL_TOOL_NAMES.has(lower))
|
|
207
|
+
return { tool: lower, kind: 'shell' };
|
|
208
|
+
// Cline and Grok MCP calls and Gemini `<server>__<tool>` names are MCP tools too (audit H1, L2).
|
|
209
|
+
if (/^(?:use_mcp_tool|use_tool|access_mcp_resource)$/.test(lower) || /^[a-z0-9_.-]+__[a-z0-9_.-]+$/.test(lower))
|
|
210
|
+
return { tool: 'mcp', kind: 'mcp' };
|
|
211
|
+
if (READ_TOOLS.has(lower))
|
|
212
|
+
return { tool: lower, kind: 'file_read' };
|
|
213
|
+
if (tables_1.FILE_TOOL_NAMES.has(lower))
|
|
214
|
+
return { tool: lower, kind: 'file_write' };
|
|
215
|
+
if (OTHER_TOOLS.includes(lower))
|
|
216
|
+
return { tool: lower, kind: 'other' };
|
|
217
|
+
return { tool: 'other', kind: 'other' };
|
|
218
|
+
}
|
|
219
|
+
/** The keyed hash of the normalized, secret-replaced action. Actions that differ outside a secret get different keys. */
|
|
220
|
+
function actionKey(salt, event) {
|
|
221
|
+
const raw = () => {
|
|
222
|
+
try {
|
|
223
|
+
return `raw\n${String(event.tool_name)}\n${typeof event.tool_input === 'string' ? event.tool_input : JSON.stringify(event.tool_input ?? null)}`;
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return 'raw\nunserializable';
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
try {
|
|
230
|
+
const action = (0, normalized_action_1.normalizedHookAction)(event, { deadlineAt: Date.now() + 150 });
|
|
231
|
+
const input = action.tool_input;
|
|
232
|
+
const covered = typeof input.command_sha256 === 'string' || typeof input.input_sha256 === 'string';
|
|
233
|
+
const empty = event.tool_input === undefined || event.tool_input === null || event.tool_input === '';
|
|
234
|
+
// A placeholder (input too large or too slow to normalize) covers nothing: key the exact input so two different inputs never share a key.
|
|
235
|
+
const material = action.truncated === true && !covered && !empty ? raw() : `norm\n${sortedJson(action)}`;
|
|
236
|
+
return { key: hmacHex(salt, 'action', material), programs: action.tool_kind === 'shell' ? action.programs || [] : [] };
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return { key: hmacHex(salt, 'action', raw()), programs: [] };
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/** The git top level of cwd, else cwd itself (both realpath), or null when the workspace is unknown. */
|
|
243
|
+
function workspaceRoot(fs, cwd) {
|
|
244
|
+
if (typeof cwd !== 'string' || !cwd.startsWith('/') || cwd.length > 4096)
|
|
245
|
+
return null;
|
|
246
|
+
try {
|
|
247
|
+
const start = fs.realpathSync(cwd);
|
|
248
|
+
let dir = start;
|
|
249
|
+
for (;;) {
|
|
250
|
+
try {
|
|
251
|
+
fs.lstatSync(dir === '/' ? '/.git' : `${dir}/.git`);
|
|
252
|
+
return dir;
|
|
253
|
+
}
|
|
254
|
+
catch { /* no .git here */ }
|
|
255
|
+
if (dir === '/')
|
|
256
|
+
return start;
|
|
257
|
+
dir = dir.slice(0, dir.lastIndexOf('/')) || '/';
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/** Program basenames, only from the classifier vocabulary; anything else is `other`. */
|
|
265
|
+
function programsOf(found) {
|
|
266
|
+
const out = [];
|
|
267
|
+
for (const name of found) {
|
|
268
|
+
const base = (0, node_path_1.basename)(String(name)).toLowerCase();
|
|
269
|
+
const known = PROGRAMS.has(base) ? base : 'other';
|
|
270
|
+
if (!out.includes(known))
|
|
271
|
+
out.push(known);
|
|
272
|
+
if (out.length >= 8)
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
return out;
|
|
276
|
+
}
|
|
277
|
+
/** A subcommand word from the fixed vocabulary, found lexically after a known program. Never any other text. */
|
|
278
|
+
function subcommandOf(event) {
|
|
279
|
+
const input = event.tool_input;
|
|
280
|
+
const command = typeof input === 'string' ? input
|
|
281
|
+
: input && typeof input === 'object' && typeof input.command === 'string' ? input.command : '';
|
|
282
|
+
const words = command.slice(0, 8192).split(/[\s;&|()<>`]+/).map((w) => w.replace(/^['"\\]+|['"\\]+$/g, ''));
|
|
283
|
+
let found = 'none';
|
|
284
|
+
for (let i = 0; i < words.length; i += 1) {
|
|
285
|
+
if (!SUB_PROGRAMS.has((0, node_path_1.basename)(words[i]).toLowerCase()))
|
|
286
|
+
continue;
|
|
287
|
+
for (let j = i + 1, seen = 0; j < words.length && seen < 3; j += 1) {
|
|
288
|
+
const word = words[j].toLowerCase();
|
|
289
|
+
if (!word || word.startsWith('-'))
|
|
290
|
+
continue;
|
|
291
|
+
seen += 1;
|
|
292
|
+
if (SUBCOMMANDS.has(word) && word !== 'none' && word !== 'other') {
|
|
293
|
+
if (RISKY_SUBCOMMANDS.includes(word))
|
|
294
|
+
return word;
|
|
295
|
+
if (found === 'none' || found === 'other')
|
|
296
|
+
found = word;
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
if (found === 'none')
|
|
300
|
+
found = 'other';
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return found;
|
|
304
|
+
}
|
|
305
|
+
/** The mapper's hits carry no target, so only the classes its reason codes and flags prove are named; the rest is `unknown`. */
|
|
306
|
+
function targetClass(category, reasons, flags) {
|
|
307
|
+
if (category === 'S1')
|
|
308
|
+
return reasons.some((r) => r.includes('protected')) ? 'protected_branch' : 'unknown';
|
|
309
|
+
if (category === 'S2')
|
|
310
|
+
return flags.includes('prod') ? 'prod_env' : 'unknown';
|
|
311
|
+
if (category === 'S5')
|
|
312
|
+
return reasons.some((r) => r.endsWith('_outside_workspace')) ? 'outside_workspace' : 'unknown';
|
|
313
|
+
if (category === 'none' && flags.includes('protected_branch_push'))
|
|
314
|
+
return 'protected_branch';
|
|
315
|
+
return 'unknown';
|
|
316
|
+
}
|
|
317
|
+
let engineCache = null;
|
|
318
|
+
function engine(fs) {
|
|
319
|
+
if (engineCache)
|
|
320
|
+
return engineCache;
|
|
321
|
+
let tables = 'unknown';
|
|
322
|
+
try {
|
|
323
|
+
tables = sha256Hex(fs.readFileSync((0, node_path_1.join)(__dirname, 'tables.js'), 'latin1')).slice(0, 16);
|
|
324
|
+
}
|
|
325
|
+
catch { /* the hash is a label; a build without the file says unknown */ }
|
|
326
|
+
engineCache = { pkg: hook_contract_1.MCP_ADAPTER_VERSION, contract: classifier_pin_1.CLASSIFIER_PIN.contract, source_commit: classifier_pin_1.CLASSIFIER_PIN.source_commit, rules: tables_1.SEATBELT_RULES_VERSION, tables };
|
|
327
|
+
return engineCache;
|
|
328
|
+
}
|
|
329
|
+
const codeList = (list, ok, max) => {
|
|
330
|
+
const out = [];
|
|
331
|
+
if (Array.isArray(list))
|
|
332
|
+
for (const item of list)
|
|
333
|
+
if (typeof item === 'string' && ok(item) && !out.includes(item)) {
|
|
334
|
+
out.push(item);
|
|
335
|
+
if (out.length >= max)
|
|
336
|
+
break;
|
|
337
|
+
}
|
|
338
|
+
return out;
|
|
339
|
+
};
|
|
340
|
+
function buildReceipt(record, context, salt, fs, now) {
|
|
341
|
+
const watch = record.enforcement === 'watch';
|
|
342
|
+
const category = CATEGORIES.has(record.category) ? record.category : 'unknown';
|
|
343
|
+
const reasons = codeList(record.reasons, (s) => REASON.test(s) && !hasHexRun(s), 16);
|
|
344
|
+
const flags = codeList(record.flags, (s) => exports.FLAG_CODES.has(s), 14);
|
|
345
|
+
// Account floor in watch: the hook passed the action. A would-stop is a flag, never a claim that Marrow stopped it.
|
|
346
|
+
const decision = (record.decision === 'stop' || record.decision === 'skipped') && !watch ? 'stop' : 'pass';
|
|
347
|
+
if (watch && record.decision === 'stop')
|
|
348
|
+
flags.push('floor_watch');
|
|
349
|
+
if (watch && record.decision === 'skipped')
|
|
350
|
+
flags.push('floor_skipped');
|
|
351
|
+
const { tool, kind } = toolOf(context.tool_name);
|
|
352
|
+
const event = { tool_name: context.tool_name, tool_input: context.tool_input, input_truncated: context.truncated === true };
|
|
353
|
+
const action = actionKey(salt, event);
|
|
354
|
+
const root = workspaceRoot(fs, context.cwd);
|
|
355
|
+
const session = typeof context.session === 'string' || typeof context.session === 'number' ? String(context.session).slice(0, 256) : '';
|
|
356
|
+
return {
|
|
357
|
+
v: 1, ts: Math.floor(now / 1000), ts_label: 'client_time', mode: record.mode, enforcement: record.enforcement,
|
|
358
|
+
harness: Object.hasOwn(HARNESS_ENUM, record.harness) ? HARNESS_ENUM[record.harness] : 'other', event: 'pre_tool', tool_kind: kind, tool, decision, category,
|
|
359
|
+
rule: RULE.test(record.rule) ? record.rule : 'unknown', reasons,
|
|
360
|
+
programs: kind === 'shell' ? programsOf(action.programs) : [], subcommand: kind === 'shell' ? subcommandOf(event) : 'none',
|
|
361
|
+
flags, target_class: targetClass(category, reasons, flags),
|
|
362
|
+
parse: PARSES.has(record.parse) ? record.parse : 'unknown',
|
|
363
|
+
action_key: action.key, workspace_key: hmacHex(salt, 'workspace', root === null ? 'unknown' : `path\n${root}`),
|
|
364
|
+
session_key: hmacHex(salt, 'session', session === '' ? 'none' : `id\n${session}`),
|
|
365
|
+
engine: engine(fs), latency_ms: Number.isFinite(record.latency_ms) ? Math.min(600_000, Math.max(0, Math.round(record.latency_ms))) : 0,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
const C = nodeFs.constants;
|
|
369
|
+
const NOFOLLOW = C.O_NOFOLLOW || 0;
|
|
370
|
+
const codeOf = (error) => String(error?.code || '');
|
|
371
|
+
function lstatOrNull(fs, path) {
|
|
372
|
+
try {
|
|
373
|
+
return fs.lstatSync(path);
|
|
374
|
+
}
|
|
375
|
+
catch (error) {
|
|
376
|
+
if (codeOf(error) === 'ENOENT')
|
|
377
|
+
return null;
|
|
378
|
+
throw error;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
/** A directory we own. `strict`: no group or other access. `.marrow` itself may be a symlink to such a directory; nothing below it may. */
|
|
382
|
+
function ensureDir(fs, path, uid, strict, followLink) {
|
|
383
|
+
let st = lstatOrNull(fs, path);
|
|
384
|
+
if (!st) {
|
|
385
|
+
try {
|
|
386
|
+
fs.mkdirSync(path, { mode: 0o700 });
|
|
387
|
+
}
|
|
388
|
+
catch (error) {
|
|
389
|
+
if (codeOf(error) !== 'EEXIST')
|
|
390
|
+
throw error;
|
|
391
|
+
}
|
|
392
|
+
st = fs.lstatSync(path);
|
|
393
|
+
}
|
|
394
|
+
let real = path;
|
|
395
|
+
if (st.isSymbolicLink()) {
|
|
396
|
+
if (!followLink)
|
|
397
|
+
throw new Refusal('unsafe_store');
|
|
398
|
+
real = fs.realpathSync(path);
|
|
399
|
+
st = fs.lstatSync(real);
|
|
400
|
+
}
|
|
401
|
+
if (!st.isDirectory() || st.uid !== uid || (st.mode & (strict ? 0o077 : 0o022)) !== 0)
|
|
402
|
+
throw new Refusal('unsafe_store');
|
|
403
|
+
return real;
|
|
404
|
+
}
|
|
405
|
+
function readSalt(fs, path, uid) {
|
|
406
|
+
const fd = fs.openSync(path, C.O_RDONLY | NOFOLLOW | (C.O_NONBLOCK || 0));
|
|
407
|
+
try {
|
|
408
|
+
const st = fs.fstatSync(fd);
|
|
409
|
+
// No link-count check on the salt: it is only read, and while another hook is creating it (temp file linked into place) it briefly has two names.
|
|
410
|
+
if (!st.isFile() || st.uid !== uid || (st.mode & 0o077) !== 0 || st.size !== 32)
|
|
411
|
+
throw new Refusal('unsafe_store');
|
|
412
|
+
const buffer = Buffer.alloc(32);
|
|
413
|
+
if (fs.readSync(fd, buffer, 0, 32, 0) !== 32)
|
|
414
|
+
throw new Refusal('unsafe_store');
|
|
415
|
+
return buffer;
|
|
416
|
+
}
|
|
417
|
+
finally {
|
|
418
|
+
fs.closeSync(fd);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
/** 32 random bytes, created once: a temp file linked into place (link fails if anything, even a symlink, is already there). */
|
|
422
|
+
function loadSalt(fs, dir, uid) {
|
|
423
|
+
const path = (0, node_path_1.join)(dir, 'salt');
|
|
424
|
+
try {
|
|
425
|
+
return readSalt(fs, path, uid);
|
|
426
|
+
}
|
|
427
|
+
catch (error) {
|
|
428
|
+
if (codeOf(error) !== 'ENOENT')
|
|
429
|
+
throw error;
|
|
430
|
+
}
|
|
431
|
+
const tmp = (0, node_path_1.join)(dir, `.salt.${process.pid}.${(0, node_crypto_1.randomBytes)(6).toString('hex')}`);
|
|
432
|
+
const fd = fs.openSync(tmp, C.O_WRONLY | C.O_CREAT | C.O_EXCL | NOFOLLOW, 0o600);
|
|
433
|
+
try {
|
|
434
|
+
fs.writeSync(fd, (0, node_crypto_1.randomBytes)(32));
|
|
435
|
+
}
|
|
436
|
+
finally {
|
|
437
|
+
fs.closeSync(fd);
|
|
438
|
+
}
|
|
439
|
+
try {
|
|
440
|
+
try {
|
|
441
|
+
fs.linkSync(tmp, path);
|
|
442
|
+
}
|
|
443
|
+
catch (error) {
|
|
444
|
+
if (codeOf(error) !== 'EEXIST')
|
|
445
|
+
throw error;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
finally {
|
|
449
|
+
try {
|
|
450
|
+
fs.unlinkSync(tmp);
|
|
451
|
+
}
|
|
452
|
+
catch { /* best effort */ }
|
|
453
|
+
}
|
|
454
|
+
return readSalt(fs, path, uid);
|
|
455
|
+
}
|
|
456
|
+
/** `found.dir` is set as soon as ~/.marrow/seatbelt is a real directory we own, so a refusal below it can still be counted. */
|
|
457
|
+
function openStore(fs, home, uid, found) {
|
|
458
|
+
if (!home.startsWith('/'))
|
|
459
|
+
throw new Refusal('unsafe_store');
|
|
460
|
+
const realHome = fs.realpathSync(home);
|
|
461
|
+
const homeStat = fs.statSync(realHome);
|
|
462
|
+
if (realHome === '/' || !homeStat.isDirectory() || homeStat.uid !== uid)
|
|
463
|
+
throw new Refusal('unsafe_store');
|
|
464
|
+
const marrow = ensureDir(fs, (0, node_path_1.join)(realHome, '.marrow'), uid, false, true);
|
|
465
|
+
const dir = (0, node_path_1.join)(marrow, 'seatbelt');
|
|
466
|
+
const seen = lstatOrNull(fs, dir);
|
|
467
|
+
if (seen && seen.isDirectory() && seen.uid === uid)
|
|
468
|
+
found.dir = dir;
|
|
469
|
+
ensureDir(fs, dir, uid, true, false);
|
|
470
|
+
found.dir = dir;
|
|
471
|
+
const receipts = (0, node_path_1.join)(dir, 'receipts');
|
|
472
|
+
ensureDir(fs, receipts, uid, true, false);
|
|
473
|
+
return { fs, uid, dir, receipts, salt: loadSalt(fs, dir, uid) };
|
|
474
|
+
}
|
|
475
|
+
const sleep = (ms) => { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); };
|
|
476
|
+
/** One writer at a time (so seq/prev stay a chain). A holder that died is stolen from after LOCK_STALE_MS. */
|
|
477
|
+
function withLock(fs, dir, waitMs, work) {
|
|
478
|
+
const path = (0, node_path_1.join)(dir, 'lock');
|
|
479
|
+
const started = Date.now();
|
|
480
|
+
let fd = null;
|
|
481
|
+
while (fd === null) {
|
|
482
|
+
try {
|
|
483
|
+
fd = fs.openSync(path, C.O_WRONLY | C.O_CREAT | C.O_EXCL | NOFOLLOW, 0o600);
|
|
484
|
+
}
|
|
485
|
+
catch (error) {
|
|
486
|
+
if (codeOf(error) !== 'EEXIST')
|
|
487
|
+
throw error;
|
|
488
|
+
if (Date.now() - started > waitMs)
|
|
489
|
+
throw new Refusal('lock_timeout');
|
|
490
|
+
try {
|
|
491
|
+
const st = fs.lstatSync(path);
|
|
492
|
+
if (Date.now() - st.mtimeMs > LOCK_STALE_MS) {
|
|
493
|
+
fs.unlinkSync(path);
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
catch { /* released meanwhile, or not ours to remove */ }
|
|
498
|
+
sleep(2);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
try {
|
|
502
|
+
return work();
|
|
503
|
+
}
|
|
504
|
+
finally {
|
|
505
|
+
try {
|
|
506
|
+
fs.closeSync(fd);
|
|
507
|
+
}
|
|
508
|
+
catch { /* ignore */ }
|
|
509
|
+
try {
|
|
510
|
+
fs.unlinkSync(path);
|
|
511
|
+
}
|
|
512
|
+
catch { /* ignore */ }
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
function readState(fs, dir) {
|
|
516
|
+
const state = { v: 1, receipt_drop: 0, drop_reasons: {}, evicted_files: 0, evicted_bytes: 0 };
|
|
517
|
+
try {
|
|
518
|
+
const fd = fs.openSync((0, node_path_1.join)(dir, 'state.json'), C.O_RDONLY | NOFOLLOW | (C.O_NONBLOCK || 0));
|
|
519
|
+
try {
|
|
520
|
+
const buffer = Buffer.alloc(8192);
|
|
521
|
+
const length = fs.readSync(fd, buffer, 0, buffer.length, 0);
|
|
522
|
+
const parsed = JSON.parse(buffer.subarray(0, length).toString('utf8'));
|
|
523
|
+
const count = (n) => (typeof n === 'number' && Number.isSafeInteger(n) && n >= 0 ? n : 0);
|
|
524
|
+
state.receipt_drop = count(parsed.receipt_drop);
|
|
525
|
+
state.evicted_files = count(parsed.evicted_files);
|
|
526
|
+
state.evicted_bytes = count(parsed.evicted_bytes);
|
|
527
|
+
for (const reason of DROP_REASONS)
|
|
528
|
+
if (parsed.drop_reasons && count(parsed.drop_reasons[reason]))
|
|
529
|
+
state.drop_reasons[reason] = count(parsed.drop_reasons[reason]);
|
|
530
|
+
}
|
|
531
|
+
finally {
|
|
532
|
+
fs.closeSync(fd);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
catch { /* missing, unreadable or not a regular file: counting starts again */ }
|
|
536
|
+
return state;
|
|
537
|
+
}
|
|
538
|
+
/** Atomic replace (temp file + rename), so a crash or a full disk never leaves a half-written state.json. */
|
|
539
|
+
function updateState(fs, dir, change) {
|
|
540
|
+
const state = readState(fs, dir);
|
|
541
|
+
change(state);
|
|
542
|
+
const tmp = (0, node_path_1.join)(dir, `.state.${process.pid}.${(0, node_crypto_1.randomBytes)(6).toString('hex')}`);
|
|
543
|
+
try {
|
|
544
|
+
const fd = fs.openSync(tmp, C.O_WRONLY | C.O_CREAT | C.O_EXCL | NOFOLLOW, 0o600);
|
|
545
|
+
try {
|
|
546
|
+
fs.writeSync(fd, `${JSON.stringify(state)}\n`);
|
|
547
|
+
}
|
|
548
|
+
finally {
|
|
549
|
+
fs.closeSync(fd);
|
|
550
|
+
}
|
|
551
|
+
fs.renameSync(tmp, (0, node_path_1.join)(dir, 'state.json'));
|
|
552
|
+
}
|
|
553
|
+
catch (error) {
|
|
554
|
+
try {
|
|
555
|
+
fs.unlinkSync(tmp);
|
|
556
|
+
}
|
|
557
|
+
catch { /* ignore */ }
|
|
558
|
+
throw error;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
const ymd = (ms) => new Date(ms).toISOString().slice(0, 10);
|
|
562
|
+
function managedFiles(fs, dir) {
|
|
563
|
+
const out = [];
|
|
564
|
+
for (const name of fs.readdirSync(dir).sort()) {
|
|
565
|
+
if (!FILE_NAME.test(name))
|
|
566
|
+
continue;
|
|
567
|
+
const path = (0, node_path_1.join)(dir, name);
|
|
568
|
+
const st = lstatOrNull(fs, path);
|
|
569
|
+
if (st)
|
|
570
|
+
out.push({ name, path, size: st.isFile() ? st.size : 0 });
|
|
571
|
+
}
|
|
572
|
+
return out;
|
|
573
|
+
}
|
|
574
|
+
/** The last line of a receipt file: its sha256 and seq, or null when the file has no line. `open`: the file does not end in a newline. */
|
|
575
|
+
function tailOf(fs, fd, size) {
|
|
576
|
+
if (size <= 0)
|
|
577
|
+
return null;
|
|
578
|
+
const length = Math.min(size, exports.MAX_RECEIPT_BYTES + 2);
|
|
579
|
+
const buffer = Buffer.alloc(length);
|
|
580
|
+
const got = fs.readSync(fd, buffer, 0, length, size - length);
|
|
581
|
+
const text = buffer.subarray(0, got).toString('utf8');
|
|
582
|
+
const open = !text.endsWith('\n');
|
|
583
|
+
const body = open ? text : text.slice(0, -1);
|
|
584
|
+
const line = body.slice(body.lastIndexOf('\n') + 1);
|
|
585
|
+
let seq = null;
|
|
586
|
+
try {
|
|
587
|
+
const parsed = JSON.parse(line);
|
|
588
|
+
if (isInt(parsed.seq, 1, Number.MAX_SAFE_INTEGER - 1))
|
|
589
|
+
seq = parsed.seq;
|
|
590
|
+
}
|
|
591
|
+
catch { /* a damaged last line: the chain restarts at 1 and the break shows */ }
|
|
592
|
+
return { hash: sha256Hex(line), seq, open };
|
|
593
|
+
}
|
|
594
|
+
/** Under the lock: retention and cap, then one O_APPEND write of the next link in the chain. */
|
|
595
|
+
function append(store, body, now, options) {
|
|
596
|
+
const { fs } = store;
|
|
597
|
+
const cap = options.maxTotalBytes ?? exports.MAX_TOTAL_BYTES;
|
|
598
|
+
const todayName = `${ymd(now)}.jsonl`;
|
|
599
|
+
let files = managedFiles(fs, store.receipts);
|
|
600
|
+
// Retention counts from the newest earlier file when the clock is further ahead than that: a clock that jumped forward
|
|
601
|
+
// (a resumed VM, a reset RTC) must not wipe the history. Day names are dates, so one day of slack is a plain string step.
|
|
602
|
+
const newestEarlier = files.filter((file) => file.name < todayName).pop();
|
|
603
|
+
const basis = newestEarlier ? Math.min(now, Date.parse(`${newestEarlier.name.slice(0, 10)}T00:00:00Z`) + 2 * 86_400_000) : now;
|
|
604
|
+
const cutoff = `${ymd(basis - (options.retentionDays ?? exports.RETENTION_DAYS) * 86_400_000)}.jsonl`;
|
|
605
|
+
let evictedFiles = 0;
|
|
606
|
+
let evictedBytes = 0;
|
|
607
|
+
const evict = (file) => {
|
|
608
|
+
try {
|
|
609
|
+
fs.unlinkSync(file.path);
|
|
610
|
+
}
|
|
611
|
+
catch (error) {
|
|
612
|
+
if (codeOf(error) !== 'ENOENT')
|
|
613
|
+
throw error;
|
|
614
|
+
}
|
|
615
|
+
evictedFiles += 1;
|
|
616
|
+
evictedBytes += file.size;
|
|
617
|
+
files = files.filter((other) => other !== file);
|
|
618
|
+
};
|
|
619
|
+
// Retention first, then oldest-first until the new record fits. Today's file is never evicted.
|
|
620
|
+
for (const file of [...files])
|
|
621
|
+
if (file.name < cutoff && file.name !== todayName)
|
|
622
|
+
evict(file);
|
|
623
|
+
let total = files.reduce((sum, file) => sum + file.size, 0);
|
|
624
|
+
for (const file of [...files]) {
|
|
625
|
+
if (total + exports.MAX_RECEIPT_BYTES <= cap)
|
|
626
|
+
break;
|
|
627
|
+
if (file.name === todayName)
|
|
628
|
+
continue;
|
|
629
|
+
evict(file);
|
|
630
|
+
total -= file.size;
|
|
631
|
+
}
|
|
632
|
+
if (evictedFiles > 0) {
|
|
633
|
+
try {
|
|
634
|
+
updateState(fs, store.dir, (state) => { state.evicted_files += evictedFiles; state.evicted_bytes += evictedBytes; });
|
|
635
|
+
}
|
|
636
|
+
catch { /* the count is best effort */ }
|
|
637
|
+
}
|
|
638
|
+
if (total + exports.MAX_RECEIPT_BYTES > cap)
|
|
639
|
+
throw new Refusal('cap_full');
|
|
640
|
+
// O_NONBLOCK: a FIFO planted as a day file must not hang the hook (it is then refused: not a regular file).
|
|
641
|
+
const fd = fs.openSync((0, node_path_1.join)(store.receipts, todayName), C.O_RDWR | C.O_APPEND | C.O_CREAT | NOFOLLOW | (C.O_NONBLOCK || 0), 0o600);
|
|
642
|
+
try {
|
|
643
|
+
const st = fs.fstatSync(fd);
|
|
644
|
+
if (!st.isFile() || st.nlink !== 1 || st.uid !== store.uid || (st.mode & 0o077) !== 0)
|
|
645
|
+
throw new Refusal('unsafe_store');
|
|
646
|
+
let tail = tailOf(fs, fd, st.size);
|
|
647
|
+
if (tail === null) {
|
|
648
|
+
// First line of the day: continue the chain from the newest earlier file.
|
|
649
|
+
const earlier = files.filter((file) => file.name < todayName && file.size > 0).pop();
|
|
650
|
+
if (earlier) {
|
|
651
|
+
try {
|
|
652
|
+
const old = fs.openSync(earlier.path, C.O_RDONLY | NOFOLLOW | (C.O_NONBLOCK || 0));
|
|
653
|
+
try {
|
|
654
|
+
const earlierStat = fs.fstatSync(old);
|
|
655
|
+
if (earlierStat.isFile())
|
|
656
|
+
tail = tailOf(fs, old, earlierStat.size);
|
|
657
|
+
}
|
|
658
|
+
finally {
|
|
659
|
+
fs.closeSync(old);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
catch {
|
|
663
|
+
tail = null;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
const { v, ...rest } = body;
|
|
668
|
+
const receipt = {
|
|
669
|
+
v, id: `sbr_${(0, node_crypto_1.randomBytes)(16).toString('hex')}`, seq: tail && tail.seq !== null ? tail.seq + 1 : 1, prev: tail ? tail.hash : ZERO_PREV, ...rest,
|
|
670
|
+
};
|
|
671
|
+
if (validateReceipt(receipt).length > 0)
|
|
672
|
+
throw new Refusal('error');
|
|
673
|
+
const bytes = Buffer.from(`${st.size > 0 && tail && tail.open ? '\n' : ''}${JSON.stringify(receipt)}\n`);
|
|
674
|
+
if (bytes.length > exports.MAX_RECEIPT_BYTES)
|
|
675
|
+
throw new Refusal('oversize');
|
|
676
|
+
if (fs.writeSync(fd, bytes, 0, bytes.length) !== bytes.length)
|
|
677
|
+
throw new Refusal('write_failed');
|
|
678
|
+
}
|
|
679
|
+
finally {
|
|
680
|
+
fs.closeSync(fd);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
function dropReason(error) {
|
|
684
|
+
if (error instanceof Refusal)
|
|
685
|
+
return error.reason;
|
|
686
|
+
const code = codeOf(error);
|
|
687
|
+
if (code === 'ELOOP' || code === 'ENOTDIR' || code === 'EISDIR')
|
|
688
|
+
return 'unsafe_store';
|
|
689
|
+
return /^(?:ENOSPC|EACCES|EPERM|EIO|EROFS|EDQUOT|EMFILE|ENFILE|EBUSY)$/.test(code) ? 'write_failed' : 'error';
|
|
690
|
+
}
|
|
691
|
+
/** `receipt_drop` (and its reason) in state.json. Best effort: with no safe directory to count in, the drop stays invisible rather than risk a write. */
|
|
692
|
+
function countDrop(fs, dir, reason, waitMs) {
|
|
693
|
+
const change = (state) => { state.receipt_drop += 1; state.drop_reasons[reason] = (state.drop_reasons[reason] || 0) + 1; };
|
|
694
|
+
try {
|
|
695
|
+
try {
|
|
696
|
+
withLock(fs, dir, Math.min(waitMs, 40), () => updateState(fs, dir, change));
|
|
697
|
+
}
|
|
698
|
+
catch (error) {
|
|
699
|
+
if (!(error instanceof Refusal && error.reason === 'lock_timeout'))
|
|
700
|
+
throw error;
|
|
701
|
+
updateState(fs, dir, change);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
catch { /* see above */ }
|
|
705
|
+
}
|
|
706
|
+
function passwdHome() {
|
|
707
|
+
try {
|
|
708
|
+
const home = (0, node_os_1.userInfo)().homedir;
|
|
709
|
+
return typeof home === 'string' && home.startsWith('/') ? home : null;
|
|
710
|
+
}
|
|
711
|
+
catch {
|
|
712
|
+
return null;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
/** Write one receipt. Never throws; says what happened. The hook calls recordSeatbeltDecision; tests call this with options. */
|
|
716
|
+
function writeSeatbeltReceipt(record, context = {}, options = {}) {
|
|
717
|
+
const fs = options.fs || nodeFs;
|
|
718
|
+
const found = {};
|
|
719
|
+
let wait = options.lockWaitMs ?? LOCK_WAIT_MS;
|
|
720
|
+
try {
|
|
721
|
+
const uid = options.uid === undefined ? (typeof process.getuid === 'function' ? process.getuid() : null) : options.uid;
|
|
722
|
+
if (typeof uid !== 'number')
|
|
723
|
+
throw new Refusal('platform');
|
|
724
|
+
const home = options.home || context.home || passwdHome();
|
|
725
|
+
if (!home)
|
|
726
|
+
throw new Refusal('unsafe_store');
|
|
727
|
+
const now = options.now ?? Date.now();
|
|
728
|
+
const store = openStore(fs, home, uid, found);
|
|
729
|
+
const body = buildReceipt(record, context, store.salt, fs, now);
|
|
730
|
+
wait = options.lockWaitMs ?? (body.decision === 'stop' ? STOP_LOCK_WAIT_MS : LOCK_WAIT_MS);
|
|
731
|
+
withLock(fs, store.dir, wait, () => append(store, body, now, options));
|
|
732
|
+
return 'written';
|
|
733
|
+
}
|
|
734
|
+
catch (error) {
|
|
735
|
+
if (found.dir)
|
|
736
|
+
countDrop(fs, found.dir, dropReason(error), wait);
|
|
737
|
+
return 'dropped';
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
let deviceSaltCache = null;
|
|
741
|
+
/**
|
|
742
|
+
* This machine's private salt: the receipts store's (~/.marrow/seatbelt/salt under the passwd home, 32 random
|
|
743
|
+
* bytes, 0600, owned by this uid, read with O_NOFOLLOW, created once), with every check openStore makes. It keys
|
|
744
|
+
* hashes that leave the machine only as a MAC (normalized-action exact_hmac); the salt itself is never sent,
|
|
745
|
+
* printed or logged. null when the store is missing, unsafe, not ours or unreadable: the caller then fails closed.
|
|
746
|
+
* Cached per process. The options are test seams (home, uid, fs) and bypass the cache.
|
|
747
|
+
*/
|
|
748
|
+
function deviceSalt(options = {}) {
|
|
749
|
+
const seams = options.home !== undefined || options.uid !== undefined || options.fs !== undefined;
|
|
750
|
+
if (!seams && deviceSaltCache)
|
|
751
|
+
return deviceSaltCache.salt;
|
|
752
|
+
let salt = null;
|
|
753
|
+
try {
|
|
754
|
+
const fs = options.fs || nodeFs;
|
|
755
|
+
const uid = options.uid === undefined ? (typeof process.getuid === 'function' ? process.getuid() : null) : options.uid;
|
|
756
|
+
const home = options.home || passwdHome();
|
|
757
|
+
if (typeof uid === 'number' && home) {
|
|
758
|
+
const found = openStore(fs, home, uid, {}).salt;
|
|
759
|
+
salt = found.length === 32 ? Buffer.from(found) : null;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
catch {
|
|
763
|
+
salt = null;
|
|
764
|
+
}
|
|
765
|
+
if (!seams)
|
|
766
|
+
deviceSaltCache = { salt };
|
|
767
|
+
return salt;
|
|
768
|
+
}
|
|
769
|
+
/** The record hook U5 calls once per decision. It never throws, writes no output and never changes a decision. */
|
|
770
|
+
function recordSeatbeltDecision(record, context) {
|
|
771
|
+
try {
|
|
772
|
+
writeSeatbeltReceipt(record, context || {});
|
|
773
|
+
}
|
|
774
|
+
catch { /* the record hook never changes a decision */ }
|
|
775
|
+
}
|
|
776
|
+
//# sourceMappingURL=record.js.map
|