feat(shared): pi-guard-base, reusable isolation primitives for scenario extensions
Extracted from pi-workspaces/memo-inbox/.pi/extensions/memo-guard.ts, which has enforced these patterns in production since 2026-07. Exports: - inside() / safeRealPath() / makePathResolver(): path containment that resolves symlinks before checking, so a link inside an allowed root cannot escape it - registerRestrictedRead(): a path-restricted 'read' that shadows the built-in. Required rather than optional: pi emits the skills block only when a tool named 'read' is active and skill bodies load through it, while the built-in 'read' accepts absolute paths and could reach the service's credential files - installGuard(): the two capability layers, setActiveTools plus a tool_call block, re-asserted on resources_discover as well as session_start - truncate(): byte-aware truncation ahead of pi's 50 KB / 2000 line caps - registerBridgeTools() / fetchBridgeSpecs(): loopback HTTP bridge, with the baseUrl asserted to be loopback. Since registerTool accepts a plain JSON Schema object, the backend can own the schema instead of a drifting copy Verified against a real pi process with zero model tokens (shared/extensions/tests/run-guard-checks.sh, 14 assertions): active tools are exactly the declared set, the read override wins with source=cli, the skills section is present and contains only the scenario's own skill, and reads of an outside file, a ../ traversal and an absolute path to ~/.config/curator/curator.env are all denied. The deny-path fixture is named .txt and renamed to .env only inside the temp work directory, because verify-no-secrets.sh correctly refused to track a file called *.env.sample.
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import {
|
||||
inside, safeRealPath, makePathResolver, truncate, textResult,
|
||||
installGuard, registerRestrictedRead,
|
||||
} from "__GUARD_BASE__";
|
||||
|
||||
const WS = "__FIXTURES__";
|
||||
const ALLOWED = ["read", "probe_noop"];
|
||||
|
||||
export default function guardProbe(pi: ExtensionAPI) {
|
||||
registerRestrictedRead(pi, {
|
||||
roots: [WS + "/.pi/skills"],
|
||||
base: WS,
|
||||
extensions: [".md"],
|
||||
denyMessage: "Read denied: only skill markdown is readable.",
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "probe_noop",
|
||||
label: "Noop",
|
||||
description: "noop",
|
||||
promptSnippet: "probe_noop: noop",
|
||||
parameters: { type: "object", properties: {}, additionalProperties: false } as any,
|
||||
async execute() { return textResult("noop"); },
|
||||
});
|
||||
|
||||
installGuard(pi, { scenario: "guard-probe", allowedTools: ALLOWED });
|
||||
|
||||
pi.on("session_start", async (_e, ctx) => {
|
||||
const sp = ctx.getSystemPrompt?.() ?? "";
|
||||
const out: string[] = [];
|
||||
out.push("ACTIVE=" + JSON.stringify(pi.getActiveTools().sort()));
|
||||
out.push("READ_SOURCE=" + JSON.stringify(
|
||||
pi.getAllTools().filter(t => t.name === "read").map(t => (t as any).sourceInfo?.source)));
|
||||
out.push("SP_HAS_SKILLS=" + String(sp.includes("available_skills")));
|
||||
out.push("SP_SKILLNAMES=" + JSON.stringify([...sp.matchAll(/<name>([^<]+)<\/name>/g)].map(m => m[1])));
|
||||
|
||||
// --- unit checks on the exported primitives ---
|
||||
out.push("INSIDE_same=" + String(inside("/a/b", "/a/b")));
|
||||
out.push("INSIDE_child=" + String(inside("/a/b", "/a/b/c")));
|
||||
out.push("INSIDE_escape=" + String(inside("/a/b", "/a/c")));
|
||||
out.push("INSIDE_prefix_trap=" + String(inside("/a/b", "/a/bc")));
|
||||
|
||||
const r = makePathResolver({ roots: [WS + "/.pi/skills"], base: WS, extensions: [".md"] });
|
||||
const tryPath = (p: string) => { try { r(p); return "ALLOW"; } catch (e) { return "DENY"; } };
|
||||
out.push("RESOLVE_skill=" + tryPath(".pi/skills/guard-skill/SKILL.md"));
|
||||
out.push("RESOLVE_outside=" + tryPath("secret-lookalike.env"));
|
||||
out.push("RESOLVE_traversal=" + tryPath(".pi/skills/../../secret-lookalike.env"));
|
||||
out.push("RESOLVE_abs_home=" + tryPath("/home/claw/.config/curator/curator.env"));
|
||||
|
||||
const big = "x".repeat(60000);
|
||||
const t = truncate(big, 1000, 100);
|
||||
out.push("TRUNC_applied=" + String(t.truncated) + " len=" + String(t.text.length < 1200));
|
||||
out.push("REALPATH_missing_throws=" + (() => { try { safeRealPath("/nope/nope"); return "no"; } catch { return "yes"; } })());
|
||||
|
||||
for (const line of out) console.error("GUARD_" + line);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user