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

The agent8 of 15

Terminal and code

About 12 minutes to read

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.

ValueWhat 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.
dockerAn ephemeral local container: --network none, --read-only with a small tmpfs /tmp, and only the workspace mounted at /work.
noneNo 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

  1. A jailed working directory. Every path goes through the same check the file tools use.
  2. 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 contains KEY, TOKEN, SECRET, PASSWORD, PASSWD, CREDENTIAL or AUTH is 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 this HOME could already find. Your API keys never reach model-run code.
  3. Its own process group, killed cleanly on timeout — SIGTERM, then SIGKILL after 5 seconds.
  4. 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:

FlagWhat it catches
fs-destroyshutil.rmtree, os.remove/unlink/rmdir, .unlink(), .rmdir()
shell-spawnos.system, os.popen, subprocess.*, pty.spawn, ctypes, importlib, and import os as … / import subprocess as …
raw-socketsocket.socket, a bare .connect((…))
dynamic-evaleval(, exec(, __import__(
fs-writeopen(..., 'w'/'a'/'x'/'r+'), os.open with a write flag, .write_text, .write_bytes, shutil.copy/move, os.rename/replace/chmod/chown
data-outrequests/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:

CommandAuto-runs
ls, ls -la, echo hi, mkdir sub, cat notes.txtyes
cat ~/.ssh/id_rsano
ls /etcno
echo x && curl http://xno
sh -c "cat notes.txt"no
tail -f notes.txtno
grep --file=/etc/passwd .no
find . -deleteno
git status, npm run build, curl https://example.comno
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 interpretersh bash zsh python perl ruby node deno php lua awk osascript … — which runs an arbitrary payload as one argument carrying no metacharacter. That is why sh -c 'cat id_rsa' auto-ran while rm -rf asked.
  • an exec wrappersudo 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 toolcurl 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/passwd is 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

SituationWindow
A typed channel, and any work-mode job180 s
Our own voice socket (voice_mode: local)60 s
An ElevenLabs agent call, or an unknown transport25 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,1011, 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:

approverMeaning
userYou decided — yes or no
allowlistAn explicit allowlist entry
savedYour saved family grant
saved-exactYour saved exact-line grant
auto-safeA provably-safe read inside the workspace
expiredThe card timed out unanswered — nobody decided
dismissedThe card was waved away when you spoke
errorThe card could not be drawn at all
defaultNo 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.