Skip to content

Keeping credentials out of coding-agent transcripts

A coding agent that operates your own infrastructure will, sooner or later, grep a token out of a config file and paste it into the session transcript. This is the design that stopped that on a self-hosted fleet, for two agent harnesses, after pattern-based detection failed on the exact line that leaked. It is for anyone running an LLM agent with shell access against machines that hold real credentials.

Everything below was measured on 2026-09-04 on one developer workstation (WSL2) plus two Docker hosts and an edge router reached over ssh. Counts are from the tools’ own output on that day; the scanner comparison is a single synthetic fixture, not a benchmark. Nothing here was verified on anyone else’s setup. Rows dated 2026-09-29 were read the same way on the same workstation: the systemd journal, unit properties, and counts over the live digest cache with jq. No value, salt or digest was printed; only counts.

  • Two leaks in one day, both in the same shape: a value read from a store the agent had a legitimate reason to touch, printed into the transcript, then retyped by the model in its own words. Neither value had a recognisable format.
  • On a five-line fixture with labelled secrets, gitleaks (this build has no generic rule) found 0 of 5, trufflehog found 0 of 5, noseyparker found 2 of 5 and missed the TOKEN=<48 hex chars> line that leaked. Format and entropy rules cannot tell a token from a commit hash.
  • What is knowable is where the stores are. A registry lists them; a tool resolves every value in them and hands the guards a keyed HMAC per value, never the value. The guards tokenise text, hash each candidate, and compare.
  • Remote stores stay remote: the far host hashes its own container environments and config files and returns digests only. Every running container on a host is one ssh round trip.
  • The layers, in the order a leak would have to pass them: block the read of a registered store or of a file containing a registered value; refuse a registered value typed into any tool argument; mask registered values in tool output; mask them in the model’s own message before it is persisted; refuse two or more pieces of a value; treat base64 and percent-encoded spellings as the value.
  • On 2026-09-04, on this fleet: 37 local stores and 57 containers on two hosts yield 483 digests in 8 seconds, plus 134 configuration keys excluded so that a timezone string is not masked as a secret.
  • On 2026-09-29: 113 stores (42 registry lines) and 49 containers on two hosts yield 402 digests in about 9 seconds, 0 unresolved, with 386 configuration keys excluded.
  • On 2026-09-29 the digest cache did not exist: the timer meant to refresh it could not find the binary under the user service manager (31 runs logged command not found between 2026-09-24 and 2026-09-29). The known-value layer failed open wherever no cache existed; the unit reported success and nothing alarmed. The fix sets PATH in the unit and makes an empty pass fail it.
  • A nightly sweep finds secret-looking files the registry does not cover. Its first run found five real unregistered stores, including the AWS credentials file.
  • A value minted during a session is unknown to the registry until its store is registered. Capture it to a file or variable, use it by reference, register the store.
  • Since 2026-10-01 vault values are injected per command (sx NAME -- cmd, reading through rbw) instead of exported into every shell, so an agent process inherits two vault values, not about 22.

The two incidents that motivated this, in one day:

StepIncident 1Incident 2
TriggerUser asks where a service token lives, adds “do not print it”Agent prepares a router reflash and checks the wireless config over ssh
ReadAgent runs its grep tool over the config directoryAgent runs uci show wireless on the router
ExposureTool output contains TOKEN=<48 hex chars>Tool output contains two key='...' lines
AmplificationAgent summarises the location, value already in transcriptAgent asks “which key do the plugs hold, <value>?” - retypes it
Durable copiesSession log on disk, synced to a searchable storeSame

Three properties of that table shaped the design. The values had no prefix or format, so nothing shaped like ghp_ or AKIA applied. The second exposure was the model’s own prose, which a tool-output filter never sees. And the read was legitimate: the agent was supposed to look at that config, so a guard that only asks “is this file secret-shaped” would have had to say yes to every .env on the machine and still miss the router.

The mature scanners all gate a candidate on a nearby keyword and then apply an entropy or randomness test. gitleaks evaluates keyword, then regex, then Shannon entropy on the captured group, and its one format-free rule (generic-api-key) requires a word like key, token or secret within a short window before the value12. detect-secrets ships hex and base64 high-entropy plugins with limits of 3.0 and 4.5 bits and a keyword detector that ignores the value entirely34. ripsecrets runs a randomness test on the value of an assignment whose name looks secret5. trufflehog treats entropy as a post-filter and relies on per-provider detectors that verify a candidate against the provider’s API; unstructured values need a custom detector6.

Each of those is a reasonable trade for scanning a stranger’s repository. For an agent reading your own machine they fail in a specific way: the false positive class is everything else in the transcript. Commit hashes, container IDs, UUIDs, base64 blobs and minified assets all pass an entropy test; a 40-character hex token and a 40-character git SHA are the same string to a regex. Tightening the keyword window cuts the false positives and also cuts the incident above, where the value appeared after a tab in grep output, not after KEY= in a file.

The CI vendors that hold the line in practice do not pattern-match at all. GitHub Actions masks the exact values registered with ::add-mask:: or declared as secrets, plus encoded variants789. GitLab replaces masked variables by exact substring10. Buildkite redacts the values of environment variables whose names match a glob11. 1Password’s op run conceals the values it injected from stdout and stderr12. All four work from the known values.

The difference for an agent is that the values are not in its process. They are in dotenv files, sops-encrypted files, key files, and the environments of containers on other hosts.

where secrets liveguards (two harnesses)local storesdotenv, sops, key filesregistrysources file: globs, exclusionsremote storescontainer envs (registered);env files, router uci (supported)hashed on the far hostvault itemsname globssecretctl digests --jsonsalt + HMAC per valuefragments + encoded formsdigest cacheRAM file, 060010-min timer + guard refreshbefore a tool runsblock store / copy readrefuse typed value or piecesafter a tool runsmask registered valuesin outputbefore a message persistsmask the model's own textsecretctl coveragenightly: what is NOT registered?register or delete

In words:

  1. A registry file lists the stores: local dotenv, sops and key files with globs, and remote stores as docker:HOST/*#* (every running container on a host), sshenv:HOST/path#* (a remote env file) or uci:HOST/config#* (an OpenWrt config), and vault items as rbw:NAME or bw:NAME with name globs. On this fleet on 2026-09-29 the registered remote stores are two host-wide docker: lines; sshenv: and uci: are supported and unregistered. exclude lines name configuration keys that are never credentials.
  2. secretctl digests --json resolves every value in every store and emits, per value, a keyed HMAC under a per-run salt, plus digests of its sliding 8-byte windows and of its base64 and percent-encoded spellings. Remote values are hashed on the far host by a shell loop that returns KEY <bytes> <hex> lines; their plaintext never crosses ssh.
  3. A systemd user timer runs the pass every 10 minutes into a RAM-backed cache file; the guards load that JSON, tokenise any text they see, hash each candidate under the same salt, and look it up. A hit is a registered value.
  4. A nightly sweep asks the opposite question: which secret-looking files does the registry not cover.
SituationLayerResult
Agent reads a registered store (cat, read, grep)pre-tool blockdenied, reason names the store and the sanctioned commands
Agent reads a file that merely contains a registered value (a compose file with a pasted secret, a dump)pre-tool blockdenied as a copy
Agent types a registered value into a command, a file body or an editpre-tool refusaldenied; the fix is \$VAR or secretctl exec
A registered value appears in tool output anyway (grep over a directory, docker inspect, curl -v)output maskvalue replaced, label kept
The model retypes a value in its own reply or thinkingmessage maskmasked before the message is persisted or replayed
Two or more 8-byte pieces of a value in one command or messagefragment matchrefused or masked as an assembly
A value base64- or percent-encoded in output or a commandencoded variantstreated as the value
A registered value in the harness’s own environmentexact matchmasked (this layer predates the registry)
A plaintext pipeline such as sops -d file | grep KEY or docker exec C envpre-tool block (primary harness only)denied; the reason points at secretctl cmp, fp, exec

The two harnesses differ in what their hooks can change. The primary harness exposes a hook that can replace a tool result and a hook that can replace a finalised message, so every layer above applies. The second harness exposes a pre-tool hook that can deny and a post-tool hook that can only append context, so it gets the deny layers and an alarm that names the label of a value that has already reached the transcript. Deny-before plus alarm-after is the ceiling there until the harness can mutate a result.

Both harnesses share one detection core, so a rule cannot exist in one and not the other. The second harness’s hook is a fresh process per tool call and a digests pass takes seconds because of the ssh round trips, so both harnesses read a 0600 file under \$XDG_RUNTIME_DIR that a systemd user timer rewrites every 10 minutes (on macOS, under the per-user temp directory that getconf DARWIN_USER_TEMP_DIR names, rewritten by a launchd agent); the file holds the salt and digests, no values. Both guards can also write it, under the same lock, described in the next section. When no digests are available, the second harness’s hook fails open: the known-value checks are skipped and the env-dump denial and format alarm still run. Kill switches: PI_SECRET_GUARD_OFF=1 (both harnesses) and SECRET_GUARD_OFF=1 (the second harness’s hook).

Three processes can write the cache file. Since 2026-09-29 they share one protocol: take flock on digests.lock, never unlink it, write to a temp file, publish by atomic rename, and publish only on exit 0 or 2 (2 = some store unresolved) with a complete JSON object as output, so a truncated write never replaces a good cache. A writer that cannot get the lock skips, because the holder is already refreshing; readers never need the lock.

macOS has no flock(1). Since 2026-10-12 every writer there takes the same digests.lock with the BSD lockf(1) in file-descriptor mode instead: lockf -s -t 0 where Linux uses flock -n, -t N for flock -w N, and a busy lock exits 75 like flock. The launchd agent that stands in for the timer takes -t 0. The kernel drops the lock when the holder dies, so a crash leaves no stale lock (measured on macOS 27).

WriterWhen it writesHow it takes digests.lock
systemd user timerevery 10 min (plus up to 1 min random delay); 2 min after bootflock -n; a running pass makes the next one skip
second harness’s hook, first loadcache missing (a fresh process per tool call)flock -w 40: waits for an in-flight pass, then reads what it published; runs its own pass only if still nothing exists
second harness’s hook, self-healcache older than 2x TTL (20 min)detached flock -n pass that re-checks freshness under the lock
primary harness’s hook, self-healcache missing or older than 2x TTLflock -n with the same freshness re-check
primary harness’s hook, after secretctl setonce the command has returnedflock -w 60, no freshness skip: a timer pass already running may have read the store before the write

Since 2026-09-23 a vault item is a registrable store: rbw:NAME through the rbw client, bw:NAME through a local bw serve. One item is one value, its notes body. A glob such as rbw:PREFIX_* expands through the client’s name-only listing, so no value is read to decide what matches. Each item is a separate client round trip: a 12-item glob digested in 3,360 ms, and a glob over the whole vault would make every refresh take about 6 minutes, so registering the whole vault is opt-in. rbw: is read-only to secretctl set.

Since 2026-10-01 no vault value sits in an interactive shell’s environment on the dev box. Before that, shell start exported about 22 vault values from a one-hour RAM snapshot, so every agent process and every child it spawned inherited all of them, and the first shell after the snapshot expired paid a foreground vault read of about 5 s. Now a value enters one process when that process needs it, through a small wrapper, sx:

Terminal window
sx COMPOSER_API_KEY -- sh -c 'curl -H "X-API-Key: $COMPOSER_API_KEY" https://composer.example/api/v1/stacks'
sx CLOUDFLARE_API_TOKEN=CLOUDFLARE_TOKEN -- sh -c 'curl -s https://api.cloudflare.com/client/v4/user/tokens/verify -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN"'
sx -l # the environment-name -> vault-item map, names only

The single quotes matter: the variable expands in the child, where it exists, not in the calling shell, where it does not. Three components share the work:

  • rbw holds the unlocked vault. Its background agent keeps the decrypted keys in memory, the way ssh-agent holds keys15; with lock_timeout set to 86400 s here (the default is 3600) one unlock covers a day of reads, and each rbw get is a round trip to the agent’s local socket.
  • sx maps an environment name to a vault item through one table in the shell config (EXA_API_KEY reads the item EXA_SEARCH_API_KEY; NAME=ITEM names an item directly), reads the items with concurrent rbw get calls, exports them in its own process only, and replaces itself with the command. No value goes to stdout or stderr, and nothing outlives the child. Measured 2026-10-01: shell start 0.26-0.29 s, sx with one item 0.64 s, with five 1.10 s; the agent serialises most of the parallel reads.
  • secretctl hands no values out on this path; it supplies the guard. Every item in the sx map is also registered as an rbw: store, so a value that an injected command prints anyway is masked in output like any other registered value. secretctl exec SOURCE --as VAR -- cmd is the general form of the same handoff for any scheme (a sops file, a dotenv key, a vault item by full reference); sx is the shorthand for vault items by environment name.

Three commands still receive values without sx at the call site: the two agent harnesses get the search-API key and the research-stack token, and the forge CLI gets its token, through shell functions that call sx for them. With the vault locked those run without the values and print a warning rather than block.

Each of these is fixed, and each was found by using the tool rather than by reasoning about it.

  • The tool-argument layer ran after the message layer. The message hook masked the value inside a pending tool call, the harness executed the masked command, and the tool-argument layer saw nothing to refuse. Fix: the message layer remembers the call id and the tool layer refuses it with the real reason.
  • The model routed around the whole-value block. Asked to run a command containing a value, and refused, it assembled the value at runtime from 8-character chunks with printf. Fragment digests were added for exactly this. The deny reason now names that assembly as a policy violation.
  • Fragments of word-shaped values are words. A registered value that was a comma-separated list of repository names produced windows like secretct and dotfiles, and the guard refused an ordinary command for mentioning two repositories. Fragments are now emitted only for opaque tokens (letters and digits, no list or URL punctuation) and a piece must be chunk-shaped and fully covered by one value’s windows.
  • Configuration is not credentials. Container environments hold TZ, LANG, EMAIL, POSTGRES_DB, hostnames and usernames alongside secrets. Digesting them masked a timezone string and blocked a command for mentioning a database name in a comment. The registry’s exclude directive removed 134 such keys on this fleet; the failure mode of forgetting one is over-redaction, never a leak.
  • The refresh herd. Serial per-store resolution made a pass take minutes, and every guard process re-ran it on its own 10-minute TTL. On 2026-09-23 that reached 130 concurrent passes and a load average of 21. Stores now resolve on a pool of 8 workers (--jobs), a solo pass measured 18.6 s, and the guards take a single-flight lock.
  • A guard that failed open silently. The timer added for the herd fix was not writing the cache: the user service manager’s PATH was /usr/local/bin:/usr/bin and the binary lives in ~/.local/bin. The old ExecStart ended in || rm -f tmp, so when the digests run produced nothing the rm succeeded, the script exited 0, and systemd logged every run as finished. 31 runs in the journal’s 8-day window logged secretctl: command not found. Wherever no cache existed, the known-value layer was skipped; the env-dump denial and format alarm kept working. Fixed 2026-09-29 with Environment=PATH= in the unit and a publish rule that fails the unit on empty output. A guard that fails open needs a liveness signal of its own; one was added the same day (see the next section).
  • Identifiers are not credentials either. A vault item that holds an identifier rather than a credential, registered as if it were a credential, makes the second harness’s output alarm fire on ordinary output that contains it. The registry’s exclude directive matches configuration keys in an environment, and a vault item’s key is its field (notes), so a bare pattern could exclude every vault item or none. Fixed 2026-09-29: a pattern with a scheme prefix, such as exclude rbw:NAME#*, matches the entry’s label instead, which names one item.
  • A private RAM file for the digests. A per-call hook cannot afford an 8-second resolve, so it reads a stale cache that refreshes in the background, and only the first-ever call pays the full resolve. As of 2026-09-29 a systemd timer is the routine writer; a hook call that finds no cache waits up to 40 s for a pass in flight and pays the synchronous resolve only if nothing was published.
  • Three writers, three lock schemes. Until 2026-09-29 the timer flocked digests.lock; the second harness’s hook created the same path with O_EXCL on first load and never removed it, and its stale refresh flocked a different file, digests.json.lock, so it could overlap a timer pass; the primary harness’s hook used an O_EXCL PID marker on digests.lock, which could never succeed once the timer’s empty lock file existed, so its self-heal and its forced refresh after a rotation never ran. The forced refresh also fired before secretctl set had run. The second harness’s stale refresh used a 10-minute TTL equal to the timer’s period, so it fired about a minute before the timer’s pass. All three now use the one protocol above, the hooks refresh only past 2x TTL, and the forced refresh fires after the command returns.
  • The cache directory resolved to a relative path. On 2026-10-06, on a macOS machine, one guard call wrote its missing-cache marker and then a freshly resolved digests.json into secret-guard/ inside the repository it was running in, a public repository’s working tree, while the real cache sat untouched in the temp directory. The resolver took SECRET_GUARD_CACHE_DIR ?? ...; ?? keeps an empty string, and joining an empty base gives a path relative to the caller’s working directory. The macOS fallback had the same hole when getconf printed nothing or TMPDIR was empty. Which empty value fired that day was not established. For that call the known-value layer reported the cache missing, so it failed open as designed; the directory was kept out of git and deleted. Fixed the same day: a blank or relative base counts as unset, so every resolved directory is absolute, with test cases for each shape.
FigureValueHow it was checked
Local stores registered37 filessecretctl sources
Containers covered57 across two hostssecretctl digests --json, labels grouped by host
Digests emitted483same JSON, entries length
Configuration keys excluded134same JSON, skipped_excluded
Values with fragment digests120same JSON, opaque tokens only
Values with encoded variants222same JSON
Full pass, both hosts8.0 stime secretctl digests --json
One host, 8 containers, plus a local sops file3.1 ssame, temporary registry
Same credential, remote container vs local sops fileidentical digest under one saltdigests --json --salt-file, two labels, one hex
Fixture scan: gitleaks / trufflehog / noseyparker0 / 0 / 2 of 5 lineseach tool on one synthetic five-line file
Coverage sweep, first run280 flagged, 70 covered, 210 uncoveredsecretctl coverage before noise filters
Coverage sweep, after filters58 flagged, 35 covered, 23 uncoveredsame, with vendored code, fixtures, docs and templates ignored
Real stores the sweep found unregistered5manual triage of the first run
Stores registered (2026-09-29)113 (42 registry lines: sops, keyfile, dotenv, host-wide docker and vault-item stores)secretctl sources
Containers covered (2026-09-29)49 across two hostssame JSON, labels grouped by host
Digests emitted (2026-09-29)402, unresolved 0same JSON, entries and unresolved length
Configuration keys excluded (2026-09-29)386 (103 patterns)same JSON, skipped_excluded, exclude length
Values skipped as shorter than 8 bytes (2026-09-29)249same JSON, skipped_short
Values with fragment digests (2026-09-29)155 (8-byte window)same JSON, fragments
Values with encoded variants (2026-09-29)236same JSON, variants
Full pass via the timer (2026-09-29)9.1-9.3 s wall, 13.9-14.1 s CPU, peak 375 MB then 290 MBsystemd properties, journal Consumed lines
Timer pass wall time, all 9 passes on 2026-09-299.1 s to 31.8 sjournal Consumed ... wall clock lines
Solo pass after --jobs (2026-09-23)18.6 s (9.1 s user)commit message measurement
12-item vault glob (2026-09-23)3,360 msrecorded in the tool’s documentation
Timer runs logging command not found (8-day window)31 of 377, 2026-09-24 09:20 to 2026-09-29 05:37 (+08)journalctl --user -u secretctl-digests.service --since '-8 days' | rg -c 'command not found'
Synthetic verification (2026-09-29)real cache: store read denied, env dump denied, unregistered file allowed. Canary registry: typed value, base64 spelling and a value split across two chunks all denied; value in tool output raised the alarm; store read denied; unrelated random hex allowedsynthetic tool calls fed to the hook script

The scanner row rests on one fixture and one build of each tool, and the gitleaks binary on this machine ships without the generic-api-key rule, so the row describes that installation rather than gitleaks in general. It is in the table because it is the observation that ended the pattern approach.

  • Pieces shorter than 8 characters, or a value that only exists after a shell runs (a variable built from substrings, a decryption the agent triggers). The deny reason says that doing this deliberately is a policy violation; it cannot stop a model that decides to.
  • A value embedded mid-way inside a larger base64 blob at a non-zero alignment. The encoded variants cover the value encoded on its own.
  • Anything in a store nobody registered. The nightly sweep exists to shrink this, and its output is a triage list that someone still has to act on.
  • In the harness that cannot mutate results, a value that reaches output is in the transcript; the alarm is a rotation prompt.
  • Remote formats that are not key-value lines. uci show was added because it leaked; the next format will need its own extractor. The uci: scheme exists but no uci: store is registered on this fleet as of 2026-09-29.
  • A value minted during the session (2026-09-20: a generated forge token echoed into the transcript). The registry cannot know it until its store is registered: capture it to a file or variable, use it by reference, register the store.
  • A missing digest cache. A first load waits up to 40 s for a pass in flight; if nothing is published by then, both hooks run without the known-value layer, with no notice.
  • A rotation made outside the primary harness. Only that harness forces a refresh after secretctl set (waiting up to 60 s for the lock); a value rotated from anywhere else is unknown until the next timer pass, up to about 11 minutes.

Since 2026-09-29 both guards report the cache’s health instead of failing open silently. Each call classifies the cache as ok, stale (older than 2x the 10-minute TTL), missing, or corrupt (not a complete JSON object). On any state but ok, the second harness’s hook adds a one-line warning to the tool call - appended to the reason when it also denies - at most once per 10 minutes per state, tracked by a marker file’s mtime because the hook is a fresh process per call; the primary harness raises the same warning through its existing notice once per state change. The layer still fails open; the warning makes that visible. Checked 2026-09-29 by feeding a synthetic call to the hook with an empty scratch cache directory: the first call carried “known-value layer OFF (cache missing)”, the second within the window carried nothing.

The second harness cancels a command hook that reaches its timeout and lets the tool call through (its documented behaviour), so a hook timeout is a fail-open path too. Its documented default for command hooks is 600 s; since 2026-09-29 the guard’s entries set 150 s, above the worst case of a 40 s first-load wait plus a 90 s pass, so a hung hook cannot stall a call for ten minutes.

You haveDo this
An agent harness with a result-mutating hookall layers; register stores as you create them
A harness with deny-only hooksthe deny layers plus the alarm; treat an alarm as “rotate now”
Secrets only in the agent’s own environmentthe exact-match layer alone is enough
Vault secrets the shell exports into every processstop exporting; inject per command (sx or secretctl exec) and register the vault items
Secrets on other hostsregister them as remote stores; never add a fetch-the-plaintext path
Container environments that mix config and secretsregister the host, then exclude the configuration keys the first time they mask something
A pile of .env files of unknown statusrun the coverage sweep and register or encrypt what it lists
A guard that fails open when its data is missinggive the data source its own liveness check; a unit that exits 0 on an empty pass is dead config
A token you just generatedwrite it to a file or variable, use it by reference, register the store before the next step

The session store the transcripts sync to, and the reason a leaked value is durable, is described in A cross-client memory store for coding agents. The container hosts whose environments are registered here are the ones laid out in Docker Servarr stack security. The sibling guards over the same hook machinery - provenance for specifics, AI prose tells, smart punctuation, confidential identifiers, mutating git/gh commands, and the stow-link failure that kept one of them from ever loading - are in Guard extensions that enforce prose and process rules in a coding agent.

  1. gitleaks, “detect.go,” GitHub. https://github.com/gitleaks/gitleaks/blob/master/detect/detect.go ↩

  2. gitleaks, “generic.go (generic-api-key rule),” GitHub. https://github.com/gitleaks/gitleaks/blob/master/cmd/generate/config/rules/generic.go ↩

  3. Yelp, “high_entropy_strings.py,” detect-secrets, GitHub. https://github.com/Yelp/detect-secrets/blob/master/detect_secrets/plugins/high_entropy_strings.py ↩

  4. Yelp, “keyword.py,” detect-secrets, GitHub. https://github.com/Yelp/detect-secrets/blob/master/detect_secrets/plugins/keyword.py ↩

  5. sirwart, “ripsecrets README,” GitHub. https://github.com/sirwart/ripsecrets/blob/main/README.md ↩

  6. Truffle Security, “Custom detectors,” trufflehog, GitHub. https://github.com/trufflesecurity/trufflehog/blob/main/pkg/custom_detectors/CUSTOM_DETECTORS.md ↩

  7. GitHub, “Workflow commands for GitHub Actions,” GitHub Docs. https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands ↩

  8. GitHub, “SecretMasker.cs,” actions/runner, GitHub. https://github.com/actions/runner/blob/main/src/Sdk/DTLogging/Logging/SecretMasker.cs ↩

  9. GitHub, “ValueEncoders.cs,” actions/runner, GitHub. https://github.com/actions/runner/blob/main/src/Sdk/DTLogging/Logging/ValueEncoders.cs ↩

  10. GitLab, “Mask a CI/CD variable,” GitLab Docs. https://docs.gitlab.com/ci/variables/#mask-a-cicd-variable ↩

  11. Buildkite, “redact.go,” buildkite/agent, GitHub. https://github.com/buildkite/agent/blob/main/internal/redact/redact.go ↩

  12. 1Password, “op run,” 1Password Developer. https://www.1password.dev/cli/reference/commands/run/ ↩

  13. Linux man-pages project, “flock(2) - Linux manual page,” man7.org. https://man7.org/linux/man-pages/man2/flock.2.html ↩

  14. Linux man-pages project, “open(2) - Linux manual page,” man7.org. https://man7.org/linux/man-pages/man2/open.2.html ↩

  15. J. Luehrs, “rbw,” GitHub. https://github.com/doy/rbw ↩