mtech-ai-local-runner 0.1.42 → 0.1.44
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 +22 -0
- package/dist/src/publication/git-snapshot.js +50 -17
- package/dist/src/publication/production-host.js +3 -3
- package/dist/src/publication/raw-git-objects.js +17 -7
- package/dist/src/publication/server-ci-host.js +2 -2
- package/dist/src/verification/runner-observed-verifier.js +20 -15
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
1
|
+
## 0.1.44: stop repeating whole-checkout checks in P1 verification
|
|
2
|
+
|
|
3
|
+
The P1 verifier rereads the whole model checkout for every source check and every candidate check. On the AI
|
|
4
|
+
Gateway repository (3,781 files) the live pilot spent about 12.5 minutes in `p1_verify`, and part of that was the same
|
|
5
|
+
check run twice with nothing in between. Each command is now bracketed by one fresh source check and one fresh
|
|
6
|
+
candidate check: before the first command, between commands and after the last. A source re-check compares the Git
|
|
7
|
+
listings and every file's mode and sha256 with the values recorded at the seal instead of rereading every base blob,
|
|
8
|
+
and an object already present in the private checkout is verified without being compressed again. Every file is
|
|
9
|
+
still read and hashed at each check, so the same changes are rejected as before.
|
|
10
|
+
|
|
11
|
+
## 0.1.43: give whole-checkout snapshots whole-checkout bounds
|
|
12
|
+
|
|
13
|
+
The P1 verifier snapshots the whole model checkout before it runs the reviewed commands. It passed the candidate limits
|
|
14
|
+
(`max_file_bytes` and `max_diff_bytes`, 16 KiB in the pilot contract) as the snapshot's own per-file and whole-tree
|
|
15
|
+
bounds, so any real project failed with `SNAPSHOT_REJECTED`: the AI Gateway checkout has 3,781 files, 48.5 MB and 588
|
|
16
|
+
files above 16 KiB. The snapshot now takes the same 128 MiB whole-tree memory bound as the publication hosts. The
|
|
17
|
+
candidate limits still apply to the changed files: their count, the diff size and, newly explicit, each changed file's
|
|
18
|
+
before and after size. Local Fix had the same defect, and Local Fix → PR could not publish on the AI Gateway repository because
|
|
19
|
+
an unchanged 1.09 MB file exceeds the 1 MiB `max_file_bytes` cap. Its production and server-CI snapshots and its Git
|
|
20
|
+
publication transport now use the same shared `SNAPSHOT_TREE_MAX_BYTES`. Changed files are still bounded by
|
|
21
|
+
`max_file_bytes`.
|
|
22
|
+
|
|
1
23
|
## 0.1.42: keep the guardian's reports when a late terminate fails
|
|
2
24
|
|
|
3
25
|
The owned guardian now reports to the host on its own pipe (fd3) and takes the host's commands on another (fd5);
|
|
@@ -3,21 +3,24 @@ import { promisify } from 'node:util';
|
|
|
3
3
|
import { constants } from 'node:fs';
|
|
4
4
|
import { lstat, mkdir, mkdtemp, open, realpath, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
5
5
|
import { createHash } from 'node:crypto';
|
|
6
|
-
import { deflateSync } from 'node:zlib';
|
|
7
6
|
import path from 'node:path';
|
|
8
7
|
import { measureCommandFile } from '../safety/registered-command-integrity.js';
|
|
9
8
|
import { protocolHash } from './candidate.js';
|
|
10
|
-
import { RawGitObjects } from './raw-git-objects.js';
|
|
9
|
+
import { deflateObject, RawGitObjects } from './raw-git-objects.js';
|
|
11
10
|
const execute = promisify(execFile), denied = () => Error('SNAPSHOT_REJECTED');
|
|
12
11
|
const digest = (data) => createHash('sha256').update(data).digest('hex');
|
|
13
12
|
const safe = (p) => p.length > 0 && !path.isAbsolute(p) && !p.includes('\\') && !p.includes('\0') && p.split('/').every(s => s !== '' && s !== '.' && s !== '..' && !/^(?:\.git|\.ssh|\.aws|\.codex|\.claude|\.env|\.netrc|\.npmrc|credentials(?:\..*)?)$/i.test(s));
|
|
14
13
|
/** P1 branch shape only. Authority still requires exact frozen-fence equality. */
|
|
14
|
+
/** Whole-checkout memory bound for a snapshot, per file and in total. A snapshot reads every
|
|
15
|
+
* file of the checkout, so callers bound candidate size on the changed files instead. */
|
|
16
|
+
export const SNAPSHOT_TREE_MAX_BYTES = 128 * 1024 * 1024;
|
|
15
17
|
export const isP1WorkingBranch = (branch) => typeof branch === 'string' && branch.length <= 255 && /^ai-work\/[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/.test(branch) && branch.split('/').every(p => !p.includes('..') && !p.endsWith('.') && !p.endsWith('.lock'));
|
|
16
18
|
/** Reads only immutable Git objects and metadata. Never runs diff against worktree
|
|
17
19
|
* content, where malicious clean filters can execute even with --no-ext-diff. */
|
|
18
20
|
export class GitSnapshotHost {
|
|
19
21
|
options;
|
|
20
22
|
seals = new WeakSet();
|
|
23
|
+
listings = new WeakMap();
|
|
21
24
|
ownedRoots = new Set();
|
|
22
25
|
constructor(options) { this.options = structuredClone(options); }
|
|
23
26
|
async git(args) {
|
|
@@ -132,15 +135,21 @@ export class GitSnapshotHost {
|
|
|
132
135
|
await fd.close();
|
|
133
136
|
}
|
|
134
137
|
}
|
|
135
|
-
|
|
138
|
+
/** Checks the options every read relies on, then that HEAD is the base commit on the working branch. */
|
|
139
|
+
async head() {
|
|
136
140
|
const o = this.options, branchAllowed = o.p1WorkingBranch === undefined ? /^ai-work\/local\/[A-Za-z0-9-]+$/.test(o.workingBranch) : o.p1WorkingBranch === o.workingBranch && isP1WorkingBranch(o.workingBranch);
|
|
137
141
|
if (!/^[a-f0-9]{40}$/.test(o.baseCommitSha) || !branchAllowed || await realpath(o.root) !== o.root || !Number.isSafeInteger(o.maxFileBytes) || o.maxFileBytes < 1 || !Number.isSafeInteger(o.maxTotalBytes) || o.maxTotalBytes < o.maxFileBytes)
|
|
138
142
|
throw denied();
|
|
139
143
|
const head = (await this.git(['rev-parse', '--verify', 'HEAD'])).toString().trim(), branch = (await this.git(['symbolic-ref', '--short', 'HEAD'])).toString().trim();
|
|
140
144
|
if (head !== o.baseCommitSha || branch !== o.workingBranch)
|
|
141
145
|
throw denied();
|
|
142
|
-
|
|
143
|
-
|
|
146
|
+
}
|
|
147
|
+
async capture() {
|
|
148
|
+
const o = this.options;
|
|
149
|
+
await this.head();
|
|
150
|
+
const head = o.baseCommitSha, branch = o.workingBranch;
|
|
151
|
+
const base = new Map(), gitlinks = new Map(), baseListing = await this.git(['ls-tree', '-r', '-z', '--full-tree', o.baseCommitSha]);
|
|
152
|
+
for (const line of baseListing.toString().split('\0').filter(Boolean)) {
|
|
144
153
|
const link = /^160000 commit ([a-f0-9]{40})\t(.+)$/s.exec(line);
|
|
145
154
|
if (link) {
|
|
146
155
|
if (!safe(link[2]))
|
|
@@ -155,8 +164,8 @@ export class GitSnapshotHost {
|
|
|
155
164
|
}
|
|
156
165
|
// Gitlinks remain exact base-tree references. Never traverse or copy their source.
|
|
157
166
|
// Staged replacement/deletion is refused rather than publishing hidden changes.
|
|
158
|
-
const staged = new Map();
|
|
159
|
-
for (const line of
|
|
167
|
+
const staged = new Map(), stagedListing = await this.git(['ls-files', '--stage', '-z']);
|
|
168
|
+
for (const line of stagedListing.toString().split('\0').filter(Boolean)) {
|
|
160
169
|
const entry = /^(100644|100755|160000) ([a-f0-9]{40}) 0\t(.+)$/s.exec(line);
|
|
161
170
|
if (!entry || !safe(entry[3]))
|
|
162
171
|
throw denied();
|
|
@@ -167,11 +176,12 @@ export class GitSnapshotHost {
|
|
|
167
176
|
expected.set(name, '160000 ' + oid);
|
|
168
177
|
if (protocolHash([...expected].sort()) !== protocolHash([...staged].sort()))
|
|
169
178
|
throw denied();
|
|
170
|
-
const
|
|
179
|
+
const untrackedListing = await this.git(['ls-files', '--others', '--exclude-standard', '-z', '--']);
|
|
180
|
+
const names = [...new Set([...base.keys(), ...untrackedListing.toString().split('\0').filter(Boolean)])].sort();
|
|
171
181
|
const blobs = await this.batchObjects([...base.values()].map(v => v.blob));
|
|
172
182
|
const files = [], changes = [];
|
|
173
183
|
let total = 0;
|
|
174
|
-
for (const name of
|
|
184
|
+
for (const name of names) {
|
|
175
185
|
if ([...gitlinks.keys()].some(p => name === p || name.startsWith(p + '/') || p.startsWith(name + '/')))
|
|
176
186
|
throw denied();
|
|
177
187
|
const current = await this.currentFile(name), prior = base.get(name);
|
|
@@ -193,7 +203,30 @@ export class GitSnapshotHost {
|
|
|
193
203
|
changes.push({ path: name, status: !prior ? 'added' : !current ? 'deleted' : 'modified', before: before?.toString('base64') ?? null, after: current?.bytes.toString('base64') ?? null, before_mode: prior?.mode ?? null, after_mode: current?.mode ?? null });
|
|
194
204
|
}
|
|
195
205
|
const observation = { base_commit_sha: o.baseCommitSha, head_commit_sha: head, working_branch: branch, diff: JSON.stringify({ format: 'local-candidate-binary-diff-v1', changes }), changed_files: changes.map(({ path, status }) => ({ path, status })) };
|
|
196
|
-
|
|
206
|
+
const listing = { base: digest(baseListing), staged: digest(stagedListing), untracked: digest(untrackedListing), names };
|
|
207
|
+
return { observation, files, listing, fingerprint: protocolHash({ observation, files: files.map(f => ({ path: f.path, mode: f.mode, sha256: digest(f.bytes) })) }) };
|
|
208
|
+
}
|
|
209
|
+
/** The checkout still equals a capture when HEAD, the branch, the three Git listings and every
|
|
210
|
+
* file's mode and sha256 are unchanged. The capture's observation follows from those alone: the
|
|
211
|
+
* base blobs that an unchanged listing names are immutable objects, so they are not read again. */
|
|
212
|
+
async matches(listing, treeFingerprint) {
|
|
213
|
+
const o = this.options;
|
|
214
|
+
await this.head();
|
|
215
|
+
if (digest(await this.git(['ls-tree', '-r', '-z', '--full-tree', o.baseCommitSha])) !== listing.base || digest(await this.git(['ls-files', '--stage', '-z'])) !== listing.staged || digest(await this.git(['ls-files', '--others', '--exclude-standard', '-z', '--'])) !== listing.untracked)
|
|
216
|
+
throw denied();
|
|
217
|
+
const files = [];
|
|
218
|
+
let total = 0;
|
|
219
|
+
for (const name of listing.names) {
|
|
220
|
+
const current = await this.currentFile(name);
|
|
221
|
+
if (!current)
|
|
222
|
+
continue;
|
|
223
|
+
total += current.bytes.length;
|
|
224
|
+
if (total > o.maxTotalBytes)
|
|
225
|
+
throw denied();
|
|
226
|
+
files.push({ path: current.path, mode: current.mode, sha256: digest(current.bytes) });
|
|
227
|
+
}
|
|
228
|
+
if (protocolHash(files) !== treeFingerprint)
|
|
229
|
+
throw denied();
|
|
197
230
|
}
|
|
198
231
|
/** Creates a separate local-only Git checkout with exact base parent objects.
|
|
199
232
|
* Raw object copying and raw file writes invoke no filters, templates or hooks;
|
|
@@ -238,7 +271,7 @@ export class GitSnapshotHost {
|
|
|
238
271
|
throw denied();
|
|
239
272
|
const dir = path.join(gitdir, 'objects', oid.slice(0, 2));
|
|
240
273
|
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
241
|
-
await writeFile(path.join(dir, oid.slice(2)),
|
|
274
|
+
await writeFile(path.join(dir, oid.slice(2)), deflateObject(raw), { flag: 'wx', mode: 0o400 });
|
|
242
275
|
}
|
|
243
276
|
const copy = async (from, to) => { for (const name of await readdir(from)) {
|
|
244
277
|
const source = path.join(from, name), target = path.join(to, name), stat = await lstat(source);
|
|
@@ -269,11 +302,10 @@ export class GitSnapshotHost {
|
|
|
269
302
|
async publicationCheckout(sealed) { return this.privateCheckout(sealed); }
|
|
270
303
|
async assertSourceUnchanged(sealed) {
|
|
271
304
|
try {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
const capture = await this.capture();
|
|
275
|
-
if (protocolHash(capture.observation) !== protocolHash(sealed.observation) || protocolHash(capture.files.map(f => ({ path: f.path, mode: f.mode, sha256: digest(f.bytes) }))) !== sealed.treeFingerprint)
|
|
305
|
+
const listing = this.listings.get(sealed);
|
|
306
|
+
if (!this.seals.has(sealed) || !listing)
|
|
276
307
|
throw denied();
|
|
308
|
+
await this.matches(listing, sealed.treeFingerprint);
|
|
277
309
|
}
|
|
278
310
|
catch {
|
|
279
311
|
throw denied();
|
|
@@ -368,9 +400,9 @@ export class GitSnapshotHost {
|
|
|
368
400
|
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
369
401
|
await writeFile(file, f.bytes, { flag: 'wx', mode: (f.mode & 0o111) ? 0o500 : 0o400 });
|
|
370
402
|
}
|
|
371
|
-
|
|
372
|
-
throw denied();
|
|
403
|
+
// The source must not have changed while it was copied.
|
|
373
404
|
const expected = protocolHash(capture.files.map(f => ({ path: f.path, mode: f.mode, sha256: digest(f.bytes) })));
|
|
405
|
+
await this.matches(capture.listing, expected);
|
|
374
406
|
const assertUnchanged = async () => {
|
|
375
407
|
const entries = [];
|
|
376
408
|
const walk = async (dir, prefix = '') => { for (const item of (await readdir(dir)).sort()) {
|
|
@@ -393,6 +425,7 @@ export class GitSnapshotHost {
|
|
|
393
425
|
await assertUnchanged();
|
|
394
426
|
const seal = { root, observation: structuredClone(capture.observation), treeFingerprint: expected, assertUnchanged };
|
|
395
427
|
this.seals.add(seal);
|
|
428
|
+
this.listings.set(seal, capture.listing);
|
|
396
429
|
return Object.freeze(seal);
|
|
397
430
|
}
|
|
398
431
|
catch {
|
|
@@ -4,7 +4,7 @@ import { lstat, realpath, open, rename, unlink } from 'node:fs/promises';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { CandidateVerifier, protocolHash } from './candidate.js';
|
|
6
6
|
import { createLocalFixExecutionHost } from './execution-host.js';
|
|
7
|
-
import { GitSnapshotHost } from './git-snapshot.js';
|
|
7
|
+
import { GitSnapshotHost, SNAPSHOT_TREE_MAX_BYTES } from './git-snapshot.js';
|
|
8
8
|
import { createSealedIsolatedVerifier } from './isolated-verifier.js';
|
|
9
9
|
import { FilePublicationJournal } from './journal.js';
|
|
10
10
|
import { DeterministicPublisher, PublicationOutcomeUnknownError } from './publisher.js';
|
|
@@ -55,7 +55,7 @@ export function createProductionLocalFixHost(options) {
|
|
|
55
55
|
} };
|
|
56
56
|
const frozenCurrent = async () => lifecycle.isFrozen() && await current();
|
|
57
57
|
const limits = scope.candidate_limits;
|
|
58
|
-
const snapshots = new GitSnapshotHost({ root: input.checkoutPath, privateRoot: root, git: options.git, baseCommitSha: binding.base_commit_sha, workingBranch: binding.working_branch, maxFileBytes:
|
|
58
|
+
const snapshots = new GitSnapshotHost({ root: input.checkoutPath, privateRoot: root, git: options.git, baseCommitSha: binding.base_commit_sha, workingBranch: binding.working_branch, maxFileBytes: SNAPSHOT_TREE_MAX_BYTES, maxTotalBytes: SNAPSHOT_TREE_MAX_BYTES });
|
|
59
59
|
const isolated = createSealedIsolatedVerifier({ host: snapshots, allowedPaths: verificationPaths, blockedPaths: ['.env', '.env.*', '.git/**', '.ssh/**', '.aws/**'], commands, current: frozenCurrent });
|
|
60
60
|
const verifier = new CandidateVerifier({ binding, root: input.checkoutPath, allowedPaths: allowed, blockedPaths: ['.env', '.env.*', '.git/**', '.ssh/**', '.aws/**'], maxFiles: limits.max_changed_files, maxDiffBytes: limits.max_diff_bytes, maxFileBytes: limits.max_file_bytes, commands, current: frozenCurrent, assertRetainedSeal: async () => { const seal = isolated.materialized(); if (!seal)
|
|
61
61
|
throw denied(); await seal.assertUnchanged(); }, observe: () => snapshots.observe(), verify: isolated.verify });
|
|
@@ -140,7 +140,7 @@ export function createProductionLocalFixHost(options) {
|
|
|
140
140
|
}
|
|
141
141
|
if (!await frozenCurrent())
|
|
142
142
|
throw denied();
|
|
143
|
-
git = new GitPublicationTransport({ checkout, privateRoot: root, git: options.git, sealedTreeFingerprint: seal.treeFingerprint, evidence: candidate.evidence, origin: options.origin, current: frozenCurrent, broker: typeof options.broker === 'function' ? options.broker({ branch: binding.working_branch, current: frozenCurrent }) : options.broker, maxFileBytes:
|
|
143
|
+
git = new GitPublicationTransport({ checkout, privateRoot: root, git: options.git, sealedTreeFingerprint: seal.treeFingerprint, evidence: candidate.evidence, origin: options.origin, current: frozenCurrent, broker: typeof options.broker === 'function' ? options.broker({ branch: binding.working_branch, current: frozenCurrent }) : options.broker, maxFileBytes: SNAPSHOT_TREE_MAX_BYTES, maxTotalBytes: SNAPSHOT_TREE_MAX_BYTES });
|
|
144
144
|
}
|
|
145
145
|
return git;
|
|
146
146
|
};
|
|
@@ -2,6 +2,8 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { deflateSync, inflateSync } from 'node:zlib';
|
|
3
3
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
/** Git reads any zlib level, and these objects live only as long as one private checkout. */
|
|
6
|
+
export const deflateObject = (raw) => deflateSync(raw, { level: 1 });
|
|
5
7
|
/** Local raw objects only: no filters, hooks, Git identity/config or transport. */
|
|
6
8
|
export class RawGitObjects {
|
|
7
9
|
checkout;
|
|
@@ -13,13 +15,21 @@ export class RawGitObjects {
|
|
|
13
15
|
if (write) {
|
|
14
16
|
const dir = path.join(this.checkout, '.git', 'objects', oid.slice(0, 2)), file = path.join(dir, oid.slice(2));
|
|
15
17
|
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
// An object already present is verified, not compressed again only to be discarded.
|
|
19
|
+
let existing = await readFile(file).catch((e) => { if (e.code !== 'ENOENT')
|
|
20
|
+
throw Error('GIT_OBJECT_REJECTED'); return undefined; });
|
|
21
|
+
if (existing === undefined)
|
|
22
|
+
try {
|
|
23
|
+
await writeFile(file, deflateObject(raw), { flag: 'wx', mode: 0o400 });
|
|
24
|
+
return oid;
|
|
25
|
+
}
|
|
26
|
+
catch (e) {
|
|
27
|
+
if (e.code !== 'EEXIST')
|
|
28
|
+
throw Error('GIT_OBJECT_REJECTED');
|
|
29
|
+
existing = await readFile(file);
|
|
30
|
+
}
|
|
31
|
+
if (!inflateSync(existing).equals(raw))
|
|
32
|
+
throw Error('GIT_OBJECT_REJECTED');
|
|
23
33
|
}
|
|
24
34
|
return oid;
|
|
25
35
|
}
|
|
@@ -8,7 +8,7 @@ import { setTimeout as delay } from 'node:timers/promises';
|
|
|
8
8
|
import { MAX_EVENT_SUMMARY_CHARS } from '../protocol.js';
|
|
9
9
|
import { protocolHash } from './candidate.js';
|
|
10
10
|
import { FilePublicationJournal } from './journal.js';
|
|
11
|
-
import { GitSnapshotHost } from './git-snapshot.js';
|
|
11
|
+
import { GitSnapshotHost, SNAPSHOT_TREE_MAX_BYTES } from './git-snapshot.js';
|
|
12
12
|
import { PublicationOutcomeUnknownError } from './publisher.js';
|
|
13
13
|
const denied = () => Error('LOCAL_FIX_SERVER_CI_REJECTED');
|
|
14
14
|
const safePath = (p) => typeof p === 'string' && p.length > 0 && !p.includes('\\') && !p.includes('\0') && !path.isAbsolute(p) && p.split('/').every(s => s !== '' && s !== '.' && s !== '..' && !/^(?:\.git|\.mcp(?:\..*)?|\.npmrc|\.netrc|id_rsa|id_ed25519|\.local-runner|\.runner|\.env(?:\..*)?|\.codex|\.claude|\.ssh|\.aws|\.config|auth\.(?:json|toml|ya?ml)(?:\..*)?|credentials(?:\..*)?|AGENTS\.md|CLAUDE\.md|mcp\.(?:json|toml|ya?ml)(?:\..*)?)$/i.test(s));
|
|
@@ -82,7 +82,7 @@ export function createServerCiLocalFixHost(options) {
|
|
|
82
82
|
return finalization ??= (async () => {
|
|
83
83
|
const report = mode === 'gateway_publish_v1' ? validationReport(completedEvent) : undefined;
|
|
84
84
|
await current();
|
|
85
|
-
const host = new GitSnapshotHost({ root: input.checkoutPath, privateRoot: root, git: options.git, baseCommitSha: binding.base_commit_sha, workingBranch: binding.working_branch, maxFileBytes:
|
|
85
|
+
const host = new GitSnapshotHost({ root: input.checkoutPath, privateRoot: root, git: options.git, baseCommitSha: binding.base_commit_sha, workingBranch: binding.working_branch, maxFileBytes: SNAPSHOT_TREE_MAX_BYTES, maxTotalBytes: SNAPSHOT_TREE_MAX_BYTES });
|
|
86
86
|
const observed = await host.observe();
|
|
87
87
|
seal = await host.materialize();
|
|
88
88
|
if (protocolHash(observed) !== protocolHash(seal.observation) || Buffer.byteLength(observed.diff) > limits.max_diff_bytes || !observed.changed_files.length || observed.changed_files.length > limits.max_changed_files)
|
|
@@ -2,12 +2,21 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { realpath } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { protocolHash, protocolJson } from '../publication/candidate.js';
|
|
5
|
-
import { GitSnapshotHost, isP1WorkingBranch } from '../publication/git-snapshot.js';
|
|
5
|
+
import { GitSnapshotHost, isP1WorkingBranch, SNAPSHOT_TREE_MAX_BYTES } from '../publication/git-snapshot.js';
|
|
6
6
|
import { createIsolatedCommandPolicy } from '../publication/isolated-verifier.js';
|
|
7
7
|
import { buildReviewedProjectRegistration } from '../publication/project-registration.js';
|
|
8
8
|
const denied = () => Error('RUNNER_VERIFICATION_REJECTED');
|
|
9
9
|
const hex = (v, n) => typeof v === 'string' && new RegExp(`^[a-f0-9]{${n}}$`).test(v);
|
|
10
10
|
const same = (a, b) => protocolHash(a) === protocolHash(b);
|
|
11
|
+
/** Byte length of each changed file's before and after content in the sealed binary diff.
|
|
12
|
+
* The snapshot itself takes the whole-checkout bound; candidate limits apply to these files. */
|
|
13
|
+
function changedFileBytes(diff) {
|
|
14
|
+
const parsed = JSON.parse(diff);
|
|
15
|
+
if (parsed.format !== 'local-candidate-binary-diff-v1' || !Array.isArray(parsed.changes))
|
|
16
|
+
throw denied();
|
|
17
|
+
return parsed.changes.flatMap((change) => [change.before, change.after].filter(v => v !== null).map(v => { if (typeof v !== 'string')
|
|
18
|
+
throw denied(); return Buffer.from(v, 'base64').length; }));
|
|
19
|
+
}
|
|
11
20
|
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
21
|
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
22
|
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));
|
|
@@ -94,14 +103,12 @@ export class RunnerObservedVerifier {
|
|
|
94
103
|
await this.active();
|
|
95
104
|
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
105
|
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:
|
|
106
|
+
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: SNAPSHOT_TREE_MAX_BYTES, maxTotalBytes: SNAPSHOT_TREE_MAX_BYTES });
|
|
98
107
|
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)))
|
|
108
|
+
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)) || changedFileBytes(observation.diff).some(n => n > o.candidateLimits.max_file_bytes))
|
|
100
109
|
throw denied();
|
|
101
110
|
await this.active();
|
|
102
|
-
await sealed.assertUnchanged();
|
|
103
111
|
const candidate = await host.verificationCandidate(sealed);
|
|
104
|
-
await candidate.assertUnchanged();
|
|
105
112
|
if (!hex(candidate.commitSha, 40))
|
|
106
113
|
throw denied();
|
|
107
114
|
const changed_paths = buildP1ChangedPaths({ candidate_commit_sha: candidate.commitSha, base_commit_sha: f.base_commit_sha, entries: observation.changed_files }, v.bounds);
|
|
@@ -109,15 +116,15 @@ export class RunnerObservedVerifier {
|
|
|
109
116
|
const registered = await buildReviewedProjectRegistration({ root: candidate.root, manifest: o.registration.manifest, review: o.registration.review, allowedPaths: o.allowedPaths, blockedPaths: o.blockedPaths });
|
|
110
117
|
const policy = await createIsolatedCommandPolicy({ root: candidate.root, commands: registered.commands, allowedPaths: o.allowedPaths, blockedPaths: o.blockedPaths }, true);
|
|
111
118
|
const receipts = [];
|
|
119
|
+
// One fresh source and candidate check before the first command, between commands and after the
|
|
120
|
+
// last. Each check rereads the whole checkout, and repeating one with nothing run in between proves nothing.
|
|
121
|
+
const snapshots = host, check = async () => { await this.active(); await snapshots.assertSourceUnchanged(sealed); await candidate.assertUnchanged(); };
|
|
112
122
|
try {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
await host.assertSourceUnchanged(sealed);
|
|
116
|
-
await candidate.assertUnchanged();
|
|
123
|
+
await check();
|
|
124
|
+
for (const [index, command] of registered.commands.entries()) {
|
|
117
125
|
const measurement = await policy.measureTest(command.id, { timeoutMs: v.bounds.max_command_runtime_ms, maxOutputBytes: v.bounds.max_output_bytes }, o.signal);
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
await candidate.assertUnchanged();
|
|
126
|
+
if (index < registered.commands.length - 1)
|
|
127
|
+
await check();
|
|
121
128
|
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
129
|
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
130
|
}
|
|
@@ -125,9 +132,7 @@ export class RunnerObservedVerifier {
|
|
|
125
132
|
finally {
|
|
126
133
|
policy.revoke();
|
|
127
134
|
}
|
|
128
|
-
await
|
|
129
|
-
await host.assertSourceUnchanged(sealed);
|
|
130
|
-
await candidate.assertUnchanged();
|
|
135
|
+
await check();
|
|
131
136
|
return { candidate_commit_sha: candidate.commitSha, snapshot_fingerprint, changed_paths, receipts };
|
|
132
137
|
}
|
|
133
138
|
catch {
|