mtech-ai-local-runner 0.1.61 → 0.1.62

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
@@ -889,10 +889,12 @@ The reviewed manual Codex path preserves the existing CLI and ChatGPT login. It
889
889
  ### Local Fix with disclosed validation
890
890
 
891
891
  The existing daemon can use a reviewed `gateway_publish_v1` installation for
892
- approved local Codex fixes. It freezes the owned model and sends only sealed,
893
- bounded changed text files to Gateway's existing publication service. Server
894
- credentials create the generated branch and canonical development PR; no local
895
- Git token, ECR, new CI installation or native PHP verifier is required.
892
+ approved local Codex fixes, and Claude fixes once the Gateway lists a reviewed
893
+ Claude release and the runner reports Claude ready. It freezes the owned model
894
+ and sends only sealed, bounded changed text files to Gateway's existing
895
+ publication service. Server credentials create the generated branch and
896
+ canonical development PR; no local Git token, ECR, new CI installation or native
897
+ PHP verifier is required.
896
898
 
897
899
  Validation is explicitly **UNVERIFIED** in this mode, with CI **not run**.
898
900
  Executor-reported checks, failures and checks not run stay attributed to the
@@ -915,6 +917,10 @@ the older `authenticated` field still means execution eligibility. A logged-in
915
917
  Claude account can remain blocked by unverified security admission. Do not log out
916
918
  or change credentials just because that legacy field is false. Unknown status is
917
919
  not evidence of logout. These diagnostics do not bypass per-job admission.
920
+ Claude reports `CLAUDE_RELEASE_NOT_LISTED` while the Gateway lists no reviewed
921
+ Claude release. Claude readiness may lag a Claude login or logout by up to 5
922
+ minutes, because the runner asks the pinned copy for the login state at most that
923
+ often.
918
924
 
919
925
  ### 0.1.50: small edits and publication-only Fix
920
926
 
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { requireSecurityRequirements } from '../../safety/security-requirements.js';
3
+ import { CLAUDE_RELEASE_MODE, CLAUDE_TRANSPORT } from '../../safety/claude-release.js';
3
4
  import { createJobSecurityReceipt } from '../../safety/job-security-receipt.js';
4
5
  import { isTerminalEventType } from "../../protocol.js";
5
6
  import { parseClaudeLine } from "./stream-parser.js";
@@ -92,7 +93,11 @@ export class ClaudeCodeAdapter {
92
93
  if (ctx.approvedTaskPackage?.resolved_provider !== "anthropic" || ctx.approvedTaskPackage?.resolved_model_id !== resolvedModelId
93
94
  || ctx.approvedTaskPackage?.repository?.base_commit_sha !== this.options.securityPolicy.pin.sourceSha)
94
95
  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
+ const requirements = requireSecurityRequirements(ctx.approvedTaskPackage?.security_requirements, { adapterId: "claude_code_cli", adapterVersion: "1", requirementsSha256: this.options.securityPolicy.pin.requirementsSha256 });
97
+ // Release mode (decision 9095c7a8): the approved package carries the fixed stream-json transport and a model of its release.
98
+ if (requirements.security_mode === CLAUDE_RELEASE_MODE && (ctx.approvedTaskPackage?.runtime_transport !== CLAUDE_TRANSPORT
99
+ || !requirements.model_ids.includes(resolvedModelId)))
100
+ throw new Error("SECURITY_REQUIREMENTS_REJECTED");
96
101
  };
97
102
  assertAdmission();
98
103
  if (!ctx.securityBinding)
@@ -277,6 +282,23 @@ export class ClaudeCodeAdapter {
277
282
  });
278
283
  return this.cancelPromise;
279
284
  }
285
+ /** Owned exit before credential-bearing publication, with Codex semantics (decision 9095c7a8). It revokes the job
286
+ * security session and the MCP endpoint, stops the owned process group and confirms it within 10 seconds. */
287
+ async freezeForPublication() {
288
+ if (!this.started || !this.handle?.terminateOwned)
289
+ throw new Error('CLAUDE_PUBLICATION_FREEZE_UNCONFIRMED');
290
+ let timer;
291
+ try {
292
+ await Promise.race([this.cancel(), new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('CLAUDE_PUBLICATION_FREEZE_UNCONFIRMED')), 10_000); })]);
293
+ }
294
+ catch {
295
+ throw new Error('CLAUDE_PUBLICATION_FREEZE_UNCONFIRMED');
296
+ }
297
+ finally {
298
+ if (timer)
299
+ clearTimeout(timer);
300
+ }
301
+ }
280
302
  securityRevoked = false;
281
303
  revokeSecurity() {
282
304
  if (this.securityRevoked)
@@ -369,6 +391,6 @@ export class ClaudeCodePlugin {
369
391
  return { ...result, capabilities: result.capabilities ?? this.capabilities };
370
392
  }
371
393
  async create(input) {
372
- return new ClaudeCodeAdapter(this.pluginOptions.options(input));
394
+ return new ClaudeCodeAdapter(await this.pluginOptions.options(input));
373
395
  }
374
396
  }
@@ -0,0 +1,15 @@
1
+ /** Reads `claude auth status --json` stdout without returning raw output. `loggedOutExit` marks Claude's exit 1,
2
+ * which never proves a login. Anything unparsable stays unknown. */
3
+ export function claudeLoginFromStatus(stdout, loggedOutExit = false) {
4
+ try {
5
+ const value = JSON.parse(stdout);
6
+ if (value && typeof value === 'object' && !Array.isArray(value) && typeof value.loggedIn === 'boolean') {
7
+ const loggedIn = value.loggedIn;
8
+ if (loggedOutExit && loggedIn)
9
+ return { authenticated: false, loginStatus: 'unknown' };
10
+ return { authenticated: loggedIn, loginStatus: loggedIn ? 'authenticated' : 'unauthenticated' };
11
+ }
12
+ }
13
+ catch { /* Unknown evidence stays closed; never expose raw output. */ }
14
+ return { authenticated: false, loginStatus: 'unknown' };
15
+ }
@@ -1,4 +1,5 @@
1
1
  import { adapterStatus } from './status.js';
2
+ import { claudeLoginFromStatus } from './claude-code/login-status.js';
2
3
  import { createCodexProductionHost } from '../safety/codex-production-host.js';
3
4
  import { execFile } from 'node:child_process';
4
5
  import { promisify } from 'node:util';
@@ -22,16 +23,6 @@ export async function discoverAdapters(env, command = run, adapterIds = ['claude
22
23
  throw new Error('invalid version');
23
24
  let authenticated = false;
24
25
  let loginStatus = 'unknown';
25
- const parseClaudeStatus = (stdout) => {
26
- try {
27
- const value = JSON.parse(stdout);
28
- if (value && typeof value === 'object' && !Array.isArray(value) && typeof value.loggedIn === 'boolean') {
29
- authenticated = value.loggedIn;
30
- loginStatus = authenticated ? 'authenticated' : 'unauthenticated';
31
- }
32
- }
33
- catch { /* Unknown evidence stays closed; never expose raw output. */ }
34
- };
35
26
  try {
36
27
  const auth = await command(binary, codex ? ['login', 'status'] : ['auth', 'status', '--json']);
37
28
  if (codex) {
@@ -39,20 +30,15 @@ export async function discoverAdapters(env, command = run, adapterIds = ['claude
39
30
  loginStatus = authenticated ? 'authenticated' : 'unauthenticated';
40
31
  }
41
32
  else
42
- parseClaudeStatus(auth.stdout);
33
+ ({ authenticated, loginStatus } = claudeLoginFromStatus(auth.stdout));
43
34
  }
44
35
  catch (error) {
45
36
  // Claude exits 1 for a known logged-out JSON response. Other failures
46
37
  // (including timeout/permission errors) do not prove logout.
47
38
  if (!codex && error && typeof error === 'object' && error.code === 1) {
48
39
  const stdout = error.stdout;
49
- if (typeof stdout === 'string') {
50
- parseClaudeStatus(stdout);
51
- if (authenticated) {
52
- authenticated = false;
53
- loginStatus = 'unknown';
54
- }
55
- }
40
+ if (typeof stdout === 'string')
41
+ ({ authenticated, loginStatus } = claudeLoginFromStatus(stdout, true));
56
42
  }
57
43
  }
58
44
  if (codex) {
@@ -1,5 +1,7 @@
1
+ import { createClaudeProductionHost } from '../safety/claude-production-host.js';
1
2
  import { adapterStatus } from '../adapters/status.js';
2
3
  import { loadInstalledLocalFixHost } from '../publication/installed-host.js';
4
+ import { isLocalFixAdapter } from '../publication/local-fix-adapters.js';
3
5
  import { createCodexDaemonHost } from '../safety/codex-daemon-host.js';
4
6
  import { reconcileTerminalState } from './terminal-recovery.js';
5
7
  import { packageVersion } from '../package-version.js';
@@ -36,6 +38,15 @@ export async function buildDaemon(input) {
36
38
  const claudeParts = [input.claudeSecurityHost, input.claudeSupportedProbe, input.claudeBinaryIdentity].filter(Boolean).length;
37
39
  if (claudeParts !== 0 && claudeParts !== 3)
38
40
  throw new Error('CLAUDE_SUPPORTED_HOST_REQUIRED');
41
+ // Claude admission (decision 9095c7a8): the compiled release host, unless a test injects the whole trusted tuple.
42
+ const claudeProduction = claudeParts === 0 ? createClaudeProductionHost({ home: input.env.HOME ?? '', env: input.env, fetchFn: input.fetchFn,
43
+ report: event => { process.stderr.write(`${JSON.stringify(event)}\n`); } }) : undefined;
44
+ const claude = {
45
+ probe: input.claudeSupportedProbe ?? (() => claudeProduction.probe()),
46
+ securityHost: input.claudeSecurityHost ?? claudeProduction.securityHost,
47
+ // Per job: a release change moves the pinned launch path, so the identity is captured at adapter creation.
48
+ binaryIdentity: async () => input.claudeBinaryIdentity ?? claudeProduction.binaryIdentity(),
49
+ };
39
50
  if (input.codexSupportedProbe && !input.codexSecurityHost)
40
51
  throw new Error('CODEX_SUPPORTED_HOST_REQUIRED');
41
52
  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;
@@ -70,12 +81,11 @@ export async function buildDaemon(input) {
70
81
  // The same parts the CLI self-test ran before starting this daemon (verifyPackagedHook).
71
82
  const hook = packagedHookCommandParts();
72
83
  const plugin = new ClaudeCodePlugin({
73
- probe: async () => input.claudeSupportedProbe ? input.claudeSupportedProbe() : (await discoverAdapters(input.env, undefined, ['claude_code_cli']))[0],
74
- options: ({ worktreePath, jobId }) => createClaudeCodeAdapterOptions({
84
+ probe: () => claude.probe(),
85
+ options: async ({ worktreePath, jobId }) => createClaudeCodeAdapterOptions({
75
86
  worktreePath, jobId, stateRoot: input.stateRoot, env: input.env,
76
87
  maxBudgetUsd: Number.MAX_SAFE_INTEGER, hookExecutablePath: hook.hookScriptPath, hookNodeExecutablePath: hook.nodeExecutablePath,
77
- binaryPath: input.env.AI_GATEWAY_LOCAL_RUNNER_CLAUDE_BINARY,
78
- securityHost: input.claudeSecurityHost, binaryIdentity: input.claudeBinaryIdentity,
88
+ securityHost: claude.securityHost, binaryIdentity: await claude.binaryIdentity(),
79
89
  }),
80
90
  });
81
91
  const codex = new CodexPlugin({
@@ -162,24 +172,26 @@ export async function buildDaemon(input) {
162
172
  snapshot: async () => {
163
173
  // Adapter probes run only between jobs, as they did when the job loop sent the heartbeat.
164
174
  if (!poller.running) {
165
- const probe = (async () => {
166
- if (input.claudeSupportedProbe) {
167
- let current;
168
- try {
169
- current = await plugin.probe();
170
- }
171
- catch {
172
- current = { available: false, authenticated: false, capabilities: [] };
173
- }
174
- const ready = adapterStatus(current).authenticated && !!current.executableVersion && plugin.capabilities.every(c => current.capabilities.includes(c));
175
- const index = probes.findIndex(p => p.id === 'claude_code_cli');
176
- if (index >= 0)
177
- probes[index] = { id: 'claude_code_cli', version: '1', ...current, available: ready };
178
- if (ready)
179
- versions.claude_code_cli = '1';
180
- else
181
- delete versions.claude_code_cli;
175
+ // Claude readiness follows the synced release list, so it is refreshed like Codex. The two refreshes run
176
+ // concurrently and each keeps its own failure handling, so Claude neither delays nor changes the Codex result.
177
+ const refreshClaude = async () => {
178
+ let current;
179
+ try {
180
+ current = await plugin.probe();
182
181
  }
182
+ catch {
183
+ current = { available: false, authenticated: false, capabilities: [] };
184
+ }
185
+ const ready = adapterStatus(current).authenticated && !!current.executableVersion && plugin.capabilities.every(c => current.capabilities.includes(c));
186
+ const index = probes.findIndex(p => p.id === 'claude_code_cli');
187
+ if (index >= 0)
188
+ probes[index] = { id: 'claude_code_cli', version: '1', ...current, available: ready };
189
+ if (ready)
190
+ versions.claude_code_cli = '1';
191
+ else
192
+ delete versions.claude_code_cli;
193
+ };
194
+ const refreshCodex = async () => {
183
195
  let current;
184
196
  try {
185
197
  current = await codex.probe();
@@ -195,6 +207,14 @@ export async function buildDaemon(input) {
195
207
  versions.codex_cli = '1';
196
208
  else
197
209
  delete versions.codex_cli;
210
+ };
211
+ // Both refreshes always run to the end, so a failure outside one never leaves the other writing under a claim;
212
+ // the first failure then surfaces as it did before.
213
+ const probe = (async () => {
214
+ const settled = await Promise.allSettled([refreshClaude(), refreshCodex()]);
215
+ const failed = settled.find((result) => result.status === "rejected");
216
+ if (failed)
217
+ throw failed.reason;
198
218
  })();
199
219
  probing = probe.catch(() => undefined);
200
220
  await probe;
@@ -231,6 +251,7 @@ export async function buildDaemon(input) {
231
251
  failureCode: result.status === "failed" ? result.failureCode : undefined }, signal);
232
252
  },
233
253
  guide,
254
+ ...(claudeProduction ? { claude: claudeProduction } : {}),
234
255
  asks,
235
256
  closureAcks,
236
257
  jobs: poller,
@@ -273,7 +294,18 @@ export async function buildDaemon(input) {
273
294
  reportRecoveryRequired: reason => { process.stderr.write(`${JSON.stringify({ event: 'runner_recovery_required', reason })}\n`); },
274
295
  });
275
296
  // Model tools have no Gateway relay. Each adapter owns and closes its job endpoint.
276
- return { daemon, close: async () => { daemon.stop(); await production?.close(); } };
297
+ // The Claude host always closes; a failing Claude close never masks a Codex close failure.
298
+ return { daemon, close: async () => {
299
+ daemon.stop();
300
+ try {
301
+ await production?.close();
302
+ }
303
+ catch (error) {
304
+ await claudeProduction?.close().catch(() => undefined);
305
+ throw error;
306
+ }
307
+ await claudeProduction?.close();
308
+ } };
277
309
  }
278
310
  export function runnerDisplayName() { return `${hostname()} local AI`; }
279
311
  /** The execution blocker a full runner reports for every adapter while it takes no jobs. */
@@ -326,7 +358,7 @@ export function localRunnerCapabilities(probes, trustedLocalFixHost = false, ver
326
358
  const status = adapterStatus(probe);
327
359
  const authenticated = status.authenticated;
328
360
  const capabilities = probe.capabilities.filter(value => value !== 'local_fix_publication_v1' && value !== 'local_fix_gateway_ci_v1' && value !== 'local_fix_gateway_publish_v1');
329
- if (trustedLocalFixHost && authenticated && probe.id === 'codex_cli') {
361
+ if (trustedLocalFixHost && authenticated && isLocalFixAdapter(probe.id)) {
330
362
  capabilities.push('local_fix_publication_v1');
331
363
  if (verificationMode === 'gateway_ci_v1')
332
364
  capabilities.push('local_fix_gateway_ci_v1');
@@ -1,5 +1,6 @@
1
1
  import { publicationError } from './publication/failure.js';
2
2
  import { issueTransportDiagnostic } from './safety/transport-diagnostic.js';
3
+ import { parseClaudeSyncRelease } from "./safety/claude-release.js";
3
4
  import { createHash } from "node:crypto";
4
5
  import { v5 as uuidv5 } from "uuid";
5
6
  import { bindingApprovalIdFromEnv, redact } from "./config.js";
@@ -33,6 +34,7 @@ export function parseRunnerSync(result, runnerId) {
33
34
  ...(Array.isArray(body.closed_delivery_ids) ? { closedDeliveryIds: body.closed_delivery_ids } : {}),
34
35
  ...("guidance_policy" in body ? { guidancePolicy: body.guidance_policy } : {}),
35
36
  ...(typeof body.guidance_policy_version === "string" ? { guidancePolicyVersion: body.guidance_policy_version } : {}),
37
+ ...("claude_release" in body ? { claudeRelease: parseClaudeSyncRelease(body.claude_release) } : {}),
36
38
  jobAvailable: body.job_available === true,
37
39
  push: parsePush(body.push, runnerId),
38
40
  syncSeconds: clampSeconds(body.sync_seconds, 15, 120, 30),
@@ -0,0 +1,4 @@
1
+ /** Runtime adapters whose admitted jobs may use employee-local Fix publication (decision 9095c7a8 adds Claude).
2
+ * Membership grants nothing alone: each job still needs its adapter's host admission and the Gateway's Fix policy. */
3
+ export const LOCAL_FIX_ADAPTERS = Object.freeze(['codex_cli', 'claude_code_cli']);
4
+ export function isLocalFixAdapter(value) { return typeof value === 'string' && LOCAL_FIX_ADAPTERS.includes(value); }
@@ -4,6 +4,7 @@ import { lstat, realpath, open, rename, unlink } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import { CandidateVerifier, protocolHash } from './candidate.js';
6
6
  import { createLocalFixExecutionHost } from './execution-host.js';
7
+ import { isLocalFixAdapter } from './local-fix-adapters.js';
7
8
  import { GitSnapshotHost, SNAPSHOT_TREE_MAX_BYTES } from './git-snapshot.js';
8
9
  import { createSealedIsolatedVerifier } from './isolated-verifier.js';
9
10
  import { FilePublicationJournal } from './journal.js';
@@ -29,7 +30,7 @@ export function createProductionLocalFixHost(options) {
29
30
  if (!lifecycle)
30
31
  throw Error('LOCAL_FIX_OWNED_LIFECYCLE_REQUIRED');
31
32
  const { job, bindingId } = input, pkg = job.taskPackage, repo = pkg.repository, scope = pkg.local_fix_scope;
32
- if (!lifecycle.isCurrent() || repo.provider_url !== options.origin || repo.key !== registration.manifest.project || job.gatewayTaskPackageFingerprint !== protocolHash(pkg) || pkg.runtime_adapter !== 'codex_cli')
33
+ if (!lifecycle.isCurrent() || repo.provider_url !== options.origin || repo.key !== registration.manifest.project || job.gatewayTaskPackageFingerprint !== protocolHash(pkg) || !isLocalFixAdapter(pkg.runtime_adapter))
33
34
  throw denied();
34
35
  const root = await realpath(options.privateRoot), stat = await lstat(root);
35
36
  if (root !== options.privateRoot || !stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0 || (process.getuid && stat.uid !== process.getuid()) || root === input.checkoutPath || root.startsWith(input.checkoutPath + path.sep))
@@ -12,6 +12,7 @@ import { protocolHash } from './candidate.js';
12
12
  import { FilePublicationJournal } from './journal.js';
13
13
  import { GitSnapshotHost, SNAPSHOT_TREE_MAX_BYTES } from './git-snapshot.js';
14
14
  import { PublicationOutcomeUnknownError } from './publisher.js';
15
+ import { isLocalFixAdapter } from './local-fix-adapters.js';
15
16
  const denied = () => Error('LOCAL_FIX_SERVER_CI_REJECTED');
16
17
  const safePath = (p) => typeof p === 'string' && p.length > 0 && !p.includes('\\') && !p.includes('\0') && !path.isAbsolute(p) && p.split('/').every(s => s !== '' && s !== '.' && s !== '..' && !/^(?:\.git|\.mcp(?:\..*)?|\.npmrc|\.netrc|id_rsa|id_ed25519|\.local-runner|\.runner|\.env(?:\..*)?|\.codex|\.claude|\.ssh|\.aws|\.config|auth\.(?:json|toml|ya?ml)(?:\..*)?|credentials(?:\..*)?|AGENTS\.md|CLAUDE\.md|mcp\.(?:json|toml|ya?ml)(?:\..*)?)$/i.test(s));
17
18
  const matches = (p, s) => s.endsWith('/**') ? safePath(s.slice(0, -3)) && (p === s.slice(0, -3) || p.startsWith(s.slice(0, -2))) : safePath(s) && p === s;
@@ -70,7 +71,7 @@ export function createServerCiLocalFixHost(options) {
70
71
  throw denied();
71
72
  const { job } = input, pkg = job.taskPackage, scope = pkg.local_fix_scope, repo = pkg.repository, rawLimits = scope?.candidate_limits, allowed = scope?.allowed_paths;
72
73
  const root = await realpath(options.privateRoot), st = await lstat(root);
73
- if (!lifecycle.isCurrent() || pkg.runtime_adapter !== 'codex_cli' || !scope || scope.verification_mode !== mode || scope.ci_profile_fingerprint !== registration.ci_profile_fingerprint || protocolHash(scope.ci_profile) !== protocolHash(registration.ci_profile) || !Array.isArray(scope.required_commands) || scope.required_commands.length || !scope.registered_tests || typeof scope.registered_tests !== 'object' || Object.keys(scope.registered_tests).length || !Array.isArray(allowed) || !allowed.length || allowed.some(p => typeof p !== 'string' || !(safePath(p) || p.endsWith('/**') && safePath(p.slice(0, -3)))) || protocolHash(allowed) !== protocolHash(pkg.allowed_paths) || repo.key !== registration.project || job.gatewayTaskPackageFingerprint !== protocolHash(pkg) || Object.entries(registration.receipt).some(([k, v]) => scope[k] !== v) || root !== options.privateRoot || !st.isDirectory() || st.isSymbolicLink() || (st.mode & 0o077) !== 0 || (process.getuid && st.uid !== process.getuid()) || root === input.checkoutPath || root.startsWith(input.checkoutPath + path.sep))
74
+ if (!lifecycle.isCurrent() || !isLocalFixAdapter(pkg.runtime_adapter) || !scope || scope.verification_mode !== mode || scope.ci_profile_fingerprint !== registration.ci_profile_fingerprint || protocolHash(scope.ci_profile) !== protocolHash(registration.ci_profile) || !Array.isArray(scope.required_commands) || scope.required_commands.length || !scope.registered_tests || typeof scope.registered_tests !== 'object' || Object.keys(scope.registered_tests).length || !Array.isArray(allowed) || !allowed.length || allowed.some(p => typeof p !== 'string' || !(safePath(p) || p.endsWith('/**') && safePath(p.slice(0, -3)))) || protocolHash(allowed) !== protocolHash(pkg.allowed_paths) || repo.key !== registration.project || job.gatewayTaskPackageFingerprint !== protocolHash(pkg) || Object.entries(registration.receipt).some(([k, v]) => scope[k] !== v) || root !== options.privateRoot || !st.isDirectory() || st.isSymbolicLink() || (st.mode & 0o077) !== 0 || (process.getuid && st.uid !== process.getuid()) || root === input.checkoutPath || root.startsWith(input.checkoutPath + path.sep))
74
75
  throw denied();
75
76
  const limits = candidateLimits(rawLimits, mode === 'gateway_publish_v1');
76
77
  const fence = { jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, launchGrant: job.launchGrant, bindingId: input.bindingId };
@@ -0,0 +1,87 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import { lstat, readdir } from 'node:fs/promises';
4
+ import { createPolicyInventoryClient, verifyPolicyObserver } from './policy-inventory-client.js';
5
+ import { CLAUDE_MANAGED_PREFERENCE_DOMAIN, CLAUDE_MANAGED_SETTINGS_DIRECTORY, CLAUDE_MANAGED_SETTINGS_FILES, CLAUDE_POLICY_OBSERVER, claudeCanonicalSha256 } from './claude-release.js';
6
+ const unverified = () => Error('CLAUDE_MANAGED_SCOPE_UNVERIFIED');
7
+ /** Claude managed sources the Runner checks on macOS (gate managed_scope_exclusion, scope unmanaged_personal_only). */
8
+ export function claudeManagedPaths(user, root = '/') {
9
+ if (!/^[A-Za-z0-9._-]{1,255}$/.test(user) || user.startsWith('.') || !path.isAbsolute(root))
10
+ throw unverified();
11
+ const at = (file) => path.join(root, file);
12
+ return { directory: at(CLAUDE_MANAGED_SETTINGS_DIRECTORY), files: [...CLAUDE_MANAGED_SETTINGS_FILES.map(at),
13
+ at(`/Library/Managed Preferences/${CLAUDE_MANAGED_PREFERENCE_DOMAIN}.plist`), at(`/Library/Managed Preferences/${user}/${CLAUDE_MANAGED_PREFERENCE_DOMAIN}.plist`)] };
14
+ }
15
+ async function absent(file) {
16
+ try {
17
+ await lstat(file);
18
+ }
19
+ catch (error) {
20
+ if (error.code === 'ENOENT')
21
+ return;
22
+ throw unverified();
23
+ }
24
+ throw unverified();
25
+ }
26
+ /** Each managed file must be absent. The settings directory must be absent or an empty real directory,
27
+ * so drop-in or unknown sources also block. Unreadable means unknown, and unknown blocks. */
28
+ export async function assertClaudeManagedPathsAbsent(user, root = '/') {
29
+ const { directory, files } = claudeManagedPaths(user, root);
30
+ for (const file of files)
31
+ await absent(file);
32
+ let s;
33
+ try {
34
+ s = await lstat(directory);
35
+ }
36
+ catch (error) {
37
+ if (error.code === 'ENOENT')
38
+ return;
39
+ throw unverified();
40
+ }
41
+ if (!s.isDirectory() || s.isSymbolicLink())
42
+ throw unverified();
43
+ let entries;
44
+ try {
45
+ entries = await readdir(directory);
46
+ }
47
+ catch {
48
+ throw unverified();
49
+ }
50
+ if (entries.length !== 0)
51
+ throw unverified();
52
+ }
53
+ /** Compiled defaults: the pinned installed observer (single-use clients) and the real managed locations. */
54
+ export function claudeManagedScopeDeps(root = '/') {
55
+ return {
56
+ checkReady: async () => { verifyPolicyObserver(CLAUDE_POLICY_OBSERVER); await createPolicyInventoryClient(CLAUDE_POLICY_OBSERVER).checkReady(); },
57
+ collect: () => createPolicyInventoryClient(CLAUDE_POLICY_OBSERVER).collectOnce(),
58
+ managedPathsAbsent: () => assertClaudeManagedPathsAbsent(os.userInfo().username, root),
59
+ };
60
+ }
61
+ export async function claudeManagedScopeReady(deps) {
62
+ try {
63
+ await deps.checkReady();
64
+ await deps.managedPathsAbsent();
65
+ }
66
+ catch {
67
+ throw unverified();
68
+ }
69
+ }
70
+ /** Per-job managed scope: no configuration profile at all for machine and user (complete, empty, at most 60 s old),
71
+ * plus absent Claude managed files. Managed, partial, unknown or stale input blocks. */
72
+ export async function collectClaudeManagedScope(deps) {
73
+ try {
74
+ const value = await deps.collect(), now = (deps.now ?? Date.now)();
75
+ const r = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
76
+ const observedAt = r.observedAt;
77
+ if (r.complete !== true || r.empty !== true || r.scope !== 'machine_and_current_user' || typeof observedAt !== 'number' || !Number.isSafeInteger(observedAt)
78
+ || observedAt > now || now - observedAt > 60_000 || typeof r.inventoryFingerprint !== 'string' || !/^[a-f0-9]{64}$/.test(r.inventoryFingerprint))
79
+ throw unverified();
80
+ await deps.managedPathsAbsent();
81
+ const bound = { observer_sha256: CLAUDE_POLICY_OBSERVER.sha256, inventory_fingerprint: r.inventoryFingerprint, managed_paths: 'absent' };
82
+ return Object.freeze({ observerSha256: CLAUDE_POLICY_OBSERVER.sha256, inventoryFingerprint: r.inventoryFingerprint, fingerprint: claudeCanonicalSha256(bound) });
83
+ }
84
+ catch {
85
+ throw unverified();
86
+ }
87
+ }