From 8a21478f36c6ede0a8f418db3a0ba17f1e9ca3a3 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 04:55:13 +0400 Subject: [PATCH] mavweb: group the allowlist by capability domain (V-452) --- cmd/mavweb/main.go | 23 +++++--- internal/tool/capability_test.go | 92 ++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 7 deletions(-) create mode 100644 internal/tool/capability_test.go diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go index be7e5eb..d70f878 100644 --- a/cmd/mavweb/main.go +++ b/cmd/mavweb/main.go @@ -27,6 +27,7 @@ import ( "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/pattern" "github.com/kami/maven/internal/tasks" + "github.com/kami/maven/internal/tool" "github.com/kami/maven/internal/voice" "github.com/kami/maven/internal/webauthn" ) @@ -746,6 +747,8 @@ func handleDash(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { var toolsTmpl = template.Must(template.New("tools").Funcs(func() template.FuncMap { m := shellFuncs() m["join"] = strings.Join + m["capability"] = func(t ipc.Tool) string { return tool.CapabilityOf(t).String() } + m["risk"] = func(t ipc.Tool) string { return string(tool.RiskOf(t)) } return m }()).Parse(shellTopHTML + toolsHTML + shellBottomHTML)) @@ -756,9 +759,9 @@ const toolsHTML = `{{template "shellTop" "tools"}}

proposed {{len .Proposed}}

{{if .Proposed}}

maven drafted these from acts she couldn't run. Fill the command (argv, space-separated) and enable. A row in an mcp: scope came from an MCP server and already knows what it calls — check the command, then enable.

-
+
namescopefrom utteranceenable as
{{range .Proposed}} - +{{end}}
namecapabilityscopefrom utteranceenable as
{{.Name}}{{.Scope}}{{.Utterance}}{{.Name}}{{capability .}}{{.Scope}}{{.Utterance}}
@@ -779,14 +782,16 @@ const toolsHTML = `{{template "shellTop" "tools"}}

enabled {{len .Enabled}}

-{{if .Enabled}}
-{{range .Enabled}} - +{{if .Enabled}}

grouped by capability domain. The dotted id is scope.domain.action — the same shape Hexis speaks — and it is derived from the row, so it always describes what the command actually does.

+{{range .Groups}}

{{.Prefix}} {{len .Tools}}

+
namescopecommand
{{.Name}}{{.Scope}}{{join .Cmd " "}}{{if .Destructive}}destructive{{end}}
+{{range .Tools}} +{{end}}
capabilitynamecommandrisk
{{capability .}}{{.Name}}{{join .Cmd " "}}{{$r := risk .}}{{if eq $r "irreversible"}}irreversible{{else if eq $r "destructive"}}destructive{{else}}safe{{end}} -
+
{{end}} {{else}}
no tools enabled
@@ -1459,12 +1464,16 @@ func handleTools(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, sessi servers = nil } w.Header().Set("Content-Type", "text/html; charset=utf-8") + // Enabled rows are shown grouped by capability domain (Vikunja #452). A + // flat list stops answering "what can she do to the house" somewhere + // around fifteen rows, and that is the question this page exists for. if err := toolsTmpl.Execute(w, struct { Msg string Proposed []ipc.Tool Enabled []ipc.Tool + Groups []tool.CapabilityGroup MCP []ipc.MCPServerStatus - }{msg, proposed, enabled, servers}); err != nil { + }{msg, proposed, enabled, tool.GroupByDomain(enabled), servers}); err != nil { log.Printf("tools render: %v", err) } } diff --git a/internal/tool/capability_test.go b/internal/tool/capability_test.go new file mode 100644 index 0000000..e82f817 --- /dev/null +++ b/internal/tool/capability_test.go @@ -0,0 +1,92 @@ +package tool + +import ( + "testing" + + "github.com/kami/maven/internal/ipc" +) + +func TestCapabilityOfDescribesTheRow(t *testing.T) { + cases := []struct { + name string + tool ipc.Tool + want string + }{ + { + "a process with a subcommand", + ipc.Tool{Name: "restart", Scope: "homelab", Cmd: []string{"docker", "restart"}}, + "homelab.docker.restart", + }, + { + "a program with a path and a flag", + ipc.Tool{Name: "backup", Scope: "homelab", Cmd: []string{"/usr/local/bin/borg", "-v"}}, + "homelab.borg.backup", + }, + { + "the house", + ipc.Tool{Name: "unlock_front", Cmd: []string{"smarthome", "lock.front_door", "unlock"}}, + "house.lock.unlock", + }, + { + "an mcp tool", + ipc.Tool{Name: "vikunja_delete_task", Cmd: []string{"mcp", "vikunja", "delete_task"}}, + "mcp_vikunja.vikunja.delete_task", + }, + { + "a proposal with no command yet", + ipc.Tool{Name: "перезапусти", Scope: "homelab"}, + "homelab.unknown.перезапусти", + }, + } + for _, c := range cases { + if got := CapabilityOf(c.tool).String(); got != c.want { + t.Errorf("%s: %q; want %q", c.name, got, c.want) + } + } +} + +// A dotted id always has three segments, so a prefix pattern cannot widen by +// accident onto a row whose scope happens to be empty. +func TestCapabilityStringAlwaysHasThreeSegments(t *testing.T) { + if got := (Capability{}).String(); got != "unknown.unknown.unknown" { + t.Errorf("empty capability = %q", got) + } + if got := (Capability{Scope: "home lab", Domain: "a.b", Action: "X"}).String(); got != "home_lab.a_b.x" { + t.Errorf("segments not folded: %q", got) + } +} + +func TestMatchCapabilityWidensOneWay(t *testing.T) { + c := CapabilityOf(ipc.Tool{Name: "unlock_front", Cmd: []string{"smarthome", "lock.front_door", "unlock"}}) + for _, p := range []string{"house", "house.lock", "house.lock.unlock", "house.*.unlock", "*.lock"} { + if !MatchCapability(p, c) { + t.Errorf("%q did not match %s", p, c) + } + } + for _, p := range []string{"homelab", "house.light", "house.lock.lock", "house.lock.unlock.now"} { + if MatchCapability(p, c) { + t.Errorf("%q matched %s", p, c) + } + } +} + +func TestGroupByDomainIsStable(t *testing.T) { + tools := []ipc.Tool{ + {Name: "restart", Scope: "homelab", Cmd: []string{"docker", "restart"}}, + {Name: "unlock_front", Cmd: []string{"smarthome", "lock.front_door", "unlock"}}, + {Name: "logs", Scope: "homelab", Cmd: []string{"docker", "logs"}}, + } + groups := GroupByDomain(tools) + if len(groups) != 2 { + t.Fatalf("%d groups; want 2", len(groups)) + } + if groups[0].Prefix != "homelab.docker" || len(groups[0].Tools) != 2 { + t.Errorf("first group %+v; want homelab.docker with 2 rows", groups[0]) + } + if groups[0].Tools[0].Name != "logs" { + t.Errorf("rows not sorted: %+v", groups[0].Tools) + } + if groups[1].Prefix != "house.lock" { + t.Errorf("second group %q; want house.lock", groups[1].Prefix) + } +}