@getmarrow/install 0.1.11 → 0.1.13

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 CHANGED
@@ -11,6 +11,72 @@ npx @getmarrow/install --repair
11
11
  npx @getmarrow/install doctor
12
12
  ```
13
13
 
14
+ ## What's New in v0.1.13
15
+
16
+ v0.1.13 turns `npx @getmarrow/install govern` into an interactive terminal setup flow when run in a real TTY.
17
+
18
+ - Select Codex, Claude Code, Cursor, OpenCode, OpenClaw, CI scripts, or a custom command with arrow keys.
19
+ - Choose passive setup, governed pilot mode, or governed enforce mode.
20
+ - Run passive setup + self-test from the TUI after explicit confirmation.
21
+ - Check Marrow status and test the before-action gate from the same screen.
22
+ - Print the exact command for the selected harness/mode so users know what to run next.
23
+ - Exit cleanly with `q`, `Esc`, or `Ctrl+C`.
24
+ - CI/non-TTY usage remains stable with `npx @getmarrow/install govern --no-interactive`.
25
+
26
+ This keeps Marrow passive-first: install once, verify Marrow is active, then let agents use the runtime/gate path automatically for risky work.
27
+
28
+ ## What's New in v0.1.12
29
+
30
+ v0.1.12 adds the Marrow governed runner for businesses that want agent governance without replacing their existing harness.
31
+
32
+ - `npx @getmarrow/install govern` prints a setup panel for detected harnesses and recommended protected commands.
33
+ - `npx @getmarrow/install run --agent <agent-id> -- <command>` wraps existing agent, deploy, merge, publish, migration, and verification commands with Marrow's pre-action runtime gate.
34
+ - Risky actions can fail closed by default when Marrow requires owner approval, blocks an action, or requires missing proof.
35
+ - Successful and failed commands automatically close outcomes through `/v1/agent/commit` with a redacted proof pack.
36
+ - The runner sends action and command metadata only; it does not upload command stdout, stderr, full environment values, or plaintext API keys.
37
+ - This gives teams a thin governance path for Codex, Claude Code, OpenClaw, OpenCode, Cursor, CI scripts, and custom shell-based agents.
38
+
39
+ Business value: Marrow can sit in front of the commands that matter most, tell the agent what prior lesson or proof is required before action, and produce an audit-ready outcome trail after the command finishes.
40
+
41
+ ### Governed Runner Quickstart
42
+
43
+ Preview the detected harnesses and protected command examples:
44
+
45
+ ```bash
46
+ npx @getmarrow/install govern
47
+ ```
48
+
49
+ In a real terminal, this opens the interactive setup flow. In CI or scripts, use:
50
+
51
+ ```bash
52
+ npx @getmarrow/install govern --no-interactive
53
+ ```
54
+
55
+ Run a harmless command through Marrow:
56
+
57
+ ```bash
58
+ MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install run --agent codex-prod --profile production -- node -e "process.exit(0)"
59
+ ```
60
+
61
+ Gate a production action before the agent executes it:
62
+
63
+ ```bash
64
+ MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install gate "deploy production worker after tests pass"
65
+ ```
66
+
67
+ Wrap a real deploy, publish, merge, or migration command only after the agent has the required proof:
68
+
69
+ ```bash
70
+ MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install run \
71
+ --agent deploy-agent \
72
+ --type deploy \
73
+ --profile production \
74
+ --policy enforce \
75
+ -- wrangler deploy
76
+ ```
77
+
78
+ Use `--policy warn` for pilot mode and `--fail-open` only for non-production local workflows where Marrow should never block execution.
79
+
14
80
  ## What's New in v0.1.10
15
81
 
16
82
  - First-run output now explains the value in agent/user language: your agent is no longer starting from zero.
@@ -1,8 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- const { runCli } = require('../src/installer');
3
+ const installer = require('../src/installer');
4
+ const governedRunner = require('../src/governed-runner');
4
5
 
5
- runCli(process.argv.slice(2)).catch((error) => {
6
+ const argv = process.argv.slice(2);
7
+ const governedCommands = new Set(['run', 'gate', 'proof', 'status', 'govern']);
8
+ const runCli = governedCommands.has(argv[0]) ? governedRunner.runCli : installer.runCli;
9
+
10
+ runCli(argv).catch((error) => {
6
11
  const message = error instanceof Error ? error.message : String(error);
7
12
  process.stderr.write(`marrow-install failed: ${message}\n`);
8
13
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getmarrow/install",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Universal installer for Marrow passive agent setup.",
5
5
  "bin": {
6
6
  "marrow-install": "bin/marrow-install.js"
@@ -15,6 +15,8 @@
15
15
  "marrow",
16
16
  "mcp",
17
17
  "installer",
18
+ "governance",
19
+ "runner",
18
20
  "passive-runtime"
19
21
  ],
20
22
  "repository": {
@@ -0,0 +1,863 @@
1
+ const { spawn } = require('node:child_process');
2
+ const crypto = require('node:crypto');
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+ const readline = require('node:readline');
7
+
8
+ const DEFAULT_BASE_URL = 'https://api.getmarrow.ai';
9
+ const HIGH_RISK_TERMS = /\b(deploy|prod|production|publish|release|merge|migration|migrate|secret|token|key|cloudflare|wrangler|npm publish|gh pr merge|git push|terraform apply|kubectl apply|delete|destroy|drop)\b/i;
10
+ const GOVERN_TUI_ROW_COUNT = 7;
11
+ function usage() {
12
+ return `Usage:
13
+ npx @getmarrow/install run --agent deploy-agent -- npm test
14
+ npx @getmarrow/install run --agent deploy-agent --type deploy --policy enforce -- wrangler deploy
15
+ npx @getmarrow/install gate "deploy production worker"
16
+ npx @getmarrow/install proof --decision-id <id> --success --summary "smoke passed"
17
+ npx @getmarrow/install status
18
+ npx @getmarrow/install govern
19
+ npx @getmarrow/install govern --no-interactive
20
+
21
+ Commands:
22
+ run Run a command through Marrow pre-action gate and automatic outcome closure
23
+ gate Check Marrow runtime/gate for an action without running a command
24
+ proof Commit an outcome/proof for an existing decision
25
+ status Read /v1/agent/status
26
+ govern Interactive setup TUI when run in a terminal; text panel in CI/non-TTY
27
+
28
+ Options:
29
+ --agent <id> Agent identity. Defaults to MARROW_FLEET_AGENT_ID, MARROW_AGENT_ID, or local user
30
+ --session <id> Session id. Defaults to marrow-run-<timestamp>
31
+ --type <type> Action type. Inferred from action/command when omitted
32
+ --action <text> Human-readable action. Defaults to the redacted command
33
+ --profile <name> Policy profile label, such as dev, staging, or production
34
+ --policy <mode> enforce, warn, or audit. Default: enforce
35
+ --fail-open If Marrow is unreachable, run anyway and mark telemetry degraded
36
+ --fail-closed If Marrow is unreachable, block the command
37
+ --owner-approved <ref> Owner approval reference for review-required gates
38
+ --proof-file <path> JSON proof to include on outcome commit
39
+ --base-url <url> Marrow API base URL
40
+ --key <key> Marrow API key. Prefer MARROW_API_KEY
41
+ --json Print machine-readable result after completion
42
+ --interactive Force interactive govern TUI when possible
43
+ --no-interactive Print govern panel instead of opening the TUI
44
+ `;
45
+ }
46
+
47
+ function nowId(prefix) {
48
+ return `${prefix}-${new Date().toISOString().replace(/[^0-9TZ]/g, '')}-${crypto.randomBytes(4).toString('hex')}`;
49
+ }
50
+
51
+ function redact(value) {
52
+ let text = String(value || '');
53
+ text = text.replace(/\b[A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD)[A-Z0-9_]*=([^\s]+)/gi, (match, captured) => match.replace(captured, '[redacted]'));
54
+ text = text.replace(/\bmrw_(?:live|test)_[A-Za-z0-9._-]+/g, '[redacted]');
55
+ text = text.replace(/\bnpm_[A-Za-z0-9._-]+/g, '[redacted]');
56
+ text = text.replace(/\bgh(?:p|o|u|s|r)_[A-Za-z0-9._-]+/g, '[redacted]');
57
+ text = text.replace(/\bsk-[A-Za-z0-9._-]+/g, '[redacted]');
58
+ return text;
59
+ }
60
+
61
+ function shellQuote(value) {
62
+ const text = String(value || '');
63
+ if (!text) return "''";
64
+ return /^[A-Za-z0-9_./:@%+=,-]+$/.test(text) ? text : `'${text.replace(/'/g, "'\\''")}'`;
65
+ }
66
+
67
+ function redactedCommand(command) {
68
+ return command.map((part) => shellQuote(redact(part))).join(' ');
69
+ }
70
+
71
+ function displayText(value, maxLength = 120) {
72
+ const text = redact(String(value || ''))
73
+ .replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, '')
74
+ .replace(/\u009d[^\u0007\u009c]*(?:\u0007|\u009c|\u001b\\)/g, '')
75
+ .replace(/[\u001b\u009b][[\]()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '')
76
+ .replace(/[\t\r\n]+/g, ' ')
77
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, '');
78
+ return text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text;
79
+ }
80
+
81
+ function shellQuoteDisplay(value) {
82
+ return shellQuote(displayText(value, 240));
83
+ }
84
+
85
+ function inferType(text) {
86
+ const value = String(text || '').toLowerCase();
87
+ if (/\b(deploy|wrangler|cloudflare|production|prod|release)\b/.test(value)) return 'deploy';
88
+ if (/\b(publish|npm publish)\b/.test(value)) return 'publish';
89
+ if (/\b(merge|gh pr merge)\b/.test(value)) return 'merge';
90
+ if (/\b(migration|migrate|schema|d1 execute|drop table)\b/.test(value)) return 'migration';
91
+ if (/\b(secret|token|key|password)\b/.test(value)) return 'security';
92
+ if (/\b(test|check|lint|typecheck|smoke)\b/.test(value)) return 'verification';
93
+ return 'general';
94
+ }
95
+
96
+ function inferSurfaces(text) {
97
+ const value = String(text || '').toLowerCase();
98
+ const surfaces = new Set();
99
+ if (/\b(git|gh|github)\b/.test(value)) surfaces.add('github');
100
+ if (/\b(wrangler|cloudflare|worker|d1|r2)\b/.test(value)) surfaces.add('cloudflare');
101
+ if (/\b(npm|pnpm|yarn|publish)\b/.test(value)) surfaces.add('npm');
102
+ if (/\b(sql|d1|migration|database|db)\b/.test(value)) surfaces.add('database');
103
+ if (/\b(curl|api|http)\b/.test(value)) surfaces.add('api');
104
+ if (surfaces.size === 0) surfaces.add('shell');
105
+ return [...surfaces];
106
+ }
107
+
108
+ function isRisky(text, type) {
109
+ return HIGH_RISK_TERMS.test(`${type || ''} ${text || ''}`);
110
+ }
111
+
112
+ function parseBaseOptions(argv, startIndex = 0) {
113
+ const options = {
114
+ apiKey: process.env.MARROW_API_KEY || process.env.MARROW_KEY || '',
115
+ baseUrl: process.env.MARROW_BASE_URL || DEFAULT_BASE_URL,
116
+ agentId: process.env.MARROW_FLEET_AGENT_ID || process.env.MARROW_AGENT_ID || os.userInfo().username || 'agent',
117
+ sessionId: process.env.MARROW_SESSION_ID || '',
118
+ profile: process.env.MARROW_GOVERN_PROFILE || 'default',
119
+ policy: process.env.MARROW_GOVERN_POLICY || 'enforce',
120
+ failOpen: process.env.MARROW_FAIL_OPEN === 'true',
121
+ failClosed: process.env.MARROW_FAIL_CLOSED === 'true',
122
+ json: false,
123
+ ownerApproval: '',
124
+ proofFile: '',
125
+ type: '',
126
+ action: '',
127
+ interactive: null,
128
+ };
129
+ let i = startIndex;
130
+ for (; i < argv.length; i += 1) {
131
+ const arg = argv[i];
132
+ if (arg === '--') break;
133
+ if (arg === '--agent' || arg === '--agent-id') options.agentId = argv[++i] || options.agentId;
134
+ else if (arg === '--session' || arg === '--session-id') options.sessionId = argv[++i] || options.sessionId;
135
+ else if (arg === '--type') options.type = argv[++i] || options.type;
136
+ else if (arg === '--action') options.action = argv[++i] || options.action;
137
+ else if (arg === '--profile') options.profile = argv[++i] || options.profile;
138
+ else if (arg === '--policy') options.policy = argv[++i] || options.policy;
139
+ else if (arg === '--fail-open') {
140
+ options.failOpen = true;
141
+ options.failClosed = false;
142
+ } else if (arg === '--fail-closed') {
143
+ options.failClosed = true;
144
+ options.failOpen = false;
145
+ } else if (arg === '--owner-approved') options.ownerApproval = argv[++i] || options.ownerApproval;
146
+ else if (arg === '--proof-file') options.proofFile = argv[++i] || options.proofFile;
147
+ else if (arg === '--base-url') options.baseUrl = argv[++i] || options.baseUrl;
148
+ else if (arg === '--key') {
149
+ options.apiKey = argv[++i] || options.apiKey;
150
+ options.keyFromArg = true;
151
+ } else if (arg === '--json') options.json = true;
152
+ else if (arg === '--interactive') options.interactive = true;
153
+ else if (arg === '--no-interactive') options.interactive = false;
154
+ else if (arg === '--help' || arg === '-h') options.help = true;
155
+ else if (arg.startsWith('--')) throw new Error(`Unknown option: ${arg}`);
156
+ else break;
157
+ }
158
+ if (!['enforce', 'warn', 'audit'].includes(options.policy)) {
159
+ throw new Error('--policy must be enforce, warn, or audit');
160
+ }
161
+ if (!options.sessionId) options.sessionId = nowId('marrow-run');
162
+ return { options, index: i };
163
+ }
164
+
165
+ function parseArgs(argv) {
166
+ const command = argv[0] || 'help';
167
+ if (command === '--help' || command === '-h' || command === 'help') return { command: 'help' };
168
+
169
+ if (command === 'run') {
170
+ const parsed = parseBaseOptions(argv, 1);
171
+ const separator = argv[parsed.index] === '--' ? parsed.index + 1 : parsed.index;
172
+ const childCommand = argv.slice(separator);
173
+ if (parsed.options.help) return { command: 'help' };
174
+ if (childCommand.length === 0) throw new Error('marrow run requires a command after --');
175
+ return { command, options: parsed.options, childCommand };
176
+ }
177
+
178
+ if (command === 'gate') {
179
+ const parsed = parseBaseOptions(argv, 1);
180
+ const action = parsed.options.action || argv.slice(parsed.index).join(' ');
181
+ if (parsed.options.help) return { command: 'help' };
182
+ if (!action) throw new Error('marrow gate requires an action string');
183
+ return { command, options: { ...parsed.options, action } };
184
+ }
185
+
186
+ if (command === 'proof') {
187
+ const parsed = parseBaseOptions(argv, 1);
188
+ const options = { ...parsed.options, decisionId: '', success: true, summary: '', outcome: '' };
189
+ for (let i = parsed.index; i < argv.length; i += 1) {
190
+ const arg = argv[i];
191
+ if (arg === '--decision-id') options.decisionId = argv[++i] || '';
192
+ else if (arg === '--success') options.success = true;
193
+ else if (arg === '--failure' || arg === '--failed') options.success = false;
194
+ else if (arg === '--summary') options.summary = argv[++i] || '';
195
+ else if (arg === '--outcome') options.outcome = argv[++i] || '';
196
+ else if (arg === '--help' || arg === '-h') return { command: 'help' };
197
+ else throw new Error(`Unknown proof option: ${arg}`);
198
+ }
199
+ if (!options.decisionId) throw new Error('marrow proof requires --decision-id');
200
+ return { command, options };
201
+ }
202
+
203
+ if (command === 'status' || command === 'govern') {
204
+ const parsed = parseBaseOptions(argv, 1);
205
+ if (parsed.options.help) return { command: 'help' };
206
+ return { command, options: parsed.options };
207
+ }
208
+
209
+ throw new Error(`Unknown command: ${command}`);
210
+ }
211
+
212
+ function headers(options) {
213
+ const h = {
214
+ Authorization: `Bearer ${options.apiKey}`,
215
+ 'Content-Type': 'application/json',
216
+ 'X-Marrow-Agent-Id': options.agentId,
217
+ 'X-Marrow-Session-Id': options.sessionId,
218
+ 'User-Agent': '@getmarrow/install governed-runner',
219
+ };
220
+ return h;
221
+ }
222
+
223
+ function dataOf(json) {
224
+ return json && typeof json === 'object' && json.data && typeof json.data === 'object' ? json.data : json;
225
+ }
226
+
227
+ async function requestJson(options, method, route, body) {
228
+ if (!options.apiKey) throw new Error('MARROW_API_KEY is required. Use --fail-open only for non-production local commands.');
229
+ const response = await fetch(new URL(route, options.baseUrl.replace(/\/$/, '/')), {
230
+ method,
231
+ headers: headers(options),
232
+ body: body === undefined ? undefined : JSON.stringify(body),
233
+ });
234
+ const text = await response.text();
235
+ let json = {};
236
+ try { json = text ? JSON.parse(text) : {}; } catch { json = { error: text.slice(0, 500) }; }
237
+ if (!response.ok) {
238
+ const error = new Error(json.error || json.message || `Marrow ${route} returned HTTP ${response.status}`);
239
+ error.status = response.status;
240
+ error.details = json.details || json;
241
+ throw error;
242
+ }
243
+ return dataOf(json);
244
+ }
245
+
246
+ function proofFromFile(filePath) {
247
+ if (!filePath) return null;
248
+ const raw = fs.readFileSync(path.resolve(filePath), 'utf8');
249
+ return JSON.parse(raw);
250
+ }
251
+
252
+ function defaultProof(input) {
253
+ const proof = proofFromFile(input.options.proofFile) || {};
254
+ return {
255
+ summary: proof.summary || `Marrow governed runner completed ${input.action}.`,
256
+ checks: Array.isArray(proof.checks) ? proof.checks : ['marrow runtime gate', 'command exit captured'],
257
+ outcome: proof.outcome || (input.success ? 'success' : 'failure'),
258
+ blockers: Array.isArray(proof.blockers) ? proof.blockers : [],
259
+ command: redactedCommand(input.childCommand || []),
260
+ exit_code: input.exitCode,
261
+ runner: '@getmarrow/install run',
262
+ profile: input.options.profile,
263
+ ...(input.options.ownerApproval ? { owner_approval: { approved_by: 'owner', reference: input.options.ownerApproval } } : {}),
264
+ ...proof,
265
+ };
266
+ }
267
+
268
+ async function preflightRuntime(options, action, type, commandText) {
269
+ return requestJson(options, 'POST', '/v1/agent/runtime', {
270
+ action,
271
+ type,
272
+ surfaces: inferSurfaces(commandText || action),
273
+ context: {
274
+ runner: '@getmarrow/install run',
275
+ profile: options.profile,
276
+ command: commandText,
277
+ policy: options.policy,
278
+ governed: true,
279
+ },
280
+ });
281
+ }
282
+
283
+ function gateDecision(runtime) {
284
+ const gate = runtime?.risk_gate || {};
285
+ const receipt = runtime?.gate_receipt || {};
286
+ return {
287
+ decision: gate.enforcement_decision || receipt.decision || gate.decision || 'unknown',
288
+ allow: gate.allow !== false,
289
+ required: Boolean(receipt.required || gate.gate_required),
290
+ ownerApprovalRequired: Boolean(receipt.owner_approval_required || gate.owner_approval_required),
291
+ receiptId: receipt.id || gate.gate_receipt_id || '',
292
+ exactNextAction: runtime?.exact_next_action || receipt.exact_fix || gate.policy?.exact_fix || '',
293
+ beforeYouAct: runtime?.before_you_act_injection?.message || runtime?.before_you_act || '',
294
+ proofPack: runtime?.proof_pack || null,
295
+ };
296
+ }
297
+
298
+ function shouldBlock(decision, options) {
299
+ if (options.policy === 'audit') return false;
300
+ if (decision.decision === 'block' || decision.allow === false) return true;
301
+ if (options.policy === 'warn') return false;
302
+ if (decision.ownerApprovalRequired && !options.ownerApproval) return true;
303
+ if (decision.decision === 'owner_approval_required' && !options.ownerApproval) return true;
304
+ return false;
305
+ }
306
+
307
+ function printGate(decision, runtime, stream = process.stdout) {
308
+ stream.write(`Marrow gate: ${decision.decision}${decision.required ? ' (required)' : ''}\n`);
309
+ if (decision.beforeYouAct) stream.write(`Before you act: ${decision.beforeYouAct}\n`);
310
+ if (decision.exactNextAction) stream.write(`Next: ${decision.exactNextAction}\n`);
311
+ if (decision.proofPack?.required) {
312
+ const missing = decision.proofPack.missing?.length ? ` missing: ${decision.proofPack.missing.join(', ')}` : '';
313
+ stream.write(`Proof pack: required${missing}\n`);
314
+ }
315
+ if (runtime?.value_proof?.owner_summary) stream.write(`Value: ${runtime.value_proof.owner_summary}\n`);
316
+ }
317
+
318
+ function runChild(command, env = process.env) {
319
+ return new Promise((resolve) => {
320
+ const child = spawn(command[0], command.slice(1), {
321
+ stdio: 'inherit',
322
+ shell: false,
323
+ env,
324
+ });
325
+ child.on('error', (error) => resolve({ exitCode: 127, error }));
326
+ child.on('close', (code, signal) => resolve({ exitCode: code ?? 1, signal: signal || null }));
327
+ });
328
+ }
329
+
330
+ async function createDecision(options, action, type) {
331
+ return requestJson(options, 'POST', '/v1/agent/think', {
332
+ action,
333
+ type,
334
+ context: {
335
+ runner: '@getmarrow/install run',
336
+ profile: options.profile,
337
+ governed: true,
338
+ },
339
+ });
340
+ }
341
+
342
+ async function commitOutcome(options, decisionId, success, outcome, proof, gateReceiptId) {
343
+ const body = {
344
+ decision_id: decisionId,
345
+ success,
346
+ outcome,
347
+ proof,
348
+ };
349
+ if (gateReceiptId) body.gate_receipt_id = gateReceiptId;
350
+ return requestJson(options, 'POST', '/v1/agent/commit', body);
351
+ }
352
+
353
+ async function runGoverned(parsed) {
354
+ const { options, childCommand } = parsed;
355
+ const commandText = redactedCommand(childCommand);
356
+ const action = options.action ? redact(options.action) : commandText;
357
+ const type = options.type || inferType(`${action} ${commandText}`);
358
+ const risky = isRisky(`${action} ${commandText}`, type);
359
+ let runtime = null;
360
+ let decision = null;
361
+ let decisionId = '';
362
+
363
+ try {
364
+ runtime = await preflightRuntime(options, action, type, commandText);
365
+ decision = gateDecision(runtime);
366
+ printGate(decision, runtime);
367
+ if (shouldBlock(decision, options)) {
368
+ return {
369
+ ok: false,
370
+ blocked: true,
371
+ exitCode: 12,
372
+ action,
373
+ type,
374
+ risky,
375
+ decision,
376
+ message: decision.exactNextAction || 'Marrow blocked this action before execution.',
377
+ };
378
+ }
379
+ const think = await createDecision(options, action, type);
380
+ decisionId = think.decision_id || think.id || think.decision?.id || '';
381
+ } catch (error) {
382
+ if (options.failOpen || (!risky && !options.failClosed)) {
383
+ process.stderr.write(`Marrow degraded: ${error.message}. Continuing because fail-open/non-risky policy allows it.\n`);
384
+ } else {
385
+ return {
386
+ ok: false,
387
+ blocked: true,
388
+ degraded: true,
389
+ exitCode: 13,
390
+ action,
391
+ type,
392
+ risky,
393
+ message: error.message,
394
+ };
395
+ }
396
+ }
397
+
398
+ const child = await runChild(childCommand);
399
+ const success = child.exitCode === 0;
400
+ const proof = defaultProof({ options, action, childCommand, exitCode: child.exitCode, success });
401
+ const outcome = success
402
+ ? `Marrow governed command succeeded with exit code ${child.exitCode}.`
403
+ : `Marrow governed command failed with exit code ${child.exitCode}.`;
404
+
405
+ let commit = null;
406
+ if (decisionId) {
407
+ try {
408
+ commit = await commitOutcome(options, decisionId, success, outcome, proof, decision?.receiptId || '');
409
+ } catch (error) {
410
+ process.stderr.write(`Marrow outcome commit failed: ${error.message}\n`);
411
+ }
412
+ }
413
+
414
+ return {
415
+ ok: success,
416
+ blocked: false,
417
+ exitCode: child.exitCode,
418
+ action,
419
+ type,
420
+ risky,
421
+ decision,
422
+ decision_id: decisionId,
423
+ outcome_committed: Boolean(commit),
424
+ };
425
+ }
426
+
427
+ async function gateOnly(parsed) {
428
+ const { options } = parsed;
429
+ const action = redact(options.action);
430
+ const type = options.type || inferType(action);
431
+ const runtime = await preflightRuntime(options, action, type, action);
432
+ const decision = gateDecision(runtime);
433
+ printGate(decision, runtime);
434
+ return { ok: !shouldBlock(decision, options), action, type, decision };
435
+ }
436
+
437
+ async function proofOnly(parsed) {
438
+ const { options } = parsed;
439
+ const proof = defaultProof({
440
+ options,
441
+ action: options.summary || options.outcome || 'manual proof closeout',
442
+ childCommand: [],
443
+ exitCode: options.success ? 0 : 1,
444
+ success: options.success,
445
+ });
446
+ const result = await commitOutcome(
447
+ options,
448
+ options.decisionId,
449
+ options.success,
450
+ options.outcome || options.summary || (options.success ? 'Manual proof closeout succeeded.' : 'Manual proof closeout failed.'),
451
+ proof,
452
+ '',
453
+ );
454
+ return { ok: true, decision_id: options.decisionId, committed: true, result };
455
+ }
456
+
457
+ async function statusOnly(parsed) {
458
+ return requestJson(parsed.options, 'GET', '/v1/agent/status');
459
+ }
460
+
461
+ function detectHarnesses(cwd = process.cwd()) {
462
+ const candidates = [
463
+ { name: 'Codex', command: 'codex', detected: fs.existsSync(path.join(cwd, 'AGENTS.md')) || fs.existsSync(path.join(os.homedir(), '.codex')) },
464
+ { name: 'Claude Code', command: 'claude -p', detected: fs.existsSync(path.join(cwd, 'CLAUDE.md')) || fs.existsSync(path.join(os.homedir(), '.claude.json')) },
465
+ { name: 'Cursor', command: 'cursor', detected: fs.existsSync(path.join(cwd, '.cursor')) || fs.existsSync(path.join(os.homedir(), '.cursor')) },
466
+ { name: 'OpenCode', command: 'opencode', detected: fs.existsSync(path.join(cwd, 'opencode.json')) || fs.existsSync(path.join(os.homedir(), '.opencode')) },
467
+ { name: 'OpenClaw', command: 'openclaw agent', detected: fs.existsSync(path.join(os.homedir(), '.openclaw')) },
468
+ { name: 'CI script', command: 'npm test', detected: fs.existsSync(path.join(cwd, 'package.json')) },
469
+ { name: 'Custom command', command: '<your-agent-command>', detected: true },
470
+ ];
471
+ return candidates;
472
+ }
473
+
474
+ function governPanel(options) {
475
+ const rows = detectHarnesses();
476
+ const agentId = displayText(options.agentId, 80);
477
+ const profile = displayText(options.profile, 80);
478
+ const policy = displayText(options.policy, 24);
479
+ const lines = [
480
+ 'Marrow Governed Runner',
481
+ '',
482
+ `Agent: ${agentId}`,
483
+ `Profile: ${profile}`,
484
+ `Policy: ${policy}`,
485
+ '',
486
+ 'Choose where your agent runs. Marrow governs the action before it executes.',
487
+ '',
488
+ 'Detected harnesses:',
489
+ ...rows.map((row, index) => ` ${index + 1}. ${row.detected ? '[x]' : '[ ]'} ${row.name} ${row.command}`),
490
+ '',
491
+ 'Recommended first commands:',
492
+ ` npx @getmarrow/install run --agent ${shellQuoteDisplay(options.agentId)} --profile production --policy enforce -- codex`,
493
+ ` npx @getmarrow/install run --agent deploy-agent --type deploy --policy enforce -- wrangler deploy`,
494
+ ` npx @getmarrow/install gate "deploy production worker after tests pass"`,
495
+ '',
496
+ 'Protected by default: deploy, merge, publish, migrations, secrets, keys, production actions.',
497
+ ];
498
+ return lines.join('\n');
499
+ }
500
+
501
+ function governModes() {
502
+ return [
503
+ {
504
+ id: 'passive',
505
+ label: 'Passive setup',
506
+ description: 'Install passive MCP/SDK/agent instructions, then run the installer self-test.',
507
+ policy: 'warn',
508
+ },
509
+ {
510
+ id: 'warn',
511
+ label: 'Governed pilot',
512
+ description: 'Wrap commands with Marrow, show gates, but do not block execution.',
513
+ policy: 'warn',
514
+ },
515
+ {
516
+ id: 'enforce',
517
+ label: 'Governed enforce',
518
+ description: 'Wrap risky commands and fail closed when Marrow blocks or requires owner approval.',
519
+ policy: 'enforce',
520
+ },
521
+ ];
522
+ }
523
+
524
+ function commandForSelection(state, options) {
525
+ const harness = state.harnesses[state.harnessIndex] || state.harnesses[0];
526
+ const mode = state.modes[state.modeIndex] || state.modes[0];
527
+ if (mode.id === 'passive') {
528
+ return 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install --yes';
529
+ }
530
+ const command = harness.command === '<your-agent-command>' ? '<your-command>' : harness.command;
531
+ const renderedCommand = command.split(/\s+/).filter(Boolean).map(shellQuote).join(' ');
532
+ return `MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install run --agent ${shellQuoteDisplay(options.agentId)} --profile ${shellQuoteDisplay(options.profile)} --policy ${shellQuoteDisplay(mode.policy)} -- ${renderedCommand}`;
533
+ }
534
+
535
+ function buildGovernState(options, cwd = process.cwd()) {
536
+ const harnesses = detectHarnesses(cwd);
537
+ const firstDetected = harnesses.findIndex((harness) => harness.detected);
538
+ return {
539
+ cursor: 0,
540
+ harnesses,
541
+ harnessIndex: firstDetected >= 0 ? firstDetected : 0,
542
+ modes: governModes(),
543
+ modeIndex: 0,
544
+ status: '',
545
+ lastResult: '',
546
+ confirmingSetup: false,
547
+ running: false,
548
+ };
549
+ }
550
+
551
+ function renderOptionBox(row, active) {
552
+ const contentWidth = 86;
553
+ const borderWidth = contentWidth + 2;
554
+ const marker = active ? '>' : ' ';
555
+ const borderChar = active ? '=' : '-';
556
+ const labelText = `[${displayText(row.label, 36)}]`;
557
+ const labelVisible = labelText.padEnd(38, ' ');
558
+ const label = `\x1b[47m\x1b[30m${labelText}\x1b[0m${' '.repeat(Math.max(0, 38 - labelText.length))}`;
559
+ const value = displayText(row.value, contentWidth - 39);
560
+ const firstLineVisible = `${labelVisible}${value}`;
561
+ const firstLine = `${label}${value}${' '.repeat(Math.max(0, contentWidth - firstLineVisible.length))}`;
562
+ const hint = displayText(row.hint, contentWidth);
563
+ return [
564
+ `${marker} +${borderChar.repeat(borderWidth)}+`,
565
+ `${marker} | ${firstLine} |`,
566
+ `${marker} | ${hint.padEnd(contentWidth, ' ')} |`,
567
+ `${marker} +${borderChar.repeat(borderWidth)}+`,
568
+ ];
569
+ }
570
+
571
+ function renderGovernTui(state, options) {
572
+ const harness = state.harnesses[state.harnessIndex] || state.harnesses[0];
573
+ const mode = state.modes[state.modeIndex] || state.modes[0];
574
+ const rows = [
575
+ {
576
+ label: 'Harness',
577
+ value: `${harness.name}${harness.detected ? ' detected' : ' not detected'} (${harness.command})`,
578
+ hint: 'Left/right changes the harness.',
579
+ },
580
+ {
581
+ label: 'Mode',
582
+ value: mode.label,
583
+ hint: mode.description,
584
+ },
585
+ {
586
+ label: 'Run passive setup + self-test',
587
+ value: state.confirmingSetup ? 'Press Enter again to run installer --yes' : 'writes local config after confirmation',
588
+ hint: 'Uses the existing installer path and self-test.',
589
+ },
590
+ {
591
+ label: 'Check Marrow status',
592
+ value: options.apiKey ? 'ready' : 'needs MARROW_API_KEY',
593
+ hint: 'Calls GET /v1/agent/status.',
594
+ },
595
+ {
596
+ label: 'Test before-action gate',
597
+ value: options.apiKey ? 'ready' : 'needs MARROW_API_KEY',
598
+ hint: 'Calls POST /v1/agent/runtime for a deploy-like action.',
599
+ },
600
+ {
601
+ label: 'Show command and exit',
602
+ value: 'Print selected command',
603
+ hint: 'Prints the command for this selection.',
604
+ },
605
+ {
606
+ label: 'Exit',
607
+ value: 'Return to shell',
608
+ hint: 'Press Enter, q, Esc, or Ctrl+C to leave setup.',
609
+ },
610
+ ];
611
+ const lines = [
612
+ '\x1b[2J\x1b[H',
613
+ '+------------------------------------------------------------+',
614
+ '| Marrow Governed Setup |',
615
+ '| Passive agent governance for day-one use |',
616
+ '+------------------------------------------------------------+',
617
+ '',
618
+ `Agent: ${displayText(options.agentId, 36)} Profile: ${displayText(options.profile, 24)} API key: ${options.apiKey ? 'present' : 'missing'}`,
619
+ '',
620
+ 'Navigation: Up/Down move Left/Right change Enter select',
621
+ 'Exit: q, Esc, or Ctrl+C',
622
+ '',
623
+ ];
624
+ rows.forEach((row, index) => {
625
+ lines.push(...renderOptionBox(row, index === state.cursor), '');
626
+ });
627
+ lines.push('Recommended command:');
628
+ lines.push(` ${commandForSelection(state, options)}`);
629
+ if (state.status) {
630
+ lines.push('');
631
+ lines.push(`Status: ${displayText(state.status, 120)}`);
632
+ }
633
+ if (state.lastResult) {
634
+ lines.push('');
635
+ lines.push(displayText(state.lastResult, 500));
636
+ }
637
+ return lines.join('\n');
638
+ }
639
+
640
+ function canUseInteractive(options, input = process.stdin, output = process.stdout) {
641
+ if (options.interactive === false) return false;
642
+ if (options.interactive === true) return Boolean(input.isTTY && output.isTTY);
643
+ return Boolean(input.isTTY && output.isTTY);
644
+ }
645
+
646
+ function waitForAnyKey(input = process.stdin) {
647
+ return new Promise((resolve) => {
648
+ const onKey = () => {
649
+ input.off('keypress', onKey);
650
+ resolve();
651
+ };
652
+ input.on('keypress', onKey);
653
+ });
654
+ }
655
+
656
+ async function runSetupSelfTest(options, input, output) {
657
+ if (!options.apiKey) {
658
+ return 'MARROW_API_KEY is missing. Create a key in your Marrow account, export it, then rerun setup.';
659
+ }
660
+ const binPath = path.resolve(__dirname, '..', 'bin', 'marrow-install.js');
661
+ output.write('\x1b[2J\x1b[HRunning Marrow passive setup and self-test...\n\n');
662
+ if (input.setRawMode) input.setRawMode(false);
663
+ const result = await runChild([process.execPath, binPath, '--yes'], {
664
+ ...process.env,
665
+ MARROW_API_KEY: options.apiKey,
666
+ MARROW_BASE_URL: options.baseUrl,
667
+ MARROW_FLEET_AGENT_ID: options.agentId,
668
+ });
669
+ output.write('\nPress any key to return to Marrow Governed Setup.');
670
+ if (input.setRawMode) input.setRawMode(true);
671
+ await waitForAnyKey(input);
672
+ return result.exitCode === 0
673
+ ? 'Marrow passive setup completed. Self-test output above is the source of truth.'
674
+ : `Marrow passive setup exited with code ${result.exitCode}. Review the output above.`;
675
+ }
676
+
677
+ async function runStatusCheck(options) {
678
+ if (!options.apiKey) return 'MARROW_API_KEY is missing. Status check skipped.';
679
+ const status = await statusOnly({ options });
680
+ const coverage = status.capture_coverage || {};
681
+ const closure = status.auto_outcome_closure || {};
682
+ const active = status.enabled ?? status.active ?? true;
683
+ const missed = Array.isArray(status.missed_hooks) && status.missed_hooks.length
684
+ ? ` missed hooks: ${status.missed_hooks.join(', ')}`
685
+ : '';
686
+ return `Marrow status: ${active ? 'active' : 'inactive'}; coverage=${coverage.status || coverage.summary || 'reported'}; outcomes=${closure.status || closure.summary || 'reported'}${missed}`;
687
+ }
688
+
689
+ async function runGateCheck(options) {
690
+ if (!options.apiKey) return 'MARROW_API_KEY is missing. Gate check skipped.';
691
+ const runtime = await preflightRuntime(options, 'deploy production worker after tests pass', 'deploy', 'wrangler deploy');
692
+ const decision = gateDecision(runtime);
693
+ const proof = decision.proofPack?.required
694
+ ? ` Proof required${decision.proofPack.missing?.length ? `; missing ${decision.proofPack.missing.join(', ')}` : ''}.`
695
+ : '';
696
+ return `Gate: ${decision.decision}${decision.required ? ' required' : ''}.${decision.exactNextAction ? ` Next: ${decision.exactNextAction}` : ''}${proof}`;
697
+ }
698
+
699
+ async function runGovernInteractive(options, input = process.stdin, output = process.stdout) {
700
+ if (!canUseInteractive(options, input, output)) {
701
+ output.write(`${governPanel(options)}\n`);
702
+ return;
703
+ }
704
+
705
+ const state = buildGovernState(options);
706
+ readline.emitKeypressEvents(input);
707
+ input.setRawMode(true);
708
+ output.write('\x1b[?25l');
709
+
710
+ let cleaned = false;
711
+ const cleanup = () => {
712
+ if (cleaned) return;
713
+ cleaned = true;
714
+ if (input.setRawMode) input.setRawMode(false);
715
+ output.write('\x1b[?25h');
716
+ };
717
+
718
+ const render = () => {
719
+ output.write(renderGovernTui(state, options));
720
+ };
721
+
722
+ render();
723
+ let keyHandler;
724
+ try {
725
+ await new Promise((resolve) => {
726
+ keyHandler = async (str, key = {}) => {
727
+ if (state.running) return;
728
+ if (key.ctrl && key.name === 'c') {
729
+ cleanup();
730
+ resolve();
731
+ return;
732
+ }
733
+ if (key.name === 'q' || key.name === 'escape' || str === 'q') {
734
+ cleanup();
735
+ resolve();
736
+ return;
737
+ }
738
+ if (key.name === 'up') {
739
+ state.cursor = (state.cursor + GOVERN_TUI_ROW_COUNT - 1) % GOVERN_TUI_ROW_COUNT;
740
+ state.confirmingSetup = false;
741
+ render();
742
+ } else if (key.name === 'down') {
743
+ state.cursor = (state.cursor + 1) % GOVERN_TUI_ROW_COUNT;
744
+ state.confirmingSetup = false;
745
+ render();
746
+ } else if (key.name === 'left' || key.name === 'right') {
747
+ const direction = key.name === 'right' ? 1 : -1;
748
+ if (state.cursor === 0) state.harnessIndex = (state.harnessIndex + direction + state.harnesses.length) % state.harnesses.length;
749
+ if (state.cursor === 1) state.modeIndex = (state.modeIndex + direction + state.modes.length) % state.modes.length;
750
+ state.confirmingSetup = false;
751
+ render();
752
+ } else if (key.name === 'return') {
753
+ state.running = true;
754
+ try {
755
+ if (state.cursor === 0) {
756
+ state.harnessIndex = (state.harnessIndex + 1) % state.harnesses.length;
757
+ state.status = 'Harness selected.';
758
+ state.confirmingSetup = false;
759
+ } else if (state.cursor === 1) {
760
+ state.modeIndex = (state.modeIndex + 1) % state.modes.length;
761
+ state.status = 'Mode selected.';
762
+ state.confirmingSetup = false;
763
+ } else if (state.cursor === 2) {
764
+ if (!state.confirmingSetup) {
765
+ state.confirmingSetup = true;
766
+ state.status = 'Confirm passive setup.';
767
+ } else {
768
+ state.lastResult = await runSetupSelfTest(options, input, output);
769
+ state.status = 'Passive setup attempted.';
770
+ state.confirmingSetup = false;
771
+ }
772
+ } else if (state.cursor === 3) {
773
+ state.status = 'Checking Marrow status...';
774
+ render();
775
+ state.lastResult = await runStatusCheck(options);
776
+ state.status = 'Status check complete.';
777
+ state.confirmingSetup = false;
778
+ } else if (state.cursor === 4) {
779
+ state.status = 'Testing before-action gate...';
780
+ render();
781
+ state.lastResult = await runGateCheck(options);
782
+ state.status = 'Gate check complete.';
783
+ state.confirmingSetup = false;
784
+ } else if (state.cursor === 5) {
785
+ cleanup();
786
+ output.write(`\n${commandForSelection(state, options)}\n`);
787
+ resolve();
788
+ return;
789
+ } else if (state.cursor === 6) {
790
+ cleanup();
791
+ resolve();
792
+ return;
793
+ }
794
+ } catch (error) {
795
+ state.lastResult = `Error: ${error instanceof Error ? error.message : String(error)}`;
796
+ state.status = 'Action failed.';
797
+ state.confirmingSetup = false;
798
+ } finally {
799
+ state.running = false;
800
+ if (!cleaned) render();
801
+ }
802
+ }
803
+ };
804
+ input.on('keypress', keyHandler);
805
+ });
806
+ } finally {
807
+ if (keyHandler) input.off('keypress', keyHandler);
808
+ if (input.pause) input.pause();
809
+ cleanup();
810
+ output.write('\n');
811
+ }
812
+ }
813
+
814
+ async function runCli(argv) {
815
+ const parsed = parseArgs(argv);
816
+ if (parsed.command === 'help') {
817
+ process.stdout.write(usage());
818
+ return;
819
+ }
820
+
821
+ if (parsed.options?.keyFromArg) {
822
+ process.stderr.write('Warning: prefer MARROW_API_KEY instead of --key because command-line args can be visible in process listings.\n');
823
+ }
824
+
825
+ let result;
826
+ if (parsed.command === 'run') result = await runGoverned(parsed);
827
+ else if (parsed.command === 'gate') result = await gateOnly(parsed);
828
+ else if (parsed.command === 'proof') result = await proofOnly(parsed);
829
+ else if (parsed.command === 'status') result = await statusOnly(parsed);
830
+ else if (parsed.command === 'govern') {
831
+ await runGovernInteractive(parsed.options);
832
+ return;
833
+ }
834
+
835
+ if (parsed.options?.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
836
+ else if (result?.blocked) process.stderr.write(`BLOCKED: ${result.message || 'Marrow blocked this action.'}\n`);
837
+ else if (parsed.command !== 'run') process.stdout.write('Marrow command completed.\n');
838
+
839
+ if (parsed.command === 'run' || result?.blocked) process.exitCode = result?.exitCode ?? (result?.ok === false ? 1 : 0);
840
+ }
841
+
842
+ module.exports = {
843
+ parseArgs,
844
+ redact,
845
+ redactedCommand,
846
+ inferType,
847
+ inferSurfaces,
848
+ commandForSelection,
849
+ buildGovernState,
850
+ gateDecision,
851
+ shouldBlock,
852
+ governPanel,
853
+ renderGovernTui,
854
+ canUseInteractive,
855
+ runGoverned,
856
+ gateOnly,
857
+ proofOnly,
858
+ statusOnly,
859
+ runStatusCheck,
860
+ runGateCheck,
861
+ runGovernInteractive,
862
+ runCli,
863
+ };
package/src/installer.js CHANGED
@@ -105,6 +105,12 @@ function findUp(startDir, names, maxDepth = 8) {
105
105
  }
106
106
 
107
107
  function projectRoot(startDir) {
108
+ const resolved = path.resolve(startDir);
109
+ if (path.basename(resolved) === '.marrow') return path.dirname(resolved);
110
+ if (path.basename(resolved) === 'env' && path.basename(path.dirname(resolved)) === '.marrow') {
111
+ return path.dirname(path.dirname(resolved));
112
+ }
113
+ if (exists(path.join(resolved, '.marrow'))) return resolved;
108
114
  const marker = findUp(startDir, ['package.json', 'pyproject.toml', 'requirements.txt', '.git', 'AGENTS.md', 'CLAUDE.md']);
109
115
  return marker ? path.dirname(marker) : path.resolve(startDir);
110
116
  }