Secret scanning

The default-on plugin, the three-way prompt, and the escapes.

Written By Dustin

Last updated 28 minutes ago

A policy encrypts what you told it to protect. Nothing detects what you forgot — the API key pasted into a config file on a Friday. So TOVIO ships a scanner that is on in every repo by default, with no install and no explicit binding: com.tovio.secret-scan, bound to pre-snapshot, running before the working copy is snapshotted.

It is a real plugin on the ordinary plugin machinery, not a special case bolted to the commit path. That is why it can be swapped, tightened, and audited like anything else here.

How default-on actually works

The binding is synthesized at run time, not stored: an enforcing, required, unscoped binding granting read_clear_paths over everything, injected into the effective binding set for pre-snapshot. A fresh clone scans on its first commit.

One consequence surprises people: the default scanner does not appear as a row in tovio plugin bindings, because that command lists the local binding store and the default binding was never written there. Its absence is not a sign that scanning is off.

The shipped binary carries the sandbox. A lean --no-default-features build injects no default binding at all, so it never scans and never blocks — silently. The shipped product is the fail-closed one; if you build your own CLI, check the feature set before you rely on scanning being there.

When it fires

An interactive human is prompted with three choices, and the sandbox itself applies none of them — it can only propose:

ChoiceWhat happens
Encrypt in placeThe host upgrades every flagged path to a protective read policy and the same commit's seal encrypts it. All flagged paths are applied in one atomic policy transaction, and each is escaped to a literal-matching glob so a filename containing glob metacharacters protects the actual file. If any flagged path does not end up protected, this fails closed and blocks — it never commits in the clear while claiming encryption.
BlockThe commit is refused — TVO-SECRET-001.
Allow onceThe flagged content is committed unencrypted for this commit only, on your explicit say-so, and a signed audit entry records who vouched and which fingerprints survived. Never the raw secrets.

A non-interactive context fails closed. CI, an agent commit with --token regardless of an inherited terminal, --json, --quiet, or piped stdin all block. A machine can never auto-encrypt and never auto-allow. That is the load-bearing guarantee of the whole feature.

In a keyless repo there is no recipient to seal to, so encrypt-in-place is impossible; the prompt says so and re-offers block or allow-once rather than attempting a seal that would fail.

What it looks for

The detector runs inside the sandbox, over the clear content the host marshals in. A protected path is never scanned in the clear — that boundary is the point of the feature, not an exception to it.

  • A vendored credential ruleset compiled to embedded data at build time, plus an entropy heuristic, scanned over whole file content so multi-line rules such as private keys match.
  • Any file whose base name contains .env, case-insensitively — .env.local, staging.env, prod.env.json — is flagged regardless of content. A committed .env is the headline threat, so the filename alone is enough.

Every finding carries a redacted preview and a stable fingerprint of the form SECRET:<rule>:<fingerprint>. The raw secret never appears in a finding, a message, a log, JSON error context, or an audit record.

False positives: the two escapes

  1. Inline — put tovio:allow-secret on the flagged line. It rides in the tracked file, so it is shareable and reviewable.
  2. Persistent — list the finding's fingerprint in .tovio/secrets-allow.toml. Local repo state, not a synced object, and keyed on the finding code so it survives swapping the scanner.

Both are fail-safe: a missing allowlist file suppresses nothing, and a malformed one suppresses nothing and warns. A parse error can never quietly disable a finding.

Turning it off, or swapping it

tovio config set secrets.scan offtovio config set secrets.scanner <plugin-id>tovio config set secrets.protect-policy <policy>

Opt-out is local and unsynced — each person governs their own scanning. A replacement scanner must declare pre-snapshot and request clear reads. Your own explicit binding for the scanner id suppresses the default only if it is at least as capable: enforcing, required, unfiltered, and granting clear reads over everything. A weaker or content-blind binding does not suppress it; both simply run.

Tightening the limits

Owner caps only ever tighten the always-on ceilings, never raise them, and the most restrictive value wins.

Config keyEnvironment override
plugins.max-timeout-msTOVIO_PLUGIN_MAX_TIMEOUT_MS
plugins.max-fuelTOVIO_PLUGIN_MAX_FUEL
plugins.max-memory-mbTOVIO_PLUGIN_MAX_MEMORY_MB

Session capture

Captured agent sessions run a mandatory secret scan of their own — agent prompts routinely contain pasted keys and .env fragments. A capture that fails is refused with TVO-PROV-007 rather than stored.

It is not a guarantee

Scanning is pattern-based. It catches common credential shapes, not everything, and it is not a substitute for policy on paths you already know are sensitive.

The wrong response to a finding is to allow-once and clean up later. Once a secret is in history in the clear, removing it needs tovio obliterate — and if it was pushed, the credential is exposed and must be rotated regardless of what you do to the history.