Sealed secrets for codellm role notes
Status: Built (2026-08-21). The seal CLI, the seal endpoint, the unseal step and the bag contract are all in.
Builds on: codellm_extraction.md (env passthrough, the requires_secrets hardening it sketches at the end of "The wire protocol"), fleet_run.md, internal/dataencryption.
TL;DR
A code role that needs a secret (the live case: KRISP_TOKEN in
docs/fleet/krisp/roles/transcript-ingest.md) can only get it today by the operator putting
the value in codellm's own environment and naming it in CODELLM_EXPOSE_ENV. Adding a secret
means editing docker-compose and redeploying, and the allowlist is flat — every code role on
that codellm sees every exposed value.
The chosen design puts the ciphertext in the role note's frontmatter and keeps the master
key in codellm alone. The note declares which frontmatter fields are sealed; codellm decrypts
exactly those, substitutes the plaintext into the delivery bag for that one run, and the role's
code reads them like any other frontmatter field. fleet never holds the key and never sees
plaintext; trip2g stores the ciphertext as ordinary note content and knows nothing about it.
---
description: Krisp meetings → transcript notes (cron ingest, deterministic)
fleet_id: codellm
mode: cron
cron_schedule: "* * * * *"
write_patterns:
- transcripts/**
unseal: [krisp_token]
unseal_env_key: SEAL_KEY # optional; SEAL_KEY is the default
krisp_token: sealed:v1:9nR2v0QkX8tWc1pE...
krisp_base_url: https://api.krisp.ai
---
cfg = fleetkit.frontmatter() # the role's own frontmatter, ciphertext included
sec = fleetkit.secrets() # what codellm unsealed for this run
base_url = cfg.krisp_base_url.rstrip('/')
token = sec.krisp_token
There is no unseal() call in the role, no env var mapping, and no master key inside the
sandbox.
Decided: unsealed values go in their own secrets section, not inline in the frontmatter.
Inline would read more uniformly — cfg.krisp_token beside cfg.krisp_base_url — and that
uniformity is why this design beat the alternatives in the first place. It loses to one thing:
the bag is what a role dumps while debugging, and print(cfg) or emit(answer=json.dumps(bag))
would publish the token into vault content, which is the store this whole feature exists to keep
clean. A separate section costs one asymmetry in the role's source and takes the secret out of
the object most likely to be dumped whole. The asymmetry is also honest: those two values are not
the same kind of thing.
Note what it does not buy: dumping the whole bag still carries the secrets. This narrows the
accident, it does not remove it.
The pivotal fact
The instance operator is also the owner of the secrets. There is no multi-tenant hosted
fleet where several vault owners each hold their own credentials.
This single fact decides the design, so it is recorded here rather than left implicit:
- It makes a symmetric master key coherent. The standard objection to a Rails-style
credentials.yml.encin a product is that the party doing the encrypting is a user who must
not hold a key that decrypts everyone's secrets. With operator == secret owner that objection
is void — this is exactly Rails' trust structure, where the encrypters are the team who
legitimately holdmaster.key. - It removes the need for a per-vault-owner scoping mechanism, which is the one thing an
embedded ciphertext gives for free and a sharedsecretstable does not. - It makes "the operator can read the secret at run time" a tautology rather than a leak.
If that fact ever changes, most of the reasoning below has to be redone.
State it precisely, because the useful form is narrower than "single operator": what the design
relies on is single role authorship — that whoever can create a note under the watched
--agents-folder glob is trusted with the secrets sealed anywhere in the vault. Operator and vault
writer are not automatically the same set; the system already has delegated-admin sessions and
note-writing agents such as the telegram inbox. Everything below scales with who can author a role
note, not with who owns the deployment.
What this does and does not protect
codellm hands the plaintext to the code it executes, so whoever operates the codellm host can
always read it — /proc/<pid>/environ, the bag file, a patched binary, or simply a print()
added to the role. No storage scheme changes that; it is a deploy-time property.
What sealing buys is that the secret is not in the vault in cleartext while the vault travels
through Obsidian sync, git, backups, and note sharing, and is not in cleartext in trip2g's DB or
its dumps. Note that today's env approach has the same property by a different route (the secret
is in no vault at all), so sealing is not competing against a plaintext baseline. What it
actually adds over today is:
- Provisioning without redeploy. A secret is added by editing a note, not by editing
docker-compose and restarting codellm. - Scoping — of accidents, not of access.
CODELLM_EXPOSE_ENVis flat: every code role on that
codellm gets every exposed value. A sealed blob is decrypted only for the role that carries it,
which contains the blast radius of a careless role. It is not access control: the ciphertext is
ordinary vault content, so anyone who can author a role note can copy another role's blob into
their own frontmatter, declare it inunseal, and print it. AAD binding would close the copy
(see Open questions); git history would not. - Per-role key separation.
unseal_env_keylets different roles seal against different
master keys, so compromising one key does not open the others, and a key can be rotated by
migrating roles one at a time instead of re-sealing everything at once.
Options considered
A. Status quo — codellm env + CODELLM_EXPOSE_ENV
The operator holds values in codellm's environment and allowlists the names
(cmd/codellm/appconfig/config.go:175); buildChildEnv supplies them from codellm's own
os.Environ() (cmd/codellm/internal/coderun/coderun.go:476). Empty allowlist = expose nothing.
Not broken, and for a single operator not even wrong — "the operator can read their own secret"
is tautological. Its real costs are ergonomic: adding a secret needs a redeploy, the allowlist
lives in docker-compose detached from the role note that depends on it (an undocumented surprise
dependency), and the allowlist is flat.
Kept, and not as a fallback. For an operator who already runs Docker secrets, Kubernetes
secrets or a Vault agent, the credential is already in a process environment and sealing is
duplicate bookkeeping. This is the normal path; sealing is what a role reaches for when a secret
belongs to it alone, or when adding one must not require a redeploy.
The one thing that changed is that env_passthrough / env_prefix came back as role fields, this
time as an intersection: the operator allowlist is still the boundary and a role can only
narrow its own share of it. Declaring neither keeps the previous behaviour (the whole allowlist),
so no existing role note is affected. Note this is a tightening, not a relaxation — before it, one
code role saw every secret every other code role on that codellm needed.
B. requires_secrets manifest + the existing secrets table
trip2g already has an encrypted secret store: secrets (key unique, value_crypt blob)
(db/schema.sql:829), AES-256-GCM via internal/dataencryption, use cases under
internal/case/admin/{setsecret,getsecret,listsecretkeys,deletesecret}, an admin UI at
assets/ui/admin/secrets/, and no read-back mutation — values never leave the server. It is
already consumed by key-prefix convention (change_webhooks:<id>:<name> →
GetSecretValues(ctx, prefix+"%"), cmd/server/config.go:168).
The role note would carry only the name (requires_secrets: [KRISP_TOKEN]) — the hardening
codellm_extraction.md already sketches — and the value would live in that table.
Best rotation and revocation story of any option: one row to update, deleteSecret as an instant
kill switch, nothing in git. Most consistent with the existing webhook pattern.
Rejected on cost and on architecture. It needs GraphQL surface, admin UI work, a scoping
convention, fleet-side resolution, and delivery wiring — and delivery reverses a documented
decision: codellm's design explicitly refused to carry secrets in the request from fleet. Over
HTTP there is no way to reach codellm's child env without crossing the wire, so "inject into the
env, not the JSON body" is cosmetic; the reversal would have to be owned honestly. Against that,
the chosen design needs no trip2g change at all.
C. Symmetric Rails-style ciphertext, encrypted through a web UI
Ciphertext in the note, master key in server config, and a UI where the user pastes a plaintext
secret and gets a blob back.
Rejected. The UI is the problem, not the ciphertext. If the browser holds the master key, a
decrypt-everything key is distributed to clients — strictly worse than today. If the server
encrypts on request, the plaintext crosses the wire to trip2g, which is byte-for-byte what
setSecret already does, so the blob buys nothing over the existing store while adding a second
crypto path.
The insight that killed it also killed the need for it: Rails' credentials:edit is a CLI,
not a web UI. With operator == secret owner, a seal subcommand is the whole "UI".
D. Asymmetric sealed blob (age / libsodium sealed box)
codellm publishes a public key; the operator seals offline with age; only codellm can open it.
Structurally the only variant where plaintext never crosses any wire at encryption time, and the
only one that gives per-vault-owner scoping by construction.
Rejected as unnecessary here. Asymmetry earns its keep when the encrypting party must not be
able to decrypt — i.e. multi-tenancy, which the pivotal fact rules out. filippo.io/age is a
fine dependency, but it would buy a property nobody needs while internal/dataencryption already
provides AES-256-GCM in-module. Revisit if multi-tenancy ever becomes real.
E. unseal() inside fleetkit
fleet forwards the frontmatter in the bag; the master key is exposed to the child through the
existing CODELLM_EXPOSE_ENV mechanism; the role calls
token = fleetkit.unseal(fm.krisp_token).
Genuinely attractive: zero Go changes in codellm, decryption is an explicit, debuggable line
in the role's own code, and no new format knowledge anywhere in Go.
Rejected because it puts the master key in the environment of every code child. Any role can
then decrypt any blob it can reach (and it can reach other roles' frontmatter via attach_notes),
so the scoping win disappears — the flat-allowlist weakness of option A comes back in a new form.
It also needs unseal implemented twice, in fleetkit.py and its node twin
(cmd/codellm/fleetkit/node/index.js).
F. Targeted unseal in codellm, injected as env vars
codellm decrypts the role's sealed fields and injects them into the child env under derived
names (krisp_token → KRISP_TOKEN), so existing role code reading os.environ['KRISP_TOKEN']
keeps working unchanged.
Superseded by the chosen design. It drags in a name-mapping rule (case conventions, collisions
with CODELLM_EXPOSE_ENV names, two roles wanting different values under one name), and it splits
the role's inputs across two sources: the secret from os.environ, the neighbouring non-secret
config from the bag. Substituting into the bag removes that whole class of questions.
Chosen design
Option F's key handling with the bag as the delivery surface.
The note
Three frontmatter fields matter:
| Field | Meaning |
|---|---|
unseal |
list of frontmatter field names whose values are sealed |
unseal_env_key |
name of the env var in codellm holding the master key; defaults to SEAL_KEY |
| the named fields | sealed:v1:<base64(nonce‖ciphertext)> |
unseal is an explicit declaration rather than sniffing values for a sealed: prefix, because:
codellm fails loudly on a declared field it cannot open, instead of silently passing ciphertext
through as an ordinary string (a typo in a blob becomes an unseal error, not a 401 from the
upstream API minutes later); a reader sees at the top of the note which fields are secret; and
codellm never attempts to decrypt arbitrary strings.
sealed:v1: carries the format version so the algorithm can change without invalidating existing
blobs.
The flow
- fleet puts the role's own frontmatter into the delivery bag. It does not decrypt anything
and holds no key. - codellm reads
unsealfrom the bag, resolvesunseal_env_keyagainst its own
environment, decrypts those fields, and replaces the ciphertext with plaintext in the bag it
writes to$FLEET_INPUT. - The role's code reads config through
fleetkit.frontmatter()and unsealed values through
fleetkit.secrets(). Both return the attribute-access mappingfleetkitalready uses for
frontmatter, so a missing key reads asNoneinstead of raising.
The ciphertext stays in the frontmatter as it was written. codellm adds the plaintext alongside
rather than substituting it, so a role can still tell that a value was sealed, and the bag's
frontmatter section is a faithful view of the note.
trip2g needs no change: it stores and serves the note like any other, ciphertext included.
Where it touches the code
fleet (cmd/fleet/internal/fleet/):
ParseRole(role.go:38) currently consumes the flat frontmatter mapmand drops it; it must
retain it onRole, plus parseunsealwith the existingparseListandunseal_env_key.fleetinput.Input(internal/fleetinput) gains a frontmatter field. It is the package already
shared by fleet and codellm, so the contract lives in the right place.buildInputBag(handler.go:237) andbuildCronInputBag(handler.go:360) populate it.
fleet parses unseal rather than passing the raw string because the frontmatter arrives
stringified: noteViewResolver.Meta (internal/graph/schema.resolvers.go:3014) renders each
value with fmt.Sprintf("%v", value), so unseal: [krisp_token] arrives as the literal
[krisp_token] — the form role.go:246 already warns about and parseList already handles.
fleet normalising it keeps that quirk out of codellm. The same stringification means bag
frontmatter is strings only: a numeric or boolean frontmatter value arrives as its text form,
so nothing downstream should expect typed values.
This weakens "fleet knows nothing about secrets" to "fleet never sees plaintext and never holds
the key" — it knows a list of field names. That is the property that matters and it is intact.
codellm (cmd/codellm/):
handleChatCompletions(internal/codellm/server.go:237) already splits body and bag via
extractBodyAndBag(:297); the unseal step goes between that and buildingcoderun.CodeInput.- The master key comes from config, alongside
ExposeEnv/ExposeEnvPrefix
(appconfig/config.go:175). - Crypto is
internal/dataencryption(AES-256-GCM), imported the same wayinternal/fleetinput
already is. No new crypto, no new dependency. - Sealing has two front doors, a
sealsubcommand and an HTTP endpoint — see below. Together they
are the entire provisioning UI.
fleetkit: nothing. bag() already exposes the delivery bag; the frontmatter simply appears
inside it, and the node twin gets it for free.
Key handling and guards
The master key must be exactly 32 bytes (dataencryption.NewManager). trip2g already has this
convention for DataEncryption.Key — a raw 32-character string in config, with boot refusing the
default value (internal/appconfig/config.go:735) — and it should not be reinvented.
The key must never reach a child process. That is the entire difference between this design
and option E, and it is undone silently by one line of docker-compose: listing SEAL_KEY in
CODELLM_EXPOSE_ENV, or letting CODELLM_EXPOSE_ENV_PREFIX sweep it in, forwards the master key
to every code child and turns this back into option E without anything visibly breaking.
So codellm refuses:
- at startup, if the default
SEAL_KEYis inExposeEnvor matchesExposeEnvPrefix; - at unseal time, for whatever name a note supplied in
unseal_env_key, since without a
registry of key names codellm only learns custom names from notes.
Same predicate, two moments. It is a deploy mistake, so it should be loud.
A note may name any env var in unseal_env_key, and that is accepted: a wrong key cannot reveal
a value, because NewManager rejects anything that is not 32 bytes and AES-GCM rejects a wrong
key on the authentication tag. What remains is a presence-and-length oracle over codellm's whole
environment, and note that the residue is the success/failure boolean, not the error text —
generic messages narrow it but do not remove it. Bounded by the fact that both surfaces now
require auth. A declared unseal field that is absent from the frontmatter should fail as
loudly as one that cannot be opened. The thing to avoid is propagating the underlying error text
outward — NewManager reports "data encryption key must be exactly 32 bytes, got %d", which
would give a note author a length-and-existence oracle over codellm's environment, surfacing
through the 422 into fleet's run logs and the delivery trace UI. codellm returns
unseal failed for "<field>" and keeps the detail in its own log.
Sealing: CLI and endpoint
Producing a blob must not require a terminal on the codellm host, so sealing has two front doors
over one operation.
CLI (built). codellm seal --env-key SEAL_KEY_V2 reads the plaintext from stdin and prints
sealed:v1:.... --env-key names the master key and defaults to SEAL_KEY, mirroring the form's
field. The value comes from stdin rather than a flag on purpose: an argv secret is visible in the
process table and lands in shell history — the same class of leak as a value in a query string.
HTTP (built). One path from config — CODELLM_SEAL_PATH / --seal-path, defaulting to
/_system/codellm/seal (the /_system/ prefix matches trip2g's own admin endpoints, and Caddy
already forwards /_system/codellm/* to the service, so the default needs no infra change).
GET renders a small HTML form, POST performs the sealing. Two fields — the env var name
holding the master key, defaulting to SEAL_KEY, and the value to seal — so the operator can
paste a credential in a browser and copy the blob straight into a note. It is always enabled; the
base path is configurable for deployments where the default collides or is inconvenient, and a
path already served by another route is refused at boot rather than panicking the mux.
Four rules, none of them optional:
-
The value travels in the POST body. The
GETcarries nothing but the empty form. Keep it
that way: a secret that reaches a URL — a GET parameter, a redirect — lands in reverse-proxy
access logs, browser history, andRefererheaders, where a provisioning path leaks worse than
the thing it protects. Enforced rather than assumed: the handler readsPostFormValueonly and
refuses any request carrying a query string. The rendered result carries the blob but not the
plaintext, which would otherwise sit in the DOM and the back-forward cache. -
Never log the value, at any level. codellm's existing habit of logging exposed env names
and never values (logExposedEnv,internal/codellm/server.go:314) is the standard to match. -
Scrub the unsealed plaintexts from every error, preview, and returned log. This is mandatory,
not hygiene, because one existing path leaks them into trip2g's database deterministically.
ParseCodeOutputembeds up to 200 bytes of the child's stdout verbatim when the JSON contract
fails to parse (internal/coderun/coderun.go:369-375,"...(got: %q)"). The doc's own
"realistic failure" — aprint(fm)before the protocol line — is exactly what makes stdout
unparseable, so the decrypted token rides that preview into the 422, into fleet's run error, into
AgentResponse.Message/Logs, and into the delivery-log store and trace UI. That is the store
sealing exists to keep clean. Captured stderr tracebacks take the same route. Unlike general
output scanning — dismissed elsewhere in this document as a control — this is precise and cheap:
codellm knows the exact strings it decrypted for this run and can redact those, with no
heuristics and no false positives. -
Generic errors. Same rule as unseal:
NewManager's"must be exactly 32 bytes, got %d"must
not reach the response, or the endpoint becomes a length-and-existence oracle over codellm's
environment for anyone who can reach it. -
Wrapped in
cfg.Auth, like its neighbours. This is a requirement, not an inherited property.
Handler(internal/codellm/server.go:214) gates the two browser-facing endpoints —
/v1/chat/completionsand/_system/codellm/graphql, including the playground'sGET— through
s.cfg.Auth, which in production isBrowserAuth(delegatedAdmin, TokenCheck): an api_key lane
and a delegated-admin cookie lane. Only/healthzand/v1/modelsare open. A seal endpoint
registered as a baremux.HandleFuncwould be the weakest thing on that mux, not an equal of
the execution endpoint beside it. Both verbs go through the same middleware, the same way the
GraphQL playground'sGETalready does.Note that
codellm_extraction.md's risk 2 ("an unauthenticated/v1/chat/completionsis
RCE-as-a-service") describes the state before that auth seam existed. It no longer holds, and
reasoning that treats codellm as ungated — including any argument that a new endpoint cannot make
things worse — is reasoning from a stale premise.
The endpoint carries no enable/disable switch. Authorization is already answered by cfg.Auth, so
a flag would be answering a deployment question inside the binary: whether this address is
published, to whom, and behind what — reverse proxy, VPN, mTLS — is an operations decision, and the
configurable base path exists to serve it. Always registered, always gated.
Costs, honestly
- Rotation of a leaked upstream token: re-seal, edit the note, sync. Cheap at this scale, but
it is note editing rather than a single row update. - Rotation of a master key: re-seal every blob sealed against it.
unseal_env_keysoftens
this — roles migrate to a new key one at a time — but it never becomes a one-liner. - Vault history: old ciphertext lives in the vault's git history forever, and a later leak of
the master key decrypts it retroactively. This is the property Rails has and everyone accepts,
but it should be named. - No instant revocation: there is no
deleteSecretequivalent. Killing a secret means editing
the note. - The plaintext lands in the bag, and the bag is a file (
FLEET_INPUT=,coderun.go:479).
Two consequences: the value is on disk inside the sandbox for the run, and — more likely to
bite — the bag is the natural thing to dump while debugging.print(fm)or
emit(answer=json.dumps(bag))publishes the token into vault content. Leaking through env
requires deliberately reaching foros.environ; here the secret rides inside the object most
likely to be dumped wholesale. - codellm learns the seal format. Its "no vault, no KB, no auth, no secrets" invariant becomes
"holds one master key". Weighed against N plaintext values in its env today, this is still a
reduction. - Roles not on codellm silently do not work. An
executor: llmrole's bag goes to a real LLM,
where nothing unseals it. Safe — the model sees ciphertext — but quietly non-functional. - The chat endpoint is a decryption oracle for anyone holding a channel credential. codellm
trusts the bag: whoever canPOST /v1/chat/completions— fleet with its api key, or any
delegated-admin cookie — can submit a synthetic bag carrying any blob ever sealed under a key
codellm holds, including ciphertext rotated away long ago and recovered from vault git history,
together with a body that prints it. Putting the seal endpoint behindcfg.Authdoes not touch
this; the execution endpoint is the oracle. So "fleet never sees plaintext and never holds the
key" describes the honest dataflow only — fleet's credential is sufficient to obtain any
plaintext on demand. Acceptable under the pivotal fact, but this is the design's real trust
boundary and should not be left implied intact. - The friction being removed was also the authorization gate. Today, granting a role a secret
requires editing docker-compose and redeploying — an out-of-band act that doubles as "the
operator specifically approved this role getting this value". Under sealing,unseal: [krisp_token]committed into a note is sufficient on its own: the next run has the plaintext,
with no separate infrastructure step. That is precisely the ergonomics being bought, and the
authorization checkpoint is what it is bought with. It holds only while vault-edit rights and
infra-deploy rights belong to the same person. A collaborator with Obsidian sync access but no
docker-compose access would, under sealing, be able to grant secret access; today they could not.
This is a narrower form of the pivotal fact, and it deserves its own line because it can stop
being true earlier: the operator may still run role code they did not author line by line — a
shared recipe, a generated script — and sealing removes the manual step that would have made them
look at it.
What this deliberately does not solve: exfiltration
Once a role holds a secret, nothing stops it from writing that secret into a note. Scope
enforcement checks the path, never the content (ScopedKB / write_patterns), so a role with
a legitimate secret and a legitimate write scope can publish the secret into the vault, and from
there onto the site. This is not a regression introduced by sealing — it is equally true of
today's CODELLM_EXPOSE_ENV path — but sealing does not improve it either.
One escalation path this design depends on is closed separately: a role that can
write note content could otherwise write a new role note declaring unseal
for any blob plus its own unrestricted write_patterns. ScopedKB now refuses
to let an agent author or edit a note carrying fleet_id — see
fleet_write_validation.md, "Built: agents may not
author role notes".
A stronger shape was considered and deferred: a separate request-building block type
(saferequest) taking a declarative request spec — jsonnet or JSON/YAML; internal/jsonneteval
already exists — where secrets are substituted only while constructing the outgoing request and
are never handed to general-purpose code at all. The role would describe which requests it may
send, not hold the credential.
It does not confine the secret on its own. If the spec can also choose the destination, a role
points its base URL at a service the author controls, that service echoes the credential back in
the response body, and ordinary code reads it out of the response — the secret is back in hand
having never been "held". Confinement requires pinning the destination, not just the shape of
the request. The templating is the easy half.
Scanning outgoing writes for the secret value is not an answer either. It catches an accidental
dump — print(fm), emit(answer=json.dumps(bag)), which is the realistic failure here — and
codellm can do that precisely, since it knows exactly which plaintexts it unsealed for the run.
But base64, XOR, or splitting the value across notes defeats it trivially, and the network channel
bypasses it entirely. It is a safety net, in the same category as a linter. It is not a control.
Where real confinement lives — and why it is not this mechanism
If the goal is for the executing code to be genuinely unable to leak a credential, the answer is
not a better way to store it. It is to cut the code off from the network and route all egress
through a proxy that holds the credentials and injects them into outgoing requests. Then the
code never holds the secret at all, and write(secret), output scanning, and encoding tricks all
stop mattering — there is nothing in the sandbox to encode. The destination pinning that the
saferequest idea above was missing comes for free, because the proxy decides where traffic may
go.
Half of it already exists: codellm's sandbox has a network switch (SandboxNetwork), so "no
direct internet" is a knob, not a new mechanism.
Two shapes, with very different cost:
- Intercepting proxy. The role speaks HTTPS to the real host; the proxy terminates TLS with
its own CA installed in the sandbox trust store, injects auth, and re-originates.mitmproxy
with a script addon or Envoy with a Lua/WASM filter does this. TLS interception — not the
allowlist — is where the complexity is. - Proxy as upstream. The role speaks plain HTTP to a pinned route (
http://proxy/krisp/...);
the proxy adds authentication and originates TLS outward. No CA, no interception, and the
destination is pinned by proxy config rather than by anything the note says. Substantially
cheaper, and sufficient for a known set of upstreams.
Off-the-shelf tooling splits along the same seam and is usually assembled rather than bought
whole: egress allowlisting (smokescreen, Squid ACLs, Envoy as an egress gateway) on one side,
enrichment and audit (mitmproxy addons, Envoy Lua/WASM, API gateways with upstream-auth plugins)
on the other.
This is deliberately out of scope. Confinement is a property of the execution environment —
network policy and egress control — not of a secret-storage format, and building it is not this
mechanism's job. Sealing makes no attempt at it.
What unseal is, stated plainly: a trade of confinement for provisioning ergonomics. It buys
"add a secret by editing a note, scoped to that role, with no redeploy" and buys nothing at all
against a role that decides to publish the value it was given. That trade is acceptable here for
one reason — the pivotal fact above: the person authoring the roles is the person who owns the
secrets, so the role is not an adversary. Under any other trust structure the trade would be
wrong, and the proxy would be the requirement rather than the alternative.
The two are complementary rather than competing. If the proxy is ever built, secrets move into its
configuration and sealing becomes optional — at the price of a configured route per upstream,
which is exactly the provisioning ergonomics sealing exists to provide.
Open questions
-
Whether
unsealshould also be honoured on attached notes. Answer: no, and the code
already enforces it.buildAttachedNotes(internal/webhookutil/attached_notes.go) constructs
Metaexplicitly fromtagsandlayoutonly — arbitrary frontmatter never travels that path —
and codellm unseals only the role's own frontmatter, so cross-role unsealing is impossible by
construction rather than by omission.Recorded as a standing invariant, because the convenient answer is dangerous:
SEAL_KEYis
the defaultunseal_env_keyfor every role, so in the common case all roles share one key.
Extending unsealing to attached-note frontmatter would let any role decrypt any other role's
sealed fields by merelyattach_notes-referencing it — reopening exactly the cross-role leak
option E was rejected for, by a different route.Caveat:
AttachedNote.Contentcarries the full note text, so another role's ciphertext is
still readable. That is what makes the AAD question below matter, and it is why option E's
rejection stands. -
Standing invariant: the
exectool must never receive the bag.makeExecInvoker
deliberately sends nofleet_inputmessage (cmd/fleet/internal/agentruntime/runtime.go:683,
"No fleet_input message: exec runs inline"), so an LLM role's mid-runexecnever sees unsealed
values. If anyone later adds the bag toexeccalls, plaintext flows into a real model
conversation and out to the provider. -
Whether to bind the ciphertext to its location with AAD.Decided: no AAD.dataencryptionpassesniladditional
data, so nothing ties a blob to the note and field it belongs to: anyone who can read a blob can
paste it into their own role under the same key and have it decrypt — relocation, not a crypto
break. The obvious binding is the note path, and it has a real cost: renaming or moving a role
note would break every blob in it, silently, at the next tick — and moving notes around is a
routine Obsidian operation. Binding only the field name does not prevent relocation, so the
half-measure buys nothing. Note also thatEncryptData/DecryptDatatake no AAD parameter and
are shared with thesecretstable andcmd/tge2e, so this means a new method beside them, not
a change to them.Rejected because the cost lands on an everyday operation and the benefit does not. Moving a note
between folders is routine in Obsidian, and binding to the path would break every blob in it
silently, at the next tick. What binding buys is preventing one role from copying another's
ciphertext — and under the pivotal fact the roles are written by the same person who owns the
secrets, so that is not an adversary. Revisit only if role authorship ever spreads beyond one
trusted party, which is the same condition that reopens the rest of this design. -
What the
--dry-runreconcile report should say about a role whoseunsealfield cannot be
opened — a startup-time validation would catch a bad blob before its first cron tick.