Skip to content

Building and checking a secretctl registry

secretctl and its guards only protect what a text file names. This guide is the loop that keeps that file honest: write one line per store in Part 1, carve configuration keys out with exclude in Part 2, confirm the file expands the way you expect with secretctl sources in Part 3, and let secretctl coverage find the secret-looking files nobody registered in Part 4. Part 5 covers the other side of the same file: how the digest cache a guard actually reads gets built from it, and on what schedule, so a third consumer can wire itself up the same way. It assumes the design in Keeping credentials out of coding-agent transcripts; this guide is the how-to for keeping that design’s one input - the registry - complete.

Prerequisites: secretctl on PATH for Parts 1 through 3 and Part 5; noseyparker on PATH as well for Part 4, since coverage refuses to run without it. Every command below ran against a scratch registry and a scratch scan directory built for this guide - canary values only, generated fresh, never a real store.

ComponentVersion at time of writing
secretctlbuilt from commit cc242ca
noseyparker0.24.0, used for the Part 4 worked example
dotfiles (digest-cache-core.ts, the systemd units)commit 9a0f672
registry filestores + excludesecretctl sourcesexpansion checksecretctl digests --jsonkeyed HMAC per valuesecretctl coverageclassify every hitclassify againstdigest cachetimer + hook writersguards(two harnesses)noseyparkersecret-looking filesregister or delete

Text fallback for the diagram:

  1. The registry file declares stores and exclude patterns.
  2. secretctl sources shows what it expands to, as a dry run with no digests computed.
  3. secretctl digests --json resolves every store into a keyed digest.
  4. A digest cache file, written by a systemd timer and self-healed by two agent-harness hooks, is what the guards actually read.
  5. Separately, noseyparker scans for secret-looking files; secretctl coverage classifies every hit against the same registry.
  6. An uncovered hit means register the store or delete the copy - the loop back into step 1.

The registry file defaults to ~/.config/secretctl/sources; $SECRETCTL_SOURCES or --sources PATH overrides it, which is what every command below uses so the worked example never touches a real registry. One entry per line, scheme:pattern[#field]; blank lines and #-prefixed lines are ignored, and a trailing #comment after a URI is stripped. Loading an empty or missing file is a hard error - an empty registry silently protects nothing, and “the guard is on” must never be true while it covers zero stores.

Nine schemes are registrable:

SchemeRegistersGlobs
dotenv:PATH#KEYa quote-aware KEY=VALUE fileyes, including **
sops:PATH#KEYa sops-encrypted file, decrypted in-processyes, same as dotenv:
keyfile:PATHa whole file as one opaque valueyes
env:NAMEthis process’s own environmentno
docker:[HOST/]CONTAINER#VARone container’s env var, or docker:HOST/*#* for every running container on a host in one ssh round triponly the HOST/* shape
sshenv:HOST/PATH#KEYone key in a remote KEY=VALUE fileno
uci:HOST/CONFIG#SECTION.OPTIONone OpenWrt UCI option, or #* for every option in that configno
bw:ITEM[#FIELD]a Bitwarden item via bw serve, whole item or a name globvia client name-only listing
rbw:ITEM[#FIELD]a Bitwarden item via the rbw CLI, same shape; read-only to setvia client name-only listing

The uci: scheme exists because of a 2026-09-04 incident: uci show wireless read over ssh put two wifi keys in a transcript that no local store held a digest for. Remote schemes (docker:HOST/..., sshenv:, uci:) never send a value back to this host - the far end hashes it and only KEY <bytes> <hex> returns.

A glob that matches no file expands to nothing, silently: the same registry file is meant to work unmodified across hosts that do not all carry the same stores. A non-glob path that does not exist is kept anyway, so secretctl digests reports it UNRESOLVED - a typo in the registry stays visible instead of quietly matching zero stores. A dotenv:/sops: glob is content-sniffed per matched file (looking for sops’s own markers in the first 64KB), not by filename, so one glob written under both schemes puts every matched file in exactly one resolver, and a sops file renamed to .txt is still read as sops. A value that looks like a filesystem path (/..., ~/..., ./...) is skipped by every reader, local and remote, everywhere it appears - a location is not a credential, and digesting one would make a guard mask an ordinary path in tool output.

Build a scratch registry with one dotenv store and one keyfile store:

Terminal window
cat > canary.env <<'EOF'
CANARY_TOKEN=demo-9f2c7a1e4b6d8035
FAKE_TZ=Asia/Singapore
EOF
openssl rand -hex 16 > canary.key
cat > registry-demo-sources <<'EOF'
dotenv:.../canary.env
keyfile:.../canary.key
EOF

FAKE_TZ is written to look like the timezone strings that live alongside real credentials in almost every container environment - it is what Part 2 carves back out.

An exclude NAME [NAME...] line (globs allowed) marks a key name as configuration rather than a credential, and skips it when digesting even though it lives inside a registered store. TZ, LANG, EMAIL, and PUID are the everyday examples: they sit in the same container environment as a real password, but digesting them would make a guard mask an ordinary timezone string. The list can only reduce what gets masked - forgetting a name costs over-redaction, never a leak.

Add one to the scratch registry:

Terminal window
cat >> registry-demo-sources <<'EOF'
exclude FAKE_TZ
EOF

A bare pattern like FAKE_TZ matches the key by name, and also matches its last /- or .-delimited segment - so exclude TZ still reaches a CONTAINER/TZ-shaped key from a docker:HOST/*#* entry. That stops working for one specific shape: a vault item registered as rbw:ITEM#FIELD always resolves under the identical key notes (the item’s whole body), so a bare exclude pattern applied to a name glob like rbw:PREFIX_* can only remove every item that glob matches, or none of them - there is no bare key that names just one.

Since commit 3a0ece7 (2026-09-29), a pattern that itself looks like a scheme prefix - matching scheme: at the start, such as rbw:, bw:, or dotenv: - is matched against the registry entry’s full LABEL instead of its bare key. exclude rbw:ITEM_NAME#* then carves exactly that one item out of the rbw:PREFIX_* glob, leaving its siblings digested as normal:

exclude TZ LANG LANGUAGE EMAIL PUID # bare key, matches every store
exclude rbw:ITEM_NAME#* # label-shaped, matches one vault item only

secretctl sources expands the registry and prints labels only - no value is read to produce this output, so it is the cheap step to run before trusting digests or coverage:

$ secretctl sources
registry: .../registry-demo-sources
line 1 dotenv:.../canary.env#* -> 1 store(s)
dotenv:.../canary.env#*
line 2 keyfile:.../canary.key#* -> 1 store(s)
keyfile:.../canary.key

Two lines in, two stores out - confirms the globs matched what was intended before a single value gets touched. secretctl digests --json goes one step further and resolves every store into a keyed HMAC-SHA256, plus counts that a consumer can check without ever decoding a digest value:

$ secretctl digests --json | jq '{entries, skipped_excluded, skipped_short, unresolved}'

Against the scratch registry above (one dotenv file with one credential key and one excluded key, one keyfile), this run resolved entries: 2 (the dotenv’s CANARY_TOKEN and the whole keyfile), skipped_excluded: 1 (FAKE_TZ, from Part 2), skipped_short: 0, unresolved: 0. A registry that resolves with unresolved above zero has a typo somewhere in it - secretctl sources on the specific line usually shows which.

Part 4: sweep for what the registry does not cover

Section titled “Part 4: sweep for what the registry does not cover”

secretctl coverage answers the opposite question from sources: not “what did I remember to register”, but “what secret-looking material exists regardless”. It needs noseyparker on PATH - an optional runtime tool, like sops or docker - and exits 2 with a stated message if it is missing.

By default it scans ~/.config, ~/infra, ~/.aws, ~/.docker, ~/.netrc, ~/.pgpass, and a short list of per-agent config files, skipping any root that does not exist on the current host; --root is repeatable to scan somewhere else instead. A built-in gitignore-style ignore list excludes vendored code, module caches, virtualenvs, test and lock files, *.md, *.bak, and placeholder-shaped files (*.example, *.sample, *.template); --ignore FILE appends more patterns of the same shape. The scan runs with git history off, so an old commit that once held a value it no longer holds does not get relitigated on every sweep.

Every file noseyparker flags is then run through one registry resolution: a hit is CLEAN against the flagged path, and the classify verdict is what decides Covered versus Uncovered - HOLDS means a registered store already accounts for it, anything else lands in the Uncovered section with the rule name that flagged it. Exit codes: 0 means every flagged file is covered, 1 means uncovered files were found, 2 means a tool error (noseyparker missing, no scan root exists). A markdown report writes to $XDG_STATE_HOME/secretctl/coverage/last.md by default, with a dated copy kept under history/ for 60 days; --report - skips the file, --json gives the same information as machine output - paths and rule names only, never a matched snippet, since the decoded finding type has no field to hold one.

Worked example: a scratch directory holding one file the registry above does not know about.

Terminal window
mkdir uncovered-dir
TOK=$(openssl rand -hex 24)
printf 'API_SECRET_KEY=%s\n' "$TOK" > uncovered-dir/stray.env
secretctl coverage --root .../ --root .../uncovered-dir --report - --json \
| jq '{flagged, covered, uncovered: [.uncovered[] | {path, rules}]}'
{
"flagged": 1,
"covered": 0,
"uncovered": [
{ "path": ".../uncovered-dir/stray.env", "rules": ["Generic Secret"] }
]
}

A first attempt at this worked example used a shorter, oddly-named placeholder value instead of a fresh openssl rand token, and noseyparker flagged nothing at all (flagged: 0) - the same lesson the design reference draws from its own fixture: format and entropy rules only catch a value that looks value-shaped to begin with, and a registered store not knowing about a credential is a different failure from noseyparker’s own rules missing it.

The nightly sweep on the dev box runs as secret-coverage.timer / secret-coverage.service - note the name: it does not follow the secretctl- prefix the digests timer uses. OnCalendar=*-*-* 04:45:00, RandomizedDelaySec=20min; the service’s ExecStart runs secretctl coverage --ignore ~/.config/secretctl/coverage-ignore and declares SuccessExitStatus=1, since exit 1 - uncovered files found - is an expected sweep outcome, not a broken unit.

The first real run of this sweep on this fleet, published in the design reference, flagged 280 files, of which 70 were already covered and 210 were not; after extending the ignore list for vendored code, fixtures, docs, and sample/template files, the same sweep flagged 58, covered 35, uncovered 23. Manual triage of that smaller list found 5 real unregistered stores, including the AWS credentials file - register or delete is the report’s own guidance for each line in its Uncovered section.

Part 5: wiring a consumer to digests —json

Section titled “Part 5: wiring a consumer to digests —json”

A guard cannot afford a multi-second resolve on every tool call, so nothing reads secretctl digests --json directly at that frequency. Instead, one cache file - $XDG_RUNTIME_DIR/secret-guard/digests.json (falling back to /dev/shm/secret-guard if the runtime directory is unavailable) - holds the last successful pass, and every writer that touches it follows the same rule, whether it is the routine timer or a hook self-healing:

  1. Take flock(1) on digests.lock in the cache directory. Use flock -n (skip immediately if held) for a routine or self-heal pass; use flock -w N only when the caller needs current data right now and can afford to wait.
  2. Never unlink digests.lock - it is a rendezvous point, and unlinking it lets two writers lock two different inodes and both believe they hold the lock.
  3. Re-check freshness under the lock: the pass you waited for, or one that finished between your check and your lock, may already have published.
  4. Run secretctl digests --json into a temporary file in the same directory (fd 9, the lock’s file descriptor, closed first with 9>&- so nothing the pass leaves running - an ssh control master, an agent - inherits it), then publish by atomic rename onto digests.json. Publish only when the exit code was 0 or 2 (2 means some store failed to resolve, but the rest of the JSON is complete) AND the output is a whole JSON object: first byte {, last non-space byte }. Anything else - empty, truncated, a usage message - is discarded, so a bad pass never overwrites a good cache.

A writer that loses the lock race simply skips: the holder is already refreshing and will publish. A reader never takes the lock at all - the rename is atomic, so it sees either the old file or the new one, never a half-written one.

The routine writer is secretctl-digests.timer / secretctl-digests.service: OnBootSec=2min, OnUnitActiveSec=10min, RandomizedDelaySec=1min, always flock -n. Two agent-harness hooks share the same protocol as self-healing readers: on first load, if the cache file is missing, a hook waits up to 40 seconds (flock -w 40) for an in-flight timer pass rather than running its own; past twice the 10-minute TTL (20 minutes, meaning the timer looks dead), a hook runs a detached flock -n self-heal pass instead. One harness also forces a refresh once a command that ran secretctl set has returned, using flock -w 60 rather than a non-blocking attempt, because a timer pass already in flight may have read the old value before the write landed - skipping in its favour there would publish a stale digest as if it were current.

If you are wiring a fourth consumer onto this same cache, budget its own bounded waits against whatever hook timeout your harness enforces: one harness sets 150 seconds on its guard entries, comfortably above the worst case of a 40-second first-load wait plus a 90-second resolve pass, precisely so a hung pass cannot stall a tool call for the length of its harness’s much longer default timeout. Where flock(1) itself is unavailable (command -v flock fails, as on macOS), every writer runs unlocked instead - accepted there because no timer exists on that platform either, so there is only ever one writer at a time in practice.

CheckHowExpected
Registry parsessecretctl sourcesone expansion line per registry entry, no parse error
Excluded keys are actually skippedsecretctl digests --json, skipped_excluded countincreases by the number of excluded keys present in your stores
One vault item can be carved out of a globadd exclude rbw:ITEM#*, re-run sources/digestsonly that item’s entry disappears; its siblings under the same glob are unaffected
Coverage sees what you expectsecretctl coverage --root DIRflagged count includes every file you know holds a credential-shaped value
A newly registered store drops out of Uncoveredadd the registry line, re-run coveragethe path that was Uncovered now counts toward Covered
The cache a consumer reads is currentcheck digests.json’s mtimeyounger than 20 minutes (2x the 10-minute TTL) under normal operation
  • The nightly coverage timer is not named secretctl-coverage. It is secret-coverage.timer / secret-coverage.service - systemctl --user list-timers | grep secretctl will not find it.
  • A digest herd already happened once. On 2026-09-23, every guard process re-running a full resolve on its own TTL reached up to 130 concurrent passes and a load average of 21, with new shells taking 5 minutes to start. The fix was concurrent store resolution inside secretctl digests (--jobs, default 8) plus the one shared cache file and lock protocol in Part 5 - do not build a fifth consumer that resolves the registry on its own schedule instead of reading the cache.
  • A silently broken timer looked identical to a working one. From 2026-09-24 to 2026-09-29, the digests timer’s ExecStart ended in a command that always exited 0 even when secretctl itself was not found on the systemd user manager’s PATH - 31 of 377 logged runs failed this way, and every one of them reported success. The fix set PATH explicitly in the unit and made the publish rule itself fail the unit on empty or incomplete output, so a dead writer now shows up in systemctl --user status instead of masquerading as a healthy one.
  • noseyparker only flags what looks value-shaped. A short, plainly-named placeholder in the Part 4 worked example was not flagged at all on the first attempt; a fresh openssl rand -hex 24 value under a keyword-bearing name was. Treat a clean coverage run as “nothing noseyparker’s current rule set recognised”, not as “nothing sensitive exists” - the registry, not the scanner, is what a value actually depends on for masking.
  • A missing registry file fails loudly, on purpose. secretctl treats an absent or empty registry as a hard error rather than a silent no-op, so a typo’d --sources path or an unset $SECRETCTL_SOURCES fails immediately instead of running every later command against zero stores.
FileWhat it is
internal/registry/registry.gothe sources-file parser, glob and ** expansion, sops/plaintext content sniff, exclude key-vs-label matching
internal/cli/coverage.gothe noseyparker scan, report, and classify pipeline; exit codes; markdown and JSON report rendering
AGENTS.mdsecretctl’s own design-invariants doc; the registry file-format section this guide draws on
dotfiles/.pi/agent/extensions/lib/digest-cache-core.tsthe one writer protocol shared by the timer and both agent-harness hooks
dotfiles/.config/systemd/user/secretctl-digests.{timer,service}the digest-cache routine writer
dotfiles/.config/systemd/user/secret-coverage.{timer,service}the nightly coverage sweep

Keeping credentials out of coding-agent transcripts is the architecture this guide’s Part 4 and Part 5 build on: the guard layers the registry feeds, the digest-cache history behind the lock protocol, and the published registry-scale and coverage-sweep numbers cited above.

secretctl: stores, digests and commands is the full command and source-URI reference behind every scheme and verb used here.

Rotating a credential end to end with secretctl is the other everyday task against the same registry - use it once a value already has a registered store, rather than when the store itself is what is missing.