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
This commit is contained in:
2026-09-02 15:13:30 +03:00
co-authored by Claude Opus 5
parent 7ff8f5c8fa
commit 991ab992da
2 changed files with 100 additions and 1 deletions
+1 -1
View File
@@ -281,7 +281,7 @@ mod tests {
#[test]
fn severity_ranks_critical_first() {
let mut v = vec![Severity::Low, Severity::Critical, Severity::Medium];
let mut v = [Severity::Low, Severity::Critical, Severity::Medium];
v.sort_by_key(|s| s.rank());
assert_eq!(v[0], Severity::Critical);
}
+99
View File
@@ -0,0 +1,99 @@
# Rotation
**Status: design note. Not implemented.** nanny records and alerts; the operator
decides what to do. This document exists so that when optional rotation is
built, it lands on a seam that is already in the right place rather than being
retrofitted through the middle of the detection path.
## Why it is not built yet
The expensive mistake would be to build rotation before the detection is
trusted. A tool that revokes a credential on a false positive costs more than
one that misses a real leak, because the false positive breaks something that
was working and the operator learns to distrust the tool. Detection quality has
to be demonstrated on a real corpus first — and the first sweep of a real
workstation produced 1538 findings, most of them fixtures in vendored
dependencies, which is exactly the state in which automated revocation would
have been a disaster.
So the order is: detect, tune until the findings are believable, then automate
the response.
## What is already in place
**`RotationHint`**, carried by a rule rather than by a finding, because what you
do about a leaked forge token does not depend on which transcript it turned up
in:
```rust
pub struct RotationHint {
pub service: String, // "anthropic", "gitea", "aws" — the dispatch key
pub instructions: String, // what a human does, right now
pub docs: Option<String>,
pub rotation_suffices: bool, // false ⇒ incident response, not rotation
}
```
`rotation_suffices` is the field that will matter most. A leaked API key is
undone by revoking it. A leaked private key is not: the key has to be replaced
*and* the old public key removed from every trust store it reached, and no
amount of automation makes that a one-button operation. Rules where this is
`false` must never be handed to an automatic executor.
**`Status::Rotated`**, distinct from `Status::Acknowledged`. Acknowledged means
the operator has seen it and decided; rotated means the leaked value is now
worthless. Only the second closes the exposure, and the UI keeps them apart so
that a triaged backlog is never mistaken for a clean one. An executor sets
`Rotated`; nothing else should.
**The `Fingerprint`.** Because the same secret in five places shares one
fingerprint, a single rotation can resolve every finding carrying it. That is
the unit of work an executor should operate on — a secret, not a location.
## What it would take
An `Remediator` port in `nanny-core`, alongside `Alerter`, dispatching on
`RotationHint::service` to per-service implementations. Sketch:
```rust
#[async_trait]
pub trait Remediator: Send + Sync {
fn service(&self) -> &str;
/// What this would do, without doing it.
async fn plan(&self, finding: &Finding) -> Result<RemediationPlan>;
/// Do it. Only ever reached after explicit operator confirmation.
async fn execute(&self, plan: &RemediationPlan) -> Result<Remediated>;
}
```
Four constraints that follow from the rest of the design, and that a future
implementation does not get to relax:
1. **An executor cannot read the leaked value, because nothing stores it.** It
therefore cannot revoke "the credential that leaked" by presenting it. It
revokes *by identity at the service* — the key with this prefix, the token
named for this consumer — which is why `agent-credentials.md` §5's practice
of naming issued credentials after their consumer matters here.
2. **Rotation needs credentials, and nanny currently has none.** This is the
real cost of the feature: nanny becomes a process holding provisioning
credentials, which is the thing the patterns-only detection design exists to
avoid. The mitigation is the mint-don't-share pattern of
`agent-credentials.md` §2 — a narrow provisioning credential per service that
can revoke and reissue and nothing else — and it should be opt-in per
service, off by default, never a blanket capability.
3. **Never automatic.** `plan` then confirm then `execute`. A monitor that
revokes credentials on its own judgement is a denial-of-service with good
intentions.
4. **Still never edits a harness's files.** Rotating makes the leaked copy
inert, which is the correct remedy. Rewriting a live session transcript to
scrub it risks breaking the harness that is writing it, and leaves the
operator believing a copy is gone when backups, the model provider's logs and
`file-history/` still hold it.
## The thing rotation does not fix
A credential that reached a model provider's servers is gone, whatever happens
locally afterwards. Rotation is the only remedy that works, which is the whole
argument for detecting the spill in the first place — and for `nanny scan`
exiting non-zero, so it can gate something before the transcript is uploaded
rather than after.