Security model
A coding agent runs commands chosen by a model that reads untrusted text (repository files, web pages, tool output, MCP results). Larik's defences are layered so that no single one has to be perfect.
Threats and the layer that handles each
| Threat | Main defence | Backup |
|---|---|---|
| Model runs a destructive or exfiltrating command | OS sandbox: writes confined, network off | Permission prompt for anything unsandboxed; deny rules |
| Prompt injection in files or web pages | System prompt marks tool output as data; web content marked untrusted | Every side effect still goes through permissions and the sandbox |
| A cloned repo ships malicious config | Shared config can only tighten; hooks and MCP servers need approval pinned to a hash | Sandbox keeps .larik/, .claude/, .mcp.json, .git/hooks, .git/config (and commondir, modules/, worktrees/, …) read-only; .git can't be moved |
| Sandboxed command plants code that runs later outside the sandbox | Protected paths inside the project stay read-only | — |
| Model overwrites a file it never saw, or one you changed | Read-before-write freshness check | /undo checkpoints |
web_fetch used for SSRF (cloud metadata, redirects) |
Resolved-IP blocklist; cross-host redirects reported, not followed | Per-domain permission |
A web page or other local process drives larik serve |
Bearer token, loopback-only listener, Host header check | --allow-remote must be explicit |
| Parallel subagents clobber each other or your work | Worktree isolation on separate branches | Nothing merges without review |
Trust boundaries
flowchart TB
subgraph TRUSTED["Trusted: written only by you"]
UC["~/.config/larik/config.json"]
LS["~/.config/larik/projects/<project-id>.json"]
USER["You, at the prompt"]
end
subgraph SHARED["Shared: anyone with repo commit access"]
PS[".larik/settings.json"]
MJ[".mcp.json"]
AG["AGENTS.md / CLAUDE.md, skills, agent defs"]
end
subgraph UNTRUSTED["Untrusted data"]
FILES["File contents, command output"]
WEBC["Web pages, search results"]
MCPR["MCP tool results"]
end
subgraph LARIK["Larik process"]
PERM["Permissions"]
HOOKS["Hooks"]
MCPS["MCP servers"]
MODEL["Model"]
end
subgraph OS["OS sandbox"]
BASH["bash commands"]
end
UC & LS -->|"may loosen: sandbox, LSP commands, search backend, approvals"| LARIK
PS & MJ -->|"may only tighten; hooks and servers wait for hash-pinned approval"| LARIK
AG -->|"instructions: same weight as the user's words, still permission-checked"| MODEL
FILES & WEBC & MCPR -->|"marked as data"| MODEL
MODEL -->|"tool calls"| PERM --> BASH
USER -->|"approves"| PERM
Instruction files, skills and agent definitions are treated as instructions, like in other harnesses: a repository you don't trust can still steer the model. What it can't do is give the model new powers. Everything the model asks for still passes through the permission checker, and bash still runs in the sandbox.
The OS sandbox
internal/sandbox/sandbox.go wraps each bash command:
- macOS:
sandbox-exec -p <profile> bash -c <cmd>with a generated Seatbelt profile (deny default, then specific allows). - Linux:
bwrapwith/bound read-only, writable paths bound read-write, protected paths re-bound read-only,--unshare-netand--unshare-pid. - Elsewhere, or without
bwrap: no sandbox; commands ask except a small list of side-effect-free forms, and Larik warns at startup.
| Allowed | Denied | |
|---|---|---|
| Read | everything | — |
| Write | project root (git root), the sandbox's own private temp directory, build caches (Go, npm, Cargo, ~/.cache, on macOS also ~/Library/Caches and the per-user temp root), personal writable paths |
everything else -- notably the literal /tmp, and inside the project .larik, .claude, .mcp.json and the git dir entries hooks, config, config.worktree, commondir, info, modules, worktrees (.git itself can't be moved or replaced) |
| Network | localhost (bind and connect) | everything else, unless personal config sets network: true |
| macOS services | a short list of system lookups CLI tools need | LaunchServices and Apple Events, so open and osascript can't launch something outside |
The protected paths matter because the project itself is writable. Without them, a sandboxed command could add a git hook, a hook in .larik/settings.json, or an MCP server in .mcp.json, and that code would later run outside the sandbox. In Seatbelt the deny rules are emitted after the allow rules, because later rules win.
The git entries are there because git trusts its directory completely: commondir, a per-worktree config.worktree, or a .git file saying gitdir: <elsewhere> would each point git at a config or hooks the command wrote somewhere writable, and core.fsmonitor or core.hooksPath there runs on the next git status outside the sandbox. Objects, refs and the index stay writable, so sandboxed commits work. .git itself is pinned in place: Seatbelt denies writes to that exact path, and bubblewrap binds it onto itself, since a mount point can't be renamed or removed. On Linux, bubblewrap can only re-bind paths that exist, so a protected path that doesn't exist yet (for example .git in a project that isn't a repository) isn't enforced there.
Known gap: build caches. The writable build caches (~/go/pkg/mod, GOCACHE, ~/.cargo/registry, ~/.npm, ~/.cache) are shared with builds you run outside the sandbox. A sandboxed command can change a cached dependency's source (a Cargo build.rs, a Go module) or a tool environment kept under ~/.cache (such as pre-commit's), and that code runs the next time an unsandboxed build uses it. They are writable because builds inside the sandbox need them. After letting the agent work on code you don't trust, clear those caches before building outside the sandbox.
The write, edit and multi_edit tools enforce the project boundary separately with os.Root. They reject protected paths and symlink components, and a failed checkpoint stops the write.
How it removes prompts. The sandbox is what makes the default mode usable: permission.Decide allows sandboxed bash without asking (permission.go:161). If a command needs more (install packages, reach the network), the model re-runs it with "sandbox": false, and that call asks, with the prompt saying it runs unconfined. When a sandboxed command fails with typical sandbox errors ("Operation not permitted", DNS failures), the bash tool appends a hint telling the model exactly that (bash.go).
Worktree subagents get a derived sandbox (ForWorktree) where the worktree replaces the project and the shared .git is writable but its hooks and config aren't. They share the parent's private temp directory rather than getting their own.
The literal /tmp is never writable. Earlier, defaultWritable granted write access to /tmp and os.TempDir() outright, on the theory that scripts need somewhere to put scratch files. In testing, a subagent running a small local model executed cd /tmp && rm -rf <name> as a "clean up after myself" step and deleted another program's files that happened to live under /tmp -- on most systems that directory is shared by every program any user on the machine runs, not scoped to the project or even to Larik. Each Sandbox now creates its own private directory (New's tmpDir, removed by Close) and exposes it to sandboxed commands as $TMPDIR/$TMP/$TEMP (sandbox.go), so mktemp, go build, npm and friends still have somewhere safe to write, without exposing the shared system directory. On macOS, os.TempDir() (the per-user temp root under /var/folders/...) stays writable as before: it's scoped to one OS user rather than the whole machine, and several macOS tools -- notably bare mktemp with no template -- resolve to it via confstr(_CS_DARWIN_USER_TEMP_DIR) regardless of $TMPDIR.
Web tools
web_fetchresolves DNS itself and refuses to connect if any resolved address is link-local (including169.254.169.254), multicast, unspecified, or a known cloud metadata address. Checking the resolved IP defeats DNS tricks. Loopback and private ranges stay reachable for local dev servers; the call still asks.- Redirects to a different host are reported to the model, not followed, so a redirect can't sidestep a
web_fetch(domain:…)rule. - Responses are capped at 5 MB and 30 s.
- The search backend receives every query, so it is honored only from personal files. A shared file can only disable web tools.
- Plan mode asks for web tools instead of denying them.
browser_navigateapplies the same address check before opening a URL, and only opens http(s). The check covers the URL the model asks for; Chrome follows redirects and loads subresources itself, so it is weaker thanweb_fetch's. The browser is off unless a personal file enables it, and its profile keeps sign-ins, so treat allowingbrowser_*tools like handing over that Chrome profile. Plan mode allows only opening pages (after asking) and reading them.
Server mode
internal/server/server.go:
- Listens on loopback only, unless
--allow-remote. - Every request except
GET /v1/healthneedsAuthorization: Bearer <token>, compared in constant time. The event stream also accepts?token=because browserEventSourcecan't set headers. checkHostrejects requests whoseHostisn'tlocalhostor a loopback IP. This blocks DNS rebinding, where a web page resolves its own domain to127.0.0.1to reach local services.- Permission requests still need an answer. A client can't make a run skip them.
Credentials
- API keys come from the environment or
api_key_env;api_keyin a config file is supported but not required. - ChatGPT (Codex) sign-in is stored in
~/.config/larik/chatgpt-auth.jsonwith owner-only permissions, refreshed before expiry, and separate from the Codex CLI's login. - MCP server sign-ins (
/mcp login) are stored per server and URL in~/.config/larik/mcp-auth/, owner-only. A server never opens a browser on its own: sign-in runs only when you ask, and a server from a shared project file must be approved first. - Session files and checkpoints are created
0600in0700directories. - Debug traces (
--debug,/debug on) are written the same way. They redact credential headers and URL keys but keep prompts, file contents and tool output, and the raw HTTP request bodies. Only personal settings can turn recording on.larik traceserves a trace on127.0.0.1only, behind a random token in the URL, and refuses requests whoseHostisn't the loopback address.