Report vulnerabilities privately through GitHub: Security → Report a vulnerability. Private reporting is enabled on this repository. Please do not open a public issue for anything exploitable.
Ambit is a personal project, not a staffed one — expect a first response in days rather than hours.
Ambit reads your agent configuration and, through the visualizer, writes back to it. Four invariants hold that surface, and a break in any of the four is a vulnerability. AGENTS.md says where each of the four is enforced in code.
-
Loopback only. The API server binds
127.0.0.1. Nothing on your LAN and nothing through a tunnel can reach it, and any path that widens that bind is in scope. -
Origin allowlist. A request whose
Originis not this app's own page is rejected with 403 before routing. The Origin has to namelocalhost,127.0.0.1or[::1]on a port the page is served from, which is the API's own and, in a checkout, the dev portnpm run devpasses to both processes; a page on any other local port is refused, token or not. CORS headers alone would be insufficient, because a simple request skips preflight and reaches the handler regardless. Every request also has to be addressed tolocalhost,127.0.0.1or[::1], and any other name is a 403 before routing: a page that rebinds its own name to this address is the same origin as the server, so the origin check cannot stop it, and the name it asked for gives it away. Anything that lets a page you visit reach the API is in scope. -
No entry creation over HTTP. An MCP entry carries a command your agent runtime executes, so creating one over HTTP would be remote code execution. Any path that turns an HTTP request into a new executable config entry is in scope. What the API may do instead:
- Toggle an existing MCP server's
enabled, edit an existing agent'sdescriptionormodel, and edit an existing command'sdescription. Every edit copies the config to<config>.bakfirst and is refused if the copy cannot be made. Adding a server produces a snippet you paste yourself. - Decide proposals, which writes to the graph and never to a config. An approval is a signed artifact that only
ambit apply, or a control-plane call presenting it, can spend, and a rejection is a row that withdraws any approval before it. - Every decision is made as the person at the page and never as an actor the request names. It is bound to the hash the page showed and refused if the proposal has changed since. The two routes that decide a queue of drafts at once,
/api/proposals/approveand/api/proposals/reject, take an explicit list of at most fifty. - A request to any of these routes with no browser behind it must present the API token. So must a post to
/api/telemetry, because work recorded there counts toward a promotion threshold a person set. - The page never runs a check. It copies
ambit verify <id>for you to type, because a declared check is a command, and the id in it is quoted, since an id can be anything an agent or a sync file's author typed.
- Toggle an existing MCP server's
-
No egress you did not type. The graph is a local SQLite file and there is no telemetry. Any other path that moves the graph off the machine is in scope, and an exfiltration path is high severity. What reaches the network from Ambit's own code, and when:
ambit notifyandambit notify-approvals, each only with a topic argument.ambit dispatch(andpropose/approvewith--dispatch), only with a webhook URL from--toorAMBIT_APPROVAL_WEBHOOK. It sends one proposal's summary or signed artifact, never a command.ambit incidents, an empty GET to each device status URL and service URL your own manifest names. It sends nothing else; what each answer says is recorded in the local graph, as a check run on that device or service and, for an answer, the time it was last seen.ambit goal --judge, the goal you typed, or with--speceach task of the spec no goal phrase matched, to a judgment model on this machine, and when none answers, to an embedding model on this machine (AMBIT_EMBED_URL, or Ollama's127.0.0.1:11434). A URL whose host is not127.0.0.1,localhostor[::1], or that carries credentials, is refused, for either.- A declared check, because a check is a command.
ambit verifyruns them,ambit applyruns the check of what it applied, and so does an agent callingambit_verifyover MCP; the curated check for Web Research fetcheshttps://example.com. Checks run only when you type one of those commands or an agent calls that tool, never from the server, whose page copiesambit verify <id>for you to type. ambit weigh, because a tool server is a command. It starts each local server your user-level agent configs declare, as your runtime does at the start of every session, asks it for its tools over stdio, and stops it; whatever a server does when it starts, such asnpxfetching its package, it does then too. It runs only when you type it, never from the server, whose page reads what was recorded. It never reads a project's committed config and never contacts a remote server.- A step the control plane's Docker adapter runs, only when you started the control plane with both
AMBIT_ADAPTER=dockerandAMBIT_DOCKER_NETWORK=bridge, and a grant for thenetworkaction on the step's capability answers ALLOW. Without all three the step's container has--network=none. The adapter never pulls (--pull=never), so no registry is reached unless you typedocker pullyourself.
What stays on the machine:
- Discovery lists the folders Ollama and LM Studio keep models in, and reads the names of what is there. It opens no model file and no manifest, and asks no model server anything.
- The server reads the local Docker socket when one exists, over a unix socket and read-only. It never starts or stops anything.
ambit graph capacityrunstailscale status --json,sysctlandnvidia-smiwhen you type it, with fixed arguments and no shell. The first asks the local Tailscale daemon, none sends anything, the report leaves out addresses, and nothing is written. The server never runs them.- The Claude Code plugin's hooks append which tool ran, Claude Code's id for the call and the time each hook ran, which failed and its error text, and when a person was asked, to
~/.local/state/ambit/claude-code.jsonl, never a tool's input or output. The nextambitcommand reads it into the graph and deletes it, writing back only the line of a call still running so its end can be paired with it, and from the transcript of a session that ended it reads the token counts, model names, message ids and times and nothing else.AMBIT_NO_LEDGER=1stops the hooks writing. - Cursor's hook, which
ambit connect cursor --ledgeradds to~/.cursor/hooks.json, appends to the same file the time it ran and the conversation's id; for a shell command or MCP call that ran, the server's and tool's names and the length Cursor states; for a failed call, Cursor's id for it, its error text, failure kind and whether it was interrupted; and why a conversation ended. Never a command's text or output, an MCP call's arguments or result, file contents, a prompt or the user's email. It is on no permission hook and prints{}, so it can neither block a call nor allow one.AMBIT_NO_LEDGER=1stops it writing. - Every
ambitcommand, and the MCP server when it opens the graph, reads the session logs Claude Code, Codex, OpenCode and Amp keep: Claude Code's transcripts,projects/<project>/<session-id>.jsonland the subagent transcripts in each session's own folder, underCLAUDE_CONFIG_DIR(only there when it is set) or~/.claudeand${XDG_CONFIG_HOME:-~/.config}/claude, read incrementally from where the last read stopped, with or without the plugin; Codex's rollouts undersessions/andarchived_sessions/inCODEX_HOMEor~/.codex, OpenCode'sopencode.dbinOPENCODE_DATA_DIRor${XDG_DATA_HOME:-~/.local/share}/opencode, opened read-only, and Amp's thread files underthreads/inAMP_DATA_DIRor~/.local/share/amp. From each session it keeps the session's id, each model's name, the times of the first and last usage it counted, the hour each count was recorded in, and the token counts by part, and nothing else: no prompt, reply, title, working directory, file path, command, or tool input or output reaches the graph. Claude Code's message ids are kept with the cursor, so a reply written on several lines, or replayed in a subagent's transcript, is counted once. A JSONL line or thread file is parsed whole to reach its counts, so that content passes through memory and is dropped; OpenCode's database is asked for the numbers in SQL, so its content is never handed over. A cursor per log file (its path, size, time, how far it was read, and the counts parsing needs to resume) stays on this machine and out ofambit sync; a Claude Code transcript's folder is named after the project's working directory, so its cursor is filed under a digest of the path and the graph holds no project's name. It writes to none of these files and opens no socket.AMBIT_NO_LEDGER=1stops the reading. - The map's page loads nothing from another origin, fonts included, so opening it, locally or on the hosted demo, sends no request anywhere but the server that served it.
Also in scope: anything that causes the engine to execute content from a scanned configuration, an infrastructure manifest or a spec ambit goal --spec reads, the one exception being ambit weigh starting the servers your own user-level configs declare, when you type it; any path by which ambit weigh starts a server from a project's committed config, contacts a remote one, or runs without being typed; and any path by which ambit gate, the Claude Code PreToolUse hook, answers allow, or by which Cursor's ledger hook blocks or allows a call. The gate may only narrow what Claude Code would do: it denies what is forbidden or over budget, asks about the rest of what it can name, and otherwise prints nothing. It reads the call from stdin and the local graph, writes nothing, opens no socket, and on any error prints nothing, so a broken gate leaves Claude Code's own permissions in charge.
The control plane's Docker adapter is never the default: a person chooses it with AMBIT_ADAPTER=docker or --adapter=docker, and when Docker cannot run the step it refuses before anything runs. It runs only what the gate already let through, one container per step from the image the person named, never one the request names. What the container gets:
- No network, unless the three conditions under No egress you did not type all hold, and never the host's network, which would put it on the loopback where the API listens.
- A read-only root with a small
/tmp, every Linux capability dropped,no-new-privileges, user65534, and limits on memory, CPU and processes. It is removed when the step ends, by name if--rmdid not get to it. - No host path, unless
AMBIT_DOCKER_WORKDIRnames a directory, which is mounted read-only at/work. A directory that is the home directory or holds it, one inside a dot-directory of home (.ssh,.aws,.configand the rest, where keys, cloud credentials and the approval key live), and one holding the Docker socket are refused, links resolved first. - No environment variable from this machine. The docker client itself starts with only what finds the daemon (
PATH,HOME,DOCKER_*and a few like them), so not even the client holds the approval key. - The request's
payload.commandas its argument vector, after the image, with no shell anywhere on the host and no request value in a docker option.
A path that breaks any of those is in scope, as is any path by which the adapter runs a step the gate refused, or by which a Docker refusal ends with the step run in the simulator.
A tech tree overlay (.ambit/techtree.json or .ambit.json in the directory ambit runs from, or AMBIT_OVERLAY_TECHTREE) may add nodes and detection patterns and may narrow the authority the curated tree states, never widen it. A cloned repository can ship either file, so the merge keeps every curated mode, takes the narrower of the curated and the overlay's for each key (forbidden over confirm over autonomous, with override: true no exception), and caps at confirm any mode the curated tree never stated, a node only the overlay names included. Any overlay that yields a grant wider than the shipped tree's is in scope.
- The declared verification checks run commands from the capability model by design. That model is code in this repository; changing it is equivalent to changing any other source file.
ambit apply <id>,ambit connectand the visualizer's config editing modify your configuration on purpose, each writing a backup first:<config>.ambit-<id>.bakforambit apply, and<file>.bakfor the other two, which every write replaces, so it holds the file as it stood before the most recent one. Each backup is a byte copy renamed over the old one, so a link planted at that name is replaced and never followed, andambit connectsays which files it changed and where each backup is.ambit applyandambit rollbackchange only the entries they add or remove, so a config's comments and layout survive both, and a rollback gives back the file's own bytes.- Findings against a fork's own capability model, or against a configuration you supplied yourself, are not vulnerabilities in Ambit.
- An approval covers the command too. A blocked call's
payload.commandis written into the step the person is shown, the proposal hash the approval signs covers the steps, and a retry with any other command is refused. Break-glass is the agent's own say-so: it never outranks a forbidden grant or a spent budget, and with the Docker adapter it is refused outright, so nothing runs in a container without a person's approval. Any path that runs a command a person did not approve is in scope. - The token rule stops a page you visit and a script acting casually. It is not a boundary against a determined process running as you, which can read the token file, record work toward a promotion threshold with it, or open the graph directly; loopback binding and the token file's
0600permissions carry that weight.
The graph is a local SQLite file whose path ambit where prints, and it describes your machines, your credentials-adjacent tooling, and your network reach. ambit graph and ambit status describe your machine too, so redact before pasting either into a report; the FAQ lists the commands that can move data and what each one sends. The server's read routes (/api/loop, /api/audit, /api/frontier and /api/run) answer from the graph under the same loopback and origin rules, and /api/loop also carries the text of a few install patches shipped in this repository's tree. The infrastructure scan probes only what your own manifest names, as described above, and records nothing: when a device or service was last seen is read from the graph, where only a typed ambit incidents writes it.