@getmarrow/install 0.1.35 → 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
@@ -76,17 +76,28 @@ npx -y @getmarrow/install@latest --repair
76
76
 
77
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
78
 
79
- ## What's New in v0.1.35
79
+ ## What's New in v0.1.36
80
80
 
81
- v0.1.35 turns server-side client update detection into a guided, low-friction operator workflow. Installer status, activation reports, and the Fleet Operator expose a request-specific advisory with the exact update and verification commands while keeping local mutation explicit:
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
82
 
83
83
  - official installer requests identify the installed `@getmarrow/install` version;
84
84
  - status, self-test, and Fleet Operator output show recommended, unrecognized, and security-required update states without conflating them;
85
85
  - generated agent instructions tell the agent to notify the operator and obey local change policy;
86
- - certified activation now pins MCP 3.9.51 and SDK 3.7.50, including the exact SDK registry integrity;
86
+ - certified activation pins the matching MCP and SDK releases, including exact SDK registry integrity;
87
87
  - `activate`, `doctor`, and `--repair` remain explicit commands and preserve unrelated hooks and configuration.
88
88
 
89
- It preserves the passive-governance verification introduced in v0.1.34. Activation 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:
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:
90
101
 
91
102
  - Claude Code installation includes exact `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, and `Stop` hooks;
92
103
  - matching pre-action/result receipts use one tool correlation, and activation fingerprints the exact hook contract without uploading configuration contents;
@@ -175,15 +186,20 @@ npx @getmarrow/install run \
175
186
  The runner:
176
187
 
177
188
  1. requests the Marrow runtime gate;
178
- 2. prints the decision, relevant lesson, owner-approval state, and required proof;
179
- 3. blocks when policy requires it;
180
- 4. runs the original command when allowed;
181
- 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.
182
194
 
183
195
  Useful commands:
184
196
 
185
197
  ```bash
186
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
187
203
  npx @getmarrow/install status
188
204
  npx @getmarrow/install doctor
189
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.35",
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 };
@@ -5,9 +5,72 @@ const os = require('node:os');
5
5
  const path = require('node:path');
6
6
  const readline = require('node:readline');
7
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');
8
17
 
9
18
  const DEFAULT_BASE_URL = 'https://api.getmarrow.ai';
10
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
+ ]);
11
74
  const GOVERN_TUI_ROW_COUNT = 7;
12
75
  const FLEET_TUI_ROW_COUNT = 12;
13
76
  function usage() {
@@ -17,6 +80,10 @@ function usage() {
17
80
  npx @getmarrow/install gate "deploy production worker"
18
81
  npx @getmarrow/install proof --decision-id <id> --success --summary "smoke passed"
19
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
20
87
  npx @getmarrow/install govern
21
88
  npx @getmarrow/install govern --no-interactive
22
89
  npx @getmarrow/install fleet
@@ -29,6 +96,10 @@ Commands:
29
96
  gate Check Marrow runtime/gate for an action without running a command
30
97
  proof Commit an outcome/proof for an existing decision
31
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
32
103
  govern Interactive setup TUI when run in a terminal; text panel in CI/non-TTY
33
104
  fleet Fleet operator TUI for live agents, workflows, gates, proof debt, and exact fixes
34
105
  integrations List Marrow-supported harness add-ons
@@ -42,9 +113,12 @@ Options:
42
113
  --action <text> Human-readable action. Defaults to the redacted command
43
114
  --profile <name> Policy profile label, such as dev, staging, or production
44
115
  --policy <mode> enforce, warn, or audit. Default: enforce
45
- --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
46
117
  --fail-closed If Marrow is unreachable, block the command
47
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
48
122
  --proof-file <path> JSON proof to include on outcome commit
49
123
  --client <label> Harness/client label. Defaults to MARROW_CLIENT, MARROW_HARNESS, or MARROW_AGENT_CLIENT
50
124
  --base-url <url> Marrow API base URL
@@ -79,6 +153,14 @@ function redactedCommand(command) {
79
153
  return command.map((part) => shellQuote(redact(part))).join(' ');
80
154
  }
81
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
+
82
164
  function normalizeClientLabel(value) {
83
165
  const raw = String(value || '').trim().toLowerCase();
84
166
  if (!raw) return '';
@@ -129,9 +211,11 @@ function shellQuoteDisplay(value) {
129
211
 
130
212
  function inferType(text) {
131
213
  const value = String(text || '').toLowerCase();
132
- if (/\b(deploy|wrangler|cloudflare|production|prod|release)\b/.test(value)) return 'deploy';
133
- if (/\b(publish|npm publish)\b/.test(value)) return 'publish';
134
- 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';
135
219
  if (/\b(migration|migrate|schema|d1 execute|drop table)\b/.test(value)) return 'migration';
136
220
  if (/\b(secret|token|key|password)\b/.test(value)) return 'security';
137
221
  if (/\b(test|check|lint|typecheck|smoke)\b/.test(value)) return 'verification';
@@ -143,6 +227,7 @@ function inferSurfaces(text) {
143
227
  const surfaces = new Set();
144
228
  if (/\b(git|gh|github)\b/.test(value)) surfaces.add('github');
145
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');
146
231
  if (/\b(npm|pnpm|yarn|publish)\b/.test(value)) surfaces.add('npm');
147
232
  if (/\b(sql|d1|migration|database|db)\b/.test(value)) surfaces.add('database');
148
233
  if (/\b(curl|api|http)\b/.test(value)) surfaces.add('api');
@@ -232,7 +317,8 @@ function detectProjectSignals(cwd = process.cwd()) {
232
317
  }
233
318
 
234
319
  function isRisky(text, type) {
235
- return HIGH_RISK_TERMS.test(`${type || ''} ${text || ''}`);
320
+ return PROTECTED_ACTION_TYPES.has(String(type || '').trim().toLowerCase())
321
+ || isProtectedCommand(`${type || ''} ${text || ''}`);
236
322
  }
237
323
 
238
324
  function parseBaseOptions(argv, startIndex = 0) {
@@ -252,6 +338,9 @@ function parseBaseOptions(argv, startIndex = 0) {
252
338
  action: '',
253
339
  client: sourceClient(),
254
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',
255
344
  };
256
345
  let i = startIndex;
257
346
  for (; i < argv.length; i += 1) {
@@ -270,6 +359,9 @@ function parseBaseOptions(argv, startIndex = 0) {
270
359
  options.failClosed = true;
271
360
  options.failOpen = false;
272
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;
273
365
  else if (arg === '--proof-file') options.proofFile = argv[++i] || options.proofFile;
274
366
  else if (arg === '--client' || arg === '--harness') options.client = sourceClient(argv[++i] || options.client);
275
367
  else if (arg === '--base-url') options.baseUrl = argv[++i] || options.baseUrl;
@@ -311,6 +403,17 @@ function parseArgs(argv) {
311
403
  return { command, options: { ...parsed.options, action } };
312
404
  }
313
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
+
314
417
  if (command === 'proof') {
315
418
  const parsed = parseBaseOptions(argv, 1);
316
419
  const options = { ...parsed.options, decisionId: '', success: true, summary: '', outcome: '' };
@@ -328,7 +431,7 @@ function parseArgs(argv) {
328
431
  return { command, options };
329
432
  }
330
433
 
331
- 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') {
332
435
  const parsed = parseBaseOptions(argv, 1);
333
436
  if (parsed.options.help) return { command: 'help' };
334
437
  return { command, options: parsed.options };
@@ -414,11 +517,14 @@ function defaultProof(input) {
414
517
  }
415
518
 
416
519
  async function preflightRuntime(options, action, type, commandText) {
520
+ const target = options.target || commandText || action;
521
+ const surfaces = inferSurfaces(commandText || action);
417
522
  const meta = sourceMeta(options, 'runtime', { action, command: commandText, action_type: type });
418
523
  return requestJson(options, 'POST', '/v1/agent/runtime', {
419
524
  action,
420
525
  type,
421
- surfaces: inferSurfaces(commandText || action),
526
+ target,
527
+ surfaces,
422
528
  source_meta: meta,
423
529
  context: {
424
530
  runner: '@getmarrow/install run',
@@ -484,6 +590,7 @@ function gateDecision(runtime) {
484
590
  return {
485
591
  decision: gate.enforcement_decision || receipt.decision || gate.decision || 'unknown',
486
592
  allow: gate.allow !== false,
593
+ riskLevel: String(gate.risk_level || receipt.risk_level || runtime?.risk_level || '').trim().toLowerCase(),
487
594
  required: Boolean(receipt.required || gate.gate_required),
488
595
  ownerApprovalRequired: Boolean(receipt.owner_approval_required || gate.owner_approval_required),
489
596
  receiptId: receipt.id || gate.gate_receipt_id || '',
@@ -525,11 +632,28 @@ function runChild(command, env = process.env) {
525
632
  });
526
633
  }
527
634
 
528
- 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) {
529
651
  const meta = sourceMeta(options, 'think', { action, action_type: type });
530
652
  return requestJson(options, 'POST', '/v1/agent/think', {
531
653
  action,
532
654
  type,
655
+ target,
656
+ surfaces,
533
657
  source_meta: meta,
534
658
  context: {
535
659
  runner: '@getmarrow/install run',
@@ -556,11 +680,16 @@ async function runGoverned(parsed) {
556
680
  const { options, childCommand } = parsed;
557
681
  const commandText = redactedCommand(childCommand);
558
682
  const action = options.action ? redact(options.action) : commandText;
559
- const type = options.type || inferType(`${action} ${commandText}`);
560
- 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);
561
686
  let runtime = null;
562
687
  let decision = null;
563
688
  let decisionId = '';
689
+ let actionPermit = null;
690
+ let permitEnforcementStarted = false;
691
+ let permitVerified = false;
692
+ const surfaces = inferSurfaces(commandText || action);
564
693
 
565
694
  try {
566
695
  runtime = await preflightRuntime(options, action, type, commandText);
@@ -578,10 +707,41 @@ async function runGoverned(parsed) {
578
707
  message: decision.exactNextAction || 'Marrow blocked this action before execution.',
579
708
  };
580
709
  }
581
- const think = await createDecision(options, action, type);
710
+ const target = options.target || commandText;
711
+ const think = await createDecision(options, action, type, target, surfaces);
582
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;
583
736
  } catch (error) {
584
- 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) {
585
745
  process.stderr.write(`Marrow degraded: ${error.message}. Continuing because fail-open/non-risky policy allows it.\n`);
586
746
  } else {
587
747
  return {
@@ -597,7 +757,8 @@ async function runGoverned(parsed) {
597
757
  }
598
758
  }
599
759
 
600
- const child = await runChild(childCommand);
760
+ const childEnv = scopedExecutionEnv(actionPermit);
761
+ const child = await runChild(childCommand, childEnv);
601
762
  const success = child.exitCode === 0;
602
763
  const proof = defaultProof({ options, action, childCommand, exitCode: child.exitCode, success });
603
764
  const outcome = success
@@ -613,6 +774,21 @@ async function runGoverned(parsed) {
613
774
  }
614
775
  }
615
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
+
616
792
  return {
617
793
  ok: success,
618
794
  blocked: false,
@@ -623,9 +799,72 @@ async function runGoverned(parsed) {
623
799
  decision,
624
800
  decision_id: decisionId,
625
801
  outcome_committed: Boolean(commit),
802
+ permit_id: actionPermit?.permit_id || null,
803
+ permit_verified: permitVerified,
804
+ permit_closed: Boolean(permitClosed),
626
805
  };
627
806
  }
628
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
+
629
868
  async function gateOnly(parsed) {
630
869
  const { options } = parsed;
631
870
  const action = redact(options.action);
@@ -1859,6 +2098,10 @@ async function runCli(argv) {
1859
2098
  else if (parsed.command === 'gate') result = await gateOnly(parsed);
1860
2099
  else if (parsed.command === 'proof') result = await proofOnly(parsed);
1861
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);
1862
2105
  else if (parsed.command === 'govern') {
1863
2106
  await runGovernInteractive(parsed.options);
1864
2107
  return;
@@ -1877,7 +2120,9 @@ async function runCli(argv) {
1877
2120
  else if (parsed.command === 'status') process.stdout.write(`${statusPanel(result)}\n`);
1878
2121
  else if (!['run', 'fleet', 'hermes', 'openclaw', 'integrations'].includes(parsed.command)) process.stdout.write('Marrow command completed.\n');
1879
2122
 
1880
- 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
+ }
1881
2126
  }
1882
2127
 
1883
2128
  module.exports = {
@@ -1890,6 +2135,7 @@ module.exports = {
1890
2135
  headers,
1891
2136
  inferType,
1892
2137
  inferSurfaces,
2138
+ isRisky,
1893
2139
  commandForSelection,
1894
2140
  buildGovernState,
1895
2141
  detectProjectSignals,
@@ -1901,6 +2147,12 @@ module.exports = {
1901
2147
  renderGovernTui,
1902
2148
  canUseInteractive,
1903
2149
  runGoverned,
2150
+ scopedExecutionEnv,
2151
+ permitOnly,
2152
+ verifyPermitOnly,
2153
+ coverageOnly,
2154
+ sidecarOnly,
2155
+ actionBinding,
1904
2156
  gateOnly,
1905
2157
  proofOnly,
1906
2158
  statusOnly,
package/src/installer.js CHANGED
@@ -7,9 +7,9 @@ const { version: INSTALLER_ADAPTER_VERSION } = require('../package.json');
7
7
  const DEFAULT_BASE_URL = 'https://api.getmarrow.ai';
8
8
  const MARROW_BLOCK_START = '<!-- marrow:passive-start -->';
9
9
  const MARROW_BLOCK_END = '<!-- marrow:passive-end -->';
10
- const MCP_ADAPTER_VERSION = '3.9.51';
11
- const SDK_ADAPTER_VERSION = '3.7.50';
12
- const SDK_ADAPTER_INTEGRITY = 'sha512-TyCXUKZAaKRPdfVqXEuIvWnOt4GLcIUbwhpX+EopzdALNzq+t5wUGHrqJlkNSvopppPTTdFVLJ7O1vtwofqFqw==';
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,
@@ -866,10 +922,69 @@ function buildPlan(detection, options) {
866
922
  });
867
923
  }
868
924
 
869
- 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
+ }
870
982
  }
871
983
 
872
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);
873
988
  const prepared = plan.writes.map((write) => {
874
989
  const before = safeRead(write.path);
875
990
  let after;
@@ -902,8 +1017,7 @@ function applyPlan(plan, options) {
902
1017
  already_present: !changed,
903
1018
  });
904
1019
  if (changed && writeApplied) {
905
- fs.mkdirSync(path.dirname(write.path), { recursive: true });
906
- fs.writeFileSync(write.path, after);
1020
+ atomicWriteManagedFile(root, write.path, after);
907
1021
  }
908
1022
  }
909
1023
  return changes;