mtech-ai-local-runner 0.1.42 → 0.1.43

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,15 @@
1
+ ## 0.1.43: give whole-checkout snapshots whole-checkout bounds
2
+
3
+ The P1 verifier snapshots the whole model checkout before it runs the reviewed commands. It passed the candidate limits
4
+ (`max_file_bytes` and `max_diff_bytes`, 16 KiB in the pilot contract) as the snapshot's own per-file and whole-tree
5
+ bounds, so any real project failed with `SNAPSHOT_REJECTED`: the AI Gateway checkout has 3,781 files, 48.5 MB and 588
6
+ files above 16 KiB. The snapshot now takes the same 128 MiB whole-tree memory bound as the publication hosts. The
7
+ candidate limits still apply to the changed files: their count, the diff size and, newly explicit, each changed file's
8
+ before and after size. Local Fix had the same defect, and Local Fix → PR could not publish on the AI Gateway repository because
9
+ an unchanged 1.09 MB file exceeds the 1 MiB `max_file_bytes` cap. Its production and server-CI snapshots and its Git
10
+ publication transport now use the same shared `SNAPSHOT_TREE_MAX_BYTES`. Changed files are still bounded by
11
+ `max_file_bytes`.
12
+
1
13
  ## 0.1.42: keep the guardian's reports when a late terminate fails
2
14
 
3
15
  The owned guardian now reports to the host on its own pipe (fd3) and takes the host's commands on another (fd5);
@@ -12,6 +12,9 @@ const execute = promisify(execFile), denied = () => Error('SNAPSHOT_REJECTED');
12
12
  const digest = (data) => createHash('sha256').update(data).digest('hex');
13
13
  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
14
  /** P1 branch shape only. Authority still requires exact frozen-fence equality. */
15
+ /** Whole-checkout memory bound for a snapshot, per file and in total. A snapshot reads every
16
+ * file of the checkout, so callers bound candidate size on the changed files instead. */
17
+ export const SNAPSHOT_TREE_MAX_BYTES = 128 * 1024 * 1024;
15
18
  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
19
  /** Reads only immutable Git objects and metadata. Never runs diff against worktree
17
20
  * content, where malicious clean filters can execute even with --no-ext-diff. */
@@ -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: limits.max_file_bytes, maxTotalBytes: 128 * 1024 * 1024 });
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: limits.max_file_bytes });
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
  };
@@ -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: limits.max_file_bytes, maxTotalBytes: 128 * 1024 * 1024 });
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,9 +103,9 @@ 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: o.candidateLimits.max_file_bytes, maxTotalBytes: Math.max(o.candidateLimits.max_file_bytes, o.candidateLimits.max_diff_bytes) });
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
111
  await sealed.assertUnchanged();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtech-ai-local-runner",
3
- "version": "0.1.42",
3
+ "version": "0.1.43",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "bin": {