Write down what agent chats are and what will bite you

CLAUDE.md gets the entries worth having been told: that the mode is
enforced in the loop rather than the prompt and why that distinction is
load-bearing; that an approved call has to be told it was approved, or the
runners' own backstop refuses the very thing somebody just allowed; that
`registry` must know the agent tools or the harness cannot name the machine
-- the same omission that cost custom tools their guidance once already;
that each command is a fresh shell and `apt-get install` needs an update
first, which are the two likeliest sources of "the agent seems stupid";
that asyncssh's four defaults are all wrong when one unix account is
shared; and that rewind rewinds the transcript and not the machine.

"Not built yet" loses agentic execution and gains the reason nothing runs
on this host -- with the two consequences stated plainly, since they are
the ones somebody has to weigh: the security of an agent chat is the
security of the host behind its profile, and there is no "no network"
switch, because the network belongs to the far side.

README gets a section that starts with the container, because that is the
intended shape and the thing a reader has to build before any of it means
anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-02 00:29:30 +02:00
parent 16e59feab2
commit 246be1fa8e
3 changed files with 204 additions and 29 deletions
+112 -13
View File
@@ -12,14 +12,15 @@ no JavaScript build step.
```bash
. .venv/bin/activate
pip install -e ".[dev,search]" # `search` adds ddgs for DuckDuckGo
pip install -e ".[dev,search,ssh]" # `search` adds ddgs for DuckDuckGo,
# `ssh` adds asyncssh for agent chats
lembas serve # http://127.0.0.1:8080
lembas info # paths + counts, useful when confused
lembas secret-key # generate LEMBAS_SECRET_KEY
lembas create-admin # create or promote an admin
pytest # 698 tests, ~40s
pytest # 837 tests, ~50s
# PLAN.md tracks what is and is not built
ruff check . # lint (line length 100)
python scripts/build_artwork.py # regenerate artwork (SVG + PWA icons;
@@ -83,6 +84,8 @@ src/lembas/
admin_prompts.py the prompt fragment editor and its preview
admin_suggestions.py the cards offered on the new-chat screen
admin_tools.py custom HTTP tools and MCP servers
admin_agents.py whether agent chats exist, and what they may spend
agents.py SSH connections, kept by the people who own them
audio.py transcribe, speak, voice discovery
library.py knowledge, notes, skills pages; memory CRUD
files.py upload, serve, remove attachments
@@ -98,6 +101,7 @@ src/lembas/
search/ ddgs, SearXNG and Firecrawl behind one shape
library/ documents, notes, memories, skills, FTS
mcp/ remote MCP servers: framing, transport, rows to tools
agent/ agent chats: the mode table, SSH, and the four tools
audio.py OpenAI-shaped /v1/audio/* client
fetch.py URL retrieval, HTML to text, the SSRF guard
sharing.py one visibility rule for every library store
@@ -110,6 +114,7 @@ src/lembas/
tools.py tool registry, schemas, streamed-call reassembly
custom_tools.py the admin-defined HTTP tool runner
tool_access.py who may be offered which admin-defined tool
interaction.py pausing a reply to ask the reader something
chat.py request building, endpoint resolution, titles
markdown.py markdown-it + pygments + nh3
crypto.py Fernet encrypt/decrypt/mask
@@ -291,6 +296,81 @@ along with the reply, and a reload starts the turn afresh -- the model asks
again. That is consistent with "a restart abandons replies in flight", but it
means an approval is not a durable record of consent.
**A chat's kind and connection are fixed at creation; only the mode moves.**
`Chat.kind`, `ssh_profile_id` and `project_dir` are chosen on the new-chat screen
and refused by `update_chat` thereafter with a 409 — a transcript whose earlier
turns ran somewhere else is not one conversation. `agent_mode` is the exception
and changes freely: it decides what gets asked about, not what the conversation
is. The mode is read **once per reply**, so switching to Auto mid-reply cannot
retroactively approve what is already queued.
**The mode is enforced in the loop, never in the prompt.** `_authorise` consults
`agent/policy.py:decide()` server-side, keyed on each `ToolDef.risk`. A model is
*told* which mode it is in so it behaves sensibly, but everything it reads — a
web page, a README, the output of the last command — is untrusted, and a rule
living only in a system message is one a poisoned file can argue with. Within an
agent chat **every** call goes through the table, including the built-ins:
`notes_edit` writes, and Plan mode meaning "look but do not touch" has to mean
that too.
**An approved call needs telling.** Every agent runner re-checks the mode as a
backstop, so a call arriving by a path that skipped `_authorise` cannot walk
past it. That backstop refused the very thing a person had just approved — the
mode says "ask", and asking is exactly what happened. `AgentContext.approved` is
threaded per call on a *copy* of the context, because a round runs its calls
together and only some of them were allowed.
**`registry(db)` must know every tool that can be offered, agent tools
included.** It maps an offered tool *name* back to a family, which is how the
harness decides that `tool.agent` applies. They are listed there unbound to any
chat. Without them `shell_run` resolves to no family, and an agent chat is told
nothing about the machine it is working on. The identical omission cost custom
tools their guidance once already; there is a test for it now.
**A tool description is schema; the harness is where "where" lives.**
Descriptions are sent verbatim and are deliberately not editable, so they state
facts about the runner. Which machine, which directory and which mode belong to
*this chat* and live in the `tool.agent` fragment, where they can change without
the schema shifting under a model mid-conversation.
**Each command is a fresh shell.** Connections are per call, so `cd build`
followed by `make` fails silently — `cwd` is a first-class parameter reaching the
executor, never spliced into the command string. This is the likeliest single
cause of "the agent seems stupid", and the harness says it out loud. So does the
other one: on a Debian-derived host `apt-get install` reports the package missing
until `apt-get update` has run.
**Files never go through a shell.** The SSH exec protocol carries one command
*string* that the far side parses, with no argv form at all, so a model-supplied
path in a command line is unavoidably a quoting problem. `file_read`/`file_write`
/`file_list` use SFTP, where a path is a path.
**asyncssh's defaults are wrong here, all four of them.** Every LLeMbas user
shares one unix account, so `known_hosts` unset reads a *shared* trust store
(and `None` disables checking entirely), `client_keys` unset loads whatever is in
`~/.ssh`, `config` unset lets a `ProxyCommand` redirect the connection, and
`agent_path` unset uses `$SSH_AUTH_SOCK`. All four are passed explicitly on every
connection, and the test that proves it needs no server.
**A pinned host key belongs to a host and a port.** Moving a profile forgets it
deliberately. `capture_host_key` completes the key exchange and stops, so a host
that has not been accepted is never offered a username, let alone a credential —
which is what makes accepting a fingerprint from a button safe.
**A plan ends the turn, but not mid-sentence.** `plan_submit` is offered in Plan
mode only, and the round after it runs with the tools withdrawn: the model gets
to say what it proposed, and cannot spend three more rounds changing its mind
about a plan somebody is being asked to approve. Carrying it out switches to
**Edit, never Auto**, and the plan goes back quoted and attributed rather than
stated — text that came out of a file the model read must not arrive wearing the
reader's authority.
**Rewind rewinds the transcript, not the machine.** Editing or regenerating in an
agent chat stamps `Chat.rewound_at` and the harness warns that files from steps
no longer in the transcript are still there. Nothing tries to undo them: the
project directory is somebody's real working tree, and deleting their work to
match would be far worse than the inconsistency.
**Unread is polled, not pushed.** A browser on another chat has no connection
to the one that finished. `/api/chats/unread` returns out-of-band dot spans and
an `HX-Trigger` for the toast; `unread_notified` stops the same arrival being
@@ -620,18 +700,37 @@ notes describe the machine.
## Not built yet
Agentic execution (local subprocess and SSH connection profiles), image
generation. A nav entry marks where each one goes. The tool loop in
`services/generation.py` is what they plug into — a new tool is a `ToolDef`
reaching `tools.resolve_tools()` plus a permission and a capability flag, not a
new code path. Its guidance is the same shape: a `prompts.register_source`
yielding one `Fragment` per row puts it in the harness, on the admin page and in
the preview without touching the assembler, the save handler or a template.
Custom HTTP tools and MCP servers are built and are the worked example of both.
Image generation, and a nav entry marks where it goes. The tool loop in
`services/generation.py` is what a new capability plugs into — a tool is a
`ToolDef` reaching `tools.resolve_tools()` plus a permission and a capability
flag, not a new code path. Its guidance is the same shape: a
`prompts.register_source` yielding one `Fragment` per row puts it in the harness,
on the admin page and in the preview without touching the assembler, the save
handler or a template. Custom HTTP tools, MCP servers and agent chats are the
three worked examples.
**Local MCP is deliberately absent.** Only remote servers over streamable HTTP.
Spawning `npx` is the agentic-execution feature, which wants a confirmation model
before it does anything; a URL is a different act with a different blast radius.
**Nothing executes on this machine, and that is the design.** Agent chats run
their commands on a host reached over SSH. A local sandbox was designed in
detail — bubblewrap, a masked data directory, a curated bind list — and dropped,
because every hard problem in it came from running on the machine that holds the
database and the encryption key: the service user cannot traverse `/home`,
granting it needs ACLs, `RLIMIT_NPROC` is counted per *uid* so a fork bomb
starves the server too, `--size` applies only to tmpfs so there is no disk quota,
and a bind list is a standing invitation to widen until the sandbox is
decoration. Over SSH, isolation is somebody's considered choice of host, using
tools far better at it than anything that could be built here — and it is the
only version that is honestly multi-user.
Two consequences worth stating plainly. **The security of an agent chat is the
security of the host behind its profile**, and nothing here can tell a throwaway
container from a production server. And there is no equivalent of the
`network: False` switch the local sandbox would have had, because the network
belongs to the far side — so an instruction injected through something the model
read can, in principle, be carried out from there.
**Local MCP is absent for the same reason.** Only remote servers over streamable
HTTP. Spawning `npx` would be a subprocess on this machine, which is the thing
that is deliberately not done.
**Unknown is not zero.** `Model.context_length` of 0 means nobody has said how
big the window is, which is different from "small". The context percentage is