mtech-ai-local-runner 0.1.93 → 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.
package/README.md CHANGED
@@ -1,3 +1,35 @@
1
+ ## Unreleased: bounded report drafting and offline preflight
2
+
3
+ The final writing guidance targets 1,200 UTF-16 code units in `report`, below
4
+ the unchanged 2,000-unit acceptance limit. Findings and uncertainty belong in
5
+ the short report; detailed citations belong in `evidence`. No automatic
6
+ truncation, content repair, model retry, new tool or launch permission is added.
7
+ Prompt guidance alone cannot guarantee model compliance: an oversized final
8
+ response still fails closed. These changes are source-only, not part of the
9
+ installed/reviewed 0.1.93 package.
10
+
11
+ From the `runners/local-agent` source checkout, before another live validation,
12
+ replay saved **final response text**, not a transcript or stream-JSON wrapper,
13
+ through the production completion validator (the script is a repository-only
14
+ rehearsal utility, not an installed Runner command):
15
+
16
+ ```sh
17
+ npm run build
18
+ node scripts/check-completion-report.mjs /absolute/path/to/final-response.txt
19
+ ```
20
+
21
+ This offline script reads a bounded UTF-8 file, emits only counts and fixed
22
+ diagnostics, and never rewrites the input, calls a model, accesses Gateway or
23
+ starts work. Exit 0 means valid transport (including a truthful `blocked`
24
+ report), 1 means rejected transport, and 2 means usage/read/encoding/input-size
25
+ failure. A reported `completed` status does not verify the investigation,
26
+ citations or Work Item completion. The exact 2026-11-05 failure is preserved as
27
+ an untrusted technical test fixture; its claims are not accepted as true.
28
+
29
+ No package publication, Gateway registration, Runner replacement or new live
30
+ execution has been performed for this source change. Those require separate
31
+ release review, exact hashes and execution approval.
32
+
1
33
  ## 0.1.93: strict investigation JSON completion guidance
2
34
 
3
35
  Claude uses native-stream progress guidance and receives scoped tool/correction
@@ -0,0 +1,20 @@
1
+ import { completionEnvelope, completionEvent, COMPLETION_REPORT_LIMITS } from './completion-report.js';
2
+ /** Offline diagnostics only: never rewrites output, creates evidence, or authorizes execution. */
3
+ export function completionReportPreflight(raw) {
4
+ const options = { earlierCodeBlocks: true };
5
+ const envelope = completionEnvelope(raw, options);
6
+ const event = completionEvent(raw, undefined, options);
7
+ const rejection = event.payload?.completion_rejection;
8
+ const valid = event.eventType === 'completed' || event.payload?.reason === 'executor_blocked';
9
+ return {
10
+ valid_transport: valid,
11
+ // This is transport status only, not independent review or Work Item completion.
12
+ completed: event.eventType === 'completed',
13
+ status: valid ? (event.eventType === 'completed' ? 'completed' : 'blocked') : 'rejected',
14
+ rejection_code: rejection?.code ?? null,
15
+ report_code_units: typeof envelope?.report === 'string' ? envelope.report.length : null,
16
+ report_limit: COMPLETION_REPORT_LIMITS.reportCodeUnits,
17
+ evidence_count: Array.isArray(envelope?.evidence) ? envelope.evidence.length : null,
18
+ pending_work_count: Array.isArray(envelope?.pending_work) ? envelope.pending_work.length : null,
19
+ };
20
+ }
@@ -22,6 +22,14 @@ export function reportRejectionCounts(value, total) {
22
22
  const MAX_EVIDENCE_ITEMS = 20;
23
23
  const MAX_EVIDENCE_CHARS = 500;
24
24
  const MAX_REPORT_ENVELOPE_CHARS = 16_000;
25
+ /** Construction/rehearsal hints; acceptance always goes through completionEvent. */
26
+ export const COMPLETION_REPORT_LIMITS = Object.freeze({
27
+ reportCodeUnits: MAX_EVENT_SUMMARY_CHARS,
28
+ targetReportCodeUnits: Math.min(1200, MAX_EVENT_SUMMARY_CHARS),
29
+ listItems: MAX_EVIDENCE_ITEMS,
30
+ itemCodeUnits: MAX_EVIDENCE_CHARS,
31
+ envelopeCodeUnits: MAX_REPORT_ENVELOPE_CHARS,
32
+ });
25
33
  const REPORT_FAILURE_CODES = ["CODEX_COMPLETION_REPORT_MISSING", "CODEX_COMPLETION_REPORT_INVALID", "CODEX_COMPLETION_REPORT_MULTIPLE", "CODEX_COMPLETION_REPORT_NOT_FINAL", "CODEX_TURN_INCOMPLETE"];
26
34
  const REPORT_DIAGNOSTIC_CODES = [...REPORT_FAILURE_CODES, "CODEX_COMPLETION_REPORT_BLOCKED"];
27
35
  /** Pair counts only; no item IDs, text, stable digests or arbitrary keys. */
@@ -68,6 +76,11 @@ export const COMPLETION_REPORT_NOTICE = "Do the assigned work yourself using the
68
76
  "Your final response must be one JSON object, without Markdown fences: " +
69
77
  '{"status":"completed","report":"actual findings and results","evidence":["file path and observed finding, or command and actual result"],"pending_work":[]}. ' +
70
78
  `The report must be nonempty and at most ${MAX_EVENT_SUMMARY_CHARS} characters. ` +
79
+ `Aim for at most ${COMPLETION_REPORT_LIMITS.targetReportCodeUnits} UTF-16 code units in the decoded report string; the hard limit remains ${MAX_EVENT_SUMMARY_CHARS}. ` +
80
+ "Count decoded string length, not tokens, words, UTF-8 bytes or JSON escape characters; an emoji can count as two units. " +
81
+ "Use short findings for the requested topics, then uncertainty and verification limits. Put detailed source citations in evidence instead of repeating them in report. " +
82
+ "Do not fill the available limit or duplicate a narrative before the JSON. Before sending your single final response, shorten your own draft to the target and recheck all fields. " +
83
+ "Never drop uncertainty or pending work to fit the report; use blocked and accurate pending_work when work is incomplete. " +
71
84
  `Include 1-${MAX_EVIDENCE_ITEMS} nonempty evidence strings, each at most ${MAX_EVIDENCE_CHARS} characters. ` +
72
85
  "Address the assigned objective and each applicable acceptance criterion with observed evidence, not a plan or promise. " +
73
86
  "Only attest to executor work you observed; Gateway records transport, completion, and review receipts separately. " +
@@ -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.93",
3
+ "version": "0.1.95",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "bin": {