mtech-ai-local-runner 0.1.86 → 0.1.88

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.
@@ -20,6 +20,75 @@ function record(value) {
20
20
  ? value
21
21
  : undefined;
22
22
  }
23
+ /** References only: never project approval payloads, lease tokens or arbitrary authored instructions. */
24
+ function continuationContext(taskPackage) {
25
+ let projectionTruncated = false;
26
+ const contract = record(taskPackage.contract) ?? {};
27
+ const workflow = record(contract.workflow_contract) ?? {};
28
+ const policy = record(contract.policy) ?? {};
29
+ const scalar = (value) => (typeof value === "string" && value.length <= 512)
30
+ || (typeof value === "number" && Number.isFinite(value)) || typeof value === "boolean";
31
+ const pick = (value, fields) => {
32
+ const source = record(value) ?? {};
33
+ return Object.fromEntries(fields.filter(key => scalar(source[key])).map(key => [key, source[key]]));
34
+ };
35
+ const items = (value) => {
36
+ if (!Array.isArray(value)) {
37
+ if (value !== undefined)
38
+ projectionTruncated = true;
39
+ return undefined;
40
+ }
41
+ if (value.length > 64)
42
+ projectionTruncated = true;
43
+ return value.slice(0, 64);
44
+ };
45
+ const strings = (value) => {
46
+ const source = items(value);
47
+ const result = source?.filter((item) => typeof item === "string" && item.length <= 512);
48
+ if (source && result?.length !== source.length)
49
+ projectionTruncated = true;
50
+ return result;
51
+ };
52
+ const execution = record(taskPackage.execution);
53
+ const coverage = record(taskPackage.coverage);
54
+ const limitKeys = ["max_turns", "max_runtime_seconds", "max_estimated_cost_usd", "max_correction_rounds",
55
+ "max_iterations", "max_model_input_tokens", "max_model_output_tokens", "max_total_tokens"];
56
+ const context = {
57
+ ...pick(contract.identity, ["work_item_id", "execution_actor_id", "environment"]),
58
+ contract: pick(contract, ["id", "content_fingerprint", "status"]),
59
+ delegation: pick(workflow, ["delegation_contract_id", "delegation_fingerprint", "delegation_revision", "acknowledgement_required"]),
60
+ selected_execution_units: strings(workflow.selected_execution_units),
61
+ allowed_execution_units: strings(workflow.allowed_execution_units),
62
+ blocked_execution_units: strings(workflow.blocked_execution_units),
63
+ policy: {
64
+ ...pick(policy, ["environment", "policy_key", ...limitKeys]),
65
+ limits: pick(policy.limits, limitKeys),
66
+ allowed_local_actions: strings(policy.allowed_local_actions),
67
+ approval_required_actions: strings(policy.approval_required_actions),
68
+ prohibited_actions: strings(policy.prohibited_actions),
69
+ },
70
+ agent_limits: pick(record(taskPackage.agent_execution)?.limits, limitKeys),
71
+ iteration: pick(taskPackage.iteration, ["id", "number", "lease_expires_at"]),
72
+ decision_references: (items(taskPackage.decisions) ?? []).map(decision => pick(decision, ["id", "decision_key", "revision_number", "content_fingerprint"])),
73
+ ...(execution ? { execution: pick(execution, ["mode", "runtime_adapter", "runtime_transport", "limits_mode"]) } : {}),
74
+ coverage: { ...pick(coverage, ["serialized_bytes", "max_bytes"]),
75
+ included_sections: strings(coverage?.included_sections),
76
+ omitted_sections: strings(coverage?.omitted_sections),
77
+ truncated_sections: strings(coverage?.truncated_sections) },
78
+ ...(projectionTruncated ? { reference_projection_truncated: true } : {}),
79
+ };
80
+ return "## Continuation references (snapshot, not new authority)\n" + JSON.stringify(context) + "\n"
81
+ + (projectionTruncated ? "Reference projection was truncated; absence is not permission. Stop affected work until the host resolves missing context. " : "")
82
+ + (execution?.limits_mode === "unbounded" ? "The host's effective limits mode remains authoritative; historical numeric caps do not override an explicitly admitted unbounded employee session. " : "")
83
+ + "Reuse valid same-scope authorization for bounded implementation, tests and corrections; do not ask again solely because an AI, session or skill changed. "
84
+ + "The host revalidates current authority; these decision references and contract snapshots are not approvals or proof of current validity. "
85
+ + "Missing or omitted decision context does not prove that there are no decisions. Do not fetch Gateway context or manage leases yourself; the Runner owns that work. "
86
+ + "Stop on changed, expired, revoked, consumed or actor-mismatched authority and server/app permission denial. "
87
+ + "Stop blocked execution units and units that depend on them; continue only independently allowed work inside the assigned scope. "
88
+ + "A new paid execution or correction attempt requires existing host admission, current contract, remaining attempts/budget and a valid lease; ordinary local retesting grants none of these. "
89
+ + "Do not replay a completed side effect. Never answer a human-only question or approve on another actor's behalf. "
90
+ + "Required review, completion and publication gates remain separate; report only observed work and evidence, not delivery or operational success inferred from a local result.";
91
+ }
23
92
  export function compilePrompt(taskPackage, options) {
24
93
  const contract = record(taskPackage.contract);
25
94
  if (!contract) {
@@ -50,6 +119,7 @@ export function compilePrompt(taskPackage, options) {
50
119
  const requiredTests = requiredArray(brief.required_tests ?? [], "required_tests");
51
120
  const sections = [
52
121
  `# Bounded implementation brief: ${options.workItemLabel}`,
122
+ continuationContext(taskPackage),
53
123
  `## Objective\n${objective}`,
54
124
  `## Required changes\n${requiredChanges.map((item) => `- ${item}`).join("\n")}`,
55
125
  `## Invariants\n${invariants.map((item) => `- ${item}`).join("\n")}`,
@@ -60,7 +60,8 @@ export function changedBytes(before, after) {
60
60
  return reject('CHANGE_MEASUREMENT_REJECTED');
61
61
  }
62
62
  export function candidatePayloadBytes(files, report) { return Buffer.byteLength(JSON.stringify({ files, ...(report === undefined ? {} : { validation_report: report }) }), 'utf8'); }
63
- export function measureSplitDelta(diff, limits, report) {
63
+ /** withModes: the frozen scope lists file modes (design 2026-11-02), so each file carries the mode it keeps; delta_modes checks it. */
64
+ export function measureSplitDelta(diff, limits, report, withModes = false) {
64
65
  const delta = JSON.parse(diff);
65
66
  if (delta?.format !== 'local-candidate-binary-diff-v1' || !Array.isArray(delta.changes) || !delta.changes.length || delta.changes.length > limits.max_changed_files)
66
67
  reject('FILE_COUNT_REJECTED');
@@ -78,7 +79,7 @@ export function measureSplitDelta(diff, limits, report) {
78
79
  changes += changedBytes(before, after);
79
80
  if (changes > limits.max_change_bytes)
80
81
  reject('CHANGE_LIMIT_REJECTED');
81
- return { path: c.path, status: c.status, content_base64: c.status === 'deleted' ? null : after.toString('base64'), content_sha256: c.status === 'deleted' ? null : createHash('sha256').update(after).digest('hex') };
82
+ return { path: c.path, status: c.status, content_base64: c.status === 'deleted' ? null : after.toString('base64'), content_sha256: c.status === 'deleted' ? null : createHash('sha256').update(after).digest('hex'), ...(withModes ? { mode: c.status === 'deleted' ? null : c.after_mode === 0o100755 ? '100755' : '100644' } : {}) };
82
83
  });
83
84
  if (!changes)
84
85
  reject('CHANGE_MEASUREMENT_REJECTED');
@@ -13,6 +13,8 @@ import { FilePublicationJournal } from './journal.js';
13
13
  import { GitSnapshotHost, SNAPSHOT_TREE_MAX_BYTES } from './git-snapshot.js';
14
14
  import { PublicationOutcomeUnknownError } from './publisher.js';
15
15
  import { isLocalFixAdapter } from './local-fix-adapters.js';
16
+ /** Design 2026-11-02: a frozen scope listing exactly these lets an unchanged 100755 regular file keep its mode. */
17
+ export const FILE_MODES = ['100644', '100755'];
16
18
  const denied = () => Error('LOCAL_FIX_SERVER_CI_REJECTED');
17
19
  /** Work Decision b3d7eb22: a failed Gateway publication call is repeated for at most this long; the coordinator heartbeat keeps the lease. */
18
20
  export const PUBLICATION_RETRY_WINDOW_MS = 900_000;
@@ -83,6 +85,10 @@ export function createServerCiLocalFixHost(options) {
83
85
  if (!lifecycle.isCurrent() || !isLocalFixAdapter(pkg.runtime_adapter) || !scope || scope.verification_mode !== mode || scope.ci_profile_fingerprint !== registration.ci_profile_fingerprint || protocolHash(scope.ci_profile) !== protocolHash(registration.ci_profile) || !Array.isArray(scope.required_commands) || scope.required_commands.length || !scope.registered_tests || typeof scope.registered_tests !== 'object' || Object.keys(scope.registered_tests).length || !Array.isArray(allowed) || !allowed.length || allowed.some(p => typeof p !== 'string' || !(safePath(p) || p.endsWith('/**') && safePath(p.slice(0, -3)))) || protocolHash(allowed) !== protocolHash(pkg.allowed_paths) || repo.key !== registration.project || job.gatewayTaskPackageFingerprint !== protocolHash(pkg) || Object.entries(registration.receipt).some(([k, v]) => scope[k] !== v) || root !== options.privateRoot || !st.isDirectory() || st.isSymbolicLink() || (st.mode & 0o077) !== 0 || (process.getuid && st.uid !== process.getuid()) || root === input.checkoutPath || root.startsWith(input.checkoutPath + path.sep))
84
86
  throw denied();
85
87
  const limits = candidateLimits(rawLimits, mode === 'gateway_publish_v1');
88
+ // Without file_modes (an older Gateway or contract) only 100644 is published and no file carries a mode; any other list is refused.
89
+ const fileModes = scope.file_modes !== undefined;
90
+ if (fileModes && protocolHash(scope.file_modes) !== protocolHash(FILE_MODES))
91
+ throw denied();
86
92
  const fence = { jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, launchGrant: job.launchGrant, bindingId: input.bindingId };
87
93
  const binding = bindingProof((await options.gateway.localPublicationContext(fence)).context);
88
94
  if (binding.runner_id !== options.runnerId || binding.binding_id !== input.bindingId || binding.job_id !== job.id || binding.generation !== job.generation || binding.work_loop_id !== job.workLoopId || binding.repository_key !== repo.key || binding.base_commit_sha !== repo.base_commit_sha || binding.working_branch !== repo.working_branch || binding.working_branch !== `ai-work/local/${binding.attempt_id}` || binding.attempt_id !== scope.attempt_id || binding.contract_fingerprint !== job.gatewayContractFingerprint || binding.task_package_fingerprint !== job.gatewayTaskPackageFingerprint || binding.launch_fingerprint !== job.launchFingerprint)
@@ -131,7 +137,7 @@ export function createServerCiLocalFixHost(options) {
131
137
  let split;
132
138
  if (limits.version === 2) {
133
139
  try {
134
- split = measureSplitDelta(observed.diff, limits, report);
140
+ split = measureSplitDelta(observed.diff, limits, report, fileModes);
135
141
  }
136
142
  catch (error) {
137
143
  const code = trustedCandidateLimitCode(error);
@@ -143,7 +149,10 @@ export function createServerCiLocalFixHost(options) {
143
149
  if (!observed.changed_files.length || observed.changed_files.length > limits.max_changed_files)
144
150
  throw publicationError(phase, 'LOCAL_FIX_FILE_COUNT_REJECTED');
145
151
  // The v1 wire represents ordinary text bytes only. Refuse any change whose
146
- // executable/type metadata would be lost by the provider's regular-file write.
152
+ // executable/type metadata would be lost by the provider's regular-file write:
153
+ // a mode change, an executable added file, and every type but a regular file.
154
+ // Under file_modes an unchanged 100755 file keeps its mode; the Gateway checks
155
+ // that mode at the frozen base and reads it back before any PR.
147
156
  phase = 'delta_format';
148
157
  let delta;
149
158
  try {
@@ -155,14 +164,16 @@ export function createServerCiLocalFixHost(options) {
155
164
  if (!delta || delta.format !== 'local-candidate-binary-diff-v1' || !Array.isArray(delta.changes) || delta.changes.length !== observed.changed_files.length)
156
165
  throw denied();
157
166
  phase = 'delta_modes';
158
- const represented = [];
167
+ const represented = [], modes = new Map();
168
+ const kept = (m) => m === 0o100644 || fileModes && m === 0o100755;
159
169
  for (const value of delta.changes) {
160
170
  if (!value || typeof value !== 'object' || Array.isArray(value))
161
171
  throw denied();
162
172
  const c = value;
163
- if (typeof c.path !== 'string' || !['added', 'modified', 'deleted'].includes(String(c.status)) || c.before_mode !== (c.status === 'added' ? null : 0o100644) || c.after_mode !== (c.status === 'deleted' ? null : 0o100644))
173
+ if (typeof c.path !== 'string' || !['added', 'modified', 'deleted'].includes(String(c.status)) || (c.status === 'added' ? c.before_mode !== null || c.after_mode !== 0o100644 : c.status === 'deleted' ? !kept(c.before_mode) || c.after_mode !== null : !kept(c.before_mode) || c.after_mode !== c.before_mode))
164
174
  throw denied();
165
175
  represented.push({ path: c.path, status: c.status });
176
+ modes.set(c.path, c.status === 'deleted' ? null : c.after_mode === 0o100755 ? '100755' : '100644');
166
177
  }
167
178
  if (protocolHash(represented) !== protocolHash(observed.changed_files))
168
179
  throw denied();
@@ -173,8 +184,9 @@ export function createServerCiLocalFixHost(options) {
173
184
  phase = 'delta_scope';
174
185
  if (!safePath(f.path) || testInfrastructure(f.path) || !allowed.some(s => matches(f.path, s)))
175
186
  throw denied();
187
+ const modeField = fileModes ? { mode: modes.get(f.path) } : {};
176
188
  if (f.status === 'deleted') {
177
- files.push({ ...f, content_base64: null, content_sha256: null });
189
+ files.push({ ...f, content_base64: null, content_sha256: null, ...modeField });
178
190
  continue;
179
191
  }
180
192
  phase = 'candidate_files';
@@ -190,12 +202,14 @@ export function createServerCiLocalFixHost(options) {
190
202
  for (const s of [after, named])
191
203
  if (!s.isFile() || s.nlink !== 1n || s.dev !== before.dev || s.ino !== before.ino || s.size !== before.size || s.mtimeNs !== before.mtimeNs || s.ctimeNs !== before.ctimeNs || s.mode !== before.mode)
192
204
  throw denied();
205
+ if (fileModes && ((before.mode & 73n) !== 0n) !== (modeField.mode === '100755'))
206
+ throw denied();
193
207
  bytes += content.length;
194
208
  if (limits.version !== 2 && bytes > limits.max_diff_bytes)
195
209
  throw publicationError(phase, 'LOCAL_FIX_DIFF_LIMIT_REJECTED');
196
210
  if (content.includes(0) || !Buffer.from(content.toString('utf8'), 'utf8').equals(content))
197
211
  throw publicationError(phase, 'LOCAL_FIX_FILE_ENCODING_REJECTED');
198
- files.push({ ...f, content_base64: content.toString('base64'), content_sha256: createHash('sha256').update(content).digest('hex') });
212
+ files.push({ ...f, content_base64: content.toString('base64'), content_sha256: createHash('sha256').update(content).digest('hex'), ...modeField });
199
213
  }
200
214
  finally {
201
215
  await fd.close();
@@ -26,11 +26,48 @@ function scopePath(root, scope) {
26
26
  throw new Error('DENIED_UNSUPPORTED_SCOPE');
27
27
  return path.resolve(root, directory);
28
28
  }
29
+ // A blocked pattern as an anchored matcher on the root-relative path: a leading "**/" is any leading folders, a
30
+ // trailing "/**" everything below, "*" and "?" stay within one segment. Brackets and braces stay unsupported and
31
+ // refuse every access.
32
+ function blockedGlob(scope) {
33
+ if (/[\[\]{}]/.test(scope))
34
+ throw new Error('DENIED_UNSUPPORTED_SCOPE');
35
+ let pattern = '';
36
+ for (let i = 0; i < scope.length; i++) {
37
+ const rest = scope.slice(i);
38
+ if (rest.startsWith('**/')) {
39
+ pattern += '(?:.*/)?';
40
+ i += 2;
41
+ }
42
+ else if (rest === '/**') {
43
+ pattern += '(?:/.*)?';
44
+ i += 2;
45
+ }
46
+ else if (rest.startsWith('**')) {
47
+ pattern += '.*';
48
+ i += 1;
49
+ }
50
+ else if (scope[i] === '*')
51
+ pattern += '[^/]*';
52
+ else if (scope[i] === '?')
53
+ pattern += '[^/]';
54
+ else
55
+ pattern += scope[i].replace(/[.+^$()|\\]/g, '\\$&');
56
+ }
57
+ return new RegExp(`^${pattern}$`);
58
+ }
29
59
  function exactBlockedScopeContains(root, scope, file) {
30
60
  // The compiled repository policy includes this private-file family. Handle
31
61
  // it only as a denial, never as a wildcard grant or an ignored block.
32
62
  if (scope === '.env.*')
33
63
  return file.startsWith(path.join(root, '.env.'));
64
+ // Registered blocks also use patterns such as **/*.keystore or **/.env*. Read only as a denial: a pattern can
65
+ // refuse more paths, never grant one, and allowed scopes keep the plain directory form of scopePath.
66
+ if (/[?*\[\]{}]/.test(scope.endsWith('/**') ? scope.slice(0, -3) : scope)) {
67
+ if (!contains(root, file))
68
+ return false;
69
+ return blockedGlob(scope).test(path.relative(root, file).split(path.sep).join('/'));
70
+ }
34
71
  return contains(scopePath(root, scope), file);
35
72
  }
36
73
  // APFS and HFS+ resolve names regardless of case and Unicode normalization, so app/secrets reaches a blocked
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtech-ai-local-runner",
3
- "version": "0.1.86",
3
+ "version": "0.1.88",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "bin": {