@getmarrow/install 0.1.51 → 0.1.53

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
@@ -53,7 +53,7 @@ Verify current claims through the [public evidence manifest](https://getmarrow.a
53
53
  ## Install
54
54
 
55
55
  ```bash
56
- npx @getmarrow/install activate
56
+ npx @getmarrow/install
57
57
  ```
58
58
 
59
59
  Required secret:
@@ -62,6 +62,8 @@ Required secret:
62
62
  export MARROW_API_KEY=mrw_live_...
63
63
  ```
64
64
 
65
+ The bare command is the default-on path: it detects and byte-idempotently writes managed configuration, runs the authenticated activation self-test, and starts the supported persistent controller. The key stays process-only and is never written to generated configuration or controller state. Use `--dry-run` for a non-writing preview, `doctor` for a read-only health check, or `--no-controller` to install and self-test without starting the controller. The explicit `activate` command remains equivalent and supported.
66
+
65
67
  ## Keeping Marrow Current
66
68
 
67
69
  Marrow's hosted API, website, and dashboard update automatically; local SDK dependencies, generated runtime files, MCP hooks/configuration, and pinned package versions do not silently rewrite themselves. Keeping them current delivers new client-side features, compatibility improvements, and any published security fixes. Supported clients report their package version during authenticated status/runtime activity, and Marrow returns a `client_update` notice with the exact action when the version is behind or unknown.
@@ -91,9 +93,15 @@ npx @getmarrow/install controller stop
91
93
 
92
94
  Persistent controller lifecycle is currently Linux-only. On macOS or Windows, activation still writes supported configuration and verifies one server-side install self-test without certifying that hooks continuously ran; run `npx @getmarrow/install sidecar` under an owner-managed service and pass `--no-controller`. The controller does not silently upgrade packages, change governance policy, rotate credentials, or modify unrelated project configuration.
93
95
 
94
- ## What's New in v0.1.51
96
+ ## What's New in v0.1.53
97
+
98
+ v0.1.53 pins the native-gate MCP candidate `3.9.75` and reconciles only Marrow-owned native hook surfaces. Codex uses `.codex/hooks.json`; Cursor and Composer use `.cursor/hooks.json`; Cline uses bounded executables under `.clinerules/hooks/`; Windsurf uses `.windsurf/hooks.json`; Gemini CLI receives named BeforeTool, AfterTool, and AfterAgent groups in `.gemini/settings.json`; Grok receives trusted global hooks in `~/.grok/hooks/marrow.json`. Grok PreToolUse validates strict private allow/deny JSON and fails closed with exit `2` when the child cannot provide an exact decision; PostToolUse/PostToolUseFailure emit compact results; one nonblocking Stop hook closes the turn with no duplicate SessionEnd hook. Grok hooks remain user-toggleable, so restart plus `/hooks` inspection is required and configuration never proves observed coverage.
99
+
100
+ The integration boundary is explicit: native hooks remain cooperative/client-reported until authoritative server receipts exist; MCP tools are on demand; Codex requires restart plus owner `/hooks` trust review; Windsurf requires restart, workspace trust review, and Restricted Mode to be off; Gemini CLI requires restart and `/hooks panel` fingerprint review/approval, with `/hooks enable-all` reserved for an explicitly disabled configuration after owner review; Grok requires restart and `/hooks` inspection because its trusted global hooks remain user-toggleable; the governed wrapper remains an explicit bounded fallback; owned Node processes use the SDK passive runtime while installed; and custom hosts require a bounded event adapter. Exact package SHA/integrity can prove artifact provenance, not runtime coverage.
101
+
102
+ ## Previous: v0.1.52
95
103
 
96
- v0.1.51 keeps MCP `3.9.74` and SDK `3.7.62`, and makes generated passive runtime identity follow the current process: `MARROW_FLEET_AGENT_ID` first, then `MARROW_AGENT_ID`, with the installer-captured identity retained only as a fallback. This prevents a stale installed harness identity from overriding the current Codex, Bob, or other canonical process identity. Managed MCP config does not store an API key or shell-style credential placeholder; the owning harness inherits the key from trusted environment or secret-manager state.
104
+ v0.1.52 keeps MCP `3.9.74` and SDK `3.7.62`, and makes generated passive runtime identity follow the current process: `MARROW_FLEET_AGENT_ID` first, then `MARROW_AGENT_ID`, with the installer-captured identity retained only as a fallback. This prevents a stale installed harness identity from overriding the current Codex, Bob, or other canonical process identity. Managed MCP config does not store an API key or shell-style credential placeholder; the owning harness inherits the key from trusted environment or secret-manager state.
97
105
 
98
106
  The integration boundary is explicit: native Claude hooks are installed only where supported and remain cooperative/client-reported until authoritative server receipts exist; MCP tools are on demand; Codex, Grok, Gemini, and similar CLIs use the governed wrapper for consequential control; owned Node processes use the SDK passive runtime while installed; and custom hosts require a bounded event adapter. Exact package SHA/integrity can prove artifact provenance, not runtime coverage. Activation-profile delivery is authenticated `client_self_reported` telemetry with `certified_coverage: false`; it acknowledges delivery but cannot attest that a hook, wrapper, or adapter ran.
99
107
 
@@ -116,7 +124,7 @@ v0.1.47 makes activate honest about reload and the first capture path:
116
124
  v0.1.46 makes default install cover every workspace honestly:
117
125
 
118
126
  - auto mode now writes MCP plus agent instructions in every project, and the SDK passive runtime whenever Node is present;
119
- - Claude native hooks still install only when `.claude` is present; detected Cursor workspaces also get `.cursor/mcp.json`;
127
+ - Claude native hooks still install only when `.claude` is present; detected Cursor workspaces get `.cursor/hooks.json` plus `.cursor/mcp.json`, and Composer uses that same Cursor-native lifecycle path;
120
128
  - Hermes, OpenClaw, and custom hosts stay event-contract only and are not claimed as native interception;
121
129
  - generated MCP setup, launch, and certified hook commands pin current MCP `3.9.64` from source `ed8293165247e89161045c7fbf68aeee4d51c6da`;
122
130
  - SDK detection and operator-approved upgrade rules pin exact public `3.7.59`;
@@ -352,8 +360,11 @@ Run `npx @getmarrow/install integrations --json` for the machine-readable matrix
352
360
  | Harnesses | Prompt / pre-action / result | Closure and proof | Cached brief | Restart survival | Evidence adapter | Safe repair |
353
361
  | --- | --- | --- | --- | --- | --- | --- |
354
362
  | Claude Code | Configured native hooks where supported; cooperative/client-reported until authoritative receipts exist | Correlated when determinable; proof is evaluated and is advisory or enforced according to plan policy | Owner-only bounded cache | Installed config and durable spool | Native hook evidence | Managed config after activation |
355
- | Cursor, Composer, Cline, Windsurf | MCP-routed only | MCP-routed; explicit or governed proof | Owner-only MCP cache | MCP config and durable spool | MCP lifecycle evidence | Managed config after activation |
356
- | Codex, OpenCode, Gemini, Grok, DeepSeek, Qwen, Kimi, MiniMax, GLM | Automatic only inside governed runner | Automatic when result is known; proof is evaluated and is advisory or enforced according to plan policy | Runner/runtime cache | Activated controller and durable buffer | Command, test, deployment, or owner evidence | Managed config after activation |
363
+ | Cursor, Composer | Native hooks plus MCP on demand | Native pre-action/result/closeout; explicit proof | Owner-only MCP cache | Hook/MCP config and durable spool | Client-self-reported lifecycle evidence | Managed config after activation and trust review |
364
+ | Cline | Native PreToolUse/PostToolUse/TaskCancel plus MCP on demand | Native pre-action/result/cancel closeout; TaskComplete unverified | Owner-only MCP cache | Non-overwriting executable hooks and durable spool | Client-self-reported lifecycle evidence | Enable Hooks, executable/workspace trust, and restart required |
365
+ | Windsurf | MCP-routed only | MCP-routed; explicit or governed proof | Owner-only MCP cache | MCP config and durable spool | MCP lifecycle evidence | Managed config after activation |
366
+ | Codex, Gemini, Grok | Native hooks plus MCP on demand | Native pre-action/result/turn closeout; Grok Stop never blocks or retries | Owner-only MCP cache | Hook/MCP config and durable spool | Client-self-reported lifecycle evidence | Restart and host hook review required |
367
+ | OpenCode, DeepSeek, Qwen, Kimi, MiniMax, GLM | Automatic only inside governed runner | Automatic when result is known; proof is evaluated and is advisory or enforced according to plan policy | Runner/runtime cache | Activated controller and durable buffer | Command, test, deployment, or owner evidence | Managed config after activation |
357
368
  | Hermes, OpenClaw, custom harnesses | Lifecycle adapter required | Adapter or governed runner required | Adapter dependent | Adapter dependent | Adapter supplied | Adapter owned |
358
369
 
359
370
  For native hooks, a successful tool exit is not treated as a successful business outcome when proof is missing. MCP coverage includes only actions routed through that MCP client. Governed-runner coverage includes only commands launched through the runner. Event-contract integrations must emit the documented lifecycle themselves.
@@ -2,10 +2,11 @@
2
2
 
3
3
  const installer = require('../src/installer');
4
4
  const governedRunner = require('../src/governed-runner');
5
+ const { runControlCli } = require('../src/control-state');
5
6
 
6
7
  const argv = process.argv.slice(2);
7
8
  const governedCommands = new Set(['run', 'gate', 'proof', 'status', 'govern', 'fleet', 'hermes', 'openclaw', 'integrations', 'permit', 'verify-permit', 'coverage', 'sidecar', 'controller']);
8
- const runCli = governedCommands.has(argv[0]) ? governedRunner.runCli : installer.runCli;
9
+ const runCli = argv[0] === 'control' ? runControlCli : governedCommands.has(argv[0]) ? governedRunner.runCli : installer.runCli;
9
10
 
10
11
  runCli(argv).catch((error) => {
11
12
  const message = error instanceof Error ? error.message : String(error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getmarrow/install",
3
- "version": "0.1.51",
3
+ "version": "0.1.53",
4
4
  "description": "Universal installer and governed runner for Marrow agent fleets.",
5
5
  "bin": {
6
6
  "marrow-install": "bin/marrow-install.js"
@@ -0,0 +1,116 @@
1
+ const crypto = require('node:crypto');
2
+ const fs = require('node:fs');
3
+ const os = require('node:os');
4
+ const path = require('node:path');
5
+
6
+ const CONTROL_STATE_VERSION = 1;
7
+ const CONTROL_STATE_DIRECTORY = '.marrow';
8
+ const CONTROL_STATE_FILENAME = 'control.json';
9
+ const CONTROL_CHANGED_BY = 'owner_cli';
10
+ const CONTROL_MAX_BYTES = 4096;
11
+ const CONTROL_BYPASS_ACTION = 'protected action bypassed while local control disabled';
12
+ const CONTROL_BYPASS_LEDGER_FILENAME = 'control-bypass-receipts.json';
13
+
14
+ class UnsafeControlStateError extends Error {
15
+ constructor() { super('Local Marrow control state is unsafe or invalid. Protected actions remain blocked; inspect ~/.marrow/control.json and run npx @getmarrow/install control status.'); this.name = 'UnsafeControlStateError'; }
16
+ }
17
+
18
+ function statePaths(home = os.homedir()) {
19
+ const directory = path.join(home, CONTROL_STATE_DIRECTORY);
20
+ return { directory, target: path.join(directory, CONTROL_STATE_FILENAME) };
21
+ }
22
+ function unsafe() { throw new UnsafeControlStateError(); }
23
+ function ownedPrivate(stat, mode) {
24
+ return !stat.isSymbolicLink() && stat.uid === (typeof process.getuid === 'function' ? process.getuid() : stat.uid) && (stat.mode & 0o777) === mode;
25
+ }
26
+ function readLocalControlState(options = {}) {
27
+ const { directory, target } = statePaths(options.home);
28
+ let ds;
29
+ try { ds = fs.lstatSync(directory); } catch (error) { if (error.code === 'ENOENT') return { enabled: true, state: 'default_enabled', changed_at: null }; return unsafe(); }
30
+ if (!ds.isDirectory() || ds.isSymbolicLink()) return unsafe();
31
+ let ts;
32
+ try { ts = fs.lstatSync(target); } catch (error) { if (error.code === 'ENOENT') return { enabled: true, state: 'default_enabled', changed_at: null }; return unsafe(); }
33
+ if (!ownedPrivate(ds, 0o700)) return unsafe();
34
+ if (!ts.isFile() || !ownedPrivate(ts, 0o600) || ts.size < 2 || ts.size > CONTROL_MAX_BYTES) return unsafe();
35
+ let fd = -1;
36
+ try {
37
+ fd = fs.openSync(target, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
38
+ const opened = fs.fstatSync(fd);
39
+ if (!opened.isFile() || opened.ino !== ts.ino || !ownedPrivate(opened, 0o600)) return unsafe();
40
+ const raw = fs.readFileSync(fd, 'utf8');
41
+ if (Buffer.byteLength(raw) > CONTROL_MAX_BYTES) return unsafe();
42
+ const value = JSON.parse(raw);
43
+ if (!value || Array.isArray(value) || Object.keys(value).sort().join(',') !== 'change_id,changed_at,changed_by,enabled,version') return unsafe();
44
+ const changedAt = typeof value.changed_at === 'string' ? value.changed_at : '';
45
+ const valid = value.version === CONTROL_STATE_VERSION && typeof value.enabled === 'boolean' && value.changed_by === CONTROL_CHANGED_BY
46
+ && changedAt && new Date(changedAt).toISOString() === changedAt && typeof value.change_id === 'string' && /^ctl_[a-f0-9]{32}$/.test(value.change_id);
47
+ if (!valid) return unsafe();
48
+ return { enabled: value.enabled, state: value.enabled ? 'enabled' : 'disabled', changed_at: changedAt, change_id: value.change_id };
49
+ } catch (error) { if (error instanceof UnsafeControlStateError) throw error; return unsafe(); }
50
+ finally { if (fd >= 0) fs.closeSync(fd); }
51
+ }
52
+ function writeLocalControlState(enabled, options = {}) {
53
+ const current = readLocalControlState(options);
54
+ if (current.enabled === enabled) return { ...current, changed: false, receipt: null };
55
+ const { directory, target } = statePaths(options.home);
56
+ if (!fs.existsSync(directory)) fs.mkdirSync(directory, { mode: 0o700 });
57
+ const ds = fs.lstatSync(directory);
58
+ if (!ds.isDirectory() || !ownedPrivate(ds, 0o700)) return unsafe();
59
+ const value = { version: CONTROL_STATE_VERSION, enabled, changed_at: new Date().toISOString(), change_id: `ctl_${crypto.randomBytes(16).toString('hex')}`, changed_by: CONTROL_CHANGED_BY };
60
+ const bytes = `${JSON.stringify(value)}\n`;
61
+ const temp = path.join(directory, `.control.${process.pid}.${crypto.randomBytes(8).toString('hex')}.tmp`);
62
+ let fd = -1;
63
+ try {
64
+ fd = fs.openSync(temp, 'wx', 0o600); fs.writeFileSync(fd, bytes); fs.fsyncSync(fd); fs.closeSync(fd); fd = -1;
65
+ fs.renameSync(temp, target);
66
+ const dirFd = fs.openSync(directory, 'r'); try { fs.fsyncSync(dirFd); } finally { fs.closeSync(dirFd); }
67
+ } finally { if (fd >= 0) fs.closeSync(fd); try { fs.unlinkSync(temp); } catch {} }
68
+ return { enabled, state: enabled ? 'enabled' : 'disabled', changed_at: value.changed_at, change_id: value.change_id, changed: true, receipt: { action: enabled ? 'local control enabled' : 'local control disabled', change_id: value.change_id, changed_at: value.changed_at } };
69
+ }
70
+ function evidence(options = {}) {
71
+ try { const s = readLocalControlState(options); return { ...s, bypass_recording_available: Boolean(options.apiKey), exact_next_action: s.enabled ? 'Run npx @getmarrow/install control disable --yes to disable local enforcement.' : 'Run npx @getmarrow/install control enable to resume local enforcement; hooks poll local state without reinstalling.' }; }
72
+ catch { return { enabled: false, state: 'error', changed_at: null, bypass_recording_available: false, exact_next_action: 'Inspect and replace unsafe ~/.marrow/control.json, then run npx @getmarrow/install control status; protected actions remain blocked.' }; }
73
+ }
74
+ async function bestEffortLifecycle(event, options = {}) {
75
+ const key = options.apiKey || process.env.MARROW_API_KEY || process.env.MARROW_KEY;
76
+ if (!key || typeof fetch !== 'function') return false;
77
+ try { const response = await fetch(`${String(options.baseUrl || process.env.MARROW_BASE_URL || 'https://api.getmarrow.ai').replace(/\/$/, '')}/v1/agent/integrations/events`, { method: 'POST', headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' }, body: JSON.stringify(event), signal: AbortSignal.timeout(1500) }); return response.ok; } catch { return false; }
78
+ }
79
+ function boundedIdentity(value, fallback) { const safe = String(value || '').trim().replace(/[^A-Za-z0-9._-]/g, '-').slice(0, 64); return safe || fallback; }
80
+ function persistBypassReceipt(input, options = {}) {
81
+ const { directory } = statePaths(options.home);
82
+ if (!fs.existsSync(directory)) fs.mkdirSync(directory, { mode: 0o700 });
83
+ const ds = fs.lstatSync(directory); if (!ds.isDirectory() || !ownedPrivate(ds, 0o700)) return unsafe();
84
+ const target = path.join(directory, CONTROL_BYPASS_LEDGER_FILENAME);
85
+ let receipts = [];
86
+ if (fs.existsSync(target)) {
87
+ const ts = fs.lstatSync(target); if (!ts.isFile() || !ownedPrivate(ts, 0o600) || ts.size > CONTROL_MAX_BYTES * 8) return unsafe();
88
+ receipts = JSON.parse(fs.readFileSync(target, 'utf8')); if (!Array.isArray(receipts)) return unsafe();
89
+ }
90
+ const receipt = { version: 1, action: CONTROL_BYPASS_ACTION, harness: boundedIdentity(input.harness, 'governed_runner'), agent_id: boundedIdentity(input.agentId, 'local-owner'), surfaces: [...new Set((input.surfaces || []).filter((v) => ['production','github','npm','secrets','database','financial','filesystem'].includes(v)))].slice(0, 6), risk_level: input.risk === 'high' ? 'high' : 'medium', correlation_id: `bypass_${crypto.randomBytes(16).toString('hex')}`, source: 'client_self_reported', intervention_disposition: 'overridden', action_changed: false, recorded_at: new Date().toISOString() };
91
+ receipts = [...receipts.slice(-63), receipt]; const bytes = `${JSON.stringify(receipts)}\n`; if (Buffer.byteLength(bytes) > CONTROL_MAX_BYTES * 8) return unsafe();
92
+ const temp = path.join(directory, `.bypass.${process.pid}.${crypto.randomBytes(8).toString('hex')}.tmp`); let fd = -1;
93
+ try { fd = fs.openSync(temp, 'wx', 0o600); fs.writeFileSync(fd, bytes); fs.fsyncSync(fd); fs.closeSync(fd); fd = -1; fs.renameSync(temp, target); }
94
+ finally { if (fd >= 0) fs.closeSync(fd); try { fs.unlinkSync(temp); } catch {} }
95
+ return receipt;
96
+ }
97
+ async function recordGovernedBypass(input, options = {}) {
98
+ const receipt = persistBypassReceipt(input, options);
99
+ const remote_delivered = await bestEffortLifecycle({ event_type: 'pre_action_checked', ...receipt }, options);
100
+ return { bypass_recorded: true, remote_delivered, receipt };
101
+ }
102
+ async function runControlCli(argv, options = {}) {
103
+ const action = argv[1] || 'status'; const json = argv.includes('--json');
104
+ if (!['status', 'disable', 'enable'].includes(action)) throw new Error('control action must be status, disable, or enable');
105
+ if (action === 'disable' && !argv.includes('--yes')) throw new Error('control disable requires --yes owner confirmation; no state was changed');
106
+ let result = action === 'status' ? evidence(options) : writeLocalControlState(action === 'enable', options);
107
+ if (result.changed) {
108
+ const delivered = await bestEffortLifecycle({ event_type: 'journey_update', action: result.receipt.action, source: 'client_self_reported', harness: 'installer', agent_id: boundedIdentity(options.agentId || process.env.MARROW_FLEET_AGENT_ID || process.env.MARROW_AGENT_ID, 'local-owner'), correlation_id: result.change_id }, options);
109
+ result = { ...result, lifecycle_delivered: delivered };
110
+ }
111
+ if (json) process.stdout.write(`${JSON.stringify({ ok: result.state !== 'error', local_control: result }, null, 2)}\n`);
112
+ else process.stdout.write(`Marrow local control: ${result.state}. ${result.exact_next_action || ''}\n`);
113
+ return result;
114
+ }
115
+
116
+ module.exports = { CONTROL_STATE_VERSION, CONTROL_STATE_DIRECTORY, CONTROL_STATE_FILENAME, CONTROL_CHANGED_BY, CONTROL_MAX_BYTES, CONTROL_BYPASS_ACTION, CONTROL_BYPASS_LEDGER_FILENAME, UnsafeControlStateError, readLocalControlState, writeLocalControlState, evidence, bestEffortLifecycle, persistBypassReceipt, recordGovernedBypass, runControlCli };
package/src/first-hour.js CHANGED
@@ -16,13 +16,37 @@ function harnessReloadPlan(detection = {}, changes = []) {
16
16
  if (detection.cursor) {
17
17
  clients.push({
18
18
  client: 'cursor',
19
- restart: 'Restart Cursor so project MCP loads.',
19
+ restart: 'Restart Cursor or Composer, then review the trusted workspace hooks with /hooks so native lifecycle hooks and project MCP load.',
20
+ });
21
+ }
22
+ if (detection.cline) {
23
+ clients.push({
24
+ client: 'cline',
25
+ restart: 'Enable Hooks in Cline, trust the project hook executables and workspace, then restart Cline. TaskComplete remains unverified.',
26
+ });
27
+ }
28
+ if (detection.windsurf) {
29
+ clients.push({
30
+ client: 'windsurf',
31
+ restart: 'Restart Windsurf, trust the workspace hook configuration, and leave Restricted Mode so native hooks can run.',
32
+ });
33
+ }
34
+ if (detection.gemini) {
35
+ clients.push({
36
+ client: 'gemini',
37
+ restart: 'Restart Gemini CLI, open /hooks panel, and review and approve the project hook fingerprints before treating native hooks as live.',
38
+ });
39
+ }
40
+ if (detection.grok) {
41
+ clients.push({
42
+ client: 'grok',
43
+ restart: 'Restart Grok, inspect /hooks, and confirm the trusted global Marrow hooks are enabled before treating them as live.',
20
44
  });
21
45
  }
22
46
  if (detection.codex) {
23
47
  clients.push({
24
48
  client: 'codex',
25
- restart: 'Start a new Codex or Grok session so AGENTS.md and MCP config load.',
49
+ restart: 'Start a new Codex session, then review the repository hooks with /hooks before treating them as trusted or live.',
26
50
  });
27
51
  }
28
52
  if (clients.length === 0) {
@@ -54,16 +78,56 @@ function firstCapturePath(detection = {}, agentId) {
54
78
  if (detection.cursor) {
55
79
  return {
56
80
  client: 'cursor',
57
- capability_level: 'mcp',
58
- command: 'marrow_agent_runtime',
59
- instruction: 'After restart, call marrow_agent_runtime before the next deploy, merge, or publish. Before the session ends, call marrow_session_end. Cursor MCP tools are on demand.',
81
+ capability_level: 'native_hooks',
82
+ command: null,
83
+ instruction: 'After restart and trusted-workspace /hooks review, Cursor and Composer use the same native pre-action, action-result, and outcome-closeout hooks. MCP tools remain on demand. Configuration does not verify runtime coverage.',
84
+ };
85
+ }
86
+ if (detection.cline) {
87
+ return {
88
+ client: 'cline',
89
+ capability_level: 'native_hooks',
90
+ command: null,
91
+ instruction: 'After Enable Hooks, executable/workspace trust, and restart, Cline uses native PreToolUse, PostToolUse, and TaskCancel hooks. MCP remains on demand. TaskComplete is documented as coming soon and is not verified coverage; configuration does not prove passive runtime coverage.',
92
+ };
93
+ }
94
+ if (detection.windsurf) {
95
+ return {
96
+ client: 'windsurf',
97
+ capability_level: 'native_hooks',
98
+ command: null,
99
+ instruction: 'After restart, workspace trust review, and leaving Restricted Mode, Windsurf uses native pre-action, success-result, and response-closeout hooks. MCP tools remain on demand. Configuration does not verify runtime coverage.',
100
+ };
101
+ }
102
+ if (detection.gemini) {
103
+ return {
104
+ client: 'gemini',
105
+ capability_level: 'native_hooks',
106
+ command: null,
107
+ instruction: 'After restart and project fingerprint review and approval in /hooks panel, Gemini CLI uses native BeforeTool, AfterTool, and AfterAgent hooks. MCP tools remain on demand. Explicit hook disablement is preserved, and configuration does not verify runtime coverage.',
108
+ };
109
+ }
110
+ if (detection.codex) {
111
+ return {
112
+ client: 'codex',
113
+ capability_level: 'native_hooks',
114
+ command: null,
115
+ instruction: 'After restart and owner /hooks trust review, Codex native hooks capture prompt, pre-action, action-result, and session-end events. Configuration alone does not verify runtime coverage.',
116
+ };
117
+ }
118
+ if (detection.grok) {
119
+ return {
120
+ client: 'grok',
121
+ capability_level: 'native_hooks',
122
+ command: null,
123
+ instruction: 'After restart and /hooks inspection, Grok uses global native pre-action, result, failure-result, and one nonblocking Stop closeout hook. Hooks remain user-toggleable, MCP tools stay on demand, and configuration does not verify observed runtime coverage. The governed runner remains an explicit bounded fallback.',
60
124
  };
61
125
  }
62
126
  return {
63
- client: detection.codex ? 'codex' : 'governed_wrapper',
127
+ client: 'governed_wrapper',
64
128
  capability_level: 'governed_wrapper',
65
129
  command: `npx @getmarrow/install run --agent ${id} -- -- <command>`,
66
- instruction: 'Wrap the next deploy, merge, or publish with the governed runner. Codex and Grok do not intercept tools natively. Before the session ends, call marrow_session_end or marrow_commit. Do not invent token counts.',
130
+ instruction: 'Wrap the next deploy, merge, or publish with the governed runner for hosts without a native gate. Before the session ends, call marrow_session_end or marrow_commit. Do not invent token counts.',
67
131
  };
68
132
  }
69
133
 
@@ -27,6 +27,8 @@ const {
27
27
  buildPlan,
28
28
  detectEnvironment,
29
29
  } = require('./installer');
30
+ const { createHostUsageCapture } = require('./usage-telemetry');
31
+ const { readLocalControlState, recordGovernedBypass } = require('./control-state');
30
32
 
31
33
  const DEFAULT_BASE_URL = 'https://api.getmarrow.ai';
32
34
  const MCP_SETUP_COMMAND = `npx -y --package=${ADAPTER_PROVENANCE.mcp.package}@${ADAPTER_PROVENANCE.mcp.version} marrow-mcp setup`;
@@ -512,11 +514,11 @@ function dataOf(json) {
512
514
  return json && typeof json === 'object' && json.data && typeof json.data === 'object' ? json.data : json;
513
515
  }
514
516
 
515
- async function requestJson(options, method, route, body) {
517
+ async function requestJson(options, method, route, body, extraHeaders = {}) {
516
518
  if (!options.apiKey) throw new Error('MARROW_API_KEY is required. Use --fail-open only for non-production local commands.');
517
519
  const response = await fetch(new URL(route, options.baseUrl.replace(/\/$/, '/')), {
518
520
  method,
519
- headers: headers(options),
521
+ headers: { ...headers(options), ...extraHeaders },
520
522
  body: body === undefined ? undefined : JSON.stringify(body),
521
523
  });
522
524
  const text = await response.text();
@@ -674,15 +676,48 @@ function printGate(decision, runtime, stream = process.stdout) {
674
676
  if (runtime?.value_proof?.owner_summary) stream.write(`Value: ${runtime.value_proof.owner_summary}\n`);
675
677
  }
676
678
 
677
- function runChild(command, env = process.env) {
679
+ function runChild(command, env = process.env, stdout = process.stdout) {
678
680
  return new Promise((resolve) => {
681
+ const usageCapture = createHostUsageCapture(command);
679
682
  const child = spawn(command[0], command.slice(1), {
680
- stdio: 'inherit',
683
+ stdio: usageCapture.supported ? ['inherit', 'pipe', 'inherit'] : 'inherit',
681
684
  shell: false,
682
685
  env,
683
686
  });
684
- child.on('error', (error) => resolve({ exitCode: 127, error }));
685
- child.on('close', (code, signal) => resolve({ exitCode: code ?? 1, signal: signal || null }));
687
+ let closed = null;
688
+ let pendingWrites = 0;
689
+ let settled = false;
690
+ const settle = () => {
691
+ if (settled || !closed || pendingWrites > 0) return;
692
+ settled = true;
693
+ resolve({
694
+ ...closed,
695
+ usageCaptureSupported: usageCapture.supported,
696
+ modelUsage: usageCapture.finish(),
697
+ });
698
+ };
699
+ if (usageCapture.supported && child.stdout) {
700
+ child.stdout.on('data', (chunk) => {
701
+ usageCapture.write(chunk);
702
+ pendingWrites += 1;
703
+ const ready = stdout.write(chunk, () => {
704
+ pendingWrites -= 1;
705
+ settle();
706
+ });
707
+ if (!ready) {
708
+ child.stdout.pause();
709
+ stdout.once('drain', () => child.stdout?.resume());
710
+ }
711
+ });
712
+ }
713
+ child.on('error', (error) => {
714
+ closed = { exitCode: 127, error };
715
+ settle();
716
+ });
717
+ child.on('close', (code, signal) => {
718
+ closed = { exitCode: code ?? 1, signal: signal || null };
719
+ settle();
720
+ });
686
721
  });
687
722
  }
688
723
 
@@ -718,7 +753,7 @@ async function createDecision(options, action, type, target, surfaces) {
718
753
  });
719
754
  }
720
755
 
721
- async function commitOutcome(options, decisionId, success, outcome, proof, gateReceiptId) {
756
+ async function commitOutcome(options, decisionId, success, outcome, proof, gateReceiptId, modelUsage = null) {
722
757
  const body = {
723
758
  decision_id: decisionId,
724
759
  success,
@@ -727,10 +762,16 @@ async function commitOutcome(options, decisionId, success, outcome, proof, gateR
727
762
  source_meta: sourceMeta(options, 'commit', { action: outcome }),
728
763
  };
729
764
  if (gateReceiptId) body.gate_receipt_id = gateReceiptId;
730
- return requestJson(options, 'POST', '/v1/agent/commit', body);
765
+ if (modelUsage) body.model_usage = modelUsage;
766
+ const commitHeaders = modelUsage
767
+ ? { 'Idempotency-Key': `marrow-run-${crypto.createHash('sha256')
768
+ .update(`${options.agentId}\u0000${options.sessionId}\u0000${decisionId}`)
769
+ .digest('hex')}` }
770
+ : {};
771
+ return requestJson(options, 'POST', '/v1/agent/commit', body, commitHeaders);
731
772
  }
732
773
 
733
- async function runGoverned(parsed) {
774
+ async function runGoverned(parsed, execution = {}) {
734
775
  const { options, childCommand } = parsed;
735
776
  const commandText = redactedCommand(childCommand);
736
777
  const action = options.action ? redact(options.action) : commandText;
@@ -746,6 +787,18 @@ async function runGoverned(parsed) {
746
787
  let protectedAction = risky;
747
788
  const surfaces = inferSurfaces(commandText || action);
748
789
 
790
+ let localControl;
791
+ try { localControl = readLocalControlState(execution.controlStateOptions || {}); }
792
+ catch (error) {
793
+ if (protectedAction) return { ok: false, blocked: true, exitCode: 13, action, type, risky, message: error.message };
794
+ localControl = { enabled: true, state: 'error' };
795
+ }
796
+ if (!localControl.enabled) {
797
+ const bypass = protectedAction ? await recordGovernedBypass({ harness: options.client, agentId: options.agentId, surfaces, risk: risky ? 'high' : 'medium' }, { ...options, ...(execution.controlStateOptions || {}) }) : { bypass_recorded: false, remote_delivered: false };
798
+ const child = await runChild(childCommand, scopedExecutionEnv(null), execution.stdout || process.stdout);
799
+ return { ok: child.exitCode === 0, blocked: false, exitCode: child.exitCode, action, type, risky, local_control: 'owner_disabled', bypass_recorded: bypass.bypass_recorded, bypass_remote_delivered: bypass.remote_delivered, decision: null, decision_id: '', permit_id: null, permit_verified: false, permit_closed: false };
800
+ }
801
+
749
802
  try {
750
803
  runtime = await preflightRuntime(options, action, type, commandText);
751
804
  decision = gateDecision(runtime);
@@ -816,7 +869,7 @@ async function runGoverned(parsed) {
816
869
  }
817
870
 
818
871
  const childEnv = scopedExecutionEnv(permitVerified ? actionPermit : null);
819
- const child = await runChild(childCommand, childEnv);
872
+ const child = await runChild(childCommand, childEnv, execution.stdout || process.stdout);
820
873
  const success = child.exitCode === 0;
821
874
  const proof = defaultProof({ options, action, childCommand, exitCode: child.exitCode, success });
822
875
  const outcome = success
@@ -826,7 +879,15 @@ async function runGoverned(parsed) {
826
879
  let commit = null;
827
880
  if (decisionId) {
828
881
  try {
829
- commit = await commitOutcome(options, decisionId, success, outcome, proof, decision?.receiptId || '');
882
+ commit = await commitOutcome(
883
+ options,
884
+ decisionId,
885
+ success,
886
+ outcome,
887
+ proof,
888
+ decision?.receiptId || '',
889
+ child.modelUsage,
890
+ );
830
891
  } catch (error) {
831
892
  process.stderr.write(`Marrow outcome commit failed: ${error.message}\n`);
832
893
  }
@@ -861,6 +922,16 @@ async function runGoverned(parsed) {
861
922
  permit_id: actionPermit?.permit_id || null,
862
923
  permit_verified: permitVerified,
863
924
  permit_closed: Boolean(permitClosed),
925
+ usage_capture: {
926
+ supported: child.usageCaptureSupported === true,
927
+ observed: Boolean(child.modelUsage),
928
+ source: child.modelUsage?.source || null,
929
+ evidence: child.modelUsage
930
+ ? 'accepted_host_reported_usage'
931
+ : child.usageCaptureSupported
932
+ ? 'no_valid_host_reported_usage'
933
+ : 'unsupported_child_command',
934
+ },
864
935
  };
865
936
  }
866
937
 
@@ -964,6 +1035,16 @@ async function gateOnly(parsed) {
964
1035
  const { options } = parsed;
965
1036
  const action = redact(options.action);
966
1037
  const type = options.type || inferType(action);
1038
+ const protectedAction = isRisky(action, type);
1039
+ let localControl;
1040
+ try { localControl = readLocalControlState(); } catch (error) {
1041
+ if (protectedAction) return { ok: false, allowed: false, blocked: true, exitCode: 13, action, type, decision: null, message: error.message };
1042
+ localControl = { enabled: true };
1043
+ }
1044
+ if (!localControl.enabled) {
1045
+ const bypass = protectedAction ? await recordGovernedBypass({ harness: options.client, agentId: options.agentId, surfaces: inferSurfaces(action), risk: 'high' }, options) : { bypass_recorded: false, remote_delivered: false };
1046
+ 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 };
1047
+ }
967
1048
  const runtime = await preflightRuntime(options, action, type, action);
968
1049
  const decision = gateDecision(runtime);
969
1050
  printGate(decision, runtime);