mtech-ai-local-runner 0.1.60 → 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 +10 -4
- package/dist/src/adapters/claude-code/adapter.js +24 -2
- package/dist/src/adapters/claude-code/login-status.js +15 -0
- package/dist/src/adapters/discovery.js +4 -18
- package/dist/src/asks/inbox.js +10 -3
- package/dist/src/asks/notifier.js +7 -0
- package/dist/src/asks/routing.js +47 -6
- package/dist/src/asks/runtime.js +2 -1
- package/dist/src/asks/store.js +6 -1
- package/dist/src/asks/types.js +16 -4
- package/dist/src/daemon/runtime.js +55 -23
- package/dist/src/gateway-client.js +2 -0
- package/dist/src/publication/local-fix-adapters.js +4 -0
- package/dist/src/publication/production-host.js +2 -1
- package/dist/src/publication/server-ci-host.js +2 -1
- package/dist/src/safety/claude-managed-scope.js +87 -0
- package/dist/src/safety/claude-production-host.js +261 -0
- package/dist/src/safety/claude-release-copy.js +233 -0
- package/dist/src/safety/claude-release.js +89 -0
- package/dist/src/safety/job-security-policy.js +6 -2
- package/dist/src/safety/job-security-receipt.js +9 -2
- package/dist/src/safety/job-security-session.js +3 -2
- package/dist/src/safety/security-requirements.js +4 -0
- package/dist/src/transport/sync-dispatch.js +9 -0
- package/package.json +1 -1
- package/dist/src/daemon/session-open-poller.js +0 -123
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { lstat, realpath } from 'node:fs/promises';
|
|
4
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
5
|
+
import { CLAUDE_CAPABILITIES } from '../adapters/discovery.js';
|
|
6
|
+
import { claudeLoginFromStatus } from '../adapters/claude-code/login-status.js';
|
|
7
|
+
import { captureClaudeBinaryIdentity } from './claude-binary-identity.js';
|
|
8
|
+
import { commandRegistryFingerprint } from './job-security-session.js';
|
|
9
|
+
import { localToolInventory } from './local-tool-server.js';
|
|
10
|
+
import { CLAUDE_MANDATORY_GATES, CLAUDE_RELEASE_MODE, CLAUDE_TRANSPORT, claudeCanonicalSha256, hostClaudePlatform, requireClaudeRequirements } from './claude-release.js';
|
|
11
|
+
import { claudeReleaseCopyPath, claudeReleaseRoot, createClaudeReleaseProvisioner, verifyClaudeCodeSignature, verifyClaudeReleaseCopy } from './claude-release-copy.js';
|
|
12
|
+
import { claudeManagedScopeDeps, claudeManagedScopeReady, collectClaudeManagedScope } from './claude-managed-scope.js';
|
|
13
|
+
/** A release whose provisioning failed is not fetched again before this delay, doubled per further consecutive failure
|
|
14
|
+
* of the same release up to CLAUDE_PROVISION_MAX_RETRY_MS. A listing change or a success resets it. */
|
|
15
|
+
export const CLAUDE_PROVISION_RETRY_MS = 15 * 60_000;
|
|
16
|
+
/** Upper bound of the provisioning backoff: a deterministic failure costs at most one download a day. */
|
|
17
|
+
export const CLAUDE_PROVISION_MAX_RETRY_MS = 24 * 60 * 60_000;
|
|
18
|
+
/** The provisioner is running another release: not a failure, so no backoff; the next sync or probe retries. */
|
|
19
|
+
const PROVISION_BUSY = 'CLAUDE_RELEASE_PROVISION_BUSY';
|
|
20
|
+
/** File identity of the copy for the probe memo; undefined when it cannot be read or its path is not its realpath. */
|
|
21
|
+
async function copyStamp(file) {
|
|
22
|
+
try {
|
|
23
|
+
if (await realpath(file) !== file)
|
|
24
|
+
return undefined;
|
|
25
|
+
const s = await lstat(file, { bigint: true });
|
|
26
|
+
return [s.dev, s.ino, s.size, s.mtimeNs, s.ctimeNs, s.mode, s.nlink, s.uid].join(':');
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** The fixed launch shape of adapters/claude-code/adapter.ts (`args`); pinned as configSha256. Change both together.
|
|
33
|
+
* `flags` is every flag name the adapter always passes, `conditional_flags` every flag it passes only for some jobs
|
|
34
|
+
* (budget, resume or new session), and `settings_keys` the leaf paths of its inline `--settings` JSON. The adapter test
|
|
35
|
+
* checks both directions: every argv flag is pinned here, and every key here is observed in argv. */
|
|
36
|
+
export const CLAUDE_LAUNCH_CONFIG = Object.freeze({ transport: CLAUDE_TRANSPORT, input_format: 'stream-json', output_format: 'stream-json', permission_mode: 'default',
|
|
37
|
+
tools: '', allowed_tools: 'ai_local', strict_mcp_config: true, setting_sources: '', disable_slash_commands: true, chrome: false,
|
|
38
|
+
flags: Object.freeze(['--allowedTools', '--disable-slash-commands', '--include-partial-messages', '--input-format', '--mcp-config', '--model', '--name',
|
|
39
|
+
'--no-chrome', '--output-format', '--permission-mode', '--print', '--setting-sources', '--settings', '--strict-mcp-config', '--tools', '--verbose']),
|
|
40
|
+
conditional_flags: Object.freeze(['--max-budget-usd', '--resume', '--session-id']),
|
|
41
|
+
settings_keys: Object.freeze(['hooks.PreToolUse']) });
|
|
42
|
+
const CHECK_IDS = { CLAUDE_RELEASE_NOT_LISTED: 'release_list', CLAUDE_RELEASE_PLATFORM_MISMATCH: 'release_platform',
|
|
43
|
+
CLAUDE_RELEASE_COPY_UNAVAILABLE: 'release_copy', CLAUDE_CODE_SIGNATURE_REJECTED: 'release_copy', CLAUDE_MANAGED_SCOPE_UNVERIFIED: 'managed_scope' };
|
|
44
|
+
/** A login probe answer (any status) is reused this long for the same release and copy identity. */
|
|
45
|
+
export const CLAUDE_LOGIN_PROBE_TTL_MS = 5 * 60_000;
|
|
46
|
+
const CLAUDE_LOGIN_PROBE_TIMEOUT_MS = 10_000;
|
|
47
|
+
/** Readiness only: after a signature failure, the same release and copy identity is not re-hashed or run through
|
|
48
|
+
* codesign again before this delay; a new identity, a new listing or the delay re-checks at once. */
|
|
49
|
+
export const CLAUDE_SIGNATURE_RECHECK_MS = 5 * 60_000;
|
|
50
|
+
/** `auth status --json` of the pinned copy, never the employee's own install, with a fixed environment and the job's
|
|
51
|
+
* account location. The documented Claude Code opt-outs keep the probe from updating itself or sending other traffic.
|
|
52
|
+
* `timeoutMs` is a trusted in-process test seam only. */
|
|
53
|
+
export function pinnedClaudeLogin(home, env, timeoutMs = CLAUDE_LOGIN_PROBE_TIMEOUT_MS) {
|
|
54
|
+
return binary => new Promise(resolve => {
|
|
55
|
+
const account = env.CLAUDE_CONFIG_DIR ?? path.join(home, '.claude');
|
|
56
|
+
if (!path.isAbsolute(account) || !path.isAbsolute(binary) || !path.isAbsolute(home))
|
|
57
|
+
return resolve({ authenticated: false, loginStatus: 'unknown' });
|
|
58
|
+
const childEnv = { PATH: '/usr/bin:/bin', LANG: 'C', LC_ALL: 'C', HOME: home, CLAUDE_CONFIG_DIR: account,
|
|
59
|
+
DISABLE_AUTOUPDATER: '1', CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' };
|
|
60
|
+
for (const key of ['USER', 'LOGNAME']) {
|
|
61
|
+
const value = env[key];
|
|
62
|
+
if (value !== undefined)
|
|
63
|
+
childEnv[key] = value;
|
|
64
|
+
}
|
|
65
|
+
execFile(binary, ['auth', 'status', '--json'], { encoding: 'utf8', timeout: timeoutMs, maxBuffer: 65_536, env: childEnv }, (error, stdout) => {
|
|
66
|
+
if (!error)
|
|
67
|
+
return resolve(claudeLoginFromStatus(stdout));
|
|
68
|
+
// Claude exits 1 for a known logged-out JSON answer; other failures prove nothing.
|
|
69
|
+
resolve(error.code === 1 && typeof stdout === 'string' ? claudeLoginFromStatus(stdout, true) : { authenticated: false, loginStatus: 'unknown' });
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
/** Compiled Claude composition (decision 9095c7a8). The Gateway's synced release is the only one fetched or run.
|
|
74
|
+
* No environment, task or saved JSON creates an admission capability. */
|
|
75
|
+
export function createClaudeProductionHost(input) {
|
|
76
|
+
const platform = input.platform ?? hostClaudePlatform(), now = input.now ?? Date.now, abort = new AbortController();
|
|
77
|
+
const managed = input.managedScope ?? claudeManagedScopeDeps(), login = input.login ?? pinnedClaudeLogin(input.home, input.env);
|
|
78
|
+
const provisioner = createClaudeReleaseProvisioner({ home: input.home, platform, fetchFn: input.fetchFn, codesign: input.codesign, signal: abort.signal });
|
|
79
|
+
const failures = new Map(), key = (r) => `${r.version}/${r.platform}/${r.binary_sha256}`;
|
|
80
|
+
let listed = null, inflight, retryKey, pending = Promise.resolve(), closed = false;
|
|
81
|
+
/** Probe-only memo (readiness runs on every heartbeat): the release and the copy's file identity, held across a
|
|
82
|
+
* full byte and code-signature verification in this process that saw the file unchanged throughout. Job paths never
|
|
83
|
+
* read it; any failed verification clears it. */
|
|
84
|
+
let probeMemo;
|
|
85
|
+
/** Probe-only negative memo: a code-signature failure of this release's copy at this file identity, until
|
|
86
|
+
* CLAUDE_SIGNATURE_RECHECK_MS passes, a new file identity appears or the listed release changes. Job paths never read it. */
|
|
87
|
+
let signatureRejected;
|
|
88
|
+
/** Readiness only: the last login probe answer, for the release, copy path and copy identity it ran against. */
|
|
89
|
+
let loginCache;
|
|
90
|
+
const report = (event) => { try {
|
|
91
|
+
input.report?.(event);
|
|
92
|
+
}
|
|
93
|
+
catch { /* A reporter never fails provisioning. */ } };
|
|
94
|
+
/** Background only: handleRelease is not awaited, so this promise settles and never rejects. */
|
|
95
|
+
const provision = (release) => {
|
|
96
|
+
const k = key(release), last = failures.get(k);
|
|
97
|
+
if (closed || release.platform !== platform || inflight !== undefined
|
|
98
|
+
|| last !== undefined && now() - last.at < Math.min(CLAUDE_PROVISION_RETRY_MS * 2 ** (last.count - 1), CLAUDE_PROVISION_MAX_RETRY_MS))
|
|
99
|
+
return;
|
|
100
|
+
inflight = k;
|
|
101
|
+
if (retryKey === k)
|
|
102
|
+
retryKey = undefined;
|
|
103
|
+
pending = (async () => {
|
|
104
|
+
try {
|
|
105
|
+
await provisioner.ensure(release);
|
|
106
|
+
failures.delete(k);
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
const code = error instanceof Error && /^CLAUDE_[A-Z_]+$/.test(error.message) ? error.message : 'CLAUDE_RELEASE_PROVISION_FAILED';
|
|
110
|
+
if (code === PROVISION_BUSY) {
|
|
111
|
+
retryKey = k;
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
failures.set(k, { count: (failures.get(k)?.count ?? 0) + 1, at: now() });
|
|
115
|
+
report({ event: 'claude_release_provision_failed', code });
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
inflight = undefined;
|
|
119
|
+
}
|
|
120
|
+
})();
|
|
121
|
+
};
|
|
122
|
+
const currentRelease = () => {
|
|
123
|
+
const release = listed;
|
|
124
|
+
if (!release)
|
|
125
|
+
throw Error('CLAUDE_RELEASE_NOT_LISTED');
|
|
126
|
+
if (release.platform !== platform)
|
|
127
|
+
throw Error('CLAUDE_RELEASE_PLATFORM_MISMATCH');
|
|
128
|
+
return release;
|
|
129
|
+
};
|
|
130
|
+
const verifyRelease = async (release) => {
|
|
131
|
+
try {
|
|
132
|
+
return await verifyClaudeReleaseCopy(input.home, release);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
probeMemo = undefined;
|
|
136
|
+
provision(release);
|
|
137
|
+
throw Error('CLAUDE_RELEASE_COPY_UNAVAILABLE');
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
/** Full location and byte verification of the listed copy: every job path. */
|
|
141
|
+
const verifiedCopy = async () => {
|
|
142
|
+
const release = currentRelease();
|
|
143
|
+
return { release, binary: await verifyRelease(release) };
|
|
144
|
+
};
|
|
145
|
+
/** Readiness only: skips the re-hash and codesign while the release and the copy's file identity are unchanged since
|
|
146
|
+
* a full verification. A copy is never reported ready, nor run by the login probe, before its signature passed here. */
|
|
147
|
+
const probedCopy = async () => {
|
|
148
|
+
const release = currentRelease(), k = key(release), memo = probeMemo;
|
|
149
|
+
if (memo?.key === k) {
|
|
150
|
+
const stamp = await copyStamp(memo.binary);
|
|
151
|
+
if (stamp !== undefined && stamp === memo.stamp)
|
|
152
|
+
return { release, binary: memo.binary, stamp };
|
|
153
|
+
}
|
|
154
|
+
probeMemo = undefined;
|
|
155
|
+
const bad = signatureRejected;
|
|
156
|
+
// The same release and file identity, failed less than the delay ago: blocked without hashing or running codesign.
|
|
157
|
+
if (bad?.key === k && now() - bad.at < CLAUDE_SIGNATURE_RECHECK_MS) {
|
|
158
|
+
const stamp = await copyStamp(bad.binary);
|
|
159
|
+
if (stamp !== undefined && stamp === bad.stamp)
|
|
160
|
+
throw Error('CLAUDE_CODE_SIGNATURE_REJECTED');
|
|
161
|
+
}
|
|
162
|
+
signatureRejected = undefined;
|
|
163
|
+
let before;
|
|
164
|
+
try {
|
|
165
|
+
before = await copyStamp(claudeReleaseCopyPath(input.home, release));
|
|
166
|
+
}
|
|
167
|
+
catch { /* No stamp, no memo; verification decides. */ }
|
|
168
|
+
const binary = await verifyRelease(release);
|
|
169
|
+
// Bytes and signature are separate checks (verifyClaudeReleaseCopy, verifyClaudeCodeSignature): both, as on job paths.
|
|
170
|
+
try {
|
|
171
|
+
await verifyClaudeCodeSignature(binary, input.codesign);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
probeMemo = undefined;
|
|
175
|
+
const rejectedStamp = await copyStamp(binary);
|
|
176
|
+
if (before !== undefined && rejectedStamp === before && listed === release)
|
|
177
|
+
signatureRejected = { key: k, binary, stamp: rejectedStamp, at: now() };
|
|
178
|
+
throw Error('CLAUDE_CODE_SIGNATURE_REJECTED');
|
|
179
|
+
}
|
|
180
|
+
const after = await copyStamp(binary);
|
|
181
|
+
if (before !== undefined && after === before && listed === release)
|
|
182
|
+
probeMemo = { key: k, binary, stamp: after };
|
|
183
|
+
return { release, binary, stamp: after };
|
|
184
|
+
};
|
|
185
|
+
const blocked = (code, executableVersion) => ({ available: false, authenticated: false, loginStatus: 'unknown', capabilities: [], reason: code,
|
|
186
|
+
...(executableVersion ? { executableVersion } : {}), admission: { checks: [{ id: CHECK_IDS[code] ?? 'release_copy', status: 'blocked', code }] } });
|
|
187
|
+
const securityHost = async (request) => {
|
|
188
|
+
if (closed)
|
|
189
|
+
throw Error('CLAUDE_HOST_CLOSED');
|
|
190
|
+
const requirements = requireClaudeRequirements(request.requirements, { adapterId: request.adapterId, adapterVersion: request.adapterVersion }), release = requirements.release;
|
|
191
|
+
const { release: synced, binary } = await verifiedCopy();
|
|
192
|
+
if (release.version !== synced.version || release.platform !== synced.platform || release.binary_sha256 !== synced.binary_sha256
|
|
193
|
+
|| !isDeepStrictEqual([...release.model_ids], [...synced.model_ids]) || release.platform !== platform || !release.model_ids.includes(request.modelId))
|
|
194
|
+
throw Error('CLAUDE_RELEASE_BINDING_REJECTED');
|
|
195
|
+
await verifyClaudeCodeSignature(binary, input.codesign);
|
|
196
|
+
const scope = await collectClaudeManagedScope(managed), commandRegistry = [];
|
|
197
|
+
const pin = { adapterId: 'claude_code_cli', adapterVersion: '1', jobId: request.jobId, generation: request.generation, modelId: request.modelId, sourceSha: request.sourceSha,
|
|
198
|
+
binarySha256: release.binary_sha256, configSha256: claudeCanonicalSha256(CLAUDE_LAUNCH_CONFIG), managedPolicySha256: scope.fingerprint,
|
|
199
|
+
toolsSha256: claudeCanonicalSha256({ tools: localToolInventory() }), commandsSha256: commandRegistryFingerprint(commandRegistry),
|
|
200
|
+
osProfileSha256: claudeCanonicalSha256({ platform }), requirementsSha256: requirements.fingerprint, securityMode: CLAUDE_RELEASE_MODE };
|
|
201
|
+
return { pin, commandRegistry,
|
|
202
|
+
// The job MCP token is the session identity (accepted risk job_mcp_token_possession_not_os_identity).
|
|
203
|
+
nativeBinding: () => ({ jobId: request.jobId, threadId: request.launchFingerprint, turnId: request.contractFingerprint, generation: request.generation }),
|
|
204
|
+
verify: async (captured) => {
|
|
205
|
+
if (closed || captured.binarySha256 !== release.binary_sha256 || captured.securityMode !== CLAUDE_RELEASE_MODE)
|
|
206
|
+
throw Error('CLAUDE_RELEASE_BINDING_REJECTED');
|
|
207
|
+
await verifyClaudeReleaseCopy(input.home, release).catch(error => { probeMemo = undefined; throw error; });
|
|
208
|
+
return { version: 2, pin: captured, checks: CLAUDE_MANDATORY_GATES.map(gate => ({ gate, status: 'passed' })) };
|
|
209
|
+
} };
|
|
210
|
+
};
|
|
211
|
+
return {
|
|
212
|
+
/** Sync consumer: the Gateway's one listed release, or null. Provisioning starts when the listed release changes,
|
|
213
|
+
* or again for the same release when the provisioner was busy. A listing change also resets the backoff. */
|
|
214
|
+
handleRelease(value) {
|
|
215
|
+
if (closed)
|
|
216
|
+
return;
|
|
217
|
+
const changed = (listed ? key(listed) : null) !== (value ? key(value) : null);
|
|
218
|
+
listed = value;
|
|
219
|
+
if (changed) {
|
|
220
|
+
failures.clear();
|
|
221
|
+
signatureRejected = undefined;
|
|
222
|
+
}
|
|
223
|
+
if (value && (changed || retryKey === key(value)))
|
|
224
|
+
provision(value);
|
|
225
|
+
},
|
|
226
|
+
/** Settles once the provisioning started so far has ended. */
|
|
227
|
+
async settled() { await pending; },
|
|
228
|
+
async probe() {
|
|
229
|
+
let copy;
|
|
230
|
+
try {
|
|
231
|
+
copy = await probedCopy();
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
return blocked(error instanceof Error ? error.message : 'CLAUDE_RELEASE_COPY_UNAVAILABLE');
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
await claudeManagedScopeReady(managed);
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
return blocked('CLAUDE_MANAGED_SCOPE_UNVERIFIED', copy.release.version);
|
|
241
|
+
}
|
|
242
|
+
// At most one vendor process per TTL, also for an employee who stays logged out; a new release or copy probes at once.
|
|
243
|
+
const identity = [key(copy.release), copy.binary, copy.stamp ?? ''].join('\0'), cached = loginCache;
|
|
244
|
+
let status;
|
|
245
|
+
if (cached?.identity === identity && now() - cached.at < CLAUDE_LOGIN_PROBE_TTL_MS)
|
|
246
|
+
status = cached.status;
|
|
247
|
+
else {
|
|
248
|
+
status = await login(copy.binary).catch(() => ({ authenticated: false, loginStatus: 'unknown' }));
|
|
249
|
+
loginCache = { identity, at: now(), status };
|
|
250
|
+
}
|
|
251
|
+
return { available: true, authenticated: status.authenticated, loginStatus: status.loginStatus, executableVersion: copy.release.version, capabilities: [...CLAUDE_CAPABILITIES] };
|
|
252
|
+
},
|
|
253
|
+
/** Per job: a fresh identity of the current verified copy, so a release change moves the launch path safely. */
|
|
254
|
+
async binaryIdentity() {
|
|
255
|
+
const { binary } = await verifiedCopy();
|
|
256
|
+
return captureClaudeBinaryIdentity({ launchPath: binary, allowedRoots: [claudeReleaseRoot(input.home)] });
|
|
257
|
+
},
|
|
258
|
+
securityHost,
|
|
259
|
+
async close() { closed = true; abort.abort(); await pending; },
|
|
260
|
+
};
|
|
261
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { constants } from 'node:fs';
|
|
5
|
+
import { chmod, lstat, mkdir, open, realpath, rename, rm } from 'node:fs/promises';
|
|
6
|
+
import { measureCommandFile } from './registered-command-integrity.js';
|
|
7
|
+
import { CLAUDE_SIGNING_IDENTIFIER, CLAUDE_TEAM_ID, claudeReleaseUrl, parseClaudeSyncRelease } from './claude-release.js';
|
|
8
|
+
const rejected = () => Error('CLAUDE_RELEASE_COPY_REJECTED');
|
|
9
|
+
const validHome = (home) => typeof home === 'string' && path.isAbsolute(home) && path.resolve(home) === home && home !== '/' && home.length <= 4096 && !/[\r\n\0]/.test(home);
|
|
10
|
+
/** Runner-owned root of pinned Claude copies. The employee's own `claude` install is never used. */
|
|
11
|
+
export function claudeReleaseRoot(home) {
|
|
12
|
+
if (!validHome(home))
|
|
13
|
+
throw rejected();
|
|
14
|
+
return path.join(home, '.local', 'share', 'mtec-ai-local-runner', 'claude');
|
|
15
|
+
}
|
|
16
|
+
export function claudeReleaseCopyPath(home, release) {
|
|
17
|
+
const r = parseClaudeSyncRelease({ version: release.version, platform: release.platform, binary_sha256: release.binary_sha256, model_ids: [...release.model_ids] });
|
|
18
|
+
if (!r)
|
|
19
|
+
throw rejected();
|
|
20
|
+
return path.join(claudeReleaseRoot(home), r.version, r.binary_sha256, 'claude');
|
|
21
|
+
}
|
|
22
|
+
/** Every path from `home` down to `target`, both inclusive. */
|
|
23
|
+
function chainFrom(home, target) {
|
|
24
|
+
const paths = [home];
|
|
25
|
+
let current = home;
|
|
26
|
+
for (const part of path.relative(home, target).split(path.sep).filter(Boolean)) {
|
|
27
|
+
current = path.join(current, part);
|
|
28
|
+
paths.push(current);
|
|
29
|
+
}
|
|
30
|
+
return paths;
|
|
31
|
+
}
|
|
32
|
+
/** Owned by the runner's user, not writable by group or others, and not a symlink. */
|
|
33
|
+
const ownerOnly = (s, uid) => s.uid === BigInt(uid) && (s.mode & 18n) === 0n && !s.isSymbolicLink();
|
|
34
|
+
/** The predicate for every directory of the copy's chain, from `home` down. */
|
|
35
|
+
const ownerOnlyDirectory = (s, uid) => ownerOnly(s, uid) && s.isDirectory();
|
|
36
|
+
/** Owner-only chain, no symlink or hard link, executable, unchanged realpath and re-hashed bytes
|
|
37
|
+
* (modeled on verifyCodexStandaloneBinary). A measurement, not an atomic exec; the identity re-measures before spawn. */
|
|
38
|
+
export async function verifyClaudeReleaseCopy(home, release) {
|
|
39
|
+
try {
|
|
40
|
+
const binary = claudeReleaseCopyPath(home, release), uid = process.getuid?.();
|
|
41
|
+
if (uid === undefined || await realpath(home) !== home)
|
|
42
|
+
throw rejected();
|
|
43
|
+
const paths = chainFrom(home, binary);
|
|
44
|
+
const inspect = async () => Promise.all(paths.map(async (file, index) => {
|
|
45
|
+
const s = await lstat(file, { bigint: true }), last = index === paths.length - 1;
|
|
46
|
+
if (last ? !ownerOnly(s, uid) || !s.isFile() || s.nlink !== 1n || (s.mode & 73n) === 0n : !ownerOnlyDirectory(s, uid))
|
|
47
|
+
throw rejected();
|
|
48
|
+
return [s.dev, s.ino, s.uid, s.mode, last ? s.mtimeNs : 0n, last ? s.ctimeNs : 0n].join(':');
|
|
49
|
+
}));
|
|
50
|
+
const before = await inspect();
|
|
51
|
+
if (await realpath(binary) !== binary || await measureCommandFile(binary) !== release.binary_sha256)
|
|
52
|
+
throw rejected();
|
|
53
|
+
const after = await inspect();
|
|
54
|
+
if (after.join('|') !== before.join('|') || await realpath(binary) !== binary)
|
|
55
|
+
throw rejected();
|
|
56
|
+
return binary;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
throw rejected();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export const CLAUDE_CODESIGN = '/usr/bin/codesign';
|
|
63
|
+
/** Apple-anchored chain through the Developer ID CA (1.2.840.113635.100.6.2.6) to a Developer ID Application leaf
|
|
64
|
+
* (1.2.840.113635.100.6.1.13) of Anthropic's team, with the official identifier. Apple Development, Apple Distribution
|
|
65
|
+
* and Mac App Store leaves of the same team do not match. */
|
|
66
|
+
export const CLAUDE_CODE_REQUIREMENT = `=anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] exists and certificate leaf[field.1.2.840.113635.100.6.1.13] exists and certificate leaf[subject.OU] = "${CLAUDE_TEAM_ID}" and identifier "${CLAUDE_SIGNING_IDENTIFIER}"`;
|
|
67
|
+
/** Absolute tool path, no shell, fixed environment; a non-zero exit rejects. */
|
|
68
|
+
export const runCodesign = args => new Promise((resolve, reject) => {
|
|
69
|
+
execFile(CLAUDE_CODESIGN, [...args], { encoding: 'utf8', timeout: 120_000, maxBuffer: 65_536, env: { PATH: '/usr/bin:/bin', LANG: 'C', LC_ALL: 'C' } }, (error, stdout, stderr) => error ? reject(Error('CLAUDE_CODE_SIGNATURE_REJECTED')) : resolve({ stdout, stderr }));
|
|
70
|
+
});
|
|
71
|
+
export async function verifyClaudeCodeSignature(file, run = runCodesign) {
|
|
72
|
+
try {
|
|
73
|
+
if (!path.isAbsolute(file) || /[\r\n\0]/.test(file))
|
|
74
|
+
throw Error();
|
|
75
|
+
await run(['--verify', '--strict', '-R', CLAUDE_CODE_REQUIREMENT, file]);
|
|
76
|
+
const { stdout, stderr } = await run(['-dv', '--verbose=2', file]);
|
|
77
|
+
const lines = `${stdout}\n${stderr}`.split('\n').map(line => line.trim());
|
|
78
|
+
const only = (key, value) => { const found = lines.filter(line => line.startsWith(`${key}=`)); return found.length === 1 && found[0] === `${key}=${value}`; };
|
|
79
|
+
if (!only('Identifier', CLAUDE_SIGNING_IDENTIFIER) || !only('TeamIdentifier', CLAUDE_TEAM_ID))
|
|
80
|
+
throw Error();
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
throw Error('CLAUDE_CODE_SIGNATURE_REJECTED');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Official builds are about 245 MB; the cap stays below measureCommandFile's 512 MiB limit. */
|
|
87
|
+
export const CLAUDE_RELEASE_MAX_BYTES = 384 * 1024 * 1024;
|
|
88
|
+
const FETCH_TIMEOUT_MS = 15 * 60_000;
|
|
89
|
+
async function privateDirectory(dir) {
|
|
90
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
91
|
+
const s = await lstat(dir), uid = process.getuid?.();
|
|
92
|
+
if (uid === undefined || !s.isDirectory() || s.isSymbolicLink() || s.uid !== uid || (s.mode & 0o077) !== 0 || await realpath(dir) !== dir)
|
|
93
|
+
throw rejected();
|
|
94
|
+
}
|
|
95
|
+
async function writeAll(file, chunk) {
|
|
96
|
+
for (let offset = 0; offset < chunk.byteLength;) {
|
|
97
|
+
const { bytesWritten } = await file.write(chunk, offset, chunk.byteLength - offset);
|
|
98
|
+
offset += bytesWritten;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** The release exactly as the strict parser reads it, or null: the URL and paths never come from a looser reading. */
|
|
102
|
+
function listedRelease(release) {
|
|
103
|
+
try {
|
|
104
|
+
return parseClaudeSyncRelease({ version: release.version, platform: release.platform, binary_sha256: release.binary_sha256, model_ids: [...release.model_ids] });
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** Guarded fetch of one listed release (design "Provisioning a listed release"). Fixed endpoint and no redirect.
|
|
111
|
+
* Exclusive 0600 temp file in a 0700 staging directory under the same root, so the same filesystem.
|
|
112
|
+
* Byte cap and streaming sha256, then the code signature, then chmod and an atomic rename,
|
|
113
|
+
* then bytes/location and the signature once more on the installed path.
|
|
114
|
+
* Bytes that fail any check are deleted and never run. */
|
|
115
|
+
export function createClaudeReleaseProvisioner(input) {
|
|
116
|
+
const maxBytes = input.maxBytes ?? CLAUDE_RELEASE_MAX_BYTES;
|
|
117
|
+
let current;
|
|
118
|
+
async function install(release) {
|
|
119
|
+
if (!input.platform || release.platform !== input.platform)
|
|
120
|
+
throw Error('CLAUDE_RELEASE_PLATFORM_MISMATCH');
|
|
121
|
+
let existing;
|
|
122
|
+
try {
|
|
123
|
+
existing = await verifyClaudeReleaseCopy(input.home, release);
|
|
124
|
+
}
|
|
125
|
+
catch { /* Absent or drifted: fetch the listed bytes. */ }
|
|
126
|
+
// Bytes and location are one check, the code signature another: no success without both on the path we return.
|
|
127
|
+
if (existing) {
|
|
128
|
+
await verifyClaudeCodeSignature(existing, input.codesign);
|
|
129
|
+
return existing;
|
|
130
|
+
}
|
|
131
|
+
const url = claudeReleaseUrl(release), target = claudeReleaseCopyPath(input.home, release), staging = path.join(claudeReleaseRoot(input.home), '.staging');
|
|
132
|
+
if (await realpath(input.home) !== input.home)
|
|
133
|
+
throw rejected();
|
|
134
|
+
await privateDirectory(staging);
|
|
135
|
+
// The installed copy must pass the owner-only chain from home (verifyClaudeReleaseCopy); refuse an unsafe parent
|
|
136
|
+
// now, so it never costs a download that the final check would throw away.
|
|
137
|
+
const uid = process.getuid?.();
|
|
138
|
+
if (uid === undefined)
|
|
139
|
+
throw rejected();
|
|
140
|
+
for (const dir of chainFrom(input.home, claudeReleaseRoot(input.home)))
|
|
141
|
+
if (!ownerOnlyDirectory(await lstat(dir, { bigint: true }), uid))
|
|
142
|
+
throw rejected();
|
|
143
|
+
const temporary = path.join(staging, `${randomUUID()}.download`);
|
|
144
|
+
const file = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
145
|
+
const controller = new AbortController(), stop = () => controller.abort(), timer = setTimeout(stop, input.timeoutMs ?? FETCH_TIMEOUT_MS);
|
|
146
|
+
if (input.signal?.aborted)
|
|
147
|
+
stop();
|
|
148
|
+
else
|
|
149
|
+
input.signal?.addEventListener('abort', stop, { once: true });
|
|
150
|
+
let installed = false;
|
|
151
|
+
try {
|
|
152
|
+
let response;
|
|
153
|
+
try {
|
|
154
|
+
response = await input.fetchFn(url, { method: 'GET', redirect: 'error', signal: controller.signal });
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
throw Error('CLAUDE_RELEASE_FETCH_REJECTED');
|
|
158
|
+
}
|
|
159
|
+
const body = response.body, declared = response.headers.get('content-length');
|
|
160
|
+
if (response.status !== 200 || !body || declared !== null && (!/^\d{1,12}$/.test(declared) || Number(declared) > maxBytes)) {
|
|
161
|
+
await body?.cancel().catch(() => { });
|
|
162
|
+
throw Error('CLAUDE_RELEASE_FETCH_REJECTED');
|
|
163
|
+
}
|
|
164
|
+
const hash = createHash('sha256'), reader = body.getReader();
|
|
165
|
+
let size = 0;
|
|
166
|
+
try {
|
|
167
|
+
for (;;) {
|
|
168
|
+
const { done, value } = await reader.read();
|
|
169
|
+
if (done)
|
|
170
|
+
break;
|
|
171
|
+
size += value.byteLength;
|
|
172
|
+
if (size > maxBytes)
|
|
173
|
+
throw Error('CLAUDE_RELEASE_SIZE_REJECTED');
|
|
174
|
+
hash.update(value);
|
|
175
|
+
await writeAll(file, value);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
await reader.cancel().catch(() => { });
|
|
180
|
+
throw error instanceof Error && error.message === 'CLAUDE_RELEASE_SIZE_REJECTED' ? error : Error('CLAUDE_RELEASE_FETCH_REJECTED');
|
|
181
|
+
}
|
|
182
|
+
await file.sync();
|
|
183
|
+
await file.close();
|
|
184
|
+
if (hash.digest('hex') !== release.binary_sha256 || await measureCommandFile(temporary).catch(() => undefined) !== release.binary_sha256)
|
|
185
|
+
throw Error('CLAUDE_RELEASE_HASH_MISMATCH');
|
|
186
|
+
await verifyClaudeCodeSignature(temporary, input.codesign);
|
|
187
|
+
await chmod(temporary, 0o500);
|
|
188
|
+
await privateDirectory(path.dirname(target));
|
|
189
|
+
await rename(temporary, target);
|
|
190
|
+
installed = true;
|
|
191
|
+
try {
|
|
192
|
+
const directory = await open(path.dirname(target), constants.O_RDONLY);
|
|
193
|
+
try {
|
|
194
|
+
await directory.sync();
|
|
195
|
+
}
|
|
196
|
+
finally {
|
|
197
|
+
await directory.close();
|
|
198
|
+
}
|
|
199
|
+
const copy = await verifyClaudeReleaseCopy(input.home, release);
|
|
200
|
+
await verifyClaudeCodeSignature(copy, input.codesign);
|
|
201
|
+
return copy;
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
await rm(target, { force: true }).catch(() => { });
|
|
205
|
+
throw error;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
finally {
|
|
209
|
+
clearTimeout(timer);
|
|
210
|
+
input.signal?.removeEventListener('abort', stop);
|
|
211
|
+
await file.close().catch(() => { });
|
|
212
|
+
if (!installed)
|
|
213
|
+
await rm(temporary, { force: true }).catch(() => { });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
/** One provisioning at a time: the same release joins the running one; another release is refused until it ends. */
|
|
218
|
+
ensure(listed) {
|
|
219
|
+
const release = listedRelease(listed);
|
|
220
|
+
if (!release)
|
|
221
|
+
return Promise.reject(Error('CLAUDE_RELEASE_FETCH_REJECTED'));
|
|
222
|
+
const key = `${release.version}/${release.platform}/${release.binary_sha256}`;
|
|
223
|
+
if (current?.key === key)
|
|
224
|
+
return current.promise;
|
|
225
|
+
if (current)
|
|
226
|
+
return Promise.reject(Error('CLAUDE_RELEASE_PROVISION_BUSY'));
|
|
227
|
+
const promise = install(release).finally(() => { if (current?.promise === promise)
|
|
228
|
+
current = undefined; });
|
|
229
|
+
current = { key, promise };
|
|
230
|
+
return promise;
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
}
|