Security and privacy
On this page
Zest runs on your laptop and talks to nobody unless you ask it to.
What the server accepts
- Localhost only. It listens on
127.0.0.1, and refuses any request whoseHostis not127.0.0.1,localhostor[::1]on its port. That stops a web page from reaching it through DNS rebinding. The same holds for the Zest service (below), which runs the editor for every page in one process. - Writes need a token. Each server makes a random token at start and puts it only in the pages it serves. Every write must carry it, and a write from another site's origin is refused even with it. Another tab cannot change your files or spend your API credit.
- Inside its folder only. Paths cannot climb out of the folder it serves, and it never serves hidden files (
.env,.git,.zest). - No surprises in the file. The editor is added to the page as it is served, never written to disk. A page's own Content-Security-Policy
<meta>is removed from the served copy only, so the editor can load. - Scripts on the page are trusted like the page. The page you open runs on the Zest server's address, so its own scripts (and anything they load) can read files Zest serves and act through the editor, as the page itself can. The token is removed from the page once the editor starts, which keeps it from scripts that load later, but this is a convenience, not a wall. Open pages whose scripts you trust; for a page you do not, use
zest shootor?zest=offto look without the editor.
Submit (the button that starts your agent)
- Off until a person says yes, in Claude Code or a terminal.
/artifact-editor:setupshows what a run may do and records the yes with its date in Zest's config file. No click in a page can turn Submit on. - Least privilege, enforced by Claude Code itself. A run starts
claude -pwith permission modedontAsk: it may read and edit files in the page's folder (Edit(//that/folder/**)), read the plugin, runzestcommands and start a subagent. Every other tool is refused, and the model is told. Web fetch and web search are removed outright, and no MCP server loads unless an approved extension names the tool it needs. A command chained onto an allowed one (zest x && curl …) is refused, because Claude Code checks each part. - Capped. A spend cap and a turn cap stop every run. Stop ends it at once.
- Wider only where your team says. An approved extension's
listenermay let rounds on its pages edit one more folder (an app's source) or run a named command. Changes and Setup list each one; the web stays denied. - Another model only if you set one.
zest runner worker(or your team's extension) can point rounds at another model behind an Anthropic-compatible endpoint. Its key is read on this machine when a round starts and goes only to that address; your Anthropic key is never passed to it. The page you edit is sent to that model, so pick one your organisation allows. - Your rules still apply. The run loads your Claude Code settings, so your own deny rules and hooks gate it too. The exception is a round your team's extension splits into tasks: each task starts slim, without settings files from the folders it starts in, and loads only the hooks and MCP servers the extension names (so a team that gates writes with hooks names those hooks). The files that carry them are written for that round into a private temporary folder (owner-only) and deleted when it ends.
- Parallel tasks stay in their own copies. A task that runs beside others edits a git worktree in your temporary folder, not your checkout, and may not edit your checkout. Its changes come back by a three-way merge that never overwrites a line you changed meanwhile; a collision leaves your file as it was and runs the task again in place.
- The reviewer only reads. When a team turns it on, the reviewer that labels answers after a round runs Claude Code with the Read tool only, on the round's trace folder and the page's folder, capped.
- On the record. Each run's full log is kept in
.zest/runs/. A run that stops early closes its round, so the page never claims your agent is still working. - What a page could do. A page open in the editor runs its own scripts with the editor's rights, so a hostile page could press Submit. The run it starts can only edit that page's own folder, cannot reach the web, and stops at your cap. Open pages you trust;
?zest=offshows a page without the editor.
Your team's zest.json
A zest.json arrives with every clone, and it can name a build, a check or an extension: code that would run as you. Zest runs none of it until a person approves that exact command in that exact folder (zest trust). The approval is a fingerprint; change the command, or a file of the extension, and it waits again. Approvals live in Zest's config file, never in the repository. Built-in packs ship with Zest and need no approval.
For an IT team: the policy file
One JSON file outside any repository, writable only by an administrator, turns features off for everyone on the machine. The editor and the command line both obey it, and the editor says what it fixed.
| Platform | Where |
|---|---|
| macOS | /Library/Application Support/Zest/policy.json |
| Linux | /etc/zest/policy.json |
| Windows | %ProgramData%\Zest\policy.json |
| any | the path in ZEST_POLICY |
{ "runner": false, "generate": false, "allowHosts": ["cdn.jsdelivr.net", "api.openverse.org"], "extensions": false, "message": "Ask IT before turning these on." }
| Key | Effect |
|---|---|
runner: false | No Submit; the editor copies the prompt instead |
generate: false | No image generation |
network: false | No network at all: library, search, downloads, generation |
allowHosts | Only these hosts (and their subdomains), checked at every redirect |
extensions: false | No team extensions from repositories (Zest's own packs still work) |
updates: false | No daily check for a newer release |
message | Shown next to anything the policy turned off |
A policy file that is not valid JSON locks everything it could lock, rather than nothing.
What Zest can touch, on screen
More > Setup > What Zest can touch lists the folder the server serves and the address it listens on, the files it writes, whether Run is on and exactly what a run may do, where each key came from, what the repository asks to run and whether you approved it, which extensions are loaded, the policy in force, and every network call the server has made since it started: the host, why, the status and the size.
The Zest service
The service runs at login, listens on 127.0.0.1:4470 only, and is the one server Zest needs: it runs the editor for every page in its own process (Editing in place).
- Its own address only. It answers a request only when its
Hostis127.0.0.1:4470orlocalhost:4470. A dev server forwarding/__zestchanges the Host to that (Next.js does it itself; Vite needschangeOrigin: true), so nothing wider is accepted. A site that rebinds its own name to this machine sends its own name, and is refused. - The button. A page loads it only in a browser marked with the
zest_editcookie (set byzest service hello); every other browser never calls your machine. It reads nothing from the page except its address. - Edit here gets a session for one page. The editor inside a running app's page asks the service for a session through the app's own address. The service gives one only when all of these hold: the browser carries the cookie; the request's
Originis the page's own and ishttponlocalhost,127.0.0.1or[::1]; itsRefererandX-Forwarded-Host, when present, name the same page; and a repo you registered says which file is behind that address. A registered repo covers its git worktrees (checkouts that share its git directory), so a page in one of them counts; no folder outside those checkouts does. The session is a random token bound to that one page's file. It ends after two hours unused (a day at most) or when the service restarts, and the editor then asks for a new one. - Every editor call needs a session. Reads as well as writes: the token rides in a header, or as
zt=in an address the browser loads by itself (a picture, the live events). A call may touch only its session's page. A write from any origin but the page's own is refused. No answer carries CORS headers. - Why another website still cannot reach it. Directly, its requests carry the wrong Host. Through your app's dev server, by rebinding its own name to your app's port, the page it runs is on its own name, not on this machine, so it gets no session, and without one no call works; the cookie is not what stops it (a site can set its own cookies), the Origin is. From its own origin, cross-site, its requests carry its own
Origin, which is never the page's, and the browser will not let it read any answer. - Pages the service serves (Open in editor,
zest open) sit at an address with a part made from a secret only this machine has (in Zest's config file, mode 600). Each page carries a session for that page. The service serves the files in that page's folder, never a hidden file, and only for folders thezestcommand or a registered repo opened. Only thezestcommand, which can read that secret, may ask it to serve a file. - Freeze frames are drawn by headless Chrome, which the service controls over a pipe, so it opens no port. Chrome goes only to the page's own address (on this machine, on that page's port), runs no script, and draws the markup the editor took, with scripts and refresh tags removed.
- Scripts on the page are trusted like the page. In a running app's page, the app's own scripts can read the session token and act through the editor on that page, as on any page Zest edits (above).
Keys
- Read from your environment,
.envfiles or Zest's config file, on the server side only. - The browser learns whether a key is set and its last four characters, nothing more.
- Keys pasted in the editor are saved to
~/.config/zest/config.jsonwith permissions600, never into your project.
What goes online, and when
| When you | Zest calls |
|---|---|
| Open the Library tab | cdn.jsdelivr.net (the starter library manifest and pictures) |
| Search | api.openverse.org, or api.pexels.com with your key |
| Generate | api.openai.com or generativelanguage.googleapis.com, with your key |
| Pick an image from search, or paste an image address | The host of that image, to copy it into assets/ |
Run zest doctor | The library CDN and Openverse, to check they answer |
| Start the editor or Claude Code, at most once a day | api.github.com, for this repo's list of releases, so Zest can say when a newer one is out. One GET with no cookies, keys or page content |
Previews come through your local server, so your browser never calls those hosts directly. Before every download, and again at every redirect, Zest resolves the host and refuses it if any address is private or local (including spellings like localhost. and [::ffff:127.0.0.1]). Every download has a size cap and one deadline for the whole answer, so a slow host cannot hang the editor. There is no analytics, no telemetry and no account. The one call Zest makes without being asked is the daily release check in the last row: ZEST_UPDATE_CHECK=0 turns it off on one machine, and "updates": false in the policy file turns it off for everyone on it.
What it writes
Your HTML file (only the bytes you change), images in assets/, and its own .zest/ folder beside the page. When a round your team splits into tasks runs in a git checkout, Zest also records the checkout as snapshot commits under refs/zest/ (local refs: git push does not send them unless you name them), makes and removes worktrees in your temporary folder, and writes the source changes your notes asked for into the checkout. Evals keep their cases and runs in .zest/evals/ at the top of the main checkout, which every worktree of the repo shares. It never commits, never moves a branch and never touches your index or staging. zest gc drops old snapshots.
The ledger
Zest keeps one more file, outside every checkout: ~/.zest/ledger.jsonl (or ledger.jsonl in $ZEST_HOME). One line per event: a note a round took, the agent's reading of it, what the round changed, and what happened next (your Right or Not right, a note you left on the same spot, a hand edit or a restore that undid it, the change reaching main). It is there so the record of what your agent got right and wrong outlives the worktree a round ran in, and spans every page on this machine.
What it stores: your notes' words, where each pointed (a selector, a tag, a few words of the element's text), the page's address and, when the editor sends it, a cut-down excerpt of the page around the note (at most 8 KB), the agent's answers, file names and line counts from each round (not the files' contents), the words before and after when you hand-edit what a round wrote (cut to 200 characters), the repo's folder name, its origin address with any user name or token taken out, the branch and commit, and this machine's name. It stays on your machine, readable by you alone (mode 600). Nothing in Zest sends it anywhere. An extension your team wrote and you approved can copy each row as it is written (ledger.append, see Your team's setup); that copy goes wherever the team's code sends it.
ZEST_LEDGER=0 turns it off: nothing new is written, and no row reaches an extension. Deleting the file loses the record and nothing else.
Mailboxes and the waiting list
When Submit hands notes to an agent already working in a page's checkout, Zest writes them to ~/.zest/mailbox/<agent id>.jsonl: the notes' words, where each is pinned, a summary of what the page showed (its address, scroll and section, never its markup), the page's path and the repo's name and origin. A page whose Submit waits for that agent to go quiet is listed in ~/.zest/waiting.json. Both stay on your machine, readable by you alone (mode 600). The agent id comes from your team's extension; Zest keeps only letters, digits, ., _ and - of it, so an id can never name a file outside the mailbox folder. Nothing in Zest sends either file anywhere; the extension's deliver hook is told an entry was written and is handed that entry.
Reporting a problem
Report it privately on GitHub: Security > Report a vulnerability on the repository. Please do not open a public issue for a security problem. SECURITY.md says what happens next.