items 5-7: passkey step-up, tools enable/disable, note RAG — end to end

Completes the three in-flight open items and fixes the away-fallthrough bug.

Item 7 — passkey step-up (WebAuthn):
- internal/webauthn: ES256/P-256 register + assert with real ecdsa signature
  verification, minimal CBOR/COSE decode, PasskeySession (L2→L3 on assert,
  decays after TTL). Drop the RS256 offer we can't verify (register-ok/
  assert-fail trap). Verify rpIdHash + UP/UV flags in FinishAssertion — UV is
  the step-up gesture. Round-trip test with negative cases (tampered sig,
  missing UV, wrong origin).
- cmd/mavweb: /auth/passkey enroll+assert page (the only surface that can do
  a WebAuthn gesture) + the four begin/finish endpoints. Without this the
  daemon's PasskeySession swap leaves /tools enable permanently blocked.
- daemon wires PasskeySession as the auth Session + srv.StepUp; policy gates
  MethodAssertStepUp at AuthRead.

Item 5 — tools page: DisableTool through store/ipc/client/wire; /tools grows a
disable action and a link to the passkey page. Lifecycle test.

Item 6 — note RAG: PhraseQuery on the phraser (LLM-composed answer over top-k
notes, raw-notes fallback); IntentQuery routes through it. Stub returns a
deterministic summary.

Item 2 — away-fallthrough: on ErrVoiceNoSession the dispatcher now reroutes
through the AWAY table (sev3→ntfy, sev4→telegram-repeat-til-ack, sev≤2→drop)
instead of silently dropping / mis-routing to the present-list remainder.
Covers DispatchNudge + DispatchReminder. 4 tests.

Also: re-add ProposeTool to CoreAPI (dropped in a comment rewrite), fix
missing imports + a duplicate block left mid-edit, drop dead AssertStepUpFunc,
gitignore /mavcaldav.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
kami
2026-07-03 18:41:13 +04:00
parent 36233058dd
commit 6239eca243
21 changed files with 1492 additions and 50 deletions
+58 -12
View File
@@ -25,6 +25,7 @@ import (
"github.com/kami/maven/internal/audio"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/voice"
"github.com/kami/maven/internal/webauthn"
)
// presenceSignals — the only fact keys /api/signal may write. mavweb is a
@@ -73,6 +74,8 @@ func main() {
// facts through CoreAPI (page heartbeat from the PWA, desk_active from a PC
// script). Empty ⇒ /api/signal returns 503 and presence stays unfed.
coreSock := flag.String("core", "", "mavend IPC socket path for presence-signal ingest (empty = disabled)")
pkOrigin := flag.String("webauthn-origin", "", "WebAuthn origin URL (e.g. https://maven.kvmx.ru)")
pkRPID := flag.String("webauthn-rpid", "", "WebAuthn RP ID (e.g. maven.kvmx.ru)")
flag.Parse()
var core ipc.CoreAPI
@@ -116,6 +119,25 @@ func main() {
mux.HandleFunc("/dash", func(w http.ResponseWriter, r *http.Request) {
handleDash(w, r, core)
})
// ----- passkey (WebAuthn) endpoints -----
// Wired when both -core and a configured origin are present. The origin
// must match the browser's view of mavweb (e.g. https://maven.kvmx.ru).
// Passkey registration + assertion are the step-up mechanism for
// AuthStepUp actions (tool enable). Without -webauthn-origin, these
// endpoints return 503 and step-up is unavailable (FloorSession).
if *pkOrigin != "" && *pkRPID != "" && core != nil {
pk := newPasskeyHandle(webauthn.Config{
Origin: *pkOrigin,
RPID: *pkRPID,
RPName: "maven",
}, core)
mux.HandleFunc("/auth/passkey", pk.Page)
mux.HandleFunc("/auth/webauthn/register/begin", pk.RegisterBegin)
mux.HandleFunc("/auth/webauthn/register/finish", pk.RegisterFinish)
mux.HandleFunc("/auth/webauthn/assert/begin", pk.AssertBegin)
mux.HandleFunc("/auth/webauthn/assert/finish", pk.AssertFinish)
}
// /tools — the authed enable surface. maven proposes acts she can't run;
// this page is where a human reviews and enables them (proposed→enabled).
// Enabling is the boundary-moving act (maven.md), so it lives ONLY here,
@@ -298,6 +320,7 @@ table{border-collapse:collapse;width:100%}td,th{border:1px solid #ccc;padding:.4
input[type=text]{width:22rem}code{background:#f4f4f4;padding:.1rem .3rem}
.d{color:#b00}.msg{background:#efe;border:1px solid #6c6;padding:.5rem;margin:1rem 0}</style>
<h1>maven · tools</h1>
<p><small>enabling requires step-up — <a href=/auth/passkey>assert a passkey</a> first.</small></p>
{{if .Msg}}<div class=msg>{{.Msg}}</div>{{end}}
<h2>proposed <small>({{len .Proposed}})</small></h2>
{{if .Proposed}}<p>maven drafted these from acts she couldn't run. Fill the command (argv, space-separated) and enable.</p>
@@ -306,15 +329,20 @@ input[type=text]{width:22rem}code{background:#f4f4f4;padding:.1rem .3rem}
<td><code>{{.Name}}</code></td><td>{{.Utterance}}</td>
<td><form method=post action=/tools>
<input type=hidden name=name value="{{.Name}}">
<input type=hidden name=action value=enable>
<input type=text name=cmd placeholder="systemctl restart" required>
<label><input type=checkbox name=destructive> destructive</label>
<button>enable</button></form></td>
</tr>{{end}}</table>
{{else}}<p>none pending.</p>{{end}}
<h2>enabled <small>({{len .Enabled}})</small></h2>
{{if .Enabled}}<table><tr><th>name</th><th>command</th><th></th></tr>
{{if .Enabled}}<table><tr><th>name</th><th>command</th><th></th><th></th></tr>
{{range .Enabled}}<tr><td><code>{{.Name}}</code></td><td><code>{{join .Cmd " "}}</code></td>
<td>{{if .Destructive}}<span class=d>destructive</span>{{end}}</td></tr>{{end}}</table>
<td>{{if .Destructive}}<span class=d>destructive</span>{{end}}</td>
<td><form method=post action=/tools style=display:inline>
<input type=hidden name=name value="{{.Name}}">
<input type=hidden name=action value=disable>
<button>disable</button></form></td></tr>{{end}}</table>
{{else}}<p>none enabled.</p>{{end}}
`
@@ -331,19 +359,37 @@ func handleTools(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
ctx := r.Context()
var msg string
if r.Method == http.MethodPost {
action := r.FormValue("action")
name := strings.TrimSpace(r.FormValue("name"))
cmd := strings.Fields(r.FormValue("cmd"))
destructive := r.FormValue("destructive") != ""
if name == "" || len(cmd) == 0 {
http.Error(w, "name and cmd required", http.StatusBadRequest)
switch action {
case "enable":
cmd := strings.Fields(r.FormValue("cmd"))
destructive := r.FormValue("destructive") != ""
if name == "" || len(cmd) == 0 {
http.Error(w, "name and cmd required", http.StatusBadRequest)
return
}
if err := core.EnableTool(ctx, name, cmd, destructive, time.Now()); err != nil {
log.Printf("tools: enable %q: %v", name, err)
http.Error(w, "enable failed: "+err.Error(), http.StatusBadGateway)
return
}
msg = "enabled " + name
case "disable":
if name == "" {
http.Error(w, "name required", http.StatusBadRequest)
return
}
if err := core.DisableTool(ctx, name); err != nil {
log.Printf("tools: disable %q: %v", name, err)
http.Error(w, "disable failed: "+err.Error(), http.StatusBadGateway)
return
}
msg = "disabled " + name
default:
http.Error(w, "unknown action", http.StatusBadRequest)
return
}
if err := core.EnableTool(ctx, name, cmd, destructive, time.Now()); err != nil {
log.Printf("tools: enable %q: %v", name, err)
http.Error(w, "enable failed: "+err.Error(), http.StatusBadGateway)
return
}
msg = "enabled " + name
}
proposed, err1 := core.ListTools(ctx, "proposed")
enabled, err2 := core.ListTools(ctx, "enabled")