Documentation and repo-hygiene pass following the client/server split:
Plan drift (audited all other active plans against current code):
- oikos-gaps-and-improvements.md: mark Section C and D.5 resolved (both
described cmd/hermes, renamed to cmd/nomos with a real LLM loop since);
refresh ~10 stale file:line citations; fix tool-count (33, not 28).
- liveness-drift-and-ux-cohesion.md: fix stale default-model claim (now
deepseek-v4-pro since 2026-07-10) and "not yet deployed" status.
- nomos-agent-code-review.md: fix C1's citation (one unauthenticated route
to nomos now, not two, after the client/server split).
- wails-desktop-app.md: record the production deploy outcome.
Repo structure: added missing directories to README/CONTRIBUTING layout
tables (checks/, tools/, cmd/webhook/, docs/operations/), fixed a broken
link, added ADR 0015 documenting the auth/CORS/client-split model (there
wasn't one despite CONTRIBUTING's own process requiring it), normalized
ADR 0013/0014's format drift, added an Authentication section to
AGENTS.md/CLIENTS.md (every example call was missing the now-required
bearer header).
Retired the Goose+Nomos workstation flow (bootstrap.sh --with-nomos,
tools/setup-nomos-soul.sh, .agents/operations/nomos-agent.md) and the
Caveman auto-install tooling (tools/setup-caveman.sh, tools/caveman/) —
both superseded by the production containerized Nomos agent, which has
never used either. Kept .agents/shared/caveman.md itself (the terse
writing-style convention agents still follow by reading it).
Deleted the orphaned legacy Python oikos/ directory — nothing imports it,
and bin/homelab (the CLI it was kept for) no longer exists in the repo.
Rewrote .agents/operations/agent-enrollment.md (365 -> ~110 lines) and
commands.md to match the current architecture instead of the retired
`homelab` CLI; migrated the still-true networking prerequisites (Netbird,
split-horizon DNS, SSH key distribution) into the knowledge base as a
runbook via upsert_knowledge rather than duplicating them in markdown.
Updated all 10 .agents/skills/ runbooks referencing the dead CLI with
their real MCP tool / REST API equivalents, or flagged them as needing
verification where no equivalent is confirmed yet.
Two real bugs found and fixed, not just docs:
- The tools/setup-*.sh auto-setup glob was tools/*.setup.sh in THREE
places (tools/post-pull.sh, bootstrap.sh, and internal/httpapi/impl.go's
GetClientContext handler) since the mechanism's introduction on
2026-06-02 — never matched any real filename, so no client has ever
picked up an auto-setup script via git-pull or the context-poller sync.
Fixed all three; the Go server-side fix is the one that actually matters
since it's what the current context-poller mechanism depends on.
- bootstrap.sh removed dead vestigial --gitea-token/--gitea-user flags
(parsed, never consumed) left over from an earlier clone-based model.
Also flagged, not fixed (documented as an open gap in
client-enrollment/SKILL.md): bootstrap.sh tells a freshly-enrolled client
to call POST /api/v1/clients/{slug}/activate to finish enrollment, but
that route doesn't exist in api/openapi.yaml — EnrollClient sets entities
to provisioning and nothing currently transitions them to active.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
18 KiB
2026-07-11 — Nomos agent code review: gaps and improvement plan
Status: In Progress — 2026-07-11. Every finding except C1 (A1-A3, B1-B3,
D1-D3, E, F1) is fixed, tested, and verified live against the running stack.
C1 (unauthenticated nomos gateway) is explicitly deferred per operator
instruction ("leave auth out for these round of fixes") — the one item
keeping this out of done/.
- A1
3919ec3, B1+B2c5ffaec, A3926969a, D1-D376f7630, A2c390164, B36d4f6de, F111c18e8. - New
internal/safegopackage (B1) andcmd/nomos/store_test.go(A2, plus a regression test for the earlier plan-append fix) are the first automated tests for any of this package's core logic — closing part of finding E, though full coverage of agent.go/main.go remains future work. - C1 remains open — nomos's gateway (port 8092) still has no authentication. Revisit separately.
Scope
A full read-through of cmd/nomos/ (agent.go, store.go, main.go, continue.go,
assent.go, tasks.go — 3,120 lines) plus targeted checks of its HTTP exposure,
goroutine safety, and test coverage. Every finding below is grounded in a
specific file:line or a runnable reproduction — two of the sharper ones
(A1, A2) were empirically confirmed with throwaway test probes before being
written up, not just read and assumed.
This is a review, not an implementation — findings are ranked by severity with a proposed fix per item; nothing here has been changed yet.
A. Correctness bugs (confirmed, not theoretical)
A1. Chat-assent word matching has real substring false positives
assent.go:73-103. isAssent/isTypedConfirmation
pad the message with spaces and word-boundary-check the negation list
(strings.Contains(m, " "+w+" ")), but the assent/confirm checks use
bare strings.Contains(m, w) — no word boundary at all. Confirmed live via a
test probe:
isAssent("not sure, maybe yesterday's logs show something useful")→true("yes"matches inside"yesterday";"not"alone isn't innegationWords, only the phrase"not yet"is).isTypedConfirmation("I haven't confirmed anything yet, let me think")→true("confirm"matches inside"confirmed";"haven't"isn't innegationWords, which only has"don't"/"do not", not other contracted negatives).
The second one is the serious half: isTypedConfirmation is the sole gate
for DESTRUCTIVE actions (agent.go:220-223) — a
message that merely mentions not having confirmed something yet can read as
an explicit confirmation.
Fix: apply the same space-padded word-boundary check to the assent/confirm
word lists that negation already uses. Expand negationWords to cover
contracted negatives (haven't, hasn't, isn't, wasn't, can't,
won't, not as a standalone word, not just "not yet"). Add both
reproduced cases as permanent regression tests in assent_test.go.
A2. Unbounded conversation history replay — no windowing, no token budget
agent.go:185-207: every single turn (chatWith)
calls a.store.getMessages(ctx, sessionID) — store.go:218-239,
SELECT ... WHERE session_id=$1 ORDER BY created_at ASC with no LIMIT,
no windowing, no summarization — and replays the entire history into the
LLM call every time. truncateToolResults (store.go:152-185)
caps each individual tool result at 4KB, but caps nothing else: not tool
args, not the number of tool calls in one message, not the total message
count, not total tokens.
This isn't theoretical — an earlier production audit (see chat-sessions-improvements) found a single turn with 70 tool calls and messages up to 106KB. Every subsequent turn of a long-running or heavily-autonomous task (exactly what auto-continuation is built for) re-sends that ever-growing history in full. This is a real cost, latency, and eventual context-length-limit risk that compounds specifically for the tasks the system is designed to run longest.
Fix: at minimum, cap replayed history to the most recent N messages or a
token budget, with older turns either dropped or collapsed into a short
system-message summary (finalSummary's existing one-shot summarization
pattern, agent.go:481-492, could be reused for this).
Needs a decision on where the cutoff lives (see open questions).
A3. A live turn's tool-call history is lost entirely if the client disconnects mid-stream
main.go handleChat: toolCalls/finalText accumulate
only in local closure variables; st.saveMessage(...) runs exactly once,
after a.chat(...) returns, using ctx := r.Context() — the same context
that cancels the instant the client disconnects (Stop button, tab close,
network blip). If a.chat returns early because that context was cancelled,
the final saveMessage call runs with an already-cancelled context and its
error return is never checked — the whole turn's tool-call history (already
real: executions launched, knowledge possibly written) is silently lost from
the persisted transcript.
Contrast with resumeSession/continueSession (continue.go:96-166),
which insert a placeholder row immediately and update it after every single
tool call — exactly the incremental-persistence pattern handleChat lacks.
Verified live this session: my own Stop-button test showed the turn's actual
tool calls (6 of them) were visible in the UI only because the SSE stream
had already pushed them to the browser's in-memory store before the abort —
none of that would have survived a page reload, since nothing was persisted.
Fix: bring handleChat in line with resumeSession's pattern — insert a
placeholder row before the turn starts, update it after each tool call using
a context not tied to the client connection for the write itself (or at
minimum, persist with context.Background() in a deferred cleanup so a
cancelled request context doesn't take the DB write down with it).
B. Robustness
B1. Zero panic recovery on any background goroutine
Every explicitly-spawned goroutine across the agent surface has no
recover():
cmd/nomos/main.go:78 go nAgent.runContinuationWorker(ctx)
cmd/nomos/main.go:80 go func() { ...sweep ticker... }()
cmd/nomos/main.go:117 go func() { ...http server... }()
cmd/nomos/main.go:347 go a.resumeSession(context.Background(), sessionID, note)
internal/mcp/server.go:477,495 go executeApprovedViaAPI(...)
internal/mcp/server.go:1134 go func() { ... }()
internal/httpapi/phase3.go:119,1456
internal/httpapi/server.go:81,533
grep -rn "recover()" cmd/nomos/ internal/mcp/ internal/httpapi/ returns
nothing. Go's default behavior for a panic in any goroutine — not just the
one handling an HTTP request, which the stdlib does recover — is to crash the
entire process. runContinuationWorker and resumeSession in particular
run complex, unattended agent logic (JSON unmarshaling of model output, tool
result parsing, map/slice indexing) with no operator watching; a single edge
case (a malformed tool result, an unexpected nil) takes down nomos for
every concurrently-running task, not just the one that hit it. This is
more consequential post-concurrency (today's work): more simultaneous
unattended goroutines running agent code means more surface area for one bad
input to end everyone's session.
Fix: wrap every explicitly-spawned goroutine body in a defer func() { if r := recover(); r != nil { slog.Error(...) } }(). A small helper
(safeGo(func())) would make this consistent and hard to forget at new call
sites.
B2. Auto-continuation processes its batch sequentially, one full turn at a time
continue.go:58-75: processContinuations fetches
up to 5 pending items and runs a.continueSession(ctx, p) for each in a
plain for loop, in the single runContinuationWorker goroutine. Each
continueSession is a full LLM turn that can run for minutes (10-minute
timeout, continue.go:134). If 3 different tasks'
executions finish in the same 4-second tick, task #3's continuation waits for
#1 and #2 to completely finish first — undercutting today's whole
concurrency effort specifically on the auto-continuation path, which is the
mechanism autonomous multi-step tasks depend on most.
Fix: spawn each pending continuation as its own goroutine (with B1's panic recovery), bounded by a small semaphore if unbounded parallelism here is a concern.
B3. No terminal state for a permanently-failed auto-continuation
continue.go:162-165: if the resumed LLM call
errors on both the initial attempt and its one retry, the code logs an error
and returns — the task is left in whatever status it was in (typically
executing), with no outcome set and no operator-visible signal beyond an
inert message buried in the transcript. There's no give-up-after-N-retries or
dead-letter marking; the task just looks silently stuck.
Fix: on final failure, call the same path complete_task would use to set
outcome='failure' with a summary explaining the resume failed, so the task
board reflects reality instead of showing a task that looks perpetually
"executing."
C. Security
C1. Nomos's own HTTP gateway has zero authentication
docker-compose.yml:144 publishes port 8092 directly
("8092:8092", comment: "mesh-published") and
Caddyfile.oikos:52-54 reverse-proxies to
it — as of the client/server split
(2026-07-12-wails-desktop-app.md), only
from nomos.hubris.network now, not two routes: /agent/* on
oikos.hubris.network was repointed to go through api's own authenticated
proxy mount instead of straight to nomos:8092, but that's combinedAuth
authenticating the hop into api, not anything nomos itself checks — this
finding is unaffected by that change, still fully open. grep -n "Authorization\|Bearer\|auth" cmd/nomos/main.go still returns nothing
for nomos's inbound routes (nomos did gain outbound auth as part of the
client/server split — it now sends Authorization: Bearer $OIKOS_MCP_BEARER_TOKEN on its own calls to api — but that's the opposite
direction from this finding) — /chat, /sessions, /sessions/{id}
(including DELETE), and /query have no credential check of any kind.
Anyone who can reach the LAN or mesh network can converse with Nomos
directly: start tasks, read/delete any session, answer pending questions,
and — via chat-assent — approve gated executions by typing "yes" or "I
confirm" to whatever the agent proposes, with no authentication at all. This
is the same class of gap
oikos-gaps-and-improvements
flagged for the api/MCP surface (items B1-B5), but specifically for nomos's
own port, which doesn't sit behind combinedAuth the way api's routes do.
Fix: put nomos's gateway behind the same auth the api process uses
(shared bearer token check at minimum), or stop publishing 8092 directly and
route all traffic through the already-authenticated api proxy exclusively.
D. Code quality
D1. Dead code: isTaskTool is defined, never called
tasks.go:139-146. The actual dispatch in
agent.go:370 calls a.handleTaskTool(...) directly
and checks its handled return value — isTaskTool is unused.
Fix: delete it, or use it in buildTools/dispatch if a cheaper
pre-check is actually wanted.
D2. N+1 query in recordTouched
store.go:720-742: loops over every slug found in a
tool call's args and issues a separate SELECT id, type FROM entities WHERE slug = $1 per slug. Fine for the common case (1-3 slugs) but doesn't batch
for tool calls naming many entities.
Fix: one SELECT id, slug, type FROM entities WHERE slug = ANY($1) for
all collected slugs, then loop over the results in memory.
D3. complete_task's outcome isn't validated
tasks.go:248-257 declares an enum in the tool
schema (success|failure|partial) but store.go:428-457
never checks it — an out-of-enum value (a model typo, or a weaker model not
respecting the schema) silently persists as-is; only "failure" is
special-cased (else status="done"), so a stray value still "completes" the
task but with a value the frontend's status/outcome rendering doesn't
recognize.
Fix: validate against the three allowed values in handleTaskTool before
calling store.completeTask, defaulting unrecognized values to "partial"
(safer than silently treating them as "success").
E. Test coverage
Zero automated tests exist for agent.go, store.go, main.go, or
tasks.go. Only assent.go's and continue.go's pure string-parsing
helpers have unit tests (assent_test.go, continue_test.go) — confirmed by
grep -l "func Test" cmd/nomos/*.go matching only those two files. This means
today's session added substantial new, safety-critical logic — session-scoped
assent/destructive windows, the mcpClientPool's creation-race handling and
eviction sweep, proposePlan's replace-vs-append branching — verified only by
live manual testing (curl + browser), with no regression protection
against a future change silently reintroducing the cross-task assent bleed or
breaking the pool's session isolation.
Fix (highest-value additions first):
store_test.go:proposePlan's append-vs-replace branch (the exact bug fixed earlier today) — needs a real DB (integration-style, matchinginternal/db/integration_test.go's pattern) or a query-mocking layer.main_test.go:mcpClientPool.get()'s concurrent-creation race path (two goroutines racing to create a client for the same new session id) andsweep()'s eviction logic — these are pure in-memory logic, no DB needed, straightforward to unit test.assent_test.go: the two confirmed false-positive cases from A1.
F. Efficiency (minor)
F1. Tool list + fleet snapshot re-fetched every single turn
agent.go:174,181: buildTools (tools/list MCP
round-trip) and fleetSnapshot (get_health_summary call) both run at the
start of every chatWith call — including auto-continuation resumes,
which can fire many times per task. The tool list changes only on an api
process restart; the fleet snapshot is a live "as of now" read, which is
arguably the point of it, but re-fetching the tool list every turn is
avoidable.
Fix: cache buildTools' result (e.g., in mcpClientPool, invalidated on
a client's re-initialize) — worth doing only if profiling shows it matters;
low priority relative to A-C.
Implementation order
- A1 (assent false positives) — smallest, highest-severity-per-line-of- code fix; ships with regression tests same-PR.
- C1 (unauthenticated gateway) — security-critical, independent of everything else here.
- B1 (panic recovery) — cheap, broad safety net; do before B2 touches the continuation worker's goroutine structure anyway.
- B2 (parallel auto-continuation) — natural follow-on to B1 since it's restructuring the same goroutine.
- A3 (incremental persistence for live turns) — moderate effort, real user-visible correctness gain.
- D1-D3 (small cleanups) — bundle together, low risk.
- A2 (history windowing) — needs a design decision (see below) before implementation; largest single change.
- B3, F1 — lower urgency, do opportunistically.
- E (tests) — ideally lands alongside each fix above (A1's tests with A1, etc.) rather than as one giant deferred test-writing pass.
Verification
- A1: the two probe cases (
isAssenton the "yesterday" message,isTypedConfirmationon the "haven't confirmed" message) become permanent tests inassent_test.go, assertingfalsepost-fix. - A2: after adding windowing, replay a session with 70+ tool calls (the documented production case) and confirm the message payload sent to the LLM stays under a fixed token/byte ceiling regardless of session length.
- A3: reproduce the Stop-button-mid-turn scenario, reload the page, and confirm the tool calls made before the abort are still present in the persisted transcript (currently: they vanish).
- B1: inject a deliberate panic in a test build of
resumeSession(or a fault-injection flag), confirm the process survives and logs the recovered panic instead of exiting. - C1: confirm an unauthenticated
curlto nomos's/chatfrom off-mesh is rejected once auth lands (currently: succeeds). - D1-D3:
go vet/build clean,complete_taskwith a bogus outcome value now rejected or defaulted rather than silently persisted.
Open questions
- A2's cutoff mechanism: a fixed N-message window, a token-budget-aware trim, or LLM-summarization of dropped history? Summarization preserves the most context but costs an extra LLM call per trim; a fixed window is simplest but could drop something the agent still needs mid-task. Leaning fixed window + summarize-on-trim as a middle ground, but this needs a decision before implementation, not during.
- C1's auth mechanism: reuse
api's existing static bearer token (simplest, matches an existing pattern) or route everything throughapi's proxy and stop publishing 8092 at all (removes the surface entirely, but changes the deploy topology)? Leaning the latter if nothing else on the LAN legitimately needs to reach nomos directly — worth confirming with the operator before picking. - B2's concurrency bound: unbounded goroutines-per-tick vs. a small
semaphore? Given the continuation batch is already capped at 5 per tick
(
pendingContinuations(ctx, 5)), unbounded is probably fine, but worth a sanity check against real task-completion clustering patterns.