// 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", "", ""] 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", "", ""] 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 }