Changelog¶
Notable changes to notenv. This project follows semantic versioning: from 1.0, the storage format and the documented interface stay compatible within the 1.x line, and breaking changes are reserved for a future major version (see COMPATIBILITY.md). Releases before 0.2.0 are listed on the GitHub releases page.
1.0.2¶
Security¶
golang.org/x/cryptoupdated from v0.45.0 to v0.54.0, clearing thirteen published advisories. notenv pulls ingolang.org/x/cryptoindirectly through its encryption library (age). Every one of the thirteen advisories is in the SSH packages of that module, which notenv never imports or calls (govulncheckconfirms no affected code paths), so no notenv release was exploitable through them. The module is updated anyway so the dependency tree carries no known-vulnerable code.golang.org/x/sysandgolang.org/x/termmove to their latest releases alongside it.
Changed¶
- CI actions updated and re-pinned by commit SHA.
actions/checkoutmoves from v6.0.3 to v7.0.0 andactions/cache/restoreplusactions/cache/savefrom v5.0.5 to v6.1.0. This only affects the project's own CI; released binaries are unchanged beyond the dependency updates above.
1.0.1¶
Changed¶
- Linting moved to golangci-lint. Go Report Card was sunset, so its README badge
is gone and both CI and
make lintnow run golangci-lint (configured in.golangci.yml). It folds in the previous gofmt, vet, gocyclo, and ineffassign gates and adds gosec and several more. This is a development-tooling change; how notenv behaves is unchanged.
1.0.0¶
notenv is 1.0. How you use it does not change; what changes is the promise around it. From this release until a future 2.0, notenv is trusted not to break: a vault written by any 1.x build is readable and writable by any other 1.x build, in either direction, and the commands, output formats, config files, and environment variables stay stable and only grow. There are no functional changes since 0.21.3. 1.0 is the point where the format and interface stop moving, not a feature release.
Added¶
- A compatibility contract (
COMPATIBILITY.md). It states exactly what 1.0 freezes (the on-storage format, bit-for-bit), what stays stable but may grow additively (the command, output, config, and environment-variable surface), and what is fungible (per-machine pins and caches). Published at Compatibility. - A supported-versions and end-of-life policy. The latest release receives security
fixes, and because every 1.x release interoperates and upgrading never migrates your
storage, upgrading to the latest patch is always the fix path. When a future 2.0 ships,
the 1.x line receives critical security fixes for six months to cover migration, then
reaches end-of-life. Stated in
SECURITY.mdand the security policies.
Changed¶
- Versioning is now a guarantee, not a warning. Before 1.0, any release could change the storage format or the interface with no migration kept. From 1.0, those changes are reserved for a future major version under the compatibility contract above.
0.21.3¶
A small follow-up to the cache commands: a way to drop cached vault keys, and a correction to guidance that pointed at the wrong cache.
Added¶
notenv cache lock. Drops cached vault keys on this machine (the master key in the OS key store: the kernel keyring on Linux, the Keychain on macOS, DPAPI on Windows), re-locking every vault so the next use prompts for the passphrase again.-sre-locks a single storage. It is the counterpart tocache clear, which removes only cached ciphertext blobs.
Fixed¶
- Cache guidance pointed at the wrong command. Three messages (the agent-handoff
fallback warning and two
doctorchecks) advised runningnotenv cache clearto evict a cached master key, which it never did:cache clearremoves only cached ciphertext. They now point at the newnotenv cache lock.
0.21.2¶
Chores in preparation for V1 release, specifically targeted at meeting OpenSSF best practices baselines.
Added¶
- SBOMs in every release. Each release archive now ships with an SPDX software bill of materials (generated by syft), alongside the existing cosign signature and SLSA build provenance.
Changed¶
- Changes to user messages in CLI. Minor changes in wording to user-facing messages in the notenv CLI.
0.21.1¶
A follow-up to 0.21.0 that smooths out the namespace command group once it was
in real use: one way to name a namespace across the whole group, short flags for
the two selectors, and fewer passphrase prompts for the harmless namespace edits.
Added¶
-n/-sshort flags for the two universal selectors:-nis--namespaceand-sis--storage, on every command.
Fixed¶
- The
namespaceverbs now select the namespace the same way.namespace create,update,delete, andrecoverpreviously took only a positional name and rejected--namespace, whilenamespace inspecttook only--namespaceand rejected a positional, so whichever form you reached for, half the group errored ("accepts 1 arg(s), received 0", or "unknown command"). Nowcreate,update,delete,recover, andinspectaccept the name either way: as a positional argument or with--namespace/-n(the two are equivalent; passing both with different values is refused). The destructive verbs (delete,recover) still require an explicit name and never fall back to the project's namespace. (namespace exportandimportare unchanged:--namespaceor the project, no positional.)
Changed¶
namespace createandupdateno longer re-prompt for the passphrase on a warm session. They are ordinary writes (reserve a name; edit a description), so they now use the master-key cache likesecret set: no passphrase when one was entered recently, a single prompt on a cold cache. The destructivenamespace deleteandrecoverstill re-unlock the vault instead of using the warm cache, so a passphrase-protected vault prompts even on a warm session (a configured machine identity unlocks as usual), on top of the confirmation, as a deliberate barrier.
0.21.0¶
Namespaces become a first-class concept. Until now a namespace existed only while
it held secrets: it appeared on the first set and vanished when its last secret
was removed, with no command to create or remove one deliberately. This release
makes a namespace a persistent container you manage on its own, and gives both
namespaces and secrets committed metadata (descriptions, and who/when stamps).
Breaking, on purpose: the namespace blob format is now version 3 and an older
(v2) vault is not read by this version. Each blob now carries namespace-level
metadata (a description, creation and last-modification stamps) and per-secret
fields. The version bump is deliberate: an older notenv refuses a v3 blob (fail
closed) rather than silently dropping the new metadata on a rewrite. There is no
in-place upgrade (pre-1.0, no stable release): export every namespace from the old
binary and import it into a fresh 0.21.0 vault. Import reads .env, which carries
no metadata, so a secret's description is not migrated (re-set it as needed) and
the new who/when stamps begin at import time.
This release also sweeps the CLI grammar one last time before the v1 interface
freeze, onto a single shape: notenv <noun> <verb> to manage things and notenv
run to use them, so the noun always carries the level. Secrets move under a
secret group, introspection collapses onto one verb (inspect, no more list),
the key group becomes credential, export/import move under namespace/
vault, flags are uniform, and every --json document carries a version.
These are breaking command renames (pre-1.0, so allowed): update any scripts
that call the old names. See Changed below for the full mapping.
Added¶
- A
namespacecommand group:inspect,create,update,delete,recover,export,import. notenv namespace inspectsummarizes the selected namespace: its description and who/when stamps, and the secrets it holds (names, lengths, descriptions, last write; never values), with--jsonand--skip-corrupt. To enumerate the vault's namespaces, usenotenv vault inspect(header-only, no passphrase). It does not report whether a namespace is empty without a key;notenv doctorsurfaces empty namespaces when one is cached.notenv namespace create NAMEstands up an empty namespace deliberately, before its first secret (setting a secret still creates one lazily too). It fails if the namespace already exists and never touches an existing one. Because creating a namespace is a deliberate, authenticated act, it also records your acceptance of it, so the first--namespace NAMEuse is not re-confirmed.notenv namespace delete NAMEpermanently removes a namespace and all of its secrets. It requires the vault passphrase and a confirmation (--yesskips only the confirmation), and works even on a namespace whose data is corrupt or missing, so it also clears a namespace that can no longer be read.- Descriptions for namespaces and secrets.
notenv namespace create NAME --description "…"sets a namespace's description at creation andnotenv namespace update NAME --description "…"changes it later. For a secret,notenv secret set KEY --descriptionrecords one when you set the value, and the newnotenv secret update KEY --description "…"amends the description of an existing secret without touching its value (the metadata-only counterpart toset; it errors if the secret does not exist).--description ""clears in every case. Descriptions show innotenv secret inspectandnotenv namespace inspect. inspectnow shows who and when.notenv secret inspect KEYreports who last wrote a secret and when;notenv namespace inspectreports the namespace's description and its creation and last-modification stamps (each with the actor). The actor isUSER@hostname, advisory and forgeable (it is the local user, not a verified vault slot), so it is a convenience for honest teams, never an audit trail. The--jsonforms gain the matching fields, all omitted when unset.notenv doctor --json. Doctor now has a machine-readable mode: a versioned object of findings (each alevelofok/note/problemand atext) plus aproblemscount. Exit is 1 whenproblems > 0, the same as the human run.notenv namespace recover NAMErebuilds a namespace whose blob is unreadable from its one-generation backup (the recovery command, relocated and renamed fromkey evict; see Changed). Last resort for honest media loss.
Changed¶
- Namespaces are now persistent. A namespace stays once it exists, even after
its last secret is removed, instead of silently disappearing. Removing the last
secret reports that the namespace is now empty and how to delete it; the
namespace itself goes away only with
notenv namespace delete. runagainst an empty namespace now fails loudly ("namespace X holds no secrets") rather than injecting nothing, on both the cold read and the warm-cache path:runis a deliberate inject-and-exec, so an empty injection is an error, not a silent success.notenv namespace inspectinstead shows an empty namespace as empty (its--jsonemits an empty secret set), since inspecting is read-only.notenv doctornames empty namespaces (when a key is cached so it can read them), with how to remove one, so empty namespaces stay discoverable.- A lone
notenv.local.tomlwith nonotenv.tomlabove it now warns. The local binding pins a namespace, it does not select one, so without a committed contract it was silently ignored; notenv now says so and points atnotenv initor--namespace. - The namespace first-use confirmation now reads "already exists in the vault" rather than "already holds secrets", since a persistent namespace may exist while holding none.
CLI grammar (breaking renames)¶
The whole command surface moves to notenv <noun> <verb> to manage things and
notenv run to use them, so the noun always carries the level. These are
breaking command renames (pre-1.0); update any scripts.
- Secrets are now a
secretgroup.set/unset/update/copyand the per-secret inspect becomenotenv secret set/unset/update/copyandnotenv secret inspect KEY.setstill creates-or-replaces a value; the secret is a leaf, so removal staysunset(notdelete). - One introspection verb,
inspect, and nolist. Inspecting a container shows its metadata and what it holds:notenv vault inspect(the vault's namespaces, id, revision, storage; wasinspect --allandnamespace list),notenv namespace inspect(the namespace's metadata and its secrets; was the bareinspectandlist),notenv credential inspect(the key slots; wascredential list).notenv secret inspect KEYinspects one secret. There is no morelistcommand; bare-name scripting moves to--json.namespace inspecttakes--skip-corrupt(the oldlist --skip-corrupt).vault inspectandcredential inspectare header-only (no passphrase);namespace/secret inspectneed an unlock (secret names live in the encrypted blob). - The
keygroup is nowcredential(full word, no abbreviation), resolving the overload between "key" the access credential and "key" the secret name (secrets keepKEY/KEY=VALUE).notenv key add/rotate/rotate-master/set-primary/ trust/forget/restore-backupbecomenotenv credential …. Removal is the uniform verbdelete:key rmbecomescredential delete, which now asks for a confirmation (--yesskips it) since it re-keys the whole vault.credential forgettakes--yesinstead of--force. key evictbecomesnotenv namespace recover NAME, relocated to thenamespacegroup and renamed because it recovers survivors rather than deletes. Its behavior is refined too: it rebuilds from the one-generation backup, and when nothing readable survives it now refuses (pointing you tonamespace delete) rather than silently emptying the namespace.export/importcarry the level in the noun, no--all.notenv namespace exportprints this namespace's secrets;notenv vault exportprints the whole vault (the offboarding path, wasexport --all);notenv namespace importreads a.envinto this namespace (wasimport).notenv inspect handoffbecomesnotenv handoff inspect(the agent self-check), now under thehandoffcommand.notenv inittakes the namespace as a positional argument (notenv init [NAMESPACE], default the directory name) instead of--namespace, so the global--namespaceselector has one meaning everywhere;initerrors if you pass it.notenv setupflag vocabulary. The mutually-exclusive--local/--remote-storagebooleans become one--storage-type local|remote(default local), and--defaultbecomes--make-default.- Every
--jsonoutput is a versioned, named-field object. export gains an envelope ({ "version": 1, "namespace": …, "secrets": {…} }, or"namespaces"forvault export) instead of a bare map, andsecret inspect,namespace inspect,vault inspect,credential inspect, andhandoff inspectcarry aversionfield. Existing fields are unchanged; consumers just see the newversion.
0.20.1¶
Added¶
notenv run --only VAR[,VAR...]injects only the named variables from the namespace, instead of all of them. It takes a comma-separated list or repeats (--only A,Bor--only A --only B), and reads the names straight from the namespace, so a credential that lives there but is not declared innotenv.toml(an MCP server's token, typically) can still be scoped into one command. A named variable the namespace does not hold is a hard error, never a silent empty injection, and an empty selection (--only "") fails closed rather than falling through to the whole namespace. Pairs withhandoff: inside a session each MCP server can pull just its own credential from the ephemeral vault.
Fixed¶
- The namespace-acceptance prompt no longer talks about exposing secrets to a
command that runs nothing. The one-time confirmation shown the first time you point
notenv at an existing namespace (a fresh checkout joining it, or a projectless
--namespace) asked whether to "expose them to commands run here", which made no sense for aset,unset,list, orinspectthat runs no command and shows no value. It now asks whether to use the namespace, which reads correctly for every command. The guard itself is unchanged: still one confirmation per (storage, namespace), still fails closed where there is no terminal to prompt on.
Documentation¶
- Agent and MCP guidance reworked around
handoff. A new MCP servers guide covers wrapping a stdio MCP server's launch command withnotenv runso its credential comes from the vault instead of plaintext in.mcp.jsonor your shell. The AI agents guide is leaner and leads withnotenv handoff -- <agent>, and the README and docs landing page no longer frame running an agent throughnotenv run.
0.20.0¶
An input-integrity pass: notenv now defines exactly what a secret value may contain
and stores it byte-for-byte, so a value is always handed back exactly as it went in
and can never leak control characters into a .env file or a terminal.
Breaking, on purpose: the namespace blob format is now version 2 and an older (v1)
vault is not read by this version. Each blob now stores its values and descriptions
base64-encoded, so any stored byte round-trips exactly; the previous format kept them
as raw JSON strings, where Go's JSON encoder silently rewrites invalid UTF-8 to the
replacement character (a value you could never get back intact). There is no in-place
upgrade (pre-1.0, no stable release): notenv export from the old binary and
notenv import into a fresh 0.20.0 vault.
Added¶
notenv copy KEY --from NS1 --to NS2copies one secret between namespaces without exposing its value. The value is read under the master and written back under the same master in a single process: it is never printed, piped, or written to disk the way aget-then-setwould leak it. It is deliberately within one vault only (there is no source/target storage flag): the namespace name is bound into each blob, so the only safe primitive is re-encrypting under the one vault's master, and both namespaces resolve through a single addressed storage. The destination key is refused if it already exists unless you pass--force, and the description rides along. Inside a handoff session the unlock is refused for any vault but the handed-off one, so an agent can only ever copy between namespaces it was already given.notenv inspect handofftells a launched program whether it is in a scoped handoff session. A coding agent started withnotenv handoff -- <agent>holds only a scoped, ephemeral copy of one namespace; one started withnotenv runinstead has the raw values in its environment and no scoped vault. This subcommand lets the program tell the two apart at startup: exit0means it is inside a live handoff, exit1means it is not, and--jsonadds the single namespace it was scoped to. It reads only its own environment and the ephemeral vault on disk, so it never unlocks a vault, asks for a passphrase, or prints a secret value. The "yes" answer is corroborated against live ground truth (the named ephemeral vault must still exist and its supervisor still be running), so a stale or clobberedNOTENV_SESSIONfails safe to "no" rather than a false sense of being scoped.notenv runnudges towardhandoffwhen the command looks like a coding agent. Running a known agent (Claude Code, Codex, Copilot, Gemini, Aider, OpenHands, Continue, OpenCode, Cursor) throughruninjects this namespace's real secret values into its environment;handoffinstead gives it a scoped ephemeral vault and keeps your master key out of reach. The guard asks before proceeding (default no), and only ever asks a human at a terminal: a non-interactive caller, or a process already inside a handoff session, proceeds untouched. It is accident-proofing, not enforcement: a wrapper invocation likenpx <agent>is not matched.
Changed¶
- Output masking covers more encoded forms and now runs in linear time. The masker also scrubs the base32, JSON-string-escaped, and HTML-escaped renderings of a secret (on top of the base64, hex, and percent-encoded forms it already caught), so a value logged inside a JSON body or rendered into a page is masked too. The matcher was rebuilt on a per-pattern KMP automaton: a held byte is never re-scanned, so masking a large captured stream is linear in its length and never quadratic in the secret's length, even for kilobyte secrets like a PEM. Output that touches no secret passes straight through. Masking remains accident-proofing, not a boundary: code that holds a secret can still move it some other way.
- A secret value must be valid UTF-8 with no control characters other than newline,
tab, and carriage return. A value becomes an environment variable and may be
written back out as a
.env, so it has to survive both: a NUL cannot ride in an environment variable at all, an ESC and friends cannot be represented in a.env, and invalid UTF-8 was silently corrupted at rest. notenv now refuses these onset,import, andedit, early and with a clear message, and enforces it at the storage layer that every write funnels through. Binary belongs base64-encoded, which is itself valid text and passes; the error says so. The newline family is allowed because real secrets carry it (PEM keys, JSON blobs, CRLF certs).
Fixed¶
handoffnow keeps every vault's master out of the agent's reach, not just the handed-off one. The session's no-cache lease and cache drop covered only the source vault, so a sibling vault you had unlocked earlier sat warm in the shared per-uid key cache, which the agent (running as you) could read directly to decrypt it, outside the handed-off scope. Handoff now drops every cached master at startup and holds a global no-cache lease for the session's lifetime, so while an agent runs no notenv process on the machine caches any master, even one unlocked in another terminal. The cost is that your other terminals re-prompt for those vaults during a handoff; the upside is the "the agent can't get your master" guarantee now holds for all your vaults, not just the one you handed off. (Single-vault setups are unaffected: the one vault was already covered.)- The handoff session guard now covers the
keycommands anddoctortoo. Every other unlock path already refused, inside a handoff session, to open any vault but the handed-off one; the mutatingkeycommands (which always prompt) anddoctor(which reads the warm session key to verify and decrypt blobs) did not. An in-sessionnotenv key …/doctor/key restore-backup/key trustpointed at a different--storagewould prompt for, or read a warm-cached master of, another vault. The guard now sits at each of those entry points, so the whole header surface fails closed in-session like the rest. As before this is accident-and-master-protection, not agent containment. handoffno longer lets a compromised agent reach your real vault through the internal builder. The hidden__handoff-buildstep unlocks the source vault to copy the handed-off namespace into the ephemeral one. A session guard refused unlocking any vault but the handed-off one on the passphrase (cold) path, but a master already warm in the session cache slipped past it: an agent (which runs with the session marker set) could invoke the builder directly against another vault whose master was cached and re-encrypt it under its own key, defeating the master-protection promise. The guard now sits at the builder's entry, before the cache is consulted, so it covers the warm path too. The legitimate builder, spawned byhandoffitself (which is not inside a session), is unaffected. Still accident-and-master-protection, not agent containment, which is the OS's job.vault copyandvault deletecan no longer destroy unrelated data. Copying a vault to a local--to-pathreconciled the destination by deleting anything the source did not have, so pointing it at a populated directory (a home directory, a project,/) deleted that directory's other files;vault deletelikewise removed its whole configured path or, on a remote, every object under its prefix, taking out other data a shared bucket happened to keep there. notenv now recognizes its own objects (reserved plumbing and<namespace>/data-*.ageblobs) and only ever deletes those: copy refuses a destination that already holds a file it did not write, delete refuses any storage (local or remote) that holds an object notenv did not create, and--to-pathrejects the filesystem root and your home directory outright. An empty destination, or one holding only the blobs of an interrupted earlier copy, is still accepted. A remote delete now also removes the vault's header objects, which it previously left behind.- Remote operations work with older rclone versions again. Deleting an object that is already gone is a no-op, but older rclone (around 1.60) reports a missing target with a generic exit code rather than the dedicated not-found one, so notenv treated it as a real failure. notenv now confirms the object is actually absent (a check that behaves the same on every rclone version) before reporting success, so a delete of a missing object no longer errors on an older rclone.
Hardened¶
- The
handoffbuilder can no longer cache your real master key. Caching the source vault's master during a handoff would leave it where the agent (which runs as you) could read it and open your real vault. A no-cache lease already suppressed this machine-wide, but the builder now also unlocks the source with a hard-coded zero cache TTL, so it cannot store the master even if the lease is somehow absent, and never honors acache_ttlan agent might have rewritten into your config. Warm reads and the post-build cache drop are unchanged; only the store is foreclosed. exportnever writes a raw control byte into the.env. Carriage return now joins newline and tab as an escaped sequence (\r), and because values are validated no other control byte can occur, so an exported value can no longer break a third-party dotenv parser's line splitting or inject a terminal escape sequence when the file is viewed. The dotenv reader decodes\r, so theexport | importround-trip stays exact.- Secret descriptions are escaped when displayed. A control character in a
description (shown by
listandinspect, and written as a comment byexport) is rendered as a visible escape, so a description can no longer break the table layout or inject a terminal escape sequence (for instance a teammate's booby-trapped description shown bynotenv list). Descriptions are still stored faithfully and--jsonoutput is left exact; only the human-facing rendering is sanitized. - The "RAM-only on Linux" guarantee is now verified, not assumed. The blob
cache, the
editbuffer, and thehandoffephemeral vault live underXDG_RUNTIME_DIR, which notenv took on faith to be the spec's tmpfs (RAM-backed, owner-only). A misconfigured or overridden value (somesudo/cron/SSH contexts, or a hand-exported one) could silently put cached ciphertext or a plaintext buffer on persistent disk. notenv now checks the directory is owner-only and on a tmpfs/ramfs filesystem before trusting it: the blob cache disables itself rather than cache on real disk, andedit(always) andhandoff(only when the source is a remote vault, since a local one already keeps ciphertext on this disk) ask before falling back to a temporary file on disk, naming the fix. macOS and Windows are unchanged; the temp-dir fallback is the documented norm there. - Passphrases are harder to brute-force offline. Since storage is dumb, anyone who copies a vault (or sits on the wire) can grind its key slots offline, so the bar is raised on both axes. The passphrase notenv generates, at creation and for onboarding, is now eight words rather than six (about 83 bits of entropy, up from 62), and every passphrase slot is wrapped with scrypt at work factor 2^19 instead of age's default 2^18, doubling both the time and the memory each offline guess costs. The work factor is the one lever that also hardens a passphrase you chose yourself. It is backward compatible: age records the work factor in each wrapped slot, so existing vaults keep opening and a slot adopts the stronger cost the next time its passphrase is set or rotated.
- The warm cache honors the handoff session guard. A
notenv run/listinside a handoff session that hit the local cache could return a vault other than the session's straight from cache, skipping the guard the cold path applies. The handed-off source vault was never exposed (its master is dropped and held out of the cache for the session), but another vault left warm in the cache could be. The warm path now applies the same guard. - A cached blob is bound to its namespace, not just the master. The warm cache's tamper check proved an entry came from the master but not which namespace it belonged to, so a same-machine writer of the cache directory could move one namespace's cached entry into another's slot and have it served as that namespace. The check now covers the entry's (scope, namespace) too, the same binding the on-storage blob already carries, so a cross-namespace entry is rejected and the authenticated read runs instead.
notenv key evictruns the onboarding gate like every other write command. A holder still on the temporary onboarding passphrase (which the inviter also knows) could run the lossyevictrepair without first replacing it; it now requires setting an own passphrase first, matchingset/import/edit/add/rm.notenv editwipes its scratch buffer on a terminal hangup, not just Ctrl-C. The buffer holding the values you type was removed onSIGINT/SIGTERMand on a clean exit, but aSIGHUP(the terminal or an SSH connection closing) orSIGQUITcould leave it behind. It now unlinks the buffer synchronously on every signal a process can catch.SIGKILLand power loss run no handler and cannot be cleaned synchronously, but the buffer is RAM-backed in the normal case (discarded on reboot/logout regardless) and reaches persistent disk only in the fallback that now prompts first; this is not papered over with a flaky next-run sweep.- Object reads are size-capped, so a hostile remote cannot exhaust memory.
Storage is treated as dumb and possibly hostile, but neither backend bounded how
much a single read pulled into memory: rclone buffered the child's whole stdout,
the local backend read the whole file, and the header is JSON-parsed before its
master-keyed tag can be checked, on paths that never unlock (
inspect --all, the namespace first-use check). A remote could serve a multi-gigabyte header (or one packed with millions of slots) and OOM the machine with no credential, pre-auth. Reads now stop at a generous cap (8 MiB for the header, 64 MiB for a namespace blob, far above any real vault) and fail closed with a clear error instead; the rclone child is killed the instant it overruns rather than left streaming. Object listings are bounded the same way (64 MiB of object-key names, ~a million keys), so a remote cannot OOM thedoctor,vault copy, or cleanup paths by serving millions of object names either. - Unlocking won't burn unbounded scrypt work on attacker-planted slots. age's
decrypt side accepts a work factor up to 2^22 (minutes and gigabytes per
attempt), and
Unlocktrial-decrypts every passphrase slot before the master or the header tag is known, so a storage-write attacker with no key could plant slots stamped at a huge work factor and make the victim's next unlock grind through them, pre-auth, per slot. notenv now clamps the accepted work factor to just above what it writes (a higher-cost slot is refused before any scrypt runs) and refuses a header with an absurd number of slots. A planted slot could never open the vault anyway (its key is not a recipient of the master); these bounds only stop the wasted work. The companion to the read-size caps above: together they close the pre-auth resource-exhaustion surface.
0.19.1¶
A correctness and footgun pass after 0.19.0, plus internal cleanup. No storage-format or surface change.
Changed¶
- Full CLI message sweep: A full pass of the user-facing CLI messages was done and many were updated for consistency and UX.
Fixed¶
importno longer stores an empty value's trailing comment as the secret. A hand-written line likePASSWORD= # set me laterparsed to the value# set me later(the whitespace before#was trimmed away before the comment was detected), which was then encrypted and injected as the secret. It now reads as an empty value, as documented.key,export --all, andvault copynow honorNOTENV_STORAGE. Their header path resolved storage by--storage/ project binding / machine default only, so a process pointed at a vault solely viaNOTENV_STORAGE(an agent, CI, orhandoff) silently operated on the machine default instead, andexport <ns>andexport --allcould resolve to two different vaults in one environment. They now use the same selection as the rest of the CLI.notenv editconfirms before wiping a whole namespace. A truncated or emptied buffer (an editor that wrote nothing, a stray select-all-delete) used to delete every secret without asking. An all-delete now requires confirmation, and aborts where there is no terminal to confirm on.edit/set/importno longer corrupt a contract whose[secrets]header has inner spaces.Declarematched only the exact string[secrets], so a hand-written[ secrets ]made it append a second[secrets]table that the next parse rejected. The header is now matched structurally, and re-declaring an existing key is a no-op.- A sub-second
crypto.cache_ttlno longer disables expiry on Linux. A value like500mstruncated to 0 seconds, which clears the kernel-keyring timeout (the key never expires); it is now floored to 1 second. - Concurrent handoffs of the same vault keep the no-cache lease. The lease is now refcounted (one marker per supervisor), so the first session to end no longer drops the protection while another handed-off agent is still running.
notenv editrefuses values it cannot represent instead of corrupting them. Its single-line, no-quoting buffer cannot round-trip a value with surrounding whitespace or an embedded newline; editing such a namespace used to silently trim or mangle the value on save.editnow refuses up front, naming the keys and pointing tonotenv set --stdin.- A header is never overwritten without a recoverable backup (rclone). The backup
step treated any "not found" in rclone's output as "no header yet" and skipped the
backup, but rclone emits that text for non-absent failures too, so a transient
failure could let a write overwrite the live header with no
.prev. The safe-write protocol now backs up only when a header exists and fails closed if the backup fails, and skips it entirely on virgin storage (one fewer request on the first write). set --stdinno longer keeps a trailing\rfrom CRLF input. A value piped from a Windows-style source (secret\r\n) stored a hidden trailing carriage return; the trailing line terminator (\r\nor\n) is now stripped as a unit.- A handoff session refuses
export/run --no-mask/vault deleteagainst a vault other than its own. These re-authenticate through a path that did not check the handoff-session guard, so they prompted instead of failing closed when pointed at a different vault inside a session; they now fail closed like the rest of the unlock surface. (Defense-in-depth: that path never caches the master.) - Registering a storage no longer silently repoints an existing name. Reusing a name
for a different location overwrote the old entry without asking, orphaning the vault that
name addressed. The exposed path was
vault copy(which also did it after a full copy); it now refuses unless you pass--force, and checks before copying so a large copy is never spent only to be rejected. Re-registering the same location stays a no-op. handoffcleans up after a session that was killed before teardown. ASIGKILL, power loss, or Ctrl-C during the vault-build phase could leave the ephemeral vault's trust pin (and, off tmpfs, its directory) behind. The build phase now tears down on Ctrl-C, and eachhandoffsweeps leftovers from earlier dead sessions (live sessions are left untouched).
0.19.0¶
The agent release: a scoped, ephemeral handoff so a coding agent can use a namespace's secrets without ever holding the key to the rest of your vault.
Added¶
notenv handoff -- <agent>. Runs an agent against an ephemeral vault holding only the resolved namespace (from the projectnotenv.toml, or--namespace), under a fresh key minted in a short-lived builder subprocess that exits before the agent starts. Your master key is never in the agent's reach, so a compromised or prompt-injected agent can decrypt at most the namespace you handed it, never the rest of your vault. The ephemeral vault is destroyed at exit. This protects the master key; it is not a sandbox (the agent still runs as you). See Agent handoff and AI agents.NOTENV_STORAGE. Point a process at a vault by configured name or a self-contained spec (local:<absolute-path>orrclone:<remote>:<base>): the env-shaped sibling of--storage.notenv inspect. Metadata about what a vault holds, never a value:inspect KEY(a secret's existence, length, description, and modified time, exit 1 if absent),inspect(the namespace's secrets with lengths and a count), andinspect --all(the vault's namespaces, id, revision, and storage, read from the header alone so it needs no passphrase).--jsonthroughout.
Changed¶
- A handoff holds a no-cache lease on its source vault. While an agent session is live, the source vault's master key stays out of the shared session cache, so unlocking the same vault in another terminal re-prompts for the duration instead of caching. Other vaults are unaffected.
- The agent skill is slimmer, pointing the agent at
notenv --helpfor the full surface rather than enumerating it, with guidance for working inside a handoff session.
Removed¶
- The MCP server (
notenv mcp). It added a second agent-integration surface and mental model without offering much overhandoff, the stronger, shell-first path. If you relied on it, open an issue and we will reconsider re-implementing it.
0.18.0¶
The storage-simplification release, and the last big break before the v1 format freeze. The append-only change log (per-write segments folded over snapshots by a Lamport clock, with compaction, per-machine sequence counters, and replay detection) is gone, replaced by a single encrypted blob per namespace under last-write-wins. Every write decrypts the current blob, applies its change, writes a fresh blob, and points the header at it under the same compare-and-swap that already guards the manifest. The hardened machinery being removed is exactly where the gnarliest concurrency bugs lived, so this deletes whole bug classes, not just code.
Breaking, on purpose: this does not read a vault written by an earlier notenv. The header format
is now version 6 and an older vault fails closed with a clear message. There is no in-place upgrade
(pre-1.0, no stable release): to move secrets across, notenv export from the old binary and
notenv import into a fresh 0.18.0 vault.
Changed¶
- One encrypted blob per namespace, last-write-wins. Two people writing the same namespace serialize on the header swap; the loser re-reads the now-current blob and re-applies its change, so writes to different keys both survive and only same-key writes resolve last-writer-wins. The property the log gave up is lock-free parallel writing, which the rare-interactive-write premise never needed. Same-key concurrent edits no longer surface as a "conflict": there is one current value, the last write.
- A corrupt namespace blob falls back to a one-generation backup. Each write keeps the blob it
superseded (recorded by a
prevpointer the header still vouches for), so a bit-rotted or truncated current blob loses at most the most recent write instead of the whole namespace. Kept on every backend; a remote's own version history is a bonus, not something notenv relies on. notenv key evict-object <object>is nownotenv key evict <namespace>. It rewrites a namespace whose current blob is unreadable from what survives (its backup, or empty if that is gone too) and drops the corrupt blobs, the per-namespace shape of the salvage story.notenv doctorreports per namespace. It verifies each namespace's current blob (and notes a missing or corrupt backup), and reports a stored object no manifest entry references as an orphan from a crashed write; the next write to that namespace reclaims it.- Orphan blobs are reclaimed automatically, with no garbage-collect step. A blob a crashed write left behind (written but never recorded in the manifest) is deleted by the next successful write to its namespace, scoped to what existed before that write so a concurrent writer's in-flight blob is never touched. Cleanup rides every write instead of being a chore you run.
Removed¶
notenv compact. There is no log to collapse; a namespace is always exactly one blob (plus its one-generation backup). Auto-compaction and the per-machine sequence-counter state it needed are gone with it.- The
versionedmachine-config field, and every behavior keyed on it. notenv no longer guesses whether a remote keeps object versions (it never verified the guess, and the fallback it pointed at was a manualrclonestep it could not perform). It now always keeps its own.prevheader backup, sonotenv key restore-backupworks on every backend, including B2. Aversioned = ...line in an existing machine config is ignored.
Hardened¶
- A header write that commits but cannot be confirmed no longer destroys the write. On an
eventually-consistent remote a post-swap read-back can fail after the swap has already landed; the
write path now treats that as committed-but-unverified and keeps the blob the live header
references, instead of deleting it and stranding the namespace. It reports "may have landed; run
notenv doctor/notenv key restore-backup" rather than a false "nothing was stored". exportandvault deleterun the rollback/substitution check that everyrun/listread does, so plaintext egress and deletion refuse a rolled-back or wholesale-replaced vault instead of acting on stale or foreign state.- Namespace existence is judged by the authenticated manifest, not the raw object listing, so an orphan blob from a crashed write no longer makes an empty namespace look populated (which could trigger a spurious "expose these secrets?" prompt).
- Removing the last secret drops the namespace entirely. An
unsetthat empties a namespace no longer leaves an empty blob behind, so the namespace stops reporting as holding secrets (no spurious first-use prompt, no false "found existing secrets" on init). - MCP namespace discovery reads the authenticated manifest, not the raw object listing, so an orphan blob from a crashed write can no longer surface as a phantom namespace over MCP.
- macOS: the cached master key no longer passes through
security's argv. It is fed to the Keychain over stdin (the tool's interactive mode) instead of as a-wargument, so it can no longer be read from the process listing or captured by an EDR/MDM agent. This matches Linux (the kernel keyring) and Windows (DPAPI), which already keep the key out of argv. No cgo; the build stays static. - The onboarding fingerprint widened from 60 to 80 bits (12 to 16 base32 characters), raising the bar against an attacker who grinds a colliding vault identity on a compromised onboarding channel, while still fitting in a chat message. The code is recomputed, not stored, so this only affects an onboarding string generated by one version and verified by another.
- An interrupted
key rmsays plainly when revocation is incomplete. If re-encryption fails after the re-key commits, the removed credential can still read the namespaces not yet re-keyed;key rmnow tells you to treat that credential as not revoked and re-run, instead of a generic error that reads like nothing changed. editno longer silently clears a description when a blank line falls between a key's description comment and the key (an editor reflow, say). An absent comment now keeps the stored description; clear one withnotenv set KEY --description "".- The warm local cache now verifies a master-keyed MAC before serving. Cached ciphertext is bound to the master with a MAC a cache-writing attacker can't forge (age encryption is to the master's public recipient, which the header exposes, so ciphertext alone wasn't proof of origin); a forged or tampered cache entry is rejected and the authenticated read runs instead.
NOTENV_ACCEPT_NAMESPACEis now a per-invocation override, not a permanent grant. Accepting a namespace via the env var no longer persists it, so a later run without the env re-confirms. An interactive accept still persists, as before.editwarns on Linux whenXDG_RUNTIME_DIRis unset, because the buffer then falls to/tmpon persistent disk where a typed value could linger if the editor or notenv is killed. PointXDG_RUNTIME_DIRat a RAM-backed dir to avoid it; existing values never reach the buffer.exportwarns that its output is fornotenv import, notsource. Values are emitted literally, so a value containing$(...)or backticks is data here but a shell would execute it onsource; the warning is in the command help and at the top of the output (the dotenv parser ignores it, so the import round-trip is unaffected).notenv key evictrefuses if the namespace changed since it read, so a concurrent repair that landed mid-evict is not clobbered by the older salvaged state.- Re-stating a value with
setorimportno longer reverts a description another machine changed concurrently: the existing description is carried forward from the live blob at write time, not a stale earlier read. - Object listings exclude storage plumbing consistently across backends. The header and its backup are filtered out of every backend's object listing through one shared definition, locked by a conformance test, so whole-vault and orphan-cleanup operations can never mistake the header for a stray object (the local and remote backends previously disagreed on what a listing returned).
0.17.1¶
A patch release: the session-key cache on macOS and Windows could store an entry that read back already expired.
Fixed¶
- The session-key cache on macOS (Keychain) and Windows (DPAPI) no longer expires an entry the
instant it is stored. Both recorded the expiry deadline at one-second resolution, so an entry
written just before a second boundary rounded its deadline down and could read back as already
expired: the first
GetafterStoremissed, forcing an unnecessary re-prompt (and a flaky cache test). The deadline is now nanosecond-resolution, so a TTL lasts its full duration. Linux was unaffected (the kernel keyring enforces the timeout natively). A cache entry written by an earlier version is treated as expired and re-fetched once.
0.17.0¶
The ergonomics-and-offboarding release. The first run loses its rough edges, output masking gets meaningfully stronger (it now scrubs a secret's common encodings, not just its literal bytes), and a clean way out arrives: export your plaintext and delete a vault, without notenv ever writing a secret to a file.
Added¶
notenv export: take your secrets and leave. Prints a namespace (or, with--all, the whole vault) as.envto standard output, the inverse ofimport, sonotenv export | notenv importround-trips a namespace. notenv never opens a plaintext file itself; it writes only to stdout, and redirecting it (notenv export > .env) is your deliberate act, so the no-plaintext-on-disk promise holds. There is deliberately no--outputflag. Bulk plaintext egress is gated likerun --no-mask: it asks for the vault's primary passphrase even when the session key is cached, refuses without a terminal, and a machine identity cannot perform it.--jsonemits a structured form.notenv vault delete: remove a vault for good. Deletes a configured vault's objects, this machine's trust state for it (the rollback pin and cached key), and its entry in the machine config, behind the primary passphrase and a type-the-name confirmation. notenv removes the live vault; a versioned remote's history and your own backups are the provider's to purge, and the message says so. There is no--force: notenv only ever destroys a vault you can prove you own. If you have lost the passphrase, delete the storage yourself and runnotenv key forget.
Changed¶
- Output masking now catches a secret's common encodings, not just its literal bytes. Because notenv knows the exact values it injected, it scrubs each one along with its base64 (standard and URL, padded and not), hex (upper and lower), and percent-encoded forms from captured output, with none of the false-positive risk a guessing scanner carries. So a token base64'd into an auth header or a password percent-encoded into a logged URL is caught now. It stays accident-proofing, not a boundary: a value transformed in a way notenv does not anticipate, or embedded in a larger blob before encoding, still passes, as do values shorter than 6 bytes. A first-byte index keeps matching fast as the pattern set grows, so injecting many secrets stays snappy.
- A smoother first run.
notenv initno longer prompts for a namespace: it defaults to the directory name and shows it in the result (--namespacestill overrides). A first-timenotenv setupno longer asks "add another storage?" right after creating your first vault (re-running setup still offers it). Andnotenv initconfirms before scaffolding a project in your home directory or a filesystem root, so a mistypedcddoes not quietly make$HOMEa notenv project.
Documentation¶
- The masking limits are restated now that common encodings are caught: the example of what defeats
masking is a transform notenv does not anticipate (piping a value through
rev), notbase64, which is now masked. The threat model, the AI-agents guide, and therunhelp text reflect this.
0.16.0¶
The pre-v1 hardening release. A five-way bug hunt across the storage, crypto, key-management, backend, and CLI layers, run before the v1 freeze with every finding fixed; the first slice of the credential broker; a way out of a corrupt object; and a crypto format that now describes its own algorithms, so a future KDF or post-quantum recipient is an additive change instead of a format break.
Security¶
- The vault credential no longer leaks into child environments. A recipient identity supplied
via
NOTENV_IDENTITYdecrypts the whole vault, andrun,mcp run_with_secrets, and theediteditor all built the child's environment from the parent's unfiltered, so the master-equivalent key was inherited by every child and could land in a log, a crash report, or anenvdump.NOTENV_IDENTITYis now stripped from the child. This is the broker's first slice: accident-proofing for the credential, the analog of the output masker, not a containment wall. A same-uid child can still read the value deliberately (that is the OS boundary's job, and the threat model says so), but the accidental leak is closed. A nestednotenv run -- notenv …still works, because the inner notenv unlocks from the session cache rather than the variable. RemoteandBaseare validated before they reach rclone. They were taken raw and kept safe only by the end-of-options marker at the exec boundary. They are now rejected on write for a leading dash, control characters, and (for the base)..path segments, so a later refactor that trusts the marker less cannot reopen flag injection.
Added¶
- A way out of a corrupt or missing object. A single recorded object that was missing,
undecryptable, or failed its manifest MAC used to fail every read and every compaction for the
whole namespace, with no tool-supported recovery. Now
notenv key evict-object <key>drops the object from the manifest and storage (acknowledged data loss, gated behind--yes), andnotenv run|list --skip-corruptreads past untrustable objects non-destructively, resolving every key it still can and naming each one it dropped. The default fold stays fail-closed: salvage is opt-in, so a dropped or tampered object is never silently skipped.doctornow decrypts and MAC-checks every recorded object when a session key is cached, so a present-but-corrupt object is caught before a read trips over it, and its advice no longer points at a compaction that cannot run. - The crypto format is now self-describing. The header records the cipher suite it uses and each passphrase slot records its KDF, and a build refuses, fail-closed, any suite or KDF it does not implement. notenv ships exactly one of each, so nothing changes today, but a future stronger KDF or a hybrid post-quantum recipient becomes an additive registry entry rather than a format break. notenv owns the choice entirely; there is no user-facing suite concept.
Fixed¶
- A reset or migrated sequence counter no longer causes a false replay lockout. Replay detection assumed a machine's sequence counter only moves forward, but that counter is local, per-scope state classified as silently migratable, while the high-water it is checked against lives on the remote keyed by the durable machine id. Losing or migrating the counter (a restore that omits it, or renaming a remote, which restarts it at zero) while the machine id survived could make an honest write look like a replayed deletion and fail the namespace closed for every machine. A write's sequence number is now floored at the fold's observed high-water for that machine, so a lost counter can never reissue a number already on storage.
- Concurrent
rotate-masterno longer strands teammates with a false rollback alarm. The rotation history lived in a separate object with no compare-and-swap, so two machines rotating from the same master could have the loser's write erase the winner's transition record, leaving a third machine pinned at the old master unable to find a signed path forward. The history now rides inside the header, so a master change and its signed record land in one compare-and-swap. Unlockno longer aborts on a slot that decrypts but does not open the master. A stale slot from an interruptedkey rm, or one planted under a known passphrase, could shadow a valid later slot sharing that passphrase. The loop now skips a slot whose key is not a recipient of the master and continues, failing only once every slot is exhausted, with a distinct message when a passphrase matched a slot that could not open the vault (a tampered or half-rotated header).restore-backupre-pins after restoring. Every other header writer advances the local pin; this one did not, so restoring a one-revision-old backup after the pin had moved ahead raised a rollback alarm on the operator's own recovery and pointed them at a security override. It now re-pins to the restored header (gated on the cached master verifying it), so an honest restore no longer reads as an attack.set-primaryonto a machine slot is refused. Assigning primary to a recipient (machine/teammate) slot froze governance: transferring or removing primary then required that one identity, and losing it made primary unrecoverable. Primary is a human governance anchor; a machine identity can no longer hold it.- A Ctrl-C during a hidden prompt no longer leaves the terminal with echo off. Hidden prompts now restore terminal state before exiting on a signal, so a subsequently typed secret is not invisible.
- The MCP output masker no longer skips short values. The CLI masker skips values under six
bytes to avoid shredding tokens like
8080; on the MCP surface the masked stream feeds a model's context, where a short PIN should not pass through, so the MCP masker has no floor. - Tidies: a leading byte-order mark on an imported
.envis stripped instead of becoming part of the first key; the local backend's path guard rejects a..path component rather than any..substring; object names now carry 64 bits of randomness.
Breaking changes¶
- Header format version 5. The rotation history moved into the header, and the header gained the cipher-suite and per-slot KDF identifiers. v4 vaults (0.13 through 0.15) are not readable by this build (pre-1.0, no migration path, consistent with earlier header bumps). The segment/snapshot payload format is unchanged at version 3.
Documentation¶
- The threat model and the agents/CI guidance frame the broker's first slice: what the credential strip contains (accidental leakage into child environments) and what it does not (a same-uid process that is actively looking, or the orchestrator that holds the credential by necessity), with the OS trust boundary that real containment requires.
0.15.0¶
Deletions become durable, the passphrase prompt stops being a tax on macOS and Windows, and the agent surface ships in both formats agents come in.
Fixed¶
- A deleted key can no longer resurrect. Deletions were the only operation whose
evidence compaction destroyed: tombstones were dropped at fold time, so a write that was
in flight while the namespace compacted (a slow upload, a laptop suspended mid-
set) could bring a deleted key back, silently, even when the deletion was strictly newer, with the outcome depending on whether a cleanup happened to run in the window. Snapshots now retain tombstones with full provenance, so a deletion keeps winning exactly what the ordering rule says it wins, compaction is value-transparent again, and a late write that loses is reported as a conflict. The storage format version bumps to 3; 0.14 vaults are not readable by this build (pre-1.0, no migration path). The 0.11 notes called the previous payload change "deliberately the last before the freeze": that claim was wrong and is withdrawn, not replaced. The simulation fuzzer's oracle is now asserted in the compaction world too; three inputs already in its corpus trip the old behavior.
Added¶
- Session key caching on macOS (Keychain) and Windows (DPAPI). Unlock once per session
on every platform instead of once per command. The native stores hold the cached key as
ciphertext under your login credentials, the same custody class machine identities
already use; what is weaker than the Linux kernel keyring is stated in the
caching guide: the TTL is enforced
lazily on read, and an expired entry persists encrypted until its next touch. Set
crypto.cache_ttl = "0"to prompt every time. CI now runs the full test suite on macOS and Windows, not just cross-builds. - The MCP server grows up and drops its experimental label. Four tools, none of which
accepts or returns a secret value, none of which writes to a vault:
list_namespaces(the discovery hop, no unlock needed),list_secrets,run_with_secrets, anddoctor(the checkup findings as data). Results are typed (structuredContentwith declared output schemas) and the read tools carryreadOnlyHint. A golden file pins the entire tool surface. The headless recipe is documented: session-cached key orNOTENV_IDENTITYto unlock,NOTENV_ACCEPT_NAMESPACEfor first use of existing namespaces. - An installable agent skill at
skills/notenv/SKILL.md: the CLI surface and the never-see-values rules in the Agent Skills format shell-first agents understand. A skill for agents with a shell, MCP for agents without one, the same surface either way.
0.14.0¶
The hardening and dailiness release. A security audit of 0.13 (no High or Medium findings) and a sweep of the threat model's own caveats drive most of it: the gaps that were wrinkles rather than physics get closed, and the daily loop gets its missing verb.
Added¶
notenv edit: bulk editing that never displays a value. The$EDITORbuffer shows every existing value as<keep>: replace it to set, delete the line to unset, add lines to create, edit the comment above a key to change its description. The diff lands as one recorded write, new keys are declared in the contract, and a key that also changed on another machine while the buffer was open stops the save with the key named. The buffer never contains stored plaintext, so it can leak at most what you typed into it; it lives in the RAM-backed runtime dir on Linux and is removed on exit and on signals.- The onboarding string now proves which vault it is for.
key addprints the one-time passphrase with a short fingerprint of the vault appended; the invited teammate's first contact verifies the served header against it before anything is trusted, so a substituted vault is refused instead of silently pinned. A legitimate re-key between the invite and first contact passes by proving itself through the signed rotation chain. Trust-on-first-use is closed for onboarded teammates. notenv doctor. One read-only checkup for the known problem states: a vanished or unreadable header, a pending rollback, a replaced vault, unfinished onboarding, objects a crashed write left unrecorded, recorded objects that are missing. It recommends and never fixes, never prompts, and exits 1 on findings so CI can run it.- Generated root passphrases.
setupaccepts Enter to generate a six-word passphrase, printed once; typed passphrases under 12 characters draw a warning naming the offline brute-force attack, at creation, atkey rotate, and during onboarding.
Changed¶
- Namespace confirmation fails closed without a terminal (audit finding). The first use
of a namespace that already holds secrets used to warn and proceed in CI and agent
harnesses; a malicious repository's committed contract on a shared runner could reach
another project's secrets that way. It now refuses unless
NOTENV_ACCEPT_NAMESPACEnames the exact namespace; the value is a list of names rather than a yes-flag, because a contract cannot write the runner's environment. Breaking for CI flows that relied on the warn-and-proceed behavior: add the variable to the pipeline. run --no-maskasks for a freshly typed passphrase, even when the session key is cached. Sending raw secret values to a captured stream is now a human's act: prompts read the terminal device, so an agent holding a warm cache cannot complete one. Strict on purpose: no identity satisfies it and no environment variable bypasses it.- rclone invocations carry an end-of-options marker (audit hardening). The argv builder separates flags from paths itself, so the guarantee that no name is ever parsed as a flag lives at the exec boundary instead of in upstream validation.
Documentation¶
- The threat model narrows its trust-on-first-use limitation (closed for onboarded
teammates), upgrades the malicious-contract property to cover headless runners, and
cross-references
doctorfrom the known limitations. The teams, new-machine, CI, agents, and environment pages cover the onboarding string,NOTENV_ACCEPT_NAMESPACE, and the unmask gate.
0.13.0¶
The principals release: passphrases are for people, identities are for machines. A vault concentrates risk, so no file at rest may be key-equivalent; this release makes that a structural property instead of advice. Teammates onboard with a one-time passphrase and end up with a credential only they know; machines enroll with an identity that lives in the platform's secret store; the on-disk identity file ceases to exist.
Added¶
- Teammate onboarding with a one-time passphrase.
notenv key add alicegenerates a high-entropy onboarding passphrase (six wordlist words), prints it once, and marks the slot provisional. Alice's first notenv command refuses to proceed until she replaces it with a passphrase only she knows; the one-time passphrase stops working at that moment, and the issuer no longer knows any credential of hers. An interceptor would need the passphrase and storage read access during that window;key rotate-masteris the remedy if you suspect one. - Machine enrollment.
notenv key add --machine cienrolls a CI job or agent: it prints a new age identity exactly once, for the platform's secret store, and saves it nowhere.--recipient age1...enrolls a public key the machine generated itself. Pair withNOTENV_READONLY=1and a read-only storage credential where the machine only reads. key listspeaks principals. The table shows human (passphrase), human (provisional), or machine (identity), plus when each slot was added, and warns about provisional slots older than a week (the holder never finished onboarding). The--jsonshape gainsprovisionalandadded, both omitted when unset.
Changed¶
- Header format v4. Slots carry the provisional flag and an advisory creation time. Older builds refuse a v4 header loudly; this build does not read v3 vaults (pre-1.0, no migration path, consistent with earlier format bumps).
key addis name-first. The slot name is a positional argument; the--passphraseand--nameflags are gone. Adding a backup passphrase slot for yourself is the same flow as onboarding a teammate: replace the one-time passphrase on first use.NOTENV_IDENTITYis the only identity source. Inline value, or a path your platform materialized. notenv no longer reads (or writes) any identity file of its own.
Removed¶
notenv key gen-identityand the default identity file. A plaintext age identity at a well-known path was the one key-equivalent artifact notenv left at rest, exactly the kind of path infostealers harvest. Humans never need one (passphrases plus the session cache cover every interactive flow), and machines get theirs from a secret store. There is no notenv-owned credential path left for a stealer list to name.
Documentation¶
- The threat model states the credential model. A new "Credentials at rest" section sets the bar (no file at rest may be key-equivalent), scores every unlock path against it, and names the honest residuals of concentrating secrets in a vault: the offline brute-force surface against a passphrase slot, and the onboarding window. The teams, CI, agents, and new-machine guides are rewritten around the split.
0.12.0¶
The documentation release. notenv gets a proper documentation site, the README becomes a landing page that points into it, and the user-facing output gets a polish pass. Nothing about the storage format or command behavior changes.
Documentation¶
- A documentation site at https://dvgils.github.io/notenv, built with MkDocs
(Material) and published from
docs/by a GitHub Pages workflow. It covers getting started, task guides (teams and keys, cloud remotes, CI, AI agents, caching and performance), a command and configuration reference, the concepts behind the design, and the full threat model. - The README is now a landing page. It keeps the pitch, the comparison table, and a quick start, and links into the site for everything deeper.
- The threat model and security policy moved into the site.
THREAT_MODEL.mdis now a pointer to the site's threat model;SECURITY.mdkeeps the private vulnerability reporting link and points its scope there too.
Changed¶
- Clearer error for a vault in an unreadable older format. Two messages pointed at
notenv key migrate, removed back in 0.9. A vault written in a storage format this build no longer reads now says exactly that, instead of naming a command that no longer exists. - Consistent house style in CLI output. Removed em-dashes from messages, prompts, and help text. Wording only; no flags, output shapes, or exit codes changed.
0.11.0¶
The agent-surface release. Software that uses your vault on your behalf — coding agents, MCP clients, CI — can now discover what exists, address it from anywhere, parse what it reads, and be constrained to read-only; and the payload format takes its last change before the v1 freeze.
Added¶
- Per-secret descriptions and write timestamps.
notenv set KEY --description "…"records what a secret is and how to use it; every write now also carries an advisory wall-clock timestamp (informational only — Lamport order remains the truth).listshows both in a table on a terminal; piped output stays bare names, one per line. Asetwithout--descriptioncarries the existing note forward (--description ""clears); imports carry notes forward too. Both fields survive compaction and ride the winning write under conflicts. This is deliberately the last payload change before the freeze — the fields are advisory and omitted when empty, so the format version does not bump and 0.10 vaults are read unchanged. - Projectless vaults: a global
--namespaceflag.notenv run --storage b2 --namespace ops -- psqlworks from any directory — nonotenv.toml, no checkout. The flag bypasses the contract entirely:runinjects every secret in the namespace under its storage key, and the contract-sync conveniences don't apply. First use of a namespace that already holds secrets is confirmed once per (storage, namespace) and recorded user-level (there is no checkout to pin in);notenv key forgetdrops those acceptances with the rest of a storage's trust state. - Read-only mode (policy).
read_only = trueon a storage entry, orNOTENV_READONLY=1for the whole process, refuses every mutating command — set, unset, import, compact, the mutatingkeyfamily, vault copy — and refuses to create a vault on virgin storage from a read command. Honestly framed: this constrains cooperating clients (an honest agent having an accident), it does not contain adversaries — anyone who can decrypt can forge writes with their own tooling. Enforced read-only is the storage credential's job: put a read-only B2 application key behind the rclone remote, or read-only directory permissions on a local vault. --jsononlistandkey list. Frozen, additively-extensible shapes designed for machine consumers:list --jsongives{namespace, secrets: [{name, description?, modified?}]}(never values);key list --jsongives{vault_id, revision, slots}. Golden tests pin both shapes.runexit codes agents can read (docker's convention): the child's own code passes through untouched; 125 is notenv's own failure, 126 the command was found but cannot run, 127 the command was not found. Until now notenv's failures exited 1, indistinguishable from a child that exited 1.notenv mcp(experimental). A Model Context Protocol server over stdio with two tools:list_secrets(names, descriptions, modified times — never values) andrun_with_secrets(inject and execute; returns exit code and output with every injected value masked). An MCP-driven agent on a machine with no checkout can discover secrets, use them, and never see one. Hand-rolled minimal protocol: zero new dependencies. Experimental: the tool surface may still change.
Changed¶
- Local vaults no longer cache folded blobs. The blob cache exists to skip a network
round-trip, and its warm path skips manifest verification — a trade justified against
a network, not against the same disk. A local vault now verifies the manifest on every
read and keeps no second ciphertext copy;
cache_ttlis remote-only. (The master-key cache is unchanged.) - The local folded-blob cache layout is now versioned and carries the secret metadata; blobs cached by earlier versions are silently re-fetched once.
0.10.0¶
The adoption release. Until now the first secret was twenty minutes away — install rclone,
make a bucket, mint credentials, walk a config wizard. It is now three commands on a clean
machine: notenv setup (a passphrase), notenv import .env, notenv run -- ….
Added¶
- Local vaults.
notenv setupnow defaults to a vault in a local directory: no accounts, no rclone, nothing to install beyond notenv. It stores byte-identical layout to a remote vault — same encryption, same authenticated header and manifest, same trust machinery — and its header writes get a true compare-and-swap (an OS file lock that the kernel releases if the holder dies), strictly stronger than the windowed swap on remotes. Confidentiality is unchanged (the same ciphertext that would sit on a provider); the honest trade is durability — no off-device copy, no versioning — so the setup message says exactly that, andvault copyis the way out. Local vaults are single-machine by design; syncing across machines is what remotes are for. - Promptless creation for agents and CI. With
NOTENV_IDENTITYset,setupcreates the vault non-interactively with that identity as its only credential — the whole zero-prompt path from nothing tonotenv runnow works headless. Only the environment variable triggers this; an identity file on disk never silently changes what setup creates. notenv import. Parse an existing.env(documented dotenv subset: comments,exportprefixes, quoted and multiline values — and never any variable expansion), validate everything up front, and store every value in a single recorded write: an import either fully happens or doesn't, and N secrets cost one header round-trip, not N. Keys are declared in the contract;--dry-runpreviews names, never values.notenv vault copy. Replicate a vault to new storage and register it as a named storage: every object byte-verified, the header installed last (the copy isn't live until complete), races with concurrent writes reconciled, the source never touched. No re-encryption, no new ceremony — pins follow the vault's own identity, exactly what that identity was designed for. Local→cloud is the intended ramp; local→local works too (removable media).- Multiple storages may now be local, remote, or any mix; config entries are validated to be exactly one kind.
Changed¶
- rclone is now an optional dependency, required only for cloud remotes. The rclone-missing failure moved from setup's front door into the remote path.
0.9.0¶
The no-shims release: 0.8.0's one-shot migration has done its job and is gone, and the project's scarcest quality resource — fuzzer-hours — now accumulates every night.
Added¶
- Nightly fuzzing. All three simulation targets (the secret log, rotation interleavings, storage-level attacks) run twenty minutes each every night with full input minimization, against the in-memory store for maximum path throughput. The working corpus persists between nights and compounds on top of the committed seeds; a crashing input is uploaded as an artifact, ready to be committed as a regression seed alongside its fix. (A separate nightly soak against a real remote is planned next.)
Removed¶
notenv key migrateand the version-1 payload reading path, per the rule that migration logic lives exactly one release. A vault still on the old formats upgrades by running notenv 0.8'snotenv key migrateonce, then returning here; this version's errors say exactly that.
0.8.0¶
Every stored secret is now bound to the vault's authenticated header. Until now the header was tamper-evident but the objects holding the values were not: a party with storage write access (but no key) could delete a write, revert it via a versioned remote, resurrect a compacted one, or copy a real object into another namespace — all silently. Each of those now alarms, naming the object. This is the last planned storage-format change before v1 freezes the format.
Added¶
- The object manifest. The header records every segment and snapshot: its object key and a keyed fingerprint of its content (keyed from the master, so the world-readable header is no guessing oracle against secret values; computed over plaintext, so rotations re-encrypting in place don't disturb it). Folds trust the manifest, not the storage listing — anything missing, altered, relocated, or replayed fails closed with the object named. Objects also carry the key they were written under inside the encrypted payload, killing copy/rename attacks including cross-namespace injection.
- Conditional header writes. Every
set/unset/compactrecords itself in the manifest through a header compare-and-swap that doubles as the master-epoch check (replacing the post-write verify of 0.5): concurrent writers retry against fresh state instead of clobbering each other, and a write racing a rotation rolls itself back exactly as before. On rclone the swap is read-compare-put-readback — a windowed best effort whose one undetectable ordering self-heals (see below); a backend with native conditional writes can implement it atomically. - In-flight write adoption. A write whose recording never landed (crashed writer, lost swap race) still folds — with a warning — and the next compaction records it durably. Snapshots track each machine's folded sequence high-water mark, which is what lets a fold tell an honest in-flight segment from a maliciously resurrected one, exactly.
- A storage-attacker simulation. Arbitrary honest histories, then one storage-level attack;
the next fold must catch it naming the object (or, for the provably value-neutral moves,
change nothing). Fuzzed in CI alongside the existing two simulations, which now also drive
crashed writers. CI fuzzing was also unstarved (bounded minimization) and the curated corpora
now live in
testdata/fuzz/, replaying as regular tests;gocyclo -over 15joins CI.
Breaking changes¶
- Header format version 3 (adds the manifest) and payload format version 2 (objects are
self-naming). Run
notenv key migrateonce per existing vault: it rewrites every object in place under your unlocked master and records the manifest in one verified header write. The command now migrates version-2 vaults only (upgrade older vaults with notenv 0.7 first) and will be removed once version-2 vaults are gone. - The header revision now advances on every write, not just key operations (each write is a header write). Pins advance along with it; no action needed.
0.7.0¶
Multi-machine key continuity: legitimate master rotations now carry cryptographic proof, so they propagate to every machine silently — and the master-changed alarm, freed from false positives, finally means what it says. This is the release that makes shared vaults usable by teams and fleets whose members come and go.
Added¶
- Signed rotation transitions. Every
key rotate-master/key rmrecords a transition signed by the outgoing master (an Ed25519 key derived from the master secret — nothing new is stored or escrowed). A machine still pinned at that master verifies the chain — multiple rotations deep if it was offline for them — and moves its pin forward without a prompt.notenv key trustremains only for changes that carry no proof: a non-holder cannot forge a transition, so the alarm now identifies genuinely unauthorized changes. (An ex-holder can forge them — they held the key — so offboarding still ends with rotating the storage credential; the threat model spells out this sharpened limit.) - Vault identity. Each vault mints a random ID at creation. Local pins are keyed by it, so trust survives relocating a vault to a new remote or base; each storage location is bound to the vault it held, so substituting a different vault at a known location is refused — however internally consistent the impostor is.
notenv key migrateupgrades a vault written by an older notenv to the current header format: one lossless, end-to-end-verified header rewrite under your unlocked master. The command is temporary and will be removed once old-format vaults are gone.- Rotation interleaving fuzzing. The multi-machine simulation now drives master rotations racing writes, compactions, and stale-key recoveries, enforcing after every step that a fold under the vault's current master succeeds (no object stranded under a dead key) and that rotations are value-transparent. A short run joins CI.
Breaking changes¶
- Header format version 2 (adds the vault ID and signing public key). Run
notenv key migrateonce per existing vault; newer notenv versions refuse the old format. - Local trust state (
pins.json) is restructured around vault IDs; prior pins are not carried over — the first unlock per vault re-pins (trust on first use), or runnotenv key trust.
0.6.0¶
The agent release: notenv's founding property — plaintext never touches disk, exists only in the child's environment — turns out to be exactly what AI agents need, because anything an agent reads lands in a model's context and persists in transcripts. This release makes captured output safe by default and documents the agent story with the same honesty as the rest of the threat model.
Added¶
- Output masking.
notenv runnow scrubs the exact secret values it injected from the child's stdout/stderr whenever the stream is captured (a pipe, a file, an agent or CI harness), replacing them with<notenv-masked:NAME>— so a server that echoes its connection string on boot no longer hands it to the log or the LLM reading the tool output. Streamed across write boundaries (split values are still caught); a live terminal is wired through untouched so colors and TUIs keep working;--maskforces masking on a terminal,--no-maskdisables it. Best-effort by design: exact byte matching only, values shorter than 6 bytes pass through. - Joining an existing namespace is confirmed. A fresh checkout whose derived namespace already holds secrets in the vault now asks once before exposing them (warns in CI). This closes the remaining namespace-pinning gap: a malicious repository named after your project could previously derive its namespace silently. A virgin namespace (the new-project flow) still pins without ceremony.
- An agents section in the README: the context-leak threat,
run/listas the verbs that separate using credentials from knowing them, a copy-pasteAGENTS.mdrecipe, and the limits stated plainly (same-UID extraction and child egress are not defended; no agent-containment claim until a broker mode exists).
Changed¶
notenv runwaits up to 10 seconds for a lingering grandchild holding the output pipe after the child exits (previously it could wait forever when output was piped); the child's real exit code is preserved.
Documentation¶
- Threat model: captured child output joins the security properties (masked, best-effort, qualified) and the adversary table; deliberate same-UID extraction and child exfiltration join the non-goals explicitly.
- Honest cost notes: passphrase unlock latency scales with the number of passphrase slots; SFTP/WebDAV passwords entered during setup briefly pass through argv (prefer key-based SFTP auth).
0.5.0¶
A security-hardening release driven by an end-to-end review of the design against its own threat model. It closes the one honest-parties data-loss race (writes concurrent with a master rotation), stops a committed contract from silently retargeting a checkout at another project's secrets, and makes every local trust decision visible and deliberate. It also folds in the unreleased 0.4.1 correctness work.
Security¶
- Writes racing a master rotation can no longer strand ciphertext. Previously, a
set(or worse, a compaction) running concurrently withnotenv key rotate-master/key rmcould leave objects sealed under the replaced master — undecryptable by everyone once that key evaporated, poisoning every read of the namespace. Now every write confirms after it lands that the master it was sealed under is still the vault's master and rolls itself back otherwise (compaction checks before deleting anything and undoes its own snapshot); the rotation re-lists the namespace after its header flip and re-keys anything a not-yet-aware writer sealed under the old master. Covered for every non-crash interleaving; the (seconds-wide, crash-only) residual is documented in the threat model. - The namespace is pinned per checkout. The committed
notenv.tomlchooses the namespace, and the namespace chooses which secrets reach a child process — so a cloned untrusted repository could name another project's namespace in your vault and havenotenv runhand that project's secrets to its scripts. The git-ignorednotenv.local.tomlnow records the namespace a checkout has accepted: an unusual namespace (not the directory's name) is confirmed interactively on first use, and a contract that later changes its namespace is refused until re-accepted withnotenv init. - A vanished header is an alarm, not virgin storage. A machine that has pinned a vault and
then finds no header refuses to walk you through creating a fresh one (which also used to
overwrite the pin, silencing the alarm forever). Restore the header, or use the new
notenv key forgetafter a deliberate vault reset. notenv key trustshows what it trades before clearing an alarm: the pinned revision and master next to the observed ones, an explicit warning on a master change or rollback, and a confirmation prompt (--yesfor scripts). Overriding a security check is no longer the path of least resistance.- Reads trust only rclone's not-found exit codes. Whether a header "doesn't exist" drives the virgin-storage decision, and stderr-text matching (fragile across rclone versions and locales) could fake it; text matching survives only for housekeeping subcommands where a false match is harmless.
- Header creation goes through the safe-write protocol (read back, authenticate, re-unlock with the new passphrase) before you walk away believing escrow is done; its freshness check also refuses to clobber a header a concurrent setup wrote.
Fixed¶
- Concurrent
set/unseton one machine can no longer collide. The sequence-counter update inNextSeqis now locked, so two simultaneous writes never read the same counter and emit segments that share a sequence number (which could leave conflict resolution non-deterministic). - A write whose verify read-back merely lags is no longer deleted. Writes are verified by reading them back; on an eventually-consistent backend that read can lag, and the old code deleted the possibly-landed object. It now deletes only on a genuine byte mismatch (real corruption) and otherwise surfaces an error for the caller to retry over.
set/unsetno longer warn about a conflict the write itself just settled; conflicts are reported from the post-write state.- Hidden prompts work on Windows when stdin is a pipe (
notenv set --stdin): prompts open the console device (CONIN$) directly, the same way/dev/ttyis used elsewhere. - A fold that hits an undecryptable object now names it, so recovery starts from an object key instead of a guess.
Breaking changes¶
- Pre-0.4 (versionless) segment and snapshot objects are no longer read. That lenient path was migration logic with no remaining users. A v0 object is refused with a pointer at the upgrade path: compact the namespace with notenv 0.4 to rewrite it, or re-add its values.
notenv key trustnow requires confirmation (interactive prompt, or--yesin scripts).
Documentation¶
- Threat model: the write/rotation concurrency guarantee and its crash residual, vanished-header detection, namespace pinning, and an explicit caveat that warm-cache runs defer the pin checks by up to one cache TTL.
- Clarified that the key header and the segment/snapshot payloads are versioned by separate, intentional rules; both now reject anything but their exact supported version.
0.4.0¶
A hardening release: more command coverage, and much deeper testing of the storage and concurrency model under imperfect conditions.
Added¶
notenv unset KEYremoves a stored secret value. It appends a tombstone the fold honors, never edits the committed contract, and warns ifnotenv runwill now report the key missing.- Same-key conflicts are now also reported on
setandunset, not only onrun/list. - Explicit on-storage format versioning. Every segment and snapshot now carries a format version. A read refuses an object written by a newer notenv with a clear "upgrade notenv" message rather than misreading it. Objects written by 0.3.0 (which had no version field) read unchanged, so 0.3.0 → 0.4.0 is the first upgrade with no storage break.
Changed¶
- Caching is documented as Linux-only by design. macOS and Windows deliberately do not cache: no platform-native store (Keychain, Credential Manager/DPAPI) matches the RAM-backed, removed-on-logout cleanup guarantee the Linux cache gives, and notenv refuses to ship a weaker cache under the same name. This is a stated decision, not a pending feature. (No behavior change; caching was never implemented on those platforms.)
Documentation¶
THREAT_MODEL.md: a full statement of assets, adversaries, the properties that hold against each, and the explicit non-goals, plusSECURITY.mdfor private vulnerability reporting.
Testing¶
- A fault-injecting
chaosstorage backend and a seeded, fuzzable multi-machine simulation (go test -fuzz=FuzzSecretLog) that check the fold and compaction invariants (no lost or wrong secrets, correct conflict reporting, transparent compaction) under concurrent, stale, and interrupted writes. A short fuzz run is part of CI.
0.3.0¶
This release makes concurrent writes safe: two machines changing secrets at the same time no longer overwrite each other. There is no automatic upgrade from 0.2.x; see Breaking changes.
Breaking changes¶
- New on-storage layout, with no migration path from 0.2.x. A namespace is now an
append-only set of per-write segment objects (folded into an occasional snapshot) under a
<namespace>/prefix, replacing the single<namespace>.ageblob. To move from 0.2.x, re-add your secrets withnotenv set. notenv is pre-1.0, so this is a one-time clean break.
Added¶
- Safe concurrent writes.
notenv setappends a uniquely named, encrypted segment instead of rewriting a shared blob, so two machines setting different keys at the same time never lose each other's change, on any remote, with no locking. Reads fold a namespace's segments over its snapshot, last write wins per key, ordered by a Lamport clock. - Conflict reporting. Setting the same key concurrently on two machines is a genuine conflict: one value wins deterministically and the other is reported on the next read and kept recoverable in its segment until the next compaction.
- Automatic compaction. Once a namespace's segments pass a threshold, a
setfolds them into a single fresh snapshot so cold reads stay fast. It is best-effort (a compaction failure never fails the write) and write-path only (reads never mutate storage).notenv compactforces it on demand. Compaction writes the new snapshot before removing what it folded and only removes objects it read, so a write (or another compaction) that lands concurrently is never lost.
Known limitations and planned work¶
- Don't run two compactions against the same namespace simultaneously: it's safe (no writes lost) but wasteful, briefly leaving redundant snapshots that the next compaction collapses.
- On an eventually-consistent remote a fold can briefly read stale (never lost) just after a compaction; strongly-consistent remotes (Backblaze B2, S3) are unaffected.
0.2.0¶
This release turns notenv from a solo, passphrase-only tool into a multi-user, multi-vault secrets manager with full key management and tamper-evident storage. There is no automatic upgrade from 0.1.x; see Breaking changes.
Breaking changes¶
- New on-storage header format and machine-config format, with no migration path from
0.1.x. To move from a 0.1.x install, run
notenv setupto create a fresh vault and re-add your secrets withnotenv set. notenv is pre-1.0 and 0.1.x shipped no key or team features, so this is a one-time clean break. ~/.config/notenv/config.tomlnow defines named storages ([storage.<name>]tables plus a top-leveldefault) instead of a single[storage]table.notenv setupwrites the new form.
Added¶
Team access and key management (notenv key ...), with no server:
notenv key add --recipient age1...adds a teammate by their age public key; they never share a secret with you. The teammate runsnotenv key gen-identityto create an identity on their machine, which then unlocks the vault with no passphrase.notenv key add --passphraseadds a backup or second-device passphrase slot.notenv key rotatechanges your own passphrase.notenv key rotate-mastermints a fresh master key and re-encrypts every secret while keeping all slots (a precaution if a machine may be compromised).notenv key rm <name|index>removes a slot and re-keys the vault, so the removed credential can no longer decrypt. This is real offboarding, not just deleting a credential.notenv key list,notenv key set-primary,notenv key restore-backup.
Multiple vaults per machine:
notenv setupadds named storages and can be re-run to add more; the first becomes the default.- A project binds to a storage at
notenv inittime, recorded in a git-ignorednotenv.local.tomlbesidenotenv.toml.--storage NAMEoverrides the choice for any command (useful in CI to pin the vault from outside the repo).
Integrity (authenticated, version-pinned headers):
- The key header is authenticated with an HMAC keyed from the master key and carries a monotonic revision that each machine pins locally. A party who can write your storage but holds no key cannot forge or alter the header undetected, and a rollback to an older header is detected and refused on any machine that has seen a newer one.
notenv key trustre-pins after a master change you have confirmed is legitimate (for example, a teammate rotated the master on another machine).
Other:
- Unlock with a configured age identity (
NOTENV_IDENTITY, or~/.config/notenv/identity) in addition to a passphrase. - Reads recover automatically from a stale cached blob or master after a remote re-key, instead of failing with a misleading error.
Security notes¶
- notenv does not own your storage, so it cannot revoke a former holder's storage write
access. For complete offboarding, also rotate the storage credential at your provider after
notenv key rm. notenv detects a rollback attempted by such a holder but, on dumb storage, cannot prevent it. - Header integrity is trust-on-first-use: a brand-new machine has no prior revision to compare against on first contact with a vault.
- Primary-slot governance is advisory (tooling-enforced), not cryptographic: in shared-master team mode every slot holder has the master key.
Known limitations and planned work¶
- No compare-and-swap on write yet: concurrent writers to one namespace race and the last write wins (object versioning on the remote preserves the overwritten bytes).
- Per-blob value-rollback detection and multi-machine signed key-continuity (so legitimate
rotations need no manual
notenv key trust) are planned. - Native key/blob caching on macOS (Keychain) and Windows (DPAPI) is not wired up yet; those platforms prompt and fetch on every run.