Kotoba

Where to start

What Kotoba isWho she is and where she runs, in one page
InstallingOne package, two commands
First runA key, a model, her first words
The approval gateHow she asks before she acts
The two voice modesLocal voice, or the agent tunnel
The soul fileChange who she is
RoadmapWhat grows next, and what was cut on purpose

Or a section

↑↓ move openesc close124 pages
All pages

Security2 of 10

The approval gate

About 8 minutes to read

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.

python
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:

  1. 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.
  2. A saved grant — either this exact line, or this command family. Both are described below.
  3. Auto-safe — a plain read inside the working folder.
  4. Ask you.
  5. 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/:

CommandResult
ls, ls -la, ls subruns, no card
cat notes.txt, cat sub/../notes.txtruns, no card
grep foo notes.txt, wc -l notes.txt, stat notes.txtruns, no card
find . -name '*.py', rg patternruns, no card
mkdir newdir, pwd, echo helloruns, no card
ls /etcasks — path outside the working directory
cat /etc/passwd, cat ~/.ssh/id_rsaasks — same reason
cat ../../../etc/passwdasks — resolved, then refused
cat $HOME/.ssh/id_rsaasks$ 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.txtasks — a flag that turns a read into something that never returns
sort -o out.txt notes.txtasks — a flag that turns a read into a write
rg --pre sh .asks — a flag that turns a read into an execution
find . -deleteasks — 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 xasks — none of them read
git status, npm install, pip install …, df -h, ps auxasks
python script.py, sh -c '…', curl …, sudo …, docker psasks

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 ls never covers ls; ….
  • 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 cat saved as a family, cat notes.txt runs and cat /etc/passwd still 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:

  • Interpreterssh 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 wrapperssudo doas su env xargs nohup time timeout ssh docker podman setsid stdbuf nice ionice chroot unshare
  • Pagers and editorsless 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 toolscurl 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: /approvals lists 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:

TurnWindow
Typed180 s
Voice, local mode60 s
Voice through the ElevenLabs agent path25 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.