Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a21478f36 | |||
| 958d2a2fc8 | |||
| 7db139b83e | |||
| 0987dabfc4 |
@@ -51,6 +51,12 @@ func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) st
|
||||
phrase := actPhrase(dec.Slots.Fn, dec.Slots.Args)
|
||||
h.park(dec.Slots.Fn, dec.Slots.Args, phrase)
|
||||
return "выполнить «" + phrase + "»? скажи «да» или «нет»."
|
||||
case errors.Is(err, tool.ErrNeedsAuthedSurface):
|
||||
// Irreversible (internal/tool/risk.go). A confirm turn would not
|
||||
// help: everything that proposed this act — the STT, the router,
|
||||
// the fuzzy allowlist match — is a guess, and a spoken "да" checks
|
||||
// none of it. She names the gap instead.
|
||||
return "это я из голоса не выполню — после него ничего не вернуть. запусти сам, если правда надо."
|
||||
case errors.Is(err, tool.ErrNotEnabled):
|
||||
return h.proposeGap(ctx, dec)
|
||||
case errors.Is(err, tool.ErrNotConnected), errors.Is(err, mcp.ErrNotConnected), errors.Is(err, mcp.ErrNoServer):
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// The act path speaks each tier (Vikunja #449): a safe row runs, a destructive
|
||||
// one costs a confirm turn, an irreversible one is refused with the reason.
|
||||
func TestActPathSpeaksTheTiers(t *testing.T) {
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
cmd []string
|
||||
destructive bool
|
||||
}{
|
||||
{"status", []string{"true"}, false},
|
||||
{"restart", []string{"true"}, true},
|
||||
{"wipe", []string{"rm", "-rf"}, true},
|
||||
} {
|
||||
if _, err := st.ProposeTool(ctx, tc.name, "test", "homelab", now); err != nil {
|
||||
t.Fatalf("propose %s: %v", tc.name, err)
|
||||
}
|
||||
if err := st.EnableTool(ctx, tc.name, tc.cmd, tc.destructive, "homelab", now); err != nil {
|
||||
t.Fatalf("enable %s: %v", tc.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
act := func(fn string) string {
|
||||
return h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: fn,
|
||||
Slots: router.Slots{Fn: fn, HasFn: true},
|
||||
})
|
||||
}
|
||||
|
||||
if reply := act("status"); !strings.HasPrefix(reply, "готово") {
|
||||
t.Errorf("safe act replied %q; want it to have run", reply)
|
||||
}
|
||||
if reply := act("restart"); !strings.Contains(reply, "скажи «да»") {
|
||||
t.Errorf("destructive act replied %q; want a confirm turn", reply)
|
||||
}
|
||||
// Clear the confirm the destructive act parked, so what is pending after
|
||||
// the irreversible one is only what the irreversible one parked.
|
||||
h.mu.Lock()
|
||||
h.pending = nil
|
||||
h.mu.Unlock()
|
||||
|
||||
reply := act("wipe")
|
||||
if strings.Contains(reply, "скажи «да»") {
|
||||
t.Fatalf("irreversible act asked for a confirm: %q", reply)
|
||||
}
|
||||
if !strings.Contains(reply, "не вернуть") {
|
||||
t.Errorf("irreversible act replied %q; want it to name the reason", reply)
|
||||
}
|
||||
// Nothing was parked, so a later "да" cannot pick it up.
|
||||
h.mu.Lock()
|
||||
pending := h.pending
|
||||
h.mu.Unlock()
|
||||
if pending != nil {
|
||||
t.Errorf("an irreversible act parked %+v", pending)
|
||||
}
|
||||
// And it is still an enabled row — refusing to run it from voice is not
|
||||
// the same as taking it off the allowlist.
|
||||
if got, err := st.LookupTool(ctx, "wipe"); err != nil || got.Status != "enabled" {
|
||||
t.Errorf("wipe is %+v, %v; want it still enabled", got, err)
|
||||
}
|
||||
}
|
||||
+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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,6 +293,57 @@ don't improvise.** Destructive ones still gate behind confirm.
|
||||
Misroute correction is append-only and grows the router's examples with use —
|
||||
same shape as `nudges.outcome` tuning cooldowns, no retrain.
|
||||
|
||||
#### Risk tiers, not one boolean
|
||||
|
||||
`Destructive` on a tool row is one bit set by whoever ticked the checkbox on
|
||||
`/tools`. It is a mechanism, and it never said which acts are destructive,
|
||||
whether a confirmed act stays confirmed, or what a new tool domain inherits.
|
||||
`internal/tool/risk.go` is the policy (Vikunja #449). The tier is DERIVED from
|
||||
the row, not stored, so it can be argued with in one place instead of being
|
||||
whatever the last person to enable the tool believed.
|
||||
|
||||
| Tier | What it is | What it costs |
|
||||
|---|---|---|
|
||||
| `safe` | a read, or a change he can undo by saying the opposite | runs on first hearing |
|
||||
| `destructive` | it changes something real and undoing it takes work | one confirm turn, every time |
|
||||
| `irreversible` | the thing does not come back: a wipe, a format, a delete with no bin | voice may not authorise it at all |
|
||||
|
||||
Three rules fall out, and they are the part that was missing:
|
||||
|
||||
- **Which acts are destructive is not only the checkbox.** A house row always
|
||||
is, because there is no read-only way to turn the heating off. A row whose
|
||||
argv names one of the irreversible verbs always is, whatever the row says.
|
||||
- **A confirmed act never stays confirmed.** At any tier. A confirmation binds
|
||||
one capability, one target and one argument list, and it dies with the parked
|
||||
turn (90s). "The same act again" is a new act. A sticky confirm is a standing
|
||||
grant and nothing on the voice path may hold one.
|
||||
- **A new domain inherits `destructive`, not `safe`.** A dispatch shape the
|
||||
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
|
||||
none of it. She names the gap and he runs it himself. The row stays enabled;
|
||||
refusing to run it from voice is not the same as taking it off the allowlist.
|
||||
|
||||
---
|
||||
|
||||
## Voice pipeline (STT / TTS)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package tool
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/mcp"
|
||||
"github.com/kami/maven/internal/smarthome"
|
||||
)
|
||||
|
||||
// Risk tiers (Vikunja #449).
|
||||
//
|
||||
// What existed before this file was a mechanism and no policy: one
|
||||
// `Destructive` boolean per row, set by whoever ticked the checkbox on /tools.
|
||||
// Nothing said which acts are destructive, whether a confirmed act stays
|
||||
// confirmed, or what a new tool domain inherits — so every domain answered
|
||||
// those questions for itself, and two of them answered differently.
|
||||
//
|
||||
// The tiers below are the policy. They are derived from the row, not stored:
|
||||
// a derivation can be argued with and corrected in one place, while a column
|
||||
// is whatever the last person to enable the tool believed.
|
||||
//
|
||||
// The three questions, answered once:
|
||||
//
|
||||
// - WHICH ACTS ARE DESTRUCTIVE. A house row always is, because there is no
|
||||
// read-only way to turn the heating off. A row whose argv names one of the
|
||||
// irreversible verbs always is, whatever the checkbox says. Everything else
|
||||
// is what the row was enabled as.
|
||||
// - DOES A CONFIRMED ACT STAY CONFIRMED. No. Never, at any tier. A
|
||||
// confirmation binds one capability, one target and one argument list, and
|
||||
// it expires with the parked turn (confirmTTL, 90s). "Same act again" is a
|
||||
// new act and costs a new turn. A sticky confirm is a standing grant, and
|
||||
// nothing on the voice path may hold one.
|
||||
// - WHAT A NEW DOMAIN INHERITS. The default is TierDestructive, not
|
||||
// TierSafe. A dispatch shape this file does not recognise gets the confirm
|
||||
// turn — a new domain must argue its way DOWN to running freely, never up
|
||||
// to needing a confirm.
|
||||
type Risk string
|
||||
|
||||
const (
|
||||
// TierSafe — a read, or a mutation the owner can undo by saying the
|
||||
// opposite. Runs on first hearing.
|
||||
TierSafe Risk = "safe"
|
||||
// TierDestructive — it changes something real and undoing it takes work.
|
||||
// One confirm turn, every time, never remembered.
|
||||
TierDestructive Risk = "destructive"
|
||||
// TierIrreversible — the thing it acts on does not come back: a wipe, a
|
||||
// format, a delete with no bin behind it. A confirm turn is not enough,
|
||||
// because the whole chain that proposed it — an STT guess, a router guess,
|
||||
// a fuzzy allowlist match — has a spoken "да" as its only check. She names
|
||||
// the gap and he runs it himself.
|
||||
TierIrreversible Risk = "irreversible"
|
||||
)
|
||||
|
||||
// Policy — what a tier requires of the act path.
|
||||
//
|
||||
// There is deliberately no "sticky for" field. Non-stickiness is the policy,
|
||||
// and a knob that could turn it off would be the thing to argue with instead
|
||||
// of the rule.
|
||||
type Policy struct {
|
||||
// Confirm — the act does not run on first hearing.
|
||||
Confirm bool
|
||||
// VoiceMayRun — a spoken confirmation is enough authority to run it.
|
||||
VoiceMayRun bool
|
||||
}
|
||||
|
||||
// PolicyFor returns the requirements of a tier. An unknown tier is treated as
|
||||
// destructive, for the same reason the default derivation is.
|
||||
func PolicyFor(r Risk) Policy {
|
||||
switch r {
|
||||
case TierSafe:
|
||||
return Policy{Confirm: false, VoiceMayRun: true}
|
||||
case TierIrreversible:
|
||||
return Policy{Confirm: true, VoiceMayRun: false}
|
||||
default:
|
||||
return Policy{Confirm: true, VoiceMayRun: true}
|
||||
}
|
||||
}
|
||||
|
||||
// irreversibleVerbs — argv heads and subcommands that destroy the thing they
|
||||
// name. Matched as whole argv elements, never as substrings: "rm" must not
|
||||
// fire on "/usr/bin/rmdir-report" and "drop" must not fire on "dropbox".
|
||||
//
|
||||
// The list is short on purpose. It is not a sandbox and it does not try to be
|
||||
// one — an enabled row can already run anything the daemon's user can run.
|
||||
// What it is, is the set of words that mean "and then it is gone", so that the
|
||||
// one act nobody can walk back is the one act a spoken "да" cannot authorise.
|
||||
var irreversibleVerbs = map[string]bool{
|
||||
"rm": true, "rmdir": true, "shred": true, "srm": true,
|
||||
"mkfs": true, "fdisk": true, "parted": true, "wipefs": true,
|
||||
"dd": true, "format": true,
|
||||
"drop": true, "drop-database": true, "destroy": true, "purge": true,
|
||||
"prune": true, "truncate": true,
|
||||
}
|
||||
|
||||
// RiskOf derives the tier of an enabled tool row.
|
||||
func RiskOf(t ipc.Tool) Risk {
|
||||
if isIrreversible(t.Cmd) {
|
||||
return TierIrreversible
|
||||
}
|
||||
// A house row is a physical change to the flat, and the confirm turn on it
|
||||
// is structural rather than a column: /tools writes the checkbox straight
|
||||
// through on enable, so unticking it once turned an unlock into a row that
|
||||
// ran on first hearing. Nothing any surface writes removes the second turn
|
||||
// from a physical device.
|
||||
if _, _, ok := smarthome.ParseCmd(t.Cmd); ok {
|
||||
return TierDestructive
|
||||
}
|
||||
// An MCP row is a call to somebody else's server. It is enabled with a
|
||||
// fingerprint of what it declared at approval time (Vikunja #251), and the
|
||||
// tier tracks the same flag every other row uses — the point of this branch
|
||||
// is that it is NOT special-cased into running freely.
|
||||
if _, _, ok := mcp.ParseCmd(t.Cmd); ok {
|
||||
if t.Destructive {
|
||||
return TierDestructive
|
||||
}
|
||||
return TierSafe
|
||||
}
|
||||
if t.Destructive {
|
||||
return TierDestructive
|
||||
}
|
||||
if len(t.Cmd) == 0 {
|
||||
// Not a shape this file knows how to read. The default is the confirm
|
||||
// turn: a new domain argues its way down, not up.
|
||||
return TierDestructive
|
||||
}
|
||||
return TierSafe
|
||||
}
|
||||
|
||||
// isIrreversible reports whether any argv element is one of the verbs that
|
||||
// destroys what it names. Every element, not just the head: "sudo rm" and
|
||||
// "docker volume prune" both hide the verb behind a wrapper.
|
||||
func isIrreversible(cmd []string) bool {
|
||||
for _, arg := range cmd {
|
||||
word := strings.ToLower(strings.TrimSpace(arg))
|
||||
// Take the last path element, so /bin/rm reads as rm.
|
||||
if i := strings.LastIndex(word, "/"); i >= 0 {
|
||||
word = word[i+1:]
|
||||
}
|
||||
if irreversibleVerbs[word] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package tool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
func TestRiskOfReadsTheRow(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
tool ipc.Tool
|
||||
want Risk
|
||||
}{
|
||||
{"a plain read", ipc.Tool{Cmd: []string{"systemctl", "status"}}, TierSafe},
|
||||
{"the checkbox", ipc.Tool{Cmd: []string{"systemctl", "restart"}, Destructive: true}, TierDestructive},
|
||||
{"a wipe", ipc.Tool{Cmd: []string{"rm", "-rf"}}, TierIrreversible},
|
||||
{"a wipe behind a wrapper", ipc.Tool{Cmd: []string{"sudo", "/bin/rm"}}, TierIrreversible},
|
||||
{"a prune behind a subcommand", ipc.Tool{Cmd: []string{"docker", "volume", "prune"}}, TierIrreversible},
|
||||
{"the house", ipc.Tool{Cmd: []string{"smarthome", "light.kitchen", "turn_off"}}, TierDestructive},
|
||||
{"the house with the box unticked", ipc.Tool{Cmd: []string{"smarthome", "lock.front", "unlock"}}, TierDestructive},
|
||||
{"an mcp read", ipc.Tool{Cmd: []string{"mcp", "vikunja", "list_tasks"}}, TierSafe},
|
||||
{"an mcp write", ipc.Tool{Cmd: []string{"mcp", "vikunja", "delete_task"}, Destructive: true}, TierDestructive},
|
||||
{"a shape nobody wrote yet", ipc.Tool{}, TierDestructive},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := RiskOf(c.tool); got != c.want {
|
||||
t.Errorf("%s: RiskOf = %q; want %q", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The default is the confirm turn. A tier this file does not know is not a
|
||||
// tier that runs freely.
|
||||
func TestPolicyForDefaultsToConfirming(t *testing.T) {
|
||||
for _, r := range []Risk{TierDestructive, Risk("whatever-lands-here-next")} {
|
||||
p := PolicyFor(r)
|
||||
if !p.Confirm || !p.VoiceMayRun {
|
||||
t.Errorf("PolicyFor(%q) = %+v; want a confirm turn she may run", r, p)
|
||||
}
|
||||
}
|
||||
if p := PolicyFor(TierSafe); p.Confirm || !p.VoiceMayRun {
|
||||
t.Errorf("PolicyFor(safe) = %+v; want it to run", p)
|
||||
}
|
||||
if p := PolicyFor(TierIrreversible); !p.Confirm || p.VoiceMayRun {
|
||||
t.Errorf("PolicyFor(irreversible) = %+v; want voice refused", p)
|
||||
}
|
||||
}
|
||||
|
||||
// An irreversible act is refused whether or not he said "да", because there is
|
||||
// no second answer that changes what it would do.
|
||||
func TestExecRefusesIrreversibleEvenConfirmed(t *testing.T) {
|
||||
api := fakeAPI{tools: map[string]ipc.Tool{
|
||||
"wipe": {Name: "wipe", Status: "enabled", Cmd: []string{"rm", "-rf"}, Destructive: true},
|
||||
}}
|
||||
e := NewExecutor(api, 0)
|
||||
ran := false
|
||||
e.run = func(context.Context, []string) (string, error) { ran = true; return "", nil }
|
||||
for _, confirmed := range []bool{false, true} {
|
||||
if _, err := e.Exec(context.Background(), "wipe", []string{"/data"}, confirmed); !errors.Is(err, ErrNeedsAuthedSurface) {
|
||||
t.Errorf("confirmed=%v: %v; want ErrNeedsAuthedSurface", confirmed, err)
|
||||
}
|
||||
}
|
||||
if ran {
|
||||
t.Fatal("an irreversible act ran from the voice path")
|
||||
}
|
||||
}
|
||||
|
||||
// A row with no cmd at all is not a shape this file reads, and it must not
|
||||
// slide through as safe.
|
||||
func TestExecConfirmsAnUnreadableRow(t *testing.T) {
|
||||
api := fakeAPI{tools: map[string]ipc.Tool{
|
||||
"mystery": {Name: "mystery", Status: "enabled"},
|
||||
}}
|
||||
e := NewExecutor(api, 0)
|
||||
if _, err := e.Exec(context.Background(), "mystery", nil, false); !errors.Is(err, ErrNeedsConfirm) {
|
||||
t.Errorf("%v; want ErrNeedsConfirm", err)
|
||||
}
|
||||
}
|
||||
+21
-2
@@ -67,6 +67,12 @@ var (
|
||||
// proposal, and drafting a new proposal for a tool that already exists and
|
||||
// is enabled is a lie about what is wrong.
|
||||
ErrNotConnected = errors.New("tool is enabled but its backend is not connected")
|
||||
// ErrNeedsAuthedSurface — the row is enabled and the act is understood,
|
||||
// and its tier is one a spoken "да" may not authorise (risk.go,
|
||||
// TierIrreversible). Held apart from ErrNeedsConfirm because there is no
|
||||
// confirm turn that would help: asking again would imply the second answer
|
||||
// changes the outcome.
|
||||
ErrNeedsAuthedSurface = errors.New("tool is irreversible and voice may not authorise it")
|
||||
)
|
||||
|
||||
// MCPCaller is the seam for an act that is an MCP tool call rather than a
|
||||
@@ -121,7 +127,12 @@ func (e *Executor) WithHome(h HomeCaller) *Executor {
|
||||
// Exec looks up name in the store and runs Cmd+args as argv (no shell).
|
||||
// confirmed=true is the second turn of a destructive act (the user said "да");
|
||||
// it bypasses the ErrNeedsConfirm gate. Non-enabled ⇒ ErrNotEnabled; a
|
||||
// destructive tool with confirmed=false ⇒ ErrNeedsConfirm.
|
||||
// destructive tool with confirmed=false ⇒ ErrNeedsConfirm; an irreversible one
|
||||
// ⇒ ErrNeedsAuthedSurface, confirmed or not.
|
||||
//
|
||||
// Exec IS the voice path. Nothing else calls it, which is why the tier check
|
||||
// needs no surface argument: the authority it can offer a tool is a spoken
|
||||
// "да", and TierIrreversible says that is not enough.
|
||||
func (e *Executor) Exec(ctx context.Context, name string, args []string, confirmed bool) (string, error) {
|
||||
t, err := e.api.LookupTool(ctx, name)
|
||||
if errors.Is(err, ipc.ErrToolNotFound) {
|
||||
@@ -133,7 +144,15 @@ func (e *Executor) Exec(ctx context.Context, name string, args []string, confirm
|
||||
if t.Status != "enabled" {
|
||||
return "", ErrNotEnabled
|
||||
}
|
||||
if t.Destructive && !confirmed {
|
||||
// The tier decides, not the column (Vikunja #449). RiskOf reads the row and
|
||||
// answers the three questions the boolean never did: which acts are
|
||||
// destructive, whether a confirm sticks (it never does), and what an
|
||||
// unrecognised shape inherits (the confirm turn).
|
||||
policy := PolicyFor(RiskOf(t))
|
||||
if !policy.VoiceMayRun {
|
||||
return "", ErrNeedsAuthedSurface
|
||||
}
|
||||
if policy.Confirm && !confirmed {
|
||||
return "", ErrNeedsConfirm
|
||||
}
|
||||
// An MCP row is a call to a configured server, not a process. Everything
|
||||
|
||||
Reference in New Issue
Block a user