mtech-ai-local-runner 0.1.77 → 0.1.80

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.
Files changed (42) hide show
  1. package/README.md +33 -0
  2. package/claude-mod/.claude-plugin/marketplace.json +1 -0
  3. package/claude-mod/.claude-plugin/plugin.json +1 -0
  4. package/claude-mod/hooks/gateway.ts +47 -0
  5. package/claude-mod/hooks/hooks.json +16 -0
  6. package/claude-mod/hooks/model.ts +155 -0
  7. package/claude-mod/hooks/register.tsx +276 -0
  8. package/claude-mod/types/index.d.ts +22 -0
  9. package/dist/src/adapters/claude-code/adapter.js +16 -5
  10. package/dist/src/adapters/claude-code/prompt-compiler.js +17 -4
  11. package/dist/src/adapters/claude-code/runtime-options.js +4 -1
  12. package/dist/src/adapters/codex/config-builder.js +2 -1
  13. package/dist/src/adapters/codex/exec-invocation.js +6 -1
  14. package/dist/src/adapters/codex/exec-preflight.js +4 -2
  15. package/dist/src/adapters/codex/exec-runtime-options.js +3 -3
  16. package/dist/src/adapters/codex/exec-tool-gate.js +7 -3
  17. package/dist/src/adapters/codex/native-policy.js +2 -2
  18. package/dist/src/adapters/codex/native-session.js +5 -4
  19. package/dist/src/adapters/codex/pre-model-gate.js +8 -3
  20. package/dist/src/asks/claude-mod.js +163 -0
  21. package/dist/src/asks/commands.js +26 -6
  22. package/dist/src/asks/doctor.js +4 -3
  23. package/dist/src/asks/integrations.js +1 -1
  24. package/dist/src/cli.js +34 -3
  25. package/dist/src/daemon/repository-access-monitor.js +66 -0
  26. package/dist/src/daemon/runtime.js +16 -1
  27. package/dist/src/gateway-client.js +2 -0
  28. package/dist/src/safety/claude-production-host.js +20 -2
  29. package/dist/src/safety/claude-release.js +4 -1
  30. package/dist/src/safety/claude-review-campaign.js +4 -4
  31. package/dist/src/safety/claude-review-run.js +7 -4
  32. package/dist/src/safety/codex-exec-production-host.js +7 -5
  33. package/dist/src/safety/codex-manual-controller.js +2 -2
  34. package/dist/src/safety/hook-policy-builder.js +3 -3
  35. package/dist/src/safety/job-tool-dispatcher.js +14 -3
  36. package/dist/src/safety/job-tool-session.js +2 -2
  37. package/dist/src/safety/local-tool-policy.js +229 -3
  38. package/dist/src/safety/local-tool-self-test.js +44 -0
  39. package/dist/src/safety/local-tool-server.js +18 -6
  40. package/dist/src/safety/local-tool-worker.js +43 -0
  41. package/dist/src/safety/pre-tool-use-hook.js +17 -2
  42. package/package.json +3 -1
@@ -0,0 +1,66 @@
1
+ import { checkRepositoryAccess, probeRepositoryAccess } from "../workspace/repository-access.js";
2
+ export const READABLE_RECHECK_MS = 30 * 60_000;
3
+ export const FAILED_RECHECK_MS = 2 * 60_000;
4
+ export class RepositoryAccessMonitor {
5
+ deps;
6
+ /** The latest sync's list, or undefined when that sync had none. */
7
+ listed;
8
+ results = new Map();
9
+ round;
10
+ constructor(deps) {
11
+ this.deps = deps;
12
+ }
13
+ /** Starts a check of the repositories that are due, unless one runs; the promise ends with that check. */
14
+ handleSync(listed) {
15
+ this.listed = listed;
16
+ if (this.round)
17
+ return this.round;
18
+ const due = this.due();
19
+ if (due.length === 0)
20
+ return Promise.resolve();
21
+ this.round = this.check(due).finally(() => { this.round = undefined; });
22
+ return this.round;
23
+ }
24
+ /** What the heartbeat's local_fix reports, or undefined while the Gateway lists no repositories. */
25
+ report() {
26
+ const listed = this.listed;
27
+ if (listed === undefined)
28
+ return undefined;
29
+ return this.deps.keys().slice(0, 50).flatMap(key => {
30
+ const result = this.results.get(key);
31
+ // A result for a URL the Gateway no longer lists says nothing about the registered repository.
32
+ if (!result || result.providerUrl !== listed.find(entry => entry.repository_key === key)?.provider_url)
33
+ return [];
34
+ return [{ repository_key: key, status: result.status, checked_at: Math.floor(result.checkedAt / 1000) }];
35
+ });
36
+ }
37
+ now() { return (this.deps.now ?? Date.now)(); }
38
+ due() {
39
+ const now = this.now();
40
+ return this.deps.keys().slice(0, 50).flatMap(key => {
41
+ const entry = this.listed?.find(candidate => candidate.repository_key === key);
42
+ if (!entry)
43
+ return [];
44
+ const result = this.results.get(key);
45
+ const wait = result?.status === "readable" ? READABLE_RECHECK_MS : FAILED_RECHECK_MS;
46
+ return !result || result.providerUrl !== entry.provider_url || now - result.checkedAt >= wait ? [entry] : [];
47
+ });
48
+ }
49
+ async check(due) {
50
+ const probe = this.deps.probe ?? probeRepositoryAccess;
51
+ const checks = await checkRepositoryAccess(due.map(entry => entry.repository_key), due, url => probe(url).catch(() => ({ status: "failed" })));
52
+ const checkedAt = this.now();
53
+ let changed = false;
54
+ for (const check of checks) {
55
+ if (check.status === "not_registered")
56
+ continue;
57
+ const providerUrl = due.find(entry => entry.repository_key === check.repository_key).provider_url;
58
+ const previous = this.results.get(check.repository_key);
59
+ if (previous?.status !== check.status || previous.providerUrl !== providerUrl)
60
+ changed = true;
61
+ this.results.set(check.repository_key, { providerUrl, status: check.status, checkedAt });
62
+ }
63
+ if (changed)
64
+ this.deps.changed?.();
65
+ }
66
+ }
@@ -35,6 +35,7 @@ import { composeReviewedP1Host } from '../verification/p1-host-composition.js';
35
35
  import { AskRuntime, withAsks } from '../asks/runtime.js';
36
36
  import { GuideRuntime, withGuide } from '../guide/runtime.js';
37
37
  import { FixConsentRuntime } from './fix-consent.js';
38
+ import { RepositoryAccessMonitor } from './repository-access-monitor.js';
38
39
  import { SuspendedPublication } from './suspended-publication.js';
39
40
  import { RoomActivityRuntime } from '../rooms/activity.js';
40
41
  import { notifyRoomActivity } from '../rooms/notifier.js';
@@ -154,6 +155,16 @@ export async function buildDaemon(input) {
154
155
  workspaces: new WorkspaceManager({ root: input.workspaceRoot, git: new GitClient(), registeredSubmodules: p1Host?.registeredSubmodules }),
155
156
  state,
156
157
  });
158
+ // Work Item 6f9e64ae: git read checks of the consented repositories, sent at once when a status changes.
159
+ const repositoryAccess = new RepositoryAccessMonitor({ keys: () => localFixHost?.report?.repository_keys ?? [],
160
+ changed: () => { void heartbeats.tick(); } });
161
+ const localFixReport = () => {
162
+ const report = localFixHost?.report;
163
+ if (!report)
164
+ return null;
165
+ const access = repositoryAccess.report();
166
+ return access ? { ...report, repository_access: access } : report;
167
+ };
157
168
  let transport;
158
169
  /** The adapter probe in progress, if any: a claim waits for it, so versions never change under a claim. */
159
170
  let probing = Promise.resolve();
@@ -254,7 +265,7 @@ export async function buildDaemon(input) {
254
265
  mode: "full",
255
266
  ...(sessionOpen ? { sessionOpen: { ...sessionOpen, ...(askInbox ? { ask_inbox: askInbox } : {}) } } : {}),
256
267
  ...(guidance ? { guidance } : {}),
257
- localFix: localFixHost?.report ?? null,
268
+ localFix: localFixReport(),
258
269
  };
259
270
  },
260
271
  send: async (payload) => { await gateway.heartbeatLocalRunner(payload); },
@@ -308,6 +319,10 @@ export async function buildDaemon(input) {
308
319
  void roomActivity.handleSync(result.roomActivityEnabled === true).catch(() => {
309
320
  process.stderr.write(`${JSON.stringify({ event: 'room_activity_scan_failed' })}\n`);
310
321
  });
322
+ // git can take up to 30 s per repository, so the check never holds the sync.
323
+ void repositoryAccess.handleSync(result.fixRepositories).catch(() => {
324
+ process.stderr.write(`${JSON.stringify({ event: 'repository_access_check_failed' })}\n`);
325
+ });
311
326
  try {
312
327
  await dispatch(result);
313
328
  }
@@ -1,5 +1,6 @@
1
1
  import { assertClaimedExecutionAttempt, assertBoundExecutionAttempt, CONTINUATION_SCHEMA, CONTINUATION_POLICY } from './verification/continuation-runner-contract.js';
2
2
  import { publicationError } from './publication/failure.js';
3
+ import { fixRepositoryList } from './workspace/repository-access.js';
3
4
  import { issueTransportDiagnostic } from './safety/transport-diagnostic.js';
4
5
  import { hasP1 } from './verification/p1-execution-host.js';
5
6
  import { resolveRunnerVerification, REVIEWED_POLICY, REVIEWED_LOOP_TYPE } from './verification/reviewed-runner-contract.js';
@@ -76,6 +77,7 @@ export function parseRunnerSync(result, runnerId) {
76
77
  ...(body.ask_alert === true ? { askAlert: true } : {}),
77
78
  ...(body.room_activity_enabled === true ? { roomActivityEnabled: true } : {}),
78
79
  ...(Array.isArray(body.fix_consent_grants) ? { fixConsentGrants: parseFixConsentGrants(body.fix_consent_grants) } : {}),
80
+ ...(Array.isArray(body.fix_repositories) ? { fixRepositories: fixRepositoryList(body) } : {}),
79
81
  ...(suspendedJob ? { suspendedJob } : {}),
80
82
  jobAvailable: body.job_available === true,
81
83
  push: parsePush(body.push, runnerId),
@@ -6,7 +6,7 @@ import { CLAUDE_CAPABILITIES } from '../adapters/discovery.js';
6
6
  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
- import { localToolInventory } from './local-tool-server.js';
9
+ import { localToolInventory, localToolInventoryFor } from './local-tool-server.js';
10
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';
@@ -39,6 +39,23 @@ export const CLAUDE_LAUNCH_CONFIG = Object.freeze({ transport: CLAUDE_TRANSPORT,
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
41
  settings_keys: Object.freeze(['hooks.PreToolUse']), env_opt_outs: CLAUDE_LAUNCH_OPT_OUTS });
42
+ /** The tool inventory a listed release was reviewed with (Work Decision f43e7aa0): the one its tools_sha256 names, or
43
+ * local-tools-v1 for an entry without it, so every job keeps the original tools until the Gateway lists another. */
44
+ export function claudeReleaseToolInventory(release) {
45
+ if (release.tools_sha256 === undefined)
46
+ return 'local-tools-v1';
47
+ const id = localToolInventoryFor(release.tools_sha256, claudeCanonicalSha256);
48
+ if (!id)
49
+ throw Error('CLAUDE_RELEASE_BINDING_REJECTED');
50
+ return id;
51
+ }
52
+ /** The inventory a Claude job pin names; the adapter and the job tools serve exactly this one. */
53
+ export function claudePinToolInventory(pin) {
54
+ const id = localToolInventoryFor(pin.toolsSha256, claudeCanonicalSha256);
55
+ if (!id)
56
+ throw Error('JOB_SECURITY_REJECTED');
57
+ return id;
58
+ }
42
59
  const CHECK_IDS = { CLAUDE_RELEASE_NOT_LISTED: 'release_list', CLAUDE_RELEASE_PLATFORM_MISMATCH: 'release_platform',
43
60
  CLAUDE_RELEASE_COPY_UNAVAILABLE: 'release_copy', CLAUDE_CODE_SIGNATURE_REJECTED: 'release_copy', CLAUDE_MANAGED_SCOPE_UNVERIFIED: 'managed_scope' };
44
61
  /** A login probe answer (any status) is reused this long for the same release and copy identity. */
@@ -193,11 +210,12 @@ export function createClaudeProductionHost(input) {
193
210
  if (release.version !== synced.version || release.platform !== synced.platform || release.binary_sha256 !== synced.binary_sha256
194
211
  || !isDeepStrictEqual([...release.model_ids], [...synced.model_ids]) || release.platform !== platform || !release.model_ids.includes(request.modelId))
195
212
  throw Error('CLAUDE_RELEASE_BINDING_REJECTED');
213
+ const tools = claudeReleaseToolInventory(release);
196
214
  await verifyClaudeCodeSignature(binary, input.codesign);
197
215
  const scope = await collectClaudeManagedScope(managed), commandRegistry = [];
198
216
  const pin = { adapterId: 'claude_code_cli', adapterVersion: '1', jobId: request.jobId, generation: request.generation, modelId: request.modelId, sourceSha: request.sourceSha,
199
217
  binarySha256: release.binary_sha256, configSha256: claudeCanonicalSha256(CLAUDE_LAUNCH_CONFIG), managedPolicySha256: scope.fingerprint,
200
- toolsSha256: claudeCanonicalSha256({ tools: localToolInventory() }), commandsSha256: commandRegistryFingerprint(commandRegistry),
218
+ toolsSha256: claudeCanonicalSha256({ tools: localToolInventory(tools) }), commandsSha256: commandRegistryFingerprint(commandRegistry),
201
219
  osProfileSha256: claudeCanonicalSha256({ platform }), requirementsSha256: requirements.fingerprint, securityMode: CLAUDE_RELEASE_MODE };
202
220
  return { pin, commandRegistry,
203
221
  // The job MCP token is the session identity (accepted risk job_mcp_token_possession_not_os_identity).
@@ -50,8 +50,11 @@ export function parseClaudeSyncRelease(value) {
50
50
  return null;
51
51
  return freeze({ version: value.version, platform: value.platform, binary_sha256: value.binary_sha256, model_ids: [...value.model_ids] });
52
52
  }
53
+ /** One listed release entry: its seven keys, and optionally tools_sha256 (Work Decision f43e7aa0; runners up to 0.1.76
54
+ * refuse an entry that carries it). Which inventory the value names is the job host's check, not this parser's. */
53
55
  export function requireClaudeRelease(value) {
54
- if (!record(value) || !isDeepStrictEqual(keys(value), ['binary_sha256', 'decision_id', 'evidence_sha256', 'model_ids', 'platform', 'review_status', 'version']) || !common(value)
56
+ if (!record(value) || !isDeepStrictEqual(keys(value).filter(k => k !== 'tools_sha256'), ['binary_sha256', 'decision_id', 'evidence_sha256', 'model_ids', 'platform', 'review_status', 'version']) || !common(value)
57
+ || Object.hasOwn(value, 'tools_sha256') && !digest(value.tools_sha256)
55
58
  || !digest(value.evidence_sha256) || typeof value.decision_id !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(value.decision_id)
56
59
  || value.review_status !== 'reviewed')
57
60
  throw Error('CLAUDE_REQUIREMENTS_REJECTED');
@@ -7,7 +7,7 @@ import { isDeepStrictEqual } from 'node:util';
7
7
  import { NodeProcessGroupHost } from '../process/node-process-group-host.js';
8
8
  import { packageVersion } from '../package-version.js';
9
9
  import { HOOK_POLICY_ENV_VAR } from './hook-policy-builder.js';
10
- import { CLAUDE_LOCAL_MCP_TOOLS } from './pre-tool-use-hook.js';
10
+ import { claudeLocalMcpTools } from './pre-tool-use-hook.js';
11
11
  import { localToolInventory } from './local-tool-server.js';
12
12
  import { measureCommandFile } from './registered-command-integrity.js';
13
13
  import { CLAUDE_LAUNCH_OPT_OUTS, CLAUDE_SIGNING_IDENTIFIER, CLAUDE_TEAM_ID, claudeCanonicalSha256, parseClaudeSyncRelease } from './claude-release.js';
@@ -15,9 +15,9 @@ import { CLAUDE_CODE_REQUIREMENT, verifyClaudeCodeSignature } from './claude-rel
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
17
  import { CLAUDE_REVIEW_KEYCHAIN_READS, CLAUDE_REVIEW_KEYCHAIN_SERVICES, 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';
18
+ import { CLAUDE_REVIEW_FAKE_KEY, CLAUDE_REVIEW_PROMPT, CLAUDE_REVIEW_RUN_TIMEOUT_MS, CLAUDE_REVIEW_SLASH_PROMPT, CLAUDE_REVIEW_TOOL_IDS, CLAUDE_REVIEW_TOOL_INVENTORY, claudeReviewEnv, runClaudeReview, runClaudeVersion } from './claude-review-run.js';
19
19
  import { CLAUDE_REVIEW_STAGES, buildClaudeReviewEvidence } from './claude-review-evidence.js';
20
- const AI_LOCAL = Object.freeze([...CLAUDE_LOCAL_MCP_TOOLS].sort());
20
+ const AI_LOCAL = Object.freeze([...claudeLocalMcpTools(CLAUDE_REVIEW_TOOL_INVENTORY)].sort());
21
21
  const DISCOVERY = Object.freeze(['initialize_response', 'initialized_notification', 'inventory_response']);
22
22
  const PLANTED_SENTINELS = Object.freeze(['api-key-helper', 'pre-tool-use', 'session-start', 'user-prompt-submit']);
23
23
  const sha = (text) => createHash('sha256').update(text).digest('hex');
@@ -112,7 +112,7 @@ export async function runClaudeReviewCampaign(input) {
112
112
  expectedEnvKeys: [...launchEnvKeys, ...reviewEnvKeys].sort(), tempRemoved });
113
113
  return buildClaudeReviewEvidence({
114
114
  release: { ...input.release, model_ids: models },
115
- protocol: { launch_config_sha256: claudeCanonicalSha256(CLAUDE_LAUNCH_CONFIG), tools_sha256: claudeCanonicalSha256({ tools: localToolInventory() }),
115
+ protocol: { launch_config_sha256: claudeCanonicalSha256(CLAUDE_LAUNCH_CONFIG), tools_sha256: claudeCanonicalSha256({ tools: localToolInventory(CLAUDE_REVIEW_TOOL_INVENTORY) }),
116
116
  sandbox_profile: CLAUDE_REVIEW_PROFILE_VERSION, sandbox_profile_template_sha256: CLAUDE_REVIEW_PROFILE_TEMPLATE_SHA256, launch_env_keys: launchEnvKeys,
117
117
  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) },
118
118
  inventory: { binary_sha256: sha256, size_bytes: size, team_id: CLAUDE_TEAM_ID, signing_identifier: CLAUDE_SIGNING_IDENTIFIER,
@@ -27,10 +27,13 @@ export function claudeReviewEnv(providerUrl) {
27
27
  const REVIEW_KEY = 'claude-release-review', REVIEW_SHA = '0'.repeat(40);
28
28
  const REVIEW_BINDING = Object.freeze({ jobId: REVIEW_KEY, threadId: `${REVIEW_KEY}-thread`, turnId: `${REVIEW_KEY}-turn`, generation: 1 });
29
29
  const REVIEW_TASK = Object.freeze({ agent_execution: { implementation_brief: { required_tests: [] } } });
30
+ /** The tool inventory a release is reviewed with (Work Decision f43e7aa0): the newest, so a listing that names it in
31
+ * tools_sha256 is backed by evidence of exactly that surface. The job MCP server, the hook and the argv all serve it. */
32
+ export const CLAUDE_REVIEW_TOOL_INVENTORY = 'local-tools-v2';
30
33
  /** A read-only review job over the fake source: src/** allowed, .env blocked, no commands. Same derivation as a real job. */
31
34
  function reviewToolPolicy(source) {
32
35
  const blocked = ['.env', '.env.*'];
33
- return jobToolPolicyOptions(source, { repositories: [{ repository_key: REVIEW_KEY, base_commit_sha: REVIEW_SHA, access: 'read', allowed_paths: ['src/**'], blocked_paths: blocked }], contract: { verification: { required_commands: [] } } }, { runtime_adapter: 'claude_code_cli', runtime_adapter_version: '1', repository: { key: REVIEW_KEY, base_commit_sha: REVIEW_SHA, access: 'read', blocked_paths: blocked }, allowed_paths: ['src/**'], required_commands: [] }, [], { runtimeAdapter: 'claude_code_cli', runtimeAdapterVersion: '1' });
36
+ return { inventory: CLAUDE_REVIEW_TOOL_INVENTORY, ...jobToolPolicyOptions(source, { repositories: [{ repository_key: REVIEW_KEY, base_commit_sha: REVIEW_SHA, access: 'read', allowed_paths: ['src/**'], blocked_paths: blocked }], contract: { verification: { required_commands: [] } } }, { runtime_adapter: 'claude_code_cli', runtime_adapter_version: '1', repository: { key: REVIEW_KEY, base_commit_sha: REVIEW_SHA, access: 'read', blocked_paths: blocked }, allowed_paths: ['src/**'], required_commands: [] }, [], { runtimeAdapter: 'claude_code_cli', runtimeAdapterVersion: '1' }) };
34
37
  }
35
38
  /** The scripted primary turn: one allowed read, a native tool, an unregistered MCP tool, a read outside the root, a planted secret. */
36
39
  export function claudeReviewSteps(layout) {
@@ -89,7 +92,7 @@ export async function runClaudeReview(input) {
89
92
  let active = true, terminal = false, exitConfirmed = false, envKeys = [], reads = { successful: 0, failed: 0 };
90
93
  let handle, provider, decoy, homeBefore;
91
94
  const tools = new JobToolSession({ policyOptions: reviewToolPolicy(layout.source), binding: () => REVIEW_BINDING, active: () => active,
92
- createServer: policy => new LocalToolServer({ policy, onDiscovery: event => { discovery.push(event); } }) });
95
+ createServer: policy => new LocalToolServer({ policy, inventory: CLAUDE_REVIEW_TOOL_INVENTORY, onDiscovery: event => { discovery.push(event); } }) });
93
96
  try {
94
97
  provider = await startClaudeReviewProvider({ prompt, model: input.model, steps: input.kind === 'slash' ? [] : claudeReviewSteps(layout), canaries: { ...layout.canaries }, fakeKey: CLAUDE_REVIEW_FAKE_KEY });
95
98
  decoy = await startClaudeReviewDecoy();
@@ -98,10 +101,10 @@ export async function runClaudeReview(input) {
98
101
  await plantHostileSources(layout, { decoyUrl: decoy.url, plantedMcpUrl: `${provider.url}/planted-mcp/${layout.canaries.planted}` });
99
102
  const mcpConfigPath = path.join(layout.cli.root, `mcp-${randomUUID()}.json`);
100
103
  await writeFile(mcpConfigPath, JSON.stringify(claudeJobMcpConfig(endpoint)), { mode: 0o600, flag: 'wx' });
101
- const policy = buildHookPolicy({ taskPackage: REVIEW_TASK, worktreeRoot: layout.source, registeredReadTools: [], generatedPaths: [mcpConfigPath] });
104
+ const policy = buildHookPolicy({ taskPackage: REVIEW_TASK, worktreeRoot: layout.source, registeredReadTools: [], generatedPaths: [mcpConfigPath], toolInventory: CLAUDE_REVIEW_TOOL_INVENTORY });
102
105
  // Exactly the adapter's release-mode argv (unbounded employee shape: no budget flag), environment and hook settings.
103
106
  const args = claudeLaunchArgs({ modelId: input.model, maxBudgetUsd: null, name: REVIEW_KEY, mcpConfigPath,
104
- settings: claudeLaunchSettings(formatHookCommand(input.hook, { worktreeRoot: layout.source })), sessionId: randomUUID() });
107
+ settings: claudeLaunchSettings(formatHookCommand(input.hook, { worktreeRoot: layout.source })), sessionId: randomUUID(), toolInventory: CLAUDE_REVIEW_TOOL_INVENTORY });
105
108
  const env = { ...claudeLaunchEnv(layout.env, policy), ...claudeReviewEnv(provider.url) };
106
109
  envKeys = Object.keys(env).sort();
107
110
  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 });
@@ -21,7 +21,7 @@ import { isReleaseExecMode, requireOfficialExecPlan, assertOfficialExecAttempt }
21
21
  import { EXEC_INPUT_GATES, releaseGates, releaseRisks } from '../adapters/codex/exec-release-policy.js';
22
22
  import { measureCommandFile, verifyCommandIntegrity } from './registered-command-integrity.js';
23
23
  import { commandRegistryFingerprint } from './job-security-session.js';
24
- import { localToolInventory } from './local-tool-server.js';
24
+ import { localToolInventory, localToolInventoryFor } from './local-tool-server.js';
25
25
  const reject = () => Error('CODEX_EXEC_PRODUCTION_REJECTED');
26
26
  const hex = (v) => typeof v === 'string' && /^[a-f0-9]{64}$/.test(v);
27
27
  const exact = (v, keys) => JSON.stringify(Object.keys(v).sort()) === JSON.stringify(keys.sort());
@@ -108,7 +108,7 @@ export function createCodexExecProductionHost(original) {
108
108
  const attempt = assertClaimedExecutionAttempt(claimed);
109
109
  let cleanup, closing;
110
110
  const close = () => { revoked = true; return closing ??= (Promise.resolve().then(() => cleanup?.())); };
111
- let revoked = false, prepared = false, selected, ctx;
111
+ let revoked = false, prepared = false, selected, ctx, tools;
112
112
  const baseline = new Map(), directories = new Map(), accountDirectories = new Map();
113
113
  let boundPlan, headObservation;
114
114
  const dirStamp = (file) => { const s = lstatSync(file, { bigint: true }); if (!s.isDirectory() || (s.mode & 63n) !== 0n || process.getuid && s.uid !== BigInt(process.getuid()))
@@ -133,7 +133,9 @@ export function createCodexExecProductionHost(original) {
133
133
  selected = await inspect();
134
134
  stage = 'codex_prepare_binding';
135
135
  const r = requireSecurityRequirements(p.security_requirements, { adapterId: 'codex_cli', adapterVersion: '1' }), release = selected.release;
136
- if (r.mdm_verification !== release.mdmVerification || !isDeepStrictEqual(r.policy_observer, release.policyObserver) || r.security_mode !== release.securityMode || value.generation !== j.generation || value.resolvedModelId !== p.resolved_model_id || !release.modelIds.includes(value.resolvedModelId) || fingerprint(value.approvedTaskPackage) !== c.task_package_fingerprint || value.securityBinding?.contractFingerprint !== c.contract_fingerprint || value.securityBinding?.taskPackageFingerprint !== c.task_package_fingerprint || value.securityBinding?.launchFingerprint !== j.launch_fingerprint || r.release_manifest_sha256 !== execReleaseFingerprint(release) || r.binary_sha256 !== release.binarySha256 || r.fixtures_sha256 !== release.fixturesFingerprint || r.commands_sha256 !== commandRegistryFingerprint(release.commands) || r.tools_sha256 !== fingerprint({ tools: localToolInventory() }) || JSON.stringify(r.model_ids) !== JSON.stringify(release.modelIds))
136
+ // The one inventory whose fingerprint the frozen descriptor names; any other tools_sha256 is refused as before.
137
+ tools = localToolInventoryFor(r.tools_sha256, fingerprint);
138
+ if (r.mdm_verification !== release.mdmVerification || !isDeepStrictEqual(r.policy_observer, release.policyObserver) || r.security_mode !== release.securityMode || value.generation !== j.generation || value.resolvedModelId !== p.resolved_model_id || !release.modelIds.includes(value.resolvedModelId) || fingerprint(value.approvedTaskPackage) !== c.task_package_fingerprint || value.securityBinding?.contractFingerprint !== c.contract_fingerprint || value.securityBinding?.taskPackageFingerprint !== c.task_package_fingerprint || value.securityBinding?.launchFingerprint !== j.launch_fingerprint || r.release_manifest_sha256 !== execReleaseFingerprint(release) || r.binary_sha256 !== release.binarySha256 || r.fixtures_sha256 !== release.fixturesFingerprint || r.commands_sha256 !== commandRegistryFingerprint(release.commands) || !tools || JSON.stringify(r.model_ids) !== JSON.stringify(release.modelIds))
137
139
  throw reject();
138
140
  if (attempt) {
139
141
  assertBoundExecutionAttempt(claimed, value.taskPackage);
@@ -174,7 +176,7 @@ export function createCodexExecProductionHost(original) {
174
176
  stage = 'codex_prepare_current';
175
177
  if (!current())
176
178
  throw reject();
177
- return { binary: selected.binary, release: structuredClone(release), actorId: c.security_context.actor_id, runnerId: input.runnerId };
179
+ return { binary: selected.binary, release: structuredClone(release), actorId: c.security_context.actor_id, runnerId: input.runnerId, tools };
178
180
  }
179
181
  catch (error) {
180
182
  revoked = true;
@@ -182,7 +184,7 @@ export function createCodexExecProductionHost(original) {
182
184
  }
183
185
  },
184
186
  async inspect(plan) {
185
- if (!current() || !selected || !ctx || plan.mode !== selected.release.securityMode || plan.credentialBackend !== selected.release.credentialBackend || plan.home !== input.home || plan.accountRoot !== account || plan.binary !== selected.binary || plan.modelId !== ctx.resolvedModelId || plan.identity.actorId !== c.security_context.actor_id || plan.identity.runnerId !== input.runnerId || plan.identity.jobId !== id || plan.identity.generation !== j.generation || plan.identity.sourceSha !== p.repository?.base_commit_sha || plan.identity.launchFingerprint !== j.launch_fingerprint || plan.identity.taskPackageFingerprint !== c.task_package_fingerprint || plan.identity.contractFingerprint !== c.contract_fingerprint || plan.identity.releaseManifestFingerprint !== execReleaseFingerprint(selected.release) || plan.identity.binarySha256 !== selected.release.binarySha256 || plan.identity.commandsFingerprint !== commandRegistryFingerprint(selected.release.commands) || plan.identity.toolSchemaFingerprint !== fingerprint({ tools: localToolInventory() }) || plan.identity.barrierFingerprint !== selected.release.fixturesFingerprint || plan.identity.requirementsFingerprint !== p.security_requirements.fingerprint)
187
+ if (!current() || !selected || !ctx || !tools || plan.mode !== selected.release.securityMode || plan.credentialBackend !== selected.release.credentialBackend || plan.home !== input.home || plan.accountRoot !== account || plan.binary !== selected.binary || plan.modelId !== ctx.resolvedModelId || plan.identity.actorId !== c.security_context.actor_id || plan.identity.runnerId !== input.runnerId || plan.identity.jobId !== id || plan.identity.generation !== j.generation || plan.identity.sourceSha !== p.repository?.base_commit_sha || plan.identity.launchFingerprint !== j.launch_fingerprint || plan.identity.taskPackageFingerprint !== c.task_package_fingerprint || plan.identity.contractFingerprint !== c.contract_fingerprint || plan.identity.releaseManifestFingerprint !== execReleaseFingerprint(selected.release) || plan.identity.binarySha256 !== selected.release.binarySha256 || plan.identity.commandsFingerprint !== commandRegistryFingerprint(selected.release.commands) || plan.identity.toolSchemaFingerprint !== fingerprint({ tools: localToolInventory(tools) }) || plan.identity.barrierFingerprint !== selected.release.fixturesFingerprint || plan.identity.requirementsFingerprint !== p.security_requirements.fingerprint)
186
188
  throw reject();
187
189
  assertOfficialExecAttempt(plan, ctx.executionAttempt, ctx.executionAttemptFingerprint, ctx.bindingId, sourceRoot);
188
190
  if (boundPlan && boundPlan !== plan)
@@ -4,7 +4,7 @@ import { eligiblePersonalAccount } from '../adapters/codex/unmanaged-scope.js';
4
4
  import { securityRequirementsFingerprint as hash } from './security-requirements.js';
5
5
  import { commandRegistryFingerprint } from './job-security-session.js';
6
6
  import { fingerprint } from '../daemon/execution-coordinator.js';
7
- import { localToolInventory } from './local-tool-server.js';
7
+ import { localToolInventoryFor } from './local-tool-server.js';
8
8
  import { isDeepStrictEqual } from 'node:util';
9
9
  import { readCodexCredentialStatus } from './codex-production-host.js';
10
10
  import path from 'node:path';
@@ -59,7 +59,7 @@ function createPrelaunchController(input, deps, mode) {
59
59
  throw rejected();
60
60
  stage = 'codex_manual_requirements';
61
61
  const job = claim.job, p = claim.task_package, r = requirements;
62
- if (r.security_mode !== release.securityMode || r.binary_sha256 !== release.binarySha256 || r.fixtures_sha256 !== release.fixturesFingerprint || r.commands_sha256 !== commandRegistryFingerprint(release.commands) || r.tools_sha256 !== fingerprint({ tools: localToolInventory() }) || !isDeepStrictEqual(r.model_ids, release.modelIds) || !release.modelIds.includes(String(p.resolved_model_id)))
62
+ if (r.security_mode !== release.securityMode || r.binary_sha256 !== release.binarySha256 || r.fixtures_sha256 !== release.fixturesFingerprint || r.commands_sha256 !== commandRegistryFingerprint(release.commands) || !localToolInventoryFor(r.tools_sha256, fingerprint) || !isDeepStrictEqual(r.model_ids, release.modelIds) || !release.modelIds.includes(String(p.resolved_model_id)))
63
63
  throw rejected();
64
64
  stage = 'codex_manual_local_policy';
65
65
  const local = readLocal();
@@ -1,4 +1,4 @@
1
- import { CLAUDE_LOCAL_MCP_TOOLS } from "./pre-tool-use-hook.js";
1
+ import { claudeLocalMcpTools } from "./pre-tool-use-hook.js";
2
2
  const DEFAULT_BLOCKED_PATHS = [".git", ".env", ".aws", ".ssh"];
3
3
  /** Defined here, not in hook-cli.ts, so the runner never has to load the hook script itself. */
4
4
  export const HOOK_POLICY_ENV_VAR = "AI_GATEWAY_RUNNER_HOOK_POLICY_B64";
@@ -15,12 +15,12 @@ function requiredTestsFromTaskPackage(taskPackage) {
15
15
  return requiredTests.map((item) => String(item));
16
16
  }
17
17
  /**
18
- * Only the four job-local MCP names may pass the hook. Source and command
18
+ * Only the job-local MCP names of the job's tool inventory may pass the hook. Source and command
19
19
  * authorization is enforced by the scoped worker, never by native Claude tools.
20
20
  */
21
21
  export function buildHookPolicy(options) {
22
22
  const requiredCommands = requiredTestsFromTaskPackage(options.taskPackage);
23
- const allowedMcpTools = [...CLAUDE_LOCAL_MCP_TOOLS];
23
+ const allowedMcpTools = [...claudeLocalMcpTools(options.toolInventory)];
24
24
  const blockedPaths = [...new Set([...DEFAULT_BLOCKED_PATHS, ...(options.additionalBlockedPaths ?? []), ...options.generatedPaths])];
25
25
  return {
26
26
  requiredCommands,
@@ -1,5 +1,6 @@
1
1
  import { validateLocalReadRange } from './local-read-range.js';
2
2
  import { localToolInventory } from './local-tool-server.js';
3
+ import { localToolNames } from './local-tool-policy.js';
3
4
  const rejected = () => new Error('JOB_TOOL_DISPATCH_REJECTED');
4
5
  /** The scoped policy's stable denial code, which LocalToolServer already lets a model see, so a model can correct its
5
6
  * call. Only an exact code qualifies: any other failure, including binding and lease checks, stays one masked rejection. */
@@ -15,14 +16,23 @@ const keys = (value, expected) => JSON.stringify(Object.keys(value).sort()) ===
15
16
  export class JobToolDispatcher {
16
17
  policy;
17
18
  active;
19
+ inventory;
18
20
  #binding;
19
21
  #revoked = false;
20
22
  #lastRequest = 0n;
21
- constructor(binding, policy, active) {
23
+ /** `inventory` is the job's host-picked tool inventory (absent: local-tools-v1); only its tools and schemas pass. */
24
+ constructor(binding, policy, active, inventory) {
22
25
  this.policy = policy;
23
26
  this.active = active;
27
+ this.inventory = inventory;
24
28
  if (![binding.jobId, binding.threadId, binding.turnId].every(id) || !Number.isSafeInteger(binding.generation) || binding.generation < 1)
25
29
  throw rejected();
30
+ try {
31
+ localToolNames(inventory);
32
+ }
33
+ catch {
34
+ throw rejected();
35
+ }
26
36
  this.#binding = Object.freeze({ ...binding });
27
37
  }
28
38
  revoke() { if (this.#revoked)
@@ -37,9 +47,10 @@ export class JobToolDispatcher {
37
47
  || !id(envelope.requestId) || !/^[1-9][0-9]*$/.test(envelope.requestId) || BigInt(envelope.requestId) <= this.#lastRequest
38
48
  || typeof envelope.tool !== 'string' || !record(envelope.args))
39
49
  throw rejected();
40
- const tool = localToolInventory().find(t => t.name === envelope.tool);
50
+ const tool = localToolInventory(this.inventory).find(t => t.name === envelope.tool);
41
51
  const properties = tool?.inputSchema.properties;
42
- const required = tool?.inputSchema.required;
52
+ // A schema whose properties are all optional (local_find) has no `required`.
53
+ const required = tool?.inputSchema.required ?? [];
43
54
  if (!record(properties) || !Array.isArray(required) || required.some(key => typeof key !== 'string' || !Object.hasOwn(envelope.args, key))
44
55
  || Object.entries(envelope.args).some(([key, value]) => {
45
56
  const schema = properties[key];
@@ -26,7 +26,7 @@ export class JobToolSession {
26
26
  throw Error();
27
27
  if (!this.dispatcher) {
28
28
  this.bound = Object.freeze({ ...binding });
29
- this.dispatcher = new JobToolDispatcher(this.bound, this.policy, () => !this.revoked && this.options.active() && sameBinding(this.bound, this.options.binding()));
29
+ this.dispatcher = new JobToolDispatcher(this.bound, this.policy, () => !this.revoked && this.options.active() && sameBinding(this.bound, this.options.binding()), this.options.policyOptions.inventory);
30
30
  }
31
31
  if (!sameBinding(this.bound, binding))
32
32
  throw Error();
@@ -42,7 +42,7 @@ export class JobToolSession {
42
42
  this.started = true;
43
43
  try {
44
44
  const scoped = { call: (tool, args) => this.call(tool, args), revoke: () => this.policy.revoke() };
45
- this.server = this.options.createServer?.(scoped) ?? new LocalToolServer({ policy: scoped });
45
+ this.server = this.options.createServer?.(scoped) ?? new LocalToolServer({ policy: scoped, inventory: this.options.policyOptions.inventory });
46
46
  const endpoint = await this.server.start();
47
47
  if (this.revoked)
48
48
  throw Error();