Skip to content

Phase 4: desktop-local agent endpoint + fact scope (ADR-0028) - #11

Merged
wellivea1 merged 2 commits into
mainfrom
codex/phase4-local-assistant
Jul 28, 2026
Merged

Phase 4: desktop-local agent endpoint + fact scope (ADR-0028)#11
wellivea1 merged 2 commits into
mainfrom
codex/phase4-local-assistant

Conversation

@wellivea1

Copy link
Copy Markdown
Owner

Completes the interrupted Phase 4 Codex session and closes the deferral ADR-0021 recorded.

What this delivers

P4-a — desktop-local agent endpoint. The desktop app serves its own loopback MCP (127.0.0.1:0, JSON-RPC at /mcp), so a voice client can drive ZeitBoard with the backend off. Four independent gates gruard it, because a loopback port is not by itself a boundary:

  • loopback bind and a per-request loopback check
  • a ≥32-char bearer token, constant-time compared
  • any request carrying an Origin header is rejected outright (DNS-rebinding defense)
  • a 0600 descriptor with validated shape, ownership-checked removal, and an exclusive startup claim

Tool surface is allowlisted and read-heavy: eight reads, exactly one direct action (set_appearance, permitted because ADR-0021 classified display settings as local/reversible/non-health), and propose-only mutations that fail honestly without an enrolled backend. There is still no approve or apply tool (ADR-0012 preserved).

P4-b — fact scope. ask_zeitboard_facts answers medication timing facts and context markers from local records. The refusal text and prompt classifier moved into shared core/agentpolicy, so chat, the backend MCP, and the local endpoint emit a byte-identical refusal and cannot drift apart. Ambiguous medication questions fail closed; decision-shaped model output is caught on the way out. Local does not mean unredacted — tools return the same projection DTOs the UI shows, never raw records.

P4-c — voice path. The runbook now documents both connectors (local endpoint vs. backend), plus scripts/smoke-local-mcp.ps1.

Also in this PR

Fixes 8 React lint errors left by the interruption:

  • useAppearance held its stored snapshot in a lazily-initialized ref read during render → now lazily-initialized state (render-safe, stable in a dependency list)
  • SettingsScreen called an async refresh directly inside an effect → now an explicit promise chain matching the backend-sync effect in the same file, which also fixes a latent setState-after-unmount

Residual (recorded in the ADR)

The endpoint runs whenever the app runs — there is no opt-in toggle, so its security rests entirely on the four gates above, and any local process that can read the config directory can read the token. An opt-in switch and per-client approval are the obvious hardening step if the threat model changes.

Verification

gofmt clean; core 10 / desktop 4 / server 7 package groups green (incl. new localagent + agentpolicy suites); 29 contract fixtures verified and validated; 222 frontend tests + typecheck + lint (incl. UI standards) green; all 8 PowerShell scripts parse; smoke script fails closed when the bridge is absent; boundary guard clean.

🤖 Generated with Claude Code

wellivea1 and others added 2 commits July 26, 2026 08:40
Completes the interrupted Phase 4 session. The desktop app now serves its
own loopback MCP endpoint, closing the deferral ADR-0021 recorded ("agent
access to local device state waits on a desktop-local agent endpoint") and
delivering phase-goals P4-a/P4-b/P4-c.

P4-a endpoint (apps/desktop/internal/localagent, cmd/zeitboard-local-mcp):
binds 127.0.0.1:0 and speaks MCP JSON-RPC at /mcp, guarded by four
independent gates - loopback bind plus a per-request loopback check, a
>=32-char bearer token compared in constant time, outright rejection of any
request carrying an Origin header (DNS-rebinding defense), and a 0600
descriptor whose shape is validated on read, whose removal is
ownership-checked, and whose creation takes an exclusive startup claim so a
second instance cannot hijack it. Tools are allowlisted and read-heavy:
eight reads, exactly one direct action (set_appearance, permitted because
ADR-0021 classified display settings as local/reversible/non-health), and
propose-only mutations that fail honestly without an enrolled backend.
There is still no approve or apply tool anywhere (ADR-0012 preserved).

P4-b facts: ask_zeitboard_facts answers medication timing facts and context
markers from local records. The refusal text and prompt classifier moved to
shared core/agentpolicy so chat, the backend MCP, and the local endpoint
emit a byte-identical refusal and cannot drift; ambiguous medication
questions fail closed, and decision-shaped model output is caught on the
way out. Local does not mean unredacted - tools return the same projection
DTOs the UI shows, never raw records.

P4-c: runbook now documents both voice paths (desktop-local endpoint that
works with the backend off, and the existing backend connector), plus
scripts/smoke-local-mcp.ps1.

Also fixes 8 React lint errors left by the interruption: useAppearance held
its stored snapshot in a lazily-initialized ref read during render (now
lazily-initialized state, which is render-safe and stable in a dependency
list), and SettingsScreen called an async status refresh directly inside an
effect (now an explicit promise chain matching the backend-sync effect in
the same file, which also fixes a latent setState-after-unmount).

Installer publishes and rolls back zeitboard-local-mcp.exe under the same
SHA-256 verification and publish-transaction marker as every other artifact.

Residual recorded honestly in the ADR: the endpoint runs whenever the app
runs, with no opt-in toggle, so its security rests entirely on those four
gates; any local process that can read the config directory can read the
token.

Verified: gofmt clean; core 10, desktop 4, server 7 package groups green
(incl. new localagent + agentpolicy suites); 29 contract fixtures verified
and validated; 222 frontend tests, typecheck, and lint (incl. UI standards)
green; all 8 PowerShell scripts parse; smoke script fails closed when the
bridge is absent; boundary guard clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ansaction

Works through the OpenCode review of PR #11. I verified each finding against
the code before acting; six were real and are fixed here, one is real but
pre-existing and is now documented rather than rushed, and the docs gaps are
closed.

1. Medical output screening was unreachable. Medical-subject prompts return
   before the provider call, so the post-provider guard - which required the
   PROMPT to look medical - could never fire. An unsolicited dosing
   recommendation from a harmless-looking prompt had nothing to catch it.
   Screening is now keyed on what the ANSWER says. A blunt fix would have
   broken ordinary replies ("take the 3 PM slot", "you should be awake at
   2"), so the check is two-tier: hard medication directives (dosage, mg,
   "start taking", "increase your ...") are refused unconditionally, while
   softer decision language only counts when the answer is itself about
   medication or treatment. Tests cover both directions.

2. Medication detection was not fail-closed. The classifier only knew a fixed
   vocabulary, so "how much Hetlioz should I take?" passed. Added the
   substances that actually come up in circadian care, and - because no list
   can be exhaustive - a phrasing rule that catches decision-shaped requests
   about taking something regardless of the substance. My own test caught the
   first version misfiring on "when should I take my lunch break?", so the
   rule stands down for activity objects; named substances still refuse via
   the vocabulary path even alongside activity words.

3. The descriptor's advertised protection was not real on Windows.
   os.Chmod(0600) only toggles the read-only attribute there and leaves the
   inherited ACL in place, while the file holds the bearer token. Rather than
   soften the claim, the descriptor now gets an explicit owner-only,
   inheritance-disabled DACL via x/sys/windows (already an indirect dep),
   applied to the staged file so the token is never briefly readable at the
   final path. A Windows test asserts the DACL is protected and has exactly
   one entry, for the current user.

4. Publish transactions were dead code. Start/Complete-ZbPublishTransaction
   existed but nothing called them, so an interrupted update could leave a new
   desktop binary beside a stale MCP bridge and still look complete. Both are
   now wired into install.ps1 and update.ps1, version.txt records the declared
   components, and three tests cover the half-published case, the completion
   case, and that the scripts actually invoke them.

6. Contract regression. Removing `answer` from the strict v1
   direct-proposal-request schema would reject existing clients that still
   send it; contracts/README.md requires a new version directory for that.
   Restored as accepted-and-ignored with a description explaining why, and the
   fixture field with it.

8. Empty Origin header was accepted, because Header.Get cannot distinguish an
   empty value from an absent header. The check now tests for presence, with a
   test. The runbook wrongly implied -WithMcp publishes the local bridge (it
   is published unconditionally; -WithMcp is the backend connector) and still
   described the descriptor as 0600 - both corrected.

7. Docs: privacy.md now describes both connectors and states plainly what the
   local endpoint may return (medication timing facts and context markers,
   projections not raw records, labels never sent to a provider by that path);
   threat-model.md gains the loopback trust boundary, the accepted residuals,
   and endpoint security-verification items; verification.md records this
   run's measured results.

5. NOT fixed here, deliberately: propose_reminder_shift does not shift a
   reminder - the server has no reminder entity, so its resolver ignores the
   reminder id and schedules the task. This is pre-existing (ADR-0010
   registry; the backend MCP and chat expose it too), not introduced by the
   local endpoint, and nothing auto-applies, so the defect today is a
   mislabelled pending proposal rather than a wrong mutation. Real semantics
   need a server-side reminder model - medication reminders are local-only per
   ADR-0025 - which is its own slice. Recorded as a known limitation in
   ADR-0028 instead of being papered over.

Verified: gofmt clean; core 10, desktop 4, server 7 package groups green; 29
contract fixtures verified and validated; 222 frontend tests, typecheck, lint
incl. UI standards; 33 installer tests; install/update dry-runs exit 0 and
show the transaction steps; boundary guard clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@wellivea1
wellivea1 merged commit 78ab5f4 into main Jul 28, 2026
9 checks passed
@wellivea1
wellivea1 deleted the codex/phase4-local-assistant branch July 28, 2026 02:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant