Security
Kraft runs coding agents on your machine, in git worktrees of your
repositories. Without a sandbox, an agent runs as your user and can do
whatever you can. Kraft binds to 127.0.0.1, and on that address its API
needs no login, so any local process can drive it.
To report a vulnerability, see Reporting a vulnerability.
Run it safely
These settings matter most. Each one is a line here and a link to the page that explains it.
- Run each repository's agents in a sandbox. Without one, an agent has your SSH agent, your logins and your home directory.
- Give the sandbox a
network:policy. A launch into a sandbox without one is refused, whatever the harness, because that sandbox's network is not restricted and the worker has no channel to Kraft.unrestricted_network: trueon the sandbox accepts that knowingly: the launch runs and gates are not enforced. Cursor ignores a proxy, sonetwork:refuses it: it runs in a sandbox only withunrestricted_network: trueand without a tool policy. See Network policy. - Keep Kraft on loopback. To reach the board from another device, set an access password first and use Tailscale: Remote access.
- Protect your default branch with a required review from someone other
than the account Kraft uses. A sandboxed worker under the
network:policy of step 2 cannot approve its own gate; an unsandboxed one can. See Agents and gates. - Do not point Kraft at content you do not trust: a repository, a trigger source or a beads queue. A sandbox does not cover Kraft's own push from the host, where the repository's pre-push hook runs as you. See Prompt injection.
- Give CI and webhook senders the
trigger-token, never themcp-token. See The bearer token. - Review a harness profile's
executableand a repository'ssetup_commandafter anyone else has had access to Settings. See Files and settings.
Default posture
This is what a fresh install does before you change any setting.
| Area | What a fresh install does | Tighten it |
|---|---|---|
| Sandbox | None. No repository sets sandbox:, so every agent runs directly on your machine, as your user. | Set sandbox: on a repository. |
| Files and logins | A worker reads anything your user can: ~/.ssh, your forge CLI's login, your agent's login, and $KRAFT_HOME, including the bearer token. It can push with your SSH agent. | Run it in a sandbox. |
| Worker environment | An allowlist, but it includes HOME and SSH_AUTH_SOCK and passes ANTHROPIC_API_KEY and CLAUDE_CODE_OAUTH_TOKEN through. | Environment variables lists it; a sandbox credentials entry, which needs network:, keeps a variable out of the container. |
| Network | Without a sandbox, a worker reaches any host your machine can. A sandbox without a network: policy is refused unless it sets unrestricted_network: true, which leaves egress open. | Add network: to the sandbox. |
| Claude Code | Shipped chains run it with --permission-mode auto and no tool list. Its own classifier decides most calls. A call that reaches Kraft's permission gate is allowed, because no allowed_tools is set, except Monitor and your deny_tools. | Set allowed_tools and deny_tools: Permission gate. |
| Gemini and Antigravity | Never reach the gate. Gemini runs with --approval-mode yolo and Antigravity with --dangerously-skip-permissions, so every call is approved. Neither can enforce a tool list, so a launch with one is refused. | Unattended runs has the flags. |
| OpenCode and Amp | Never reach the gate. OpenCode runs with --auto and Amp asks for no approvals. Both enforce deny_tools and allowed_tools through rules Kraft writes at launch. | Unattended runs. |
| Codex and Cursor | They reach the gate through a pre-tool hook, installed only when the task's policy has something to enforce. With no allowed_tools, the gate gives no opinion, so the CLI's own mode decides every call outside deny_tools and the grants. | Permission gate. |
| Codex and Cursor, when Kraft cannot answer | The CLI's own mode decides, unless the task holds allowed_tools: then the hook denies. In a sandbox under network:, the hook denies on any failure. | Permission gate. |
| Escalation turns | They hold the git-commit, git-rebase and git-push grants, which the permission gate allows. They can push their item's branch, including with --force-with-lease. | defaults.escalation_grants: [] removes the gate's allow only. See Escalation turns can force-push. |
| Local API | While Kraft is bound to 127.0.0.1, a request from this machine needs no password or token. Any local process or user can file, start and approve work. | Not possible on loopback. See Out of scope. |
| Gates | Most shipped gates wait for you. See Agents and gates. | Require a review on your forge. |
| An agent deciding its own gates | An unsandboxed worker can. See Agents and gates. | Sandbox it with a network: policy, and require a review. |
Threat model
Kraft runs on your machine, binds 127.0.0.1 by default, and edits your
repositories through git worktrees using whatever coding agent you have
configured. Five threats shape it:
| Threat | What stops it | Section |
|---|---|---|
| A web page in your browser | A loopback bind that checks the Host, no framing, and no images fetched from other sites | Network and browser |
| Another machine on your network | A loopback bind by default; a password, allowed_hosts and a login throttle when you expose it | Network and browser, Login |
| An agent acting beyond its own work item | Refused item verbs, a sandbox, and a network policy | Agents and gates, Workers and the sandbox |
| A repository's content or settings | Search reads only regular files inside the repository; Settings need the password only off loopback | Files and settings |
| Text an agent reads that steers it | Nothing filters it. A sandbox with a network: policy, a tool policy and the gates limit the damage | Prompt injection |
What Kraft does not try to protect against is under Out of scope.
Prompt injection
Everything a worker reads is input to the agent, and text in it can steer the agent. That includes:
- the repository's files, including ones other people wrote;
- a work item's title and description, whether a person typed it, a trigger posted it or auto-intake copied it from a bead;
- review comments on the merge request, which the feedback loop asks the agent to address.
Kraft does not filter or sanitize any of it. What limits the damage is what
limits any other agent mistake: a
sandbox with a network:
policy, a tool policy (allowed_tools, deny_tools), and the gates, where a
person reads the change before it merges. Under the
default posture, an injected instruction runs with your
user's access.
Do not point Kraft at a repository, a trigger source or a bead queue whose content you do not trust. A sandbox limits what the agent can do, but not what runs as you on the host: Kraft pushes the branch from the host, and the repository's own pre-push hook runs then.
Out of scope
Kraft does not try to protect against these:
| Not protected | Why |
|---|---|
| Other users and processes on the same machine | The loopback API has no login, so anyone who can open a connection to it can drive Kraft. $KRAFT_HOME/run itself is 0700, so other users cannot read the databases and logs in it. |
| An agent running without a sandbox | It has your user's access. See Default posture. |
| The agent CLI and its provider | Kraft runs the CLI you installed. What that CLI sends to its model provider, and what the provider keeps, is between you and the provider. See Data and privacy. |
| A repository's own commands | Its setup_command and test commands run with the worker's access, and a pre-push hook runs as you when Kraft pushes the branch. |
| Anyone who has your password or the bearer token | Either one grants full control. |
| The machine and the forge | A compromised OS, container runtime, gh or glab login, or git remote is outside what Kraft can check. |
Network and browser
- Loopback by default.
kraft admin startrefuses to bind a non-loopback address without a password set inaccess.yaml. There is no way to expose the API or UI on a LAN or the internet without opting in. - Remote access is the recommended way to
reach the board from a phone or another machine: bind the machine's
Tailscale address. A bare
--host 0.0.0.0also listens on your LAN, and a Cloudflare Quick Tunnel is the public internet, guarded by the password and a login throttle. - No framing by another site. Every response carries
Content-Security-Policy: frame-ancestors 'self'andX-Frame-Options: SAMEORIGIN. Your browser refuses to show the board inside a frame on any other site. Without this, a page could hide the board under its own content and turn your click into Resume or Approve (clickjacking). That matters most on a loopback bind, where the board needs no login. - The API docs pages load nothing unpinned.
/docsand/redocneed no login. Their scripts come from a CDN, each pinned to one release and checked by hash. Their policy lets an image load only from Kraft itself or inline, so no other site learns your Kraft's address from them. See The schema. - A document cannot make your browser fetch another site. Specs, plans,
notes, review threads and gate pages are Markdown a worker can write. An
image in them shows only when it comes from the board's own address or is
inline (
data:). Any other image shows as a link,image: host/path, and your browser fetches it only if you click.
Otherwise a sandboxed worker could put data in an image's URL and have your browser send it out, past the worker'snetwork:policy.
Host allowlist
The rules differ by bind:
- A loopback bind answers only to
127.0.0.1,localhostand[::1], so a web page cannot reach it under another name. A trailing dot (localhost.) or a longer spelling of::1is the same name, and is accepted too. - A non-loopback bind checks the
Hostheader againstallowed_hostsinaccess.yamlfor every request that carriesSec-Fetch-Site,Origin,Refereror a pageAccept. That covers every page load and login: a password alone does not authorize an arbitrary hostname. - Each entry is kept in the form the
Hostis compared in: lowercase, with no scheme, port or trailing dot. A wildcard is refused when you save it, andkraft admin doctornames one already in the file. - This machine's own
127.0.0.1,localhostand[::1]always pass, since a browser sends them only when it is on this machine. - A request with none of those headers, as the CLI, MCP and
curlsend, needs no entry. It still needs the password for anything but the unauthenticated routes such as/api/health. - The board's live event connection, the
/api/ws/eventsWebSocket, follows the sameHostrules, and refuses anOriginfrom another site. The board's own page and a page on this machine pass.
Agents and gates
Which gates wait for you. The shipped default chain stops and waits for
you to approve the spec and the plan, before it opens a draft merge request,
and again before it marks it ready. There are two exceptions:
- A spec or plan you attach at intake arrives already decided, so its gate is dropped.
- The
chain_revision_approvalgate passes on its own when the revision proposes no change, which is the usual case.
If your forge requires a review, the item then waits for that approval before
it merges. A gate you give an auto_review task lets that agent's verdict
approve it.
The ten item verbs are approve, reject, pause, resume, skip,
abandon, retry, complete, cancel and escalate. The rules differ for
a sandboxed worker and an unsandboxed one.
A sandboxed worker under a network: policy cannot decide its own gates.
- It reaches Kraft only through its session's channel, which acts as that session. The channel allows only reads, progress, thread replies and permission asks on the worker's own item, and an escalation turn's retry of it.
- It cannot use any of the ten item verbs on its item, revise its attachments, overrides, policy or budget, or file work, whatever it sends. Anything else is refused and recorded.
- A permission hook that gets no answer denies. See Callbacks from a sandbox.
A sandbox with unrestricted_network: true has no channel, but nothing
blocks a direct connection. Without network: the container has the
runtime's default network, which is why Kraft refuses such a launch unless the
sandbox sets that key. Kraft gives it no channel and no kraft shim, and it
adds nothing that stops the container dialing the host. Whether it can reach
Kraft's loopback-bound API depends on the runtime.
If it can, Kraft cannot tell the worker's calls from yours, because the loopback API needs no login. Do not count on such a sandbox to keep a worker from approving its own gate.
An unsandboxed worker can, because it runs as you. It can read Kraft's token, write Kraft's database and edit its config. No check inside Kraft can stop it approving its own gate or starting work it filed.
What refuses a worker acting on its own item. Three checks do, and none is a boundary against a worker that is not sandboxed.
- The
kraftCLI and the MCP server refuse a worker's item verbs on its own item, and its changes to that item's attachments, overrides, policy and budget. They check this on the client, from the item the worker stands in. - The API refuses the same calls when the request carries the worker's session
id (
X-Kraft-Session-Id). A caller that leaves the header off passes. - The review tools are refused by the API from any request that carries a session id, on any item, not only the worker's own.
The MCP tools page lists every refusal.
Filing and starting in one step. The MCP create tool has no autostart
option, so everything filed through it lands paused. A person starts it, from
the board or by asking their agent to resume it. The server refuses
kraft item create --autostart (403, nothing filed) from a request that carries
a Kraft session id or the MCP client tag; see
kraft item create.
The server cannot tell a coding agent that runs
kraft item create --autostart in a shell on your machine from you, so that
call starts the item. Anyone with a shell can start work this way.
What this does not stop. These refusals stop an agent following the normal path, not one working around it. If a gate must hold against the agent, follow steps 1, 2 and 4 of Run it safely. See Default posture for why a sandbox is the line, and Use Kraft from your agent.
Escalation turns can force-push
An escalation turn is the agent Kraft starts to help with a stopped item. It can commit, rebase and push its item's branch, and a push can be a force-push.
The turn is told to push only that branch, with --force-with-lease. Its
git-push grant is the permission gate's allow for a push to a plain remote
name whose refspecs all update the item's own branch and nothing else (see
Grants). The grant does not require
--force-with-lease: a push without a force flag matches too.
A plain --force, -f or a + refspec does not match the grant, but nothing
blocks it either. On Claude, the call goes to Claude's classifier, or to the
permission gate, which allows it when no allowed_tools is set. On Codex and
Cursor, with no allowed_tools, the gate gives no opinion and the CLI's own
mode decides. The other harnesses never reach the gate: see
Default posture.
In Kraft's gate, only a deny_tools entry, an allowed_tools list that leaves it out, or
(on Cursor) a write to Kraft's hook file stops a call the grant does not cover.
A grant is the gate's allow only: on Cursor, a hook allow does not outrank
--auto-review, so whether the CLI runs the call is up to Cursor.
Kraft starts a turn by itself when an item gets stuck: a task still failing
after recovery and the fix loop, or a fix loop that hits its cap, stalls on
findings that stop changing, or whose judge
stops it. This is auto_escalate_stuck, on by default, up to three times by
default (auto_escalate_stuck_cap). Config errors, budget and time caps,
infrastructure failures, wait timeouts and a worker's question are not
answered this way.
The turn holds the git-commit, git-rebase and git-push grants:
- A grant is allowed even under an
allowed_toolslist. Onlydeny_toolsoverrides it. - A grant does not stop git's own hooks, which the agent can edit.
The turn runs as a person's session, not a worker's. The CLI does not treat it as a worker acting on its own item. The API refuses it, by its session id, only the calls listed further down. A turn that is not sandboxed can:
- Complete, cancel or abandon the item.
- Change its agent overrides, and the overrides of a node that has not started.
- Ask for a retry of a
needs_humanitem, which Kraft performs once the turn ends.
A sandboxed turn under a network: policy has only its session's channel, as
a sandboxed worker does. Of the calls above, the channel
carries the retry and nothing else.
The turn's prompt tells it not to skip or abandon the item, and to give you the command instead. That is an instruction, not a check: no check stops an abandon from a turn that is not sandboxed. Kraft's own prompt does not mention complete or cancel.
While a turn runs, the API answers 409 to these calls on its item, whoever makes them:
- Skip, resume and a second escalation, the turn's own calls included.
- Pause, because the item is stopped and pause takes only a running one.
- A change to its attachments, because the item has started.
Those depend on the item's state, not on who is calling.
The API also refuses the turn a few calls by its session: approve and reject, raising the budget, changing the budget or policy, and a reviewer's calls. A gate, a review and a spending cap are a person's decision.
A reviewer's calls are opening, editing, deleting, resolving or reopening a thread, adding, editing or deleting a comment, and submitting a review. A turn can still reply to a thread.
Those refusals apply when the call carries a session id. The check
reads the X-Kraft-Session-Id header and is a consistency check, not a security
boundary. A turn that is not sandboxed can leave the header off.
Set defaults.escalation_grants: [] in policy.yaml to remove the grants.
That removes the gate's allow only. On Claude, the turn's pushes then go to
Claude's classifier, or to the permission gate, which allows them when no
allowed_tools is set. On Codex and Cursor, with no allowed_tools, the CLI's
own mode decides them: the gate gives no opinion, or is not asked at all when
no deny_tools is set and no grant but git-commit is left.
Workers and the sandbox
A worker's environment is allowlisted, not inherited. It does not take
whatever the server's own shell had set. A repository's repos.yaml entry adds
what it declares. See
Environment variables.
A sandbox bounds what a worker writes, and with a network policy what it reaches.
- A sandboxed worker cannot move a branch in your repository other than its own.
- A tool policy Kraft cannot enforce for that harness in the sandbox refuses
the launch. Where it is enforced, an
allowed_toolslist on Claude runs inmanualmode, and Claude then runs read-only shell commands such ascatwithout asking Kraft's gate, ifBashis in the list. - Of Kraft's results folder it sees only its own work item's files, read-only, and writes only its own result file. Another item's diff, findings and instructions are not in the container.
- Git state it plants in its worktree, such as a HEAD naming another branch or a rebase or merge in progress, stops the item for you. Kraft never commits, rebases or aborts over it.
What it reaches.
- Without a
networkpolicy its network is not restricted, and on a cloud VM the metadata address is reachable. Kraft refuses the launch unless the sandbox setsunrestricted_network: true, andkraft admin doctorreports the sandbox either way. - With one, the worker has only loopback and reaches out only through Kraft's own proxy, on a channel that is its session's alone. Only the hosts the policy allows are reachable. Loopback, link-local, unspecified and multicast addresses, cloud metadata and the addresses the host's own name resolves to never are. Another non-public address, such as an RFC 1918 one, is reachable only when an allow entry names its host exactly. Refusals are recorded.
- On Docker Desktop and Podman machine that channel is a mutual-TLS connection, and a session's certificate expires after seven days, so a longer session fails closed. See Network policy.
- The limit: the allow list is enforced on the requested name and the dialed address. So an allowed name on a shared CDN address can reach other names that address serves (domain fronting). Through an upstream proxy, the upstream resolves the name again, so the checked address is not necessarily the one reached.
A variable listed under credentials never enters the container. It needs
a network: policy: a sandbox policy that lists credentials without one is
refused when it loads. The container holds a sentinel in its place, and the
proxy holds the real value in memory. Other variables are not covered:
ANTHROPIC_API_KEY and CLAUDE_CODE_OAUTH_TOKEN are forwarded into the
container unless you list them.
The proxy injects a credential only for a host the allow list names exactly. A host reached only through a wildcard is a blind tunnel, where the worker sees only the sentinel.
For the credential's host the proxy terminates TLS, with a certificate from Kraft's CA, which such a container trusts. It verifies the real host against the server's own roots plus the sandbox's extra CA, and replaces the sentinel in the declared header. It refuses any request that does not carry the sentinel.
The limits: HTTP/1.1 only, no upgrade to a managed host, and a host that echoed the key in its response would hand it to the worker. See Credentials.
A sandbox covers a workspace's members as it covers the root. Each member is checked out from its connected repository, never from a git directory the worker can write. The files host git trusts for it are read-only in the container. A member Kraft did not check out, or one a worker swapped, stops the item before host git runs in it. See Workspaces.
A Kit runs only what Kraft enforces. A kind: kit sandbox runs a Kit
pinned by digest whose descriptor decodes strictly. Kraft lowers the Kit onto
the same Docker sandbox: its network lists, credentials and limits alone, with
no harness host added.
- A required capability Kraft does not enforce stops the item naming it before anything runs, and an optional one is skipped and recorded.
- A Kit credential's value comes only from the server variable
sandbox.yamlbinds to its service, never from a variable the Kit names. - Kraft reads one Kit you wrote. A Kit that needs a capability Kraft does not enforce is refused, as Docker's published ones are. See Kits.
Files and settings
- The permission hook imports nothing from the worktree. Cursor runs the
hook from the worktree, which the worker writes, with the worker's
environment. Kraft starts it with Python's
-I, so neither the current directory nor aPYTHONPATHis on its import path. A file in the worktree named like a module Kraft imports cannot run in its place and answer the hook. See Cursor.
This does not protect Kraft's own install: an unsandboxed worker can change that as it can any file your user owns (see Default posture). - Search indexes only files inside the repository. Kraft indexes the
Markdown files git tracks under a repository's
.engineering/. It skips a symlink, wherever it points, and anything else that is not a regular file. A repository cannot commit a link to a file outside it, such as a token in your home directory, and have search hand that file to you or your agent. The scan also never runs acore.fsmonitorprogram the repository's own git config names. - A worker's session summary is read only from its own worktree. It is read
only if it is a Markdown file, so a worker cannot point search at a file
such as your checkout's
.env. - Settings can change what the server runs. A harness profile's
executable(Settings › Harnesses) is the program every agent launch on that harness profile starts. A repository'ssetup_command(Settings › Repos) runs in every new worktree.
Both are editable by anyone who can open the Settings pages. On a loopback bind, a request from this machine needs no password, so anyone with access to this machine can change them. Off loopback, the access password (Settings › Access) guards command execution, not only configuration. Keep the bind on loopback or behind the access password, and review these fields after anyone else has had access. install.shis a shell script fetched and piped from the internet. It is short by design: read it before you run it,install.sh.
The bearer token
$KRAFT_HOME/run/mcp-token is a full-admin credential. A request that
carries it as Authorization: Bearer <token> passes the login check on every
route: it can file, start, approve and cancel work, and change settings. It is
not limited to /api/triggers.
$KRAFT_HOME/run/trigger-token is the one to hand out. It passes the login
check on POST /api/triggers only, so its holder can file work, which lands
paused, and nothing else. On any other route Kraft treats it as no credential
and answers 401. The mcp-token works on POST /api/triggers too.
- Kraft creates both files with mode
0600on first start and keeps them after that.kraft admin doctorfails itsmcp tokenortrigger tokencheck if the file's mode gives anyone but you any access. - The
kraftCLI and the MCP server read the file on every call, so they need no setup. - To rotate either, follow Rotate the token.
trigger-token, never the
mcp-token. Anyone with the mcp-token has full control of this Kraft
instance and of the work it runs in your repos.Login
Remote access uses a password, stored in access.yaml as a salted scrypt
hash. A successful login sets an HttpOnly, SameSite=Lax session cookie.
Remote access shows how to set it up.
- Failed logins are throttled per client address. After 5 wrong passwords
from one address within 15 minutes, Kraft answers that address
429with aRetry-Afterheader. It does so even for the right password, until 15 minutes have passed since the last failure. A successful login clears the count. The counts live in memory, so a restart clears them too. - The address is the one Kraft sees. A proxy or tunnel on the same machine
can name the real client in
X-Forwarded-For. Kraft believes that header only from127.0.0.1, so a remote caller cannot pick its own address. - A tunnel that sends no such header makes the throttle global. Every visitor looks like the same address, so 5 wrong guesses from anyone lock everyone out for 15 minutes. That still guards the password, but use a long random one before you expose the board anywhere.
- The cookie is
Secureover HTTPS. When the login request arrives over HTTPS, or carries theX-Forwarded-Proto: httpsa tunnel sets, the cookie is markedSecureand the browser never sends it over plain HTTP. A login over plain HTTP, such as loopback or Tailscale, gets a cookie without it.
Reporting a vulnerability
Report vulnerabilities privately, not in a public issue. Use GitHub private reporting, as described in SECURITY.md. Only the latest release gets security fixes: see Supported versions.
Related
- Data and privacy: what leaves your machine and where Kraft keeps secrets.
- Permission gate: what the permission gate allows each harness to do.
- Sandboxed workers: how to run agents in a container.
- Remote access: how to reach the board from another device.