da62a2f25e
An allowlist row stores cmd ["mcp", server, tool]. That is a late-bound reference to a name the far end owns, so the row pins nothing about behaviour: a server could redefine an enabled read-only list_tasks into something that writes, and Maven would keep calling it with no confirm turn and no second approval. Discovery now stores a fingerprint of the declared shape, name, description, input schema and readOnlyHint, and compares it on every refresh. A mismatch drops the row back to proposed and, if it stopped claiming read-only, marks it destructive. destructive is only ever raised. A row predating the column adopts its fingerprint silently, because an upgrade is not a redefinition. Nothing retracted a proposal either, so a tool a connected server no longer offers stayed enabled and failed at call time with an internal string. Those rows are withdrawn, with provenance saying why, and only for servers that are actually connected so a restart does not disarm what he approved. Argument binding rested on readOnlyHint, which the same server writes. A server advertising delete_project as read-only got an unconfirmed argument-carrying call. Binding now also requires the tool be named in allow_tools, something local, and refuses a required property the schema never describes rather than guessing it is a string. wireMCP dialled synchronously from run, and on the passkey path from inside the unlock handler, so one black-holed endpoint delayed boot and the answer to an unlock. The first dial happens on the refresh goroutine under the daemon context. Two servers whose names flatten to one local allowlist name no longer share a row. Found in review of #71.
335 lines
12 KiB
Go
335 lines
12 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// Tool — one act in the allowlist. Scope namespaces tools (e.g. "homelab").
|
|
// Cmd is the fixed argv prefix run with the utterance's args appended (no
|
|
// shell). Status 'proposed' is a scaffold that drives nothing; 'enabled' is
|
|
// the human-flipped, runnable form.
|
|
type Tool struct {
|
|
Name string
|
|
Scope string
|
|
Cmd []string
|
|
Destructive bool
|
|
Status string // proposed | enabled
|
|
Utterance string // provenance: the utterance that scaffolded a proposal
|
|
CreatedTs time.Time
|
|
UpdatedTs time.Time
|
|
}
|
|
|
|
var (
|
|
// ErrToolNotFound — no tool row with this name.
|
|
ErrToolNotFound = errors.New("store: tool not found")
|
|
// ErrToolCmd — an enable supplied an empty argv (an enabled tool must run something).
|
|
ErrToolCmd = errors.New("store: enabled tool needs a non-empty cmd")
|
|
)
|
|
|
|
// ProposeTool inserts a 'proposed' scaffold for name (provenance = utterance)
|
|
// if no row for name exists yet. Returns true when a new proposal was written,
|
|
// false when a row (proposed or enabled) already existed. maven calls this when
|
|
// she classifies an act whose verb isn't on the enabled allowlist — she drafts
|
|
// the registration; a human enables it. Never overwrites an enabled tool.
|
|
// scope defaults to "homelab" when empty.
|
|
func (s *Store) ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error) {
|
|
if scope == "" {
|
|
scope = "homelab"
|
|
}
|
|
res, err := s.db.ExecContext(ctx, `
|
|
INSERT INTO tools (name, scope, cmd, destructive, status, utterance, created_ts, updated_ts)
|
|
VALUES (?, ?, '[]', 0, 'proposed', ?, ?, ?)
|
|
ON CONFLICT(name) DO NOTHING`,
|
|
name, scope, utterance, ts.UnixMilli(), ts.UnixMilli())
|
|
if err != nil {
|
|
return false, fmt.Errorf("propose tool: %w", err)
|
|
}
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return false, fmt.Errorf("propose tool: rows affected: %w", err)
|
|
}
|
|
return n > 0, nil
|
|
}
|
|
|
|
// ProposeMCPTool is ProposeTool for a tool discovered on an MCP server
|
|
// (Vikunja #251): the proposal already knows what it would run, so cmd and
|
|
// destructive are written with it and Kami only has to press enable.
|
|
//
|
|
// It is still a PROPOSAL. Discovery cannot grant a capability — that is the
|
|
// whole reason a server can be configured without its tools becoming live.
|
|
// Like ProposeTool it never touches an existing row, so re-discovery on every
|
|
// restart is idempotent and cannot silently re-arm a tool that was disabled or
|
|
// change the cmd of one already enabled.
|
|
//
|
|
// The row is NOT what protects him, and it is worth being exact about that.
|
|
// cmd is ["mcp", server, tool]: a late-bound reference to a name the remote
|
|
// server owns. The tool it points at can be redefined on the far end without
|
|
// the row changing at all, so "the cmd cannot change" is true and beside the
|
|
// point. fingerprint is what closes that: it records the declared shape (name,
|
|
// description, input schema, readOnlyHint) at the time the proposal was
|
|
// written, and ReconcileMCPTool compares against it on every later discovery.
|
|
// Pass "" for a row with nothing to fingerprint (a Home Assistant device).
|
|
func (s *Store) ProposeMCPTool(ctx context.Context, name, scope string, cmd []string, destructive bool, utterance, fingerprint string, ts time.Time) (bool, error) {
|
|
if len(cmd) == 0 {
|
|
return false, ErrToolCmd
|
|
}
|
|
if scope == "" {
|
|
scope = "homelab"
|
|
}
|
|
raw, err := json.Marshal(cmd)
|
|
if err != nil {
|
|
return false, fmt.Errorf("propose mcp tool: %w", err)
|
|
}
|
|
d := 0
|
|
if destructive {
|
|
d = 1
|
|
}
|
|
res, err := s.db.ExecContext(ctx, `
|
|
INSERT INTO tools (name, scope, cmd, destructive, status, utterance, fingerprint, created_ts, updated_ts)
|
|
VALUES (?, ?, ?, ?, 'proposed', ?, ?, ?, ?)
|
|
ON CONFLICT(name) DO NOTHING`,
|
|
name, scope, string(raw), d, utterance, fingerprint, ts.UnixMilli(), ts.UnixMilli())
|
|
if err != nil {
|
|
return false, fmt.Errorf("propose mcp tool: %w", err)
|
|
}
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return false, fmt.Errorf("propose mcp tool: rows affected: %w", err)
|
|
}
|
|
return n > 0, nil
|
|
}
|
|
|
|
// ProposeSmartHomeTool is ProposeTool for a controllable device discovered on
|
|
// the Home Assistant instance (Vikunja #256). Like ProposeMCPTool the proposal
|
|
// already knows what it would run, so cmd is written with it and Kami only has
|
|
// to press enable.
|
|
//
|
|
// It is still a PROPOSAL, and destructive is not a parameter: there is no
|
|
// read-only way to turn a lamp off, so every house row carries the confirm
|
|
// turn. Re-discovery on every refresh is idempotent — an existing row is never
|
|
// touched, so a device he disabled stays disabled.
|
|
func (s *Store) ProposeSmartHomeTool(ctx context.Context, name, scope string, cmd []string, utterance string, ts time.Time) (bool, error) {
|
|
return s.ProposeMCPTool(ctx, name, scope, cmd, true, utterance, "", ts)
|
|
}
|
|
|
|
// ToolChange — what ReconcileMCPTool did to an existing row.
|
|
type ToolChange struct {
|
|
// Changed — the discovered shape differs from the approved one.
|
|
Changed bool
|
|
// Demoted — the row was enabled and is now 'proposed' again, so the
|
|
// capability is off until a human looks at it a second time.
|
|
Demoted bool
|
|
// Escalated — destructive went from 0 to 1. It never goes the other way.
|
|
Escalated bool
|
|
}
|
|
|
|
// ReconcileMCPTool compares a freshly discovered tool against the row that was
|
|
// approved, and escalates when they disagree.
|
|
//
|
|
// The failure this exists for: day 1 the server offers list_tasks with
|
|
// readOnlyHint true, so the row is proposed non-destructive and Kami enables
|
|
// it. Day 30 the server is upgraded, or taken over, and list_tasks now writes.
|
|
// Insert-or-skip does nothing on that discovery — the row is still enabled,
|
|
// still destructive=0 — and the confirm turn never fires, because the flag was
|
|
// frozen against a claim the server has since withdrawn.
|
|
//
|
|
// So: a differing fingerprint drops the row back to 'proposed' and rewrites the
|
|
// provenance, and a tool that stopped claiming read-only gets destructive=1.
|
|
// destructive is only ever raised, never lowered: relaxing it on the say-so of
|
|
// the same server that changed underneath us would undo the point.
|
|
//
|
|
// A row with an empty stored fingerprint predates this and simply adopts the
|
|
// discovered one — an upgrade is not a redefinition.
|
|
func (s *Store) ReconcileMCPTool(ctx context.Context, name, fingerprint string, destructive bool, utterance string, ts time.Time) (ToolChange, error) {
|
|
var (
|
|
stored string
|
|
status string
|
|
wasDest int
|
|
)
|
|
err := s.db.QueryRowContext(ctx,
|
|
`SELECT fingerprint, status, destructive FROM tools WHERE name = ?`, name).
|
|
Scan(&stored, &status, &wasDest)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return ToolChange{}, ErrToolNotFound
|
|
}
|
|
if err != nil {
|
|
return ToolChange{}, fmt.Errorf("reconcile mcp tool: %w", err)
|
|
}
|
|
var ch ToolChange
|
|
if stored == "" {
|
|
if _, err := s.db.ExecContext(ctx,
|
|
`UPDATE tools SET fingerprint = ?, updated_ts = ? WHERE name = ?`,
|
|
fingerprint, ts.UnixMilli(), name); err != nil {
|
|
return ToolChange{}, fmt.Errorf("reconcile mcp tool: %w", err)
|
|
}
|
|
return ch, nil
|
|
}
|
|
if stored == fingerprint {
|
|
return ch, nil
|
|
}
|
|
ch.Changed = true
|
|
ch.Demoted = status == "enabled"
|
|
d := wasDest
|
|
if destructive && wasDest == 0 {
|
|
d, ch.Escalated = 1, true
|
|
}
|
|
if _, err := s.db.ExecContext(ctx, `
|
|
UPDATE tools
|
|
SET fingerprint = ?, destructive = ?, status = 'proposed', utterance = ?, updated_ts = ?
|
|
WHERE name = ?`,
|
|
fingerprint, d, utterance, ts.UnixMilli(), name); err != nil {
|
|
return ToolChange{}, fmt.Errorf("reconcile mcp tool: %w", err)
|
|
}
|
|
return ch, nil
|
|
}
|
|
|
|
// WithdrawTool disarms a row whose remote tool no longer exists: it drops back
|
|
// to 'proposed' and its provenance says why.
|
|
//
|
|
// Nothing else retracted a proposal, so a tool a server stopped offering kept
|
|
// its row forever, and an ENABLED one stayed enabled and failed at call time
|
|
// with an internal string the act path does not match. /tools is where he would
|
|
// go to find out and it was the one place that did not say. Returns whether the
|
|
// row was still enabled.
|
|
func (s *Store) WithdrawTool(ctx context.Context, name, utterance string, ts time.Time) (bool, error) {
|
|
res, err := s.db.ExecContext(ctx, `
|
|
UPDATE tools SET status = 'proposed', utterance = ?, updated_ts = ?
|
|
WHERE name = ? AND status = 'enabled'`,
|
|
utterance, ts.UnixMilli(), name)
|
|
if err != nil {
|
|
return false, fmt.Errorf("withdraw tool: %w", err)
|
|
}
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return false, fmt.Errorf("withdraw tool: rows affected: %w", err)
|
|
}
|
|
if n > 0 {
|
|
return true, nil
|
|
}
|
|
// Not enabled: still refresh the provenance so the proposed row says it.
|
|
_, err = s.db.ExecContext(ctx,
|
|
`UPDATE tools SET utterance = ?, updated_ts = ? WHERE name = ?`,
|
|
utterance, ts.UnixMilli(), name)
|
|
if err != nil {
|
|
return false, fmt.Errorf("withdraw tool: %w", err)
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// EnableTool fills cmd + destructive and flips status to 'enabled'. This is the
|
|
// human "enable" act (the authed surface calls it); it upserts so enabling a
|
|
// name that was never proposed still works. An empty cmd is refused — an
|
|
// enabled tool that runs nothing is a footgun, not a tool.
|
|
// scope defaults to "homelab" when empty.
|
|
func (s *Store) EnableTool(ctx context.Context, name string, cmd []string, destructive bool, scope string, ts time.Time) error {
|
|
if len(cmd) == 0 {
|
|
return ErrToolCmd
|
|
}
|
|
if scope == "" {
|
|
scope = "homelab"
|
|
}
|
|
raw, err := json.Marshal(cmd)
|
|
if err != nil {
|
|
return fmt.Errorf("enable tool: %w", err)
|
|
}
|
|
d := 0
|
|
if destructive {
|
|
d = 1
|
|
}
|
|
_, err = s.db.ExecContext(ctx, `
|
|
INSERT INTO tools (name, scope, cmd, destructive, status, utterance, created_ts, updated_ts)
|
|
VALUES (?, ?, ?, ?, 'enabled', '', ?, ?)
|
|
ON CONFLICT(name) DO UPDATE SET scope=excluded.scope, cmd=excluded.cmd, destructive=excluded.destructive,
|
|
status='enabled', updated_ts=excluded.updated_ts`,
|
|
name, scope, string(raw), d, ts.UnixMilli(), ts.UnixMilli())
|
|
if err != nil {
|
|
return fmt.Errorf("enable tool: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteTool permanently removes a tool row. Used for "dismiss" on proposed
|
|
// tools — there's no dismissed status, the proposal is simply gone and maven
|
|
// can re-propose it later if the same gap is encountered. Idempotent: deleting
|
|
// a tool that doesn't exist is a no-op.
|
|
func (s *Store) DeleteTool(ctx context.Context, name string) error {
|
|
_, err := s.db.ExecContext(ctx, `DELETE FROM tools WHERE name = ?`, name)
|
|
return err
|
|
}
|
|
|
|
// DisableTool sets a tool's status from 'enabled' back to 'proposed'. This is
|
|
// the "disable" act on the authed surface — the tool stays in the store (its
|
|
// provenance preserved) but won't run until re-enabled. Idempotent: disabling
|
|
// a tool that is already proposed or doesn't exist is a no-op.
|
|
func (s *Store) DisableTool(ctx context.Context, name string) error {
|
|
_, err := s.db.ExecContext(ctx,
|
|
`UPDATE tools SET status = 'proposed', updated_ts = ? WHERE name = ? AND status = 'enabled'`,
|
|
time.Now().UnixMilli(), name)
|
|
if err != nil {
|
|
return fmt.Errorf("disable tool: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// LookupTool returns the tool by name. ErrToolNotFound when absent.
|
|
func (s *Store) LookupTool(ctx context.Context, name string) (Tool, error) {
|
|
row := s.db.QueryRowContext(ctx, `
|
|
SELECT name, scope, cmd, destructive, status, utterance, created_ts, updated_ts
|
|
FROM tools WHERE name = ?`, name)
|
|
t, err := scanTool(row)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Tool{}, ErrToolNotFound
|
|
}
|
|
return t, err
|
|
}
|
|
|
|
// ListTools returns tools filtered by status ("" ⇒ all), name-sorted.
|
|
func (s *Store) ListTools(ctx context.Context, status string) ([]Tool, error) {
|
|
q := `SELECT name, scope, cmd, destructive, status, utterance, created_ts, updated_ts FROM tools`
|
|
var args []any
|
|
if status != "" {
|
|
q += ` WHERE status = ?`
|
|
args = append(args, status)
|
|
}
|
|
q += ` ORDER BY name ASC`
|
|
rows, err := s.db.QueryContext(ctx, q, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list tools: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []Tool
|
|
for rows.Next() {
|
|
t, err := scanTool(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, t)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// scanner is the shared shape of *sql.Row and *sql.Rows.
|
|
type scanner interface{ Scan(...any) error }
|
|
|
|
func scanTool(sc scanner) (Tool, error) {
|
|
var t Tool
|
|
var cmdJSON string
|
|
var d int
|
|
var created, updated int64
|
|
if err := sc.Scan(&t.Name, &t.Scope, &cmdJSON, &d, &t.Status, &t.Utterance, &created, &updated); err != nil {
|
|
return Tool{}, err
|
|
}
|
|
if err := json.Unmarshal([]byte(cmdJSON), &t.Cmd); err != nil {
|
|
return Tool{}, fmt.Errorf("scan tool %q cmd: %w", t.Name, err)
|
|
}
|
|
t.Destructive = d != 0
|
|
t.CreatedTs = time.UnixMilli(created).UTC()
|
|
t.UpdatedTs = time.UnixMilli(updated).UTC()
|
|
return t, nil
|
|
}
|