@getmarrow/mcp 3.9.97 → 3.9.99

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.
Files changed (52) hide show
  1. package/README.md +57 -338
  2. package/dist/cli.js +249 -47
  3. package/dist/cli.js.map +1 -1
  4. package/dist/hook-context.d.ts.map +1 -1
  5. package/dist/hook-context.js +135 -9
  6. package/dist/hook-context.js.map +1 -1
  7. package/dist/hook-contract.d.ts +26 -5
  8. package/dist/hook-contract.d.ts.map +1 -1
  9. package/dist/hook-contract.js +144 -10
  10. package/dist/hook-contract.js.map +1 -1
  11. package/dist/hook-pre-action.d.ts +40 -1
  12. package/dist/hook-pre-action.d.ts.map +1 -1
  13. package/dist/hook-pre-action.js +429 -35
  14. package/dist/hook-pre-action.js.map +1 -1
  15. package/dist/hook-session.d.ts.map +1 -1
  16. package/dist/hook-session.js +32 -0
  17. package/dist/hook-session.js.map +1 -1
  18. package/dist/hook.d.ts +12 -0
  19. package/dist/hook.d.ts.map +1 -1
  20. package/dist/hook.js +149 -5
  21. package/dist/hook.js.map +1 -1
  22. package/dist/host-approval-state.d.ts +137 -0
  23. package/dist/host-approval-state.d.ts.map +1 -0
  24. package/dist/host-approval-state.js +510 -0
  25. package/dist/host-approval-state.js.map +1 -0
  26. package/dist/host-approval.d.ts +436 -0
  27. package/dist/host-approval.d.ts.map +1 -0
  28. package/dist/host-approval.js +1417 -0
  29. package/dist/host-approval.js.map +1 -0
  30. package/dist/host-session.d.ts +82 -0
  31. package/dist/host-session.d.ts.map +1 -0
  32. package/dist/host-session.js +570 -0
  33. package/dist/host-session.js.map +1 -0
  34. package/dist/index.d.ts +150 -2
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +748 -33
  37. package/dist/index.js.map +1 -1
  38. package/dist/lifecycle-spool.d.ts.map +1 -1
  39. package/dist/lifecycle-spool.js +6 -5
  40. package/dist/lifecycle-spool.js.map +1 -1
  41. package/dist/normalized-action.d.ts +120 -0
  42. package/dist/normalized-action.d.ts.map +1 -0
  43. package/dist/normalized-action.js +1109 -0
  44. package/dist/normalized-action.js.map +1 -0
  45. package/dist/runtime-contract.d.ts +108 -4
  46. package/dist/runtime-contract.d.ts.map +1 -1
  47. package/dist/runtime-contract.js +178 -35
  48. package/dist/runtime-contract.js.map +1 -1
  49. package/dist/types.d.ts +128 -0
  50. package/dist/types.d.ts.map +1 -1
  51. package/package.json +21 -25
  52. package/server.json +15 -4
@@ -0,0 +1,137 @@
1
+ /** A hold outlives its 30-minute gate receipt only long enough to settle a late report. */
2
+ export declare const HOLD_RECORD_TTL_MS: number;
3
+ export declare const APPROVAL_CODE: RegExp;
4
+ export type HoldMode = 'ask' | 'wait';
5
+ export type HoldState = 'open' | 'allowed' | 'resolved';
6
+ export type HoldReport = {
7
+ verdict: 'approved' | 'declined';
8
+ host: string;
9
+ host_session_id: string;
10
+ hook_event: string | null;
11
+ pre_action_event_id: string | null;
12
+ asked_at: string;
13
+ answered_at: string;
14
+ decision_id?: string;
15
+ /** The exact action answered for (secrets removed); kept only until the report is delivered. */
16
+ normalized_action?: Record<string, unknown>;
17
+ };
18
+ export type HoldCommit = {
19
+ success: boolean;
20
+ outcome: string;
21
+ };
22
+ export type HoldOutbox = {
23
+ report: HoldReport | null;
24
+ /** The commit that follows the report (a denial, or an outcome that needs no proof). */
25
+ commit: HoldCommit | null;
26
+ attempts: number;
27
+ next_at: number;
28
+ };
29
+ export type HoldRecord = {
30
+ id: string;
31
+ key_ref: string;
32
+ host: string;
33
+ harness: string;
34
+ session_id: string;
35
+ host_session_id: string;
36
+ agent_id: string | null;
37
+ correlation: string;
38
+ tool_use_id: string | null;
39
+ generation_id: string | null;
40
+ tool_name: string;
41
+ hook_event: string;
42
+ mode: HoldMode;
43
+ state: HoldState;
44
+ gate_receipt_id: string;
45
+ decision_id: string | null;
46
+ asked_at: string;
47
+ dialog_at: string | null;
48
+ pre_action_event_id: string | null;
49
+ proof_required: boolean;
50
+ proof_fields: string[];
51
+ expires_at: string | null;
52
+ code: string | null;
53
+ /**
54
+ * The owner's one-tap approval link: sent; to send (now, or again after a
55
+ * retryable failure); sent only when the operator asks by retrying the action
56
+ * (on_request); could not be sent and will not be retried (failed); or not
57
+ * offered for this hold (null).
58
+ */
59
+ owner_link?: 'sent' | 'unsent' | 'on_request' | 'failed' | null;
60
+ /** Link requests made for this hold (the server allows a few per receipt). */
61
+ link_attempts?: number;
62
+ /** Denied only because Claude Code showed no dialog; a retry where it can asks instead. */
63
+ dialog_later?: boolean;
64
+ /** The host's own prompt text for a later ask (the notice and the reason). */
65
+ ask_text?: string | null;
66
+ /** An older Marrow service without chat or terminal approvals: only the owner approves. */
67
+ legacy_service?: boolean;
68
+ /** Arbitration review: the owner picks a proposal; the commit needs these receipts. */
69
+ arbitration_receipt_id?: string | null;
70
+ /** A hold that waits quietly: a person is here but cannot be asked, or nobody is (unattended). */
71
+ quiet?: 'attended' | 'unattended' | null;
72
+ /** Left to the host's own approval step, which Marrow does not observe. */
73
+ not_observed?: boolean;
74
+ /** The normalized action the runtime call carried (hashes and program names only). */
75
+ normalized_action?: Record<string, unknown>;
76
+ /** Arbitration: a person was at this session when it was held (a retry there is their request for the owner's link). */
77
+ person_present?: boolean;
78
+ action: {
79
+ action: string;
80
+ target: string;
81
+ type: string;
82
+ surfaces: string[];
83
+ };
84
+ outbox: HoldOutbox | null;
85
+ created_at: number;
86
+ updated_at: number;
87
+ };
88
+ export declare class UnsafeHostApprovalStateError extends Error {
89
+ constructor();
90
+ }
91
+ export type HoldScope = {
92
+ apiKey: string;
93
+ baseUrl: string;
94
+ agentId?: string | null;
95
+ };
96
+ export type NewHold = Omit<HoldRecord, 'id' | 'key_ref' | 'code' | 'outbox' | 'created_at' | 'updated_at' | 'state' | 'dialog_at'> & {
97
+ withCode: boolean;
98
+ };
99
+ /** Records a held action. A previous open hold of the same action and session is replaced. */
100
+ export declare function recordHold(scope: HoldScope, input: NewHold, home?: string): HoldRecord;
101
+ export type HoldQuery = {
102
+ correlation?: string;
103
+ toolUseId?: string | null;
104
+ sessionId?: string;
105
+ hostSessionId?: string;
106
+ generationId?: string | null;
107
+ mode?: HoldMode;
108
+ states?: HoldState[];
109
+ code?: string;
110
+ id?: string;
111
+ };
112
+ /** The stored form of a Marrow session id (the bound buildHeaders applies to X-Marrow-Session-Id). */
113
+ export declare function boundSessionId(value: string): string;
114
+ export declare function findHolds(scope: HoldScope, query: HoldQuery, home?: string): HoldRecord[];
115
+ /** Applies a change to one hold under the lock; returns the updated hold, or null when it is gone. */
116
+ export declare function updateHold(scope: HoldScope, id: string, change: (hold: HoldRecord) => HoldRecord | null, home?: string): HoldRecord | null;
117
+ /**
118
+ * Compare-and-set under the lock: takes an open hold for one run. Returns the
119
+ * claimed hold, or null when another call already claimed or settled it, so
120
+ * one approval never lets two identical calls run.
121
+ */
122
+ export declare function claimHold(scope: HoldScope, id: string, change: (hold: HoldRecord) => HoldRecord, home?: string): HoldRecord | null;
123
+ /**
124
+ * Remembers which categories the account owner protects, as Marrow reported
125
+ * them for this key: added when a hold says they need the owner's verified
126
+ * approval, removed when a readable answer says they do not.
127
+ */
128
+ export declare function noteProtectedCategories(scope: HoldScope, protectedNow: string[], notProtected: string[], home?: string): void;
129
+ /** The categories (of those given) this key last saw the account owner protect. */
130
+ export declare function protectedCategoriesAmong(scope: HoldScope, categories: string[], home?: string): string[];
131
+ /** Marks the oldest matching open ask whose dialog was not yet seen; returns it, if any. */
132
+ export declare function markDialogShown(scope: HoldScope, query: HoldQuery, at: string, home?: string): HoldRecord | null;
133
+ export declare function setSessionMarker(kind: 'interactive' | 'prompt_hook', scope: HoldScope, hostSessionId: string, value: boolean, home?: string): void;
134
+ export declare function sessionMarker(kind: 'interactive' | 'prompt_hook', scope: HoldScope, hostSessionId: string, home?: string): boolean | null;
135
+ /** True when this key has any hold that still needs work (cheap check before network). */
136
+ export declare function hasPendingHolds(scope: HoldScope, query?: HoldQuery, home?: string): boolean;
137
+ //# sourceMappingURL=host-approval-state.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"host-approval-state.d.ts","sourceRoot":"","sources":["../src/host-approval-state.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,510 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.UnsafeHostApprovalStateError = exports.APPROVAL_CODE = exports.HOLD_RECORD_TTL_MS = void 0;
4
+ exports.recordHold = recordHold;
5
+ exports.boundSessionId = boundSessionId;
6
+ exports.findHolds = findHolds;
7
+ exports.updateHold = updateHold;
8
+ exports.claimHold = claimHold;
9
+ exports.noteProtectedCategories = noteProtectedCategories;
10
+ exports.protectedCategoriesAmong = protectedCategoriesAmong;
11
+ exports.markDialogShown = markDialogShown;
12
+ exports.setSessionMarker = setSessionMarker;
13
+ exports.sessionMarker = sessionMarker;
14
+ exports.hasPendingHolds = hasPendingHolds;
15
+ const node_crypto_1 = require("node:crypto");
16
+ const node_fs_1 = require("node:fs");
17
+ const node_os_1 = require("node:os");
18
+ const node_path_1 = require("node:path");
19
+ /**
20
+ * Owner-only local state for held actions (~/.marrow/host-approvals/state.json,
21
+ * 0700/0600, never a symlink). It links the hook process that saw a hold to
22
+ * the later hook processes that see the operator's answer (PermissionRequest,
23
+ * PostToolUse, PostToolBatch, a typed reply) and keeps an unsent report so it
24
+ * is resent unchanged. It stores identifiers, timestamps and Marrow's own
25
+ * coarse action classification; an undelivered report also carries the
26
+ * normalized action it answers for (secrets removed) until it is delivered.
27
+ * No prompts, tool output or credentials. Records are bound to the API key, base URL and agent by a keyed
28
+ * hash, so another key on the same machine never sees them.
29
+ */
30
+ const STATE_VERSION = 1;
31
+ const STATE_DIRECTORY = 'host-approvals';
32
+ const STATE_FILENAME = 'state.json';
33
+ const STATE_MAX_BYTES = 512 * 1024;
34
+ const MAX_HOLDS = 64;
35
+ const MAX_MARKERS = 128;
36
+ /** A hold outlives its 30-minute gate receipt only long enough to settle a late report. */
37
+ exports.HOLD_RECORD_TTL_MS = 2 * 60 * 60 * 1000;
38
+ const MARKER_TTL_MS = 24 * 60 * 60 * 1000;
39
+ /** How long an owner-protected category seen from Marrow is remembered for outages. */
40
+ const PROTECTED_CATEGORY_TTL_MS = 30 * 24 * 60 * 60 * 1000;
41
+ const MAX_PROTECTED = 64;
42
+ const CATEGORY = /^[a-z][a-z0-9_]{0,63}$/;
43
+ const CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
44
+ exports.APPROVAL_CODE = /^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{6}$/;
45
+ const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/;
46
+ const HEX32 = /^[a-f0-9]{32}$/;
47
+ class UnsafeHostApprovalStateError extends Error {
48
+ constructor() {
49
+ super('Local Marrow host-approval state is unsafe or invalid. Repair ~/.marrow/host-approvals before retrying.');
50
+ this.name = 'UnsafeHostApprovalStateError';
51
+ }
52
+ }
53
+ exports.UnsafeHostApprovalStateError = UnsafeHostApprovalStateError;
54
+ function unsafe() { throw new UnsafeHostApprovalStateError(); }
55
+ function owned(stat) {
56
+ const current = typeof process.getuid === 'function' ? process.getuid() : null;
57
+ return current == null || stat.uid === current;
58
+ }
59
+ function exactPrivate(stat, mode) {
60
+ return owned(stat) && !stat.isSymbolicLink() && (stat.mode & 0o777) === mode;
61
+ }
62
+ function paths(home = process.env.HOME || (0, node_os_1.homedir)()) {
63
+ const marrow = (0, node_path_1.join)(home, '.marrow');
64
+ const directory = (0, node_path_1.join)(marrow, STATE_DIRECTORY);
65
+ return { marrow, directory, target: (0, node_path_1.join)(directory, STATE_FILENAME) };
66
+ }
67
+ function ensureDirectories(home) {
68
+ const result = paths(home);
69
+ if (!(0, node_fs_1.existsSync)(result.marrow))
70
+ (0, node_fs_1.mkdirSync)(result.marrow, { mode: 0o700 });
71
+ const marrowStat = (0, node_fs_1.lstatSync)(result.marrow);
72
+ if (!marrowStat.isDirectory() || marrowStat.isSymbolicLink() || !owned(marrowStat) || (marrowStat.mode & 0o022) !== 0)
73
+ unsafe();
74
+ if (!(0, node_fs_1.existsSync)(result.directory))
75
+ (0, node_fs_1.mkdirSync)(result.directory, { mode: 0o700 });
76
+ const directoryStat = (0, node_fs_1.lstatSync)(result.directory);
77
+ if (!directoryStat.isDirectory() || !exactPrivate(directoryStat, 0o700))
78
+ unsafe();
79
+ return result;
80
+ }
81
+ function withLock(home, callback) {
82
+ const { directory } = ensureDirectories(home);
83
+ const lock = (0, node_path_1.join)(directory, '.state.lock');
84
+ let fd = -1;
85
+ for (let attempt = 0; attempt < 200; attempt += 1) {
86
+ try {
87
+ fd = (0, node_fs_1.openSync)(lock, node_fs_1.constants.O_WRONLY | node_fs_1.constants.O_CREAT | node_fs_1.constants.O_EXCL | (node_fs_1.constants.O_NOFOLLOW || 0), 0o600);
88
+ const opened = (0, node_fs_1.fstatSync)(fd);
89
+ if (!opened.isFile() || !exactPrivate(opened, 0o600))
90
+ return unsafe();
91
+ break;
92
+ }
93
+ catch (error) {
94
+ if (error.code !== 'EEXIST')
95
+ return unsafe();
96
+ let stat;
97
+ try {
98
+ stat = (0, node_fs_1.lstatSync)(lock);
99
+ }
100
+ catch {
101
+ continue;
102
+ }
103
+ if (!stat.isFile() || !exactPrivate(stat, 0o600))
104
+ return unsafe();
105
+ if (Date.now() - stat.mtimeMs > 10_000) {
106
+ try {
107
+ (0, node_fs_1.unlinkSync)(lock);
108
+ }
109
+ catch { /* another process removed it */ }
110
+ continue;
111
+ }
112
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
113
+ }
114
+ }
115
+ if (fd < 0)
116
+ return unsafe();
117
+ try {
118
+ return callback();
119
+ }
120
+ finally {
121
+ (0, node_fs_1.closeSync)(fd);
122
+ try {
123
+ (0, node_fs_1.unlinkSync)(lock);
124
+ }
125
+ catch { /* a stopped process leaves a bounded stale lock */ }
126
+ }
127
+ }
128
+ function emptyState() {
129
+ return { version: STATE_VERSION, secret: (0, node_crypto_1.randomBytes)(32).toString('base64url'), holds: {}, interactive: {}, prompt_hook: {}, protected_categories: {} };
130
+ }
131
+ function isIso(value) {
132
+ return typeof value === 'string' && value.length <= 40 && Number.isFinite(Date.parse(value));
133
+ }
134
+ function validReport(value) {
135
+ if (value === null)
136
+ return true;
137
+ if (!value || typeof value !== 'object' || Array.isArray(value))
138
+ return false;
139
+ const report = value;
140
+ return (report.verdict === 'approved' || report.verdict === 'declined')
141
+ && typeof report.host === 'string' && report.host.length <= 32
142
+ && typeof report.host_session_id === 'string' && IDENTIFIER.test(report.host_session_id)
143
+ && (report.hook_event === null || (typeof report.hook_event === 'string' && /^[A-Za-z][A-Za-z0-9_.:-]{0,63}$/.test(report.hook_event)))
144
+ && (report.pre_action_event_id === null || (typeof report.pre_action_event_id === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(report.pre_action_event_id)))
145
+ && isIso(report.asked_at) && isIso(report.answered_at)
146
+ && (report.decision_id === undefined || (typeof report.decision_id === 'string' && IDENTIFIER.test(report.decision_id)))
147
+ && (report.normalized_action === undefined || (Boolean(report.normalized_action) && typeof report.normalized_action === 'object'
148
+ && !Array.isArray(report.normalized_action) && JSON.stringify(report.normalized_action).length <= 16_384));
149
+ }
150
+ function validHold(value) {
151
+ if (!value || typeof value !== 'object' || Array.isArray(value))
152
+ return false;
153
+ const hold = value;
154
+ const outbox = hold.outbox;
155
+ return /^hold_[a-f0-9]{24}$/.test(hold.id)
156
+ && HEX32.test(hold.key_ref)
157
+ && typeof hold.host === 'string' && hold.host.length <= 32
158
+ && typeof hold.harness === 'string' && hold.harness.length <= 32
159
+ && typeof hold.session_id === 'string' && hold.session_id.length <= 256
160
+ && typeof hold.host_session_id === 'string' && IDENTIFIER.test(hold.host_session_id)
161
+ && (hold.agent_id === null || (typeof hold.agent_id === 'string' && hold.agent_id.length <= 128))
162
+ && HEX32.test(hold.correlation)
163
+ && (hold.tool_use_id === null || (typeof hold.tool_use_id === 'string' && hold.tool_use_id.length <= 128))
164
+ && (hold.generation_id === null || (typeof hold.generation_id === 'string' && hold.generation_id.length <= 128))
165
+ && typeof hold.tool_name === 'string' && hold.tool_name.length <= 256
166
+ && typeof hold.hook_event === 'string' && hold.hook_event.length <= 64
167
+ && (hold.mode === 'ask' || hold.mode === 'wait')
168
+ && (hold.state === 'open' || hold.state === 'allowed' || hold.state === 'resolved')
169
+ && typeof hold.gate_receipt_id === 'string' && IDENTIFIER.test(hold.gate_receipt_id)
170
+ && (hold.decision_id === null || (typeof hold.decision_id === 'string' && IDENTIFIER.test(hold.decision_id)))
171
+ && isIso(hold.asked_at)
172
+ && (hold.dialog_at === null || isIso(hold.dialog_at))
173
+ && (hold.pre_action_event_id === null || typeof hold.pre_action_event_id === 'string')
174
+ && typeof hold.proof_required === 'boolean'
175
+ && Array.isArray(hold.proof_fields) && hold.proof_fields.length <= 24 && hold.proof_fields.every((field) => typeof field === 'string' && field.length <= 64)
176
+ && (hold.expires_at === null || isIso(hold.expires_at))
177
+ && (hold.code === null || (typeof hold.code === 'string' && exports.APPROVAL_CODE.test(hold.code)))
178
+ && (hold.owner_link === undefined || hold.owner_link === null || ['sent', 'unsent', 'on_request', 'failed'].includes(hold.owner_link))
179
+ && (hold.link_attempts === undefined || (Number.isSafeInteger(hold.link_attempts) && hold.link_attempts >= 0 && hold.link_attempts <= 100))
180
+ && (hold.dialog_later === undefined || typeof hold.dialog_later === 'boolean')
181
+ && (hold.ask_text === undefined || hold.ask_text === null || (typeof hold.ask_text === 'string' && hold.ask_text.length <= 600))
182
+ && (hold.legacy_service === undefined || typeof hold.legacy_service === 'boolean')
183
+ && (hold.arbitration_receipt_id === undefined || hold.arbitration_receipt_id === null
184
+ || (typeof hold.arbitration_receipt_id === 'string' && IDENTIFIER.test(hold.arbitration_receipt_id)))
185
+ && (hold.quiet === undefined || hold.quiet === null || hold.quiet === 'attended' || hold.quiet === 'unattended')
186
+ && (hold.not_observed === undefined || typeof hold.not_observed === 'boolean')
187
+ && (hold.person_present === undefined || typeof hold.person_present === 'boolean')
188
+ && (hold.normalized_action === undefined || (Boolean(hold.normalized_action) && typeof hold.normalized_action === 'object'
189
+ && !Array.isArray(hold.normalized_action) && JSON.stringify(hold.normalized_action).length <= 16_384))
190
+ && Boolean(hold.action) && typeof hold.action.action === 'string' && hold.action.action.length <= 512
191
+ && typeof hold.action.target === 'string' && hold.action.target.length <= 256
192
+ && typeof hold.action.type === 'string' && hold.action.type.length <= 64
193
+ && Array.isArray(hold.action.surfaces) && hold.action.surfaces.length <= 16 && hold.action.surfaces.every((surface) => typeof surface === 'string' && surface.length <= 64)
194
+ && (outbox === null || (Boolean(outbox) && typeof outbox === 'object'
195
+ && validReport(outbox.report)
196
+ && (outbox.commit === null || (Boolean(outbox.commit) && typeof outbox.commit.success === 'boolean' && typeof outbox.commit.outcome === 'string' && outbox.commit.outcome.length <= 600))
197
+ && Number.isSafeInteger(outbox.attempts) && outbox.attempts >= 0 && outbox.attempts <= 1000
198
+ && Number.isSafeInteger(outbox.next_at) && outbox.next_at >= 0))
199
+ && Number.isSafeInteger(hold.created_at) && Number.isSafeInteger(hold.updated_at);
200
+ }
201
+ function validMarkers(value) {
202
+ if (!value || typeof value !== 'object' || Array.isArray(value))
203
+ return false;
204
+ const entries = Object.entries(value);
205
+ return entries.length <= MAX_MARKERS && entries.every(([key, marker]) => /^[a-f0-9]{64}$/.test(key)
206
+ && Boolean(marker) && typeof marker === 'object'
207
+ && Number.isSafeInteger(marker.at) && typeof marker.value === 'boolean');
208
+ }
209
+ function validateState(value) {
210
+ if (!value || typeof value !== 'object' || Array.isArray(value))
211
+ return unsafe();
212
+ const state = value;
213
+ if (state.version !== STATE_VERSION || !/^[A-Za-z0-9_-]{43}$/.test(state.secret)
214
+ || !state.holds || typeof state.holds !== 'object' || Array.isArray(state.holds)
215
+ || !validMarkers(state.interactive) || !validMarkers(state.prompt_hook))
216
+ return unsafe();
217
+ if (state.protected_categories === undefined)
218
+ state.protected_categories = {};
219
+ if (!validMarkers(state.protected_categories))
220
+ state.protected_categories = {};
221
+ const holds = Object.entries(state.holds);
222
+ if (holds.length > MAX_HOLDS)
223
+ return unsafe();
224
+ // A record this client cannot read is dropped, so one bad record never blocks later hooks.
225
+ for (const [key, hold] of holds)
226
+ if (!validHold(hold) || hold.id !== key)
227
+ delete state.holds[key];
228
+ return state;
229
+ }
230
+ function readState(home) {
231
+ const { target, directory } = ensureDirectories(home);
232
+ if (!(0, node_fs_1.existsSync)(target))
233
+ return { state: emptyState(), target, directory };
234
+ const stat = (0, node_fs_1.lstatSync)(target);
235
+ if (!stat.isFile() || !exactPrivate(stat, 0o600) || stat.size < 2 || stat.size > STATE_MAX_BYTES)
236
+ return unsafe();
237
+ let fd = -1;
238
+ try {
239
+ fd = (0, node_fs_1.openSync)(target, node_fs_1.constants.O_RDONLY | (node_fs_1.constants.O_NOFOLLOW || 0));
240
+ const opened = (0, node_fs_1.fstatSync)(fd);
241
+ if (!opened.isFile() || opened.ino !== stat.ino || !exactPrivate(opened, 0o600))
242
+ return unsafe();
243
+ const raw = (0, node_fs_1.readFileSync)(fd, 'utf8');
244
+ return { state: validateState(JSON.parse(raw)), target, directory };
245
+ }
246
+ catch (error) {
247
+ if (error instanceof UnsafeHostApprovalStateError)
248
+ throw error;
249
+ return unsafe();
250
+ }
251
+ finally {
252
+ if (fd >= 0)
253
+ (0, node_fs_1.closeSync)(fd);
254
+ }
255
+ }
256
+ function prune(state, now) {
257
+ for (const [id, hold] of Object.entries(state.holds)) {
258
+ const expiry = hold.expires_at ? Date.parse(hold.expires_at) : NaN;
259
+ const settled = hold.state === 'resolved' && !hold.outbox;
260
+ const pastExpiry = Number.isFinite(expiry) && now > expiry + 10 * 60_000 && !hold.outbox;
261
+ if (now - hold.created_at > exports.HOLD_RECORD_TTL_MS || pastExpiry || (settled && now - hold.updated_at > 10 * 60_000))
262
+ delete state.holds[id];
263
+ }
264
+ const holds = Object.values(state.holds).sort((a, b) => b.updated_at - a.updated_at);
265
+ for (const hold of holds.slice(MAX_HOLDS))
266
+ delete state.holds[hold.id];
267
+ for (const markers of [state.interactive, state.prompt_hook]) {
268
+ for (const [key, marker] of Object.entries(markers))
269
+ if (now - marker.at > MARKER_TTL_MS)
270
+ delete markers[key];
271
+ const ordered = Object.entries(markers).sort((a, b) => b[1].at - a[1].at);
272
+ for (const [key] of ordered.slice(MAX_MARKERS))
273
+ delete markers[key];
274
+ }
275
+ const protectedCategories = state.protected_categories || {};
276
+ for (const [key, marker] of Object.entries(protectedCategories))
277
+ if (now - marker.at > PROTECTED_CATEGORY_TTL_MS)
278
+ delete protectedCategories[key];
279
+ for (const [key] of Object.entries(protectedCategories).sort((a, b) => b[1].at - a[1].at).slice(MAX_PROTECTED))
280
+ delete protectedCategories[key];
281
+ state.protected_categories = protectedCategories;
282
+ }
283
+ function writeState(state, target, directory) {
284
+ prune(state, Date.now());
285
+ const raw = `${JSON.stringify(state)}\n`;
286
+ if (Buffer.byteLength(raw) > STATE_MAX_BYTES)
287
+ unsafe();
288
+ const temporary = (0, node_path_1.join)(directory, `.state-${process.pid}-${(0, node_crypto_1.randomBytes)(8).toString('hex')}.tmp`);
289
+ let fd = -1;
290
+ try {
291
+ fd = (0, node_fs_1.openSync)(temporary, node_fs_1.constants.O_WRONLY | node_fs_1.constants.O_CREAT | node_fs_1.constants.O_EXCL | (node_fs_1.constants.O_NOFOLLOW || 0), 0o600);
292
+ (0, node_fs_1.writeFileSync)(fd, raw, 'utf8');
293
+ (0, node_fs_1.closeSync)(fd);
294
+ fd = -1;
295
+ (0, node_fs_1.renameSync)(temporary, target);
296
+ }
297
+ finally {
298
+ if (fd >= 0)
299
+ (0, node_fs_1.closeSync)(fd);
300
+ try {
301
+ if ((0, node_fs_1.existsSync)(temporary))
302
+ (0, node_fs_1.unlinkSync)(temporary);
303
+ }
304
+ catch { /* best effort cleanup */ }
305
+ }
306
+ }
307
+ function keyed(secret, value, length = 64) {
308
+ return (0, node_crypto_1.createHmac)('sha256', Buffer.from(secret, 'base64url')).update(JSON.stringify(value)).digest('hex').slice(0, length);
309
+ }
310
+ function keyRef(state, scope) {
311
+ return keyed(state.secret, ['key', scope.apiKey, scope.baseUrl.replace(/\/+$/, ''), scope.agentId || null], 32);
312
+ }
313
+ function freshCode(state, gateReceiptId) {
314
+ const taken = new Set(Object.values(state.holds).map((hold) => hold.code).filter(Boolean));
315
+ for (let round = 0; round < 32; round += 1) {
316
+ const digest = (0, node_crypto_1.createHmac)('sha256', Buffer.from(state.secret, 'base64url')).update(`code:${round}:${gateReceiptId}`).digest();
317
+ let code = '';
318
+ for (let index = 0; index < 6; index += 1)
319
+ code += CODE_ALPHABET[digest[index] % CODE_ALPHABET.length];
320
+ if (!taken.has(code))
321
+ return code;
322
+ }
323
+ return unsafe();
324
+ }
325
+ /** Records a held action. A previous open hold of the same action and session is replaced. */
326
+ function recordHold(scope, input, home) {
327
+ return withLock(home, () => {
328
+ const { state, target, directory } = readState(home);
329
+ const ref = keyRef(state, scope);
330
+ const now = Date.now();
331
+ for (const [id, hold] of Object.entries(state.holds)) {
332
+ if (hold.key_ref === ref && hold.correlation === input.correlation && hold.mode === input.mode
333
+ && hold.state !== 'resolved' && !hold.outbox && (hold.mode === 'wait' || hold.tool_use_id === input.tool_use_id)) {
334
+ delete state.holds[id];
335
+ }
336
+ }
337
+ const { withCode, ...fields } = input;
338
+ const record = {
339
+ ...fields,
340
+ id: `hold_${(0, node_crypto_1.randomBytes)(12).toString('hex')}`,
341
+ key_ref: ref,
342
+ code: withCode ? freshCode(state, input.gate_receipt_id) : null,
343
+ outbox: null,
344
+ state: 'open',
345
+ dialog_at: null,
346
+ created_at: now,
347
+ updated_at: now,
348
+ };
349
+ state.holds[record.id] = record;
350
+ writeState(state, target, directory);
351
+ return record;
352
+ });
353
+ }
354
+ /** The stored form of a Marrow session id (the bound buildHeaders applies to X-Marrow-Session-Id). */
355
+ function boundSessionId(value) {
356
+ return value.replace(/[^\x20-\x7E]/g, '').slice(0, 256);
357
+ }
358
+ function matches(hold, ref, query) {
359
+ if (hold.key_ref !== ref)
360
+ return false;
361
+ if (query.id && hold.id !== query.id)
362
+ return false;
363
+ if (query.correlation && hold.correlation !== query.correlation)
364
+ return false;
365
+ if (query.toolUseId && hold.tool_use_id && hold.tool_use_id !== query.toolUseId)
366
+ return false;
367
+ if (query.sessionId !== undefined && hold.session_id !== boundSessionId(query.sessionId))
368
+ return false;
369
+ if (query.hostSessionId !== undefined && hold.host_session_id !== query.hostSessionId)
370
+ return false;
371
+ if (query.generationId && hold.generation_id && hold.generation_id !== query.generationId)
372
+ return false;
373
+ if (query.mode && hold.mode !== query.mode)
374
+ return false;
375
+ if (query.states && !query.states.includes(hold.state))
376
+ return false;
377
+ if (query.code && hold.code !== query.code)
378
+ return false;
379
+ return true;
380
+ }
381
+ /** Reads without creating ~/.marrow/host-approvals when nothing was ever held. */
382
+ function peekState(home) {
383
+ return (0, node_fs_1.existsSync)(paths(home).target) ? readState(home).state : emptyState();
384
+ }
385
+ function findHolds(scope, query, home) {
386
+ const state = peekState(home);
387
+ const ref = keyRef(state, scope);
388
+ return Object.values(state.holds)
389
+ .filter((hold) => matches(hold, ref, query))
390
+ .sort((a, b) => a.created_at - b.created_at);
391
+ }
392
+ /** Applies a change to one hold under the lock; returns the updated hold, or null when it is gone. */
393
+ function updateHold(scope, id, change, home) {
394
+ return withLock(home, () => {
395
+ const { state, target, directory } = readState(home);
396
+ const ref = keyRef(state, scope);
397
+ const current = state.holds[id];
398
+ if (!current || current.key_ref !== ref)
399
+ return null;
400
+ const next = change(structuredClone(current));
401
+ if (next === null)
402
+ delete state.holds[id];
403
+ else
404
+ state.holds[id] = { ...next, id: current.id, key_ref: current.key_ref, updated_at: Date.now() };
405
+ writeState(state, target, directory);
406
+ return next === null ? null : state.holds[id];
407
+ });
408
+ }
409
+ /**
410
+ * Compare-and-set under the lock: takes an open hold for one run. Returns the
411
+ * claimed hold, or null when another call already claimed or settled it, so
412
+ * one approval never lets two identical calls run.
413
+ */
414
+ function claimHold(scope, id, change, home) {
415
+ return withLock(home, () => {
416
+ const { state, target, directory } = readState(home);
417
+ const ref = keyRef(state, scope);
418
+ const current = state.holds[id];
419
+ if (!current || current.key_ref !== ref || current.state !== 'open')
420
+ return null;
421
+ const next = change(structuredClone(current));
422
+ state.holds[id] = { ...next, id: current.id, key_ref: current.key_ref, updated_at: Date.now() };
423
+ writeState(state, target, directory);
424
+ return state.holds[id];
425
+ });
426
+ }
427
+ /**
428
+ * Remembers which categories the account owner protects, as Marrow reported
429
+ * them for this key: added when a hold says they need the owner's verified
430
+ * approval, removed when a readable answer says they do not.
431
+ */
432
+ function noteProtectedCategories(scope, protectedNow, notProtected, home) {
433
+ const add = protectedNow.filter((category) => CATEGORY.test(category)).slice(0, 16);
434
+ const remove = notProtected.filter((category) => CATEGORY.test(category) && !add.includes(category)).slice(0, 16);
435
+ if (add.length === 0 && remove.length === 0)
436
+ return;
437
+ withLock(home, () => {
438
+ const { state, target, directory } = readState(home);
439
+ const ref = keyRef(state, scope);
440
+ const map = state.protected_categories || {};
441
+ let changed = false;
442
+ for (const category of add) {
443
+ map[keyed(state.secret, ['protected', ref, category])] = { at: Date.now(), value: true };
444
+ changed = true;
445
+ }
446
+ for (const category of remove) {
447
+ const key = keyed(state.secret, ['protected', ref, category]);
448
+ if (map[key]) {
449
+ delete map[key];
450
+ changed = true;
451
+ }
452
+ }
453
+ state.protected_categories = map;
454
+ if (changed)
455
+ writeState(state, target, directory);
456
+ });
457
+ }
458
+ /** The categories (of those given) this key last saw the account owner protect. */
459
+ function protectedCategoriesAmong(scope, categories, home) {
460
+ try {
461
+ const state = peekState(home);
462
+ const ref = keyRef(state, scope);
463
+ const map = state.protected_categories || {};
464
+ return categories.filter((category) => CATEGORY.test(category)
465
+ && map[keyed(state.secret, ['protected', ref, category])]?.value === true
466
+ && Date.now() - map[keyed(state.secret, ['protected', ref, category])].at <= PROTECTED_CATEGORY_TTL_MS);
467
+ }
468
+ catch {
469
+ return [];
470
+ }
471
+ }
472
+ /** Marks the oldest matching open ask whose dialog was not yet seen; returns it, if any. */
473
+ function markDialogShown(scope, query, at, home) {
474
+ return withLock(home, () => {
475
+ const { state, target, directory } = readState(home);
476
+ const ref = keyRef(state, scope);
477
+ const candidate = Object.values(state.holds)
478
+ .filter((hold) => matches(hold, ref, { ...query, mode: 'ask', states: ['open'] }) && hold.dialog_at === null)
479
+ .sort((a, b) => a.created_at - b.created_at)[0];
480
+ if (!candidate)
481
+ return null;
482
+ candidate.dialog_at = at;
483
+ candidate.updated_at = Date.now();
484
+ writeState(state, target, directory);
485
+ return candidate;
486
+ });
487
+ }
488
+ function setSessionMarker(kind, scope, hostSessionId, value, home) {
489
+ withLock(home, () => {
490
+ const { state, target, directory } = readState(home);
491
+ state[kind][keyed(state.secret, [kind, keyRef(state, scope), hostSessionId])] = { at: Date.now(), value };
492
+ writeState(state, target, directory);
493
+ });
494
+ }
495
+ function sessionMarker(kind, scope, hostSessionId, home) {
496
+ const state = peekState(home);
497
+ const marker = state[kind][keyed(state.secret, [kind, keyRef(state, scope), hostSessionId])];
498
+ return marker ? marker.value : null;
499
+ }
500
+ /** True when this key has any hold that still needs work (cheap check before network). */
501
+ function hasPendingHolds(scope, query = {}, home) {
502
+ try {
503
+ return findHolds(scope, { ...query, states: ['open', 'allowed'] }, home).length > 0
504
+ || findHolds(scope, { ...query, states: ['resolved'] }, home).some((hold) => hold.outbox !== null);
505
+ }
506
+ catch {
507
+ return false;
508
+ }
509
+ }
510
+ //# sourceMappingURL=host-approval-state.js.map