@getmarrow/install 0.1.67 → 0.1.69

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.
@@ -14,6 +14,7 @@ const {
14
14
  verifyActionPermit,
15
15
  } = require('./enforcement-client');
16
16
  const { startGovernanceSidecar } = require('./governance-sidecar');
17
+ const { readOwnerApiKey } = require('./owner-env');
17
18
  const {
18
19
  controllerStatus,
19
20
  ensureCurrentGovernanceController,
@@ -28,6 +29,8 @@ const {
28
29
  detectEnvironment,
29
30
  detectedClient,
30
31
  localControllerAgentId,
32
+ maintenanceMcpRuntime,
33
+ argumentLabel,
31
34
  } = require('./installer');
32
35
  const { createHostUsageCapture } = require('./usage-telemetry');
33
36
  const { readLocalControlState, recordGovernedBypass } = require('./control-state');
@@ -129,14 +132,22 @@ Commands:
129
132
  Options:
130
133
  --agent <id> Registered agent id. Defaults to MARROW_FLEET_AGENT_ID or MARROW_AGENT_ID.
131
134
  When unset, Marrow uses the API key's bound agent or the plan's agent seat.
132
- --session <id> Session id. Defaults to marrow-run-<timestamp>
135
+ --session <id> Session id. Default: one per agent, project directory, user, Marrow
136
+ service and UTC day (marrow-run-<hash>), so reruns share it
133
137
  --type <type> Action type. Inferred from action/command when omitted
134
138
  --action <text> Human-readable action. Defaults to the redacted command
135
139
  --profile <name> Policy profile label, such as dev, staging, or production
136
- --policy <mode> enforce, warn, or audit. Default: enforce
140
+ --policy <mode> enforce, warn, or audit. Default: enforce. Reported to Marrow; a gate
141
+ Marrow enforces (a block or a hold) stops the command under every mode
137
142
  --fail-open For non-protected, low-risk actions only, run if Marrow is unreachable
138
143
  --fail-closed If Marrow is unreachable, block the command
139
- --owner-approved <ref> Owner approval reference for review-required gates
144
+ --owner-approved <ref> No longer used and has no effect: approvals happen in the host's own
145
+ prompt, at this runner's terminal prompt, or by the account owner's link
146
+ --request-owner-link When the account owner declined this action earlier, ask the owner again
147
+ with a one-tap approval link (only the owner can reverse the decline)
148
+ --approval-wait <sec> How long run waits for an approval before it holds (0 to 3600).
149
+ Default: at an interactive terminal, until the owner's link
150
+ expires; otherwise 0, and a later run picks up the answer
140
151
  --permit <token> Short-lived action permit. Prefer MARROW_ACTION_PERMIT
141
152
  --target <text> Protected target binding, such as repository/environment
142
153
  --sidecar-port <port> Loopback sidecar port. Default: ephemeral
@@ -147,21 +158,594 @@ Options:
147
158
  --key <key> Marrow API key. Prefer MARROW_API_KEY
148
159
  --json Print machine-readable result after completion
149
160
  --interactive Force interactive govern TUI when possible
150
- --no-interactive Print govern/fleet panel instead of opening the TUI
161
+ --no-interactive run: never prompt (a hold waits for a person elsewhere);
162
+ govern/fleet: print the panel instead of opening the TUI
151
163
  `;
152
164
  }
153
165
 
154
- function nowId(prefix) {
155
- return `${prefix}-${new Date().toISOString().replace(/[^0-9TZ]/g, '')}-${crypto.randomBytes(4).toString('hex')}`;
166
+ // Redaction replaces a secret VALUE and nothing else. Everything around it stays visible,
167
+ // because the redacted text is what Marrow sees, what the owner approves and what an action
168
+ // permit is bound to: two actions that differ anywhere outside a secret must stay different.
169
+ //
170
+ // Three kinds of text:
171
+ // - 'text': free text (--action, --target, displayed lines). A value is one shell word:
172
+ // adjacent quoted and unquoted pieces belong to it, and it ends at unquoted whitespace.
173
+ // - 'script': a shell script (the word after `sh -c`, `bash -lc`, ...). As 'text', and an
174
+ // unquoted ; | & always ends the value, because the shell splits there.
175
+ // - 'word': one argv word that is not a script. The shell already removed its quoting, so
176
+ // quotes and spaces are part of the value; it ends only before another `name=`, or at a
177
+ // new line.
178
+ const REDACTED = '[redacted]';
179
+ // Marrow's own marker for a hidden value that may also hide command text: a value holding a
180
+ // blank, a quote, a shell operator or a separator (; & | < > ( ) ` # , ? $( ...). Marrow never binds
181
+ // an action that carries it to an approval (no learned, task or fleet reuse), so two actions that
182
+ // differ only inside such a value can never share one. The text must match Marrow's exactly.
183
+ const AMBIGUOUS = '[REDACTED_AMBIGUOUS]';
184
+ const AMBIGUOUS_SPAN_RE = /[\s'"`;&|<>()#,?]|\$[({A-Za-z_]/;
185
+ const MARKERS = new Set([REDACTED, AMBIGUOUS]);
186
+ const hiddenAs = (span) => (AMBIGUOUS_SPAN_RE.test(String(span)) ? AMBIGUOUS : REDACTED);
187
+ // A shell variable reference ($NAME in capitals, ${name}) names a secret; it is not one, and stays
188
+ // visible in shell text and free text (Marrow's own rule). In an argv word the shell has already
189
+ // expanded variables, so `$ecret1`-shaped text there is literal data and stays hidden.
190
+ const REFERENCE_RE = /^\$(?:[A-Z_][A-Z0-9_]*|\{[A-Za-z_][A-Za-z0-9_]*\})$/;
191
+
192
+ // A credential name CONTAINS one of these words in one of its segments (split at _ . - and
193
+ // camelCase): TF_TOKEN, PGPASSWORD, SECRET_KEY_BASE, GITHUB_TOKEN_V2, X_API_KEY_ID, apiKey,
194
+ // client_secret, basic_auth, Authorization, SLACK_WEBHOOK. `pass` and `pwd` count only as a
195
+ // whole segment (DB_PASS, MYSQL_PWD; not bypass or compass).
196
+ const CREDENTIAL_WORD_RE = /token|secret|password|passwd|passphrase|key|credential|auth|webhook/;
197
+ const CREDENTIAL_SEGMENTS = new Set(['pass', 'pwd']);
198
+ // Segments that contain such a word but name no secret.
199
+ const NON_SECRET_WORDS = new Set([
200
+ 'keyspace', 'keyspaces', 'keyboard', 'keyboards', 'keyword', 'keywords', 'keymap', 'keymaps', 'keyframe', 'keyframes',
201
+ 'keystroke', 'keystrokes', 'keynote', 'keyless', 'hotkey', 'hotkeys', 'monkey', 'monkeypatch', 'donkey', 'turkey', 'hockey',
202
+ 'jockey', 'whiskey', 'tokenizer', 'tokenizers', 'tokenize', 'tokenization', 'detokenize', 'author', 'authors', 'authored',
203
+ 'authorship', 'authority', 'authorities', 'oauth', 'oauth2', 'passwordless', 'secretary',
204
+ ]);
205
+ // Whole names (normalized: lower case, _ for . and -, camelCase split) that name no secret.
206
+ const NON_SECRET_NAMES = new Set([
207
+ 'max_tokens', 'max_new_tokens', 'max_output_tokens', 'max_completion_tokens', 'max_input_tokens', 'num_tokens',
208
+ 'token_count', 'token_limit', 'token_budget', 'token_type', 'token_usage', 'tokens_used',
209
+ 'auth_type', 'auth_method', 'auth_mode', 'auth_provider', 'auth_scheme',
210
+ 'key_type', 'key_size', 'key_length', 'key_algorithm', 'public_key', 'primary_key', 'foreign_key', 'sort_key',
211
+ 'partition_key', 'hash_key', 'range_key', 'cache_key', 'idempotency_key', 'pwd',
212
+ ]);
213
+ // A name whose last segment says what kind of thing the value is (a file, a name, a region)
214
+ // names where a secret is, not the secret: --key-file, KEY_VAULT_NAME. A URL, URI, endpoint,
215
+ // domain or host under a credential name (SECRET_URL, TOKEN_URL, a webhook) is the secret itself.
216
+ const NON_SECRET_LAST_SEGMENTS = new Set([
217
+ 'file', 'files', 'path', 'paths', 'dir', 'arn', 'alias', 'name',
218
+ 'region', 'sock', 'socket', 'type', 'mode', 'method', 'version', 'count', 'limit', 'ttl', 'size', 'length', 'format',
219
+ ]);
220
+
221
+ function nameSegments(name) {
222
+ return String(name || '').replace(/^-+/, '').replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase().replace(/[.-]/g, '_')
223
+ .split('_').filter(Boolean);
224
+ }
225
+
226
+ function credentialName(name) {
227
+ const segments = nameSegments(name);
228
+ if (!segments.length || NON_SECRET_NAMES.has(segments.join('_'))) return false;
229
+ if (segments.length > 1 && NON_SECRET_LAST_SEGMENTS.has(segments[segments.length - 1])) return false;
230
+ return segments.some((segment) => CREDENTIAL_SEGMENTS.has(segment)
231
+ || (CREDENTIAL_WORD_RE.test(segment) && !NON_SECRET_WORDS.has(segment)));
232
+ }
233
+
234
+ // A flag whose next word is a secret: its last segment is a credential word (--token,
235
+ // --github-token, --db-password, --secret-key, --api-key, --key). `--skip-auth` is not.
236
+ const FLAG_SECRET_LAST = new Set(['token', 'password', 'passwd', 'passphrase', 'pass', 'pwd', 'secret', 'key', 'apikey', 'credential', 'credentials']);
237
+ function secretFlag(flag) {
238
+ const text = String(flag || '');
239
+ if (!/^--[A-Za-z][A-Za-z0-9_-]*$/.test(text)) return false;
240
+ const segments = nameSegments(text);
241
+ return FLAG_SECRET_LAST.has(segments[segments.length - 1]) && credentialName(text);
242
+ }
243
+
244
+ const NAME_ASSIGNMENT_AHEAD = /(?:[A-Za-z_][A-Za-z0-9_.-]*=|"[A-Za-z_][A-Za-z0-9_.-]*"\s*:)/y;
245
+ const COMMAND_START = /[A-Za-z_./$({`~!]/;
246
+ const aheadAt = (pattern, text, index) => {
247
+ pattern.lastIndex = index;
248
+ return pattern.test(text);
249
+ };
250
+
251
+ // Shell quoting in 'text' and 'script' modes: each closed quoted region, as [open, close]
252
+ // (close is the index of the closing quote). As in the shell, a region may span lines; an
253
+ // unclosed quote is a literal.
254
+ function quoteRegions(text, mode) {
255
+ const regions = [];
256
+ if (mode === 'word') return regions;
257
+ let index = 0;
258
+ while (index < text.length) {
259
+ const char = text[index];
260
+ if (char === '\\') {
261
+ index += 2;
262
+ continue;
263
+ }
264
+ if (char === "'" || char === '"') {
265
+ let at = index + 1;
266
+ while (at < text.length && text[at] !== char) at += char === '"' && text[at] === '\\' ? 2 : 1;
267
+ if (at < text.length && text[at] === char) {
268
+ regions.push([index, at]);
269
+ index = at + 1;
270
+ continue;
271
+ }
272
+ }
273
+ index += 1;
274
+ }
275
+ return regions;
276
+ }
277
+
278
+ // Finds the quoted region that opens at, or contains, a position (binary search), and where
279
+ // the simple command around a position starts (after the last unquoted ; | & ( or line break).
280
+ function regionFinder(regions, text = '') {
281
+ let separators = null;
282
+ const last = (position) => {
283
+ let low = 0;
284
+ let high = regions.length - 1;
285
+ let found = -1;
286
+ while (low <= high) {
287
+ const middle = (low + high) >> 1;
288
+ if (regions[middle][0] <= position) {
289
+ found = middle;
290
+ low = middle + 1;
291
+ } else high = middle - 1;
292
+ }
293
+ return found === -1 ? null : regions[found];
294
+ };
295
+ return {
296
+ opening(position) {
297
+ const region = last(position);
298
+ return region && region[0] === position ? region : null;
299
+ },
300
+ around(position) {
301
+ const region = last(position - 1);
302
+ return region && region[0] < position && position <= region[1] ? region : null;
303
+ },
304
+ commandStart(position) {
305
+ if (!separators) {
306
+ separators = [];
307
+ let next = 0;
308
+ for (let index = 0; index < text.length; index += 1) {
309
+ while (next < regions.length && regions[next][1] < index) next += 1;
310
+ if (next < regions.length && regions[next][0] <= index && index <= regions[next][1]) {
311
+ index = regions[next][1];
312
+ continue;
313
+ }
314
+ if (/[;|&\n(]/.test(text[index])) separators.push(index);
315
+ }
316
+ }
317
+ let low = 0;
318
+ let high = separators.length;
319
+ while (low < high) {
320
+ const middle = (low + high) >> 1;
321
+ if (separators[middle] < position) low = middle + 1;
322
+ else high = middle;
323
+ }
324
+ return low === 0 ? 0 : separators[low - 1] + 1;
325
+ },
326
+ };
156
327
  }
157
328
 
158
- function redact(value) {
159
- let text = String(value || '');
160
- text = text.replace(/\b[A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD)[A-Z0-9_]*=([^\s]+)/gi, (match, captured) => match.replace(captured, '[redacted]'));
161
- text = text.replace(/\bmrw_(?:live|test)_[A-Za-z0-9._-]+/g, '[redacted]');
162
- text = text.replace(/\bnpm_[A-Za-z0-9._-]+/g, '[redacted]');
163
- text = text.replace(/\bgh(?:p|o|u|s|r)_[A-Za-z0-9._-]+/g, '[redacted]');
164
- text = text.replace(/\bsk-[A-Za-z0-9._-]+/g, '[redacted]');
329
+ // A PEM block is one value from its BEGIN line through its END line, when it is shaped like
330
+ // one: BEGIN ends its line and every line up to END is base64, blank, or a Proc-Type/DEK-Info
331
+ // header. Anything else between the markers (commands) is not swallowed.
332
+ const PEM_BEGIN_RE = /-----BEGIN [A-Z0-9 ]{1,64}-----[ \t]*\r?\n/y;
333
+ const PEM_BODY_LINE_RE = /^(?:[A-Za-z0-9+/=]{0,128}|(?:Proc-Type|DEK-Info):[ -~]{0,128})[ \t]*\r?$/;
334
+ const PEM_END_LINE_RE = /^-----END [A-Z0-9 ]{1,64}-----/;
335
+ function pemEnd(text, start, limit) {
336
+ if (!aheadAt(PEM_BEGIN_RE, text, start)) return -1;
337
+ let lineStart = PEM_BEGIN_RE.lastIndex;
338
+ // Key data lines are long (64 base64 characters); only the last may be short, so a command
339
+ // word between two markers is not taken for key data.
340
+ let longLines = 0;
341
+ let shortLines = 0;
342
+ while (lineStart < limit) {
343
+ const newline = text.indexOf('\n', lineStart);
344
+ const lineEnd = newline === -1 || newline > limit ? limit : newline;
345
+ const line = text.slice(lineStart, Math.min(lineEnd, lineStart + 200));
346
+ const end = PEM_END_LINE_RE.exec(line);
347
+ if (end) return longLines > 0 && shortLines <= 1 ? lineStart + end[0].length : -1;
348
+ if (lineEnd - lineStart > 200 || !PEM_BODY_LINE_RE.test(line)) return -1;
349
+ const data = /^[A-Za-z0-9+/=]+/.exec(line.trim());
350
+ if (data && !/^(?:Proc-Type|DEK-Info):/.test(line)) {
351
+ if (shortLines > 0) return -1;
352
+ if (data[0].length >= 40) longLines += 1;
353
+ else shortLines += 1;
354
+ }
355
+ lineStart = lineEnd + 1;
356
+ }
357
+ return -1;
358
+ }
359
+
360
+ // In an argv word, a value continues on the next line only when that line is key data (16 to
361
+ // 128 base64 characters, = padding included) and does not start a new setting (`name: ...`,
362
+ // `- name: ...`, `name=value`). A line that holds a command or a sentence ends it.
363
+ const NEXT_LINE_SETTING_RE = /[ \t]*(?:-[ \t]+)?(?:[A-Za-z_][A-Za-z0-9_.-]*[ \t]*:(?=[ \t\r\n]|$)|[A-Za-z_][A-Za-z0-9_.-]*=(?=[^=\r\n]))/y;
364
+ const NEXT_LINE_CONTINUES_RE = /[A-Za-z0-9+/=]{16,128}[ \t]*(?:\r?\n|$)/y;
365
+
366
+ // Where a value starting at `start` ends, scanning no further than `limit` (the closing quote
367
+ // when the value starts inside quotes).
368
+ // `field` is true where a value is one field of a list (a URL query, an HTTP client's form or
369
+ // header argument, a field inside a longer word): there it also ends before another `name=` after
370
+ // & , #. In a whole-word NAME=value it does not, so a secret holding `&x=` stays hidden.
371
+ function valueEnd(text, start, mode, regions, limit = text.length, field = true) {
372
+ // In a script, a key block counts only inside quotes (limit is then the closing quote).
373
+ const pem = mode === 'script' && limit === text.length ? -1 : pemEnd(text, start, limit);
374
+ if (pem !== -1) return pem;
375
+ let index = start;
376
+ while (index < limit) {
377
+ const char = text[index];
378
+ const next = index + 1;
379
+ if (mode === 'word') {
380
+ if (char === '\n' || char === '\r') {
381
+ if (char === '\r' && text[next] === '\n') {
382
+ index = next;
383
+ continue;
384
+ }
385
+ if (next >= limit || aheadAt(NEXT_LINE_SETTING_RE, text, next) || !aheadAt(NEXT_LINE_CONTINUES_RE, text, next)) break;
386
+ index = next;
387
+ continue;
388
+ }
389
+ if (field && /[\s&,#;|]/.test(char) && aheadAt(NAME_ASSIGNMENT_AHEAD, text, next)) break;
390
+ // A JSON string list of commands (["export TOKEN=...","rm -rf x"]): the secret ends with its string.
391
+ if ((char === '"' || char === "'") && /[,\]}]/.test(text[next] || '')) break;
392
+ index = next;
393
+ continue;
394
+ }
395
+ if (/\s/.test(char)) break;
396
+ if (char === "'" || char === '"') {
397
+ const region = regions.opening(index);
398
+ index = region && region[1] < limit ? region[1] + 1 : next;
399
+ continue;
400
+ }
401
+ if (char === '\\' && next < limit && text[next] !== '\n') {
402
+ index = next + 1;
403
+ continue;
404
+ }
405
+ // Command substitution is never part of a literal secret.
406
+ if (char === '`' || (char === '$' && text[next] === '(')) break;
407
+ if (char === ';' || char === '|' || char === '&') {
408
+ if (mode === 'script') break;
409
+ if (char !== ';' && text[next] === char) break;
410
+ if (next >= limit || /\s/.test(text[next])) break;
411
+ if ((char !== '&' || field) && aheadAt(NAME_ASSIGNMENT_AHEAD, text, next)) break;
412
+ if (char !== '&' && COMMAND_START.test(text[next])) break;
413
+ }
414
+ if (field && (char === '#' || char === ',') && aheadAt(NAME_ASSIGNMENT_AHEAD, text, next)) break;
415
+ index = next;
416
+ }
417
+ return index;
418
+ }
419
+
420
+ // Whether the value at `start` is one field of a list: inside a URL (a ? or :// earlier in the
421
+ // same word), or the unquoted form or header argument of an HTTP client.
422
+ function fieldValue(text, start, regions) {
423
+ let wordStart = start;
424
+ const floor = Math.max(0, start - 512);
425
+ while (wordStart > floor && !/[\s'"]/.test(text[wordStart - 1])) wordStart -= 1;
426
+ const before = text.slice(wordStart, start);
427
+ if (/[?]|:\/\//.test(before)) return true;
428
+ if (!FIELD_ARGUMENT_RE.test(text.slice(Math.max(0, wordStart - 24), wordStart))) return false;
429
+ const commandStart = regions.commandStart(wordStart);
430
+ return HTTP_CLIENT_COMMAND_RE.test(text.slice(commandStart, Math.min(wordStart, commandStart + 256)));
431
+ }
432
+
433
+ // A value that starts inside a quoted region follows the same rules inside the quotes (a quoted
434
+ // script `ssh host 'TOKEN=S; rm -rf /data'` keeps its commands visible); reaching the closing
435
+ // quote, it runs on through anything glued after it.
436
+ const FIELD_ARGUMENT_RE = /(?:^|\s)(?:-d|-H|-F|--data(?:-raw|-binary|-urlencode|-ascii)?|--json|--form(?:-string)?|--header)(?:\s+|=)$/;
437
+ const HTTP_CLIENT_COMMAND_RE = /^\s*(?:(?:sudo|env|command|exec|time|nice|nohup)(?:\s+-[A-Za-z-]+)*\s+|[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*(?:\S*\/)?(?:curl|wget|http|https|xh)\s/;
438
+ // Whether the quoted region at `open` is a field or header argument of an HTTP client: the
439
+ // simple command it belongs to (back to the last unquoted ; | & ( or line break) starts with
440
+ // curl, wget, http, https or xh. `watch -d` or `tmux new -d` run their payload instead.
441
+ function httpFieldArgument(text, open, regions) {
442
+ if (!FIELD_ARGUMENT_RE.test(text.slice(Math.max(0, open - 24), open))) return false;
443
+ const start = regions.commandStart(open);
444
+ return HTTP_CLIENT_COMMAND_RE.test(text.slice(start, Math.min(open, start + 256)));
445
+ }
446
+ const HEADER_LIST_AHEAD = /,\s*[A-Za-z_][A-Za-z0-9_.-]*\s*:/y;
447
+ function quotedValueEnd(text, start, region, mode, regions) {
448
+ // The quoted argument of -d/--data/--json/-F/--form/-H/--header is one field or header: its
449
+ // value runs to the closing quote (spaces included), or to the next field or header in it.
450
+ let end = region[1];
451
+ if (httpFieldArgument(text, region[0], regions)) {
452
+ for (let index = start; index < region[1]; index += 1) {
453
+ if ((/[\s&,#;|]/.test(text[index]) && aheadAt(NAME_ASSIGNMENT_AHEAD, text, index + 1))
454
+ || (text[index] === ',' && aheadAt(HEADER_LIST_AHEAD, text, index))) {
455
+ end = index;
456
+ break;
457
+ }
458
+ }
459
+ } else end = valueEnd(text, start, mode, regions, region[1], true);
460
+ if (end < region[1]) return end;
461
+ const after = region[1] + 1;
462
+ if (after < text.length && !/[\s;|&)]/.test(text[after])) return valueEnd(text, after, mode, regions);
463
+ return region[1];
464
+ }
465
+
466
+ // A YAML block scalar (`password: |` or `>`): the more-indented lines after it are the value.
467
+ const YAML_BLOCK_RE = /[|>][-+0-9]{0,3}[ \t]*(?=\r?\n)/y;
468
+ function yamlBlockEnd(text, keyIndex, start) {
469
+ if (!aheadAt(YAML_BLOCK_RE, text, start)) return -1;
470
+ const lineStart = text.lastIndexOf('\n', keyIndex) + 1;
471
+ const keyIndent = /^[ \t]*/.exec(text.slice(lineStart, lineStart + 256))[0].length;
472
+ let end = text.indexOf('\n', start);
473
+ while (end !== -1 && end < text.length) {
474
+ const lineEnd = text.indexOf('\n', end + 1) === -1 ? text.length : text.indexOf('\n', end + 1);
475
+ const line = text.slice(end + 1, lineEnd);
476
+ const indent = /^[ \t]*/.exec(line)[0].length;
477
+ if (line.trim() && indent <= keyIndent) break;
478
+ end = lineEnd === text.length ? text.length : lineEnd;
479
+ if (lineEnd === text.length) break;
480
+ }
481
+ return end === -1 ? -1 : end;
482
+ }
483
+
484
+ // A JSON value after a quoted name: a string, or an object or array (all of it), or a bare
485
+ // literal.
486
+ function jsonValueEnd(text, start) {
487
+ const open = text[start];
488
+ if (open === '{' || open === '[') {
489
+ let depth = 0;
490
+ let index = start;
491
+ while (index < text.length) {
492
+ const char = text[index];
493
+ if (char === '"') {
494
+ index += 1;
495
+ while (index < text.length && text[index] !== '"') index += text[index] === '\\' ? 2 : 1;
496
+ } else if (char === '{' || char === '[') depth += 1;
497
+ else if (char === '}' || char === ']') {
498
+ depth -= 1;
499
+ if (depth === 0) return index + 1;
500
+ }
501
+ index += 1;
502
+ }
503
+ return text.length;
504
+ }
505
+ let index = start;
506
+ while (index < text.length && !/[\s,}\]]/.test(text[index])) index += 1;
507
+ return index;
508
+ }
509
+
510
+ // The value at `start`: its end and its redacted form, or null when there is nothing to redact.
511
+ const ESCAPED_QUOTED_RE = /\\"((?:[^"\\]|\\[^"])*)\\"/y;
512
+ const DOUBLE_QUOTED_RE = /"((?:[^"\\]|\\.)*)"/y;
513
+ const SINGLE_QUOTED_RE = /'([^']*)'/y;
514
+ function secretValueAt(text, start, mode, regions, jsonName = false, wholeWord = false) {
515
+ if (start >= text.length) return null;
516
+ const sticky = (pattern, open) => {
517
+ pattern.lastIndex = start;
518
+ const match = pattern.exec(text);
519
+ return match ? { end: start + match[0].length, value: match[1], replacement: `${open}${hiddenAs(match[1])}${open}` } : null;
520
+ };
521
+ let found = null;
522
+ if (text.startsWith('\\"', start)) found = sticky(ESCAPED_QUOTED_RE, '\\"');
523
+ else if (jsonName && text[start] === '"') found = sticky(DOUBLE_QUOTED_RE, '"');
524
+ else if (jsonName && text[start] === "'") found = sticky(SINGLE_QUOTED_RE, "'");
525
+ else if (jsonName) {
526
+ const end = jsonValueEnd(text, start);
527
+ found = { end, value: text.slice(start, end), replacement: hiddenAs(text.slice(start, end)) };
528
+ }
529
+ if (!found) {
530
+ const around = mode === 'word' ? null : regions.around(start);
531
+ let end;
532
+ if (around) end = quotedValueEnd(text, start, around, mode, regions);
533
+ else if (mode === 'word' && wholeWord) end = text.length;
534
+ else end = valueEnd(text, start, mode, regions, text.length, mode === 'word' ? true : !wholeWord || fieldValue(text, start, regions));
535
+ const value = text.slice(start, end);
536
+ // One quoted piece keeps its quotes; anything else is replaced whole. A value that may also
537
+ // hide command text or a separator becomes the ambiguous marker.
538
+ const piece = !around && mode !== 'word' ? regions.opening(start) : null;
539
+ const single = Boolean(piece) && piece[1] + 1 === end;
540
+ const inner = single ? value.slice(1, -1) : value;
541
+ found = { end, value: inner, replacement: single ? `${value[0]}${hiddenAs(inner)}${value[0]}` : hiddenAs(value) };
542
+ }
543
+ if (!found.value || MARKERS.has(found.value) || (mode !== 'word' && REFERENCE_RE.test(found.value))) return null;
544
+ return found;
545
+ }
546
+
547
+ // `name: value` with a plain word mid-sentence ("rotate the key: production first") is prose;
548
+ // a `host:port` or `name:/path` is not a credential. JSON, headers ("apikey: ..."), YAML lines
549
+ // (indented too) and env-style names are credentials.
550
+ function credentialColon(text, index, lead, quote, name, valueStart, lineIsBlankBefore) {
551
+ if (text[valueStart] === '/' || (lead === '/' && text[index - 1] === '/')) return false;
552
+ if (name.includes('.') && /^\d+(?:[/?#]|$)/.test(text.slice(valueStart, valueStart + 12))) return false;
553
+ if (quote || lead === '' || /[\n{[,"'=]/.test(lead)) return true;
554
+ if (/[ \t]/.test(lead) && lineIsBlankBefore(index + 1)) return true;
555
+ return !/^(?:[a-z]+|[A-Z][a-z]+)$/.test(name);
556
+ }
557
+
558
+ // Whether only blanks (and a YAML list dash) come before a position on its line. Positions are
559
+ // asked in increasing order, so the scan is linear.
560
+ function blankLineTracker(text) {
561
+ let at = 0;
562
+ let blank = true;
563
+ return (position) => {
564
+ for (; at < position; at += 1) {
565
+ const char = text[at];
566
+ if (char === '\n') blank = true;
567
+ else if (char !== ' ' && char !== '\t' && char !== '-') blank = false;
568
+ }
569
+ return blank;
570
+ };
571
+ }
572
+
573
+ const AUTH_SCHEME_RE = /^(?:bearer|basic|token|digest|bot|apikey|negotiate)$/i;
574
+ const ASSIGNMENT_PREFIX_SOURCE = '(^|[^A-Za-z0-9_.-])(\\\\?["\']?)([A-Za-z0-9_.-]+)\\2(\\s*[:=]\\s*)';
575
+ // A whole-word NAME=value (the name starts its word, or follows --env= or --build-arg=) whose
576
+ // value runs to the end of the word, unless the word is a field list (`field`).
577
+ const WHOLE_WORD_PREFIX_RE = /^(?:-{1,2}(?:env|build-arg|e)=)?$/;
578
+ function redactAssignments(text, mode, field = false) {
579
+ const pattern = new RegExp(ASSIGNMENT_PREFIX_SOURCE, 'g');
580
+ const regions = regionFinder(quoteRegions(text, mode), text);
581
+ const lineIsBlankBefore = blankLineTracker(text);
582
+ let out = '';
583
+ let last = 0;
584
+ let match;
585
+ while ((match = pattern.exec(text)) !== null) {
586
+ const [whole, lead, quote, name, separator] = match;
587
+ const valueStart = match.index + whole.length;
588
+ const colon = !separator.includes('=');
589
+ let value = null;
590
+ if (credentialName(name)
591
+ && !(colon && !quote && /authorization$/i.test(name)) // the header pass did these
592
+ && !(colon && !credentialColon(text, match.index, lead, quote, name, valueStart, lineIsBlankBefore))) {
593
+ const block = colon && (mode !== 'script' || regions.around(valueStart)) ? yamlBlockEnd(text, match.index, valueStart) : -1;
594
+ const nameStart = match.index + lead.length + quote.length;
595
+ let wholeWord = false;
596
+ if (!colon && !quote && !field) {
597
+ if (mode === 'word') wholeWord = WHOLE_WORD_PREFIX_RE.test(text.slice(0, nameStart));
598
+ else {
599
+ // In free text and scripts: the name starts its word.
600
+ wholeWord = nameStart === 0 || /[\s'"]/.test(text[nameStart - 1]);
601
+ }
602
+ }
603
+ value = block !== -1
604
+ ? { end: block, value: text.slice(valueStart, block), replacement: `${text[valueStart]} ${AMBIGUOUS}` }
605
+ : secretValueAt(text, valueStart, mode, regions, Boolean(quote) && colon, wholeWord);
606
+ // `X-Token: Bearer [redacted]`: the scheme pass did this one.
607
+ if (value && AUTH_SCHEME_RE.test(value.value) && /^ \[(?:redacted|REDACTED_AMBIGUOUS)\]/.test(text.slice(value.end, value.end + 24))) value = null;
608
+ }
609
+ if (!value) {
610
+ // Scan again from the next character: a rejected match must not hide a secret inside it.
611
+ pattern.lastIndex = match.index + 1;
612
+ continue;
613
+ }
614
+ out += `${text.slice(last, valueStart)}${value.replacement}`;
615
+ last = value.end;
616
+ pattern.lastIndex = value.end;
617
+ }
618
+ return out + text.slice(last);
619
+ }
620
+
621
+ // A prefix (a header, a flag, `config set NAME`) followed by a secret value.
622
+ function redactAfter(text, pattern, mode, accept = () => true) {
623
+ const regions = regionFinder(quoteRegions(text, mode), text);
624
+ let out = '';
625
+ let last = 0;
626
+ let match;
627
+ pattern.lastIndex = 0;
628
+ while ((match = pattern.exec(text)) !== null) {
629
+ const valueStart = match.index + match[0].length;
630
+ const value = accept(match, valueStart, text) ? secretValueAt(text, valueStart, mode, regions) : null;
631
+ if (!value) {
632
+ // Scan again from the next character: a rejected match must not hide the next one.
633
+ pattern.lastIndex = match.index + 1;
634
+ continue;
635
+ }
636
+ out += `${text.slice(last, valueStart)}${value.replacement}`;
637
+ last = value.end;
638
+ pattern.lastIndex = value.end;
639
+ }
640
+ return out + text.slice(last);
641
+ }
642
+
643
+ const AUTH_HEADER_RE = /\b(?:proxy-)?authorization\s*:(?!\s*digest\s)\s*(?:(?:bearer|basic|token|bot|apikey|negotiate)\s+)?/gi;
644
+ // Digest credentials (username, nonce, response...) are one value, to the end of the header.
645
+ const DIGEST_HEADER_RE = /\b(?:proxy-)?authorization\s*:\s*digest\s+/gi;
646
+ const SECRET_FLAG_TEXT_RE = /(?<!\S)(--[A-Za-z][A-Za-z0-9_-]*)\s+/g;
647
+ const CONFIG_SET_RE = /\b(?:config|configure)\s+set\s+(\S+)\s+/g;
648
+ const OPENSSL_PASS_RE = /(?<!\S)-pass(?:in|out)?\s+pass:/g;
649
+ // -u user:password for HTTP clients (docker run -u 1000:0 is a user and group, not a secret).
650
+ const USER_FLAG_RE = /(?<![^\s;|&(])(?:curl|wget|http|https|xh|ab)(?=\s)[^\n;|&]{0,240}?\s(?:-u|--user)\s+[^\s:'"]+:/g;
651
+ const HTTP_CLIENTS = ['curl', 'wget', 'http', 'https', 'xh', 'ab'];
652
+ // A plain switch (true, off) under a credential-looking name is not a secret; a number may be.
653
+ const PLAIN_SETTING_RE = /(?:true|false|yes|no|on|off)(?=\s|$)/iy;
654
+ // A word after a secret flag that starts with a dash is a secret only when it looks like one.
655
+ const WORD_AT_RE = /\S{1,512}/y;
656
+ const wordAt = (text, index) => {
657
+ WORD_AT_RE.lastIndex = index;
658
+ const match = WORD_AT_RE.exec(text);
659
+ return match ? match[0] : '';
660
+ };
661
+ const looksSecret = (word) => word.length >= 16 && /[0-9]/.test(word) && /[A-Za-z]/.test(word) && !/^--?[a-z][a-z0-9-]*(?:=|$)/.test(word);
662
+ // Short flags that take a secret in particular tools: docker/podman login -p, redis-cli -a,
663
+ // sshpass -p, gh secret set --body/-b, and mysql's glued -pPASSWORD.
664
+ const TOOL_SECRET_FLAGS = [
665
+ [['docker', 'podman', 'nerdctl'], 'login', '-p'],
666
+ [['redis-cli'], null, '-a'],
667
+ [['sshpass'], null, '-p'],
668
+ [['gh'], 'secret', '--body'],
669
+ [['gh'], 'secret', '-b'],
670
+ ];
671
+ const MYSQL_CLIENTS = ['mysql', 'mysqladmin', 'mysqldump'];
672
+ const TOOL_FLAG_TEXT_RE = /\b(?:(?:docker|podman|nerdctl)\s+login\b[^\n;|&]{0,240}?\s-p|redis-cli\b[^\n;|&]{0,240}?\s-a|sshpass\b[^\n;|&]{0,240}?\s-p|gh\s+secret\s+set\b[^\n;|&]{0,240}?\s(?:--body|-b))\s+/g;
673
+ const MYSQL_GLUED_RE = /\b(?:mysql|mysqladmin|mysqldump)\b[^\n;|&]{0,240}?\s-p(?=[^\s-])/g;
674
+ const BARE_SCHEME_RE = /\b(bearer|basic)(\s+)([^\s'"]{8,})/gi;
675
+ // Linear: the scheme cannot start inside a longer run of scheme characters.
676
+ const URL_USERINFO_RE = /(?<![A-Za-z0-9+.-])([A-Za-z][A-Za-z0-9+.-]{0,31}:\/\/)([^\s'"/?#@:]+)(?::([^\s'"/?#]*))?@/g;
677
+ const TOKEN_SHAPES = [
678
+ /\bmrw_(?:live|test)_[A-Za-z0-9._-]+/g,
679
+ /\bnpm_[A-Za-z0-9._-]+/g,
680
+ /\bgh(?:p|o|u|s|r)_[A-Za-z0-9._-]+/g,
681
+ /\bgithub_pat_[A-Za-z0-9._-]+/g,
682
+ /\bglpat-[A-Za-z0-9._-]+/g,
683
+ /\bsk-[A-Za-z0-9._-]+/g,
684
+ /\b[sr]k_(?:live|test)_[A-Za-z0-9]+/g,
685
+ /\bwhsec_[A-Za-z0-9]+/g,
686
+ /\bxox[abposr]-[A-Za-z0-9._-]+/g,
687
+ /\bAKIA[0-9A-Z]{16}\b/g,
688
+ /(?<![A-Za-z0-9_-])eyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}/g,
689
+ ];
690
+
691
+ function redactDigest(text, mode) {
692
+ const regions = regionFinder(quoteRegions(text, mode), text);
693
+ let out = '';
694
+ let last = 0;
695
+ let match;
696
+ DIGEST_HEADER_RE.lastIndex = 0;
697
+ while ((match = DIGEST_HEADER_RE.exec(text)) !== null) {
698
+ const valueStart = match.index + match[0].length;
699
+ const around = mode === 'word' ? null : regions.around(valueStart);
700
+ const newline = text.indexOf('\n', valueStart);
701
+ const lineEnd = newline === -1 ? text.length : newline;
702
+ let end = around ? Math.min(around[1], lineEnd) : lineEnd;
703
+ if (!around && mode !== 'word') {
704
+ // Unquoted: up to a shell operator outside the header's own quoted parameters.
705
+ let quoted = false;
706
+ for (let index = valueStart; index < end; index += 1) {
707
+ const char = text[index];
708
+ if (char === '"') quoted = !quoted;
709
+ else if (!quoted && (char === ';' || char === '|' || (char === '&' && text[index + 1] === '&'))) {
710
+ end = index;
711
+ while (end > valueStart && /\s/.test(text[end - 1])) end -= 1;
712
+ break;
713
+ }
714
+ }
715
+ }
716
+ if (end <= valueStart || MARKERS.has(text.slice(valueStart, end))) continue;
717
+ out += `${text.slice(last, valueStart)}${hiddenAs(text.slice(valueStart, end))}`;
718
+ last = end;
719
+ DIGEST_HEADER_RE.lastIndex = end;
720
+ }
721
+ return out + text.slice(last);
722
+ }
723
+
724
+ function redact(value, mode = 'text', { field = false } = {}) {
725
+ // No control sequence may hide a credential name (TO<ESC>[0mKEN=...), and none is sent.
726
+ let text = stripControls(String(value || ''));
727
+ text = redactDigest(text, mode);
728
+ text = redactAfter(text, AUTH_HEADER_RE, mode);
729
+ // Basic credentials are base64 with a digit, + / or = in practice; a plain word is prose.
730
+ text = text.replace(BARE_SCHEME_RE, (match, scheme, space, credential) => {
731
+ const prose = /^basic$/i.test(scheme) && !(/^[A-Za-z0-9+/]+={0,2}$/.test(credential) && /[0-9+/=]/.test(credential));
732
+ return MARKERS.has(credential) || prose ? match : `${scheme}${space}${hiddenAs(credential)}`;
733
+ });
734
+ text = text.replace(URL_USERINFO_RE, (match, scheme, user, password) => {
735
+ if (password !== undefined) return password && !MARKERS.has(password) ? `${scheme}${user}:${hiddenAs(password)}@` : match;
736
+ // A user with no password is a name (git@, deploy@) unless it is token-like.
737
+ return user.length >= 20 && /[0-9]/.test(user) && !MARKERS.has(user) ? `${scheme}${hiddenAs(user)}@` : match;
738
+ });
739
+ text = redactAfter(text, SECRET_FLAG_TEXT_RE, mode, (match, at, source) => secretFlag(match[1])
740
+ && (source[at] !== '-' || looksSecret(wordAt(source, at))));
741
+ text = redactAfter(text, CONFIG_SET_RE, mode, (match, at, source) => credentialName(String(match[1]).split(/[:/]/).pop())
742
+ && !aheadAt(PLAIN_SETTING_RE, source, at));
743
+ text = redactAfter(text, OPENSSL_PASS_RE, mode);
744
+ text = redactAfter(text, USER_FLAG_RE, mode);
745
+ text = redactAfter(text, TOOL_FLAG_TEXT_RE, mode);
746
+ text = redactAfter(text, MYSQL_GLUED_RE, mode);
747
+ text = redactAssignments(text, mode, field);
748
+ for (const shape of TOKEN_SHAPES) text = text.replace(shape, REDACTED);
165
749
  return text;
166
750
  }
167
751
 
@@ -171,8 +755,136 @@ function shellQuote(value) {
171
755
  return /^[A-Za-z0-9_./:@%+=,-]+$/.test(text) ? text : `'${text.replace(/'/g, "'\\''")}'`;
172
756
  }
173
757
 
758
+ // Which argv words are shell scripts: the word after a -c flag (-c, -lc, -ec) of a shell or
759
+ // su, the words after eval or watch, a remote command after ssh, and any word that holds a
760
+ // command line (a space and a shell operator or substitution; a line break alone is a value's).
761
+ const SHELL_NAMES = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'ash', 'mksh', 'fish', 'su', 'runuser']);
762
+ const COMMAND_LINE_RE = /\s/;
763
+ const SHELL_SYNTAX_RE = /[;|`]|&&|\$\(/;
764
+ const ASSIGNMENT_WORD_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;
765
+ const ENV_FLAG_WORDS = new Set(['-e', '--env', '--build-arg', '--set-env-vars']);
766
+ function argvModes(words, names) {
767
+ let sawSsh = false;
768
+ let envAssignments = false;
769
+ return words.map((word, index) => {
770
+ // An environment setting (after env, -e, --env, --build-arg) is one value, whatever it holds.
771
+ const setting = ASSIGNMENT_WORD_RE.test(word) && (envAssignments || (index > 0 && ENV_FLAG_WORDS.has(words[index - 1])));
772
+ if (names[index] === 'env') envAssignments = true;
773
+ else if (!ASSIGNMENT_WORD_RE.test(word) && !word.startsWith('-')) envAssignments = false;
774
+ if (setting) return 'word';
775
+ let mode = 'word';
776
+ if (index >= 2 && /^-[A-Za-z]*c[A-Za-z]*$/.test(words[index - 1])) {
777
+ // Back over the shell's own flags (at most 16) to the shell.
778
+ for (let at = index - 2, steps = 0; at >= 0 && steps < 16; at -= 1, steps += 1) {
779
+ if (SHELL_NAMES.has(names[at])) {
780
+ mode = 'script';
781
+ break;
782
+ }
783
+ if (!words[at].startsWith('-')) break;
784
+ }
785
+ }
786
+ if (index >= 1 && (names[index - 1] === 'eval' || names[index - 1] === 'watch')) mode = 'script';
787
+ if (COMMAND_LINE_RE.test(word) && (sawSsh || SHELL_SYNTAX_RE.test(word))) mode = 'script';
788
+ if (names[index] === 'ssh') sawSsh = true;
789
+ return mode;
790
+ });
791
+ }
792
+
793
+ // One argv word, redacted, given the words before it (`seen` holds the names of the words
794
+ // before the previous one).
795
+ function redactArgvWord(words, names, index, seen, mode) {
796
+ const previous = index > 0 ? words[index - 1] : '';
797
+ const word = words[index];
798
+ if (secretFlag(previous) && (!word.startsWith('-') || looksSecret(word))) return hiddenAs(word);
799
+ // -u user:password for HTTP clients: the name stays.
800
+ if ((previous === '-u' || previous === '--user') && HTTP_CLIENTS.some((name) => seen.has(name)) && /^[^:]+:./.test(word)) {
801
+ return shellQuote(`${word.split(':')[0]}:${hiddenAs(word.slice(word.indexOf(':') + 1))}`);
802
+ }
803
+ if ((previous === '-passin' || previous === '-passout' || previous === '-pass') && word.startsWith('pass:')) return shellQuote(`pass:${hiddenAs(word.slice(5))}`);
804
+ if (TOOL_SECRET_FLAGS.some(([tools, verb, flag]) => previous === flag && tools.some((name) => seen.has(name)) && (!verb || seen.has(verb)))) return hiddenAs(word);
805
+ if (/^-p[^\s-]/.test(word) && MYSQL_CLIENTS.some((name) => seen.has(name) || names[index - 1] === name)) return `-p${hiddenAs(word.slice(2))}`;
806
+ if (index >= 3 && words[index - 2] === 'set' && /^(?:config|configure)$/.test(words[index - 3])
807
+ && credentialName(previous.split(/[:/]/).pop()) && !aheadAt(PLAIN_SETTING_RE, word, 0)) return hiddenAs(word);
808
+ // The form or header argument of an HTTP client is a field list: its fields stay apart.
809
+ const field = mode === 'word' && FIELD_ARGUMENT_RE.test(` ${previous} `) && HTTP_CLIENTS.some((name) => seen.has(name));
810
+ return shellQuote(redact(word, mode, { field }));
811
+ }
812
+
174
813
  function redactedCommand(command) {
175
- return command.map((part) => shellQuote(redact(part))).join(' ');
814
+ const words = command.map((part) => String(part ?? ''));
815
+ const names = words.map((word) => word.split('/').pop());
816
+ const modes = argvModes(words, names);
817
+ const seen = new Set();
818
+ const out = [];
819
+ for (let index = 0; index < words.length; index += 1) {
820
+ if (index >= 2) seen.add(names[index - 2]);
821
+ out.push(redactArgvWord(words, names, index, seen, modes[index]));
822
+ }
823
+ return out.join(' ');
824
+ }
825
+
826
+ // --action, --target and --type exactly as typed stay in this process: for local risk
827
+ // classification and the local hold key. Every request and every printed line uses the
828
+ // redacted values that replace them on the options.
829
+ const RAW_INPUTS = Symbol('marrow raw inputs');
830
+ function withRedactedInputs(options) {
831
+ const raw = {
832
+ action: typeof options.action === 'string' ? options.action : '',
833
+ target: typeof options.target === 'string' ? options.target : '',
834
+ type: typeof options.type === 'string' ? options.type : '',
835
+ };
836
+ const secured = {
837
+ ...options,
838
+ action: raw.action ? redact(raw.action) : options.action,
839
+ target: raw.target ? redact(raw.target) : options.target,
840
+ type: raw.type ? redact(raw.type) : options.type,
841
+ };
842
+ Object.defineProperty(secured, RAW_INPUTS, { value: raw, enumerable: false });
843
+ return secured;
844
+ }
845
+
846
+ function rawInputs(options) {
847
+ if (options && options[RAW_INPUTS]) return options[RAW_INPUTS];
848
+ return { action: String(options?.action || ''), target: String(options?.target || ''), type: String(options?.type || '') };
849
+ }
850
+
851
+ // Of two inferred types, the one that is protected; if both or neither are, the first (the type
852
+ // of the command as typed, so a DROP behind a secret stays migration, not security).
853
+ function stricterType(first, second) {
854
+ if (PROTECTED_ACTION_TYPES.has(first) || !PROTECTED_ACTION_TYPES.has(second)) return first;
855
+ return second;
856
+ }
857
+
858
+ // One governed action: the redacted text that may leave this machine, and a risk verdict that
859
+ // is never looser than the command as typed. `surfacesFrom` keeps each command's surface
860
+ // source: run uses the command, permit and verify-permit the target, gate the action.
861
+ function governedInputs(options, childCommand = null, surfacesFrom = 'command') {
862
+ const raw = rawInputs(options);
863
+ const rawCommand = Array.isArray(childCommand) ? childCommand.map(String).join(' ') : '';
864
+ const commandText = Array.isArray(childCommand) ? redactedCommand(childCommand) : '';
865
+ // Redacted again in case options did not come through parseArgs (redaction is idempotent).
866
+ const action = options.action ? redact(options.action) : commandText;
867
+ const sentTarget = options.target ? redact(options.target) : '';
868
+ const target = sentTarget || commandText || action;
869
+ const rawAction = raw.action || rawCommand;
870
+ const rawRisk = `${rawAction} ${rawCommand} ${raw.target}`;
871
+ const redactedRisk = `${action} ${commandText} ${sentTarget}`;
872
+ const type = options.type ? redact(options.type) : stricterType(inferType(rawRisk), inferType(redactedRisk));
873
+ const risky = isRisky(redactedRisk, type) || isRisky(rawRisk, type);
874
+ let surfaceText = [commandText || action, rawCommand || rawAction];
875
+ if (surfacesFrom === 'target') surfaceText = [sentTarget || action, raw.target || rawAction];
876
+ else if (surfacesFrom === 'action') surfaceText = [action, rawAction];
877
+ // Surfaces are fixed labels, so the raw text can add some without anything raw leaving.
878
+ const surfaces = inferSurfaces(surfaceText.join(' '));
879
+ // Marrow decides whether an approval may be reused from the action text alone: an ambiguous
880
+ // hidden value in the target or the type is carried into the action, so it is never bound.
881
+ const sentAction = (target.includes(AMBIGUOUS) || String(type).includes(AMBIGUOUS)) && !action.includes(AMBIGUOUS)
882
+ ? `${action} ${AMBIGUOUS}` : action;
883
+ return {
884
+ commandText, action: sentAction, target, type, risky, surfaces,
885
+ // Local only, for the hold key: the command and inputs exactly as typed.
886
+ holdMaterial: { argv: Array.isArray(childCommand) ? childCommand.map(String) : [], action: raw.action, target: raw.target, type: raw.type },
887
+ };
176
888
  }
177
889
 
178
890
  function isProtectedCommand(text) {
@@ -218,13 +930,18 @@ function sourceClient(value) {
218
930
  }
219
931
 
220
932
  function displayText(value, maxLength = 120) {
221
- const text = redact(String(value || ''))
222
- .replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, '')
223
- .replace(/\u009d[^\u0007\u009c]*(?:\u0007|\u009c|\u001b\\)/g, '')
933
+ // redact() drops control sequences first, so one cannot split a credential name.
934
+ const text = redact(String(value || '')).replace(/[\t\r\n]+/g, ' ');
935
+ return text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text;
936
+ }
937
+
938
+ // Terminal control sequences and control characters (tabs and line breaks stay).
939
+ function stripControls(value) {
940
+ return value
941
+ .replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g, '')
942
+ .replace(/\u009d[^\u0007\u009c\u009d\u001b]*(?:\u0007|\u009c|\u001b\\)/g, '')
224
943
  .replace(/[\u001b\u009b][[\]()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '')
225
- .replace(/[\t\r\n]+/g, ' ')
226
944
  .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, '');
227
- return text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text;
228
945
  }
229
946
 
230
947
  function shellQuoteDisplay(value) {
@@ -354,7 +1071,9 @@ function isRisky(text, type) {
354
1071
 
355
1072
  function parseBaseOptions(argv, startIndex = 0) {
356
1073
  const options = {
357
- apiKey: process.env.MARROW_API_KEY || process.env.MARROW_KEY || '',
1074
+ // The key from this terminal, or the owner-only ~/.marrow/env the installer stores, so a run
1075
+ // started from an agent opened outside the install terminal still finds it.
1076
+ apiKey: process.env.MARROW_API_KEY || process.env.MARROW_KEY || ownerEnvApiKey(),
358
1077
  baseUrl: process.env.MARROW_BASE_URL || DEFAULT_BASE_URL,
359
1078
  // A local username is never a registered agent. With no configured id, the server
360
1079
  // resolves the key-bound agent or the plan seat instead.
@@ -365,7 +1084,11 @@ function parseBaseOptions(argv, startIndex = 0) {
365
1084
  failOpen: process.env.MARROW_FAIL_OPEN === 'true',
366
1085
  failClosed: process.env.MARROW_FAIL_CLOSED === 'true',
367
1086
  json: false,
368
- ownerApproval: '',
1087
+ // --owner-approved is accepted and ignored: an approval is what Marrow records, never a
1088
+ // reference the caller supplies.
1089
+ ownerApprovedFlagIgnored: false,
1090
+ requestOwnerLink: false,
1091
+ approvalWaitSeconds: null,
369
1092
  proofFile: '',
370
1093
  type: '',
371
1094
  action: '',
@@ -395,7 +1118,16 @@ function parseBaseOptions(argv, startIndex = 0) {
395
1118
  } else if (arg === '--fail-closed') {
396
1119
  options.failClosed = true;
397
1120
  options.failOpen = false;
398
- } else if (arg === '--owner-approved') options.ownerApproval = argv[++i] || options.ownerApproval;
1121
+ } else if (arg === '--owner-approved') {
1122
+ // Its value is read and dropped; it never reaches Marrow, proof or a permit.
1123
+ if (argv[i + 1] !== undefined && !String(argv[i + 1]).startsWith('--')) i += 1;
1124
+ options.ownerApprovedFlagIgnored = true;
1125
+ } else if (arg === '--request-owner-link') options.requestOwnerLink = true;
1126
+ else if (arg === '--approval-wait') {
1127
+ const value = Number(argv[++i]);
1128
+ if (!Number.isInteger(value) || value < 0 || value > 3600) throw new Error('--approval-wait must be a whole number of seconds from 0 to 3600');
1129
+ options.approvalWaitSeconds = value;
1130
+ }
399
1131
  else if (arg === '--permit') options.permit = argv[++i] || options.permit;
400
1132
  else if (arg === '--target') options.target = argv[++i] || options.target;
401
1133
  else if (arg === '--sidecar-port') options.sidecarPort = argv[++i] || options.sidecarPort;
@@ -412,13 +1144,16 @@ function parseBaseOptions(argv, startIndex = 0) {
412
1144
  else if (arg === '--interactive') options.interactive = true;
413
1145
  else if (arg === '--no-interactive') options.interactive = false;
414
1146
  else if (arg === '--help' || arg === '-h') options.help = true;
415
- else if (arg.startsWith('--')) throw new Error(`Unknown option: ${arg}`);
1147
+ else if (arg.startsWith('--')) throw new Error(`Unknown option: ${argumentLabel(arg)}`);
416
1148
  else break;
417
1149
  }
418
1150
  if (!['enforce', 'warn', 'audit'].includes(options.policy)) {
419
1151
  throw new Error('--policy must be enforce, warn, or audit');
420
1152
  }
421
- if (!options.sessionId) options.sessionId = nowId('marrow-run');
1153
+ if (!options.sessionId) {
1154
+ options.sessionId = defaultRunnerSession(options);
1155
+ options.sessionDefaulted = true;
1156
+ }
422
1157
  return { options, index: i };
423
1158
  }
424
1159
 
@@ -435,7 +1170,7 @@ function parseArgs(argv) {
435
1170
  if (childCommand[0] === '--') childCommand = childCommand.slice(1);
436
1171
  if (parsed.options.help) return { command: 'help' };
437
1172
  if (childCommand.length === 0) throw new Error('marrow run requires a command after --');
438
- return { command, options: parsed.options, childCommand };
1173
+ return { command, options: withRedactedInputs(parsed.options), childCommand };
439
1174
  }
440
1175
 
441
1176
  if (command === 'gate') {
@@ -443,7 +1178,7 @@ function parseArgs(argv) {
443
1178
  const action = parsed.options.action || argv.slice(parsed.index).join(' ');
444
1179
  if (parsed.options.help) return { command: 'help' };
445
1180
  if (!action) throw new Error('marrow gate requires an action string');
446
- return { command, options: { ...parsed.options, action } };
1181
+ return { command, options: withRedactedInputs({ ...parsed.options, action }) };
447
1182
  }
448
1183
 
449
1184
  if (command === 'permit' || command === 'verify-permit') {
@@ -454,7 +1189,7 @@ function parseArgs(argv) {
454
1189
  if (command === 'verify-permit' && !parsed.options.permit) {
455
1190
  throw new Error('verify-permit requires MARROW_ACTION_PERMIT or --permit');
456
1191
  }
457
- return { command, options: { ...parsed.options, action } };
1192
+ return { command, options: withRedactedInputs({ ...parsed.options, action }) };
458
1193
  }
459
1194
 
460
1195
  if (command === 'proof') {
@@ -474,7 +1209,7 @@ function parseArgs(argv) {
474
1209
  }
475
1210
  const parsed = parseBaseOptions(baseArgv, 1);
476
1211
  if (parsed.options.help) return { command: 'help' };
477
- if (parsed.index < baseArgv.length) throw new Error(`Unknown proof option: ${baseArgv[parsed.index]}`);
1212
+ if (parsed.index < baseArgv.length) throw new Error(`Unknown proof option: ${argumentLabel(baseArgv[parsed.index])}`);
478
1213
  const options = { ...parsed.options, ...proofOptions };
479
1214
  if (!options.decisionId) throw new Error('marrow proof requires --decision-id');
480
1215
  return { command, options };
@@ -496,7 +1231,7 @@ function parseArgs(argv) {
496
1231
  return { command, options: parsed.options };
497
1232
  }
498
1233
 
499
- throw new Error(`Unknown command: ${command}`);
1234
+ throw new Error(`Unknown command: ${argumentLabel(command)}`);
500
1235
  }
501
1236
 
502
1237
  function headers(options) {
@@ -536,13 +1271,14 @@ function dataOf(json) {
536
1271
  return json && typeof json === 'object' && json.data && typeof json.data === 'object' ? json.data : json;
537
1272
  }
538
1273
 
539
- async function rawRequest(options, method, route, body, extraHeaders = {}, { timeoutMs } = {}) {
540
- if (!options.apiKey) throw new Error('MARROW_API_KEY is required. Use --fail-open only for non-production local commands.');
1274
+ async function rawRequest(options, method, route, body, extraHeaders = {}, { timeoutMs, signal } = {}) {
1275
+ if (!options.apiKey) throw new Error(NO_KEY_TEXT);
1276
+ const requestSignal = requestAbortSignal(timeoutMs, signal);
541
1277
  const response = await fetch(new URL(route, options.baseUrl.replace(/\/$/, '/')), {
542
1278
  method,
543
1279
  headers: { ...headers(options), ...extraHeaders },
544
1280
  body: body === undefined ? undefined : JSON.stringify(body),
545
- ...(timeoutMs ? { signal: AbortSignal.timeout(timeoutMs) } : {}),
1281
+ ...(requestSignal ? { signal: requestSignal } : {}),
546
1282
  });
547
1283
  const text = await response.text();
548
1284
  let json = {};
@@ -550,6 +1286,17 @@ async function rawRequest(options, method, route, body, extraHeaders = {}, { tim
550
1286
  return { response, json };
551
1287
  }
552
1288
 
1289
+ // The request's abort signal: its timeout, and an outside signal (a wait the operator stopped).
1290
+ function requestAbortSignal(timeoutMs, external) {
1291
+ if (!external) return timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined;
1292
+ const controller = new AbortController();
1293
+ const abort = () => controller.abort();
1294
+ if (external.aborted) abort();
1295
+ else external.addEventListener('abort', abort, { once: true });
1296
+ if (timeoutMs) setTimeout(abort, timeoutMs).unref?.();
1297
+ return controller.signal;
1298
+ }
1299
+
553
1300
  function responseError(route, response, json) {
554
1301
  const error = new Error(json.error || json.message || `Marrow ${route} returned HTTP ${response.status}`);
555
1302
  error.status = response.status;
@@ -683,14 +1430,23 @@ function defaultProof(input) {
683
1430
  runner: '@getmarrow/install run',
684
1431
  profile: input.options.profile,
685
1432
  source_meta: sourceMeta(input.options, 'proof', { action: input.action }),
686
- ...(input.options.ownerApproval ? { owner_approval: { approved_by: 'owner', reference: input.options.ownerApproval } } : {}),
687
- ...proof,
1433
+ ...withoutCallerApproval(proof),
688
1434
  };
689
1435
  }
690
1436
 
691
- async function preflightRuntime(options, action, type, commandText) {
692
- const target = options.target || commandText || action;
693
- const surfaces = inferSurfaces(commandText || action);
1437
+ // A caller-written approval is not an approval: Marrow reads the approval it recorded for the
1438
+ // gate receipt. The runner never writes proof.owner_approval and drops one a proof file carries.
1439
+ function withoutCallerApproval(proof) {
1440
+ if (!proof || typeof proof !== 'object' || Array.isArray(proof)) return {};
1441
+ const { owner_approval: _ignored, ...rest } = proof;
1442
+ return rest;
1443
+ }
1444
+
1445
+ async function preflightRuntime(options, action, type, commandText, surfaceList = null) {
1446
+ // Only redacted text is sent: action and commandText are redacted by the caller, and the
1447
+ // target is redacted again here in case a caller passed options that were not.
1448
+ const target = redact(options.target || '') || commandText || action;
1449
+ const surfaces = Array.isArray(surfaceList) ? surfaceList : inferSurfaces(commandText || action);
694
1450
  const meta = sourceMeta(options, 'runtime', { action, command: commandText, action_type: type });
695
1451
  const project = detectProjectSignals(options.root || process.cwd());
696
1452
  const body = {
@@ -724,7 +1480,7 @@ async function recommendGovernanceMode(options, project = detectProjectSignals()
724
1480
  skipped: true,
725
1481
  reason: 'MARROW_API_KEY missing',
726
1482
  project,
727
- exact_fix: 'export MARROW_API_KEY=mrw_live_... && npx @getmarrow/install govern',
1483
+ exact_fix: 'Put MARROW_API_KEY=<your_key> in ~/.marrow/env (run chmod 700 ~/.marrow && chmod 600 ~/.marrow/env), then run npx @getmarrow/install govern. Fallback: MARROW_API_KEY=<your_key> npx @getmarrow/install govern',
728
1484
  };
729
1485
  }
730
1486
  const action = 'configure Marrow governance mode for this project';
@@ -829,27 +1585,1038 @@ function gateDecision(runtime) {
829
1585
  };
830
1586
  }
831
1587
 
832
- function shouldBlock(decision, options) {
1588
+ function shouldBlock(decision, _options = {}) {
833
1589
  // Marrow withheld authorization (allow:false, or an observation-only answer): blocked under
834
1590
  // every plan mode and local policy.
835
1591
  if (decision.observationOnly || decision.allow === false) return true;
836
- if (options.policy === 'audit') return false;
837
- // An advisory plan (enforced:false) never blocks locally; the runner shows the warning.
1592
+ // Only Marrow makes a gate advisory (enforced:false); the runner then shows the warning and
1593
+ // runs. A local --policy warn|audit (or MARROW_GOVERN_POLICY) never loosens a gate Marrow
1594
+ // enforces: a block or a hold stops the command under every local policy, even when the
1595
+ // gate reports allow:true for a hold.
838
1596
  if (decision.enforced === false) return false;
839
1597
  if (decision.decision === 'block') return true;
840
- if (options.policy === 'warn') return false;
841
1598
  const approvalRequired = decision.ownerApprovalRequired
842
1599
  || decision.decision === 'review_required'
843
1600
  || decision.decision === 'owner_approval_required'
844
1601
  || decision.enforcementDecision === 'owner_approval_required';
845
- return Boolean(approvalRequired && !options.ownerApproval);
1602
+ return Boolean(approvalRequired);
846
1603
  }
847
1604
 
848
- function blockMessage(decision) {
1605
+ // The gate as the runner shows it: a hold's served next step (written for agents, with endpoint
1606
+ // paths, and for arbitration a dashboard receipt) is replaced by the runner's own text.
1607
+ function runnerGateDecision(runtime) {
1608
+ const decision = gateDecision(runtime);
1609
+ const hold = heldApproval(runtime, decision);
1610
+ return hold ? { ...decision, exactNextAction: holdNextText(hold) } : decision;
1611
+ }
1612
+
1613
+ function blockMessage(decision, hold = null) {
849
1614
  if (decision.observationOnly) return 'Marrow answered in observation-only mode, which cannot authorize this action. Retry for a fresh gate.';
1615
+ if (hold) return holdNextText(hold);
850
1616
  return decision.exactNextAction || 'Marrow blocked this action before execution.';
851
1617
  }
852
1618
 
1619
+ // ---------------------------------------------------------------------------
1620
+ // Held actions (review_required): who can approve is Marrow's decision, read from the runtime.
1621
+ // Approvals keep people in flow: with a person at an interactive terminal the runner asks once;
1622
+ // with nobody there (CI, scripts, pipes, an agent's own shell) the action holds quietly and the
1623
+ // command does not run. The account owner's one-tap link is requested only when Marrow says it
1624
+ // would be sent (approval_link_available: an owner-locked category, arbitration, the owner's own
1625
+ // decline the operator asked to reverse, or an owner who turned on unattended pings). A later
1626
+ // run of the same command picks the hold up on the same gate receipt.
1627
+ // The runner never relays the server's agent-directed text for a hold, never prints a link,
1628
+ // a token or an address, and never names the dashboard as a step.
1629
+ // ---------------------------------------------------------------------------
1630
+
1631
+ const GATE_RECEIPT_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{2,199}$/;
1632
+ const HOST_SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/;
1633
+ const DECISION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{2,199}$/;
1634
+ const APPROVAL_LINK_CHANNELS = new Set(['email', 'telegram', 'slack']);
1635
+ const APPROVAL_LINK_MAX_REQUESTS = 3;
1636
+ const APPROVAL_PROMPT_TIMEOUT_MS = 5 * 60_000;
1637
+ const APPROVAL_STATUS_MIN_POLL_MS = 1_000;
1638
+ const APPROVAL_STATUS_MAX_POLL_MS = 15_000;
1639
+ const APPROVAL_REQUEST_RETRY_MS = 2_000;
1640
+ // The backend lets a decline stand for this long (same decision, or the same action in the
1641
+ // receipt's session); the local record keeps it for the same time across reruns.
1642
+ const DECLINE_STANDS_MS = 30 * 60_000;
1643
+ const HOLD_RECORD_MAX_AGE_MS = 24 * 60 * 60_000;
1644
+ const RUNNER_HOOK_EVENT = 'governed_runner_prompt';
1645
+ const OWNER_APPROVAL_NOTICE = 'Approvals happen in the host\'s own prompt, at this runner\'s terminal prompt, or through the one-tap link Marrow sends the account owner.';
1646
+
1647
+ function isoTime(value) {
1648
+ if (typeof value !== 'string' || value.length > 40) return null;
1649
+ const parsed = Date.parse(value);
1650
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
1651
+ }
1652
+
1653
+ const safeId = (value, pattern = DECISION_ID_RE) => (typeof value === 'string' && pattern.test(value) ? value : '');
1654
+
1655
+ // The hold from the runtime's completion contract. Endpoints are built from the receipt id here,
1656
+ // never taken from response text. `legacy`: a Marrow service from before terminal approvals.
1657
+ function heldApproval(runtime, decision) {
1658
+ const value = recordOf(runtime);
1659
+ const completion = recordOf(value.completion_contract);
1660
+ const guidance = recordOf(completion.owner_approval);
1661
+ if (!decision || decision.observationOnly || decision.decision === 'block') return null;
1662
+ const receiptId = String(decision.receiptId || '');
1663
+ const expiresAt = isoTime(recordOf(value.gate_receipt).expires_at) || isoTime(completion.gate_receipt_expires_at);
1664
+ const poll = Number(guidance.approval_status_poll_after_ms);
1665
+ const pollAfterMs = Number.isFinite(poll) && poll > 0 ? poll : 3_000;
1666
+ const announcesLinks = typeof guidance.approval_link_available === 'boolean';
1667
+ if (guidance.mode === 'arbitration_review_required') {
1668
+ const arbitration = recordOf(value.arbitration);
1669
+ const arbitrationReceiptId = safeId(arbitration.receipt_id) || safeId(completion.arbitration_receipt_id);
1670
+ const linkAvailable = announcesLinks ? guidance.approval_link_available === true : typeof guidance.approval_link_endpoint === 'string';
1671
+ if (!GATE_RECEIPT_ID_RE.test(receiptId) || !arbitrationReceiptId || !linkAvailable) {
1672
+ return { kind: 'legacy', arbitration: true, gateReceiptId: receiptId };
1673
+ }
1674
+ return {
1675
+ kind: 'arbitration',
1676
+ gateReceiptId: receiptId,
1677
+ arbitrationReceiptId,
1678
+ arbitrationDecisionId: safeId(arbitration.decision_id),
1679
+ linkAvailable: true,
1680
+ linkReason: 'owner_locked',
1681
+ // Marrow sends an arbitration link only when a person at this session asks for it
1682
+ // (person_present: true) or the owner turned on unattended pings.
1683
+ personPresentLinks: guidance.approval_link_person_present === true,
1684
+ expiresAt,
1685
+ pollAfterMs,
1686
+ };
1687
+ }
1688
+ if (guidance.mode !== 'ordinary_non_arbitrated' || !GATE_RECEIPT_ID_RE.test(receiptId)) return null;
1689
+ // Before terminal approvals the service has no host-approval route to record an answer.
1690
+ if (guidance.host_approval_endpoint === undefined && guidance.host_approval_accepted === undefined) {
1691
+ return { kind: 'legacy', arbitration: false, gateReceiptId: receiptId };
1692
+ }
1693
+ const refusal = ['owner_decline_stands', 'verified_approval_required', 'approval_state_unavailable']
1694
+ .includes(guidance.host_approval_refusal_reason) ? guidance.host_approval_refusal_reason : null;
1695
+ // A service that does not announce links (before round 4) sends one for an owner-locked hold
1696
+ // or the owner's decline; the runner asks for nothing else there.
1697
+ const linkAvailable = announcesLinks
1698
+ ? guidance.approval_link_available === true
1699
+ : typeof guidance.approval_link_endpoint === 'string' && (refusal === 'verified_approval_required' || refusal === 'owner_decline_stands');
1700
+ const linkReason = ['owner_locked', 'owner_decline_stands', 'unattended_owner_ping'].includes(guidance.approval_link_reason)
1701
+ ? guidance.approval_link_reason
1702
+ : refusal === 'verified_approval_required' ? 'owner_locked' : refusal === 'owner_decline_stands' ? 'owner_decline_stands' : null;
1703
+ return {
1704
+ kind: 'ordinary',
1705
+ gateReceiptId: receiptId,
1706
+ accepted: guidance.host_approval_accepted === true && !refusal,
1707
+ refusal,
1708
+ operatorOnly: guidance.host_approval_operator_only === true,
1709
+ ownerDeclinedAt: isoTime(guidance.owner_declined_at),
1710
+ earlierDeclineAt: isoTime(guidance.earlier_decline_at),
1711
+ operatorNotice: typeof guidance.operator_notice === 'string' ? displayText(guidance.operator_notice, 160) : '',
1712
+ linkAvailable,
1713
+ linkReason: linkAvailable ? linkReason : null,
1714
+ expiresAt,
1715
+ pollAfterMs,
1716
+ };
1717
+ }
1718
+
1719
+ // Answers that refuse for good: the request itself or its scope is wrong, the permit or the
1720
+ // approval ran out, or the target is gone. Anything else (no answer, 5xx) may pass on a rerun.
1721
+ const PERMIT_REFUSAL_STATUSES = new Set([400, 403, 404, 409]);
1722
+
1723
+ function permitErrorCode(error) {
1724
+ const code = error?.details?.code || error?.details?.details?.code;
1725
+ if (typeof code === 'string' && /^[A-Z][A-Z0-9_]{1,79}$/.test(code)) return code;
1726
+ return error?.status ? `HTTP ${error.status}` : 'no answer';
1727
+ }
1728
+
1729
+ function holdNextText(hold) {
1730
+ if (hold.kind === 'legacy') {
1731
+ return `Marrow holds this action for approval (gate receipt ${hold.gateReceiptId}). This Marrow service does not take approvals from a terminal yet, so it stays held and nothing ran.`;
1732
+ }
1733
+ if (hold.kind === 'arbitration') {
1734
+ return `Marrow holds this action for arbitration review (gate receipt ${hold.gateReceiptId}). The account owner picks one proposal; run it with npx @getmarrow/install run, which asks Marrow to send the owner a one-tap link and runs it once the owner approves this one.`;
1735
+ }
1736
+ if (hold.refusal === 'owner_decline_stands') {
1737
+ return `The account owner declined this action${hold.ownerDeclinedAt ? ` at ${hold.ownerDeclinedAt}` : ''}, and only the owner can reverse that. To ask the owner with a one-tap approval link, rerun with --request-owner-link.`;
1738
+ }
1739
+ if (hold.refusal === 'approval_state_unavailable') {
1740
+ return 'Marrow could not read the approval state for this action, so it did not run. Retry in a moment.';
1741
+ }
1742
+ if (hold.refusal === 'verified_approval_required') {
1743
+ return `Marrow holds this action until the account owner approves it (gate receipt ${hold.gateReceiptId}). Run it with npx @getmarrow/install run, which asks Marrow to send the owner a one-tap link.`;
1744
+ }
1745
+ return `Marrow holds this action until a person approves it (gate receipt ${hold.gateReceiptId}). Run it with npx @getmarrow/install run in an interactive terminal to approve it there.`;
1746
+ }
1747
+
1748
+ function gateReceiptRoute(gateReceiptId, suffix) {
1749
+ return `/v1/agent/gate-receipts/${encodeURIComponent(gateReceiptId)}/${suffix}`;
1750
+ }
1751
+
1752
+ function errorCode(json) {
1753
+ const details = recordOf(json?.details);
1754
+ const code = typeof details.code === 'string' ? details.code : typeof json?.code === 'string' ? json.code : '';
1755
+ return /^[A-Z0-9_]{1,80}$/.test(code) ? code : '';
1756
+ }
1757
+
1758
+
1759
+ // Where a run that has no key points the person: one command, in a terminal on this machine.
1760
+ const NO_KEY_TEXT = 'Marrow can\'t find your key: run `npx @getmarrow/install` once in this machine\'s terminal.';
1761
+
1762
+ function ownerEnvApiKey() {
1763
+ try {
1764
+ return readOwnerApiKey(process.env.HOME || process.env.USERPROFILE || os.homedir()).apiKey || '';
1765
+ } catch {
1766
+ return '';
1767
+ }
1768
+ }
1769
+
1770
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1771
+
1772
+ // ---------------------------------------------------------------------------
1773
+ // Who is at this terminal. An agent driving a pseudo-terminal must not answer the prompt.
1774
+ // ---------------------------------------------------------------------------
1775
+
1776
+ // Environment set by agent hosts for the commands they run (Claude Code, Gemini CLI, Codex,
1777
+ // Cursor's agent, OpenCode).
1778
+ const AGENT_HOST_ENV = Object.freeze([
1779
+ 'CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT', 'CLAUDE_CODE_CHILD_SESSION', 'GEMINI_CLI',
1780
+ 'CODEX_SANDBOX', 'CODEX_SANDBOX_NETWORK_DISABLED', 'CODEX_MANAGED_BY_NPM', 'CODEX_THREAD_ID',
1781
+ 'CURSOR_AGENT', 'OPENCODE',
1782
+ ]);
1783
+ const AGENT_HOST_PROGRAM_RE = /^(?:claude|codex|gemini|cursor-agent|opencode|aider|goose)(?:\.exe|\.js|\.mjs|\.cjs)?$/i;
1784
+ const AGENT_HOST_PATH_RE = /(?:^|[\\/])(?:@anthropic-ai[\\/]claude-code|@openai[\\/]codex|@google[\\/]gemini-cli)(?:[\\/]|$)/i;
1785
+
1786
+ function linuxProcessArgs(pid) {
1787
+ try {
1788
+ const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');
1789
+ const ppid = Number(stat.slice(stat.lastIndexOf(')') + 2).split(' ')[1]);
1790
+ const args = fs.readFileSync(`/proc/${pid}/cmdline`).toString('utf8').split('\0').filter(Boolean).slice(0, 8);
1791
+ return Number.isSafeInteger(ppid) ? { ppid, args } : null;
1792
+ } catch {
1793
+ return null;
1794
+ }
1795
+ }
1796
+
1797
+ // An agent host among this process's ancestors (Linux /proc; elsewhere the environment decides).
1798
+ function agentHostAncestor(reader = process.platform === 'linux' ? linuxProcessArgs : () => null, startPid = process.ppid) {
1799
+ let pid = startPid;
1800
+ for (let depth = 0; depth < 8 && pid > 1; depth += 1) {
1801
+ const info = reader(pid);
1802
+ if (!info) return false;
1803
+ const programs = info.args.slice(0, 2);
1804
+ if (programs.some((arg) => AGENT_HOST_PROGRAM_RE.test(path.basename(String(arg))) || AGENT_HOST_PATH_RE.test(String(arg)))) return true;
1805
+ pid = info.ppid;
1806
+ }
1807
+ return false;
1808
+ }
1809
+
1810
+ function agentHostDetected(env = process.env, reader) {
1811
+ if (AGENT_HOST_ENV.some((name) => typeof env[name] === 'string' && env[name] !== '' && env[name] !== '0' && env[name].toLowerCase() !== 'false')) return true;
1812
+ return agentHostAncestor(reader);
1813
+ }
1814
+
1815
+ // One terminal line, read only from a person at an interactive terminal: stdin and stderr are
1816
+ // terminals, not CI, not --no-interactive, and no agent host runs this process.
1817
+ function approvalPromptAvailable(options, io = {}) {
1818
+ const env = io.env || process.env;
1819
+ if (agentHostDetected(env, io.processReader)) return false;
1820
+ if (options.interactive === false) return false;
1821
+ const ci = String(env.CI || '').toLowerCase();
1822
+ if (ci && ci !== 'false' && ci !== '0') return false;
1823
+ if (typeof io.approvalPrompt === 'function') return true;
1824
+ const input = io.stdin || process.stdin;
1825
+ const output = io.stderr || process.stderr;
1826
+ return Boolean(input?.isTTY && output?.isTTY);
1827
+ }
1828
+
1829
+ function askLine(question, io = {}) {
1830
+ if (typeof io.approvalPrompt === 'function') return Promise.resolve(io.approvalPrompt(question));
1831
+ const input = io.stdin || process.stdin;
1832
+ const output = io.stderr || process.stderr;
1833
+ const timeoutMs = Number.isFinite(io.promptTimeoutMs) ? io.promptTimeoutMs : APPROVAL_PROMPT_TIMEOUT_MS;
1834
+ return new Promise((resolve) => {
1835
+ const rl = readline.createInterface({ input, output, terminal: true });
1836
+ let done = false;
1837
+ let timer = null;
1838
+ const finish = (value) => {
1839
+ if (done) return;
1840
+ done = true;
1841
+ if (timer) clearTimeout(timer);
1842
+ rl.close();
1843
+ if (input.pause) input.pause();
1844
+ resolve(value);
1845
+ };
1846
+ // No answer is never a yes.
1847
+ timer = setTimeout(() => {
1848
+ output.write('\n');
1849
+ finish(null);
1850
+ }, timeoutMs);
1851
+ rl.on('SIGINT', () => {
1852
+ output.write('\n');
1853
+ finish(null);
1854
+ });
1855
+ rl.on('close', () => finish(null));
1856
+ rl.question(question, (answer) => finish(answer));
1857
+ });
1858
+ }
1859
+
1860
+ function parseAnswer(value) {
1861
+ if (typeof value !== 'string') return null;
1862
+ if (/^\s*y(?:es)?\s*$/i.test(value)) return 'yes';
1863
+ if (/^\s*n(?:o)?\s*$/i.test(value)) return 'no';
1864
+ return null;
1865
+ }
1866
+
1867
+ function runnerHostSessionId(sessionId) {
1868
+ const session = String(sessionId || '');
1869
+ return HOST_SESSION_ID_RE.test(session)
1870
+ ? session
1871
+ : `runner-${crypto.createHash('sha256').update(session).digest('hex').slice(0, 32)}`;
1872
+ }
1873
+
1874
+ // ---------------------------------------------------------------------------
1875
+ // The default session and the local record of a command's hold.
1876
+ // ---------------------------------------------------------------------------
1877
+
1878
+ function baseUrlOrigin(value) {
1879
+ try {
1880
+ const url = new URL(String(value || DEFAULT_BASE_URL));
1881
+ return `${url.protocol}//${url.host}`;
1882
+ } catch {
1883
+ return 'invalid-base-url';
1884
+ }
1885
+ }
1886
+
1887
+ function realCwd() {
1888
+ try {
1889
+ return fs.realpathSync(process.cwd());
1890
+ } catch {
1891
+ return path.resolve(process.cwd());
1892
+ }
1893
+ }
1894
+
1895
+ // One session per agent, project directory, OS user and Marrow service, per UTC day, so the
1896
+ // backend sees reruns of a command as the same session (an owner's decline stands across
1897
+ // them). The local hold record carries the session a hold was made in, so picking a hold up
1898
+ // later, from another terminal or the next day, uses that session.
1899
+ function defaultRunnerSession(options, now = new Date()) {
1900
+ const uid = typeof process.getuid === 'function' ? String(process.getuid()) : os.userInfo().username;
1901
+ const day = now.toISOString().slice(0, 10).replace(/-/g, '');
1902
+ const digest = crypto.createHash('sha256')
1903
+ .update(['runner-session-v1', uid, realCwd(), String(options.agentId || ''), baseUrlOrigin(options.baseUrl), day].join('\0'))
1904
+ .digest('hex');
1905
+ return `marrow-run-${digest.slice(0, 24)}`;
1906
+ }
1907
+
1908
+ // The record key: an HMAC, under a private random salt kept in the hold directory, of the
1909
+ // command exactly as typed (every argv word), the raw --action, --target and --type, and this
1910
+ // agent, project, user and service. Any difference in the command, including text after a
1911
+ // secret, is a different key. The key never leaves this machine; the record stores only the
1912
+ // key (as its binding), ids, states and times, never the command.
1913
+ function runnerHoldKey(options, material, salt) {
1914
+ const uid = typeof process.getuid === 'function' ? String(process.getuid()) : os.userInfo().username;
1915
+ const key = salt || crypto.randomBytes(32);
1916
+ // Which API key runs it (as its own HMAC, never the key) and the profile and policy it runs
1917
+ // under: an approval under one is never picked up under another.
1918
+ const apiKeyMark = options.apiKey ? crypto.createHmac('sha256', key).update(`api-key\0${options.apiKey}`).digest('hex') : '';
1919
+ return crypto.createHmac('sha256', key).update(JSON.stringify([
1920
+ 'runner-hold-v2', baseUrlOrigin(options.baseUrl), String(options.agentId || ''), apiKeyMark,
1921
+ String(options.profile || ''), String(options.policy || ''), realCwd(), uid,
1922
+ Array.isArray(material?.argv) ? material.argv.map(String) : [],
1923
+ String(material?.action || ''), String(material?.target || ''), String(material?.type || ''),
1924
+ ])).digest('hex');
1925
+ }
1926
+
1927
+ const HOLD_KEY_RE = /^[0-9a-f]{64}$/;
1928
+
1929
+ const HOLD_RECORD_VERSION = 2;
1930
+ const HOLD_RECORD_KEYS = new Set([
1931
+ 'version', 'binding', 'kind', 'state', 'gate_receipt_id', 'decision_id', 'runtime_decision_id', 'arbitration_receipt_id',
1932
+ 'session_id', 'declined_by', 'declined_at', 'receipt_expires_at', 'approvable_here', 'link_reason',
1933
+ 'link_sent_at', 'link_expires_at', 'link_person_present', 'denial_committed', 'created_at', 'updated_at',
1934
+ ]);
1935
+
1936
+ function holdRecordStore(io = {}) {
1937
+ const home = io.home || os.homedir();
1938
+ const directory = path.join(home, '.marrow', 'runner-holds');
1939
+ const uid = typeof process.getuid === 'function' ? process.getuid() : null;
1940
+ const file = (key) => path.join(directory, `${key}.json`);
1941
+ const ensureDirectory = () => {
1942
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
1943
+ const stat = fs.lstatSync(directory);
1944
+ if (!stat.isDirectory() || stat.isSymbolicLink() || (uid !== null && stat.uid !== uid)) throw new Error('unsafe runner hold directory');
1945
+ if ((stat.mode & 0o077) !== 0) fs.chmodSync(directory, 0o700);
1946
+ };
1947
+ const valid = (record) => {
1948
+ if (!record || typeof record !== 'object' || Array.isArray(record) || record.version !== HOLD_RECORD_VERSION) return false;
1949
+ if (!HOLD_KEY_RE.test(String(record.binding || ''))) return false;
1950
+ if (Object.keys(record).some((key) => !HOLD_RECORD_KEYS.has(key))) return false;
1951
+ if (!['ordinary', 'arbitration'].includes(record.kind) || !['waiting', 'declined'].includes(record.state)) return false;
1952
+ if (!GATE_RECEIPT_ID_RE.test(String(record.gate_receipt_id || '')) || !HOST_SESSION_ID_RE.test(String(record.session_id || ''))) return false;
1953
+ if (record.decision_id && !DECISION_ID_RE.test(record.decision_id)) return false;
1954
+ if (record.runtime_decision_id && !DECISION_ID_RE.test(record.runtime_decision_id)) return false;
1955
+ if (record.arbitration_receipt_id && !DECISION_ID_RE.test(record.arbitration_receipt_id)) return false;
1956
+ return Number.isFinite(Date.parse(record.created_at));
1957
+ };
1958
+ let saltValue = null;
1959
+ let saltStored = false;
1960
+ const saltFile = path.join(directory, '.salt');
1961
+ const ownedPrivateFile = (stat) => stat.isFile() && !stat.isSymbolicLink() && (uid === null || stat.uid === uid) && (stat.mode & 0o077) === 0;
1962
+ const readSalt = () => {
1963
+ try {
1964
+ const stat = fs.lstatSync(saltFile);
1965
+ if (!ownedPrivateFile(stat) || stat.size > 128) return null;
1966
+ const stored = fs.readFileSync(saltFile, 'utf8').trim();
1967
+ return HOLD_KEY_RE.test(stored) ? Buffer.from(stored, 'hex') : null;
1968
+ } catch {
1969
+ return null;
1970
+ }
1971
+ };
1972
+ // Saves this run's salt before the first record is written under a key made with it. A salt
1973
+ // file that is not a private regular file of this user is replaced (the directory is this
1974
+ // user's and owner-only, checked by ensureDirectory). If another run saved a different salt
1975
+ // first, nothing is written: the next run starts a new hold rather than miss this one.
1976
+ const storeSalt = () => {
1977
+ if (!saltValue || saltStored) return true;
1978
+ const current = readSalt();
1979
+ if (!current) {
1980
+ try {
1981
+ const stat = fs.lstatSync(saltFile);
1982
+ if (!stat.isDirectory()) fs.unlinkSync(saltFile);
1983
+ } catch { /* none yet */ }
1984
+ const temp = path.join(directory, `.salt.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`);
1985
+ fs.writeFileSync(temp, `${saltValue.toString('hex')}\n`, { flag: 'wx', mode: 0o600 });
1986
+ try { fs.linkSync(temp, saltFile); } catch { /* another run saved one first */ }
1987
+ fs.unlinkSync(temp);
1988
+ }
1989
+ const saved = readSalt();
1990
+ saltStored = Boolean(saved && crypto.timingSafeEqual(saved, saltValue));
1991
+ return saltStored;
1992
+ };
1993
+ return {
1994
+ // The private salt for hold keys: 32 random bytes in an owner-only file. Reading it creates
1995
+ // nothing; a run with no saved salt uses a new one, saved when its first record is written.
1996
+ salt() {
1997
+ if (saltValue) return saltValue;
1998
+ saltValue = readSalt();
1999
+ saltStored = Boolean(saltValue);
2000
+ if (!saltValue) saltValue = crypto.randomBytes(32);
2001
+ return saltValue;
2002
+ },
2003
+ read(key) {
2004
+ try {
2005
+ if (!HOLD_KEY_RE.test(String(key || ''))) return null;
2006
+ const target = file(key);
2007
+ const stat = fs.lstatSync(target);
2008
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 8192 || (uid !== null && stat.uid !== uid) || (stat.mode & 0o077) !== 0) return null;
2009
+ const record = JSON.parse(fs.readFileSync(target, 'utf8'));
2010
+ if (!valid(record) || Date.now() - Date.parse(record.created_at) > HOLD_RECORD_MAX_AGE_MS) return null;
2011
+ // Picked up only by exactly the command it was made for.
2012
+ if (!crypto.timingSafeEqual(Buffer.from(record.binding, 'hex'), Buffer.from(key, 'hex'))) return null;
2013
+ return record;
2014
+ } catch {
2015
+ return null;
2016
+ }
2017
+ },
2018
+ write(key, record) {
2019
+ try {
2020
+ if (!HOLD_KEY_RE.test(String(key || ''))) return false;
2021
+ ensureDirectory();
2022
+ if (!storeSalt()) return false;
2023
+ const now = new Date().toISOString();
2024
+ const value = Object.fromEntries(Object.entries({ created_at: now, ...record, version: HOLD_RECORD_VERSION, binding: key, updated_at: now })
2025
+ .filter(([name, entry]) => HOLD_RECORD_KEYS.has(name) && entry !== undefined && entry !== null && entry !== ''));
2026
+ const temp = path.join(directory, `.${key}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`);
2027
+ fs.writeFileSync(temp, `${JSON.stringify(value)}\n`, { flag: 'wx', mode: 0o600 });
2028
+ fs.renameSync(temp, file(key));
2029
+ return true;
2030
+ } catch {
2031
+ return false;
2032
+ }
2033
+ },
2034
+ remove(key) {
2035
+ try { fs.unlinkSync(file(key)); } catch { /* already gone */ }
2036
+ try { fs.unlinkSync(path.join(directory, `${key}.claim`)); } catch { /* no claim */ }
2037
+ },
2038
+ // Gives the approval back (the run stopped before the command started, on a failure a
2039
+ // later run may not meet); the record stays, so the next run picks the approval up again.
2040
+ release(key) {
2041
+ try { fs.unlinkSync(path.join(directory, `${key}.claim`)); } catch { /* no claim */ }
2042
+ },
2043
+ // One run per approval: the first run to claim an approved receipt runs it; an identical
2044
+ // run started at the same time does not. A claim older than the receipt's life is stale.
2045
+ claim(key, gateReceiptId) {
2046
+ try {
2047
+ if (!HOLD_KEY_RE.test(String(key || ''))) return false;
2048
+ ensureDirectory();
2049
+ const target = path.join(directory, `${key}.claim`);
2050
+ try {
2051
+ const stat = fs.lstatSync(target);
2052
+ if (stat.isFile() && Date.now() - stat.mtimeMs > 60 * 60_000) fs.unlinkSync(target);
2053
+ } catch { /* no claim yet */ }
2054
+ fs.writeFileSync(target, `${gateReceiptId}\n`, { flag: 'wx', mode: 0o600 });
2055
+ return true;
2056
+ } catch {
2057
+ return false;
2058
+ }
2059
+ },
2060
+ };
2061
+ }
2062
+
2063
+ // ---------------------------------------------------------------------------
2064
+ // Marrow calls for a hold.
2065
+ // ---------------------------------------------------------------------------
2066
+
2067
+ // POSTs that answer a hold are resent unchanged on a retryable answer (the host route and the
2068
+ // link route record nothing on those), at most three attempts.
2069
+ async function holdPost(options, route, body, io = {}) {
2070
+ const retryMs = Number.isFinite(io.approvalRetryMs) ? io.approvalRetryMs : APPROVAL_REQUEST_RETRY_MS;
2071
+ let last = { ok: false, status: 0, code: 'request_failed', json: {} };
2072
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
2073
+ try {
2074
+ const { response, json } = await rawRequest(options, 'POST', route, body, {}, { timeoutMs: 15_000 });
2075
+ const code = errorCode(json);
2076
+ last = { ok: response.ok, status: response.status, code, json, data: response.ok ? dataOf(json) : null };
2077
+ if (response.ok) return last;
2078
+ const retryable = response.status >= 500
2079
+ || (response.status === 429 && recordOf(json?.details).retryable === true)
2080
+ || ['MARROW_OWNER_APPROVAL_STATE_UNAVAILABLE', 'MARROW_APPROVAL_LINK_UNDELIVERED'].includes(code);
2081
+ if (!retryable) return last;
2082
+ } catch (error) {
2083
+ last = { ok: false, status: 0, code: isTimeout(error) ? 'timed_out' : 'request_failed', json: {} };
2084
+ }
2085
+ if (attempt < 3) await sleep(retryMs);
2086
+ }
2087
+ return last;
2088
+ }
2089
+
2090
+ async function holdPostOnce(options, route, body) {
2091
+ try {
2092
+ const { response, json } = await rawRequest(options, 'POST', route, body, {}, { timeoutMs: 15_000 });
2093
+ return { ok: response.ok, status: response.status, code: errorCode(json), json, data: response.ok ? dataOf(json) : null };
2094
+ } catch (error) {
2095
+ return { ok: false, status: 0, code: isTimeout(error) ? 'timed_out' : 'request_failed', json: {} };
2096
+ }
2097
+ }
2098
+
2099
+ async function reportRunnerVerdict(options, gateReceiptId, verdict, times, decisionIds, io) {
2100
+ const decisionId = verdict === 'approved' ? decisionIds.decisionId : decisionIds.runtimeDecisionId;
2101
+ const body = {
2102
+ verdict,
2103
+ host: 'other',
2104
+ host_session_id: runnerHostSessionId(options.sessionId),
2105
+ hook_event: RUNNER_HOOK_EVENT,
2106
+ asked_at: times.askedAt,
2107
+ answered_at: times.answeredAt,
2108
+ ...(decisionId && DECISION_ID_RE.test(decisionId) ? { decision_id: decisionId } : {}),
2109
+ };
2110
+ const result = await holdPost(options, gateReceiptRoute(gateReceiptId, 'host-approval'), body, io);
2111
+ if (result.ok) {
2112
+ const recorded = recordOf(recordOf(result.data).host_approval);
2113
+ return { recorded: recorded.verdict === verdict, verdict: recorded.verdict === 'declined' ? 'declined' : recorded.verdict === 'approved' ? 'approved' : null, answeredBy: typeof recorded.answered_by === 'string' ? recorded.answered_by : null };
2114
+ }
2115
+ if (result.code === 'MARROW_OWNER_APPROVAL_ALREADY_DECIDED') {
2116
+ const existing = recordOf(result.json?.details).existing_verdict;
2117
+ return { recorded: false, verdict: existing === 'approved' ? 'approved' : existing === 'declined' ? 'declined' : null, code: result.code };
2118
+ }
2119
+ if (result.code === 'MARROW_OWNER_APPROVAL_DECLINED') return { recorded: false, verdict: 'declined', code: result.code };
2120
+ return { recorded: false, verdict: null, code: result.code || `HTTP_${result.status}` };
2121
+ }
2122
+
2123
+ // Asks Marrow to send the owner a one-tap link. A 200 answer that says it was not sent (the owner
2124
+ // has not turned on unattended pings) is a quiet hold, not an error. Keeps only the channel and
2125
+ // expiry: the response never contains the link, and its recipient hint is dropped here.
2126
+ // `personPresent` is sent (as true) only for a hold whose service asks for it, and only when a
2127
+ // person at this terminal runs the command; never for an unattended or agent-terminal run.
2128
+ async function requestOwnerLink(options, gateReceiptId, decisionId, state, io, { personPresent = false } = {}) {
2129
+ while (state.linkRequests < APPROVAL_LINK_MAX_REQUESTS) {
2130
+ state.linkRequests += 1;
2131
+ const body = decisionId && DECISION_ID_RE.test(decisionId) ? { decision_id: decisionId } : {};
2132
+ if (personPresent === true) body.person_present = true;
2133
+ const result = await holdPostOnce(options, gateReceiptRoute(gateReceiptId, 'approval-link'), body);
2134
+ if (result.ok) {
2135
+ const data = recordOf(result.data);
2136
+ const link = recordOf(data.approval_link);
2137
+ // The owner already has a live link for this hold: no new email; wait on its answer.
2138
+ if (data.state === 'already_sent' || data.reason === 'link_live') {
2139
+ if (!Object.keys(link).length) return { sent: false, code: 'MARROW_APPROVAL_LINK_ANSWER_INVALID' };
2140
+ state.channel = APPROVAL_LINK_CHANNELS.has(link.channel) ? link.channel : 'owner channel';
2141
+ state.expiresAt = isoTime(link.expires_at);
2142
+ return { sent: true, alreadyLive: true };
2143
+ }
2144
+ if (data.sent === false || data.state === 'not_sent') {
2145
+ return { sent: false, quiet: true, reason: typeof data.reason === 'string' ? data.reason.slice(0, 40) : 'not_sent' };
2146
+ }
2147
+ if (!Object.keys(link).length) return { sent: false, code: 'MARROW_APPROVAL_LINK_ANSWER_INVALID' };
2148
+ state.channel = APPROVAL_LINK_CHANNELS.has(link.channel) ? link.channel : 'owner channel';
2149
+ state.expiresAt = isoTime(link.expires_at);
2150
+ return { sent: true };
2151
+ }
2152
+ const retryable = result.status >= 500 || result.status === 0
2153
+ || (result.status === 429 && recordOf(result.json?.details).retryable === true)
2154
+ || ['MARROW_OWNER_APPROVAL_STATE_UNAVAILABLE', 'MARROW_APPROVAL_LINK_UNDELIVERED'].includes(result.code);
2155
+ if (!retryable) return { sent: false, code: result.code || `HTTP_${result.status}` };
2156
+ if (state.linkRequests < APPROVAL_LINK_MAX_REQUESTS) {
2157
+ await sleep(Number.isFinite(io.approvalRetryMs) ? io.approvalRetryMs : APPROVAL_REQUEST_RETRY_MS);
2158
+ }
2159
+ }
2160
+ return { sent: false, code: 'MARROW_APPROVAL_LINK_REQUESTS_EXHAUSTED' };
2161
+ }
2162
+
2163
+ function linkSentText(state, alreadyLive) {
2164
+ const until = state.expiresAt ? `, until ${state.expiresAt}` : '';
2165
+ return alreadyLive
2166
+ ? `The account owner already has a one-tap approval link for this action (${state.channel})${until}; no new link was sent.`
2167
+ : `An approval link was sent to the account owner (${state.channel}). It works once${until}.`;
2168
+ }
2169
+
2170
+ const LINK_FAILURE_TEXT = {
2171
+ MARROW_APPROVAL_CHANNEL_UNAVAILABLE: 'the account has no approval channel Marrow can send it to',
2172
+ MARROW_APPROVAL_LINK_LIMITED: 'enough approval links were already sent for this action or this hour',
2173
+ MARROW_OWNER_APPROVAL_ALREADY_DECIDED: 'this hold already has an answer',
2174
+ MARROW_ARBITRATION_OWNER_APPROVAL_REQUIRED: 'this action is governed by arbitration review',
2175
+ MARROW_PRE_ACTION_GATE_USED: 'its gate receipt was already used',
2176
+ MARROW_PRE_ACTION_GATE_EXPIRED: 'its gate receipt expired',
2177
+ MARROW_APPROVAL_LINK_NOT_HELD: 'Marrow no longer holds this action',
2178
+ MARROW_APPROVAL_LINK_REQUESTS_EXHAUSTED: `Marrow could not deliver it after ${APPROVAL_LINK_MAX_REQUESTS} requests`,
2179
+ };
2180
+
2181
+ // One status read: the state, plus the approval fields the runner keeps.
2182
+ async function readHoldStatus(options, gateReceiptId, signal = undefined) {
2183
+ try {
2184
+ const { response, json } = await rawRequest(options, 'GET', gateReceiptRoute(gateReceiptId, 'owner-approval'), undefined, {}, { timeoutMs: 15_000, signal });
2185
+ if (response.status === 404) return { state: 'unknown' };
2186
+ if (!response.ok) return { state: 'unavailable' };
2187
+ const view = recordOf(dataOf(json));
2188
+ const stateValue = typeof view.state === 'string' ? view.state : 'unavailable';
2189
+ return {
2190
+ state: stateValue,
2191
+ source: typeof view.approval_source === 'string' ? view.approval_source.slice(0, 40) : null,
2192
+ answeredBy: typeof view.approval_answered_by === 'string' ? view.approval_answered_by.slice(0, 40) : null,
2193
+ ownerApprovalReceiptId: safeId(view.owner_approval_receipt_id),
2194
+ pollAfterMs: Number(view.poll_after_ms),
2195
+ };
2196
+ } catch {
2197
+ return { state: 'unavailable' };
2198
+ }
2199
+ }
2200
+
2201
+ const WAITING_STATES = new Set(['pending', 'arbitration_review', 'unavailable']);
2202
+
2203
+ // Waits on the status endpoint until the hold is answered, expires, or the deadline passes.
2204
+ // Ctrl+C (SIGINT) or SIGTERM (an agent host's tool timeout, for example) stops the wait. The
2205
+ // hold is recorded before any wait starts, so the run then exits held and a later run of the
2206
+ // command picks up the answer on the same receipt.
2207
+ async function waitForOwnerAnswer(options, gateReceiptId, pollAfterMs, deadline, io) {
2208
+ const minPoll = Number.isFinite(io.approvalPollMs) ? io.approvalPollMs : APPROVAL_STATUS_MIN_POLL_MS;
2209
+ let pollMs = Math.max(minPoll, Math.min(pollAfterMs, APPROVAL_STATUS_MAX_POLL_MS));
2210
+ const stop = waitInterruption(io);
2211
+ try {
2212
+ for (;;) {
2213
+ const status = await readHoldStatus(options, gateReceiptId, stop.signal);
2214
+ if (stop.signal.aborted) return { state: 'interrupted', signal: stop.reason };
2215
+ if (!WAITING_STATES.has(status.state)) return status;
2216
+ if (Number.isFinite(status.pollAfterMs) && status.pollAfterMs > 0) pollMs = Math.max(minPoll, Math.min(status.pollAfterMs, APPROVAL_STATUS_MAX_POLL_MS));
2217
+ const remaining = deadline - Date.now();
2218
+ if (remaining <= 0) return { state: 'timeout' };
2219
+ // A stop during the sleep aborts the next status read at once; the check above returns.
2220
+ await abortableSleep(Math.min(pollMs, remaining), stop.signal);
2221
+ }
2222
+ } finally {
2223
+ stop.release();
2224
+ }
2225
+ }
2226
+
2227
+ // SIGINT and SIGTERM while a run waits: they stop the wait instead of killing the process.
2228
+ function waitInterruption(io = {}) {
2229
+ const target = io.signals || process;
2230
+ const controller = new AbortController();
2231
+ const stop = { signal: controller.signal, reason: null, release: () => {} };
2232
+ const handlers = ['SIGINT', 'SIGTERM'].map((name) => {
2233
+ const handler = () => {
2234
+ if (!stop.reason) stop.reason = name;
2235
+ controller.abort();
2236
+ };
2237
+ target.on(name, handler);
2238
+ return [name, handler];
2239
+ });
2240
+ stop.release = () => {
2241
+ for (const [name, handler] of handlers) target.removeListener(name, handler);
2242
+ };
2243
+ return stop;
2244
+ }
2245
+
2246
+ function abortableSleep(ms, signal) {
2247
+ return new Promise((resolve) => {
2248
+ if (signal.aborted) {
2249
+ resolve();
2250
+ return;
2251
+ }
2252
+ let timer = null;
2253
+ const done = () => {
2254
+ clearTimeout(timer);
2255
+ signal.removeEventListener('abort', done);
2256
+ resolve();
2257
+ };
2258
+ timer = setTimeout(done, ms);
2259
+ signal.addEventListener('abort', done, { once: true });
2260
+ });
2261
+ }
2262
+
2263
+ // A gate denial report closes trusted only with its receipt and no proof: there is no
2264
+ // completion to prove, and the receipt is what makes the denial verifiable.
2265
+ // An arbitration hold's denial carries its arbitration receipt with the gate receipt, as Marrow
2266
+ // closes an arbitrated decision only with both.
2267
+ async function commitHoldDenial(options, decisionIds, gateReceiptId, outcome, arbitrationReceiptId = '') {
2268
+ const decisionId = decisionIds.runtimeDecisionId || decisionIds.decisionId;
2269
+ if (!decisionId) return 'not_recorded';
2270
+ try {
2271
+ const receipt = decisionIds.runtimeDecisionId ? gateReceiptId : '';
2272
+ const commit = await commitOutcome(options, decisionId, false, outcome, undefined, receipt, null,
2273
+ { extras: receipt && arbitrationReceiptId ? { arbitration_receipt_id: arbitrationReceiptId } : {} });
2274
+ return commit?.committed === true ? 'committed' : 'not_committed';
2275
+ } catch {
2276
+ return 'failed';
2277
+ }
2278
+ }
2279
+
2280
+ function heldText(hold, interactive) {
2281
+ if (hold.kind === 'arbitration') return `Marrow holds this action for arbitration review (gate receipt ${hold.gateReceiptId}): the account owner picks one proposal.`;
2282
+ return `Marrow holds this action until ${hold.accepted && !hold.operatorOnly && interactive ? 'you approve it' : hold.refusal ? 'the account owner approves it' : 'a person approves it'} (gate receipt ${hold.gateReceiptId}).`;
2283
+ }
2284
+
2285
+ function quietHoldText(hold, interactive) {
2286
+ if (hold.kind === 'ordinary' && hold.operatorOnly && hold.accepted) {
2287
+ return `You declined this action${hold.earlierDeclineAt ? ` at ${hold.earlierDeclineAt}` : ' earlier'}, so it stays held and nothing ran.`;
2288
+ }
2289
+ if (hold.kind === 'ordinary' && hold.accepted && !interactive) {
2290
+ return 'It is held until a person approves it, and nothing ran. Rerun this command in an interactive terminal to approve it there; that run picks up this hold.';
2291
+ }
2292
+ if (hold.kind === 'arbitration' && hold.personPresentLinks && !interactive) {
2293
+ return 'It is held for the account owner\'s review, and nothing ran. Rerun this command in an interactive terminal to send the owner a one-tap link; that run picks up this hold.';
2294
+ }
2295
+ return 'It stays held until the account owner approves it, and nothing ran. Rerun this command to check; a later run picks up this hold.';
2296
+ }
2297
+
2298
+ // Resolves one hold in this run. Returns { approved: true, ... } only when Marrow recorded an
2299
+ // approval: the operator's typed "y" at an interactive terminal (reported as the operator's
2300
+ // answer, client-attested), or the account owner's approval. Otherwise the command does not
2301
+ // run, and the hold is remembered so the next run of this command picks it up.
2302
+ async function resolveHold(options, hold, context) {
2303
+ const io = context.io || {};
2304
+ const err = io.stderr || process.stderr;
2305
+ const say = (text) => err.write(`${text}\n`);
2306
+ const records = context.records;
2307
+ const state = { linkRequests: 0, channel: null, expiresAt: null, source: null };
2308
+ const decisionIds = context.decisionIds;
2309
+ const summary = (extra = {}) => ({
2310
+ gate_receipt_id: hold.gateReceiptId,
2311
+ link_requests: state.linkRequests,
2312
+ ...(state.channel ? { channel: state.channel, expires_at: state.expiresAt } : {}),
2313
+ ...(state.source ? { source: state.source } : {}),
2314
+ ...extra,
2315
+ });
2316
+ const recordBase = () => ({
2317
+ kind: hold.kind,
2318
+ gate_receipt_id: hold.gateReceiptId,
2319
+ decision_id: decisionIds.decisionId,
2320
+ runtime_decision_id: decisionIds.runtimeDecisionId,
2321
+ arbitration_receipt_id: hold.arbitrationReceiptId,
2322
+ session_id: options.sessionId,
2323
+ receipt_expires_at: hold.expiresAt,
2324
+ approvable_here: hold.kind === 'ordinary' && hold.accepted && !hold.operatorOnly,
2325
+ link_reason: hold.linkReason,
2326
+ link_person_present: hold.personPresentLinks === true ? true : undefined,
2327
+ });
2328
+ const remember = (extra = {}) => records?.write(context.holdKey, { ...recordBase(), state: 'waiting', ...extra });
2329
+ const refused = (message, extra = {}) => ({ approved: false, exitCode: 12, message, approval: summary(extra) });
2330
+ const linkFields = () => (state.channel ? { link_sent_at: state.linkSentAt || new Date().toISOString(), link_expires_at: state.expiresAt } : {});
2331
+ const held = (message, extra = {}) => {
2332
+ remember(linkFields());
2333
+ return refused(message, { state: 'held', ...extra });
2334
+ };
2335
+ const denial = async (message, outcome, declinedBy, extra) => {
2336
+ const commitState = await commitHoldDenial(options, decisionIds, hold.gateReceiptId, outcome, hold.kind === 'arbitration' ? hold.arbitrationReceiptId : '');
2337
+ records?.write(context.holdKey, { ...recordBase(), state: 'declined', declined_by: declinedBy, declined_at: new Date().toISOString(), denial_committed: commitState === 'committed' ? 'yes' : undefined });
2338
+ return refused(message, { ...extra, denial_commit: commitState });
2339
+ };
2340
+
2341
+ if (hold.refusal === 'approval_state_unavailable') return refused(holdNextText(hold), { state: 'state_unavailable' });
2342
+ const interactive = approvalPromptAvailable(options, io);
2343
+ say(heldText(hold, interactive));
2344
+
2345
+ let asked = Boolean(options.requestOwnerLink);
2346
+ let ownerOnly = hold.kind === 'arbitration' || !hold.accepted || hold.operatorOnly;
2347
+ let linkAvailable = hold.linkAvailable;
2348
+
2349
+ // 1. The operator answers here, at an interactive terminal, when Marrow lets the operator
2350
+ // approve: not after an earlier operator decline (Marrow then accepts only a marked answer in
2351
+ // a host's own dialog, which this prompt is not), and not for owner-only holds.
2352
+ if (hold.kind === 'ordinary' && !ownerOnly && interactive) {
2353
+ const askedAt = new Date().toISOString();
2354
+ const raw = await askLine(`${hold.operatorNotice ? `${hold.operatorNotice} ` : ''}Approve and run it now? [y/N] `, io);
2355
+ const answer = parseAnswer(raw);
2356
+ const times = { askedAt, answeredAt: new Date().toISOString() };
2357
+ state.source = 'operator_prompt';
2358
+ if (answer === null) {
2359
+ remember();
2360
+ return refused('No answer was given, so nothing ran and nothing was recorded. Rerun this command to answer; it picks up this hold.', { state: 'no_answer' });
2361
+ }
2362
+ const report = await reportRunnerVerdict(options, hold.gateReceiptId, answer === 'yes' ? 'approved' : 'declined', times, decisionIds, io);
2363
+ if (answer === 'no') {
2364
+ if (report.verdict !== 'declined') {
2365
+ remember();
2366
+ return refused(`Marrow could not record the decline (${report.code || 'not recorded'}). Nothing ran.`, { state: 'declined_unrecorded' });
2367
+ }
2368
+ return denial('Declined. The action did not run.', 'Denied by Marrow pre-action gate: the operator declined in the governed runner.', 'operator', { state: 'declined' });
2369
+ }
2370
+ if (report.verdict === 'declined') {
2371
+ return denial('This action was already declined. It did not run.', 'Denied by Marrow pre-action gate: the approval was declined.', 'account_owner', { state: 'declined' });
2372
+ }
2373
+ if (report.verdict === 'approved') {
2374
+ say('Marrow recorded your approval (client-attested).');
2375
+ // Remembered before the run starts: if Marrow is unavailable before the command starts,
2376
+ // the next run picks this approval up instead of asking again.
2377
+ remember();
2378
+ return { approved: true, approval: summary({ state: 'approved', answered_by: report.answeredBy }) };
2379
+ }
2380
+ if (report.code === 'MARROW_VERIFIED_OWNER_APPROVAL_REQUIRED') {
2381
+ ownerOnly = true;
2382
+ linkAvailable = true;
2383
+ hold = { ...hold, linkReason: 'owner_locked' };
2384
+ say('Only the account owner can approve this action.');
2385
+ } else if (report.code === 'MARROW_OWNER_DECLINE_STANDS') {
2386
+ ownerOnly = true;
2387
+ linkAvailable = true;
2388
+ asked = true;
2389
+ hold = { ...hold, refusal: 'owner_decline_stands', linkReason: 'owner_decline_stands' };
2390
+ say('The account owner declined this action; only the owner can reverse that.');
2391
+ } else {
2392
+ remember();
2393
+ return refused(`Marrow could not record your approval (${report.code || 'not recorded'}). Nothing ran.`, { state: 'not_recorded' });
2394
+ }
2395
+ }
2396
+
2397
+ // 2. Only the owner reverses the owner's own decline, and Marrow asks them only on request.
2398
+ if (hold.kind === 'ordinary' && hold.refusal === 'owner_decline_stands' && !asked) {
2399
+ asked = interactive
2400
+ ? parseAnswer(await askLine(`The account owner declined this action${hold.ownerDeclinedAt ? ` at ${hold.ownerDeclinedAt}` : ''}; only the owner can reverse that. Ask the owner now with a one-tap approval link? [y/N] `, io)) === 'yes'
2401
+ : false;
2402
+ if (!asked) {
2403
+ records?.write(context.holdKey, { ...recordBase(), state: 'declined', declined_by: 'account_owner', declined_at: hold.ownerDeclinedAt || new Date().toISOString() });
2404
+ return refused(holdNextText(hold), { state: 'owner_decline_stands' });
2405
+ }
2406
+ }
2407
+
2408
+ // 3. The owner's one-tap link, only where Marrow says it would be sent.
2409
+ let linkSent = false;
2410
+ if (linkAvailable) {
2411
+ state.source = 'owner_link';
2412
+ const link = await requestOwnerLink(options, hold.gateReceiptId, decisionIds.decisionId, state, io,
2413
+ { personPresent: Boolean(hold.personPresentLinks && interactive) });
2414
+ if (link.sent) {
2415
+ linkSent = true;
2416
+ state.linkSentAt = new Date().toISOString();
2417
+ say(linkSentText(state, link.alreadyLive));
2418
+ } else if (!link.quiet) {
2419
+ remember();
2420
+ return refused(`No approval link was sent: ${LINK_FAILURE_TEXT[link.code] || `Marrow answered ${link.code}`}. Nothing ran.`, { state: 'link_not_sent', code: link.code });
2421
+ }
2422
+ }
2423
+
2424
+ // 4. Wait while a person is here for the owner's link, or when asked to (--approval-wait);
2425
+ // otherwise hold quietly. A later run of this command picks the answer up on this receipt.
2426
+ const waitSeconds = options.approvalWaitSeconds;
2427
+ const linkDeadline = linkSent && state.expiresAt ? Date.parse(state.expiresAt) : linkSent ? Date.now() + 10 * 60_000 : null;
2428
+ const wait = waitSeconds == null
2429
+ ? (interactive && linkSent ? linkDeadline - Date.now() : 0)
2430
+ : linkSent ? Math.min(waitSeconds * 1000, linkDeadline - Date.now()) : waitSeconds * 1000;
2431
+ if (wait <= 0) {
2432
+ return held(linkSent
2433
+ ? 'It is held until the owner approves it, and nothing ran. Rerun this command after they answer; it picks up this hold.'
2434
+ : quietHoldText(hold, interactive), linkAvailable && !linkSent ? { link: 'not_sent' } : {});
2435
+ }
2436
+ // Recorded before the wait: Ctrl+C, or a kill such as an agent host's tool timeout, leaves the
2437
+ // hold for the next run, which reuses this receipt and this link (no new email).
2438
+ remember(linkFields());
2439
+ say(linkSent
2440
+ ? 'Waiting for the owner\'s answer. Ctrl+C stops waiting; a later run of this command picks up the answer.'
2441
+ : 'Waiting for a person to approve it. Ctrl+C stops waiting; a later run of this command picks up the answer.');
2442
+ const answer = await waitForOwnerAnswer(options, hold.gateReceiptId, hold.pollAfterMs, Date.now() + wait, io);
2443
+ // The hold was recorded before the wait, so an approval found here is already on record for
2444
+ // the next run if this one stops before the command starts.
2445
+ return finishOwnerAnswer(answer, { hold, say, summary, refused, held, denial, records, holdKey: context.holdKey, linkDeadline });
2446
+ }
2447
+
2448
+ // What the runner does with the owner's answer (in this run, or picked up by a later run).
2449
+ async function finishOwnerAnswer(answer, ctx) {
2450
+ const { hold, say, summary, refused, held, denial, records, holdKey } = ctx;
2451
+ if (answer.state === 'approved') {
2452
+ if (hold.kind === 'arbitration' && !answer.ownerApprovalReceiptId) {
2453
+ return refused('Marrow reported the arbitration as approved without an owner approval receipt, so nothing ran.', { state: 'approval_incomplete' });
2454
+ }
2455
+ say(hold.kind === 'arbitration' ? 'The account owner approved a proposal. Checking that it is this one.' : 'The action was approved. Running it.');
2456
+ return {
2457
+ approved: true,
2458
+ approval: summary({ state: 'approved', approved_via: answer.source, answered_by: answer.answeredBy }),
2459
+ commitExtras: hold.kind === 'arbitration'
2460
+ ? { arbitration_receipt_id: hold.arbitrationReceiptId, owner_approval_receipt_id: answer.ownerApprovalReceiptId }
2461
+ : {},
2462
+ };
2463
+ }
2464
+ if (answer.state === 'declined') {
2465
+ return denial(hold.kind === 'arbitration' ? 'The account owner approved none of the proposals. The action did not run.' : 'The approval was declined. The action did not run.',
2466
+ hold.kind === 'arbitration' ? 'Denied by Marrow pre-action gate: the account owner approved none of the proposals.' : 'Denied by Marrow pre-action gate: the account owner declined.',
2467
+ answer.answeredBy === 'account_owner' || hold.kind === 'arbitration' ? 'account_owner' : 'operator', { state: 'declined' });
2468
+ }
2469
+ if (answer.state === 'interrupted') {
2470
+ return held('Stopped waiting. Nothing ran; it stays held, and a later run of this command picks up the answer on the same receipt.', { state: 'interrupted' });
2471
+ }
2472
+ if (answer.state === 'timeout') {
2473
+ return held(ctx.linkDeadline && Date.now() >= ctx.linkDeadline - 1000
2474
+ ? 'The approval link expired before anyone answered. Nothing ran; rerun the command to check or ask again.'
2475
+ : 'Stopped waiting before the owner answered. Nothing ran; a later run of this command picks up their answer.', { state: 'timeout' });
2476
+ }
2477
+ records?.remove(holdKey);
2478
+ return refused(answer.state === 'expired'
2479
+ ? 'The gate receipt expired before it was approved. Nothing ran; rerun the command for a fresh gate.'
2480
+ : `Marrow reports this hold as ${answer.state}. Nothing ran.`, { state: answer.state });
2481
+ }
2482
+
2483
+ // A hold an earlier run of this exact command left behind. Its answer decides, on the same gate
2484
+ // receipt: no new receipt and no new email. Returns { state: 'none' } when there is nothing to
2485
+ // pick up (or it ran out), { state: 'blocked', ... } when the command must not run, or
2486
+ // { state: 'approved', ... } to run it on that receipt.
2487
+ async function resumeRecordedHold(options, records, holdKey, io = {}) {
2488
+ const record = records.read(holdKey);
2489
+ if (!record) return { state: 'none' };
2490
+ const err = io.stderr || process.stderr;
2491
+ const say = (text) => err.write(`${text}\n`);
2492
+ const now = Date.now();
2493
+ const hold = {
2494
+ kind: record.kind,
2495
+ gateReceiptId: record.gate_receipt_id,
2496
+ arbitrationReceiptId: record.arbitration_receipt_id || '',
2497
+ accepted: record.approvable_here === true,
2498
+ operatorOnly: false,
2499
+ refusal: null,
2500
+ linkAvailable: Boolean(record.link_reason),
2501
+ linkReason: record.link_reason || null,
2502
+ personPresentLinks: record.link_person_present === true,
2503
+ expiresAt: record.receipt_expires_at || null,
2504
+ pollAfterMs: 3_000,
2505
+ };
2506
+ const decisionIds = { decisionId: record.decision_id || '', runtimeDecisionId: record.runtime_decision_id || '' };
2507
+ const holdOptions = { ...options, sessionId: record.session_id };
2508
+ const base = { gateReceiptId: record.gate_receipt_id, decisionId: decisionIds.decisionId || decisionIds.runtimeDecisionId };
2509
+ const blocked = (message, approval = {}) => ({ state: 'blocked', exitCode: 12, message, ...base, approval: { gate_receipt_id: record.gate_receipt_id, resumed: true, ...approval } });
2510
+
2511
+ if (record.state === 'declined') {
2512
+ const declinedAt = Date.parse(record.declined_at);
2513
+ if (!Number.isFinite(declinedAt) || now - declinedAt > DECLINE_STANDS_MS) {
2514
+ records.remove(holdKey);
2515
+ return { state: 'none' };
2516
+ }
2517
+ if (record.declined_by === 'account_owner') {
2518
+ if (options.requestOwnerLink) {
2519
+ // A new hold in the decline's own session, where Marrow sees the decline and sends the
2520
+ // owner the link to reverse it.
2521
+ records.remove(holdKey);
2522
+ return { state: 'none', sessionId: record.session_id };
2523
+ }
2524
+ return blocked(`The account owner declined this action at ${new Date(declinedAt).toISOString()}, and only the owner can reverse that. Nothing ran. To ask the owner with a one-tap approval link, rerun with --request-owner-link.`, { state: 'owner_decline_stands' });
2525
+ }
2526
+ return blocked(`You declined this action at ${new Date(declinedAt).toISOString()}. That answer stands until ${new Date(declinedAt + DECLINE_STANDS_MS).toISOString()}, and nothing ran.`, { state: 'declined' });
2527
+ }
2528
+
2529
+ if (hold.expiresAt && Date.parse(hold.expiresAt) <= now) {
2530
+ records.remove(holdKey);
2531
+ return { state: 'none' };
2532
+ }
2533
+ const status = await readHoldStatus(holdOptions, record.gate_receipt_id);
2534
+ if (status.state === 'unavailable') {
2535
+ return blocked(`Marrow holds this action (gate receipt ${record.gate_receipt_id}) and its approval state could not be read, so nothing ran. Retry in a moment.`, { state: 'state_unavailable' });
2536
+ }
2537
+ if (['unknown', 'expired', 'used', 'not_held'].includes(status.state)) {
2538
+ records.remove(holdKey);
2539
+ return { state: 'none' };
2540
+ }
2541
+ const state = { linkRequests: 0, channel: null, expiresAt: record.link_expires_at || null, source: null };
2542
+ const summary = (extra = {}) => ({ gate_receipt_id: record.gate_receipt_id, resumed: true, link_requests: state.linkRequests, ...(state.channel ? { channel: state.channel, expires_at: state.expiresAt } : {}), ...extra });
2543
+ const refusedHere = (message, extra = {}) => ({ approved: false, exitCode: 12, message, approval: summary(extra) });
2544
+ const keep = (extra = {}) => records.write(holdKey, { ...record, ...extra });
2545
+ const linkFieldsHere = () => (state.channel ? { link_sent_at: state.linkSentAt || new Date().toISOString(), link_expires_at: state.expiresAt } : {});
2546
+ const heldHere = (message, extra = {}) => {
2547
+ keep(linkFieldsHere());
2548
+ return refusedHere(message, { state: 'held', ...extra });
2549
+ };
2550
+ const denialHere = async (message, outcome, declinedBy, extra) => {
2551
+ const commitState = record.denial_committed === 'yes' ? 'committed' : await commitHoldDenial(holdOptions, decisionIds, record.gate_receipt_id, outcome, record.kind === 'arbitration' ? record.arbitration_receipt_id || '' : '');
2552
+ records.write(holdKey, { ...record, state: 'declined', declined_by: declinedBy, declined_at: new Date().toISOString(), denial_committed: commitState === 'committed' ? 'yes' : undefined });
2553
+ return refusedHere(message, { ...extra, denial_commit: commitState });
2554
+ };
2555
+ const toResult = (resolved) => (resolved.approved
2556
+ ? { state: 'approved', hold, ...base, options: holdOptions, approval: resolved.approval, commitExtras: resolved.commitExtras || {} }
2557
+ : { state: 'blocked', exitCode: resolved.exitCode, message: resolved.message, ...base, approval: resolved.approval });
2558
+ const ctx = { hold, say, summary, refused: refusedHere, held: heldHere, denial: denialHere, records, holdKey, linkDeadline: state.expiresAt ? Date.parse(state.expiresAt) : null };
2559
+
2560
+ if (status.state === 'approved' || status.state === 'declined') {
2561
+ say(`Picking up the earlier hold of this command (gate receipt ${record.gate_receipt_id}).`);
2562
+ return toResult(await finishOwnerAnswer(status, ctx));
2563
+ }
2564
+
2565
+ // Still waiting.
2566
+ const interactive = approvalPromptAvailable(holdOptions, io);
2567
+ say(`This command is still held (gate receipt ${record.gate_receipt_id}).`);
2568
+ if (hold.kind === 'ordinary' && hold.accepted && interactive) {
2569
+ const askedAt = new Date().toISOString();
2570
+ const raw = await askLine('Approve and run it now? [y/N] ', io);
2571
+ const answer = parseAnswer(raw);
2572
+ const times = { askedAt, answeredAt: new Date().toISOString() };
2573
+ state.source = 'operator_prompt';
2574
+ if (answer === null) return toResult(refusedHere('No answer was given, so nothing ran and nothing was recorded.', { state: 'no_answer' }));
2575
+ const report = await reportRunnerVerdict(holdOptions, record.gate_receipt_id, answer === 'yes' ? 'approved' : 'declined', times, decisionIds, io);
2576
+ if (answer === 'no') {
2577
+ if (report.verdict !== 'declined') return toResult(refusedHere(`Marrow could not record the decline (${report.code || 'not recorded'}). Nothing ran.`, { state: 'declined_unrecorded' }));
2578
+ return toResult(await denialHere('Declined. The action did not run.', 'Denied by Marrow pre-action gate: the operator declined in the governed runner.', 'operator', { state: 'declined' }));
2579
+ }
2580
+ if (report.verdict === 'approved') {
2581
+ say('Marrow recorded your approval (client-attested).');
2582
+ return toResult({ approved: true, approval: summary({ state: 'approved', source: 'operator_prompt', answered_by: report.answeredBy }) });
2583
+ }
2584
+ if (report.verdict === 'declined') return toResult(await denialHere('This action was already declined. It did not run.', 'Denied by Marrow pre-action gate: the approval was declined.', 'account_owner', { state: 'declined' }));
2585
+ return toResult(refusedHere(`Marrow could not record your approval (${report.code || 'not recorded'}). Nothing ran.`, { state: 'not_recorded' }));
2586
+ }
2587
+
2588
+ // Waiting on the owner. A link that expired is asked for again only where Marrow sends one.
2589
+ const linkExpired = !record.link_expires_at || Date.parse(record.link_expires_at) <= now;
2590
+ if (hold.linkAvailable && hold.linkReason !== 'unattended_owner_ping' && linkExpired) {
2591
+ state.source = 'owner_link';
2592
+ const link = await requestOwnerLink(holdOptions, record.gate_receipt_id, decisionIds.decisionId, state, io,
2593
+ { personPresent: Boolean(hold.personPresentLinks && interactive) });
2594
+ if (link.sent) {
2595
+ state.linkSentAt = new Date().toISOString();
2596
+ say(linkSentText(state, link.alreadyLive));
2597
+ }
2598
+ }
2599
+ const deadlineAt = state.expiresAt ? Date.parse(state.expiresAt) : record.link_expires_at ? Date.parse(record.link_expires_at) : null;
2600
+ const waitSeconds = options.approvalWaitSeconds;
2601
+ const wait = waitSeconds == null
2602
+ ? (interactive && deadlineAt ? deadlineAt - Date.now() : 0)
2603
+ : Math.min(waitSeconds * 1000, deadlineAt ? deadlineAt - Date.now() : waitSeconds * 1000);
2604
+ if (wait <= 0) {
2605
+ // "After they answer" only when the owner has a live link; otherwise the quiet-hold text,
2606
+ // which says where a link can be sent from.
2607
+ const linkLive = Boolean(state.channel) || (Boolean(record.link_expires_at) && Date.parse(record.link_expires_at) > now);
2608
+ return toResult(heldHere(hold.kind === 'ordinary' && hold.accepted
2609
+ ? 'It is held until a person approves it, and nothing ran. Rerun this command in an interactive terminal to approve it there.'
2610
+ : linkLive ? 'It stays held until the account owner approves it, and nothing ran. Rerun this command after they answer.' : quietHoldText(hold, interactive)));
2611
+ }
2612
+ // A new link is recorded before the wait, so a stopped or killed run leaves it for the next.
2613
+ if (state.channel) keep(linkFieldsHere());
2614
+ say('Waiting for the owner\'s answer. Ctrl+C stops waiting; a later run of this command picks up the answer.');
2615
+ const answer = await waitForOwnerAnswer(holdOptions, record.gate_receipt_id, 3_000, Date.now() + wait, io);
2616
+ ctx.linkDeadline = deadlineAt;
2617
+ return toResult(await finishOwnerAnswer(answer, ctx));
2618
+ }
2619
+
853
2620
  function gateModeText(decision) {
854
2621
  const parts = [decision.enforced === true
855
2622
  ? 'enforced'
@@ -862,10 +2629,15 @@ function gateModeText(decision) {
862
2629
  return parts.join('; ');
863
2630
  }
864
2631
 
865
- function printGate(decision, runtime, stream = process.stdout) {
2632
+ function printGate(decision, runtime, stream = process.stdout, { resolvesHolds = false } = {}) {
866
2633
  stream.write(`Marrow gate: ${decision.decision} (${gateModeText(decision)})\n`);
867
2634
  if (decision.beforeYouAct) stream.write(`Before you act: ${decision.beforeYouAct}\n`);
868
- if (decision.exactNextAction) stream.write(`Next: ${decision.exactNextAction}\n`);
2635
+ // A hold's served text is written for agents; the runner states its own next step instead,
2636
+ // and `run` explains the hold itself while it resolves it.
2637
+ const hold = shouldBlock(decision, { policy: 'enforce' }) ? heldApproval(runtime, decision) : null;
2638
+ if (hold) {
2639
+ if (!resolvesHolds) stream.write(`Next: ${holdNextText(hold)}\n`);
2640
+ } else if (decision.exactNextAction) stream.write(`Next: ${decision.exactNextAction}\n`);
869
2641
  if (decision.proofPack?.required) {
870
2642
  const missing = decision.proofPack.missing?.length ? ` missing: ${decision.proofPack.missing.join(', ')}` : '';
871
2643
  stream.write(`Proof pack: required${missing}\n`);
@@ -959,7 +2731,7 @@ function commitIdempotencyKey(options, decisionId, { perInvocation = true } = {}
959
2731
  return `marrow-run-${crypto.createHash('sha256').update(parts.join('\u0000')).digest('hex')}`;
960
2732
  }
961
2733
 
962
- async function commitOutcome(options, decisionId, success, outcome, proof, gateReceiptId, modelUsage = null, { perInvocation = true } = {}) {
2734
+ async function commitOutcome(options, decisionId, success, outcome, proof, gateReceiptId, modelUsage = null, { perInvocation = true, extras = {} } = {}) {
963
2735
  const body = {
964
2736
  decision_id: decisionId,
965
2737
  success,
@@ -968,6 +2740,9 @@ async function commitOutcome(options, decisionId, success, outcome, proof, gateR
968
2740
  source_meta: sourceMeta(options, 'commit', { action: outcome }),
969
2741
  };
970
2742
  if (gateReceiptId) body.gate_receipt_id = gateReceiptId;
2743
+ // An arbitration hold closes with its arbitration receipt and the owner's approval receipt.
2744
+ if (extras.arbitration_receipt_id) body.arbitration_receipt_id = extras.arbitration_receipt_id;
2745
+ if (extras.owner_approval_receipt_id) body.owner_approval_receipt_id = extras.owner_approval_receipt_id;
971
2746
  if (modelUsage) body.model_usage = modelUsage;
972
2747
  return durableRequestJson(options, '/v1/agent/commit', body, commitIdempotencyKey(options, decisionId, { perInvocation }));
973
2748
  }
@@ -980,12 +2755,10 @@ async function decisionForAction(options, decision, action, type, target, surfac
980
2755
  }
981
2756
 
982
2757
  async function runGoverned(parsed, execution = {}) {
983
- const { options, childCommand } = parsed;
984
- const commandText = redactedCommand(childCommand);
985
- const action = options.action ? redact(options.action) : commandText;
986
- const riskText = `${action} ${commandText} ${options.target || ''}`;
987
- const type = options.type || inferType(riskText);
988
- const risky = isRisky(riskText, type);
2758
+ const { childCommand } = parsed;
2759
+ let options = parsed.options;
2760
+ const inputs = governedInputs(options, childCommand);
2761
+ const { commandText, action, type, risky, surfaces, target } = inputs;
989
2762
  let runtime = null;
990
2763
  let decision = null;
991
2764
  let decisionId = '';
@@ -995,7 +2768,16 @@ async function runGoverned(parsed, execution = {}) {
995
2768
  let degraded = false;
996
2769
  let advisory = false;
997
2770
  let protectedAction = risky;
998
- const surfaces = inferSurfaces(commandText || action);
2771
+ let hold = null;
2772
+ let holdApproval = null;
2773
+ let approvedHold = false;
2774
+ let gateReceiptId = '';
2775
+ let commitExtras = {};
2776
+ let decisionResolved = false;
2777
+ let claimed = false;
2778
+ const records = holdRecordStore(execution);
2779
+ const holdKey = runnerHoldKey(options, inputs.holdMaterial, records.salt());
2780
+ const blocked = (exitCode, message, extra = {}) => ({ ok: false, blocked: true, exitCode, action, type, risky, ...extra, message });
999
2781
 
1000
2782
  let localControl;
1001
2783
  try { localControl = readLocalControlState(execution.controlStateOptions || {}); }
@@ -1010,51 +2792,109 @@ async function runGoverned(parsed, execution = {}) {
1010
2792
  }
1011
2793
 
1012
2794
  try {
1013
- runtime = await preflightRuntime(options, action, type, commandText);
1014
- decision = gateDecision(runtime);
1015
- protectedAction = risky
1016
- || decision.required === true
1017
- || decision.riskLevel === 'high'
1018
- || decision.riskLevel === 'critical';
1019
- if (!decision.recognized) {
1020
- if (protectedAction) throw new Error('Marrow runtime returned no gate decision, so this protected action cannot be checked.');
1021
- process.stderr.write('Marrow gate: no decision returned; continuing because this action is not protected.\n');
2795
+ // 0. An earlier run of this exact command left a hold: its answer decides, on the same gate
2796
+ // receipt (no new receipt, no new email). Once a hold exists nothing may run it unapproved.
2797
+ const resumed = await resumeRecordedHold(options, records, holdKey, execution);
2798
+ if (resumed.state !== 'none') protectedAction = true;
2799
+ if (resumed.state === 'blocked') {
2800
+ return blocked(resumed.exitCode, resumed.message, { decision_id: resumed.decisionId || '', gate_receipt_id: resumed.gateReceiptId, approval: resumed.approval });
2801
+ }
2802
+ // Asking the owner to reverse their decline starts a new hold in the decline's session.
2803
+ if (resumed.state === 'none' && resumed.sessionId) options = { ...options, sessionId: resumed.sessionId };
2804
+ if (resumed.state === 'approved') {
2805
+ hold = resumed.hold;
2806
+ approvedHold = true;
2807
+ options = resumed.options;
2808
+ holdApproval = resumed.approval;
2809
+ gateReceiptId = resumed.gateReceiptId;
2810
+ decisionId = resumed.decisionId;
2811
+ decisionSource = 'earlier_hold';
2812
+ decisionResolved = true;
2813
+ commitExtras = resumed.commitExtras || {};
1022
2814
  } else {
1023
- printGate(decision, runtime);
2815
+ runtime = await preflightRuntime(options, action, type, commandText, surfaces);
2816
+ decision = runnerGateDecision(runtime);
2817
+ protectedAction = risky
2818
+ || decision.required === true
2819
+ || decision.riskLevel === 'high'
2820
+ || decision.riskLevel === 'critical';
2821
+ if (!decision.recognized) {
2822
+ if (protectedAction) throw new Error('Marrow runtime returned no gate decision, so this protected action cannot be checked.');
2823
+ process.stderr.write('Marrow gate: no decision returned; continuing because this action is not protected.\n');
2824
+ } else {
2825
+ printGate(decision, runtime, execution.gateOutput || process.stdout, { resolvesHolds: true });
2826
+ }
2827
+ gateReceiptId = decision.receiptId || '';
2828
+ if (shouldBlock(decision, options)) {
2829
+ // Held or blocked: from here every failure blocks; nothing degrades into running it.
2830
+ protectedAction = true;
2831
+ hold = heldApproval(runtime, decision);
2832
+ if (!hold || hold.kind === 'legacy') {
2833
+ return blocked(12, blockMessage(decision, hold), { decision, gate_receipt_id: gateReceiptId || null });
2834
+ }
2835
+ // The decision is resolved first so the operator's answer and the owner's link name it.
2836
+ ({ decisionId, source: decisionSource } = await decisionForAction(options, decision, action, type, target, surfaces));
2837
+ decisionResolved = true;
2838
+ const resolved = await resolveHold(options, hold, {
2839
+ io: execution,
2840
+ records,
2841
+ holdKey,
2842
+ decisionIds: { decisionId, runtimeDecisionId: decision.runtimeDecisionId || hold.arbitrationDecisionId || '' },
2843
+ });
2844
+ holdApproval = resolved.approval;
2845
+ if (!resolved.approved) {
2846
+ return blocked(resolved.exitCode, resolved.message, { decision, decision_id: decisionId, gate_receipt_id: hold.gateReceiptId, approval: holdApproval });
2847
+ }
2848
+ approvedHold = true;
2849
+ commitExtras = resolved.commitExtras || {};
2850
+ }
1024
2851
  }
1025
- if (shouldBlock(decision, options)) {
1026
- return {
1027
- ok: false,
1028
- blocked: true,
1029
- exitCode: 12,
1030
- action,
1031
- type,
1032
- risky,
1033
- decision,
1034
- message: blockMessage(decision),
1035
- };
2852
+ if (approvedHold) {
2853
+ // One run per approval: an identical run already using it keeps this one from running.
2854
+ if (!records.claim(holdKey, gateReceiptId)) {
2855
+ return blocked(12, `An identical run is already using the approval of gate receipt ${gateReceiptId}, so this one did not run.`, { decision_id: decisionId, gate_receipt_id: gateReceiptId, approval: holdApproval });
2856
+ }
2857
+ claimed = true;
1036
2858
  }
1037
- const target = options.target || commandText;
1038
- ({ decisionId, source: decisionSource } = await decisionForAction(options, decision, action, type, target, surfaces));
2859
+ if (!decisionResolved) ({ decisionId, source: decisionSource } = await decisionForAction(options, decision, action, type, target, surfaces));
1039
2860
  // Permits exist only where the plan enforces the gate. An advisory plan shows its warning,
1040
2861
  // runs the command and still records the outcome; it never asks for a permit it cannot get.
1041
- const permitRequired = protectedAction && decision.enforced !== false;
1042
- if (protectedAction && !permitRequired) {
2862
+ // An approved ordinary hold runs on its gate receipt, as the MCP hooks do, and the commit
2863
+ // closes that receipt. An approved arbitration hold needs the permit: Marrow issues it only
2864
+ // when the proposal the owner approved is this exact action.
2865
+ const arbitrationApproved = approvedHold && hold?.kind === 'arbitration';
2866
+ const permitRequired = arbitrationApproved || (protectedAction && !approvedHold && decision?.enforced !== false);
2867
+ if (protectedAction && !approvedHold && !permitRequired) {
1043
2868
  advisory = true;
1044
- process.stderr.write(`Marrow advisory: this ${type} action is not enforced on this plan (gate ${decision.decision}). Running it and recording the outcome.\n`);
2869
+ process.stderr.write(`Marrow advisory: this ${type} action is not enforced on this plan (gate ${decision?.decision}). Running it and recording the outcome.\n`);
1045
2870
  }
1046
2871
  if (permitRequired) {
1047
- if (!decision.receiptId) throw new Error('Marrow runtime returned no gate receipt, so no action permit can be issued.');
1048
- actionPermit = await issueActionPermit(requestJson, options, {
1049
- action,
1050
- type,
1051
- target,
1052
- surfaces,
1053
- decisionId,
1054
- gateReceiptId: decision.receiptId,
1055
- ownerApproval: options.ownerApproval,
1056
- proofRequirements: decision.proofPack?.required_fields || decision.proofPack?.missing || [],
1057
- });
2872
+ if (!gateReceiptId) throw new Error('Marrow runtime returned no gate receipt, so no action permit can be issued.');
2873
+ try {
2874
+ actionPermit = await issueActionPermit(requestJson, options, {
2875
+ action,
2876
+ type,
2877
+ target,
2878
+ surfaces,
2879
+ decisionId,
2880
+ gateReceiptId,
2881
+ ...(arbitrationApproved ? { ownerApproval: commitExtras.owner_approval_receipt_id } : {}),
2882
+ proofRequirements: decision?.proofPack?.required_fields || decision?.proofPack?.missing || [],
2883
+ });
2884
+ } catch (error) {
2885
+ if (arbitrationApproved) {
2886
+ const code = permitErrorCode(error);
2887
+ if (PERMIT_REFUSAL_STATUSES.has(error.status)) {
2888
+ const refused = new Error(`Marrow refused the permit for this command on the owner's arbitration approval (${code}), so it did not run. Marrow issues it only when the proposal the owner approved is this exact action.`);
2889
+ refused.status = error.status;
2890
+ throw refused;
2891
+ }
2892
+ const unavailable = new Error(`Marrow could not issue the permit for the owner's arbitration approval right now (${code}), so it did not run. Rerun the command; it picks up the approval.`);
2893
+ unavailable.status = error.status;
2894
+ throw unavailable;
2895
+ }
2896
+ throw error;
2897
+ }
1058
2898
  if (!actionPermit?.permit || !actionPermit?.permit_id) {
1059
2899
  throw new Error('Marrow did not issue a valid action permit.');
1060
2900
  }
@@ -1078,6 +2918,12 @@ async function runGoverned(parsed, execution = {}) {
1078
2918
  degraded = true;
1079
2919
  process.stderr.write(`Marrow degraded: ${error.message}. Continuing because this action is not protected${decisionId ? '' : '; its outcome will not be recorded'}.\n`);
1080
2920
  } else {
2921
+ // A refusal spends the hold (a fresh gate is needed); a failure a later run may not meet
2922
+ // (Marrow unreachable or unavailable) keeps it, so the next run picks the approval up.
2923
+ if (claimed) {
2924
+ if (PERMIT_REFUSAL_STATUSES.has(error.status)) records.remove(holdKey);
2925
+ else records.release(holdKey);
2926
+ }
1081
2927
  return {
1082
2928
  ok: false,
1083
2929
  blocked: true,
@@ -1086,6 +2932,7 @@ async function runGoverned(parsed, execution = {}) {
1086
2932
  action,
1087
2933
  type,
1088
2934
  risky,
2935
+ ...(hold ? { gate_receipt_id: gateReceiptId || null, approval: holdApproval } : {}),
1089
2936
  message: error.message,
1090
2937
  };
1091
2938
  }
@@ -1109,8 +2956,9 @@ async function runGoverned(parsed, execution = {}) {
1109
2956
  success,
1110
2957
  outcome,
1111
2958
  proof,
1112
- decision?.receiptId || '',
2959
+ gateReceiptId,
1113
2960
  child.modelUsage,
2961
+ { extras: commitExtras },
1114
2962
  );
1115
2963
  commitState = commit?.committed === true ? 'committed' : 'not_committed';
1116
2964
  if (commitState === 'not_committed') {
@@ -1122,6 +2970,8 @@ async function runGoverned(parsed, execution = {}) {
1122
2970
  } else if (!degraded) {
1123
2971
  process.stderr.write('Marrow outcome not recorded: no decision id was returned before the command ran.\n');
1124
2972
  }
2973
+ // The approval is spent: this command's hold is done.
2974
+ if (approvedHold) records.remove(holdKey);
1125
2975
 
1126
2976
  let permitClosed = null;
1127
2977
  if (actionPermit?.permit) {
@@ -1151,7 +3001,8 @@ async function runGoverned(parsed, execution = {}) {
1151
3001
  decision,
1152
3002
  decision_id: decisionId,
1153
3003
  decision_source: decisionSource,
1154
- gate_receipt_id: decision?.receiptId || null,
3004
+ gate_receipt_id: gateReceiptId || null,
3005
+ ...(holdApproval ? { approval: holdApproval } : {}),
1155
3006
  outcome_committed: commitState === 'committed',
1156
3007
  outcome_commit_state: commitState,
1157
3008
  permit_id: actionPermit?.permit_id || null,
@@ -1172,17 +3023,14 @@ async function runGoverned(parsed, execution = {}) {
1172
3023
 
1173
3024
  async function permitOnly(parsed) {
1174
3025
  const { options } = parsed;
1175
- const action = redact(options.action);
1176
- const type = options.type || inferType(action);
1177
- const target = options.target || action;
1178
- const surfaces = inferSurfaces(target);
1179
- const runtime = await preflightRuntime(options, action, type, target);
1180
- const decision = gateDecision(runtime);
3026
+ const { action, type, target, surfaces } = governedInputs(options, null, 'target');
3027
+ const runtime = await preflightRuntime(options, action, type, target, surfaces);
3028
+ const decision = runnerGateDecision(runtime);
1181
3029
  if (!decision.recognized) {
1182
3030
  return { ok: false, blocked: true, exitCode: 13, decision, message: 'Marrow runtime returned no gate decision, so no permit was issued.' };
1183
3031
  }
1184
3032
  if (shouldBlock(decision, options)) {
1185
- return { ok: false, blocked: true, exitCode: 12, decision, message: blockMessage(decision) };
3033
+ return { ok: false, blocked: true, exitCode: 12, decision, message: blockMessage(decision, heldApproval(runtime, decision)) };
1186
3034
  }
1187
3035
  const { decisionId } = await decisionForAction(options, decision, action, type, target, surfaces);
1188
3036
  if (decision.enforced === false) {
@@ -1207,7 +3055,6 @@ async function permitOnly(parsed) {
1207
3055
  surfaces,
1208
3056
  decisionId,
1209
3057
  gateReceiptId: decision.receiptId,
1210
- ownerApproval: options.ownerApproval,
1211
3058
  proofRequirements: decision.proofPack?.required_fields || decision.proofPack?.missing || [],
1212
3059
  });
1213
3060
  return { ok: true, decision_id: decisionId, gate_receipt_id: decision.receiptId, ...result };
@@ -1215,13 +3062,11 @@ async function permitOnly(parsed) {
1215
3062
 
1216
3063
  async function verifyPermitOnly(parsed) {
1217
3064
  const { options } = parsed;
1218
- const action = redact(options.action);
1219
- const type = options.type || inferType(action);
1220
- const surfaces = inferSurfaces(options.target || action);
3065
+ const { action, type, target, surfaces } = governedInputs(options, null, 'target');
1221
3066
  const result = await verifyActionPermit(requestJson, options, {
1222
3067
  action,
1223
3068
  type,
1224
- target: options.target || action,
3069
+ target,
1225
3070
  surfaces,
1226
3071
  permit: options.permit,
1227
3072
  });
@@ -1244,13 +3089,16 @@ async function sidecarOnly(parsed) {
1244
3089
  const detection = detectEnvironment(root, process.env);
1245
3090
  // Maintenance re-applies the controller's own configured agent id and base URL. A different
1246
3091
  // value found in managed MCP config is replaced unless the owner allowlisted it, and the
1247
- // divergence is reported. It never edits the owner's Hermes config.
3092
+ // divergence is reported. It never edits the owner's Hermes config. Hooks stay on the
3093
+ // verified local MCP runtime when there is one (checked, never installed, here).
3094
+ const mcpRuntime = maintenanceMcpRuntime(detection, { mcpLocalRuntime: process.env.MARROW_LOCAL_RUNTIME !== '0' });
1248
3095
  const plan = buildPlan(detection, {
1249
3096
  mode: managedMode,
1250
3097
  agentId: options.agentId,
1251
3098
  baseUrl: options.baseUrl,
1252
3099
  client: options.client,
1253
3100
  maintenance: true,
3101
+ mcpRuntime,
1254
3102
  });
1255
3103
  const changes = applyPlan(plan, { yes: true, dryRun: false, doctor: false });
1256
3104
  const repaired = changes.filter((change) => change.applied).map((change) => change.label);
@@ -1310,20 +3158,18 @@ async function controllerOnly(parsed) {
1310
3158
 
1311
3159
  async function gateOnly(parsed) {
1312
3160
  const { options } = parsed;
1313
- const action = redact(options.action);
1314
- const type = options.type || inferType(action);
1315
- const protectedAction = isRisky(action, type);
3161
+ const { action, type, risky: protectedAction, surfaces } = governedInputs(options, null, 'action');
1316
3162
  let localControl;
1317
3163
  try { localControl = readLocalControlState(); } catch (error) {
1318
3164
  if (protectedAction) return { ok: false, allowed: false, blocked: true, exitCode: 13, action, type, decision: null, message: error.message };
1319
3165
  localControl = { enabled: true };
1320
3166
  }
1321
3167
  if (!localControl.enabled) {
1322
- const bypass = protectedAction ? await recordGovernedBypass({ harness: options.client, agentId: options.agentId, surfaces: inferSurfaces(action), risk: 'high' }, options) : { bypass_recorded: false, remote_delivered: false };
3168
+ const bypass = protectedAction ? await recordGovernedBypass({ harness: options.client, agentId: options.agentId, surfaces, risk: 'high' }, options) : { bypass_recorded: false, remote_delivered: false };
1323
3169
  return { ok: true, allowed: true, state: 'owner_disabled', action, type, bypass_recorded: bypass.bypass_recorded, bypass_remote_delivered: bypass.remote_delivered, decision: null, permit: null };
1324
3170
  }
1325
- const runtime = await preflightRuntime(options, action, type, action);
1326
- const decision = gateDecision(runtime);
3171
+ const runtime = await preflightRuntime(options, action, type, action, surfaces);
3172
+ const decision = runnerGateDecision(runtime);
1327
3173
  if (!decision.recognized) {
1328
3174
  return {
1329
3175
  ok: false,
@@ -1357,7 +3203,9 @@ async function gateOnly(parsed) {
1357
3203
  ...(blocked ? {
1358
3204
  message: decision.observationOnly
1359
3205
  ? blockMessage(decision)
1360
- : decision.exactNextAction || `Marrow gate ${decision.decision}: this action needs owner approval or a policy change before it runs.`,
3206
+ : heldApproval(runtime, decision)
3207
+ ? blockMessage(decision, heldApproval(runtime, decision))
3208
+ : decision.exactNextAction || `Marrow gate ${decision.decision}: this action needs approval or a policy change before it runs.`,
1361
3209
  } : {}),
1362
3210
  };
1363
3211
  }
@@ -1734,7 +3582,7 @@ async function fleetSnapshot(options) {
1734
3582
  data: {
1735
3583
  enabled: false,
1736
3584
  missed_hooks: ['api_key'],
1737
- recommended_fix: 'export MARROW_API_KEY=mrw_live_... && npx @getmarrow/install fleet',
3585
+ recommended_fix: 'Put MARROW_API_KEY=<your_key> in ~/.marrow/env (run chmod 700 ~/.marrow && chmod 600 ~/.marrow/env), then run npx @getmarrow/install fleet. Fallback: MARROW_API_KEY=<your_key> npx @getmarrow/install fleet',
1738
3586
  },
1739
3587
  },
1740
3588
  capacity: { ok: false, error: 'MARROW_API_KEY missing', data: {} },
@@ -1798,19 +3646,24 @@ function fleetPanel(snapshot) {
1798
3646
 
1799
3647
  // Governed commands never name an invented agent id. MARROW_AGENT_ID, when set, is a registered
1800
3648
  // agent; otherwise Marrow resolves the key's bound agent or the plan seat.
1801
- const GENERIC_GOVERNED_COMMAND = 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install run --profile production --policy warn -- <harness-command>';
3649
+ // Commands read the key from ~/.marrow/env; the env-prefixed form is the labelled fallback.
3650
+ const KEY_FILE_SETUP = 'Put MARROW_API_KEY=<your_key> in ~/.marrow/env, then run chmod 700 ~/.marrow && chmod 600 ~/.marrow/env.';
3651
+ const KEY_ENV_FALLBACK_PREFIX = 'MARROW_API_KEY=<your_key> ';
3652
+ const isRunnableCommand = (command) => String(command).startsWith('npx ');
3653
+
3654
+ const GENERIC_GOVERNED_COMMAND = 'npx @getmarrow/install run --profile production --policy warn -- <harness-command>';
1802
3655
 
1803
3656
  function localSupportedHarnesses() {
1804
3657
  const harnesses = [
1805
- { display_name: 'OpenAI Codex', client_label: 'codex', category: 'agent_harness', support_level: 'governed_runner', install_command: 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install run -- codex' },
1806
- { display_name: 'Claude Code', client_label: 'claude-code', category: 'agent_harness', support_level: 'native_mcp_or_sdk', install_command: `MARROW_API_KEY=mrw_live_xxx ${MCP_SETUP_COMMAND}` },
1807
- { display_name: 'Cursor', client_label: 'cursor', category: 'ide_agent', support_level: 'native_mcp_or_sdk', install_command: `MARROW_API_KEY=mrw_live_xxx ${MCP_SETUP_COMMAND}` },
1808
- { display_name: 'Cursor Composer', client_label: 'composer', category: 'ide_agent', support_level: 'native_mcp_or_sdk', install_command: `MARROW_API_KEY=mrw_live_xxx ${MCP_SETUP_COMMAND}` },
1809
- { display_name: 'Windsurf', client_label: 'windsurf', category: 'ide_agent', support_level: 'native_mcp_or_sdk', install_command: `MARROW_API_KEY=mrw_live_xxx ${MCP_SETUP_COMMAND}` },
1810
- { display_name: 'Cline', client_label: 'cline', category: 'ide_agent', support_level: 'native_mcp_or_sdk', install_command: `MARROW_API_KEY=mrw_live_xxx ${MCP_SETUP_COMMAND}` },
1811
- { display_name: 'OpenCode', client_label: 'opencode', category: 'agent_harness', support_level: 'governed_runner', install_command: 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install run -- opencode' },
1812
- { display_name: 'Hermes Agent', client_label: 'hermes', category: 'agent_harness', support_level: 'first_class_addon', install_command: 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install hermes' },
1813
- { display_name: 'OpenClaw', client_label: 'openclaw', category: 'agent_harness', support_level: 'first_class_addon', install_command: 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install openclaw' },
3658
+ { display_name: 'OpenAI Codex', client_label: 'codex', category: 'agent_harness', support_level: 'governed_runner', install_command: 'npx @getmarrow/install run -- codex' },
3659
+ { display_name: 'Claude Code', client_label: 'claude-code', category: 'agent_harness', support_level: 'native_mcp_or_sdk', install_command: `${MCP_SETUP_COMMAND}` },
3660
+ { display_name: 'Cursor', client_label: 'cursor', category: 'ide_agent', support_level: 'native_mcp_or_sdk', install_command: `${MCP_SETUP_COMMAND}` },
3661
+ { display_name: 'Cursor Composer', client_label: 'composer', category: 'ide_agent', support_level: 'native_mcp_or_sdk', install_command: `${MCP_SETUP_COMMAND}` },
3662
+ { display_name: 'Windsurf', client_label: 'windsurf', category: 'ide_agent', support_level: 'native_mcp_or_sdk', install_command: `${MCP_SETUP_COMMAND}` },
3663
+ { display_name: 'Cline', client_label: 'cline', category: 'ide_agent', support_level: 'native_mcp_or_sdk', install_command: `${MCP_SETUP_COMMAND}` },
3664
+ { display_name: 'OpenCode', client_label: 'opencode', category: 'agent_harness', support_level: 'governed_runner', install_command: 'npx @getmarrow/install run -- opencode' },
3665
+ { display_name: 'Hermes Agent', client_label: 'hermes', category: 'agent_harness', support_level: 'first_class_addon', install_command: 'npx @getmarrow/install hermes' },
3666
+ { display_name: 'OpenClaw', client_label: 'openclaw', category: 'agent_harness', support_level: 'first_class_addon', install_command: 'npx @getmarrow/install openclaw' },
1814
3667
  { display_name: 'Gemini CLI', client_label: 'gemini', category: 'model_cli', support_level: 'governed_runner', install_command: GENERIC_GOVERNED_COMMAND },
1815
3668
  { display_name: 'Grok CLI', client_label: 'grok', category: 'model_cli', support_level: 'governed_runner', install_command: GENERIC_GOVERNED_COMMAND },
1816
3669
  { display_name: 'DeepSeek', client_label: 'deepseek', category: 'model_cli', support_level: 'governed_runner', install_command: GENERIC_GOVERNED_COMMAND },
@@ -1818,14 +3671,17 @@ function localSupportedHarnesses() {
1818
3671
  { display_name: 'Kimi', client_label: 'kimi', category: 'model_cli', support_level: 'governed_runner', install_command: GENERIC_GOVERNED_COMMAND },
1819
3672
  { display_name: 'MiniMax', client_label: 'minimax', category: 'model_cli', support_level: 'governed_runner', install_command: GENERIC_GOVERNED_COMMAND },
1820
3673
  { display_name: 'GLM', client_label: 'glm', category: 'model_cli', support_level: 'governed_runner', install_command: GENERIC_GOVERNED_COMMAND },
1821
- { display_name: 'MCP-compatible clients', client_label: 'mcp', category: 'mcp_client', support_level: 'native_mcp_or_sdk', install_command: `MARROW_API_KEY=mrw_live_xxx ${MCP_SETUP_COMMAND}` },
1822
- { display_name: 'CI scripts and deploy runners', client_label: 'ci', category: 'ci_runner', support_level: 'governed_runner', install_command: 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install run --profile production --policy enforce -- <ci-or-deploy-command>' },
3674
+ { display_name: 'MCP-compatible clients', client_label: 'mcp', category: 'mcp_client', support_level: 'native_mcp_or_sdk', install_command: `${MCP_SETUP_COMMAND}` },
3675
+ { display_name: 'CI scripts and deploy runners', client_label: 'ci', category: 'ci_runner', support_level: 'governed_runner', install_command: 'npx @getmarrow/install run --profile production --policy enforce -- <ci-or-deploy-command>' },
1823
3676
  { display_name: 'Custom shell/API harness', client_label: 'custom', category: 'custom_runner', support_level: 'event_contract', install_command: 'POST /v1/agent/integrations/events with harness, event_type, agent_id, and action' },
1824
3677
  ];
1825
3678
  return harnesses.map((harness) => {
1826
3679
  const capability = HARNESS_CAPABILITY_REGISTRY.find((entry) => entry.client === harness.client_label)
1827
3680
  || HARNESS_CAPABILITY_REGISTRY.find((entry) => entry.client === 'custom');
1828
- return { ...harness, capability_level: capability.capability_level };
3681
+ const fallback = isRunnableCommand(harness.install_command)
3682
+ ? { install_command_env_fallback: `${KEY_ENV_FALLBACK_PREFIX}${harness.install_command}` }
3683
+ : {};
3684
+ return { ...harness, ...fallback, key_setup: KEY_FILE_SETUP, capability_level: capability.capability_level };
1829
3685
  });
1830
3686
  }
1831
3687
 
@@ -1932,8 +3788,8 @@ function localIntegrationManifest(name) {
1932
3788
  title: 'Marrow + Hermes Agent',
1933
3789
  client_label: 'hermes',
1934
3790
  command: 'hermes',
1935
- install_command: 'MARROW_API_KEY=mrw_live_xxx npx -y @getmarrow/install@latest update',
1936
- governed_command: 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install run --profile production --policy enforce -- hermes',
3791
+ install_command: 'npx -y @getmarrow/install@latest update',
3792
+ governed_command: 'npx @getmarrow/install run --profile production --policy enforce -- hermes',
1937
3793
  agent_identity: 'Leave MARROW_AGENT_ID unset: Marrow uses the API key\'s bound agent or the Free plan seat. On paid plans, register the agent with POST /v1/agents or bind the key to exactly one agent before setting MARROW_AGENT_ID.',
1938
3794
  capture_points: [
1939
3795
  '/goal -> Marrow completion contract',
@@ -1951,8 +3807,8 @@ function localIntegrationManifest(name) {
1951
3807
  title: 'Marrow + OpenClaw',
1952
3808
  client_label: 'openclaw',
1953
3809
  command: 'openclaw agent',
1954
- install_command: 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install openclaw',
1955
- governed_command: 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install run --profile production --policy enforce -- openclaw agent',
3810
+ install_command: 'npx @getmarrow/install openclaw',
3811
+ governed_command: 'npx @getmarrow/install run --profile production --policy enforce -- openclaw agent',
1956
3812
  capture_points: [
1957
3813
  'agent sessions -> workflow sessions',
1958
3814
  'handoff/result files -> proof packs',
@@ -1990,6 +3846,9 @@ function renderIntegrationPanel(name, manifest, source = 'local', detectedHarnes
1990
3846
  const installCommands = Array.isArray(manifest.install_commands)
1991
3847
  ? manifest.install_commands
1992
3848
  : [manifest.install_command, manifest.governed_command].filter(Boolean);
3849
+ const keyLines = installCommands.some((command) => isRunnableCommand(command))
3850
+ ? ['', `Key: ${KEY_FILE_SETUP}`, `Fallback: prefix a command above with ${KEY_ENV_FALLBACK_PREFIX.trim()}`]
3851
+ : [];
1993
3852
  const detected = detectedHarnesses.find((harness) => harness.name.toLowerCase().includes(name === 'openclaw' ? 'openclaw' : 'hermes'))?.detected;
1994
3853
  const lines = [
1995
3854
  title,
@@ -2003,6 +3862,7 @@ function renderIntegrationPanel(name, manifest, source = 'local', detectedHarnes
2003
3862
  '',
2004
3863
  'Install / run:',
2005
3864
  ...installCommands.map((command) => ` ${displayText(command, 140)}`),
3865
+ ...keyLines,
2006
3866
  '',
2007
3867
  'Why this matters:',
2008
3868
  ` ${displayText(manifest.marrow_positioning || 'Keep the harness. Add Marrow governance, proof packs, outcomes, and buyer-grade fleet value.', 140)}`,
@@ -2140,11 +4000,11 @@ function commandForSelection(state, options) {
2140
4000
  const harness = state.harnesses[state.harnessIndex] || state.harnesses[0];
2141
4001
  const mode = state.modes[state.modeIndex] || state.modes[0];
2142
4002
  if (mode.id === 'passive') {
2143
- return 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install --yes';
4003
+ return 'npx -y @getmarrow/install@latest activate';
2144
4004
  }
2145
4005
  const command = harness.command === '<your-agent-command>' ? '<your-command>' : harness.command;
2146
4006
  const renderedCommand = command.split(/\s+/).filter(Boolean).map(shellQuote).join(' ');
2147
- return `MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install run ${options.agentId ? `--agent ${shellQuoteDisplay(options.agentId)} ` : ''}--profile ${shellQuoteDisplay(options.profile)} --policy ${shellQuoteDisplay(mode.policy)} -- ${renderedCommand}`;
4007
+ return `npx @getmarrow/install run ${options.agentId ? `--agent ${shellQuoteDisplay(options.agentId)} ` : ''}--profile ${shellQuoteDisplay(options.profile)} --policy ${shellQuoteDisplay(mode.policy)} -- ${renderedCommand}`;
2148
4008
  }
2149
4009
 
2150
4010
  function buildGovernState(options, cwd = process.cwd()) {
@@ -2249,6 +4109,9 @@ function renderGovernTui(state, options) {
2249
4109
  });
2250
4110
  lines.push('Recommended command:');
2251
4111
  lines.push(` ${commandForSelection(state, options)}`);
4112
+ if (state.modes[state.modeIndex]?.id !== 'passive') {
4113
+ lines.push(` Key: ${KEY_FILE_SETUP} Fallback: prefix with ${KEY_ENV_FALLBACK_PREFIX.trim()}`);
4114
+ }
2252
4115
  if (state.status) {
2253
4116
  lines.push('');
2254
4117
  lines.push(`Status: ${displayText(state.status, 120)}`);
@@ -2406,7 +4269,7 @@ function waitForAnyKey(input = process.stdin) {
2406
4269
 
2407
4270
  async function runSetupSelfTest(options, input, output) {
2408
4271
  if (!options.apiKey) {
2409
- return 'MARROW_API_KEY is missing. Create a key in your Marrow account, export it, then rerun setup.';
4272
+ return 'MARROW_API_KEY is missing. Create a key at https://getmarrow.ai/signup, save it in ~/.marrow/env, then run chmod 700 ~/.marrow && chmod 600 ~/.marrow/env, then rerun setup.';
2410
4273
  }
2411
4274
  const binPath = path.resolve(__dirname, '..', 'bin', 'marrow-install.js');
2412
4275
  output.write('\x1b[2J\x1b[HRunning Marrow passive setup and self-test...\n\n');
@@ -2440,7 +4303,7 @@ async function runStatusCheck(options) {
2440
4303
  async function runGateCheck(options) {
2441
4304
  if (!options.apiKey) return 'MARROW_API_KEY is missing. Gate check skipped.';
2442
4305
  const runtime = await preflightRuntime(options, 'deploy production worker after tests pass', 'deploy', 'wrangler deploy');
2443
- const decision = gateDecision(runtime);
4306
+ const decision = runnerGateDecision(runtime);
2444
4307
  const proof = decision.proofPack?.required
2445
4308
  ? ` Proof required${decision.proofPack.missing?.length ? `; missing ${decision.proofPack.missing.join(', ')}` : ''}.`
2446
4309
  : '';
@@ -2645,7 +4508,7 @@ async function runFleetInteractive(options, input = process.stdin, output = proc
2645
4508
  } else if (state.cursor === 2) {
2646
4509
  const receipts = state.snapshot.arbitrations.receipts;
2647
4510
  state.lastResult = receipts.length
2648
- ? receipts.map((receipt, index) => `${index + 1}. ${receipt.id}: ${receipt.resolution}${receipt.decision_id ? `; decision=${receipt.decision_id}` : ''}${receipt.selected_proposal_id ? `; selected=${receipt.selected_proposal_id}` : ''}; ${receipt.exact_next_action || receipt.conflict_type}${receipt.owner_approval_required ? ' Owner approval must be issued from an authenticated Marrow dashboard session.' : ''}`).join(' ')
4511
+ ? receipts.map((receipt, index) => `${index + 1}. ${receipt.id}: ${receipt.resolution}${receipt.decision_id ? `; decision=${receipt.decision_id}` : ''}${receipt.selected_proposal_id ? `; selected=${receipt.selected_proposal_id}` : ''}; ${receipt.exact_next_action || receipt.conflict_type}${receipt.owner_approval_required ? ' Needs the account owner\'s approval; an agent cannot approve it.' : ''}`).join(' ')
2649
4512
  : 'No agent disagreements or arbitration receipts returned yet.';
2650
4513
  } else if (state.cursor === 6) {
2651
4514
  state.lastResult = state.snapshot.recent_decisions.length
@@ -2684,6 +4547,9 @@ async function runCli(argv) {
2684
4547
  if (parsed.options?.keyFromArg) {
2685
4548
  process.stderr.write('Warning: prefer MARROW_API_KEY instead of --key because command-line args can be visible in process listings.\n');
2686
4549
  }
4550
+ if (parsed.options?.ownerApprovedFlagIgnored) {
4551
+ process.stderr.write(`Note: --owner-approved no longer does anything. ${OWNER_APPROVAL_NOTICE}\n`);
4552
+ }
2687
4553
 
2688
4554
  let result;
2689
4555
  if (parsed.command === 'run') result = await runGoverned(parsed);
@@ -2740,6 +4606,19 @@ module.exports = {
2740
4606
  recordGovernanceModeSelection,
2741
4607
  gateDecision,
2742
4608
  shouldBlock,
4609
+ heldApproval,
4610
+ holdNextText,
4611
+ resolveHold,
4612
+ resumeRecordedHold,
4613
+ holdRecordStore,
4614
+ runnerHoldKey,
4615
+ governedInputs,
4616
+ withRedactedInputs,
4617
+ defaultRunnerSession,
4618
+ agentHostDetected,
4619
+ agentHostAncestor,
4620
+ approvalPromptAvailable,
4621
+ withoutCallerApproval,
2743
4622
  governPanel,
2744
4623
  renderGovernTui,
2745
4624
  canUseInteractive,