113 lines
4.2 KiB
TypeScript
113 lines
4.2 KiB
TypeScript
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
|
|
import { readFileSync } from "node:fs";
|
|
import { normalize, resolve } from "node:path";
|
|
|
|
interface Config {
|
|
blocked_paths: string[];
|
|
blocked_commands: string[];
|
|
}
|
|
|
|
function loadConfig(cwd: string): Config {
|
|
const raw = readFileSync(resolve(cwd, ".pi/sensitive-paths.json"), "utf-8");
|
|
return JSON.parse(raw) as Config;
|
|
}
|
|
|
|
/** Convert a glob-like pattern to a case-insensitive regex. */
|
|
function patternToRegex(pattern: string): RegExp {
|
|
let escaped = pattern
|
|
.replace(/[.+^${}()|[\]\\]/g, "\\$&") // Escape regex specials except *
|
|
.replace(/\*/g, ".*"); // * → .*
|
|
return new RegExp(escaped, "i");
|
|
}
|
|
|
|
export default function (pi: ExtensionAPI) {
|
|
let config: Config;
|
|
try {
|
|
config = loadConfig(process.cwd());
|
|
} catch {
|
|
return; // Config missing or invalid — fail open
|
|
}
|
|
|
|
const pathRegexes = config.blocked_paths.map(patternToRegex);
|
|
const commandRegexes = config.blocked_commands.map((r) => new RegExp(r, "i"));
|
|
|
|
// Proactively tell the agent which paths are off-limits so it doesn't try in the first place
|
|
pi.on("before_agent_start", async (event, _ctx) => {
|
|
if (config.blocked_paths.length === 0) return;
|
|
const pathList = config.blocked_paths.map((p) => ` - \`${p}\``).join("\n");
|
|
return {
|
|
systemPrompt:
|
|
event.systemPrompt +
|
|
`\n\n## Sensitive File Guard\nThese path patterns are protected and all tool access is permanently blocked:\n${pathList}\n\nIf a tool call is blocked, DO NOT retry with alternate tools (bash, find, ls, grep, etc.). Instead, tell the user: "That path is protected by sensitive-file-guard."`,
|
|
};
|
|
});
|
|
|
|
pi.on("tool_call", (event, ctx) => {
|
|
// --- Path-based tools ---
|
|
if (
|
|
isToolCallEventType("read", event) ||
|
|
isToolCallEventType("grep", event) ||
|
|
isToolCallEventType("write", event) ||
|
|
isToolCallEventType("edit", event) ||
|
|
isToolCallEventType("find", event) ||
|
|
isToolCallEventType("ls", event)
|
|
) {
|
|
const path = event.input.path;
|
|
if (typeof path !== "string") return;
|
|
|
|
const resolved = normalize(resolve(ctx.cwd, path));
|
|
const match = pathRegexes.find((r) => r.test(resolved));
|
|
if (match) {
|
|
const reason =
|
|
`🚫 ACCESS DENIED by sensitive-file-guard: paths matching "${match}" are permanently protected. ` +
|
|
`DO NOT retry with other tools (bash, find, ls, grep, etc.). ` +
|
|
`Tell the user the file is protected and STOP.`;
|
|
if (ctx.hasUI)
|
|
ctx.ui.notify(`Blocked sensitive path: ${path}`, "warning");
|
|
return { block: true, reason };
|
|
}
|
|
return;
|
|
}
|
|
|
|
// --- Bash tool ---
|
|
if (isToolCallEventType("bash", event)) {
|
|
const command = event.input.command;
|
|
if (typeof command !== "string") return;
|
|
|
|
// Check for blocked path fragments in the command text
|
|
const pathHit = config.blocked_paths.find((p) => {
|
|
// Rough substring match: does the pattern text (with glob stripped)
|
|
// appear in the command?
|
|
const literal = p.replace(/[*?]/g, "");
|
|
return (
|
|
literal.length > 0 &&
|
|
command.toLowerCase().includes(literal.toLowerCase())
|
|
);
|
|
});
|
|
|
|
if (pathHit) {
|
|
const reason =
|
|
`🚫 ACCESS DENIED by sensitive-file-guard: commands targeting "${pathHit}" are permanently blocked. ` +
|
|
`DO NOT retry with alternative commands. Tell the user the path is protected and STOP.`;
|
|
if (ctx.hasUI)
|
|
ctx.ui.notify(
|
|
`Blocked sensitive path in bash: ${pathHit}`,
|
|
"warning",
|
|
);
|
|
return { block: true, reason };
|
|
}
|
|
|
|
// Check for blocked commands (env, printenv, set)
|
|
const cmdHit = commandRegexes.find((r) => r.test(command));
|
|
if (cmdHit) {
|
|
const reason =
|
|
`🚫 ACCESS DENIED by sensitive-file-guard: this command matches a blocked pattern. ` +
|
|
`DO NOT retry. Tell the user the command is blocked and STOP.`;
|
|
if (ctx.hasUI) ctx.ui.notify(`Blocked sensitive command`, "warning");
|
|
return { block: true, reason };
|
|
}
|
|
}
|
|
});
|
|
}
|