Security2 of 10
The approval gate
On this page
The approval gate decides whether a command may run. It sits in front of exactly two tools — shell
and execute_code — and it is the control that everything else on the default install rests on.
Other tools raise approval cards too: installing an MCP server, connecting a discovered one, and the two Discord tools that change a server. Those go straight to a card every time. They have no grant machinery, so nothing on this page about saving or revoking a permission applies to them — they cannot be made automatic.
Everything below was measured, not read off the regular expressions. The gate exposes a predicate,
would_auto_allow(action, risk_kind), which answers the one question that matters: would this run
without asking me? The tables on this page are its output.
gate = ApprovalGate(host_exec=True, workspace_root=workdir)
gate.would_auto_allow("ls", "exec") # True
gate.would_auto_allow("ls /etc", "exec") # False
host_exec=True is what the running system passes when KOTOBA_SANDBOX=local. workspace_root is
her working directory.
The order of decision
For each command the gate tries, in this order:
- Allow-list — a set of literal actions that always pass. The running system builds the gate with no allow-list at all, so in practice this is empty.
- A saved grant — either this exact line, or this command family. Both are described below.
- Auto-safe — a plain read inside the working folder.
- Ask you.
- With no way to ask, deny.
A command matching the dangerous list skips straight past 1–3 and always asks. No grant of either kind can cover it.
What runs without asking
On the default local backend, with no grants saved, this is the whole of it: a first token from a
29-command read list, with no shell metacharacter, no escape flag, and every path argument resolving
inside the working directory.
The 29 commands, read out of the code:
base64 cat cut date echo find grep head hexdump id
ls mkdir nl od printf pwd rg sort stat strings
tac tail tr true uname uniq wc whoami xxd
mkdir is on that list and is not a read; it is there so that tidying files does not interrupt you.
Everything else on it reads.
Measured, on a working directory containing notes.txt and sub/:
| Command | Result |
|---|---|
ls, ls -la, ls sub | runs, no card |
cat notes.txt, cat sub/../notes.txt | runs, no card |
grep foo notes.txt, wc -l notes.txt, stat notes.txt | runs, no card |
find . -name '*.py', rg pattern | runs, no card |
mkdir newdir, pwd, echo hello | runs, no card |
ls /etc | asks — path outside the working directory |
cat /etc/passwd, cat ~/.ssh/id_rsa | asks — same reason |
cat ../../../etc/passwd | asks — resolved, then refused |
cat $HOME/.ssh/id_rsa | asks — $ is a metacharacter |
cat {a,b} | asks — braces are metacharacters |
grep --file=/etc/passwd . | asks — the path inside a flag is checked like an operand |
tail -f notes.txt | asks — a flag that turns a read into something that never returns |
sort -o out.txt notes.txt | asks — a flag that turns a read into a write |
rg --pre sh . | asks — a flag that turns a read into an execution |
find . -delete | asks — and is labelled dangerous, so it can never be saved |
ls | grep foo, echo x && …, ls; … | asks — any chaining or redirect metacharacter |
rm notes.txt, mv a b, cp a b, touch x | asks — none of them read |
git status, npm install, pip install …, df -h, ps aux | asks |
python script.py, sh -c '…', curl …, sudo …, docker ps | asks |
Two of those deserve their reason spelled out.
Metacharacters. ; & | newline < > $ { }` all end the auto-safe route immediately, whatever the
first token is. The gate reads a command with POSIX word-splitting; the shell also expands braces and
variables. Where the two readings can differ, the gate refuses to guess and asks instead.
Flags that change what a command is. Six of the read commands have flags that make them execute, write, or never return. The command stays on the list; only the flag asks:
find: -exec -execdir -ok -okdir -delete -fprintf -fprint -fprint0 -fls
rg: --pre --pre-glob --hostname-bin
sort: -o --output --compress-program --files0-from
tail: -f -F --follow --retry
grep: -Z
head: --zero-terminated
Long options are matched by prefix, because an unambiguous abbreviation is honoured by the option parser too, and short flags are matched inside a cluster.
The two kinds of grant
When a card is shown, it may offer one or both of two standing permissions. They are different promises, they are stored separately, and they are revoked separately.
This exact line. On the web card: "Always allow this exact command" — "Only this line, exactly
as written — anything else still asks." In the terminal it is the t key. The stored key is the
command line, byte for byte: no prefix matching, no glob, no whitespace or case folding. Measured:
with npm run build && ./deploy.sh saved exactly, the same line with one extra space asks again, and
npm run build on its own asks again.
This whole command family. On the web card: "Always allow "npm" — don't ask again" — "Every
"npm" command runs without asking, until you revoke it in Settings." In the terminal it is the a
key. The stored key is the command's first token, and it covers every future command line starting
with that token. Measured: with git saved as a family, git status runs unasked and so does
git push. A family grant is a broad promise; take it only where you mean it.
A family grant keeps three conditions on every later use:
- The line must carry no metacharacter. A saved
lsnever coversls; …. - The family must not be an interpreter, an exec-wrapper, a pager or a transfer tool (below).
- If the family is one of the 29 read commands, the path jail still applies. Measured: with
catsaved as a family,cat notes.txtruns andcat /etc/passwdstill asks.
An exact grant faces none of those three, and does not need to: the stored key is precisely the line you read on the card.
Families that can never be saved
Saving these would mean granting arbitrary execution under a name that does not describe what runs. The card withholds the family key and prints the reason. Read out of the code:
- Interpreters —
sh bash zsh dash ksh fish ash csh tcsh busybox python pypy perl ruby node nodejs deno bun php lua tclsh rscript osascript awk gawk mawk nawk - Exec wrappers —
sudo doas su env xargs nohup time timeout ssh docker podman setsid stdbuf nice ionice chroot unshare - Pagers and editors —
less more pg vi vim view vimdiff rvim rview nano pico emacs ed ex(each can spawn a shell from inside; the file argument is a decoy) - Transfer tools —
curl wget scp sftp rsync ftp nc ncat netcat socat
The transfer group is there for a specific reason: their whole job is moving bytes off the machine, and the file to send is an ordinary argument carrying no metacharacter and often no absolute path — nothing the jail walk can see. "Always allow curl" cannot honestly mean what it appears to mean, so it is not offered. The exact line is still grantable.
Why a chained line can never be saved as a family
npm does not describe what npm run build && ./deploy.sh runs. Saved as a family it would grant
every future npm line while never matching that compound again — broader than intended and useless
for the thing you were looking at. The gate refuses, and the card says:
This line chains commands, so a rule for "npm" would never match it again.
The exact-line key stays on offer, because one string with a && in it is still one string.
Dangerous lines are never savable at all
A command matching the dangerous list gets neither key:
It's a recursive delete, so it asks every time — this one can't be saved.
Revoking a grant
Both kinds live in one table and are listed together.
- Terminal:
/approvalslists them with numbers;/approvals rm <name-or-number>takes one back. An exact grant is a whole command line, so the number is there to save you retyping it. - Web: Settings → Security lists them with a revoke control, which calls
DELETE /api/approvals?pattern=….
Revoking only makes her ask more, so neither surface asks you to confirm.
How long a card stays open
A card that nobody answers is not an approval. The window depends on how the turn reaches you:
| Turn | Window |
|---|---|
| Typed | 180 s |
| Voice, local mode | 60 s |
| Voice through the ElevenLabs agent path | 25 s |
The 25 s is not a property of speech: on that path the platform times a silent turn out and re-fires
it, which would orphan the card. An unanswered card is recorded as expired, not as a refusal —
the audit trail distinguishes a decision you made from one you never saw.
On Windows, nothing is automatic
The gate reads command lines the way a POSIX shell would; Windows runs PowerShell. Rather than guess,
every automatic route closes there: the allow-list, both saved-grant kinds, and auto-safe. Measured on
a simulated Windows host with local selected, ls, cat notes.txt, npm install and dir all
ask, and both grant keys are withheld with:
On Windows I can't read a command line the way the shell will, so every one of them asks.
The one grant that still works there is execute_code, which is not a shell command line and was
never read as one.
The limits of this control
The gate decides whether a command runs. It has no opinion about what the command then does. Once
you approve a line on KOTOBA_SANDBOX=local, it runs as your user account with your file
permissions, and nothing narrows it afterwards. The card is the boundary — which is why what the card
shows matters, and why a family grant over a name that does not describe what runs is refused rather
than offered with a warning.
The gate is also blind to the two tools that never consult it: write_file and patch write inside
the working directory without a card.
