Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 6 additions & 16 deletions apps/pi-extension/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import {
} from "./plannotator-events.js";
import {
getToolsForPhase,
isPlanWritePathAllowed,
PLAN_SUBMIT_TOOL,
type Phase,
stripPlanningOnlyTools,
Expand Down Expand Up @@ -703,24 +704,13 @@ export default function plannotator(pi: ExtensionAPI): void {
pi.on("tool_call", async (event, ctx) => {
if (phase !== "planning") return;

if (event.toolName === "write") {
const targetPath = resolve(ctx.cwd, event.input.path as string);
const allowedPath = resolvePlanPath(ctx.cwd);
if (targetPath !== allowedPath) {
if (event.toolName === "write" || event.toolName === "edit") {
const inputPath = event.input.path as string;
if (!isPlanWritePathAllowed(planFilePath, inputPath, ctx.cwd)) {
const verb = event.toolName === "write" ? "writes" : "edits";
return {
block: true,
reason: `Plannotator: writes are restricted to ${planFilePath} during planning. Blocked: ${event.input.path}`,
};
}
}

if (event.toolName === "edit") {
const targetPath = resolve(ctx.cwd, event.input.path as string);
const allowedPath = resolvePlanPath(ctx.cwd);
if (targetPath !== allowedPath) {
return {
block: true,
reason: `Plannotator: edits are restricted to ${planFilePath} during planning. Blocked: ${event.input.path}`,
reason: `Plannotator: ${verb} are restricted to ${planFilePath} during planning. Blocked: ${inputPath}`,
};
}
}
Expand Down
33 changes: 33 additions & 0 deletions apps/pi-extension/tool-scope.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test";
import {
getToolsForPhase,
isPlanWritePathAllowed,
PLAN_SUBMIT_TOOL,
stripPlanningOnlyTools,
} from "./tool-scope";
Expand Down Expand Up @@ -44,3 +45,35 @@ describe("pi plan tool scoping", () => {
]);
});
});

describe("plan write path gate", () => {
const cwd = "/r";

test("default PLAN.md allows exact file and blocks everything else", () => {
expect(isPlanWritePathAllowed("PLAN.md", "PLAN.md", cwd)).toBe(true);
expect(isPlanWritePathAllowed("PLAN.md", "src/app.ts", cwd)).toBe(false);
});

test("trailing-slash directory scopes to files inside", () => {
expect(isPlanWritePathAllowed("plans/", "plans/foo.md", cwd)).toBe(true);
expect(isPlanWritePathAllowed("plans/", "src/app.ts", cwd)).toBe(false);
});

test("bare directory name (no slash, no extension) scopes to files inside", () => {
expect(isPlanWritePathAllowed("plans", "plans/foo.md", cwd)).toBe(true);
expect(isPlanWritePathAllowed("plans", "src/app.ts", cwd)).toBe(false);
});

test("file inside a subdir allows siblings and blocks outside", () => {
expect(isPlanWritePathAllowed("plans/foo.md", "plans/bar.md", cwd)).toBe(true);
expect(isPlanWritePathAllowed("plans/foo.md", "src/app.ts", cwd)).toBe(false);
});

test("path traversal is rejected", () => {
expect(isPlanWritePathAllowed("plans/", "../../etc/passwd", cwd)).toBe(false);
});

test("absolute input paths resolve the same as relative", () => {
expect(isPlanWritePathAllowed("plans/", "/r/plans/foo.md", cwd)).toBe(true);
});
});
31 changes: 31 additions & 0 deletions apps/pi-extension/tool-scope.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";

export type Phase = "idle" | "planning" | "executing";

export const PLAN_SUBMIT_TOOL = "plannotator_submit_plan";
Expand All @@ -22,3 +24,32 @@ export function getToolsForPhase(
...new Set([...tools, ...PLANNING_DISCOVERY_TOOLS, PLAN_SUBMIT_TOOL]),
];
}

// Treat planFilePath as directory-scoped when it ends with a separator or its
// basename has no extension (e.g. "plans/", "plans"). Otherwise scope is the
// single file.
function isPlanPathDirectoryScoped(planFilePath: string): boolean {
if (planFilePath.endsWith("/") || planFilePath.endsWith(sep)) return true;
const base = basename(planFilePath);
return base.length > 0 && !base.includes(".");
}

export function isPlanWritePathAllowed(
planFilePath: string,
inputPath: string,
cwd: string,
): boolean {
const targetAbs = resolve(cwd, inputPath);
const allowedAbs = resolve(cwd, planFilePath);
if (targetAbs === allowedAbs) return true;

const dirScoped = isPlanPathDirectoryScoped(planFilePath);
const scopeDir = dirScoped ? allowedAbs : dirname(allowedAbs);

// Never scope to cwd root — a default like "PLAN.md" would otherwise unlock
// every file in the project.
if (resolve(scopeDir) === resolve(cwd)) return false;

const rel = relative(scopeDir, targetAbs);
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
}