import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path"; import { realpath } from "node:fs/promises"; import { createEditToolDefinition, createFindToolDefinition, createGrepToolDefinition, createLsToolDefinition, createReadToolDefinition, createWriteToolDefinition, type ToolDefinition } from "@earendil-works/pi-coding-agent"; import type { TSchema } from "typebox"; import type { TaskEnvelope } from "../types.js"; import type { WarRoomWorkerBinding } from "../workflows/war-room.js"; import { isProtectedPath, PROTECTED_PATHS } from "./protected-paths.js"; export const WORKER_ROOT_ENV = "ULTRAPI_WORKER_ROOT"; export const WORKER_SCOPE_ENV = "ULTRAPI_WORKER_SCOPE"; export const WORKER_WAR_ROOM_ENV = "ULTRAPI_WAR_ROOM"; const SCOPE_MARKER = /([A-Za-z0-9_-]+)<\/ultrapi-worker-scope>/; const WAR_ROOM_MARKER = /([A-Za-z0-9_-]+)<\/ultrapi-war-room>/; export const SCOPED_READ_TOOLS = ["ultra_read", "ultra_grep", "ultra_find", "ultra_ls"] as const; export const SCOPED_WRITE_TOOLS = ["ultra_edit", "ultra_write"] as const; type WorkerScope = TaskEnvelope["scope"]; export type GovernanceViolationKind = "scope-escape" | "protected-path" | "blocked-command" | "readonly-write-attempt" | "empty-result"; export class GovernanceViolation extends Error { constructor(readonly kind: GovernanceViolationKind, readonly attemptedPath?: string) { super("Worker path is outside its declared scope"); this.name = "GovernanceViolation"; } } export function governanceViolationOf(error: unknown): GovernanceViolation | undefined { return error instanceof GovernanceViolation ? error : undefined; } function contains(parent: string, candidate: string): boolean { const path = relative(parent, candidate); return path === "" || (!isAbsolute(path) && path !== ".." && !path.startsWith(`..${sep}`)); } async function canonical(path: string): Promise { let cursor = resolve(path); const suffix: string[] = []; for (;;) { try { return resolve(await realpath(cursor), ...suffix); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; const parent = dirname(cursor); if (parent === cursor) throw error; suffix.unshift(basename(cursor)); cursor = parent; } } } export function parseWorkerScope(value: string | undefined): WorkerScope | undefined { if (!value) return undefined; try { const parsed = JSON.parse(value) as Partial; if (!Array.isArray(parsed.paths) || parsed.paths.length === 0 || parsed.paths.some((path) => typeof path !== "string") || !Array.isArray(parsed.excludedPaths) || parsed.excludedPaths.some((path) => typeof path !== "string")) return undefined; return { paths: parsed.paths, excludedPaths: parsed.excludedPaths, ...(Array.isArray(parsed.symbols) && parsed.symbols.every((symbol) => typeof symbol === "string") ? { symbols: parsed.symbols } : {}) }; } catch { return undefined; } } export function workerScopeMarker(scope: WorkerScope): string { return `${Buffer.from(JSON.stringify(scope)).toString("base64url")}`; } /** * A task carries exactly one marker of each kind, written by the controller. It also carries * untrusted text: a war-room feed is other members' claims verbatim, and those are validated * for length but never for markers. Taking the first match therefore let a member widen its * peers' scope by writing a marker into a claim, because the injected copy is interpolated * ahead of the real one. * * Position is not a trust signal, so ambiguity is refused rather than resolved. Returning * undefined is the safe direction: the worker extension registers no scoped tools at all * without a scope, so an injection costs the attacker its own capabilities. */ function soleMarker(task: string, marker: RegExp): string | undefined { const matches = [...task.matchAll(new RegExp(marker.source, "g"))]; return matches.length === 1 ? matches[0]![1] : undefined; } export function workerScopeFromTask(task: string): WorkerScope | undefined { const encoded = soleMarker(task, SCOPE_MARKER); if (!encoded) return undefined; try { return parseWorkerScope(Buffer.from(encoded, "base64url").toString("utf8")); } catch { return undefined; } } export function workerWarRoomMarker(binding: WarRoomWorkerBinding): string { return `${Buffer.from(JSON.stringify(binding)).toString("base64url")}`; } export function workerWarRoomFromTask(task: string): WarRoomWorkerBinding | undefined { const encoded = soleMarker(task, WAR_ROOM_MARKER); if (!encoded) return undefined; try { const parsed = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")) as { credential?: unknown; signalPath?: unknown }; return typeof parsed.credential === "string" && parsed.credential.length >= 16 && parsed.credential.length <= 128 && typeof parsed.signalPath === "string" && parsed.signalPath.length > 0 ? { credential: parsed.credential, signalPath: parsed.signalPath } : undefined; } catch { return undefined; } } // Non-global replaces removed only the first marker, so an injected one was stripped while // the real one was handed to the model in its prompt. Strip every occurrence. export function stripWorkerScopeMarker(task: string): string { return task.replace(new RegExp(SCOPE_MARKER.source, "g"), "").replace(new RegExp(WAR_ROOM_MARKER.source, "g"), "").replace(/\n{3,}/g, "\n\n"); } async function resolveWorkerPath(root: string, candidate: string, scope: WorkerScope, recursive: boolean): Promise { const lexicalRoot = resolve(root); const lexicalCandidate = resolve(root, candidate); if (isProtectedPath(lexicalRoot, lexicalCandidate) && contains(lexicalRoot, lexicalCandidate)) throw new GovernanceViolation("protected-path", lexicalCandidate); if (!contains(lexicalRoot, lexicalCandidate) || isProtectedPath(lexicalRoot, lexicalCandidate)) throw new GovernanceViolation("scope-escape", lexicalCandidate); const [realRoot, realCandidate] = await Promise.all([canonical(lexicalRoot), canonical(lexicalCandidate)]); if (isProtectedPath(realRoot, realCandidate) && contains(realRoot, realCandidate)) throw new GovernanceViolation("protected-path", realCandidate); if (!contains(realRoot, realCandidate) || isProtectedPath(realRoot, realCandidate)) throw new GovernanceViolation("scope-escape", realCandidate); const allowed = await Promise.all(scope.paths.map((path) => canonical(resolve(lexicalRoot, path)))); const excluded = await Promise.all(scope.excludedPaths.map((path) => canonical(resolve(lexicalRoot, path)))); const protectedPaths = (await Promise.all(PROTECTED_PATHS.map(async (path) => { try { return await realpath(resolve(realRoot, path)); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; } }))).filter((path): path is string => path !== undefined); if (recursive && protectedPaths.some((path) => contains(realCandidate, path))) throw new GovernanceViolation("protected-path", realCandidate); if (!allowed.some((path) => contains(path, realCandidate)) || excluded.some((path) => contains(path, realCandidate) || recursive && contains(realCandidate, path))) throw new GovernanceViolation("scope-escape", realCandidate); return realCandidate; } export async function assertWorkerPath(root: string, candidate: string, scope: WorkerScope): Promise { await resolveWorkerPath(root, candidate, scope, false); } function pathFromInput(input: unknown): string { if (!input || typeof input !== "object") return "."; const path = (input as { path?: unknown }).path; return typeof path === "string" && path.trim() ? path : "."; } function scoped(definition: ToolDefinition, name: string, root: string, scope: WorkerScope, recursive = false): ToolDefinition { return { ...definition, async execute(id, params, signal, update, ctx) { const path = await resolveWorkerPath(root, pathFromInput(params), scope, recursive); return await definition.execute(id, { ...(params as object), path } as never, signal, update, ctx); }, name, label: name }; } export function scopedReadOnlyToolDefinitions(root: string, scope: WorkerScope): Array> { return [scoped(createReadToolDefinition(root), SCOPED_READ_TOOLS[0], root, scope), scoped(createGrepToolDefinition(root), SCOPED_READ_TOOLS[1], root, scope, true), scoped(createFindToolDefinition(root), SCOPED_READ_TOOLS[2], root, scope, true), scoped(createLsToolDefinition(root), SCOPED_READ_TOOLS[3], root, scope, true)]; } export function scopedWriterToolDefinitions(root: string, scope: WorkerScope): Array> { return [...scopedReadOnlyToolDefinitions(root, scope), scoped(createEditToolDefinition(root), SCOPED_WRITE_TOOLS[0], root, scope), scoped(createWriteToolDefinition(root), SCOPED_WRITE_TOOLS[1], root, scope)]; }