@zxh126/pi-redact-secrets

Redact secrets from pi tool output, user input and messages before they reach the model or the session file.

Packages

Package details

extension

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, age and pass do 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-creds with 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.
  • secretCommand runs through execFile without a shell, so the command and its arguments are platform-native (pwsh on Windows, sh/node/python3 on Linux). Nothing is hardcoded.
  • The log file is created with mode 0600 on POSIX (ignored on Windows).

What it does not do

  • Tool arguments are not redacted. Rewriting bash/pwsh arguments would change what actually runs, so pwsh -Command "login --password hunter2" keeps the secret in the tool call. Put secrets in environment variables or files instead.
  • tool_result.details is 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/**/*.jsonl if 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_DB under 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/bash arguments, replaced just before execution; the transcript keeps the placeholder. Add write/edit to 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. Set injection.substitution.blockOnUnknown: false to 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 secretFiles instead.
  • /secret db=value lands in the editor's in-memory history (no disk, cleared when pi exits). /secret db avoids it.
  • DPAPI blobs are bound to the user profile; an administrator password reset destroys them.
  • Values already written to sessions/*.jsonl are not rewritten.

License

MIT