Phase log 23: 2026-08-31 - Review-01 defect fixes, the Key Pools sidebar, and the Nodes design
Edited 2026-09-04 - phase numbers renumbered. Phases 21–36 as originally written in this file were ids allocated in design order; they are now queue positions, so every phase reference here has been rewritten to the new numbers and the plan table re-sorted. Nothing else was changed: findings, verification output, decisions and dates are as written. The old→new map is in
CLAUDE.mdunder Phase Status → Renumbering map. A record that is edited and says so beats one that is quietly wrong - that was the call, and this line is how it stays honest.Two further corrections of fact: the two statuses in the plan table that read 🟡 for Phases 19 and 20 are now ✅ (both completed 2026-09-04), and the dependency note for Phase 30 records that its blocker was satisfied by this very session. One pre-existing error was also corrected: the plan section was titled "19 through 29" while its own table ran to 35 - it now names the real range. The sections below still appear in the order they were designed, not the order they run - read the plan table for sequence.
Version at start and end: 0.8.1. Nothing bumped; nothing committed.
Goal
Started as "read review/review-01.md and fix the bugs". Four live defects were
confirmed against current source and three of them fixed. Mid-session the goal
changed twice: the user asked to design a Nodes feature (distributed config
agents), and to add a Key Pools sidebar section. Both landed - the sidebar as
code, Nodes as the design below.
Current Progress
Review defects - verified against source, then fixed
| Review § | Defect | State |
|---|---|---|
| §3.4 | A stolen bearer token never expires | fixed |
| §2.2 | No schema version anywhere | fixed |
| §3.1 | Scoped sub-user writes to projects/categories vanish silently | fixed |
| §3.2 | CLI silently exports .env for 7 of 11 project types | fixed |
| §3.3 | Icon CDN leaks the list of services you hold credentials for | fixed |
All four §3 defects are closed. Remaining review items (§7.1 route tests, §2.3 escaping, §2.4 permex, §2.1 storage, §8 lint) are scheduled in the phase plan.
Verification:
cargo test --workspace 123 + 28 + 23 + 19 + 19 + 11 + 6 + 1 passed, 0 failed
npm run check 887 passed, 40 files - version, format, lint, typecheck, test
npm run lint:rust clean (clippy --workspace --all-targets -D warnings)
npm run check is green end to end, after cargo update and after every change
below.
npm test was 856 at the start of the session. The 31 new tests are
tests/pool-sidebar.test.ts (13), tests/icon-bundle.test.ts (12),
tests/inject-into-form.test.ts (4), one alias-parity case in
tests/cli-parity.test.ts, and one added while rewriting the two
tests/modals.test.ts cases that had pinned the inject bug. Rust gained 11 in envv-cli/tests/parity.rs, 4 in
vault-core and 2 in envv-server.
Exporter parity: 4 of 11 formats → 11 of 11
The seven types that were TypeScript-only are ported to envv-cli/src/exporters.rs
and asserted against the goldens that already existed. chunks.rs gains
default_format_for() covering all eleven project types, --format gains a
value_parser so a bad value is a clap usage error (exit 2) at parse time, and
the error text no longer calls seven graduated types "experimental".
Icons bundled; the CDN is gone from the CSP
scripts/gen-icons.mjs (npm run icons) generates src/ts/icon-paths.ts from
simple-icons@16.29.0 - 372 paths, rendered as data: URIs. img-src no longer
lists https://cdn.simpleicons.org, so a stray remote icon URL is now blocked
rather than silently fetched.
The "→ Inject" buttons did nothing visible
Reported by the user during the session. All four generator panes routed through one function whose precondition check was wrong. Fixed, with the class of bug promoted to Phase 26.
Dependencies refreshed
cargo update across the workspace - every semver-compatible bump, ~150 crates.
cargo build --workspace and cargo test --workspace green afterwards
(123 + 23 + 28 + 19 + 19 + 11 + 6 + 1, 0 failed). Cargo.lock moved and is
committed, as it must be.
"Latest LTS" needs restating, because Rust has no such thing. Crates have no LTS channel and no long-term-support branch; there is the latest release and whatever your manifest range allows. So the request splits in two:
- The semver-compatible half - done above, and safe by definition.
- The pinned half - twelve crates are held behind their latest release, and
eight of those are pinned to an exact version by a documented decision in
CLAUDE.md("Dependency Pinning (exact - do not change)"), written afterrand 0.10andrusqlite 0.39broke the build.
What is actually held back:
| Crate | Have | Latest | Why it is pinned / what a bump costs |
|---|---|---|---|
rusqlite | 0.31.0 | 0.40.2 | Nine majors. The pin says "0.32+ breaks API". This is the big one and it is the SQLCipher binding - the single most security-relevant dependency in the tree |
rcgen | 0.13.2 | 0.14.10 | Certificate generation; API churn between majors |
reqwest | 0.12.28 | 0.13.4 | Used by the pinning HTTPS proxy - a bump touches the custom ServerCertVerifier |
argon2 | 0.5.3 | 0.6.0 | The KDF. A bump must not change derived output, or every existing vault stops opening |
aes-gcm | 0.10.3 | 0.11.1 | .vaultbak envelope; must stay byte-compatible with the app's WebCrypto |
pbkdf2 / hmac / sha2 | 0.12 / 0.12 / 0.10 | 0.13 / 0.13 / 0.11 | Same envelope, same constraint - these three move together with aes-gcm |
notify | 6.1.1 | 8.2.0 | Two majors behind; only envv watch uses it, so the blast radius is small |
time | 0.3.37 | 0.3.55 | Exact-pinned at a patch level; the pin is stricter than the reason for it |
dirs | 5.0.1 | 6.0.0 | Path resolution - and pools.json living in the same directory for app and CLI is verified, not assumed, so a bump must re-verify it |
base64 | 0.22.1 | 0.23.1 | Trivial |
tower-http / webpki-roots | 0.6.11 / 0.26 | 0.7.0 / 1.0.9 | Server middleware and root store |
Recommendation, not done here: treat this as its own piece of work, because
four of these (argon2, aes-gcm, pbkdf2, sha2) can silently change bytes
that existing vaults and existing .vaultbak files depend on. The test that
makes it safe is a round-trip against artefacts produced by the current
build - open a vault written before the bump, restore a backup written before
the bump - and that fixture does not exist yet. Write it first; bump second.
rusqlite deserves its own pass entirely.
The UI lab - scaffolded, deliberately not run
Committed as a harness; execution deferred to Phase 28, at the user's direction: run it after every major release, not continuously.
New: playwright.config.ts and ui-lab/{viewports,seed,audit,layout}.ts,
npm run ui-lab / npm run ui-lab:report, @playwright/test as a
devDependency. Screenshots and findings are gitignored; the harness is not.
Phase 28 has the design and the remaining work.
Key Pools sidebar
A Key Pools section beside Tags and Env Prefixes, grouping entries by
VaultEntry.pool with member counts, click-to-filter, and the section editor /
drag-reorder / collapse behaviour every other section already has.
The bugs that mattered
Ranked by what they did to the user, not by how wrong the line was.
1. A scoped sub-user's project and category edits were accepted and discarded
vault-core/src/users.rs, merge_user_vault_write. It rebuilt api_keys only:
#![allow(unused)] fn main() { let mut out = full_vault.clone(); obj.insert("api_keys".to_string(), Value::Array(result)); }
projects and user_categories came straight from full_vault. The server then
answered 204 No Content.
What that did: a sub-user connected to a remote creates a project, renames a
category, edits a WireGuard peer or adds a chunk. Success toast - because the
frontend applies the change to its own st.vault first. The screen shows the
change. The server stored nothing. It surfaces on the next reload, attributable
to nothing. This is the project's own worst bug class (silently wrong, with a
confirming UI) sitting in the RBAC write path.
The fix is not one line, because the submission is a filtered document. An absent project means "deleted" if the user could see it and "never shown" if they could not, and confusing the two deletes other people's projects on every sub-user save. So:
merge_user_vault_writenow takes the read expression too, and recomputesfilter_vault_for_userto learn exactly what the client was served.- Anything outside that served set is preserved untouched - it cannot have been meant.
- Inside it, the submitted version wins, subject to writability.
- Container writability = every entry inside it is writable, the same ALL-scope rule entry writes already use. Otherwise one entry in a shared project lets you rename or delete the project for everybody.
- A change to something the user may not change is an
Err→ 403 naming it, never a silent no-op. - A submitted key that exists in the full vault but was not served is not treated as new - that would be a read-scope bypass through the write path.
One hazard found while writing the tests, and it is the reason this was nearly a
worse bug than the one being fixed: an absent key is not an empty one. The
server's payload validation permits a PUT carrying only api_keys, and any
hand-rolled agent client will send exactly that. Merging [] against a served
set would have wiped the taxonomy on the first such call. projects and
user_categories are merged only when the key is present.
2. The ${…/password} alias was missing from the CLI's resolver
Found the instant the seven new exporters were asserted from both sides, which is precisely what the parity harness exists for.
canonical_field in envv-cli/src/refs.rs is one half of a twin pair;
FIELD_ALIASES in src/ts/chunk-ops.ts is the other. TypeScript maps
PASSWORD, PASS and PWD to the entry's api_key - a password entry stores
its secret there - and Rust did not.
So ${PgProd/password} resolved correctly in the desktop app and came out of the
CLI as the literal text ${PgProd/password}: into .pgpass, into envv render
output, into envv exec, into every .env and every config the CLI writes. A
.pgpass whose password is the string ${PgProd/password} fails to authenticate
and the error names nothing useful.
Fixed, and pinned from both sides by
tests/fixtures/parity/field-aliases.json - asserted through the real
resolution path rather than by comparing the two tables, so it tests what the
exporters actually do.
3. Every "→ Inject" button reported success and discarded the value
Reported by the user mid-session as "the secret generator inject button does nothing".
injectIntoForm guarded on document.getElementById('f-key') being non-null.
#f-key is static markup in index.html, so it is present whether or not
the Add/Edit modal is open. With the form closed, all four generator panes wrote
the generated secret into a hidden input, showed a green "Injected into form"
toast, and lost it - openModal does not read that field back and closeModal
clears the draft.
The guard is now on the overlay carrying .open. This is invariant 8 read
backwards: presence in the DOM is not the same as being on screen.
The worst part, and the reason Phase 26 exists. There was a test for this function - and it asserted the bug:
it('writes the generated value into the key field', () => {
injectIntoForm('generated-secret'); // modal never opened
expect(($('f-key') as HTMLInputElement).value).toBe('generated-secret');
});
It called injectIntoForm with the modal closed and expected the write to land.
It passed for exactly the reason the feature was broken, and fixing the feature
made it fail. A functional test written against a function's behaviour rather
than a user's outcome does not merely miss a bug like this - it pins it in
place and makes the fix look like a regression. Both cases in
tests/modals.test.ts are rewritten, and behavioural coverage now lives in
tests/inject-into-form.test.ts.
4. session_ttl bounded only the sessions nobody was using
envv-server/src/lib.rs. extract_session set expires_at = now + session_ttl
on every authenticated request, /api/ping included, and the desktop pings every
90 seconds by design. The comment on Session said the window stopped "an
abandoned one - or a stolen token". The second half was false for any token
an attacker actually uses: using it renews it, forever.
Fixed with hard_expires_at, fixed at mint and never moved; the rolling slide is
clamped to it, and purge_expired compares against deadline() = min(both).
--session-max-hours (default 24, 0 disables). The desktop's LAN host session
and the ENVV_PASSWORD auto-unlock session are exempt - neither token is ever
handed to a caller, so there is nothing for a ceiling to bound.
The comment is corrected, which matters on its own: a comment asserting a security property the code does not have is worse than no comment.
5. Three binaries, one untyped blob, no version
Grepped: no schema_version anywhere, and vault_meta held exactly one key
(data_hash) despite being built for this.
The problem had already been hit and solved by hand, once - the legacy
rate_limit string is dual-written so a current build's vault stays readable to
an older one. That is a mechanism-shaped problem solved with a convention, and
the next field that skips the convention breaks old readers with no way to detect
it and no way to refuse.
VAULT_SCHEMA_VERSION = 1, stamped into vault_meta inside the same transaction
as the data and its hash. load_vault and save_vault refuse a version
higher than they understand, with SCHEMA_ERR as the marker prefix. Absent is
treated as 1 (every vault in the field), and the next save stamps it. An
unparseable stamp is treated as unknown, not as absent - something wrote a
value this build cannot interpret, which is the same situation as a future
version.
Decisions made
| Decision | Rationale |
|---|---|
The merge takes read as well as write | A filtered submission cannot be interpreted without knowing what was filtered out. Absent ≠ deleted |
| Project/category writability = all contained entries writable | Mirrors the existing ALL-scope rule for entry writes. Anything weaker lets one shared entry hand over the container |
An absent projects key means unchanged, never empty | The server permits an api_keys-only PUT, and any agent client sends one. Treating absent as empty would wipe the taxonomy on the first such call |
| Refuse with 403 rather than merge-what-we-can | A partially-applied write with a success status is the failure this whole fix is about |
| Hard session ceiling separate from the idle window | They answer different questions: idle bounds abandonment, the ceiling bounds theft. One value cannot do both |
| Sessions the caller never receives are exempt from the ceiling | The LAN host token and the auto-unlock token exist only so the key stays reachable in-process |
| Schema refusal on read and write | Refusing only on write still lets an old build read a future document and show it wrong |
The pool sidebar groups via poolsOf() | A second pass over pool would be a second grouping scheme that agrees until it does not - including the trim and the untrusted-input guard |
| Pool swap state stays out of the sidebar | Cursor/cooldowns need a refresh cycle and live in pools.json; the sidebar needs neither and must work on a remote vault and in the browser dev server |
PersistedView.poolFilter is optional | A lastView written by an earlier build genuinely has no such key, and localStorage is read without a migration |
The Key Pools sidebar - what was touched
| File | Change |
|---|---|
index.html | #sidebar-section-pools + #pool-filter-list, between Tags and Env Prefixes |
src/ts/state.ts | st.activePoolFilter; cleared in resetViewState and clearAllFilters; saved in saveViewState; validated in restoreViewState; added to ALL_SIDEBAR_SECTIONS, DATA_GATED_SECTIONS, defaults, and the one-time section migration |
src/ts/types.ts | PersistedView.poolFilter?, and 'pools' in both section unions |
src/ts/render.ts | renderPoolSection(), plus pool: <name> in activeFilterLabels() |
src/ts/filters.ts | the filter clause, comparing against the trimmed name |
src/ts/vault.ts | .pool-filter-btn toggle, and clearing it under the "All" button |
src/ts/settings-panel.ts | { key: 'pools', label: 'Key Pools' } in the order editor |
src/css/activity-tools.css | .pool-chip-sidebar, mono like every other sidebar badge |
tests/helpers.ts | resetState clears it, or one test leaks the filter into the next |
tests/pool-sidebar.test.ts | 13 tests, new file |
Every invariant this had to satisfy, and why each is a real hazard rather than box-ticking:
- Invariant 3 - a pool name from the previous vault leaves the grid empty with every secret present and invisible.
- Invariant 7 - a pool stops existing the moment its last member's
poolfield is cleared. Nothing in the session can clear thelocalStoragecopy, so validation on read is the only thing between the user and an empty grid. - Invariant 4 - the name is interpolated into
innerHTMLand comes from the vault. Two tests: an escaped<img onerror>name, and a non-stringpoolthat must not become a pool called[object Object]. - Invariant 8 - the section is gated in settings and gated on having data, and both are asserted separately.
The trim test is the one that found a real inconsistency: poolsOf() trims, so a
member written as "openai " groups under openai in the sidebar - and a raw
k.pool !== filter comparison would then hide that member from the grid the
moment its own row was clicked. Verified by reverting the trim and watching only
that test fail.
Design - Nodes
Not implemented. Fully specified, every fork decided with the user this session.
What it is
A hub holds the vault. A node is an agent process on another host that observes, and optionally applies, config files derived from that vault. The workstation, both visible and managed from one place.
The naming matters and was corrected during the discussion: calling both ends "node" would have cost a rename after the docs existed.
Decided
| Axis | Decision |
|---|---|
| Node power | Observe + apply. apply is per target, default false |
| Secrets | Push: hub renders, node writes bytes. Pull: node reads a file already on its own disk |
| Truth | Per target: push (vault → file) or pull (file → vault) |
| Reload command | Node-local, on the target line. Hub triggers by target id, never by string |
| Enrollment | One-time hub-minted token, single-use and expiring; both ends then pin the other's cert fingerprint permanently |
| Transport | TLS 1.3 + mutual fingerprint pinning |
| State | Outside the vault blob - nodes.json, beside pools.json |
| Hub down or locked | Observe keeps running, drift buffers in a capped ring, push idles |
The invariant this rests on
A node never holds a vault key, never holds a scoped read token, and never learns a secret it did not already have local filesystem access to.
The absolute version ("a node never holds plaintext") does not survive contact
with pull targets: wg0.conf contains PrivateKey, and reading it is the whole
point of a pull target. The narrower statement is still strong and is actually
true on both directions - push hands the node bytes it never resolves, pull hands
it a file that was already on its own disk.
Why the reload command is node-local
This was the one answer that had to be argued with, and the argument is concrete rather than theoretical:
- Vault write is not owner-only, and §3.1's fix above means scoped sub-users
can now legitimately write
projects. A vault-side reload command would make - Vault content is untrusted input by invariant 4 - it arrives from a remote
server or an imported
.vaultbak. Restoring a backup someone handed you would - The node already declares its target list locally.
reload = "..."joins a file the user is writing anyway, so the UX cost is one line and the RCE path disappears entirely.
The node's entire config
[[target]]
id = "darthdemono-nginx"
path = "/etc/nginx/sites-enabled/darthdemono.conf"
project = "edge"
exporter = "nginx"
mode = "push" # push | pull
apply = false # observe until deliberately flipped
validate = "nginx -t" # must pass before reload
reload = "systemctl reload nginx"
Everything executable is in that file, on that box, written by the operator. Nothing the hub sends can add to it.
Defaults agreed for the rest
- Apply is transactional. temp + fsync + rename;
validatemust pass beforereload; on failure restore the previous file, do not reload, report with stderr. Last 3 versions kept on the node. Phase 18's validation matrix already knowsnginx -t/wg-quick strip/apachectl -t- reuse it rather than inventing a second table. - Push trigger: hub pushes on vault save over a live dial-out connection; node re-checks on a 60s timer as the fallback.
- Drift: node sends a SHA-256 of the on-disk file per beat; the hub requests full content only when hashes differ. A quiet node costs 32 bytes a minute.
- Heartbeat payload: hostname, OS, kernel, arch,
envvversion, uptime, per-target status. No IP - the hub has the socket address where it needs one. - Audit: applies and enrollments write hash-chain rows. Heartbeats write nothing. Putting a per-beat counter in the chain is the read-event and pool-cursor mistake for a third time.
- Privileges: dedicated user, shipped systemd unit, write paths bounded by the target list. Not root.
- Docker: observe-only is easy; push from a container is not - reloading the host's nginx from inside one needs host PID/dbus and is a worse hole than the one the node-local reload closes. Ship the container as observe-capable and document the binary + unit as the path for apply.
- Cardinality: many targets per node, many nodes per hub, one hub per node. Two nodes may push the same project to different hosts; drift is tracked per node.
Sequencing
Nodes is Phase 31 - see the phase plan below. It sits after review §7.1 (the ~30 HTTP-level server tests) because it adds a second network surface to a server that currently has zero request-path tests, and every historical security bug in this project lived exactly there.
Two of its consequences are phases of their own: T5 approval (29) and T6 blast radius (28), both of which need the hub↔node channel to exist first.
Idea backlog
Raised this session, none started. Two tiers, and the split is the point.
Tier 1 is extension - each one names a mechanism that already exists and adds a use for it. Cheap, useful, and none of them changes what the product is.
Tier 2 is position - things that would make this tool the only one that does a particular job. They are ranked by that, not by cost, and each carries its honest hard part, because an idea without one is a pitch rather than a plan.
The framing that produced tier 2, worth keeping: this repository is the only place where five things sit together - the secret values, the structure of the config that consumes them, a reference graph linking the two, a tamper-evident log of changes, and (with Nodes) presence on the machines where the config actually runs. Every idea below is a job that needs at least three of those five. That is why they are not available to a secrets manager, to a config manager, or to a scanner - each of those has one or two.
Tier 1 - extensions of existing mechanisms
| # | Idea | Rests on |
|---|---|---|
| 1 | envv mcp serve - expose the command tree as MCP tools, generated from the same describe document, so redaction stays enforced by Resolver::for_output rather than by the server author | agentio.rs, describe |
| 2 | Scoped tokens - attach a permex expression to the token and AND it with the user's, exactly as a class expression is ANDed today. envv token new --scope 'project:Edge AND env:staging' --expires 1h | permex.rs, effective_permission_expr |
| 3 | --dry-run --json returns a change document, not a refusal - what would be added, updated and deleted, with fingerprints. Emitted from the same single write point that enforces the flag | Access::save |
| 4 | envv diff <project> --against <path> - structural diff with values masked and ${ref}s visible, so an agent can see why prod differs without reading a secret | the Rust config parsers, out.rs |
| 5 | Rotation adapters on the enrich --online signature table - mint, store, revoke, with the vault as the crash-safe journal | enrich.rs, entry rotate |
| 6 | Reverse impact view - "what breaks if I rotate this". The ${Provider/field} edges already exist; nothing renders the reverse direction | resolveFieldRef |
| 7 | Per-project config history and visual diff - entries have version_history; projects and chunks have none, so "what changed in my nginx config last Tuesday" is unanswerable | version_history, exporters |
| 8 | Infra-aware search prefixes - pool:, used-by:, expires:<30d, unreferenced:true, rotated:>90d | parseSearch |
| 9 | envv doctor on the unlock screen - it already runs when the vault will not open, which is exactly when nobody can reach a terminal | doctor |
| 10 | Health-scan rows get a fix button - each row already knows its own remedy | scan.rs, tools.ts |
Tier 2 - the ones that would make this one of a kind
T1. envv shield -- <command> - redact known secrets from any program's output
The project's thesis is "values move vault → target without passing through the
orchestrator's context", and today that holds only for commands EnvVault owns.
terraform apply, kubectl describe, docker inspect, a stack trace, a verbose
curl - all of them will happily print a key EnvVault is holding, into a terminal,
a CI log, a screen share, or an agent's context window.
envv shield wraps any command, holds the vault's values in memory as an
Aho-Corasick automaton, and streams stdout/stderr through it, replacing any hit
with sha256:<12 hex> - the same fingerprint the rest of the CLI already uses,
so an equal fingerprint still means an equal secret and the output stays useful.
Why only here: it needs the plaintext secrets, locally, at the moment the output is produced. A cloud scanner cannot have them by definition, and a regex scanner cannot know which strings matter. This is the same engine as T2.
Hard part: streaming replacement must handle a secret split across a read
boundary (keep a suffix window of max_secret_len - 1), and a partial write must
never emit a prefix of a real secret. Binary output must pass through untouched
or be refused. Performance is fine - Aho-Corasick is linear - but the automaton
build cost per invocation is not, so it needs to be worth doing for long-running
commands and skippable for short ones. And it changes program output, so it must
never be on by default for anything a script parses.
T2. envv scan --exposed <path> - secret-exposure scanning with no false positives
Same engine, pointed at the filesystem instead of a pipe. Walk a directory, a git
history, a Docker image layer, /etc, a node's own host - and report every place
one of your secrets appears in plaintext, by file:line and by which entry it
belongs to, naming the entry by fingerprint and never echoing the value.
gitleaks and trufflehog guess by regex and entropy, so they produce false
positives by construction and miss anything that does not look like a token. This
produces zero false positives, because it is not guessing - it is asking
whether a string you actually hold appears in a file. It also finds the ones no
regex catches: a database URL, a WireGuard private key, a passphrase.
With Nodes this becomes continuous: a node already watching /etc/nginx can
answer "is one of my secrets sitting in a world-readable file on this host?" on
every beat, and the hub never receives the secret - only the location.
Hard part: it needs the vault unlocked, so it can never run as a plain CI hook
without a scoped token. Git-history scanning is heavy (every blob, every ref).
And the report is itself sensitive - a list of where your secrets are is exactly
what an attacker wants - so it must obey the same output policy as everything
else, --out or nothing.
T3. Cross-format semantic validation - the config compiler
The 29 chunk types are already a typed intermediate representation of config;
they are just never treated as one. Phase 18 built a validation matrix that
checks each generated file against its real tool (nginx -t, wg-quick strip,
apachectl -t). Every one of those checks is within a single file.
The interesting errors are between files and between formats:
- an nginx
proxy_passpointing at a Docker service this project does not define - two WireGuard peers with overlapping
AllowedIPs - a Traefik router naming a middleware that was deleted
- a k8s Deployment mounting a Secret no chunk creates
- a Compose service reading an env var that no
env_filechunk sets - a
pg_connectionwhose host is a Docker service on a network the app container is not attached to
nginx -t cannot see any of these, because it only ever sees nginx. No tool
does, because no tool holds all eleven formats in one model. This is the
"config editor that understands infrastructure" in one feature, and it is the
most direct answer to what the product is trying to be.
Hard part: the rules are per format-pair and there are a lot of possible pairs, so it needs either a small rule language or the discipline to hand-write only the six or eight checks that actually bite and stop there. A false positive here is much more expensive than a missing check - a validator that cries wolf gets turned off, and then it protects nothing.
T4. Version control that can hold the whole file
Everybody keeps config in git and secrets somewhere else, and the two drift. The copy in git is redacted, so it is not the file that runs; the file that runs exists only on the server and is versioned by nobody. That gap is universal and nobody has closed it, because a version-control system that holds secrets is a version-control system you cannot push.
A local-first, encrypted store can hold both. Snapshot the fully rendered
config - secrets included - on every change, diff any two points, and (with
Nodes) diff intent against what is actually on the host right now. .vaultbak
already makes it portable and envv audit --verify already makes the history
tamper-evident.
Hard part, and it is real: §2.1 is the ceiling. Snapshots inside the single-row JSON blob grow superlinearly against the thing written most often, so this needs real tables first. And config history means old secrets live forever, which is a liability as much as a feature - so it needs purge, and purge fights tamper-evidence exactly the way audit retention does. The honest version of this feature ships with a retention policy and a checkpoint record, or it does not ship.
T5. Human-in-the-loop apply, bound to a content hash
Nodes make an agent genuinely able to change production. That is the point, and it is also the reason nobody sensible would turn it on.
The push path already has the right chokepoint: the hub renders, the node
applies. Insert approval between them. The agent's apply returns pending; the
desktop shows the exact diff; a human taps approve; the hub releases an approval
token bound to the SHA-256 of the rendered content, and the node refuses
anything whose hash does not match.
Paired with scoped tokens (tier 1, #2), the shape of a safe arrangement falls out: staging applies freely, prod requires a tap. That is the combination that makes it reasonable to hand an LLM real infrastructure, and it is a property of the architecture rather than of the prompt.
Hard part: binding to the content hash is not optional - approving "a change to nginx" rather than "this exact byte string" is a time-of-check/time-of-use hole wide enough to drive anything through. The agent has to handle an asynchronous outcome. And approval fatigue is the real failure mode: approve-everything is one click away from approve-nothing, so the diff has to be genuinely readable or the feature actively makes things worse.
T6. Blast radius - the exact rotation list after a compromise
A host is compromised. The question is always the same and is never answerable: which credentials were on that machine, and when?
The hub knows what it rendered. The node knows what it wrote and when. The audit chain knows what changed. Together that is a precise answer - materialised there, and one command to rotate exactly those and nothing else.
Today the honest answer to that question is "rotate everything", which is why nobody does it, which is why compromises stay live.
Hard part: local envv exec materialisation is deliberately not audited -
every read used to write a row, and pruning would break the chain. So this needs
a separate, bounded, ring-buffered materialisation log outside the hash chain,
which is precisely the shape CLAUDE.md already prescribes for read auditing.
Getting that bound right is the whole job.
T7. Brokered ephemeral credentials
Stop storing the long-lived key. Store the ability to mint a short one: AWS STS,
GCP service-account tokens, GitHub App installation tokens, database roles with a
TTL. envv exec hands the child a credential that is dead in fifteen minutes.
This is the property that makes an exfiltrated secret nearly worthless, and it is what enterprises pay HashiCorp Vault for - a cluster, an operator, a service. The differentiated version is the same guarantee on a laptop, with no server.
Hard part: every provider is its own adapter with its own failure modes, the stored root credential becomes far more valuable than what it replaces, clock skew turns into mysterious auth failures, and a crash between mint and use leaks a live credential that nothing will revoke. It is the largest item on this list and probably the most valuable.
T8. Break-glass recovery
The honest weakness of local-first: lose the laptop, or the person, and the infrastructure is unreachable. Every hosted competitor has an answer here and this project currently has none - which is a real objection, not a nitpick.
A k-of-n Shamir split of a recovery key - a second credential that opens the same vault - as encrypted share files, printable sheets, or per-device shares. The roadmap already names the mechanism it needs: the key-provider abstraction, with "the same vault openable by more than one credential" called out as what people usually mean by multi-region key management.
Hard part: introducing a second key path means PRAGMA rekey, which rewrites the
database and must be transactional with the same compare-and-swap that guards
ordinary writes. Shares must be verifiable without reconstructing the secret.
And the UX is the hard half, not the crypto - a recovery flow that is frightening
to use will not be tested, and an untested recovery flow is not a recovery flow.
T9. Least-privilege drift
enrich --online already asks each issuer what a credential is actually
permitted to do and stores the answer in scopes. The reference graph knows what
each credential is wired into. Comparing the two surfaces the finding nobody
else can produce: this key was granted repo:write and is referenced only by a
read-only CI job.
Hard part, stated plainly: this is scope-versus-declared-use, not
scope-versus-observed-use. envv exec never sees the child's HTTP traffic, and
it should not start. So the finding is a strong hint, not proof, and the wording
in the UI has to say so - the same discipline the compliance-reporting note
already applies to itself.
Phase plan - 19 through 37
Everything above, sequenced. Six tier-2 ideas (T3, T1, T2, T4, T6, T5) are promoted to phases here at the user's direction, in the order they gave, with two forced insertions where a dependency makes that order impossible to run: storage before T4, and Nodes before T6.
Phase status was re-verified against source for this plan, not copied from
CLAUDE.md. That file still says Phases 17–20 are "staged, not started"; two
of them are done and a third is nearly. What follows is what grep says.
| Phase | Name | Contents | Status |
|---|---|---|---|
| 19 | UX & accessibility | onboarding wizard, TOTP for sub-users, accessibility pass, Settings-panel overflow, envv use | ✅ - completed 2026-09-04, the session records/25-2026-09-04-caps-lock-bug-and-phase-19-finished-totp-onbo.md |
| 20 | Operability & release | tracing, /api/health, Retry-After, request ids, envv name collision | ✅ - completed 2026-09-04; the name collision was closed as a decision (no rename) |
| 23 | Storage | §2.1 - row-per-entry, schema v2 | 📋 |
| 24 | Close review-01 | §3.2, §3.3, §7.1, §2.4, §8 | 📋 |
| 25 | Escape by construction | §2.3 - an escaping tagged template across the renderer | 📋 |
| 26 | Does it actually do anything? | behavioural tests + a control-by-control UI efficacy audit | 📋 |
| 27 | UI ⇄ CLI parity | close the gap list above; make the check mechanical | 📋 |
| 28 | The UI lab | real-browser layout audit across a viewport matrix; run after every major release | 🟡 scaffolded, never run |
| 29 | The redaction engine, outward | T1 envv shield, T2 envv scan --exposed | 📋 |
| 30 | The config compiler | T3 - cross-format semantic validation | 📋 |
| 31 | Nodes | hub/node, enrollment, observe, push/pull | 📋 |
| 32 | Config time machine | T4 - versioned config with its secrets | 📋 |
| 33 | Blast radius | T6 - materialisation log, exact rotation list | 📋 |
| 34 | Approval | T5 - human-in-the-loop apply, hash-bound | 📋 |
| 35 | Stack integrations | Prometheus, Grafana, Homarr/Homepage, Nextcloud, VS Code, and the adapter shape they share | 📋 |
| 36 | The provider catalogue | envvault site as the public enrichment source; static, refreshed on a schedule | 📋 |
| 37 | Pentest before 1.0.0 | authorised, against the user's own hub, nodes and workstation | 📋 |
Phases 21 and 22 are absent from this table on purpose. They did not exist
when it was written: the ${Provider/ID} defect (21) and the credential-shape
work (22) were designed the following session in the session records/24-phase-22-designed-credential-shape-copy-profiles-env-nam.md, and
were numbered 36.0 and 36 there before the renumbering moved them to the front.
The gap between 20 and 23 is that, not an omission.
These were deliverable groupings, not a strict serial order, the same caveat Phases 17–20 carry - but the dependency edges below are hard, and reading the number as permission to reorder across one of them will cost a rewrite.
That caveat is spent. Since the 2026-09-04 renumbering the phase number is the queue position, and the order above satisfies every dependency edge in this document instead of cutting across them.
The detailed sections below appear in the order they were designed, not the order they run. They were not reordered, because several of them say "above" and "below" about each other and silently inverting one of those is a worse defect than an out-of-order document. Read the table for sequence; the sections run in this order:
24 · 25 · 30 · 29 · 23 · 32 · 31 · 33 · 34 · 26 · 35 · 27 · 28 · 36 · 37
Phase 19 - UX & accessibility 🟡
Verified state. envv use and .envv.json shipped (envv-cli/src/context.rs,
Commands::Use at main.rs:385). The rest has not started: grep -c aria-label
returns 3 for index.html and 0 for render.ts, and there is no TOTP
surface anywhere in src/ts/.
Scope
- Accessibility pass. This is the large half. The grid is built by
innerHTMLinterpolation, so roles and labels have to be authored in the same strings that every escaping rule already governs - do it after Phase 25, or do it twice. - TOTP for sub-user login only.
vault-corehad it with anti-replay and it was removed in Phase 7 because nothing reached it. The owner authenticates by deriving the key and has nothing for a second factor to gate. - Onboarding wizard.
- Settings-panel overflow fix - hours, no dependencies, and it has been "hours, no dependencies" for three documents.
Risk. Doing the a11y pass before Phase 25 means writing every aria-*
attribute into raw template literals and then migrating them. Sequence it after.
Phase 20 - Operability & release 🟡
Verified state. tracing is live in all four crates (vault-core/src/telemetry.rs
plus the three binaries). /api/health is a real route (lib.rs:2072) with
tests. Retry-After is emitted (rate_retry_after, lib.rs:110). Request ids
exist and are on every span and error log (lib.rs:170–219).
What is left is one decision, not code: envv-cli/Cargo.toml still ships a
binary named envv, and README:1023 records that this collides with an unrelated
product at envvault.dev with an incompatible command tree. Whoever installs both
gets whichever PATH finds first, and every command then fails in a way that
names neither cause.
This blocks nothing technically and blocks everything socially. After 1.0 it is a breaking change. Decide it in 20 or accept it forever.
Phase 24 - Close review-01 📋
The correctness floor. Everything after this adds surface; this is the phase that makes the existing surface trustworthy enough to add to.
Scope
- §3.2 - port the seven missing exporters to Rust.
envv-cli/src/chunks.rs:403defaults toenvfor haproxy, k8s, traefik, apache, ansible, postgres and ssh_config. The goldens already exist intests/fixtures/parity/and are asserted from TypeScript only; adding them toenvv-cli/tests/parity.rstakes the parity guarantee from 4 of 11 formats to 11 of 11. Also fix the error text, which still calls those types "experimental" after Phase 18 graduated them, and give--formatavalue_parser. - §3.3 - bundle the icons.
src/ts/icons.ts:779fetches every provider logo fromcdn.simpleicons.org, so a third party learns the exact set of services you hold credentials for, per vault open, against a README that opens with "talks to nothing else … no telemetry". Generate theSI_REGISTRYsubset from thesimple-iconsnpm package at build time and drop the host from the CSP. - §7.1 - roughly 30 HTTP-level route tests (
tower::ServiceExt::oneshot), one per route across {no auth, wrong auth, scoped auth, owner}.envv-serverhas 21 routes and zero request-path tests, and every historical security bug in this project - 200-empty vault,revoke_tokenauthorising the wrong id,/api/vault/expiringleaking to non-owners,verify_totp_codefailing open - lived exactly there. - §2.4 - pin or delete the TypeScript
permex. It is an unrecognised fourth twin pair, in the security core, missingrequire_allandcombine, andperm-editor.ts:148prints a match count the server will not honour. Either apermex.jsonparity fixture asserted from both sides, or delete the TS evaluator and source the preview from Rust over IPC. - §8 - the 237-suppression lint backlog. 61% of it is
no-floating-promisesandno-misused-promises, which in this codebase means an unawaitedpersist()racing the conflict prompt and a failed save reporting through a toast after the UI has moved on. Order and rules are in review §8; the two that matter most aretsc --noEmitimmediately after every--fix, and never--suppress-all.
Done when parity covers 11 formats, no request leaves the machine on a vault
open, every route has a test at four auth levels, eslint-suppressions.json is
deleted, and permex has one implementation or a fixture.
Phase 25 - Escape by construction 📋
Depends on nothing. Blocks 19's a11y pass, and materially de-risks 31.
render.ts has 51 esc() calls against 299 ${} interpolations; chunk-ops.ts
89 against 485. Many of those are safe and nothing proves which. Invariant 4
says vault data is untrusted input and the enforcement is a human remembering.
An <code>html…</code> tagged template that escapes interpolations by default, with an
explicit raw() opt-out, converts the invariant from a convention into a
mechanism - and it is the same instinct that produced
Resolver::for_output / ::materialising, applied to the surface that has
vastly more attack area and a documented history of stored XSS.
Why it belongs before Nodes. Phase 31 introduces a genuinely new class of untrusted string into the UI: hostnames, file paths, validator stderr and OS strings arriving from a remote machine. Auditing 784 interpolations by eye is not a plan; a tagged template makes the question not arise.
Mechanical migration. The two ESLint rules disabled in eslint.config.js stop
mattering afterwards.
Phase 30 - The config compiler (T3) 📋
Depends on 24 (the Rust exporters, so the checks run in both halves) - satisfied: those landed in this very session, so this phase is unblocked.
The 29 chunk types are already a typed IR and are never treated as one. Phase 18's validation matrix checks each generated file against its real tool - every check within a single file. The errors that bite are between files and between formats.
Scope - the first six rules, and then stop.
- nginx
proxy_passnaming a Docker service this project does not define - two WireGuard peers with overlapping
AllowedIPs - a Traefik router naming a middleware that no longer exists
- a k8s Deployment mounting a Secret no chunk creates
- a Compose service reading an env var no
env_filechunk sets - a
pg_connectionhost that is a Docker service on a network the consuming container is not attached to
Surfaces as: a CLI check (envv check <project>), a panel in the config view,
and - once 27 exists - a gate a node runs before apply.
Risk, and it decides the feature. A false positive costs far more than a missing check. A validator that cries wolf gets turned off, and then protects nothing. Six rules that are always right beat forty that are usually right. Do not build a rule language until the hand-written six have been used in anger.
Phase 29 - The redaction engine, outward (T1, T2) 📋
Depends on nothing. Self-contained, no storage or network work.
One engine, two commands. The vault's values are loaded into an Aho–Corasick
automaton in memory; matches are replaced with the existing sha256:<12 hex>
fingerprint, so equal output still means equal secret.
- T1
envv shield -- <cmd>streams a child's stdout/stderr through it. The project's thesis - values never pass through the orchestrator's context - today holds only for commands EnvVault owns. This extends it toterraform apply,kubectl describe, a stack trace, a verbose curl. - T2
envv scan --exposed <path>points the same engine at the filesystem: a repo, a git history, an image layer,/etc. Reportsfile:lineand the owning entry by fingerprint, never echoing a value. Zero false positives, because it is not guessing by regex and entropy - it is asking whether a string you actually hold appears in a file.
Hard parts, both load-bearing
- A secret split across a read boundary needs a retained suffix window of
max_secret_len - 1, and a partial write must never emit a prefix of a real secret. This is the whole correctness story for T1. - Binary output passes through untouched or is refused; never half-masked.
shieldchanges program output, so it is never default-on for anything a script parses.- The T2 report is itself sensitive - a list of where your secrets are is
precisely what an attacker wants - so it obeys the existing output policy:
--out, or refuse. - Both need the vault unlocked, so neither is a plain CI hook without a scoped token (Phase 24 §2.4 / tier-1 #2).
Later: a node running T2 against its own host on every beat, reporting only locations, never values.
Phase 23 - Storage 📋
Depends on nothing. Blocks 32. Gets more expensive every phase.
CREATE TABLE vault (id INTEGER PRIMARY KEY CHECK (id = 1), data TEXT NOT NULL);
SQLCipher is used as a file format, not a database. Every write is a full
read-modify-write; compare-and-swap is whole-blob, so two people editing
different entries conflict and both branches of the conflict prompt are
destructive; version_history (50 revisions per entry, secret material included)
lives inside the blob and grows superlinearly against the thing written most
often; and no index is possible on anything.
Row-per-entry. Access and VaultStore already abstract storage, which is why
this is still cheap and why it will not stay that way. Bump
VAULT_SCHEMA_VERSION to 2 - the mechanism added this session exists precisely
so an older build refuses the new document instead of silently truncating it.
This is the ceiling on Phases 32, and on tier-1 ideas 6, 7 and 8. If it slips past 26 it will not happen.
Phase 32 - Config time machine (T4) 📋
Depends on 23. Second half depends on 31.
Config lives in git, secrets live elsewhere, and the two drift. The copy in git is redacted, so it is not the file that runs; the file that runs is versioned by nobody. A local-first encrypted store is the only thing that can hold both.
Scope
- Snapshot the fully rendered config - secrets included - on every change.
- Diff any two points, in the UI and as
envv history <project> --since. - Once 27 lands: diff intent against what is actually on the host right now.
The two hard parts are not the diff.
- Storage. This is why 25 comes first; snapshots in the blob would be the superlinear failure by construction.
- Retention. Config history means old secrets live forever, which is a liability as much as a feature - and purge fights tamper-evidence exactly the way audit retention does. It needs a checkpoint record attesting to the pruned prefix. Ship with a retention policy, or do not ship.
Phase 31 - Nodes 📋
Depends on 24 (§7.1 route tests) and, in practice, 25.
The design is in full above - hub/node terminology, per-node dial or listen,
per-target push or pull, node-local reload commands, one-time enrollment with
mutual fingerprint pinning, TLS 1.3, state in nodes.json, observe-continues /
push-idles when the hub is locked.
The dependency on 21 is not negotiable. Nodes adds a second network surface to a server that currently has zero request-path tests.
Ship with apply = false as the only default. See the note under 29.
Phase 33 - Blast radius (T6) 📋
Depends on 31.
After a compromise the question is always which credentials were on that machine, and when? It is never answerable, so the honest answer is "rotate everything", so nobody does, so compromises stay live.
The hub knows what it rendered; the node knows what it wrote and when; the audit returns the exact entries and one command to rotate those and nothing else.
The real work is the log, not the query. Local envv exec materialisation is
deliberately unaudited - every read used to write a row, and pruning would break
the chain. This needs a separate, bounded, ring-buffered materialisation log
outside the hash chain, which is exactly the shape CLAUDE.md already
prescribes for read auditing. Getting that bound right is the phase.
Phase 34 - Approval (T5) 📋
Depends on 31.
The push path already has the correct chokepoint: hub renders, node applies.
Insert approval between them. The agent's apply returns pending; the desktop
shows the exact diff; a human approves; the hub releases an approval token
bound to the SHA-256 of the rendered content; the node refuses any hash
mismatch.
With scoped tokens the safe arrangement falls out: staging applies freely, prod requires a tap. That is what makes handing an LLM real infrastructure reasonable - a property of the architecture rather than of the prompt.
Hard parts
- Hash-binding is not optional. Approving "a change to nginx" rather than "these exact bytes" is a time-of-check/time-of-use hole wide enough to drive anything through.
- The agent has to handle an asynchronous outcome, which changes the CLI contract
and therefore
describe. - Approval fatigue is the real failure mode. Approve-everything is one click from approve-nothing, so the diff must be genuinely readable or the feature makes things worse than no gate at all.
Sequencing warning, stated plainly. T5 is placed after T6 because that is the
order it was asked for, and it is defensible while node apply stays off. But
T5 is the safety property for the capability Phase 31 introduces. If apply
is going to be switched on for a production target before 29 ships, pull T5
forward into 27 - otherwise there are two phases during which the dangerous thing
exists with nothing but a per-target boolean in front of it.
Phase 26 - Does it actually do anything? 📋
Depends on nothing. Could start today, and probably should.
The suite is 882 frontend tests and it did not catch this, found by hand in five minutes during this session:
injectIntoFormguarded ondocument.getElementById('f-key')being non-null.#f-keyis static markup inindex.html, so it is present whether or not the Add/Edit modal is open. Every "→ Inject" button in the Tools panel - secret generator, password generator, API-key generator, hash generator, all four route through that one function - therefore wrote the generated secret into a hidden input with the form closed, showed a green "Injected into form" toast, and threw the value away:openModalnever reads that field back andcloseModalclears the draft. Pressed a button, told it worked, nothing happened.
Fixed this session (src/ts/modals.ts, tests/inject-into-form.test.ts). The
phase is about the class, because that bug was not unlucky - it was structural.
Why 882 tests missed it - they did worse than miss it. There was a test for
injectIntoForm, and it called the function with the modal closed and asserted
the write succeeded. It passed because the bug existed, and fixing the bug
turned it red. A functional test asserts given this input, does this function
return that output; injectIntoForm('x') did exactly what its code said.
Nothing asserted what a user cares about - did pressing this control change
anything the user can see, and was the feedback true?
So the phase is not "add more tests". It is: a test that never drives the control can lock in the broken behaviour, and there is no way to know which tests those are without walking the controls.
This is invariant 8 read backwards. That invariant says hiding a control is not the same as preventing the action. This is the mirror: presence in the DOM is not the same as being on screen, and a test that queries the DOM directly cannot tell the difference. Add it to Pitfalls as its own entry when this phase starts.
Two deliverables.
30a - Behavioural tests. A different assertion shape, applied to controls rather than functions:
- Drive the control, not the function:
document.getElementById('sg-inject')!.click(), notinjectIntoForm(v). That is what caught nothing before. - Assert on observable effect: a value the user can see, a toast whose text
is true, a repaint that happened. Not on
st.*. The Phase 12 lesson already says this - "assert this ondocument, not onst.*; five passing state-only tests missed it" - and it was applied to one bug rather than adopted as a rule. - Assert the negative branch is honest: with the precondition unmet, the control must not report success. That single assertion is the whole bug above.
- Cover the closed / hidden / not-yet-opened state of every panel, because that is where "the element exists but the user cannot see it" lives.
30b - A UI efficacy audit: "what does this control even do?" Walk every button, menu item and toggle in the app and answer three questions in writing:
- What does it do, in one sentence?
- What does the user see happen? If nothing, it is broken or it is decoration.
- What happens when its precondition is unmet - does it refuse honestly, or claim success?
Known candidates, before the walk even starts: the other three → Inject
buttons (same root cause, now fixed together); the Tools panes at 0% coverage
(chunks/env-link.ts, chunks/edit-modal.ts); the config-view click handlers
and the generator/calculator panes, which CLAUDE.md already lists as large
untested regions of render.ts and tools.ts.
Output is a checklist with a verdict per control - works / silent / lies / decoration - and a bug per non-"works". A control that lies is worse than a control that is missing, because the user stops looking for the real one.
Phase 35 - Stack integrations 📋
Depends on 31 (Nodes) for the ones that live on other machines; the read-only half of several can land earlier.
The homelab stack is where this product is actually used, and each of these is a
place secrets and config already meet - today by hand, in a .env nobody
versions.
They are not five features. Four of the five reduce to one adapter shape, and building the shape first is the difference between a phase and a treadmill:
source of truth → EnvVault projection, where the projection is one of: a rendered file (exporter), a scraped read (importer), or a live link (node).
| Integration | What it actually is | Shape |
|---|---|---|
| Prometheus | scrape_configs with basic_auth, bearer_token, tls_config - credentials inline in YAML, in a file nobody wants in git | new ChunkTypes + exporter; exactly the T3 case: a scrape job pointing at a target no chunk defines |
| Grafana | datasource provisioning YAML with secureJsonData, plus API tokens | exporter + enrich --online adapter (Grafana's API reports token scope and expiry) |
| Homarr / Homepage | dashboards whose widgets hold an API key per service - Homepage's services.yaml, Homarr's integrations | exporter, and the best fit on this list: a dashboard config is a list of services with credentials, which is what the vault already is |
| Nextcloud | app passwords, S3/object-store config, config.php secrets | importer + rotation adapter; app passwords are revocable per-device, which is what makes rotation actually safe |
| VS Code | different: an editor integration, not a config file | see below |
VS Code is the odd one and the most valuable. Not an exporter - an extension that shells out to the CLI that already exists:
${ref}completion and hover-preview (fingerprint, never the value) while editing a chunk or a.env.- A "run with secrets" task provider that wraps
envv exec, so a debug launch gets real credentials without a.envon disk. - Inline T2 exposure warnings in the editor gutter: this literal is a secret you hold.
- Codelens on a config file the vault also renders: drifted from EnvVault.
It needs no new backend at all - envv describe, envv exec, envv render
and envv diff are the whole API - and it puts the product where the work
happens. Sequence it after tier-1 #1 (envv mcp serve), because both are
"expose the existing command tree to an external client" and doing MCP first
settles the contract.
The rule that keeps this from becoming eleven more treadmills. §1 of the review is blunt about the existing eleven exporters: "every exporter is a promise to track upstream syntax forever… eleven treadmills, adopted to serve a feature nobody asked for by name." Five more formats makes it sixteen. So:
- Build the adapter shape first, as a declared template + a validator + an optional online probe. If an integration cannot be expressed in it, that is information about the shape, not a reason to hand-write another exporter.
- Every integration ships with a validation-matrix entry (Phase 18's rule: a job that fails when handed nonsense, or the green tick means nothing), and a golden fixture asserted from both TypeScript and Rust - 11 of 11 today, and this phase must not take it back down.
- Prometheus and Homepage first. Both are plain YAML with credentials inline, both are things the user runs, and both exercise the shape without needing an API adapter. Grafana and Nextcloud need live services and belong after.
New standing rule - UI ⇄ CLI capability parity
Whatever is doable in the UI must be doable from the CLI, and whatever is doable from the CLI must be doable in the UI.
Set by the user on 2026-08-31. Recorded as invariant 10 in CLAUDE.md
(appended, not inserted - four code comments already cite "invariant 9" and
renumbering would have silently pointed them at the wrong rule).
It is a capability rule, not an interaction rule, and the distinction is the whole of it:
- In scope: anything that changes the vault, changes a credential, produces an artefact, or answers a question about the data. If one half can do it and the other cannot, that is a bug with a name now.
- Out of scope: presentation. Sidebar order, section collapse, card size, drag-reorder, an icon grid, the undo toast. These are affordances for looking at things, and a CLI counterpart would be ceremony.
- The redaction policy is not an exception, it is the mapping. The UI's
reveal button ↔
--reveal; the UI's copy-to-clipboard ↔--out/exec/render. Phase 14's rule - stdout redacts by default, a vault-wide export to stdout is refused - stays exactly as it is. Parity of capability, not of default. - Two twins already prove the shape:
pools.tsreads pool state over IPC rather than reimplementing it, and the eleven exporters are one golden fixture asserted from both sides. Where a capability needs to exist twice, that is how.
The gap list as of 0.8.1
Audited from envv describe's leaf commands (78 of them) against the 21 tool
panes and the 41 Tauri commands. Not a guess - both sides enumerated.
In the UI, missing from the CLI
| Capability | Where it lives | Note |
|---|---|---|
Settings - theme, auto-lock, envCopyField, expiry warning | settings-panel.ts | envv config get/set. envCopyField in particular already changes what the CLI resolves, and can only be set from the app |
| Remote vault management - add, test, connect, TOFU pin, re-pin on rotation | remote-panel.ts | envv login --server covers auth only. No envv remote add/ls/rm, no fingerprint pin management, no probe_cert_fingerprint |
| Bulk operations - multi-select delete, export, tag | tools.ts | CLI is per-entry. A bulk delete is where the array-index bug class lived, so the CLI version must be id-keyed from the start |
| Entry diff | diff pane | Feeds tier-2 T2/envv diff; already on the list |
| Reset vault | reset_vault | Destructive; needs --yes and a non-tty refusal |
| "Open to LAN" | lan.ts | envv-server is the near-equivalent, but it is a different process serving a different file. Decide whether that counts before building envv lan |
| Utility panes - CIDR, cron, UUID/ULID, hash, string tools, JSON/YAML formatter, token validator, API-key patterns | tools.ts | Eight small ones. Cheap; envv gen hash, envv fmt, envv cidr, … |
| Template library management | templates pane | envv render consumes a template; nothing manages the library |
In the CLI, missing from the UI
| Capability | Command | Note |
|---|---|---|
| Metadata enrichment | enrich, enrich --online | The biggest gap. It fills account_name, scopes, expires_at, rate_limit from the issuer, and a 401 is the only reliable way to learn a stored credential was revoked. Entirely invisible in the app |
| Vendor import | import-vault (Bitwarden / 1Password / Proton) | Onboarding lives in the UI; the importer does not. Pairs with Phase 19's wizard |
| Vault diagnosis | doctor | Review already wants it on the unlock screen - the moment it is needed is the moment there is no terminal |
| Full-fidelity archive | backup archive, restore-archive | The UI has .vaultbak export/import only |
.env watch-and-sync | watch | No UI equivalent |
| Per-directory context | use, .envv.json | Arguably CLI-only by nature - a directory has no meaning in the app. Flag as a deliberate exemption rather than leaving it ambiguous |
| Run a command with secrets | exec | The materialisation path. A UI "run with these secrets" is plausible and is what a VS Code integration would surface (Phase 35) |
| Pool advance | pool next | The pane shows state and cooldowns but cannot advance the cursor |
| Strict-write toggle | user strict-write | Users panel has no control for it, and it changes what writes are allowed |
Exempt by nature, both directions - describe, completions, sessions,
whoami, login/logout, and every presentation affordance listed above.
Record the exemptions explicitly; an unwritten exemption is indistinguishable
from an oversight, which is how this list got long.
How it changes the plan
- Phase 26 grows a third deliverable, 30c: the parity audit becomes a
standing checklist, regenerated from
envv describerather than maintained by hand - the same "prefer the command to the list" disciplineCLAUDE.mdalready prescribes and under-applies. A test that diffs the two capability sets against a declared exemption file is the mechanism; anything else decays. - Several gaps are already phases.
envv diffis tier-1 #4,doctorin the UI is tier-1 #9,envv configoverlaps nothing yet. Fold rather than duplicate. enrichin the UI is the one worth pulling forward. It is the largest capability the product has that a user of the app cannot reach at all.
Phase 27 - UI ⇄ CLI parity 📋
Close the seventeen-entry gap list in New standing rule above, and make the
check mechanical: a test that diffs the two capability sets against a declared
exemptions file, with the CLI side regenerated from envv describe rather than
maintained by hand. A hand-maintained list of what is missing decays into a list
of what was missing once.
Pull enrich into the UI first - it is the largest capability the product
has that an app user cannot reach at all.
Phase 28 - The UI lab 🟡 scaffolded, never run
Depends on nothing. Cadence: after every major release, at the user's direction, not continuously in CI.
The gap this closes, stated precisely. Vitest runs in jsdom, and jsdom has
no layout engine: every offsetWidth is 0, no stylesheet is applied, nothing
has a position. So the 887-test suite is structurally incapable of seeing a
clipped label, a button pushed outside its panel, two controls stacked on each
other, or a 12×12 hit target. Those are the bugs a person notices in one glance,
and there are no human testers. This is the only way they get found.
Scaffolded this session, not executed (npx playwright test; the Vite
webServer did not come up inside the harness and debugging it was deferred):
| File | What it is |
|---|---|
playwright.config.ts | Chromium against npx vite; reduced-motion so a screenshot is deterministic |
ui-lab/viewports.ts | 9 sizes, each with the reason it is in the list |
ui-lab/seed.ts | A deliberately hostile vault - longest plausible provider name, CJK/RTL/emoji, four-tag rows, a 66-character category, nested projects |
ui-lab/audit.ts | Seven measured rules, run in-page |
ui-lab/layout.spec.ts | 12 screens × 9 viewports, screenshots plus a grouped findings report |
Why measurement rather than screenshots. A screenshot needs someone to
interpret it and cannot be diffed meaningfully across nine viewports. A rule that
says "this element's content is 40px wider than its box, overflow-x is
visible, and text-overflow is not ellipsis" is a bug report with a
selector attached. Screenshots are written alongside the findings so a human
or a model can confirm what the numbers describe - they are the evidence, not the
method.
The seven rules: page-level horizontal overflow; content clipped horizontally
with neither scroll nor ellipsis; content clipped vertically under
overflow-y: hidden; elements painted outside the viewport; interactive targets
under 24×24 (WCAG 2.2); controls with no accessible name; and interactive
controls overlapping each other - the last of which is invisible in every
screenshot because the top one looks perfectly fine and the one underneath is
simply unclickable.
Viewport matrix. tauri.conf.json declares minWidth: 860, minHeight: 600,
so 860×600 is a promise the app makes, not a hypothetical. Fractional scaling
(FHD @125%, 4K @150%) is in the list separately from size because sub-pixel
rounding is where a 1px border eats a glyph.
To finish it: get the webServer block to start Vite (run npx vite by hand
and point baseURL at it if the managed server keeps failing), run once, triage
the findings, and only then decide which rules are noisy enough to relax. Do not
relax a rule before seeing its output - the whole value is in the first run.
Related, and cheaper: the claude-in-chrome skill can drive a real browser
interactively for one-off "what does this actually look like" questions. That is
a complement, not a substitute: it is not repeatable and it does not measure.
Phase 36 - The provider catalogue 📋
Depends on the name decision in Phase 20. Blocks nothing; makes enrich
much better.
The goal. A public, static site that holds base information about a large set
of API providers - key prefixes and signatures, docs and rotation URLs, scope
vocabularies, rate-limit shapes, revocation endpoints, expiry semantics -
refreshed on a schedule, useful to anyone with a browser, and consumed by the CLI
and the app as the source for enrich.
What already exists to build on: enrich.rs carries ~40 issuer signatures
(ghp_, sk-ant-, AKIA, sk_live_, …) plus structural shapes (PEM, JWT,
postgres://), compiled into the binary. Today that table can only be updated by
shipping a release. That is the actual problem this solves.
Architecture, and the constraints that decide it.
- Static, GitHub Pages compatible. A scheduled GitHub Actions workflow
refreshes the catalogue and commits it; Pages serves it. Note that
schedule:in Actions is best-effort and routinely runs late - sometimes by tens of minutes under load. Design for "at least every 15 minutes" as an intention, never as a guarantee, and stamp every build withgenerated_atso a stale catalogue is visible rather than assumed fresh. - The client fetches the whole catalogue, never a provider. This is the hard rule and it is §3.3 exactly over again: a per-provider request tells the server which providers you hold credentials for - the same metadata leak the icon CDN had, arriving through a different door. One file, fetched whole, cached locally, diffed offline. If the catalogue ever grows too large for that, it is sharded by something unrelated to the vault (alphabetical buckets), never by what the caller wants.
- Ship a bundled snapshot. The network copy is an update, not a
dependency.
envv enrichmust work identically on an air-gapped machine with whatever shipped in the binary - same reasoning as bundling the icons. - Sign it, and pin the key. The catalogue drives
enrich, which writes into the vault. A tampered catalogue mislabels secret types, points rotation at an attacker's URL, or teaches the client that a live credential is expired. Signed manifest, key pinned in the binary, refuse on mismatch. This is the one part that must not be built casually. - It contains no secrets and no user data, by construction. It is public reference material about public APIs. Say so on the page, because a site attached to a secrets manager will be assumed otherwise.
Consumption: envv catalogue update|show|diff, and the same over IPC in the
app - invariant 10 applies from day one rather than being retrofitted.
⚠ The name collision decides the domain. envvault.dev is an existing,
unrelated SaaS product that also ships a binary called envv - that is
documented at README:1023 and is the open item in Phase 20. Publishing "the
envvault site" at a domain that already belongs to someone else's product is not
a naming inconvenience, it is a collision that will confuse every user who
searches for either. Resolve Phase 20's naming decision before this phase
starts, not after the content exists.
Phase 37 - Pentest before 1.0.0 📋
Depends on Phase 24 (route tests) for a baseline, and reaches its final form only after Phase 31 (Nodes) adds the second network surface.
Scope, as given: the user's own hub, their own nodes, and their own workstation. Authorised testing against their own infrastructure. Before any of it runs, put the scope in writing - hosts, ports, time window, what is explicitly belonging to a third party) - because "my server" changes meaning the moment a shared host or a hosted control plane is involved.
Two passes, not one. The surface keeps growing, so a single pass before 1.0.0 tests something that no longer exists by 1.0.0:
- 35a, after Phase 24 - the surface as it is today: desktop app,
envv-server, CLI, at-rest storage. - 35b, after Phase 31 - everything above plus the hub↔node channel, enrollment, and the apply path, which is remote code execution by design and is therefore the highest-value target in the product.
Surfaces and what each one deserves
| Surface | Focus |
|---|---|
| At rest | SQLCipher parameters, the Argon2id cost, .vaultbak's PBKDF2 iteration count, whether the key is actually zeroized (inspect a core dump, do not trust the call) |
envv-server | The 21 routes across the full authz matrix; the tier hierarchy and min_tier_to_grant; the rate limiter; the new session ceiling; If-Match races against concurrent writers |
| RBAC | permex evaluation, strict-write ALL-scope coverage, the new project/category merge - that code is days old and is the newest privilege boundary in the product |
| Desktop | IPC command surface (41 commands), CSP, WebKitGTK behaviours, the pinning proxy and the TOFU bootstrap |
| CLI | argv and environment leakage, sessions.json and pools.json permissions, --password-command handling, what lands in shell history |
| Nodes (35b) | Enrollment token replay, fingerprint pinning both directions, the reload allowlist as a command-injection boundary, approval token binding |
| Supply chain | cargo audit, cargo deny, npm audit, the GHCR image, and the release workflow's own permissions |
Methods worth naming, because "pentest it" is not a plan
- Static:
cargo audit,cargo deny,semgrep, CodeQL on both languages. - Fuzzing is the highest-yield item here and is currently absent entirely.
Three parsers take untrusted input and none is fuzzed: the vault JSON
(invariant 4 says it is untrusted and the enforcement is a human), the permex
expression parser (a security decision procedure), and the eleven config
parsers.
cargo-fuzzover those three. - API: the server already emits an OpenAPI document via
utoipa, soschemathesiscan drive it directly - property testing against the spec, which also catches where the spec lies about the implementation. - Crypto review: nonce derivation in the
.vaultbakAES-GCM envelope, constant-time comparison paths, TOFU re-pin behaviour on rotation. - Timing: the legacy SHA-256 comparison was already fixed once for this; verify it stayed fixed, and check the permex evaluator does not leak scope membership through latency.
Deliverable is a review/review-NN.md in the existing format - findings
ranked by consequence to the user, each with a repro and a file:line, and
an explicit list of what was not looked at. A pentest report that claims full
coverage it did not have is worse than no report.
What Worked
- Reading the review's file:line references and re-verifying each before touching anything. All four §3 defects were still live at current HEAD. The review's own warning ("check it before acting") was worth following and cost about ten minutes.
- Writing the §3.1 tests before believing the fix. Two of them failed on the first correct-looking implementation and exposed the absent-vs-empty hazard, which would have been a worse bug than the one being fixed.
- Revert-verification on the pool trim. One test failed, the other twelve passed - which is what "load-bearing" looks like. A revert that fails nothing means the code was never doing anything.
poolsOf()already existing, exported, and already guarding untrusted input. The sidebar reused it and inherited both properties free.
What Didn't Work
ex("*")is not a valid permex expression. Five tests failed on the parse, not on the logic.vault:*is the wildcard.- The first §3.1 implementation deleted every visible project whenever the
submission omitted
projects, which the existing tests do. Caught by those tests, but only because they happened to submit partial documents. PersistedViewbeing fully required broke seven unrelated assertions inui-persist.test.tsthe moment a field was added. Optional is also the honest type for something read back out oflocalStorage.npm installtimed out on the first attempt, twice (ETIMEDOUT), and worked on a plain retry both times. Not a proxy problem; just retry.simple-iconsdoes not export its ownpackage.json, sorequire('simple-icons/package.json')fails withERR_PACKAGE_PATH_NOT_EXPORTED. Read it off disk fromnode_modulesinstead.- A backtick inside a template literal that holds a serialised function. The
in-page audit is a
\…`string handed topage.evaluate, and a comment inside it reading "visibleoverflow is usually intentional" closed the literal at that word.tscreportedTS1005: ',' expected` sixty lines later, which points at the symptom rather than the cause. - Playwright's managed
webServernever came up, and Vite started fine by hand. Left for Phase 28 rather than debugged; the harness is the deliverable this session, the first run is not.
Next Steps
- The ordered plan is Phase plan - 19 through 37 above. What follows is the detail for the two items at the front of Phase 24, kept here because they are the next thing anyone should actually type. Everything else lives in the plan; do not maintain a second copy of it here.
- Phase 24 is now the front of the queue, minus its first two items (§3.2
and §3.3 are done). What remains there: the ~30 HTTP route tests, the
permextwin pair, and the lint backlog. - Two things this session left for someone to decide, both cheap:
- 55 registry slugs have no icon in
simple-icons@16.29.0- trademark removals (microsoft, slack, twitter, openai, java, oracle, linkedin …). They render as letter avatars, which is what they already rendered as when the CDN 404'd them.npm run iconsprints the list every run. Either drop the dead rows fromSI_REGISTRYor accept the letters. SI_REGISTRYhad 29 duplicate slugs and one duplicate Fly.io row, so the picker listed those providers twice. Deduped;categoryis documented as grouping the picker and is in fact never read, which is why nobody noticed. Either render the grouping or drop the field.
- 55 registry slugs have no icon in
<details> <summary>Original §3.2 / §3.3 notes, kept for the record - both now done</summary>
-
§3.2 - the CLI exporter gap.
envv-cli/src/chunks.rs:403defaults toenvfor haproxy, k8s, traefik, apache, ansible, postgres and ssh_config, soenvv project export my-lbwrites a.envthat is nothaproxy.cfg, or fails withnot_found: has no env_file chunks- a diagnosis naming neither cause nor fix. The error message also still calls those types "experimental", which Phase 18's graduation contradicts. The fixtures for all seven already exist intests/fixtures/parity/(apache.conf,haproxy.cfg,ansible.yml,k8s.yaml,ssh_config,traefik.yaml,pgpass) and are asserted from TypeScript only. Porting the seven exporters toexporters.rsand adding them toenvv-cli/tests/parity.rscloses the defect and takes the parity guarantee from 4 of 11 formats to 11 of 11. The four simple ones (apache, haproxy, ansible, postgres) are ~160 lines of TS between them. Minimum acceptable fix if the port is deferred: the default-format match returns an explicitinvalidnaming the type, and--formatgets avalue_parser. -
§3.3 - bundle the icons.
src/ts/icons.ts:779fetches every provider logo fromcdn.simpleicons.org, so a third party and any on-path observer learn the exact set of services you hold credentials for, per vault open. The README opens with "talks to nothing else … no telemetry".simple-iconsis an npm package; generating theSI_REGISTRYsubset at build time and dropping the host from the CSP closes it permanently. </details> -
Review §7.1 - ~30 HTTP-level server tests (
tower::ServiceExt::oneshot, one per route across no-auth / wrong-auth / scoped / owner). Highest-value day available in the repo, and a hard prerequisite for Phase 31. -
Only the human can do:
- Confirm the 24h default
--session-max-hourssuits how you actually use the LAN share. It will now log a peer out after a day however active they are. - Decide the
envvname collision (Phase 20). It is the last item in that phase, it is a decision rather than code, and after 1.0 it is a breaking change. - Decide whether node
applywill be enabled on a production target before Phase 34. If yes, T5 moves into 31 - see the sequencing warning there.
- Confirm the 24h default
Notes for whoever picks this up
- Nothing is committed. The working tree carries this session's changes on
top of pre-existing uncommitted work (
calendar.rs,context.rs,timeline.ts,telemetry.rsand others were already modified or untracked before this session started). Checkgit statusand decide what belongs in which commit - do not assume everything in the tree is from here. - Two new devDependencies, both offline-safe once installed:
simple-icons(build-time only, feedsnpm run icons) and@playwright/test(Phase 28; its Chromium was already in~/.cache/ms-playwright, so no browser download was needed). - New files this session:
scripts/gen-icons.mjs,src/ts/icon-paths.ts(generated, committed, 387 KB, in.prettierignore),playwright.config.ts,ui-lab/{viewports,seed,audit,layout.spec}.ts,tests/{pool-sidebar,icon-bundle,inject-into-form}.test.ts,tests/fixtures/parity/field-aliases.json. Cargo.lockmoved -cargo update, semver-compatible only. The exact pins did not move and should not be moved without the round-trip fixture described under Dependencies refreshed.merge_user_vault_writechanged signature. Four arguments now:(full_vault, user_data, read, write). One production caller (envv-server/src/lib.rs) and five tests.AppState::newchanged signature.session_max_hourssits betweensession_ttl_minsandlan_mode. Two production callers.CLAUDE.md's phase table is now wrong in a new way. It stops at 20 and calls 17–20 "staged, not started"; this document plans every phase from 19 to the 1.0.0 pentest, and records that 17 and 18 shipped, 20 is all but done, and 19 is barely begun. Fold the new table intoCLAUDE.mdwhen Phase 24 starts - not before, because a plan copied into two files is a plan that will disagree with itself, which is the exact failure §6 of the review documents.- The
review/review-01.md§6 documentation-drift table is still entirely unaddressed -CLAUDE.mdstill says Phases 17–20 are "staged, not started", still says there are no tags, still says 755 tests and ten tool panes. The test count in this document isnpm testoutput from today; do not copy it intoCLAUDE.mdas a second place for a number to go stale. - Two things worth promoting into
CLAUDE.md's Pitfalls Reference when someone next edits it, both cost time this session:- jsdom has no layout engine. Every measurement is 0 and no stylesheet applies, so no Vitest test can ever see clipping, overlap or hit-target size. That is not a coverage gap to fill; it is the wrong instrument. Phase 28.
- A backtick inside a template literal holding serialised code closes it. The error surfaces far from the cause and reads as a syntax error in unrelated code.
- The user set a standing rule this session (invariant 10, UI ⇄ CLI
capability parity) and two long-term goals that are now Phases 36 and 37.
Phase 36 cannot start until the
envvnaming collision in Phase 20 is decided, and Phase 37 needs a written scope from the user before anything runs. - The next handoff is
24-phase-22-designed-credential-shape-copy-profiles-env-nam.md. The next review isreview/review-02.md.
7 line(s) about the maintainer's own machines were left out of this page.