@d3ara1n/pi-access-denied
Sandbox write/edit/bash to the project dir — prompt / deny / allow modes with per-session allow-deny memory
Package details
Install @d3ara1n/pi-access-denied from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:@d3ara1n/pi-access-denied- Package
@d3ara1n/pi-access-denied- Version
0.3.2- Published
- Jun 24, 2026
- Downloads
- 234/mo · 234/wk
- Author
- d3ara1n
- License
- MIT
- Types
- extension
- Size
- 81.6 KB
- Dependencies
- 0 dependencies · 1 peer
Pi manifest JSON
{
"extensions": [
"./src/index.ts"
]
}Security note
Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.
README
pi-access-denied
Sandbox write / edit / bash to the project directory — any access outside an allowlist requires your authorization first.
Why
By default, pi's write / edit / bash can read and write any file the agent process has permission to. This extension adds an access boundary: outside the project directory = needs authorization.
About pi's built-in "trusted projects": that controls whether to load a project's local config / resources / extensions (defense against malicious
.pi/settings.jsonexecuting code) — its semantics is code-execution trust. What this extension does is restrict the file-access range of agent tools. The two are orthogonal, so it does not reuse pi's trust store; it only borrows theask / always / neverUX pattern.
Three modes
| Mode | Behavior |
|---|---|
prompt |
Ask the user on each out-of-bounds access (default) |
deny |
Block every out-of-bounds access without asking |
allow |
Passthrough (effectively disable the gate) |
Authorization panel (prompt mode only):
When a tool reaches outside the allowlist, a bottom-anchored panel lists every out-of-bounds path on its own row, each defaulting to Accept. A single horizontal action bar reflects the focused path's current choice:
- Accept (default) — allow this one call; shows no marker on its row
- Always accept — remember the path and everything beneath it, don't ask again this session; marks the row
[always-accept](green) - Deny — block this one call; marks the row
[deny](red) - Always deny — permanently block that path and everything beneath it this session; marks the row
[always-deny](red)
Each path keeps its own choice, so a multi-path bash call can accept some paths while denying others in a single pass. Submitting with any deny present pops a single global reason input (leave empty for a default reason); Esc there returns to the path list rather than committing a no-reason deny.
Keys: ↑/↓ move path focus · ←/→ change the focused path's action (no wrap) · Tab cycles the action (wraps) · Enter submit · Esc cancel the whole authorization (or, in the reason input, go back to the path list).
"Always" memory uses prefix coverage (not exact paths): authorizing /a/b also covers /a/b/c, /a/b/c/d, … so you never get re-prompted for a path whose ancestor you already decided on. When you remember a broader path, any narrower entries it now subsumes are dropped, keeping the list minimal and the status view free of "parent listed next to its own child" oddity. Memory is session-only — restarting pi, /reload, /new, /resume all clear it.
Install
Add the package path to the extensions array in ~/.pi/agent/settings.json:
{
"extensions": [
"/absolute/path/to/pi-access-denied"
]
}
Then /reload (or restart pi). The status bar will show something like 🔐 access:prompt.
Configuration
Under the accessDenied key in settings.json (global ~/.pi/agent/settings.json or per-project .pi/settings.json; project overrides global):
{
"accessDenied": {
"mode": "prompt", // prompt | deny | allow, default prompt
"extraAllowedDirs": [ // extra full read/write roots (~ and $HOME allowed)
"~/Documents/notes",
"/tmp/build-out"
],
"extraSafePaths": [], // finer-grained paths that never prompt
"tools": ["write", "edit", "bash"] // which tools to gate, default these three
}
}
Built-in safe paths (never prompt)
The gate's purpose is to stop an out-of-control agent from leaving permanent footprints outside the project (configs, user data, system files) — not to isolate users or hide other programs' data. So task-scoped scratch space that the OS reclaims is always allowed:
- Pseudo-devices:
/dev/null,/dev/stdin,/dev/stdout,/dev/stderr,/dev/zero,/dev/urandom,/dev/random,/dev/fd/(the process's own file descriptors). On Windows, the native device namesNUL/CON/AUX/PRN/COM1-9/LPT1-9are also recognized (matched by basename, soC:\proj\NUL, bareNUL, andNUL.txtall work). - Scratch dirs:
/tmp(system shared, auto-cleaned) andos.tmpdir()(per-user temp; on Linux these are the same place). macOS/tmp->/private/tmpsymlink is handled. On Git Bash for Windows,/tmpmaps to%TEMP%— see Cross-platform behavior.
Deliberately not allowed: /dev/tty (can capture keyboard input), /dev/disk* (block devices), and anything that persists — home dir, /etc, /var, /usr, etc.
Use extraSafePaths to add your own safe paths (e.g. a log dir you always read).
Command
/access-denied # show status (mode, allowlist, session memory)
/access-denied prompt # switch to prompt mode
/access-denied deny # switch to deny mode
/access-denied allow # switch to allow mode
/access-denied reset # clear session always-allow / always-deny memory
Path resolution
Allowlist = current project cwd + configured extraAllowedDirs.
A target path is resolved + normalized; if it falls inside any allowed dir (including the dir itself) it passes, otherwise it triggers authorization.
write/edit: takes thepathargument directly — exact.bash: heuristic token scan of the command string; only clearly escaping tokens are judged:- absolute paths starting with
/ ~/$HOMEprefixes..parent climbs (../x,a/..,a/../b)
Relative paths under
cwd(e.g.src/foo.ts,cat README.md) are left alone by default.- absolute paths starting with
Quoted strings and heredoc bodies are treated as data, not paths. A quoted run (echo '...', sed 's|a|b|g', printf '%s' ...) is a literal passed to a program, so it's skipped entirely — this is what stops a JS block comment like '/* header */ code' at the start of a quoted string from being mistaken for absolute path /. Likewise, a <<DELIM ... DELIM heredoc body is stdin data, so every /... token inside embedded code is ignored. Only the opener line (e.g. cat /etc/passwd <<EOF) is still scanned.
Backslash escapes are honored inside unquoted tokens. Agent\ Workspace is one token (a path containing a literal space), not two — the \ + next char is kept together and the backslash stripped, so /a/Agent\ Workspace/b is treated as /a/Agent Workspace/b. This covers \ (space), \;, \(, \|, even \\ → \. It applies only to unquoted tokens; inside quotes the backslash is left untouched (quotes already protect the content).
Note: read-only commands that traverse outside cwd (like find /, ls /etc) are also gated — bash access outside the project is blocked regardless of read/write, by design.
Cross-platform behavior
pi runs commands through Git Bash on Windows, so bash command strings arrive in MSYS style (/dev/null, /tmp, /c/Users/...). Node's path module does not understand MSYS path conventions — path.win32.normalize("/dev/null") yields \dev\null, which would otherwise fail to match the Unix-style safe-path constants. The gate handles MSYS paths itself instead of relying on Node:
Safe-path constants work on both platforms.
/dev/null,/dev/std*,/dev/fd/, and/tmpare matched after normalizing separators to/, so the\dev\nullproduced by Windows path resolution is recognized as the same safe path as the POSIX/dev/null.MSYS drive notation.
/c/Users/meis resolved toC:\Users\mebefore allowlist checks (case-insensitive, as MSYS is). So a command writing under cwd in MSYS style (/c/proj/src/...) is correctly seen as in-bounds rather than mis-resolved toC:\c\proj\..../tmpon Git Bash for Windows maps to%TEMP%(the OS-reclaimed per-user temp) by default, matching the Unix/tmpsemantics, and is treated as safe. (If you reconfigured/etc/fstabto mount/tmpat a permanent location, that location is still treated as safe — extremely rare configuration, and the blast radius is limited to/tmpwrites.)
Cannot be resolved statically (treated as out-of-bounds, conservatively): MSYS paths whose real Windows target depends on the install location or mount table — /usr/..., /etc/..., the MSYS root /. If your workflow needs these, add them to extraAllowedDirs with their real Windows paths.
Limitations (bash heuristic)
A bash command is an arbitrary shell string, so perfect static path analysis is impossible. Known blind spots:
- Unexpanded
$VAR(other than$HOME) can't be analyzed statically and is skipped (allowed). e.g.cat $SECRET_FILE. - Paths produced by command substitution / pipelines are invisible, e.g.
cat $(somecmd),echo {a,b}brace expansion. - An assignment like
X=/etc/passwdgenerally triggers no real access and is skipped. - Quoted real paths are no longer caught as a side effect of treating quoted runs as data:
cat '/etc/passwd'passes through even thoughcat /etc/passwd(bare) is blocked. The bare-path check still covers the common case; this only loosens quoted-path arguments. - Complex quoting can in theory cause misjudgment. Plain backslash escapes in unquoted tokens are handled (see Path resolution), but nested/layered quoting (
"'$x'") is not.
This is a protection layer, not an absolute sandbox — it blocks the vast majority of straightforward out-of-bounds access (cat /etc/passwd, rm ~/notes, echo x > /etc/foo) but not deliberate evasion. For strong isolation, combine with pi's containerization / SSH remote execution.
Non-interactive mode
In -p (print), --mode json, --mode rpc without a UI, prompt mode can't show a dialog, so it fails safe: out-of-bounds access is blocked (reason: no UI to authorize). To allow access in those modes, set mode to allow.
Design notes
- Session state is stored on
globalThis(per the monorepo convention, avoiding module-identity issues from pi's absolute-path loading); reset to the configured default onsession_start. - Authorization memory is never persisted — by design; restarting forgets, preventing authorization drift into hidden risk.
- Interception uses pi's
tool_callevent, returning{ block: true, reason }; the deny reason is passed back to the LLM as the block reason.