mtech-ai-local-runner 0.1.63 → 0.1.65
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/dist/src/adapters/claude-code/adapter.js +52 -36
- package/dist/src/adapters/claude-code/runtime-options.js +5 -1
- package/dist/src/cli.js +4 -0
- package/dist/src/commands/claude-release.js +105 -0
- package/dist/src/safety/claude-production-host.js +2 -2
- package/dist/src/safety/claude-release-copy.js +61 -25
- package/dist/src/safety/claude-release.js +18 -1
- package/dist/src/safety/claude-review-campaign.js +125 -0
- package/dist/src/safety/claude-review-evidence.js +96 -0
- package/dist/src/safety/claude-review-fetch.js +72 -0
- package/dist/src/safety/claude-review-layout.js +92 -0
- package/dist/src/safety/claude-review-provider.js +154 -0
- package/dist/src/safety/claude-review-run.js +148 -0
- package/dist/src/safety/claude-review-sandbox.js +35 -0
- package/package.json +1 -1
- package/dist/src/daemon/job-poller.js +0 -42
- package/dist/src/daemon/session-open-poller.js +0 -123
|
@@ -1,6 +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
|
+
import { CLAUDE_LAUNCH_OPT_OUTS, CLAUDE_RELEASE_MODE, CLAUDE_TRANSPORT } from '../../safety/claude-release.js';
|
|
4
4
|
import { createJobSecurityReceipt } from '../../safety/job-security-receipt.js';
|
|
5
5
|
import { isTerminalEventType } from "../../protocol.js";
|
|
6
6
|
import { parseClaudeLine } from "./stream-parser.js";
|
|
@@ -23,6 +23,46 @@ function allowlistedEnv(env) {
|
|
|
23
23
|
}
|
|
24
24
|
return result;
|
|
25
25
|
}
|
|
26
|
+
export function claudeLaunchArgs(input) {
|
|
27
|
+
const args = [
|
|
28
|
+
"--print",
|
|
29
|
+
"--input-format", "stream-json",
|
|
30
|
+
"--output-format", "stream-json",
|
|
31
|
+
"--verbose",
|
|
32
|
+
"--include-partial-messages",
|
|
33
|
+
"--no-chrome",
|
|
34
|
+
"--permission-mode", "default",
|
|
35
|
+
"--model", input.modelId,
|
|
36
|
+
...(input.maxBudgetUsd === null ? [] : ["--max-budget-usd", String(input.maxBudgetUsd)]),
|
|
37
|
+
"--name", input.name,
|
|
38
|
+
"--strict-mcp-config",
|
|
39
|
+
"--mcp-config", input.mcpConfigPath,
|
|
40
|
+
"--settings", JSON.stringify(input.settings),
|
|
41
|
+
"--setting-sources", "",
|
|
42
|
+
"--disable-slash-commands",
|
|
43
|
+
"--tools", "",
|
|
44
|
+
"--allowedTools", LOCAL_NAMES.join(","),
|
|
45
|
+
];
|
|
46
|
+
if (input.resumeSessionId) {
|
|
47
|
+
args.push("--resume", input.resumeSessionId);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
args.push("--session-id", input.sessionId);
|
|
51
|
+
}
|
|
52
|
+
return args;
|
|
53
|
+
}
|
|
54
|
+
/** The inline `--settings`: only the runner's PreToolUse hook. */
|
|
55
|
+
export function claudeLaunchSettings(hookCommand) {
|
|
56
|
+
return { hooks: { PreToolUse: [{ matcher: "*", hooks: [{ type: "command", command: hookCommand }] }] } };
|
|
57
|
+
}
|
|
58
|
+
/** The allowlisted environment, the documented opt-outs and the encoded hook policy; nothing else of the runner's environment reaches the CLI. */
|
|
59
|
+
export function claudeLaunchEnv(env, hookPolicy) {
|
|
60
|
+
return { ...allowlistedEnv(env), ...CLAUDE_LAUNCH_OPT_OUTS, [HOOK_POLICY_ENV_VAR]: encodeHookPolicy(hookPolicy) };
|
|
61
|
+
}
|
|
62
|
+
/** The one stream-json user message line written to the CLI's stdin. */
|
|
63
|
+
export function claudeUserInput(text) {
|
|
64
|
+
return `${JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text }] } })}\n`;
|
|
65
|
+
}
|
|
26
66
|
function contractBudget(taskPackage) {
|
|
27
67
|
const pkg = taskPackage;
|
|
28
68
|
const execution = pkg.agent_execution;
|
|
@@ -141,40 +181,16 @@ export class ClaudeCodeAdapter {
|
|
|
141
181
|
registeredReadTools: this.options.registeredReadTools,
|
|
142
182
|
generatedPaths: [mcpConfigPath],
|
|
143
183
|
});
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
"--include-partial-messages",
|
|
155
|
-
"--no-chrome",
|
|
156
|
-
"--permission-mode", "default",
|
|
157
|
-
"--model", resolvedModelId,
|
|
158
|
-
...(maxBudgetUsd === null ? [] : ["--max-budget-usd", String(maxBudgetUsd)]),
|
|
159
|
-
"--name", this.options.workItemLabel.trim().slice(0, 80) || `work-item-${ctx.bindingId.slice(0, 8)}`,
|
|
160
|
-
"--strict-mcp-config",
|
|
161
|
-
"--mcp-config", mcpConfigPath,
|
|
162
|
-
"--settings", JSON.stringify(settings),
|
|
163
|
-
"--setting-sources", "",
|
|
164
|
-
"--disable-slash-commands",
|
|
165
|
-
"--tools", "",
|
|
166
|
-
"--allowedTools", LOCAL_NAMES.join(","),
|
|
167
|
-
];
|
|
168
|
-
if (ctx.resumeSessionId) {
|
|
169
|
-
args.push("--resume", ctx.resumeSessionId);
|
|
170
|
-
}
|
|
171
|
-
else {
|
|
172
|
-
args.push("--session-id", ctx.sessionId);
|
|
173
|
-
}
|
|
174
|
-
const env = {
|
|
175
|
-
...allowlistedEnv(this.options.env),
|
|
176
|
-
[HOOK_POLICY_ENV_VAR]: encodeHookPolicy(hookPolicy),
|
|
177
|
-
};
|
|
184
|
+
const args = claudeLaunchArgs({
|
|
185
|
+
modelId: resolvedModelId,
|
|
186
|
+
maxBudgetUsd,
|
|
187
|
+
name: this.options.workItemLabel.trim().slice(0, 80) || `work-item-${ctx.bindingId.slice(0, 8)}`,
|
|
188
|
+
mcpConfigPath,
|
|
189
|
+
settings: claudeLaunchSettings(hookCommand),
|
|
190
|
+
sessionId: ctx.sessionId,
|
|
191
|
+
resumeSessionId: ctx.resumeSessionId,
|
|
192
|
+
});
|
|
193
|
+
const env = claudeLaunchEnv(this.options.env, hookPolicy);
|
|
178
194
|
await this.options.assertCurrentInputs?.();
|
|
179
195
|
if (this.cancelled)
|
|
180
196
|
throw new Error('adapter startup cancelled');
|
|
@@ -186,7 +202,7 @@ export class ClaudeCodeAdapter {
|
|
|
186
202
|
this.handle = handle;
|
|
187
203
|
if (this.cancelled)
|
|
188
204
|
throw new Error("adapter startup cancelled");
|
|
189
|
-
handle.stdin.write(
|
|
205
|
+
handle.stdin.write(claudeUserInput(prompt));
|
|
190
206
|
}
|
|
191
207
|
catch (error) {
|
|
192
208
|
// Settle only the ownership-acquisition fence before cleanup. Waiting for
|
|
@@ -30,6 +30,10 @@ async function canonicalFuture(file) {
|
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
|
+
/** The job MCP config: only the job-scoped ai_local server, with its token. Jobs and the offline release review write this. */
|
|
34
|
+
export function claudeJobMcpConfig(endpoint) {
|
|
35
|
+
return { mcpServers: { ai_local: { type: 'http', url: endpoint.url, headers: { Authorization: `Bearer ${endpoint.token}` } } } };
|
|
36
|
+
}
|
|
33
37
|
/** Wiring only. No release verifier or admission certificate exists here. */
|
|
34
38
|
export function createClaudeCodeAdapterOptions(input) {
|
|
35
39
|
if (input.securityHost && (input.securityPolicy || input.nativeBinding || input.commandRegistry) || !!input.securityHost !== !!input.binaryIdentity)
|
|
@@ -146,7 +150,7 @@ export function createClaudeCodeAdapterOptions(input) {
|
|
|
146
150
|
}
|
|
147
151
|
},
|
|
148
152
|
writeLocalMcpConfig: endpoint => {
|
|
149
|
-
writeFileSync(config, JSON.stringify(
|
|
153
|
+
writeFileSync(config, JSON.stringify(claudeJobMcpConfig(endpoint)), { mode: 0o600, flag: 'wx' });
|
|
150
154
|
return config;
|
|
151
155
|
},
|
|
152
156
|
};
|
package/dist/src/cli.js
CHANGED
|
@@ -34,6 +34,7 @@ import { runHookClient } from "./asks/hook-client.js";
|
|
|
34
34
|
import { installGuideIntegrations, installIntegrations, parseFeatures, parseSetupIntegrations, removeAsksInstall, removeGuideInstall, runIntegrationsCommand } from "./asks/commands.js";
|
|
35
35
|
import { asksDoctorReport } from "./asks/doctor.js";
|
|
36
36
|
import { guideDoctorReport, guideReportShown } from "./guide/doctor.js";
|
|
37
|
+
import { runClaudeReleaseCommand } from "./commands/claude-release.js";
|
|
37
38
|
function hookAction(result) {
|
|
38
39
|
return result.reason === "invalid_command"
|
|
39
40
|
? "Run the runner with a Node executable and package path outside the workspace, without quotes, $, backquotes or backslashes, then rerun doctor."
|
|
@@ -178,6 +179,9 @@ export async function main(argv, env, io = {}) {
|
|
|
178
179
|
}
|
|
179
180
|
if (argv[0] === "integrations")
|
|
180
181
|
return runIntegrationsCommand(argv.slice(1), { env, stateRoot: defaultStateRootFromEnv(env), stdout, stderr });
|
|
182
|
+
// Offline Claude release review (Work Item 1c8026f1, P2c): no runner config, daemon or Gateway call.
|
|
183
|
+
if (argv[0] === "claude-release")
|
|
184
|
+
return runClaudeReleaseCommand(argv.slice(1), { stdout, stderr, fetchFn: io.fetchFn ?? fetch, ...io.claudeRelease });
|
|
181
185
|
let config;
|
|
182
186
|
try {
|
|
183
187
|
config = loadConfig(argv, env);
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { lstat, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { packagedHookCommandParts } from '../safety/hook-command.js';
|
|
6
|
+
import { measureCommandFile } from '../safety/registered-command-integrity.js';
|
|
7
|
+
import { hostClaudePlatform, parseClaudeSyncRelease } from '../safety/claude-release.js';
|
|
8
|
+
import { privateDirectory, verifyClaudeCodeSignature } from '../safety/claude-release-copy.js';
|
|
9
|
+
import { fetchClaudeReleaseForReview } from '../safety/claude-review-fetch.js';
|
|
10
|
+
import { runClaudeReviewCampaign } from '../safety/claude-review-campaign.js';
|
|
11
|
+
import { CLAUDE_AUTH_OBSERVATION_MAX_BYTES, CLAUDE_REVIEW_EVIDENCE_FILE, claudeReleaseCandidate, claudeReviewEvidenceSha256, parseClaudeAuthObservation } from '../safety/claude-review-evidence.js';
|
|
12
|
+
export const CLAUDE_RELEASE_USAGE = 'Usage: mtec-ai-local-runner claude-release fetch --version <version> --platform <darwin-arm64|darwin-x64> --out <dir>\n'
|
|
13
|
+
+ ' mtec-ai-local-runner claude-release review --binary <path> --version <version> --platform <platform> --sha256 <sha256> --model <id> [--model <id> ...] --out <dir> [--auth-observation <file>]';
|
|
14
|
+
function flags(argv, single, repeated = []) {
|
|
15
|
+
const values = new Map();
|
|
16
|
+
for (let i = 0; i < argv.length; i += 2) {
|
|
17
|
+
const flag = argv[i], value = argv[i + 1], name = flag.startsWith('--') ? flag.slice(2) : '';
|
|
18
|
+
if (!name || value === undefined || value.startsWith('--') || ![...single, ...repeated].includes(name))
|
|
19
|
+
return null;
|
|
20
|
+
const list = values.get(name) ?? [];
|
|
21
|
+
if (list.length && !repeated.includes(name))
|
|
22
|
+
return null;
|
|
23
|
+
list.push(value);
|
|
24
|
+
values.set(name, list);
|
|
25
|
+
}
|
|
26
|
+
return values;
|
|
27
|
+
}
|
|
28
|
+
const one = (f, name) => f.get(name)?.[0];
|
|
29
|
+
const codeOf = (error) => error instanceof Error && /^CLAUDE_[A-Z_]+$/.test(error.message) ? error.message : 'CLAUDE_REVIEW_FAILED';
|
|
30
|
+
const absolute = (value) => typeof value === 'string' && path.isAbsolute(value) && path.resolve(value) === value && !/[\r\n\0]/.test(value);
|
|
31
|
+
/** Read-only roots for the reviewed CLI's PreToolUse hook: this Node's prefix and the installed package (dist/src/commands -> package). */
|
|
32
|
+
function reviewReadRoots() { return [path.dirname(path.dirname(process.execPath)), path.resolve(fileURLToPath(new URL('../../../', import.meta.url)))]; }
|
|
33
|
+
async function boundedObservation(file) {
|
|
34
|
+
const s = await lstat(file).catch(() => undefined);
|
|
35
|
+
if (!s?.isFile() || s.size > CLAUDE_AUTH_OBSERVATION_MAX_BYTES)
|
|
36
|
+
throw Error('CLAUDE_AUTH_OBSERVATION_REJECTED');
|
|
37
|
+
return readFile(file);
|
|
38
|
+
}
|
|
39
|
+
async function writeEvidence(out, evidence) {
|
|
40
|
+
const file = path.join(out, CLAUDE_REVIEW_EVIDENCE_FILE), temporary = path.join(out, `.${randomUUID()}.evidence`);
|
|
41
|
+
try {
|
|
42
|
+
await writeFile(temporary, `${JSON.stringify(evidence, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
|
|
43
|
+
await rename(temporary, file);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
await rm(temporary, { force: true }).catch(() => { });
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
return file;
|
|
50
|
+
}
|
|
51
|
+
/** `claude-release fetch|review` (design "Release review"). fetch is the only networked step; review needs no network beyond loopback. */
|
|
52
|
+
export async function runClaudeReleaseCommand(argv, io) {
|
|
53
|
+
const usage = () => { io.stderr(CLAUDE_RELEASE_USAGE); return 1; };
|
|
54
|
+
const failed = (error) => { io.stderr(JSON.stringify({ ok: false, code: codeOf(error) })); return 1; };
|
|
55
|
+
if (argv[0] === 'fetch') {
|
|
56
|
+
const f = flags(argv.slice(1), ['version', 'platform', 'out']);
|
|
57
|
+
if (!f || f.size !== 3)
|
|
58
|
+
return usage();
|
|
59
|
+
try {
|
|
60
|
+
const result = await fetchClaudeReleaseForReview({ version: one(f, 'version'), platform: one(f, 'platform'), out: one(f, 'out'), fetchFn: io.fetchFn, ...(io.codesign ? { codesign: io.codesign } : {}) });
|
|
61
|
+
io.stdout(JSON.stringify({ ok: true, ...result }));
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
return failed(error);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (argv[0] !== 'review')
|
|
69
|
+
return usage();
|
|
70
|
+
const f = flags(argv.slice(1), ['binary', 'version', 'platform', 'sha256', 'out', 'auth-observation'], ['model']);
|
|
71
|
+
if (!f || !['binary', 'version', 'platform', 'sha256', 'out', 'model'].every(name => f.has(name)))
|
|
72
|
+
return usage();
|
|
73
|
+
try {
|
|
74
|
+
const release = { version: one(f, 'version'), platform: one(f, 'platform'), binary_sha256: one(f, 'sha256') }, models = [...f.get('model')];
|
|
75
|
+
const binary = one(f, 'binary'), out = one(f, 'out'), observation = one(f, 'auth-observation');
|
|
76
|
+
if (!parseClaudeSyncRelease({ ...release, model_ids: models }) || !absolute(binary) || !absolute(out) || observation !== undefined && !absolute(observation))
|
|
77
|
+
throw Error('CLAUDE_REVIEW_INPUT_REJECTED');
|
|
78
|
+
if (release.platform !== hostClaudePlatform())
|
|
79
|
+
throw Error('CLAUDE_REVIEW_PLATFORM_MISMATCH');
|
|
80
|
+
// The reviewed bytes are the named bytes: re-hash and re-verify the signature before anything runs (RW-03).
|
|
81
|
+
const s = await lstat(binary).catch(() => undefined);
|
|
82
|
+
if (!s?.isFile() || await measureCommandFile(binary).catch(() => undefined) !== release.binary_sha256)
|
|
83
|
+
throw Error('CLAUDE_REVIEW_BINARY_REJECTED');
|
|
84
|
+
await verifyClaudeCodeSignature(binary, io.codesign);
|
|
85
|
+
const auth = observation === undefined ? undefined : parseClaudeAuthObservation(await boundedObservation(observation), release);
|
|
86
|
+
try {
|
|
87
|
+
await privateDirectory(out);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
throw Error('CLAUDE_REVIEW_OUTPUT_REJECTED');
|
|
91
|
+
}
|
|
92
|
+
const evidence = await (io.campaign ?? runClaudeReviewCampaign)({ binary, release, models, hook: io.hook ?? packagedHookCommandParts(), readRoots: reviewReadRoots(),
|
|
93
|
+
...(auth ? { authObservation: auth } : {}), ...(io.codesign ? { codesign: io.codesign } : {}) });
|
|
94
|
+
const file = await writeEvidence(out, evidence), entry = claudeReleaseCandidate(evidence);
|
|
95
|
+
if (!entry) {
|
|
96
|
+
io.stdout(JSON.stringify({ ok: false, evidence: file, evidence_sha256: claudeReviewEvidenceSha256(evidence), failed: evidence.scenarios.filter(x => x.result === 'failed').map(x => x.id) }));
|
|
97
|
+
return 2;
|
|
98
|
+
}
|
|
99
|
+
io.stdout(JSON.stringify({ ok: true, evidence: file, ready_for_listing: evidence.verdict.ready_for_listing, blocked_by: evidence.verdict.blocked_by, entry }));
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
return failed(error);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -7,7 +7,7 @@ import { claudeLoginFromStatus } from '../adapters/claude-code/login-status.js';
|
|
|
7
7
|
import { captureClaudeBinaryIdentity } from './claude-binary-identity.js';
|
|
8
8
|
import { commandRegistryFingerprint } from './job-security-session.js';
|
|
9
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';
|
|
10
|
+
import { CLAUDE_LAUNCH_OPT_OUTS, CLAUDE_MANDATORY_GATES, CLAUDE_RELEASE_MODE, CLAUDE_TRANSPORT, claudeCanonicalSha256, hostClaudePlatform, requireClaudeRequirements } from './claude-release.js';
|
|
11
11
|
import { claudeReleaseCopyPath, claudeReleaseRoot, createClaudeReleaseProvisioner, verifyClaudeCodeSignature, verifyClaudeReleaseCopy } from './claude-release-copy.js';
|
|
12
12
|
import { claudeManagedScopeDeps, claudeManagedScopeReady, collectClaudeManagedScope } from './claude-managed-scope.js';
|
|
13
13
|
/** A release whose provisioning failed is not fetched again before this delay, doubled per further consecutive failure
|
|
@@ -38,7 +38,7 @@ export const CLAUDE_LAUNCH_CONFIG = Object.freeze({ transport: CLAUDE_TRANSPORT,
|
|
|
38
38
|
flags: Object.freeze(['--allowedTools', '--disable-slash-commands', '--include-partial-messages', '--input-format', '--mcp-config', '--model', '--name',
|
|
39
39
|
'--no-chrome', '--output-format', '--permission-mode', '--print', '--setting-sources', '--settings', '--strict-mcp-config', '--tools', '--verbose']),
|
|
40
40
|
conditional_flags: Object.freeze(['--max-budget-usd', '--resume', '--session-id']),
|
|
41
|
-
settings_keys: Object.freeze(['hooks.PreToolUse']) });
|
|
41
|
+
settings_keys: Object.freeze(['hooks.PreToolUse']), env_opt_outs: CLAUDE_LAUNCH_OPT_OUTS });
|
|
42
42
|
const CHECK_IDS = { CLAUDE_RELEASE_NOT_LISTED: 'release_list', CLAUDE_RELEASE_PLATFORM_MISMATCH: 'release_platform',
|
|
43
43
|
CLAUDE_RELEASE_COPY_UNAVAILABLE: 'release_copy', CLAUDE_CODE_SIGNATURE_REJECTED: 'release_copy', CLAUDE_MANAGED_SCOPE_UNVERIFIED: 'managed_scope' };
|
|
44
44
|
/** A login probe answer (any status) is reused this long for the same release and copy identity. */
|
|
@@ -86,7 +86,8 @@ export async function verifyClaudeCodeSignature(file, run = runCodesign) {
|
|
|
86
86
|
/** Official builds are about 245 MB; the cap stays below measureCommandFile's 512 MiB limit. */
|
|
87
87
|
export const CLAUDE_RELEASE_MAX_BYTES = 384 * 1024 * 1024;
|
|
88
88
|
const FETCH_TIMEOUT_MS = 15 * 60_000;
|
|
89
|
-
|
|
89
|
+
/** A 0700 directory owned by the runner's user at its own realpath, created if absent. */
|
|
90
|
+
export async function privateDirectory(dir) {
|
|
90
91
|
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
91
92
|
const s = await lstat(dir), uid = process.getuid?.();
|
|
92
93
|
if (uid === undefined || !s.isDirectory() || s.isSymbolicLink() || s.uid !== uid || (s.mode & 0o077) !== 0 || await realpath(dir) !== dir)
|
|
@@ -98,6 +99,63 @@ async function writeAll(file, chunk) {
|
|
|
98
99
|
offset += bytesWritten;
|
|
99
100
|
}
|
|
100
101
|
}
|
|
102
|
+
/** A 200 response with a body whose declared length, if any, is within maxBytes. */
|
|
103
|
+
async function boundedBody(response, maxBytes) {
|
|
104
|
+
const body = response.body, declared = response.headers.get('content-length');
|
|
105
|
+
if (response.status !== 200 || !body || declared !== null && (!/^\d{1,12}$/.test(declared) || Number(declared) > maxBytes)) {
|
|
106
|
+
await body?.cancel().catch(() => { });
|
|
107
|
+
throw Error('CLAUDE_RELEASE_FETCH_REJECTED');
|
|
108
|
+
}
|
|
109
|
+
return body;
|
|
110
|
+
}
|
|
111
|
+
/** Streams one official artifact into an open exclusive file under a byte cap, hashing it as it is written. */
|
|
112
|
+
export async function readClaudeArtifact(response, file, maxBytes) {
|
|
113
|
+
const reader = (await boundedBody(response, maxBytes)).getReader(), hash = createHash('sha256');
|
|
114
|
+
let size = 0;
|
|
115
|
+
try {
|
|
116
|
+
for (;;) {
|
|
117
|
+
const { done, value } = await reader.read();
|
|
118
|
+
if (done)
|
|
119
|
+
break;
|
|
120
|
+
size += value.byteLength;
|
|
121
|
+
if (size > maxBytes)
|
|
122
|
+
throw Error('CLAUDE_RELEASE_SIZE_REJECTED');
|
|
123
|
+
hash.update(value);
|
|
124
|
+
await writeAll(file, value);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
await reader.cancel().catch(() => { });
|
|
129
|
+
throw error instanceof Error && error.message === 'CLAUDE_RELEASE_SIZE_REJECTED' ? error : Error('CLAUDE_RELEASE_FETCH_REJECTED');
|
|
130
|
+
}
|
|
131
|
+
return { sha256: hash.digest('hex'), size };
|
|
132
|
+
}
|
|
133
|
+
/** One small official JSON document (the release manifest), at most maxBytes. */
|
|
134
|
+
export async function readClaudeJson(response, maxBytes) {
|
|
135
|
+
const reader = (await boundedBody(response, maxBytes)).getReader(), chunks = [];
|
|
136
|
+
let size = 0;
|
|
137
|
+
try {
|
|
138
|
+
for (;;) {
|
|
139
|
+
const { done, value } = await reader.read();
|
|
140
|
+
if (done)
|
|
141
|
+
break;
|
|
142
|
+
size += value.byteLength;
|
|
143
|
+
if (size > maxBytes)
|
|
144
|
+
throw Error();
|
|
145
|
+
chunks.push(value);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
await reader.cancel().catch(() => { });
|
|
150
|
+
throw Error('CLAUDE_RELEASE_FETCH_REJECTED');
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)));
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
throw Error('CLAUDE_RELEASE_FETCH_REJECTED');
|
|
157
|
+
}
|
|
158
|
+
}
|
|
101
159
|
/** The release exactly as the strict parser reads it, or null: the URL and paths never come from a looser reading. */
|
|
102
160
|
function listedRelease(release) {
|
|
103
161
|
try {
|
|
@@ -156,32 +214,10 @@ export function createClaudeReleaseProvisioner(input) {
|
|
|
156
214
|
catch {
|
|
157
215
|
throw Error('CLAUDE_RELEASE_FETCH_REJECTED');
|
|
158
216
|
}
|
|
159
|
-
const
|
|
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
|
-
}
|
|
217
|
+
const { sha256 } = await readClaudeArtifact(response, file, maxBytes);
|
|
182
218
|
await file.sync();
|
|
183
219
|
await file.close();
|
|
184
|
-
if (
|
|
220
|
+
if (sha256 !== release.binary_sha256 || await measureCommandFile(temporary).catch(() => undefined) !== release.binary_sha256)
|
|
185
221
|
throw Error('CLAUDE_RELEASE_HASH_MISMATCH');
|
|
186
222
|
await verifyClaudeCodeSignature(temporary, input.codesign);
|
|
187
223
|
await chmod(temporary, 0o500);
|
|
@@ -85,5 +85,22 @@ export function claudeReleaseUrl(release) {
|
|
|
85
85
|
const r = parseClaudeSyncRelease({ version: release.version, platform: release.platform, binary_sha256: release.binary_sha256, model_ids: [...release.model_ids] });
|
|
86
86
|
if (!r)
|
|
87
87
|
throw Error('CLAUDE_RELEASE_FETCH_REJECTED');
|
|
88
|
-
return
|
|
88
|
+
return claudeArtifactUrl(r.version, r.platform);
|
|
89
|
+
}
|
|
90
|
+
/** Documented Claude Code opt-outs on every Claude launch, job and release review alike (owner choice 2026-10-28): the
|
|
91
|
+
* pinned copy never updates itself and sends no telemetry, error reports or other non-essential traffic. */
|
|
92
|
+
export const CLAUDE_LAUNCH_OPT_OUTS = Object.freeze({ DISABLE_AUTOUPDATER: '1', CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
|
|
93
|
+
DISABLE_TELEMETRY: '1', DISABLE_ERROR_REPORTING: '1', DISABLE_BUG_COMMAND: '1', DISABLE_NON_ESSENTIAL_MODEL_CALLS: '1' });
|
|
94
|
+
const RELEASE_VERSION = /^\d{1,4}\.\d{1,4}\.\d{1,6}$/;
|
|
95
|
+
/** The official per-version manifest; platforms[<platform>].checksum is the binary sha256 (Work Evidence c85ec02c). */
|
|
96
|
+
export function claudeManifestUrl(version) {
|
|
97
|
+
if (typeof version !== 'string' || !RELEASE_VERSION.test(version))
|
|
98
|
+
throw Error('CLAUDE_RELEASE_FETCH_REJECTED');
|
|
99
|
+
return `${CLAUDE_DOWNLOAD_BASE}/${version}/manifest.json`;
|
|
100
|
+
}
|
|
101
|
+
/** The uncompressed official binary of one version and platform; the only artifact the Runner fetches. */
|
|
102
|
+
export function claudeArtifactUrl(version, platform) {
|
|
103
|
+
if (typeof version !== 'string' || !RELEASE_VERSION.test(version) || !CLAUDE_PLATFORMS.includes(platform))
|
|
104
|
+
throw Error('CLAUDE_RELEASE_FETCH_REJECTED');
|
|
105
|
+
return `${CLAUDE_DOWNLOAD_BASE}/${version}/${platform}/claude`;
|
|
89
106
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { constants, existsSync } from 'node:fs';
|
|
5
|
+
import { chmod, copyFile, lstat, rm, stat } from 'node:fs/promises';
|
|
6
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
7
|
+
import { NodeProcessGroupHost } from '../process/node-process-group-host.js';
|
|
8
|
+
import { packageVersion } from '../package-version.js';
|
|
9
|
+
import { HOOK_POLICY_ENV_VAR } from './hook-policy-builder.js';
|
|
10
|
+
import { CLAUDE_LOCAL_MCP_TOOLS } from './pre-tool-use-hook.js';
|
|
11
|
+
import { localToolInventory } from './local-tool-server.js';
|
|
12
|
+
import { measureCommandFile } from './registered-command-integrity.js';
|
|
13
|
+
import { CLAUDE_LAUNCH_OPT_OUTS, CLAUDE_SIGNING_IDENTIFIER, CLAUDE_TEAM_ID, claudeCanonicalSha256, parseClaudeSyncRelease } from './claude-release.js';
|
|
14
|
+
import { CLAUDE_CODE_REQUIREMENT, verifyClaudeCodeSignature } from './claude-release-copy.js';
|
|
15
|
+
import { CLAUDE_LAUNCH_CONFIG } from './claude-production-host.js';
|
|
16
|
+
import { CLAUDE_REVIEW_PROFILE_TEMPLATE_SHA256, CLAUDE_REVIEW_PROFILE_VERSION } from './claude-review-sandbox.js';
|
|
17
|
+
import { CLAUDE_REVIEW_KEYCHAIN_READS, CLAUDE_REVIEW_PLANTED_SOURCES, createClaudeReviewLayout } from './claude-review-layout.js';
|
|
18
|
+
import { CLAUDE_REVIEW_FAKE_KEY, CLAUDE_REVIEW_PROMPT, CLAUDE_REVIEW_RUN_TIMEOUT_MS, CLAUDE_REVIEW_SLASH_PROMPT, CLAUDE_REVIEW_TOOL_IDS, claudeReviewEnv, runClaudeReview, runClaudeVersion } from './claude-review-run.js';
|
|
19
|
+
import { CLAUDE_REVIEW_STAGES, buildClaudeReviewEvidence } from './claude-review-evidence.js';
|
|
20
|
+
const AI_LOCAL = Object.freeze([...CLAUDE_LOCAL_MCP_TOOLS].sort());
|
|
21
|
+
const DISCOVERY = Object.freeze(['initialize_response', 'initialized_notification', 'inventory_response']);
|
|
22
|
+
const PLANTED_SENTINELS = Object.freeze(['api-key-helper', 'pre-tool-use', 'session-start', 'user-prompt-submit']);
|
|
23
|
+
const sha = (text) => createHash('sha256').update(text).digest('hex');
|
|
24
|
+
function scenario(id, checks, counts) {
|
|
25
|
+
const failures = Object.keys(checks).filter(name => !checks[name]).sort();
|
|
26
|
+
return { id, stage: CLAUDE_REVIEW_STAGES[id], result: failures.length ? 'failed' : 'passed', counts, failures };
|
|
27
|
+
}
|
|
28
|
+
/** Scenario verdicts from recorded runs. Counts are booleans' tallies and script-fixed numbers only, so they are deterministic. */
|
|
29
|
+
export function evaluateClaudeReview(input) {
|
|
30
|
+
const { runs, canaries } = input, IDS = CLAUDE_REVIEW_TOOL_IDS;
|
|
31
|
+
const scripted = runs.filter(r => r.kind !== 'slash'), slash = runs.find(r => r.kind === 'slash'), planted = runs.find(r => r.kind === 'planted');
|
|
32
|
+
const baseline = runs.find(r => r.kind === 'baseline' && r.model === planted?.model);
|
|
33
|
+
const every = (list, test) => list.length > 0 && list.every(test);
|
|
34
|
+
const messages = (r) => r.requests.filter(q => q.kind === 'primary' || q.kind === 'auxiliary');
|
|
35
|
+
const first = (r) => r.requests.find(q => q.kind === 'primary');
|
|
36
|
+
const offered = (r) => r ? [...(first(r)?.toolNames ?? [])].sort() : [];
|
|
37
|
+
const tool = (r, id) => r?.toolResults.get(id);
|
|
38
|
+
const denied = (r, id) => tool(r, id)?.isError === true;
|
|
39
|
+
const hits = (name, list = runs) => list.reduce((n, r) => n + (r.canaryHits[name] ?? 0), 0);
|
|
40
|
+
const sentinel = (names) => runs.some(r => r.sentinels.some(s => names.includes(s)));
|
|
41
|
+
const initialized = (r) => DISCOVERY.every(event => r.discovery.includes(event));
|
|
42
|
+
const returned = (r) => { const t = tool(r, IDS.allowed); return !!t && !t.isError && t.text.includes(canaries.source); };
|
|
43
|
+
const names = [...new Set(runs.flatMap(r => r.requests.flatMap(q => q.toolNames)))].sort();
|
|
44
|
+
// Source content may reach the provider only inside the allowed read's tool result.
|
|
45
|
+
const implicit = runs.reduce((n, r) => n + r.requests.filter(q => q.canaries.includes('source') && !q.toolResultIds.includes(IDS.allowed)).length, 0);
|
|
46
|
+
const slashFirst = slash ? first(slash) : undefined;
|
|
47
|
+
// Refused locally means no Messages request at all; any other request without the literal prompt is unexplained.
|
|
48
|
+
const delivery = !slash ? 'missing' : slashFirst?.firstUserText === CLAUDE_REVIEW_SLASH_PROMPT ? 'literal' : slash.terminal && messages(slash).length === 0 ? 'refused_locally' : 'missing';
|
|
49
|
+
const versionLine = new RegExp(`^${input.release.version.replace(/\./g, '\\.')}(?:\\s|$)`);
|
|
50
|
+
return [
|
|
51
|
+
scenario('inventory', { copy_sha256_matches: input.copySha256 === input.release.binary_sha256, copy_unchanged: input.copyUnchanged,
|
|
52
|
+
version_output_matches: versionLine.test(input.version.line), version_run_sandboxed: input.version.sandboxed, version_run_exit_confirmed: input.version.exitConfirmed }, { version_runs: 1 }),
|
|
53
|
+
scenario('tools_offered', { primary_requests_seen: every(scripted, r => !!first(r)), exact_ai_local_set: every(scripted, r => isDeepStrictEqual(offered(r), [...AI_LOCAL])),
|
|
54
|
+
no_other_tool_names: names.every(name => AI_LOCAL.includes(name)) }, { runs_checked: scripted.length, offered_names: names }),
|
|
55
|
+
scenario('mcp_startup', { initialized: every(runs, initialized) }, { runs_initialized: runs.filter(initialized).length }),
|
|
56
|
+
scenario('allowed_read', { content_returned: every(scripted, returned), served_by_ai_local: every(scripted, r => r.reads.successful >= 1) }, { reads_returned: scripted.filter(returned).length }),
|
|
57
|
+
scenario('disallowed_tool', { native_denied: every(scripted, r => denied(r, IDS.native)), unregistered_mcp_denied: every(scripted, r => denied(r, IDS.unregistered)),
|
|
58
|
+
native_not_executed: !sentinel(['bash']) }, { runs_checked: scripted.length }),
|
|
59
|
+
scenario('settings_ignored', { baseline_compared: !!planted && !!baseline, same_tools: !!planted && !!baseline && isDeepStrictEqual(offered(planted), offered(baseline)),
|
|
60
|
+
same_denials: !!planted && !!baseline && Object.values(IDS).every(id => tool(planted, id) !== undefined && tool(planted, id)?.isError === tool(baseline, id)?.isError),
|
|
61
|
+
no_planted_content: hits('planted') === 0, no_planted_mcp: runs.every(r => r.requests.every(q => q.kind !== 'planted_mcp')), no_planted_hook: !sentinel(PLANTED_SENTINELS),
|
|
62
|
+
no_planted_model: runs.every(r => r.requests.every(q => q.model !== 'planted-model')) }, { planted_sources: CLAUDE_REVIEW_PLANTED_SOURCES.length }),
|
|
63
|
+
scenario('slash_refused', { not_expanded: !!slash && hits('planted', [slash]) === 0, no_command_side_effect: !sentinel(['slash']), delivered_or_refused: delivery !== 'missing' }, { delivery }),
|
|
64
|
+
scenario('source_exclusion', { outside_denied: every(scripted, r => denied(r, IDS.outside)), secret_denied: every(scripted, r => denied(r, IDS.secret)),
|
|
65
|
+
no_outside_content: hits('outside') === 0, no_secret_content: hits('secret') === 0, no_implicit_source: implicit === 0 }, { runs_checked: scripted.length }),
|
|
66
|
+
scenario('fake_credentials', { fake_key_only: scripted.length > 0 && runs.every(r => messages(r).every(q => q.apiKey === CLAUDE_REVIEW_FAKE_KEY)),
|
|
67
|
+
no_authorization_header: runs.every(r => messages(r).every(q => !q.authorization)), no_credential_content: hits('credential') === 0,
|
|
68
|
+
fake_key_not_in_body: runs.every(r => r.fakeKeyInBody === 0), environment_exact: every(runs, r => isDeepStrictEqual(r.envKeys, [...input.expectedEnvKeys])),
|
|
69
|
+
keychain_reads_only: runs.every(r => r.keychainOperations.every(op => CLAUDE_REVIEW_KEYCHAIN_READS.includes(op))) }, { runs_checked: runs.length, environment_keys: [...input.expectedEnvKeys] }),
|
|
70
|
+
scenario('communication_boundary', { sandboxed: every(runs, r => r.sandboxed) && input.version.sandboxed, no_unlisted_destination: runs.every(r => r.decoyConnections === 0) }, { sandboxed_runs: runs.filter(r => r.sandboxed).length + (input.version.sandboxed ? 1 : 0) }),
|
|
71
|
+
scenario('cleanup', { runs_terminal: every(runs, r => r.terminal), owned_exit_confirmed: every(runs, r => r.exitConfirmed) && input.version.exitConfirmed,
|
|
72
|
+
temp_removed: input.tempRemoved }, { owned_exits: runs.length + 1 }),
|
|
73
|
+
];
|
|
74
|
+
}
|
|
75
|
+
/** The offline campaign (design "Release review"): stages 1, 2, 4 and 5 against one exact binary, fake data, loopback only. */
|
|
76
|
+
export async function runClaudeReviewCampaign(input) {
|
|
77
|
+
if (process.platform !== 'darwin' || !existsSync('/usr/bin/sandbox-exec'))
|
|
78
|
+
throw Error('CLAUDE_REVIEW_UNSUPPORTED_HOST');
|
|
79
|
+
const models = [...input.models];
|
|
80
|
+
if (!parseClaudeSyncRelease({ ...input.release, model_ids: models }))
|
|
81
|
+
throw Error('CLAUDE_REVIEW_INPUT_REJECTED');
|
|
82
|
+
const now = input.now ?? (() => new Date()), startedAt = now().toISOString(), sandbox = input.sandbox ?? true, timeoutMs = input.timeoutMs ?? CLAUDE_REVIEW_RUN_TIMEOUT_MS;
|
|
83
|
+
const processHost = input.processHost ?? new NodeProcessGroupHost(), deniedHome = os.userInfo().homedir;
|
|
84
|
+
const layout = await createClaudeReviewLayout(), runs = [];
|
|
85
|
+
let version = { line: '', sandboxed: sandbox, exitConfirmed: false }, sha256 = '', size = 0, copyUnchanged = false, tempRemoved = false;
|
|
86
|
+
try {
|
|
87
|
+
// Run only a private copy of the named bytes, re-hashed and re-verified here (RW-03).
|
|
88
|
+
const copy = path.join(layout.cli.bin, 'claude');
|
|
89
|
+
await copyFile(input.binary, copy, constants.COPYFILE_EXCL | constants.COPYFILE_FICLONE);
|
|
90
|
+
await chmod(copy, 0o500);
|
|
91
|
+
sha256 = await measureCommandFile(copy);
|
|
92
|
+
size = (await stat(copy)).size;
|
|
93
|
+
if (sha256 !== input.release.binary_sha256)
|
|
94
|
+
throw Error('CLAUDE_REVIEW_BINARY_REJECTED');
|
|
95
|
+
await verifyClaudeCodeSignature(copy, input.codesign);
|
|
96
|
+
const common = { binary: copy, layout, hook: input.hook, readRoots: input.readRoots, deniedHome, processHost, sandbox, timeoutMs };
|
|
97
|
+
version = await runClaudeVersion(common);
|
|
98
|
+
for (const model of models)
|
|
99
|
+
runs.push(await runClaudeReview({ ...common, kind: 'baseline', model }));
|
|
100
|
+
runs.push(await runClaudeReview({ ...common, kind: 'planted', model: models[0] }));
|
|
101
|
+
runs.push(await runClaudeReview({ ...common, kind: 'slash', model: models[0] }));
|
|
102
|
+
copyUnchanged = await measureCommandFile(copy).catch(() => '') === sha256;
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
await rm(layout.root, { recursive: true, force: true }).catch(() => { });
|
|
106
|
+
tempRemoved = await lstat(layout.root).then(() => false, () => true);
|
|
107
|
+
}
|
|
108
|
+
const launchEnvKeys = [...Object.keys(layout.env), ...Object.keys(CLAUDE_LAUNCH_OPT_OUTS), HOOK_POLICY_ENV_VAR].sort(), reviewEnvKeys = Object.keys(claudeReviewEnv('http://127.0.0.1:1')).sort();
|
|
109
|
+
const scenarios = evaluateClaudeReview({ release: input.release, copySha256: sha256, copyUnchanged, version, runs, canaries: layout.canaries,
|
|
110
|
+
expectedEnvKeys: [...launchEnvKeys, ...reviewEnvKeys].sort(), tempRemoved });
|
|
111
|
+
return buildClaudeReviewEvidence({
|
|
112
|
+
release: { ...input.release, model_ids: models },
|
|
113
|
+
protocol: { launch_config_sha256: claudeCanonicalSha256(CLAUDE_LAUNCH_CONFIG), tools_sha256: claudeCanonicalSha256({ tools: localToolInventory() }),
|
|
114
|
+
sandbox_profile: CLAUDE_REVIEW_PROFILE_VERSION, sandbox_profile_template_sha256: CLAUDE_REVIEW_PROFILE_TEMPLATE_SHA256, launch_env_keys: launchEnvKeys,
|
|
115
|
+
review_env_keys: reviewEnvKeys, prompt_sha256: sha(CLAUDE_REVIEW_PROMPT), slash_prompt_sha256: sha(CLAUDE_REVIEW_SLASH_PROMPT), scripted_tool_ids: Object.values(CLAUDE_REVIEW_TOOL_IDS) },
|
|
116
|
+
inventory: { binary_sha256: sha256, size_bytes: size, team_id: CLAUDE_TEAM_ID, signing_identifier: CLAUDE_SIGNING_IDENTIFIER,
|
|
117
|
+
code_requirement_sha256: sha(CLAUDE_CODE_REQUIREMENT), version_output: version.line },
|
|
118
|
+
scenarios,
|
|
119
|
+
...(input.authObservation ? { authObservation: { sha256: input.authObservation.sha256, decision_id: input.authObservation.observation.decision_id } } : {}),
|
|
120
|
+
observations: { started_at: startedAt, finished_at: now().toISOString(), root: layout.root, runner_package_version: input.runnerVersion ?? packageVersion(),
|
|
121
|
+
runs: runs.map(r => ({ kind: r.kind, model: r.model, duration_ms: r.durationMs, auxiliary_requests: r.requests.filter(q => q.kind === 'auxiliary').length,
|
|
122
|
+
unknown_requests: r.requests.filter(q => q.kind === 'unknown' || q.kind === 'invalid').length, proxy_attempts: [...r.proxyAttempts], keychain_lookups: r.keychainOperations.length,
|
|
123
|
+
requests: r.requests.filter(q => q.kind === 'primary' || q.kind === 'auxiliary').map(q => ({ kind: q.kind, step: q.step, model_matches: q.model === r.model, prompt_matched: q.promptMatched, reminders: q.reminders, tools: q.toolNames.length })) })) },
|
|
124
|
+
});
|
|
125
|
+
}
|