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.
| Component | Version at time of writing |
|---|---|
| secretctl | built from commit cc242ca |
| noseyparker | 0.24.0, used for the Part 4 worked example |
| dotfiles (digest-cache-core.ts, the systemd units) | commit 9a0f672 |
Architecture
Section titled “Architecture”Text fallback for the diagram:
- The registry file declares stores and
excludepatterns. secretctl sourcesshows what it expands to, as a dry run with no digests computed.secretctl digests --jsonresolves every store into a keyed digest.- A digest cache file, written by a systemd timer and self-healed by two agent-harness hooks, is what the guards actually read.
- Separately, noseyparker scans for secret-looking files;
secretctl coverageclassifies every hit against the same registry. - An uncovered hit means register the store or delete the copy - the loop back into step 1.
Part 1: declare the stores
Section titled “Part 1: declare the stores”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:
| Scheme | Registers | Globs |
|---|---|---|
dotenv:PATH#KEY | a quote-aware KEY=VALUE file | yes, including ** |
sops:PATH#KEY | a sops-encrypted file, decrypted in-process | yes, same as dotenv: |
keyfile:PATH | a whole file as one opaque value | yes |
env:NAME | this process’s own environment | no |
docker:[HOST/]CONTAINER#VAR | one container’s env var, or docker:HOST/*#* for every running container on a host in one ssh round trip | only the HOST/* shape |
sshenv:HOST/PATH#KEY | one key in a remote KEY=VALUE file | no |
uci:HOST/CONFIG#SECTION.OPTION | one OpenWrt UCI option, or #* for every option in that config | no |
bw:ITEM[#FIELD] | a Bitwarden item via bw serve, whole item or a name glob | via client name-only listing |
rbw:ITEM[#FIELD] | a Bitwarden item via the rbw CLI, same shape; read-only to set | via 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:
cat > canary.env <<'EOF'CANARY_TOKEN=demo-9f2c7a1e4b6d8035FAKE_TZ=Asia/SingaporeEOFopenssl rand -hex 16 > canary.keycat > registry-demo-sources <<'EOF'dotenv:.../canary.envkeyfile:.../canary.keyEOFFAKE_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.
Part 2: exclude configuration keys
Section titled “Part 2: exclude configuration keys”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:
cat >> registry-demo-sources <<'EOF'exclude FAKE_TZEOFA 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 storeexclude rbw:ITEM_NAME#* # label-shaped, matches one vault item onlyPart 3: check the expansion with sources
Section titled “Part 3: check the expansion with sources”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 sourcesregistry: .../registry-demo-sources
line 1 dotenv:.../canary.env#* -> 1 store(s) dotenv:.../canary.env#* line 2 keyfile:.../canary.key#* -> 1 store(s) keyfile:.../canary.keyTwo 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.
mkdir uncovered-dirTOK=$(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:
- Take
flock(1)ondigests.lockin the cache directory. Useflock -n(skip immediately if held) for a routine or self-heal pass; useflock -w Nonly when the caller needs current data right now and can afford to wait. - 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. - 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.
- Run
secretctl digests --jsoninto a temporary file in the same directory (fd 9, the lock’s file descriptor, closed first with9>&-so nothing the pass leaves running - an ssh control master, an agent - inherits it), then publish by atomic rename ontodigests.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.
Verification
Section titled “Verification”| Check | How | Expected |
|---|---|---|
| Registry parses | secretctl sources | one expansion line per registry entry, no parse error |
| Excluded keys are actually skipped | secretctl digests --json, skipped_excluded count | increases by the number of excluded keys present in your stores |
| One vault item can be carved out of a glob | add exclude rbw:ITEM#*, re-run sources/digests | only that item’s entry disappears; its siblings under the same glob are unaffected |
| Coverage sees what you expect | secretctl coverage --root DIR | flagged count includes every file you know holds a credential-shaped value |
| A newly registered store drops out of Uncovered | add the registry line, re-run coverage | the path that was Uncovered now counts toward Covered |
| The cache a consumer reads is current | check digests.json’s mtime | younger than 20 minutes (2x the 10-minute TTL) under normal operation |
Gotchas and lessons learned
Section titled “Gotchas and lessons learned”- The nightly coverage timer is not named
secretctl-coverage. It issecret-coverage.timer/secret-coverage.service-systemctl --user list-timers | grep secretctlwill 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
ExecStartended in a command that always exited 0 even whensecretctlitself was not found on the systemd user manager’sPATH- 31 of 377 logged runs failed this way, and every one of them reported success. The fix setPATHexplicitly in the unit and made the publish rule itself fail the unit on empty or incomplete output, so a dead writer now shows up insystemctl --user statusinstead 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 24value under a keyword-bearing name was. Treat a cleancoveragerun 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.
secretctltreats an absent or empty registry as a hard error rather than a silent no-op, so a typo’d--sourcespath or an unset$SECRETCTL_SOURCESfails immediately instead of running every later command against zero stores.
File reference
Section titled “File reference”| File | What it is |
|---|---|
internal/registry/registry.go | the sources-file parser, glob and ** expansion, sops/plaintext content sniff, exclude key-vs-label matching |
internal/cli/coverage.go | the noseyparker scan, report, and classify pipeline; exit codes; markdown and JSON report rendering |
AGENTS.md | secretctl’s own design-invariants doc; the registry file-format section this guide draws on |
dotfiles/.pi/agent/extensions/lib/digest-cache-core.ts | the 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 |
Related docs
Section titled “Related docs”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.