@miraland-labs/conduit-bridge 0.10.0 → 0.11.0

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  Local Bridge CLI for [Conduit](https://github.com/miralandlabs/conduit). Connects a computer to one organization, claims work, and drives a local agent.
4
4
 
5
- **Package version:** `0.10.0` — **ExecutionClass** permission policy (`observe` | `observe_network` | `publish_artifact` | `verify` | `mutate_repo`) compiled once from the assignment contract and projected into every driver, so prompts match enforcement without a growing publish-command allow-list. Artifact evidence digests still normalize bare 64-hex to `sha256:<hex>`. New **Experimental Pi** lane (`pi` from `@earendil-works/pi-coding-agent`, local fuel); `observe_network` is refused on Pi (no fetch-without-bash). Heartbeat protocol 2 requires a clean workspace plus a ready, versioned driver lane before dispatch. Attempt worktrees fetch/start from the initiative base when the source tip is behind or history was rewritten, without resetting the source checkout. Also includes **ops** helpers for macOS / Linux / Windows, LaunchAgent/systemd **PATH** for `~/.local/bin`, disconnect/disengage, multi-driver lanes, shared slots **1–4**, worktrees, and optional `--ensure-checkout`. Protocol 1 heartbeats remain compatible for presence but cannot receive work.
5
+ **Package version:** `0.11.0` — **ExecutionClass inversion:** the control plane stamps `execution_class` on the assignment; Bridge launches from that stamp (fail closed if missing), asserts the full class floor after ceilings, and projects permissions locally (“Bridge wins” on tools). Ordinary grants are derived server-side as classMax project ceiling; landing is package `lands`, not a class axiom. Workspace briefs discover verification commands for Node, Make, Cargo, Go, pytest, and Gradle. Control-plane `instruction` is **additive** evidence/class prose; Bridge always appends local RULES (shell allow-list, verbatim paste, criterion mapping, hard-denied). Prompt is data; permissions stay local residual risk if the control plane is compromised (sharpest for `observe_network`). Artifact digests still normalize bare 64-hex to `sha256:<hex>`. Experimental Pi lane remains; `observe_network` is refused on Pi. Heartbeat protocol 2, worktrees, ops helpers, multi-driver lanes, and slots **1–4** unchanged. Protocol 1 heartbeats remain compatible for presence but cannot receive work.
6
6
 
7
7
  ## Prerequisites
8
8
 
@@ -78,7 +78,7 @@ repository delivery with PR/evidence and a successful rework cycle. All other la
78
78
 
79
79
  ## Grant enforcement
80
80
 
81
- Assignments compile to an **ExecutionClass**, then each driver projects that class:
81
+ The control plane authors **ExecutionClass**; Bridge requires the stamped class and projects it:
82
82
 
83
83
  | Driver | Fuel | Mechanism |
84
84
  | --- | --- | --- |
package/dist/brief.js CHANGED
@@ -3,9 +3,13 @@ import { join, resolve } from "node:path";
3
3
  import { execFile } from "node:child_process";
4
4
  import { promisify } from "node:util";
5
5
  const execFileAsync = promisify(execFile);
6
- const MANIFESTS = ["package.json", "wrangler.jsonc", "wrangler.toml", "tsconfig.json", "Cargo.toml", "pyproject.toml", "go.mod", "Makefile"];
6
+ const MANIFESTS = [
7
+ "package.json", "wrangler.jsonc", "wrangler.toml", "tsconfig.json",
8
+ "Cargo.toml", "pyproject.toml", "pytest.ini", "setup.cfg", "go.mod",
9
+ "Makefile", "build.gradle", "build.gradle.kts", "gradlew",
10
+ ];
7
11
  const VERIFICATION_SCRIPTS = ["verify", "typecheck", "lint", "test", "build"];
8
- const EXCLUDED_DIRECTORIES = new Set(["node_modules", "dist", "build", "target", "coverage"]);
12
+ const EXCLUDED_DIRECTORIES = new Set(["node_modules", "dist", "build", "target", "coverage", ".venv", "venv"]);
9
13
  export async function buildWorkspaceBrief(workspace) {
10
14
  const entries = await readdir(workspace, { withFileTypes: true });
11
15
  const modules = entries
@@ -18,7 +22,7 @@ export async function buildWorkspaceBrief(workspace) {
18
22
  base_commit: await gitHeadCommit(workspace),
19
23
  modules,
20
24
  manifests,
21
- verification: await verificationCommands(workspace, files),
25
+ verification: await discoverVerificationCommands(workspace, files),
22
26
  };
23
27
  }
24
28
  export function normalizeRepositoryUrl(url) {
@@ -191,7 +195,8 @@ async function gitDirectories(workspace) {
191
195
  return { worktree, common: worktree };
192
196
  }
193
197
  }
194
- async function verificationCommands(workspace, files) {
198
+ /** Exported for tests — discovers bounded verification commands from workspace manifests. */
199
+ export async function discoverVerificationCommands(workspace, files) {
195
200
  const commands = [];
196
201
  if (files.has("package.json")) {
197
202
  try {
@@ -209,5 +214,36 @@ async function verificationCommands(workspace, files) {
209
214
  }
210
215
  catch { /* unreadable Makefiles do not broaden execution */ }
211
216
  }
212
- return commands;
217
+ if (files.has("Cargo.toml")) {
218
+ commands.push("cargo test");
219
+ }
220
+ if (files.has("go.mod")) {
221
+ commands.push("go test ./...");
222
+ }
223
+ if (files.has("pyproject.toml") || files.has("pytest.ini") || files.has("setup.cfg")) {
224
+ let hasPytest = files.has("pytest.ini");
225
+ if (!hasPytest && files.has("pyproject.toml")) {
226
+ try {
227
+ const pyproject = await readFile(join(workspace, "pyproject.toml"), "utf8");
228
+ hasPytest = /\bpytest\b/i.test(pyproject);
229
+ }
230
+ catch { /* unreadable manifests do not broaden execution */ }
231
+ }
232
+ if (!hasPytest && files.has("setup.cfg")) {
233
+ try {
234
+ const setup = await readFile(join(workspace, "setup.cfg"), "utf8");
235
+ hasPytest = /\[tool:\s*pytest\]|\bpytest\b/i.test(setup);
236
+ }
237
+ catch { /* unreadable manifests do not broaden execution */ }
238
+ }
239
+ // Only when pytest is evidenced — bare pyproject.toml is often unittest/nox/poetry without pytest.
240
+ if (hasPytest) {
241
+ commands.push("python -m pytest");
242
+ }
243
+ }
244
+ if (files.has("build.gradle") || files.has("build.gradle.kts")) {
245
+ if (files.has("gradlew"))
246
+ commands.push("./gradlew test");
247
+ }
248
+ return [...new Set(commands)];
213
249
  }
package/dist/driver.js CHANGED
@@ -3,8 +3,8 @@ import { existsSync } from "node:fs";
3
3
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
4
  import { join } from "node:path";
5
5
  import { z } from "zod";
6
- import { deniedCommands, executionClassPromptRules, parsePiJsonl, projectClaude, projectCoarseMode, projectCodex, projectCursor, projectKiroTools, projectPi, resolveExecutionClass, } from "./execution-class.js";
7
- export { branchCreateCommands, deniedCommands, isBoundedVerificationCommand, prCreateCommands, resolveExecutionClass, } from "./execution-class.js";
6
+ import { deniedCommands, executionClassPromptRules, parsePiJsonl, projectClaude, projectCoarseMode, projectCodex, projectCursor, projectKiroTools, projectPi, requireStampedExecutionClass, resolveExecutionClass, } from "./execution-class.js";
7
+ export { branchCreateCommands, deniedCommands, isBoundedVerificationCommand, prCreateCommands, requireStampedExecutionClass, resolveExecutionClass, } from "./execution-class.js";
8
8
  function deliveryLanguageRule(language) {
9
9
  if (language === "zh") {
10
10
  return "- Write every human-facing delivery packet string (outcome, changes, verification, assumptions, risks, limitations, criterion text) in Chinese (Simplified). Keep JSON keys and enum token values in English.";
@@ -55,12 +55,7 @@ export const claudeDeniedTools = deniedCommands.map((command) => `Bash(${command
55
55
  function resolveRunClass(input) {
56
56
  if (input.executionClass)
57
57
  return input.executionClass;
58
- return resolveExecutionClass({
59
- grants: input.grants,
60
- capabilities: input.capabilities,
61
- deliverable: input.deliverable,
62
- workRole: input.workRole,
63
- });
58
+ throw new Error("Driver run is missing stamped execution_class. Pass executionClass from the assignment stamp.");
64
59
  }
65
60
  export function buildAssignmentPrompt(context) {
66
61
  const packageContext = context.workPackage;
@@ -91,7 +86,7 @@ export function buildAssignmentPrompt(context) {
91
86
  lines.push("", `BOUNDARIES — never violate these\n${boundaries.map((item) => `- ${item}`).join("\n")}`);
92
87
  if (acceptance?.length)
93
88
  lines.push("", `ACCEPTANCE CRITERIA — the delivery is judged against these\n${acceptance.map((item) => `- ${item}`).join("\n")}`);
94
- if (evidence?.length) {
89
+ if (evidence?.length && !spec.instruction) {
95
90
  lines.push("", "REQUIRED EVIDENCE");
96
91
  for (const kind of evidence) {
97
92
  if (kind === "test") {
@@ -115,12 +110,14 @@ export function buildAssignmentPrompt(context) {
115
110
  lines.push("", `REWORK FEEDBACK — an independent review returned this delivery; address every point\n${rework}`);
116
111
  const mustCommit = context.grants.includes("repo_write") && context.grants.includes("branch_create");
117
112
  const mustOpenPr = mustCommit && context.grants.includes("pr_create") && Boolean(changeScope?.length);
118
- const executionClass = context.executionClass ?? resolveExecutionClass({
119
- grants: context.grants,
120
- capabilities: spec.required_capabilities,
121
- deliverable: packageContext?.deliverable ?? spec.deliverable,
122
- workRole: role,
123
- });
113
+ const executionClass = context.executionClass
114
+ ?? requireStampedExecutionClass(spec.execution_class);
115
+ // Phase B: control-plane instruction is additive evidence/class prose.
116
+ // Local permission projection + hardened delivery rules stay Bridge-authored (Bridge wins on tools;
117
+ // skipping them when instruction was present recreated the summarize/guess-shell canary failures).
118
+ if (typeof spec.instruction === "string" && spec.instruction.trim()) {
119
+ lines.push("", spec.instruction.trim());
120
+ }
124
121
  const languageRule = deliveryLanguageRule(packageContext?.working_language ?? spec.working_language);
125
122
  const classRules = executionClassPromptRules({
126
123
  executionClass,
@@ -131,6 +128,11 @@ export function buildAssignmentPrompt(context) {
131
128
  mustOpenPr,
132
129
  });
133
130
  lines.push("", "RULES", "- Stay within the change scope and boundaries.", ...(languageRule ? [languageRule] : []), ...classRules, "- Never merge, deploy, force-push, push to protected branches, or touch production.", `- Hard-denied commands (all drivers): ${deniedCommands.join("; ")}.`, "- Do not invent evidence. Report unknown when you could not verify a criterion.",
131
+ // The reviewer requests changes when "verification output is only summarized (not verbatim)"
132
+ // (REVIEWER_PROMPT). Only the bounded-shell rule said to report real output, so publish_artifact
133
+ // — the class the reviewer scrutinises hardest — summarized, and every criterion came back
134
+ // unsupported. Stated once here so it holds for every class rather than per branch.
135
+ "- Paste the actual command output into the evidence details: the HTTP status line, the digest command's output, the fetched body. A summary of what a command printed (\"returned HTTP 200\", \"digest matches\") is treated as unsupported and returns the delivery. Writing the proof to a file does not count — only the delivery packet is reviewed, so the output must be in the evidence entry itself.",
134
136
  // A met claim with nothing backing it is rejected server-side ("Met acceptance criteria require
135
137
  // mapped evidence"). Observed live: six criteria marked met, evidence mapped to four, whole
136
138
  // delivery lost. Say it here rather than let the agent discover it by failing.
@@ -422,9 +424,10 @@ export const codexDriver = {
422
424
  * publish shell under open class policy. Research-only networked assignments
423
425
  * deny Shell(*) and Write(**).
424
426
  */
425
- export function cursorPermissionsForGrants(grants, verificationCommands = [], capabilities = [], deliverable = "repository", workRole) {
426
- const executionClass = resolveExecutionClass({ grants, capabilities, deliverable, workRole });
427
- const { allow, deny } = projectCursor(executionClass, { grants, verificationCommands, capabilities });
427
+ export function cursorPermissionsForGrants(grants, verificationCommands = [], capabilities = [], deliverable = "repository", workRole, executionClass) {
428
+ const cls = executionClass
429
+ ?? resolveExecutionClass({ grants, capabilities, deliverable, workRole });
430
+ const { allow, deny } = projectCursor(cls, { grants, verificationCommands, capabilities });
428
431
  return { allow, deny };
429
432
  }
430
433
  /**
@@ -4,7 +4,8 @@
4
4
  */
5
5
  /** Bounded verification commands a `test_run` grant may execute. */
6
6
  export function isBoundedVerificationCommand(command) {
7
- return /^(npm run (verify|typecheck|lint|test|build)|pnpm (verify|typecheck|lint|test|build)|yarn (verify|typecheck|lint|test|build)|cargo (test|check)|go test(?: \.\/\.\.\.)?|make (test|check))$/.test(command);
7
+ // Exact forms only a trailing `.*` on pytest would admit `pytest ; curl … | sh`.
8
+ return /^(npm run (verify|typecheck|lint|test|build)|pnpm (verify|typecheck|lint|test|build)|yarn (verify|typecheck|lint|test|build)|cargo (test|check)|go test(?: \.\/\.\.\.)?|make (test|check)|python -m pytest|pytest(?: [\w./=-]+)?|\.\/gradlew test)$/.test(command);
8
9
  }
9
10
  /** Shell commands each grant authorizes — mapped per driver so they cannot drift. */
10
11
  export const branchCreateCommands = [
@@ -26,6 +27,10 @@ export const EXECUTION_CLASSES = [
26
27
  "verify",
27
28
  "mutate_repo",
28
29
  ];
30
+ /**
31
+ * Migrator / test helper only. Launch must use the control-plane stamped class
32
+ * via `requireStampedExecutionClass` — do not re-derive as source of truth.
33
+ */
29
34
  export function resolveExecutionClass(input) {
30
35
  const grants = input.grants;
31
36
  const capabilities = input.capabilities ?? [];
@@ -47,6 +52,34 @@ export function resolveExecutionClass(input) {
47
52
  return "observe";
48
53
  return "mutate_repo";
49
54
  }
55
+ const STAMPED_CLASSES = new Set(EXECUTION_CLASSES);
56
+ /** Fail closed when the assignment has no stamped class (upgrade Bridge / redeploy CP). */
57
+ export function requireStampedExecutionClass(stamped) {
58
+ if (typeof stamped === "string" && STAMPED_CLASSES.has(stamped))
59
+ return stamped;
60
+ throw new Error("Assignment is missing stamped execution_class. Upgrade Bridge to 0.11+ and redeploy the control plane so plans author ExecutionClass.");
61
+ }
62
+ /** Minimum ordinary grants that must survive project/machine ceilings (mirrors control-plane classFloor). */
63
+ export function classFloor(executionClass) {
64
+ switch (executionClass) {
65
+ case "observe":
66
+ case "observe_network":
67
+ case "verify":
68
+ return ["repo_read"];
69
+ case "publish_artifact":
70
+ case "mutate_repo":
71
+ return ["repo_read", "repo_write"];
72
+ }
73
+ }
74
+ export function assertClassFloor(executionClass, ordinaryGrants) {
75
+ const grants = new Set(ordinaryGrants);
76
+ for (const grant of classFloor(executionClass)) {
77
+ if (!grants.has(grant)) {
78
+ return { ok: false, reason: `${executionClass}_requires_${grant}` };
79
+ }
80
+ }
81
+ return { ok: true };
82
+ }
50
83
  /**
51
84
  * Live HTTP is a property of the assignment's capabilities, not of its class.
52
85
  *
package/dist/execution.js CHANGED
@@ -2,7 +2,8 @@ import { createHash } from "node:crypto";
2
2
  import { z } from "zod";
3
3
  import { ConduitRequestError } from "./client.js";
4
4
  import { redactSecrets } from "./config.js";
5
- import { DRIVERS, agentReportTemplate, buildAssignmentPrompt, evidenceKinds, fuelEndpoint, normalizeEvidenceDigest, parseAgentReport, pickModelCandidate, resolveExecutionClass, tierForRisk } from "./driver.js";
5
+ import { DRIVERS, agentReportTemplate, buildAssignmentPrompt, evidenceKinds, fuelEndpoint, normalizeEvidenceDigest, parseAgentReport, pickModelCandidate, requireStampedExecutionClass, tierForRisk } from "./driver.js";
6
+ import { assertClassFloor } from "./execution-class.js";
6
7
  import { pickDriverForClaim, resolveDriverFuel } from "./drivers.js";
7
8
  import { attemptWorktreePath, createAttemptWorktree, proveResumeWorktree, quarantineAttemptWorktree, removeAttemptWorktree, } from "./attempt-worktree.js";
8
9
  import { buildWorkspaceBrief, ensureCommitAvailable, normalizeRepositoryUrl, resolveAttemptStartCommit } from "./brief.js";
@@ -50,6 +51,13 @@ const taskSpecSchema = z.object({
50
51
  repository: z.object({ url: z.string().optional(), base_commit: z.string().optional() }).nullable().optional(),
51
52
  risk_level: z.string().optional(),
52
53
  deliverable: z.enum(["repository", "artifact"]).optional().default("repository"),
54
+ execution_class: z.enum(["observe", "observe_network", "publish_artifact", "verify", "mutate_repo"]).optional(),
55
+ lands: z.boolean().optional(),
56
+ privileged_grants: z.array(z.string()).optional(),
57
+ external_network: z.boolean().optional(),
58
+ instruction: z.string().optional(),
59
+ evidence_standard: z.unknown().optional(),
60
+ working_language: z.enum(["en", "zh"]).optional(),
53
61
  });
54
62
  const executionContractSchema = z.object({
55
63
  repository_fingerprint: z.string().nullable().optional().default(null),
@@ -324,33 +332,43 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
324
332
  }
325
333
  await client.updateAttempt(taskId, { worktreePath: attemptWorkspace });
326
334
  }
327
- // Same list the driver hands to the permission contract, so the prompt states exactly what is
328
- // executable rather than leaving the agent to guess and hit rejections.
329
- const executionClass = resolveExecutionClass({
330
- grants,
331
- capabilities: spec.required_capabilities,
332
- deliverable,
333
- workRole: workPackage?.work_role ?? spec.work_role,
334
- });
335
- if (typeof spec.execution_class === "string" && spec.execution_class !== executionClass) {
336
- console.error(`Assignment ${taskId}: stamped execution_class=${spec.execution_class} differs from Bridge-derived ${executionClass}; using Bridge-derived class for launch`);
337
- }
338
- // An artifact deliverable that does not classify as publish_artifact cannot write the file it is
339
- // being asked to publish, so the run is doomed before it starts. Refuse here rather than spend an
340
- // agent run and fail at the delivery gate with a missing artifact and no stated cause. Reachable
341
- // when a control plane older than the artifact-grant normalization dispatches such a package —
342
- // exactly the window between publishing Bridge and deploying the control plane.
335
+ // Stamped class is the source of truth (Bridge 0.11+). Do not re-derive from grants.
336
+ let executionClass;
337
+ try {
338
+ executionClass = requireStampedExecutionClass(spec.execution_class);
339
+ }
340
+ catch (error) {
341
+ const detail = error instanceof Error ? error.message : "missing stamped execution_class";
342
+ console.error(`Assignment ${taskId}: ${detail}`);
343
+ // Retryable: a machine that ops-installed Bridge 0.11 before the control-plane deploy can recover
344
+ // once assignments carry stamped execution_class (deploy CP publish ops install; see handoff).
345
+ await queueTerminal(client, taskId, {
346
+ action: "fail",
347
+ body: { error: detail, retryable: true, idempotency_key: `bridge:fail:${active.attemptId}:missing-class` },
348
+ });
349
+ return;
350
+ }
351
+ // An artifact deliverable that is not publish_artifact cannot write/publish — fail closed.
343
352
  if (artifactDelivery && executionClass !== "publish_artifact") {
344
- const detail = `Artifact deliverable resolved to execution class ${executionClass}, which cannot write or publish it. The work package needs the repo_write grant.`;
353
+ const detail = `Artifact deliverable stamped as execution class ${executionClass}, which cannot write or publish it. Revise the plan to use publish_artifact.`;
345
354
  console.error(`Assignment ${taskId}: ${detail}`);
346
- // Not retryable: retrying cannot add a grant, so this must reach the owner as a plan correction
347
- // rather than burn attempts. Same terminal path every other launch failure uses.
348
355
  await queueTerminal(client, taskId, {
349
356
  action: "fail",
350
357
  body: { error: detail, retryable: false, idempotency_key: `bridge:fail:${active.attemptId}:artifact-class` },
351
358
  });
352
359
  return;
353
360
  }
361
+ // Full classFloor must survive project/machine ceilings (observe/verify need repo_read too).
362
+ const floor = assertClassFloor(executionClass, grants);
363
+ if (!floor.ok) {
364
+ const detail = `Class floor unmet: ${floor.reason}`;
365
+ console.error(`Assignment ${taskId}: ${detail}`);
366
+ await queueTerminal(client, taskId, {
367
+ action: "fail",
368
+ body: { error: detail, retryable: false, idempotency_key: `bridge:fail:${active.attemptId}:class-floor` },
369
+ });
370
+ return;
371
+ }
354
372
  const prompt = buildAssignmentPrompt({ taskId, objective: task.objective, spec, grants, workspace: attemptWorkspace, currentHead: worktreeStart, reworkFeedback, workPackage, verificationCommands: liveBrief?.verification ?? [], executionClass });
355
373
  const resuming = Boolean(options.forceResumeSessionId);
356
374
  await client.attemptRequest(taskId, "progress", {
@@ -449,6 +467,8 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
449
467
  grants: grants.filter((grant) => grant === "repo_read"),
450
468
  capabilities: [],
451
469
  verificationCommands: [],
470
+ // Envelope repair is read-only observe authority regardless of the original class.
471
+ executionClass: "observe",
452
472
  resumeSessionId: result.sessionId ?? undefined,
453
473
  timeoutMs,
454
474
  model: selection.model,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miraland-labs/conduit-bridge",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Conduit Bridge CLI — join, connect, disconnect, multi-driver lanes, and run Claude Code / Codex / Cursor / OpenCode / Pi / Kiro / Antigravity agents for a Conduit organization",
5
5
  "type": "module",
6
6
  "bin": {