@espadat/motus1.2.0
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.
Untested on omp
This is a pi package. No one has reported whether it works on omp yet. If you try it, add your result below.
Reports 0
No reports yet.
Readme
motus
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. - Claude Code: blocks only, through a
PreToolUsehook. Claude Code ignores output rewrites for Bash, so there is nothing to redact with. - Five sources:
env,command,mise,paand1password. 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 load motus before every other extension:
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
packagesof~/.pi/agent/settings.json: handlers run in load order, so an extension loaded earlier sees raw tool output and messages. - 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 envor!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 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"
refsnames each secret by its key.fromEnv = truealso loads env vars whose values areop://references, named by the var.- All refs resolve in one
op injectcall at session start. Expect one 1Password biometric or session prompt then, every session. - A locked or missing
opfails the source. Withrequired = 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
entriesorwhole = true.wholeloads every entry except*/meta. dirdefaults to${PA_DIR:-~/.local/share/pa}.- Kept lines: non-blank, at least
minValueLengthchars. One kept line givesweb/github. Several giveweb/github:1,web/github:2. An entry with none loads nothing. - Entries decrypt one by one within the source's
timeoutMs(default 10 s). Raise it for longentrieslists or a largewholestore.
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
degradedAllowToolsis blocked,!commands included. /motus status: loaded counts, failures and state./motus continue: accept partial protection for this session. Blocks still apply.- 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
SecretSourceandSecretshapes inmeta/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".