mavweb: group the allowlist by capability domain (V-452)
This commit is contained in:
+16
-7
@@ -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"}}
|
||||
<section class=card>
|
||||
<h2 class=card-title>proposed <span class=badge>{{len .Proposed}}</span></h2>
|
||||
{{if .Proposed}}<p class=hint>maven drafted these from acts she couldn't run. Fill the command (argv, space-separated) and enable. A row in an <code>mcp:</code> scope came from an MCP server and already knows what it calls — check the command, then enable.</p>
|
||||
<div class=scroll><table><tr><th>name</th><th>scope</th><th>from utterance</th><th>enable as</th></tr>
|
||||
<div class=scroll><table><tr><th>name</th><th>capability</th><th>scope</th><th>from utterance</th><th>enable as</th></tr>
|
||||
{{range .Proposed}}<tr>
|
||||
<td><code>{{.Name}}</code></td><td><span class=badge>{{.Scope}}</span></td><td>{{.Utterance}}</td>
|
||||
<td><code>{{.Name}}</code></td><td><code>{{capability .}}</code></td><td><span class=badge>{{.Scope}}</span></td><td>{{.Utterance}}</td>
|
||||
<td><form method=post action=/tools>
|
||||
<input type=hidden name=name value="{{.Name}}">
|
||||
<input type=hidden name=scope value="{{.Scope}}">
|
||||
@@ -779,14 +782,16 @@ const toolsHTML = `{{template "shellTop" "tools"}}
|
||||
</section>
|
||||
<section class=card>
|
||||
<h2 class=card-title>enabled <span class=badge>{{len .Enabled}}</span></h2>
|
||||
{{if .Enabled}}<div class=scroll><table><tr><th>name</th><th>scope</th><th>command</th><th></th><th></th></tr>
|
||||
{{range .Enabled}}<tr><td><code>{{.Name}}</code></td><td><span class=badge>{{.Scope}}</span></td><td><code>{{join .Cmd " "}}</code></td>
|
||||
<td>{{if .Destructive}}<span class=red>destructive</span>{{end}}</td>
|
||||
{{if .Enabled}}<p class=hint>grouped by capability domain. The dotted id is <code>scope.domain.action</code> — the same shape Hexis speaks — and it is derived from the row, so it always describes what the command actually does.</p>
|
||||
{{range .Groups}}<h3 class=card-title><code>{{.Prefix}}</code> <span class=badge>{{len .Tools}}</span></h3>
|
||||
<div class=scroll><table><tr><th>capability</th><th>name</th><th>command</th><th>risk</th><th></th></tr>
|
||||
{{range .Tools}}<tr><td><code>{{capability .}}</code></td><td><code>{{.Name}}</code></td><td><code>{{join .Cmd " "}}</code></td>
|
||||
<td>{{$r := risk .}}{{if eq $r "irreversible"}}<span class=red>irreversible</span>{{else if eq $r "destructive"}}<span class=red>destructive</span>{{else}}<span class=badge>safe</span>{{end}}</td>
|
||||
<td><form method=post action=/tools class=inline-form>
|
||||
<input type=hidden name=name value="{{.Name}}">
|
||||
<input type=hidden name=scope value="{{.Scope}}">
|
||||
<input type=hidden name=action value=disable>
|
||||
<button class=btn>disable</button></form></td></tr>{{end}}</table></div>
|
||||
<button class=btn>disable</button></form></td></tr>{{end}}</table></div>{{end}}
|
||||
{{else}}<div class=empty>
|
||||
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-settings"/></svg>
|
||||
<div>no tools enabled</div>
|
||||
@@ -1459,12 +1464,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user