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.
- package/README.md +43 -0
- package/dist/src/adapters/claude-code/adapter.js +7 -1
- package/dist/src/adapters/claude-code/stream-parser.js +22 -12
- package/dist/src/daemon/execution-coordinator.js +63 -4
- package/dist/src/daemon/runtime.js +4 -1
- package/dist/src/gateway-client.js +8 -3
- package/dist/src/publication/candidate.js +3 -2
- package/dist/src/publication/git-snapshot.js +87 -6
- package/dist/src/publication/git-transport.js +4 -30
- package/dist/src/publication/installed-host.js +3 -1
- package/dist/src/publication/isolated-verifier.js +20 -16
- package/dist/src/publication/project-registration.js +1 -1
- package/dist/src/publication/raw-git-objects.js +42 -0
- package/dist/src/publication/recovery.js +1 -1
- package/dist/src/publication/server-ci-host.js +48 -8
- package/dist/src/safety/codex-exec-production-host.js +13 -25
- package/dist/src/safety/job-tool-policy.js +9 -0
- package/dist/src/safety/local-tool-policy.js +17 -3
- package/dist/src/safety/pinned-git-head.js +113 -0
- package/dist/src/safety/subprocess-sandbox.js +72 -38
- package/dist/src/verification/p1-execution-host.js +78 -0
- package/dist/src/verification/runner-observed-verifier.js +147 -0
- package/dist/src/workspace/workspace-manager.js +2 -1
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
9
|
+
export async function createIsolatedCommandPolicy(input, allowCandidateReads = false) {
|
|
10
10
|
const options = { ...input, commands: structuredClone(input.commands), allowedPaths: [...input.allowedPaths], blockedPaths: [...input.blockedPaths] };
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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) {
|
|
19
|
-
|
|
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
|
-
|
|
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 !==
|
|
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
|
|
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'
|
|
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
|
-
|
|
130
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|