zero-knowledge
Zero-Knowledge as Architectural Blindness (2/5)
Most secret managers expose getSecret(name), and the plaintext lands in the AI's transcript. The lodos schema makes that exchange structurally unanswerable, the AI cannot see a secret's value, and the build enforces it.
Your AI assistant just asked to see your Stripe live key. Most secret managers would let it, the API exposes getSecret(name), the agent calls it, the plaintext lands in the transcript, and from there into whatever pipeline the agent talks to. The interesting question is what kind of schema makes that exchange structurally unanswerable.
For lodos I picked a position that's easy to advertise and hard to engineer: the AI cannot see the value of any secret in the vault. Not "won't", cannot. There is no code path that returns a plaintext secret to the calling AI. The Prisma schema has no column the AI could read; the IPC layer has no method that returns a decrypted field; the MCP tool registry has no tool whose contract is give me the value. If a future me tries to add one, the build script catches it before merge.
This post is the schema walkthrough, what it actually looks like to design architectural blindness instead of policy blindness, and what you give up in exchange. The vault is the layer I'm most proud of, partly because the engineering is honest about what it doesn't do.
The two-store model
The naive design is one column: encryptedJson holds the section payload, you decrypt on read, you serve fields from the decrypted blob. That works, and it has one bug class, any read path eventually decrypts. The lookup that wanted account_id (public) also decrypted api_key (sensitive). The audit log might catch the wasted decrypt; the leak to the LLM transcript probably doesn't.
So vault sections have two stores, side by side:
model SecretSection {
id String @id @default(cuid())
name String // "stripe", "aws", "github"
encryptedJson Bytes // libsodium secretbox(sensitive-fields, sectionKey)
nonSensitive Json // { account_id, region, username, ... } - plain
fieldSchema Json // [{key, type, sensitive, required}, ...]
keyWrapped Bytes // sectionKey, wrapped by masterDEK
// ...
}

Every field knows whether it's sensitive: true or sensitive: false. The routing happens once, at write time. Sensitive fields go through libsodium; non-sensitive fields land in a plaintext JSON column. Reading a non-sensitive field is a SQLite read, no decrypt, no DEK access, no audit write. Reading a sensitive field requires the master password.
You lose: the ability to lie to yourself about which fields are which. You can't decide at read time. The schema forces the call upfront.
You get: a bug class deleted. The chat MCP tool secrets_field_metadata returns the real length for non-sensitive fields and 0 for sensitive ones, not because we hid the length, but because the metadata path never touches encryptedJson. The discipline holds at the type level, not at the comment level.
The encryption pipeline
The sensitive side runs on Argon2id and libsodium. Nothing exotic:
// On unlock
const masterDEK = await argon2id(masterPassword, {
memLimit: 64 * 1024 * 1024, // 64MB
opsLimit: 3,
salt: vault.argon2Salt,
});
// HMAC check against vault.masterKeyCheck - fail-fast on wrong password
// On read of a sensitive field
const sectionKey = unwrap(section.keyWrapped, masterDEK);
const plaintext = sodium.crypto_secretbox_open(
section.encryptedJson, section.nonce, sectionKey,
);
const value = JSON.parse(plaintext)[field];
sectionKey.fill(0); // zero-fill before GC

Three properties matter for the blindness claim. First, the master DEK lives in main-process memory only, never in the renderer, never in the chat-engine subprocess, never in an IPC payload. Second, a compromised single section is not a compromised vault: each section has its own random sectionKey, wrapped by master DEK. Third, the unlock state has an idle timeout of five minutes and an absolute cap of thirty; after that, every section is one re-prompt away from being unreadable.
This is the part where I expect security-minded readers to say "yes but" and start asking about memory dumps, swap files, GPU residency. Fair questions, mostly out of scope here. The relevant point is that the AI cannot reach into this pipeline. The Agent SDK runs in a subprocess that has no access to vault state; it talks to the main process over IPC, and the IPC surface has no method that returns plaintext.
Pattern A, B, D: never C
There are exactly four ways a secret can be used in this system:
- A: Never leaves the vault. A secret is referenced by name in a workflow, decrypted main-process side, injected into a subprocess env, never returned to the AI. The AI sees
{{ secrets.stripe.live_key }}in YAML and an HTTP 200 in the response. - B: Narrow egress filter. A value like
postgres://user:pass@host/dbis decrypted main-process side, a filter extractshostonly, and that becomes theallowedEgressvalue. The secret as a value never crosses the IPC boundary; a derived projection does. - C: Decrypt and return to the AI. ❌ Does not exist. No MCP tool registered. No IPC method. No code path. If you grep the codebase for any tool name matching
_value_get$, the result is empty by construction. The build script enforces that. - D: Inject and run. A subprocess is spawned with the secret in its env. Six layers between the secret and any AI surface: env-only (not argv), encoding-redactor on stdout/stderr, no-shell-string-build, content-firewall on output, HMAC audit log entry with opaque path, hard-coded egress allowlist per call.

The point of writing these as patterns is that C is the one most products ship. Their secret manager tool returns the plaintext to the agent loop, and they call that integration. Pattern A through D are integration too, they just don't put the secret in the transcript.
The trade is honest. Some workflows are easier with Pattern C. None are necessary with Pattern C. So we ship A, B, D, and we use a build-time grep to keep C out.
The opaque audit chain
The audit log is the part that surprised me most when I designed it.
A vault audit row says "section X, field Y was injected by actor Z at time T, signature S, prev-signature P." The natural schema stores fieldPath as plaintext. That's a leak: anyone with read access to the audit table sees that the AI agent injected stripe.live_key, not just that it injected something. For a regulated buyer doing compliance review, that audit table itself becomes secret material.
So the schema doesn't store the path. It stores the hash:
fieldPathHash: sha256(sectionName + '.' + fieldKey).slice(0, 16)
A sixteen-character opaque token. Even the error responses use it: when injection fails, the error is { pathHash: "a3f2…", sectionExists: true }, never { field: "stripe.live_key", error: "not found" }. The audit reader, running in the main process in the user's own context, does the reverse lookup. The AI never sees the plaintext path, and a leaked audit table reveals nothing about which secrets are stored.
The chain is HMAC-linked, every row signs (payload + prev-signature) with a sub-key derived from master DEK. verifyAuditChain() runs on startup; a tampered or deleted row breaks the chain and surfaces in the UI as a forensic alert. Hard-delete is forbidden; archive is a soft-delete with the row left in place.
You can screenshot the audit table and show it to a SOC2 auditor. The auditor sees an actor identifier, a section, an opaque field hash, a timestamp, and an HMAC chain. The actual secret names don't leak. That is what compliance buyers are paying for.
When the user couldn't tell the architecture from a bug
During the first real dogfood, a user ran echo $API_KEY through secret_inject_and_run. The call failed with MCP_LETHAL_TRIFECTA_GATE. The user took it to the AI assistant for diagnosis; the AI spent about 3k tokens explaining that the command was rejected because it was "static text" and recommended switching to a curl call. Wrong. The gate condition was allowedEgress.length === 0 && riskTier === 'novel', the call had no egress allowlist, so it was rejected as potentially exfiltrating without a declared destination.
The bug ticket got reclassified as moat-win. The architecture worked correctly, and the AI's confusion was itself evidence: the system refused based on egress shape, not command content, and the AI's wrong diagnosis was downstream of an unhelpful tool description, not a broken refusal. We edited the description; the gate stayed put.
The lesson I keep returning to is that a refusal the AI doesn't understand is still a refusal. Architectural blindness doesn't require the LLM to be on board with it.
What this is worth commercially
The pitch I make to founders thinking about this kind of work is straightforward. SOC2 and ISO buyers will not accept "we promise" as a policy. They will accept "we cannot." A vendor that can see your live keys and promises not to is a vendor that occasionally has CVEs in their logging stack. A vendor whose schema cannot see the value is a different category of risk, and that category prices differently.
The Phase-3 path I left open from day one is per-recipient wrap. The section key is already an indirection; today it's wrapped once by master DEK. In a team setting, it gets wrapped N times, once per recipient public key, and the server still cannot decrypt because it doesn't hold any private key. The wrap is additive, not a rewrite, because the per-section indirection was kept generic from the first commit. That door stays open without any feature shipped for it now.
The next layer
Schema design protects the secret at rest and in inject. But schemas are code, and code can be edited. The next post, 32 Build-Time Invariants That Fail My Build If I Regress, walks through the script that keeps the Prisma schema from regressing, the workflow engine from growing an eval, and the MCP tool registry from sprouting a secret_value_get. Refusal becomes a grep, and the grep ships with its own fake violation to prove the grep still has teeth.