Closes#1.
Findings are keyed on (fingerprint, path, detail, byte_offset), so editing a
file moves every offset after the edit and the next sweep inserts new rows
beside the old ones. Nothing removed them, and the count at the top of
`nanny status` drifted away from reality.
The obvious fix — retire anything the last sweep did not re-confirm — is
wrong, and wrong in the direction that matters. 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; whole-mode sources are stat-skipped entirely
when unchanged. Both are indistinguishable from "the secret is gone" if you
look only at the findings table, and reaping on absence would have retired
every real transcript spill on the machine.
So collectors make a positive claim instead. ExcerptSink::note_complete_scan
declares that a container was read cover to cover — whole-mode files that
were actually opened, tail files whose cursor was invalidated by rotation,
truncation or --full, the database only from an empty cursor. It defaults to
a no-op so a collector cannot make the guarantee by accident: silence costs
a stale row, a wrong claim costs an exposure reported as handled.
Two outcomes follow. Superseded — the same secret still in the same file at
a new offset — merges into its successor and the stale row goes, carrying
first_seen and the operator's triage where the successor has none. That is
the real win: a decision now survives a reformat, and triage that evaporates
when someone runs a formatter is triage nobody does twice. Vanished — gone
from the container, or the container gone — is recorded and explicitly not
resolved. A spill edited away is not a spill that never happened; the value
already reached a model provider, and the row is the only thing that still
says a rotation may be owed. Hence a vanished_at timestamp orthogonal to
status rather than a status of its own, findings shown as "(gone)" rather
than hidden, and re-confirmation clearing the mark so a briefly unavailable
filesystem heals itself.
Six tests, of which the two that matter most assert the negative: an
incremental tail and a stat-skipped source must retire nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XAqHWfdMAsYu1o36tgeima
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, and
none of the existing levers fits it. So a third: a downgrade, keyed on rule
and path, saying the thing the operator means — this rule, in this place, is
not that serious.
nanny downgrade --under ~/git/cichlid --rule pem-private-key --to low
nanny downgrades / nanny undowngrade <id>
The path scope is mandatory. A placeless downgrade is a worse way of editing
the rule, and quietly neutering one fleet-wide from the command line is the
mistake worth making impossible. --rule is optional so a whole checkout can
be lowered at once; the most specific downgrade wins, rule-specific over
catch-all, then longest path. A downgrade replaces the context-derived
severity because it is the more specific statement: nanny guessed from the
path, the operator knows what the code is for.
The second half is a guard the first half made necessary. pem-private-key
matches the header, not the key, and that header is identical in every
private key ever generated — one fingerprint covered 27 findings across 9
unrelated files on this machine. Suppressing it because one instance is a
false positive would silence every future private-key leak, and nothing
about `nanny ignore N --everywhere` would suggest it. Rules now carry
matches_marker, set on the two rules where it is true, and unscoped
suppression of one is refused with the downgrade offered instead.
Refused *before* anything is written. The first cut checked after the status
changes had landed, which left a half-applied command — worse for the
operator than either doing it or refusing. For rules that do capture the
secret, --everywhere now prints the reach it is about to take.
Findings carry demotion_reason so a lowered severity always accounts for
itself in one field, whether a context or an operator put it there.
Verified against the case that prompted it: cichlid-api/src/main.rs:95 now
reads "low (certain) — critical for this rule, lowered: code that detects
PEM headers, not keys", six findings lowered, and the same rule elsewhere is
untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XAqHWfdMAsYu1o36tgeima
Both lines were written but the edits never landed, so a demoted finding
displayed as plain `medium (certain)` with no account of why it was not the
`critical` its rule says. That is precisely the failure the design set out to
avoid: a silently downgraded finding is one nobody can trust, and it is worse
than no demotion at all.
Verified on the live database — nanny's own test fixture now reads
"medium (certain) — critical for this rule, lowered because this is a test
fixture" — and the suppression round trip works against real data: remember
the value once, and a later sweep credits the suppression with the copies it
caught instead of reopening them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XAqHWfdMAsYu1o36tgeima
Two mechanisms for the same complaint: nanny's own test fixtures were being
reported as critical, and dismissing them was per-row work that came back on
the next sweep.
Severity was purely a property of the rule — an Anthropic key is critical
because Anthropic keys are critical. That answers "what kind of credential"
and not the question an operator asks, which is what a leak at this location
costs them. A finding now carries the rule's severity, the effective one,
and the context that separates them; filters, sorts and alerts use the
effective one, and every demotion can state what it was and why. Contexts
are tests, benchmarks, fixtures, examples, documentation and .example
configs.
Demote, never drop. 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 moves a finding below alert.min_severity, which stops
the interruption without losing the record.
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 distinguish a
transcript quoting a test file from one that printed a live key, so nothing
there is demoted.
Rust needs more than a path rule, because its convention puts a
#[cfg(test)] mod tests inside the production source file — which is the
literal case that prompted this. Matches after that attribute in a .rs file
are classified as fixtures. Deliberately Rust-only: every other language
here keeps tests in their own files, which the path rules already catch.
The second mechanism is suppression. Marking a finding ignored settles one
row, but "that is an invented token" is true of the value wherever it turns
up next, transcripts included. Suppressions are keyed by fingerprint and
optionally scoped to a path prefix, so the table holds the same one-way
salted hashes as the findings table and no values — remembering a judgement
risks nothing the database did not already. Suppressed findings are still
recorded, as ignored, so the counts do not lie about what is on disk, and
each suppression tracks what it caught so one that stopped earning its place
is visible.
nanny ignore 849 --everywhere
nanny ignore --under ~/git/upstream --everywhere --note "their fixtures"
nanny suppressions / nanny unsuppress <id>
I in the triage screen, next to i
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XAqHWfdMAsYu1o36tgeima
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
Two defects found by putting the whole pipeline under test against a
synthetic home directory.
serde_json serialises an i128 happily and then refuses to read it back
inside an internally tagged enum, so every Cursor::Digest came back
unreadable. Unreadable cursors are treated as "never read this source",
which is safe and therefore silent: the whole-file fast path existed and
never once fired, and every memory, snapshot and working-tree file was
re-read on every sweep. Timestamps are now i64 nanoseconds, good to 2262,
with a round-trip test over every cursor variant. A warm sweep of a real
home directory drops from 25s to 3.3s.
Collectors now declare a cadence. Working trees and the opencode database
are Periodic; harness stores are Reactive. A sweep triggered by a file event
runs only the reactive collectors, because inotify fires every second or two
during an active agent session and re-walking a hundred thousand files each
time would leave the daemon permanently busy. Only a full sweep records
liveness, so `nanny status` cannot report health it has not established.
Adds end-to-end tests over a synthetic home, including the one that matters
most: dumping every text column of every row and asserting no part of any
planted secret appears anywhere in the database.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XAqHWfdMAsYu1o36tgeima
nanny scans the stores that claude-code, opencode and pi write — session
transcripts, agent memories, cached pastes, shell snapshots, tool output,
opencode's SQLite database — plus shell history and repository working
trees, and records where and when a credential leaked into them.
Detection is by shape alone. nanny deliberately has no read access to any
credential store (~/.agents, ~/.ssh, each harness's own auth file), so the
monitor is not itself a process worth compromising. The cost is false
positives, managed with per-rule confidence, entropy floors and allow
patterns.
The central invariant is that a matched secret never leaves the scanner:
Secret<'a> borrows, does not clone into an owned form, does not serialise,
and Debugs as <redacted, N bytes>. What is stored is a salted keyed hash, a
length, and a preview with every matched span masked — so nanny's own
database cannot become the next spill.
Records and alerts only; it never edits a harness's files. Rules carry a
RotationHint as the seam for optional rotation later.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XAqHWfdMAsYu1o36tgeima