fix: keep pause/resume in sync between loop and tree; close read_artifact symlink escape
- research_control pause/resume now validate the loop state before touching the tree, so pausing a stopped loop can no longer leave the tree 'paused' while the loop is not (spawn would fail until a restart). - read_artifact confinement resolves symlinks (realpath) before checking the project boundary, preventing symlink-based file leaks. - Adds confinement tests (42 tests passing, tsc clean).
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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()); },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+8
-2
@@ -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({
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user