Greywall and pi: the built-in profile is not enough
pi has no permission system, and it does not hide it: its README says so explicitly (“Pi does not include a built-in permission system for restricting filesystem, process, network, or credential access”), and it sends you straight to a sandbox if you want one (“If you need stronger boundaries, containerize or sandbox Pi”). I am assuming that is exactly what you are looking for if you are reading this article, so let us get to it.
Claude Code has its own permission system, which asks for confirmation before writing. opencode does too. For them, Greywall reinforces a first line that already exists. For pi, there is nothing to reinforce: it is the only line that exists.
What does Greywall bring to a harness?
Greywall is a containerless, deny-by-default sandbox built on bubblewrap, Landlock, seccomp, and a transparent network proxy. For pi, it provides three things: it decides which files the agent opens, down to the individual file; it confines outbound connections to a logged allowlist (an agent that reaches github.com exfiltrates via a gist with no effort, keep that in mind); and it replaces environment credentials with placeholders, so that an API key never travels in cleartext through the sandboxed process. I detailed this setup, with measurements to back it up, in Securing pi, an AI coding agent, with Greywall.
Why the built-in profile does not do the job?
Greywall ships a pi profile, which you invoke with --profile pi. I do not recommend it, for three reasons.
On the network side, it only opens api.openai.com and api.anthropic.com (so if your provider is Z.ai, OpenRouter, or Ollama Cloud, your agent runs in a vacuum). On the paths side, 2 of its 3 entries have never existed in pi, because ~/.config/pi and ~/.cache/pi are XDG paths it has never used, since it stores everything under ~/.pi/agent. And on the security side, the real problem: its allowWrite: ["~/.pi"] leaves extensions/, git/, bin/, settings.json, and auth.json writable, that is, exactly the elements through which an agent makes itself persistent from one session to the next. You sandbox pi and you leave it the ability to rewrite its own startup code: that is not a detail.
The obvious fix fails, and silently
Removing ~/.pi from allowWrite is the first reflex. But it does not work, because pi reads its settings through proper-lockfile, which creates the directory ~/.pi/agent/settings.json.lock before each access (yes, a directory, not a file). The parent being read-only, this mkdir returns EROFS, so pi does not open its settings, so it ignores the packages field, and no installed extension loads.
The only sign is an innocuous-looking warning at startup, which says nothing about the real problem: an agent stripped of its packages, with no message linking one to the other. You only understand it by comparing pi list inside the sandbox and outside the sandbox.
The profile I propose
I keep ~/.pi/agent writable for the lock, and I freeze each target with denyWrite. I verified that a denyWrite path holds even with a writable parent, because it is a mount point: EROFS on write, EBUSY on rm as well as mv, and recursive on a directory (meaning you do not bypass it through the back door).
The profile has 3 allowWrite entries and 13 denyWrite entries. It is here:
https://gist.github.com/mwolff44/20a51174c89b13f30f85a814126a3cca
It closes pi’s persistence vectors, makes auth.json readable but not modifiable (pi has to authenticate somewhere), freezes the security policies while leaving security/ writable so that the audit log and its rotation keep running, and keeps ~/.agents, ~/.skills, and ~/AGENTS.md out of writes, which closes persistent prompt injection.
Copy it into ~/.config/greywall/learned/pi.json, replace YOUR_PROVIDER with your provider’s domain, then launch the agent:
greywall --profile pi -- pi
Two warnings before you rely on it. A file placed in learned/ entirely replaces the built-in profile, protection baseline included, so this one must remain self-sufficient: you cannot remove lines from it at random on the pretext that they look redundant. And it only carries the openings, because the global denials (including the masking of your system keyring) are declared separately in ~/.config/greywall/greywall.json, which the article details.
What the sandbox does not cover
A gap I had not anticipated. Greywall’s command blocking splits the string on |, ||, &&, and ;, then compares each segment by strict prefix, but it never splits on a newline. A git push --force placed on the second line of a script, in a heredoc, or in a .sh file, therefore passes through command.deny unseen. This is verified, and facing an agent that writes multi-line scripts all day, it is not a textbook case.
Greywall cannot get out of this alone: it only observes an already-split shell string. Something has to inspect the tool call before it becomes that string, and that is the job of pi-secured-setup, the extension I maintain for pi (guards, scanners, and detailed audit log in Securing pi from the inside). It also closes another hole: Greywall’s credential substitution only covers headers and URL parameters, never the body of a POST request, where the extension scans and redacts the secrets it finds there.
You still have to look closely, because the 4 forms of the bypass do not land on the same net. Measured on version 1.1.1:
| Form | Classified segment | Verdict |
|---|---|---|
git push --force on 2nd line of a script | the whole script | dangerous |
bash <<< "git push --force" | the whole string | dangerous |
heredoc bash <<EOF | the opening only | unknown command |
bash script.sh | the call only | unknown command |
The first two land on a real rule, an unanchored regular expression tested on the entire segment, so the newline does not bother it. The last two land on a looser net, that of unknown commands, because the extension absorbs the body of a heredoc by design (so that a line like rm -rf / is not taken for a standalone command) and obviously does not open a script file to read what is inside.
All 4 end up asking for confirmation, even with no one to answer: launched with pi -p or in a pipeline, with no interface available, a confirmation request becomes a refusal, so the behavior remains fail-closed. But the last two only hold as long as bash and sh remain unclassified elsewhere. Yet the rules are additive: a project’s .pi/security/command-rules.json can flip them to safe or moderate, and the confirmation becomes an automatic approval without anyone having actually decided it. That is the link to watch.
The two layers therefore do not overlap, they complement each other: Greywall decides what the process can reach, the extension decides what the agent can request. All that remains is to make them coexist without stepping on each other, which will be the topic of the next article.