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 ```bash
. .venv/bin/activate . .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 serve # http://127.0.0.1:8080
lembas info # paths + counts, useful when confused lembas info # paths + counts, useful when confused
lembas secret-key # generate LEMBAS_SECRET_KEY lembas secret-key # generate LEMBAS_SECRET_KEY
lembas create-admin # create or promote an admin 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 # PLAN.md tracks what is and is not built
ruff check . # lint (line length 100) ruff check . # lint (line length 100)
python scripts/build_artwork.py # regenerate artwork (SVG + PWA icons; 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_prompts.py the prompt fragment editor and its preview
admin_suggestions.py the cards offered on the new-chat screen admin_suggestions.py the cards offered on the new-chat screen
admin_tools.py custom HTTP tools and MCP servers 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 audio.py transcribe, speak, voice discovery
library.py knowledge, notes, skills pages; memory CRUD library.py knowledge, notes, skills pages; memory CRUD
files.py upload, serve, remove attachments files.py upload, serve, remove attachments
@@ -98,6 +101,7 @@ src/lembas/
search/ ddgs, SearXNG and Firecrawl behind one shape search/ ddgs, SearXNG and Firecrawl behind one shape
library/ documents, notes, memories, skills, FTS library/ documents, notes, memories, skills, FTS
mcp/ remote MCP servers: framing, transport, rows to tools 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 audio.py OpenAI-shaped /v1/audio/* client
fetch.py URL retrieval, HTML to text, the SSRF guard fetch.py URL retrieval, HTML to text, the SSRF guard
sharing.py one visibility rule for every library store sharing.py one visibility rule for every library store
@@ -110,6 +114,7 @@ src/lembas/
tools.py tool registry, schemas, streamed-call reassembly tools.py tool registry, schemas, streamed-call reassembly
custom_tools.py the admin-defined HTTP tool runner custom_tools.py the admin-defined HTTP tool runner
tool_access.py who may be offered which admin-defined tool 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 chat.py request building, endpoint resolution, titles
markdown.py markdown-it + pygments + nh3 markdown.py markdown-it + pygments + nh3
crypto.py Fernet encrypt/decrypt/mask 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 again. That is consistent with "a restart abandons replies in flight", but it
means an approval is not a durable record of consent. 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 **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 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 an `HX-Trigger` for the toast; `unread_notified` stops the same arrival being
@@ -620,18 +700,37 @@ notes describe the machine.
## Not built yet ## Not built yet
Agentic execution (local subprocess and SSH connection profiles), image Image generation, and a nav entry marks where it goes. The tool loop in
generation. A nav entry marks where each one goes. The tool loop in `services/generation.py` is what a new capability plugs into — a tool is a
`services/generation.py` is what they plug into — a new tool is a `ToolDef` `ToolDef` reaching `tools.resolve_tools()` plus a permission and a capability
reaching `tools.resolve_tools()` plus a permission and a capability flag, not a flag, not a new code path. Its guidance is the same shape: a
new code path. Its guidance is the same shape: a `prompts.register_source` `prompts.register_source` yielding one `Fragment` per row puts it in the harness,
yielding one `Fragment` per row puts it in the harness, on the admin page and in on the admin page and in the preview without touching the assembler, the save
the preview without touching the assembler, the save handler or a template. handler or a template. Custom HTTP tools, MCP servers and agent chats are the
Custom HTTP tools and MCP servers are built and are the worked example of both. three worked examples.
**Local MCP is deliberately absent.** Only remote servers over streamable HTTP. **Nothing executes on this machine, and that is the design.** Agent chats run
Spawning `npx` is the agentic-execution feature, which wants a confirmation model their commands on a host reached over SSH. A local sandbox was designed in
before it does anything; a URL is a different act with a different blast radius. 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 **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 big the window is, which is different from "small". The context percentage is
+36 -13
View File
@@ -5,8 +5,9 @@ that would be expensive to revisit. Kept current as work lands; the detail of
*how* things work lives in [`CLAUDE.md`](CLAUDE.md). *how* things work lives in [`CLAUDE.md`](CLAUDE.md).
**Status:** usable daily. Streaming chat, attachments, reasoning, tool calling **Status:** usable daily. Streaming chat, attachments, reasoning, tool calling
with web search, a knowledge library, notes, memory and skills, speech in and with web search, custom HTTP tools and MCP servers, agent chats that work on a
out, users and groups, model administration, installable as an app. 437 tests, machine over SSH, a knowledge library, notes, memory and skills, speech in and
out, users and groups, model administration, installable as an app. 837 tests,
`ruff` clean. `ruff` clean.
--- ---
@@ -66,6 +67,9 @@ be a different project, not a refactor.
the next turn, for the same reasons reasoning is not the next turn, for the same reasons reasoning is not
- [x] A round's calls run together, and the reply says which tool is running — - [x] A round's calls run together, and the reply says which tool is running —
a remote tool taking seconds with nothing streaming looks like a hang a remote tool taking seconds with nothing streaming looks like a hang
- [x] **A reply can stop and ask you something** — one or more questions on one
card, with answers to pick from and a box to write your own, answered
together. The same mechanism carries command approvals
- [x] **Custom HTTP tools** — an administrator describes one call: a JSON Schema, - [x] **Custom HTTP tools** — an administrator describes one call: a JSON Schema,
a URL template, headers, an encrypted secret and how to read the answer. a URL template, headers, an encrypted secret and how to read the answer.
Arguments may fill a hole but never move the target: the scheme and host Arguments may fill a hole but never move the target: the scheme and host
@@ -78,8 +82,36 @@ be a different project, not a refactor.
a model as instructions a model as instructions
- [x] Both gated like the built-ins — a model capability, a permission — and - [x] Both gated like the built-ins — a model capability, a permission — and
restrictable to groups, with guidance of their own on `/admin/prompts` restrictable to groups, with guidance of their own on `/admin/prompts`
- [x] Local MCP over stdio is deliberately absent: spawning a subprocess is the - [x] Local MCP over stdio is deliberately absent: spawning a subprocess would
agentic-execution feature and wants a confirmation model first run on this machine, which nothing here does
### Agent chats
- [x] A chat is a **Chat** or an **Agent**, chosen when it starts and fixed
thereafter — a transcript whose earlier turns ran somewhere else is not
one conversation. Knowledge, memories and skills are shared across both
- [x] **Nothing runs on the LLeMbas host.** Commands go to a machine reached
over SSH, so containment is somebody's considered choice of host — a
container built for the job — rather than a sandbox built here. A local
one was designed in detail and dropped; see CLAUDE.md for why
- [x] **SSH connections are user-owned**, like notes. An administrator decides
only whether the feature exists at all
- [x] Trust on first use, made explicit: adding a host does not connect to it,
**Check** shows its fingerprint with nothing sent, and only accepting
pins it. A host that later answers with a different key is refused
- [x] Four modes as a table over what each tool does to the world —
**Manual** asks about everything, **Edit** writes freely but asks before
commands, **Auto** asks about nothing, **Plan** reads freely and changes
nothing. Switchable at any time; read once per reply
- [x] Enforced in the generation loop, not in the prompt: a rule a model is
merely told is one a poisoned file can argue with
- [x] A deny list beats **Auto**; an allow list cannot be matched by a command
containing anything that joins two commands together
- [x] `shell_run`, `file_read`, `file_write`, `file_list` — files over SFTP,
never through a shell, because the SSH exec protocol has no argv form
- [x] **Plan mode ends with a plan** you can carry out with one button, which
switches to Edit and sends it back quoted rather than as an instruction
- [x] Per-reply budgets on steps, wall clock and output, with time spent
waiting for you subtracted
### The library ### The library
- [x] **Knowledge bases** — documents, images and saved web pages, grouped into - [x] **Knowledge bases** — documents, images and saved web pages, grouped into
@@ -171,15 +203,6 @@ be a different project, not a refactor.
In the order they are likely to be worth doing. In the order they are likely to be worth doing.
### Agentic execution
Two modes, as originally specified:
- **local** — subprocess on the machine LLeMbas runs on
- **remote** — SSH connection profiles, with `shell.run` / `fs.read` / `fs.write`
Needs a confirmation model before it does anything. Note that the systemd unit
is deliberately only `ProtectSystem=full` rather than `strict` **because** of
this — revisit the hardening when the real filesystem needs are known.
### Image generation ### Image generation
Left until last from the start, as it needs heavy customisation. ComfyUI is Left until last from the start, as it needs heavy customisation. ComfyUI is
already running on this machine and is the obvious first target. already running on this machine and is the obvious first target.
+56 -3
View File
@@ -55,6 +55,17 @@ runtime. Clone it, `pip install -e .`, run it.
template, a secret) and a model can make it. Or add an **MCP server** by URL template, a secret) and a model can make it. Or add an **MCP server** by URL
and its tools appear beside the built-in ones. Both restrictable to groups, and its tools appear beside the built-in ones. Both restrictable to groups,
and neither can be pointed at your own network unless you say so and neither can be pointed at your own network unless you say so
- **Agent chats** — start a chat as an *Agent* instead, pointed at one of your
own SSH connections and a directory on it, and a model can read files, write
files and run commands **there**. Nothing ever runs on the machine LLeMbas
itself is on. What it may do without asking is a mode you set and can change
mid-conversation: *Manual* shows you everything first, *Edit* writes freely
but asks before commands, *Auto* asks about nothing, and *Plan* reads freely,
changes nothing, and finishes by proposing steps you can carry out with one
button. Adding a host shows you its fingerprint before anything is sent to it
- **It can ask you things** — a model that needs a decision can stop and put a
few questions on one card, with answers to pick from and a box to write your
own. In any chat, not only an agent one
- **Speech in and out** — dictate a message and have replies read aloud, against - **Speech in and out** — dictate a message and have replies read aloud, against
any OpenAI-compatible audio endpoint (whisper.cpp, Speaches, Kokoro…). Each any OpenAI-compatible audio endpoint (whisper.cpp, Speaches, Kokoro…). Each
person picks their own voice person picks their own voice
@@ -83,8 +94,7 @@ runtime. Clone it, `pip install -e .`, run it.
**Planned** **Planned**
Agentic execution (local and over SSH) · image generation · OCR for scanned Image generation · OCR for scanned PDFs · semantic search in the library.
PDFs · semantic search in the library.
See [PLAN.md](PLAN.md) for what is built, what is not, and why. See [PLAN.md](PLAN.md) for what is built, what is not, and why.
@@ -95,7 +105,8 @@ git clone https://git.houmeres.sk/Houmeres/LLeMbas.git
cd LLeMbas cd LLeMbas
python -m venv .venv && . .venv/bin/activate python -m venv .venv && . .venv/bin/activate
pip install -e ".[dev,search]" # `search` adds DuckDuckGo; drop it if unwanted pip install -e ".[dev,search,ssh]" # search: DuckDuckGo. ssh: agent chats.
# Drop either if you do not want it
cp .env.example .env cp .env.example .env
lembas secret-key # paste the result into LEMBAS_SECRET_KEY lembas secret-key # paste the result into LEMBAS_SECRET_KEY
@@ -141,6 +152,48 @@ Recorded audio is passed straight through and never written to disk.
> The microphone needs HTTPS or localhost. Browsers do not grant it over plain > The microphone needs HTTPS or localhost. Browsers do not grant it over plain
> HTTP, so a LAN install without TLS will not offer dictation. > HTTP, so a LAN install without TLS will not offer dictation.
### Agent chats
**Admin → Agents** to turn the feature on, then **Connections** in the sidebar
to add a machine. Three things have to line up before an agent chat can start:
the feature enabled, the *Run commands* permission, and a model flagged **Agent
execution**. All three are off by default, on purpose.
Nothing an agent does runs on the machine LLeMbas is on. Commands go to a host
you name over SSH, which means **the containment is that host** — a container
built for the job is a very different thing from a key to a server you care
about, and LLeMbas cannot tell them apart. A throwaway container is the intended
shape:
```bash
docker run -d --name agent-box -p 127.0.0.1:2222:22 <an sshd image>
```
Adding a connection does not connect to it. **Check** shows you the host's
fingerprint with nothing sent — not your username, not your key — and only
accepting pins it. If that host later answers with a different key, it is
refused rather than quietly trusted.
Then start a chat with the **Agent** toggle, pick the connection and a
directory, and choose a mode. The mode is in the chat header and changes at any
time:
| | Reads | Writes files | Runs commands |
|---|---|---|---|
| **Manual** | asks | asks | asks |
| **Edit** | free | free | asks |
| **Auto** | free | free | free |
| **Plan** | free | asks | asks |
The mode is enforced in the reply loop, not written into the prompt: everything
a model reads — a web page, a README, the last command's output — is untrusted,
and a rule that lives only in a system message is one a poisoned file can argue
with. In **Auto**, nothing stands between that and a command running.
*Plan* finishes by proposing steps, with a button that carries them out — which
switches to *Edit*, never *Auto*, because the plan was written under a mode
where every command still asked.
### The library ### The library
**Sidebar → Library**, and **Settings → Memory**. Nothing is on by default for a **Sidebar → Library**, and **Settings → Memory**. Nothing is on by default for a