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 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 whose `HOME` is under `<stateRoot>/claude-jobs/` (where every Claude job runs) is refused, and Codex jobs
179
- run with `features.hooks=false`, so they run no hook at all.
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 home = path.join(control, 'home'), cwd = path.join(control, 'cwd'), config = path.join(control, 'mcp.json');
57
+ const cwd = path.join(control, 'cwd'), config = path.join(control, 'mcp.json');
58
58
  const env = {};
59
- for (const key of ['PATH', 'LANG', 'LC_ALL', 'USER', 'LOGNAME'])
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.HOME = home;
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 [home, cwd, env.TMPDIR])
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 HOME: <stateRoot>/claude-jobs/<id>/home (src/adapters/claude-code/runtime-options.ts). */
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
- /** True when HOME resolves inside <stateRoot>/claude-jobs/: a Claude job this runner started, never a person's session. */
19
- function isRunnerJobHome(env, stateRoot) {
20
- const home = env.HOME;
21
- if (!home || !isAbsolute(home))
22
- return false;
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 homes from the real state root; the shim may name it through a symlink.
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
- const resolved = resolve(home);
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 runs with its HOME under <stateRoot>/claude-jobs/, so a hook that
37
- * sees such a HOME is refused whatever else the environment says. Codex jobs run with features.hooks=false (the Codex
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 (isRunnerJobHome(env, stateRoot))
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
- function isRunnerJobHome(env, stateRoot) {
21
- const home = env.HOME;
22
- if (!home || !isAbsolute(home))
23
- return false;
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
- const resolved = resolve(home);
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 (isRunnerJobHome(env, stateRoot))
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 and the job's
51
- * account location. The documented Claude Code opt-outs keep the probe from updating itself or sending other traffic.
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 ?? path.join(home, '.claude');
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.keychainOperations.every(op => CLAUDE_REVIEW_KEYCHAIN_READS.includes(op))) }, { runs_checked: runs.length, environment_keys: [...input.expectedEnvKeys] }),
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.keychainOperations.length,
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
- export const CLAUDE_REVIEW_PLANTED_SOURCES = Object.freeze(['config_settings', 'home_settings', 'cwd_settings', 'cwd_settings_local', 'source_settings',
9
- 'source_settings_local', 'config_memory', 'home_memory', 'cwd_memory', 'source_memory', 'cwd_mcp', 'source_mcp', 'config_user_config', 'home_user_config',
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, 'config'), cwd: path.join(c, 'cwd'), tmp: path.join(c, 'tmp'),
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" (exit 44),
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', CLAUDE_CONFIG_DIR: cli.config } };
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 takeKeychainOperations(layout) {
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(op => /^[a-z-]{1,40}$/.test(op) ? op : 'unexpected');
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, takeKeychainOperations, takeSentinels } from './claude-review-layout.js';
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
- keychainOperations: await takeKeychainOperations(layout), sentinels: await takeSentinels(layout), durationMs: Math.round(performance.now() - started) };
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtech-ai-local-runner",
3
- "version": "0.1.65",
3
+ "version": "0.1.66",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "bin": {