mtech-ai-local-runner 0.1.33 → 0.1.35
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 +34 -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/adapters/codex/prompt-compiler.js +24 -1
- package/dist/src/daemon/execution-coordinator.js +62 -3
- package/dist/src/daemon/runtime.js +1 -0
- package/dist/src/gateway-client.js +7 -2
- 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/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/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
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { realpathSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
4
5
|
const quote = (value) => JSON.stringify(value);
|
|
5
6
|
/** Deny-default Seatbelt profile. No inherited profile, environment or shell. */
|
|
6
7
|
export function sandboxProfile(request) {
|
|
@@ -17,46 +18,79 @@ export function sandboxProfile(request) {
|
|
|
17
18
|
const write = (request.writeFiles ?? []).map(file => `(literal ${quote(file)})`).join(' ');
|
|
18
19
|
return `(version 1)(deny default)(allow process-exec (literal ${quote(executable)}))(allow sysctl-read)(allow file-read-metadata)(allow file-map-executable (subpath "/System/Library") (subpath "/usr/lib") (literal ${quote(executable)}))(allow file-read-data (subpath "/System/Library") (subpath "/usr/lib") (subpath "/usr/share") ${read})${write ? `(allow file-write* ${write})` : ''}`;
|
|
19
20
|
}
|
|
20
|
-
|
|
21
|
+
/** One restricted execution primitive. P1 retains no output; only legacy callers
|
|
22
|
+
* opt into bounded stdout retention. Completion waits for close (stream drain),
|
|
23
|
+
* never exit or a timeout callback. No grants differ from sandboxProfile. */
|
|
24
|
+
async function executeSandboxed(request, retainStdout) {
|
|
25
|
+
const start = performance.now();
|
|
26
|
+
const empty = (status, reason) => ({ measurement: { status, reason, exit_code: null, duration_ms: Math.max(0, Math.floor(performance.now() - start)), stdout_sha256: null, stderr_sha256: null, stdout_bytes: 0, stderr_bytes: 0 }, output: '' });
|
|
27
|
+
if (request.signal?.aborted)
|
|
28
|
+
return empty('cancelled', 'aborted');
|
|
21
29
|
if (process.platform !== 'darwin')
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
return empty('blocked', 'sandbox_unavailable');
|
|
31
|
+
const limit = request.maxOutputBytes ?? 1024 * 1024;
|
|
32
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1024 * 1024 || (request.timeoutMs !== undefined && (!Number.isSafeInteger(request.timeoutMs) || request.timeoutMs < 1)))
|
|
33
|
+
return empty('blocked', 'invalid_bounds');
|
|
34
|
+
let profile, executable;
|
|
35
|
+
try {
|
|
36
|
+
profile = sandboxProfile(request);
|
|
37
|
+
executable = realpathSync(request.executable);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return empty('blocked', 'spawn_failed');
|
|
41
|
+
}
|
|
42
|
+
return new Promise(resolve => {
|
|
43
|
+
const stdout = createHash('sha256'), stderr = createHash('sha256'), chunks = [];
|
|
44
|
+
let outBytes = 0, errBytes = 0, reason, timer;
|
|
45
|
+
let child;
|
|
46
|
+
try {
|
|
47
|
+
child = spawn('/usr/bin/sandbox-exec', ['-p', profile, executable, ...request.argv], {
|
|
48
|
+
cwd: request.cwd ?? '/', env: { PATH: '/usr/bin:/bin', LANG: 'C', HOME: '/nonexistent', TMPDIR: '/nonexistent' }, stdio: ['ignore', 'pipe', 'pipe'], detached: true,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
resolve(empty('blocked', 'spawn_failed'));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const kill = () => { try {
|
|
56
|
+
if (child.pid)
|
|
57
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
58
|
+
}
|
|
59
|
+
catch { /* own group already exited */ } };
|
|
60
|
+
const stop = (why) => { reason ??= why; kill(); };
|
|
61
|
+
const abort = () => stop('aborted');
|
|
62
|
+
request.signal?.addEventListener('abort', abort, { once: true });
|
|
63
|
+
if (request.signal?.aborted)
|
|
64
|
+
abort();
|
|
65
|
+
if (request.timeoutMs !== undefined)
|
|
66
|
+
timer = setTimeout(() => stop('timeout'), request.timeoutMs);
|
|
67
|
+
child.stdout.on('data', (chunk) => { outBytes += chunk.length; stdout.update(chunk); if (outBytes + (retainStdout ? 0 : errBytes) > limit)
|
|
68
|
+
stop('output_limit');
|
|
69
|
+
else if (retainStdout)
|
|
70
|
+
chunks.push(chunk); });
|
|
71
|
+
// Legacy callers bounded stdout only and drained arbitrary stderr.
|
|
72
|
+
// P1 bounds both streams together, without retaining either stream.
|
|
73
|
+
child.stderr.on('data', (chunk) => { errBytes += chunk.length; stderr.update(chunk); if (!retainStdout && outBytes + errBytes > limit)
|
|
74
|
+
stop('output_limit'); });
|
|
75
|
+
child.stdout.on('error', () => stop('spawn_failed'));
|
|
76
|
+
child.stderr.on('error', () => stop('spawn_failed'));
|
|
77
|
+
child.on('error', () => stop('spawn_failed'));
|
|
78
|
+
child.on('close', (code, signal) => {
|
|
34
79
|
clearTimeout(timer);
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if (child.pid)
|
|
43
|
-
process.kill(-child.pid, 'SIGKILL');
|
|
44
|
-
}
|
|
45
|
-
catch { /* already exited */ }
|
|
46
|
-
};
|
|
47
|
-
const timer = request.timeoutMs === undefined ? undefined : setTimeout(() => { kill(); finish(new Error('LOCAL_SANDBOX_UNAVAILABLE: command timed out')); }, request.timeoutMs);
|
|
48
|
-
child.stdout.on('data', (chunk) => {
|
|
49
|
-
size += chunk.length;
|
|
50
|
-
if (size > 1024 * 1024) {
|
|
51
|
-
kill();
|
|
52
|
-
finish(new Error('LOCAL_SANDBOX_UNAVAILABLE: output limit'));
|
|
53
|
-
}
|
|
54
|
-
else
|
|
55
|
-
chunks.push(chunk);
|
|
80
|
+
request.signal?.removeEventListener('abort', abort);
|
|
81
|
+
kill();
|
|
82
|
+
if (request.signal?.aborted)
|
|
83
|
+
reason = 'aborted';
|
|
84
|
+
reason ??= signal ? 'signal' : code === 0 ? undefined : 'nonzero_exit';
|
|
85
|
+
const status = reason === 'aborted' ? 'cancelled' : reason === 'timeout' ? 'timed_out' : reason === 'output_limit' || reason === 'spawn_failed' ? 'blocked' : reason ? 'failed' : 'passed';
|
|
86
|
+
resolve({ measurement: { status, exit_code: code, duration_ms: Math.max(0, Math.floor(performance.now() - start)), stdout_sha256: reason === 'spawn_failed' ? null : stdout.digest('hex'), stderr_sha256: reason === 'spawn_failed' ? null : stderr.digest('hex'), stdout_bytes: outBytes, stderr_bytes: errBytes, ...(reason ? { reason } : {}) }, output: retainStdout && status === 'passed' ? Buffer.concat(chunks).toString('utf8') : '' });
|
|
56
87
|
});
|
|
57
|
-
// Do not disclose arbitrary subprocess stderr or paths to the model.
|
|
58
|
-
child.stderr.resume();
|
|
59
|
-
child.on('error', () => { kill(); finish(new Error('LOCAL_SANDBOX_UNAVAILABLE: could not start restricted process')); });
|
|
60
|
-
child.on('close', (code) => { kill(); finish(code === 0 ? undefined : new Error('LOCAL_SANDBOX_UNAVAILABLE: restricted process failed')); });
|
|
61
88
|
});
|
|
62
89
|
}
|
|
90
|
+
export async function runSandboxedMeasured(request) { return (await executeSandboxed(request, false)).measurement; }
|
|
91
|
+
export async function runSandboxed(request) {
|
|
92
|
+
const result = await executeSandboxed(request, true);
|
|
93
|
+
if (result.measurement.status !== 'passed')
|
|
94
|
+
throw new Error('LOCAL_SANDBOX_UNAVAILABLE: restricted process failed');
|
|
95
|
+
return result.output;
|
|
96
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { RunnerObservedVerifier } from './runner-observed-verifier.js';
|
|
2
|
+
import { isP1WorkingBranch } from '../publication/git-snapshot.js';
|
|
3
|
+
import { protocolHash } from '../publication/candidate.js';
|
|
4
|
+
export class RunnerObservedExecutionHost {
|
|
5
|
+
privateRoot;
|
|
6
|
+
registration;
|
|
7
|
+
constructor(privateRoot, registration) {
|
|
8
|
+
this.privateRoot = privateRoot;
|
|
9
|
+
this.registration = structuredClone(registration);
|
|
10
|
+
}
|
|
11
|
+
options(input) {
|
|
12
|
+
const p = input.job.taskPackage, r = p.repository;
|
|
13
|
+
return { root: input.checkoutPath, privateRoot: this.privateRoot, registration: this.registration, verification: p.verification,
|
|
14
|
+
fence: { binding_id: input.bindingId, job_id: input.job.id, generation: input.job.generation, contract_fingerprint: p.contract_fingerprint, base_commit_sha: r.base_commit_sha, working_branch: r.working_branch },
|
|
15
|
+
allowedPaths: p.allowed_paths, blockedPaths: r.blocked_paths, candidateLimits: p.candidate_limits,
|
|
16
|
+
current: input.current, freezeOwnedModel: input.freezeOwnedModel, assertModelFrozen: input.assertModelFrozen, signal: input.signal };
|
|
17
|
+
}
|
|
18
|
+
assertEligible(job) {
|
|
19
|
+
const r = this.registration;
|
|
20
|
+
if (r.manifest.schema !== 'local-project-verification-v1' || !r.review.receiptId?.trim() || !r.review.reviewer?.trim() || r.review.manifestFingerprint !== protocolHash(r.manifest))
|
|
21
|
+
throw Error('P1_REGISTRATION_REJECTED');
|
|
22
|
+
new RunnerObservedVerifier(this.options({ job, checkoutPath: '', bindingId: 'preflight', signal: new AbortController().signal, current: async () => false, freezeOwnedModel: async () => { throw Error('P1_NOT_FROZEN'); }, assertModelFrozen: async () => { throw Error('P1_NOT_FROZEN'); } })).assertContract();
|
|
23
|
+
}
|
|
24
|
+
verify(input) { return new RunnerObservedVerifier(this.options(input)).verify(); }
|
|
25
|
+
}
|
|
26
|
+
const record = (v) => { if (!v || typeof v !== 'object' || Array.isArray(v))
|
|
27
|
+
throw Error('P1_CONTRACT_REJECTED'); return v; };
|
|
28
|
+
const equal = (a, b) => a !== undefined && b !== undefined && protocolHash(a) === protocolHash(b);
|
|
29
|
+
export const hasP1 = (p) => {
|
|
30
|
+
const v = p.verification, policy = p.policy;
|
|
31
|
+
return policy?.policy_key === 'local-runner-verified-v1' || !!v && (v.schema_version !== undefined || v.mode !== undefined);
|
|
32
|
+
};
|
|
33
|
+
/** Compare host launch claims to authoritative WEC/bundle fields before start. */
|
|
34
|
+
export function assertP1Bindings(p, canonical, bundle = false) {
|
|
35
|
+
const v = record(p.verification), r = record(p.repository), limits = record(p.candidate_limits);
|
|
36
|
+
if (p.local_fix_scope !== undefined || p.verification_mode === 'gateway_ci_v1' || p.runtime_adapter !== 'codex_cli' || p.runtime_adapter_version !== '1' || p.runtime_transport !== 'official_exec_v1' || v.schema_version !== 'local-runner-verification-v1' || v.policy_key !== 'local-runner-verified-v1' || v.mode !== 'runner_observed_v1' || v.runtime_adapter !== p.runtime_adapter || v.runtime_transport !== p.runtime_transport || r.access !== 'write' || !isP1WorkingBranch(r.working_branch) || !equal(p.allowed_paths, v.allowed_paths) || !equal(r.allowed_paths, v.allowed_paths) || !Array.isArray(r.blocked_paths))
|
|
37
|
+
throw Error('P1_CONTRACT_REJECTED');
|
|
38
|
+
if (Object.keys(limits).sort().join(',') !== 'max_changed_files,max_diff_bytes,max_file_bytes' || Object.values(limits).some(n => !Number.isSafeInteger(n) || Number(n) < 1))
|
|
39
|
+
throw Error('P1_CONTRACT_REJECTED');
|
|
40
|
+
if (!canonical)
|
|
41
|
+
return;
|
|
42
|
+
const execution = canonical.agent_execution;
|
|
43
|
+
if (canonical.local_fix_scope !== undefined || execution?.local_fix_scope !== undefined || execution?.publication_mode === 'gateway_ci_v1')
|
|
44
|
+
throw Error('P1_CONTRACT_REJECTED');
|
|
45
|
+
if (!bundle) {
|
|
46
|
+
const policy = record(canonical.policy);
|
|
47
|
+
if (policy.policy_key !== 'local-runner-verified-v1' || policy.loop_type !== 'agent_runtime_implementation')
|
|
48
|
+
throw Error('P1_CONTRACT_REJECTED');
|
|
49
|
+
}
|
|
50
|
+
const repos = canonical.repositories;
|
|
51
|
+
if (!Array.isArray(repos) || repos.length !== 1)
|
|
52
|
+
throw Error('P1_CONTRACT_REJECTED');
|
|
53
|
+
const repo = record(repos[0]);
|
|
54
|
+
const bound = bundle ? record(canonical.contract) : canonical;
|
|
55
|
+
if (!equal(v, bound.verification) || !equal(limits, record(bound.policy).candidate_limits) || repo.repository_key !== r.key || repo.access !== r.access || repo.base_commit_sha !== r.base_commit_sha || repo.working_branch !== r.working_branch || !equal(repo.allowed_paths, r.allowed_paths) || !equal(repo.blocked_paths, r.blocked_paths))
|
|
56
|
+
throw Error('P1_CONTRACT_REJECTED');
|
|
57
|
+
if (bundle && (bound.content_fingerprint !== p.contract_fingerprint || canonical.verification !== undefined && !equal(v, canonical.verification) || canonical.candidate_limits !== undefined && !equal(limits, canonical.candidate_limits)))
|
|
58
|
+
throw Error('P1_CONTRACT_REJECTED');
|
|
59
|
+
}
|
|
60
|
+
/** P1 model events carry narrative/progress only. Proof is injected afterwards. */
|
|
61
|
+
export function p1ModelEvent(event) {
|
|
62
|
+
if (event.eventType === 'test_started' || event.eventType === 'test_result')
|
|
63
|
+
return { eventType: 'progress', summary: 'model tool activity', payload: { kind: 'tool_activity' } };
|
|
64
|
+
const payload = {};
|
|
65
|
+
for (const key of ['reason', 'investigation_summary', 'question']) {
|
|
66
|
+
const value = event.payload?.[key];
|
|
67
|
+
if (typeof value === 'string')
|
|
68
|
+
payload[key] = value.slice(0, 6000);
|
|
69
|
+
}
|
|
70
|
+
// Only parsers can attach these provider observations; never completion text.
|
|
71
|
+
const usage = event.payload?.usage;
|
|
72
|
+
if (usage && typeof usage === 'object' && !Array.isArray(usage)) {
|
|
73
|
+
const u = usage, provider = ['openai', 'anthropic'].includes(String(u.provider)) ? u.provider : null;
|
|
74
|
+
const source = ['codex_cli.turn.completed', 'codex_app_server.thread/tokenUsage/updated', 'claude_code_cli.result'].includes(String(u.source)) ? u.source : null;
|
|
75
|
+
payload.usage = { provider, source, ...Object.fromEntries(['input_tokens', 'cached_input_tokens', 'output_tokens', 'duration_ms'].map(k => [k, typeof u[k] === 'number' && Number.isSafeInteger(u[k]) && Number(u[k]) >= 0 && Number(u[k]) <= 2147483647 ? u[k] : null])) };
|
|
76
|
+
}
|
|
77
|
+
return { ...event, payload };
|
|
78
|
+
}
|