diff --git a/cmd/mavend/actions_act_risk_test.go b/cmd/mavend/actions_act_risk_test.go new file mode 100644 index 0000000..24fd271 --- /dev/null +++ b/cmd/mavend/actions_act_risk_test.go @@ -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) + } +} diff --git a/internal/tool/risk_test.go b/internal/tool/risk_test.go new file mode 100644 index 0000000..4662e1f --- /dev/null +++ b/internal/tool/risk_test.go @@ -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) + } +}