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)
|
phrase := actPhrase(dec.Slots.Fn, dec.Slots.Args)
|
||||||
h.park(dec.Slots.Fn, dec.Slots.Args, phrase)
|
h.park(dec.Slots.Fn, dec.Slots.Args, phrase)
|
||||||
return "выполнить «" + 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):
|
case errors.Is(err, tool.ErrNotEnabled):
|
||||||
return h.proposeGap(ctx, dec)
|
return h.proposeGap(ctx, dec)
|
||||||
case errors.Is(err, tool.ErrNotConnected), errors.Is(err, mcp.ErrNotConnected), errors.Is(err, mcp.ErrNoServer):
|
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/ipc"
|
||||||
"github.com/kami/maven/internal/pattern"
|
"github.com/kami/maven/internal/pattern"
|
||||||
"github.com/kami/maven/internal/tasks"
|
"github.com/kami/maven/internal/tasks"
|
||||||
|
"github.com/kami/maven/internal/tool"
|
||||||
"github.com/kami/maven/internal/voice"
|
"github.com/kami/maven/internal/voice"
|
||||||
"github.com/kami/maven/internal/webauthn"
|
"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 {
|
var toolsTmpl = template.Must(template.New("tools").Funcs(func() template.FuncMap {
|
||||||
m := shellFuncs()
|
m := shellFuncs()
|
||||||
m["join"] = strings.Join
|
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
|
return m
|
||||||
}()).Parse(shellTopHTML + toolsHTML + shellBottomHTML))
|
}()).Parse(shellTopHTML + toolsHTML + shellBottomHTML))
|
||||||
|
|
||||||
@@ -756,9 +759,9 @@ const toolsHTML = `{{template "shellTop" "tools"}}
|
|||||||
<section class=card>
|
<section class=card>
|
||||||
<h2 class=card-title>proposed <span class=badge>{{len .Proposed}}</span></h2>
|
<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>
|
{{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>
|
{{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>
|
<td><form method=post action=/tools>
|
||||||
<input type=hidden name=name value="{{.Name}}">
|
<input type=hidden name=name value="{{.Name}}">
|
||||||
<input type=hidden name=scope value="{{.Scope}}">
|
<input type=hidden name=scope value="{{.Scope}}">
|
||||||
@@ -779,14 +782,16 @@ const toolsHTML = `{{template "shellTop" "tools"}}
|
|||||||
</section>
|
</section>
|
||||||
<section class=card>
|
<section class=card>
|
||||||
<h2 class=card-title>enabled <span class=badge>{{len .Enabled}}</span></h2>
|
<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>
|
{{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 .Enabled}}<tr><td><code>{{.Name}}</code></td><td><span class=badge>{{.Scope}}</span></td><td><code>{{join .Cmd " "}}</code></td>
|
{{range .Groups}}<h3 class=card-title><code>{{.Prefix}}</code> <span class=badge>{{len .Tools}}</span></h3>
|
||||||
<td>{{if .Destructive}}<span class=red>destructive</span>{{end}}</td>
|
<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>
|
<td><form method=post action=/tools class=inline-form>
|
||||||
<input type=hidden name=name value="{{.Name}}">
|
<input type=hidden name=name value="{{.Name}}">
|
||||||
<input type=hidden name=scope value="{{.Scope}}">
|
<input type=hidden name=scope value="{{.Scope}}">
|
||||||
<input type=hidden name=action value=disable>
|
<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>
|
{{else}}<div class=empty>
|
||||||
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-settings"/></svg>
|
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-settings"/></svg>
|
||||||
<div>no tools enabled</div>
|
<div>no tools enabled</div>
|
||||||
@@ -1459,12 +1464,16 @@ func handleTools(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, sessi
|
|||||||
servers = nil
|
servers = nil
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
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 {
|
if err := toolsTmpl.Execute(w, struct {
|
||||||
Msg string
|
Msg string
|
||||||
Proposed []ipc.Tool
|
Proposed []ipc.Tool
|
||||||
Enabled []ipc.Tool
|
Enabled []ipc.Tool
|
||||||
|
Groups []tool.CapabilityGroup
|
||||||
MCP []ipc.MCPServerStatus
|
MCP []ipc.MCPServerStatus
|
||||||
}{msg, proposed, enabled, servers}); err != nil {
|
}{msg, proposed, enabled, tool.GroupByDomain(enabled), servers}); err != nil {
|
||||||
log.Printf("tools render: %v", err)
|
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 —
|
Misroute correction is append-only and grows the router's examples with use —
|
||||||
same shape as `nudges.outcome` tuning cooldowns, no retrain.
|
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)
|
## 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
|
// proposal, and drafting a new proposal for a tool that already exists and
|
||||||
// is enabled is a lie about what is wrong.
|
// is enabled is a lie about what is wrong.
|
||||||
ErrNotConnected = errors.New("tool is enabled but its backend is not connected")
|
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
|
// 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).
|
// 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 "да");
|
// confirmed=true is the second turn of a destructive act (the user said "да");
|
||||||
// it bypasses the ErrNeedsConfirm gate. Non-enabled ⇒ ErrNotEnabled; a
|
// 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) {
|
func (e *Executor) Exec(ctx context.Context, name string, args []string, confirmed bool) (string, error) {
|
||||||
t, err := e.api.LookupTool(ctx, name)
|
t, err := e.api.LookupTool(ctx, name)
|
||||||
if errors.Is(err, ipc.ErrToolNotFound) {
|
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" {
|
if t.Status != "enabled" {
|
||||||
return "", ErrNotEnabled
|
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
|
return "", ErrNeedsConfirm
|
||||||
}
|
}
|
||||||
// An MCP row is a call to a configured server, not a process. Everything
|
// An MCP row is a call to a configured server, not a process. Everything
|
||||||
|
|||||||
Reference in New Issue
Block a user