diff --git a/CHANGELOG.md b/CHANGELOG.md index edc7bb3..66aca3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,11 @@ cannot exhaust memory or disk. - Verified that Pi's extension tool registry is per-extension-instance, so reload re-registration is safe (no fix required). +- `research_control pause`/`resume` no longer desync the agent tree from the + loop: pausing a non-running loop now errors instead of silently marking the + tree as paused, and resume requires the loop to actually be paused. +- `read_artifact` now resolves symlinks before the confinement check, so a + symlink inside the project cannot leak files from outside it. ## 0.1.1 — 2026-07-31 diff --git a/src/supervisor.ts b/src/supervisor.ts index ff5c543..06cf9b7 100644 --- a/src/supervisor.ts +++ b/src/supervisor.ts @@ -49,7 +49,7 @@ export class SupervisorIntegration { name: "research_control", label: "Research loop control", description: "Start, record, inspect, pause, resume, or stop the explicit research state machine. Record one report per completed iteration; coded stop conditions prevent infinite prompt loops.", promptSnippet: "Control the bounded autonomous research loop", parameters: Type.Object({ action: StringEnum(["start", "status", "record_iteration", "pause", "resume", "stop"] as const), goal: Type.Optional(Type.String()), report: Type.Optional(Type.Object({ goal: Type.String(), tasks: Type.Array(Type.String()), activeAgents: Type.Array(Type.String()), expectedOutput: Type.String(), state: Type.String(), newFindings: Type.Integer({ minimum: 0 }), closedQuestions: Type.Integer({ minimum: 0 }), contradictions: Type.Integer({ minimum: 0 }), reason: Type.String(), goalAchieved: Type.Optional(Type.Boolean()), criticalMethodError: Type.Optional(Type.Boolean()), onlyExternalQuestions: Type.Optional(Type.Boolean()), userDecisionRequired: Type.Optional(Type.Boolean()) })) }), - execute: async (_id, params) => { const { tree, loop } = this.required(); if (params.action === "start") { if (!params.goal?.trim()) throw new Error("goal is required"); const loopState = loop.snapshot(); const loopTerminal = ["stopped", "completed"].includes(loopState.status); const root = tree.inspect(tree.rootId); const rootSpawnable = !["cancelled", "failed", "archived"].includes(root.status); if (loopTerminal || tree.status !== "active" || !rootSpawnable) tree.restart(params.goal); loop.setGoal(params.goal); loop.start(); return toolText(loop.snapshot()); } if (params.action === "status") return toolText(loop.snapshot()); if (params.action === "pause") { loop.pause(); tree.pause(); } else if (params.action === "resume") { loop.resume(); tree.resume(); } else if (params.action === "stop") { loop.stop(); await tree.stop(); } else { if (!params.report) throw new Error("report is required"); return toolText(loop.record(params.report)); } return toolText(loop.snapshot()); }, + execute: async (_id, params) => { const { tree, loop } = this.required(); if (params.action === "start") { if (!params.goal?.trim()) throw new Error("goal is required"); const loopState = loop.snapshot(); const loopTerminal = ["stopped", "completed"].includes(loopState.status); const root = tree.inspect(tree.rootId); const rootSpawnable = !["cancelled", "failed", "archived"].includes(root.status); if (loopTerminal || tree.status !== "active" || !rootSpawnable) tree.restart(params.goal); loop.setGoal(params.goal); loop.start(); return toolText(loop.snapshot()); } if (params.action === "status") return toolText(loop.snapshot()); if (params.action === "pause") { if (loop.snapshot().status !== "running") throw new Error(`Cannot pause a ${loop.snapshot().status} loop`); loop.pause(); tree.pause(); } else if (params.action === "resume") { if (loop.snapshot().status !== "paused") throw new Error("Only a paused loop can resume"); loop.resume(); tree.resume(); } else if (params.action === "stop") { loop.stop(); await tree.stop(); } else { if (!params.report) throw new Error("report is required"); return toolText(loop.record(params.report)); } return toolText(loop.snapshot()); }, }); } diff --git a/src/tools/index.ts b/src/tools/index.ts index 7d63bb7..791bc54 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,5 +1,5 @@ import { resolve, sep } from "node:path"; -import { readFileSync } from "node:fs"; +import { readFileSync, realpathSync } from "node:fs"; import { StringEnum } from "@earendil-works/pi-ai"; import { defineTool, type ToolDefinition } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; @@ -10,7 +10,13 @@ import type { WebGateway } from "./web.js"; interface ToolDeps { tree: AgentTree; parentId: string; memory: ResearchMemory; web: WebGateway; experiments: ExperimentRunner; cwd: string } const text = (value: unknown, details: unknown = {}) => ({ content: [{ type: "text" as const, text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }], details }); -function confined(root: string, path: string): string { const absolute = resolve(root, path); if (absolute !== root && !absolute.startsWith(`${root}${sep}`)) throw new Error("Path escapes the allowed project/state directory"); return absolute; } +export function confined(root: string, path: string): string { + const absolute = resolve(root, path); + let real = absolute; let realRoot = root; + try { real = realpathSync(absolute); realRoot = realpathSync(root); } catch { /* file may not exist; the lexical check below still applies */ } + if (real !== realRoot && !real.startsWith(`${realRoot}${sep}`)) throw new Error("Path escapes the allowed project/state directory"); + return absolute; +} export function createResearchTools(deps: ToolDeps): ToolDefinition[] { const spawn = defineTool({ diff --git a/tests/confine.test.ts b/tests/confine.test.ts new file mode 100644 index 0000000..373ebd9 --- /dev/null +++ b/tests/confine.test.ts @@ -0,0 +1,23 @@ +import { mkdtempSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { confined } from "../src/tools/index.js"; + +describe("read_artifact confinement", () => { + it("rejects paths that escape the project directory lexically", () => { + const root = mkdtempSync(resolve(tmpdir(), "hm-confine-")); + expect(() => confined(root, "../../etc/passwd")).toThrow(/escapes/); + }); + it("rejects symlinks that point outside the project directory", () => { + const root = mkdtempSync(resolve(tmpdir(), "hm-confine-")); + const outside = mkdtempSync(resolve(tmpdir(), "hm-outside-")); + const target = resolve(outside, "secret.txt"); writeFileSync(target, "secret"); + const link = resolve(root, "leak.md"); symlinkSync(target, link); + expect(() => confined(root, "leak.md")).toThrow(/escapes/); + }); + it("allows paths inside the project directory", () => { + const root = mkdtempSync(resolve(tmpdir(), "hm-confine-")); + expect(confined(root, "memory/findings/x.md")).toBe(resolve(root, "memory/findings/x.md")); + }); +});