@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 +44 -6
- package/bin/marrow-install.js +1 -1
- package/package.json +1 -1
- package/src/enforcement-client.js +94 -0
- package/src/governance-sidecar.js +213 -0
- package/src/governed-runner.js +303 -14
- package/src/installer.js +146 -16
package/README.md
CHANGED
|
@@ -62,9 +62,42 @@ Required secret:
|
|
|
62
62
|
export MARROW_API_KEY=mrw_live_...
|
|
63
63
|
```
|
|
64
64
|
|
|
65
|
-
##
|
|
65
|
+
## Keeping Marrow Current
|
|
66
66
|
|
|
67
|
-
|
|
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.
|
|
157
|
-
3.
|
|
158
|
-
4.
|
|
159
|
-
5.
|
|
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
|
package/bin/marrow-install.js
CHANGED
|
@@ -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
|
@@ -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 };
|
package/src/governed-runner.js
CHANGED
|
@@ -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
|
|
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)
|
|
132
|
-
|
|
133
|
-
if (/\b(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
557
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
|
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' ||
|
|
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
|
|
10
|
-
const
|
|
11
|
-
const
|
|
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
|
-
|
|
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:
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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;
|