mtech-ai-local-runner 0.1.46 → 0.1.47
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 +13 -0
- package/dist/src/adapters/claude-code/adapter.js +57 -36
- package/dist/src/adapters/claude-code/runtime-options.js +114 -33
- package/dist/src/config.js +22 -2
- package/dist/src/daemon/runtime.js +22 -1
- package/dist/src/safety/claude-binary-identity.js +81 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
## 0.1.47: Claude security host wiring remains gated
|
|
2
|
+
|
|
3
|
+
Claude now shares the job security session and checks a live opaque executable
|
|
4
|
+
identity before launch. Account/control overlaps and aliases fail before control
|
|
5
|
+
creation; preparation failure and cancellation revoke authority. A trusted
|
|
6
|
+
host/probe/identity tuple is an in-process composition seam, not an environment
|
|
7
|
+
switch or a receipt loaded from disk. Fake-data tests do not enable production:
|
|
8
|
+
exact-release/native-policy, managed-input and credential/IPC/network evidence
|
|
9
|
+
and production verifier registration remain outstanding. Existing login, settings
|
|
10
|
+
and executable path are preserved. This release neither upgrades nor starts an
|
|
11
|
+
installed daemon. See `docs/ai-ops-gateway/claude-execution-admission-remaining.md`
|
|
12
|
+
in the Gateway repository for the consolidated verification boundary.
|
|
13
|
+
|
|
1
14
|
## 0.1.46: login and execution are separate
|
|
2
15
|
|
|
3
16
|
A locally logged-in account may still be blocked by security admission. New
|
|
@@ -74,45 +74,49 @@ export class ClaudeCodeAdapter {
|
|
|
74
74
|
throw new Error("adapter has already been started");
|
|
75
75
|
this.started = true;
|
|
76
76
|
this.p1 = ctx.approvedTaskPackage?.verification?.mode === 'runner_observed_v1';
|
|
77
|
-
const generation = ctx.generation;
|
|
78
|
-
if (!Number.isSafeInteger(generation) || generation === undefined || generation < 1)
|
|
79
|
-
throw new Error("JOB_SECURITY_REJECTED");
|
|
80
|
-
const assertAdmission = () => {
|
|
81
|
-
const repositories = ctx.taskPackage?.repositories;
|
|
82
|
-
if (!Array.isArray(repositories) || repositories.length !== 1 || repositories[0]?.base_commit_sha !== this.options.securityPolicy?.pin.sourceSha)
|
|
83
|
-
throw new Error('JOB_SECURITY_REJECTED');
|
|
84
|
-
requireJobSecurity(this.options.securityPolicy, { adapterId: "claude_code_cli", adapterVersion: "1", jobId: this.options.jobId ?? ctx.bindingId, modelId: resolvedModelId, generation });
|
|
85
|
-
if (ctx.approvedTaskPackage?.resolved_provider !== "anthropic" || ctx.approvedTaskPackage?.resolved_model_id !== resolvedModelId
|
|
86
|
-
|| ctx.approvedTaskPackage?.repository?.base_commit_sha !== this.options.securityPolicy.pin.sourceSha)
|
|
87
|
-
throw new Error("SECURITY_REQUIREMENTS_REJECTED");
|
|
88
|
-
requireSecurityRequirements(ctx.approvedTaskPackage?.security_requirements, { adapterId: "claude_code_cli", adapterVersion: "1", requirementsSha256: this.options.securityPolicy.pin.requirementsSha256 });
|
|
89
|
-
};
|
|
90
|
-
assertAdmission();
|
|
91
|
-
if (!ctx.securityBinding)
|
|
92
|
-
throw new Error('SECURITY_REQUIREMENTS_REJECTED');
|
|
93
|
-
this.receipt = createJobSecurityReceipt(this.options.securityPolicy, { jobId: this.options.jobId ?? ctx.bindingId, generation, modelId: resolvedModelId, sourceSha: this.options.securityPolicy.pin.sourceSha, ...ctx.securityBinding, requirements: ctx.approvedTaskPackage?.security_requirements });
|
|
94
|
-
const pkg = ctx.taskPackage;
|
|
95
|
-
const cwd = this.options.cwd;
|
|
96
|
-
const rel = cwd ? path.relative(path.resolve(this.options.worktreeRoot), path.resolve(cwd)) : "";
|
|
97
|
-
if (!cwd || !path.isAbsolute(cwd) || rel === "" || (!rel.startsWith(".." + path.sep) && rel !== ".." && !path.isAbsolute(rel)))
|
|
98
|
-
throw new Error("trusted cwd must live outside the assigned worktree");
|
|
99
|
-
const unbounded = isUnboundedLocalExecution(ctx.taskPackage);
|
|
100
|
-
if (!unbounded && (!Number.isFinite(this.options.maxBudgetUsd) || this.options.maxBudgetUsd <= 0)) {
|
|
101
|
-
throw new Error("refusing to start: no positive remaining contract budget was supplied");
|
|
102
|
-
}
|
|
103
|
-
if (!this.options.worktreeRoot) {
|
|
104
|
-
throw new Error("refusing to start: no assigned worktree was supplied");
|
|
105
|
-
}
|
|
106
|
-
if (!ctx.sessionId) {
|
|
107
|
-
throw new Error("refusing to start: no runner-generated session ID was supplied");
|
|
108
|
-
}
|
|
109
|
-
const hookCommand = this.requireTrustedHookCommand();
|
|
110
|
-
const prompt = compilePrompt(ctx.taskPackage, { workItemLabel: this.options.workItemLabel });
|
|
111
|
-
if (this.cancelled)
|
|
112
|
-
throw new Error('adapter startup cancelled');
|
|
113
77
|
let acquired;
|
|
114
78
|
this.startupAcquisition = new Promise(resolve => { acquired = resolve; });
|
|
115
79
|
try {
|
|
80
|
+
const generation = ctx.generation;
|
|
81
|
+
if (!Number.isSafeInteger(generation) || generation === undefined || generation < 1)
|
|
82
|
+
throw new Error("JOB_SECURITY_REJECTED");
|
|
83
|
+
if (this.options.prepareSecurity)
|
|
84
|
+
await this.options.prepareSecurity(ctx);
|
|
85
|
+
if (this.cancelled)
|
|
86
|
+
throw new Error('adapter startup cancelled');
|
|
87
|
+
const assertAdmission = () => {
|
|
88
|
+
const repositories = ctx.taskPackage?.repositories;
|
|
89
|
+
if (!Array.isArray(repositories) || repositories.length !== 1 || repositories[0]?.base_commit_sha !== this.options.securityPolicy?.pin.sourceSha)
|
|
90
|
+
throw new Error('JOB_SECURITY_REJECTED');
|
|
91
|
+
requireJobSecurity(this.options.securityPolicy, { adapterId: "claude_code_cli", adapterVersion: "1", jobId: this.options.jobId ?? ctx.bindingId, modelId: resolvedModelId, generation });
|
|
92
|
+
if (ctx.approvedTaskPackage?.resolved_provider !== "anthropic" || ctx.approvedTaskPackage?.resolved_model_id !== resolvedModelId
|
|
93
|
+
|| ctx.approvedTaskPackage?.repository?.base_commit_sha !== this.options.securityPolicy.pin.sourceSha)
|
|
94
|
+
throw new Error("SECURITY_REQUIREMENTS_REJECTED");
|
|
95
|
+
requireSecurityRequirements(ctx.approvedTaskPackage?.security_requirements, { adapterId: "claude_code_cli", adapterVersion: "1", requirementsSha256: this.options.securityPolicy.pin.requirementsSha256 });
|
|
96
|
+
};
|
|
97
|
+
assertAdmission();
|
|
98
|
+
if (!ctx.securityBinding)
|
|
99
|
+
throw new Error('SECURITY_REQUIREMENTS_REJECTED');
|
|
100
|
+
this.receipt = createJobSecurityReceipt(this.options.securityPolicy, { jobId: this.options.jobId ?? ctx.bindingId, generation, modelId: resolvedModelId, sourceSha: this.options.securityPolicy.pin.sourceSha, ...ctx.securityBinding, requirements: ctx.approvedTaskPackage?.security_requirements });
|
|
101
|
+
const pkg = ctx.taskPackage;
|
|
102
|
+
const cwd = this.options.cwd;
|
|
103
|
+
const rel = cwd ? path.relative(path.resolve(this.options.worktreeRoot), path.resolve(cwd)) : "";
|
|
104
|
+
if (!cwd || !path.isAbsolute(cwd) || rel === "" || (!rel.startsWith(".." + path.sep) && rel !== ".." && !path.isAbsolute(rel)))
|
|
105
|
+
throw new Error("trusted cwd must live outside the assigned worktree");
|
|
106
|
+
const unbounded = isUnboundedLocalExecution(ctx.taskPackage);
|
|
107
|
+
if (!unbounded && (!Number.isFinite(this.options.maxBudgetUsd) || this.options.maxBudgetUsd <= 0)) {
|
|
108
|
+
throw new Error("refusing to start: no positive remaining contract budget was supplied");
|
|
109
|
+
}
|
|
110
|
+
if (!this.options.worktreeRoot) {
|
|
111
|
+
throw new Error("refusing to start: no assigned worktree was supplied");
|
|
112
|
+
}
|
|
113
|
+
if (!ctx.sessionId) {
|
|
114
|
+
throw new Error("refusing to start: no runner-generated session ID was supplied");
|
|
115
|
+
}
|
|
116
|
+
const hookCommand = this.requireTrustedHookCommand();
|
|
117
|
+
const prompt = compilePrompt(ctx.taskPackage, { workItemLabel: this.options.workItemLabel });
|
|
118
|
+
if (this.cancelled)
|
|
119
|
+
throw new Error('adapter startup cancelled');
|
|
116
120
|
if (!this.options.createTools || !this.options.writeLocalMcpConfig)
|
|
117
121
|
throw new Error("job-local tool setup is required");
|
|
118
122
|
this.tools = await this.options.createTools(ctx, pkg, ctx.approvedTaskPackage ?? {});
|
|
@@ -166,6 +170,9 @@ export class ClaudeCodeAdapter {
|
|
|
166
170
|
...allowlistedEnv(this.options.env),
|
|
167
171
|
[HOOK_POLICY_ENV_VAR]: encodeHookPolicy(hookPolicy),
|
|
168
172
|
};
|
|
173
|
+
await this.options.assertCurrentInputs?.();
|
|
174
|
+
if (this.cancelled)
|
|
175
|
+
throw new Error('adapter startup cancelled');
|
|
169
176
|
assertAdmission();
|
|
170
177
|
const handle = this.options.processHost.spawn(this.options.binaryPath ?? "claude", args, {
|
|
171
178
|
cwd,
|
|
@@ -259,6 +266,7 @@ export class ClaudeCodeAdapter {
|
|
|
259
266
|
if (this.cancelPromise)
|
|
260
267
|
return this.cancelPromise;
|
|
261
268
|
this.localReadObservations();
|
|
269
|
+
this.revokeSecurity();
|
|
262
270
|
this.revokeTools(this.tools);
|
|
263
271
|
const cleanup = (async () => { await this.startupAcquisition; await this.cleanup(); })();
|
|
264
272
|
// This bounds cleanup confirmation, never admitted model runtime. Unknown
|
|
@@ -269,6 +277,18 @@ export class ClaudeCodeAdapter {
|
|
|
269
277
|
});
|
|
270
278
|
return this.cancelPromise;
|
|
271
279
|
}
|
|
280
|
+
securityRevoked = false;
|
|
281
|
+
revokeSecurity() {
|
|
282
|
+
if (this.securityRevoked)
|
|
283
|
+
return;
|
|
284
|
+
this.securityRevoked = true;
|
|
285
|
+
try {
|
|
286
|
+
this.options.revokeSecurity?.();
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
this.revokeFailed = true;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
272
292
|
revokeTools(tools) {
|
|
273
293
|
if (!tools || this.revokedTools === tools)
|
|
274
294
|
return;
|
|
@@ -291,6 +311,7 @@ export class ClaudeCodeAdapter {
|
|
|
291
311
|
this.tools = undefined;
|
|
292
312
|
this.handle = null;
|
|
293
313
|
const operation = Promise.resolve().then(async () => {
|
|
314
|
+
this.revokeSecurity();
|
|
294
315
|
this.revokeTools(tools);
|
|
295
316
|
let failed = this.revokeFailed;
|
|
296
317
|
try {
|
|
@@ -1,15 +1,52 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { mkdir, realpath, rm } from 'node:fs/promises';
|
|
4
|
+
import { lstat } from 'node:fs/promises';
|
|
5
|
+
import { JobSecuritySession } from '../../safety/job-security-session.js';
|
|
6
|
+
import { assertCurrentClaudeBinary, claudeBinaryLaunchPath } from '../../safety/claude-binary-identity.js';
|
|
4
7
|
import { writeFileSync } from 'node:fs';
|
|
5
8
|
import { NodeProcessGroupHost } from '../../process/node-process-group-host.js';
|
|
6
9
|
import { JobToolSession } from '../../safety/job-tool-session.js';
|
|
7
10
|
import { jobToolPolicyOptions } from '../../safety/job-tool-policy.js';
|
|
8
11
|
import { requireJobSecurity } from '../../safety/job-security-policy.js';
|
|
9
12
|
const inside = (root, file) => file === root || file.startsWith(root + path.sep);
|
|
13
|
+
/** Resolve the existing prefix without creating a path through an account alias. */
|
|
14
|
+
async function canonicalFuture(file) {
|
|
15
|
+
let parent = file;
|
|
16
|
+
const tail = [];
|
|
17
|
+
for (;;) {
|
|
18
|
+
try {
|
|
19
|
+
await lstat(parent);
|
|
20
|
+
return path.join(await realpath(parent), ...tail);
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (error.code !== 'ENOENT')
|
|
24
|
+
throw error;
|
|
25
|
+
const next = path.dirname(parent);
|
|
26
|
+
if (next === parent)
|
|
27
|
+
throw error;
|
|
28
|
+
tail.unshift(path.basename(parent));
|
|
29
|
+
parent = next;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
10
33
|
/** Wiring only. No release verifier or admission certificate exists here. */
|
|
11
34
|
export function createClaudeCodeAdapterOptions(input) {
|
|
35
|
+
if (input.securityHost && (input.securityPolicy || input.nativeBinding || input.commandRegistry) || !!input.securityHost !== !!input.binaryIdentity)
|
|
36
|
+
throw Error('CLAUDE_SECURITY_AUTHORITY_CONFLICT');
|
|
37
|
+
const security = new JobSecuritySession(input.securityHost);
|
|
38
|
+
let securityPolicy = input.securityPolicy, disposed = false;
|
|
12
39
|
const checkout = path.resolve(input.worktreePath), state = path.resolve(input.stateRoot);
|
|
40
|
+
const account = input.env.CLAUDE_CONFIG_DIR ?? (input.env.HOME ? path.join(input.env.HOME, '.claude') : undefined);
|
|
41
|
+
if (account !== undefined && (!path.isAbsolute(account) || /[\r\n\0]/.test(account)))
|
|
42
|
+
throw Error('CLAUDE_ACCOUNT_CONTEXT_UNAVAILABLE');
|
|
43
|
+
const assertOutsideAccount = (storage, source, auth = account) => { if (auth && [storage, source].some(root => inside(path.resolve(auth), root) || inside(root, path.resolve(auth))))
|
|
44
|
+
throw Error('CLAUDE_ACCOUNT_CONTROL_OVERLAP'); };
|
|
45
|
+
assertOutsideAccount(state, checkout);
|
|
46
|
+
const revoke = () => { disposed = true; security.revoke(); securityPolicy?.revoke(); };
|
|
47
|
+
const binary = input.binaryIdentity ? claudeBinaryLaunchPath(input.binaryIdentity) : input.binaryPath;
|
|
48
|
+
if (input.binaryPath && binary !== input.binaryPath)
|
|
49
|
+
throw Error('CLAUDE_BINARY_IDENTITY_REJECTED');
|
|
13
50
|
if (inside(checkout, state))
|
|
14
51
|
throw new Error('Claude job controls must be outside the checkout');
|
|
15
52
|
const control = path.join(state, 'claude-jobs', randomUUID());
|
|
@@ -22,47 +59,91 @@ export function createClaudeCodeAdapterOptions(input) {
|
|
|
22
59
|
env.TMPDIR = path.join(control, 'tmp'); // Claude owns credential access. Preserve the account location by path only;
|
|
23
60
|
// never read/copy its files or send this environment to scoped workers.
|
|
24
61
|
// https://code.claude.com/docs/en/authentication#log-in-with-multiple-accounts
|
|
25
|
-
env.CLAUDE_CONFIG_DIR =
|
|
62
|
+
env.CLAUDE_CONFIG_DIR = account;
|
|
26
63
|
if (env.CLAUDE_CONFIG_DIR && !path.isAbsolute(env.CLAUDE_CONFIG_DIR))
|
|
27
64
|
throw new Error('Claude account configuration path must be absolute');
|
|
28
65
|
return {
|
|
29
|
-
processHost: new NodeProcessGroupHost(), worktreeRoot: checkout, jobId: input.jobId, securityPolicy
|
|
30
|
-
|
|
66
|
+
processHost: new NodeProcessGroupHost(), worktreeRoot: checkout, jobId: input.jobId, get securityPolicy() { return securityPolicy; },
|
|
67
|
+
...(input.securityHost ? { prepareSecurity: async (ctx) => {
|
|
68
|
+
try {
|
|
69
|
+
if (disposed || !ctx.securityBinding || !account)
|
|
70
|
+
throw Error('JOB_SECURITY_SESSION_REJECTED');
|
|
71
|
+
const approved = ctx.approvedTaskPackage, repo = approved?.repository;
|
|
72
|
+
securityPolicy = await security.authorize({ adapterId: 'claude_code_cli', adapterVersion: '1', jobId: input.jobId, generation: ctx.generation, modelId: ctx.resolvedModelId,
|
|
73
|
+
sourceSha: String(repo?.base_commit_sha ?? ''), requirements: approved?.security_requirements, ...ctx.securityBinding });
|
|
74
|
+
if (disposed)
|
|
75
|
+
throw Error('JOB_SECURITY_SESSION_REJECTED');
|
|
76
|
+
await assertCurrentClaudeBinary(input.binaryIdentity, binary, securityPolicy.pin.binarySha256);
|
|
77
|
+
return securityPolicy;
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
revoke();
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
} } : {}),
|
|
84
|
+
revokeSecurity: revoke,
|
|
85
|
+
assertCurrentInputs: async () => { if (disposed)
|
|
86
|
+
throw Error('JOB_SECURITY_SESSION_REJECTED'); if (input.binaryIdentity) {
|
|
87
|
+
if (!securityPolicy)
|
|
88
|
+
throw Error('JOB_SECURITY_SESSION_REJECTED');
|
|
89
|
+
await assertCurrentClaudeBinary(input.binaryIdentity, binary, securityPolicy.pin.binarySha256);
|
|
90
|
+
} },
|
|
91
|
+
cwd, env, binaryPath: binary, hookExecutablePath: input.hookExecutablePath, hookNodeExecutablePath: input.hookNodeExecutablePath,
|
|
31
92
|
maxBudgetUsd: input.maxBudgetUsd ?? 1, workItemLabel: input.workItemLabel ?? input.jobId,
|
|
32
93
|
registeredReadTools: [], writeMcpConfig: () => { throw new Error('Gateway relay is not a model tool endpoint'); },
|
|
33
94
|
createTools: async (ctx, pkg, approved) => {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
await
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
95
|
+
if (disposed)
|
|
96
|
+
throw Error('JOB_SECURITY_SESSION_REJECTED');
|
|
97
|
+
const policyOptions = jobToolPolicyOptions(checkout, pkg, approved, input.securityHost ? security.commands() : input.commandRegistry ?? [], { runtimeAdapter: 'claude_code_cli', runtimeAdapterVersion: '1' });
|
|
98
|
+
const canonicalAccount = account ? await canonicalFuture(account) : undefined;
|
|
99
|
+
assertOutsideAccount(await canonicalFuture(state), await canonicalFuture(checkout), canonicalAccount);
|
|
100
|
+
assertOutsideAccount(await canonicalFuture(control), await canonicalFuture(checkout), canonicalAccount);
|
|
101
|
+
let owned = false;
|
|
102
|
+
try {
|
|
103
|
+
await mkdir(state, { recursive: true, mode: 0o700 });
|
|
104
|
+
const realState = await realpath(state), realCheckout = await realpath(checkout);
|
|
105
|
+
assertOutsideAccount(realState, realCheckout, account ? await canonicalFuture(account) : undefined);
|
|
106
|
+
if (realState !== state || inside(realCheckout, realState))
|
|
107
|
+
throw new Error('Claude job controls must be outside the checkout');
|
|
108
|
+
await mkdir(path.dirname(control), { recursive: true, mode: 0o700 });
|
|
109
|
+
if (await realpath(path.dirname(control)) !== path.dirname(control))
|
|
110
|
+
throw Error('CLAUDE_CONTROL_ALIAS_REJECTED');
|
|
111
|
+
await mkdir(control, { mode: 0o700 });
|
|
112
|
+
owned = true;
|
|
113
|
+
for (const directory of [home, cwd, env.TMPDIR])
|
|
114
|
+
await mkdir(directory, { mode: 0o700 });
|
|
115
|
+
let active = true;
|
|
116
|
+
const tools = new JobToolSession({
|
|
117
|
+
policyOptions,
|
|
118
|
+
binding: () => {
|
|
119
|
+
const binding = input.securityHost ? security.binding() : input.nativeBinding?.();
|
|
120
|
+
return binding && binding.jobId === input.jobId && binding.generation === securityPolicy?.pin.generation ? binding : null;
|
|
121
|
+
},
|
|
122
|
+
active: () => {
|
|
123
|
+
if (!active)
|
|
124
|
+
return false;
|
|
125
|
+
try {
|
|
126
|
+
requireJobSecurity(securityPolicy, { adapterId: 'claude_code_cli', adapterVersion: '1', jobId: input.jobId, modelId: ctx.resolvedModelId, generation: ctx.generation, sourceSha: String(approved.repository.base_commit_sha) });
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
return { start: () => tools.start(), revoke: () => { active = false; tools.revoke(); }, close: async () => { active = false; revoke(); try {
|
|
135
|
+
await tools.close();
|
|
54
136
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
finally {
|
|
137
|
+
finally {
|
|
138
|
+
await rm(control, { recursive: true, force: true });
|
|
139
|
+
} } };
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
revoke();
|
|
143
|
+
if (owned)
|
|
64
144
|
await rm(control, { recursive: true, force: true });
|
|
65
|
-
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
66
147
|
},
|
|
67
148
|
writeLocalMcpConfig: endpoint => {
|
|
68
149
|
writeFileSync(config, JSON.stringify({ mcpServers: { ai_local: { type: 'http', url: endpoint.url, headers: { Authorization: `Bearer ${endpoint.token}` } } } }), { mode: 0o600, flag: 'wx' });
|
package/dist/src/config.js
CHANGED
|
@@ -43,6 +43,25 @@ function parseWorkLoopId(args) {
|
|
|
43
43
|
}
|
|
44
44
|
return value;
|
|
45
45
|
}
|
|
46
|
+
/*
|
|
47
|
+
* The LaunchAgent starts the daemon as `run --state-root=<root>`, with the root setup used (Work Item 2ae45a4f).
|
|
48
|
+
* launchd does not pass setup's XDG_STATE_HOME, so the argument, not the environment, decides the root whenever it
|
|
49
|
+
* is given. Only one absolute, normalized path is accepted.
|
|
50
|
+
*/
|
|
51
|
+
function parseStateRootArgument(args) {
|
|
52
|
+
const prefix = "--state-root=";
|
|
53
|
+
const found = args.filter((arg) => arg.startsWith(prefix));
|
|
54
|
+
if (found.length === 0)
|
|
55
|
+
return undefined;
|
|
56
|
+
if (found.length > 1)
|
|
57
|
+
throw new Error("--state-root may be given only once");
|
|
58
|
+
const value = found[0].slice(prefix.length);
|
|
59
|
+
if (!value || !path.isAbsolute(value) || value === "/" || value.includes("\0")
|
|
60
|
+
|| path.normalize(value) !== value || value.endsWith("/")) {
|
|
61
|
+
throw new Error("--state-root must be a normalized absolute path");
|
|
62
|
+
}
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
46
65
|
function parseAbsolutePath(value, label, required) {
|
|
47
66
|
if (!value) {
|
|
48
67
|
if (required)
|
|
@@ -98,8 +117,9 @@ export function loadConfig(argv, env) {
|
|
|
98
117
|
if (!mode || !ALL_MODES.includes(mode)) {
|
|
99
118
|
throw new Error(`mode must be one of ${ALL_MODES.join(", ")}`);
|
|
100
119
|
}
|
|
101
|
-
const
|
|
102
|
-
|
|
120
|
+
const isServiceMode = SERVICE_MODES.includes(mode);
|
|
121
|
+
const stateRoot = (isServiceMode ? parseStateRootArgument(rest) : undefined) ?? defaultStateRootFromEnv(env);
|
|
122
|
+
if (isServiceMode) {
|
|
103
123
|
let manualJobId;
|
|
104
124
|
if (mode === 'manual-codex' || mode === 'publication-recover') {
|
|
105
125
|
if (rest.length !== 1 || !rest[0].startsWith('--job-id=') || !UUID_PATTERN.test(rest[0].slice(9)))
|
|
@@ -26,6 +26,9 @@ import { buildTerminalEvidenceCollector } from "../safety/terminal-evidence.js";
|
|
|
26
26
|
import { packagedHookCommandParts } from "../safety/hook-command.js";
|
|
27
27
|
import { composeReviewedP1Host } from '../verification/p1-host-composition.js';
|
|
28
28
|
export async function buildDaemon(input) {
|
|
29
|
+
const claudeParts = [input.claudeSecurityHost, input.claudeSupportedProbe, input.claudeBinaryIdentity].filter(Boolean).length;
|
|
30
|
+
if (claudeParts !== 0 && claudeParts !== 3)
|
|
31
|
+
throw new Error('CLAUDE_SUPPORTED_HOST_REQUIRED');
|
|
29
32
|
if (input.codexSupportedProbe && !input.codexSecurityHost)
|
|
30
33
|
throw new Error('CODEX_SUPPORTED_HOST_REQUIRED');
|
|
31
34
|
const production = !input.codexSecurityHost && !input.codexSupportedProbe ? createCodexDaemonHost({ runnerId: input.runnerId, binary: input.env.AI_GATEWAY_LOCAL_RUNNER_CODEX_BINARY ?? 'codex', home: input.env.HOME ?? '', codexHome: input.env.CODEX_HOME, searchPath: input.env.PATH }) : undefined;
|
|
@@ -52,11 +55,12 @@ export async function buildDaemon(input) {
|
|
|
52
55
|
// The same parts the CLI self-test ran before starting this daemon (verifyPackagedHook).
|
|
53
56
|
const hook = packagedHookCommandParts();
|
|
54
57
|
const plugin = new ClaudeCodePlugin({
|
|
55
|
-
probe: async () => (await discoverAdapters(input.env, undefined, ['claude_code_cli']))[0],
|
|
58
|
+
probe: async () => input.claudeSupportedProbe ? input.claudeSupportedProbe() : (await discoverAdapters(input.env, undefined, ['claude_code_cli']))[0],
|
|
56
59
|
options: ({ worktreePath, jobId }) => createClaudeCodeAdapterOptions({
|
|
57
60
|
worktreePath, jobId, stateRoot: input.stateRoot, env: input.env,
|
|
58
61
|
maxBudgetUsd: Number.MAX_SAFE_INTEGER, hookExecutablePath: hook.hookScriptPath, hookNodeExecutablePath: hook.nodeExecutablePath,
|
|
59
62
|
binaryPath: input.env.AI_GATEWAY_LOCAL_RUNNER_CLAUDE_BINARY,
|
|
63
|
+
securityHost: input.claudeSecurityHost, binaryIdentity: input.claudeBinaryIdentity,
|
|
60
64
|
}),
|
|
61
65
|
});
|
|
62
66
|
const codex = new CodexPlugin({
|
|
@@ -109,6 +113,23 @@ export async function buildDaemon(input) {
|
|
|
109
113
|
const poller = new JobPoller({
|
|
110
114
|
reportError: (diagnostic) => { process.stderr.write(`${JSON.stringify({ event: "poller_failed", ...diagnostic })}\n`); },
|
|
111
115
|
heartbeat: async () => {
|
|
116
|
+
if (input.claudeSupportedProbe) {
|
|
117
|
+
let current;
|
|
118
|
+
try {
|
|
119
|
+
current = await plugin.probe();
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
current = { available: false, authenticated: false, capabilities: [] };
|
|
123
|
+
}
|
|
124
|
+
const ready = adapterStatus(current).authenticated && !!current.executableVersion && plugin.capabilities.every(c => current.capabilities.includes(c));
|
|
125
|
+
const index = probes.findIndex(p => p.id === 'claude_code_cli');
|
|
126
|
+
if (index >= 0)
|
|
127
|
+
probes[index] = { id: 'claude_code_cli', version: '1', ...current, available: ready };
|
|
128
|
+
if (ready)
|
|
129
|
+
versions.claude_code_cli = '1';
|
|
130
|
+
else
|
|
131
|
+
delete versions.claude_code_cli;
|
|
132
|
+
}
|
|
112
133
|
let current;
|
|
113
134
|
try {
|
|
114
135
|
current = await codex.probe();
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { lstat, realpath, readlink } from 'node:fs/promises';
|
|
3
|
+
import { measureCommandFile } from './registered-command-integrity.js';
|
|
4
|
+
const live = new WeakMap();
|
|
5
|
+
const rejected = () => Error('CLAUDE_BINARY_IDENTITY_REJECTED');
|
|
6
|
+
const inside = (root, file) => file === root || file.startsWith(root + path.sep);
|
|
7
|
+
const validPath = (value) => typeof value === 'string' && path.isAbsolute(value) && path.resolve(value) === value && value !== '/' && value.length <= 4096 && !/[\r\n\0]/.test(value);
|
|
8
|
+
/** Measurement only, never a release certificate or atomic path-based exec.
|
|
9
|
+
* Explicit roots are supplied by the trusted host, not task/config JSON. */
|
|
10
|
+
async function measure(input) {
|
|
11
|
+
if (!validPath(input.launchPath) || !Array.isArray(input.allowedRoots) || !input.allowedRoots.length || input.allowedRoots.length > 8 || !input.allowedRoots.every(validPath))
|
|
12
|
+
throw rejected();
|
|
13
|
+
const roots = [...new Set(input.allowedRoots)], uid = process.getuid?.();
|
|
14
|
+
if (uid === undefined)
|
|
15
|
+
throw rejected();
|
|
16
|
+
for (const root of roots) {
|
|
17
|
+
const s = await lstat(root, { bigint: true });
|
|
18
|
+
if (await realpath(root) !== root || !s.isDirectory() || (s.mode & 18n) !== 0n || (s.uid !== BigInt(uid) && s.uid !== 0n))
|
|
19
|
+
throw rejected();
|
|
20
|
+
}
|
|
21
|
+
const allowed = (file) => roots.some(root => inside(root, file));
|
|
22
|
+
const target = await realpath(input.launchPath);
|
|
23
|
+
if (!allowed(input.launchPath) || !allowed(target))
|
|
24
|
+
throw rejected();
|
|
25
|
+
const inspect = async () => {
|
|
26
|
+
const stamps = [];
|
|
27
|
+
for (const file of [...new Set([input.launchPath, target])]) {
|
|
28
|
+
const root = roots.filter(r => inside(r, file)).sort((a, b) => b.length - a.length)[0];
|
|
29
|
+
const parts = [root];
|
|
30
|
+
let current = root;
|
|
31
|
+
for (const part of path.relative(root, file).split(path.sep).filter(Boolean)) {
|
|
32
|
+
current = path.join(current, part);
|
|
33
|
+
parts.push(current);
|
|
34
|
+
}
|
|
35
|
+
for (let i = 0; i < parts.length; i++) {
|
|
36
|
+
const name = parts[i], s = await lstat(name, { bigint: true }), link = s.isSymbolicLink(), last = i === parts.length - 1;
|
|
37
|
+
if ((s.uid !== BigInt(uid) && s.uid !== 0n) || (!link && (s.mode & 18n) !== 0n) || !allowed(await realpath(name))
|
|
38
|
+
|| (!last && !s.isDirectory() && !link) || (last && !link && (!s.isFile() || s.nlink !== 1n || (s.mode & 73n) === 0n)))
|
|
39
|
+
throw rejected();
|
|
40
|
+
stamps.push([name, s.dev.toString(), s.ino.toString(), s.uid.toString(), s.mode.toString(), link ? await readlink(name) : null,
|
|
41
|
+
last || link ? s.mtimeNs.toString() : null, last || link ? s.ctimeNs.toString() : null, last ? s.nlink.toString() : null]);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (await realpath(input.launchPath) !== target)
|
|
45
|
+
throw rejected();
|
|
46
|
+
return JSON.stringify(stamps);
|
|
47
|
+
};
|
|
48
|
+
const before = await inspect(), sha256 = await measureCommandFile(target), after = await inspect();
|
|
49
|
+
if (before !== after)
|
|
50
|
+
throw rejected();
|
|
51
|
+
return { sha256, stamp: after };
|
|
52
|
+
}
|
|
53
|
+
export async function captureClaudeBinaryIdentity(input) {
|
|
54
|
+
try {
|
|
55
|
+
const copy = structuredClone(input), measured = await measure(copy), identity = Object.freeze({ sha256: measured.sha256 });
|
|
56
|
+
live.set(identity, { input: copy, measured });
|
|
57
|
+
return identity;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
throw rejected();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export function claudeBinaryLaunchPath(identity) {
|
|
64
|
+
const value = live.get(identity);
|
|
65
|
+
if (!value)
|
|
66
|
+
throw rejected();
|
|
67
|
+
return value.input.launchPath;
|
|
68
|
+
}
|
|
69
|
+
export async function assertCurrentClaudeBinary(identity, launchPath, sha256) {
|
|
70
|
+
try {
|
|
71
|
+
const value = live.get(identity);
|
|
72
|
+
if (!value || launchPath !== value.input.launchPath || sha256 !== value.measured.sha256)
|
|
73
|
+
throw rejected();
|
|
74
|
+
const current = await measure(value.input);
|
|
75
|
+
if (current.sha256 !== sha256 || current.stamp !== value.measured.stamp)
|
|
76
|
+
throw rejected();
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
throw rejected();
|
|
80
|
+
}
|
|
81
|
+
}
|