POST /api/ptt and /ws were listed as ungated on the grounds that mavend's
voice port is only reachable inside the deploy. mavweb is the thing proxying
into it from outside, so that argument does not hold. Audio posted to
/api/ptt runs the same router, the same LLM and the same applyAction that
POST /api/chat was gated on, which means speaking a light-switch act reached
the act path while typing it did not.
Both now take stepUpOK, so they fail open by default and deny under
-require-stepup exactly like the other four. Registration moved down next to
/api/chat because the gate needs stepUpSession. The route table records the
reason and names the session-scoped assertion the hands-free case wants as a
separate task. The SECURITY startup lines are one surface per line now.
Found in review of #51.
Second half of the MCP client: the tools the manager discovers become rows in
the existing act allowlist instead of a parallel capability system.
An MCP tool is encoded in the columns that already exist — cmd
["mcp",<server>,<tool>], scope mcp:<server> — so no migration, and
ProposeTool/EnableTool/DisableTool, tool.Matcher and the confirm turn need no
changes. One branch in Executor.Exec routes such a row to the manager instead
of exec, and "mcp" is never run as a binary.
Discovery only ever PROPOSES. destructive comes from the inverse of the MCP
readOnlyHint, so a tool that does not promise to be read-only inherits the
confirm turn, and enabling stays on /tools behind step-up.
Voice args are positional and MCP args are named, so CallPositional binds only
what it can defend: no required properties runs bare, and a read-only tool with
exactly one required string or number gets the tail. Everything else refuses
with ErrNeedsArgs rather than guessing. The read-only condition was learned
against the live Vikunja server: update_task requires only task_id and takes
the rest as optional, so one guessed argument blanked the fields it did not
mention. A partially-filled write destroys what it omits, so a mutating tool
never receives a guessed argument.
Also: a read-only mcp_servers IPC method and an "MCP servers" card on /tools
showing transport, target and state, with the trust level of a local target
spelled out. There is deliberately no call-a-tool IPC method and no run button,
so mutation keeps exactly one path.
Vikunja #251
/api/chat reaches the router, the LLM and, through applyAction, the whole
act path, so it is the widest state-changing surface mavweb serves. It was
the only one with no gate. It now goes through stepUpOK like POST /tools,
POST /routines and POST /api/revert: unchanged in the default deploy
(WebAuthn unconfigured, fail-open behind wg+nginx), 403 under
-require-stepup or an unasserted passkey session.
The route table now carries an explicit enumeration of every state-changing
route and its gate, and the two startup SECURITY log lines name /routines
and /api/chat alongside /tools and /api/revert.
The loopback -addr default the task also asked for landed earlier in
d12de58; the compose already publishes mavweb on 127.0.0.1 only.
Two merge fixes on top of the branch:
- migrations: keep both new steps, snooze stays #8, the routine columns
become #9. Both agents had numbered theirs #8.
- accepting no longer takes a reminder id, on the web surface too. The
web accept path had the same one-shot-reminder bug the voice path did,
so both now just flip the status and let the tick loop schedule.
The test that asserted "accept creates a reminder and links it" asserted
the bug. It now asserts that accepting creates no reminder.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
Accepting a routine gives the trigger loop a new standing reason to speak to
the human, so it is the same authority tier as enabling a tool and shares the
stepUpOK gate; dismiss only ever makes maven quieter, so it is ungated.
Look at handleRoutines and acceptRoutine in cmd/mavweb/main.go: accept creates
the recurring reminder, then links it via the new ipc AcceptProposedRoutine.
The page now says what maven noticed in her own words (pattern.PhraseRoutine).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
The /tools enable action takes name+cmd from form fields and calls
core.EnableTool, so it defines arbitrary argv that internal/tool then executes.
Its step-up gate read `session != nil && !session.IsStepUp()`, and
stepUpSession is nil unless both -webauthn-origin and -webauthn-rpid are set —
so with neither flag the gate was skipped entirely. compose passed neither and
published 9201 on every host interface, while /ptt proxies to the voice server
unauthenticated, so a caller could enable a tool, trigger it, and answer its
own confirm turn. internal/tool's boundary reasoning ("a compromised router
can't grant itself a capability") held; the outer boundary it depends on was an
unwritten deployment assumption.
The fail-open itself stays: gating on a session that can never be asserted
would 403 permanently, and that reasoning is sound. What was missing is the
compensating control.
- compose publishes 127.0.0.1:9201 so reaching the UI requires the wg tunnel by
construction rather than by convention. Verified no other service reaches
mavweb by host-published port; mavpoll is host-networked but only dials
netdata and kuma.
- stepUpOK() replaces the two inline gates in handleTools and handleRevert, so
one decision point covers both surfaces.
- -require-stepup (default false, behaviour byte-for-byte unchanged) fails those
actions closed when step-up cannot be asserted.
- A startup warning names both unguarded surfaces when stepUpSession is nil,
in fail-open and fail-closed variants.
Also repoints one doc comment at DESIGN.md, since it shared a hunk with the
warning block.
The committed kuma key is deliberately left for a separate change: the old
value is in git history forever, so rotation means a genuinely new key, not a
re-commit under a variable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X5JApcrCRVGmqrxnhynSik
RevertFact voids the latest fact for a key — a store mutation — but
/api/revert had no step-up gate, while POST /tools required L3. Close the
inconsistency: thread the same *webauthn.PasskeySession into handleRevert
and reject with 403 when a configured session isn't asserted. nil session
(WebAuthn unconfigured) keeps prior behavior — transport-level auth only.
Tests: un-asserted session → 403 and RevertFact not called; asserted → 200.
The RevertFact mock now records its key so the gate assertion is meaningful.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Task-7 verification commit (d52f60c) added the CalendarEvents mock
method but left fakeCore's struct block misaligned, so `gofmt -l` still
flagged this file despite the "all gates green" claim. Realign it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Add CalendarEvents method to recordingAPI in auth_test.go
- Add CalendarEvents method to fakeCore in handlers_test.go
Co-Authored-By: opencode <opencode@anthropic.com>
- digest queue no longer dropped on failed dispatch (retry next tick)
- collapsed reminders marked fired/rescheduled only after digest delivers
- /tools step-up gate skipped when WebAuthn is not configured (was 403 forever)
- passkey credential store rolls back memory on persist failure
- auth_test fake updated for TickTrace (branch build break)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ASstMtsZWLSRcD1Tq8T68Q
RecentNudges IPC method, store adapter, dispatch, and client proxy.
Web UI at /notifications showing recent nudge history with color-coded
outcomes, nav links from /dash and /history.
New template showing the most recent tick's rule evaluation results
with color-coded table, expandable gate detail panels, nav links
to dash and history. Uses the TickTrace IPC method from #15.
Add a local PasskeySession that handleTools checks before processing
any POST action (enable/disable). If the session hasn't been asserted
within the 5-minute TTL, return 403 Forbidden.
Changes:
- webauthn/session.go: add IsStepUp() convenience method (nil-safe)
- webauthn.go: PasskeyHandle holds a *PasskeySession; AssertFinish
calls session.Assert() after IPC step-up
- main.go: create stepUpSession, pass to handleTools and
newPasskeyHandle; handleTools returns 403 if !session.IsStepUp()
- handlers_test.go: update TestEnableTool_NoInProcessAuthGate to
expect 403; add TestEnableTool_WithAuthGate_RequiresStepUp for
the happy path with asserted session; update all 10 call sites
Add a 'scope' TEXT column (default 'homelab') to the tools table so tools
can be namespaced by scope (e.g. "homelab:restart", "datacenter:reboot").
Backward-compat: bare name defaults to "homelab" scope.
Changes:
- Migration #1: ALTER TABLE tools ADD COLUMN scope
- store.Tool: add Scope field, update all SQL and scanTool()
- ipc.Tool DTO and request types: add Scope field
- CoreAPI interface: pass scope in ProposeTool/EnableTool
- storeAPI adapters: forward scope
- cmd/mavend/voice: pass scope (empty → homelab)
- cmd/mavweb/tools: show scope column in UI tables, hidden fields
- All tests updated for scope field
- Migration test made dynamic (startVer = len(migrations))
- New credentialStore type in credentials.go loads/saves
map[id]localCred to a JSON file. Thread-safe with sync.RWMutex,
writes to disk on every mutation.
- PasskeyHandle replaces sync.RWMutex+map with *credentialStore.
Inline save/lookip/update closures delegate to store methods.
- newPasskeyHandle now takes a storePath parameter and returns an
error; callers updated.
- New -passkey-file flag (default ./passkeys.json) configures the
credential store path in main.go.
- Tests use os.CreateTemp in t.TempDir() so each test gets an
isolated, auto-cleaned store file.
- handlers_test.go: first tests for cmd/mavweb (feature-ranking #2). Covers
the /tools enable/disable surface (arg parsing, error mapping, html escaping)
and the webauthn handler contracts (method guards, malformed input). 14 cases.
Verified the enable path is genuinely gated: an un-asserted call fails at the
mavend IPC boundary (Requirement(EnableTool)=AuthStepUp), so mavweb stays a
trust-nothing pass-through and core mediates.
- policy.go: DisableTool now also requires AuthStepUp. It mutates the same tool
allowlist as EnableTool and is a lever to silence a security-relevant tool;
gating allowlist mutation uniformly beats a split rule. ProposeTool stays
maven-callable (no passkey). Corrects the stale api.go comment that claimed
all three gated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>