d94ed2e630
V-633 gave tools spoken aliases, so a Russian act reaches a tool. It resolves the verb only: the rest of the sentence became argv. "перезагрузи роутер" ran as systemctl restart роутер, which is a real tool, a real word and a target that cannot exist on this box. She then reported systemctl's own confusion as if she had tried something sensible, and on a destructive row she spent a confirm turn on it first. The executor now refuses, ahead of the confirm gate, and names the word it could not place. The check is the script and not a word list: a unit, a container, a host and a path are ASCII here, so a Cyrillic argv element means the alias match swallowed the verb and handed on the next word. Process rows only. An MCP argument is not a target — a task title is Russian and always was — and a house row drops the spoken args already. It does not try to guess the right target. Identity is Nexus's, and a target Nexus resolves reaches Hexis through handleHexisAct before this executor is asked.
316 lines
13 KiB
Go
316 lines
13 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")
|
|
)
|
|
|
|
// 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 "", fmt.Errorf("%w: %q", ErrUnknownTarget, 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.
|
|
//
|
|
// 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
|
|
}
|