mtech-ai-local-runner 0.1.32 → 0.1.34

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.
@@ -49,9 +49,11 @@ export async function loadInstalledLocalFixHost(input) {
49
49
  const raw = await readPrivateJson(file);
50
50
  if (raw.schema === 'local-fix-host-installation-v2') {
51
51
  const config = raw;
52
- if (config.enabled !== true || config.verification_mode !== 'gateway_ci_v1' || config.runnerId !== input.runnerId)
52
+ if (config.enabled !== true || !['gateway_ci_v1', 'gateway_publish_v1'].includes(config.verification_mode) || config.runnerId !== input.runnerId)
53
53
  throw denied();
54
54
  assertServerCiRegistration(config.registration);
55
+ if (config.verification_mode !== (config.registration.verification_mode ?? 'gateway_ci_v1'))
56
+ throw denied();
55
57
  const packageRoot = await realpath(fileURLToPath(new URL('../../../', import.meta.url)));
56
58
  const assertReleaseCurrent = () => verifyLocalFixRuntime(packageRoot, config.runtimeFiles, config.registration.receipt);
57
59
  await assertReleaseCurrent();
@@ -6,25 +6,29 @@ import { protocolHash } from './candidate.js';
6
6
  /** Reuses the exact registered-command sandbox: no ambient credentials, network,
7
7
  * subprocess permissions, repository writes, or additional dependency grants.
8
8
  * Unsupported PHP/Node project commands remain denied by existing confinement. */
9
- function buildIsolatedVerifier(input, allowCandidateReads = false) {
9
+ export async function createIsolatedCommandPolicy(input, allowCandidateReads = false) {
10
10
  const options = { ...input, commands: structuredClone(input.commands), allowedPaths: [...input.allowedPaths], blockedPaths: [...input.blockedPaths] };
11
- return async (snapshot) => {
12
- const executionCommands = [];
13
- for (const command of options.commands) {
14
- const derived = structuredClone(command), names = command.candidateReadPaths ?? [];
15
- if (!Array.isArray(names) || new Set(names).size !== names.length || (!allowCandidateReads && names.length))
11
+ const executionCommands = [];
12
+ for (const command of options.commands) {
13
+ const derived = structuredClone(command), names = command.candidateReadPaths ?? [];
14
+ if (!Array.isArray(names) || new Set(names).size !== names.length || (!allowCandidateReads && names.length))
15
+ throw Error('ISOLATED_VERIFICATION_REJECTED');
16
+ for (const name of names) {
17
+ if (typeof name !== 'string' || !name || path.isAbsolute(name) || name.includes('\\') || name.split('/').some(p => !p || p === '.' || p === '..' || /^(?:\.git|\.env(?:\..*)?|\.ssh|\.aws|\.codex|\.claude|\.npmrc|\.netrc|credentials(?:\..*)?|auth\.(?:json|toml|ya?ml)|AGENTS\.md|CLAUDE\.md)$/i.test(p)) || !options.allowedPaths.includes(name) || Object.hasOwn(command.integrity.readFiles, name) || options.blockedPaths.some(p => name === p || name.startsWith(p.replace(/\/\*\*$/, '') + '/')))
16
18
  throw Error('ISOLATED_VERIFICATION_REJECTED');
17
- for (const name of names) {
18
- if (typeof name !== 'string' || !name || path.isAbsolute(name) || name.includes('\\') || name.split('/').some(p => !p || p === '.' || p === '..' || /^(?:\.git|\.env(?:\..*)?|\.ssh|\.aws|\.codex|\.claude|\.npmrc|\.netrc|credentials(?:\..*)?|auth\.(?:json|toml|ya?ml)|AGENTS\.md|CLAUDE\.md)$/i.test(p)) || !options.allowedPaths.includes(name) || Object.hasOwn(command.integrity.readFiles, name) || options.blockedPaths.some(p => name === p || name.startsWith(p.replace(/\/\*\*$/, '') + '/')))
19
- throw Error('ISOLATED_VERIFICATION_REJECTED');
20
- const file = path.join(options.root, name);
21
- if (await realpath(file) !== file)
22
- throw Error('ISOLATED_VERIFICATION_REJECTED');
23
- derived.integrity.readFiles[name] = await measureCommandFile(file);
24
- }
25
- executionCommands.push(derived);
19
+ const file = path.join(options.root, name);
20
+ if (await realpath(file) !== file)
21
+ throw Error('ISOLATED_VERIFICATION_REJECTED');
22
+ derived.integrity.readFiles[name] = await measureCommandFile(file);
26
23
  }
27
- const policy = new LocalToolPolicy({ worktreeRoot: options.root, access: 'read', allowedPaths: options.allowedPaths, blockedPaths: options.blockedPaths, requiredCommands: executionCommands.map(c => ({ ...c, readPaths: Object.keys(c.integrity.readFiles) })) });
24
+ executionCommands.push(derived);
25
+ }
26
+ return new LocalToolPolicy({ worktreeRoot: options.root, access: 'read', allowedPaths: options.allowedPaths, blockedPaths: options.blockedPaths, requiredCommands: executionCommands.map(c => ({ ...c, readPaths: Object.keys(c.integrity.readFiles) })) });
27
+ }
28
+ function buildIsolatedVerifier(input, allowCandidateReads = false) {
29
+ const options = { ...input, commands: structuredClone(input.commands), allowedPaths: [...input.allowedPaths], blockedPaths: [...input.blockedPaths] };
30
+ return async (snapshot) => {
31
+ const policy = await createIsolatedCommandPolicy(options, allowCandidateReads);
28
32
  try {
29
33
  if (snapshot.commands.length !== options.commands.length)
30
34
  throw Error('ISOLATED_VERIFICATION_REJECTED');
@@ -5,7 +5,7 @@ import { evaluateCommand } from '../safety/command-policy.js';
5
5
  import { protocolHash } from './candidate.js';
6
6
  const denied = () => new Error('PROJECT_REGISTRATION_REJECTED');
7
7
  const privateSegment = /^(?:\.git|\.mcp(?:\..*)?|\.env(?:\..*)?|\.ssh|\.aws|\.config|\.codex|\.claude|\.runner|\.local-runner|\.npmrc|\.netrc|id_rsa|id_ed25519|credentials(?:\..*)?|auth\.(?:json|toml|ya?ml)(?:\..*)?|mcp\.(?:json|toml|ya?ml)(?:\..*)?|AGENTS\.md|CLAUDE\.md|vendor|node_modules)$/i;
8
- function exactName(name) { return typeof name === 'string' && !!name && !path.isAbsolute(name) && !/[\\\0*?\[\]{}]/.test(name) && name.split('/').every(s => !!s && s !== '.' && s !== '..' && !privateSegment.test(s)); }
8
+ function exactName(name) { return typeof name === 'string' && !!name && !name.startsWith('-') && !path.isAbsolute(name) && !/[\\\0*?\[\]{}]/.test(name) && name.split('/').every(s => !!s && s !== '.' && s !== '..' && !privateSegment.test(s)); }
9
9
  function blocked(name, scopes) { return scopes.some(scope => scope === '.env.*' ? name.startsWith('.env.') : name === scope || name.startsWith(scope.replace(/\/\*\*$/, '') + '/')); }
10
10
  /** Validates reviewed exact registrations against the current host and scope.
11
11
  * Receipt authenticity/ownership belongs to host release attestation; this API
@@ -0,0 +1,42 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { deflateSync, inflateSync } from 'node:zlib';
3
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ /** Local raw objects only: no filters, hooks, Git identity/config or transport. */
6
+ export class RawGitObjects {
7
+ checkout;
8
+ constructor(checkout) {
9
+ this.checkout = checkout;
10
+ }
11
+ async object(type, bytes, write) {
12
+ const raw = Buffer.concat([Buffer.from(`${type} ${bytes.length}\0`), bytes]), oid = createHash('sha1').update(raw).digest('hex');
13
+ if (write) {
14
+ const dir = path.join(this.checkout, '.git', 'objects', oid.slice(0, 2)), file = path.join(dir, oid.slice(2));
15
+ await mkdir(dir, { recursive: true, mode: 0o700 });
16
+ try {
17
+ await writeFile(file, deflateSync(raw), { flag: 'wx', mode: 0o400 });
18
+ }
19
+ catch (e) {
20
+ if (e.code !== 'EEXIST' || !inflateSync(await readFile(file)).equals(raw))
21
+ throw Error('GIT_OBJECT_REJECTED');
22
+ }
23
+ }
24
+ return oid;
25
+ }
26
+ async tree(entries, write, prefix = '') {
27
+ const names = new Map(), children = new Set();
28
+ for (const entry of entries) {
29
+ if (!entry.path.startsWith(prefix))
30
+ continue;
31
+ const rest = entry.path.slice(prefix.length), slash = rest.indexOf('/');
32
+ if (slash >= 0)
33
+ children.add(rest.slice(0, slash));
34
+ else
35
+ names.set(rest, { mode: entry.mode.toString(8), oid: entry.gitlink ?? await this.object('blob', entry.bytes, write) });
36
+ }
37
+ for (const child of children)
38
+ names.set(child, { mode: '40000', oid: await this.tree(entries, write, prefix + child + '/') });
39
+ const chunks = [...names].sort(([a, av], [b, bv]) => Buffer.compare(Buffer.from(a + (av.mode === '40000' ? '/' : '')), Buffer.from(b + (bv.mode === '40000' ? '/' : '')))).map(([name, v]) => Buffer.concat([Buffer.from(`${v.mode} ${name}\0`), Buffer.from(v.oid, 'hex')]));
40
+ return this.object('tree', Buffer.concat(chunks), write);
41
+ }
42
+ }
@@ -32,7 +32,7 @@ export async function recoverLocalFixPublication(input) {
32
32
  let session;
33
33
  try {
34
34
  session = await input.host.prepare({ job, checkoutPath: r.checkoutPath, bindingId, lifecycle: { isFrozen: () => true, isCurrent: () => current, securityReceipt: () => r.securityReceipt } });
35
- const receipt = await session.finalize();
35
+ const receipt = await session.finalize(event);
36
36
  if (heartbeatError)
37
37
  throw new PublicationOutcomeUnknownError();
38
38
  const cursor = await input.gateway.syncLocalPublicationCursor(bindingId, s.eventCursor ?? 0);
@@ -5,6 +5,7 @@ import { constants } from 'node:fs';
5
5
  import { lstat, open, realpath } from 'node:fs/promises';
6
6
  import path from 'node:path';
7
7
  import { setTimeout as delay } from 'node:timers/promises';
8
+ import { MAX_EVENT_SUMMARY_CHARS } from '../protocol.js';
8
9
  import { protocolHash } from './candidate.js';
9
10
  import { FilePublicationJournal } from './journal.js';
10
11
  import { GitSnapshotHost } from './git-snapshot.js';
@@ -15,21 +16,59 @@ const matches = (p, s) => s.endsWith('/**') ? safePath(s.slice(0, -3)) && (p ===
15
16
  const bindingFields = ['binding_id', 'actor_id', 'runner_id', 'job_id', 'generation', 'attempt_id', 'execution_contract_id', 'work_loop_id', 'repository_lease_id', 'repository_key', 'base_commit_sha', 'working_branch', 'contract_fingerprint', 'task_package_fingerprint', 'launch_fingerprint'];
16
17
  const bindingProof = (v) => { const b = Object.fromEntries(bindingFields.map(k => [k, v[k]])); if (!Number.isSafeInteger(b.generation) || b.generation < 1 || bindingFields.some(k => k !== 'generation' && (typeof b[k] !== 'string' || !b[k])))
17
18
  throw denied(); return b; };
18
- export function assertServerCiRegistration(r) { const { review, ...manifest } = r; if (!review || review.status !== 'reviewed' || review.fingerprint !== protocolHash(manifest) || !r.project || r.ci_profile_fingerprint !== protocolHash(r.ci_profile) || Object.values(r.receipt).length !== 3 || Object.values(r.receipt).some(v => !/^[a-f0-9]{64}$/.test(v)))
19
- throw denied(); }
19
+ export function assertServerCiRegistration(r) {
20
+ const { review, ...manifest } = r;
21
+ if (r.verification_mode !== undefined && !['gateway_ci_v1', 'gateway_publish_v1'].includes(r.verification_mode))
22
+ throw denied();
23
+ const p = r.ci_profile;
24
+ if (r.verification_mode === 'gateway_publish_v1' && (!p || Object.keys(p).some(k => !['review_status', 'validation_policy', 'destination_branch', 'trusted_files'].includes(k)) || p.review_status !== 'reviewed' || p.validation_policy !== 'disclose_unverified' || typeof p.destination_branch !== 'string' || !p.destination_branch))
25
+ throw denied();
26
+ // Both server modes use the Gateway's ordered list of pinned file records.
27
+ // Older CI registrations without a local profile schema remain compatible.
28
+ if (r.verification_mode === 'gateway_publish_v1' || p?.trusted_files !== undefined) {
29
+ const files = p?.trusted_files, seen = new Set();
30
+ if (!Array.isArray(files) || !files.length || files.length > 50)
31
+ throw denied();
32
+ for (const file of files) {
33
+ if (!file || typeof file !== 'object' || Array.isArray(file) || Object.keys(file).some(k => !['path', 'sha256'].includes(k)) || typeof file.path !== 'string' || !safePath(file.path) || !/^[A-Za-z0-9_][A-Za-z0-9_./-]*$/.test(file.path) || /(^|\/)(vendor|node_modules|secrets)(\/|$)/.test(file.path) || seen.has(file.path) || typeof file.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(file.sha256))
34
+ throw denied();
35
+ seen.add(file.path);
36
+ }
37
+ if (!seen.has('bitbucket-pipelines.yml'))
38
+ throw denied();
39
+ }
40
+ if (!review || review.status !== 'reviewed' || review.fingerprint !== protocolHash(manifest) || !r.project || r.ci_profile_fingerprint !== protocolHash(r.ci_profile) || Object.values(r.receipt).length !== 3 || Object.values(r.receipt).some(v => !/^[a-f0-9]{64}$/.test(v)))
41
+ throw denied();
42
+ }
43
+ /** Executor text is disclosure only; never derive trusted statuses from it. */
44
+ function validationReport(event) {
45
+ if (event === undefined)
46
+ return { summary: 'No executor validation report was provided.', evidence: [] };
47
+ if (event.eventType !== 'completed' || typeof event.summary !== 'string' || event.summary.length > MAX_EVENT_SUMMARY_CHARS || !event.summary.trim())
48
+ throw denied();
49
+ const evidence = event.payload?.completion_evidence === undefined ? [] : event.payload.completion_evidence;
50
+ if (!Array.isArray(evidence) || evidence.length > 20 || evidence.some(v => typeof v !== 'string' || v.length > 500 || !v.trim()))
51
+ throw denied();
52
+ const clean = (v) => v.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '').trim();
53
+ const summary = clean(event.summary), items = evidence.map(clean);
54
+ if (!summary || items.some(v => !v))
55
+ throw denied();
56
+ return { summary, evidence: items };
57
+ }
20
58
  /** Only host-owned frozen bytes cross the Gateway boundary. Git is exclusively
21
59
  * a pinned credential-free object reader; verification/publication run remotely. */
22
60
  export function createServerCiLocalFixHost(options) {
23
61
  const registration = structuredClone(options.registration);
24
62
  assertServerCiRegistration(registration);
25
- return { requiresOwnedFreeze: true, verificationMode: 'gateway_ci_v1', prepare: async (input) => {
63
+ const mode = registration.verification_mode ?? 'gateway_ci_v1';
64
+ return { requiresOwnedFreeze: true, verificationMode: mode, prepare: async (input) => {
26
65
  await options.assertReleaseCurrent?.();
27
66
  const lifecycle = input.lifecycle;
28
67
  if (!lifecycle)
29
68
  throw denied();
30
69
  const { job } = input, pkg = job.taskPackage, scope = pkg.local_fix_scope, repo = pkg.repository, limits = scope?.candidate_limits, allowed = scope?.allowed_paths;
31
70
  const root = await realpath(options.privateRoot), st = await lstat(root);
32
- if (!lifecycle.isCurrent() || pkg.runtime_adapter !== 'codex_cli' || !scope || scope.verification_mode !== 'gateway_ci_v1' || scope.ci_profile_fingerprint !== registration.ci_profile_fingerprint || protocolHash(scope.ci_profile) !== protocolHash(registration.ci_profile) || !Array.isArray(scope.required_commands) || scope.required_commands.length || !scope.registered_tests || typeof scope.registered_tests !== 'object' || Object.keys(scope.registered_tests).length || !Array.isArray(allowed) || !allowed.length || allowed.some(p => typeof p !== 'string' || !(safePath(p) || p.endsWith('/**') && safePath(p.slice(0, -3)))) || protocolHash(allowed) !== protocolHash(pkg.allowed_paths) || repo.key !== registration.project || job.gatewayTaskPackageFingerprint !== protocolHash(pkg) || Object.entries(registration.receipt).some(([k, v]) => scope[k] !== v) || !limits || ['max_changed_files', 'max_diff_bytes', 'max_file_bytes'].some(k => !Number.isSafeInteger(limits[k]) || limits[k] < 1) || root !== options.privateRoot || !st.isDirectory() || st.isSymbolicLink() || (st.mode & 0o077) !== 0 || (process.getuid && st.uid !== process.getuid()) || root === input.checkoutPath || root.startsWith(input.checkoutPath + path.sep))
71
+ if (!lifecycle.isCurrent() || pkg.runtime_adapter !== 'codex_cli' || !scope || scope.verification_mode !== mode || scope.ci_profile_fingerprint !== registration.ci_profile_fingerprint || protocolHash(scope.ci_profile) !== protocolHash(registration.ci_profile) || !Array.isArray(scope.required_commands) || scope.required_commands.length || !scope.registered_tests || typeof scope.registered_tests !== 'object' || Object.keys(scope.registered_tests).length || !Array.isArray(allowed) || !allowed.length || allowed.some(p => typeof p !== 'string' || !(safePath(p) || p.endsWith('/**') && safePath(p.slice(0, -3)))) || protocolHash(allowed) !== protocolHash(pkg.allowed_paths) || repo.key !== registration.project || job.gatewayTaskPackageFingerprint !== protocolHash(pkg) || Object.entries(registration.receipt).some(([k, v]) => scope[k] !== v) || !limits || ['max_changed_files', 'max_diff_bytes', 'max_file_bytes'].some(k => !Number.isSafeInteger(limits[k]) || limits[k] < 1) || root !== options.privateRoot || !st.isDirectory() || st.isSymbolicLink() || (st.mode & 0o077) !== 0 || (process.getuid && st.uid !== process.getuid()) || root === input.checkoutPath || root.startsWith(input.checkoutPath + path.sep))
33
72
  throw denied();
34
73
  const fence = { jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, launchGrant: job.launchGrant, bindingId: input.bindingId };
35
74
  const binding = bindingProof((await options.gateway.localPublicationContext(fence)).context);
@@ -39,8 +78,9 @@ export function createServerCiLocalFixHost(options) {
39
78
  const current = async () => { if (closed || !lifecycle.isCurrent() || !lifecycle.isFrozen())
40
79
  throw denied(); await options.assertReleaseCurrent?.(); if (protocolHash(bindingProof((await options.gateway.localPublicationContext(fence)).context)) !== protocolHash(binding) || closed || !lifecycle.isCurrent() || !lifecycle.isFrozen())
41
80
  throw denied(); await seal?.assertUnchanged(); };
42
- return { close: async () => { closed = true; }, finalize() {
81
+ return { close: async () => { closed = true; }, finalize(completedEvent) {
43
82
  return finalization ??= (async () => {
83
+ const report = mode === 'gateway_publish_v1' ? validationReport(completedEvent) : undefined;
44
84
  await current();
45
85
  const host = new GitSnapshotHost({ root: input.checkoutPath, privateRoot: root, git: options.git, baseCommitSha: binding.base_commit_sha, workingBranch: binding.working_branch, maxFileBytes: limits.max_file_bytes, maxTotalBytes: 128 * 1024 * 1024 });
46
86
  const observed = await host.observe();
@@ -99,7 +139,7 @@ export function createServerCiLocalFixHost(options) {
99
139
  await fd.close();
100
140
  }
101
141
  }
102
- const journal = new FilePublicationJournal(root), key = protocolHash({ mode: 'gateway_ci_v1', binding }), deltaFingerprint = protocolHash({ binding, files, profile: registration.ci_profile_fingerprint });
142
+ const journal = new FilePublicationJournal(root), key = protocolHash({ mode, binding }), deltaFingerprint = protocolHash({ binding, files, profile: registration.ci_profile_fingerprint, ...(report ? { validation_report: report } : {}) });
103
143
  return journal.exclusive(key, async () => {
104
144
  const previous = await journal.load(key);
105
145
  if (previous && previous.candidate_fingerprint !== deltaFingerprint)
@@ -114,7 +154,7 @@ export function createServerCiLocalFixHost(options) {
114
154
  let result;
115
155
  stageStarted = true;
116
156
  try {
117
- result = (await options.gateway.executeServerCiPublication({ ...fence, operation: 'stage', files, securityReceipt: { ...security }, hostReceipt: { ...registration.receipt } })).result;
157
+ result = (await options.gateway.executeServerCiPublication({ ...fence, operation: 'stage', files, ...(report ? { validationReport: report } : {}), securityReceipt: { ...security }, hostReceipt: { ...registration.receipt } })).result;
118
158
  }
119
159
  catch {
120
160
  throw new PublicationOutcomeUnknownError();
@@ -135,7 +175,7 @@ export function createServerCiLocalFixHost(options) {
135
175
  if (result.status === 'completed') {
136
176
  await current();
137
177
  const ci = result.ci;
138
- if (result.verification_mode !== 'gateway_ci_v1' || ci?.status !== 'passed' || typeof result.commit_sha !== 'string' || !/^[a-f0-9]{40}$/.test(result.commit_sha) || ci.commit_sha !== result.commit_sha || result.working_branch !== binding.working_branch)
178
+ if (result.verification_mode !== mode || (mode === 'gateway_ci_v1' ? ci?.status !== 'passed' || ci.commit_sha !== result.commit_sha : ci?.status !== 'not_run' || result.verification?.status !== 'unverified') || typeof result.commit_sha !== 'string' || !/^[a-f0-9]{40}$/.test(result.commit_sha) || result.working_branch !== binding.working_branch)
139
179
  throw new PublicationOutcomeUnknownError();
140
180
  return result;
141
181
  }
@@ -9,6 +9,8 @@ import { parse } from 'smol-toml';
9
9
  import { CODEX_REPORT_SCHEMA } from '../adapters/codex/prompt-compiler.js';
10
10
  import { lstatSync } from 'node:fs';
11
11
  import { readStableText, regularFileStamp as stamp } from './stable-file-read.js';
12
+ import { observePinnedGitHead } from './pinned-git-head.js';
13
+ import { executionError } from './execution-failure.js';
12
14
  import { readCodexCredentialStatus, registeredCommandValid } from './codex-production-host.js';
13
15
  import { securityRequirementsFingerprint as hash, requireSecurityRequirements } from './security-requirements.js';
14
16
  import { fingerprint } from '../daemon/execution-coordinator.js';
@@ -101,13 +103,13 @@ export function createCodexExecProductionHost(original) {
101
103
  const close = () => { revoked = true; return closing ??= (Promise.resolve().then(() => cleanup?.())); };
102
104
  let revoked = false, prepared = false, selected, ctx;
103
105
  const baseline = new Map(), directories = new Map(), accountDirectories = new Map();
104
- let boundPlan;
106
+ let boundPlan, headObservation;
105
107
  const dirStamp = (file) => { const s = lstatSync(file, { bigint: true }); if (!s.isDirectory() || (s.mode & 63n) !== 0n || process.getuid && s.uid !== BigInt(process.getuid()))
106
108
  throw reject(); return [s.dev, s.ino, s.mode, s.uid].join(':'); };
107
109
  const accountDirStamp = (file) => { const s = lstatSync(file, { bigint: true }); if (!s.isDirectory() || s.isSymbolicLink())
108
110
  throw reject(); return [s.dev, s.ino, s.mode, s.uid].join(':'); };
109
111
  const current = () => { try {
110
- return !revoked && !!selected && !!ctx && (selected.release.securityMode !== UNMANAGED_EXEC_MODE || selected.policyObservation?.isCurrent() === true) && (lease.get(id)?.expires ?? 0) > Date.now() && [...baseline].every(([file, value]) => stamp(file) === value) && [...directories].every(([file, value]) => dirStamp(file) === value) && [...accountDirectories].every(([file, value]) => accountDirStamp(file) === value);
112
+ return !revoked && !!selected && !!ctx && headObservation?.isCurrent() === true && (selected.release.securityMode !== UNMANAGED_EXEC_MODE || selected.policyObservation?.isCurrent() === true) && (lease.get(id)?.expires ?? 0) > Date.now() && [...baseline].every(([file, value]) => stamp(file) === value) && [...directories].every(([file, value]) => dirStamp(file) === value) && [...accountDirectories].every(([file, value]) => accountDirStamp(file) === value);
111
113
  }
112
114
  catch {
113
115
  return false;
@@ -118,34 +120,19 @@ export function createCodexExecProductionHost(original) {
118
120
  throw reject();
119
121
  prepared = true;
120
122
  ctx = structuredClone(value);
123
+ let stage = 'codex_prepare_release';
121
124
  try {
122
125
  selected = await inspect();
126
+ stage = 'codex_prepare_binding';
123
127
  const r = requireSecurityRequirements(p.security_requirements, { adapterId: 'codex_cli', adapterVersion: '1' }), release = selected.release;
124
128
  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))
125
129
  throw reject();
130
+ stage = 'codex_prepare_source';
126
131
  const source = await realpath(sourceRoot), home = await realpath(account);
127
132
  if (inside(source, home) || inside(home, source))
128
133
  throw reject();
129
- const dot = path.join(source, '.git');
130
- const ds = await lstat(dot);
131
- let head;
132
- if (ds.isFile()) {
133
- const gitData = await readStableText(dot, 4096), git = gitData.text;
134
- if (!git.startsWith('gitdir: '))
135
- throw reject();
136
- const dir = path.resolve(source, git.slice(8).trim());
137
- if (inside(home, dir))
138
- throw reject();
139
- head = path.join(dir, 'HEAD');
140
- baseline.set(dot, gitData.stamp);
141
- }
142
- else if (ds.isDirectory() && !ds.isSymbolicLink())
143
- head = path.join(dot, 'HEAD');
144
- else
145
- throw reject();
146
- const headData = await readStableText(head, 4096);
147
- if (headData.text.trim() !== p.repository?.base_commit_sha)
148
- throw reject();
134
+ headObservation = await observePinnedGitHead(source, p.repository, home);
135
+ stage = 'codex_prepare_instructions';
149
136
  const wrapper = CODEX_GLOBAL_INSTRUCTION_WRAPPER, ws = await lstat(wrapper);
150
137
  if (!ws.isFile() || ws.isSymbolicLink() || ws.uid !== 0 || (ws.mode & 0o022) || await realpath(wrapper) !== wrapper)
151
138
  throw reject();
@@ -156,9 +143,9 @@ export function createCodexExecProductionHost(original) {
156
143
  throw reject();
157
144
  accountDirectories.set(ancestor, accountDirStamp(ancestor));
158
145
  }
159
- baseline.set(head, headData.stamp);
160
146
  baseline.set(selected.binary, selected.binaryStamp);
161
147
  baseline.set(selected.config, selected.configStamp);
148
+ stage = 'codex_prepare_commands';
162
149
  for (const command of release.commands) {
163
150
  const paths = command.readPaths ?? [], files = paths.map(v => path.join(source, v));
164
151
  const measured = [command.executable, ...files].map(f => [f, stamp(f)]);
@@ -169,13 +156,14 @@ export function createCodexExecProductionHost(original) {
169
156
  baseline.set(f, s);
170
157
  }
171
158
  }
159
+ stage = 'codex_prepare_current';
172
160
  if (!current())
173
161
  throw reject();
174
162
  return { binary: selected.binary, release: structuredClone(release), actorId: c.security_context.actor_id, runnerId: input.runnerId };
175
163
  }
176
- catch {
164
+ catch (error) {
177
165
  revoked = true;
178
- throw reject();
166
+ throw executionError(error, stage, 'CODEX_EXEC_PREFLIGHT_REJECTED');
179
167
  }
180
168
  },
181
169
  async inspect(plan) {
@@ -1,4 +1,5 @@
1
1
  import path from 'node:path';
2
+ import { protocolHash } from '../publication/candidate.js';
2
3
  function object(value) {
3
4
  if (!value || typeof value !== 'object' || Array.isArray(value))
4
5
  throw new Error('Invalid Job policy');
@@ -51,6 +52,14 @@ export function jobToolPolicyOptions(worktreeRoot, pkg, approved, registry, runt
51
52
  const approvedCommands = strings(approved.required_commands);
52
53
  if (commands.length !== approvedCommands.length || JSON.stringify([...commands].sort()) !== JSON.stringify([...approvedCommands].sort()))
53
54
  throw new Error('Job required commands differ from approval');
55
+ if (approved.verification !== undefined) {
56
+ const verification = object(approved.verification);
57
+ if (runtime.runtimeAdapter !== 'codex_cli' || runtime.runtimeAdapterVersion !== '1' || approved.runtime_transport !== 'official_exec_v1' || verification.runtime_adapter !== runtime.runtimeAdapter || verification.runtime_transport !== approved.runtime_transport || verification.policy_key !== 'local-runner-verified-v1' || protocolHash(repo.blocked_paths) !== protocolHash(source.blocked_paths))
58
+ throw new Error('P1 Job runtime or scope differs from approval');
59
+ if (approved.local_fix_scope !== undefined || verification.mode !== 'runner_observed_v1' || verification.schema_version !== 'local-runner-verification-v1' || pkg.verification !== undefined && protocolHash(verification) !== protocolHash(pkg.verification) || protocolHash(verification) !== protocolHash(object(pkg.contract).verification) || protocolHash(repo.allowed_paths) !== protocolHash(approved.allowed_paths) || protocolHash(verification.allowed_paths) !== protocolHash(approved.allowed_paths))
60
+ throw new Error('P1 Job verification differs from approval');
61
+ return { worktreeRoot, access: repo.access === 'write' && source.access === 'write' ? 'write' : 'read', allowedPaths: strings(approved.allowed_paths), blockedPaths: strings(repo.blocked_paths), requiredCommands: [] };
62
+ }
54
63
  // Writable local Fix checks run only after the owned model/tool endpoint is
55
64
  // closed, in the reviewed host verifier. They are not model command grants.
56
65
  const localFix = approved.local_fix_scope;
@@ -2,7 +2,7 @@ import path from 'node:path';
2
2
  import { randomBytes } from 'node:crypto';
3
3
  import { lstat, realpath } from 'node:fs/promises';
4
4
  import { evaluateCommand } from './command-policy.js';
5
- import { runSandboxed } from './subprocess-sandbox.js';
5
+ import { runSandboxed, runSandboxedMeasured } from './subprocess-sandbox.js';
6
6
  import { LOCAL_TOOL_WORKER } from './local-tool-worker.js';
7
7
  import { verifyCommandIntegrity } from './registered-command-integrity.js';
8
8
  export const LOCAL_TOOL_NAMES = ['local_read', 'local_search', 'local_edit', 'local_test'];
@@ -86,7 +86,9 @@ export class LocalToolPolicy {
86
86
  throw new Error('DENIED_SIZE');
87
87
  return file;
88
88
  }
89
- async test(input) {
89
+ async prepareTest(input) {
90
+ if (this.abort.signal.aborted)
91
+ throw new Error('DENIED_REVOKED');
90
92
  const command = this.options.requiredCommands.find(command => command.id === input.command);
91
93
  if (!command || !path.isAbsolute(command.executable) || command.argv.some(arg => typeof arg !== 'string'))
92
94
  throw new Error('DENIED_COMMAND');
@@ -99,7 +101,19 @@ export class LocalToolPolicy {
99
101
  const argv = executable === await realpath(process.execPath) ? ['--no-addons', '--no-global-search-paths', ...command.argv] : command.argv;
100
102
  const readFiles = await Promise.all((command.readPaths ?? []).map(file => this.authorizedFile(file)));
101
103
  await verifyCommandIntegrity(command.integrity, executable, command.readPaths ?? [], readFiles);
102
- const output = await runSandboxed({ executable, argv, readFiles, cwd: this.options.worktreeRoot, signal: this.abort.signal });
104
+ return { command, executable, readFiles, request: { executable, argv, readFiles, cwd: this.options.worktreeRoot, signal: this.abort.signal } };
105
+ }
106
+ /** Same scope/integrity preparation and sandbox as legacy local_test, with
107
+ * no raw output retained. Only host verification calls this API. */
108
+ async measureTest(commandId, bounds, signal) {
109
+ const { command, executable, readFiles, request } = await this.prepareTest({ command: commandId });
110
+ const measurement = await runSandboxedMeasured({ ...request, ...bounds, signal: signal ? AbortSignal.any([this.abort.signal, signal]) : this.abort.signal });
111
+ await verifyCommandIntegrity(command.integrity, executable, command.readPaths ?? [], readFiles);
112
+ return measurement;
113
+ }
114
+ async test(input) {
115
+ const { command, executable, readFiles, request } = await this.prepareTest(input);
116
+ const output = await runSandboxed(request);
103
117
  await verifyCommandIntegrity(command.integrity, executable, command.readPaths ?? [], readFiles);
104
118
  if (this.abort.signal.aborted)
105
119
  throw new Error('DENIED_REVOKED');
@@ -0,0 +1,113 @@
1
+ import path from 'node:path';
2
+ import { lstat, realpath } from 'node:fs/promises';
3
+ import { lstatSync, realpathSync } from 'node:fs';
4
+ import { readStableText, regularFileStamp } from './stable-file-read.js';
5
+ const denied = () => Error('PINNED_GIT_HEAD_REJECTED');
6
+ const inside = (root, file) => file === root || file.startsWith(root + path.sep);
7
+ const branchValid = (value) => typeof value === 'string' && value.length <= 255
8
+ && /^ai-work\/[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value)
9
+ && value.split('/').every(p => !p.includes('..') && !p.endsWith('.') && !p.endsWith('.lock'));
10
+ // Packed refs may have no loose-ref parent yet. Check the nearest existing
11
+ // ancestor too, so a symlink to an empty directory cannot evade absence checks.
12
+ function canonicalParent(file) {
13
+ let parent = path.dirname(file);
14
+ for (;;) {
15
+ try {
16
+ return lstatSync(parent).isDirectory() && realpathSync(parent) === parent;
17
+ }
18
+ catch (e) {
19
+ if (e.code !== 'ENOENT' || parent === path.dirname(parent))
20
+ return false;
21
+ parent = path.dirname(parent);
22
+ }
23
+ }
24
+ }
25
+ /** Observe Git metadata only: no Git process, filters, hooks, account or config reads.
26
+ * A symbolic HEAD is permitted only for the exact approved writable branch. */
27
+ export async function observePinnedGitHead(source, repository, excludedRoot) {
28
+ const files = new Map(), absent = new Set();
29
+ const directory = async (file) => { if (inside(excludedRoot, file) || inside(file, excludedRoot) || await realpath(file) !== file || !(await lstat(file)).isDirectory())
30
+ throw denied(); return file; };
31
+ const read = async (file, max = 4096) => { if (await realpath(file) !== file)
32
+ throw denied(); const value = await readStableText(file, max); files.set(file, value.stamp); return value.text; };
33
+ const optional = async (file, max = 4096) => { try {
34
+ return await read(file, max);
35
+ }
36
+ catch (e) {
37
+ if (e.code !== 'ENOENT')
38
+ throw e;
39
+ if (!canonicalParent(file))
40
+ throw denied();
41
+ absent.add(file);
42
+ return undefined;
43
+ } };
44
+ const expected = repository.base_commit_sha;
45
+ if (typeof expected !== 'string' || !/^[a-f0-9]{40}$/.test(expected))
46
+ throw denied();
47
+ const dot = path.join(source, '.git'), entry = await lstat(dot);
48
+ let gitdir;
49
+ if (entry.isFile()) {
50
+ const value = await read(dot), match = /^gitdir: ([^\r\n\0]+)\n?$/.exec(value);
51
+ if (!match)
52
+ throw denied();
53
+ gitdir = await directory(path.resolve(source, match[1]));
54
+ }
55
+ else if (entry.isDirectory() && !entry.isSymbolicLink())
56
+ gitdir = await directory(dot);
57
+ else
58
+ throw denied();
59
+ const head = (await read(path.join(gitdir, 'HEAD'))).trim();
60
+ if (/^[a-f0-9]{40}$/.test(head)) {
61
+ if (head !== expected)
62
+ throw denied();
63
+ }
64
+ else {
65
+ const branch = repository.working_branch;
66
+ if (repository.access !== 'write' || !branchValid(branch) || head !== `ref: refs/heads/${branch}`)
67
+ throw denied();
68
+ const commonText = await optional(path.join(gitdir, 'commondir'));
69
+ if (commonText !== undefined && !/^[^\r\n\0]+\n?$/.test(commonText))
70
+ throw denied();
71
+ const common = commonText === undefined ? gitdir : await directory(path.resolve(gitdir, commonText.trim()));
72
+ const ref = `refs/heads/${branch}`, loose = await optional(path.join(common, ref));
73
+ let commit;
74
+ if (loose !== undefined) {
75
+ if (!/^[a-f0-9]{40}\n?$/.test(loose))
76
+ throw denied();
77
+ commit = loose.trim();
78
+ }
79
+ else {
80
+ const packed = await read(path.join(common, 'packed-refs'), 1_048_576);
81
+ for (const line of packed.split('\n')) {
82
+ if (!line || line.startsWith('#') || /^\^[a-f0-9]{40}$/.test(line))
83
+ continue;
84
+ const match = /^([a-f0-9]{40}) (refs\/[^\s\0]+)$/.exec(line);
85
+ if (!match)
86
+ throw denied();
87
+ if (match[2] === ref) {
88
+ if (commit !== undefined)
89
+ throw denied();
90
+ commit = match[1];
91
+ }
92
+ }
93
+ }
94
+ if (commit !== expected)
95
+ throw denied();
96
+ }
97
+ const isCurrent = () => { try {
98
+ return [...files].every(([file, stamp]) => realpathSync(file) === file && regularFileStamp(file) === stamp) && [...absent].every(file => { if (!canonicalParent(file))
99
+ return false; try {
100
+ lstatSync(file);
101
+ return false;
102
+ }
103
+ catch (e) {
104
+ return e.code === 'ENOENT';
105
+ } });
106
+ }
107
+ catch {
108
+ return false;
109
+ } };
110
+ if (!isCurrent())
111
+ throw denied();
112
+ return { isCurrent };
113
+ }