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.
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 |
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.
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.
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
A rule's severity answers "what kind of credential is this" — an Anthropic key is
critical because Anthropic keys are critical. That is half the question. The
other half is what a leak at this location costs you, and without it a fake key
in a #[cfg(test)] block scores the same as a live one in a transcript.
So in a working tree, severity is the rule's severity demoted by context —
tests, benchmarks, fixtures, examples, documentation, .example/.tmpl configs
— and the finding says so:
severity medium (certain) — critical for this rule, lowered because this is test fixture
Demoted, not dropped. crates/nanny-core/src/scan.rs:271 should read
medium — test fixture, not vanish; a tool that hides things is one you cannot
reason about, and nanny's silence is only worth something if it means something.
Demotion drops a finding below alert.min_severity, so it stops interrupting
without disappearing.
Only in working trees. A .md in a repo is documentation; a .md in
~/.claude/projects/*/memory/ is an agent memory, which is exactly where a real
credential ends up. In a harness store there is no way to tell "the transcript
quotes a test file" from "the transcript printed a live key", so nothing there is
demoted.
Marking a finding ignored settles one row. But "that is an invented token"
is true of the value, wherever it turns up next — including in the transcript
of the session where an agent read the file it lives in. So a judgement can be
remembered:
nanny ignore 849 --everywhere # this value is not a credential, anywhere
nanny ignore --under ~/git/upstream --everywhere --note "their fixtures, not my keys" # ...or just not here
nanny suppressions # what has been remembered, and what it caught
nanny unsuppress 3 # forget it; the value is reported again
In the triage screen, i ignores the row and I ignores the value everywhere.
Suppressions are keyed by fingerprint, which is what makes the feature safe to
have at all: the table holds the same one-way salted hashes as the findings
table and no values, so remembering a judgement risks nothing the database did
not already. Suppressed findings are still recorded — the inventory stays
honest — they simply land ignored and never alert. nanny suppressions shows
how many each has caught, so one that has stopped earning its place is visible
rather than accumulating silently.
The two mechanisms compose. Demotion handles the fixture where it lives; suppression handles every copy of that same fake token wherever an agent quoted it, transcripts included.
Context classification handles what nanny can work out on its own. It cannot work out what a repository is for, and code that detects secrets looks exactly like code that leaks them:
|| line.starts_with("-----BEGIN EC PRIVATE KEY-----")
That is pem-private-key, at full severity, in ordinary non-test source. A
downgrade says the thing you actually mean:
nanny downgrade --under ~/git/cichlid --rule pem-private-key --to low \
--note "code that detects PEM headers, not keys"
nanny downgrades # what is in force, and how much each has lowered
nanny undowngrade 1 # remove one; the rule's own severity applies again
--under is mandatory and --rule is optional (omit it to lower everything
under a path — for a checkout that is someone else's problem). --dry-run shows
what would change. The most specific downgrade wins: rule-specific over
catch-all, then longest path.
Deliberately not any of the near neighbours. It is not a rules.d override, which
changes the rule everywhere and pem-private-key is worth keeping sharp. It is
not an exclusion, which stops nanny reading the file so a credential landing
there later goes unseen. And it is not a fingerprint suppression — see below.
pem-private-key matches the header, not the key, and that header is
identical in every private key ever generated. On one real machine a single
fingerprint covered 27 findings across 9 unrelated files. Suppressing it because
one instance is a false positive would silence every future private-key leak,
and nothing about the command would suggest it.
So rules carry matches_marker, and unscoped suppression of one is refused
before anything is written:
$ nanny ignore 631 --everywhere
Error: `pem-private-key` matches a fixed marker, not the secret itself, so every
instance shares one fingerprint — suppressing it here would silence real leaks
everywhere. Nothing has been changed.
Scope it: nanny ignore 631 --everywhere --under <path>
Or lower it in place, which is usually what is meant:
nanny downgrade --under <path> --rule pem-private-key --to low
Set matches_marker = true on any local rule whose capture group cannot tell one
instance from another. For rules that do capture the secret, --everywhere
prints the reach it is about to take before taking it.
Findings are keyed on (fingerprint, path, detail, byte_offset). Edit a file and
every offset after the edit moves, so without reconciliation the next sweep
inserts new rows beside the old ones and the count at the top of nanny status
drifts away from reality.
Reaping is deliberately conservative, because the failure mode is nanny
reporting an exposure as handled when it is not. The absence of a confirmation
proves nothing — tail sources resume from a cursor, so a finding at offset 500
of a 40 MB transcript is never re-confirmed on a normal sweep. What licenses a
conclusion is a positive "I read all of this container and it was not there":
whole-mode files that were actually opened, tail files whose cursor was
invalidated by rotation, truncation or --full, and the database only when
scanned from an empty cursor.
Given that, two outcomes:
first_seen and the operator's triage where the successor
has none. A decision survives a reformat, which matters more than the row
count: triage that evaporates when someone runs a formatter is triage nobody
does twice.vanished_at is a
timestamp orthogonal to status, findings show as (gone) rather than
disappearing, and an untriaged one still counts as needing attention.Re-confirmation clears the mark, so a container on a filesystem that was briefly unavailable heals itself.
~/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 ... --everywhere 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.
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.
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/.
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 |
| daemon at rest | 49 MB resident, 4 filesystem watches |
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:
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.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.$HOME and ~/git — which the shell
and working-tree collectors did, because those are their roots — cost 342 MB
resident and a stream of permission denials from container storage, for events
that were either ignored or already covered. Periodic collectors now register
no watch at all, and the shell collector watches $HOME shallowly. 342 MB → 49 MB.Everything else follows ~/git/architecture/generic.md. These do not, and each
is deliberate:
~/.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.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.~/.config/nanny/config.toml, not /etc/nanny/, for the
same reason as the unit scope.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.
13 activities
Implemented in bee34ef.
A nanny scan --full over 60,882 sources / 979 MB:
reaped 45 stale row(s) whose location shifted, 1 no longer on disk
| | before | after |
| --- | --- | --- |
| findings | 1330 | 1285 |
| rows for nanny-core/src/scan.rs | 52 | 13 |
The 39 rows that went from one file are exactly the case the issue describes: that file was edited repeatedly during the session, and every edit shifted the offsets of everything below it.
Triage survived — the two ignored findings are still ignored, the suppression has caught 14 and the downgrade has lowered 12, all through the merges.
The one vanished finding is honest rather than incidental: crates/nanny-data/tests/end_to_end.rs had a hard-coded fixture token replaced with a generated one, so that value genuinely is no longer in that file. It shows as open and on disk: no, not as resolved.
Six new end-to-end tests. The two that earn their place assert the negative:
a_tail_source_read_incrementally_is_never_reaped — appends to a transcript across three sweeps and asserts the finding near the start is still present. This is the one that would have retired every real transcript spill on the machine.a_source_that_was_skipped_this_sweep_is_not_reaped — a quiet sweep stat-skips every whole-mode source, and must conclude nothing about any of them.Plus: an offset-shifting edit merges rather than duplicating and carries the operator's status and note forward; a scrubbed secret is marked gone but stays open; a deleted file takes its findings with it; and a restored secret clears its own mark.
None of substance. One thing became clearer while building it: the merge carrying triage forward is worth more than the row-count reduction. Triage that evaporates when someone runs a formatter is triage nobody does a second time.
wednesday, september 2, 2026 — 15:56:30 utcbee34ef feat: reap findings whose location no longer holds the secret8c55ab2 feat: scoped severity downgrades, and refuse suppressing a marker fingerprint0232c78 fix(cli): print the demotion reason and the --everywhere hint in `show`467c8d3 feat: severity depends on where a finding is, and judgements are remembered04699f5 fix(daemon): stop recursively watching $HOME and every working treeeff78fc feat(cli): bulk triage, and stop truncating locations twice510f856 fix: make cursors round-trip, and stop re-walking trees on every event991ab99 docs: record the rotation seam and the exclusion evidence