diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go index 14aca14..b954ba2 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
@@ -1551,12 +1556,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/docs/design.md b/docs/design.md index 9607606..13e7b37 100644 --- a/docs/design.md +++ b/docs/design.md @@ -380,6 +380,23 @@ Three rules fall out, and they are the part that was missing: policy does not recognise gets the confirm turn. A domain argues its way down to running freely; it never has to argue its way up to being gated. +#### Capability ids + +A row is also read as a dotted capability id, `scope.domain.action` — the same +shape Hexis has always spoken, which made the local surface the odd one out +(Vikunja #452). `homelab.docker.restart`, `house.lock.unlock`, +`mcp_vikunja.vikunja.delete_task`. + +Derived, not stored, for the reason the tier is: a derivation is one place to +argue with. The name is still the primary key and nothing about lookup or +execution changed — this is a way to READ the allowlist, not a second one. +`/tools` groups the enabled rows by `scope.domain` and prints the id and the +tier beside each, because a flat list stops answering "what can she do to the +house" somewhere around fifteen rows. + +`MatchCapability` widens one way: `house` and `house.lock` both cover +`house.lock.unlock`, and nothing lets a narrower id claim a wider pattern. + The irreversible tier is refused rather than asked about, because a confirm turn would be theatre: everything that proposed the act — an STT guess, a router guess, a fuzzy allowlist match — is a guess, and a spoken "да" checks diff --git a/internal/tool/capability.go b/internal/tool/capability.go new file mode 100644 index 0000000..5af177a --- /dev/null +++ b/internal/tool/capability.go @@ -0,0 +1,140 @@ +package tool + +import ( + "sort" + "strings" + + "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/mcp" + "github.com/kami/maven/internal/smarthome" +) + +// Capability ids (Vikunja #452). +// +// A tool row is flat: one name, one scope, one enabled bit. Permission is +// therefore per name, and nothing groups. Hexis has spoken dotted capability +// ids since it existed, so the local surface was the odd one out — and the +// flat shape gets expensive around fifteen rows, when "what can she do to the +// house" stops being a question anyone can answer by reading a list. +// +// A capability id is scope.domain.action: homelab.docker.restart, +// house.lock.unlock, mcp_vikunja.vikunja.delete_task. +// +// DERIVED, not stored, for the same reason the risk tier is (risk.go): a +// derivation is one place to argue with, a column is whatever the last person +// to enable the row happened to type. The name stays the primary key and +// nothing about lookup or execution changes — this is a way to READ the +// allowlist, not a second allowlist. +type Capability struct { + Scope string + Domain string + Action string +} + +// String renders the dotted id. An empty segment becomes "unknown" rather than +// collapsing, so an id always has three parts and a prefix match cannot +// accidentally widen. +func (c Capability) String() string { + return capSegment(c.Scope) + "." + capSegment(c.Domain) + "." + capSegment(c.Action) +} + +func capSegment(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + s = strings.ReplaceAll(s, ".", "_") + s = strings.ReplaceAll(s, " ", "_") + if s == "" { + return "unknown" + } + return s +} + +// CapabilityOf derives the id of a tool row. +// +// The domain is the thing acted on and the action is what is done to it, read +// off whichever dispatch shape the row uses: +// +// - a house row: the Home Assistant entity domain and the service, so +// light.kitchen + turn_off becomes house.light.turn_off. Its scope is +// "house" whatever the row says, because the entity id is what decides +// what it touches. +// - an MCP row: the server handle and the remote tool name. +// - a process row: the program (path stripped) and its first subcommand, or +// the tool name when the argv carries no second word. +func CapabilityOf(t ipc.Tool) Capability { + if entityID, service, ok := smarthome.ParseCmd(t.Cmd); ok { + domain := entityID + if i := strings.Index(entityID, "."); i > 0 { + domain = entityID[:i] + } + return Capability{Scope: "house", Domain: domain, Action: service} + } + if server, remote, ok := mcp.ParseCmd(t.Cmd); ok { + return Capability{Scope: "mcp_" + server, Domain: server, Action: remote} + } + scope := t.Scope + if scope == "" { + scope = "homelab" + } + if len(t.Cmd) == 0 { + // A proposal has no argv yet. It still gets an id, because "what did + // she ask for" is exactly the question the proposed list answers. + return Capability{Scope: scope, Domain: "unknown", Action: t.Name} + } + program := t.Cmd[0] + if i := strings.LastIndex(program, "/"); i >= 0 { + program = program[i+1:] + } + action := t.Name + if len(t.Cmd) > 1 && !strings.HasPrefix(t.Cmd[1], "-") { + action = t.Cmd[1] + } + return Capability{Scope: scope, Domain: program, Action: action} +} + +// MatchCapability reports whether an id matches a pattern. A pattern is a +// dotted id whose segments may be "*", and a pattern with fewer segments than +// the id matches every id under it: "house" and "house.*" both cover +// house.lock.unlock. +// +// Prefix widening is deliberate and one-directional. "house.lock" covers every +// action on the locks; nothing lets a narrower id claim a wider pattern. +func MatchCapability(pattern string, c Capability) bool { + want := strings.Split(strings.ToLower(strings.TrimSpace(pattern)), ".") + got := strings.Split(c.String(), ".") + if len(want) > len(got) { + return false + } + for i, w := range want { + if w == "*" || w == "" { + continue + } + if w != got[i] { + return false + } + } + return true +} + +// GroupByDomain buckets rows by "scope.domain" and returns the buckets in a +// stable order, which is what makes the allowlist readable past the point +// where a flat list stops being. +func GroupByDomain(tools []ipc.Tool) []CapabilityGroup { + byKey := map[string][]ipc.Tool{} + for _, t := range tools { + c := CapabilityOf(t) + byKey[capSegment(c.Scope)+"."+capSegment(c.Domain)] = append(byKey[capSegment(c.Scope)+"."+capSegment(c.Domain)], t) + } + out := make([]CapabilityGroup, 0, len(byKey)) + for k, v := range byKey { + sort.Slice(v, func(i, j int) bool { return v[i].Name < v[j].Name }) + out = append(out, CapabilityGroup{Prefix: k, Tools: v}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Prefix < out[j].Prefix }) + return out +} + +// CapabilityGroup — one scope.domain and the rows under it. +type CapabilityGroup struct { + Prefix string + Tools []ipc.Tool +} 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) + } +}