Scenarios - memo-inbox: mirrored by copying; the live directory was not moved or modified and the service was not restarted. All four tracked files match byte for byte (pi-diff.sh reports SAME). Marked deploy = "mirror" so deploy-scenario.sh refuses --apply: applying a mirror would invert the direction of truth and could change a service in daily use. - curator: target configuration, not yet deployed. .pi/SYSTEM.md replaces pi's coding-assistant prompt; durable role text is in .pi/APPEND_SYSTEM.md; profile.toml is the single source of truth for the launch contract. - pi-grok: registered only. It is genuinely a coding agent, so the isolation baseline does not apply in full. Corrections to the documentation, found by testing rather than by reading - AGENTS.override.md does NOT block parent-directory context files; it only shadows its own directory. Verified: with an override file in the workspace, a marker in /tmp/AGENTS.md still reached the system prompt. The only effective switch is --no-context-files, so durable role text must live in .pi/APPEND_SYSTEM.md, which is a system-prompt file and unaffected by -nc. Verified end state: no coding-assistant framing, no pi-docs block, own identity and role text present, no parent pollution, only own skills/tools. - PI_CODING_AGENT_DIR isolates settings/models/auth/trust/extensions/skills/ prompts/themes under the agent directory -- stronger than the --no-* flags because it also repoints credentials -- but does NOT cover ~/.agents/skills. Measured: find-skills, modsearch and summarize still leak. So it complements --no-skills rather than replacing it. - --append-system-prompt accepts a file path, which pi-grok relies on. - cwd is what anchors .pi discovery: a probe that forgot cwd silently lost .pi/SYSTEM.md and kept the coding-assistant persona. Tooling (all dry-run by default; none of them restarts a service) - pi-diff.sh: compares tracked config against the live install in both directions, with a key-redacted comparison for models.json - deploy-scenario.sh: installs a workspace and renders profile.toml into .pi/launch.json, then checks that every referenced path exists - deploy-runtime.sh: renders models.json from its template, refusing placeholder or missing keys. Verified byte-identical to the live file - pi-backup.sh / pi-restore.sh: archives outside the repo, sha256 manifest verified before any restore, live paths preserved rather than overwritten Fixed while testing: pi-backup.sh compared the destination against the repo root literally, so a relative --dest ./backups wrote credential archives into the work tree. Now canonicalised with realpath; ./backups, an absolute in-repo path and ./docs/../backups are all refused.
17 KiB
Pi Runtime Notes
Verified against pi 0.84.3 on Debian 13 / node v22.23.2, 2026-08-27. Every claim below is either quoted from
dist/source or reproduced by the probe harness inevidence/probe-harness/. Results:evidence/2026-08-27-isolation-probe.md.
This file records behaviour that the official docs either do not state or state
only in passing, but which determines whether a dedicated Pi agent works at all.
Re-verify after every pi update.
1. The skills section requires an active read tool
dist/core/system-prompt.js:
const hasRead = tools.includes("read");
// Append skills section (only if read tool is available)
if (hasRead && skills.length > 0) {
prompt += formatSkillsForPrompt(skills);
}
tools is the active tool-name list, passed as selectedTools from
agent-session.js:_rebuildSystemPrompt(this.getActiveToolNames()).
Consequence: --no-tools silently removes the entire skills block.
Any SKILL.md is then unreachable, and --skill <path> becomes a no-op.
Probe evidence (--no-tools vs --no-builtin-tools):
| Flags | available_skills in prompt |
prompt length |
|---|---|---|
--no-tools |
false | 1859 |
--no-builtin-tools |
true | 3413 |
This was a live defect in the Curator scenario: it ran --no-tools --skill …
for its whole lifetime, so its 64-line media policy never reached the model.
2. Skill injection is progressive, not full text
formatSkillsForPrompt emits only <name>, <description> and <location>
per skill, plus the instruction "Use the read tool to load a skill's file when
the task matches its description."
So the body of a SKILL.md costs nothing up front but is only ever read via
the read tool. A skill body is unreachable without a working read.
Corollary: if you override read with a path-restricted implementation, the
allowlist must include the skill directory or skill bodies cannot be loaded.
3. --skill does not double-load an auto-discovered skill
dist/core/skills.js:loadSkills() dedupes twice — by canonical realpath, and
by skill name. Defaults are added first, so on a name collision the
auto-discovered skill wins and a collision diagnostic is emitted.
Same file via symlink is skipped silently.
4. Tool gating semantics
| Flag | registry filter | initial active set | extension tools |
|---|---|---|---|
| (none) | none | settings.defaultTools ?? [read,bash,edit,write] |
all activated |
--tools a,b |
strict allowlist across every source | [a,b] |
must be named to exist |
--no-tools |
empty allowlist | [] |
also killed |
--no-builtin-tools |
none | [] |
all activated |
--exclude-tools x |
removes x |
filtered | applies to all sources |
From dist/core/agent-session.js:
const isAllowedTool = (name) =>
(!allowedToolNames || allowedToolNames.has(name)) && !excludedToolNames?.has(name);
Under --no-builtin-tools the built-ins stay registered but inactive, so an
extension can re-enable them with pi.setActiveTools([...]). Under --no-tools
they were never registered and cannot be revived.
Probed: --no-builtin-tools + one extension →
ACTIVE_TOOLS=["probe_plain_schema","read"], while bash/edit/write/grep/find/ls
remain listed as [builtin] and inactive.
5. registerTool accepts a plain JSON Schema object — verified
Declared type is TypeBox TSchema, but a raw JSON Schema object works:
pi.registerTool({
name: "probe_plain_schema",
parameters: { type: "object", properties: { q: { type: "string" } }, required: ["q"] } as any,
...
});
Probe result: the tool appears as probe_plain_schema[cli] and is active.
Why this matters: a backend can serve its tool definitions as JSON Schema
over HTTP and a generic bridge extension can register them at runtime, so the
schema has exactly one source of truth. See shared/extensions/.
Note --tools is a registry-level allowlist, so dynamically registered tools
must either be named in --tools or you must use --no-builtin-tools
(no allowlist) and let the extension govern the active set.
6. An extension can override a built-in tool by name — verified
Registering name: "read" replaces the built-in in the registry: the probe
showed read[cli] instead of read[builtin] while other built-ins kept
[builtin]. This is the supported way to give an agent a path-restricted
read while still satisfying the requirement in §1.
memo-guard.ts in the memo-inbox scenario has used this in production.
7. Only tools with promptSnippet appear in the human-readable tool list
dist/core/system-prompt.js: "A tool appears in Available tools only when the
caller provides a one-line snippet."
The tool schemas are always sent through the provider's tool-calling API, so
an omitted promptSnippet does not break invocation — but the model loses the
prose overview. Always set promptSnippet, and promptGuidelines where useful.
8. .pi/SYSTEM.md replaces the default prompt; the replacement branch omits tools and guidelines
Default prompt (system-prompt.js:82) begins:
You are an expert coding assistant operating inside pi, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files.
and ends with a block of absolute paths to pi's own README / docs / examples plus "read the docs and examples, and follow .md cross-references before implementing".
For a non-coding agent this is not merely noise: it is a ready-made escalation path for prompt injection.
The customPrompt branch (system-prompt.js:13-33) concatenates only:
SYSTEM.md → APPEND_SYSTEM.md → <project_context>(AGENTS.md) → skills section → cwd
It does not include toolsList or Guidelines. A replacement SYSTEM.md
must therefore carry its own tool overview and output discipline.
.pi/SYSTEM.md lives under .pi/, so it requires project trust — pass
--approve in non-interactive modes.
Probe evidence:
| Combination | coding-assistant text | pi-docs block | SYSTEM.md applied | prompt length |
|---|---|---|---|---|
| isolation flags only | true | true | false | 2619 |
+ .pi/SYSTEM.md + --approve |
false | false | true | 960 |
9. Context files are loaded from every parent directory, and AGENTS.override.md does not stop that
Load order: ~/.pi/agent/AGENTS.md, then each parent directory walking up from
cwd, then cwd.
AGENTS.override.md replaces AGENTS.md/CLAUDE.md for its own directory
only. Parent directories still layer normally — the docs say so explicitly
("Context files from other directories still layer normally") and it is easy to
get this wrong.
Probed: with AGENTS.override.md present in the workspace and a marker file at
/tmp/AGENTS.md, the marker still appeared in the system prompt.
| Configuration | parent /tmp/AGENTS.md in prompt |
|---|---|
workspace has AGENTS.override.md |
yes |
--no-context-files (-nc) |
no |
So a stray ~/AGENTS.md or ~/pi-workspaces/AGENTS.md silently contaminates
every scenario rooted below it, and an override file will not save you. Neither
existed on this host as of 2026-08-27, but nothing prevents one from appearing.
The only way to make the personality deterministic is -nc. Since that also
drops the workspace's own context file, the durable role content has to move into
.pi/SYSTEM.md and .pi/APPEND_SYSTEM.md, which are system-prompt files rather
than context files and are therefore unaffected by -nc.
Verified combination — no coding-assistant framing, no pi-docs block, own identity and role text present, parent pollution absent, only the scenario's own skill listed:
--no-builtin-tools --no-extensions -e <ext> --no-skills --skill <dir>
--no-prompt-templates --no-themes --approve -nc
+ .pi/SYSTEM.md + .pi/APPEND_SYSTEM.md
Note also that cwd is what anchors this discovery: launching pi from the wrong
working directory silently drops .pi/SYSTEM.md and every workspace context
file. A probe that forgot cwd produced a 2601-character prompt with the
coding-assistant persona intact; with the correct cwd it produced 4082
characters with the persona replaced.
10. Project trust gates .pi/, and CLI -e bypasses it
Trust is required when the directory contains .pi/settings.json,
.pi/{extensions,skills,prompts,themes}, .pi/SYSTEM.md,
.pi/APPEND_SYSTEM.md, or a project .agents/skills. A bare .pi does not
count.
Always loaded regardless of trust: AGENTS.override.md, AGENTS.md,
CLAUDE.md, user/global extensions, and CLI -e extensions — the last one
is deliberate so they can handle the project_trust event.
Non-interactive modes (-p, --mode json, --mode rpc) never prompt; they use
defaultProjectTrust (ask default / always / never) unless --approve /
--no-approve overrides for the run.
Design consequence: loading a scenario extension with an absolute
-e /path/to/ext.ts avoids the trust question entirely, which is the most
robust option for a systemd-managed gateway.
10b. PI_CODING_AGENT_DIR isolates the agent directory — but not ~/.agents/skills
PI_CODING_AGENT_DIR Override the config directory; default is ~/.pi/agent
This is the strongest isolation lever available, and it is stronger than the
--no-* flags because it repoints credentials and trust as well as
resources. The pi-grok scenario on this host uses it:
export PI_CODING_AGENT_DIR="$PI_GROK_HOME/.pi-agent"
Measured, with a marker extension planted in both directories and no --no-*
flags at all:
| Resource | default agent dir | PI_CODING_AGENT_DIR=<iso> |
|---|---|---|
<dir>/extensions/* |
DEFAULT_EXT_LOADED |
ISO_EXT_LOADED — isolated |
<dir>/skills/* |
(none) | iso-skill present — isolated |
~/.agents/skills/* |
find-skills, modsearch, summarize |
find-skills, modsearch, summarize — still leaks |
So it isolates settings.json, models.json, auth.json, trust.json,
extensions/, skills/, prompts/ and themes/ under the agent directory,
but ~/.agents/skills/ is a separate discovery root that it does not touch.
Practical consequences:
- Use
PI_CODING_AGENT_DIRper scenario when scenarios should not share provider credentials, trust decisions or model defaults. It is the only way to stop one scenario'sauth.jsonfrom being readable by another's agent. - It does not replace
--no-skills. Keep the loading flags as well. - A companion variable exists:
PI_CODING_AGENT_SESSION_DIR, overridden by--session-dir.
10c. --append-system-prompt accepts a file path
The help text says "Append text or file contents". pi-grok relies on this:
--append-system-prompt "$PI_GROK_HOME/AGENTS.md"
This is a third way to inject durable role text, alongside
.pi/APPEND_SYSTEM.md and AGENTS.md. Unlike a context file it is immune to the
parent-directory walk, and unlike .pi/APPEND_SYSTEM.md it needs no project
trust. Useful when the role text must live outside the workspace.
11. User-global resources leak into every scenario
Probed with no isolation flags, from an unrelated workspace, the skills list was:
PROBE_SKILLNAMES=["find-skills","modsearch","summarize"]
Those come from ~/.agents/skills/. find-skills in particular instructs the
agent to discover and install further skills — an unwanted capability surface
for a narrow-purpose agent. ~/.pi/agent/extensions/*.ts leak the same way.
With --no-skills --skill <abs> the list becomes exactly ["probe-skill"].
12. No output-schema flag; use a terminating tool with constrained sampling
There is no --output-schema / --response-format. Structured output is
achieved with a tool whose parameters is the desired schema:
{
name: "emit_result",
parameters: <schema>,
constrainedSampling: { type: "json_schema", strict: "prefer" },
async execute(_id, params) {
return { content: [...], details: params, terminate: true };
}
}
ConstrainedSamplingConfig:
| { type: "json_schema"; strict: "prefer" | "require" }
| { type: "grammar"; variants: Partial<Record<"openai_lark" | "openai_regex", string>> }
strict: "prefer" falls back gracefully; "require" fails the request if the
provider cannot honour it. terminate: true ends the turn only when every
finalized tool result in the batch sets it.
This replaces regex-scraping JSON out of prose.
13. Tools must truncate their own output
Built-in caps are 50 KB and 2000 lines. Helpers exported for this:
truncateHead, truncateTail, truncateLine, formatSize,
DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES.
Errors: throw from execute to set isError: true. Returning an
error-shaped object does not mark the call failed.
AgentToolResult.content goes to the model; details is persisted in the
session but not sent to the model.
14. Per-call gating hook
pi.on("tool_call", async (event) => {
if (!ALLOWED.includes(event.toolName)) {
return { block: true, reason: "…" };
}
});
ToolCallEventResult = { block?, reason?, terminate? }. To rewrite arguments,
mutate event.input in place. Later handlers observe earlier mutations and no
re-validation happens afterwards.
Combined with pi.setActiveTools() on session_start and
resources_discover, this gives two independent layers of capability control
that survive a mistake in the CLI flags.
15. RPC mode is strict JSONL and is meant to be long-lived
--mode rpc: commands as JSON objects on stdin, one per line; responses and
events as JSON lines on stdout.
Split records on
\nonly. Accept optional\r\nby stripping a trailing\r. Nodereadlineis not protocol-compliant because it also splits onU+2028/U+2029, which are valid inside JSON strings.
33 commands, including prompt, steer, follow_up, abort, new_session,
get_state, set_model, set_thinking_level, compact,
set_auto_compaction, get_session_stats, switch_session, fork,
get_entries.
message_update carries deltas only — no cumulative message, no
partial. Treat message_end.message as authoritative.
There is no command to inject a tool result from the host. Host-side capabilities must be exposed as an extension tool that calls back out.
Probing get_state alone starts the agent, fires session_start, and exits
without ever contacting the model — a zero-cost way to inspect the resolved
system prompt and tool set.
16. Usage and cost are recorded per assistant message
Every AssistantMessage carries:
Usage { input, output, cacheRead, cacheWrite, totalTokens,
cost: { input, output, cacheRead, cacheWrite, total } }
ToolResultMessage, CompactionEntry and BranchSummaryEntry carry an
optional usage for nested LLM work. Live equivalent: RPC
get_session_stats, which also returns
contextUsage { tokens, contextWindow, percent }.
Observability therefore needs no extra instrumentation — read the session JSONL
or subscribe to message_update.
17. Sessions never rotate or expire
--session-id <id> is an idempotent "use this project session, create if
missing" primitive (mutually exclusive with --session, --continue,
--resume; validated against ^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$).
There is no built-in TTL, rotation, size cap or pruning. Files grow monotonically until deleted by hand.
18. Auto-compaction rarely triggers on large-context models
Condition: contextTokens > contextWindow - reserveTokens.
{ "compaction": { "enabled": true, "reserveTokens": 16384, "keepRecentTokens": 20000 } }
With a 1,050,000-token context window the threshold is ~1.03 M tokens, so
latency and cost degrade for a very long time before compaction ever fires.
A gateway must implement its own rotation policy — see
gateway-patterns.md.
19. allowed-tools in SKILL.md frontmatter is not enforced
dist/core/skills.js:loadSkillFromFile consumes only name, description and
disable-model-invocation. allowed-tools is documented as experimental and is
not read in 0.84.3.
The memo-inbox scenario declares allowed-tools: in its SKILL.md; the real
enforcement is the ALLOWED_TOOLS array plus setActiveTools and the
tool_call hook inside memo-guard.ts. Do not rely on the frontmatter field
for security.
20. There is no sandbox
docs/security.md: "Pi does not include a built-in sandbox. Built-in tools can
read files, write files, edit files, and run shell commands with the permissions
of the pi process. Extensions are TypeScript modules that run with the same
permissions."
A path-restricted read override is an application-level boundary, not a kernel
one. Real isolation requires bubblewrap / container / micro-VM; pi ships
examples/extensions/sandbox/ (bubblewrap, .pi/sandbox.json) and
examples/extensions/gondolin/ (micro-VM) as starting points.
Re-verification
cd docs/evidence/probe-harness
./collect-evidence.sh > ../$(date -u +%Y-%m-%d)-isolation-probe.md
The harness costs zero model tokens: it sends a single get_state over RPC and
reads the resolved system prompt from session_start.