10 Commits

Author SHA1 Message Date
f71e9323db fix: five review findings, and a sixth the fixes exposed
External review of the ruleset and reap. All five reproduced before
changing anything; two were worse than reported.

A capture narrower than the credential it matches stops early and the
preview then prints the rest of the key as surrounding context. The OpenAI
rule excluded `-` and `_`, so a modern sk-proj key ending on `-` had its tail
printed raw, and one ending on `_` matched nothing at all — a missed key, not
just a leaky preview. The rule is fixed, but the guarantee now holds
structurally: masked spans are widened over the whole credential-shaped run,
so any rule with that flaw is survivable, including ones added later.

The two NAME=value heuristics were blind inside JSONL transcripts, which is
the surface nanny exists for. A transcript's newlines are the two characters
\n and its quotes are \", so (?m)^ never matched; and \b does not match after
an underscore, so SECRET inside SUPABASE_SECRET_KEY was invisible. Measured
before: 3/3 in a raw .env dump, 0/3 inside a transcript line. After: 3/3 in
both. It also made the ruleset six times faster — consuming a real character
instead of a zero-width assertion lets the engine build a literal prefilter,
and 37.6 MB went from 1.7s to 0.29s.

Reap supersession paired each stale row against any survivor with a matching
fingerprint. Where one fingerprint covers several distinct secrets — which is
what a marker rule does — deleting one of two private keys from a file paired
both stale rows against the single survivor and recorded a deletion as a
move. Pairing is now one-to-one, which is also the right answer for ordinary
rules where the pool holds one entry.

The harness was inferred from the path although the collector knew it, so a
repository with its own .claude/ directory was labelled as the claude-code
harness and never got the working-tree demotion. Excerpts carry the
collector's answer now. And .txt no longer counts as documentation: a scratch
notes.txt is the likeliest place for a pasted key, and demoting it quietened
exactly the finding worth shouting about.

The sixth was found by running the fixes: editing two heuristics made 167
findings report as "no longer on disk" when nothing had moved. Reaping
compares against what a previous sweep recorded, so it is only meaningful
when both used the same ruleset. Sweeps now record a ruleset digest and stand
the reap down when it changes. The 172 rows falsely marked gone on this
machine have been cleared.

While confirming the transcript fix on real data, MAX_PROMPT_TOKENS turned
out to be a whole false-positive class the relaxation exposed. A suffix must
now start with an underscore, so plural names — counts and collections — no
longer match while SECRET_KEY_ID still does.

Measured on roosta: 743 new findings, 192 of them in claude transcripts that
were previously invisible, including 13 OpenAI keys.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XAqHWfdMAsYu1o36tgeima
2026-09-03 14:45:29 +03:00
bee34efbb3 feat: reap findings whose location no longer holds the secret
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
2026-09-02 18:55:40 +03:00
8c55ab2e20 feat: scoped severity downgrades, and refuse suppressing a marker fingerprint
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
2026-09-02 17:20:13 +03:00
0232c78f01 fix(cli): print the demotion reason and the --everywhere hint in show
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
2026-09-02 16:24:15 +03:00
467c8d35ad feat: severity depends on where a finding is, and judgements are remembered
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
2026-09-02 16:21:22 +03:00
04699f56ed fix(daemon): stop recursively watching $HOME and every working tree
Both the shell collector and the working-tree collector handed the watcher
their root directory, because that is what the port asked for. Rooted at
$HOME and ~/git, that asks the kernel for one inotify watch descriptor per
directory the user owns: 342 MB resident on this machine, plus a permanent
stream of permission denials from podman volume storage.

Neither watch bought anything. The shell history files that matter sit
directly in $HOME, and working trees are swept periodically precisely
because walking them is expensive — so every event they raised was
discarded.

watch_roots now returns WatchRoot { path, recursive }. A Periodic collector
registers no watch at all; the shell collector watches $HOME shallowly.
342 MB -> 49 MB, and the denials are gone. A test pins the policy, since the
regression is invisible in any fixture small enough to be a unit test.

Also moves StartLimitIntervalSec/StartLimitBurst to [Unit], where systemd
actually reads them — `systemd-analyze --user verify` was warning that it
was ignoring them in [Service].

Verified live: installed to ~/.local/bin, enabled as a systemd --user unit,
baseline swept 60849 sources / 977 MB, and a synthetic spill produced a
desktop notification.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XAqHWfdMAsYu1o36tgeima
2026-09-02 15:31:28 +03:00
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
510f856174 fix: make cursors round-trip, and stop re-walking trees on every event
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
2026-09-02 15:19:35 +03:00
991ab992da docs: record the rotation seam and the exclusion evidence
Adds doc/rotation.md: what is already in place for optional rotation
(RotationHint, Status::Rotated, fingerprint-as-unit-of-work), what it would
take, and the four constraints a future executor does not get to relax —
chiefly that nanny holding provisioning credentials is the real cost of the
feature and must be opt-in per service.

Broadens the noise exclusions after a first sweep of a real ~/git produced
1030 working-tree findings, 273 of them from one crate's vendored
target/container/cargo/registry. Excluding all of target/ rather than just
debug/ and release/, plus vendored dependencies, build output, tool caches
and minified assets. .git/config stays readable: a password in a remote URL
is a genuine spill.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XAqHWfdMAsYu1o36tgeima
2026-09-02 15:13:30 +03:00
7ff8f5c8fa feat: watch local agent harnesses for leaked credentials
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
2026-09-02 15:11:53 +03:00