The testing pass: 2140 tests to 2283, and four bugs that no amount of reading had turned up. Three came from driving the JavaScript under a Node DOM stub, which is the practice CLAUDE.md sets out and this is the reason it does. The terminal dropped every keystroke after a reconnect. `onclose` closed over the module-level socket rather than its own, and close() queues its event -- so the old socket's close arrived after a new one was assigned and nulled the live one. Output kept coming, because onmessage is bound to the object, while every send gates on the variable. It also announced "Disconnected" about a shell that had just reconnected. Two scripts were loaded twice on /messages, once by base.html and again by the page. Each is an IIFE with its own state, so four keyboard shortcuts toggled their panel twice and therefore did nothing, /help opened two dialogs, and an @ mention attached its file twice. A sweep refuses any template re-loading what base.html has. The microphone had no guard while the permission prompt was up, so each click opened another stream and only the last was ever stopped. And a skill shared with you took its name out of your own library: create checked uniqueness against what is *visible* rather than what is owned, against a (owner_id, name) constraint, and told you to edit a row you cannot edit. --ink-faint failed the contrast minimum in both themes -- 3.85 and 3.19 against 4.5 -- so the smallest text on every screen was the hardest to read. Measured in a headless browser rather than judged by eye. And the suite runs on 3.11 and 3.12 now as well as 3.14. It had only ever run on 3.14 while the image ships 3.12 and the packaging claimed 3.11: the interpreter most people would run was the one nothing had tested. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
23 KiB
The 0.9 audit
A working document, written during the pass over everything that landed between
0.8.1 and 0.9.8 -- 16 commits, 163 files, +16,795 / −1,608. It exists so the
findings survive the session that produced them, and so the security and testing
stages have something to work from rather than re-deriving it.
This file is temporary. Everything in it either becomes a CHANGELOG.md
line, a paragraph in CLAUDE.md, or an entry under "After 1.0.0" in PLAN.md.
It is deleted before the release.
Each finding carries a disposition:
- fixed -- landed, with a test
- later -- real, but the fix changes what a feature does, so it is the user's call and it is written up rather than done
- stated -- correct as it stands, and recorded so the next audit does not spend an hour rediscovering that it is deliberate
The harness and the instruction prompts
{{timezone}} shipped as a hole — fixed
clock.name_for returns "" for anybody who has never chosen a zone, which is
the default state of every account. substitute drops a line only when the
whole line is blank after expansion, and this variable sits inside a
sentence, so every such request carried:
- Times the person gives you are in unless they say otherwise.
Both the comment at harness.py:191 and the fragment's own hint asserted the
line disappeared. Neither had ever been true. The sibling call site
(schedule/compile.py:102) has always had the fallback.
Worth noting why the fallback is right rather than merely convenient: stamp
is clock.now_for(user), which already falls back to the server's zone, and
{{now}} already prints its offset. The times were already in that zone.
Withholding the label from a value the model had been given was not restraint.
The admin preview could not show most of what it previews — fixed
admin_prompts.py built its variables with context_variables(db, user, [], None)
— no tools, no chat — and backfilled six names. Every other requires gate
stayed empty, so eleven fragments could never appear in a preview whatever an
administrator ticked: the whole agent surface, both scheduling fragments and
the helper warning. Editing tool.agent and pressing preview showed a system
message with tool.agent missing from it, silently.
Fixed with sample values, which is what SAMPLE_DOCUMENTS already did for
attachments, plus two controls the "Tools offered" row could not reach — "This
chat is" (ordinary / scheduled task / helper) and an agent-mode select. The
samples are gated exactly as context_variables gates the real values,
because a preview that admits a fragment the real request would not is worse
than one that omits it.
Fragments named tools that had been withdrawn — fixed for Plan mode, later for the rest
resolve_tools filters per tool; harness._families re-derives gates per
family. So a family survives on its readers while its writers are gone, and
the family-gated fragment still names them — under core.tool_list saying
"Anything not named there does not exist here — calling it costs a round and
returns nothing."
Fixed: Plan mode. agent/tools.py:1185 withdraws plan_update there, and
tool.plan_update was gated on {{plan}}, which is set whenever a plan exists
in any mode. A new plan_editable variable — the plan text, blank in Plan mode
— now gates it. Writing the test found a second instance the audit had missed:
context.plan also said "Change it with plan_update", so that sentence moved to
the fragment whose job it is.
Later: a read-only helper keeps seven fragments naming fifteen withdrawn
write tools (notes_create, report_write, file_write, skill_create, the
three schedule writers…). The principled fix is the split tool.skills /
tool.skills_write already demonstrates, applied to notes, report,
schedule and agent_edits — four more fragment pairs. That is a prompt
restructure rather than a bug fix and it is proposed for after 1.0.0. The cost
today is bounded: {{tool_names}} is authoritative and the model has it, so a
helper wastes at most one round discovering a writer is absent.
Three tool descriptions disagreed with their runners — all fixed
subagent_runopened "a second model with the same tools" and contradicted itself two sentences later. The child is forced toask:False,subagent:False,write:Falsewith commands pinned toSAFE_COMMANDS. The leading clause is what a model reads when sizing a task.knowledge_getreturnedextracted_textwhole while every sibling reader capped and said so —fetchat 20k,file_read, the memories block, the skill index, the project listing.MAX_EXTRACTED_CHARSdefaults to 120,000, so one call on a long PDF filled an ordinary window with nothing reporting it. Its description said "in full", which is why this read as correct: the tool did exactly what it claimed. NowMAX_DOCUMENT_CHARS = 40_000, cut with the model told, which is whatfetchandfile_readboth do._run_ask_userreadargs["question"], singular, against a schema declaringquestionsand a list — so its event always carried an emptyquery. Harmless only because the path is documented unreachable, which is exactly why nothing caught it.
Fragment text that was false — fixed
tool.subagent_agent said a helper "reads and reports … and nothing else, in
every mode" beside a write parameter that makes one write files, and named
seven of the twenty-three commands in SAFE_COMMANDS — so a model
avoided commands it was allowed, which costs nothing visible and is therefore
never reported. Both halves are now pinned by tests against the real list and
the real schema, because prose and a constant drift the moment one is edited
alone.
The distinction the wording now has to carry, and did not before: commands
are fixed to the read-only list in every mode, since an unattended chat cannot
approve anything; files may be written, but only by a helper asked for with
write and only from Edit or Auto.
tool.background promises a notification that can be off — later
"When a background job finishes you are told in a new turn" has no requires
for agents.background_notify, and the runner itself branches on that flag and
says "Check on it with job_output" instead. One fragment, two behaviours. The
fix is a variable and a gate, the same shape as plan_editable; deferred only
because it wants the split above rather than a third one-off.
FAMILY_ASK has no fragment at all — later
Every other family in FAMILIES contributes one. ask_user is offered in
nearly every chat and all of its guidance lives in its schema description,
which is the one thing an administrator cannot edit. Proposed rather than done:
it is new prompt text, and new prompt text in an audit stage is how an audit
turns into a feature.
Smaller, all stated
model_nameis resolved on every request and rendered by no shipped fragment. It is documented inVARIABLESand available to an administrator's override, so it is an escape hatch rather than dead work. The cost is one smallselecton a table with tens of rows.- Four pairs of fragments share an
order, which undercuts "sparse so a custom tool can be slotted between two of these". Ties break alphabetically and deterministically. One has a readable consequence:context.schedulesorts belowcontext.memories, so a scheduled task's own instruction appears under the reader's remembered facts. core.roundsis dark on a stock install, sinceDEFAULT_CHAT_ROUNDS = 0. Deliberate —core.keep_workingis its complement and exactly one ever fires.- A row-backed fragment with empty
guidancecan never be selected, so for those "never configured" and "deliberately switched off" are the same state — the one distinction the absent-vs-empty convention exists to preserve.
Controls
The four sweeps CLAUDE.md is shaped around were run by hand over every
template and all four came back clean: 68 htmx verbs against 179 registered
routes with zero method mismatches, 88 plain form actions and 30
fetch/htmx.ajax call sites with zero, every verb-carrying element declaring
its own hx-target or hx-swap="none" checked against every ancestor, no empty
verb attributes, both form="…" sites correct, and every hx-target,
hx-include and from: selector resolving to an id that exists.
Making each one a test that sweeps is Stage 2's work. A one-off audit that finds nothing is worth much less than the same audit made permanent.
Capabilities that are built, documented and unreachable — Stage 2
The Model.params_json and library.share family: the code is right, the route
validates, and nothing can reach it.
- Folder nesting.
PATCH /api/folders/{id}handlesparent_idwith a cycle guard andMAX_DEPTH = 8,partials/_folder.htmlrecurses, andREADME.mdadvertises "arbitrarily nested". No template or script submitsparent_id. - Moving a chat into a folder.
api/chats.pyhandlesfolder_idon PATCH; only the composer submits it, at creation.chat/_composer.htmleven carries a comment reasoning about "the only way into a folder was to make the chat elsewhere and move it". Connection.extra_headers_jsonis read into every LLM request and written by no form, so its documented use — OpenRouter'sHTTP-Referer— is unreachable. later: nothing advertises it, so nothing is currently untrue.
Written and never read: Chat.compacted_at, User.last_login_at,
Schedule.last_fire_at, Schedule.compiled_at. Neither read nor written:
general.require_approval (its comment says "reserved"), and the back form
parameter on two admin_models routes.
A third, found by asking where generated images go. db.delete(chat)
cascades to the message and attachment rows and leaves every file on disk;
files.remove_files_for_chats was written for exactly that and was called from
one place, the temporary-chat sweep. The delete button, a schedule's task chat,
a helper's chat and account deletion all went straight to db.delete.
Underneath it, a second fault that made the first invisible: files.claim
bound message_id and never chat_id, so anything uploaded on the new-chat
screen kept an empty chat_id for the rest of its life. Six readers filter on
that column, so for those files the harness did not name them among the attached
documents, the canvas refused to open them, and the one caller the cleanup
helper had found nothing to remove. Both fixed, and chat_service.delete_chats
is now the only way a chat is deleted.
Stated: Message.parent_id and content_parts_json are deliberate forward
-looking columns; Chat.archived is already recorded in PLAN.md as a column
nothing surfaces.
Verified by rendering, not by reading — Stage 2
There is no browser extension on this machine, so /admin/prompts and
/admin/customization were checked by rendering them through TestClient,
rewriting the asset URLs to file://, and driving headless Chromium against the
result. Worth recording because the first run of that harness measured an
unstyled page: the templates emit absolute http://testserver/static/… URLs
via url_for, not /static/…, so the replacement matched nothing and no CSS
loaded. It reported all five tab panels visible at once — a dramatic finding
that was entirely an artefact. The harness now fails loudly on any unrewritten
asset URL.
What it measured once fixed:
/admin/prompts, tab bar's on-screen position across the five tabs: 385 → 642px before (the page moving under the reader), 195px constant after, withscrollTopstaying at 0./admin/customization, document height: 5495px before, 2820px after.
Documentation — Stage 2
README.mdis stale by six phases. "Planned: Image generation · OCR · semantic search" — two of the three shipped. Nothing under Features mentions schedules, reports, subagents, branding, quotas, web push, updates, Docker or LXC.CLAUDE.mdsays "Three topics live indocs/notes/" and lists seven.CLAUDE.mdsays 2088 tests; there are 2092 before this stage.tests/__pycache__/test_zz_{dump,live}*.pycare stale bytecode for two files that no longer exist.
Security — Stage 3
Two privilege escalations in the update helper, both root, both fixed. The
helper is the one place this application deliberately crosses a privilege
boundary, and it crossed it twice more than intended. Neither is reachable
from the web interface: both need code execution as the lembas service
account first. That is precisely the boundary the unprivileged split exists to
hold, so "you need a foothold" is the threat model, not a mitigation.
1. Root ran a script the service account owns.
ExecStart=/bin/bash __PREFIX__/app/deploy/update.sh — inside the checkout,
owned lembas:lembas, because install.sh clones as that user. So: write your
payload into deploy/update.sh, touch data/update-requested (the service
account owns that directory too), and systemd runs it as root. The web
interface's AdminUser check is not the gate systemd honours.
There is a second path needing no compromise at all: an update pulls new code
as the service user, and root then executes whatever deploy/update.sh that
pull contained. Control of the branch was control of root.
Fixed by installing a root-owned copy at /usr/local/lib/lembas/update.sh and
pointing the unit there. The cost — improving update.sh needs the installer
re-run — is the right one: root should not execute a script that arrived over
the network a moment ago. The script warns when its own copy has fallen behind.
The old test asserted the vulnerable line
(assert "ExecStart=/bin/bash __PREFIX__/app/deploy/update.sh" in unit). It
passed for the life of the feature and pinned the bug in place — the recurring
failure of this codebase, applied to a privilege boundary: an assertion about
the text rather than about the property the text was meant to have.
2. Root sourced a file the service account can replace.
. "$PREFIX/.deploy-env". The file is root-owned, having been written with
sudo tee — but $PREFIX is the service account's own directory at mode 755,
and write permission on a directory is all it takes to unlink a file and put
another there. On the live host .deploy-env did not even exist, so it could
simply be created. . runs its contents as root.
This one survived the first fix entirely, and the helper is what made it
reachable: before the .path unit existed, update.sh only ran when an
administrator invoked it from a shell. Fixed by parsing the two values it wants
with strict patterns instead of sourcing. The test asserts that nothing
under $PREFIX is sourced, rather than naming .deploy-env, because the next
file read from there would have the same problem.
Upgrade note: a host that installed the helper before this keeps the old
unit, and only re-running the installer moves it. update.sh now detects that
it is running from inside the checkout and says so loudly — otherwise the
vulnerable hosts are exactly the ones that never hear about it.
Also fixed, sub-threshold as a vulnerability but a real bug: the share
panel built its hx-vals by pasting the search term into a JSON string. Jinja
escapes the quote for HTML and the parser decodes it again before htmx parses
the JSON, so a " in a search term ended the string and silently stopped every
checkbox in the panel from submitting anything. q was the last key, so an
injected one would also have won a duplicate-key parse. Built with | tojson
over the whole object now.
3. A read-only command that was not read-only. SAFE_COMMANDS — the list a
subagent is pinned to, in every mode, unattended, with no approval card
possible — contained find *. GNU find writes files (-fprintf), runs
programs (-exec … +) and deletes them (-delete), and none of those needs a
character policy._UNSAFE refuses. rg --pre is the same shape.
So the chain was: a parent in Plan mode — which promises "reads freely,
changes nothing" — spawns a helper on a RISK_READ tool with no card; the
helper's shell_run survives because writes_off drops RISK_WRITE and
deliberately keeps RISK_EXECUTE; find . -maxdepth 0 -fprintf ~/.ssh/authorized_keys 'ssh-ed25519 …' matches find * and runs. Prompt
injection from a page the model just read is enough to start it.
Fixed with policy._ACTION, refusing those flags in subject() rather than
trimming the allow list — a pattern cannot express "and no dangerous flags",
and "this one looks read-only" is exactly the reasoning that put find * there.
It costs a false refusal on grep -- -delete, which is the right direction to
be wrong in: a refusal asks, an allow does not.
4. 0.0.0.0 walked past the loopback guard. _literal answered from
is_loopback, and 0.0.0.0/:: are is_unspecified — so it returned a
decided False, which short-circuited resolves_here and skipped the DNS
half too. connect() to either goes to loopback on Linux, so an SSH profile
pointed at 0.0.0.0 reached this host's own sshd: the one spelling of "this
machine" that walked past the guard whose whole job is that sentence.
5. Push endpoints skipped the SSRF guard. POST /api/push/subscribe
checked startswith("https://") and nothing else, and send_one POSTed to it
with no check_url — the only outbound client in the codebase not going
through the guard. Delivery is triggered by the caller: send a message, close
the tab, and _persist announces it because nobody is following. Checked now
at subscribe and again before the POST, since the row outlives the first
check.
6. A chat could be put in somebody else's folder. effective_system_prompt
walks up from the chat through its folder and that folder's parents, so this
reads another account's system prompt through a field that looks like a tag.
Both paths had it, and _new_chat's is the instructive one: it resolved the
folder, discarded it when it was not the caller's, and then stored the raw
id anyway — so the ownership check governed which seeds were applied and
not where the chat actually went.
Clean
Checked and found sound: the branding CSS and custom-theme generation (ids and
colour values both validated on read, so a row written by hand still cannot
emit a malformed rule; served as text/css rather than inline, so there is no
HTML context to escape); the unauthenticated branding asset route (random
names, traversal guarded twice, magic-number sniffing, SVG excluded); sharing
authorisation on every route; the request file's contents reaching nothing;
updates._git's fixed argv; and the container (non-root, no secret baked, no
docker socket, loopback only).
Carried forward from earlier stages
Questions raised before the security stage, answered by it:
- the three hand-rolled redirect loops each re-run
check_urlper hop (confirmed); does each also drop the secret when a hop leaves its origin? admin_tools.pycallscheck_url(server.url, allow_private=True)unconditionally at save — a syntax check, with the per-row flag governing at call time. Confirm that reading is right.- the subagent restriction is the one gate that fails open unattended.
_child_scopeforcingask:False,subagent:False,write:Falseand pinningallowtoSAFE_COMMANDSis confirmed present; the question is whether every path reaches it. pyproject.tomlpins no upper bounds anddeploy/update.shrunspip install -eon every update, so a breaking upstream release arrives on a button press. pip'sonly-if-neededdefault limits the blast radius, which is why this is a note rather than an emergency.
Testing — Stage 4
2140 tests became 2283, and writing them found four bugs that reading had
not — three of them by driving the JavaScript under a Node DOM stub, which is
the practice CLAUDE.md sets out and the reason it does.
- The terminal dropped every keystroke after a reconnect.
socket.oncloseclosed over the module-level variable rather than its own connection, andclose()queues its event: both reconnect paths close the old socket and immediately open a new one, so the old one'sclosearrived afterwards and nulled the live socket. Output kept coming, becauseonmessageis bound to the object; every send gates onsocket && readyState === OPEN. It also said "Disconnected" about a shell that had just reconnected, becauseclosedOnPurposehad been cleared for the new connection. - Two scripts were loaded twice on
/messages— once bybase.htmland again by the page. Each is an IIFE with its own state, so four keyboard shortcuts toggled their panel twice and therefore did nothing,/helpopened two dialogs, and an@mention attached its file twice. A sweep now refuses any template re-loading whatbase.htmlalready has. - The microphone had no guard while the permission prompt was up. State only
left
idleaftergetUserMediaresolved, so every click in that window opened another stream; only the last was stopped. - A shared skill took its name out of your own library.
createchecked uniqueness withby_name, which is scoped to what is visible — owned or shared — against a(owner_id, name)constraint.documents.create_basenext door asks it correctly. Found by writing HTTP tests forapi/library.py.
Also: --ink-faint failed the 4.5:1 contrast minimum in both themes (3.85
in Moria, 3.19 in Shire), measured by computing the ratios in a headless browser
rather than by eye. Raised in both.
Verified rather than assumed: the suite now runs on Python 3.11, 3.12 and
3.14. It had only ever run on 3.14, while Dockerfile ships 3.12 and
requires-python claimed 3.11 — the interpreter most people would actually run
was the one nothing had tested. Both pass.
One measurement worth keeping honest: the slow marker splits 365 tests that
stand up a real server, shell, PTY or git repository, but the fast subset is
111s against 140s for everything. The split is for CI, not for a dramatic
saving.
Still open after the testing stage
api/library.py: 28 routes, effectively zero HTTP coverage. Highest exposure — auth, ownership and permission gates all live at that boundary.- No real
sync_schemaupgrade test exists.conftest.pyrunscreate_allthensync_schema, so the differ is only ever exercised as a no-op. Hard rule 4 has no other safety net. cli.py: zero tests, andcreate-adminis the documented bootstrap.- SSE has three end-to-end tests;
services/sse.pyhas none. composer.js,terminal.jsandaudio.js— 1,188 lines — have no assertion of any kind.- The suite has only ever run on Python 3.14. The Dockerfile ships 3.12 and
requires-pythonclaims 3.11.