The web app5 of 12
The approval card
On this page
When Kotoba needs a decision from you — permission to run something, or a value only you can supply — a card takes over the screen. It is the one surface in the browser that authorises anything.
Screenshot placeholder: an approval card centred over a blurred stage — a yellow tick badge, the command on one line, a "Show full command" pill under it, then a green "Yes, go ahead" and a grey "No" side by side, and a wide purple "Always allow this exact command" button below with a small grey note under it.
The kinds of card
One component draws all of them. There are three modes, and the input mode has four flavours
(components/panels/InputRequestPanel.tsx:71-75, lib/store.ts:96-101):
| Mode / kind | What it asks | How the answer travels |
|---|---|---|
approval | may this action run | POST /api/session/{id}/input |
input · text | a value she can use out loud | spoken back into the conversation, or posted if the tool is blocked waiting |
input · key | a reusable credential of hers | posted only — masked, never echoed |
input · secret | your one-time password | posted only — masked, used once, never saved |
open_link | permission to open a URL in a new tab | nothing is posted; the tab opens on your click |
The card is portalled to <body> and drawn over a blurred backdrop
(InputRequestPanel.tsx:43-46, 156-176).
It cannot be dismissed by accident. There is no Escape handler, and clicking the backdrop does
nothing — both pinned by a test (tests/approval-card-grants.test.mjs:21-22). Every button is
single-flight: a second submit from a double-click or Enter-then-click is ignored
(InputRequestPanel.tsx:63, 114, 130, 140, 152).
Only the newest card is drawn, and answering it dismisses that card by its own request id — not
"whichever opened last" (CompanionExperience.tsx:843-848, lib/approval-card.ts:51-53).
What an approval card shows
- A headline. The first non-blank line of the command, capped at 120 characters
(
lib/approval-card.ts:1, 35-39). A card asking for a yes must name what runs. - Show full command. When the command is longer than the headline, the whole thing opens in a
dark monospace block (
InputRequestPanel.tsx:190-224). - A notice block, on the cards that carry one — an alert line, named fact rows, a warning, and a
quoted description. Every field is drawn under a label the backend chose, so an outside server can
only ever supply a value, never name a field (
lib/store.ts:86-95).
Nothing in the notice is clipped. The label is one string, and clipping it would decide for you which
half matters — on an external-tool install card, the half that used to lose was "Secrets needed:", on
the one card where a third party is asking for a secret (InputRequestPanel.tsx:1-10).
Every drawn value is stripped of invisible and bidirectional control characters at the one door into
client state (lib/store.ts:248-256). This is not decoration: a right-to-left override inside a shell
label reverses the rest of the line on screen, so what you approve is not what will run. The test that
pins it pushes those exact bytes through the store and checks that no readable byte and no
right-to-left name is lost in the process (tests/input-request-scrub.test.mjs).
The two grants
Beside Yes, go ahead and No, a card may offer up to two standing grants
(lib/approval-card.ts:28-33). Which appear is the backend's decision, sent as flags on the card, and
the browser never second-guesses them.
Always allow this exact command
Saves this one command line, character for character. The stored key is the text you read on the
card, and matching is byte-for-byte: no prefix, no glob, no whitespace folding, no case folding
(api/src/kotoba/core/approval.py:458-473). echo a and echo a are two different grants, and that
is the point — you granted the line you read.
The card says so, in one sentence under the button: "Only this line, exactly as written — anything
else still asks. Revoke it in Settings." (InputRequestPanel.tsx:86-87.)
It is withheld in three cases (api/src/kotoba/core/approval.py:350-368): a command matching a
dangerous pattern, an action whose family was named explicitly by the tool rather than derived from
the command line, and Windows — where no stored grant is consulted at all.
Always allow "npm" — don't ask again
The broader grant. The button carries the family name, or reads Always allow her to run Python
when the action is code rather than a shell command (InputRequestPanel.tsx:80-85).
It saves a command family — the first token of the command line — so every future command starting
with that token runs unasked. The note under it says exactly that:
"Every "npm" command runs without asking, until you revoke it in Settings." For code execution it
reads differently and more bluntly: "Any code she writes then runs on this computer without asking,
until you revoke it in Settings." (InputRequestPanel.tsx:80-92.)
A family is a summary of an infinite set of future commands, and the summary is lossy. So it is
withheld whenever the summary would stop naming what runs
(api/src/kotoba/core/approval.py:324-347):
- the command matches a dangerous pattern;
- the line chains commands —
npm run build && ./deploy.shis not described bynpm, and a savednpmrule would never match that line again while silently granting every simplenpm; - the family is an interpreter or an exec wrapper — saving
sh,pythonorenvis saving arbitrary execution; - the host is Windows.
When the family grant is withheld, the card says why. One sentence, generated by the backend so
the browser and the terminal cannot disagree (api/src/kotoba/core/approval.py:371-397), drawn under
the buttons (InputRequestPanel.tsx:292-296):
This line chains commands, so a rule for "npm" would never match it again. "sh" only names what runs it, not what runs — saving that would allow anything. It's a recursive delete, so it asks every time — this one can't be saved.
A chained command still offers the narrow grant. Chaining removes the family button, not the exact one: the exact key is the whole line as one string, so metacharacters inside it are part of that one string rather than an escape from a shorter one (
api/src/kotoba/core/approval.py:351-357).
Both grants are revocable from Settings → Security → Allowed commands, and each row says which
width it is: just this line: … or any "npm" command (SettingsPanel.tsx:482-490).
Cards that ask you to type
The field is masked for a key or a secret, with a reveal control. A line under the box states
which of the two it is (InputRequestPanel.tsx:336-342):
- secret — "Used once for this step and then discarded — it's never saved and never appears in the conversation."
- key — "Saved with your credentials on the server only — it never appears in the conversation."
A blocking card — any key or secret, or any card the backend marked as waiting — delivers its
value by POST and the value never re-enters the conversation
(lib/approval-card.ts:41-49, InputRequestPanel.tsx:138-147). A non-blocking text card is spoken
back into the turn instead, because that is what it is for. The call shell enforces the same rule from
its side: a posted value is never echoed into the transcript
(CompanionExperience.tsx:600-608).
A key card also sends its storage name, or the backend cannot file it and the value would be promised
and then dropped (lib/approval-card.ts:41-49).
Open-link cards
The URL is shown in full and is also a real anchor. Clicking either the anchor or Open in new tab
opens it with noopener,noreferrer, on your gesture so it is not blocked as a popup. Nothing is
posted back and nothing is echoed (InputRequestPanel.tsx:112-118, 298-317). Not now just closes
the card.
While the events channel is down
Approval cards ride the events channel. When that channel drops, the stage notice says so and names
the consequence: "Approvals and live progress are paused until it's back." (lib/notices.ts:14-16, 79.) Nothing is silently approved; the card simply does not arrive until the channel is back.
