0ade0ec734
read_only was the whole decision on the Hexis act path, which flattened three answers into two. A capability that wipes the thing it names got the same single spoken "да" as one that restarts a service, and requires_confirmation — which the Hexis contract calls server-derived and never settable by a caller — was read by nobody. docs/ecosystem.md §17.3 says confirmation follows risk. RiskOfCapability reads Hexis's risk, read_only and requires_confirmation and returns one of the three tiers internal/tool already had. It takes plain values rather than a Capability, so internal/tool keeps no dependency on the Hexis client. RiskOf keeps deriving, because a shell row the owner ticked on /tools has no upstream to ask. Every disagreement between the three fields goes up, never down: safe and mutating is a contradiction and takes the confirm, an unrecognised tier takes the confirm, and requires_confirmation may only raise. Same default as an unrecognised dispatch shape — argue your way down, never up. The irreversible refusal was a Go literal in two places and is now one deck entry, act_needs_authed_surface. It lost four words to the persona ceiling.
112 lines
4.5 KiB
Go
112 lines
4.5 KiB
Go
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)
|
|
}
|
|
}
|
|
|
|
// Hexis owns the tier of a Hexis capability, so this reads rather than derives
|
|
// (Vikunja #523). The cases that matter are the ones where the three fields
|
|
// disagree, or where the tier is a word this package has never seen: every one
|
|
// of those goes up to a confirm, never down to running freely.
|
|
func TestRiskOfCapabilityReadsHexis(t *testing.T) {
|
|
for _, c := range []struct {
|
|
name string
|
|
risk string
|
|
ro bool
|
|
confirm bool
|
|
want Risk
|
|
}{
|
|
{"hexis says irreversible", "irreversible", false, true, TierIrreversible},
|
|
{"case and space do not change the tier", " Irreversible ", false, true, TierIrreversible},
|
|
{"hexis says destructive", "destructive", false, true, TierDestructive},
|
|
{"a read hexis calls safe", "safe", true, false, TierSafe},
|
|
{"safe but mutating is a contradiction", "safe", false, false, TierDestructive},
|
|
{"safe but wants a confirm is a contradiction", "safe", true, true, TierDestructive},
|
|
{"no tier, read-only, no confirm", "", true, false, TierSafe},
|
|
{"no tier and mutating", "", false, false, TierDestructive},
|
|
{"no tier but hexis wants a confirm", "", true, true, TierDestructive},
|
|
{"a word we have never seen", "spicy", true, false, TierDestructive},
|
|
} {
|
|
if got := RiskOfCapability(c.risk, c.ro, c.confirm); got != c.want {
|
|
t.Errorf("%s: RiskOfCapability(%q, ro=%v, confirm=%v) = %q; want %q",
|
|
c.name, c.risk, c.ro, c.confirm, got, c.want)
|
|
}
|
|
}
|
|
}
|