@getmarrow/install 0.1.66 → 0.1.68
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +174 -286
- package/package.json +1 -1
- package/src/controller-manager.js +3 -1
- package/src/first-hour.js +1 -1
- package/src/governed-runner.js +1978 -114
- package/src/installer.js +821 -72
- package/src/mcp-runtime.js +447 -0
- package/src/pins.js +19 -0
package/src/governed-runner.js
CHANGED
|
@@ -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.
|
|
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>
|
|
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
|
|
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
|
-
|
|
155
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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')
|
|
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)
|
|
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(
|
|
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
|
-
...(
|
|
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
|
-
...(
|
|
687
|
-
...proof,
|
|
1433
|
+
...withoutCallerApproval(proof),
|
|
688
1434
|
};
|
|
689
1435
|
}
|
|
690
1436
|
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
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 = {
|
|
@@ -829,27 +1585,1038 @@ function gateDecision(runtime) {
|
|
|
829
1585
|
};
|
|
830
1586
|
}
|
|
831
1587
|
|
|
832
|
-
function shouldBlock(decision,
|
|
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
|
-
|
|
837
|
-
//
|
|
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
|
|
1602
|
+
return Boolean(approvalRequired);
|
|
846
1603
|
}
|
|
847
1604
|
|
|
848
|
-
|
|
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
|
-
|
|
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 {
|
|
984
|
-
|
|
985
|
-
const
|
|
986
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
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
|
-
|
|
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 (
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
blocked:
|
|
1029
|
-
|
|
1030
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1042
|
-
|
|
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
|
|
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 (!
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
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
|
-
|
|
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:
|
|
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 =
|
|
1176
|
-
const
|
|
1177
|
-
const
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
-
:
|
|
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
|
}
|
|
@@ -2440,7 +4288,7 @@ async function runStatusCheck(options) {
|
|
|
2440
4288
|
async function runGateCheck(options) {
|
|
2441
4289
|
if (!options.apiKey) return 'MARROW_API_KEY is missing. Gate check skipped.';
|
|
2442
4290
|
const runtime = await preflightRuntime(options, 'deploy production worker after tests pass', 'deploy', 'wrangler deploy');
|
|
2443
|
-
const decision =
|
|
4291
|
+
const decision = runnerGateDecision(runtime);
|
|
2444
4292
|
const proof = decision.proofPack?.required
|
|
2445
4293
|
? ` Proof required${decision.proofPack.missing?.length ? `; missing ${decision.proofPack.missing.join(', ')}` : ''}.`
|
|
2446
4294
|
: '';
|
|
@@ -2645,7 +4493,7 @@ async function runFleetInteractive(options, input = process.stdin, output = proc
|
|
|
2645
4493
|
} else if (state.cursor === 2) {
|
|
2646
4494
|
const receipts = state.snapshot.arbitrations.receipts;
|
|
2647
4495
|
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 ? '
|
|
4496
|
+
? 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
4497
|
: 'No agent disagreements or arbitration receipts returned yet.';
|
|
2650
4498
|
} else if (state.cursor === 6) {
|
|
2651
4499
|
state.lastResult = state.snapshot.recent_decisions.length
|
|
@@ -2684,6 +4532,9 @@ async function runCli(argv) {
|
|
|
2684
4532
|
if (parsed.options?.keyFromArg) {
|
|
2685
4533
|
process.stderr.write('Warning: prefer MARROW_API_KEY instead of --key because command-line args can be visible in process listings.\n');
|
|
2686
4534
|
}
|
|
4535
|
+
if (parsed.options?.ownerApprovedFlagIgnored) {
|
|
4536
|
+
process.stderr.write(`Note: --owner-approved no longer does anything. ${OWNER_APPROVAL_NOTICE}\n`);
|
|
4537
|
+
}
|
|
2687
4538
|
|
|
2688
4539
|
let result;
|
|
2689
4540
|
if (parsed.command === 'run') result = await runGoverned(parsed);
|
|
@@ -2740,6 +4591,19 @@ module.exports = {
|
|
|
2740
4591
|
recordGovernanceModeSelection,
|
|
2741
4592
|
gateDecision,
|
|
2742
4593
|
shouldBlock,
|
|
4594
|
+
heldApproval,
|
|
4595
|
+
holdNextText,
|
|
4596
|
+
resolveHold,
|
|
4597
|
+
resumeRecordedHold,
|
|
4598
|
+
holdRecordStore,
|
|
4599
|
+
runnerHoldKey,
|
|
4600
|
+
governedInputs,
|
|
4601
|
+
withRedactedInputs,
|
|
4602
|
+
defaultRunnerSession,
|
|
4603
|
+
agentHostDetected,
|
|
4604
|
+
agentHostAncestor,
|
|
4605
|
+
approvalPromptAvailable,
|
|
4606
|
+
withoutCallerApproval,
|
|
2743
4607
|
governPanel,
|
|
2744
4608
|
renderGovernTui,
|
|
2745
4609
|
canUseInteractive,
|