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 { // One parse of an entity id, in the package that owns the format. domain := smarthome.DomainOf(entityID) if domain == "" { domain = entityID } 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) key := capSegment(c.Scope) + "." + capSegment(c.Domain) byKey[key] = append(byKey[key], 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 }