mtech-ai-local-runner 0.1.43 → 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
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
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
|
+
|
|
1
11
|
## 0.1.43: give whole-checkout snapshots whole-checkout bounds
|
|
2
12
|
|
|
3
13
|
The P1 verifier snapshots the whole model checkout before it runs the reviewed commands. It passed the candidate limits
|
|
@@ -3,11 +3,10 @@ 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));
|
|
@@ -21,6 +20,7 @@ export const isP1WorkingBranch = (branch) => typeof branch === 'string' && branc
|
|
|
21
20
|
export class GitSnapshotHost {
|
|
22
21
|
options;
|
|
23
22
|
seals = new WeakSet();
|
|
23
|
+
listings = new WeakMap();
|
|
24
24
|
ownedRoots = new Set();
|
|
25
25
|
constructor(options) { this.options = structuredClone(options); }
|
|
26
26
|
async git(args) {
|
|
@@ -135,15 +135,21 @@ export class GitSnapshotHost {
|
|
|
135
135
|
await fd.close();
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
|
-
|
|
138
|
+
/** Checks the options every read relies on, then that HEAD is the base commit on the working branch. */
|
|
139
|
+
async head() {
|
|
139
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);
|
|
140
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)
|
|
141
142
|
throw denied();
|
|
142
143
|
const head = (await this.git(['rev-parse', '--verify', 'HEAD'])).toString().trim(), branch = (await this.git(['symbolic-ref', '--short', 'HEAD'])).toString().trim();
|
|
143
144
|
if (head !== o.baseCommitSha || branch !== o.workingBranch)
|
|
144
145
|
throw denied();
|
|
145
|
-
|
|
146
|
-
|
|
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)) {
|
|
147
153
|
const link = /^160000 commit ([a-f0-9]{40})\t(.+)$/s.exec(line);
|
|
148
154
|
if (link) {
|
|
149
155
|
if (!safe(link[2]))
|
|
@@ -158,8 +164,8 @@ export class GitSnapshotHost {
|
|
|
158
164
|
}
|
|
159
165
|
// Gitlinks remain exact base-tree references. Never traverse or copy their source.
|
|
160
166
|
// Staged replacement/deletion is refused rather than publishing hidden changes.
|
|
161
|
-
const staged = new Map();
|
|
162
|
-
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)) {
|
|
163
169
|
const entry = /^(100644|100755|160000) ([a-f0-9]{40}) 0\t(.+)$/s.exec(line);
|
|
164
170
|
if (!entry || !safe(entry[3]))
|
|
165
171
|
throw denied();
|
|
@@ -170,11 +176,12 @@ export class GitSnapshotHost {
|
|
|
170
176
|
expected.set(name, '160000 ' + oid);
|
|
171
177
|
if (protocolHash([...expected].sort()) !== protocolHash([...staged].sort()))
|
|
172
178
|
throw denied();
|
|
173
|
-
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();
|
|
174
181
|
const blobs = await this.batchObjects([...base.values()].map(v => v.blob));
|
|
175
182
|
const files = [], changes = [];
|
|
176
183
|
let total = 0;
|
|
177
|
-
for (const name of
|
|
184
|
+
for (const name of names) {
|
|
178
185
|
if ([...gitlinks.keys()].some(p => name === p || name.startsWith(p + '/') || p.startsWith(name + '/')))
|
|
179
186
|
throw denied();
|
|
180
187
|
const current = await this.currentFile(name), prior = base.get(name);
|
|
@@ -196,7 +203,30 @@ export class GitSnapshotHost {
|
|
|
196
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 });
|
|
197
204
|
}
|
|
198
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 })) };
|
|
199
|
-
|
|
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();
|
|
200
230
|
}
|
|
201
231
|
/** Creates a separate local-only Git checkout with exact base parent objects.
|
|
202
232
|
* Raw object copying and raw file writes invoke no filters, templates or hooks;
|
|
@@ -241,7 +271,7 @@ export class GitSnapshotHost {
|
|
|
241
271
|
throw denied();
|
|
242
272
|
const dir = path.join(gitdir, 'objects', oid.slice(0, 2));
|
|
243
273
|
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
244
|
-
await writeFile(path.join(dir, oid.slice(2)),
|
|
274
|
+
await writeFile(path.join(dir, oid.slice(2)), deflateObject(raw), { flag: 'wx', mode: 0o400 });
|
|
245
275
|
}
|
|
246
276
|
const copy = async (from, to) => { for (const name of await readdir(from)) {
|
|
247
277
|
const source = path.join(from, name), target = path.join(to, name), stat = await lstat(source);
|
|
@@ -272,11 +302,10 @@ export class GitSnapshotHost {
|
|
|
272
302
|
async publicationCheckout(sealed) { return this.privateCheckout(sealed); }
|
|
273
303
|
async assertSourceUnchanged(sealed) {
|
|
274
304
|
try {
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
const capture = await this.capture();
|
|
278
|
-
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)
|
|
279
307
|
throw denied();
|
|
308
|
+
await this.matches(listing, sealed.treeFingerprint);
|
|
280
309
|
}
|
|
281
310
|
catch {
|
|
282
311
|
throw denied();
|
|
@@ -371,9 +400,9 @@ export class GitSnapshotHost {
|
|
|
371
400
|
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
372
401
|
await writeFile(file, f.bytes, { flag: 'wx', mode: (f.mode & 0o111) ? 0o500 : 0o400 });
|
|
373
402
|
}
|
|
374
|
-
|
|
375
|
-
throw denied();
|
|
403
|
+
// The source must not have changed while it was copied.
|
|
376
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);
|
|
377
406
|
const assertUnchanged = async () => {
|
|
378
407
|
const entries = [];
|
|
379
408
|
const walk = async (dir, prefix = '') => { for (const item of (await readdir(dir)).sort()) {
|
|
@@ -396,6 +425,7 @@ export class GitSnapshotHost {
|
|
|
396
425
|
await assertUnchanged();
|
|
397
426
|
const seal = { root, observation: structuredClone(capture.observation), treeFingerprint: expected, assertUnchanged };
|
|
398
427
|
this.seals.add(seal);
|
|
428
|
+
this.listings.set(seal, capture.listing);
|
|
399
429
|
return Object.freeze(seal);
|
|
400
430
|
}
|
|
401
431
|
catch {
|
|
@@ -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
|
}
|
|
@@ -108,9 +108,7 @@ export class RunnerObservedVerifier {
|
|
|
108
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))
|
|
109
109
|
throw denied();
|
|
110
110
|
await this.active();
|
|
111
|
-
await sealed.assertUnchanged();
|
|
112
111
|
const candidate = await host.verificationCandidate(sealed);
|
|
113
|
-
await candidate.assertUnchanged();
|
|
114
112
|
if (!hex(candidate.commitSha, 40))
|
|
115
113
|
throw denied();
|
|
116
114
|
const changed_paths = buildP1ChangedPaths({ candidate_commit_sha: candidate.commitSha, base_commit_sha: f.base_commit_sha, entries: observation.changed_files }, v.bounds);
|
|
@@ -118,15 +116,15 @@ export class RunnerObservedVerifier {
|
|
|
118
116
|
const registered = await buildReviewedProjectRegistration({ root: candidate.root, manifest: o.registration.manifest, review: o.registration.review, allowedPaths: o.allowedPaths, blockedPaths: o.blockedPaths });
|
|
119
117
|
const policy = await createIsolatedCommandPolicy({ root: candidate.root, commands: registered.commands, allowedPaths: o.allowedPaths, blockedPaths: o.blockedPaths }, true);
|
|
120
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(); };
|
|
121
122
|
try {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
await host.assertSourceUnchanged(sealed);
|
|
125
|
-
await candidate.assertUnchanged();
|
|
123
|
+
await check();
|
|
124
|
+
for (const [index, command] of registered.commands.entries()) {
|
|
126
125
|
const measurement = await policy.measureTest(command.id, { timeoutMs: v.bounds.max_command_runtime_ms, maxOutputBytes: v.bounds.max_output_bytes }, o.signal);
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
await candidate.assertUnchanged();
|
|
126
|
+
if (index < registered.commands.length - 1)
|
|
127
|
+
await check();
|
|
130
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) };
|
|
131
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 });
|
|
132
130
|
}
|
|
@@ -134,9 +132,7 @@ export class RunnerObservedVerifier {
|
|
|
134
132
|
finally {
|
|
135
133
|
policy.revoke();
|
|
136
134
|
}
|
|
137
|
-
await
|
|
138
|
-
await host.assertSourceUnchanged(sealed);
|
|
139
|
-
await candidate.assertUnchanged();
|
|
135
|
+
await check();
|
|
140
136
|
return { candidate_commit_sha: candidate.commitSha, snapshot_fingerprint, changed_paths, receipts };
|
|
141
137
|
}
|
|
142
138
|
catch {
|