mtech-ai-local-runner 0.1.65 → 0.1.66
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 +3 -2
- package/dist/src/adapters/claude-code/runtime-options.js +8 -8
- package/dist/src/asks/hook-client.js +16 -13
- package/dist/src/guide/hook-client.js +8 -7
- package/dist/src/safety/claude-production-host.js +6 -5
- package/dist/src/safety/claude-review-campaign.js +5 -3
- package/dist/src/safety/claude-review-layout.js +37 -12
- package/dist/src/safety/claude-review-run.js +5 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -175,8 +175,9 @@ Only a session that a person is attending is served. A Claude Code session count
|
|
|
175
175
|
is `1` or, where Claude Code does not export that variable, when `CLAUDE_CODE_ENTRYPOINT` names a terminal, the Claude
|
|
176
176
|
desktop app or the VS Code extension. A headless `claude -p` or Agent SDK run gets nothing and is never counted as
|
|
177
177
|
having seen an ask. Codex exports no such marker, so every Codex hook session counts. The runner's own jobs never
|
|
178
|
-
count: a hook
|
|
179
|
-
|
|
178
|
+
count: a hook that sees the runner's hook policy variable, or a `TMPDIR` under `<stateRoot>/claude-jobs/` (where every
|
|
179
|
+
Claude job keeps its controls), is refused. A Claude job keeps the person's `HOME`, so `HOME` alone does not tell it
|
|
180
|
+
apart. Codex jobs run with `features.hooks=false`, so they run no hook at all.
|
|
180
181
|
|
|
181
182
|
An ask is shown at a session start, at the next prompt (where it also counts as seen), at a turn end, or by waking an
|
|
182
183
|
idle Claude session through an `asyncRewake` Stop watcher. An ask shown at a session start, at a turn end or by a wake
|
|
@@ -54,16 +54,16 @@ export function createClaudeCodeAdapterOptions(input) {
|
|
|
54
54
|
if (inside(checkout, state))
|
|
55
55
|
throw new Error('Claude job controls must be outside the checkout');
|
|
56
56
|
const control = path.join(state, 'claude-jobs', randomUUID());
|
|
57
|
-
const
|
|
57
|
+
const cwd = path.join(control, 'cwd'), config = path.join(control, 'mcp.json');
|
|
58
58
|
const env = {};
|
|
59
|
-
|
|
59
|
+
// Claude owns credential access, so it runs in the employee's own login context: their HOME, USER and LOGNAME, and
|
|
60
|
+
// CLAUDE_CONFIG_DIR only when they set it. macOS finds the login keychain through HOME, and a set CLAUDE_CONFIG_DIR
|
|
61
|
+
// moves Claude's Keychain item, so either change would hide the terminal's login (Work Decision b501d68d, design
|
|
62
|
+
// 2026-10-28-claude-job-login-context). Never read or copy its files or send this environment to scoped workers.
|
|
63
|
+
for (const key of ['PATH', 'LANG', 'LC_ALL', 'USER', 'LOGNAME', 'HOME', 'CLAUDE_CONFIG_DIR'])
|
|
60
64
|
if (input.env[key] !== undefined)
|
|
61
65
|
env[key] = input.env[key];
|
|
62
|
-
env.
|
|
63
|
-
env.TMPDIR = path.join(control, 'tmp'); // Claude owns credential access. Preserve the account location by path only;
|
|
64
|
-
// never read/copy its files or send this environment to scoped workers.
|
|
65
|
-
// https://code.claude.com/docs/en/authentication#log-in-with-multiple-accounts
|
|
66
|
-
env.CLAUDE_CONFIG_DIR = account;
|
|
66
|
+
env.TMPDIR = path.join(control, 'tmp');
|
|
67
67
|
if (env.CLAUDE_CONFIG_DIR && !path.isAbsolute(env.CLAUDE_CONFIG_DIR))
|
|
68
68
|
throw new Error('Claude account configuration path must be absolute');
|
|
69
69
|
return {
|
|
@@ -114,7 +114,7 @@ export function createClaudeCodeAdapterOptions(input) {
|
|
|
114
114
|
throw Error('CLAUDE_CONTROL_ALIAS_REJECTED');
|
|
115
115
|
await mkdir(control, { mode: 0o700 });
|
|
116
116
|
owned = true;
|
|
117
|
-
for (const directory of [
|
|
117
|
+
for (const directory of [cwd, env.TMPDIR])
|
|
118
118
|
await mkdir(directory, { mode: 0o700 });
|
|
119
119
|
let active = true;
|
|
120
120
|
const tools = new JobToolSession({
|
|
@@ -13,29 +13,32 @@ const STATE_ROOT_FLAG = "--state-root=";
|
|
|
13
13
|
* Agent SDK, mcp, local-agent, remote and the GitHub action for the rest), so any other value is not a person.
|
|
14
14
|
*/
|
|
15
15
|
const INTERACTIVE_ENTRYPOINTS = new Set(["cli", "claude-desktop", "claude-vscode"]);
|
|
16
|
-
/** Where the Claude adapter puts each job's
|
|
16
|
+
/** Where the Claude adapter puts each job's controls, its TMPDIR among them: <stateRoot>/claude-jobs/<id>/
|
|
17
|
+
* (src/adapters/claude-code/runtime-options.ts). Runners before 0.1.66 also put the job's HOME there. */
|
|
17
18
|
const CLAUDE_JOBS_DIR = "claude-jobs";
|
|
18
|
-
/**
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
/** HOOK_POLICY_ENV_VAR of src/safety/hook-policy-builder.ts, named here so this client loads no safety module; a test keeps the two in step. */
|
|
20
|
+
const RUNNER_HOOK_POLICY_ENV = "AI_GATEWAY_RUNNER_HOOK_POLICY_B64";
|
|
21
|
+
/** True when the hook runs in a Claude job this runner started, never a person's session: the environment carries the
|
|
22
|
+
* runner's hook policy, which every job launch sets and the runner's own hook needs, or its TMPDIR or HOME resolves
|
|
23
|
+
* inside <stateRoot>/claude-jobs/. A job keeps the person's HOME, so HOME alone no longer tells it apart. */
|
|
24
|
+
function isRunnerJob(env, stateRoot) {
|
|
25
|
+
if (env[RUNNER_HOOK_POLICY_ENV] !== undefined)
|
|
26
|
+
return true;
|
|
23
27
|
const roots = new Set([resolve(stateRoot)]);
|
|
24
|
-
// The adapter builds job
|
|
28
|
+
// The adapter builds job controls from the real state root; the shim may name it through a symlink.
|
|
25
29
|
try {
|
|
26
30
|
roots.add(realpathSync(stateRoot));
|
|
27
31
|
}
|
|
28
32
|
catch { /* A state root that does not exist holds no jobs. */ }
|
|
29
|
-
|
|
30
|
-
return [...roots].some(root => resolved.startsWith(join(root, CLAUDE_JOBS_DIR) + sep));
|
|
33
|
+
return [env.TMPDIR, env.HOME].some(dir => !!dir && isAbsolute(dir) && [...roots].some(root => resolve(dir).startsWith(join(root, CLAUDE_JOBS_DIR) + sep)));
|
|
31
34
|
}
|
|
32
35
|
/**
|
|
33
36
|
* True when a person is attending the session that runs this hook (owner decision 2026-10-23). The one place that
|
|
34
37
|
* decides it, so it can be adjusted after the pilot.
|
|
35
38
|
*
|
|
36
|
-
* The runner's own jobs never count. A Claude job
|
|
37
|
-
*
|
|
38
|
-
* adapter's isolation overrides), so they never run a hook at all.
|
|
39
|
+
* The runner's own jobs never count. A Claude job carries the runner's hook policy and a TMPDIR under
|
|
40
|
+
* <stateRoot>/claude-jobs/, so a hook that sees either is refused whatever else the environment says. Codex jobs run
|
|
41
|
+
* with features.hooks=false (the Codex adapter's isolation overrides), so they never run a hook at all.
|
|
39
42
|
*
|
|
40
43
|
* Claude Code: CLAUDE_CODE_SESSION_ATTENDED, where the Claude Code version exports it, is the answer: "1" is a
|
|
41
44
|
* person, any other value is not, whatever the entrypoint says (a scheduled desktop task has the desktop entrypoint
|
|
@@ -47,7 +50,7 @@ function isRunnerJobHome(env, stateRoot) {
|
|
|
47
50
|
* `input` is the parsed hook input, passed for the day Codex exposes such a marker.
|
|
48
51
|
*/
|
|
49
52
|
export function isHumanSession(app, env, _input, stateRoot) {
|
|
50
|
-
if (
|
|
53
|
+
if (isRunnerJob(env, stateRoot))
|
|
51
54
|
return false;
|
|
52
55
|
if (app !== "claude")
|
|
53
56
|
return true;
|
|
@@ -16,22 +16,23 @@ const START_SOURCES = new Set(["startup", "clear", "compact"]);
|
|
|
16
16
|
/** The same values as the asks hook client (src/asks/hook-client.ts): a test keeps the two in step. */
|
|
17
17
|
const INTERACTIVE_ENTRYPOINTS = new Set(["cli", "claude-desktop", "claude-vscode"]);
|
|
18
18
|
const CLAUDE_JOBS_DIR = "claude-jobs";
|
|
19
|
+
/** HOOK_POLICY_ENV_VAR of src/safety/hook-policy-builder.ts, as in the asks hook client; a test keeps them in step. */
|
|
20
|
+
const RUNNER_HOOK_POLICY_ENV = "AI_GATEWAY_RUNNER_HOOK_POLICY_B64";
|
|
19
21
|
const TRUNCATED_SESSION_ID = /"session_id"\s*:\s*"([^"\\]{1,128})"/;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if (
|
|
23
|
-
return
|
|
22
|
+
/** The same rule as the asks hook client: the runner's hook policy, or a TMPDIR or HOME inside <stateRoot>/claude-jobs/. */
|
|
23
|
+
function isRunnerJob(env, stateRoot) {
|
|
24
|
+
if (env[RUNNER_HOOK_POLICY_ENV] !== undefined)
|
|
25
|
+
return true;
|
|
24
26
|
const roots = new Set([resolve(stateRoot)]);
|
|
25
27
|
try {
|
|
26
28
|
roots.add(realpathSync(stateRoot));
|
|
27
29
|
}
|
|
28
30
|
catch { /* A state root that does not exist holds no jobs. */ }
|
|
29
|
-
|
|
30
|
-
return [...roots].some(root => resolved.startsWith(join(root, CLAUDE_JOBS_DIR) + sep));
|
|
31
|
+
return [env.TMPDIR, env.HOME].some(dir => !!dir && isAbsolute(dir) && [...roots].some(root => resolve(dir).startsWith(join(root, CLAUDE_JOBS_DIR) + sep)));
|
|
31
32
|
}
|
|
32
33
|
/** True when a person attends the session: the asks hook client's rule, repeated so this client loads nothing of asks. */
|
|
33
34
|
export function isHumanSession(app, env, stateRoot) {
|
|
34
|
-
if (
|
|
35
|
+
if (isRunnerJob(env, stateRoot))
|
|
35
36
|
return false;
|
|
36
37
|
if (app !== "claude")
|
|
37
38
|
return true;
|
|
@@ -47,15 +47,16 @@ const CLAUDE_LOGIN_PROBE_TIMEOUT_MS = 10_000;
|
|
|
47
47
|
/** Readiness only: after a signature failure, the same release and copy identity is not re-hashed or run through
|
|
48
48
|
* codesign again before this delay; a new identity, a new listing or the delay re-checks at once. */
|
|
49
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
|
|
51
|
-
*
|
|
50
|
+
/** `auth status --json` of the pinned copy, never the employee's own install, with a fixed environment in the job's login
|
|
51
|
+
* context: the employee's HOME, USER and LOGNAME, and CLAUDE_CONFIG_DIR only when they set it (runtime-options.ts).
|
|
52
|
+
* The documented Claude Code opt-outs keep the probe from updating itself or sending other traffic.
|
|
52
53
|
* `timeoutMs` is a trusted in-process test seam only. */
|
|
53
54
|
export function pinnedClaudeLogin(home, env, timeoutMs = CLAUDE_LOGIN_PROBE_TIMEOUT_MS) {
|
|
54
55
|
return binary => new Promise(resolve => {
|
|
55
|
-
const account = env.CLAUDE_CONFIG_DIR
|
|
56
|
-
if (!path.isAbsolute(account) || !path.isAbsolute(binary) || !path.isAbsolute(home))
|
|
56
|
+
const account = env.CLAUDE_CONFIG_DIR;
|
|
57
|
+
if (account !== undefined && !path.isAbsolute(account) || !path.isAbsolute(binary) || !path.isAbsolute(home))
|
|
57
58
|
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
|
+
const childEnv = { PATH: '/usr/bin:/bin', LANG: 'C', LC_ALL: 'C', HOME: home, ...(account === undefined ? {} : { CLAUDE_CONFIG_DIR: account }),
|
|
59
60
|
DISABLE_AUTOUPDATER: '1', CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' };
|
|
60
61
|
for (const key of ['USER', 'LOGNAME']) {
|
|
61
62
|
const value = env[key];
|
|
@@ -14,7 +14,7 @@ import { CLAUDE_LAUNCH_OPT_OUTS, CLAUDE_SIGNING_IDENTIFIER, CLAUDE_TEAM_ID, clau
|
|
|
14
14
|
import { CLAUDE_CODE_REQUIREMENT, verifyClaudeCodeSignature } from './claude-release-copy.js';
|
|
15
15
|
import { CLAUDE_LAUNCH_CONFIG } from './claude-production-host.js';
|
|
16
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';
|
|
17
|
+
import { CLAUDE_REVIEW_KEYCHAIN_READS, CLAUDE_REVIEW_KEYCHAIN_SERVICES, CLAUDE_REVIEW_PLANTED_SOURCES, createClaudeReviewLayout } from './claude-review-layout.js';
|
|
18
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
19
|
import { CLAUDE_REVIEW_STAGES, buildClaudeReviewEvidence } from './claude-review-evidence.js';
|
|
20
20
|
const AI_LOCAL = Object.freeze([...CLAUDE_LOCAL_MCP_TOOLS].sort());
|
|
@@ -66,7 +66,9 @@ export function evaluateClaudeReview(input) {
|
|
|
66
66
|
scenario('fake_credentials', { fake_key_only: scripted.length > 0 && runs.every(r => messages(r).every(q => q.apiKey === CLAUDE_REVIEW_FAKE_KEY)),
|
|
67
67
|
no_authorization_header: runs.every(r => messages(r).every(q => !q.authorization)), no_credential_content: hits('credential') === 0,
|
|
68
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.
|
|
69
|
+
keychain_reads_only: runs.every(r => r.keychainLookups.every(k => CLAUDE_REVIEW_KEYCHAIN_READS.includes(k.operation))),
|
|
70
|
+
// Each run looks up the Keychain, and only under the terminal login's services: no config-dir hash suffix.
|
|
71
|
+
keychain_service_default: runs.every(r => r.keychainLookups.length > 0 && r.keychainLookups.every(k => CLAUDE_REVIEW_KEYCHAIN_SERVICES.includes(k.service))) }, { runs_checked: runs.length, environment_keys: [...input.expectedEnvKeys] }),
|
|
70
72
|
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
73
|
scenario('cleanup', { runs_terminal: every(runs, r => r.terminal), owned_exit_confirmed: every(runs, r => r.exitConfirmed) && input.version.exitConfirmed,
|
|
72
74
|
temp_removed: input.tempRemoved }, { owned_exits: runs.length + 1 }),
|
|
@@ -119,7 +121,7 @@ export async function runClaudeReviewCampaign(input) {
|
|
|
119
121
|
...(input.authObservation ? { authObservation: { sha256: input.authObservation.sha256, decision_id: input.authObservation.observation.decision_id } } : {}),
|
|
120
122
|
observations: { started_at: startedAt, finished_at: now().toISOString(), root: layout.root, runner_package_version: input.runnerVersion ?? packageVersion(),
|
|
121
123
|
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.
|
|
124
|
+
unknown_requests: r.requests.filter(q => q.kind === 'unknown' || q.kind === 'invalid').length, proxy_attempts: [...r.proxyAttempts], keychain_lookups: r.keychainLookups.length, home_writes: [...r.homeWrites],
|
|
123
125
|
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
126
|
});
|
|
125
127
|
}
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import os from 'node:os';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { randomBytes } from 'node:crypto';
|
|
4
|
-
import { chmod, mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { chmod, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises';
|
|
5
5
|
export const CLAUDE_REVIEW_SOURCE_FILE = 'src/review.txt';
|
|
6
6
|
/** Read-only lookups the absent-item helper may record; anything else is a credential write or export attempt. */
|
|
7
7
|
export const CLAUDE_REVIEW_KEYCHAIN_READS = Object.freeze(['find-generic-password', 'find-internet-password']);
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
/** The Keychain services of the employee's terminal login: Claude Code's OAuth item and its legacy API key item. A service with a
|
|
9
|
+
* config-dir hash suffix means the CLI would look past that login (design 2026-10-28-claude-job-login-context). */
|
|
10
|
+
export const CLAUDE_REVIEW_KEYCHAIN_SERVICES = Object.freeze(['Claude Code-credentials', 'Claude Code']);
|
|
11
|
+
export const CLAUDE_REVIEW_PLANTED_SOURCES = Object.freeze(['config_settings', 'cwd_settings', 'cwd_settings_local', 'source_settings',
|
|
12
|
+
'source_settings_local', 'config_memory', 'cwd_memory', 'source_memory', 'cwd_mcp', 'source_mcp', 'config_user_config', 'home_user_config',
|
|
10
13
|
'config_command', 'cwd_command', 'config_skill']);
|
|
11
14
|
const shq = (value) => `'${value.replace(/'/g, `'\\''`)}'`;
|
|
12
15
|
const canary = (label) => `${label}-canary-${randomBytes(16).toString('hex')}`;
|
|
@@ -14,7 +17,7 @@ export async function createClaudeReviewLayout(base = os.tmpdir()) {
|
|
|
14
17
|
const root = await realpath(await mkdtemp(path.join(base, 'claude-release-review-')));
|
|
15
18
|
await chmod(root, 0o700);
|
|
16
19
|
const c = path.join(root, 'cli');
|
|
17
|
-
const cli = { root: c, bin: path.join(c, 'bin'), home: path.join(c, 'home'), config: path.join(c, '
|
|
20
|
+
const cli = { root: c, bin: path.join(c, 'bin'), home: path.join(c, 'home'), config: path.join(c, 'home', '.claude'), cwd: path.join(c, 'cwd'), tmp: path.join(c, 'tmp'),
|
|
18
21
|
sentinels: path.join(c, 'sentinels'), keychainLog: path.join(c, 'keychain.log') };
|
|
19
22
|
const source = path.join(root, 'source'), outside = path.join(root, 'outside');
|
|
20
23
|
for (const dir of [cli.root, cli.bin, cli.home, cli.config, cli.cwd, cli.tmp, cli.sentinels, path.join(cli.home, '.aws'), source, path.join(source, 'src'), outside])
|
|
@@ -28,22 +31,20 @@ export async function createClaudeReviewLayout(base = os.tmpdir()) {
|
|
|
28
31
|
await write(path.join(cli.config, '.credentials.json'), JSON.stringify({ claudeAiOauth: { accessToken: canaries.credential, refreshToken: canaries.credential, expiresAt: 4102444800000, scopes: ['user:inference'] } }));
|
|
29
32
|
await write(path.join(cli.home, '.aws', 'credentials'), `[default]\naws_access_key_id=${canaries.credential}\naws_secret_access_key=${canaries.credential}\n`);
|
|
30
33
|
await write(path.join(cli.home, '.netrc'), `machine api.anthropic.com login review password ${canaries.credential}\n`);
|
|
31
|
-
// Absent-item Keychain helper, first on PATH: records the operation name only and answers "item not found"
|
|
32
|
-
// as the real helper does for a missing item. The review profile denies the real /usr/bin/security and the Keychain services.
|
|
33
|
-
await write(path.join(cli.bin, 'security'), `#!/bin/sh\nprintf '%s\\n' "$1" >> ${shq(cli.keychainLog)}\necho 'security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain.' >&2\nexit 44\n`, 0o700);
|
|
34
|
-
return { root, source, outside, cli, canaries, env: { PATH: `${cli.bin}:/usr/bin:/bin`, HOME: cli.home, TMPDIR: cli.tmp, LANG: 'C', LC_ALL: 'C'
|
|
34
|
+
// Absent-item Keychain helper, first on PATH: records the operation and the -s service name only and answers "item not found"
|
|
35
|
+
// (exit 44), as the real helper does for a missing item. The review profile denies the real /usr/bin/security and the Keychain services.
|
|
36
|
+
await write(path.join(cli.bin, 'security'), `#!/bin/sh\nservice='';previous=''\nfor argument in "$@";do [ "$previous" = '-s' ] && service="$argument";previous="$argument";done\nprintf '%s\\t%s\\n' "$1" "$service" >> ${shq(cli.keychainLog)}\necho 'security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain.' >&2\nexit 44\n`, 0o700);
|
|
37
|
+
return { root, source, outside, cli, canaries, env: { PATH: `${cli.bin}:/usr/bin:/bin`, HOME: cli.home, TMPDIR: cli.tmp, LANG: 'C', LC_ALL: 'C' } };
|
|
35
38
|
}
|
|
36
39
|
export function claudeReviewPlantedFiles(layout) {
|
|
37
40
|
const { cli, source } = layout;
|
|
38
41
|
return [
|
|
39
42
|
{ id: 'config_settings', kind: 'settings', file: path.join(cli.config, 'settings.json') },
|
|
40
|
-
{ id: 'home_settings', kind: 'settings', file: path.join(cli.home, '.claude', 'settings.json') },
|
|
41
43
|
{ id: 'cwd_settings', kind: 'settings', file: path.join(cli.cwd, '.claude', 'settings.json') },
|
|
42
44
|
{ id: 'cwd_settings_local', kind: 'settings', file: path.join(cli.cwd, '.claude', 'settings.local.json') },
|
|
43
45
|
{ id: 'source_settings', kind: 'settings', file: path.join(source, '.claude', 'settings.json') },
|
|
44
46
|
{ id: 'source_settings_local', kind: 'settings', file: path.join(source, '.claude', 'settings.local.json') },
|
|
45
47
|
{ id: 'config_memory', kind: 'memory', file: path.join(cli.config, 'CLAUDE.md') },
|
|
46
|
-
{ id: 'home_memory', kind: 'memory', file: path.join(cli.home, '.claude', 'CLAUDE.md') },
|
|
47
48
|
{ id: 'cwd_memory', kind: 'memory', file: path.join(cli.cwd, 'CLAUDE.md') },
|
|
48
49
|
{ id: 'source_memory', kind: 'memory', file: path.join(source, 'CLAUDE.md') },
|
|
49
50
|
{ id: 'cwd_mcp', kind: 'mcp', file: path.join(cli.cwd, '.mcp.json') },
|
|
@@ -85,8 +86,32 @@ export async function takeSentinels(layout) {
|
|
|
85
86
|
return names.map(name => /^[a-z-]{1,40}$/.test(name) ? name : 'unexpected');
|
|
86
87
|
}
|
|
87
88
|
/** Operation names that the absent-item helper recorded; cleared for the next run. */
|
|
88
|
-
export async function
|
|
89
|
+
export async function takeKeychainLookups(layout) {
|
|
89
90
|
const text = await readFile(layout.cli.keychainLog, 'utf8').catch(() => '');
|
|
90
91
|
await rm(layout.cli.keychainLog, { force: true });
|
|
91
|
-
return text.split('\n').filter(Boolean).slice(0, 256).map(
|
|
92
|
+
return text.split('\n').filter(Boolean).slice(0, 256).map(line => {
|
|
93
|
+
const [operation = '', service = '', ...rest] = line.split('\t');
|
|
94
|
+
return { operation: /^[a-z-]{1,40}$/.test(operation) ? operation : 'unexpected', service: rest.length === 0 && /^[A-Za-z0-9 ._-]{0,64}$/.test(service) ? service : 'unexpected' };
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
/** Every regular file under the fake HOME with its size and modification time, so a run can list what the CLI created or changed. */
|
|
98
|
+
export async function snapshotReviewHome(layout) {
|
|
99
|
+
const files = new Map();
|
|
100
|
+
const walk = async (dir) => {
|
|
101
|
+
for (const entry of await readdir(dir, { withFileTypes: true }).catch(() => [])) {
|
|
102
|
+
const file = path.join(dir, entry.name);
|
|
103
|
+
if (entry.isDirectory())
|
|
104
|
+
await walk(file);
|
|
105
|
+
else if (entry.isFile() && files.size < 4096) {
|
|
106
|
+
const info = await lstat(file);
|
|
107
|
+
files.set(path.relative(layout.cli.home, file), `${info.size}:${info.mtimeMs}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
await walk(layout.cli.home);
|
|
112
|
+
return files;
|
|
113
|
+
}
|
|
114
|
+
/** Paths under the fake HOME, relative to it, that a run created or changed: names only, at most 64. */
|
|
115
|
+
export function reviewHomeWrites(before, after) {
|
|
116
|
+
return [...after].filter(([file, stamp]) => before.get(file) !== stamp).map(([file]) => file).sort().slice(0, 64);
|
|
92
117
|
}
|
|
@@ -11,7 +11,7 @@ import { jobToolPolicyOptions } from './job-tool-policy.js';
|
|
|
11
11
|
import { LocalToolServer, localEndpointReadObservations } from './local-tool-server.js';
|
|
12
12
|
import { claudeReviewProfile, claudeReviewSandboxArgs } from './claude-review-sandbox.js';
|
|
13
13
|
import { startClaudeReviewDecoy, startClaudeReviewProvider } from './claude-review-provider.js';
|
|
14
|
-
import { CLAUDE_REVIEW_SOURCE_FILE, plantHostileSources,
|
|
14
|
+
import { CLAUDE_REVIEW_SOURCE_FILE, plantHostileSources, reviewHomeWrites, snapshotReviewHome, takeKeychainLookups, takeSentinels } from './claude-review-layout.js';
|
|
15
15
|
export const CLAUDE_REVIEW_FAKE_KEY = 'sk-ant-claude-release-review-fake-key';
|
|
16
16
|
export const CLAUDE_REVIEW_PROMPT = 'Offline Claude release review. Follow the scripted provider. No real project, account or model.';
|
|
17
17
|
export const CLAUDE_REVIEW_SLASH_PROMPT = '/review-planted now';
|
|
@@ -87,7 +87,7 @@ export async function runClaudeReview(input) {
|
|
|
87
87
|
const layout = input.layout, started = performance.now(), discovery = [];
|
|
88
88
|
const prompt = input.kind === 'slash' ? CLAUDE_REVIEW_SLASH_PROMPT : CLAUDE_REVIEW_PROMPT;
|
|
89
89
|
let active = true, terminal = false, exitConfirmed = false, envKeys = [], reads = { successful: 0, failed: 0 };
|
|
90
|
-
let handle, provider, decoy;
|
|
90
|
+
let handle, provider, decoy, homeBefore;
|
|
91
91
|
const tools = new JobToolSession({ policyOptions: reviewToolPolicy(layout.source), binding: () => REVIEW_BINDING, active: () => active,
|
|
92
92
|
createServer: policy => new LocalToolServer({ policy, onDiscovery: event => { discovery.push(event); } }) });
|
|
93
93
|
try {
|
|
@@ -106,6 +106,7 @@ export async function runClaudeReview(input) {
|
|
|
106
106
|
envKeys = Object.keys(env).sort();
|
|
107
107
|
const profile = claudeReviewProfile({ root: layout.root, cliRoot: layout.cli.root, ports: [provider.port, Number(new URL(endpoint.url).port)], readRoots: input.readRoots, deniedHome: input.deniedHome });
|
|
108
108
|
const [command, argv] = input.sandbox ? claudeReviewSandboxArgs(profile, input.binary, args) : [input.binary, args];
|
|
109
|
+
homeBefore = await snapshotReviewHome(layout);
|
|
109
110
|
handle = input.processHost.spawn(command, argv, { cwd: layout.cli.cwd, env });
|
|
110
111
|
terminal = await drive(handle, claudeUserInput(prompt), input.timeoutMs);
|
|
111
112
|
exitConfirmed = await confirmExit(handle);
|
|
@@ -122,7 +123,8 @@ export async function runClaudeReview(input) {
|
|
|
122
123
|
const p = provider;
|
|
123
124
|
return { kind: input.kind, model: input.model, sandboxed: input.sandbox, terminal, exitConfirmed, requests: [...p.requests], toolResults: new Map(p.toolResults),
|
|
124
125
|
canaryHits: { ...p.canaryHits }, fakeKeyInBody: p.fakeKeyInBody, proxyAttempts: [...p.proxyAttempts], decoyConnections: decoy.connections(), discovery, reads, envKeys,
|
|
125
|
-
|
|
126
|
+
keychainLookups: await takeKeychainLookups(layout), homeWrites: homeBefore ? reviewHomeWrites(homeBefore, await snapshotReviewHome(layout)) : [],
|
|
127
|
+
sentinels: await takeSentinels(layout), durationMs: Math.round(performance.now() - started) };
|
|
126
128
|
}
|
|
127
129
|
/** Stage 1: `--version` of the exact copy, under the review profile with no network grant at all. */
|
|
128
130
|
export async function runClaudeVersion(input) {
|