From 38af4e50d1bab6ff173bb36acfb62518b4e9b2af Mon Sep 17 00:00:00 2001 From: OMK Release Bot Date: Sat, 13 Jun 2026 08:14:02 +0900 Subject: [PATCH] feat(orchestration): add lane grant isolation scaffold --- src/orchestration/lane-grant.ts | 231 ++++++++++++++++++++++++++++++++ test/lane-grant.test.mjs | 143 ++++++++++++++++++++ 2 files changed, 374 insertions(+) create mode 100644 src/orchestration/lane-grant.ts create mode 100644 test/lane-grant.test.mjs diff --git a/src/orchestration/lane-grant.ts b/src/orchestration/lane-grant.ts new file mode 100644 index 0000000000..a21919cb60 --- /dev/null +++ b/src/orchestration/lane-grant.ts @@ -0,0 +1,231 @@ +import { join, posix } from "path"; +import type { DagNode } from "./dag.js"; +import { validateWorktreeId } from "../util/worktree.js"; + +export type LaneAuthority = "read-only" | "advisory" | "review-only" | "execute-tests" | "write-scoped" | "memory-write"; + +export interface LaneGrant { + laneId: string; + role: string; + goal: string; + authority: LaneAuthority; + scope: string; + allowedPaths?: readonly string[]; + blockedPaths?: readonly string[]; + forbiddenActions?: readonly string[]; + skills: readonly string[]; + hooks: readonly string[]; + mcp: readonly string[]; + acceptance: readonly string[]; + evidenceOutput: string; +} + +export interface LaneGrantValidationOptions { + requireBlockedPaths?: boolean; +} + +export interface LaneIsolationPlan { + laneId: string; + branchName: string; + worktreePath: string; + evidenceOutput: string; + readOnly: boolean; + authority: LaneAuthority; +} + +export interface LaneIsolationPlanOptions { + runId: string; + worktreeRoot?: string; +} + +export interface LaneWriterConflict { + leftLaneId: string; + rightLaneId: string; + path: string; + conflictingPath: string; +} + +const READ_ONLY_AUTHORITIES = new Set(["read-only", "advisory", "review-only"]); +const WRITE_AUTHORITIES = new Set(["execute-tests", "write-scoped", "memory-write"]); + +export function isReadOnlyLane(grant: LaneGrant): boolean { + return READ_ONLY_AUTHORITIES.has(grant.authority); +} + +export function isWriteLane(grant: LaneGrant): boolean { + return WRITE_AUTHORITIES.has(grant.authority) && (grant.allowedPaths?.length ?? 0) > 0; +} + +export function validateLaneGrant(grant: LaneGrant, options: LaneGrantValidationOptions = {}): LaneGrant { + validateWorktreeId(grant.laneId, "laneId"); + assertNonEmptyText(grant.role, "role"); + assertNonEmptyText(grant.goal, "goal"); + assertNonEmptyText(grant.scope, "scope"); + assertNonEmptyArray(grant.skills, "skills"); + assertNonEmptyArray(grant.hooks, "hooks"); + assertNonEmptyArray(grant.mcp, "mcp"); + assertNonEmptyArray(grant.acceptance, "acceptance"); + validateEvidenceOutput(grant.evidenceOutput); + + if (options.requireBlockedPaths && (grant.blockedPaths?.length ?? 0) === 0) { + throw new Error(`Lane ${grant.laneId} must declare blockedPaths`); + } + + if (isReadOnlyLane(grant) && (grant.allowedPaths?.length ?? 0) > 0) { + throw new Error(`Read-only lane ${grant.laneId} must not declare product write paths`); + } + + if (grant.authority === "write-scoped" && (grant.allowedPaths?.length ?? 0) === 0) { + throw new Error(`Write-scoped lane ${grant.laneId} must declare allowedPaths`); + } + + for (const path of grant.allowedPaths ?? []) { + normalizeRepoPath(path, "allowedPaths"); + } + for (const path of grant.blockedPaths ?? []) { + normalizeRepoPath(path, "blockedPaths"); + } + + return grant; +} + +export function createLaneGrantFromDagNode(node: DagNode, overrides: Partial = {}): LaneGrant { + const routing = node.routing ?? {}; + const authority = overrides.authority ?? inferLaneAuthority(node); + return validateLaneGrant({ + laneId: overrides.laneId ?? node.id, + role: overrides.role ?? node.role, + goal: overrides.goal ?? node.name, + authority, + scope: overrides.scope ?? (routing.readOnly ? "read-only DAG lane" : "bounded DAG lane"), + allowedPaths: overrides.allowedPaths, + blockedPaths: overrides.blockedPaths ?? ["**/.env*", "**/*secret*", "**/*key*"], + forbiddenActions: overrides.forbiddenActions ?? ["secret disclosure", "out-of-scope edits", "destructive git operations"], + skills: overrides.skills ?? routing.skills ?? routing.assignedCapabilities?.skills ?? ["omk-context-broker"], + hooks: overrides.hooks ?? routing.hooks ?? routing.assignedCapabilities?.hooks ?? ["subagent-stop-audit.sh"], + mcp: overrides.mcp ?? routing.mcpServers ?? routing.assignedCapabilities?.mcpServers ?? ["omk-project"], + acceptance: overrides.acceptance ?? node.outputs?.map((output) => output.name) ?? ["lane completes with evidence"], + evidenceOutput: overrides.evidenceOutput ?? join(".omk", "runs", "lanes", `${node.id}.md`), + }); +} + +export function createLaneIsolationPlan(grant: LaneGrant, options: LaneIsolationPlanOptions): LaneIsolationPlan { + validateLaneGrant(grant); + const runId = validateWorktreeId(options.runId, "runId"); + const laneId = validateWorktreeId(grant.laneId, "laneId"); + const root = options.worktreeRoot ?? join(".omk", "worktrees"); + + return { + laneId, + branchName: `work/${runId}/${laneId}`, + worktreePath: join(root, runId, laneId), + evidenceOutput: grant.evidenceOutput, + readOnly: isReadOnlyLane(grant), + authority: grant.authority, + }; +} + +export function findParallelWriterConflicts(grants: readonly LaneGrant[]): LaneWriterConflict[] { + const conflicts: LaneWriterConflict[] = []; + const validated = grants.map((grant) => validateLaneGrant(grant)); + + assertUniqueEvidenceOutputs(validated); + + for (let i = 0; i < validated.length; i++) { + const left = validated[i]; + if (!isWriteLane(left)) continue; + const leftPaths = (left.allowedPaths ?? []).map((path) => normalizeRepoPath(path, "allowedPaths")); + + for (let j = i + 1; j < validated.length; j++) { + const right = validated[j]; + if (!isWriteLane(right)) continue; + const rightPaths = (right.allowedPaths ?? []).map((path) => normalizeRepoPath(path, "allowedPaths")); + + for (const leftPath of leftPaths) { + for (const rightPath of rightPaths) { + if (pathsOverlap(leftPath, rightPath)) { + conflicts.push({ + leftLaneId: left.laneId, + rightLaneId: right.laneId, + path: leftPath, + conflictingPath: rightPath, + }); + } + } + } + } + } + + return conflicts; +} + +export function assertNoParallelWriterConflicts(grants: readonly LaneGrant[]): void { + const conflicts = findParallelWriterConflicts(grants); + if (conflicts.length > 0) { + const summary = conflicts + .map((conflict) => `${conflict.leftLaneId}:${conflict.path} conflicts with ${conflict.rightLaneId}:${conflict.conflictingPath}`) + .join("; "); + throw new Error(`Parallel writer conflict: ${summary}`); + } +} + +function inferLaneAuthority(node: DagNode): LaneAuthority { + if (node.routing?.readOnly) return "read-only"; + if (["explorer", "researcher", "reviewer", "security", "qa"].includes(node.role)) return "review-only"; + if (node.role === "tester") return "execute-tests"; + return "write-scoped"; +} + +function assertNonEmptyText(value: string, label: string): void { + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error(`Lane grant requires ${label}`); + } +} + +function assertNonEmptyArray(values: readonly string[], label: string): void { + if (!Array.isArray(values) || values.length === 0 || values.some((value) => typeof value !== "string" || value.trim().length === 0)) { + throw new Error(`Lane grant requires non-empty ${label}`); + } +} + +function validateEvidenceOutput(path: string): string { + const normalized = normalizeRepoPath(path, "evidenceOutput"); + if (!normalized.startsWith(".omk/runs/")) { + throw new Error(`Lane evidenceOutput must stay under .omk/runs`); + } + return normalized; +} + +function normalizeRepoPath(path: string, label: string): string { + if (typeof path !== "string" || path.trim().length === 0) { + throw new Error(`Invalid ${label}`); + } + const normalized = path.replace(/\\/g, "/").replace(/^\.\//, ""); + if ( + posix.isAbsolute(normalized) || + normalized === "." || + normalized === ".." || + normalized.startsWith("../") || + normalized.includes("/../") || + normalized.endsWith("/..") + ) { + throw new Error(`Invalid ${label}: path escapes repository scope`); + } + return normalized; +} + +function pathsOverlap(left: string, right: string): boolean { + return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`); +} + +function assertUniqueEvidenceOutputs(grants: readonly LaneGrant[]): void { + const seen = new Map(); + for (const grant of grants) { + const output = validateEvidenceOutput(grant.evidenceOutput); + const previousLane = seen.get(output); + if (previousLane) { + throw new Error(`Lane evidenceOutput conflict: ${previousLane} and ${grant.laneId} both write ${output}`); + } + seen.set(output, grant.laneId); + } +} diff --git a/test/lane-grant.test.mjs b/test/lane-grant.test.mjs new file mode 100644 index 0000000000..3597ac63d4 --- /dev/null +++ b/test/lane-grant.test.mjs @@ -0,0 +1,143 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + assertNoParallelWriterConflicts, + createLaneGrantFromDagNode, + createLaneIsolationPlan, + findParallelWriterConflicts, + isReadOnlyLane, + isWriteLane, + validateLaneGrant, +} from "../dist/orchestration/lane-grant.js"; + +function baseGrant(overrides = {}) { + return { + laneId: "impl-core", + role: "coder", + goal: "Implement bounded core change", + authority: "write-scoped", + scope: "src/core only", + allowedPaths: ["src/core"], + blockedPaths: ["**/.env*", "**/*secret*", "**/*key*"], + skills: ["omk-typescript-strict"], + hooks: ["protect-secrets.sh"], + mcp: ["omk-project"], + acceptance: ["tests pass"], + evidenceOutput: ".omk/runs/run-1/impl-core.md", + ...overrides, + }; +} + +test("validateLaneGrant enforces explicit provisioning fields", () => { + const grant = validateLaneGrant(baseGrant()); + + assert.equal(grant.laneId, "impl-core"); + assert.equal(isWriteLane(grant), true); + assert.equal(isReadOnlyLane(grant), false); +}); + +test("read-only lanes cannot declare product write paths", () => { + assert.throws( + () => validateLaneGrant(baseGrant({ authority: "read-only", allowedPaths: ["src"] })), + /must not declare product write paths/ + ); +}); + +test("write-scoped lanes must declare allowedPaths", () => { + assert.throws( + () => validateLaneGrant(baseGrant({ allowedPaths: [] })), + /must declare allowedPaths/ + ); +}); + +test("evidence output must stay under .omk/runs", () => { + assert.throws( + () => validateLaneGrant(baseGrant({ evidenceOutput: "../leak.md" })), + /escapes repository scope/ + ); + assert.throws( + () => validateLaneGrant(baseGrant({ evidenceOutput: "tmp/lane.md" })), + /must stay under \.omk\/runs/ + ); + assert.throws( + () => validateLaneGrant(baseGrant({ allowedPaths: ["src/.."] })), + /escapes repository scope/ + ); +}); + +test("parallel writer conflicts catch same path and parent child overlap", () => { + const conflicts = findParallelWriterConflicts([ + baseGrant({ laneId: "impl-a", allowedPaths: ["src/runtime"] }), + baseGrant({ laneId: "impl-b", allowedPaths: ["src/runtime/router.ts"], evidenceOutput: ".omk/runs/run-1/impl-b.md" }), + baseGrant({ laneId: "review", authority: "review-only", allowedPaths: [], evidenceOutput: ".omk/runs/run-1/review.md" }), + ]); + + assert.deepEqual(conflicts, [ + { + leftLaneId: "impl-a", + rightLaneId: "impl-b", + path: "src/runtime", + conflictingPath: "src/runtime/router.ts", + }, + ]); + assert.throws( + () => assertNoParallelWriterConflicts([ + baseGrant({ laneId: "impl-a", allowedPaths: ["src/runtime"] }), + baseGrant({ laneId: "impl-b", allowedPaths: ["src/runtime/router.ts"], evidenceOutput: ".omk/runs/run-1/impl-b.md" }), + ]), + /Parallel writer conflict/ + ); +}); + +test("parallel writer checks reject duplicate evidence outputs", () => { + assert.throws( + () => assertNoParallelWriterConflicts([ + baseGrant({ laneId: "impl-a" }), + baseGrant({ laneId: "impl-b", allowedPaths: ["src/other"] }), + ]), + /evidenceOutput conflict/ + ); +}); + +test("createLaneIsolationPlan returns deterministic branch and worktree paths", () => { + const grant = baseGrant({ laneId: "impl-core" }); + const plan = createLaneIsolationPlan(grant, { runId: "run-20260613", worktreeRoot: ".omk/worktrees" }); + + assert.deepEqual(plan, { + laneId: "impl-core", + branchName: "work/run-20260613/impl-core", + worktreePath: ".omk/worktrees/run-20260613/impl-core", + evidenceOutput: ".omk/runs/run-1/impl-core.md", + readOnly: false, + authority: "write-scoped", + }); +}); + +test("createLaneGrantFromDagNode derives routing grants from DAG nodes", () => { + const grant = createLaneGrantFromDagNode( + { + id: "review-api", + name: "Review API surface", + role: "reviewer", + dependsOn: [], + status: "pending", + retries: 0, + maxRetries: 1, + routing: { + readOnly: true, + skills: ["omk-code-review"], + hooks: ["stop-verify.sh"], + mcpServers: ["filesystem-readonly"], + }, + outputs: [{ name: "review evidence", gate: "summary" }], + }, + { evidenceOutput: ".omk/runs/run-1/review-api.md" } + ); + + assert.equal(grant.authority, "read-only"); + assert.equal(grant.skills.includes("omk-code-review"), true); + assert.equal(grant.mcp.includes("filesystem-readonly"), true); + assert.equal(grant.forbiddenActions.includes("out-of-scope edits"), true); + assert.equal(isReadOnlyLane(grant), true); +});