Part of forgectl — see the command roster.
forgectl env touches .env files without ever putting a secret value in argv, terminal output, or a session transcript: key names are always visible, values never print. It's built for agent-driven workflows — an agent can be trusted with the tool even though it can't be trusted to keep a value out of its own transcript, because the tool structurally never hands one back.
forgectl env keys [--file .env] # list KEY names only — never values
forgectl env set KEY [--file .env] [--clipboard] # value from piped stdin, no-echo prompt, or clipboard — never argv
forgectl env get KEY --clipboard [--file .env] # value to clipboard only; no print path exists
forgectl env check [--file .env] [--example .env.example] # missing/extra keys, names only
forgectl env redact [--file .env] # print file with values and comments masked ****
# --file must name an env file (.env, .env.*, *.env); --any-file overrides, TTY-confirmed only
forgectl env set a.b.key --sops [--file secrets.sops.yaml] # one key into a SOPS-encrypted YAML file
# dotted path, arbitrary depth; defaults to secrets.sops.yaml at the REPO ROOT
# requires `sops` on PATH (`forgectl doctor` reports its version)The same guarantee as the .env path, for SOPS YAML: the value arrives on piped stdin, a no-echo prompt, or --clipboard, and never enters an argv, terminal output, or a transcript.
The two obvious alternatives both fail that. sops set file '["a"]["b"]' '"value"' puts the plaintext in argv — visible in ps, left in shell history. sops file opens $EDITOR on the whole decrypted document, which is a lot of exposed plaintext to paste one line into.
How it works. sops <file> decrypts to a temp file, runs $EDITOR, and re-encrypts whatever comes back. forgectl sets EDITOR to itself (a hidden __sops-edit subcommand), passes the key path in the environment, and passes the value as a file whose path is in the environment. The value itself never enters an environment or an argv.
The diff is reviewable, deliberately. The edit is line-wise text, not a YAML round-trip: re-emitting the document would reflow every block and reorder keys, and in an encrypted file every reflowed line is a ciphertext change. Untouched values keep byte-identical ciphertext, so a replace changes 3 lines — the value plus sops' own lastmodified and mac — and an add changes 2 and removes 1.
Success means it landed encrypted. After the write, forgectl decrypts the value back and compares it byte-exactly, and re-parses the ciphertext to confirm the scalar at that exact path carries an ENC[AES256_GCM, marker. The second check is not redundant: a value stored in cleartext round-trips through a decrypt perfectly well, so a round-trip alone cannot detect it.
Target rules — both must hold, and there is no escape hatch:
- the filename matches
*.sops.yaml,*.sops.yml,*.enc.yaml,*.enc.yml,secrets.yaml,secrets.yml, orsecrets.*.yaml/.yml - the file content carries a top-level
sops:mapping
--any-file is refused with --sops rather than silently ignored. A SOPS file under some other name is unreachable — that is a deliberate refusal, not a gap: the alternative is an interactive confirmation, and the confirmation path is where a time-of-check/time-of-use defect lived. Renaming the file costs less than that surface.
What it refuses, and why refusing is the right answer:
| Refusal | Reason |
|---|---|
| A missing block, at any depth | A block forgectl invented would encrypt fine and the consumer would read nothing from it |
| A path whose key or any ancestor falls outside the file's encryption rules | sops would write the value in cleartext beside its encrypted siblings — measured live with unencrypted_suffix in force |
| A path naming a block rather than a scalar | Writing a scalar over a mapping header strands its children |
| A dotted key name | a.b.c cannot distinguish {a, b.c} from {a, b, c}; escaping is a surface for a case no estate file has |
The top-level sops block |
It holds the file's own recipients, MAC, and rules |
| A value with a newline, a C0 control byte other than tab, or invalid UTF-8 | YAML forbids these in a scalar, and the resulting unparseable document makes sops re-invoke its editor without bound |
| A document shape the line model cannot bound | A sequence where a mapping was expected, tab indentation, a multi-document stream, a header with a trailing comment — each would mis-place the key and corrupt the file silently |
A file larger than 4 MiB, or a merge key (<<) in the file's top-level mapping or in its sops: block |
The size bounds the parse (4 MiB holds about 2.8 MiB of plaintext values), and a merged-in encryption rule would otherwise go unread. Merge keys and anchors anywhere inside your values, such as <<: *defaults in a Helm values file, are fine: the value is written beside them and no merge is applied |
Out of scope: reading or listing SOPS values, creating a missing file or block, non-scalar values, and key rotation or recipient management.
Gitignore *.sops.yaml.lock. The lock helper leaves a non-secret sibling beside whatever it locked, by design.
After an interrupted write, the next one refuses. Every scratch file or directory forgectl creates beside a target carries a short hash of the target's name: .forgectl-env-<hash>-<random>/ for the atomic write's scratch directory, and .forgectl-sops-<hash>-<random>/ for the --sops work directory. Under --sops, a catchable signal (SIGINT, SIGTERM, SIGHUP, SIGQUIT, an external SIGABRT) removes the plaintext on the way out. The plain .env write has no signal handler, so any signal that kills that write can leave its scratch directory behind, and the temp file inside holds the whole new file. If the signal lands once --sops has launched the sops edit, forgectl can't tell whether sops has written the file, so forgectl also keeps the target's pre-run ciphertext beside the target as .forgectl-sops-<hash>.backup. If something already sits at that name, forgectl neither replaces nor removes it, and keeps the backup in the work directory instead, as backup, with only the .gitignore beside it. Two edge cases lose that backup. If the work directory also can't be emptied down to the backup, because a sops process keeps writing into it, forgectl removes the whole directory, backup included, rather than keep a backup beside plaintext. If an entry in the work directory can't be deleted at all, forgectl removes everything else, the backup included, and the directory stays behind holding that entry and its .gitignore, so git still ignores it and the next write still refuses on it. Either way the target may hold a value forgectl never verified, so compare the target with git. forgectl does not restore the backup itself, because a sops process may still be writing. The work directory is also sops's TMPDIR for the edit, so the decrypted copy of the whole file that sops hands its editor lives there too. sops removes that copy itself on SIGINT and SIGTERM but not on SIGHUP or SIGQUIT (measured on sops 3.13.3), so it must not sit in the system temp directory. SIGKILL, a crash, or a power loss can leave these files behind, and they can hold a secret in plaintext, inside the repository. Neither directory shows in git status: forgectl writes a .gitignore containing * into each before anything else goes in, so git add -A can't commit what a killed run leaves there, and git status doesn't list the directory either. forgectl removes that .gitignore last, and only once nothing else is left: if a file inside can't be deleted, the .gitignore and the directory stay, and git keeps ignoring them. If a file appears between that check and the directory's removal, such as a late write from a sops process, forgectl puts the .gitignore back before it gives up on the directory. The .gitignore doesn't stop git stash --all (or -a), which stashes ignored files too: it copies the directory, plaintext included, into a stash commit in .git, and removes the directory from the working tree. So the next write's check also reads what every stash entry holds of the target's directory, and refuses when a stash holds one of that target's scratch names, naming the stash (stash@{N}) and the directory. It doesn't pop or drop the stash, because the stash can also hold your other work. Don't run git stash --all while one of these directories is beside a target; if you did, pop the stash and delete the directory, or drop the stash. A dropped stash's objects stay in .git until git gc prunes them. The stash check runs only when the target is inside a git work tree and git runs there. If a repository's stashes can't be read, the write refuses. The atomic write renames its temp file out of the scratch directory onto the target, then removes the directory. The rename is atomic because the scratch directory sits inside the target's directory, so both names are on the same filesystem. Find a leftover directory with ls -a beside the target, or let the refusal below name it. The next env set on the same target finds them while holding that target's lock and refuses. The refusal names every leftover and deletes nothing. A sops process that outlived forgectl may still be using the work directory, and the leftover is the only evidence that a write died partway through. To clear it:
- Make sure no
sopsprocess is still running against the file (pgrep -fl sops). - If the refusal names a ciphertext backup (
.forgectl-sops-<hash>.backup, orbackupinside a work directory), the target may hold a value forgectl never verified. Compare the target with the backup, or with git, and restore whichever is right. Both are ciphertext, socpthe backup over the target, or rungit checkout -- <file>. - Delete the named leftovers.
If a --sops run fails and its restore fails too, the error names where the ciphertext backup was kept, either .forgectl-sops-<hash>.backup beside the target or backup inside a work directory it leaves behind. The next write refuses on it the same way.
The scan refuses on a name, not on proof that forgectl made it. A file committed to the repository under one of these names, whether by accident or on purpose, blocks every env set on that target until someone deletes it. That's accepted: the refusal names the file and removes nothing, so the cost is denial of service, never a lost value. The alternatives are worse. Exempting files git tracks would silence a real leftover once someone commits it with git add -A, and an ignore flag would be a bypass that any agent could pass. A directory forgectl can write but can't list is refused for the same reason: the scan can't run there. Scratch names lowercase the target's name, so on a case-sensitive volume .ENV and .env share them. When both exist, the refusal says the entries may belong to the other one.
Before the scratch directory, the atomic write put its temp file directly beside the target as .env-<hash>.<random>.tmp, where git status shows it. A leftover under that name still refuses the next write the same way. Leftovers from a forgectl version that predates hashed names (.env-<random>.tmp, .forgectl-sops-<digits>/) can't be tied to a target. They draw a warning on stderr rather than a refusal. Delete them by hand after checking them.
env check's exit codes are part of its contract, not incidental: exit 1 means the file and its example both exist but disagree — missing and/or extra keys (drift); exit 2 means either the env file or the --example file is absent, so no comparison could run at all. env check --json emits the drift as a single object on stdout, {"missing":[...],"extra":[...]}, for scripted callers. Under --json, stderr is empty on exit 0 and on drift. Every other failure writes exactly one error object to stderr and nothing to stdout, with no human error frame, shaped {"error":"<message>","code":"<code>","path":"<file>"}:
code |
Exit | When | path |
|---|---|---|---|
file_not_found |
2 |
the env file or the --example file is absent (error is always "env file not found") |
the missing file, relative to the repository root |
check_failed |
1 |
anything else: a refused --file/--example name, a file outside the repository, a file that won't parse, an unknown flag, a flag missing its value, or a stray argument (error is the message) |
for a refused --file/--example name, or an --any-file refusal (no interactive terminal, or the confirmation declined or failed), that file, relative to the repository root; otherwise "" (outside the repository, a parse failure, a bad flag or argument) |
A caller tells drift from a check_failed at exit 1 by where the output went: drift puts its verdict on stdout, and a failure leaves stdout empty and puts its object on stderr. This is the repo-wide --json stderr contract (json-contract.md) with env check's own code strings.
One check_failed exits 2: a config.toml that does not parse or cannot be read stops env check before it starts, as it stops every verb that reads config, and that refusal keeps its exit 2 under --json.
Blessed value producers for env set, non-inline patterns first:
op read op://vault/item/field | forgectl env set API_KEY # 1Password by composition
forgectl env set API_KEY < value.txt # from a file
forgectl env set API_KEY --clipboard # from the clipboard
forgectl env set API_KEY # interactive, no echoNever inline the secret in the producing command itself — printf 'secret' | forgectl env set KEY puts the value in that command's own argv and shell history/transcript. forgectl can't close a channel it doesn't own; the pipe's left-hand side is your responsibility, not env set's.
Residual risk — read before relying on --clipboard:
-
Clipboard contents are readable by every local process, and clipboard managers (Raycast, Maccy, Alfred, Paste) persist history to disk by default — a
get --clipboard'd secret can outlive the command that copied it. Clear it: paste over the clipboard with something innocuous, or purge the specific entry from your clipboard manager's history (each has its own delete/clear-history command). -
Accepted, not fixed: a hardlink read (
ln /outside/secret ./x.env) can read a file outside the intended tree — but creating the hardlink already implies filesystem access, so this adds nothing an attacker with that access didn't already have; the write path is neutralized (writeAtomicrenames a fresh inode, so a pre-existing hardlink to the target never receives the new content). -
The resolve-to-write TOCTOU was accepted in v1 and is now closed. The original ruling — "openat-style hardening is overkill for a local, single-operator CLI" — rested on the wrong threat: the attacker is not another operator, it is a cloned repository, which can ship a symlink (git stores one as mode
120000) and needs no local access at all.Two things changed. Resolution happens exactly once, and its result travels as a value rather than a boolean, so the path a human confirms is the path that gets written. And that value carries an open descriptor on the containing directory, pinned at resolution, with every read, write, and rename performed relative to it — so no later operation re-walks the path by name. That second half is what closes the interesting case: a fix that carried only the path still let an intermediate directory be swapped during the confirmation, which redirected the write exactly as the original bug did.
What remains: the directory is pinned by path immediately after resolution, so its own components are walked once more at that instant — a window of microseconds rather than of operator think-time, and the same ordinary same-uid local race that predates this command. Closing even that would need a component-by-component walk from the repository root.
-
--sopswidens the authorityenv setgrants, and the paragraph below predates it. Granting a sessionenv setnow also grants write authority over repo-contained SOPS documents — a materially larger thing than a.env, because a SOPS file typically holds production credentials rather than local development ones. The bounds are the same in shape (repo containment, a filename allowlist, a content check) and there is no--any-fileoverride on that route, but the blast radius of the authority is bigger. Grant it deliberately. -
Agent-write threat model, one line: running
env set/env getunder an agent grants that agent write authority over repo-contained env files for the duration of the session — containment (refuses outside the git repo), the env-file-name rule (below), 0600 permissions, and atomic writes bound the blast radius, but they don't remove the authority itself. The two subcommands grant distinct authorities:env setis write authority (the agent can create or overwrite a key in the file);env get --clipboardis read/exfil authority (the agent can copy an existing secret to the clipboard, where — see the residual-risk note above — any local process or clipboard manager can then read it too). Granting one does not imply granting the other.
Consumer compatibility: what round-trips, and where other parsers differ. forgectl's own parser round-trips every value it writes. Other consumers of the same file do not always agree, and three cases matter (each measured against stock bash and python-dotenv):
| Case | forgectl | bash (. ./file) |
python-dotenv |
|---|---|---|---|
A value with a raw \r inside quotes (e.g. A='x<CR>y') |
round-trips | keeps the \r |
reads it back as \n, a silent change to the secret |
A multi-line value, written as KEY="l1\nl2" |
round-trips | keeps the two characters \ n; a multi-line PEM breaks |
decodes to a real newline |
| A file with CRLF line endings (forgectl preserves them on edit) | round-trips | keeps a trailing \r on every value |
strips it |
Workarounds. For bash, do not source the file when a value is multi-line or the file is CRLF: read it through forgectl, or convert (dos2unix) and use $'...' for a value that needs a real newline. For python-dotenv, avoid \r in values entirely. env set therefore refuses a new value that contains a carriage return with a clear error (a trailing \n or \r\n from the producing command is still stripped as before). This guards new writes only: a \r already on disk still parses and rewrites untouched, and there is no \r escape, because adding one would change the meaning of existing double-quoted \r text on disk.
PATH is trusted. forgectl resolves gh, git, tmux and the other tools it runs through the inherited PATH, the same as any shell would, and does not pin them in config. An attacker who can put an executable earlier on your PATH already controls what your shell runs, so a pin would only move the trust. Two narrower properties hold and are worth knowing: sops is resolved to an absolute path once per env set --sops and that path is what runs (the sops binary the value is written with is the one the write started with), and the $EDITOR re-invocation uses forgectl's own executable path (os.Executable()), never argv[0] or a PATH lookup. Go also refuses a PATH entry that resolves to the current directory (exec.ErrDot), so a binary planted in a cloned repo is not picked up by a relative PATH entry.
Safety notes:
-
Values never appear in argv, stdout, or log output — every value-bearing operation lives inside the domain package, not the CLI layer.
-
Every write lands at
0600; a looser pre-existing mode is tightened and reported (tightened "<file>" to 0600, the path quoted and escaped) rather than silently left alone. -
--fileis refused unless it resolves inside the current git repository (walk-up.gitdetection, symlink-escape checked) — no editing a.envoutside the repo you're working in. -
--filemust also name an env file —.env,.env.*(.env.local,.env.prod,.env.staging,.env.example), or*.env. Repo-containment alone is not a bound worth having:.git/configis inside the repo, andKEY=valueis valid git-config syntax, so an unconstrained--fileturnsenv setintocore.sshCommand— arbitrary code execution on the nextgit fetch..envrc(direnv executes it) andMakefile(KEY=valueis valid make) are the same shape. A blocklist would be whack-a-mole against every future execute-on-read format, so the allowlist is the bound. The point of this tool is to be the thing you hand an agent instead of raw shell; it must not be a shell in a trench coat. -
--any-fileoverrides that rule behind an interactive confirmation, and the confirmation names the resolved path, so it cannot be used to approve a file you were not shown. With no TTY — a piped invocation, a CI job, a harness tool call — it refuses outright.What that gate does and does not bound. It stops a caller with no pty, which covers the common agent case. It does not stop an agent running inside a terminal multiplexer pane: stdin there is a real pty and the prompt is answerable. An earlier version of this note claimed the TTY gate was the bound on an agent; that was wrong, and it is corrected here rather than quietly dropped. Read it as raising the cost and covering the ptyless case. The bound that does not depend on a pty is the env-file-name allowlist above —
--any-fileis the deliberate, human-facing way around it, and granting a session that flag is granting real authority. -
--clipboardis macOS-only (shells out topbcopy/pbpaste); it errors clearly on other platforms rather than silently no-op'ing. -
Secret lengths stay out of the logs too:
envbuilds its clipboard client withclip.WithSensitive(), which drops the byte-count the clipboard layer otherwise logs atinfo. A length is signal — it distinguishes key types and tracks rotations — which is the same reasonredactmasks to a fixed****rather than revealing length. -
redactmasks comments as well as values. A dotenv comment is where a commented-out old key or a# prod token: …note lives, so every#line, and every trailing comment after a quoted value, prints as its leading spaces and tabs plus a fixed# ****. Masking rather than dropping keeps the output aligned with the file. The exceptions: a multi-line quoted value collapses to oneKEY=****line, an unterminated quote masks everything to the end of the file as one****, and a line redact cannot parse (including a#line led by a BOM or other non-space whitespace) prints as a bare****. A#inside a quoted value is part of the value, and after an unquoted value# …is part of the value too; either way it is masked with the value.