0e82cb442f
Recovering it by cutting on quotes in err.Error() meant the reply depended on the wording of an error string. UnknownTargetError carries the word and errors.Is still holds.
331 lines
14 KiB
Go
331 lines
14 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"
|
|
"unicode"
|
|
|
|
"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")
|
|
// ErrUnknownTarget — the act matched a tool and the target it carries cannot
|
|
// be one. A process row's args become argv for a real program, and a unit,
|
|
// container or host is named in ASCII on this box, so a Cyrillic tail is a
|
|
// word from the sentence rather than a target. Held apart from every failure
|
|
// above because the command never ran: forwarding it would spend a confirm
|
|
// turn on an act that cannot succeed, and then report the program's own
|
|
// confusion as if she had tried something sensible (V-634).
|
|
ErrUnknownTarget = errors.New("the act names a target the system cannot have")
|
|
)
|
|
|
|
// UnknownTargetError carries the word the executor could not place, because the
|
|
// reply names it: "«роутер» — не знаю такой цели" is actionable and "не
|
|
// получилось" sends him to the log. errors.Is(err, ErrUnknownTarget) holds.
|
|
type UnknownTargetError struct{ Target string }
|
|
|
|
func (e *UnknownTargetError) Error() string {
|
|
return fmt.Sprintf("%s: %q", ErrUnknownTarget, e.Target)
|
|
}
|
|
|
|
func (e *UnknownTargetError) Unwrap() error { return ErrUnknownTarget }
|
|
|
|
// 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
|
|
}
|
|
// A process row's args become argv, so the target has to be able to exist.
|
|
// Checked before the confirm gate below, because asking "выполнить X?" about
|
|
// an act that cannot run spends a turn on nothing (V-634). The other two
|
|
// dispatches are exempt: an MCP tool may take Russian text as an argument,
|
|
// since a task title is not a target, and a house row drops the spoken args.
|
|
if !isMCPRow(t.Cmd) && !isHouseRow(t.Cmd) {
|
|
if bad, ok := firstUnknownTarget(args); !ok {
|
|
return "", &UnknownTargetError{Target: bad}
|
|
}
|
|
}
|
|
// 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)
|
|
}
|
|
// The row's own argv is what names the program. An enabled row with an empty
|
|
// cmd used to fall through to exec with argv built from args alone, so the
|
|
// spoken tail became argv[0] and STT text picked the binary. A proposal is
|
|
// drafted with no cmd, and /tools can enable one before anybody fills it in,
|
|
// so this was reachable without any compromise. A row that names nothing runs
|
|
// nothing.
|
|
if len(t.Cmd) == 0 {
|
|
return "", ErrNotEnabled
|
|
}
|
|
argv := append(append([]string(nil), t.Cmd...), args...)
|
|
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.
|
|
// Aliases are spoken phrases per tool name, wired from the deployment config so
|
|
// a Russian utterance can reach an English tool name. They are not stored on the
|
|
// tool row: an ad-hoc tool enabled through /tools has no aliases and needs none.
|
|
type Matcher struct {
|
|
api API
|
|
aliases map[string][]string
|
|
}
|
|
|
|
// NewMatcher builds a store-backed act matcher.
|
|
func NewMatcher(api API) *Matcher { return &Matcher{api: api} }
|
|
|
|
// WithAliases returns the matcher carrying spoken aliases per tool name.
|
|
func (m *Matcher) WithAliases(a map[string][]string) *Matcher {
|
|
m.aliases = a
|
|
return m
|
|
}
|
|
|
|
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-phrase-first prefix match over the live enabled allowlist and
|
|
// its configured aliases.
|
|
func (m *Matcher) Match(utterance string) (string, []string, bool) {
|
|
return router.DefaultActMatcher{Fns: m.names(), Aliases: m.aliases}.Match(utterance)
|
|
}
|
|
|
|
// firstUnknownTarget reports whether every arg could name something on this box,
|
|
// and returns the first that could not.
|
|
//
|
|
// The check is the script, not a word list: this is not a fourth Russian
|
|
// mechanism (CLAUDE.md § "Russian patterns"). A systemd unit, a container, a
|
|
// host and a path are written in ASCII, so a non-ASCII rune in an argv element
|
|
// means the alias match swallowed the verb and handed on the next word of the
|
|
// sentence. "перезагрузи роутер" is the case: restart is a real tool and
|
|
// "роутер" is a real word, and `systemctl restart роутер` is neither.
|
|
//
|
|
// Every process row this box enables takes a system identifier (systemctl,
|
|
// docker, journalctl, df). A process row that legitimately wanted Russian text
|
|
// would want a different dispatch, not a hole in this check.
|
|
//
|
|
// It deliberately does not try to guess the right target. Identity is Nexus's
|
|
// (CLAUDE.md § "The ecosystem"), and a target Nexus resolves reaches Hexis
|
|
// through handleHexisAct before this executor is asked.
|
|
func firstUnknownTarget(args []string) (string, bool) {
|
|
for _, a := range args {
|
|
for _, r := range a {
|
|
if r > unicode.MaxASCII {
|
|
return a, false
|
|
}
|
|
}
|
|
}
|
|
return "", true
|
|
}
|
|
|
|
func isMCPRow(cmd []string) bool {
|
|
_, _, ok := mcp.ParseCmd(cmd)
|
|
return ok
|
|
}
|
|
|
|
func isHouseRow(cmd []string) bool {
|
|
_, _, ok := smarthome.ParseCmd(cmd)
|
|
return ok
|
|
}
|