The agent8 of 15
Terminal and code
On this page
Two tools, two toolsets, one gate: shell (terminal) and execute_code (code). These are the
only two tools routed through the ApprovalGate. (Four others put a card on your screen by a
different route — mcp_install, mcp_find, discord_act, discord_apply_plan — but they ask
directly rather than going through the allowlist-and-grants machinery described here.)
Read this page before you grant anything.
Where the commands run
KOTOBA_SANDBOX picks the backend. The default is local, and local means the host.
| Value | What runs where |
|---|---|
local (default) | On your machine, as you. A child process in a jailed working directory, with a scrubbed environment, its own process group and output caps. No container. |
docker | An ephemeral local container: --network none, --read-only with a small tmpfs /tmp, and only the workspace mounted at /work. |
none | No execution backend. shell and execute_code are not offered at all. |
The setting is called the sandbox, and on the default backend that name means the four safeguards listed below, not isolation. The tool descriptions she reads say "in your working folder", which is true on all three backends. The backend module's own docstring is the plain version:
local: run on the HOST — a child process in a jailed workdir, scrubbed env, timeouts.
What none covers, exactly: no tool of hers starts a child process on your machine — shell and
execute_code are withheld, and search_files answers from its own walk instead of spawning
ripgrep. What it does not cover: an MCP stdio server and the browser are installed and approved
separately, and they still launch when used. The setting governs the agent, not the app.
The four safeguards on local
- A jailed working directory. Every path goes through the same check the file tools use.
- A scrubbed environment. The child gets an allow-list only:
PATH,HOME,LANG,LC_ALL,SHELL,PYTHONPATH,VIRTUAL_ENV,TZ,TERM, plus the desktop names on Linux (DISPLAY,WAYLAND_DISPLAY,DBUS_SESSION_BUS_ADDRESS, the XDG set) or the Windows names. Anything whose name containsKEY,TOKEN,SECRET,PASSWORD,PASSWD,CREDENTIALorAUTHis dropped — with one name exempted by hand,XAUTHORITY, which collides with that last marker and only holds the path to an X11 cookie a child under thisHOMEcould already find. Your API keys never reach model-run code. - Its own process group, killed cleanly on timeout — SIGTERM, then SIGKILL after 5 seconds.
- Output caps. 200,000 bytes per stream at the sandbox, trimmed further before it reaches the model.
Be clear about what safeguard 2 does not do. The desktop variables are not credentials, but they are reach: an approved command can paint a window, read the clipboard and talk to the session bus — which on a typical desktop includes the keyring. The gate is the only thing in front of that.
$TMPDIR, $TMP and $TEMP all point at ~/.kotoba/tmp, not the system /tmp and not your Files.
Which shell
POSIX gets sh -c. Windows gets PowerShell by argv — an absolute path under %SYSTEMROOT%, with
-NoProfile -NonInteractive -EncodedCommand and a base64 UTF-16LE payload, so the command reaches the
interpreter byte for byte. create_subprocess_shell on Windows would run whatever COMSPEC names
(cmd.exe), and then the shell she was taught and the shell that ran matched only by luck.
One function answers "which shell" for all three places that must agree: the prompt she is taught, the interpreter that launches, and what the gate parses. Disagreement between those three is a security hole, so there is exactly one origin.
shell
Takes: command, and an optional timeout (default 60 s). Whatever it asks for, the loop's own
harness cancels the call at 600 s.
Returns exit=<code> plus up to 6,000 characters of stdout and 1,500 of stderr. On the deferred path
— where the command ran after the turn ended — those are 2,000 and 500, phrased as a sentence
("I ran … (exit code 0). Output: …") because that text is relayed aloud.
The schema tells her she can open things on your desktop: xdg-open, open, start. It also
tells her to run the opener in the foreground, because … & exits 0 even when the file does not
exist, and the exit code is her only proof.
execute_code
Takes: code, and an optional timeout (default 60 s, same 600 s harness ceiling).
The approval label is the literal string run Python: followed by the whole snippet, so nothing
is approved unseen.
An "always allow" for execute_code is saved under one fixed family name, not per snippet — that
grant covers computation. Code that does any of the following re-prompts anyway, even under the
grant:
| Flag | What it catches |
|---|---|
fs-destroy | shutil.rmtree, os.remove/unlink/rmdir, .unlink(), .rmdir() |
shell-spawn | os.system, os.popen, subprocess.*, pty.spawn, ctypes, importlib, and import os as … / import subprocess as … |
raw-socket | socket.socket, a bare .connect((…)) |
dynamic-eval | eval(, exec(, __import__( |
fs-write | open(..., 'w'/'a'/'x'/'r+'), os.open with a write flag, .write_text, .write_bytes, shutil.copy/move, os.rename/replace/chmod/chown |
data-out | requests/httpx/aiohttp .post/.put/.patch, a built GET URL (f-string, +, .format), urlopen(data=…), smtplib, ftplib, paramiko |
secret-read | .ssh, id_rsa, id_ed25519, /etc/shadow, /etc/passwd, .aws/credentials, .netrc, .keystore_key, .env |
Two of those deserve a note.
Fetching stays unflagged; sending does not. A plain GET is common and legitimate, and the execution environment is scrubbed. But scrubbing the environment cannot stop code that reads a key off disk and posts it — and a GET whose URL is built rather than written carries whatever was concatenated into the query string, which is sending wearing a reader's name.
import os as o walked past every spelling below it, because os was matched by name. So the
rebinding import is itself flagged, along with ctypes, which reaches libc's system() without
naming a module the list knew.
The open() mode is matched across balanced parentheses, because the path argument is usually a
nested call — a plain "not a bracket" match stopped short of os.path.join(...) and missed even an
append to ~/.bashrc. The mode pattern accepts any short run of mode letters containing a writing
one, because "at" and "r+" are the two that quietly grow a dotfile.
The gate
The decision order, from ApprovalGate.confirm:
allowlist → saved grant (exact line, then family) → auto-safe → ask you → default (deny)
A dangerous command is never auto-safe and is never covered by a saved grant of either width. With no asker wired at all, the default is deny.
What runs without asking
On the host, a command auto-runs only if all of these hold:
- its first token is one of these read commands —
ls pwd echo cat head tail wc grep rg find stat whoami id date uname true printf sort uniq cut tr mkdir base64 xxd od strings hexdump tac nl - it contains no shell metacharacter from
; & | \newline < > $ { }` - it carries no flag that makes the command execute, write or hang —
find -exec/-delete/-fprintf,rg --pre,sort -o/--compress-program,tail -f,head --zero-terminated,grep -Z - every path argument stays inside the workspace, including a path glued to a flag
(
--file=…,-ofile)
mkdir is on the list so that "organize my files" does not prompt. mv, cp and rm are not —
they can overwrite or delete.
Measured, with the workspace containing notes.txt:
| Command | Auto-runs |
|---|---|
ls, ls -la, echo hi, mkdir sub, cat notes.txt | yes |
cat ~/.ssh/id_rsa | no |
ls /etc | no |
echo x && curl http://x | no |
sh -c "cat notes.txt" | no |
tail -f notes.txt | no |
grep --file=/etc/passwd . | no |
find . -delete | no |
git status, npm run build, curl https://example.com | no |
sudo ls, rm -rf / | no |
Each "no" above is a specific hole that was closed. $ and {} are in the metacharacter set because
shlex does not brace-expand or expand variables and /bin/sh -c does — so cat {/etc/passwd,notes.txt}
and cat $HOME/.ssh/id_rsa both arrived as one relative-looking token, resolved inside the workspace,
and auto-ran with no card. The rule that encodes: whatever the gate cannot resolve the way the shell
will, it must ask instead.
A bare word naming a file that does exist is treated as a path, because a symlink there can resolve
outside — so cat link must not auto-run where cat ./link asks.
Dangerous patterns — always a card, never saveable
recursive-delete · delete-root-or-home · privilege-escalation · pipe-to-shell ·
raw-disk-write · filesystem-format · fork-bomb · recursive-chmod-root · power-control ·
history-or-key-wipe · destructive-find · file-truncate-shred · registry-delete ·
backup-wipe
The last two are the Windows spellings of the same harm — that platform has no /etc to protect and
no rm to catch.
recursive-delete asks only for a recursive flag, in any spelling or bundle. Demanding -f as well
read rm -r ~/ as ordinary — -f only silences prompts, -r is the one that empties the tree — so
the pair let a whole home directory through, and its card then offered to remember it.
The two widths of "always allow"
They are separate keys, separate stores and separate Settings rows, because revoking one must not silently revoke the other.
A family grant remembers a first token (npm, git). It is refused when the token does not name
what runs:
- an interpreter —
sh bash zsh python perl ruby node deno php lua awk osascript… — which runs an arbitrary payload as one argument carrying no metacharacter. That is whysh -c 'cat id_rsa'auto-ran whilerm -rfasked. - an exec wrapper —
sudo doas su env xargs nohup time timeout ssh docker podman chroot unshare… - a pager or editor that can spawn a shell —
less!cmd,vi:!cmd. The file argument is a decoy. - a transfer tool —
curl wget scp sftp rsync ftp nc socat. Their whole job is to move bytes off the machine, and the file to send is an ordinary argument:-d @/etc/passwdis not even an absolute path.
It is also refused when the command line carries metacharacters, because npm does not name what
npm run build && ./deploy.sh runs.
An exact grant remembers one command line, byte for byte. No prefix, no glob, no whitespace or
case folding. echo a and echo a are two separate grants, and that is the point: you granted the
line you read. Metacharacters inside it are fine — they are part of one string rather than an escape
from a shorter one.
Neither survives a dangerous command. Neither is offered on Windows.
When the broad grant is withheld, the card says why in one sentence — the same sentence in the browser and in the terminal:
"'sh' only names what runs it, not what runs — saving that would allow anything." "This line chains commands, so a rule for 'npm' would never match it again." "It's a recursive delete, so it asks every time — this one can't be saved."
Windows
On a Windows host with the local backend, every automatic route is closed. The allowlist, both
saved grants and auto-safe all read the command line in POSIX while PowerShell is what runs it, so
the card is always shown and both "always" keys are withheld rather than saved into a dead rule.
"On Windows I can't read a command line the way the shell will, so every one of them asks."
The dangerous-pattern list still runs, so the card still says when the thing you are about to approve deletes something.
How long a card stays open
| Situation | Window |
|---|---|
| A typed channel, and any work-mode job | 180 s |
Our own voice socket (voice_mode: local) | 60 s |
| An ElevenLabs agent call, or an unknown transport | 25 s |
The 25 seconds was never a property of speech. It binds a turn through /v1, where ElevenLabs times
a silent turn out and re-fires it, orphaning the approval. On our own socket nothing re-fires, and
"25 seconds to read an rm -rf" is hostile.
Both unknowns fall to the tight side. That costs an early deny; the other way costs an abandoned card, or 155 seconds taken off a typed one.
An exec tool is always given at least the window plus 5 seconds of compute budget, so the loop can
never cancel the tool while its own card is still on your screen.
The deferred path
There is one transport that cannot block: an ElevenLabs agent turn. There the tool shows the card, ends the turn, and a detached task waits outside it — up to 180 seconds — then runs the action and announces the result through the work state.
That path is keyed on the real transport, not on channel == "voice". Local voice blocks in place.
It is one request, one run: the schedule keys on your request plus the normalised action and refuses
a second, because nothing durable records that a tool ran and the announce turn re-derives the same
call respelled (1,101 → 1, 101).
The audit trail
Every gated decision and every action writes a row to audit_log, in ~/.kotoba/kotoba.db — or in
api/kotoba.db on a checkout that already has one, since a database beside the package wins:
action, risk_kind, approved, approver, detail, session_id, created_at.
detail says which event it is — decision (someone said yes or no) or executed /
executed:failed (it actually ran). Two rows for one gated command: one for the decision, one for
the run. Every action tool writes an executed row, gated or not, and those carry
approver="agent-loop".
On a decision row, approver names whose authority allowed it, and the vocabulary is exact:
approver | Meaning |
|---|---|
user | You decided — yes or no |
allowlist | An explicit allowlist entry |
saved | Your saved family grant |
saved-exact | Your saved exact-line grant |
auto-safe | A provably-safe read inside the workspace |
expired | The card timed out unanswered — nobody decided |
dismissed | The card was waved away when you spoke |
error | The card could not be drawn at all |
default | No asker wired; denied |
The last three exist because a card that expired unread was once written down as
approved=0, approver="user" — the trail asserting you saw it and refused it.
A tool that never executed does not get an executed row. The gate's decision is the only trace.
