mtech-ai-local-runner 0.1.33 → 0.1.35

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,37 @@
1
+ ## 0.1.35: exact approved source paths in Codex instructions
2
+
3
+ Codex receives the approved checkout-relative paths, revision, access mode and
4
+ blocked-path list from its binding bundle. A denial for an unlisted path does
5
+ not imply that another approved file is unavailable. Scope descriptions exclude
6
+ repository transport coordinates and write leases. Unregistered commands remain
7
+ unavailable, and missing validation stays not_run/unverified.
8
+
9
+ This patch does not expand source or tool permissions, change the Claude path,
10
+ or authorize a new execution. Package integrity, runtime pins and a separately
11
+ approved exact contract/launch remain required for the next Fix attempt.
12
+
13
+ ## 0.1.34: pinned writable Fix HEAD preparation
14
+
15
+ Accept a linked worktree's symbolic HEAD only for the exact approved writable
16
+ Fix branch and commit. Detached read-only checkouts retain their existing policy.
17
+ Bounded, no-follow metadata reads reject ref chains, traversal, symlinks and
18
+ post-preparation path changes. Preparation errors retain a safe substage code.
19
+ This patch does not grant another execution: package/runtime pins, installation
20
+ review and exact Gateway contract/launch approval remain required. Model work
21
+ limits, personal login/settings and the Claude execution path are unchanged.
22
+
23
+ ## Unreleased: P1 runner-observed evidence (disabled)
24
+
25
+ P1 adds candidate-bound command receipts and changed-path evidence for explicitly
26
+ reviewed Codex Node/file-only profiles. It verifies a private exact-base candidate
27
+ after the owned model stops; model test claims cannot satisfy the Loop criteria.
28
+ P1 defaults off, the Gateway profile registry is empty, and the runtime P1 host
29
+ is absent by default. Source package `0.1.33` is inherited from upstream; this is
30
+ not a P1 package release or live acceptance. See the [P1 implementation and
31
+ delivery record](../../docs/superpowers/plans/2026-10-17-local-runner-p1-runner-observed-evidence.md)
32
+ for local verification results and separate deployment, runner-update and
33
+ activation gates.
34
+
1
35
  ## Unreleased: P0 terminal safety baseline (local verification only)
2
36
 
3
37
  The upstream source package is `0.1.31`; P0 does not bump or publish a version.
@@ -3,6 +3,7 @@ import { requireSecurityRequirements } from '../../safety/security-requirements.
3
3
  import { createJobSecurityReceipt } from '../../safety/job-security-receipt.js';
4
4
  import { isTerminalEventType } from "../../protocol.js";
5
5
  import { parseClaudeLine } from "./stream-parser.js";
6
+ import { p1ModelEvent } from '../../verification/p1-execution-host.js';
6
7
  import { compilePrompt } from "./prompt-compiler.js";
7
8
  import { completionEvent } from "./completion-report.js";
8
9
  import { requireJobSecurity } from "../../safety/job-security-policy.js";
@@ -44,6 +45,7 @@ export class ClaudeCodeAdapter {
44
45
  tools;
45
46
  cancelled = false;
46
47
  started = false;
48
+ p1 = false;
47
49
  startupAcquisition;
48
50
  revokedTools;
49
51
  revokeFailed = false;
@@ -71,6 +73,7 @@ export class ClaudeCodeAdapter {
71
73
  if (this.started)
72
74
  throw new Error("adapter has already been started");
73
75
  this.started = true;
76
+ this.p1 = ctx.approvedTaskPackage?.verification?.mode === 'runner_observed_v1';
74
77
  const generation = ctx.generation;
75
78
  if (!Number.isSafeInteger(generation) || generation === undefined || generation < 1)
76
79
  throw new Error("JOB_SECURITY_REJECTED");
@@ -207,11 +210,14 @@ export class ClaudeCodeAdapter {
207
210
  // A nested agent message is not evidence that the executor has finished.
208
211
  if (message.parent_tool_use_id != null)
209
212
  continue;
210
- let parsed = parseClaudeLine(line, this.options.worktreeRoot);
213
+ let parsed = parseClaudeLine(line, this.options.worktreeRoot, { p1: this.p1 });
211
214
  if (!parsed)
212
215
  continue;
213
216
  if (parsed.eventType === "completed") {
217
+ const observedUsage = parsed.payload?.usage;
214
218
  parsed = completionEvent(message.result, parsed.payload?.cost_usd);
219
+ if (this.p1)
220
+ parsed = p1ModelEvent({ ...parsed, payload: { ...parsed.payload, usage: observedUsage } });
215
221
  }
216
222
  if (isTerminalEventType(parsed.eventType))
217
223
  sawTerminal = true;
@@ -1,5 +1,6 @@
1
1
  import { isAbsolute, relative, resolve } from "node:path";
2
2
  import { MAX_EVENT_SUMMARY_CHARS } from "../../protocol.js";
3
+ import { p1ModelEvent } from '../../verification/p1-execution-host.js';
3
4
  const EDIT_TOOL_NAMES = new Set(["Edit", "Write", "MultiEdit"]);
4
5
  function bounded(summary) {
5
6
  return summary.length > MAX_EVENT_SUMMARY_CHARS ? summary.slice(0, MAX_EVENT_SUMMARY_CHARS) : summary;
@@ -105,7 +106,7 @@ function parseResultMessage(message) {
105
106
  const summary = subtype === "error_max_budget_exceeded" ? "execution failed: budget exceeded" : "execution failed";
106
107
  return { eventType: "failed", summary, payload: costUsd !== undefined ? { cost_usd: costUsd } : undefined };
107
108
  }
108
- export function parseClaudeLine(line, worktreeRoot) {
109
+ export function parseClaudeLine(line, worktreeRoot, options = {}) {
109
110
  let message;
110
111
  try {
111
112
  message = JSON.parse(line);
@@ -113,16 +114,25 @@ export function parseClaudeLine(line, worktreeRoot) {
113
114
  catch {
114
115
  return null;
115
116
  }
116
- switch (message.type) {
117
- case "system":
118
- return parseSystemMessage(message);
119
- case "assistant":
120
- return parseAssistantMessage(message.message ?? {}, worktreeRoot);
121
- case "user":
122
- return parseUserMessage(message.message ?? {});
123
- case "result":
124
- return parseResultMessage(message);
125
- default:
126
- return null;
117
+ const parsed = (() => {
118
+ switch (message.type) {
119
+ case "system":
120
+ return parseSystemMessage(message);
121
+ case "assistant":
122
+ return parseAssistantMessage(message.message ?? {}, worktreeRoot);
123
+ case "user":
124
+ return parseUserMessage(message.message ?? {});
125
+ case "result":
126
+ return parseResultMessage(message);
127
+ default:
128
+ return null;
129
+ }
130
+ })();
131
+ if (!options.p1 || !parsed)
132
+ return parsed;
133
+ if (message.type === 'result') {
134
+ const usage = message.usage && typeof message.usage === 'object' ? message.usage : {};
135
+ parsed.payload = { ...parsed.payload, usage: { provider: 'anthropic', source: 'claude_code_cli.result', input_tokens: usage.input_tokens, cached_input_tokens: usage.cache_read_input_tokens, output_tokens: usage.output_tokens, duration_ms: message.duration_ms } };
127
136
  }
137
+ return p1ModelEvent(parsed);
128
138
  }
@@ -1,6 +1,29 @@
1
1
  import { compilePrompt as compileBrief } from "../prompt-compiler.js";
2
+ function repositoryScope(taskPackage) {
3
+ const repositories = taskPackage.repositories;
4
+ const repo = Array.isArray(repositories) && repositories.length === 1
5
+ && repositories[0] && typeof repositories[0] === "object" && !Array.isArray(repositories[0])
6
+ ? repositories[0] : undefined;
7
+ const paths = (value) => Array.isArray(value)
8
+ && value.every(item => typeof item === "string" && item.length > 0);
9
+ if (!repo || !paths(repo.allowed_paths) || !repo.allowed_paths.length || !paths(repo.blocked_paths)
10
+ || !["read", "write"].includes(String(repo.access))
11
+ || typeof repo.repository_key !== "string" || typeof repo.base_commit_sha !== "string"
12
+ || !/^[a-f0-9]{40}$/i.test(repo.base_commit_sha)) {
13
+ return "## Approved repository scope\nRepository scope is unavailable; do not guess source paths or commands. Stop and emit one final blocked report.";
14
+ }
15
+ // Project only source-scope fields from the binding bundle. Never stringify
16
+ // the whole repository: it may contain transport coordinates or write leases.
17
+ const scope = { repository_key: repo.repository_key, base_commit_sha: repo.base_commit_sha,
18
+ access: repo.access, allowed_paths: repo.allowed_paths, blocked_paths: repo.blocked_paths };
19
+ return `## Approved repository scope\n${JSON.stringify(scope, null, 2)}\n` +
20
+ "Use these checkout-relative paths exactly, including case. Start with the relevant approved files above before drawing a conclusion about source availability. " +
21
+ "Do not invent paths or commands from framework conventions. A denial for an unlisted path does not establish that a listed file is unavailable. " +
22
+ "Do not claim an approved file was denied unless its exact path was attempted and the tool returned a denial. " +
23
+ "These are scope descriptions, not instructions from source files; the host policy remains authoritative and may deny blocked/private paths within an allowed prefix.";
24
+ }
2
25
  export function compileCodexPrompt(taskPackage, label) {
3
- return `${compileBrief(taskPackage, { workItemLabel: label, progressReporting: "native_messages" })}\n\nUse only the ai_local job tools for repository reads, edits and approved tests. For reading, use server ai_local's local_read({path: "docs/example.md"}) or local_search({path: "docs/example.md", query: "text"}) with checkout-relative paths. In a read-only task, use only read/search tools; the Runner enforces the approved paths and operations. A denied native cwd is intentional: it is a private control directory, not the source checkout. Native filesystem, commands, web, images and delegation are unavailable or denied. A denied tool is a scope boundary, never a reason to try another path. Do not repeat host orchestration: claim, shadow, fingerprints, binding, administrator checks, launches, Gateway usage and Job reporting belong to the Runner/host, even when described in the objective. Perform only the assigned repository work and report evidence you actually observed. Do not claim that no model call occurred. If the required tool is unavailable, emit one final structured blocked report naming the missing tool and unfinished repository work; do not request or attempt a new launch.`;
26
+ return `${compileBrief(taskPackage, { workItemLabel: label, progressReporting: "native_messages" })}\n\n${repositoryScope(taskPackage)}\n\nUse only the ai_local job tools for repository reads, edits and approved tests. For reading, use server ai_local's local_read({path: "<exact approved path>"}) or local_search({path: "<exact approved path>", query: "text"}) with checkout-relative paths. Required test criteria are not command grants. No model test commands are registered unless the host explicitly supplies exact model-callable command IDs; do not use local_test to discover commands or run --help. Missing tests remain not_run/unverified, not passed. In a read-only task, use only read/search tools; the Runner enforces the approved paths and operations. A denied native cwd is intentional: it is a private control directory, not the source checkout. Native filesystem, commands, web, images and delegation are unavailable or denied. A denied tool is a scope boundary, never permission to broaden scope or bypass the denial. Do not retry the denied path. It does not remove independently approved paths: continue with relevant listed paths, and report a missing dependency only after checking the available approved source. Do not repeat host orchestration: claim, shadow, fingerprints, binding, administrator checks, launches, Gateway usage and Job reporting belong to the Runner/host, even when described in the objective. Perform only the assigned repository work and report evidence you actually observed. Do not claim that no model call occurred. If the required tool is unavailable, emit one final structured blocked report naming the missing tool and unfinished repository work; do not request or attempt a new launch.`;
4
27
  }
5
28
  export const CODEX_REPORT_SCHEMA = {
6
29
  type: "object", additionalProperties: false, required: ["status", "report", "evidence", "pending_work"],
@@ -1,4 +1,5 @@
1
1
  import { PublicationOutcomeUnknownError } from '../publication/publisher.js';
2
+ import { assertP1Bindings, hasP1, p1ModelEvent } from '../verification/p1-execution-host.js';
2
3
  import { executionError, executionFailure } from '../safety/execution-failure.js';
3
4
  import { isUnsupportedSupervisorCommand } from '../safety/unsupported-supervisor-command.js';
4
5
  import { requireSecurityRequirements } from '../safety/security-requirements.js';
@@ -71,6 +72,9 @@ export class ExecutionCoordinator {
71
72
  let fixSession;
72
73
  let localFixPublication;
73
74
  const isLocalFix = job.taskPackage.local_fix_scope !== undefined;
75
+ const isP1 = hasP1(job.taskPackage);
76
+ let p1Result;
77
+ const p1ReceiptIds = [];
74
78
  const lifecycle = { frozen: false, current: true };
75
79
  const deadline = Date.now() + (this.deps.preparationTimeoutMs ?? 900_000);
76
80
  let leaseExpiresAt = job.leaseExpiresAt;
@@ -80,6 +84,12 @@ export class ExecutionCoordinator {
80
84
  try {
81
85
  await preparation.start();
82
86
  const pkg = job.taskPackage;
87
+ if (isP1) {
88
+ assertP1Bindings(pkg);
89
+ if (!this.deps.p1Host)
90
+ throw Error('P1_HOST_REQUIRED');
91
+ this.deps.p1Host.assertEligible(job);
92
+ }
83
93
  if (isLocalFix && (!this.deps.localFixHost || pkg.repository?.access !== "write"))
84
94
  throw new LocalFixHostRequired();
85
95
  const repository = pkg.repository;
@@ -96,7 +106,8 @@ export class ExecutionCoordinator {
96
106
  repositoryKey: text(repository?.key, "repository key"),
97
107
  providerUrl: text(repository?.provider_url, "repository provider URL"),
98
108
  baseCommitSha: text(repository?.base_commit_sha, "repository SHA"),
99
- ...(isLocalFix ? { workingBranch: text(repository?.working_branch, "approved fix branch") } : {})
109
+ ...(isLocalFix || isP1 ? { workingBranch: text(repository?.working_branch, "approved fix branch") } : {}),
110
+ ...(isP1 ? { p1WorkingBranch: text(repository?.working_branch, 'approved P1 branch') } : {})
100
111
  }));
101
112
  stage = "adapter";
102
113
  adapter = await preparation.step(() => this.deps.registry.select(adapterId, adapterVersion, { worktreePath: workspace.path, jobId: job.id }), late => late.cancel());
@@ -107,11 +118,17 @@ export class ExecutionCoordinator {
107
118
  if (contractFingerprint(gatewayShadow.contractPayload) !== expectedContractFingerprint || gatewayShadow.contractFingerprint !== expectedContractFingerprint || gatewayShadow.taskPackageFingerprint !== fingerprint(pkg)) {
108
119
  throw new GatewayToolError("contract or task-package fingerprint mismatch before binding", 'work_execution_contract_show', 'JOB_SHADOW_FENCE_INVALID');
109
120
  }
121
+ if (isP1)
122
+ assertP1Bindings(pkg, gatewayShadow.contractPayload);
123
+ else if (hasP1(gatewayShadow.contractPayload))
124
+ throw Error('P1_CONTRACT_REJECTED');
110
125
  stage = "binding";
111
126
  await step(() => this.deps.state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, phase: "binding" }));
112
127
  preparation.beginBinding();
113
128
  bindingAttempted = true;
114
129
  binding = await step(() => this.deps.gateway.createBinding({ ...job, checkoutPath: workspace.path }, preparation.signal));
130
+ if (isP1)
131
+ assertP1Bindings(pkg, binding.taskPackage, true);
115
132
  await step(() => this.deps.state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, phase: "running", bindingId: binding.bindingId, workspaceManifest: workspace.manifest }));
116
133
  await preparation.stop();
117
134
  preparation.assert();
@@ -164,6 +181,7 @@ export class ExecutionCoordinator {
164
181
  const executor = adapter;
165
182
  let heartbeatFailure;
166
183
  const evidenceAbort = new AbortController();
184
+ const verificationAbort = new AbortController();
167
185
  const controlAbort = new AbortController();
168
186
  let leaseBoundary;
169
187
  let rejectFence;
@@ -202,6 +220,7 @@ export class ExecutionCoordinator {
202
220
  heartbeatFailure = executionError(error, 'heartbeat', 'EXECUTION_FAILED');
203
221
  controlAbort.abort();
204
222
  evidenceAbort.abort();
223
+ verificationAbort.abort();
205
224
  rejectFence(heartbeatFailure);
206
225
  // Cancellation cannot queue behind transport or generator cleanup.
207
226
  void stopExecutor().catch(() => { });
@@ -239,6 +258,8 @@ export class ExecutionCoordinator {
239
258
  let commandTerminal;
240
259
  if (["cancel", "pause", "request_replan"].includes(command.command)) {
241
260
  lifecycle.current = false;
261
+ if (isP1)
262
+ verificationAbort.abort();
242
263
  commandTerminal = { eventType: "cancelled", summary: `supervisor ${command.command} acknowledged`,
243
264
  payload: { reason: command.command === 'pause' ? 'paused' : command.command === 'request_replan' ? 'replan_requested' : 'cancelled' } };
244
265
  await stopExecutor();
@@ -253,6 +274,8 @@ export class ExecutionCoordinator {
253
274
  if (!isUnsupportedSupervisorCommand(error))
254
275
  throw error;
255
276
  lifecycle.current = false;
277
+ if (isP1)
278
+ verificationAbort.abort();
256
279
  await stopExecutor();
257
280
  requireControlFence();
258
281
  commandTerminal = { eventType: 'failed', summary: 'supervisor command is unsupported for the approved launch',
@@ -329,7 +352,9 @@ export class ExecutionCoordinator {
329
352
  if (!ignoreControlFailure && (controlFailure || heartbeatFailure))
330
353
  throw controlFailure || heartbeatFailure;
331
354
  };
332
- const append = async (event) => {
355
+ const append = async (event, hostProof = false) => {
356
+ if (isP1 && !hostProof)
357
+ event = p1ModelEvent(event);
333
358
  const previousStage = executionStage;
334
359
  let payload = event.payload;
335
360
  if (isTerminalEventType(event.eventType)) {
@@ -338,6 +363,8 @@ export class ExecutionCoordinator {
338
363
  const evidence = localFixPublication && typeof localFixPublication.diff_hash === 'string' && typeof localFixPublication.changed_file_manifest_hash === 'string' ? { diffHash: localFixPublication.diff_hash, changedFileManifestHash: localFixPublication.changed_file_manifest_hash } : await fenced(this.deps.terminalEvidenceCollector.collect({ checkoutPath: workspace.path, baseSha: workspace.baseCommitSha, signal: evidenceAbort.signal }));
339
364
  requireControlFence();
340
365
  payload = { ...payload, diff_hash: evidence.diffHash, changed_file_manifest_hash: evidence.changedFileManifestHash };
366
+ if (isP1 && event.eventType === 'completed' && p1Result)
367
+ payload = { ...payload, candidate_commit_sha: p1Result.candidate_commit_sha, snapshot_fingerprint: p1Result.snapshot_fingerprint, changed_paths: p1Result.changed_paths, verification_receipt_ids: [...p1ReceiptIds] };
341
368
  }
342
369
  // A vendor report cannot supply host observations, even when its shape looks valid.
343
370
  if (payload) {
@@ -373,8 +400,11 @@ export class ExecutionCoordinator {
373
400
  }
374
401
  requireControlFence();
375
402
  eventUncertain = true;
376
- await fenced(this.deps.gateway.appendEvent(binding.bindingId, normalized));
403
+ const receipt = await fenced(this.deps.gateway.appendEvent(binding.bindingId, normalized));
377
404
  requireControlFence();
405
+ const eventId = receipt?.id;
406
+ if (isP1 && hostProof && (typeof eventId !== 'string' || !eventId.trim() || p1ReceiptIds.includes(eventId)))
407
+ throw Error('P1_EVENT_ID_UNCONFIRMED');
378
408
  sequence = normalized.sequence;
379
409
  executionStage = 'state_save';
380
410
  if (event.eventType === "checkpoint_ready")
@@ -382,6 +412,7 @@ export class ExecutionCoordinator {
382
412
  await this.deps.state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, phase: "running", bindingId: binding.bindingId, eventCursor: sequence, ...(isTerminalEventType(event.eventType) ? { terminalEventType: event.eventType } : {}), workspaceManifest: workspace.manifest });
383
413
  eventUncertain = false;
384
414
  executionStage = previousStage;
415
+ return typeof eventId === 'string' ? eventId : undefined;
385
416
  };
386
417
  const publicationStep = async (operation) => {
387
418
  requireControlFence();
@@ -434,6 +465,33 @@ export class ExecutionCoordinator {
434
465
  if (requestedTerminal)
435
466
  break;
436
467
  if (isTerminalEventType(event.eventType)) {
468
+ if (event.eventType === 'completed' && isP1) {
469
+ if (!adapter.freezeForPublication)
470
+ throw Error('P1_OWNED_FREEZE_UNAVAILABLE');
471
+ const current = async () => { requireControlFence(); return lifecycle.current && !requestedTerminal && !verificationAbort.signal.aborted; };
472
+ const assertModelFrozen = async () => { if (!await current() || !lifecycle.frozen)
473
+ throw Error('P1_MODEL_NOT_FROZEN'); };
474
+ const freezeOwnedModel = async () => { if (!await current())
475
+ throw Error('P1_FENCE_LOST'); if (!lifecycle.frozen) {
476
+ await publicationStep(() => adapter.freezeForPublication());
477
+ lifecycle.frozen = true;
478
+ } await assertModelFrozen(); };
479
+ await freezeOwnedModel();
480
+ await readControls();
481
+ p1Result = await publicationStep(() => this.deps.p1Host.verify({ job, checkoutPath: workspace.path, bindingId: binding.bindingId, signal: verificationAbort.signal, current, freezeOwnedModel, assertModelFrozen }));
482
+ await readControls();
483
+ await assertModelFrozen();
484
+ const seen = new Set();
485
+ for (const receipt of p1Result.receipts) {
486
+ if (seen.has(receipt.receipt_id))
487
+ throw Error('P1_DUPLICATE_RECEIPT');
488
+ seen.add(receipt.receipt_id);
489
+ await readControls();
490
+ await assertModelFrozen();
491
+ const id = await publicationStep(() => append({ eventType: 'test_result', summary: 'runner command observed', payload: { observer: 'runner', runner_receipt: receipt } }, true));
492
+ p1ReceiptIds.push(id);
493
+ }
494
+ }
437
495
  if (event.eventType === "completed" && fixSession) {
438
496
  // Stop only this owned model before running verified host publication.
439
497
  // Controls/heartbeat remain active throughout the publication phase.
@@ -547,6 +605,7 @@ export class ExecutionCoordinator {
547
605
  if (heartbeatTimer)
548
606
  clearInterval(heartbeatTimer);
549
607
  lifecycle.current = false;
608
+ verificationAbort.abort();
550
609
  if (leaseBoundary)
551
610
  clearTimeout(leaseBoundary);
552
611
  await fixSession?.close();
@@ -58,6 +58,7 @@ export async function buildDaemon(input) {
58
58
  const state = new FileDaemonStateStore(input.stateRoot);
59
59
  const coordinator = new ExecutionCoordinator({
60
60
  localFixHost,
61
+ p1Host: input.p1Host,
61
62
  reportExecutionFailure: (diagnostic) => { process.stderr.write(`${JSON.stringify({ event: "execution_failed", ...diagnostic })}\n`); },
62
63
  reportPreparationFailure: (diagnostic) => { process.stderr.write(`${JSON.stringify({ event: "preparation_failed", ...diagnostic })}\n`); },
63
64
  terminalEvidenceCollector: buildTerminalEvidenceCollector(),
@@ -32,15 +32,17 @@ function epochToIso(value) {
32
32
  function compactRecord(value) {
33
33
  return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== null && (!Array.isArray(item) || item.length > 0)));
34
34
  }
35
- function shadowTaskPackage(contract) {
35
+ export function shadowTaskPackage(contract) {
36
36
  const payload = contract.contract_payload;
37
37
  const execution = nested(payload, "agent_execution");
38
38
  const brief = nested(execution, "implementation_brief");
39
+ const verification = nested(payload, 'verification.mode') === 'runner_observed_v1' ? payload.verification : undefined;
39
40
  const repositories = (payload.repositories ?? []).map((repository) => ({
40
41
  repository_key: requiredString(repository.repository_key, "repository key"),
41
42
  access: repository.access === "write" ? "write" : "read",
42
43
  responsibility: repository.responsibility === "primary" ? "primary" : "supporting",
43
44
  base_commit_sha: requiredString(repository.base_commit_sha, "base commit SHA"),
45
+ ...(verification ? { allowed_paths: repository.allowed_paths ?? [], blocked_paths: repository.blocked_paths ?? [], working_branch: repository.working_branch ?? null } : {}),
44
46
  }));
45
47
  const implementationBrief = compactRecord({
46
48
  design_artifact_id: brief.design_artifact_id,
@@ -55,6 +57,7 @@ function shadowTaskPackage(contract) {
55
57
  });
56
58
  return {
57
59
  bundle_version: "agent-execution-v1",
60
+ ...(verification ? { verification, candidate_limits: nested(payload, 'policy.candidate_limits') ?? {} } : {}),
58
61
  contract: compactRecord({
59
62
  id: requiredString(contract.id, "execution contract ID"),
60
63
  status: requiredString(contract.status, "execution contract status"),
@@ -354,6 +357,7 @@ export class McpGatewayClient {
354
357
  const fingerprint = requiredString(nested(context.bundle, "contract.content_fingerprint"), "contract fingerprint");
355
358
  const result = await this.callTool("agent_runtime_event_append", {
356
359
  binding_id: bindingId,
360
+ ...(nested(context.bundle, 'contract.verification.mode') === 'runner_observed_v1' || nested(context.bundle, 'verification.mode') === 'runner_observed_v1' ? { job_fence: context.jobFence } : {}),
357
361
  ...(isTerminalEventType(event.eventType) ? { lease_token: context.leaseToken } : {}),
358
362
  event: {
359
363
  protocol_version: event.protocolVersion,
@@ -368,7 +372,7 @@ export class McpGatewayClient {
368
372
  contract_fingerprint: fingerprint,
369
373
  },
370
374
  });
371
- return { sequence: result.event.sequence };
375
+ return { sequence: result.event.sequence, ...(typeof result.event.id === 'string' ? { id: result.event.id } : {}) };
372
376
  }
373
377
  async readBindingEvents(bindingId, afterSequence) {
374
378
  const page = await this.callTool("agent_runtime_events_read", {
@@ -481,6 +485,7 @@ export class McpGatewayClient {
481
485
  const iterationId = requiredString(result.binding.iteration_id, "iteration ID");
482
486
  signal?.throwIfAborted();
483
487
  this.context = {
488
+ jobFence: { job_id: input.jobId, generation: input.generation, lease_token: input.jobLeaseToken },
484
489
  workLoopId: requiredString(result.work_loop_id, "Work Loop ID"),
485
490
  leaseToken: requiredString(result.lease_token, "Work Loop lease token"),
486
491
  leaseExpiresAt: epochToIso(result.binding.lease_expires_at),
@@ -2,10 +2,11 @@ import { createHash } from 'node:crypto';
2
2
  import { lstat, realpath } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { measureCommandFile } from '../safety/registered-command-integrity.js';
5
- export function protocolHash(value) {
5
+ export function protocolJson(value) {
6
6
  const canonical = (v) => Array.isArray(v) ? v.map(canonical) : v && typeof v === 'object' ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, c]) => [k, canonical(c)])) : v;
7
- return createHash('sha256').update(JSON.stringify(canonical(value)).replace(/[\u007f-\uffff]/g, c => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`)).digest('hex');
7
+ return JSON.stringify(canonical(value)).replace(/[\u007f-\uffff]/g, c => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
8
8
  }
9
+ export function protocolHash(value) { return createHash('sha256').update(protocolJson(value)).digest('hex'); }
9
10
  const denied = () => new Error('CANDIDATE_REJECTED');
10
11
  function freeze(v) { if (v && typeof v === 'object') {
11
12
  for (const c of Object.values(v))
@@ -1,20 +1,24 @@
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, writeFile } from 'node:fs/promises';
4
+ import { lstat, mkdir, mkdtemp, open, realpath, readdir, readFile, rm, writeFile } from 'node:fs/promises';
5
5
  import { createHash } from 'node:crypto';
6
6
  import { deflateSync } from 'node:zlib';
7
7
  import path from 'node:path';
8
8
  import { measureCommandFile } from '../safety/registered-command-integrity.js';
9
9
  import { protocolHash } from './candidate.js';
10
+ import { RawGitObjects } from './raw-git-objects.js';
10
11
  const execute = promisify(execFile), denied = () => Error('SNAPSHOT_REJECTED');
11
12
  const digest = (data) => createHash('sha256').update(data).digest('hex');
12
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
+ /** P1 branch shape only. Authority still requires exact frozen-fence equality. */
15
+ 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'));
13
16
  /** Reads only immutable Git objects and metadata. Never runs diff against worktree
14
17
  * content, where malicious clean filters can execute even with --no-ext-diff. */
15
18
  export class GitSnapshotHost {
16
19
  options;
17
20
  seals = new WeakSet();
21
+ ownedRoots = new Set();
18
22
  constructor(options) { this.options = structuredClone(options); }
19
23
  async git(args) {
20
24
  const o = this.options;
@@ -129,8 +133,8 @@ export class GitSnapshotHost {
129
133
  }
130
134
  }
131
135
  async capture() {
132
- const o = this.options;
133
- if (!/^[a-f0-9]{40}$/.test(o.baseCommitSha) || !/^ai-work\/local\/[A-Za-z0-9-]+$/.test(o.workingBranch) || await realpath(o.root) !== o.root || !Number.isSafeInteger(o.maxFileBytes) || o.maxFileBytes < 1 || !Number.isSafeInteger(o.maxTotalBytes) || o.maxTotalBytes < o.maxFileBytes)
136
+ 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);
137
+ 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)
134
138
  throw denied();
135
139
  const head = (await this.git(['rev-parse', '--verify', 'HEAD'])).toString().trim(), branch = (await this.git(['symbolic-ref', '--short', 'HEAD'])).toString().trim();
136
140
  if (head !== o.baseCommitSha || branch !== o.workingBranch)
@@ -168,7 +172,7 @@ export class GitSnapshotHost {
168
172
  const files = [], changes = [];
169
173
  let total = 0;
170
174
  for (const name of [...names].sort()) {
171
- if ([...gitlinks.keys()].some(p => name === p || name.startsWith(p + '/')))
175
+ if ([...gitlinks.keys()].some(p => name === p || name.startsWith(p + '/') || p.startsWith(name + '/')))
172
176
  throw denied();
173
177
  const current = await this.currentFile(name), prior = base.get(name);
174
178
  let before = null;
@@ -194,7 +198,7 @@ export class GitSnapshotHost {
194
198
  /** Creates a separate local-only Git checkout with exact base parent objects.
195
199
  * Raw object copying and raw file writes invoke no filters, templates or hooks;
196
200
  * no local fetch/upload-pack, network or account credentials are involved. */
197
- async publicationCheckout(sealed) {
201
+ async privateCheckout(sealed, prefix = 'publisher-') {
198
202
  try {
199
203
  if (!this.seals.has(sealed))
200
204
  throw denied();
@@ -202,7 +206,8 @@ export class GitSnapshotHost {
202
206
  const o = this.options, parent = o.privateRoot;
203
207
  if (await realpath(parent) !== parent || (await lstat(parent)).mode & 0o077)
204
208
  throw denied();
205
- const root = await mkdtemp(path.join(parent, 'publisher-')), gitdir = path.join(root, '.git');
209
+ const root = await mkdtemp(path.join(parent, prefix)), gitdir = path.join(root, '.git');
210
+ this.ownedRoots.add(root);
206
211
  await mkdir(path.join(gitdir, 'objects'), { recursive: true, mode: 0o700 });
207
212
  const ref = path.join(gitdir, 'refs', 'heads', o.workingBranch);
208
213
  await mkdir(path.dirname(ref), { recursive: true, mode: 0o700 });
@@ -261,6 +266,81 @@ export class GitSnapshotHost {
261
266
  throw denied();
262
267
  }
263
268
  }
269
+ async publicationCheckout(sealed) { return this.privateCheckout(sealed); }
270
+ async assertSourceUnchanged(sealed) {
271
+ try {
272
+ if (!this.seals.has(sealed))
273
+ throw denied();
274
+ const capture = await this.capture();
275
+ if (protocolHash(capture.observation) !== protocolHash(sealed.observation) || protocolHash(capture.files.map(f => ({ path: f.path, mode: f.mode, sha256: digest(f.bytes) }))) !== sealed.treeFingerprint)
276
+ throw denied();
277
+ }
278
+ catch {
279
+ throw denied();
280
+ }
281
+ }
282
+ /** Verification only; creates no publication capability or remote operation. */
283
+ async verificationCandidate(sealed) {
284
+ try {
285
+ if (this.options.p1WorkingBranch === undefined)
286
+ throw denied();
287
+ const root = await this.privateCheckout(sealed, 'verification-'), entries = [];
288
+ const walk = async (dir, prefix = '') => { for (const name of (await readdir(dir)).sort()) {
289
+ if (!prefix && name === '.git')
290
+ continue;
291
+ const rel = prefix ? prefix + '/' + name : name, file = path.join(dir, name), s = await lstat(file);
292
+ if (s.isSymbolicLink() || (!s.isDirectory() && (!s.isFile() || s.nlink !== 1)))
293
+ throw denied();
294
+ if (s.isDirectory())
295
+ await walk(file, rel);
296
+ else
297
+ entries.push({ path: rel, mode: s.mode & 0o111 ? 0o100755 : 0o100644, bytes: await readFile(file) });
298
+ } };
299
+ await walk(root);
300
+ entries.sort((a, b) => a.path < b.path ? -1 : 1);
301
+ if (protocolHash(entries.map(e => ({ path: e.path, mode: e.mode, sha256: digest(e.bytes) }))) !== sealed.treeFingerprint)
302
+ throw denied();
303
+ for (const line of (await this.git(['ls-tree', '-r', '-z', this.options.baseCommitSha])).toString().split('\0')) {
304
+ const link = /^160000 commit ([a-f0-9]{40})\t(.+)$/s.exec(line);
305
+ if (link)
306
+ entries.push({ path: link[2], mode: 0o160000, bytes: Buffer.alloc(0), gitlink: link[1] });
307
+ }
308
+ 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);
309
+ await writeFile(path.join(root, '.git', 'HEAD'), commitSha + '\n', { mode: 0o600 });
310
+ // Bind every private checkout byte, including raw Git metadata, before tests.
311
+ const fingerprint = async () => { const values = []; const visit = async (dir, prefix = '') => { for (const name of (await readdir(dir)).sort()) {
312
+ const relative = prefix ? prefix + '/' + name : name, file = path.join(dir, name), s = await lstat(file);
313
+ if (s.isSymbolicLink() || (!s.isDirectory() && (!s.isFile() || s.nlink !== 1)))
314
+ throw denied();
315
+ if (s.isDirectory())
316
+ await visit(file, relative);
317
+ else
318
+ values.push({ path: relative, mode: s.mode & 0o111 ? 0o100755 : 0o100644, sha256: await measureCommandFile(file) });
319
+ } }; await visit(root); return protocolHash(values); };
320
+ const expected = await fingerprint();
321
+ await sealed.assertUnchanged();
322
+ return Object.freeze({ root, commitSha, assertUnchanged: async () => { await sealed.assertUnchanged(); if (await fingerprint() !== expected)
323
+ throw denied(); } });
324
+ }
325
+ catch {
326
+ throw denied();
327
+ }
328
+ }
329
+ /** Caller owns this lifecycle. Never returns success if private cleanup fails. */
330
+ async dispose() {
331
+ let failed = false;
332
+ for (const root of this.ownedRoots) {
333
+ try {
334
+ await rm(root, { recursive: true, force: true });
335
+ this.ownedRoots.delete(root);
336
+ }
337
+ catch {
338
+ failed = true;
339
+ }
340
+ }
341
+ if (failed)
342
+ throw denied();
343
+ }
264
344
  async observe() { try {
265
345
  const a = await this.capture(), b = await this.capture();
266
346
  if (a.fingerprint !== b.fingerprint)
@@ -282,6 +362,7 @@ export class GitSnapshotHost {
282
362
  if (!stat.isDirectory() || (stat.mode & 0o077) !== 0 || (process.getuid && stat.uid !== process.getuid()))
283
363
  throw denied();
284
364
  const capture = await this.capture(), root = await mkdtemp(path.join(parent, 'candidate-'));
365
+ this.ownedRoots.add(root);
285
366
  for (const f of capture.files) {
286
367
  const file = path.join(root, f.path);
287
368
  await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
@@ -1,10 +1,10 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
3
  import { createHash } from 'node:crypto';
4
- import { deflateSync, inflateSync } from 'node:zlib';
5
- import { lstat, readdir, readFile, writeFile, mkdir } from 'node:fs/promises';
4
+ import { lstat, readdir, readFile, writeFile } from 'node:fs/promises';
6
5
  import path from 'node:path';
7
6
  import { protocolHash } from './candidate.js';
7
+ import { RawGitObjects } from './raw-git-objects.js';
8
8
  import { assertPinnedGit, assertPrivateCheckout, publicationGitEnvironment, publicationGitArgs, strictPublicationOrigin } from './git-credential-broker.js';
9
9
  const execute = promisify(execFile), denied = () => Error('GIT_PUBLICATION_REJECTED');
10
10
  /** Only DeterministicPublisher may dispatch validated signed requests. This
@@ -79,34 +79,8 @@ export class GitPublicationTransport {
79
79
  throw denied();
80
80
  return entries;
81
81
  }
82
- async object(type, bytes, write) { const raw = Buffer.concat([Buffer.from(`${type} ${bytes.length}\0`), bytes]), oid = createHash('sha1').update(raw).digest('hex'); if (write) {
83
- const dir = path.join(this.options.checkout, '.git', 'objects', oid.slice(0, 2)), file = path.join(dir, oid.slice(2));
84
- await mkdir(dir, { recursive: true, mode: 0o700 });
85
- try {
86
- await writeFile(file, deflateSync(raw), { flag: 'wx', mode: 0o400 });
87
- }
88
- catch (e) {
89
- if (e.code !== 'EEXIST' || !inflateSync(await readFile(file)).equals(raw))
90
- throw denied();
91
- }
92
- } return oid; }
93
- async tree(entries, write, prefix = '') {
94
- const names = new Map();
95
- const children = new Set();
96
- for (const entry of entries) {
97
- if (!entry.path.startsWith(prefix))
98
- continue;
99
- const rest = entry.path.slice(prefix.length), slash = rest.indexOf('/');
100
- if (slash >= 0)
101
- children.add(rest.slice(0, slash));
102
- else
103
- names.set(rest, { mode: entry.mode.toString(8), oid: entry.gitlink ?? await this.object('blob', entry.bytes, write) });
104
- }
105
- for (const child of children)
106
- names.set(child, { mode: '40000', oid: await this.tree(entries, write, prefix + child + '/') });
107
- const chunks = [...names].sort(([a, av], [b, bv]) => Buffer.compare(Buffer.from(a + (av.mode === '40000' ? '/' : '')), Buffer.from(b + (bv.mode === '40000' ? '/' : '')))).map(([name, v]) => Buffer.concat([Buffer.from(`${v.mode} ${name}\0`), Buffer.from(v.oid, 'hex')]));
108
- return this.object('tree', Buffer.concat(chunks), write);
109
- }
82
+ async object(type, bytes, write) { return new RawGitObjects(this.options.checkout).object(type, bytes, write); }
83
+ async tree(entries, write) { return new RawGitObjects(this.options.checkout).tree(entries, write); }
110
84
  marker(r) { return `Local Fix ${r.attempt_id}\n\nLocal-Fix-Publication: ${r.publication_id}\nLocal-Fix-Candidate: ${r.candidate_fingerprint}\n`; }
111
85
  async commit(r, entries, write) { const tree = await this.tree(entries, write); return this.object('commit', Buffer.from(`tree ${tree}\nparent ${r.base_commit_sha}\nauthor Local Fix <local-fix@invalid> 1 +0000\ncommitter Local Fix <local-fix@invalid> 1 +0000\n\n${this.marker(r)}Local-Fix-Request: ${r.request_fingerprint}\n`), write); }
112
86
  scope(r) { return { origin: this.options.origin, checkout: this.options.checkout, ref: `refs/heads/${r.working_branch}`, sha: r.accepted_results.committed_sha }; }