mtech-ai-local-runner 0.1.94 → 0.1.95

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,7 +1,7 @@
1
1
  import { execFile, spawn } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
3
  import { constants } from 'node:fs';
4
- import { lstat, mkdir, mkdtemp, open, realpath, readdir, readFile, rm, writeFile } from 'node:fs/promises';
4
+ import { lstat, mkdir, mkdtemp, open, realpath, readdir, readFile, readlink, rm, writeFile } from 'node:fs/promises';
5
5
  import { createHash } from 'node:crypto';
6
6
  import path from 'node:path';
7
7
  import { measureCommandFile } from '../safety/registered-command-integrity.js';
@@ -11,6 +11,7 @@ const execute = promisify(execFile), denied = () => Error('SNAPSHOT_REJECTED');
11
11
  const digest = (data) => createHash('sha256').update(data).digest('hex');
12
12
  const structural = (p) => p.length > 0 && !path.isAbsolute(p) && !p.includes('\\') && !p.includes('\0') && p.split('/').every(s => s !== '' && s !== '.' && s !== '..' && !/^\.git$/i.test(s));
13
13
  const safe = (p) => structural(p) && p.split('/').every(s => !/^(?:\.ssh|\.aws|\.codex|\.claude|\.env|\.netrc|\.npmrc|credentials(?:\..*)?)$/i.test(s));
14
+ const TRACKED_SYMLINK = /^120000 blob ([a-f0-9]{40})\t(.+)$/s;
14
15
  /** P1 branch shape only. Authority still requires exact frozen-fence equality. */
15
16
  /** Whole-checkout memory bound for a snapshot, per file and in total. A snapshot reads every
16
17
  * file of the checkout, so callers bound candidate size on the changed files instead. */
@@ -136,6 +137,19 @@ export class GitSnapshotHost {
136
137
  await fd.close();
137
138
  }
138
139
  }
140
+ /** A tracked symlink must stay the exact base link. Its target is read with readlink and never followed. */
141
+ async assertTrackedSymlink(relative, target) {
142
+ let component = this.options.root;
143
+ const parts = relative.split('/');
144
+ for (let i = 0; i < parts.length; i++) {
145
+ component = path.join(component, parts[i]);
146
+ const stat = await lstat(component);
147
+ if (i < parts.length - 1 ? stat.isSymbolicLink() || !stat.isDirectory() : !stat.isSymbolicLink())
148
+ throw denied();
149
+ }
150
+ if (!(await readlink(component, { encoding: 'buffer' })).equals(target))
151
+ throw denied();
152
+ }
139
153
  /** Checks the options every read relies on, then that HEAD is the base commit on the working branch. */
140
154
  async head() {
141
155
  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);
@@ -149,7 +163,7 @@ export class GitSnapshotHost {
149
163
  const o = this.options;
150
164
  await this.head();
151
165
  const head = o.baseCommitSha, branch = o.workingBranch;
152
- const base = new Map(), gitlinks = new Map(), protectedNames = new Set(), baseListing = await this.git(['ls-tree', '-r', '-z', '--full-tree', o.baseCommitSha]);
166
+ const base = new Map(), gitlinks = new Map(), symlinks = new Map(), protectedNames = new Set(), baseListing = await this.git(['ls-tree', '-r', '-z', '--full-tree', o.baseCommitSha]);
153
167
  for (const line of baseListing.toString().split('\0').filter(Boolean)) {
154
168
  const link = /^160000 commit ([a-f0-9]{40})\t(.+)$/s.exec(line);
155
169
  if (link) {
@@ -158,6 +172,13 @@ export class GitSnapshotHost {
158
172
  gitlinks.set(link[2], link[1]);
159
173
  continue;
160
174
  }
175
+ const symlink = TRACKED_SYMLINK.exec(line);
176
+ if (symlink) {
177
+ if (!safe(symlink[2]))
178
+ throw denied();
179
+ symlinks.set(symlink[2], symlink[1]);
180
+ continue;
181
+ }
161
182
  const match = /^(100644|100755) blob ([a-f0-9]{40})\t(.+)$/s.exec(line);
162
183
  if (!match || !structural(match[3]))
163
184
  throw denied();
@@ -166,11 +187,11 @@ export class GitSnapshotHost {
166
187
  protectedNames.add(match[3]);
167
188
  }
168
189
  // Protected names the base commit already tracks stay as exact base content; only a change to one is refused.
169
- // Gitlinks remain exact base-tree references. Never traverse or copy their source.
190
+ // Gitlinks and tracked symlinks remain exact base-tree references. Never traverse or copy their source.
170
191
  // Staged replacement/deletion is refused rather than publishing hidden changes.
171
192
  const staged = new Map(), stagedListing = await this.git(['ls-files', '--stage', '-z']);
172
193
  for (const line of stagedListing.toString().split('\0').filter(Boolean)) {
173
- const entry = /^(100644|100755|160000) ([a-f0-9]{40}) 0\t(.+)$/s.exec(line);
194
+ const entry = /^(100644|100755|120000|160000) ([a-f0-9]{40}) 0\t(.+)$/s.exec(line);
174
195
  if (!entry || !structural(entry[3]))
175
196
  throw denied();
176
197
  staged.set(entry[3], entry[1] + ' ' + entry[2]);
@@ -178,15 +199,26 @@ export class GitSnapshotHost {
178
199
  const expected = new Map([...base.entries()].map(([name, value]) => [name, value.mode.toString(8) + ' ' + value.blob]));
179
200
  for (const [name, oid] of gitlinks)
180
201
  expected.set(name, '160000 ' + oid);
202
+ for (const [name, oid] of symlinks)
203
+ expected.set(name, '120000 ' + oid);
181
204
  if (protocolHash([...expected].sort()) !== protocolHash([...staged].sort()))
182
205
  throw denied();
183
206
  const untrackedListing = await this.git(['ls-files', '--others', '--exclude-standard', '-z', '--']);
184
207
  const names = [...new Set([...base.keys(), ...untrackedListing.toString().split('\0').filter(Boolean)])].sort();
185
- const blobs = await this.batchObjects([...base.values()].map(v => v.blob));
208
+ const blobs = await this.batchObjects([...[...base.values()].map(v => v.blob), ...symlinks.values()]);
209
+ const symlinkTargets = new Map();
210
+ for (const [name, oid] of symlinks) {
211
+ const blob = blobs.get(oid);
212
+ if (!blob || blob.type !== 'blob')
213
+ throw denied();
214
+ symlinkTargets.set(name, blob.bytes);
215
+ await this.assertTrackedSymlink(name, blob.bytes);
216
+ }
217
+ const kept = [...gitlinks.keys(), ...symlinks.keys()];
186
218
  const files = [], changes = [];
187
219
  let total = 0;
188
220
  for (const name of names) {
189
- if ([...gitlinks.keys()].some(p => name === p || name.startsWith(p + '/') || p.startsWith(name + '/')))
221
+ if (kept.some(p => name === p || name.startsWith(p + '/') || p.startsWith(name + '/')))
190
222
  throw denied();
191
223
  const current = await this.currentFile(name, protectedNames.has(name)), prior = base.get(name);
192
224
  let before = null;
@@ -211,7 +243,7 @@ export class GitSnapshotHost {
211
243
  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 });
212
244
  }
213
245
  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 })) };
214
- const listing = { base: digest(baseListing), staged: digest(stagedListing), untracked: digest(untrackedListing), names, protectedNames };
246
+ const listing = { base: digest(baseListing), staged: digest(stagedListing), untracked: digest(untrackedListing), names, protectedNames, symlinks: symlinkTargets };
215
247
  return { observation, files, listing, fingerprint: protocolHash({ observation, files: files.map(f => ({ path: f.path, mode: f.mode, sha256: digest(f.bytes) })) }) };
216
248
  }
217
249
  /** The checkout still equals a capture when HEAD, the branch, the three Git listings and every
@@ -222,6 +254,8 @@ export class GitSnapshotHost {
222
254
  await this.head();
223
255
  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)
224
256
  throw denied();
257
+ for (const [name, target] of listing.symlinks)
258
+ await this.assertTrackedSymlink(name, target);
225
259
  const files = [];
226
260
  let total = 0;
227
261
  for (const name of listing.names) {
@@ -264,7 +298,7 @@ export class GitSnapshotHost {
264
298
  for (const entry of (await this.git(['ls-tree', '-r', '-t', '-z', o.baseCommitSha])).toString().split('\0').filter(Boolean)) {
265
299
  if (/^160000 commit [a-f0-9]{40}\t/.test(entry))
266
300
  continue;
267
- const match = /^(?:100644|100755|040000) (blob|tree) ([a-f0-9]{40})\t/.exec(entry);
301
+ const match = /^(?:100644|100755|120000|040000) (blob|tree) ([a-f0-9]{40})\t/.exec(entry);
268
302
  if (!match)
269
303
  throw denied();
270
304
  objects.set(match[2], match[1]);
@@ -344,6 +378,9 @@ export class GitSnapshotHost {
344
378
  const link = /^160000 commit ([a-f0-9]{40})\t(.+)$/s.exec(line);
345
379
  if (link)
346
380
  entries.push({ path: link[2], mode: 0o160000, bytes: Buffer.alloc(0), gitlink: link[1] });
381
+ const symlink = TRACKED_SYMLINK.exec(line);
382
+ if (symlink)
383
+ entries.push({ path: symlink[2], mode: 0o120000, bytes: Buffer.alloc(0), baseBlob: symlink[1] });
347
384
  }
348
385
  const objects = new RawGitObjects(root), tree = await objects.tree(entries, true), commitSha = await objects.object('commit', Buffer.from(`tree ${tree}\nparent ${this.options.baseCommitSha}\nauthor Local Runner Verification <runner-verification@invalid> 1 +0000\ncommitter Local Runner Verification <runner-verification@invalid> 1 +0000\n\nLocal runner verification candidate v1\n`), true);
349
386
  await writeFile(path.join(root, '.git', 'HEAD'), commitSha + '\n', { mode: 0o600 });
@@ -67,13 +67,13 @@ export class GitPublicationTransport {
67
67
  if (protocolHash(entries.map(e => ({ path: e.path, mode: e.mode, sha256: createHash('sha256').update(e.bytes).digest('hex') }))) !== o.sealedTreeFingerprint)
68
68
  throw denied();
69
69
  for (const line of (await this.git(['ls-tree', '-r', '-z', request.base_commit_sha])).split('\0').filter(Boolean)) {
70
- const gitlink = /^160000 commit ([a-f0-9]{40})\t(.+)$/.exec(line);
71
- if (!gitlink)
70
+ const gitlink = /^160000 commit ([a-f0-9]{40})\t(.+)$/.exec(line), symlink = /^120000 blob ([a-f0-9]{40})\t(.+)$/.exec(line), kept = gitlink ?? symlink;
71
+ if (!kept)
72
72
  continue;
73
- const name = gitlink[2];
73
+ const name = kept[2];
74
74
  if (!name || name.split('/').some(p => !p || p === '.' || p === '..' || p === '.git') || name.includes('\\') || entries.some(e => e.path === name || e.path.startsWith(name + '/') || name.startsWith(e.path + '/')))
75
75
  throw denied();
76
- entries.push({ path: name, mode: 0o160000, bytes: Buffer.alloc(0), gitlink: gitlink[1] });
76
+ entries.push(gitlink ? { path: name, mode: 0o160000, bytes: Buffer.alloc(0), gitlink: gitlink[1] } : { path: name, mode: 0o120000, bytes: Buffer.alloc(0), baseBlob: symlink[1] });
77
77
  }
78
78
  if (!await o.current())
79
79
  throw denied();
@@ -42,7 +42,7 @@ export class RawGitObjects {
42
42
  if (slash >= 0)
43
43
  children.add(rest.slice(0, slash));
44
44
  else
45
- names.set(rest, { mode: entry.mode.toString(8), oid: entry.gitlink ?? await this.object('blob', entry.bytes, write) });
45
+ names.set(rest, { mode: entry.mode.toString(8), oid: entry.gitlink ?? entry.baseBlob ?? await this.object('blob', entry.bytes, write) });
46
46
  }
47
47
  for (const child of children)
48
48
  names.set(child, { mode: '40000', oid: await this.tree(entries, write, prefix + child + '/') });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtech-ai-local-runner",
3
- "version": "0.1.94",
3
+ "version": "0.1.95",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "bin": {