@zxh126/pi-redact-secrets
Redact secrets from pi tool output, user input and messages before they reach the model or the session file.
Package details
Install @zxh126/pi-redact-secrets from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:@zxh126/pi-redact-secrets- Package
@zxh126/pi-redact-secrets- Version
0.2.1- Published
- Oct 31, 2026
- Downloads
- 696/mo · 465/wk
- Author
- zxh126
- License
- MIT
- Types
- extension
- Size
- 150.2 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-redact-secrets
English · 简体中文
Redact secrets before they reach the model or the session file.
A pi extension that hooks the three places where text enters a conversation and masks credentials in flight:
| Hook | Catches |
|---|---|
input |
Anything you type or paste — including messages relayed from Telegram/QQ/Slack bridges |
tool_result |
Output of every tool: bash, pwsh, read, grep, MCP servers, codemode scripts |
message_end |
The model's own output, so it cannot echo a secret back into the transcript |
Because redaction happens on the way in, the secret never reaches the model
context and never lands in ~/.pi/agent/sessions/*.jsonl.
you / tool ──▶ [ redact ] ──▶ model context ──▶ session file
│
└─ patterns (known shapes) + literals (your exact values)
Install
pi install npm:@zxh126/pi-redact-secrets
Try it without changing your settings:
pi -e npm:@zxh126/pi-redact-secrets
Then start pi (or /reload a running session) and check the status:
/redact
Configure
Everything is optional. Without a config file you get the standard rule set and no literal secrets.
/redact init # writes ~/.pi/agent/redact-secrets.json with the defaults
// ~/.pi/agent/redact-secrets.json
{
"redactInput": true,
"redactToolResult": true,
"redactMessage": true,
"profile": "standard", // "standard" | "strict"
"secrets": ["my-exact-password"],
"secretFiles": ["~/.env", "~/.pi/agent/dbhub/mysql.toml"],
"secretCommand": {
"command": "pwsh",
"args": ["-NoProfile", "-File", "~/.pi/agent/dump-secrets.ps1"],
"timeoutMs": 8000
},
"minSecretLength": 8,
"logFile": "~/.pi/agent/redact-secrets.log"
}
Set PI_REDACT_SECRETS_CONFIG to use a different config path (handy for projects).
| Key | Default | Meaning |
|---|---|---|
redactInput |
true |
Redact user input. Turn off if you sometimes need to hand a real secret to the model on purpose. |
redactToolResult |
true |
Redact tool output. |
redactMessage |
true |
Redact finalized messages. |
profile |
"standard" |
strict adds a high-entropy rule for random strings that match no known shape. Noisier because base64 blobs match too. |
secrets |
[] |
Literal values to replace verbatim. |
secretFiles |
[] |
Files to mine for literals: .env/TOML/YAML KEY=value lines with secret-looking keys, scheme://user:pass@ DSNs, and .json files with secret-looking keys. |
secretCommand |
— | Executable whose stdout is a JSON array of literal strings, or one secret per line. Runs without a shell, with a timeout. |
minSecretLength |
8 |
Shorter literals are ignored — they would corrupt ordinary text. |
logFile |
~/.pi/agent/redact-secrets.log |
One line per redaction: counts per rule, never the values. |
Why literals matter
Patterns only catch recognisable secrets (sk-…, ghp_…, PEM blocks, DSNs,
password=…). A database password like 264613@Ms looks like ordinary text and
would slip through. Literals close that gap: point secretFiles at the config
files your machine already uses, or have secretCommand produce the values at
session start.
The extension ships no cryptography of its own. It reads plaintext once at session start and keeps it in memory, so encryption is the job of the secret store you already trust — one per platform:
| Platform | Store | secret-tool-style command |
|---|---|---|
| Windows | DPAPI (CryptProtectData) |
pwsh -File dump-secrets.ps1 |
| macOS | Keychain | security find-generic-password -s pi-secrets -w |
| Linux (desktop) | Secret Service (GNOME Keyring / KWallet) | secret-tool lookup service pi-secrets |
| Linux (server) | 0600 file, or systemd-creds on systemd ≥ 250 |
cat ~/.pi-secrets / systemd-creds decrypt x.cred - |
Two constraints matter when you pick one, because secretCommand runs without a
TTY (so nothing can prompt for a passphrase at session start):
- Must be unlocked without a prompt. DPAPI, Keychain and Secret Service all
qualify: the OS already holds the key from your login.
gpg,ageandpassdo not — they ask for a passphrase through pinentry, which fails from a background command unless an agent is already unlocked. If you want them anyway, use--pinentry-mode loopback --passphrase-file, or a wrapper that pulls the passphrase from a keyring. - Durability. User-scope DPAPI blobs are bound to the user profile. Windows
re-encrypts the master keys when you change your own password, but an
administrator password reset (or a profile rebuild) leaves them
undecryptable — the secrets are gone, and Windows Credential Manager entries
(git credentials, RDP passwords) go with them. Keep a password reset disk, or
put long-lived secrets somewhere whose recovery does not depend on the account
(password manager, hardware token,
systemd-credswith TPM). This is the same trade-off any login-bound key store makes, including Keychain and Secret Service — DPAPI is not special here, it is just the one that fails loudest.
Each of those is one executable that prints the values, so secretCommand stays
platform-neutral:
// Windows — DPAPI blobs, decrypted by PowerShell
{ "secretCommand": { "command": "pwsh", "args": ["-NoProfile", "-File", "~/dump-secrets.ps1"] } }
// macOS — one Keychain item per line
{ "secretCommand": { "command": "security", "args": ["find-generic-password", "-s", "pi-secrets", "-w"] } }
// Linux — Secret Service
{ "secretCommand": { "command": "secret-tool", "args": ["lookup", "service", "pi-secrets"] } }
// Linux without a keyring — a file only your account can read
{ "secretCommand": { "command": "cat", "args": ["/home/me/.pi-secrets"] } }
A command may print either a JSON array of strings or plain lines (one secret per
line). Shell pipelines need an explicit shell, because secretCommand never uses
one: { "command": "sh", "args": ["-c", "pass show pi/secrets"] }.
A Windows DPAPI example (dump-secrets.ps1) that prints a JSON array:
$list = [System.Collections.Generic.List[string]]::new()
function AddV($v) {
if (-not $v) { return }
$s = [string]$v
if ($s.Length -ge 8 -and $s -notmatch '^\d+$') { if (-not $list.Contains($s)) { $list.Add($s) } }
}
# … read your own config files or decrypt DPAPI blobs here …
AddV (ConvertTo-SecureString (Get-Content "$HOME\.secret.dpapi" -Raw) |
ForEach-Object { [Runtime.InteropServices.Marshal]::PtrToStringBSTR(
[Runtime.InteropServices.Marshal]::SecureStringToBSTR($_)) })
ConvertTo-Json -InputObject @($list) -Compress
On Linux, the equivalent without a keyring is a chmod 600 file; if you need it
encrypted and prompt-free, use systemd-creds (systemd ≥ 250) or a TPM-backed
store:
printf '%s\n' "$DB_PASSWORD" "$SMTP_CODE" > ~/.pi-secrets
chmod 600 ~/.pi-secrets
# secretCommand = { command = "cat", args = ["/home/me/.pi-secrets"] }
secretFiles is the zero-setup option: it works with a chmod 600 file you
already have (.env, ~/.pgpass-style configs, dbhub/mysql.toml). It gives you
no encryption, only whatever filesystem permissions you set — fine for a
single-user laptop, not enough for a shared host.
Rules
| Rule | Example input | Output |
|---|---|---|
pem |
-----BEGIN OPENSSH PRIVATE KEY-----… |
<REDACTED-PEM> |
auth-header |
Authorization: Bearer abcdef123456 |
Authorization: Bearer <REDACTED> |
openai-key |
sk-proj-abcdefghijklmnop |
sk-<REDACTED> |
github-token |
ghp_abcdefghijklmnopqrst |
<REDACTED-GH> |
npm-token |
npm_abcdefghijklmnopqrstuvwxyz |
npm_<REDACTED> |
aws-access-key |
AKIAIOSFODNN7EXAMPLE |
AKIA<REDACTED> |
jwt |
eyJhbGciOi….eyJzdWIi….s3cr3t |
<REDACTED-JWT> |
dsn |
mysql://root:pa55word@host/db |
mysql://root:***@host/db |
url-userinfo |
https://user:pass@host |
https://***:***@host |
json-secret |
{"appSecret": "eFh2DD4o…"} |
{"appSecret": "<REDACTED>"} |
key-value |
SMTP_PASS=…, db_password: …, 密码:… |
SMTP_PASS=<REDACTED> |
high-entropy (strict) |
long mixed-case random string | <REDACTED-HIGH-ENTROPY> |
Rules are idempotent: already-masked values (***, <REDACTED>) are not
matched again, so text is not rewritten twice when a session is resumed.
Commands
| Command | Effect |
|---|---|
/redact |
Rule count, literal count and sources, toggles, config and log paths |
/redact reload |
Re-read the config file and re-run secretCommand |
/redact init |
Write a sample config (never overwrites) |
Platform support
The extension is plain Node.js: no native modules, no shell-outs, no platform branches.
| Status | |
|---|---|
| Windows 10/11 | Verified (Node 24, PowerShell 7 + pi 1.4) |
| macOS | Expected to work — same code paths as Linux |
| Linux (x64/arm64) | Expected to work; ubuntu-latest × Node 22/24 in CI |
- Node ≥ 22.18 is required, because the package ships TypeScript sources and relies on Node's native type stripping (pi itself already requires Node 22+).
- Paths use
path.join/os.homedir();~and~/are expanded in config values. No drive letters or Windows-only APIs anywhere. secretCommandruns throughexecFilewithout a shell, so the command and its arguments are platform-native (pwshon Windows,sh/node/python3on Linux). Nothing is hardcoded.- The log file is created with mode
0600on POSIX (ignored on Windows).
What it does not do
- Tool arguments are not redacted. Rewriting
bash/pwsharguments would change what actually runs, sopwsh -Command "login --password hunter2"keeps the secret in the tool call. Put secrets in environment variables or files instead. tool_result.detailsis left alone — UI renderers depend on it.- Redaction is best-effort, pattern-based. It reduces exposure; it is not a guarantee. Anything that never leaves the machine is still the safer design.
- Secrets already written to a session file stay there. Use a one-off pass over
~/.pi/agent/sessions/**/*.jsonlif you need to clean history.
Development
git clone <repo> && cd pi-redact-secrets
npm test # node --test, no build step needed (Node ≥ 22.18 strips types)
npm run check # tsc --noEmit
pi -e . # load the package from this directory
CI runs npm test and npm run check on ubuntu-latest with Node 22 and 24.
test/extension.test.ts drives the extension through a fake ExtensionAPI, so the
three hooks, config loading, literal collection and the log file are covered without
starting pi.
Layout:
src/index.ts pi wiring: hooks + /redact command
src/rules.ts pattern rules and replacement templates
src/redactor.ts pure redaction engine (unit-tested, no I/O)
src/literals.ts literal-secret collection (config, files, command)
src/config.ts config schema, defaults and validation
The engine has no pi dependency, so it can be embedded elsewhere:
import { Redactor } from "@zxh126/pi-redact-secrets/src/redactor.ts";
const redactor = new Redactor({ profile: "strict" });
redactor.setSecrets(["my-exact-password"]);
redactor.redact("password=my-exact-password"); // { text: "password=<REDACTED>", hits: 1, byRule: {…} }
Store and reference (0.2.0 and later)
0.2.0 folds the standalone pi-secret-ref package into this one: storing, injecting, referencing, and redacting all share a single in-memory copy of each value. One extension, no load-order coupling.
Commands
| Command | Effect |
|---|---|
/secret db=<value> |
Store a value. Visible while typing; the transcript only shows db=****** |
/secret add db <value> |
Same, as a verb. add, set and put are aliases — easier when the value contains =, ? or & (a signed URL) |
/secret db |
Same, but the value comes from a prompt — no editor, no ↑ history |
/secret |
List key names, injected variable names, backend (never values) |
/secret test db |
Verify a key decrypts and is injected; reports the length only |
/secret rm db |
Delete and restore that environment variable |
/secret import db --from <spec> |
env:NAME, dpapi:<path>, <file>, or command |
/secret reload |
Re-read config, literal sources, and the store |
/redact |
Rule count, literal count, stored key count, backend, load time |
References: four equivalent spellings
[$secret.db] [$secret:db] <secret.db> <secret:db>
Key names are case-insensitive. Two expansion strategies:
- A — environment variables (default). Written in your own message, expanded to
$env:PI_SECRET_DB(or$PI_SECRET_DBunder bash). The model sees a variable name; the value is expanded inside the child process. - B — execution-time substitution (opt-in, allowlisted tools). Written inside
pwsh/basharguments, replaced just before execution; the transcript keeps the placeholder. Addwrite/editto the allowlist only if you accept that the value then reaches disk.
Unknown keys fail closed on both paths: A leaves the reference untouched and warns, B blocks the tool call rather than letting an empty password authenticate. A channel without a UI (QQ, other bot bridges) gets the warning as a note appended to the message, so the model knows the reference was ignored.
Reversible input redaction (injection.vaultInput)
Redacting your own message used to be one-way: https://…?token=abc123&id=42 reached the model as token=<REDACTED>, and the task — open that link — became impossible.
With injection.vaultInput (default true) the matched value is vaulted in memory — never written to the store — and the message is rewritten to reference it:
see https://ex.example.com/page?token=[$secret:auto1]&id=42 please
> pi-redact-secrets: 1 secret value in your message was replaced by a reference …: [$secret:auto1] -> PI_SECRET_AUTO1. …
The model keeps a usable placeholder instead of a dead <REDACTED>; the transcript and the session file hold no value; shell tools see PI_SECRET_AUTO1. A value that already belongs to a /secret key reuses that key rather than minting an autoN, and autoN keys never shadow a stored key. Set vaultInput: false for the old one-way masking.
Note: because B scans whole arguments of allowlisted tools, any reference spelling in documentation, code, or scripts is treated as a reference — an unknown key blocks the call. When writing docs, describe the syntax indirectly (
secret.key) or keep examples out of shell tools. Setinjection.substitution.blockOnUnknown: falseto relax it: unknown keys are left as-is instead of blocking the call.
Backends
| Platform | Backend | Location |
|---|---|---|
| Windows | DPAPI, per-user | store/<key>.dpapi |
| macOS | Keychain (service pi-redact-secrets) |
system keychain |
| Linux | Secret Service (secret-tool) |
system keyring |
| Fallback | Filesystem permissions only, not encrypted | store/<key>.secret (0600) |
store/index.json holds {backend, updated, length} only. Startup decrypts every DPAPI blob in one helper process, not one per key.
New config sections
{
"store": { "dir": "~/.pi/agent/secrets/store", "backend": "auto" },
"injection": {
"envPrefix": "PI_SECRET_",
"exposeKeyNames": true,
"envRefSyntax": "pwsh",
"vaultInput": true,
"substitution": { "enabled": true, "tools": ["pwsh", "bash"], "blockOnUnknown": true }
}
}
Every pre-0.2.0 key keeps working; omitting these two sections reproduces the old behaviour exactly.
Limits
- The model can still run
echo $env:PI_SECRET_DB. Known values are redacted from tool output, but redaction is pattern matching — base64 or substrings slip through. - Child processes inherit the environment: MCP servers, nested pi, PTYs. Prefer short-lived credentials for high-value secrets.
- The Windows DPAPI backend hands plaintext to PowerShell through an environment variable, and Windows caps one at 32767 characters, so values over 30000 characters are refused. Keep PEM bundles in a file and reference it from
secretFilesinstead. /secret db=valuelands in the editor's in-memory history (no disk, cleared when pi exits)./secret dbavoids it.- DPAPI blobs are bound to the user profile; an administrator password reset destroys them.
- Values already written to
sessions/*.jsonlare not rewritten.
License
MIT