@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 +24 -8
- 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 +266 -14
- package/src/installer.js +129 -15
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.
|
|
79
|
+
## What's New in v0.1.36
|
|
80
80
|
|
|
81
|
-
v0.1.
|
|
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
|
|
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
|
-
|
|
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.
|
|
179
|
-
3.
|
|
180
|
-
4.
|
|
181
|
-
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.
|
|
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
|
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
|
@@ -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
|
|
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)
|
|
133
|
-
|
|
134
|
-
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';
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
560
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
|
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' ||
|
|
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.
|
|
11
|
-
const SDK_ADAPTER_VERSION = '3.7.
|
|
12
|
-
const SDK_ADAPTER_INTEGRITY = 'sha512-
|
|
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,
|
|
@@ -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
|
-
|
|
906
|
-
fs.writeFileSync(write.path, after);
|
|
1020
|
+
atomicWriteManagedFile(root, write.path, after);
|
|
907
1021
|
}
|
|
908
1022
|
}
|
|
909
1023
|
return changes;
|