mtech-ai-local-runner 0.1.7 → 0.1.9

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 (56) hide show
  1. package/README.md +77 -2
  2. package/dist/src/adapters/claude-code/adapter.js +157 -98
  3. package/dist/src/adapters/claude-code/completion-report.js +2 -64
  4. package/dist/src/adapters/claude-code/prompt-compiler.js +1 -60
  5. package/dist/src/adapters/claude-code/runtime-options.js +72 -0
  6. package/dist/src/adapters/codex/adapter.js +170 -0
  7. package/dist/src/adapters/codex/app-server-events.js +84 -0
  8. package/dist/src/adapters/codex/compatibility.js +45 -0
  9. package/dist/src/adapters/codex/config-builder.js +40 -0
  10. package/dist/src/adapters/codex/host-admission.js +3 -0
  11. package/dist/src/adapters/codex/inspection-rpc.js +48 -0
  12. package/dist/src/adapters/codex/job-rpc.js +189 -0
  13. package/dist/src/adapters/codex/native-policy.js +112 -0
  14. package/dist/src/adapters/codex/native-proof.js +14 -0
  15. package/dist/src/adapters/codex/native-session.js +375 -0
  16. package/dist/src/adapters/codex/plugin.js +26 -0
  17. package/dist/src/adapters/codex/pre-model-gate.js +112 -0
  18. package/dist/src/adapters/codex/prompt-compiler.js +13 -0
  19. package/dist/src/adapters/codex/runtime-options.js +163 -0
  20. package/dist/src/adapters/codex/scratch.js +28 -0
  21. package/dist/src/adapters/codex/stdio-rpc.js +113 -0
  22. package/dist/src/adapters/codex/stream-parser.js +85 -0
  23. package/dist/src/adapters/codex/supported-admission.js +97 -0
  24. package/dist/src/adapters/codex/tool-dispatcher.js +20 -0
  25. package/dist/src/adapters/completion-report.js +64 -0
  26. package/dist/src/adapters/discovery.js +76 -0
  27. package/dist/src/adapters/prompt-compiler.js +60 -0
  28. package/dist/src/adapters/registry.js +10 -5
  29. package/dist/src/cli.js +50 -58
  30. package/dist/src/commands/doctor.js +7 -1
  31. package/dist/src/commands/setup.js +3 -0
  32. package/dist/src/daemon/execution-coordinator.js +91 -19
  33. package/dist/src/daemon/job-poller.js +10 -1
  34. package/dist/src/daemon/runtime.js +55 -41
  35. package/dist/src/gateway-client.js +19 -3
  36. package/dist/src/process/node-process-group-host.js +74 -0
  37. package/dist/src/safety/cli-host-admission.js +74 -0
  38. package/dist/src/safety/cli-security-modes.js +4 -0
  39. package/dist/src/safety/codex-production-host.js +267 -0
  40. package/dist/src/safety/codex-supported-releases.js +1 -0
  41. package/dist/src/safety/hook-cli.js +21 -7
  42. package/dist/src/safety/hook-policy-builder.js +4 -11
  43. package/dist/src/safety/job-security-policy.js +62 -0
  44. package/dist/src/safety/job-security-receipt.js +20 -0
  45. package/dist/src/safety/job-security-session.js +133 -0
  46. package/dist/src/safety/job-tool-dispatcher.js +52 -0
  47. package/dist/src/safety/job-tool-policy.js +61 -0
  48. package/dist/src/safety/job-tool-session.js +82 -0
  49. package/dist/src/safety/local-tool-policy.js +101 -0
  50. package/dist/src/safety/local-tool-server.js +174 -0
  51. package/dist/src/safety/local-tool-worker.js +57 -0
  52. package/dist/src/safety/pre-tool-use-hook.js +3 -39
  53. package/dist/src/safety/registered-command-integrity.js +52 -0
  54. package/dist/src/safety/security-requirements.js +38 -0
  55. package/dist/src/safety/subprocess-sandbox.js +62 -0
  56. package/package.json +1 -1
@@ -2,6 +2,16 @@ import { createHash } from "node:crypto";
2
2
  import { v5 as uuidv5 } from "uuid";
3
3
  import { bindingApprovalIdFromEnv, redact } from "./config.js";
4
4
  import { isTerminalEventType } from "./protocol.js";
5
+ /** Safe diagnostic metadata: never includes arguments, response bodies or credentials. */
6
+ export class GatewayToolError extends Error {
7
+ toolName;
8
+ code;
9
+ constructor(message, toolName, code) {
10
+ super(message);
11
+ this.toolName = toolName;
12
+ this.code = code;
13
+ }
14
+ }
5
15
  function nested(value, path) {
6
16
  return path.split(".").reduce((current, key) => {
7
17
  if (!current || typeof current !== "object")
@@ -113,6 +123,7 @@ export class McpGatewayClient {
113
123
  throw new Error("No token provider or static token configured");
114
124
  }
115
125
  async callTool(name, args) {
126
+ let failureCode = "GATEWAY_TRANSPORT_ERROR";
116
127
  try {
117
128
  const token = await this.resolveToken();
118
129
  const response = await this.fetchFn(this.mcpUrl, {
@@ -122,14 +133,17 @@ export class McpGatewayClient {
122
133
  });
123
134
  const body = (await response.json());
124
135
  if (!response.ok || body.error) {
136
+ failureCode = !response.ok ? `GATEWAY_HTTP_${response.status}` : "GATEWAY_RPC_ERROR";
125
137
  throw new Error(`Gateway MCP ${name} failed with status ${response.status}: ${JSON.stringify(body.error ?? body)}`);
126
138
  }
127
139
  const text = nested(body, "result.content.0.text");
128
- if (nested(body, "result.isError") || typeof text !== "string") {
140
+ if (typeof text !== "string") {
141
+ failureCode = "GATEWAY_INVALID_TOOL_RESULT";
129
142
  throw new Error(`Gateway MCP ${name} returned an invalid tool result`);
130
143
  }
131
144
  const envelope = JSON.parse(text);
132
- if (envelope.status !== "completed") {
145
+ if (envelope.status !== "completed" || nested(body, "result.isError")) {
146
+ failureCode = envelope.status === "failed" ? "GATEWAY_TOOL_REJECTED" : "GATEWAY_INVALID_TOOL_RESULT";
133
147
  throw new Error(`Gateway MCP ${name} did not complete: ${JSON.stringify(envelope)}`);
134
148
  }
135
149
  return nested(envelope, "result.data");
@@ -148,7 +162,7 @@ export class McpGatewayClient {
148
162
  }
149
163
  const leaseToken = this.context?.leaseToken ?? this.initialLeaseToken ?? "";
150
164
  sanitized = redact(sanitized, leaseToken);
151
- throw new Error(redact(sanitized, this.bindingApprovalId ?? ""));
165
+ throw new GatewayToolError(redact(sanitized, this.bindingApprovalId ?? ""), name, failureCode);
152
166
  }
153
167
  }
154
168
  async resolveCompatibility(input) {
@@ -421,6 +435,8 @@ export class McpGatewayClient {
421
435
  const shown = await this.callTool("work_loop_show", { work_loop_id: input.workLoopId });
422
436
  if (shown.loop.environment !== "development")
423
437
  throw new Error("local agent runtime is development-only");
438
+ if (shown.loop.status === "cancelled")
439
+ throw new GatewayToolError("Work Loop is cancelled", "work_loop_show", "WORK_LOOP_CANCELLED");
424
440
  const result = await this.callTool("work_execution_contract_show", {
425
441
  contract_id: requiredString(shown.loop.work_execution_contract_id, "execution contract ID"),
426
442
  });
@@ -0,0 +1,74 @@
1
+ import { spawn } from "node:child_process";
2
+ /** Each invocation owns one new process group; no name-based process sweeping. */
3
+ export class NodeProcessGroupHost {
4
+ spawn(command, args, options) {
5
+ if (process.platform !== "darwin")
6
+ throw new Error("Codex process isolation requires macOS");
7
+ const child = spawn(command, args, { ...options, shell: false, detached: true, stdio: ["pipe", "pipe", "pipe"] });
8
+ let failure;
9
+ let owned = true;
10
+ const kill = (signal = "SIGKILL") => {
11
+ if (!owned || !child.pid)
12
+ return;
13
+ try {
14
+ process.kill(-child.pid, signal);
15
+ }
16
+ catch (error) {
17
+ const code = error.code;
18
+ if (code === "EPERM" && child.exitCode === null && child.signalCode === null) {
19
+ // macOS can expose the root PID after stdout EOF while its process
20
+ // group is already exiting. Signal only this ChildProcess and retry
21
+ // group cleanup from its exit callback after it has been reaped.
22
+ child.kill(signal);
23
+ return;
24
+ }
25
+ if (code !== "ESRCH")
26
+ throw new Error("Provider process group cleanup could not be confirmed");
27
+ }
28
+ if (signal === "SIGKILL")
29
+ owned = false;
30
+ };
31
+ child.on("error", () => { failure = new Error("Provider process could not start"); });
32
+ child.stdin.on("error", () => { failure = new Error("Provider process input closed"); });
33
+ // Provider stderr can contain account details. Consume without publishing it.
34
+ child.stderr.resume();
35
+ child.once("exit", () => {
36
+ try {
37
+ kill();
38
+ }
39
+ catch {
40
+ failure = new Error("Provider process group cleanup could not be confirmed");
41
+ }
42
+ });
43
+ return {
44
+ stdin: { write: (text) => { child.stdin.write(text); }, end: () => { child.stdin.end(); } },
45
+ stdout: { async *[Symbol.asyncIterator]() {
46
+ let pending = Buffer.alloc(0);
47
+ try {
48
+ for await (const chunk of child.stdout) {
49
+ pending = Buffer.concat([pending, Buffer.from(chunk)]);
50
+ let end;
51
+ while ((end = pending.indexOf(10)) !== -1) {
52
+ if (end > 256_000)
53
+ throw new Error("Provider event exceeded the allowed size");
54
+ const line = pending.subarray(0, end).toString("utf8").replace(/\r$/, "");
55
+ pending = pending.subarray(end + 1);
56
+ yield line;
57
+ }
58
+ if (pending.length > 256_000)
59
+ throw new Error("Provider event exceeded the allowed size");
60
+ }
61
+ if (pending.length)
62
+ yield pending.toString("utf8");
63
+ if (failure)
64
+ throw failure;
65
+ }
66
+ finally {
67
+ kill();
68
+ }
69
+ } },
70
+ kill: (signal) => kill((signal ?? "SIGKILL")),
71
+ once: (_event, callback) => { child.once("exit", callback); },
72
+ };
73
+ }
74
+ }
@@ -0,0 +1,74 @@
1
+ import { constants } from 'node:fs';
2
+ import { open, lstat, stat, access } from 'node:fs/promises';
3
+ import { createHash } from 'node:crypto';
4
+ import path from 'node:path';
5
+ // This digest identifies historical offline evidence only. It grants no capability.
6
+ const OFFLINE_OBSERVED_BINARY = '0d5ac7aae20c4eda6242428d5e218c55ba96d8763fda2a271088ff37104b9d28';
7
+ /** A byte measurement, not a spawn certificate. No candidate executable is run.
8
+ * Reject nonregular/symlink/mutable inputs. Check both descriptor and pathname;
9
+ * these checks do not purport to make a later path-based spawn atomic. */
10
+ async function measure(file) {
11
+ const fd = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
12
+ try {
13
+ const before = await fd.stat({ bigint: true });
14
+ if (!before.isFile() || !(before.mode & 73n) || before.size > 512n * 1024n * 1024n)
15
+ throw Error('binary');
16
+ const hash = createHash('sha256');
17
+ let bytes = 0n;
18
+ for await (const chunk of fd.createReadStream({ autoClose: false })) {
19
+ bytes += BigInt(chunk.length);
20
+ if (bytes > 512n * 1024n * 1024n)
21
+ throw Error('binary');
22
+ hash.update(chunk);
23
+ }
24
+ const after = await fd.stat({ bigint: true }), named = await lstat(file, { bigint: true });
25
+ for (const stat of [after, named])
26
+ if (!stat.isFile() || stat.dev !== before.dev || stat.ino !== before.ino
27
+ || stat.size !== before.size || stat.mtimeNs !== before.mtimeNs || stat.ctimeNs !== before.ctimeNs)
28
+ throw Error('binary');
29
+ if (bytes !== before.size)
30
+ throw Error('binary');
31
+ return hash.digest('hex');
32
+ }
33
+ finally {
34
+ await fd.close();
35
+ }
36
+ }
37
+ /** Reports independent prerequisites together, never converting a version/hash,
38
+ * fixture result, environment flag or task-package claim into release admission.
39
+ * No reviewed native policy-binding/outer-isolation verifier exists yet. */
40
+ export async function diagnoseCliHostAdmission(adapterId, binary, env) {
41
+ const checks = [];
42
+ let digest;
43
+ const lookup = (env.PATH ?? '').split(path.delimiter);
44
+ const candidates = path.isAbsolute(binary) ? [binary] : /^[A-Za-z0-9_-]+$/.test(binary) && lookup.every(part => path.isAbsolute(part))
45
+ ? lookup.map(part => path.join(part, binary)) : [];
46
+ for (const candidate of candidates) {
47
+ try {
48
+ await access(candidate, constants.X_OK);
49
+ if (!(await stat(candidate)).isFile())
50
+ continue;
51
+ }
52
+ catch {
53
+ continue;
54
+ }
55
+ // The first executable is what exec lookup selects. Never hash a substitute.
56
+ try {
57
+ digest = await measure(candidate);
58
+ }
59
+ catch { /* Fixed diagnostic only. */ }
60
+ break;
61
+ }
62
+ checks.push(digest ? { id: 'binary_identity', status: 'observed', code: adapterId === 'codex_cli' && digest === OFFLINE_OBSERVED_BINARY ? 'BINARY_OFFLINE_OBSERVED_ONLY' : 'BINARY_NOT_OFFLINE_REVIEWED' }
63
+ : { id: 'binary_identity', status: 'blocked', code: 'BINARY_IDENTITY_UNAVAILABLE' });
64
+ for (const [id, code] of [
65
+ ['policy_binding', 'NATIVE_POLICY_BINDING_UNAVAILABLE'],
66
+ ['native_isolation', 'NATIVE_ISOLATION_UNVERIFIED'],
67
+ ['managed_sources', 'MANAGED_SOURCE_COVERAGE_UNVERIFIED'],
68
+ ['provider_network', 'PROVIDER_NETWORK_BOUNDARY_UNVERIFIED'],
69
+ ['release_admission', 'RELEASE_ADMISSION_UNAVAILABLE'],
70
+ ])
71
+ checks.push({ id, status: 'blocked', code });
72
+ return { status: 'unsupported', available: false, executable: false, capabilities: [],
73
+ ...(digest ? { observedBinarySha256: digest } : {}), checks, reasonCodes: checks.map(check => check.code) };
74
+ }
@@ -0,0 +1,4 @@
1
+ /** Explicitly narrower authority; never presented as native isolation. */
2
+ export const SUPPORTED_CLI_MODE = 'supported_cli_policy_v1';
3
+ export const SUPPORTED_CLI_GATES = Object.freeze(['binary_identity', 'supported_permissions', 'managed_policy_compatibility', 'restricted_mcp', 'rpc_anti_reattachment', 'login_preservation', 'owned_lifecycle', 'gateway_launch_binding']);
4
+ export const SUPPORTED_CLI_RISKS = Object.freeze(['vendor_native_runtime', 'shared_account_authentication', 'extensions_and_hosted_services', 'ipc_network_outside_command_sandbox']);
@@ -0,0 +1,267 @@
1
+ import path from 'node:path';
2
+ import { constants } from 'node:fs';
3
+ import { open, lstat } from 'node:fs/promises';
4
+ import { requireCodexNativeProof } from '../adapters/codex/native-proof.js';
5
+ import { admitSupportedCodexPolicy } from '../adapters/codex/supported-admission.js';
6
+ import { admitJobSecurity } from './job-security-policy.js';
7
+ import { commandRegistryFingerprint } from './job-security-session.js';
8
+ import { fingerprint } from '../daemon/execution-coordinator.js';
9
+ import { requireSecurityRequirements } from './security-requirements.js';
10
+ import { SUPPORTED_CLI_GATES } from './cli-security-modes.js';
11
+ import { verifyCommandIntegrity, measureCommandFile } from './registered-command-integrity.js';
12
+ import { SUPPORTED_CLI_RISKS } from './cli-security-modes.js';
13
+ import { CODEX_SUPPORTED_RELEASES } from './codex-supported-releases.js';
14
+ import { securityRequirementsFingerprint } from './security-requirements.js';
15
+ const reject = () => new Error('CODEX_PRODUCTION_HOST_REJECTED');
16
+ /** Read original file backend only. No CLI, Keychain, HTTP, refresh or persistence.
17
+ * This is local credential availability, not remote entitlement/validity proof. */
18
+ export async function readCodexCredentialStatus(accountRoot) {
19
+ const fd = await open(path.join(accountRoot, 'auth.json'), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
20
+ try {
21
+ const stat = await fd.stat();
22
+ if (!stat.isFile() || stat.size > 1_048_576)
23
+ throw reject();
24
+ const chunks = [];
25
+ let size = 0;
26
+ for await (const chunk of fd.createReadStream({ autoClose: false })) {
27
+ size += chunk.length;
28
+ if (size > 1_048_576)
29
+ throw reject();
30
+ chunks.push(chunk);
31
+ }
32
+ const raw = Buffer.concat(chunks).toString('utf8');
33
+ const a = JSON.parse(raw), token = a?.tokens?.access_token;
34
+ if (a.auth_mode !== 'chatgpt' || typeof token !== 'string' || token.length > 32768)
35
+ throw reject();
36
+ const parts = token.split('.');
37
+ if (parts.length !== 3)
38
+ throw reject();
39
+ const exp = JSON.parse(Buffer.from(parts[1], 'base64url').toString()).exp;
40
+ if (!Number.isSafeInteger(exp) || exp <= 0)
41
+ throw reject();
42
+ return { backend: 'file', available: true, expiresAt: exp * 1000 };
43
+ }
44
+ catch {
45
+ throw reject();
46
+ }
47
+ finally {
48
+ await fd.close();
49
+ }
50
+ }
51
+ export const codexReleaseFingerprint = (release) => securityRequirementsFingerprint(release);
52
+ function registeredCommandValid(c) {
53
+ try {
54
+ const fields = ['id', 'executable', 'argv', 'integrity', ...(c.readPaths === undefined ? [] : ['readPaths'])];
55
+ if (JSON.stringify(Object.keys(c).sort()) !== JSON.stringify(fields.sort()) || typeof c.id !== 'string' || !c.id || c.id.length > 256 || /[\r\n\0]/.test(c.id)
56
+ || typeof c.executable !== 'string' || !path.isAbsolute(c.executable) || !Array.isArray(c.argv) || c.argv.some(a => typeof a !== 'string' || /[\r\n\0]/.test(a)))
57
+ return false;
58
+ const paths = c.readPaths ?? [], integrity = c.integrity;
59
+ return Array.isArray(paths) && new Set(paths).size === paths.length && paths.every(p => typeof p === 'string' && !!p && !path.isAbsolute(p) && !p.split(/[\\/]/).some(s => !s || s === '.' || s === '..') && !/[\0*?\[\]{}]/.test(p))
60
+ && !!integrity && JSON.stringify(Object.keys(integrity).sort()) === JSON.stringify(['executableSha256', 'readFiles'])
61
+ && /^[a-f0-9]{64}$/.test(integrity.executableSha256) && !!integrity.readFiles && !Array.isArray(integrity.readFiles)
62
+ && JSON.stringify(Object.keys(integrity.readFiles).sort()) === JSON.stringify([...paths].sort())
63
+ && Object.values(integrity.readFiles).every(s => typeof s === 'string' && /^[a-f0-9]{64}$/.test(s));
64
+ }
65
+ catch {
66
+ return false;
67
+ }
68
+ }
69
+ function reviewed(value) {
70
+ try {
71
+ return JSON.stringify(Object.keys(value).sort()) === JSON.stringify(['version', 'adapterVersion', 'executableVersion', 'binarySha256', 'modelIds', 'decisionId', 'acceptedResidualRisks', 'commands'].sort())
72
+ && value.version === 'codex-supported-release-v1' && value.adapterVersion === '1' && /^[a-f0-9]{64}$/.test(value.binarySha256)
73
+ && typeof value.executableVersion === 'string' && !!value.executableVersion && !/[\r\n\0]/.test(value.executableVersion)
74
+ && /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(value.decisionId)
75
+ && Array.isArray(value.modelIds) && value.modelIds.length > 0 && new Set(value.modelIds).size === value.modelIds.length
76
+ && value.modelIds.every(v => typeof v === 'string' && v.length > 0 && v.length < 256 && !/[\r\n\0]/.test(v))
77
+ && JSON.stringify(value.acceptedResidualRisks) === JSON.stringify(SUPPORTED_CLI_RISKS) && Array.isArray(value.commands) && new Set(value.commands.map(c => c.id)).size === value.commands.length && value.commands.every(registeredCommandValid);
78
+ }
79
+ catch {
80
+ return false;
81
+ }
82
+ }
83
+ const capabilities = ['repository_read', 'repository_edit', 'shell_test', 'git_diff'];
84
+ export function createCodexProductionHost(input) {
85
+ input = { ...input };
86
+ const releases = structuredClone(input.releases ?? CODEX_SUPPORTED_RELEASES);
87
+ const claims = new Map();
88
+ const scopes = new Map();
89
+ const account = path.resolve(input.codexHome ?? path.join(input.home, '.codex'));
90
+ async function inspect() {
91
+ if (!path.isAbsolute(input.home) || !input.runnerId || !releases.length)
92
+ throw reject();
93
+ const paths = path.isAbsolute(input.binary) ? [input.binary] : input.binary === 'codex' && input.searchPath?.split(path.delimiter).every(p => path.isAbsolute(p)) ? input.searchPath.split(path.delimiter).map(p => path.join(p, 'codex')) : [];
94
+ for (const binary of paths) {
95
+ let stat;
96
+ try {
97
+ stat = await lstat(binary);
98
+ }
99
+ catch (e) {
100
+ if (e.code === 'ENOENT')
101
+ continue;
102
+ throw reject();
103
+ }
104
+ // Preserve PATH precedence: never skip a present unreviewed first executable.
105
+ if (!stat.isFile() || !(stat.mode & 0o111) || (stat.mode & 0o022))
106
+ throw reject();
107
+ const sha = await measureCommandFile(binary), matches = releases.filter(r => reviewed(r) && r.binarySha256 === sha);
108
+ if (matches.length !== 1)
109
+ throw reject();
110
+ const credential = await (input.readCredentialStatus ?? readCodexCredentialStatus)(account);
111
+ if (credential.backend !== 'file' || !credential.available || !Number.isFinite(credential.expiresAt) || credential.expiresAt <= Date.now())
112
+ throw reject();
113
+ return { binary, release: matches[0] };
114
+ }
115
+ throw reject();
116
+ }
117
+ return {
118
+ async probe() {
119
+ try {
120
+ const { release } = await inspect();
121
+ return { available: true, authenticated: true, executableVersion: release.executableVersion, capabilities: [...capabilities] };
122
+ }
123
+ catch {
124
+ return { available: false, authenticated: false, capabilities: [], reason: 'CODEX_REVIEWED_RELEASE_OR_LOCAL_CREDENTIALS_UNAVAILABLE' };
125
+ }
126
+ },
127
+ registerClaim(value) {
128
+ const claim = structuredClone(value), job = claim.job, pkg = claim.task_package, context = claim.security_context;
129
+ if (claim.status !== 'claimed' || !job || !pkg || !context || context.runner_id !== input.runnerId
130
+ || typeof context.actor_id !== 'string' || !context.actor_id || /[\r\n\0]/.test(context.actor_id)
131
+ || !job.id || !Number.isSafeInteger(job.generation) || job.generation < 1 || !['claimed', 'shadowing'].includes(job.status)
132
+ || !/^[a-f0-9]{64}$/.test(job.launch_fingerprint) || !/^[a-f0-9]{64}$/.test(claim.contract_fingerprint ?? '')
133
+ || pkg.contract_fingerprint !== claim.contract_fingerprint || fingerprint(pkg) !== claim.task_package_fingerprint
134
+ || pkg.runtime_adapter !== 'codex_cli' || pkg.runtime_adapter_version !== '1' || pkg.resolved_provider !== 'openai'
135
+ || claims.size > 0 || scopes.size > 0)
136
+ throw reject();
137
+ requireSecurityRequirements(pkg.security_requirements, { adapterId: 'codex_cli', adapterVersion: '1' });
138
+ claims.set(job.id, claim);
139
+ },
140
+ job(id, sourceRoot) {
141
+ const claim = claims.get(id);
142
+ if (!claim || !path.isAbsolute(sourceRoot) || scopes.has(id))
143
+ throw reject();
144
+ const pkg = claim.task_package, job = claim.job, context = claim.security_context;
145
+ let prepared = false, revoked = false, used = false;
146
+ let selected;
147
+ let policy, supported, native;
148
+ let request;
149
+ const revoke = () => { revoked = true; policy?.revoke(); supported?.policy.revoke(); native?.close(); };
150
+ const assertPrepared = async () => {
151
+ if (revoked || !prepared || !selected || !request)
152
+ throw reject();
153
+ const current = await inspect();
154
+ if (revoked || current.binary !== selected.binary || codexReleaseFingerprint(current.release) !== codexReleaseFingerprint(selected.release))
155
+ throw reject();
156
+ for (const c of selected.release.commands) {
157
+ const paths = c.readPaths ?? [];
158
+ if (paths.some(p => path.isAbsolute(p) || p.split(/[\\/]/).includes('..')))
159
+ throw reject();
160
+ await verifyCommandIntegrity(c.integrity, c.executable, paths, paths.map(p => path.join(sourceRoot, p)));
161
+ }
162
+ if (revoked)
163
+ throw reject();
164
+ };
165
+ const scope = {
166
+ async prepare(value) {
167
+ if (prepared || revoked)
168
+ throw reject();
169
+ prepared = true;
170
+ try {
171
+ const r = Object.freeze(structuredClone(value));
172
+ selected = await inspect();
173
+ const requirements = requireSecurityRequirements(r.requirements, { adapterId: 'codex_cli', adapterVersion: '1' });
174
+ if (revoked || requirements.security_mode !== 'supported_cli_policy_v1'
175
+ || requirements.release_manifest_sha256 !== codexReleaseFingerprint(selected.release)
176
+ || r.adapterId !== 'codex_cli' || r.adapterVersion !== '1' || r.jobId !== job.id || r.generation !== job.generation
177
+ || r.modelId !== pkg.resolved_model_id || !selected.release.modelIds.includes(r.modelId)
178
+ || r.sourceSha !== pkg.repository?.base_commit_sha
179
+ || fingerprint(r.requirements) !== fingerprint(pkg.security_requirements)
180
+ || r.launchFingerprint !== job.launch_fingerprint || r.contractFingerprint !== claim.contract_fingerprint
181
+ || r.taskPackageFingerprint !== claim.task_package_fingerprint)
182
+ throw reject();
183
+ request = r;
184
+ await assertPrepared();
185
+ }
186
+ catch {
187
+ revoke();
188
+ throw reject();
189
+ }
190
+ },
191
+ async assertPrepared() { try {
192
+ await assertPrepared();
193
+ }
194
+ catch {
195
+ revoke();
196
+ throw reject();
197
+ } },
198
+ commands() { if (revoked || !selected || !request)
199
+ throw reject(); return structuredClone(selected.release.commands); },
200
+ binaryPath() { if (revoked || !selected || !request)
201
+ throw reject(); return selected.binary; },
202
+ policy: () => policy,
203
+ binding() {
204
+ try {
205
+ if (revoked || !native || !policy)
206
+ return null;
207
+ const b = native.binding();
208
+ return b ? { jobId: job.id, generation: job.generation, ...b } : null;
209
+ }
210
+ catch {
211
+ return null;
212
+ }
213
+ },
214
+ async admit(proof) {
215
+ if (used || revoked)
216
+ throw reject();
217
+ used = true;
218
+ try {
219
+ await assertPrepared();
220
+ const n = requireCodexNativeProof(proof), r = request, release = selected.release;
221
+ if (n.binaryPath !== selected.binary || n.sourceRoot !== sourceRoot || n.home !== input.home || n.codexHome !== account
222
+ || n.expected.model !== r.modelId || n.expected.provider !== 'openai')
223
+ throw reject();
224
+ const pin = { adapterId: r.adapterId, adapterVersion: r.adapterVersion, jobId: r.jobId, generation: r.generation, modelId: r.modelId, sourceSha: r.sourceSha,
225
+ securityMode: 'supported_cli_policy_v1', requirementsSha256: r.requirements.fingerprint,
226
+ binarySha256: release.binarySha256, configSha256: n.expected.configFingerprint, managedPolicySha256: n.managedPolicySha256,
227
+ toolsSha256: n.expected.toolSchemaFingerprint, commandsSha256: commandRegistryFingerprint(release.commands),
228
+ osProfileSha256: fingerprint({ mode: 'supported_cli_policy_v1', config: n.expected.configFingerprint, sourceRoot }) };
229
+ const binding = { jobId: r.jobId, generation: r.generation, actorId: context.actor_id, runnerId: context.runner_id,
230
+ repositoryKey: String(pkg.repository.key), sourceSha: r.sourceSha,
231
+ launchFingerprint: r.launchFingerprint, contractFingerprint: r.contractFingerprint, taskPackageFingerprint: r.taskPackageFingerprint,
232
+ binarySha256: release.binarySha256, requirementsFingerprint: pin.requirementsSha256, managedPolicyFingerprint: n.managedPolicySha256,
233
+ commandsFingerprint: pin.commandsSha256, decisionId: release.decisionId };
234
+ // All checks originate in this host and the retained transport validators.
235
+ // No caller-supplied passed/check array is consumed.
236
+ policy = await admitJobSecurity(pin, async (frozen) => {
237
+ requireCodexNativeProof(proof);
238
+ if (revoked)
239
+ throw reject();
240
+ return { version: 2, pin: frozen, checks: SUPPORTED_CLI_GATES.map(gate => ({ gate, status: 'passed' })) };
241
+ });
242
+ supported = { binding, policy: await admitSupportedCodexPolicy({ ...n.expected, ...binding, mode: 'supported_cli_policy_v1' }, async (frozen) => {
243
+ requireCodexNativeProof(proof);
244
+ if (revoked)
245
+ throw reject();
246
+ return { version: 'codex-supported-policy-evidence-v1', mode: 'supported_cli_policy_v1', pin: frozen, acceptedResidualRisks: release.acceptedResidualRisks, checks: SUPPORTED_CLI_GATES.map(gate => ({ gate, status: 'passed' })) };
247
+ }) };
248
+ requireCodexNativeProof(proof);
249
+ if (revoked)
250
+ throw reject();
251
+ native = n;
252
+ return supported;
253
+ }
254
+ catch {
255
+ revoke();
256
+ throw reject();
257
+ }
258
+ }, revoke,
259
+ };
260
+ scopes.set(id, scope);
261
+ return scope;
262
+ },
263
+ close() { for (const scope of scopes.values())
264
+ scope.revoke(); scopes.clear(); claims.clear(); },
265
+ releaseJob(id) { scopes.get(id)?.revoke(); scopes.delete(id); claims.delete(id); },
266
+ };
267
+ }
@@ -0,0 +1 @@
1
+ export const CODEX_SUPPORTED_RELEASES = Object.freeze([]);
@@ -1,5 +1,6 @@
1
1
  import { evaluateHookEvent } from "./pre-tool-use-hook.js";
2
2
  import path from "node:path";
3
+ export const MAX_HOOK_INPUT_BYTES = 262144;
3
4
  export const HOOK_POLICY_ENV_VAR = "AI_GATEWAY_RUNNER_HOOK_POLICY_B64";
4
5
  function denyMalformed() {
5
6
  return {
@@ -23,7 +24,7 @@ function isStringArray(value) {
23
24
  return Array.isArray(value) && value.every((item) => typeof item === "string");
24
25
  }
25
26
  export function parsePolicy(encoded) {
26
- if (!encoded)
27
+ if (!encoded || encoded.length > MAX_HOOK_INPUT_BYTES)
27
28
  return null;
28
29
  try {
29
30
  if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(encoded)) {
@@ -55,9 +56,11 @@ export function parsePolicy(encoded) {
55
56
  }
56
57
  }
57
58
  export function parseHookInput(raw) {
59
+ if (Buffer.byteLength(raw, "utf8") > MAX_HOOK_INPUT_BYTES)
60
+ return null;
58
61
  try {
59
62
  const parsed = JSON.parse(raw);
60
- if (typeof parsed !== "object" || parsed === null)
63
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
61
64
  return null;
62
65
  return parsed;
63
66
  }
@@ -75,15 +78,26 @@ export function decideFromRaw(rawInput, policyEnvValue) {
75
78
  return evaluateHookEvent(input, policy);
76
79
  }
77
80
  export async function runHookCli(readStdin, env, writeStdout) {
78
- const rawInput = await readStdin();
79
- const decision = decideFromRaw(rawInput, env[HOOK_POLICY_ENV_VAR]);
80
- writeStdout(JSON.stringify(decision));
81
- return 0;
81
+ try {
82
+ const rawInput = await readStdin();
83
+ const decision = decideFromRaw(rawInput, env[HOOK_POLICY_ENV_VAR]);
84
+ writeStdout(JSON.stringify(decision));
85
+ return 0;
86
+ }
87
+ catch {
88
+ writeStdout(JSON.stringify(denyMalformed()));
89
+ return 2;
90
+ }
82
91
  }
83
92
  async function readAllStdin() {
84
93
  const chunks = [];
94
+ let size = 0;
85
95
  for await (const chunk of process.stdin) {
86
- chunks.push(Buffer.from(chunk));
96
+ const bytes = Buffer.from(chunk);
97
+ size += bytes.length;
98
+ if (size > MAX_HOOK_INPUT_BYTES)
99
+ throw new Error("HOOK_INPUT_TOO_LARGE");
100
+ chunks.push(bytes);
87
101
  }
88
102
  return Buffer.concat(chunks).toString("utf8");
89
103
  }
@@ -1,3 +1,4 @@
1
+ import { CLAUDE_LOCAL_MCP_TOOLS } from "./pre-tool-use-hook.js";
1
2
  const DEFAULT_BLOCKED_PATHS = [".git", ".env", ".aws", ".ssh"];
2
3
  function requiredTestsFromTaskPackage(taskPackage) {
3
4
  const pkg = taskPackage;
@@ -9,21 +10,13 @@ function requiredTestsFromTaskPackage(taskPackage) {
9
10
  }
10
11
  return requiredTests.map((item) => String(item));
11
12
  }
12
- function allowedGatewayToolsFromTaskPackage(taskPackage) {
13
- const pkg = taskPackage;
14
- const contract = pkg.contract;
15
- const policy = contract?.policy;
16
- const allowed = policy?.allowed_gateway_tools;
17
- return new Set(Array.isArray(allowed) ? allowed.filter((tool) => typeof tool === "string") : []);
18
- }
19
13
  /**
20
- * The hook policy is derived only from the canonical task package and the
21
- * runner's own generated paths — never from anything Claude writes.
14
+ * Only the four job-local MCP names may pass the hook. Source and command
15
+ * authorization is enforced by the scoped worker, never by native Claude tools.
22
16
  */
23
17
  export function buildHookPolicy(options) {
24
18
  const requiredCommands = requiredTestsFromTaskPackage(options.taskPackage);
25
- const allowedGatewayTools = allowedGatewayToolsFromTaskPackage(options.taskPackage);
26
- const allowedMcpTools = [...new Set(options.registeredReadTools.filter((tool) => allowedGatewayTools.has(tool)))];
19
+ const allowedMcpTools = [...CLAUDE_LOCAL_MCP_TOOLS];
27
20
  const blockedPaths = [...new Set([...DEFAULT_BLOCKED_PATHS, ...(options.additionalBlockedPaths ?? []), ...options.generatedPaths])];
28
21
  return {
29
22
  requiredCommands,
@@ -0,0 +1,62 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { SUPPORTED_CLI_MODE, SUPPORTED_CLI_GATES } from './cli-security-modes.js';
3
+ /** Mandatory for every CLI. New adapter IDs do not alter this gate set. */
4
+ export const JOB_SECURITY_GATES = Object.freeze(['binary_binding', 'native_tools', 'managed_policy', 'credential_boundary', 'source_command_scope', 'request_binding', 'provider_egress', 'owned_lifecycle']);
5
+ const issued = new WeakMap();
6
+ const rejected = () => new Error('JOB_SECURITY_REJECTED');
7
+ const keys = ['adapterId', 'adapterVersion', 'jobId', 'generation', 'modelId', 'sourceSha', 'binarySha256', 'configSha256', 'managedPolicySha256', 'toolsSha256', 'commandsSha256', 'osProfileSha256', 'requirementsSha256'];
8
+ const object = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
9
+ const exact = (v, names) => JSON.stringify(Object.keys(v).sort()) === JSON.stringify([...names].sort());
10
+ function validPin(value) {
11
+ if (!object(value) || !exact(value, value.securityMode === undefined ? keys : [...keys, 'securityMode'])
12
+ || value.securityMode !== undefined && (value.securityMode !== SUPPORTED_CLI_MODE || value.adapterId !== 'codex_cli') || !Number.isSafeInteger(value.generation) || Number(value.generation) < 1)
13
+ return false;
14
+ for (const name of keys.filter(k => k !== 'generation')) {
15
+ const v = value[name];
16
+ if (typeof v !== 'string' || !v || v.length > 256 || /[\r\n\0]/.test(v))
17
+ return false;
18
+ }
19
+ if (!/^[a-f0-9]{40}$/.test(String(value.sourceSha)))
20
+ return false;
21
+ return keys.filter(k => k.endsWith('Sha256')).every(k => /^[a-f0-9]{64}$/.test(String(value[k])));
22
+ }
23
+ export const jobSecurityPinKeys = (pin) => pin.securityMode ? [...keys, 'securityMode'] : [...keys];
24
+ const encoded = (pin) => JSON.stringify(Object.fromEntries(jobSecurityPinKeys(pin).map(key => [key, pin[key]])));
25
+ export async function admitJobSecurity(expected, verify) {
26
+ try {
27
+ if (!validPin(expected))
28
+ throw rejected();
29
+ const pin = Object.freeze(structuredClone(expected));
30
+ const receipt = await verify(pin);
31
+ const gates = pin.securityMode === SUPPORTED_CLI_MODE ? SUPPORTED_CLI_GATES : JOB_SECURITY_GATES;
32
+ if (!object(receipt) || !exact(receipt, ['version', 'pin', 'checks']) || receipt.version !== (pin.securityMode ? 2 : 1)
33
+ || !validPin(receipt.pin) || encoded(receipt.pin) !== encoded(pin) || !Array.isArray(receipt.checks)
34
+ || receipt.checks.length !== gates.length)
35
+ throw rejected();
36
+ const seen = new Set();
37
+ for (const check of receipt.checks) {
38
+ if (!object(check) || !exact(check, ['gate', 'status']) || check.status !== 'passed' || typeof check.gate !== 'string'
39
+ || !gates.includes(check.gate) || seen.has(check.gate))
40
+ throw rejected();
41
+ seen.add(check.gate);
42
+ }
43
+ let revoked = false;
44
+ const policy = Object.freeze({ pin, fingerprint: createHash('sha256').update(encoded(pin)).digest('hex'), revoke: () => { revoked = true; } });
45
+ issued.set(policy, () => !revoked);
46
+ return policy;
47
+ }
48
+ catch {
49
+ throw rejected();
50
+ }
51
+ }
52
+ export function requireJobSecurity(value, expected) {
53
+ if (!object(value) || !issued.get(value)?.())
54
+ throw rejected();
55
+ const pin = value.pin;
56
+ for (const key of ['adapterId', 'adapterVersion', 'jobId', 'modelId'])
57
+ if (pin[key] !== expected[key])
58
+ throw rejected();
59
+ for (const key of ['sourceSha', 'generation'])
60
+ if (expected[key] !== undefined && pin[key] !== expected[key])
61
+ throw rejected();
62
+ }