import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import type { Dir } from "node:fs";
import { opendir, readFile, realpath } from "node:fs/promises";
import { homedir } from "node:os";
import { isAbsolute, join, parse, relative, resolve, sep } from "node:path";
const DEFAULT_MAX_ENTRIES = 5_000;
const DEFAULT_MAX_DURATION_MS = 100;
const ROOT_IGNORE_FILES = [".gitignore", ".ignore", ".rgignore"] as const;
export interface SearchScopeGuardOptions {
maxEntries?: number;
maxDurationMs?: number;
homeDirectory?: string;
now?: () => number;
openDirectory?: (path: string) => Promise
;
}
type WorkspaceInspection = "small" | "large" | "slow" | "unreadable";
function pathKey(path: string): string {
return process.platform === "win32" ? path.toLowerCase() : path;
}
function samePath(left: string, right: string): boolean {
return pathKey(left) === pathKey(right);
}
function isWithin(root: string, target: string): boolean {
const child = relative(root, target);
return child === "" || (child !== ".." && !child.startsWith(`..${sep}`) && !isAbsolute(child));
}
export function resolveSearchScopePath(inputPath: unknown, cwd: string): string {
const value = typeof inputPath === "string" && inputPath.trim() ? inputPath.trim() : ".";
if (value === "~") return homedir();
if (value.startsWith("~/") || value.startsWith("~\\")) {
return resolve(homedir(), value.slice(2));
}
return resolve(cwd, value);
}
async function hasApplicableIgnoreFile(root: string, toolName: string): Promise {
const candidates = toolName === "grep" ? ROOT_IGNORE_FILES : ROOT_IGNORE_FILES.slice(0, 1);
for (const name of candidates) {
try {
const content = await readFile(join(root, name), "utf8");
const hasExclusion = content.split(/\r?\n/).some((line) => {
const pattern = line.trim();
return pattern.length > 0 && !pattern.startsWith("#") && !pattern.startsWith("!");
});
if (hasExclusion) return true;
} catch {
// Missing or unreadable ignore files do not make a root scan safe.
}
}
return false;
}
type DeadlineResult =
| { status: "ok"; value: T }
| { status: "timeout" }
| { status: "error" };
async function settleBeforeDeadline(
operation: Promise,
remainingMs: number,
onLateValue?: (value: T) => void,
): Promise> {
if (remainingMs <= 0) {
void operation.then((value) => onLateValue?.(value), () => undefined);
return { status: "timeout" };
}
return new Promise((resolveResult) => {
let settled = false;
const timer = setTimeout(() => {
settled = true;
resolveResult({ status: "timeout" });
}, remainingMs);
operation.then(
(value) => {
if (settled) {
onLateValue?.(value);
return;
}
settled = true;
clearTimeout(timer);
resolveResult({ status: "ok", value });
},
() => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolveResult({ status: "error" });
},
);
});
}
function closeDirectory(handle: Dir): void {
void handle.close().catch(() => undefined);
}
async function inspectWorkspace(
root: string,
options: SearchScopeGuardOptions,
): Promise {
const maxEntries = Math.max(1, options.maxEntries ?? DEFAULT_MAX_ENTRIES);
const maxDurationMs = Math.max(1, options.maxDurationMs ?? DEFAULT_MAX_DURATION_MS);
const now = options.now ?? Date.now;
const openDirectory = options.openDirectory ?? opendir;
const deadline = now() + maxDurationMs;
const pending = [root];
let cursor = 0;
let entries = 0;
while (cursor < pending.length) {
const directory = pending[cursor++]!;
const opening = openDirectory(directory);
const opened = await settleBeforeDeadline(opening, deadline - now(), closeDirectory);
if (opened.status === "timeout") return "slow";
if (opened.status === "error") return "unreadable";
const handle = opened.value;
try {
while (true) {
const reading = handle.read();
const read = await settleBeforeDeadline(reading, deadline - now(), () => closeDirectory(handle));
if (read.status === "timeout") return "slow";
if (read.status === "error") return "unreadable";
const entry = read.value;
if (!entry) break;
entries += 1;
if (entries >= maxEntries) return "large";
if (entry.isDirectory()) pending.push(join(directory, entry.name));
}
} finally {
closeDirectory(handle);
}
}
return "small";
}
export async function searchScopeBlockReason(
toolName: string,
input: Readonly>,
cwd: string,
options: SearchScopeGuardOptions = {},
): Promise {
if (toolName !== "grep" && toolName !== "search" && toolName !== "fffind") return undefined;
const workspaceRoot = resolve(cwd);
const searchPath = resolveSearchScopePath(input.path, workspaceRoot);
let canonicalWorkspaceRoot: string;
let canonicalSearchPath: string;
try {
[canonicalWorkspaceRoot, canonicalSearchPath] = await Promise.all([
realpath(workspaceRoot),
realpath(searchPath),
]);
} catch {
return `${toolName} cannot safely resolve its search path; choose an existing path under ${workspaceRoot}.`;
}
if (samePath(canonicalSearchPath, parse(canonicalSearchPath).root)) {
return `${toolName} cannot scan a filesystem root; choose a project subdirectory.`;
}
const homeDirectory = resolve(options.homeDirectory ?? homedir());
const canonicalHomeDirectory = await realpath(homeDirectory).catch(() => homeDirectory);
if (samePath(canonicalSearchPath, canonicalHomeDirectory)) {
return `${toolName} cannot scan the home directory; choose a specific project directory.`;
}
if (!isWithin(canonicalWorkspaceRoot, canonicalSearchPath)) {
return `${toolName} cannot scan outside the current workspace; choose a path under ${workspaceRoot}.`;
}
if (!samePath(canonicalSearchPath, canonicalWorkspaceRoot)) return undefined;
if (await hasApplicableIgnoreFile(canonicalWorkspaceRoot, toolName)) return undefined;
const inspection = await inspectWorkspace(canonicalWorkspaceRoot, options);
if (inspection === "small") return undefined;
const detail = inspection === "large"
? `at least ${Math.max(1, options.maxEntries ?? DEFAULT_MAX_ENTRIES)} entries`
: inspection === "slow"
? `the ${Math.max(1, options.maxDurationMs ?? DEFAULT_MAX_DURATION_MS)}ms inspection budget was exceeded`
: "the directory tree could not be inspected safely";
return `${toolName} root search blocked: this workspace has no applicable ignore file and ${detail}. Specify a narrower path or add ${toolName === "grep" ? ".gitignore/.ignore/.rgignore" : ".gitignore"}.`;
}
export function registerSearchScopeGuard(
pi: ExtensionAPI,
options: SearchScopeGuardOptions = {},
): void {
pi.on("tool_call", async (event, ctx) => {
const reason = await searchScopeBlockReason(event.toolName, event.input, ctx.cwd, options);
return reason ? { block: true, reason } : undefined;
});
}