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 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 = input.env.CLAUDE_CONFIG_DIR ?? (input.env.HOME ? path.join(input.env.HOME, '.claude') : undefined);
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: input.securityPolicy,
30
- cwd, env, binaryPath: input.binaryPath, hookExecutablePath: input.hookExecutablePath, hookNodeExecutablePath: input.hookNodeExecutablePath,
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
- await mkdir(state, { recursive: true, mode: 0o700 });
35
- const realState = await realpath(state), realCheckout = await realpath(checkout);
36
- if (realState !== state || inside(realCheckout, realState))
37
- throw new Error('Claude job controls must be outside the checkout');
38
- await mkdir(control, { recursive: true, mode: 0o700 });
39
- for (const directory of [home, cwd, env.TMPDIR])
40
- await mkdir(directory, { mode: 0o700 });
41
- let active = true;
42
- const tools = new JobToolSession({
43
- policyOptions: jobToolPolicyOptions(checkout, pkg, approved, input.commandRegistry ?? [], { runtimeAdapter: 'claude_code_cli', runtimeAdapterVersion: '1' }),
44
- binding: () => {
45
- const binding = input.nativeBinding?.();
46
- return binding && binding.jobId === input.jobId && binding.generation === input.securityPolicy?.pin.generation ? binding : null;
47
- },
48
- active: () => {
49
- if (!active)
50
- return false;
51
- try {
52
- requireJobSecurity(input.securityPolicy, { adapterId: 'claude_code_cli', adapterVersion: '1', jobId: input.jobId, modelId: ctx.resolvedModelId });
53
- return true;
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
- catch {
56
- return false;
57
- }
58
- },
59
- });
60
- return { start: () => tools.start(), revoke: () => { active = false; tools.revoke(); }, close: async () => { active = false; try {
61
- await tools.close();
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' });
@@ -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 stateRoot = defaultStateRootFromEnv(env);
102
- if (SERVICE_MODES.includes(mode)) {
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtech-ai-local-runner",
3
- "version": "0.1.46",
3
+ "version": "0.1.47",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "bin": {