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.
@@ -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
- export async function runSandboxed(request) {
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
- throw new Error('LOCAL_SANDBOX_UNAVAILABLE: macOS Seatbelt required');
23
- const profile = sandboxProfile(request);
24
- return new Promise((resolve, reject) => {
25
- const chunks = [];
26
- let size = 0, settled = false;
27
- const child = spawn('/usr/bin/sandbox-exec', ['-p', profile, realpathSync(request.executable), ...request.argv], {
28
- cwd: request.cwd ?? '/', env: { PATH: '/usr/bin:/bin', LANG: 'C', HOME: '/nonexistent', TMPDIR: '/nonexistent' }, stdio: ['ignore', 'pipe', 'pipe'], detached: true, signal: request.signal,
29
- });
30
- const finish = (error) => {
31
- if (settled)
32
- return;
33
- settled = true;
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
- if (error)
36
- reject(error);
37
- else
38
- resolve(Buffer.concat(chunks).toString('utf8'));
39
- };
40
- const kill = () => {
41
- try {
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
+ }
@@ -0,0 +1,147 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { realpath } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { protocolHash, protocolJson } from '../publication/candidate.js';
5
+ import { GitSnapshotHost, isP1WorkingBranch } from '../publication/git-snapshot.js';
6
+ import { createIsolatedCommandPolicy } from '../publication/isolated-verifier.js';
7
+ import { buildReviewedProjectRegistration } from '../publication/project-registration.js';
8
+ const denied = () => Error('RUNNER_VERIFICATION_REJECTED');
9
+ const hex = (v, n) => typeof v === 'string' && new RegExp(`^[a-f0-9]{${n}}$`).test(v);
10
+ const same = (a, b) => protocolHash(a) === protocolHash(b);
11
+ const ceilings = { max_commands: 8, max_command_runtime_ms: 120000, max_output_bytes: 1048576, max_manifest_entries: 100, max_manifest_bytes: 32768, max_contract_bytes: 65536 };
12
+ const privatePart = /^(?:\.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;
13
+ const exactPath = (name) => typeof name === 'string' && /^[A-Za-z0-9_][A-Za-z0-9_.-]*(?:\/[A-Za-z0-9_.-]+)*$/.test(name) && name.split('/').every(p => p !== '.' && p !== '..' && !privatePart.test(p));
14
+ const isBlocked = (name, scopes) => scopes.some(p => p === '.env.*' ? name.startsWith('.env.') : p === '**' || name === p || name.startsWith(p.replace(/\/\*\*$/, '') + '/'));
15
+ function keys(object, allowed) { if (!object || typeof object !== 'object' || Array.isArray(object) || Object.keys(object).some(k => !allowed.includes(k)))
16
+ throw denied(); }
17
+ /** Canonical P1 domain. Never uses legacy terminal T/U bytes or diff hashing. */
18
+ export function buildP1ChangedPaths(input, bounds) {
19
+ const entries = [...input.entries].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
20
+ if (!hex(input.candidate_commit_sha, 40) || !hex(input.base_commit_sha, 40) || entries.length < 1 || entries.length > bounds.max_manifest_entries || new Set(entries.map(e => e.path)).size !== entries.length || entries.some(e => !exactPath(e.path) || !['added', 'modified', 'deleted'].includes(e.status)))
21
+ throw denied();
22
+ const value = { schema_version: 'runner-changed-paths-v1', candidate_commit_sha: input.candidate_commit_sha, base_commit_sha: input.base_commit_sha, digest_domain: 'p1-changed-paths-v1', entries };
23
+ const manifest = { ...value, manifest_sha256: protocolHash(value) };
24
+ if (Buffer.byteLength(protocolJson(manifest)) > bounds.max_manifest_bytes)
25
+ throw denied();
26
+ return manifest;
27
+ }
28
+ /** Verification-only host composition. All temporary artifacts are disposed
29
+ * before resolving, including failed commands; cleanup failure rejects the
30
+ * entire result. No publisher, remote, model report or credentials are used. */
31
+ export class RunnerObservedVerifier {
32
+ options;
33
+ constructor(input) { this.options = { ...input, verification: structuredClone(input.verification), fence: structuredClone(input.fence), registration: input.registration && structuredClone(input.registration), allowedPaths: [...input.allowedPaths], blockedPaths: [...input.blockedPaths], candidateLimits: { ...input.candidateLimits } }; }
34
+ assertContract() { try {
35
+ this.validate();
36
+ }
37
+ catch {
38
+ throw denied();
39
+ } }
40
+ validate() {
41
+ const o = this.options, v = o.verification, p = v.profile_definition, r = o.registration, f = o.fence;
42
+ keys(v, ['schema_version', 'mode', 'policy_key', 'profile_id', 'profile_fingerprint', 'project_id', 'runtime_adapter', 'runtime_transport', 'profile_definition', 'frozen_criteria_fingerprint', 'frozen_loop_contract', 'commands', 'command_manifest', 'bounds', 'allowed_paths', 'required_commands', 'qa_scenario_ids', 'required_criteria', 'independent_review_required']);
43
+ keys(p, ['schema_version', 'reviewed', 'project_ids', 'runtime_adapters', 'runtime_transports', 'allowed_paths', 'bounds', 'commands', 'bindings']);
44
+ keys(v.bounds, Object.keys(ceilings));
45
+ if (!r || !r.review || ![f.binding_id, f.job_id].every(id => typeof id === 'string' && /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(id)) || !Number.isSafeInteger(f.generation) || f.generation < 1 || !hex(f.contract_fingerprint, 64) || !hex(f.base_commit_sha, 40) || !isP1WorkingBranch(f.working_branch) || v.schema_version !== 'local-runner-verification-v1' || v.mode !== 'runner_observed_v1' || v.policy_key !== 'local-runner-verified-v1' || !v.profile_id || p.schema_version !== 'local-runner-profile-v1' || p.reviewed !== true || r.profileId !== v.profile_id || r.profileFingerprint !== v.profile_fingerprint || protocolHash(p) !== v.profile_fingerprint || protocolHash(v.frozen_loop_contract) !== v.frozen_criteria_fingerprint)
46
+ throw denied();
47
+ for (const key of Object.keys(ceilings))
48
+ if (!Number.isSafeInteger(v.bounds[key]) || v.bounds[key] < 1 || v.bounds[key] > ceilings[key])
49
+ throw denied();
50
+ if (!same(v.bounds, p.bounds) || Buffer.byteLength(protocolJson(v)) > v.bounds.max_contract_bytes || !same(o.allowedPaths, v.allowed_paths) || !same(v.allowed_paths, p.allowed_paths) || !Array.isArray(v.allowed_paths) || !v.allowed_paths.length || v.allowed_paths.length > v.bounds.max_manifest_entries || new Set(v.allowed_paths).size !== v.allowed_paths.length || v.allowed_paths.some(n => !exactPath(n) || isBlocked(n, o.blockedPaths)))
51
+ throw denied();
52
+ for (const list of [p.project_ids, p.runtime_adapters, p.runtime_transports])
53
+ if (!Array.isArray(list) || !list.length || list.some(s => typeof s !== 'string' || !s))
54
+ throw denied();
55
+ if (!p.project_ids.includes(v.project_id) || v.runtime_adapter !== 'codex_cli' || !p.runtime_adapters.includes(v.runtime_adapter) || v.runtime_transport !== 'official_exec_v1' || !p.runtime_transports.includes(v.runtime_transport) || r.manifest.project !== v.project_id || !same(v.command_manifest, p.commands) || !same(v.command_manifest, r.manifest.commands) || !Array.isArray(p.commands) || !p.commands.length || p.commands.length > v.bounds.max_commands || new Set(p.commands.map(c => c.id)).size !== p.commands.length)
56
+ throw denied();
57
+ for (const command of p.commands) {
58
+ keys(command, ['id', 'executable', 'argv', 'integrity', 'candidateReadPaths']);
59
+ keys(command.integrity, ['executableSha256', 'readFiles']);
60
+ if (!/^[a-z0-9][a-z0-9_-]{0,95}$/.test(command.id) || !path.isAbsolute(command.executable) || path.basename(command.executable) !== 'node' || !hex(command.integrity.executableSha256, 64) || !Array.isArray(command.argv) || !command.argv.length || command.argv.length > 32 || command.argv.some(a => !exactPath(a)))
61
+ throw denied();
62
+ }
63
+ if (!Array.isArray(p.bindings) || new Set(p.bindings.map(b => b.criterion_id)).size !== p.bindings.length || !['targeted_tests', 'required_suite'].every(c => p.bindings.some(b => b.criterion_id === c)))
64
+ throw denied();
65
+ const expected = p.bindings.map(b => { keys(b, ['criterion_id', 'command_id']); const command = p.commands.find(c => c.id === b.command_id); if (!command || !['targeted_tests', 'required_suite', 'reproduction'].includes(b.criterion_id))
66
+ throw denied(); return { ...b, command_fingerprint: protocolHash(command) }; });
67
+ if (!same(v.commands, expected))
68
+ throw denied();
69
+ const frozen = v.frozen_loop_contract, criteria = frozen.criteria;
70
+ if (frozen.loop_type !== 'agent_runtime_implementation' || frozen.policy_key !== v.policy_key || !same(frozen.mandatory_criteria, ['targeted_tests', 'required_suite', 'diff_scope']) || !criteria || !['targeted_tests', 'required_suite', 'diff_scope'].every(id => Object.hasOwn(criteria, id)))
71
+ throw denied();
72
+ for (const [id, c] of Object.entries(criteria))
73
+ if (!['targeted_tests', 'required_suite', 'diff_scope', 'reproduction'].includes(id) || c.minimum_trust !== 'client_reported' || !same(c.producers, ['runtime_event']) || !same(c.verifiers, [id === 'diff_scope' ? 'runner_changed_paths' : 'runner_command_receipt']))
74
+ throw denied();
75
+ for (const limit of Object.values(o.candidateLimits))
76
+ if (!Number.isSafeInteger(limit) || limit < 1)
77
+ throw denied();
78
+ }
79
+ async active() {
80
+ if (this.options.signal?.aborted || !await this.options.current() || this.options.signal?.aborted)
81
+ throw denied();
82
+ await this.options.assertModelFrozen();
83
+ if (this.options.signal?.aborted)
84
+ throw denied();
85
+ }
86
+ async verify() {
87
+ let host;
88
+ try {
89
+ this.validate();
90
+ const o = this.options, v = o.verification, f = o.fence;
91
+ if (o.signal?.aborted || !await o.current())
92
+ throw denied();
93
+ await o.freezeOwnedModel();
94
+ await this.active();
95
+ if (!path.isAbsolute(o.root) || await realpath(o.root) !== o.root || !path.isAbsolute(o.privateRoot) || await realpath(o.privateRoot) !== o.privateRoot || o.privateRoot === o.root || o.privateRoot.startsWith(o.root + '/'))
96
+ throw denied();
97
+ host = o.host ?? new GitSnapshotHost({ root: o.root, privateRoot: o.privateRoot, git: o.registration.git, baseCommitSha: f.base_commit_sha, workingBranch: f.working_branch, p1WorkingBranch: f.working_branch, maxFileBytes: o.candidateLimits.max_file_bytes, maxTotalBytes: Math.max(o.candidateLimits.max_file_bytes, o.candidateLimits.max_diff_bytes) });
98
+ const sealed = await host.materialize(), observation = structuredClone(sealed.observation);
99
+ if (observation.base_commit_sha !== f.base_commit_sha || observation.head_commit_sha !== f.base_commit_sha || observation.working_branch !== f.working_branch || !observation.changed_files.length || observation.changed_files.length > o.candidateLimits.max_changed_files || Buffer.byteLength(observation.diff) > o.candidateLimits.max_diff_bytes || observation.changed_files.some(e => !o.allowedPaths.includes(e.path) || !exactPath(e.path) || isBlocked(e.path, o.blockedPaths)))
100
+ throw denied();
101
+ await this.active();
102
+ await sealed.assertUnchanged();
103
+ const candidate = await host.verificationCandidate(sealed);
104
+ await candidate.assertUnchanged();
105
+ if (!hex(candidate.commitSha, 40))
106
+ throw denied();
107
+ const changed_paths = buildP1ChangedPaths({ candidate_commit_sha: candidate.commitSha, base_commit_sha: f.base_commit_sha, entries: observation.changed_files }, v.bounds);
108
+ const snapshot_fingerprint = protocolHash({ digest_domain: 'p1-snapshot-v1', fence: f, profile_fingerprint: v.profile_fingerprint, frozen_criteria_fingerprint: v.frozen_criteria_fingerprint, tree_fingerprint: sealed.treeFingerprint, diff_sha256: createHash('sha256').update(observation.diff).digest('hex'), changed_paths });
109
+ const registered = await buildReviewedProjectRegistration({ root: candidate.root, manifest: o.registration.manifest, review: o.registration.review, allowedPaths: o.allowedPaths, blockedPaths: o.blockedPaths });
110
+ const policy = await createIsolatedCommandPolicy({ root: candidate.root, commands: registered.commands, allowedPaths: o.allowedPaths, blockedPaths: o.blockedPaths }, true);
111
+ const receipts = [];
112
+ try {
113
+ for (const command of registered.commands) {
114
+ await this.active();
115
+ await host.assertSourceUnchanged(sealed);
116
+ await candidate.assertUnchanged();
117
+ const measurement = await policy.measureTest(command.id, { timeoutMs: v.bounds.max_command_runtime_ms, maxOutputBytes: v.bounds.max_output_bytes }, o.signal);
118
+ await this.active();
119
+ await host.assertSourceUnchanged(sealed);
120
+ await candidate.assertUnchanged();
121
+ const identity = { binding_id: f.binding_id, job_id: f.job_id, generation: f.generation, contract_fingerprint: f.contract_fingerprint, profile_fingerprint: v.profile_fingerprint, frozen_criteria_fingerprint: v.frozen_criteria_fingerprint, candidate_commit_sha: candidate.commitSha, snapshot_fingerprint, command_id: command.id, command_fingerprint: protocolHash(command) };
122
+ receipts.push({ schema_version: 'runner-command-receipt-v1', receipt_id: protocolHash({ digest_domain: 'p1-command-receipt-id-v1', ...identity }), ...identity, observed_at: Math.floor(Date.now() / 1000), ...measurement });
123
+ }
124
+ }
125
+ finally {
126
+ policy.revoke();
127
+ }
128
+ await this.active();
129
+ await host.assertSourceUnchanged(sealed);
130
+ await candidate.assertUnchanged();
131
+ return { candidate_commit_sha: candidate.commitSha, snapshot_fingerprint, changed_paths, receipts };
132
+ }
133
+ catch {
134
+ throw denied();
135
+ }
136
+ finally {
137
+ if (host)
138
+ try {
139
+ await host.dispose();
140
+ await this.active();
141
+ }
142
+ catch {
143
+ throw denied();
144
+ }
145
+ }
146
+ }
147
+ }
@@ -1,6 +1,7 @@
1
1
  import { access, mkdir, realpath, rename, writeFile } from "node:fs/promises";
2
2
  import { createHash } from "node:crypto";
3
3
  import { resolve, join, sep } from "node:path";
4
+ import { isP1WorkingBranch } from '../publication/git-snapshot.js';
4
5
  export class WorkspaceManager {
5
6
  deps;
6
7
  constructor(deps) {
@@ -13,7 +14,7 @@ export class WorkspaceManager {
13
14
  throw new Error("Invalid repository key");
14
15
  if (!/^[a-f0-9]{40}$/.test(input.baseCommitSha))
15
16
  throw new Error("Repository revision must be an exact SHA");
16
- if (input.workingBranch !== undefined && !/^ai-work\/local\/[A-Za-z0-9-]+$/.test(input.workingBranch))
17
+ if (input.p1WorkingBranch !== undefined ? (input.p1WorkingBranch !== input.workingBranch || !isP1WorkingBranch(input.p1WorkingBranch)) : (input.workingBranch !== undefined && !/^ai-work\/local\/[A-Za-z0-9-]+$/.test(input.workingBranch)))
17
18
  throw new Error('Invalid approved fix branch');
18
19
  if (!input.providerUrl)
19
20
  throw new Error("Repository provider remote is required");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtech-ai-local-runner",
3
- "version": "0.1.32",
3
+ "version": "0.1.34",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "bin": {