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:
Kai
2026-08-26 22:57:52 -07:00
parent 65d2f5988b
commit 7b5e0b093d
6 changed files with 536 additions and 0 deletions
@@ -0,0 +1,5 @@
---
name: guard-skill
description: GUARD_SKILL_DESC marker for verifying that pi-guard-base keeps the skills section reachable.
---
GUARD_SKILL_BODY
+1
View File
@@ -0,0 +1 @@
# Guard probe workspace
+1
View File
@@ -0,0 +1 @@
SHOULD_NOT_BE_READABLE=1
+58
View File
@@ -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);
});
}
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
# Zero-token verification of pi-guard-base against a real pi process.
#
# Drives pi with `get_state` only, so no model call is billed. Checks the two
# capability layers, the `read` override, skill reachability, path containment
# (including symlink/traversal/absolute escapes) and truncation.
#
# Usage: shared/extensions/tests/run-guard-checks.sh
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO="$(git -C "$HERE" rev-parse --show-toplevel)"
PI_BIN="${PI_BIN:-/home/claw/.npm-global/bin/pi}"
WORK="$(mktemp -d /tmp/pi-guard-checks.XXXXXX)"
trap 'rm -rf "$WORK"' EXIT
cp -r "$HERE/fixtures/." "$WORK/"
mv "$WORK/secret-lookalike.txt" "$WORK/secret-lookalike.env"
sed -e "s|__FIXTURES__|$WORK|g" \
-e "s|__GUARD_BASE__|$REPO/shared/extensions/pi-guard-base.ts|" \
"$HERE/guard-probe.ts.in" > "$WORK/guard-ext.ts"
OUT="$(printf '{"id":"1","type":"get_state"}\n' | timeout 120 "$PI_BIN" \
--mode rpc --no-session --no-builtin-tools \
--no-extensions -e "$WORK/guard-ext.ts" \
--no-skills --skill "$WORK/.pi/skills/guard-skill" \
--no-prompt-templates --no-themes --approve \
--provider zenmux --model openai/gpt-5.6-luna 2>&1 >/dev/null | grep '^GUARD_')"
echo "$OUT"
echo
fails=0
expect() {
if grep -qxF "GUARD_$1" <<<"$OUT"; then
echo " PASS $1"
else
echo " FAIL $1 (actual: $(grep "^GUARD_${1%%=*}=" <<<"$OUT" || echo '<absent>'))"
fails=$((fails + 1))
fi
}
echo "== assertions =="
expect 'ACTIVE=["probe_noop","read"]'
expect 'READ_SOURCE=["cli"]'
expect 'SP_HAS_SKILLS=true'
expect 'SP_SKILLNAMES=["guard-skill"]'
expect 'INSIDE_same=true'
expect 'INSIDE_child=true'
expect 'INSIDE_escape=false'
expect 'INSIDE_prefix_trap=false'
expect 'RESOLVE_skill=ALLOW'
expect 'RESOLVE_outside=DENY'
expect 'RESOLVE_traversal=DENY'
expect 'RESOLVE_abs_home=DENY'
expect 'TRUNC_applied=true len=true'
expect 'REALPATH_missing_throws=yes'
echo
if [ "$fails" -eq 0 ]; then echo "RESULT: all checks passed"; else echo "RESULT: $fails check(s) failed"; fi
exit $((fails > 0))