diff --git a/docs/design.md b/docs/design.md index f29a2ad..7320532 100644 --- a/docs/design.md +++ b/docs/design.md @@ -321,6 +321,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 +}