mtech-ai-local-runner 0.1.63 → 0.1.64

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.
@@ -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 settings = {
145
- hooks: {
146
- PreToolUse: [{ matcher: "*", hooks: [{ type: "command", command: hookCommand }] }],
147
- },
148
- };
149
- const args = [
150
- "--print",
151
- "--input-format", "stream-json",
152
- "--output-format", "stream-json",
153
- "--verbose",
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(`${JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text: prompt }] } })}\n`);
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({ mcpServers: { ai_local: { type: 'http', url: endpoint.url, headers: { Authorization: `Bearer ${endpoint.token}` } } } }), { mode: 0o600, flag: 'wx' });
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
- async function privateDirectory(dir) {
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 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
- }
217
+ const { sha256 } = await readClaudeArtifact(response, file, maxBytes);
182
218
  await file.sync();
183
219
  await file.close();
184
- if (hash.digest('hex') !== release.binary_sha256 || await measureCommandFile(temporary).catch(() => undefined) !== release.binary_sha256)
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 `${CLAUDE_DOWNLOAD_BASE}/${r.version}/${r.platform}/claude`;
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,123 @@
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
+ const delivery = !slash ? 'missing' : slashFirst?.firstUserText === CLAUDE_REVIEW_SLASH_PROMPT ? 'literal' : slash.terminal && !slashFirst ? 'refused_locally' : 'missing';
48
+ const versionLine = new RegExp(`^${input.release.version.replace(/\./g, '\\.')}(?:\\s|$)`);
49
+ return [
50
+ scenario('inventory', { copy_sha256_matches: input.copySha256 === input.release.binary_sha256, copy_unchanged: input.copyUnchanged,
51
+ version_output_matches: versionLine.test(input.version.line), version_run_sandboxed: input.version.sandboxed, version_run_exit_confirmed: input.version.exitConfirmed }, { version_runs: 1 }),
52
+ scenario('tools_offered', { primary_requests_seen: every(scripted, r => !!first(r)), exact_ai_local_set: every(scripted, r => isDeepStrictEqual(offered(r), [...AI_LOCAL])),
53
+ no_other_tool_names: names.every(name => AI_LOCAL.includes(name)) }, { runs_checked: scripted.length, offered_names: names }),
54
+ scenario('mcp_startup', { initialized: every(runs, initialized) }, { runs_initialized: runs.filter(initialized).length }),
55
+ 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 }),
56
+ scenario('disallowed_tool', { native_denied: every(scripted, r => denied(r, IDS.native)), unregistered_mcp_denied: every(scripted, r => denied(r, IDS.unregistered)),
57
+ native_not_executed: !sentinel(['bash']) }, { runs_checked: scripted.length }),
58
+ scenario('settings_ignored', { baseline_compared: !!planted && !!baseline, same_tools: !!planted && !!baseline && isDeepStrictEqual(offered(planted), offered(baseline)),
59
+ same_denials: !!planted && !!baseline && Object.values(IDS).every(id => tool(planted, id) !== undefined && tool(planted, id)?.isError === tool(baseline, id)?.isError),
60
+ 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),
61
+ no_planted_model: runs.every(r => r.requests.every(q => q.model !== 'planted-model')) }, { planted_sources: CLAUDE_REVIEW_PLANTED_SOURCES.length }),
62
+ scenario('slash_refused', { not_expanded: !!slash && hits('planted', [slash]) === 0, no_command_side_effect: !sentinel(['slash']), delivered_or_refused: delivery !== 'missing' }, { delivery }),
63
+ scenario('source_exclusion', { outside_denied: every(scripted, r => denied(r, IDS.outside)), secret_denied: every(scripted, r => denied(r, IDS.secret)),
64
+ no_outside_content: hits('outside') === 0, no_secret_content: hits('secret') === 0, no_implicit_source: implicit === 0 }, { runs_checked: scripted.length }),
65
+ scenario('fake_credentials', { fake_key_only: scripted.length > 0 && runs.every(r => messages(r).every(q => q.apiKey === CLAUDE_REVIEW_FAKE_KEY)),
66
+ no_authorization_header: runs.every(r => messages(r).every(q => !q.authorization)), no_credential_content: hits('credential') === 0,
67
+ fake_key_not_in_body: runs.every(r => r.fakeKeyInBody === 0), environment_exact: every(runs, r => isDeepStrictEqual(r.envKeys, [...input.expectedEnvKeys])),
68
+ keychain_reads_only: runs.every(r => r.keychainOperations.every(op => CLAUDE_REVIEW_KEYCHAIN_READS.includes(op))) }, { runs_checked: runs.length, environment_keys: [...input.expectedEnvKeys] }),
69
+ 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) }),
70
+ scenario('cleanup', { runs_terminal: every(runs, r => r.terminal), owned_exit_confirmed: every(runs, r => r.exitConfirmed) && input.version.exitConfirmed,
71
+ temp_removed: input.tempRemoved }, { owned_exits: runs.length + 1 }),
72
+ ];
73
+ }
74
+ /** The offline campaign (design "Release review"): stages 1, 2, 4 and 5 against one exact binary, fake data, loopback only. */
75
+ export async function runClaudeReviewCampaign(input) {
76
+ if (process.platform !== 'darwin' || !existsSync('/usr/bin/sandbox-exec'))
77
+ throw Error('CLAUDE_REVIEW_UNSUPPORTED_HOST');
78
+ const models = [...input.models];
79
+ if (!parseClaudeSyncRelease({ ...input.release, model_ids: models }))
80
+ throw Error('CLAUDE_REVIEW_INPUT_REJECTED');
81
+ const now = input.now ?? (() => new Date()), startedAt = now().toISOString(), sandbox = input.sandbox ?? true, timeoutMs = input.timeoutMs ?? CLAUDE_REVIEW_RUN_TIMEOUT_MS;
82
+ const processHost = input.processHost ?? new NodeProcessGroupHost(), deniedHome = os.userInfo().homedir;
83
+ const layout = await createClaudeReviewLayout(), runs = [];
84
+ let version = { line: '', sandboxed: sandbox, exitConfirmed: false }, sha256 = '', size = 0, copyUnchanged = false, tempRemoved = false;
85
+ try {
86
+ // Run only a private copy of the named bytes, re-hashed and re-verified here (RW-03).
87
+ const copy = path.join(layout.cli.bin, 'claude');
88
+ await copyFile(input.binary, copy, constants.COPYFILE_EXCL | constants.COPYFILE_FICLONE);
89
+ await chmod(copy, 0o500);
90
+ sha256 = await measureCommandFile(copy);
91
+ size = (await stat(copy)).size;
92
+ if (sha256 !== input.release.binary_sha256)
93
+ throw Error('CLAUDE_REVIEW_BINARY_REJECTED');
94
+ await verifyClaudeCodeSignature(copy, input.codesign);
95
+ const common = { binary: copy, layout, hook: input.hook, readRoots: input.readRoots, deniedHome, processHost, sandbox, timeoutMs };
96
+ version = await runClaudeVersion(common);
97
+ for (const model of models)
98
+ runs.push(await runClaudeReview({ ...common, kind: 'baseline', model }));
99
+ runs.push(await runClaudeReview({ ...common, kind: 'planted', model: models[0] }));
100
+ runs.push(await runClaudeReview({ ...common, kind: 'slash', model: models[0] }));
101
+ copyUnchanged = await measureCommandFile(copy).catch(() => '') === sha256;
102
+ }
103
+ finally {
104
+ await rm(layout.root, { recursive: true, force: true }).catch(() => { });
105
+ tempRemoved = await lstat(layout.root).then(() => false, () => true);
106
+ }
107
+ 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();
108
+ const scenarios = evaluateClaudeReview({ release: input.release, copySha256: sha256, copyUnchanged, version, runs, canaries: layout.canaries,
109
+ expectedEnvKeys: [...launchEnvKeys, ...reviewEnvKeys].sort(), tempRemoved });
110
+ return buildClaudeReviewEvidence({
111
+ release: { ...input.release, model_ids: models },
112
+ protocol: { launch_config_sha256: claudeCanonicalSha256(CLAUDE_LAUNCH_CONFIG), tools_sha256: claudeCanonicalSha256({ tools: localToolInventory() }),
113
+ sandbox_profile: CLAUDE_REVIEW_PROFILE_VERSION, sandbox_profile_template_sha256: CLAUDE_REVIEW_PROFILE_TEMPLATE_SHA256, launch_env_keys: launchEnvKeys,
114
+ 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) },
115
+ inventory: { binary_sha256: sha256, size_bytes: size, team_id: CLAUDE_TEAM_ID, signing_identifier: CLAUDE_SIGNING_IDENTIFIER,
116
+ code_requirement_sha256: sha(CLAUDE_CODE_REQUIREMENT), version_output: version.line },
117
+ scenarios,
118
+ ...(input.authObservation ? { authObservation: { sha256: input.authObservation.sha256, decision_id: input.authObservation.observation.decision_id } } : {}),
119
+ observations: { started_at: startedAt, finished_at: now().toISOString(), root: layout.root, runner_package_version: input.runnerVersion ?? packageVersion(),
120
+ runs: runs.map(r => ({ kind: r.kind, model: r.model, duration_ms: r.durationMs, auxiliary_requests: r.requests.filter(q => q.kind === 'auxiliary').length,
121
+ unknown_requests: r.requests.filter(q => q.kind === 'unknown' || q.kind === 'invalid').length, proxy_attempts: [...r.proxyAttempts], keychain_lookups: r.keychainOperations.length })) },
122
+ });
123
+ }
@@ -0,0 +1,96 @@
1
+ import { isDeepStrictEqual } from 'node:util';
2
+ import { CLAUDE_RELEASE_GATES, claudeCanonicalSha256 } from './claude-release.js';
3
+ /*
4
+ * Offline Claude release review evidence (design "Release review", decision 9095c7a8). A fake-data campaign result, never
5
+ * a release certificate: the owner reviews it and lists the release in claude_release.php through a Gateway PR that cites
6
+ * a Work Decision. The campaign, the gate map and this format are Runner source constants.
7
+ */
8
+ export const CLAUDE_REVIEW_EVIDENCE_VERSION = 'claude-release-review-evidence-v1';
9
+ export const CLAUDE_REVIEW_CAMPAIGN = 'claude-release-campaign-v1';
10
+ export const CLAUDE_REVIEW_EVIDENCE_FILE = 'claude-release-evidence.json';
11
+ /** The only top-level key evidence_sha256 leaves out. Timestamps, temp paths, durations, the runner package version, proxy
12
+ * attempts and auxiliary or Keychain lookup counts live under it and nowhere else. */
13
+ export const CLAUDE_REVIEW_VOLATILE_KEY = 'observations';
14
+ export const CLAUDE_REVIEW_SCENARIOS = Object.freeze(['inventory', 'tools_offered', 'mcp_startup', 'allowed_read', 'disallowed_tool', 'settings_ignored',
15
+ 'slash_refused', 'source_exclusion', 'fake_credentials', 'communication_boundary', 'cleanup']);
16
+ /** Stages of docs/ai-ops-gateway/claude-native-validation-scope-0147.md. Stage 3 (managed sources) is the per-job runtime check. */
17
+ export const CLAUDE_REVIEW_STAGES = Object.freeze({ inventory: 1, tools_offered: 2, mcp_startup: 2,
18
+ allowed_read: 2, disallowed_tool: 2, settings_ignored: 2, slash_refused: 2, source_exclusion: 2, fake_credentials: 4, communication_boundary: 5, cleanup: 2 });
19
+ /** The four gates a fake-data campaign can evidence. allowed_read is the positive control of both tool-plane gates:
20
+ * a denial without a working allowed read proves nothing. */
21
+ export const CLAUDE_REVIEW_GATE_SCENARIOS = Object.freeze({
22
+ binary_release: Object.freeze(['inventory']),
23
+ official_permissions: Object.freeze(['tools_offered', 'disallowed_tool', 'settings_ignored', 'slash_refused']),
24
+ required_mcp_startup: Object.freeze(['mcp_startup', 'tools_offered', 'allowed_read']),
25
+ source_exclusion: Object.freeze(['allowed_read', 'source_exclusion', 'fake_credentials', 'communication_boundary']),
26
+ });
27
+ /** managed_scope_exclusion is checked per job by the installed observer and the managed-path check (claude-managed-scope.ts). */
28
+ export const CLAUDE_MANAGED_SCOPE_BASIS = 'observer_inventory_and_managed_paths_per_job';
29
+ const gated = (gate) => Object.hasOwn(CLAUDE_REVIEW_GATE_SCENARIOS, gate);
30
+ export function buildClaudeReviewEvidence(input) {
31
+ if (!isDeepStrictEqual(input.scenarios.map(s => s.id), [...CLAUDE_REVIEW_SCENARIOS])
32
+ || input.scenarios.some(s => s.stage !== CLAUDE_REVIEW_STAGES[s.id] || s.result !== (s.failures.length ? 'failed' : 'passed')))
33
+ throw Error('CLAUDE_REVIEW_EVIDENCE_REJECTED');
34
+ const passed = new Set(input.scenarios.filter(s => s.result === 'passed').map(s => s.id)), gates = {}, failedGates = [];
35
+ for (const gate of CLAUDE_RELEASE_GATES) {
36
+ if (gated(gate)) {
37
+ const scenarios = [...CLAUDE_REVIEW_GATE_SCENARIOS[gate]], ok = scenarios.every(id => passed.has(id));
38
+ gates[gate] = { status: ok ? 'passed' : 'failed', scenarios };
39
+ if (!ok)
40
+ failedGates.push(gate);
41
+ }
42
+ else if (gate === 'managed_scope_exclusion')
43
+ gates[gate] = { status: 'runtime_check', basis: CLAUDE_MANAGED_SCOPE_BASIS };
44
+ else
45
+ gates[gate] = input.authObservation ? { status: 'observed', observation_sha256: input.authObservation.sha256 } : { status: 'requires_owner_login_observation' };
46
+ }
47
+ const clean = passed.has('cleanup'), evidenced = failedGates.length === 0, auth = input.authObservation;
48
+ return { version: CLAUDE_REVIEW_EVIDENCE_VERSION, campaign: CLAUDE_REVIEW_CAMPAIGN,
49
+ release: { version: input.release.version, platform: input.release.platform, binary_sha256: input.release.binary_sha256, model_ids: [...input.release.model_ids] },
50
+ protocol: structuredClone(input.protocol), inventory: structuredClone(input.inventory), scenarios: structuredClone([...input.scenarios]), gates,
51
+ auth_observation: auth ? { sha256: auth.sha256, decision_id: auth.decision_id } : null,
52
+ verdict: { evidenced_gates_passed: evidenced, campaign_clean: clean, ready_for_listing: evidenced && clean && !!auth,
53
+ blocked_by: [...failedGates, ...(clean ? [] : ['cleanup']), ...(auth ? [] : ['auth_backend_compatibility'])] },
54
+ observations: structuredClone(input.observations) };
55
+ }
56
+ /** claudeCanonicalSha256 of the evidence without CLAUDE_REVIEW_VOLATILE_KEY: equal for the same binary and inputs. */
57
+ export function claudeReviewEvidenceSha256(evidence) {
58
+ const { observations: _volatile, ...stable } = evidence;
59
+ return claudeCanonicalSha256(stable);
60
+ }
61
+ /** Null unless every evidenced gate and the campaign cleanup passed. */
62
+ export function claudeReleaseCandidate(evidence) {
63
+ if (!evidence.verdict.evidenced_gates_passed || !evidence.verdict.campaign_clean)
64
+ return null;
65
+ return { version: evidence.release.version, platform: evidence.release.platform, binary_sha256: evidence.release.binary_sha256,
66
+ evidence_sha256: claudeReviewEvidenceSha256(evidence), model_ids: [...evidence.release.model_ids] };
67
+ }
68
+ export const CLAUDE_AUTH_OBSERVATION_VERSION = 'claude-auth-observation-v1';
69
+ export const CLAUDE_AUTH_OBSERVATION_MAX_BYTES = 4096;
70
+ const OBSERVATION_KEYS = ['auth_status', 'command', 'decision_id', 'exit_code', 'keychain_prompt_shown', 'launch', 'release', 'version'];
71
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, TOKEN = /^[A-Za-z0-9._-]{1,32}$/;
72
+ const plain = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
73
+ export function parseClaudeAuthObservation(bytes, release) {
74
+ const rejected = () => Error('CLAUDE_AUTH_OBSERVATION_REJECTED');
75
+ if (bytes.byteLength > CLAUDE_AUTH_OBSERVATION_MAX_BYTES)
76
+ throw rejected();
77
+ let value;
78
+ try {
79
+ value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
80
+ }
81
+ catch {
82
+ throw rejected();
83
+ }
84
+ if (!plain(value) || !isDeepStrictEqual(Object.keys(value).sort(), OBSERVATION_KEYS))
85
+ throw rejected();
86
+ const r = value.release, s = value.auth_status;
87
+ if (value.version !== CLAUDE_AUTH_OBSERVATION_VERSION || value.launch !== 'runner_pinned_copy' || value.command !== 'auth status --json' || value.exit_code !== 0
88
+ || value.keychain_prompt_shown !== false || typeof value.decision_id !== 'string' || !UUID.test(value.decision_id)
89
+ || !plain(r) || !isDeepStrictEqual(Object.keys(r).sort(), ['binary_sha256', 'platform', 'version'])
90
+ || r.version !== release.version || r.platform !== release.platform || r.binary_sha256 !== release.binary_sha256
91
+ || !plain(s) || s.loggedIn !== true || Object.keys(s).some(k => !['loggedIn', 'authMethod', 'apiProvider'].includes(k))
92
+ || ['authMethod', 'apiProvider'].some(k => s[k] !== undefined && (typeof s[k] !== 'string' || !TOKEN.test(s[k]))))
93
+ throw rejected();
94
+ const observation = structuredClone(value);
95
+ return { observation, sha256: claudeCanonicalSha256(observation) };
96
+ }