958d2a2fc8
scope.domain.action, the shape Hexis has always spoken, derived from the row rather than stored — a derivation is one place to argue with, a column is whatever the last person to enable the tool typed. 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. MatchCapability widens one way, so house.lock covers every action on the locks and nothing narrower can claim a wider pattern.
141 lines
4.7 KiB
Go
141 lines
4.7 KiB
Go
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
|
|
}
|