Files
claude f4a021d3da a tool row with no cmd no longer execs the utterance (V-581)
An enabled row whose Cmd is empty built argv from the spoken args alone. So
args[0] became the program name, and free text picked the binary. A proposal is
drafted with no cmd. /tools can enable one before anybody fills it in, so
reaching this took no compromise. Exec now refuses such a row with ErrNotEnabled
before it builds argv. TestExecEmptyCmdRefuses pins it.

Three smaller reads in the same sweep. CapabilityOf parsed a Home Assistant
entity id by hand where smarthome.DomainOf already does it. The fallback for an
id with no dot is unchanged. GroupByDomain built its map key twice per row. The
zenmoney and Home Assistant HTTP clients read an error body before checking the
status that discards it. The status check moved ahead of the read in both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:11:53 +04:00

143 lines
4.8 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 {
// 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
}