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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user