mtech-ai-local-runner 0.1.9 → 0.1.12

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 (44) hide show
  1. package/README.md +23 -0
  2. package/dist/src/adapters/claude-code/adapter.js +10 -27
  3. package/dist/src/adapters/claude-code/runtime-options.js +1 -1
  4. package/dist/src/adapters/codex/account-sources.js +113 -0
  5. package/dist/src/adapters/codex/adapter.js +60 -4
  6. package/dist/src/adapters/codex/config-builder.js +3 -0
  7. package/dist/src/adapters/codex/exec-invocation.js +62 -0
  8. package/dist/src/adapters/codex/exec-launch-host.js +215 -0
  9. package/dist/src/adapters/codex/exec-observer.js +83 -0
  10. package/dist/src/adapters/codex/exec-preflight.js +41 -0
  11. package/dist/src/adapters/codex/exec-release-policy.js +44 -0
  12. package/dist/src/adapters/codex/exec-runtime-options.js +122 -0
  13. package/dist/src/adapters/codex/exec-tool-gate.js +81 -0
  14. package/dist/src/adapters/codex/global-instructions.js +32 -0
  15. package/dist/src/adapters/codex/manual-policy-scope.js +67 -0
  16. package/dist/src/adapters/codex/native-policy.js +20 -4
  17. package/dist/src/adapters/codex/native-session.js +4 -23
  18. package/dist/src/adapters/codex/unmanaged-scope.js +85 -0
  19. package/dist/src/auth/token-provider.js +2 -0
  20. package/dist/src/cli.js +54 -6
  21. package/dist/src/config.js +9 -2
  22. package/dist/src/daemon/execution-coordinator.js +25 -4
  23. package/dist/src/daemon/manual-codex.js +83 -0
  24. package/dist/src/daemon/runtime.js +17 -9
  25. package/dist/src/gateway-client.js +3 -2
  26. package/dist/src/safety/codex-exec-production-host.js +249 -0
  27. package/dist/src/safety/codex-exec-releases.js +3 -0
  28. package/dist/src/safety/codex-manual-controller.js +84 -0
  29. package/dist/src/safety/codex-manual-profile-query.js +30 -0
  30. package/dist/src/safety/codex-manual-registered.js +30 -0
  31. package/dist/src/safety/codex-native-observation.js +68 -0
  32. package/dist/src/safety/codex-native-process.js +59 -0
  33. package/dist/src/safety/codex-production-host.js +1 -1
  34. package/dist/src/safety/codex-reviewed-manual-release.js +37 -0
  35. package/dist/src/safety/codex-runtime-host.js +36 -0
  36. package/dist/src/safety/execution-failure.js +32 -0
  37. package/dist/src/safety/hook-cli.js +18 -2
  38. package/dist/src/safety/hook-command.js +156 -0
  39. package/dist/src/safety/hook-policy-builder.js +2 -0
  40. package/dist/src/safety/local-tool-server.js +41 -4
  41. package/dist/src/safety/security-requirements.js +11 -0
  42. package/dist/src/safety/stable-file-read.js +33 -0
  43. package/native/bin/codex-observe-darwin-arm64 +0 -0
  44. package/package.json +5 -3
package/README.md CHANGED
@@ -10,6 +10,13 @@ The package stores safe runner configuration locally and keeps OAuth refresh cre
10
10
 
11
11
  Public npm installation requires no npm account or publishing OTP. Setup requires Gateway OAuth login, and local Claude Code must already be authenticated (`claude auth status`).
12
12
 
13
+ ## 0.1.10: enforced PreToolUse hook
14
+
15
+ Earlier releases configured a hook command that the shell could not run, so Claude
16
+ Code ran tool calls without the hook policy. This release runs the hook through
17
+ Node. Unless the packaged hook denies a `git push origin HEAD` probe, `setup` and
18
+ the daemon stop and `doctor` reports `hook_not_enforcing`.
19
+
13
20
  ## 0.1.9: gated Codex foundation and shared CLI security
14
21
 
15
22
  This package includes the production Codex host/probe and common authenticated
@@ -117,3 +124,19 @@ These preparation controls end at binding and do not impose time, turn, token
117
124
  or cost termination limits on an approved unbounded employee-local Claude run.
118
125
  Existing generation fencing, scope restrictions, cancellation and audit remain
119
126
  in force.
127
+
128
+ ### Post-binding execution diagnostics (0.1.12)
129
+
130
+ The manual command and daemon log an `execution_failed` diagnostic (the manual
131
+ command uses `manual_execution_failed`) before attempting Gateway failure
132
+ reporting. Failed events and completion results carry `execution_failure` with
133
+ a fixed `stage` and `code`. Inner host-issued diagnostics survive outer catches;
134
+ unknown errors use a generic code. Credential values, raw exceptions, causes,
135
+ stacks, filesystem paths and provider stderr are not included. These diagnostics
136
+ do not prove whether provider inference occurred. A Gateway reporting outage
137
+ retains the local diagnostic and does not turn an unacknowledged terminal event
138
+ into a completed job. Existing reconciliation rules still apply.
139
+
140
+ ### Manual Codex pilot (0.1.11)
141
+
142
+ The reviewed manual Codex path preserves the existing CLI and ChatGPT login. It is separate from unattended daemon readiness. After the exact CLI/native reader and Gateway release are installed, an employee-authorized foreground pilot uses `mtec-ai-local-runner manual-codex --job-id=<approved Job UUID>`. It refuses a running daemon, unresolved recovery state, unknown release/account or a different Job. It does not stop services or alter personal settings. A direct administrator policy inventory occurs just before the approved execution; historical inventory is not continuous MDM monitoring. The Gateway OAuth client refuses refresh in this manual command. Actual vendor account startup may still refresh shared credentials and requires disclosure in the exact launch approval.
@@ -1,20 +1,16 @@
1
1
  import path from "node:path";
2
2
  import { requireSecurityRequirements } from '../../safety/security-requirements.js';
3
3
  import { createJobSecurityReceipt } from '../../safety/job-security-receipt.js';
4
- import { existsSync } from "node:fs";
5
4
  import { isTerminalEventType } from "../../protocol.js";
6
5
  import { parseClaudeLine } from "./stream-parser.js";
7
6
  import { compilePrompt } from "./prompt-compiler.js";
8
7
  import { completionEvent } from "./completion-report.js";
9
8
  import { requireJobSecurity } from "../../safety/job-security-policy.js";
10
- import { buildHookPolicy, encodeHookPolicy } from "../../safety/hook-policy-builder.js";
11
- import { HOOK_POLICY_ENV_VAR } from "../../safety/hook-cli.js";
9
+ import { HOOK_POLICY_ENV_VAR, buildHookPolicy, encodeHookPolicy } from "../../safety/hook-policy-builder.js";
10
+ import { formatHookCommand } from "../../safety/hook-command.js";
12
11
  const ALLOWED_ENV_KEYS = ["PATH", "HOME", "USER", "LOGNAME", "LANG", "LC_ALL", "TMPDIR", "NODE_ENV", "CLAUDE_CONFIG_DIR"];
13
12
  const ALLOWED_SUPERVISOR_COMMANDS = new Set(["continue", "answer_question", "request_revision", "request_checkpoint", "submit_for_review"]);
14
13
  const LOCAL_NAMES = ["local_read", "local_search", "local_edit", "local_test"].map(name => `mcp__ai_local__${name}`);
15
- function defaultPathExists(p) {
16
- return existsSync(p);
17
- }
18
14
  function allowlistedEnv(env) {
19
15
  const result = {};
20
16
  for (const key of ALLOWED_ENV_KEYS) {
@@ -92,7 +88,7 @@ export class ClaudeCodeAdapter {
92
88
  if (!ctx.sessionId) {
93
89
  throw new Error("refusing to start: no runner-generated session ID was supplied");
94
90
  }
95
- const hookPath = this.requireTrustedHookPath();
91
+ const hookCommand = this.requireTrustedHookCommand();
96
92
  const prompt = compilePrompt(ctx.taskPackage, { workItemLabel: this.options.workItemLabel });
97
93
  try {
98
94
  if (!this.options.createTools || !this.options.writeLocalMcpConfig)
@@ -115,7 +111,7 @@ export class ClaudeCodeAdapter {
115
111
  });
116
112
  const settings = {
117
113
  hooks: {
118
- PreToolUse: [{ matcher: "*", hooks: [{ type: "command", command: hookPath, onFailure: "block" }] }],
114
+ PreToolUse: [{ matcher: "*", hooks: [{ type: "command", command: hookCommand }] }],
119
115
  },
120
116
  };
121
117
  const args = [
@@ -247,25 +243,12 @@ export class ClaudeCodeAdapter {
247
243
  }
248
244
  }
249
245
  }
250
- requireTrustedHookPath() {
251
- const hookPath = this.options.hookExecutablePath;
252
- if (!hookPath || !path.isAbsolute(hookPath)) {
253
- throw new Error("refusing to start: PreToolUse hook executable path must be an absolute path");
254
- }
255
- if (!/^[A-Za-z0-9._/:\\-]+$/.test(hookPath)) {
256
- throw new Error("refusing to start: PreToolUse hook must use a safe executable path without shell metacharacters");
257
- }
258
- const worktreeRoot = path.resolve(this.options.worktreeRoot);
259
- const rel = path.relative(worktreeRoot, path.resolve(hookPath));
260
- const isInsideWorktree = rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
261
- if (isInsideWorktree) {
262
- throw new Error("refusing to start: PreToolUse hook executable must live outside the assigned worktree");
263
- }
264
- const exists = this.options.hookExecutableExists ?? defaultPathExists;
265
- if (!exists(hookPath)) {
266
- throw new Error("refusing to start: PreToolUse hook executable was not found at the trusted path");
267
- }
268
- return hookPath;
246
+ /** Claude Code treats a hook it cannot run as no objection, so the hook runs through Node. */
247
+ requireTrustedHookCommand() {
248
+ return formatHookCommand({
249
+ nodeExecutablePath: this.options.hookNodeExecutablePath ?? process.execPath,
250
+ hookScriptPath: this.options.hookExecutablePath,
251
+ }, { worktreeRoot: this.options.worktreeRoot, exists: this.options.hookExecutableExists });
269
252
  }
270
253
  requireHandle() {
271
254
  if (!this.handle) {
@@ -27,7 +27,7 @@ export function createClaudeCodeAdapterOptions(input) {
27
27
  throw new Error('Claude account configuration path must be absolute');
28
28
  return {
29
29
  processHost: new NodeProcessGroupHost(), worktreeRoot: checkout, jobId: input.jobId, securityPolicy: input.securityPolicy,
30
- cwd, env, binaryPath: input.binaryPath, hookExecutablePath: input.hookExecutablePath,
30
+ cwd, env, binaryPath: input.binaryPath, hookExecutablePath: input.hookExecutablePath, hookNodeExecutablePath: input.hookNodeExecutablePath,
31
31
  maxBudgetUsd: input.maxBudgetUsd ?? 1, workItemLabel: input.workItemLabel ?? input.jobId,
32
32
  registeredReadTools: [], writeMcpConfig: () => { throw new Error('Gateway relay is not a model tool endpoint'); },
33
33
  createTools: async (ctx, pkg, approved) => {
@@ -0,0 +1,113 @@
1
+ import path from 'node:path';
2
+ import { constants } from 'node:fs';
3
+ import { lstat, open, readdir, realpath } from 'node:fs/promises';
4
+ import { createHash } from 'node:crypto';
5
+ import { parse } from 'smol-toml';
6
+ function denied() { throw new Error('CODEX_ACCOUNT_SOURCE_REJECTED'); }
7
+ const digest = (value) => createHash('sha256').update(value).digest('hex');
8
+ async function stat(file) { try {
9
+ return await lstat(file);
10
+ }
11
+ catch (e) {
12
+ if (e.code === 'ENOENT')
13
+ return null;
14
+ throw e;
15
+ } }
16
+ async function file(file) {
17
+ const s = await stat(file);
18
+ if (!s)
19
+ return null;
20
+ if (!s.isFile() || s.isSymbolicLink() || s.nlink !== 1 || s.size > 65_536)
21
+ denied();
22
+ const fd = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
23
+ try {
24
+ const actual = await fd.stat();
25
+ if (!actual.isFile() || actual.nlink !== 1 || actual.dev !== s.dev || actual.ino !== s.ino || actual.size > 65_536)
26
+ denied();
27
+ const bytes = Buffer.alloc(65_537);
28
+ let total = 0;
29
+ while (total < bytes.length) {
30
+ const { bytesRead } = await fd.read(bytes, total, bytes.length - total, total);
31
+ if (!bytesRead)
32
+ break;
33
+ total += bytesRead;
34
+ }
35
+ const after = await fd.stat(), named = await stat(file);
36
+ if (!named || !named.isFile() || named.isSymbolicLink() || named.nlink !== 1 || named.dev !== actual.dev || named.ino !== actual.ino
37
+ || after.nlink !== 1 || total > 65_536 || total !== actual.size || after.size !== actual.size || after.mtimeMs !== actual.mtimeMs || after.ctimeMs !== actual.ctimeMs)
38
+ denied();
39
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, total));
40
+ }
41
+ finally {
42
+ await fd.close();
43
+ }
44
+ }
45
+ function preferences(text) {
46
+ const v = text === null ? {} : parse(text), result = {};
47
+ for (const [key, value] of Object.entries(v)) {
48
+ // Every accepted leaf is replaced by the trusted generated -c overrides.
49
+ // Tables (hooks/features/MCP/providers/profiles), unknown fields and backend
50
+ // selection are deliberately not ambient inputs, even when disabled/empty.
51
+ if (key === 'project_doc_max_bytes') {
52
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0)
53
+ denied();
54
+ result[key] = value;
55
+ continue;
56
+ }
57
+ if (!['model', 'model_provider', 'approval_policy', 'default_permissions', 'forced_login_method', 'web_search'].includes(key)
58
+ || typeof value !== 'string' || !value || value.length > 256 || /[\r\n\0]/.test(value))
59
+ denied();
60
+ if (key === 'model_provider' && value !== 'openai' || key === 'forced_login_method' && value !== 'chatgpt')
61
+ denied();
62
+ if (key === 'approval_policy' && !['never', 'on-request', 'untrusted', 'on-failure'].includes(value))
63
+ denied();
64
+ if (key === 'web_search' && !['disabled', 'cached', 'live'].includes(value))
65
+ denied();
66
+ result[key] = value;
67
+ }
68
+ return Object.freeze(Object.fromEntries(Object.entries(result).sort(([a], [b]) => a.localeCompare(b))));
69
+ }
70
+ export async function inspectCodexAccountSources(home, cwd, account = path.join(home, '.codex')) {
71
+ try {
72
+ if (![home, cwd, account].every(p => path.isAbsolute(p) && path.resolve(p) === p && !/[\r\n\0]/.test(p)))
73
+ denied();
74
+ const root = await stat(account);
75
+ if (root && (!root.isDirectory() || root.isSymbolicLink() || await realpath(account) !== account))
76
+ denied();
77
+ const blocked = ['/etc/codex/rules'];
78
+ let parent = cwd;
79
+ while (true) {
80
+ const candidate = path.join(parent, '.codex');
81
+ if (candidate !== account)
82
+ blocked.push(candidate);
83
+ const next = path.dirname(parent);
84
+ if (next === parent)
85
+ break;
86
+ parent = next;
87
+ }
88
+ for (const entry of blocked)
89
+ if (await stat(entry))
90
+ denied();
91
+ const configPath = path.join(account, 'config.toml'), raw = await file(configPath), userConfig = preferences(raw);
92
+ const instructions = await file(path.join(account, 'AGENTS.md'));
93
+ if (instructions !== null && instructions.trim() !== '')
94
+ denied();
95
+ const rulesPath = path.join(account, 'rules'), rules = await stat(rulesPath);
96
+ if (rules && (!rules.isDirectory() || rules.isSymbolicLink() || (await readdir(rulesPath)).length !== 0))
97
+ denied();
98
+ return Object.freeze({ version: 1, configPath, userConfig, fingerprint: digest(JSON.stringify({ account, config: raw === null ? null : digest(raw), instructions: instructions === null ? null : digest(instructions), rules: rules ? 'empty-directory' : null })) });
99
+ }
100
+ catch {
101
+ denied();
102
+ }
103
+ }
104
+ /** Retain one source snapshot; any later drift revokes preparation/turn startup. */
105
+ export function codexAccountSourceGuard(home, cwd, account) {
106
+ let snapshot;
107
+ return async () => {
108
+ const current = await inspectCodexAccountSources(home, cwd, account);
109
+ if (snapshot && snapshot.fingerprint !== current.fingerprint)
110
+ denied();
111
+ return snapshot ??= (current);
112
+ };
113
+ }
@@ -1,3 +1,5 @@
1
+ import { executionError } from '../../safety/execution-failure.js';
2
+ import { CodexExecLaunchHost } from './exec-launch-host.js';
1
3
  import { requireSecurityRequirements } from '../../safety/security-requirements.js';
2
4
  import { createJobSecurityReceipt } from '../../safety/job-security-receipt.js';
3
5
  import { requireJobSecurity } from '../../safety/job-security-policy.js';
@@ -16,8 +18,11 @@ export class CodexAdapter {
16
18
  tools;
17
19
  started = false;
18
20
  receipt;
19
- securityReceipt() { return this.receipt ? structuredClone(this.receipt) : undefined; }
21
+ terminalAudit;
22
+ securityReceipt() { return this.terminalAudit ? structuredClone(this.terminalAudit) : this.usingOfficialExec ? this.ownedExec?.receipt() : this.receipt ? structuredClone(this.receipt) : undefined; }
20
23
  cancelled = false;
24
+ usingOfficialExec = false;
25
+ ownedExec;
21
26
  parser = new CodexStreamParser();
22
27
  startupAbort = new AbortController();
23
28
  securityPolicy;
@@ -31,9 +36,43 @@ export class CodexAdapter {
31
36
  if (!await this.options.verifyCompatibility())
32
37
  throw new Error(CODEX_UNSUPPORTED_DIAGNOSTIC);
33
38
  this.assertNotCancelled();
39
+ const approved = object(ctx.approvedTaskPackage);
40
+ const transport = approved.runtime_transport;
41
+ if (transport !== undefined && transport !== 'retained_app_server') {
42
+ if (transport !== 'official_exec_v1')
43
+ throw new Error('CODEX_EXEC_TRANSPORT_UNAVAILABLE');
44
+ const host = this.options.officialExec ?? await this.options.createOfficialExec?.(ctx, this.startupAbort.signal);
45
+ if (!(host instanceof CodexExecLaunchHost))
46
+ throw new Error('CODEX_EXEC_TRANSPORT_UNAVAILABLE');
47
+ if (this.cancelled) {
48
+ await this.options.closeOfficialExec?.();
49
+ throw new Error('CODEX_EXEC_PREFLIGHT_REJECTED');
50
+ }
51
+ try {
52
+ host.assertAvailableContext(ctx, this.options.jobId);
53
+ }
54
+ catch (error) {
55
+ throw executionError(error, 'codex_context', 'CODEX_EXEC_PREFLIGHT_REJECTED');
56
+ }
57
+ this.ownedExec = host;
58
+ this.usingOfficialExec = true;
59
+ let stage = 'codex_prompt';
60
+ try {
61
+ const prompt = compileCodexPrompt(object(ctx.taskPackage), this.options.jobId);
62
+ stage = 'codex_launch';
63
+ this.handle = await this.ownedExec.start(ctx, this.options.jobId, prompt, this.startupAbort.signal);
64
+ this.receipt = this.ownedExec.receipt();
65
+ this.assertNotCancelled();
66
+ return;
67
+ }
68
+ catch (error) {
69
+ const safe = executionError(error, stage, 'CODEX_EXEC_PREFLIGHT_REJECTED');
70
+ await this.cancel().catch(() => { });
71
+ throw safe;
72
+ }
73
+ }
34
74
  if (!this.options.startVerifiedSession)
35
75
  throw new Error("CODEX_PRE_MODEL_UNAVAILABLE");
36
- const approved = object(ctx.approvedTaskPackage);
37
76
  const pkg = object(ctx.taskPackage);
38
77
  const execution = object(pkg.execution);
39
78
  const source = object(approved.repository);
@@ -122,6 +161,15 @@ export class CodexAdapter {
122
161
  const events = this.parser.parse(line);
123
162
  for (const event of events) {
124
163
  if (["completed", "failed", "scope_conflict", "blocked"].includes(event.eventType)) {
164
+ if (event.eventType === 'completed' && this.usingOfficialExec) {
165
+ const audit = this.ownedExec?.receipt();
166
+ if (!audit) {
167
+ await this.cancel();
168
+ yield { eventType: 'failed', summary: 'Codex completion lacked its validated security audit' };
169
+ return;
170
+ }
171
+ this.terminalAudit = structuredClone(audit);
172
+ }
125
173
  await this.cancel();
126
174
  yield event;
127
175
  return;
@@ -139,6 +187,8 @@ export class CodexAdapter {
139
187
  async cancel() {
140
188
  this.cancelled = true;
141
189
  try {
190
+ if (this.usingOfficialExec)
191
+ await this.ownedExec?.cancel();
142
192
  this.options.revokeSecurity?.();
143
193
  }
144
194
  finally {
@@ -148,10 +198,16 @@ export class CodexAdapter {
148
198
  finally {
149
199
  this.startupAbort.abort();
150
200
  try {
151
- this.handle?.kill("SIGKILL");
201
+ if (!this.usingOfficialExec)
202
+ this.handle?.kill("SIGKILL");
152
203
  }
153
204
  finally {
154
- await this.tools?.close();
205
+ try {
206
+ await this.tools?.close();
207
+ }
208
+ finally {
209
+ await this.options.closeOfficialExec?.();
210
+ }
155
211
  }
156
212
  }
157
213
  }
@@ -32,7 +32,10 @@ export function buildCodexInvocation(input) {
32
32
  if (input.scratch)
33
33
  env.TMPDIR = input.scratch.tmpDir;
34
34
  return {
35
+ // The owned control cwd is deliberately outside Git. Runner separately verifies
36
+ // the approved source SHA and scope; this flag does not change permissions.
35
37
  args: ["--no-daemon", "exec", ...CODEX_ISOLATION_EXEC_FLAGS,
38
+ ...(input.sourceRoot && input.sourceRoot !== input.worktreeRoot ? ["--skip-git-repo-check"] : []),
36
39
  ...overrides.flatMap((setting) => ["-c", setting]),
37
40
  "--model", input.modelId, "-C", input.worktreeRoot, "--output-schema", input.schemaPath, "-"],
38
41
  env,
@@ -0,0 +1,62 @@
1
+ import { UNMANAGED_EXEC_MODE } from './unmanaged-scope.js';
2
+ import path from 'node:path';
3
+ import { CODEX_GLOBAL_INSTRUCTION_WRAPPER, globalInstructionProfile } from './global-instructions.js';
4
+ import { randomBytes } from 'node:crypto';
5
+ import { buildCodexInvocation } from './config-builder.js';
6
+ import { securityRequirementsFingerprint as hash } from '../../safety/security-requirements.js';
7
+ export const EXEC_TRANSPORT = 'official_exec_v1';
8
+ export const EXEC_RELEASE_MODE = 'supported_cli_exec_release_policy_v2';
9
+ export const EXEC_MODE = 'supported_cli_exec_policy_v1';
10
+ export const isReleaseExecMode = (mode) => mode === EXEC_RELEASE_MODE || mode === UNMANAGED_EXEC_MODE;
11
+ const plans = new WeakMap();
12
+ const consumed = new WeakSet();
13
+ const fail = () => new Error('CODEX_EXEC_PLAN_REJECTED');
14
+ export function buildOfficialExecPlan(input) {
15
+ try {
16
+ const i = structuredClone(input.identity), inv = structuredClone(input.invocation);
17
+ if (input.policyMode !== undefined && !isReleaseExecMode(input.policyMode))
18
+ throw fail();
19
+ const keys = ['jobId', 'generation', 'actorId', 'runnerId', 'sourceSha', 'binarySha256', 'releaseManifestFingerprint', 'requirementsFingerprint', 'contractFingerprint', 'taskPackageFingerprint', 'launchFingerprint', 'commandsFingerprint', 'toolSchemaFingerprint', 'barrierFingerprint'];
20
+ if (JSON.stringify(Object.keys(i).sort()) !== JSON.stringify(keys.sort()) || !Number.isSafeInteger(i.generation) || i.generation < 1 || !/^[a-f0-9]{40}$/.test(i.sourceSha))
21
+ throw fail();
22
+ for (const [key, value] of Object.entries(i))
23
+ if (key !== 'generation' && (typeof value !== 'string' || !value || value.length > 256 || /[\r\n\0]/.test(value) || !['jobId', 'actorId', 'runnerId', 'sourceSha'].includes(key) && !/^[a-f0-9]{64}$/.test(value)))
24
+ throw fail();
25
+ if (!path.isAbsolute(input.binary) || /[\r\n\0]/.test(input.binary) || !['file', 'keyring', 'auto'].includes(input.credentialBackend) || !inv.scratch || !inv.sourceRoot)
26
+ throw fail();
27
+ const allowed = ['HOME', 'CODEX_HOME', 'PATH', 'USER', 'LOGNAME', 'LANG', 'LC_ALL', 'TMPDIR'];
28
+ if (Object.keys(inv.env).some(k => inv.env[k] !== undefined && (!allowed.includes(k) || typeof inv.env[k] !== 'string' || /[\r\n\0]/.test(inv.env[k]) || inv.env[k].length > 32768)) || inv.env.PATH?.split(path.delimiter).some(p => !path.isAbsolute(p)))
29
+ throw fail();
30
+ const cwd = path.resolve(inv.worktreeRoot), source = path.resolve(inv.sourceRoot);
31
+ if (cwd === source || cwd.startsWith(source + path.sep) || source.startsWith(cwd + path.sep))
32
+ throw fail();
33
+ const generated = buildCodexInvocation(inv), accountRoot = path.resolve(inv.env.CODEX_HOME ?? path.join(inv.env.HOME, '.codex'));
34
+ // Preserve the original backend explicitly; this is not proof of credentials.
35
+ generated.args.splice(generated.args.indexOf('--model'), 0, '-c', `cli_auth_credentials_store=${JSON.stringify(input.credentialBackend)}`);
36
+ const actual = isReleaseExecMode(input.policyMode) ? { binary: CODEX_GLOBAL_INSTRUCTION_WRAPPER, args: ['-p', globalInstructionProfile(accountRoot), input.binary, ...generated.args], env: generated.env } : { binary: input.binary, ...generated };
37
+ const executionNonce = randomBytes(32).toString('hex');
38
+ const plan = Object.freeze({ transport: EXEC_TRANSPORT, mode: input.policyMode ?? EXEC_MODE, executionNonce, identity: Object.freeze(i), modelId: inv.modelId, home: inv.env.HOME, accountRoot, credentialBackend: input.credentialBackend, binary: input.binary, cwd, endpoint: inv.endpoint.url, invocationFingerprint: hash({ ...actual }) });
39
+ plans.set(plan, structuredClone(actual));
40
+ return plan;
41
+ }
42
+ catch {
43
+ throw fail();
44
+ }
45
+ }
46
+ /** Opaque process-local plan; serializing a plan cannot recreate invocation authority. */
47
+ export function requireOfficialExecPlan(plan) {
48
+ const p = plans.get(plan);
49
+ if (!p)
50
+ throw fail();
51
+ return structuredClone(p);
52
+ }
53
+ /** A plan authorizes one attempt across all host instances, including failed attempts. */
54
+ export function claimOfficialExecPlan(plan) {
55
+ assertOfficialExecPlanAvailable(plan);
56
+ consumed.add(plan);
57
+ }
58
+ export function assertOfficialExecPlanAvailable(plan) {
59
+ requireOfficialExecPlan(plan);
60
+ if (consumed.has(plan))
61
+ throw fail();
62
+ }
@@ -0,0 +1,215 @@
1
+ import { executionError } from '../../safety/execution-failure.js';
2
+ import { UNMANAGED_EXEC_MODE } from './unmanaged-scope.js';
3
+ import { fingerprint as taskFingerprint } from '../../daemon/execution-coordinator.js';
4
+ import { securityRequirementsFingerprint as hash } from '../../safety/security-requirements.js';
5
+ import { ExecToolGate } from './exec-tool-gate.js';
6
+ import { assessReleaseExec, requireReleaseDescriptor, releaseGates, EXEC_INPUT_GATES } from './exec-release-policy.js';
7
+ import { officialExecObservationLimits } from './exec-observer.js';
8
+ import { EXEC_GATES, assessExecPreflight, requireExecHandshake } from './exec-preflight.js';
9
+ import { EXEC_MODE, isReleaseExecMode, EXEC_TRANSPORT, assertOfficialExecPlanAvailable, claimOfficialExecPlan, requireOfficialExecPlan } from './exec-invocation.js';
10
+ const fail = () => new Error('CODEX_EXEC_PREFLIGHT_REJECTED');
11
+ /** No default native inspector, handshake observer or OS process host is created.
12
+ * Missing per-execution evidence blocks before spawn. Synthetic callbacks grant
13
+ * only the fake host used by tests, never a compiled production release entry. */
14
+ export class CodexExecLaunchHost {
15
+ plan;
16
+ deps;
17
+ used = false;
18
+ ownsAttempt = false;
19
+ stopped = false;
20
+ handle;
21
+ inspection;
22
+ exitAudit;
23
+ inspectionFingerprint;
24
+ handshake;
25
+ receiptValue;
26
+ abort = new AbortController();
27
+ closePromise;
28
+ constructor(plan, deps) {
29
+ this.plan = plan;
30
+ this.deps = deps;
31
+ requireOfficialExecPlan(plan);
32
+ this.deps = Object.freeze({ ...deps, processHost: Object.freeze({ spawn: deps.processHost.spawn.bind(deps.processHost) }) });
33
+ }
34
+ current() { if (this.stopped || this.abort.signal.aborted || this.inspection?.isCurrent() !== true || !(isReleaseExecMode(this.plan.mode) ? assessReleaseExec(this.inspection.snapshot, this.plan) : assessExecPreflight(this.inspection.snapshot, this.plan)).ready || hash(this.inspection.snapshot) !== this.inspectionFingerprint) {
35
+ if (this.plan.mode === UNMANAGED_EXEC_MODE)
36
+ void this.cancel().catch(() => { });
37
+ throw fail();
38
+ } }
39
+ assertContext(ctx, jobId) {
40
+ const a = ctx.approvedTaskPackage, r = a?.security_requirements, b = ctx.taskPackage, execution = b?.execution;
41
+ if (isReleaseExecMode(this.plan.mode)) {
42
+ if (!r)
43
+ throw fail();
44
+ requireReleaseDescriptor(r);
45
+ if (r.binary_sha256 !== this.plan.identity.binarySha256 || r.fixtures_sha256 !== this.plan.identity.barrierFingerprint || r.commands_sha256 !== this.plan.identity.commandsFingerprint || r.tools_sha256 !== this.plan.identity.toolSchemaFingerprint || !r.model_ids.includes(this.plan.modelId))
46
+ throw fail();
47
+ }
48
+ if (!a || !r || jobId !== this.plan.identity.jobId || ctx.generation !== this.plan.identity.generation || ctx.resumeSessionId || ctx.resolvedModelId !== this.plan.modelId || a.runtime_adapter !== 'codex_cli' || a.runtime_adapter_version !== '1' || a.runtime_transport !== EXEC_TRANSPORT || a.resolved_provider !== 'openai' || a.resolved_model_id !== this.plan.modelId || a.repository?.base_commit_sha !== this.plan.identity.sourceSha || a.contract_fingerprint !== this.plan.identity.contractFingerprint
49
+ || taskFingerprint(a) !== this.plan.identity.taskPackageFingerprint || ctx.securityBinding?.taskPackageFingerprint !== this.plan.identity.taskPackageFingerprint || ctx.securityBinding.contractFingerprint !== this.plan.identity.contractFingerprint || ctx.securityBinding.launchFingerprint !== this.plan.identity.launchFingerprint
50
+ || b?.repositories?.length !== 1 || b.repositories[0]?.base_commit_sha !== this.plan.identity.sourceSha
51
+ || execution?.runtime_transport !== EXEC_TRANSPORT || execution?.mode !== 'employee_ai_work_session' || execution.runtime_adapter !== 'codex_cli' || execution.limits_mode !== 'unbounded'
52
+ || r.version !== (this.plan.mode === UNMANAGED_EXEC_MODE ? 'cli-exec-security-requirements-v3' : isReleaseExecMode(this.plan.mode) ? 'cli-exec-security-requirements-v2' : 'cli-exec-security-requirements-v1') || r.security_mode !== this.plan.mode || r.transport !== EXEC_TRANSPORT || r.status !== 'verified' || r.adapter_id !== 'codex_cli' || r.adapter_version !== '1' || JSON.stringify(r.mandatory_gates) !== JSON.stringify(isReleaseExecMode(this.plan.mode) ? [...releaseGates(this.plan.mode), ...EXEC_INPUT_GATES, 'authenticated_mcp_discovery'] : EXEC_GATES) || r.release_manifest_sha256 !== this.plan.identity.releaseManifestFingerprint || r.fingerprint !== this.plan.identity.requirementsFingerprint || hash(r) !== r.fingerprint)
53
+ throw fail();
54
+ }
55
+ assertAvailableContext(ctx, jobId) { if (this.used || this.stopped)
56
+ throw fail(); this.assertContext(ctx, jobId); assertOfficialExecPlanAvailable(this.plan); if (isReleaseExecMode(this.plan.mode) && (!(this.deps.execTools instanceof ExecToolGate) || !this.deps.execTools.matches(this.plan)))
57
+ throw fail(); }
58
+ async start(ctx, jobId, prompt, signal) {
59
+ if (this.used || this.stopped)
60
+ throw fail();
61
+ this.assertAvailableContext(ctx, jobId);
62
+ claimOfficialExecPlan(this.plan);
63
+ this.ownsAttempt = true;
64
+ this.used = true;
65
+ let stage = 'codex_context';
66
+ let timer, external;
67
+ try {
68
+ this.assertContext(ctx, jobId);
69
+ requireOfficialExecPlan(this.plan);
70
+ if (typeof prompt !== 'string' || !prompt || prompt.length > 1_000_000 || signal?.aborted)
71
+ throw fail();
72
+ const stopped = new Promise((_, reject) => { external = () => { void this.cancel().catch(() => { }); reject(fail()); }; this.abort.signal.addEventListener('abort', () => reject(fail()), { once: true }); signal?.addEventListener('abort', external, { once: true }); timer = setTimeout(external, 30_000); });
73
+ const preparation = (async () => {
74
+ const supported = isReleaseExecMode(this.plan.mode);
75
+ stage = 'codex_inspection';
76
+ this.inspection = await (supported ? this.deps.inspectRelease?.(this.plan, this.abort.signal) : this.deps.inspect?.(this.plan, this.abort.signal));
77
+ const assessment = supported ? assessReleaseExec(this.inspection?.snapshot, this.plan) : assessExecPreflight(this.inspection?.snapshot, this.plan);
78
+ if (!supported && !this.deps.inspect)
79
+ assessment.reasonCodes.push(...officialExecObservationLimits());
80
+ this.deps.onAssessment?.(structuredClone(assessment));
81
+ if (!assessment.ready || !supported && typeof this.deps.handshake !== 'function')
82
+ throw fail();
83
+ this.inspectionFingerprint = hash(this.inspection.snapshot);
84
+ this.current();
85
+ const invocation = requireOfficialExecPlan(this.plan);
86
+ const manual = ctx.approvedTaskPackage?.security_requirements?.mdm_verification === 'manual_prelaunch';
87
+ if (manual && typeof this.inspection.beforeSpawn !== 'function')
88
+ throw fail();
89
+ this.inspection.beforeSpawn?.();
90
+ stage = 'codex_spawn';
91
+ const h = this.deps.processHost.spawn(invocation.binary, invocation.args, { cwd: this.plan.cwd, env: invocation.env });
92
+ this.handle = h;
93
+ if (this.stopped) {
94
+ h.kill('SIGKILL');
95
+ throw fail();
96
+ }
97
+ h.once('exit', (code, signal) => {
98
+ // Preserve audit only for a clean owned exit that was still authorized.
99
+ // This immutable record never restores tool or spawn authority.
100
+ if (code === 0 && signal === null && !this.stopped && this.receiptValue) {
101
+ try {
102
+ this.current();
103
+ this.exitAudit = structuredClone(this.receiptValue);
104
+ }
105
+ catch { }
106
+ }
107
+ void this.cancel().catch(() => { });
108
+ });
109
+ this.current();
110
+ if (supported) {
111
+ const gate = this.deps.execTools;
112
+ gate.bind(h, () => { try {
113
+ this.current();
114
+ return true;
115
+ }
116
+ catch {
117
+ return false;
118
+ } });
119
+ // Vendor exec consumes stdin before it can initialize its required MCP.
120
+ // Prompt submission may cause provider/account effects; do not call this no-model.
121
+ h.stdin.write(prompt);
122
+ this.current();
123
+ h.stdin.end();
124
+ this.current();
125
+ gate.promptSubmitted(h);
126
+ stage = 'codex_mcp';
127
+ const observed = await gate.wait(h, this.abort.signal);
128
+ this.current();
129
+ requireExecHandshake(observed, h, this.plan);
130
+ gate.authorize(h);
131
+ this.current();
132
+ requireExecHandshake(observed, h, this.plan);
133
+ this.receiptValue = Object.freeze({ receipt_version: this.plan.mode === UNMANAGED_EXEC_MODE ? 'cli-exec-job-security-receipt-v3' : 'cli-exec-job-security-receipt-v2', evidence_origin: 'employee_runner', transport: EXEC_TRANSPORT, security_mode: this.plan.mode, ...(this.plan.mode === UNMANAGED_EXEC_MODE ? { policy_scope: this.inspection.snapshot.policyScope } : {}), phase: 'post_prompt_mcp_authorized', policy_evidence: 'reviewed_release_and_current_inputs', mcp_evidence: 'authenticated_server_discovery', native_policy_observed: false, first_inference_observed: false, execution_nonce: this.plan.executionNonce, invocation_fingerprint: this.plan.invocationFingerprint, ...this.plan.identity });
134
+ return h;
135
+ }
136
+ const handshake = await this.deps.handshake?.(h, this.plan, this.abort.signal);
137
+ this.current();
138
+ requireExecHandshake(handshake, h, this.plan);
139
+ this.handshake = handshake;
140
+ // An input hash is not a native effective-config certificate. Preserve the
141
+ // evidence origin in the audit receipt and never reuse AppServer authority.
142
+ const receipt = Object.freeze({ receipt_version: 'cli-exec-job-security-receipt-v1', evidence_origin: 'employee_runner', transport: EXEC_TRANSPORT, security_mode: EXEC_MODE, phase: 'exec_pre_prompt', execution_nonce: this.plan.executionNonce, invocation_fingerprint: this.plan.invocationFingerprint, ...this.plan.identity });
143
+ this.current();
144
+ requireExecHandshake(handshake, h, this.plan);
145
+ h.stdin.write(prompt);
146
+ this.current();
147
+ h.stdin.end();
148
+ this.current();
149
+ requireExecHandshake(handshake, h, this.plan);
150
+ this.receiptValue = receipt;
151
+ return h;
152
+ })();
153
+ return await Promise.race([preparation, stopped]);
154
+ }
155
+ catch (error) {
156
+ const safe = executionError(error, stage, 'CODEX_EXEC_PREFLIGHT_REJECTED');
157
+ await this.cancel().catch(() => { });
158
+ throw safe;
159
+ }
160
+ finally {
161
+ if (timer)
162
+ clearTimeout(timer);
163
+ if (external)
164
+ signal?.removeEventListener('abort', external);
165
+ }
166
+ }
167
+ receipt() { if (this.exitAudit)
168
+ return structuredClone(this.exitAudit); if (!this.receiptValue)
169
+ return undefined; try {
170
+ if (this.plan.mode === UNMANAGED_EXEC_MODE)
171
+ this.current();
172
+ return this.receiptValue ? structuredClone(this.receiptValue) : undefined;
173
+ }
174
+ catch {
175
+ return undefined;
176
+ } }
177
+ cancel() {
178
+ if (this.closePromise)
179
+ return this.closePromise;
180
+ this.stopped = true;
181
+ this.receiptValue = undefined;
182
+ if (!this.ownsAttempt) {
183
+ this.closePromise = Promise.resolve();
184
+ return this.closePromise;
185
+ }
186
+ let finish, reject;
187
+ this.closePromise = new Promise((resolve, rejectPromise) => { finish = resolve; reject = rejectPromise; });
188
+ let cleanupError = false;
189
+ // Publish closePromise first: synchronous exit/cancel cannot recurse or skip cleanup.
190
+ try {
191
+ this.deps.execTools?.revoke();
192
+ }
193
+ catch {
194
+ cleanupError = true;
195
+ }
196
+ try {
197
+ this.deps.revokeTools();
198
+ }
199
+ catch {
200
+ cleanupError = true;
201
+ }
202
+ this.abort.abort();
203
+ try {
204
+ this.handle?.kill('SIGKILL');
205
+ }
206
+ catch {
207
+ cleanupError = true;
208
+ }
209
+ Promise.resolve().then(() => this.deps.closeTools()).then(() => { if (cleanupError)
210
+ reject(new Error('CODEX_EXEC_CLEANUP_UNCONFIRMED'));
211
+ else
212
+ finish(); }, () => reject(new Error('CODEX_EXEC_CLEANUP_UNCONFIRMED')));
213
+ return this.closePromise;
214
+ }
215
+ }