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.
Generalises PiRPC from pi-workspaces/memo-inbox/telegram-gateway/gateway.py,
which has run this pattern in production since 2026-07, and closes the four gaps
both existing scenarios shared:
- explicit minimal env, so provider and backend API keys never reach the node
process (verified: 6 variables, an injected secret is withheld)
- start_new_session plus killpg on stop, so a stuck node tree cannot outlive the
turn (verified: no orphan after stop)
- the loading-isolation flags are part of the launch contract instead of
something each caller has to remember
- a per-turn deadline enforced with RPC abort rather than by killing the process
Retains the original's proven mechanics: strict newline-only JSONL framing,
correlation by id, agent_settled as terminal event, and receipts harvested from
tool_execution_end rather than from model prose.
PiLaunchConfig warns when skills are configured but no 'read' tool can be
active, which is exactly the condition that silently disabled Curator's SKILL.md.
Includes a zero-token smoke test: it drives a real pi process with get_state
only, so no model call is billed.