Threat model
What Zest protects, from whom, and how. Each line names the code that does it, so a reviewer can check the claim.
What is worth protecting
- Your files. Zest writes HTML pages, images and its own
.zest/folder. - Your keys. Image-model and Pexels keys, read on your machine.
- Your agent's reach. Submit starts Claude Code, which can act as you.
- Your spend. Image generations and agent runs cost money.
Who might try
| Threat | What they could try | What stops it | Where |
|---|---|---|---|
| Another website open in your browser | Call the local server to write files, read pages, spend credit, or press Submit | The server answers only to localhost host names (stops DNS rebinding); every write needs a token made when the server starts, given only to the editor; a write from another origin is refused; no CORS headers, so other sites cannot read answers | server.mjs (host check, X-Zest-Token, origin check) |
A website, through your app's dev server (it rebinds its own name to your app's port, which forwards /__zest to the Zest service) | Get an editing session, or read or write a page through the app's proxy | A session needs an Origin that is the page's own, on this machine, mapped by a repo you registered (an attacker's page sends its own name); Referer and X-Forwarded-Host must agree; every editor call, read or write, needs the session's token; a session touches one page | service.mjs (session, sessionFor), server.mjs (pin) |
| A website, at the Zest service directly | Open files, mint sessions, read pages it serves | Host must be the service's own address; pages it serves sit at an address made from a secret in Zest's config file; only the zest command (which can read that secret) may ask it to serve a file; no CORS headers | service.mjs (host check, /__zest/f/, /__zest/serve) |
| A freeze frame | Point Chrome somewhere else, or run the page's scripts again | Chrome loads only the page's own address on this machine and port, with scripts off, over a pipe (no port); refresh tags and scripts are removed from the markup | freeze.mjs (checkUrl, clean), shoot.mjs (pipe) |
| A repository you cloned | Ship a zest.json whose build, check or extension runs code as you | Nothing a zest.json names runs until you approve that exact command in that folder, from a terminal or Claude Code; a changed command or extension file needs approving again; approvals live outside the repo | config.mjs (fingerprints, zest trust) |
| A page you open | Its own scripts act through the editor (they run on the editor's address, or the editor runs in theirs with Edit here) | The token is removed from the page after the editor starts; a session touches only its one page; a run it could start edits only that page's folder, cannot reach the web, and stops at your cap. Pages you do not trust: ?zest=off, or zest shoot | editor.js, runner.mjs, service.mjs |
| Text in a page or a note (prompt injection) | Tell your agent to do something else during a round | Claude Code enforces the round's permissions, not the prompt: edits only inside the page's folder (and the folders an approved extension's listener names), Bash only for zest, no web fetch or search, no MCP servers unless an approved extension names one; chained commands are checked part by part; spend and turn caps | runner.mjs (--permission-mode dontAsk, allow list, --strict-mcp-config, caps) |
| A picked image address | Make Zest fetch from your own network (SSRF), hang it, or fill the disk | Every address and every redirect is resolved and refused if private or local; one deadline for the whole answer; a size cap; SVGs with script are refused | images.mjs (assertPublic, fetchCapped, saveAsset) |
| A crafted path | Read outside the served folder, or a hidden file such as .env | Paths are resolved and must stay inside the root; any path segment starting with a dot is refused | server.mjs (safePath) |
| Another local process | Read your keys | Keys live in your environment, .env beside the page, or Zest's config file at mode 600; the browser only ever learns that a key is set and its last four characters | keys.mjs |
| A broken save | Lose or corrupt your page | Every write is atomic, keeps the file's mode, goes through symlinks, keeps a version first, and is refused if the tab is stale; non-UTF-8 pages are read-only | store.mjs, server.mjs |
| An organisation's rules | Someone turns on what IT has not approved | A policy file outside any repo turns Run, generation, network, extensions or the release check off for everyone on the machine; a broken policy locks everything it could | egress.mjs |
| The release check | Learn something about you from the daily request, or push a fake release | One GET to GitHub's public releases list with no cookies, keys, file names or page content; Zest only reads a version number from it and never downloads or runs anything; updating stays a command you run | update.mjs |
| The button | A site uses it to open files | It opens a page only when a repo you registered maps that address, never a hidden path, and only for a browser carrying the cookie you set | service.mjs (/__zest/open) |
What Zest does not try to stop
- Code you approved. An approved build, check or extension runs as you, like any script you run yourself. Read what
zest trustlists before you say yes. - Your own agent with your own permissions. A prompt you paste into Claude Code runs with whatever you allow there; Run's limits apply to Run.
- Someone with your user account. They can read your files without Zest.