rob thijssen eff78fcddf feat(cli): bulk triage, and stop truncating locations twice
Status changes take selectors — --rule, --harness, --under <path>,
--fingerprint — with --dry-run, alongside bare ids. A first sweep of a real
machine reports over a thousand findings, most of them the same few test
fixtures repeated through vendored dependencies, and dismissing those one id
at a time is data entry rather than triage: a tool that demands it gets
abandoned instead of tuned. --fingerprint is the useful one for a genuine
leak, since one rotation resolves every copy of the secret at once.

Also fixes the location column, which was shortened twice. short_path keeps
the tail of a path, then the table clipped the right-hand end, so the row
lost both the filename and the line number — the two things that identify a
finding. Column widths are now derived from the same constants as the
constraints, and format::location budgets for the line-number suffix before
shortening the path around it.

Verified by driving the TUI through a pty and reconstructing the frame.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XAqHWfdMAsYu1o36tgeima
2026-09-02 15:23:58 +03:00

nanny

Watches the local agent harnesses — claude-code, opencode, pi — for credentials that have leaked into the places they write, and records where and when, so the leak can be rotated before somebody else finds it.

Agents spill secrets as a matter of routine. systemctl cat prints every Environment= line, env | grep -i token prints the value it matched on, journalctl -u echoes authorization headers, and all of it lands in a session transcript that is then stored on disk, sent to a model provider, and kept indefinitely. The architecture repo's agent-credentials.md §6 is the rule ("treat stdout as publication"); nanny is the thing that notices when the rule was broken.

It runs locally, on the machine whose harnesses it watches. It is not a service, has no network access, and needs no credentials of its own.

What it watches

Taken from an actual workstation rather than from each project's documentation, because the interesting surfaces are the ones nobody advertises.

harness surfaces
claude-code (~/.claude) session transcripts (projects/**/*.jsonl), prompt history, agent memories, file-history/ (a copy of every file an agent edited), paste-cache/, shell-snapshots/ and session-env/ (the environment a tool call ran with), persisted tool output, todos and tasks, the harness's own logs
opencode (~/.local/share/opencode) opencode.db — the part, message and session_input tables, read read-only — plus logs, session diffs and cached tool output
pi (~/.pi) agent/sessions/**/*.jsonl, settings, the ACP session map
shell .bash_history, .zsh_history, fish, python, psql, node histories
working trees everything textual under ~/git, because the file most likely to hold a credential is the one with no extension and no name you would think to glob for

What it will not read

Detection is by shape alone. nanny never reads a credential store, and the paths below are refused rather than merely deprioritised:

~/.agents, ~/.gnupg, ~/.password-store, ~/.ssh, ~/.step, each harness's own auth file, ~/.aws/credentials, ~/.netrc, ~/.npmrc, ~/.docker/config.json, ~/.kube/config, and nanny's own state directory.

Two reasons. A credential in one of those is not a spill — it is the credential living where it belongs, and reporting it would bury the real findings. And a monitor that reads every secret on the machine is a process worth compromising, which defeats the point of having it.

The cost of that choice is false positives, since nanny cannot recognise a house credential that has no distinctive format. confidence, min_entropy and per-rule allow patterns are how that is managed; nanny ignore is how the survivors are dealt with.

The redaction invariant

A matched secret never leaves the scanner. A tool that finds leaked credentials and writes them into its own database has not fixed the problem, it has added another copy.

What is recorded is a salted keyed hash (so the same secret in five places is recognisably one secret, and a stolen findings database reveals nothing), the length of the match, and a masked preview:

…"Authorization: Bearer ••••[51]", "url": "https://…

This is enforced by the type system rather than by discipline. nanny_entities::Secret borrows the matched span, cannot be cloned into an owned form, cannot be serialised, and its Debug prints Secret(<redacted, 51 bytes>) — so a stray {:?} in a log statement is harmless rather than a rotation. Nothing downstream of the scanner is able to disclose a value, because nothing downstream is given one.

Using it

nanny                       # the triage screen
nanny status                # what is watched, what has been found
nanny scan                  # scan now; exits non-zero if anything is new
nanny findings --json       # for scripts
nanny show 42               # one finding in full, including how to rotate it
nanny ack 42                # seen and triaged; the credential is still live
nanny rotated 42            # the leaked value is now worthless
nanny ignore 42             # not a secret
nanny rules                 # the active ruleset
nanny doctor                # is nanny itself configured safely

ack, rotated, ignore and reopen take ids or a selector — --rule, --harness, --under <path>, --fingerprint — with --dry-run to see what would change first. That matters more than it sounds: a first sweep of a real machine reports over a thousand findings, most of them the same few test fixtures repeated through vendored dependencies, and dismissing those one id at a time is data entry rather than triage.

nanny ignore --under ~/git/some-upstream-checkout --note "not my credentials"
nanny rotated --fingerprint c7c0c9894025e096   # every copy of one secret, at once

Tuning the noise

~/git is watched by default because a spilled value there is a spill whether or not it was ever committed. On a machine with third-party checkouts it is also where most of the false positives are: someone else's test keys, committed on purpose. Build output, vendored dependencies, tool caches and minified assets are excluded already; beyond that, exclude in the config takes globs, and nanny ignore --under handles a checkout you would rather keep but not hear about.

The triage screen is the default because the usual question is "do I have to rotate something right now", and that is a list to walk rather than a query to compose:

nanny  3 unresolved  1 critical  ·  showing unresolved
┌ findings ───────────────────────────────────────────────────────────────────┐
│ WHEN      SEVERITY  HARNESS  WHAT                    WHERE            STATUS │
│▸12:04:31  critical  claude   Anthropic API key       …/a1f2.jsonl:8814  open │
│ 09:51:02  high      opencode Gitea or Forgejo token  …/opencode.db     open  │
│ Aug 31    critical  pi       PEM private key         …/msg-14.jsonl:22 ack   │
└─────────────────────────────────────────────────────────────────────────────┘
┌ finding 1 ──────────────────────────────────────────────────────────────────┐
│ what      Anthropic API key (anthropic-api-key)                             │
│ where     ~/.claude/projects/-home-…/a1f2.jsonl:8814                        │
│ seen      first 12:04:31 · last 12:04:31 · 1 time                           │
│ match     104 bytes · certain confidence · fingerprint 3f9a2c1de4b70852      │
│ context   …"content":"the key is ••••[104] — use it"…                       │
│                                                                             │
│ rotate    Revoke the key in the Anthropic console and issue a replacement.  │
└─────────────────────────────────────────────────────────────────────────────┘
 j/k move · a ack · R rotated · i ignore · o reopen · f filter · y show path · q quit

a and R are deliberately different actions. Acknowledged means seen and decided; rotated means the leaked value is now worthless. Only the second one closes the exposure, and conflating them turns a triaged backlog into one that looks clean.

The daemon raises a desktop notification for anything at or above alert.min_severity (default high). It says what leaked and where; it has no access to the value, so there is nothing for it to put on a shared screen.

Rotation

nanny records and alerts. It does not rotate anything, and it never edits a harness's files — mutating a live session transcript to redact it is a good way to break the harness that is writing it.

Optional rotation is planned. The seam is already in place rather than being retrofitted later: every rule may carry a RotationHint naming the issuing service, what to do about a leak, and whether rotation is even sufficient (for a private key it is not — that is incident response). nanny show prints it, and a future executor dispatches on the service name. See doc/rotation.md.

Building and running

cargo build --release
install -Dm755 target/release/nanny-daemon ~/.local/bin/nanny-daemon
install -Dm755 target/release/nanny        ~/.local/bin/nanny

install -Dm644 asset/systemd/nanny.service ~/.config/systemd/user/nanny.service
systemctl --user daemon-reload
systemctl --user enable --now nanny.service
loginctl enable-linger "$USER"    # so it watches when you are not logged in

Configuration is optional; asset/config/config.toml.tmpl documents every setting and its default. Local rule overrides go in ~/.config/nanny/rules.d/.

Performance

On a real workstation — 286 MB of claude transcripts, a 52 MB opencode database, 25 pi sessions and fifty repositories under ~/git:

cold sweep, everything read 38 s, 112 MB resident
warm sweep, nothing changed 3.3 s

Every source carries a cursor: appended files are tailed from where they were left, rewritten files are skipped on an unchanged length and mtime, and database tables resume from the last row id. The expensive collectors — working trees, the opencode database — run only on the periodic sweep, never on a file event, because during an active agent session inotify fires every second or two and re-walking a hundred thousand files each time would leave the daemon never idle.

Four things were measured rather than assumed. Each is written up where the code lives, because each looked correct and was not:

  • A combined RegexSet cost 11.0 s for a 37 MB transcript; running the same twenty-two patterns individually cost 1.7 s. A combined automaton cannot use the per-pattern literal prefilters (sk-ant-, ghp_, -----BEGIN) that make the individual searches nearly free. The obvious optimisation was a 6× pessimisation.
  • Cutting chunks at the last newline in each window stalls to one byte per iteration on a file with a short first line and one enormous second one — a minified bundle, a base64 payload. It consumed five gigabytes of heap before it was caught. A newline is now only accepted as a cut point in the second half of a window.
  • Rebuilding the preview mask for every match is quadratic in the number of matches in a chunk. Built once and binary-searched instead.
  • serde_json will serialise an i128 and then refuse to read it back inside a tagged enum. Every whole-file cursor was silently unreadable, so the fast path existed and never once fired — a warm sweep cost 25 s instead of 3.3 s, and nothing failed loudly enough to notice. Timestamps are i64 nanoseconds, and a round-trip test now guards it.

Deviations from house convention

Everything else follows ~/git/architecture/generic.md. These do not, and each is deliberate:

  • §5 Postgres. State is SQLite, in ~/.local/state/nanny at 0600. nanny watches one operator's home directory on one machine: there is no second consumer and nothing to replicate. More to the point, the data is a map of where credentials have leaked on this host, which should not travel the mesh to a shared cluster or sit in a backup somebody else can restore.
  • §8 service accounts. nanny runs as a systemd --user unit, not a system service under a dedicated account. What it watches is the operator's home directory; a system service cannot see it with ProtectHome=true, and relaxing that so a daemon can read a human's home is a worse posture than running as that human. The unit still carries the hardening that means anything in user scope, plus IPAddressDeny=any and RestrictAddressFamilies=AF_UNIX — nanny has no business on the network.
  • §9 firewalld, §10 SELinux ports. Not applicable: nanny listens on nothing.
  • §3 config path. ~/.config/nanny/config.toml, not /etc/nanny/, for the same reason as the unit scope.
  • §7 CI deployment. There is no deploy workflow. nanny is installed on the workstation it watches, by the operator, from a local build.

Layout

crates/
├── nanny-entities/   types, and the redaction invariant that makes the rest safe
├── nanny-core/       rules, scanner, exclusions, the ports the data layer implements
├── nanny-data/       SQLite store, harness collectors, desktop alerting
├── nanny-daemon/     the watcher: inotify plus a periodic sweep
└── nanny-cli/        `nanny` — the triage screen and the scriptable subcommands
asset/systemd/        the systemd --user unit
asset/config/         config and rule-override templates
doc/                  design notes

Detection lives in nanny-core and knows nothing about the filesystem; the collectors live in nanny-data and know nothing about what a secret looks like. That split is what lets the rules be tested without a disk and the readers be tested without a ruleset.

Description
diaper duty for agents who shit the bed
Readme 646 KiB
Languages
Rust 100%