@getmarrow/install 0.1.34 → 0.1.36

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
@@ -62,9 +62,42 @@ Required secret:
62
62
  export MARROW_API_KEY=mrw_live_...
63
63
  ```
64
64
 
65
- ## What's New in v0.1.34
65
+ ## Keeping Marrow Current
66
66
 
67
- v0.1.34 verifies whether passive governance is actually active after install. Activation now registers a bounded capability profile, a one-way configuration fingerprint, expected and observed hook surfaces, and a server-accepted lifecycle receipt. The Fleet Operator shows activation state, capture coverage, outcome closure, intervention follow-through, drift, and the exact repair:
67
+ 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.
68
+
69
+ ```bash
70
+ npx -y @getmarrow/install@latest activate
71
+ npx -y @getmarrow/install@latest doctor
72
+
73
+ # Use only when doctor reports drift
74
+ npx -y @getmarrow/install@latest --repair
75
+ ```
76
+
77
+ `activate` reconciles Marrow-managed entries while retaining unrelated user hooks and configuration. Detection and notification are automatic; local changes remain explicit and subject to the operator's normal change policy.
78
+
79
+ ## What's New in v0.1.36
80
+
81
+ v0.1.36 combines guided, operator-controlled client updates with a signed permit boundary for protected actions. Installer status, activation reports, and the Fleet Operator expose request-specific update advisories with exact update and verification commands while keeping local mutation explicit:
82
+
83
+ - official installer requests identify the installed `@getmarrow/install` version;
84
+ - status, self-test, and Fleet Operator output show recommended, unrecognized, and security-required update states without conflating them;
85
+ - generated agent instructions tell the agent to notify the operator and obey local change policy;
86
+ - certified activation pins the matching MCP and SDK releases, including exact SDK registry integrity;
87
+ - `activate`, `doctor`, and `--repair` remain explicit commands and preserve unrelated hooks and configuration.
88
+
89
+ The governed runner makes protected actions executable only through a short-lived, signed Marrow permit bound to the exact account, agent, session, action, target, canonical action surfaces, runtime gate, and decision before starting the child process. It then closes that permit with exact evidence and the real outcome:
90
+
91
+ - deploy, publish, merge, migration, credential, and other protected work fails closed when its permit cannot be verified;
92
+ - the child process receives only the scoped permit, never the Marrow API key through a new broker interface;
93
+ - permits are single-use, expire within minutes, and cannot be replayed for another agent, action, target, or session;
94
+ - `permit` and `verify-permit` provide deterministic CI choke points;
95
+ - the loopback `sidecar` keeps private state owner-only and reports hook/configuration drift;
96
+ - `coverage` reports permit closure, bypasses, stale sidecars, and hook health with exact repair steps;
97
+ - correlated result hooks can close evidence automatically, while incomplete protected work remains visible;
98
+ - controlled break-glass access requires an authenticated account owner, a current runtime gate, a reason, a short expiry, and evidence closure.
99
+
100
+ It preserves the measurable passive-governance coverage introduced in v0.1.34:
68
101
 
69
102
  - Claude Code installation includes exact `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, and `Stop` hooks;
70
103
  - matching pre-action/result receipts use one tool correlation, and activation fingerprints the exact hook contract without uploading configuration contents;
@@ -153,15 +186,20 @@ npx @getmarrow/install run \
153
186
  The runner:
154
187
 
155
188
  1. requests the Marrow runtime gate;
156
- 2. prints the decision, relevant lesson, owner-approval state, and required proof;
157
- 3. blocks when policy requires it;
158
- 4. runs the original command when allowed;
159
- 5. records success or failure and attaches a redacted proof pack.
189
+ 2. records the governed decision against that exact gate;
190
+ 3. requests and verifies a single-use permit bound to the exact action, target, and canonical action surfaces;
191
+ 4. blocks protected work if policy or permit verification fails;
192
+ 5. runs the original command with the scoped permit, not the Marrow API key;
193
+ 6. records success or failure, supplies every exact server-required proof field through a redacted proof pack, and closes the permit.
160
194
 
161
195
  Useful commands:
162
196
 
163
197
  ```bash
164
198
  npx @getmarrow/install gate --agent deploy-agent --type deploy --action "deploy production"
199
+ npx @getmarrow/install permit --agent deploy-agent --type deploy --action "deploy production"
200
+ MARROW_ACTION_PERMIT=... npx @getmarrow/install verify-permit --agent deploy-agent --type deploy --action "deploy production"
201
+ npx @getmarrow/install coverage --agent deploy-agent
202
+ npx @getmarrow/install sidecar --agent deploy-agent
165
203
  npx @getmarrow/install status
166
204
  npx @getmarrow/install doctor
167
205
  npx @getmarrow/install --repair
@@ -4,7 +4,7 @@ const installer = require('../src/installer');
4
4
  const governedRunner = require('../src/governed-runner');
5
5
 
6
6
  const argv = process.argv.slice(2);
7
- const governedCommands = new Set(['run', 'gate', 'proof', 'status', 'govern', 'fleet', 'hermes', 'openclaw', 'integrations']);
7
+ const governedCommands = new Set(['run', 'gate', 'proof', 'status', 'govern', 'fleet', 'hermes', 'openclaw', 'integrations', 'permit', 'verify-permit', 'coverage', 'sidecar']);
8
8
  const runCli = governedCommands.has(argv[0]) ? governedRunner.runCli : installer.runCli;
9
9
 
10
10
  runCli(argv).catch((error) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getmarrow/install",
3
- "version": "0.1.34",
3
+ "version": "0.1.36",
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,94 @@
1
+ const crypto = require('node:crypto');
2
+
3
+ function sha256(value) {
4
+ return crypto.createHash('sha256').update(String(value || '')).digest('hex');
5
+ }
6
+
7
+ function actionBinding(input) {
8
+ const action = String(input.action || '').trim();
9
+ const actionType = String(input.type || 'general').trim().toLowerCase();
10
+ const target = String(input.target || '').trim();
11
+ return {
12
+ action,
13
+ action_type: actionType,
14
+ target: target || action,
15
+ action_hash: sha256(action),
16
+ target_hash: sha256(target || action),
17
+ };
18
+ }
19
+
20
+ async function enforcementRequest(requestJson, options, operation, input = {}) {
21
+ return requestJson(options, 'POST', '/v1/agent/enforcement', {
22
+ operation,
23
+ ...input,
24
+ });
25
+ }
26
+
27
+ async function issueActionPermit(requestJson, options, input) {
28
+ const binding = actionBinding(input);
29
+ return enforcementRequest(requestJson, options, 'issue', {
30
+ ...binding,
31
+ session_id: options.sessionId,
32
+ agent_id: options.agentId,
33
+ harness: options.client,
34
+ policy_mode: options.policy,
35
+ decision_id: input.decisionId || null,
36
+ gate_receipt_id: input.gateReceiptId || null,
37
+ owner_approval_receipt_id: input.ownerApproval || null,
38
+ surfaces: Array.isArray(input.surfaces) ? input.surfaces : [],
39
+ proof_requirements: Array.isArray(input.proofRequirements) ? input.proofRequirements : [],
40
+ });
41
+ }
42
+
43
+ async function verifyActionPermit(requestJson, options, input) {
44
+ const binding = actionBinding(input);
45
+ return enforcementRequest(requestJson, options, 'verify', {
46
+ ...binding,
47
+ surfaces: Array.isArray(input.surfaces) ? input.surfaces : [],
48
+ permit: input.permit,
49
+ session_id: options.sessionId,
50
+ agent_id: options.agentId,
51
+ harness: options.client,
52
+ });
53
+ }
54
+
55
+ async function closeActionPermit(requestJson, options, input) {
56
+ return enforcementRequest(requestJson, options, 'close', {
57
+ permit: input.permit,
58
+ permit_id: input.permitId || null,
59
+ decision_id: input.decisionId || null,
60
+ session_id: options.sessionId,
61
+ agent_id: options.agentId,
62
+ success: Boolean(input.success),
63
+ evidence: input.evidence || {},
64
+ });
65
+ }
66
+
67
+ async function recordEnforcementHeartbeat(requestJson, options, input = {}) {
68
+ return enforcementRequest(requestJson, options, 'heartbeat', {
69
+ session_id: options.sessionId,
70
+ agent_id: options.agentId,
71
+ harness: options.client,
72
+ sidecar_instance_id: input.sidecarInstanceId || null,
73
+ config_fingerprint: input.configFingerprint || null,
74
+ expected_hooks: input.expectedHooks || ['pre_action', 'action_result', 'outcome_closure'],
75
+ observed_hooks: input.observedHooks || ['pre_action'],
76
+ });
77
+ }
78
+
79
+ async function readEnforcementCoverage(requestJson, options) {
80
+ const query = new URLSearchParams();
81
+ if (options.agentId) query.set('agent_id', options.agentId);
82
+ return requestJson(options, 'GET', `/v1/agent/enforcement${query.size ? `?${query}` : ''}`);
83
+ }
84
+
85
+ module.exports = {
86
+ actionBinding,
87
+ sha256,
88
+ enforcementRequest,
89
+ issueActionPermit,
90
+ verifyActionPermit,
91
+ closeActionPermit,
92
+ recordEnforcementHeartbeat,
93
+ readEnforcementCoverage,
94
+ };
@@ -0,0 +1,213 @@
1
+ const crypto = require('node:crypto');
2
+ const fs = require('node:fs');
3
+ const http = require('node:http');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+
7
+ const MAX_BODY_BYTES = 64 * 1024;
8
+
9
+ function sidecarStateDir() {
10
+ return process.env.MARROW_SIDECAR_STATE_DIR || path.join(os.homedir(), '.marrow', 'sidecar');
11
+ }
12
+
13
+ function currentUid() {
14
+ return typeof process.getuid === 'function' ? process.getuid() : null;
15
+ }
16
+
17
+ function createPrivateDirectoryWithoutSymlinks(directory) {
18
+ const resolved = path.resolve(directory);
19
+ const parsed = path.parse(resolved);
20
+ let current = parsed.root;
21
+ for (const segment of resolved.slice(parsed.root.length).split(path.sep).filter(Boolean)) {
22
+ current = path.join(current, segment);
23
+ try {
24
+ fs.mkdirSync(current, { mode: 0o700 });
25
+ } catch (error) {
26
+ if (error?.code !== 'EEXIST') throw error;
27
+ }
28
+ const stat = fs.lstatSync(current);
29
+ if (stat.isSymbolicLink() || !stat.isDirectory() || fs.realpathSync(current) !== current) {
30
+ throw new Error('Sidecar state directory cannot contain symlinked path components.');
31
+ }
32
+ }
33
+ return resolved;
34
+ }
35
+
36
+ function assertPrivateStateDirectory(directory) {
37
+ const resolved = createPrivateDirectoryWithoutSymlinks(directory);
38
+ const stat = fs.lstatSync(resolved);
39
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
40
+ throw new Error('Sidecar state directory must be a private real directory.');
41
+ }
42
+ if (fs.realpathSync(resolved) !== resolved) {
43
+ throw new Error('Sidecar state directory cannot contain symlinked path components.');
44
+ }
45
+ const uid = currentUid();
46
+ if (uid !== null && stat.uid !== uid) {
47
+ throw new Error('Sidecar state directory must be owned by the current user.');
48
+ }
49
+ if ((stat.mode & 0o077) !== 0) {
50
+ throw new Error('Sidecar state directory permissions must be 0700 or stricter.');
51
+ }
52
+ return resolved;
53
+ }
54
+
55
+ function assertSafeStateFile(filePath) {
56
+ if (!fs.existsSync(filePath)) return;
57
+ const stat = fs.lstatSync(filePath);
58
+ const uid = currentUid();
59
+ if (stat.isSymbolicLink() || !stat.isFile()) {
60
+ throw new Error('Sidecar state file must be a private regular file.');
61
+ }
62
+ if (uid !== null && stat.uid !== uid) {
63
+ throw new Error('Sidecar state file must be owned by the current user.');
64
+ }
65
+ if ((stat.mode & 0o077) !== 0) {
66
+ throw new Error('Sidecar state file permissions must be 0600 or stricter.');
67
+ }
68
+ }
69
+
70
+ function writePrivateJsonAtomic(filePath, value) {
71
+ const directory = assertPrivateStateDirectory(path.dirname(filePath));
72
+ const target = path.join(directory, path.basename(filePath));
73
+ assertSafeStateFile(target);
74
+ const temporary = path.join(directory, '.active-' + process.pid + '-' + crypto.randomBytes(8).toString('hex') + '.tmp');
75
+ let descriptor;
76
+ try {
77
+ descriptor = fs.openSync(temporary, 'wx', 0o600);
78
+ fs.writeFileSync(descriptor, JSON.stringify(value, null, 2) + '\n', 'utf8');
79
+ fs.fsyncSync(descriptor);
80
+ fs.closeSync(descriptor);
81
+ descriptor = undefined;
82
+ assertSafeStateFile(target);
83
+ fs.renameSync(temporary, target);
84
+ fs.chmodSync(target, 0o600);
85
+ } finally {
86
+ if (descriptor !== undefined) {
87
+ try { fs.closeSync(descriptor); } catch {}
88
+ }
89
+ try { fs.unlinkSync(temporary); } catch {}
90
+ }
91
+ }
92
+
93
+ function unlinkPrivateStateFile(filePath) {
94
+ try {
95
+ const stat = fs.lstatSync(filePath);
96
+ const uid = currentUid();
97
+ if (!stat.isSymbolicLink() && stat.isFile() && (uid === null || stat.uid === uid)) {
98
+ fs.unlinkSync(filePath);
99
+ }
100
+ } catch {}
101
+ }
102
+
103
+ async function readJson(req) {
104
+ const chunks = [];
105
+ let bytes = 0;
106
+ for await (const chunk of req) {
107
+ bytes += chunk.length;
108
+ if (bytes > MAX_BODY_BYTES) throw new Error('request_too_large');
109
+ chunks.push(chunk);
110
+ }
111
+ if (chunks.length === 0) return {};
112
+ const value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
113
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('invalid_json');
114
+ return value;
115
+ }
116
+
117
+ function json(res, status, value) {
118
+ res.writeHead(status, {
119
+ 'Content-Type': 'application/json; charset=utf-8',
120
+ 'Cache-Control': 'no-store',
121
+ 'X-Content-Type-Options': 'nosniff',
122
+ });
123
+ res.end(JSON.stringify(value));
124
+ }
125
+
126
+ async function startGovernanceSidecar(options, handlers) {
127
+ if (!options.apiKey) throw new Error('MARROW_API_KEY is required to start the governance sidecar.');
128
+ const port = Number(options.sidecarPort || process.env.MARROW_SIDECAR_PORT || 0);
129
+ if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('Invalid sidecar port.');
130
+ const authToken = crypto.randomBytes(32).toString('hex');
131
+ const instanceId = `sidecar-${crypto.randomUUID()}`;
132
+ const startedAt = new Date().toISOString();
133
+ let latestCoverage = null;
134
+
135
+ const server = http.createServer(async (req, res) => {
136
+ try {
137
+ if (req.socket.remoteAddress !== '127.0.0.1' && req.socket.remoteAddress !== '::1') {
138
+ return json(res, 403, { ok: false, error: 'loopback_only' });
139
+ }
140
+ if (req.headers.authorization !== `Bearer ${authToken}`) {
141
+ return json(res, 401, { ok: false, error: 'invalid_sidecar_token' });
142
+ }
143
+ const url = new URL(req.url || '/', 'http://127.0.0.1');
144
+ if (req.method === 'GET' && url.pathname === '/health') {
145
+ return json(res, 200, { ok: true, instance_id: instanceId, started_at: startedAt });
146
+ }
147
+ if (req.method === 'GET' && url.pathname === '/coverage') {
148
+ latestCoverage = await handlers.coverage();
149
+ return json(res, 200, latestCoverage);
150
+ }
151
+ if (req.method === 'POST' && ['/permit', '/verify', '/close'].includes(url.pathname)) {
152
+ const body = await readJson(req);
153
+ const operation = url.pathname.slice(1);
154
+ return json(res, 200, await handlers[operation](body));
155
+ }
156
+ return json(res, 404, { ok: false, error: 'not_found' });
157
+ } catch (error) {
158
+ return json(res, error?.message === 'request_too_large' ? 413 : 400, {
159
+ ok: false,
160
+ error: error instanceof Error ? error.message : 'sidecar_request_failed',
161
+ });
162
+ }
163
+ });
164
+
165
+ await new Promise((resolve, reject) => {
166
+ server.once('error', reject);
167
+ server.listen(port, '127.0.0.1', resolve);
168
+ });
169
+ const address = server.address();
170
+ const boundPort = typeof address === 'object' && address ? address.port : port;
171
+ const stateFile = path.join(sidecarStateDir(), 'active.json');
172
+ try {
173
+ writePrivateJsonAtomic(stateFile, {
174
+ instance_id: instanceId,
175
+ pid: process.pid,
176
+ host: '127.0.0.1',
177
+ port: boundPort,
178
+ token: authToken,
179
+ started_at: startedAt,
180
+ });
181
+ } catch (error) {
182
+ await new Promise((resolve) => server.close(resolve));
183
+ throw error;
184
+ }
185
+
186
+ const heartbeat = async () => {
187
+ try {
188
+ latestCoverage = await handlers.heartbeat({ sidecarInstanceId: instanceId });
189
+ } catch {
190
+ // Coverage will mark stale heartbeat; never weaken execution policy here.
191
+ }
192
+ };
193
+ await heartbeat();
194
+ const timer = setInterval(heartbeat, 30_000);
195
+ timer.unref();
196
+
197
+ let closed = false;
198
+ const close = () => {
199
+ if (closed) return;
200
+ closed = true;
201
+ clearInterval(timer);
202
+ unlinkPrivateStateFile(stateFile);
203
+ process.off('SIGINT', close);
204
+ process.off('SIGTERM', close);
205
+ server.close();
206
+ };
207
+ process.once('SIGINT', close);
208
+ process.once('SIGTERM', close);
209
+
210
+ return { server, instanceId, port: boundPort, stateFile, close };
211
+ }
212
+
213
+ module.exports = { startGovernanceSidecar, sidecarStateDir };
@@ -4,9 +4,73 @@ const fs = require('node:fs');
4
4
  const os = require('node:os');
5
5
  const path = require('node:path');
6
6
  const readline = require('node:readline');
7
+ const { version: INSTALLER_PACKAGE_VERSION } = require('../package.json');
8
+ const {
9
+ actionBinding,
10
+ closeActionPermit,
11
+ issueActionPermit,
12
+ readEnforcementCoverage,
13
+ recordEnforcementHeartbeat,
14
+ verifyActionPermit,
15
+ } = require('./enforcement-client');
16
+ const { startGovernanceSidecar } = require('./governance-sidecar');
7
17
 
8
18
  const DEFAULT_BASE_URL = 'https://api.getmarrow.ai';
9
19
  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;
20
+ const PROTECTED_COMMAND_PATTERNS = [
21
+ /\b(?:npm|pnpm|yarn)(?:\s+npm)?\b[\s\S]{0,8192}\b(?:publish|unpublish|deprecate|access|owner|team|token|login|logout|profile\s+(?:set|enable-2fa|disable-2fa)|dist-tag|tag\s+(?:add|remove))\b/i,
22
+ /\b(?:cargo\s+(?:publish|yank|owner)|twine\s+upload|gem\s+(?:push|yank|owner)|(?:dotnet\s+nuget|nuget)\s+(?:push|delete))\b/i,
23
+ /\bgit\b[\s\S]{0,8192}\b(?:push|commit|merge|rebase|reset|tag|clean|rm|cherry-pick|revert|worktree\s+(?:add|move|remove|prune|repair|lock|unlock)|branch\s+(?:-[dDmM]|--delete|--move)|remote\s+(?:add|remove|rename|set-url|set-head|prune|update)|checkout\s+-[bB]|switch\s+-[cC])\b/i,
24
+ /\bgh\b[\s\S]{0,8192}\b(?:auth\s+logout|pr\s+(?:merge|close|reopen|edit|review|comment)|issue\s+(?:create|close|reopen|edit|comment)|run\s+(?:cancel|delete|rerun)|release\s+(?:create|delete|edit|upload)|repo\s+(?:archive|delete|edit|fork|rename)|workflow\s+run|secret\s+(?:set|delete)|variable\s+(?:set|delete))\b/i,
25
+ /\bgh\s+api\b[\s\S]{0,8192}(?:(?:--method|-X)(?:=|\s+)(?:POST|PUT|PATCH|DELETE)\b|(?:-f|-F|--field|--raw-field|--input)(?:=|\s+))/i,
26
+ /\b(?:kubectl|oc)\b[\s\S]{0,8192}\b(?:apply|create|delete|edit|patch|replace|rollout|scale|set|drain|cordon|uncordon|taint|exec|cp|run|expose|autoscale|label|annotate|reconcile|certificate\s+(?:approve|deny))\b/i,
27
+ /\b(?:terraform|terragrunt|tofu)\b[\s\S]{0,8192}\b(?:apply|destroy|import|taint|untaint|force-unlock|state\s+(?:mv|rm|push|replace-provider)|workspace\s+(?:new|delete))\b/i,
28
+ /\bpulumi\b[\s\S]{0,8192}\b(?:up|destroy|import|refresh|stack\s+rm|config\s+(?:set|rm))\b/i,
29
+ /\bhelm\b[\s\S]{0,8192}\b(?:install|upgrade|uninstall|rollback|push)\b/i,
30
+ /\bflux\b[\s\S]{0,8192}\b(?:bootstrap|create|delete|install|reconcile|resume|suspend|tag|uninstall)\b/i,
31
+ /\bnomad\b[\s\S]{0,8192}\b(?:job\s+(?:dispatch|plan|promote|run|scale|stop)|alloc\s+stop|deployment\s+(?:fail|promote)|acl\s+(?:bootstrap|policy|role|token))\b/i,
32
+ /\bcdk\b[\s\S]{0,8192}\b(?:bootstrap|deploy|destroy|import|rollback)\b/i,
33
+ /\bansible-playbook\b/i,
34
+ /\b(?:docker|podman)\b[\s\S]{0,8192}\b(?:push|buildx\s+build\b[\s\S]*--push)\b/i,
35
+ /\bwrangler\b[\s\S]{0,8192}\b(?:deploy|delete|rollback|execute|apply|put|bulk|secret|publish)\b/i,
36
+ /\bcurl\b[\s\S]{0,8192}(?:(?:-X\s*|--request(?:=|\s+))(?:POST|PUT|PATCH|DELETE)\b|--(?:json|data(?:-ascii|-raw|-binary|-urlencode)?)(?:=|\s+)|-[dF](?:\s+|[^A-Za-z])|--form(?:-string)?(?:=|\s+)|(?:-T|--upload-file)(?:=|\s+))/i,
37
+ /\b(?:http|xh)\b[\s\S]{0,8192}(?:\b(?:POST|PUT|PATCH|DELETE)\b|(?:--form|--raw|-f)\b|\s[^\s=:@]+(?::=|=|@))/i,
38
+ /\bwget\b[\s\S]{0,8192}(?:--post-data|--post-file|--body-data|--body-file|--method(?:=|\s+)(?:POST|PUT|PATCH|DELETE))\b/i,
39
+ /\b(?:psql|mysql|sqlite3|duckdb)\b[\s\S]{0,8192}(?:\b(?:drop|delete|update|insert|alter|truncate|create|grant|revoke|call|do)\b|(?:-f|--file|\.read|source)(?:=|\s+)|\s<\s*[^\s])/i,
40
+ /\bredis-cli\b[\s\S]{0,8192}\b(?:set|setex|psetex|mset|del|unlink|getdel|incr|decr|append|expire|persist|rename|move|flushall|flushdb|shutdown|eval|evalsha|fcall|fcall_ro|function|script\s+(?:load|flush|kill)|config\s+set|acl\s+setuser|hset|hdel|lpush|rpush|lpop|rpop|sadd|srem|zadd|zrem|xadd|xdel|publish|restore|migrate)\b/i,
41
+ /\baws\b[\s\S]{0,8192}\b(?:create|update|delete|put|attach|detach|associate|disassociate|terminate|stop|start|reboot|modify|restore|rotate|tag|untag|deploy|sync|s3\s+(?:cp|mv|rm)|s3api\s+put-object|ssm\s+(?:put-parameter|delete-parameter|delete-parameters))\b/i,
42
+ /\bgcloud\b[\s\S]{0,8192}\b(?:create|update|delete|deploy|add|remove|set|destroy|disable|restore|storage\s+(?:cp|mv|rm|rsync)|pubsub\s+(?:topics|subscriptions)\s+(?:create|delete|update))\b/i,
43
+ /\baz\b[\s\S]{0,8192}\b(?:create|update|delete|set|deploy|start|stop|restart|restore|storage\s+blob\s+(?:upload|delete|copy)|group\s+(?:create|delete|update))\b/i,
44
+ /\brclone\b[\s\S]{0,8192}\b(?:copy|copyto|sync|move|moveto|delete|deletefile|purge|mkdir|rmdir|bisync)\b/i,
45
+ /\bgsutil\b[\s\S]{0,8192}\b(?:cp|mv|rm|rsync|setacl|setmeta|web)\b/i,
46
+ /(?:^|[;&|]\s*|\bsudo\s+|\benv\s+)mc\b[\s\S]{0,8192}\b(?:cp|mv|rm|mirror|mb|rb|anonymous|admin)\b/i,
47
+ /\boci\b[\s\S]{0,8192}\bos\b[\s\S]{0,8192}\b(?:put|upload|bulk-upload|delete|rename|restore|reencrypt)\b/i,
48
+ /\b(?:vault|op)\b[\s\S]{0,8192}\b(?:write|put|patch|delete|edit|create|move|rotate|revoke|destroy|share)\b/i,
49
+ /\bpass\b[\s\S]{0,8192}\b(?:insert|edit|generate|rm|remove|mv|cp|init|git)\b/i,
50
+ /(?:^|[\s;&|]|\bsudo\s+|\benv\s+)(?:(?:\/[^\s/]+)*\/)?(?:rm\b|unlink\b|shred\b|truncate\b|dd\b[\s\S]{0,8192}\bof=|find\b[\s\S]{0,8192}\s-delete\b|xargs\b[\s\S]{0,8192}(?:(?:\/[^\s/]+)*\/)?rm\b)/i,
51
+ ];
52
+ const PROTECTED_ACTION_TYPES = new Set([
53
+ 'credential',
54
+ 'credentials',
55
+ 'deploy',
56
+ 'financial',
57
+ 'merge',
58
+ 'migration',
59
+ 'production',
60
+ 'publish',
61
+ 'release',
62
+ 'secret',
63
+ 'security',
64
+ ]);
65
+ const SAFE_MARROW_CHILD_METADATA = new Set([
66
+ 'MARROW_AGENT_ID',
67
+ 'MARROW_AGENT_CLIENT',
68
+ 'MARROW_CLIENT',
69
+ 'MARROW_FLEET_AGENT_ID',
70
+ 'MARROW_GOVERN_PROFILE',
71
+ 'MARROW_HARNESS',
72
+ 'MARROW_SESSION_ID',
73
+ ]);
10
74
  const GOVERN_TUI_ROW_COUNT = 7;
11
75
  const FLEET_TUI_ROW_COUNT = 12;
12
76
  function usage() {
@@ -16,6 +80,10 @@ function usage() {
16
80
  npx @getmarrow/install gate "deploy production worker"
17
81
  npx @getmarrow/install proof --decision-id <id> --success --summary "smoke passed"
18
82
  npx @getmarrow/install status
83
+ npx @getmarrow/install permit --action "deploy production" --type deploy
84
+ MARROW_ACTION_PERMIT=... npx @getmarrow/install verify-permit --action "deploy production" --type deploy
85
+ npx @getmarrow/install coverage
86
+ npx @getmarrow/install sidecar
19
87
  npx @getmarrow/install govern
20
88
  npx @getmarrow/install govern --no-interactive
21
89
  npx @getmarrow/install fleet
@@ -28,6 +96,10 @@ Commands:
28
96
  gate Check Marrow runtime/gate for an action without running a command
29
97
  proof Commit an outcome/proof for an existing decision
30
98
  status Read /v1/agent/status
99
+ permit Issue a short-lived action-bound permit after Marrow policy evaluation
100
+ verify-permit Verify a permit before CI, deploy, publish, merge, migration, or credential access
101
+ coverage Show enforcement, hook-health, closure, and bypass coverage
102
+ sidecar Run the loopback-only Marrow governance sidecar
31
103
  govern Interactive setup TUI when run in a terminal; text panel in CI/non-TTY
32
104
  fleet Fleet operator TUI for live agents, workflows, gates, proof debt, and exact fixes
33
105
  integrations List Marrow-supported harness add-ons
@@ -41,9 +113,12 @@ Options:
41
113
  --action <text> Human-readable action. Defaults to the redacted command
42
114
  --profile <name> Policy profile label, such as dev, staging, or production
43
115
  --policy <mode> enforce, warn, or audit. Default: enforce
44
- --fail-open If Marrow is unreachable, run anyway and mark telemetry degraded
116
+ --fail-open For non-protected, low-risk actions only, run if Marrow is unreachable
45
117
  --fail-closed If Marrow is unreachable, block the command
46
118
  --owner-approved <ref> Owner approval reference for review-required gates
119
+ --permit <token> Short-lived action permit. Prefer MARROW_ACTION_PERMIT
120
+ --target <text> Protected target binding, such as repository/environment
121
+ --sidecar-port <port> Loopback sidecar port. Default: ephemeral
47
122
  --proof-file <path> JSON proof to include on outcome commit
48
123
  --client <label> Harness/client label. Defaults to MARROW_CLIENT, MARROW_HARNESS, or MARROW_AGENT_CLIENT
49
124
  --base-url <url> Marrow API base URL
@@ -78,6 +153,14 @@ function redactedCommand(command) {
78
153
  return command.map((part) => shellQuote(redact(part))).join(' ');
79
154
  }
80
155
 
156
+ function isProtectedCommand(text) {
157
+ const raw = String(text || '');
158
+ if (raw.length > 8192) return true;
159
+ const value = raw.slice(0, 8192);
160
+ return HIGH_RISK_TERMS.test(value)
161
+ || PROTECTED_COMMAND_PATTERNS.some((pattern) => pattern.test(value));
162
+ }
163
+
81
164
  function normalizeClientLabel(value) {
82
165
  const raw = String(value || '').trim().toLowerCase();
83
166
  if (!raw) return '';
@@ -128,9 +211,11 @@ function shellQuoteDisplay(value) {
128
211
 
129
212
  function inferType(text) {
130
213
  const value = String(text || '').toLowerCase();
131
- if (/\b(deploy|wrangler|cloudflare|production|prod|release)\b/.test(value)) return 'deploy';
132
- if (/\b(publish|npm publish)\b/.test(value)) return 'publish';
133
- if (/\b(merge|gh pr merge)\b/.test(value)) return 'merge';
214
+ if (/\b(deploy|wrangler|cloudflare|production|prod|release)\b/.test(value)
215
+ || /\b(?:kubectl|terraform|pulumi|helm)\b/.test(value) && isProtectedCommand(value)) return 'deploy';
216
+ if (/\b(publish|unpublish|deprecate|npm publish)\b/.test(value)) return 'publish';
217
+ if (/\b(merge|gh pr merge)\b/.test(value)
218
+ || /\bgit\b[^\n;&|]{0,240}\bpush\b/.test(value)) return 'merge';
134
219
  if (/\b(migration|migrate|schema|d1 execute|drop table)\b/.test(value)) return 'migration';
135
220
  if (/\b(secret|token|key|password)\b/.test(value)) return 'security';
136
221
  if (/\b(test|check|lint|typecheck|smoke)\b/.test(value)) return 'verification';
@@ -142,6 +227,7 @@ function inferSurfaces(text) {
142
227
  const surfaces = new Set();
143
228
  if (/\b(git|gh|github)\b/.test(value)) surfaces.add('github');
144
229
  if (/\b(wrangler|cloudflare|worker|d1|r2)\b/.test(value)) surfaces.add('cloudflare');
230
+ if (/\b(kubectl|terraform|pulumi|helm|production|prod)\b/.test(value)) surfaces.add('production');
145
231
  if (/\b(npm|pnpm|yarn|publish)\b/.test(value)) surfaces.add('npm');
146
232
  if (/\b(sql|d1|migration|database|db)\b/.test(value)) surfaces.add('database');
147
233
  if (/\b(curl|api|http)\b/.test(value)) surfaces.add('api');
@@ -231,7 +317,8 @@ function detectProjectSignals(cwd = process.cwd()) {
231
317
  }
232
318
 
233
319
  function isRisky(text, type) {
234
- return HIGH_RISK_TERMS.test(`${type || ''} ${text || ''}`);
320
+ return PROTECTED_ACTION_TYPES.has(String(type || '').trim().toLowerCase())
321
+ || isProtectedCommand(`${type || ''} ${text || ''}`);
235
322
  }
236
323
 
237
324
  function parseBaseOptions(argv, startIndex = 0) {
@@ -251,6 +338,9 @@ function parseBaseOptions(argv, startIndex = 0) {
251
338
  action: '',
252
339
  client: sourceClient(),
253
340
  interactive: null,
341
+ permit: process.env.MARROW_ACTION_PERMIT || '',
342
+ target: process.env.MARROW_ACTION_TARGET || '',
343
+ sidecarPort: process.env.MARROW_SIDECAR_PORT || '0',
254
344
  };
255
345
  let i = startIndex;
256
346
  for (; i < argv.length; i += 1) {
@@ -269,6 +359,9 @@ function parseBaseOptions(argv, startIndex = 0) {
269
359
  options.failClosed = true;
270
360
  options.failOpen = false;
271
361
  } else if (arg === '--owner-approved') options.ownerApproval = argv[++i] || options.ownerApproval;
362
+ else if (arg === '--permit') options.permit = argv[++i] || options.permit;
363
+ else if (arg === '--target') options.target = argv[++i] || options.target;
364
+ else if (arg === '--sidecar-port') options.sidecarPort = argv[++i] || options.sidecarPort;
272
365
  else if (arg === '--proof-file') options.proofFile = argv[++i] || options.proofFile;
273
366
  else if (arg === '--client' || arg === '--harness') options.client = sourceClient(argv[++i] || options.client);
274
367
  else if (arg === '--base-url') options.baseUrl = argv[++i] || options.baseUrl;
@@ -310,6 +403,17 @@ function parseArgs(argv) {
310
403
  return { command, options: { ...parsed.options, action } };
311
404
  }
312
405
 
406
+ if (command === 'permit' || command === 'verify-permit') {
407
+ const parsed = parseBaseOptions(argv, 1);
408
+ const action = parsed.options.action || argv.slice(parsed.index).join(' ');
409
+ if (parsed.options.help) return { command: 'help' };
410
+ if (!action) throw new Error(`${command} requires --action or an action string`);
411
+ if (command === 'verify-permit' && !parsed.options.permit) {
412
+ throw new Error('verify-permit requires MARROW_ACTION_PERMIT or --permit');
413
+ }
414
+ return { command, options: { ...parsed.options, action } };
415
+ }
416
+
313
417
  if (command === 'proof') {
314
418
  const parsed = parseBaseOptions(argv, 1);
315
419
  const options = { ...parsed.options, decisionId: '', success: true, summary: '', outcome: '' };
@@ -327,7 +431,7 @@ function parseArgs(argv) {
327
431
  return { command, options };
328
432
  }
329
433
 
330
- if (command === 'status' || command === 'govern' || command === 'fleet' || command === 'hermes' || command === 'openclaw' || command === 'integrations') {
434
+ if (command === 'status' || command === 'govern' || command === 'fleet' || command === 'hermes' || command === 'openclaw' || command === 'integrations' || command === 'coverage' || command === 'sidecar') {
331
435
  const parsed = parseBaseOptions(argv, 1);
332
436
  if (parsed.options.help) return { command: 'help' };
333
437
  return { command, options: parsed.options };
@@ -343,6 +447,8 @@ function headers(options) {
343
447
  'X-Marrow-Agent-Id': options.agentId,
344
448
  'X-Marrow-Session-Id': options.sessionId,
345
449
  'X-Marrow-Client': sourceClient(options.client),
450
+ 'X-Marrow-Package': '@getmarrow/install',
451
+ 'X-Marrow-Package-Version': INSTALLER_PACKAGE_VERSION,
346
452
  'User-Agent': '@getmarrow/install governed-runner',
347
453
  };
348
454
  return h;
@@ -411,11 +517,14 @@ function defaultProof(input) {
411
517
  }
412
518
 
413
519
  async function preflightRuntime(options, action, type, commandText) {
520
+ const target = options.target || commandText || action;
521
+ const surfaces = inferSurfaces(commandText || action);
414
522
  const meta = sourceMeta(options, 'runtime', { action, command: commandText, action_type: type });
415
523
  return requestJson(options, 'POST', '/v1/agent/runtime', {
416
524
  action,
417
525
  type,
418
- surfaces: inferSurfaces(commandText || action),
526
+ target,
527
+ surfaces,
419
528
  source_meta: meta,
420
529
  context: {
421
530
  runner: '@getmarrow/install run',
@@ -481,6 +590,7 @@ function gateDecision(runtime) {
481
590
  return {
482
591
  decision: gate.enforcement_decision || receipt.decision || gate.decision || 'unknown',
483
592
  allow: gate.allow !== false,
593
+ riskLevel: String(gate.risk_level || receipt.risk_level || runtime?.risk_level || '').trim().toLowerCase(),
484
594
  required: Boolean(receipt.required || gate.gate_required),
485
595
  ownerApprovalRequired: Boolean(receipt.owner_approval_required || gate.owner_approval_required),
486
596
  receiptId: receipt.id || gate.gate_receipt_id || '',
@@ -522,11 +632,28 @@ function runChild(command, env = process.env) {
522
632
  });
523
633
  }
524
634
 
525
- async function createDecision(options, action, type) {
635
+ function scopedExecutionEnv(permit) {
636
+ const env = {};
637
+ for (const [name, value] of Object.entries(process.env)) {
638
+ if (name.startsWith('MARROW_') && !SAFE_MARROW_CHILD_METADATA.has(name)) continue;
639
+ if (name.startsWith('ACTION_PERMIT_')) continue;
640
+ env[name] = value;
641
+ }
642
+ if (permit?.permit) {
643
+ env.MARROW_ACTION_PERMIT = permit.permit;
644
+ env.MARROW_ACTION_PERMIT_ID = String(permit.permit_id || '');
645
+ env.MARROW_GOVERNANCE_VERIFIED = 'true';
646
+ }
647
+ return env;
648
+ }
649
+
650
+ async function createDecision(options, action, type, target, surfaces) {
526
651
  const meta = sourceMeta(options, 'think', { action, action_type: type });
527
652
  return requestJson(options, 'POST', '/v1/agent/think', {
528
653
  action,
529
654
  type,
655
+ target,
656
+ surfaces,
530
657
  source_meta: meta,
531
658
  context: {
532
659
  runner: '@getmarrow/install run',
@@ -553,11 +680,16 @@ async function runGoverned(parsed) {
553
680
  const { options, childCommand } = parsed;
554
681
  const commandText = redactedCommand(childCommand);
555
682
  const action = options.action ? redact(options.action) : commandText;
556
- const type = options.type || inferType(`${action} ${commandText}`);
557
- const risky = isRisky(`${action} ${commandText}`, type);
683
+ const riskText = `${action} ${commandText} ${options.target || ''}`;
684
+ const type = options.type || inferType(riskText);
685
+ const risky = isRisky(riskText, type);
558
686
  let runtime = null;
559
687
  let decision = null;
560
688
  let decisionId = '';
689
+ let actionPermit = null;
690
+ let permitEnforcementStarted = false;
691
+ let permitVerified = false;
692
+ const surfaces = inferSurfaces(commandText || action);
561
693
 
562
694
  try {
563
695
  runtime = await preflightRuntime(options, action, type, commandText);
@@ -575,10 +707,41 @@ async function runGoverned(parsed) {
575
707
  message: decision.exactNextAction || 'Marrow blocked this action before execution.',
576
708
  };
577
709
  }
578
- const think = await createDecision(options, action, type);
710
+ const target = options.target || commandText;
711
+ const think = await createDecision(options, action, type, target, surfaces);
579
712
  decisionId = think.decision_id || think.id || think.decision?.id || '';
713
+ permitEnforcementStarted = true;
714
+ actionPermit = await issueActionPermit(requestJson, options, {
715
+ action,
716
+ type,
717
+ target,
718
+ surfaces,
719
+ decisionId,
720
+ gateReceiptId: decision?.receiptId || '',
721
+ ownerApproval: options.ownerApproval,
722
+ proofRequirements: decision?.proofPack?.required_fields || decision?.proofPack?.missing || [],
723
+ });
724
+ if (!actionPermit?.permit || !actionPermit?.permit_id) {
725
+ throw new Error('Marrow did not issue a valid action permit.');
726
+ }
727
+ const verified = await verifyActionPermit(requestJson, options, {
728
+ action,
729
+ type,
730
+ target,
731
+ surfaces,
732
+ permit: actionPermit.permit,
733
+ });
734
+ if (verified?.verified !== true) throw new Error('Marrow action permit verification failed.');
735
+ permitVerified = true;
580
736
  } catch (error) {
581
- if (options.failOpen || (!risky && !options.failClosed)) {
737
+ const protectedAction = risky
738
+ || decision?.required === true
739
+ || decision?.riskLevel === 'high'
740
+ || decision?.riskLevel === 'critical';
741
+ const canDegrade = !permitEnforcementStarted
742
+ && !protectedAction
743
+ && (options.failOpen || !options.failClosed);
744
+ if (canDegrade) {
582
745
  process.stderr.write(`Marrow degraded: ${error.message}. Continuing because fail-open/non-risky policy allows it.\n`);
583
746
  } else {
584
747
  return {
@@ -594,7 +757,8 @@ async function runGoverned(parsed) {
594
757
  }
595
758
  }
596
759
 
597
- const child = await runChild(childCommand);
760
+ const childEnv = scopedExecutionEnv(actionPermit);
761
+ const child = await runChild(childCommand, childEnv);
598
762
  const success = child.exitCode === 0;
599
763
  const proof = defaultProof({ options, action, childCommand, exitCode: child.exitCode, success });
600
764
  const outcome = success
@@ -610,6 +774,21 @@ async function runGoverned(parsed) {
610
774
  }
611
775
  }
612
776
 
777
+ let permitClosed = null;
778
+ if (actionPermit?.permit) {
779
+ try {
780
+ permitClosed = await closeActionPermit(requestJson, options, {
781
+ permit: actionPermit.permit,
782
+ permitId: actionPermit.permit_id,
783
+ decisionId,
784
+ success,
785
+ evidence: proof,
786
+ });
787
+ } catch (error) {
788
+ process.stderr.write(`Marrow permit close failed: ${error.message}\n`);
789
+ }
790
+ }
791
+
613
792
  return {
614
793
  ok: success,
615
794
  blocked: false,
@@ -620,9 +799,72 @@ async function runGoverned(parsed) {
620
799
  decision,
621
800
  decision_id: decisionId,
622
801
  outcome_committed: Boolean(commit),
802
+ permit_id: actionPermit?.permit_id || null,
803
+ permit_verified: permitVerified,
804
+ permit_closed: Boolean(permitClosed),
623
805
  };
624
806
  }
625
807
 
808
+ async function permitOnly(parsed) {
809
+ const { options } = parsed;
810
+ const action = redact(options.action);
811
+ const type = options.type || inferType(action);
812
+ const target = options.target || action;
813
+ const surfaces = inferSurfaces(target);
814
+ const runtime = await preflightRuntime(options, action, type, target);
815
+ const decision = gateDecision(runtime);
816
+ if (shouldBlock(decision, options)) {
817
+ return { ok: false, blocked: true, exitCode: 12, decision, message: decision.exactNextAction };
818
+ }
819
+ const think = await createDecision(options, action, type, target, surfaces);
820
+ const decisionId = think.decision_id || think.id || think.decision?.id || '';
821
+ const result = await issueActionPermit(requestJson, options, {
822
+ action,
823
+ type,
824
+ target,
825
+ surfaces,
826
+ decisionId,
827
+ gateReceiptId: decision.receiptId,
828
+ ownerApproval: options.ownerApproval,
829
+ proofRequirements: decision?.proofPack?.required_fields || decision?.proofPack?.missing || [],
830
+ });
831
+ return { ok: true, decision_id: decisionId, ...result };
832
+ }
833
+
834
+ async function verifyPermitOnly(parsed) {
835
+ const { options } = parsed;
836
+ const action = redact(options.action);
837
+ const type = options.type || inferType(action);
838
+ const surfaces = inferSurfaces(options.target || action);
839
+ const result = await verifyActionPermit(requestJson, options, {
840
+ action,
841
+ type,
842
+ target: options.target || action,
843
+ surfaces,
844
+ permit: options.permit,
845
+ });
846
+ const verified = result?.verified === true;
847
+ return { ...result, ok: verified, exitCode: verified ? 0 : 14 };
848
+ }
849
+
850
+ async function coverageOnly(parsed) {
851
+ return readEnforcementCoverage(requestJson, parsed.options);
852
+ }
853
+
854
+ async function sidecarOnly(parsed) {
855
+ const options = parsed.options;
856
+ const sidecar = await startGovernanceSidecar(options, {
857
+ permit: (input) => issueActionPermit(requestJson, options, input),
858
+ verify: (input) => verifyActionPermit(requestJson, options, input),
859
+ close: (input) => closeActionPermit(requestJson, options, input),
860
+ coverage: () => readEnforcementCoverage(requestJson, options),
861
+ heartbeat: (input) => recordEnforcementHeartbeat(requestJson, options, input),
862
+ });
863
+ process.stdout.write(`Marrow governance sidecar active on 127.0.0.1:${sidecar.port}. Press Ctrl+C to stop.\n`);
864
+ await new Promise((resolve) => sidecar.server.once('close', resolve));
865
+ return { ok: true };
866
+ }
867
+
626
868
  async function gateOnly(parsed) {
627
869
  const { options } = parsed;
628
870
  const action = redact(options.action);
@@ -657,6 +899,31 @@ async function statusOnly(parsed) {
657
899
  return requestJson(parsed.options, 'GET', '/v1/agent/status');
658
900
  }
659
901
 
902
+ function statusPanel(status = {}) {
903
+ const update = status.client_update && typeof status.client_update === 'object'
904
+ ? status.client_update
905
+ : null;
906
+ const lines = [
907
+ 'Marrow runtime status',
908
+ `Health: ${displayText(firstDefined(status.health, status.status, status.ok === false ? 'degraded' : 'unknown'), 40)}`,
909
+ ];
910
+ const notification = update?.notification_state || update?.notification;
911
+ const priority = notification === 'security_required' || notification === 'recommended'
912
+ ? notification
913
+ : update?.version_status === 'unknown' || notification === 'unknown' || notification === 'version_unknown'
914
+ ? 'version_unknown'
915
+ : update?.priority || 'recommended';
916
+ if (update && (update.update_available === true || update.version_status === 'unknown' || notification === 'unknown' || notification === 'version_unknown' || priority === 'security_required')) {
917
+ lines.push(`Client update: ${displayText(priority, 32)}; installed=${displayText(update.installed_version || update.current_version || 'unknown', 32)}; latest=${displayText(update.latest_version || 'unknown', 32)}`);
918
+ lines.push('Automatic notification: yes; automatic local mutation: no; operator policy applies.');
919
+ if (update.update_command || update.exact_update_command) lines.push(`Update: ${displayText(update.update_command || update.exact_update_command, 240)}`);
920
+ if (update.verification_command || update.exact_verification_command) lines.push(`Verify: ${displayText(update.verification_command || update.exact_verification_command, 240)}`);
921
+ } else {
922
+ lines.push('Client update: current or unavailable.');
923
+ }
924
+ return lines.join('\n');
925
+ }
926
+
660
927
  async function optionalRequestJson(options, method, route, body) {
661
928
  try {
662
929
  return { ok: true, data: await requestJson(options, method, route, body) };
@@ -809,6 +1076,9 @@ function normalizeFixCommands(status, capacity) {
809
1076
  add(status.activation_coverage?.exact_fix);
810
1077
  add(status.activation_coverage?.drift?.repair_command, true);
811
1078
  add(status.passive_activation?.exact_fix);
1079
+ if (status.client_update?.update_available === true || status.client_update?.version_status === 'unknown' || status.client_update?.notification_state === 'unknown' || status.client_update?.notification === 'version_unknown') {
1080
+ add(status.client_update?.update_command || status.client_update?.exact_update_command, true);
1081
+ }
812
1082
  add(capacity.exact_next_action, true);
813
1083
  add(capacity.next_action, true);
814
1084
  if (listValue(status.missed_hooks, status.degraded_hooks).length) add('npx @getmarrow/install --repair');
@@ -944,6 +1214,7 @@ function normalizeFleetSnapshot(raw, options) {
944
1214
  gates,
945
1215
  arbitrations,
946
1216
  activation_coverage: activationCoverage,
1217
+ client_update: status.client_update || null,
947
1218
  agents,
948
1219
  fix_commands: fixCommands.length ? fixCommands : ['npx @getmarrow/install doctor'],
949
1220
  source_errors: errors,
@@ -1003,6 +1274,9 @@ function fleetPanel(snapshot) {
1003
1274
  `Failed/stale outcomes: ${snapshot.failed_stale_outcomes}`,
1004
1275
  `Backpressure/capacity status: ${snapshot.backpressure_status}${snapshot.capacity_next_action ? ` - ${snapshot.capacity_next_action}` : ''}`,
1005
1276
  `Degraded hooks: ${degraded}`,
1277
+ snapshot.client_update && (snapshot.client_update.update_available === true || snapshot.client_update.version_status === 'unknown' || snapshot.client_update.notification_state === 'unknown' || snapshot.client_update.notification === 'version_unknown')
1278
+ ? `Marrow client update: ${displayText(snapshot.client_update.notification_state === 'security_required' ? 'security_required' : snapshot.client_update.notification_state === 'recommended' ? 'recommended' : snapshot.client_update.version_status === 'unknown' || snapshot.client_update.notification_state === 'unknown' || snapshot.client_update.notification === 'version_unknown' ? 'version_unknown' : snapshot.client_update.priority || 'recommended', 32)}; installed=${displayText(snapshot.client_update.installed_version || snapshot.client_update.current_version || 'unknown', 32)}; latest=${displayText(snapshot.client_update.latest_version || 'unknown', 32)}; operator approval required`
1279
+ : 'Marrow client update: current or unavailable',
1006
1280
  `Deploy/publish/merge gates: deploy=${snapshot.gates.deploy} publish=${snapshot.gates.publish} merge=${snapshot.gates.merge}`,
1007
1281
  '',
1008
1282
  'Live agent roster:',
@@ -1824,6 +2098,10 @@ async function runCli(argv) {
1824
2098
  else if (parsed.command === 'gate') result = await gateOnly(parsed);
1825
2099
  else if (parsed.command === 'proof') result = await proofOnly(parsed);
1826
2100
  else if (parsed.command === 'status') result = await statusOnly(parsed);
2101
+ else if (parsed.command === 'permit') result = await permitOnly(parsed);
2102
+ else if (parsed.command === 'verify-permit') result = await verifyPermitOnly(parsed);
2103
+ else if (parsed.command === 'coverage') result = await coverageOnly(parsed);
2104
+ else if (parsed.command === 'sidecar') result = await sidecarOnly(parsed);
1827
2105
  else if (parsed.command === 'govern') {
1828
2106
  await runGovernInteractive(parsed.options);
1829
2107
  return;
@@ -1839,9 +2117,12 @@ async function runCli(argv) {
1839
2117
 
1840
2118
  if (parsed.options?.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
1841
2119
  else if (result?.blocked) process.stderr.write(`BLOCKED: ${result.message || 'Marrow blocked this action.'}\n`);
2120
+ else if (parsed.command === 'status') process.stdout.write(`${statusPanel(result)}\n`);
1842
2121
  else if (!['run', 'fleet', 'hermes', 'openclaw', 'integrations'].includes(parsed.command)) process.stdout.write('Marrow command completed.\n');
1843
2122
 
1844
- if (parsed.command === 'run' || result?.blocked) process.exitCode = result?.exitCode ?? (result?.ok === false ? 1 : 0);
2123
+ if (parsed.command === 'run' || parsed.command === 'verify-permit' || result?.blocked) {
2124
+ process.exitCode = result?.exitCode ?? (result?.ok === false ? 1 : 0);
2125
+ }
1845
2126
  }
1846
2127
 
1847
2128
  module.exports = {
@@ -1854,6 +2135,7 @@ module.exports = {
1854
2135
  headers,
1855
2136
  inferType,
1856
2137
  inferSurfaces,
2138
+ isRisky,
1857
2139
  commandForSelection,
1858
2140
  buildGovernState,
1859
2141
  detectProjectSignals,
@@ -1865,9 +2147,16 @@ module.exports = {
1865
2147
  renderGovernTui,
1866
2148
  canUseInteractive,
1867
2149
  runGoverned,
2150
+ scopedExecutionEnv,
2151
+ permitOnly,
2152
+ verifyPermitOnly,
2153
+ coverageOnly,
2154
+ sidecarOnly,
2155
+ actionBinding,
1868
2156
  gateOnly,
1869
2157
  proofOnly,
1870
2158
  statusOnly,
2159
+ statusPanel,
1871
2160
  runStatusCheck,
1872
2161
  runGateCheck,
1873
2162
  runGovernInteractive,
package/src/installer.js CHANGED
@@ -2,14 +2,14 @@ const fs = require('node:fs');
2
2
  const path = require('node:path');
3
3
  const os = require('node:os');
4
4
  const crypto = require('node:crypto');
5
+ const { version: INSTALLER_ADAPTER_VERSION } = require('../package.json');
5
6
 
6
7
  const DEFAULT_BASE_URL = 'https://api.getmarrow.ai';
7
8
  const MARROW_BLOCK_START = '<!-- marrow:passive-start -->';
8
9
  const MARROW_BLOCK_END = '<!-- marrow:passive-end -->';
9
- const INSTALLER_ADAPTER_VERSION = '0.1.34';
10
- const MCP_ADAPTER_VERSION = '3.9.50';
11
- const SDK_ADAPTER_VERSION = '3.7.49';
12
- const SDK_ADAPTER_INTEGRITY = 'sha512-9a6pWWACubWTmulG7TwwgMNP/iSzoEh4wCH2oWUyO8XHeD2Eokj1GXrkCyX86md3bV7vGX6kyllfswZDlGj6WA==';
10
+ const MCP_ADAPTER_VERSION = '3.9.52';
11
+ const SDK_ADAPTER_VERSION = '3.7.51';
12
+ const SDK_ADAPTER_INTEGRITY = 'sha512-0l4UOeJLZ8izIoci4TRy22svKm3xvoyyAbU16aB3vnqxvsrdv3TDaz2EqXpo0vz62IEMlx/F+IoiQ9t9xKzdFA==';
13
13
  const SDK_ADAPTER_TARBALL = `https://registry.npmjs.org/@getmarrow/sdk/-/sdk-${SDK_ADAPTER_VERSION}.tgz`;
14
14
  const MCP_PACKAGE_SPEC = `@getmarrow/mcp@${MCP_ADAPTER_VERSION}`;
15
15
  const MCP_CONTEXT_HOOK_COMMAND = `npx -y ${MCP_PACKAGE_SPEC} context-hook`;
@@ -291,6 +291,7 @@ function fingerprint(value) {
291
291
  function npmTokenPaths(env = process.env) {
292
292
  const home = env.HOME || env.USERPROFILE || os.homedir();
293
293
  return {
294
+ home,
294
295
  openclawEnv: path.join(home, '.openclaw', '.env'),
295
296
  credentialFile: path.join(home, '.openclaw', 'credentials', 'npm-getmarrow-token.txt'),
296
297
  npmrc: path.join(home, '.npmrc'),
@@ -301,7 +302,14 @@ function inspectNpmTokenConfig(env = process.env) {
301
302
  const paths = npmTokenPaths(env);
302
303
  const openclawToken = readEnvVar(paths.openclawEnv, 'NPM_TOKEN');
303
304
  const credentialToken = readFirstLineSecret(paths.credentialFile);
304
- const npmrcToken = readNpmrcToken(paths.npmrc);
305
+ let npmrcToken = '';
306
+ let unsafeNpmrcPath = false;
307
+ try {
308
+ assertDirectOwnerFile(paths.home, paths.npmrc, { allowMissing: true });
309
+ npmrcToken = readNpmrcToken(paths.npmrc);
310
+ } catch {
311
+ unsafeNpmrcPath = true;
312
+ }
305
313
  const sourceToken = openclawToken || credentialToken;
306
314
  const mismatch = Boolean(sourceToken && npmrcToken && fingerprint(sourceToken) !== fingerprint(npmrcToken));
307
315
  const missingNpmrcToken = Boolean(sourceToken && !npmrcToken);
@@ -310,15 +318,18 @@ function inspectNpmTokenConfig(env = process.env) {
310
318
  safe: {
311
319
  npm_token: {
312
320
  checked: true,
313
- repairable: Boolean(sourceToken && (mismatch || missingNpmrcToken)),
321
+ repairable: Boolean(sourceToken && (mismatch || missingNpmrcToken) && !unsafeNpmrcPath),
314
322
  mismatch,
315
323
  missing_npmrc_token: missingNpmrcToken,
324
+ unsafe_path: unsafeNpmrcPath,
316
325
  sources: {
317
326
  openclaw_env: { path: paths.openclawEnv, present: Boolean(openclawToken), fingerprint: fingerprint(openclawToken) },
318
327
  credential_file: { path: paths.credentialFile, present: Boolean(credentialToken), fingerprint: fingerprint(credentialToken) },
319
328
  npmrc: { path: paths.npmrc, present: Boolean(npmrcToken), fingerprint: fingerprint(npmrcToken) },
320
329
  },
321
- recommended_fix: mismatch || missingNpmrcToken
330
+ recommended_fix: unsafeNpmrcPath
331
+ ? 'Refusing automatic npm token repair because ~/.npmrc or its home directory is not a direct, regular owner path.'
332
+ : mismatch || missingNpmrcToken
322
333
  ? 'Run npx @getmarrow/install --repair to sync ~/.npmrc from the active OpenClaw/getmarrow npm token source.'
323
334
  : null,
324
335
  },
@@ -327,7 +338,49 @@ function inspectNpmTokenConfig(env = process.env) {
327
338
  };
328
339
  }
329
340
 
330
- function upsertNpmrcToken(filePath, token) {
341
+ function assertDirectOwnerFile(homePath, filePath, { allowMissing = false } = {}) {
342
+ const home = path.resolve(homePath);
343
+ const target = path.resolve(filePath);
344
+ if (target !== path.join(home, '.npmrc')) throw new Error('npm token repair target must be the direct owner ~/.npmrc');
345
+ if (!fs.existsSync(home)) throw new Error('npm token repair owner home does not exist');
346
+ const homeStat = fs.lstatSync(home);
347
+ if (!homeStat.isDirectory() || homeStat.isSymbolicLink() || fs.realpathSync(home) !== home) {
348
+ throw new Error('npm token repair owner home must be a direct, non-symbolic directory');
349
+ }
350
+ if (!fs.existsSync(target)) {
351
+ if (allowMissing) return;
352
+ throw new Error('npm token repair target does not exist');
353
+ }
354
+ const targetStat = fs.lstatSync(target);
355
+ if (targetStat.isSymbolicLink() || !targetStat.isFile()) {
356
+ throw new Error('npm token repair target must be a regular file, not a symbolic link');
357
+ }
358
+ }
359
+
360
+ function atomicWriteOwnerFile(homePath, filePath, contents) {
361
+ const home = path.resolve(homePath);
362
+ const target = path.resolve(filePath);
363
+ const allowMissing = !fs.existsSync(target);
364
+ assertDirectOwnerFile(home, target, { allowMissing });
365
+ const tempPath = path.join(home, `.npmrc.marrow-${process.pid}-${crypto.randomBytes(6).toString('hex')}.tmp`);
366
+ let descriptor;
367
+ try {
368
+ descriptor = fs.openSync(tempPath, 'wx', 0o600);
369
+ fs.writeFileSync(descriptor, contents, { encoding: 'utf8' });
370
+ fs.fsyncSync(descriptor);
371
+ fs.closeSync(descriptor);
372
+ descriptor = undefined;
373
+ assertDirectOwnerFile(home, target, { allowMissing });
374
+ fs.renameSync(tempPath, target);
375
+ fs.chmodSync(target, 0o600);
376
+ } finally {
377
+ if (descriptor !== undefined) fs.closeSync(descriptor);
378
+ if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
379
+ }
380
+ }
381
+
382
+ function upsertNpmrcToken(homePath, filePath, token) {
383
+ assertDirectOwnerFile(homePath, filePath, { allowMissing: true });
331
384
  const before = safeRead(filePath);
332
385
  const tokenLine = `//registry.npmjs.org/:_authToken=${token}`;
333
386
  let after;
@@ -338,14 +391,17 @@ function upsertNpmrcToken(filePath, token) {
338
391
  after = `${before}${separator}${tokenLine}\n`;
339
392
  }
340
393
  if (before !== after) {
341
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
342
394
  if (before) {
343
395
  const backupPath = `${filePath}.marrow-backup`;
344
- fs.writeFileSync(backupPath, before, { mode: 0o600 });
396
+ if (fs.existsSync(backupPath) && fs.lstatSync(backupPath).isSymbolicLink()) {
397
+ throw new Error('npm token repair backup must not be a symbolic link');
398
+ }
399
+ const backupTemp = path.join(path.resolve(homePath), `.npmrc.marrow-backup-${process.pid}-${crypto.randomBytes(6).toString('hex')}.tmp`);
400
+ fs.writeFileSync(backupTemp, before, { mode: 0o600, flag: 'wx' });
401
+ fs.renameSync(backupTemp, backupPath);
345
402
  fs.chmodSync(backupPath, 0o600);
346
403
  }
347
- fs.writeFileSync(filePath, after, { mode: 0o600 });
348
- fs.chmodSync(filePath, 0o600);
404
+ atomicWriteOwnerFile(homePath, filePath, after);
349
405
  }
350
406
  return before !== after;
351
407
  }
@@ -355,7 +411,7 @@ function repairConfigDiagnostics(diagnostics, env = process.env) {
355
411
  const npm = diagnostics.npm_token;
356
412
  const repairs = [];
357
413
  if (npm?.repairable && inspection.raw.sourceToken) {
358
- const changed = upsertNpmrcToken(inspection.raw.paths.npmrc, inspection.raw.sourceToken);
414
+ const changed = upsertNpmrcToken(inspection.raw.paths.home, inspection.raw.paths.npmrc, inspection.raw.sourceToken);
359
415
  repairs.push({
360
416
  type: 'npm_token_npmrc_sync',
361
417
  changed,
@@ -380,6 +436,7 @@ Marrow should run passively after install:
380
436
  - Before risky work, use Marrow's decision brief or passive prompt hook.
381
437
  - After meaningful work, record the outcome so future agents learn from it.
382
438
  - Check health with \`marrow_agent_status\` or \`GET /v1/agent/status\`.
439
+ - When status/runtime returns a \`client_update\` notice, tell the operator and use its exact update and verification commands only when local change policy permits.
383
440
 
384
441
  Required environment:
385
442
 
@@ -865,10 +922,69 @@ function buildPlan(detection, options) {
865
922
  });
866
923
  }
867
924
 
868
- return { mode, writes };
925
+ return { mode, root: detection.root, writes };
926
+ }
927
+
928
+ function assertContainedManagedTarget(root, targetPath) {
929
+ const resolvedRoot = path.resolve(root);
930
+ const resolvedTarget = path.resolve(targetPath);
931
+ if (resolvedTarget === resolvedRoot || !resolvedTarget.startsWith(`${resolvedRoot}${path.sep}`)) {
932
+ throw new Error(`Refusing installer write outside project root: ${resolvedTarget}`);
933
+ }
934
+ if (!fs.existsSync(resolvedRoot)) throw new Error(`Project root does not exist: ${resolvedRoot}`);
935
+ const rootStat = fs.lstatSync(resolvedRoot);
936
+ if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
937
+ throw new Error(`Refusing installer write through unsafe project root: ${resolvedRoot}`);
938
+ }
939
+ const realRoot = fs.realpathSync(resolvedRoot);
940
+ const relativeParent = path.relative(resolvedRoot, path.dirname(resolvedTarget));
941
+ let current = resolvedRoot;
942
+ for (const segment of relativeParent.split(path.sep).filter(Boolean)) {
943
+ current = path.join(current, segment);
944
+ if (!fs.existsSync(current)) break;
945
+ const stat = fs.lstatSync(current);
946
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
947
+ throw new Error(`Refusing installer write through unsafe path component: ${current}`);
948
+ }
949
+ const realCurrent = fs.realpathSync(current);
950
+ if (realCurrent !== realRoot && !realCurrent.startsWith(`${realRoot}${path.sep}`)) {
951
+ throw new Error(`Refusing installer write outside resolved project root: ${current}`);
952
+ }
953
+ }
954
+ if (fs.existsSync(resolvedTarget)) {
955
+ const targetStat = fs.lstatSync(resolvedTarget);
956
+ if (targetStat.isSymbolicLink() || !targetStat.isFile()) {
957
+ throw new Error(`Refusing installer write to unsafe managed target: ${resolvedTarget}`);
958
+ }
959
+ }
960
+ return { resolvedRoot, resolvedTarget };
961
+ }
962
+
963
+ function atomicWriteManagedFile(root, targetPath, contents) {
964
+ const { resolvedTarget } = assertContainedManagedTarget(root, targetPath);
965
+ const parent = path.dirname(resolvedTarget);
966
+ fs.mkdirSync(parent, { recursive: true, mode: 0o700 });
967
+ assertContainedManagedTarget(root, resolvedTarget);
968
+ const existingMode = fs.existsSync(resolvedTarget)
969
+ ? fs.lstatSync(resolvedTarget).mode & 0o777
970
+ : 0o600;
971
+ const tempPath = path.join(
972
+ parent,
973
+ `.${path.basename(resolvedTarget)}.marrow-${process.pid}-${crypto.randomBytes(6).toString('hex')}`,
974
+ );
975
+ try {
976
+ fs.writeFileSync(tempPath, contents, { flag: 'wx', mode: existingMode });
977
+ assertContainedManagedTarget(root, resolvedTarget);
978
+ fs.renameSync(tempPath, resolvedTarget);
979
+ } finally {
980
+ if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
981
+ }
869
982
  }
870
983
 
871
984
  function applyPlan(plan, options) {
985
+ if (!Array.isArray(plan?.writes) || plan.writes.length === 0) return [];
986
+ const root = path.resolve(plan.root || path.dirname(plan.writes[0].path));
987
+ for (const write of plan.writes) assertContainedManagedTarget(root, write.path);
872
988
  const prepared = plan.writes.map((write) => {
873
989
  const before = safeRead(write.path);
874
990
  let after;
@@ -901,8 +1017,7 @@ function applyPlan(plan, options) {
901
1017
  already_present: !changed,
902
1018
  });
903
1019
  if (changed && writeApplied) {
904
- fs.mkdirSync(path.dirname(write.path), { recursive: true });
905
- fs.writeFileSync(write.path, after);
1020
+ atomicWriteManagedFile(root, write.path, after);
906
1021
  }
907
1022
  }
908
1023
  return changes;
@@ -954,6 +1069,8 @@ async function runSelfTest(options) {
954
1069
  'content-type': 'application/json',
955
1070
  'x-marrow-session-id': `install-${Date.now()}`,
956
1071
  'x-marrow-client': options.client || sourceClient(),
1072
+ 'x-marrow-package': '@getmarrow/install',
1073
+ 'x-marrow-package-version': INSTALLER_ADAPTER_VERSION,
957
1074
  };
958
1075
  if (options.agentId) headers['x-marrow-agent-id'] = options.agentId;
959
1076
 
@@ -1141,6 +1258,7 @@ async function runSelfTest(options) {
1141
1258
  first_value_signal: firstValueSignal,
1142
1259
  install_value_moment: installValueMoment,
1143
1260
  token_value_proof: tokenValueProof,
1261
+ client_update: status.client_update || runtime.client_update || runtime.status?.client_update || null,
1144
1262
  performance_proof: performance && performance.ok !== false ? {
1145
1263
  avoided_mistakes: performance.avoided_mistakes ?? performance.avoided_repeated_mistakes ?? 0,
1146
1264
  reused_winning_decisions: performance.reused_winning_decisions ?? 0,
@@ -1299,6 +1417,18 @@ function printReport(report) {
1299
1417
  process.stdout.write(`- one-call runtime: ${report.selfTest.runtime_active ? 'active' : 'not verified'}\n`);
1300
1418
  if (report.selfTest.error) process.stdout.write(`- error: ${report.selfTest.error}\n`);
1301
1419
  if (report.selfTest.next_action) process.stdout.write(`- next action: ${report.selfTest.next_action}\n`);
1420
+ const update = report.selfTest.client_update;
1421
+ const notification = update?.notification_state || update?.notification;
1422
+ if (update && (update.update_available === true || update.version_status === 'unknown' || notification === 'unknown' || notification === 'version_unknown' || notification === 'security_required')) {
1423
+ process.stdout.write('\nMarrow client update:\n');
1424
+ process.stdout.write(`- priority: ${notification === 'security_required' ? 'security_required' : notification === 'recommended' ? 'recommended' : update.version_status === 'unknown' || notification === 'unknown' || notification === 'version_unknown' ? 'version_unknown' : update.priority || 'recommended'}\n`);
1425
+ process.stdout.write(`- installed: ${update.installed_version || update.current_version || 'unknown'}\n`);
1426
+ process.stdout.write(`- latest: ${update.latest_version || 'unknown'}\n`);
1427
+ process.stdout.write('- automatic notification: yes\n');
1428
+ process.stdout.write('- automatic local mutation: no; operator policy applies\n');
1429
+ if (update.update_command || update.exact_update_command) process.stdout.write(`- update: ${update.update_command || update.exact_update_command}\n`);
1430
+ if (update.verification_command || update.exact_verification_command) process.stdout.write(`- verify: ${update.verification_command || update.exact_verification_command}\n`);
1431
+ }
1302
1432
  if (report.selfTest.first_value_signal) {
1303
1433
  process.stdout.write('\nFirst value:\n');
1304
1434
  const valueMoment = report.selfTest.install_value_moment;