This document is the threat model: what odin trusts, what it executes on your behalf, and what that means for you. It's written plainly, not to alarm you — odin does real, useful things to your machine by design, and you should go in with your eyes open.
Odin runs whatever you draw on the canvas, for real, on your Mac. A
canvas is not a sandboxed description of infrastructure — it's closer to a
shell script. If you wouldn't run a script from a given source, don't
odin canvas set or odin import-tf a canvas from that source either.
Odin has no authentication of its own. Anything that can reach the
control app's port (default 127.0.0.1:4200, loopback-only — see below)
can apply a canvas, and applying a canvas can do anything the local user
account can do: run containers, boot VMs, write files under .odin/.
The trust boundary is the machine and the person running odin, not odin's own routes. There is no login, no API token, no per-user permission model. This is intentional for a local-first dev tool, not an oversight — but it means the bind address and the canvas source are your only real controls.
Clicking Apply materializes and runs a canvas's nodes for real:
- Container images — an ECS node's
imagefield is pulled and run verbatim (agent/hcl.py→ the gateway →compute/tasks.py), with the image's own entrypoint: odin's generated task definition carries onlyname/image/essential/portMappings, and the canvas has nocommandfield for ecs, so a canvas cannot supply the process to run — only the image that supplies it. (compute/tasks.pywould honour acommandin a task definition, and a task definition registered against odin's gateway by something other than the canvas — a direct AWS-SDKRegisterTaskDefinitioncall — can therefore run one. That is the same trust boundary as every other gateway call: the machine.) Nothing sandboxes or scans the image; odin trusts it the waydocker run <image>trusts an image. - Lambda code — any Lambda node's inline code (
agent/hcl.py,compute/functions.py) is zipped and executed verbatim inside the function's runtime container. Same absence of sandboxing or scanning. - EC2 user-data as root — an EC2 node's
userDatafield (agent/hcl.py) becomes a cloud-init script (compute/cloud_init.py) that runs as root inside a real Lima VM on first boot. There is no review step between drawing the node and that script executing. - Terraform/OpenTofu itself — Apply shells out to
tofu applyagainst odin's own gateway.import-tfparses arbitrary HCL you paste in.
None of this is a bug to be patched away — it's the point of the tool (NORTHSTAR.md: "a local-first AWS"). The mitigation is the same one you'd apply to any script: know where the canvas came from.
Treat a canvas (or a .tf file you import-tf) from someone else
exactly like a shell script you're about to run — read it, or trust the
source, before you Apply it.
odin start (and --dev) bind 127.0.0.1 unless you explicitly pass
--host. Given there's no authentication, the bind address is the actual
access boundary: loopback means only processes on your own machine can
reach it at all.
--host 0.0.0.0 (or any LAN address) is available for people who
genuinely want that — e.g. driving odin from another machine on a trusted
network — but it's opt-in, and doing it prints a warning every time:
odin has no authentication — anyone who can reach this port can run containers on this machine. Don't do it on a network you don't trust.
As defense-in-depth against a browser-based attack (a malicious page
sitting open in a tab, POSTing to localhost:4200 behind your back), the
control app also rejects any state-changing request whose Origin or
Referer header is present and not loopback. This doesn't replace the
bind-address boundary — it narrows the blast radius of a compromised or
malicious web page you happen to have open while odin is running.
The gateway (the SigV4-verifying reverse proxy on port 4266) is
different and binds 0.0.0.0 deliberately: workload containers reach it
via host.docker.internal, which needs the host's real interface, not
loopback. That's safe specifically because every single request the
gateway handles is SigV4-verified before it's classified or forwarded —
there's no equivalent per-request check on the control app, which is why
its own default is loopback instead.
A drawn security group is real enforcement — a compiled Nebula firewall on
the mesh — but it governs exactly ONE path: the overlay address odin
publishes as DATABASE_URL_MESH / endpoint_mesh (and an EC2 node's
MESH_IP). Every backing also keeps its published Docker host port,
because odin's own probes, the gateway's forwarding and host-side clients all
ride it, and nothing gates that port: any process on your Mac, any
container that can reach the host, and any EC2 Lima VM (via
host.lima.internal:<port>, which is precisely what the DATABASE_URL_VM
fact hands it) reaches the database with no security group in the path.
Closing the host path would mean making the Mac itself a data-plane mesh
member — a host tun device, i.e. root/sudoers — which odin rejects
outright. So the honest boundary is: security groups govern traffic between
drawn resources over the mesh; they do not sandbox your own machine. If you
want a consumer to be subject to its security group, give it the *_MESH
fact.
Moving a resource out of a security group on the canvas is real: odin re-signs that member's Nebula certificate with its new groups and restarts its daemon, so the peer re-handshakes under the new identity and new connections are refused before Apply even returns (measured at 0.11s). A membership change odin cannot apply fails the Apply rather than reporting success.
It also kills a connection that was already open through the path you just revoked. That took a second mechanism, because nebula's firewall keeps a conntrack entry per flow and re-validates it only when its own ruleset version changes — never when a peer's certificate does. So odin also advances the admitting member's ruleset version whenever anyone's group membership moves in that environment: a reload whose rules are byte-for-byte identical, which makes nebula re-check every flow it is already holding against the peer's current certificate. It is a reload, not a restart — no other tunnel is dropped.
Measured end to end, with a real VM holding a real TCP session to a real
Postgres across the revoke and then pushing a genuine startup packet down it:
the session timed out (dropped), while a still-permitted port on the same
database over the same tunnel answered connection refused at the same
instant — so the silence is a firewall decision, not a dead overlay. Before
this, that same session was answered
(R\x00\x00\x00\x17\x00\x00\x00\nSCRAM-SHA-256).
What that does not mean:
- It is not instant, and there is no fixed number. The flow dies when the Apply carrying the revoke finishes its mesh passes — re-certifying and restarting every member whose groups moved, then reloading every admitting member — so the window is bounded by that Apply's duration, and it scales with how many members are involved and how busy the machine is. Two runs on one laptop bracket it: ~2–5s across 20 separate revoked flows (each proven live with a real round trip immediately beforehand, against both kinds of admitting member), and 12.8s for a single flow through an earlier, slower Apply path. Neither figure is a property of odin; a larger environment will be slower than both. Until the passes complete, the open session keeps working.
- It depends on an ordering odin controls but cannot prove. The admitting
member re-checks the flow against the certificate it currently holds for the
peer, so the peer must already have re-handshaked under its new one. odin
enforces that (every re-certified member is restarted and pokes each peer
before any admitting member is reloaded, all synchronously). If that poke
fails, the admitting member can re-validate against the old certificate,
stamp the flow as current, and that one flow survives — until it closes on
its own or nebula's
firewall.conntracktimeouts expire it. A later Apply will not revisit it, because nothing about the membership has changed by then; another membership change, or restarting the admitting member, will. - It covers members odin gates. The admitting member can be an EC2 VM or a database (a backing container with a mesh sidecar); both are handled. The published host port is still not gated at all — see above.
ROADMAP's security-group section documents the mechanism in full.
A field like an RDS password is stored, and used, in cleartext:
- It lands in
.odin/canvas.json, in every immutable Stack revision under.odin/<env>/stacks/, and in.odin/<env>/world.json(which can also carry a live, resolvedDATABASE_URLwith the password embedded — the Fabric reads it from there to wire up other nodes' env vars, so it can't be redacted without breaking that). The same resolved fact is appended to the durable event log.odin/<env>/events.jsonl, and the value tofu was sent is in the generated.odin/<env>/tf/main.tfand comes back in.odin/<env>/tf/terraform.tfstate(plusterraform.tfstate.backup). - Every file odin creates that can carry a secret or a credential is
0600(owner read/write only), and every directory odin creates to hold one is0700:.odin/itself, each.odin/<env>/, and thegateway/,stacks/,tf/andnebula/(plusnebula/hosts/) directories inside an env — the only real protection is that another local account on the same machine can't read them. Anyone with your user account, or root, can. The exhaustive file list, because a mode you can't verify is worth nothing:canvas.json,<env>/stacks/*.json,<env>/HEAD,<env>/world.json,<env>/events.jsonl,<env>/keys.json,<env>/gateway/*.json, the files in<env>/tf/(including tofu's ownterraform.tfstateand its.backup),<env>/nebula/*, and anodin exportarchive.- Two of those are not odin's files to write: tofu creates and rewrites
terraform.tfstate/.backupitself, at0644under the default umask. Odin pre-creates both0600before everytofuinvocation, which sticks because tofu's local state manager rewrites state in place (same inode, truncate, write — no rename), verified against OpenTofu 1.12 across two applies and a destroy. Anything else tofu leaves in the workspace is re-tightened on the next materialize. - v0.7.0 and earlier got this wrong for exactly the files listed above as
"the ones that matter":
tf/main.tf, both state files,events.jsonland the export archive were world-readable (0644). Fixed in v0.7.1; restoring an old archive tightens its files on the way in, and an old workspace is tightened on the next Apply. - The directory half of the claim was false through v0.7.2, and a
fresh-user audit caught it:
.odin/and.odin/<env>/were0755whenever a writer that used a plainmkdir(the goaws config file,odin start's pidfile directory) created them before the private-mkdir helper did — while.odin/default/was0700, because there the order happened to run the other way. Every such writer now goes through the one helper, and that helper tightens a directory it finds group- or world-accessible, so an existing store is healed rather than left as the first writer set it. - One directory under
.odin/is not0700, and deliberately so:<env>/tf/.terraform/, which is tofu's, not odin's.tofu initcreates it (0755under the default umask) and re-creates it on every run; it holds provider plugins and their metadata — hundreds of files, no canvas value ever among them — so_lock_downinsimulate/workspace.pyskips that subtree instead of re-chmod'ing plugins on every apply. The three workspace files that DO carry secrets (main.tf,terraform.tfstate,terraform.tfstate.backup) sit beside it, not inside it, and are0600. Concretely:find .odin -type d ! -perm 700prints<env>/tf/.terraformand its subdirectories, and nothing else — the only other thing that can appear there is a directory a pre-v0.7.3 odin left0755and nothing has written to since, which the helper above tightens on the next write.
- Two of those are not odin's files to write: tofu creates and rewrites
.odin/is gitignored, so a normalgit add/commitwon't leak it into a repo — but nothing stops you from committing it deliberately, so don't.- Fields that look like a secret (
password,secret,token,keyin the name) are flagged internally and kept out of places that don't need the real value: the translation agent's prompt to the Claude API, and every line oftofu's own apply/destroy log output. They are not redacted anywhere the real value is functionally required (the reconciler, the generated Terraform that actually gets applied,world. json's ref-resolution facts) — redacting those would just break the feature while giving a false sense of security.
A secret node (Secrets Manager) and an ssm node hold values that are
the secret — that's the whole point of the node — and they land in the same
places, plus one more:
-
The canvas JSON and every immutable Stack revision, both
0600— and both still cleartext; the encryption below covers the gateway sidecars only. -
The per-env gateway sidecars are ENCRYPTED at rest (v0.8.18). The values in
.odin/<env>/gateway/secretsctl.jsonandssmctl.jsonare AES-256-GCM envelopes (odin-kms-v1:<keyId>:<base64>) under real key material odin generates and keeps at.odin/<env>/kms.json,0600— the same mode and the same directory askeys.json. Every secret and every parameter, including a plainString.KmsKeyId/KeyIdname the key that really did the sealing; a value with no key named goes under the env's default key, so this is unconditional rather than opt-in. Measured bytests/gateway/test_kms_at_rest.py, which reads the file off disk. -
This paragraph used to say the opposite, and the retraction matters. Until v0.8.17 it read: "A
SecureStringis not encrypted at rest. There is no KMS in odin ...KmsKeyId/KeyIdare accepted and echoed back for Terraform fidelity while encrypting nothing." That was accurate for eleven releases. One clause of it survives: aSecureStringis still stored byte-for-byte like a plainString, because odin encrypts both — the type is still not the protection, it is just that what both get is now real. -
What the encryption does NOT buy, said plainly for the same reason the old limit was. The key file is in the same tree as the ciphertext and owned by the same user, so this separates two FILES, not two permissions: anyone who can read
.odin/can still read your secrets. odin runs unattended and has nowhere to hide a key it must also be able to use. What you do get: the plaintext is not in the sidecar, so anything that ships or leaks the gateway directory alone carries no secret; a destroyed KMS key is destroyed data, immediately and loudly (DecryptionFailure/InvalidKeyId, naming the key); and a ciphertext is bound to(env, service, name), so one moved between records or environments fails to open rather than answering as the wrong secret.docs/limits.mdcarries the full bound. -
What does protect a value: it never enters a World fact, so it never travels on the WebSocket or into
world.jsonorevents.jsonl; it's redacted out of the translation agent's prompt and out of every streamedtofulog line; and reading it back through the gateway requires a principal an IAM edge allows — no edge, realAccessDenied. (On the tofu log path odin's scrub is belt-and-braces rather than the sole protection: the AWS provider already printssecret_stringas(sensitive value). Belt-and-braces is deliberate — the provider's judgement of what is sensitive is not odin's to rely on.) -
The tofu workspace is where the plaintext legitimately appears, because
tofuhas to send it:tf/main.tfon the way out andtf/terraform.tfstate(+.backup) on the way back. Three files, not one — and the file mode above is the whole of their protection. -
An
odin exportarchive is a copy of all of the above: it contains the env's issued gateway credentials (keys.json), the gateway's secret and parameter sidecars together withkms.json, the key that opens them (without it the restored env would be unrecoverable, so it has to ride along), and every canvas secret in the stack revisions,world.jsonand the tofu workspace, unencrypted, in a file that is easy to email or drop in cloud storage. An archive is exactly as sensitive as it was before the sidecars were encrypted — that is the honest reading, and the reason it is spelled out here rather than left for someone to infer the opposite. The archive is written0600and every member inside it is stored0600, so a restore can only ever tighten a store's modes, never loosen them. Treat it like a private key file — and note that the mode does not survive the things people do to archives:scp, a chat upload, or an object store will give the copy whatever mode it likes. -
odin importrefuses to restore into a live store, because a running odin holds reconcilers and an in-memory World that would keep reconciling against a store they never read. Liveness is proved, not guessed: a running server holds an exclusive lock on.odin/lock, and the guard asks the kernel who holds it. It reads no process's command line — v0.7.1 did, and called an operator's own shell a live server mid-restore. The lock file holds only a pid; it is not a secret, and it is not a security boundary either. Anyone who can write your.odin/can already do worse than take a lock, and--ignore-live-serverdeliberately skips the check, so treat the refusal as a safety interlock for you, never as access control.
If you need real secret hygiene (rotation, least-privilege access,
encryption at rest), odin's local .odin/ store is not that system — treat
canvas secrets as dev/test-grade, not production credentials. That holds for
a secret or ssm node exactly as much as for an RDS password: it's a
faithful API surface for the thing you drew, not a vault.
Please don't open a public GitHub issue for a security problem. Use
GitHub's private reporting instead: on the repo, go to Security → Report
a vulnerability (or https://github.com/kessler-frost/odin/security/advisories/new).
Include what you found, how to reproduce it, and the impact you think it
has. We'll get back to you.