@espadat/motus

Keep API keys and tokens out of your AI coding agent. Redacts exact secret values from tool output and session files in pi, and blocks credential files and printing commands in pi and Claude Code. Fails closed.

Packages

Package details

extension

Install @espadat/motus from npm and Pi will load the resources declared by the package manifest.

$ pi install npm:@espadat/motus
Package
@espadat/motus
Version
1.4.1
Published
Nov 13, 2026
Downloads
1,651/mo · 344/wk
Author
sripwoud
License
MIT
Types
extension
Size
86 KB
Dependencies
2 dependencies · 1 peer
Pi manifest JSON
{
  "extensions": [
    "./extensions/motus.ts"
  ]
}

Security note

Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.

README

motus

motus.espadat.com

Stop your AI coding agent from leaking your API keys.

An agent that runs env, cat .env or forge -vvvv sends every value it prints to the model provider. In pi it also lands in the session files, where memory tools copy it again. motus loads your secrets at session start and replaces each exact value with [REDACTED:NAME] before output leaves the tool. It also blocks the commands and files that print secrets in the first place.

  • pi: redaction plus blocks. Covers tool results, saved messages, prompts, ! shell commands and the provider request. A ! or !! command that holds a loaded value is refused: pi would save the command raw. Reference the secret by variable name.
  • Claude Code: blocks only, through a PreToolUse hook. Claude Code ignores output rewrites for Bash, so there is nothing to redact with.
  • Five sources: env, command, mise, pa and 1password. One TOML file configures all of it.
  • Web3 safe: no entropy or hex rules, so tx hashes, addresses and storage slots pass through untouched.
  • Fail closed: a required source that fails where it applies blocks tools until you accept partial protection.

It stops accidental leaks only. A hostile or prompt-injected agent can exfiltrate a secret before any output is redacted. Use a sandbox for that.

Status: 1.0 is not published to npm yet, and the end-to-end smoke test in pi is still open (meta/plan.md).

Install

pi

Install globally and put motus first in packages:

pi install npm:@espadat/motus

pi install appends motus to the end of packages in ~/.pi/agent/settings.json. Move it to the top:

{
  "packages": [
    "npm:@espadat/motus",
    "npm:other-extension"
  ]
}
  • Global, never --local: subagent pi processes only load global extensions.
  • First in packages of ~/.pi/agent/settings.json. This orders motus among packages only.
  • Handlers run in load order, so an extension loaded earlier sees raw tool output and messages. These always load before every entry in packages: -e extensions, the extensions array in settings, and ~/.pi/agent/extensions. In a trusted project, .pi/extensions, project settings entries and project packages also load earlier. Audit those locations.
  • pi can't report handler order, so motus doesn't check either rule.

Create ~/.config/motus/config.toml. Without it every session starts degraded.

Claude Code

Install the package so motus-cc-hook is on your PATH:

npm install --global @espadat/motus

Add the hook to ~/.claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Read|Bash",
        "hooks": [{ "type": "command", "command": "motus-cc-hook" }]
      }
    ]
  }
}

The hook reads the same ~/.config/motus/config.toml. Create it first: without it the hook denies every Read and Bash call. It denies Read paths and Bash commands that match blockPaths, blockCommands or claudeCode.blockCommands. Invalid config, bad input or a crash denies the call too.

[!WARNING] ! shell mode bypasses motus. Never run !env, !mise env or !pa … in Claude Code.

Claude Code adds ! commands and their output to the conversation, so values reach the model on the next request. ! commands are not tool calls, so PreToolUse never fires and motus-cc-hook never sees them. pi has no such gap: its user_bash handler redacts ! output. Read secret values in your own terminal.

Config

~/.config/motus/config.toml:

prefixes = ["sk-ant-", "ghp_", "github_pat_", "AKIA", "xox[bp]-"]
blockPaths = [
  "~/.config/mise/age.txt",
  "${PA_DIR:-~/.local/share/pa}/identities",
]
blockCommands = ["pa", "gh auth token", "op read"]
degradedAllowTools = []
minValueLength = 8

[[sources]]
type = "mise"
listFile = "~/.config/rotate-mise-secrets/files"

[claudeCode]
blockCommands = ["env", "printenv", "mise env", "cat .env*"]
Key Default Meaning
sources [], plus an env source unless you list one Where secrets load from
prefixes sk-ant-, ghp_, github_pat_, AKIA, xox[bp]- Vendor key prefixes redacted even when no source loaded the key
blockPaths [] Files and directories nobody may read. ~, $HOME and ${VAR:-fallback} expand. A directory blocks all beneath it
blockCommands [] Commands to block, matched at command position. "gh auth token" needs both words. * is a glob
degradedAllowTools [] Tools still allowed while the session is degraded
minValueLength 8 Shorter values are ignored, with a warning that names the key
claudeCode.blockCommands [] Extra blocks for the Claude Code hook only, since it can't redact

Unknown keys are errors. Every source also takes id (defaults to its type), required (default true) and timeoutMs (default 10000), the longest the source may go without progress. The config never holds secret values.

Sources

type Options Loads
env none process.env entries whose names look secret (KEY, TOKEN, SECRET, …)
command command A shell command printing a {"NAME": "value"} JSON object
mise listFile Age-encrypted mise values, decrypted with mise env --json
pa entries or whole = true, dir pa store entries
1password refs, fromEnv op:// references, resolved in one op inject call
[[sources]]
type = "command"
id = "vault"
command = "my-vault export --json"

1Password

[[sources]]
type = "1password"
fromEnv = true

[sources.refs]
API_TOKEN = "op://dev/api/token"
  • refs names each secret by its key. fromEnv = true also loads env vars whose values are op:// references, named by the var.
  • All refs resolve in one op inject call at session start. Expect one 1Password biometric or session prompt then, every session.
  • A locked or missing op fails the source. With required = true (the default) the session starts degraded.

pa

Loads pa entries at session start, so their values get redacted. Keep pa in blockCommands so pa show stays blocked.

[[sources]]
type = "pa"
entries = ["web/github", "mail"]
  • Exactly one of entries or whole = true. whole loads every entry except */meta.
  • dir defaults to ${PA_DIR:-~/.local/share/pa}.
  • Kept lines: non-blank, at least minValueLength chars. One kept line gives web/github. Several give web/github:1, web/github:2. An entry with none loads nothing.
  • Entries decrypt one by one. timeoutMs (default 10 s) bounds each entry, not the whole store, so a large whole store or parallel sub-agents sharing one TPM still load. Raise it only when a single entry is slow, such as a YubiKey PIN prompt.

A store created by pa without a plugin uses a plain age key, so it decrypts with no prompt. Use whole for it, or for a TPM-keyed store (age-plugin-tpm). A YubiKey-keyed store asks for a PIN and a touch on every startup. The PIN needs a GUI pinentry, because pi owns the terminal. Without one, the source times out and the session degrades. Prefer a short entries list there.

Several stores mean several sources, each with its own id and dir. Here a TPM store loads whole and a YubiKey store loads one entry:

[[sources]]
type = "pa"
id = "pa-machine"
dir = "~/.local/share/pa-machine"
whole = true

[[sources]]
type = "pa"
id = "pa-yubikey"
dir = "~/.local/share/pa"
entries = ["web/github"]
timeoutMs = 30000

Use

  • Session start reports how many secrets loaded and from which sources, never values.
  • Degraded session: a required source failed where it applies, so every tool not in degradedAllowTools is blocked, ! commands included.
  • /motus status: loaded counts, failures and state.
  • /motus continue: accept partial protection for this session. Blocks still apply. Refused when the config did not load (missing, invalid, or an unresolvable blockPaths entry): motus cannot enforce blocks it never read. Fix the config and run /motus reload.
  • /motus reload: re-read the config and load every source again, then replace the session's state with the result. Ends the degraded state if every required source that applies now loads; otherwise the status shows the new reason. Also ends a continue. Secrets from earlier loads stay redacted even if their source now fails. Only you can run it.
  • There is no /motus off. Read secret values in your own terminal.

1.0 stability

These follow semver after 1.0. Changing one is a major release.

  • Config schema: the keys, defaults and source options in Config.
  • Source contract: the SecretSource and Secret shapes in meta/spec.md.
  • Placeholder: the [REDACTED:NAME] text.

Everything else can change in a minor release: exact block reasons, status text, derived forms and any module under src/ other than the source contract.

Post-1.0

Planned adapters: pass, bitwarden, sops, secret-tool and security. See meta/spec.md.

Docs

File Contents
meta/spec.md Decisions, architecture, source adapters, config, failure modes
meta/plan.md Build order and test plan
meta/research/varlock.md Why varlock doesn't fix this problem
meta/research/oss-survey.md 49 pi packages surveyed; why we build instead of fork
meta/personal/ Machine-specific inventory and hardening log. Must not be published (gitignored)

Why

Past incident: secrets printed during an AI session meant rotating many keys. Root cause: mise decrypts age secrets into the shell env. pi inherits them, so one env or forge -vvvv sends them to the model and into session files, and memsearch then copies them into ~/memories.

Motus et bouche cousue: "not a word, lips sealed".