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 +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/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,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
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
3
|
+
/*
|
|
4
|
+
* Runner mirror of the Gateway Claude release contract (AiOpsClaudeReleasePolicy, Work Decision 9095c7a8).
|
|
5
|
+
* Gates, risks, transport, distribution endpoint and signing identity change only with a Runner release.
|
|
6
|
+
* Only the reviewed release entry moves with the Gateway: the `claude_release` sync field and the job descriptor.
|
|
7
|
+
*/
|
|
8
|
+
export const CLAUDE_RELEASE_MODE = 'supported_cli_claude_release_policy_v1';
|
|
9
|
+
export const CLAUDE_REQUIREMENTS_VERSION = 'cli-claude-security-requirements-v1';
|
|
10
|
+
export const CLAUDE_TRANSPORT = 'official_stream_json_v1';
|
|
11
|
+
export const CLAUDE_EVIDENCE_CLASS = 'reviewed_claude_release';
|
|
12
|
+
export const CLAUDE_POLICY_SCOPE = 'unmanaged_personal_only';
|
|
13
|
+
export const CLAUDE_PLATFORMS = Object.freeze(['darwin-arm64', 'darwin-x64']);
|
|
14
|
+
export const CLAUDE_RELEASE_GATES = Object.freeze(['binary_release', 'official_permissions', 'managed_scope_exclusion', 'required_mcp_startup', 'source_exclusion', 'auth_backend_compatibility']);
|
|
15
|
+
export const CLAUDE_INPUT_GATES = Object.freeze(['exact_inputs', 'launch_binding', 'repository_scope', 'private_paths_environment', 'owned_lifecycle']);
|
|
16
|
+
export const CLAUDE_MANDATORY_GATES = Object.freeze([...CLAUDE_RELEASE_GATES, ...CLAUDE_INPUT_GATES]);
|
|
17
|
+
export const CLAUDE_RISKS = Object.freeze(['vendor_native_policy_enforcement', 'managed_policy_not_directly_observed_per_job', 'first_inference_order_not_observed_per_job',
|
|
18
|
+
'job_mcp_token_possession_not_os_identity', 'account_startup_may_refresh_shared_credentials', 'concurrent_policy_change_not_atomic',
|
|
19
|
+
'local_account_metadata_not_remote_entitlement', 'privileged_prelaunch_mdm_snapshot_not_continuously_reobserved', 'release_list_served_by_gateway']);
|
|
20
|
+
/** Apple Developer ID team and code identifier of official builds (Work Evidence c85ec02c). */
|
|
21
|
+
export const CLAUDE_TEAM_ID = 'Q6L2SF6YDW';
|
|
22
|
+
export const CLAUDE_SIGNING_IDENTIFIER = 'com.anthropic.claude-code';
|
|
23
|
+
/** Official native-build distribution. Never taken from the Gateway, a task, config or env. */
|
|
24
|
+
export const CLAUDE_DOWNLOAD_BASE = 'https://downloads.claude.ai/claude-code-releases';
|
|
25
|
+
/** The installed privileged observer; the same reviewed binary the Codex daemon release pins. */
|
|
26
|
+
export const CLAUDE_POLICY_OBSERVER = Object.freeze({ protocol: 'policy-inventory-v1', sha256: '2a07812cd47782826a359b749096b681a6ee49e91f990d48abdc60f2d46e4c9c' });
|
|
27
|
+
/** macOS managed sources (docs/superpowers/specs/2026-10-16-cli-managed-auth-lab-scope.json) and the MDM preference domain. */
|
|
28
|
+
export const CLAUDE_MANAGED_SETTINGS_DIRECTORY = '/Library/Application Support/ClaudeCode';
|
|
29
|
+
export const CLAUDE_MANAGED_SETTINGS_FILES = Object.freeze([`${CLAUDE_MANAGED_SETTINGS_DIRECTORY}/managed-settings.json`, `${CLAUDE_MANAGED_SETTINGS_DIRECTORY}/managed-mcp.json`]);
|
|
30
|
+
export const CLAUDE_MANAGED_PREFERENCE_DOMAIN = 'com.anthropic.claudecode';
|
|
31
|
+
const record = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
32
|
+
const keys = (v) => Object.keys(v).sort();
|
|
33
|
+
const digest = (v) => typeof v === 'string' && /^[a-f0-9]{64}$/.test(v);
|
|
34
|
+
const models = (v) => Array.isArray(v) && v.length > 0 && new Set(v).size === v.length && v.every(m => typeof m === 'string' && /^[A-Za-z0-9._:-]{1,128}$/.test(m));
|
|
35
|
+
const common = (v) => typeof v.version === 'string' && /^\d{1,4}\.\d{1,4}\.\d{1,6}$/.test(v.version)
|
|
36
|
+
&& CLAUDE_PLATFORMS.includes(v.platform) && digest(v.binary_sha256) && models(v.model_ids);
|
|
37
|
+
function canonical(v) { if (Array.isArray(v))
|
|
38
|
+
return v.map(canonical); if (record(v))
|
|
39
|
+
return Object.fromEntries(Object.keys(v).sort().map(k => [k, canonical(v[k])])); return v; }
|
|
40
|
+
function freeze(value) { if (value && typeof value === 'object') {
|
|
41
|
+
for (const item of Object.values(value))
|
|
42
|
+
freeze(item);
|
|
43
|
+
Object.freeze(value);
|
|
44
|
+
} return value; }
|
|
45
|
+
/** Same canonical JSON as securityRequirementsFingerprint and the Gateway's JSON_UNESCAPED_SLASHES hashing. */
|
|
46
|
+
export function claudeCanonicalSha256(value) {
|
|
47
|
+
return createHash('sha256').update(JSON.stringify(canonical(value)).replace(/[\u007f-]/g, c => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'))).digest('hex');
|
|
48
|
+
}
|
|
49
|
+
/** The sync field `claude_release`: the one release this runner may fetch. Null or malformed both read as not listed. */
|
|
50
|
+
export function parseClaudeSyncRelease(value) {
|
|
51
|
+
if (!record(value) || !isDeepStrictEqual(keys(value), ['binary_sha256', 'model_ids', 'platform', 'version']) || !common(value))
|
|
52
|
+
return null;
|
|
53
|
+
return freeze({ version: value.version, platform: value.platform, binary_sha256: value.binary_sha256, model_ids: [...value.model_ids] });
|
|
54
|
+
}
|
|
55
|
+
export function requireClaudeRelease(value) {
|
|
56
|
+
if (!record(value) || !isDeepStrictEqual(keys(value), ['binary_sha256', 'decision_id', 'evidence_sha256', 'model_ids', 'platform', 'review_status', 'version']) || !common(value)
|
|
57
|
+
|| !digest(value.evidence_sha256) || typeof value.decision_id !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(value.decision_id)
|
|
58
|
+
|| value.review_status !== 'reviewed')
|
|
59
|
+
throw Error('CLAUDE_REQUIREMENTS_REJECTED');
|
|
60
|
+
return freeze(structuredClone(value));
|
|
61
|
+
}
|
|
62
|
+
const DESCRIPTOR_KEYS = ['accepted_residual_risks', 'adapter_id', 'adapter_version', 'evidence_class', 'fingerprint', 'mandatory_gates', 'model_ids', 'policy_scope', 'release', 'release_manifest_sha256', 'security_mode', 'status', 'transport', 'version'];
|
|
63
|
+
/** Strict parser for the frozen Claude job descriptor: only the verified descriptor of one reviewed release passes. */
|
|
64
|
+
export function requireClaudeRequirements(value, expected) {
|
|
65
|
+
const rejected = () => Error('CLAUDE_REQUIREMENTS_REJECTED');
|
|
66
|
+
if (!record(value) || !isDeepStrictEqual(keys(value), DESCRIPTOR_KEYS))
|
|
67
|
+
throw rejected();
|
|
68
|
+
const release = requireClaudeRelease(value.release), { fingerprint, ...unsigned } = value;
|
|
69
|
+
if (value.version !== CLAUDE_REQUIREMENTS_VERSION || value.security_mode !== CLAUDE_RELEASE_MODE || value.transport !== CLAUDE_TRANSPORT
|
|
70
|
+
|| value.adapter_id !== 'claude_code_cli' || value.adapter_id !== expected.adapterId || value.adapter_version !== '1' || value.adapter_version !== expected.adapterVersion
|
|
71
|
+
|| !isDeepStrictEqual(value.mandatory_gates, [...CLAUDE_MANDATORY_GATES]) || value.status !== 'verified' || value.evidence_class !== CLAUDE_EVIDENCE_CLASS
|
|
72
|
+
|| value.policy_scope !== CLAUDE_POLICY_SCOPE || !isDeepStrictEqual(value.accepted_residual_risks, [...CLAUDE_RISKS])
|
|
73
|
+
|| value.release_manifest_sha256 !== claudeCanonicalSha256(release) || !isDeepStrictEqual(value.model_ids, [...release.model_ids])
|
|
74
|
+
|| !digest(fingerprint) || claudeCanonicalSha256(unsigned) !== fingerprint || expected.requirementsSha256 !== undefined && fingerprint !== expected.requirementsSha256)
|
|
75
|
+
throw rejected();
|
|
76
|
+
return freeze(structuredClone(value));
|
|
77
|
+
}
|
|
78
|
+
/** Host platform in the Gateway's naming, or undefined where no official build is admitted. */
|
|
79
|
+
export function hostClaudePlatform(platform = process.platform, arch = process.arch) {
|
|
80
|
+
if (platform !== 'darwin')
|
|
81
|
+
return undefined;
|
|
82
|
+
return arch === 'arm64' ? 'darwin-arm64' : arch === 'x64' ? 'darwin-x64' : undefined;
|
|
83
|
+
}
|
|
84
|
+
export function claudeReleaseUrl(release) {
|
|
85
|
+
const r = parseClaudeSyncRelease({ version: release.version, platform: release.platform, binary_sha256: release.binary_sha256, model_ids: [...release.model_ids] });
|
|
86
|
+
if (!r)
|
|
87
|
+
throw Error('CLAUDE_RELEASE_FETCH_REJECTED');
|
|
88
|
+
return `${CLAUDE_DOWNLOAD_BASE}/${r.version}/${r.platform}/claude`;
|
|
89
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { SUPPORTED_CLI_MODE, SUPPORTED_CLI_GATES } from './cli-security-modes.js';
|
|
3
|
+
import { CLAUDE_RELEASE_MODE, CLAUDE_MANDATORY_GATES } from './claude-release.js';
|
|
3
4
|
/** Mandatory for every CLI. New adapter IDs do not alter this gate set. */
|
|
4
5
|
export const JOB_SECURITY_GATES = Object.freeze(['binary_binding', 'native_tools', 'managed_policy', 'credential_boundary', 'source_command_scope', 'request_binding', 'provider_egress', 'owned_lifecycle']);
|
|
5
6
|
const issued = new WeakMap();
|
|
@@ -7,9 +8,12 @@ const rejected = () => new Error('JOB_SECURITY_REJECTED');
|
|
|
7
8
|
const keys = ['adapterId', 'adapterVersion', 'jobId', 'generation', 'modelId', 'sourceSha', 'binarySha256', 'configSha256', 'managedPolicySha256', 'toolsSha256', 'commandsSha256', 'osProfileSha256', 'requirementsSha256'];
|
|
8
9
|
const object = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
9
10
|
const exact = (v, names) => JSON.stringify(Object.keys(v).sort()) === JSON.stringify([...names].sort());
|
|
11
|
+
/** Each explicit mode belongs to exactly one adapter and one gate set (Claude: decision 9095c7a8). */
|
|
12
|
+
const MODE_ADAPTER = Object.freeze({ [SUPPORTED_CLI_MODE]: 'codex_cli', [CLAUDE_RELEASE_MODE]: 'claude_code_cli' });
|
|
13
|
+
const modeGates = (mode) => mode === SUPPORTED_CLI_MODE ? SUPPORTED_CLI_GATES : mode === CLAUDE_RELEASE_MODE ? CLAUDE_MANDATORY_GATES : JOB_SECURITY_GATES;
|
|
10
14
|
function validPin(value) {
|
|
11
15
|
if (!object(value) || !exact(value, value.securityMode === undefined ? keys : [...keys, 'securityMode'])
|
|
12
|
-
|| value.securityMode !== undefined && (value.securityMode !==
|
|
16
|
+
|| value.securityMode !== undefined && (typeof value.securityMode !== 'string' || !Object.hasOwn(MODE_ADAPTER, value.securityMode) || MODE_ADAPTER[value.securityMode] !== value.adapterId) || !Number.isSafeInteger(value.generation) || Number(value.generation) < 1)
|
|
13
17
|
return false;
|
|
14
18
|
for (const name of keys.filter(k => k !== 'generation')) {
|
|
15
19
|
const v = value[name];
|
|
@@ -28,7 +32,7 @@ export async function admitJobSecurity(expected, verify) {
|
|
|
28
32
|
throw rejected();
|
|
29
33
|
const pin = Object.freeze(structuredClone(expected));
|
|
30
34
|
const receipt = await verify(pin);
|
|
31
|
-
const gates = pin.securityMode
|
|
35
|
+
const gates = modeGates(pin.securityMode);
|
|
32
36
|
if (!object(receipt) || !exact(receipt, ['version', 'pin', 'checks']) || receipt.version !== (pin.securityMode ? 2 : 1)
|
|
33
37
|
|| !validPin(receipt.pin) || encoded(receipt.pin) !== encoded(pin) || !Array.isArray(receipt.checks)
|
|
34
38
|
|| receipt.checks.length !== gates.length)
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { requireJobSecurity } from './job-security-policy.js';
|
|
2
2
|
import { requireSecurityRequirements } from './security-requirements.js';
|
|
3
|
+
import { SUPPORTED_CLI_MODE } from './cli-security-modes.js';
|
|
4
|
+
import { CLAUDE_RELEASE_MODE } from './claude-release.js';
|
|
3
5
|
/** Audit linkage only. Gateway must preserve employee_runner provenance; serialized
|
|
4
6
|
* JSON never mints host authority or native isolation certification. */
|
|
5
7
|
export function createJobSecurityReceipt(policy, input) {
|
|
@@ -10,8 +12,13 @@ export function createJobSecurityReceipt(policy, input) {
|
|
|
10
12
|
const r = requireSecurityRequirements(input.requirements, { adapterId: policy.pin.adapterId, adapterVersion: policy.pin.adapterVersion, requirementsSha256: policy.pin.requirementsSha256 });
|
|
11
13
|
if (policy.pin.securityMode !== r.security_mode)
|
|
12
14
|
throw Error();
|
|
13
|
-
const supported =
|
|
14
|
-
|
|
15
|
+
const supported = policy.pin.securityMode === SUPPORTED_CLI_MODE, claude = policy.pin.securityMode === CLAUDE_RELEASE_MODE;
|
|
16
|
+
// Claude contract: docs/superpowers/plans/2026-10-27-claude-admission-p2a-gateway.md. The pinned binary is the listed release binary.
|
|
17
|
+
if (claude && policy.pin.binarySha256 !== r.release?.binary_sha256)
|
|
18
|
+
throw Error();
|
|
19
|
+
return Object.freeze({ ...(supported || claude ? { security_mode: r.security_mode, accepted_residual_risks: Object.freeze([...r.accepted_residual_risks]) } : {}),
|
|
20
|
+
...(claude ? { transport: r.transport, release_manifest_sha256: r.release_manifest_sha256 } : {}),
|
|
21
|
+
receipt_version: claude ? 'cli-claude-job-security-receipt-v1' : supported ? 'cli-job-security-receipt-v2' : 'cli-job-security-receipt-v1', evidence_origin: 'employee_runner', phase: 'pre_execution', job_id: input.jobId, generation: input.generation,
|
|
15
22
|
requirements_fingerprint: r.fingerprint, launch_fingerprint: input.launchFingerprint, contract_fingerprint: input.contractFingerprint, task_package_fingerprint: input.taskPackageFingerprint, policy_fingerprint: policy.fingerprint, pin: Object.freeze({ ...policy.pin }) });
|
|
16
23
|
}
|
|
17
24
|
catch {
|