0987dabfc4
The Destructive column was a mechanism with no policy behind it: nothing said which acts are destructive, whether a confirmed act stays confirmed, or what a new tool domain inherits, so each domain answered for itself. Three tiers, derived from the row rather than stored, so the answer can be argued with in one place instead of being whatever the last person to tick the checkbox believed. Safe runs. Destructive costs a confirm turn, every time — a confirmation binds one capability, one target and one argument list, and it dies with the parked turn. Irreversible is refused: a confirm turn there would be theatre, because the STT, the router and the fuzzy allowlist match are all guesses and a spoken "да" checks none of them. She names the gap; the row stays enabled. An unrecognised dispatch shape inherits destructive, not safe. A domain argues its way down to running freely, never up to being gated.
244 lines
10 KiB
Go
244 lines
10 KiB
Go
// Package tool is maven's act executor: it runs the ENABLED tools from the
|
|
// store's allowlist, and drafts 'proposed' scaffolds for acts that aren't on
|
|
// it yet.
|
|
//
|
|
// Boundary discipline (docs/design.md § "Tool registration — drafting is suggest,
|
|
// enabling is act"):
|
|
//
|
|
// - The store is the allowlist. Only status='enabled' rows run. A verb not
|
|
// on it → refuse ("not on the list → refuse, don't improvise") and draft a
|
|
// 'proposed' scaffold instead. Enabling a proposal is a human act on an
|
|
// authed surface (mavweb), gated at AuthStepUp — never the voice path, so
|
|
// a compromised router can't grant itself a capability.
|
|
// - Args are passed as argv, NEVER through a shell. STT text lands as
|
|
// positional arguments to Cmd; there is no `sh -c`, so "restart nginx;
|
|
// rm -rf" can't inject — the tail is one argv element to the named binary.
|
|
// - An enabled row whose cmd is ["smarthome", "<entity_id>", "<service>"] is
|
|
// a Home Assistant service call instead of a process (Vikunja #256), by
|
|
// exactly the same trick and under exactly the same rules. Control rows are
|
|
// always destructive, so flipping something in his flat always costs a
|
|
// confirm turn.
|
|
// - An enabled row whose cmd is ["mcp", "<server>", "<tool>"] is a call to a
|
|
// configured MCP server instead of a process (Vikunja #251). It goes
|
|
// through every rule above unchanged — enabled, and confirmed if it
|
|
// mutates — because the store is still the allowlist; only the dispatch at
|
|
// the bottom of Exec differs.
|
|
// - Destructive tools don't run on first hearing: Exec returns ErrNeedsConfirm
|
|
// and the handler runs a confirm turn ("выполнить X? да/нет"); only a
|
|
// confirmed re-Exec runs them. A gate assumes a fully-formed action, which
|
|
// an enabled+matched act is (docs/design.md § "Confirmation is not one
|
|
// mechanism").
|
|
package tool
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/ipc"
|
|
"github.com/kami/maven/internal/mcp"
|
|
"github.com/kami/maven/internal/router"
|
|
"github.com/kami/maven/internal/smarthome"
|
|
)
|
|
|
|
// API — the narrow slice of ipc.CoreAPI the executor and matcher need. Backed
|
|
// in-process by the daemon's store adapter (a direct sqlite query per call —
|
|
// acts are rare, personal-scale; no cache).
|
|
type API interface {
|
|
LookupTool(ctx context.Context, name string) (ipc.Tool, error)
|
|
ListTools(ctx context.Context, status string) ([]ipc.Tool, error)
|
|
ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error)
|
|
}
|
|
|
|
var (
|
|
// ErrNotEnabled — the fn isn't an enabled tool (absent, or still proposed).
|
|
ErrNotEnabled = errors.New("tool not on the enabled allowlist")
|
|
// ErrNeedsConfirm — the fn is enabled but destructive; needs a confirm turn.
|
|
ErrNeedsConfirm = errors.New("destructive tool needs confirmation")
|
|
// ErrNotConnected — the row is enabled and well formed, but the thing it
|
|
// dispatches to is not wired: the mcp block was dropped from the config
|
|
// while enabled MCP rows remained, or the same for the house. Held apart
|
|
// from ErrNotEnabled because the act path turns that one into a fresh
|
|
// 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
|
|
// process (Vikunja #251). internal/mcp.Manager satisfies it via CallPositional.
|
|
// nil ⇒ MCP is not configured, and an MCP row refuses to run rather than
|
|
// silently doing nothing.
|
|
type MCPCaller interface {
|
|
CallPositional(ctx context.Context, server, tool string, args []string) (string, error)
|
|
}
|
|
|
|
// HomeCaller is the seam for an act that is a Home Assistant service call
|
|
// rather than a process (Vikunja #256). internal/smarthome.Client satisfies it.
|
|
// nil ⇒ the house is not configured, and a house row refuses to run rather than
|
|
// silently doing nothing.
|
|
type HomeCaller interface {
|
|
CallService(ctx context.Context, entityID, service string) (string, error)
|
|
}
|
|
|
|
// Executor runs enabled tools. run is the exec seam (default: real process);
|
|
// tests swap it. timeout bounds each invocation.
|
|
type Executor struct {
|
|
api API
|
|
timeout time.Duration
|
|
run func(ctx context.Context, argv []string) (string, error)
|
|
mcp MCPCaller
|
|
home HomeCaller
|
|
}
|
|
|
|
// NewExecutor builds the executor. timeout<=0 defaults to 30s.
|
|
func NewExecutor(api API, timeout time.Duration) *Executor {
|
|
if timeout <= 0 {
|
|
timeout = 30 * time.Second
|
|
}
|
|
return &Executor{api: api, timeout: timeout, run: runProcess}
|
|
}
|
|
|
|
// WithMCP attaches the MCP caller. Called once at wiring time when the mcp
|
|
// config block is present; without it, a row whose cmd is ["mcp", …] refuses.
|
|
func (e *Executor) WithMCP(m MCPCaller) *Executor {
|
|
e.mcp = m
|
|
return e
|
|
}
|
|
|
|
// WithHome attaches the Home Assistant caller. Called once at wiring time when
|
|
// the smarthome block is enabled; without it, a row whose cmd is
|
|
// ["smarthome", …] refuses.
|
|
func (e *Executor) WithHome(h HomeCaller) *Executor {
|
|
e.home = h
|
|
return e
|
|
}
|
|
|
|
// 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; 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) {
|
|
return "", ErrNotEnabled
|
|
}
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if t.Status != "enabled" {
|
|
return "", ErrNotEnabled
|
|
}
|
|
// 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
|
|
// above still applied: it had to be enabled, and a mutating one had to be
|
|
// confirmed. Only the dispatch differs.
|
|
if server, remote, ok := mcp.ParseCmd(t.Cmd); ok {
|
|
if e.mcp == nil {
|
|
return "", fmt.Errorf("%w: %s is an MCP tool and no mcp block is configured", ErrNotConnected, name)
|
|
}
|
|
ctx, cancel := context.WithTimeout(ctx, e.timeout)
|
|
defer cancel()
|
|
return e.mcp.CallPositional(ctx, server, remote, args)
|
|
}
|
|
// A house row is a Home Assistant service call, not a process (Vikunja
|
|
// #256). Same story: enabled, and confirmed — every control row is
|
|
// destructive, because there is no read-only way to turn the heating off.
|
|
// The spoken args are dropped on purpose: the entity and the service come
|
|
// from the row Kami enabled, so a router that misheard can pick the wrong
|
|
// row but can never compose a target of its own.
|
|
if entityID, service, ok := smarthome.ParseCmd(t.Cmd); ok {
|
|
if e.home == nil {
|
|
return "", fmt.Errorf("%w: %s is a house tool and no smarthome block is configured", ErrNotConnected, name)
|
|
}
|
|
// The confirm turn on a house row is structural, not a column. The
|
|
// proposal is written destructive=true, but /tools writes the checkbox
|
|
// straight through on enable (destructive=excluded.destructive), so
|
|
// unticking it once turned home_lock_front_door_unlock into a row that
|
|
// ran on first hearing. Nothing any surface writes can remove the
|
|
// second turn from a physical device.
|
|
if !confirmed {
|
|
return "", ErrNeedsConfirm
|
|
}
|
|
ctx, cancel := context.WithTimeout(ctx, e.timeout)
|
|
defer cancel()
|
|
return e.home.CallService(ctx, entityID, service)
|
|
}
|
|
argv := append(append([]string(nil), t.Cmd...), args...)
|
|
if len(argv) == 0 {
|
|
return "", ErrNotEnabled
|
|
}
|
|
ctx, cancel := context.WithTimeout(ctx, e.timeout)
|
|
defer cancel()
|
|
return e.run(ctx, argv)
|
|
}
|
|
|
|
func runProcess(ctx context.Context, argv []string) (string, error) {
|
|
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
|
|
var buf bytes.Buffer
|
|
cmd.Stdout = &buf
|
|
cmd.Stderr = &buf
|
|
err := cmd.Run()
|
|
out := strings.TrimSpace(buf.String())
|
|
if err != nil {
|
|
return out, fmt.Errorf("run %v: %w", argv, err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Matcher — a router.ActMatcher whose allowlist is the live set of enabled
|
|
// tool names (one source of truth with the executor). Match delegates to the
|
|
// router's default prefix logic over the current names. The interface's Match
|
|
// has no ctx, so it queries with a background context — an in-process sqlite
|
|
// read on the daemon.
|
|
type Matcher struct{ api API }
|
|
|
|
// NewMatcher builds a store-backed act matcher.
|
|
func NewMatcher(api API) *Matcher { return &Matcher{api: api} }
|
|
|
|
func (m *Matcher) names() []string {
|
|
ts, err := m.api.ListTools(context.Background(), "enabled")
|
|
if err != nil {
|
|
log.Printf("tool: list enabled tools: %v", err)
|
|
return nil
|
|
}
|
|
names := make([]string, len(ts))
|
|
for i, t := range ts {
|
|
names[i] = t.Name
|
|
}
|
|
return names
|
|
}
|
|
|
|
// Allowlist — the enabled verbs (for stage-0 grammar wiring / introspection).
|
|
func (m *Matcher) Allowlist() []string { return m.names() }
|
|
|
|
// Match — longest-verb-first prefix match over the live enabled allowlist.
|
|
func (m *Matcher) Match(utterance string) (string, []string, bool) {
|
|
return router.DefaultActMatcher{Fns: m.names()}.Match(utterance)
|
|
}
|