@tduyng/pi-guard
Secret redaction, cross-session path reservations, git safety, and a rejection guard. Three of the four add no schema at all.
Package details
Install @tduyng/pi-guard from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:@tduyng/pi-guard- Package
@tduyng/pi-guard- Version
0.0.5- Published
- Nov 10, 2026
- Downloads
- 843/mo · 651/wk
- Author
- tienduy-nguyen
- License
- Apache-2.0
- Types
- extension
- Size
- 62.2 KB
- Dependencies
- 1 dependency · 1 peer
Pi manifest JSON
{
"extensions": [
"./src/reserve.ts",
"./src/cloak.ts",
"./src/git-interceptor.ts",
"./src/reject-guard.ts"
]
}Security note
Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.
README
@tduyng/pi-guard
Four guards for the ways a session ends badly. Three are hooks and spawn no schema, so a session that never needs them pays nothing.
| Guard | Failure it prevents |
|---|---|
reserve |
A peer session edits a file this session is working on |
cloak |
A key file lands in the transcript and goes to the provider |
git-interceptor |
Git opens an editor and hangs the turn, or --no-verify skips a hook |
reject-guard |
One unhandled promise takes down the session |
reserve is deferred at 215 tokens, and it arms itself on the run where a second live pi session holds a registration in this folder. A session that is alone in its folder pays nothing. Once armed it stays armed, because the tool list is the front of every request and dropping it again rewrites that front.
Sessions in one folder
Pi serializes writes inside one process. Two sessions in one folder do not see each other, so the second overwrites a file the first is halfway through reshaping.
A session writes what it holds to .pi/reservations/<session>.json. A tool_call hook refuses edit and write against a path a live peer holds, and it checks bash too, so sed -i, git checkout and a redirect into a reserved path are turned away while a read-only command passes.
reserve({action: "reserve", paths: ["extensions/pi-goal/src/"]})
reserve({action: "list"})
reserve({action: "release"})
A directory reserves its whole subtree, an exact path reserves one file, and release with no paths gives everything back. The refusal names the holder, so the other session is one that can be inspected.
Liveness is process.kill(pid, 0), so a killed session takes its reservations with it. No heartbeat and no daemon. Writes go through a temp file and a rename, so two sessions reserving at once cannot leave a half-written file.
Every path is compared in its canonical spelling, resolving symlinks through the deepest part of it that exists. Without that the guard failed on macOS in the ordinary case: /tmp is /private/tmp, so a reservation written as /tmp/project/src never matched a call recorded as /private/tmp/project/src, and a live run showed the tool listing the peer while the edit went through. A symlinked project folder had the same effect.
A mutation that names no path is invisible to this, so a bare git commit still takes whatever is staged. git add -A, git add -u, git add . and git commit -a are refused while a live peer session is in the folder, because a shared index means that commit can carry the peer's files under this session's subject, and the log cannot tell them apart afterwards. Name the paths instead, or commit from a subagent with isolation: "worktree", whose checkout is its own. Two sessions committing in one repository also race on .git/index.lock, and no reservation covers that.
Secrets in the transcript
A tool_result hook cleans read results on their way into the model. Three checks, each switchable:
| Check | Catches | Replacement |
|---|---|---|
| Vendor keys | AWS, OpenAI, Anthropic, Stripe, GitHub, Slack, GitLab, Google, npm, SendGrid, Hugging Face, Replicate, DigitalOcean, PyPI, Shopify, Square, age, JWTs, Bearer |
First 4 characters, stars, [REDACTED:type] |
| PEM blocks | -----BEGIN ...----- certificates and keys |
[REDACTED:PEM TYPE] |
| Sensitive paths | .pem, .crt, .p12, SSH keys, .aws/credentials, .npmrc, .netrc, .docker/config.json, .kube/config, .git-credentials, .pgpass, .htpasswd, terraform.tfstate |
The whole read becomes [REDACTED:Protected File (...)] |
| Assigned secrets | A NAME=value line in any tool result whose name ends in KEY, TOKEN, SECRET, PASSWORD or PASSWD, or a credential name carrying a value past 16 characters |
The value becomes [REDACTED:assigned secret] |
| URL passwords | The password half of scheme://user:password@host, as git remote -v prints it |
The password becomes [REDACTED:url password], user and host stay |
A bare value has no name to match, so printenv DB_PASSWORD and echo $DB_PASSWORD are refused at the command instead, the same way a file read is. Writing the value to a file, or reading only its length, is allowed, because that is how a release step sets up auth. A pipeline is refused: the guard cannot tell whether the next program prints the value.
Replaying the 11,437 recorded shell commands found 10 newly refused. Seven are the commands written to test these rules. One is a printf piped into awk that prints a length. The other two are heredoc bodies under an interpreter. Those the guard already refuses for env-file text, since it cannot tell what the body will run.
Secrets have known prefixes, so AKIA, sk-, ghp_, glpat-, xoxb-, AIza and a dozen more are matched as literals with a word boundary. An earlier version scanned by entropy and flagged nix store hashes and sha256 digests in derivations, so that check is gone. The prefix list cannot cover a key the vendor invented last week: env printed OPENCODE_API_KEY and COMMAND_CODE_API_KEY in full until the assignment rule ran over tool results too.
Each layer has been checked in a running session, not only in a test. A read of an env file returned [REDACTED:Protected File (Environment File)]. A background task that printed TOKEN_LINE=abcdef1234567890 came back as TOKEN_LINE=[REDACTED:assigned secret]. A subagent child that read the same line reported it to its parent with the value masked and the name intact, which is the layer easiest to get wrong, since the parent sees only the child's summary. A codemode script that reads the same file is covered without a rule of its own: the sandbox has no filesystem, so it goes through the read tool and inherits that mask.
Custom rules go in <agent-dir>/cloak.json or .pi/cloak.json. A project file overrides the scalar keys and appends its patterns.
| Key | Default | Meaning |
|---|---|---|
enabled |
true |
Master switch |
cloakCharacter |
* |
Masking character |
cloakLength |
null |
Fixed replacement length, null keeps the original |
tryAllPatterns |
true |
Apply every matching rule, not just the first |
patterns |
[] |
Rules with filePattern (glob), cloakPattern (regex), optional replace |
vendorKeys |
true |
Built-in prefix detection |
pemBlocks |
true |
PEM detection |
sensitiveFileTypes |
true |
Whole-file redaction |
When two matches overlap, the first rule applied wins. A custom pattern replaces only its matched spans, while a sensitive file type replaces the whole read. /cloak-status shows whether cloak is on, how many custom patterns loaded, and which built-in checks are active, including the two that run on every tool result and the shell guard, so the line reports what ran rather than only the keys a config file can turn off.
Git in a non-interactive turn
GIT_EDITOR=true, GIT_SEQUENCE_EDITOR=true and GIT_MERGE_AUTOEDIT=no go on process.env. Pi builds a child's environment by spreading process.env, so one assignment covers the bash tool, bg, and every other spawn.
The extension blocks a bash command containing --no-verify, because the agent should fix what the hook complains about or ask. The check is string matching, best effort, and not a security boundary.
It also blocks a shell command that would read an environment file, an SSH key, or a .pem, .key, .p12 or .pfx file, along with the names cloak already hides on a read: .npmrc, .pypirc, .netrc, .git-credentials, .pgpass, .htpasswd and terraform state, since a shell prints those bytes without cloak in the way. A glob counts as the file it expands to: a read through .env*, id_* or the whole key directory is refused, because a shell adds those names before the command ever runs and the literal name never appears. In recorded sessions 13 of 28 blocks read nothing secret, and each one cost a turn, so four cases pass:
- Templates:
.env.example,.env.dist,.env.sampleand.env.template. - Metadata:
ls,stat,test,touch,file,echo,git ls-filesandgit check-ignore, with no pipe, redirect or substitution in the same command. - A
.keyor.pemname that is not a file in the working directory or acdtarget, such asitem.keyin code or.keyin jq. A glob or a variable in the name still blocks. - A heredoc body under a writer,
catortee, which only stores what the model typed. A body an interpreter runs, such aspython3 - <<EOF, still blocks.
The rejection that kills a session
Pi installs an uncaughtException handler and no unhandledRejection handler, so one unhandled rejected promise tears down the session. The guard adds the missing listener, logs the reason with a stack trace to stderr prefixed [reject-guard] unhandled rejection (session kept alive):, and the turn continues.
It installs once. /reload re-evaluates the module, and a flag on globalThis stops a second registration from logging every rejection twice.
Install
pi install npm:@tduyng/pi-guard
Local checkout: pi install /path/to/dotfiles/pi/agent/extensions/pi-guard
All four guards activate on install. Add cloak.json when a rule fires too widely or misses a secret shape.
Limits
- Cloak reads the
readresult on its way into the model. It does not touch the outbound provider payload, the assistant's reply, or any tool other thanread, and it does not redact a file pasted into a prompt. - The redaction a child session sees is a separate, shorter list: private key blocks, AWS access keys,
sk-keys, bearer tokens, Google API keys, connection strings that carry a password, and anyKEY/TOKEN/SECRET/PASSWORDassignment in either case. The lower-case form and the password inside a URL were missing until a battery of seventeen shapes showed a child reading an env file in full. - A reservation is advisory between pi sessions. A shell opened by hand is outside it.
--no-verifyblocking can be worked around by anyone who wants to.