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

Plugins6 of 7

The security position, stated plainly

About 6 minutes to read

On this page

A plugin is somebody else's Python running inside her process, as your user account. It is not sandboxed from your machine, and nothing inspects it before it runs.

Everything on this page is a fact about the code, not a warning added for form's sake. Read it before you install a plugin you did not write.


What "installed" means

Installing a folder plugin is copying a directory into ~/.kotoba/plugins/. The next time she starts, the loader executes every .py file in it whose name does not begin with _ — before any tool is called, before any conversation happens, and whether or not the model ever chooses the tool. A plugin's module body runs on start.

Installing an entry-point plugin is pip install, which is the same thing with a package manager in front of it, plus whatever the distribution's own build does.

There is no signature, no manifest of permissions, no review, and no allow-list of what a plugin may import. core/plugins.py catches exceptions so a bad plugin does not stop the server — that is crash-safety, not security. It stops a plugin from breaking things. It does nothing about a plugin that works exactly as its author intended.


What a plugin can reach

Everything the process can. Concretely, and with no permission of any kind:

  • The filesystem, as you. Your home, your keys, your projects. The path jail (validate_within_dir) is a helper a tool chooses to call. Nothing applies it for you.
  • The network. Outbound, unfiltered.
  • Your environment. os.environ in the running process. Both entry points call load_dotenv() before anything else, so whatever is in api/.env — including provider keys kept there rather than in the key store — is in os.environ by the time your plugin loads.
  • Every secret you saved in the app. A plugin holds ctx.db. await ctx.db.get_key(name) returns the plaintext — the keystore decrypts for anything running in her process, because that is what it is for. list_key_names() enumerates them first. Encryption at rest protects the file on disk from someone reading the file; it does not protect it from code running as you.
  • Her conversation history, through the same ctx.db.
  • Other tools. Nothing stops a plugin importing kotoba.tools.action.shell and calling it, or importing kotoba.core.approval and reasoning about the gate.
  • The rest of Python. subprocess, socket, ctypes.

The mitigations documented elsewhere in this site — the scrubbed child environment, the sandbox backends, the workspace jail — apply to code she runs through her tools: a shell command, a Python snippet in execute_code. A plugin is not code she runs. It is code that is her.


What the approval gate does, and does not, cover

This is the part most often assumed backwards.

The approval gate is something a tool calls. It is not something applied to a tool.

There is no interception. execute_with_heartbeat does not consult the gate. RISK does not trigger it. Of the tools in the tree, exactly six ask for permission, and each does it by calling the gate itself:

ToolHow it asks
shellawait ctx.approval.confirm(command, "exec")
execute_codeawait ctx.approval.confirm(code, "exec", family="execute_code", force_ask=risky)
mcp_install, mcp_findinteraction.ask_approval(...) before launching a server
discord_act, discord_apply_planinteraction.ask_approval(...), one card per batch

Every other tool — every file write, every MCP tool — runs without a card.

So: declaring RISK = "exec" in your plugin does not make anything ask the user. It gives your call a step row, an audit-log row, and a longer time budget. Nothing else. A plugin that deletes a directory with risk="read" and never calls the gate deletes the directory, silently, and draws only a status line.

Asking, if you want to

If your tool does something the person should confirm, ask:

python
async def execute(args, ctx):
    action = f"delete {args['name']} from the remote service"
    if ctx.approval is not None and not await ctx.approval.confirm(action, "exec"):
        from kotoba.core.loop import note_tool_refusal
        note_tool_refusal(ctx)
        return "I didn't do it — you declined."
    ...

Three things to know about that call:

  • confirm(action, "read") auto-approves without asking, always. ApprovalGate.auto_safe returns True for risk_kind == "read" immediately. Pass "exec" if you mean to ask.
  • The string you pass is what the card shows and what the audit log records. Write it for a person reading it in one second.
  • Know what "always" then grants. With no family=, the saved grant is keyed on the first token of your action string, and it will auto-approve every future action starting with that token — subject to the gate's own refusals (it will not save a compound line, an interpreter name, or anything matching a destructive pattern). With an explicit family="my_tool", the saved grant covers every future call of your tool, whatever the action text. execute_code uses the explicit form and compensates by passing force_ask=True for snippets that touch disk, network or a secret path. If you take the explicit form, do something equivalent.

Before you install someone else's plugin

  1. Read it. All of it. It is usually one file. If it is not — if it is a package with a build step — read what the build does too.

  2. Look at what runs at import, not just inside execute(). Module-level code runs on every start, whether or not the tool is ever used.

  3. Look for network calls and for ctx.db. A tool that fetches the weather has no business touching your key store or posting anywhere but the weather service.

  4. Prefer a distribution you can read on a package index, with a source repository, over a pasted file.

  5. Load it against a throwaway home first, so the first time its module body runs there are no keys and no conversation history for it to find. KOTOBA_HOME and KOTOBA_PLUGINS_PATH point anywhere you like:

    bash
    mkdir -p /tmp/kotoba-trial/plugins
    cp -r ./some-plugin /tmp/kotoba-trial/plugins/
    KOTOBA_HOME=/tmp/kotoba-trial \
    KOTOBA_PLUGINS_PATH=/tmp/kotoba-trial/plugins \
    DATABASE_URL=sqlite:////tmp/kotoba-trial/trial.db \
      python -c "import kotoba.tools; from kotoba.core.plugins import loaded_plugins; print(loaded_plugins())"
    

    Be clear about what that buys you: an empty key store and an empty database. It is not a sandbox. The plugin still runs as your user, with your whole filesystem and your network. If you want real isolation, use a container or a separate machine.

  6. If you decide against it, delete the directory or pip uninstall. Turning the switch off in Settings deregisters its tools and stops it loading next time, which is enough to stop it being used — but a module that already ran this session has already run. Nothing can un-run code.


For plugin authors

You are asking people to run your code as themselves. A few things make that easier to say yes to:

  • Keep the module body empty of work. Define your schema and your functions; do the work in execute(). Then "what runs at import" is nothing, and it is visible at a glance.
  • Declare the true RISK. It is what the audit log records and what the person watching the panel sees.
  • Call the gate for anything destructive or irreversible, even though nothing forces you to.
  • Take secrets from the environment, not from ctx.db, unless reading the key store is the point of your tool. A plugin that never touches ctx.db is a plugin nobody has to audit for it.
  • Say what you touch, in your README. Which network hosts, which environment variables, which paths.