@getmarrow/install 0.1.52 → 0.1.54
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 +26 -5
- package/bin/marrow-install.js +2 -1
- package/package.json +1 -1
- package/src/control-state.js +116 -0
- package/src/first-hour.js +71 -7
- package/src/governed-runner.js +23 -0
- package/src/installer.js +1113 -57
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
|
|
56
|
+
npx @getmarrow/install
|
|
57
57
|
```
|
|
58
58
|
|
|
59
59
|
Required secret:
|
|
@@ -62,6 +62,14 @@ 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
|
+
|
|
67
|
+
### MCP tool profiles
|
|
68
|
+
|
|
69
|
+
Ordinary setup leaves `MARROW_TOOL_PROFILE` unset, which selects the documented 17-tool `primary` surface. Set `MARROW_TOOL_PROFILE=core` only for the legacy seven-tool minimal surface, or `MARROW_TOOL_PROFILE=full` for the complete advanced/legacy catalog. Explicit `primary`, `core`, and `full` values are accepted; any other value fails with an exact bounded repair and never falls back to a broader profile.
|
|
70
|
+
|
|
71
|
+
Tool visibility is not authorization. Every visible call still reaches Marrow's backend authentication, tenant, key-permission, plan, proof, and policy enforcement. `doctor --self-test` reports the configured and effective profile, the expected visible count, reloaded MCP visible names/count, and non-authorizing backend entitlement/upgrade projection. Until the owning harness restarts and matching MCP status is observed, actual visibility stays unavailable and the profile remains not-live. The self-test reads backend availability status; it does not invoke paid write tools to discover access.
|
|
72
|
+
|
|
65
73
|
## Keeping Marrow Current
|
|
66
74
|
|
|
67
75
|
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,7 +99,17 @@ npx @getmarrow/install controller stop
|
|
|
91
99
|
|
|
92
100
|
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
101
|
|
|
94
|
-
## What's New in v0.1.
|
|
102
|
+
## What's New in v0.1.54
|
|
103
|
+
|
|
104
|
+
v0.1.54 pins sealed MCP candidate `3.9.77` from source `1e782d8ba6bbb54bfaa322f0300565c7176f1969` and makes ordinary setup use the primary tool surface without writing a profile variable. Explicit `core` and `full` selections remain preserved, invalid values fail closed with a bounded repair, and human/JSON self-test output distinguishes configured/effective profile, expected visibility, actual post-reload visibility, and backend-projected entitlement state. Backend projections are status evidence only and always report `authorizes_calls: false`.
|
|
105
|
+
|
|
106
|
+
## Previous: v0.1.53
|
|
107
|
+
|
|
108
|
+
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.
|
|
109
|
+
|
|
110
|
+
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.
|
|
111
|
+
|
|
112
|
+
## Previous: v0.1.52
|
|
95
113
|
|
|
96
114
|
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
115
|
|
|
@@ -116,7 +134,7 @@ v0.1.47 makes activate honest about reload and the first capture path:
|
|
|
116
134
|
v0.1.46 makes default install cover every workspace honestly:
|
|
117
135
|
|
|
118
136
|
- 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
|
|
137
|
+
- 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
138
|
- Hermes, OpenClaw, and custom hosts stay event-contract only and are not claimed as native interception;
|
|
121
139
|
- generated MCP setup, launch, and certified hook commands pin current MCP `3.9.64` from source `ed8293165247e89161045c7fbf68aeee4d51c6da`;
|
|
122
140
|
- SDK detection and operator-approved upgrade rules pin exact public `3.7.59`;
|
|
@@ -352,8 +370,11 @@ Run `npx @getmarrow/install integrations --json` for the machine-readable matrix
|
|
|
352
370
|
| Harnesses | Prompt / pre-action / result | Closure and proof | Cached brief | Restart survival | Evidence adapter | Safe repair |
|
|
353
371
|
| --- | --- | --- | --- | --- | --- | --- |
|
|
354
372
|
| 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
|
|
356
|
-
|
|
|
373
|
+
| 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 |
|
|
374
|
+
| 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 |
|
|
375
|
+
| 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 |
|
|
376
|
+
| 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 |
|
|
377
|
+
| 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
378
|
| Hermes, OpenClaw, custom harnesses | Lifecycle adapter required | Adapter or governed runner required | Adapter dependent | Adapter dependent | Adapter supplied | Adapter owned |
|
|
358
379
|
|
|
359
380
|
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.
|
package/bin/marrow-install.js
CHANGED
|
@@ -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
|
@@ -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
|
|
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
|
|
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: '
|
|
58
|
-
command:
|
|
59
|
-
instruction: 'After restart
|
|
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:
|
|
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
|
|
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
|
|
package/src/governed-runner.js
CHANGED
|
@@ -28,6 +28,7 @@ const {
|
|
|
28
28
|
detectEnvironment,
|
|
29
29
|
} = require('./installer');
|
|
30
30
|
const { createHostUsageCapture } = require('./usage-telemetry');
|
|
31
|
+
const { readLocalControlState, recordGovernedBypass } = require('./control-state');
|
|
31
32
|
|
|
32
33
|
const DEFAULT_BASE_URL = 'https://api.getmarrow.ai';
|
|
33
34
|
const MCP_SETUP_COMMAND = `npx -y --package=${ADAPTER_PROVENANCE.mcp.package}@${ADAPTER_PROVENANCE.mcp.version} marrow-mcp setup`;
|
|
@@ -786,6 +787,18 @@ async function runGoverned(parsed, execution = {}) {
|
|
|
786
787
|
let protectedAction = risky;
|
|
787
788
|
const surfaces = inferSurfaces(commandText || action);
|
|
788
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
|
+
|
|
789
802
|
try {
|
|
790
803
|
runtime = await preflightRuntime(options, action, type, commandText);
|
|
791
804
|
decision = gateDecision(runtime);
|
|
@@ -1022,6 +1035,16 @@ async function gateOnly(parsed) {
|
|
|
1022
1035
|
const { options } = parsed;
|
|
1023
1036
|
const action = redact(options.action);
|
|
1024
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
|
+
}
|
|
1025
1048
|
const runtime = await preflightRuntime(options, action, type, action);
|
|
1026
1049
|
const decision = gateDecision(runtime);
|
|
1027
1050
|
printGate(decision, runtime);
|