initial commit
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
// 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 (maven.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.
|
||||
// - 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 (maven.md "confirmation is not one mechanism").
|
||||
package tool
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// 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 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")
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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}
|
||||
}
|
||||
|
||||
// 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.
|
||||
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
|
||||
}
|
||||
if t.Destructive && !confirmed {
|
||||
return "", ErrNeedsConfirm
|
||||
}
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package tool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
// fakeAPI — an in-memory tool store for the executor/matcher tests.
|
||||
type fakeAPI struct{ tools map[string]ipc.Tool }
|
||||
|
||||
func (f fakeAPI) LookupTool(_ context.Context, name string) (ipc.Tool, error) {
|
||||
t, ok := f.tools[name]
|
||||
if !ok {
|
||||
return ipc.Tool{}, ipc.ErrToolNotFound
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
func (f fakeAPI) ListTools(_ context.Context, status string) ([]ipc.Tool, error) {
|
||||
var out []ipc.Tool
|
||||
for _, t := range f.tools {
|
||||
if status == "" || t.Status == status {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (f fakeAPI) ProposeTool(_ context.Context, _, _ string, _ time.Time) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// TestExec covers the allowlist boundary: enabled runs, unknown/proposed refuse,
|
||||
// destructive needs confirm, and args land as argv (no shell) after the prefix.
|
||||
func TestExec(t *testing.T) {
|
||||
api := fakeAPI{tools: map[string]ipc.Tool{
|
||||
"restart": {Name: "restart", Cmd: []string{"systemctl", "restart"}, Status: "enabled"},
|
||||
"drop": {Name: "drop", Cmd: []string{"dropdb"}, Destructive: true, Status: "enabled"},
|
||||
"draft": {Name: "draft", Cmd: []string{"x"}, Status: "proposed"},
|
||||
}}
|
||||
|
||||
var gotArgv []string
|
||||
e := NewExecutor(api, 0)
|
||||
e.run = func(_ context.Context, argv []string) (string, error) { gotArgv = argv; return "ok", nil }
|
||||
|
||||
// enabled → runs, args appended to the fixed prefix as argv.
|
||||
out, err := e.Exec(context.Background(), "restart", []string{"nginx; rm -rf /"}, false)
|
||||
if err != nil || out != "ok" {
|
||||
t.Fatalf("enabled: out=%q err=%v", out, err)
|
||||
}
|
||||
want := []string{"systemctl", "restart", "nginx; rm -rf /"}
|
||||
if !reflect.DeepEqual(gotArgv, want) {
|
||||
t.Fatalf("argv=%v want %v (injection must stay one argv element)", gotArgv, want)
|
||||
}
|
||||
|
||||
// unknown → refuse.
|
||||
if _, err := e.Exec(context.Background(), "nope", nil, false); !errors.Is(err, ErrNotEnabled) {
|
||||
t.Fatalf("unknown: err=%v want ErrNotEnabled", err)
|
||||
}
|
||||
// proposed (not enabled) → refuse.
|
||||
if _, err := e.Exec(context.Background(), "draft", nil, false); !errors.Is(err, ErrNotEnabled) {
|
||||
t.Fatalf("proposed: err=%v want ErrNotEnabled", err)
|
||||
}
|
||||
// destructive unconfirmed → needs confirm; confirmed → runs.
|
||||
if _, err := e.Exec(context.Background(), "drop", nil, false); !errors.Is(err, ErrNeedsConfirm) {
|
||||
t.Fatalf("destructive: err=%v want ErrNeedsConfirm", err)
|
||||
}
|
||||
if _, err := e.Exec(context.Background(), "drop", []string{"db"}, true); err != nil {
|
||||
t.Fatalf("destructive confirmed: err=%v", err)
|
||||
}
|
||||
if want := []string{"dropdb", "db"}; !reflect.DeepEqual(gotArgv, want) {
|
||||
t.Fatalf("confirmed argv=%v want %v", gotArgv, want)
|
||||
}
|
||||
|
||||
// matcher allowlist = enabled names only (proposed excluded).
|
||||
m := NewMatcher(api)
|
||||
fn, args, ok := m.Match("restart nginx")
|
||||
if !ok || fn != "restart" || !reflect.DeepEqual(args, []string{"nginx"}) {
|
||||
t.Fatalf("match: fn=%q args=%v ok=%v", fn, args, ok)
|
||||
}
|
||||
if _, _, ok := m.Match("draft something"); ok {
|
||||
t.Fatal("proposed tool must not match (not enabled)")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user