- ApprovalService (core/app/approval.go) + ApprovalRepo (postgres adapter) with
full decide transaction: HMAC token verify, approval flip, execution un-gate,
session-scoped window keys (+session suffix matching GovernanceStore gate),
nomos session flip, audit+event on failure abort. httpapi DecideApproval now
a thin presenter delegating to the service. ListPending payload format fixed
(json.Unmarshal not raw-wrap).
- execlog folded into postgres adapter: internal/execlog deleted, NewExecutionLog
/ ReadExecutionLog live in the db package, callers updated (mcp, httpapi).
- execworker poller over ExecutionService.DispatchQueued: advisory lock leak
fixed (defer/recover per execution), correlation_id preserved via Finalize
event emission (ExecRunRepo.Finalize now emits execution.{status} with
correlation_id from the row).
- Phase 8 session export-rename completed: Store, New, and all 53 methods
exported; cmd/nomos/ agent.go fixed to use session.PendingContinuation etc.
- Coverage gates: ExecutionService.Submit 93.1%, PolicyService.Decide 100%.
- Plans index updated, VERSION bumped to 0.36.0.
921 lines
33 KiB
Go
921 lines
33 KiB
Go
// Package mcp implements the Oikos MCP interface (plan R3-10).
|
|
// Uses the official MCP Go SDK with Streamable HTTP transport.
|
|
package mcp
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html"
|
|
"io"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/dtoro/oikos/internal/actuator"
|
|
"github.com/dtoro/oikos/internal/adapters/postgres"
|
|
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
|
|
"github.com/dtoro/oikos/internal/core/app"
|
|
"github.com/dtoro/oikos/internal/core/domain"
|
|
"github.com/dtoro/oikos/internal/core/ports"
|
|
"github.com/dtoro/oikos/internal/observability"
|
|
"github.com/dtoro/oikos/internal/policy"
|
|
"github.com/dtoro/oikos/internal/remote"
|
|
"github.com/google/jsonschema-go/jsonschema"
|
|
"github.com/google/uuid"
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
"golang.org/x/crypto/ssh"
|
|
)
|
|
|
|
// prop is one input-schema property (name → type + description).
|
|
type prop struct {
|
|
name, typ, desc string
|
|
}
|
|
|
|
// objSchema builds an "object" JSON Schema from a list of properties. The
|
|
// MCP SDK requires every tool to declare an object input schema so tools
|
|
// are self-describing to the agent; a nil schema panics at registration.
|
|
func objSchema(props ...prop) *jsonschema.Schema {
|
|
s := &jsonschema.Schema{Type: "object", Properties: map[string]*jsonschema.Schema{}}
|
|
for _, p := range props {
|
|
s.Properties[p.name] = &jsonschema.Schema{Type: p.typ, Description: p.desc}
|
|
}
|
|
return s
|
|
}
|
|
|
|
// NewHandler creates an http.Handler that serves the Oikos MCP server.
|
|
// agentID is the Nomos agent entity UUID; tool calls are logged to agent_activity.
|
|
func NewHandler(pool *db.Pool, token string, agentID uuid.UUID, sec ports.Secrets, entities *app.EntityService, relService *app.RelationshipService, execSvc *app.ExecutionService) http.Handler {
|
|
s := newServer(pool, agentID, sec, entities, relService, execSvc)
|
|
handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
|
|
if token != "" {
|
|
if r.Header.Get("Authorization") != "Bearer "+token {
|
|
return nil
|
|
}
|
|
}
|
|
return s
|
|
}, nil)
|
|
return handler
|
|
}
|
|
|
|
// toolHandler is the function signature registered via AddTool.
|
|
type toolHandler = mcp.ToolHandler
|
|
|
|
func newServer(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets, entities *app.EntityService, relService *app.RelationshipService, execSvc *app.ExecutionService) *mcp.Server {
|
|
s := mcp.NewServer(&mcp.Implementation{Name: "oikos", Version: "dev"}, &mcp.ServerOptions{
|
|
Logger: slog.Default(),
|
|
})
|
|
for _, t := range allTools(pool, agentID, sec, entities, relService, execSvc) {
|
|
s.AddTool(t.tool, withActivityLogging(pool, agentID, t.tool.Name, t.handler))
|
|
}
|
|
|
|
// Resource templates: let MCP clients browse and attach entities,
|
|
// knowledge entries, and executions as conversation resources.
|
|
s.AddResourceTemplate(&mcp.ResourceTemplate{
|
|
URITemplate: "oikos://entity/{slug}",
|
|
Name: "Entity",
|
|
Description: "Oikos entity by slug (e.g. host:hubris, lxc:jellyfin)",
|
|
MIMEType: "application/json",
|
|
}, resourceHandler(pool, func(ctx context.Context, matches map[string]string) (string, error) {
|
|
slug := matches["slug"]
|
|
var id uuid.UUID
|
|
if u, err := uuid.Parse(slug); err == nil {
|
|
id = u
|
|
} else {
|
|
pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", slug).Scan(&id)
|
|
}
|
|
if id == uuid.Nil {
|
|
return "", fmt.Errorf("entity not found: %s", slug)
|
|
}
|
|
result := queryEntity(ctx, pool, slug)
|
|
return result.Content[0].(*mcp.TextContent).Text, nil
|
|
}))
|
|
|
|
s.AddResourceTemplate(&mcp.ResourceTemplate{
|
|
URITemplate: "oikos://knowledge/{id}",
|
|
Name: "Knowledge",
|
|
Description: "Knowledge entry by entity slug or UUID",
|
|
MIMEType: "application/json",
|
|
}, resourceHandler(pool, func(ctx context.Context, matches map[string]string) (string, error) {
|
|
idOrSlug := matches["id"]
|
|
var entityID uuid.UUID
|
|
if u, err := uuid.Parse(idOrSlug); err == nil {
|
|
entityID = u
|
|
} else {
|
|
pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", idOrSlug).Scan(&entityID)
|
|
}
|
|
if entityID == uuid.Nil {
|
|
return "", fmt.Errorf("knowledge not found: %s", idOrSlug)
|
|
}
|
|
result := queryRows(ctx, pool, `
|
|
SELECT ke.title, ke.content, ke.tags::text, e.slug, e.type AS kind,
|
|
ke.updated_at::text
|
|
FROM knowledge_entities ke
|
|
JOIN entities e ON e.id = ke.entity_id
|
|
WHERE ke.entity_id = $1`, entityID)
|
|
return result.Content[0].(*mcp.TextContent).Text, nil
|
|
}))
|
|
|
|
s.AddResourceTemplate(&mcp.ResourceTemplate{
|
|
URITemplate: "oikos://execution/{id}",
|
|
Name: "Execution",
|
|
Description: "Execution by UUID (returns status, result, timing)",
|
|
MIMEType: "application/json",
|
|
}, resourceHandler(pool, func(ctx context.Context, matches map[string]string) (string, error) {
|
|
result := queryRows(ctx, pool, `
|
|
SELECT e.entity_id, te.slug AS target, e.action, e.risk_class,
|
|
e.status, e.result::text, e.duration_ms,
|
|
e.started_at::text, e.completed_at::text
|
|
FROM executions e
|
|
JOIN entities te ON te.id = e.target_entity_id
|
|
WHERE e.entity_id = $1`, matches["id"])
|
|
return result.Content[0].(*mcp.TextContent).Text, nil
|
|
}))
|
|
|
|
return s
|
|
}
|
|
|
|
// resourceHandler adapts a simple func(ctx, params) → (string, error) into
|
|
// an MCP ResourceHandler, reading the URI matched by a ResourceTemplate.
|
|
func resourceHandler(pool *db.Pool, fn func(ctx context.Context, matches map[string]string) (string, error)) mcp.ResourceHandler {
|
|
return func(ctx context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) {
|
|
uri := req.Params.URI
|
|
matches := matchURITemplate(uri)
|
|
if matches == nil {
|
|
return nil, mcp.ResourceNotFoundError(uri)
|
|
}
|
|
|
|
text, err := fn(ctx, matches)
|
|
if err != nil {
|
|
return nil, mcp.ResourceNotFoundError(uri)
|
|
}
|
|
|
|
result, err := json.MarshalIndent(json.RawMessage(text), "", " ")
|
|
if err != nil {
|
|
result = []byte(text)
|
|
}
|
|
|
|
return &mcp.ReadResourceResult{
|
|
Contents: []*mcp.ResourceContents{{
|
|
URI: uri,
|
|
MIMEType: "application/json",
|
|
Text: string(result),
|
|
}},
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
// matchURITemplate extracts parameters from a URI that matches one of the
|
|
// oikos:// resource templates. Returns nil if the URI doesn't match.
|
|
func matchURITemplate(uri string) map[string]string {
|
|
// oikos://entity/{slug}
|
|
if rest, ok := strings.CutPrefix(uri, "oikos://entity/"); ok && rest != "" {
|
|
return map[string]string{"slug": rest}
|
|
}
|
|
// oikos://knowledge/{id}
|
|
if rest, ok := strings.CutPrefix(uri, "oikos://knowledge/"); ok && rest != "" {
|
|
return map[string]string{"id": rest}
|
|
}
|
|
// oikos://execution/{id}
|
|
if rest, ok := strings.CutPrefix(uri, "oikos://execution/"); ok && rest != "" {
|
|
return map[string]string{"id": rest}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// withActivityLogging wraps a tool handler to record agent_activity rows.
|
|
func withActivityLogging(pool *db.Pool, agentID uuid.UUID, toolName string, next mcp.ToolHandler) mcp.ToolHandler {
|
|
if agentID == uuid.Nil {
|
|
return next
|
|
}
|
|
return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
start := time.Now()
|
|
result, err := next(ctx, req)
|
|
duration := int(time.Since(start).Milliseconds())
|
|
|
|
// Build input summary (first 500 chars of args)
|
|
inputSummary := ""
|
|
if req != nil && len(req.Params.Arguments) > 0 {
|
|
inputSummary = string(req.Params.Arguments)
|
|
}
|
|
if len(inputSummary) > 500 {
|
|
inputSummary = inputSummary[:500]
|
|
}
|
|
|
|
// Build output summary
|
|
outputSummary := ""
|
|
success := err == nil
|
|
if result != nil {
|
|
for _, c := range result.Content {
|
|
if tc, ok := c.(*mcp.TextContent); ok {
|
|
outputSummary = tc.Text
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if err != nil {
|
|
outputSummary = err.Error()
|
|
success = false
|
|
}
|
|
if len(outputSummary) > 500 {
|
|
outputSummary = outputSummary[:500]
|
|
}
|
|
|
|
correlationID := uuid.New().String()
|
|
|
|
entityID := resolveArgEntityID(ctx, pool, argsMap(req))
|
|
var entityIDArg any
|
|
if entityID != uuid.Nil {
|
|
entityIDArg = entityID
|
|
}
|
|
|
|
_, logErr := pool.Exec(ctx, `
|
|
INSERT INTO agent_activity
|
|
(agent_id, activity_type, tool_name, entity_id, input_summary, output_summary,
|
|
duration_ms, success, correlation_id)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
|
agentID, "tool_call", toolName, entityIDArg, inputSummary, outputSummary,
|
|
duration, success, correlationID)
|
|
if logErr != nil {
|
|
slog.Warn("mcp: log agent_activity", "error", logErr)
|
|
}
|
|
|
|
return result, err
|
|
}
|
|
}
|
|
|
|
// entityArgKeys lists tool-argument keys, in priority order, that commonly
|
|
// carry the target entity's slug or UUID. Tool input schemas aren't
|
|
// consistent about naming this (target, entity_slug, slug, service_slug,
|
|
// lxc_slug, entity_id all appear across server.go's tool registrations), so
|
|
// this is a best-effort lookup used to tag agent_activity rows with the
|
|
// entity a tool call acted on.
|
|
var entityArgKeys = []string{
|
|
"target", "entity_slug", "slug", "slug_or_id",
|
|
"service_slug", "lxc_slug", "entity_id", "about",
|
|
}
|
|
|
|
// resolveArgEntityID best-effort resolves the entity a tool call acted on
|
|
// from its arguments, trying entityArgKeys in order. Returns uuid.Nil if no
|
|
// key is present or none resolves to a known entity.
|
|
func resolveArgEntityID(ctx context.Context, pool *db.Pool, args map[string]any) uuid.UUID {
|
|
for _, key := range entityArgKeys {
|
|
v, _ := args[key].(string)
|
|
if v == "" {
|
|
continue
|
|
}
|
|
if u, err := uuid.Parse(v); err == nil {
|
|
return u
|
|
}
|
|
var id uuid.UUID
|
|
if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", v).Scan(&id); err == nil {
|
|
return id
|
|
}
|
|
}
|
|
return uuid.Nil
|
|
}
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────
|
|
|
|
func argsMap(req *mcp.CallToolRequest) map[string]any {
|
|
if req == nil || len(req.Params.Arguments) == 0 {
|
|
return nil
|
|
}
|
|
var m map[string]any
|
|
json.Unmarshal(req.Params.Arguments, &m)
|
|
return m
|
|
}
|
|
|
|
// slugArg returns the first non-empty string value among the given keys.
|
|
// Tools historically used inconsistent param names for "the entity slug"
|
|
// (slug_or_id, entity_id, service_slug, lxc_slug, target). This lets a single
|
|
// handler accept any of them, so an agent guessing "slug" still works.
|
|
func slugArg(m map[string]any, keys ...string) string {
|
|
if m == nil {
|
|
return ""
|
|
}
|
|
for _, k := range keys {
|
|
if s, ok := m[k].(string); ok && strings.TrimSpace(s) != "" {
|
|
return strings.TrimSpace(s)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func getFloat(m map[string]any, key string, def float64) float64 {
|
|
if m == nil {
|
|
return def
|
|
}
|
|
switch v := m[key].(type) {
|
|
case float64:
|
|
return v
|
|
case int:
|
|
return float64(v)
|
|
case json.Number:
|
|
f, err := v.Float64()
|
|
if err != nil {
|
|
return def
|
|
}
|
|
return f
|
|
}
|
|
return def
|
|
}
|
|
|
|
func nStr(v any) any {
|
|
if v == nil {
|
|
return nil
|
|
}
|
|
s, _ := v.(string)
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
return s
|
|
}
|
|
|
|
func textResult(s string) *mcp.CallToolResult {
|
|
return &mcp.CallToolResult{
|
|
Content: []mcp.Content{&mcp.TextContent{Text: s}},
|
|
}
|
|
}
|
|
|
|
// jsonOut builds a valid {"output": "..."} JSON payload for an execution's
|
|
// result column. Command output contains quotes/backslashes/control chars, so
|
|
// it must be JSON-marshaled — a hand-built string fails the ::jsonb cast and
|
|
// silently drops the status update, leaving the execution stuck.
|
|
func jsonOut(out string) []byte {
|
|
b, _ := json.Marshal(map[string]any{"output": out})
|
|
return b
|
|
}
|
|
|
|
// jsonErr builds a valid {"error": "..."} JSON payload for an execution's
|
|
// result column — same rationale as jsonOut, for the failure path.
|
|
func jsonErr(format string, args ...any) []byte {
|
|
b, _ := json.Marshal(map[string]any{"error": fmt.Sprintf(format, args...)})
|
|
return b
|
|
}
|
|
|
|
func queryEntity(ctx context.Context, pool *db.Pool, idOrSlug string) *mcp.CallToolResult {
|
|
if strings.TrimSpace(idOrSlug) == "" {
|
|
return textResult("slug_or_id is required — expected an entity slug (e.g. lxc:seanime) or UUID")
|
|
}
|
|
var id uuid.UUID
|
|
if u, err := uuid.Parse(idOrSlug); err == nil {
|
|
id = u
|
|
} else {
|
|
pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", idOrSlug).Scan(&id)
|
|
}
|
|
if id == uuid.Nil {
|
|
return textResult(fmt.Sprintf("entity not found: %s", idOrSlug))
|
|
}
|
|
return queryRows(ctx, pool, `
|
|
SELECT slug, type, name, state, attributes,
|
|
maintenance_until::text, version, created_at, updated_at
|
|
FROM entities WHERE id = $1`, id)
|
|
}
|
|
|
|
func queryRows(ctx context.Context, pool *db.Pool, query string, args ...any) *mcp.CallToolResult {
|
|
rows, err := pool.Query(ctx, query, args...)
|
|
if err != nil {
|
|
return textResult(fmt.Sprintf("error: %v", err))
|
|
}
|
|
defer rows.Close()
|
|
|
|
cols := rows.FieldDescriptions()
|
|
var items []map[string]any
|
|
items = make([]map[string]any, 0)
|
|
for rows.Next() {
|
|
vals, err := rows.Values()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
m := make(map[string]any)
|
|
for i, col := range cols {
|
|
m[string(col.Name)] = fmt.Sprintf("%v", vals[i])
|
|
}
|
|
items = append(items, m)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return textResult(fmt.Sprintf("error: %v", err))
|
|
}
|
|
|
|
data, _ := json.MarshalIndent(items, "", " ")
|
|
return textResult(string(data))
|
|
}
|
|
|
|
func annotateJSONResult(result *mcp.CallToolResult, rendererID string) *mcp.CallToolResult {
|
|
if len(result.Content) == 0 {
|
|
return result
|
|
}
|
|
tc, ok := result.Content[0].(*mcp.TextContent)
|
|
if !ok || tc.Text == "" {
|
|
return result
|
|
}
|
|
var items []map[string]any
|
|
if err := json.Unmarshal([]byte(tc.Text), &items); err != nil {
|
|
return result
|
|
}
|
|
wrapper := map[string]any{
|
|
"__renderer": rendererID,
|
|
"data": items,
|
|
}
|
|
data, _ := json.MarshalIndent(wrapper, "", " ")
|
|
return textResult(string(data))
|
|
}
|
|
|
|
// ─── SSH helpers ─────────────────────────────────────────────────────────
|
|
|
|
var (
|
|
sshUser string
|
|
sshKey []byte
|
|
sshPool = make(map[string]*ssh.Client)
|
|
sshPoolMu sync.Mutex
|
|
)
|
|
|
|
func initSSH() {
|
|
if sshUser == "" {
|
|
sshUser = os.Getenv("OIKOS_SSH_USER")
|
|
if sshUser == "" {
|
|
sshUser = "root"
|
|
}
|
|
}
|
|
keyPath := os.Getenv("OIKOS_SSH_KEY_PATH")
|
|
if keyPath == "" {
|
|
keyPath = "/etc/oikos/ssh_key"
|
|
}
|
|
if len(sshKey) == 0 {
|
|
var err error
|
|
sshKey, err = os.ReadFile(keyPath)
|
|
if err != nil {
|
|
slog.Warn("mcp ssh: cannot read key", "path", keyPath, "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// sshExecTimeout bounds how long a single remote command may run — see the
|
|
// matching constant/comment in httpapi/phase3.go. Without it, a hung remote
|
|
// command (piped install script stuck retrying DNS, etc.) blocks this
|
|
// goroutine forever with no way for the caller to ever get an answer.
|
|
const sshExecTimeout = 10 * time.Minute
|
|
|
|
func sshExec(ctx context.Context, host, user, command string) (string, error) {
|
|
return sshExecStream(ctx, host, user, command, nil)
|
|
}
|
|
|
|
// sshExecStream runs a command and reports its combined output, forwarding
|
|
// each chunk to sink as it arrives. A nil sink behaves exactly as before.
|
|
func sshExecStream(ctx context.Context, host, user, command string, sink db.ExecLogSink) (string, error) {
|
|
initSSH()
|
|
if len(sshKey) == 0 {
|
|
return "", fmt.Errorf("no SSH key available")
|
|
}
|
|
if user == "" {
|
|
user = sshUser
|
|
}
|
|
|
|
signer, err := actuator.LoadSignerFromBytes(sshKey)
|
|
if err != nil {
|
|
return "", fmt.Errorf("parse key: %w", err)
|
|
}
|
|
|
|
client, err := actuator.Dial(ctx, actuator.DialOptions{Host: host, User: user, Signer: signer})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer client.Close()
|
|
|
|
return actuator.RunStreaming(ctx, client, command, sink, sshExecTimeout)
|
|
}
|
|
|
|
// resolveHost resolves a host:<slug> to its reachable IP and SSH user. A thin
|
|
// wrapper over the shared resolver (internal/remote), kept so slug-based
|
|
// callers keep working; the shared resolver also prefers public_ipv4 over
|
|
// mesh and honors a per-entity ssh.user.
|
|
func resolveHost(ctx context.Context, pool *db.Pool, entitySlug string) (hostIP string, sshUserOut string, err error) {
|
|
return remote.ResolveHost(ctx, pool, entitySlug, sshUser)
|
|
}
|
|
|
|
// htmlTagRe strips HTML tags for the naive text extraction in httpGet.
|
|
var htmlTagRe = regexp.MustCompile(`(?s)<(script|style)[^>]*>.*?</(script|style)>|<[^>]+>`)
|
|
|
|
// httpGet fetches a public URL and returns sanitized, size-capped text so the
|
|
// agent can read a service's README/site before provisioning. Guards: scheme
|
|
// allow-list, request timeout, 16KB body cap, and blocking of RFC1918/loopback
|
|
// hosts to avoid using the tool as an SSRF pivot into the private mesh.
|
|
func httpGet(ctx context.Context, rawURL string) *mcp.CallToolResult {
|
|
if rawURL == "" {
|
|
return textResult("error: url required")
|
|
}
|
|
u, err := url.Parse(strings.TrimSpace(rawURL))
|
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
|
|
return textResult("error: url must be an absolute http(s) URL")
|
|
}
|
|
if isPrivateHost(u.Hostname()) {
|
|
return textResult("error: refusing to fetch private/loopback address")
|
|
}
|
|
|
|
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
|
defer cancel()
|
|
hreq, err := http.NewRequestWithContext(cctx, http.MethodGet, u.String(), nil)
|
|
if err != nil {
|
|
return textResult(fmt.Sprintf("error: %v", err))
|
|
}
|
|
hreq.Header.Set("User-Agent", "oikos-nomos/1.0 (+homelab agent)")
|
|
hreq.Header.Set("Accept", "text/plain, text/html, application/json;q=0.9, */*;q=0.5")
|
|
|
|
client := &http.Client{Timeout: 20 * time.Second}
|
|
resp, err := client.Do(hreq)
|
|
if err != nil {
|
|
return textResult(fmt.Sprintf("error: fetch failed: %v", err))
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
const cap = 256 * 1024 // read a bit extra pre-strip; final output capped below
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, cap))
|
|
ct := resp.Header.Get("Content-Type")
|
|
text := sanitizeBody(ct, string(body))
|
|
return textResult(fmt.Sprintf("GET %s → %d %s\n\n%s", u.String(), resp.StatusCode, ct, text))
|
|
}
|
|
|
|
// sanitizeBody strips scripts/styles/tags from HTML, unescapes entities,
|
|
// collapses whitespace, and caps the result to ~16KB of readable text.
|
|
func sanitizeBody(contentType, raw string) string {
|
|
text := raw
|
|
if strings.Contains(contentType, "html") {
|
|
text = htmlTagRe.ReplaceAllString(text, " ")
|
|
text = html.UnescapeString(text)
|
|
text = strings.Join(strings.Fields(text), " ")
|
|
}
|
|
if len(text) > 16*1024 {
|
|
text = text[:16*1024] + "\n…[truncated]"
|
|
}
|
|
return text
|
|
}
|
|
|
|
// isPrivateHost reports whether host is loopback, link-local, or RFC1918.
|
|
func isPrivateHost(host string) bool {
|
|
host = strings.ToLower(host)
|
|
if host == "localhost" || strings.HasSuffix(host, ".local") || strings.HasSuffix(host, ".internal") {
|
|
return true
|
|
}
|
|
ip := net.ParseIP(host)
|
|
if ip == nil {
|
|
return false // hostname; DNS may still resolve private — acceptable for a homelab tool
|
|
}
|
|
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsUnspecified()
|
|
}
|
|
|
|
// resolveExecTarget resolves any target slug (host:, lxc:, or vm:) to the SSH
|
|
// endpoint that will actually run the command, and a wrap function that turns
|
|
// a plain shell command into whatever must actually be sent over that SSH
|
|
// connection: identity for a host, `pct exec <pve_id> -- ...` for an LXC,
|
|
// `qm guest exec <pve_id> -- ...` for a VM.
|
|
//
|
|
// Delegates to the shared resolver (internal/remote), the single path used by
|
|
// both the MCP `run` tool and the scheduler's checks. The historical notes
|
|
// (host attr without prefix, vm host-resolution chain, nested-quoting
|
|
// handling via base64) all still hold — they now live in remote.guestWrap.
|
|
func resolveExecTarget(ctx context.Context, pool *db.Pool, targetSlug string) (host, user string, wrap func(cmd string) string, err error) {
|
|
et, err := remote.ResolveExecTarget(ctx, pool, targetSlug, sshUser)
|
|
if err != nil {
|
|
return "", "", nil, err
|
|
}
|
|
return et.Host, et.User, et.Wrap, nil
|
|
}
|
|
|
|
// resolveProxmoxHostSlug resolves the Proxmox host slug that owns a given
|
|
// LXC/VM target (see internal/remote.ResolveProxmoxHostSlug for the chain).
|
|
// This slug-based wrapper looks up the entity id so slug callers keep working;
|
|
// the shared resolver takes an id directly.
|
|
func resolveProxmoxHostSlug(ctx context.Context, pool *db.Pool, entitySlug, hostAttr string) string {
|
|
var id uuid.UUID
|
|
if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", entitySlug).Scan(&id); err != nil {
|
|
id = uuid.Nil
|
|
}
|
|
return remote.ResolveProxmoxHostSlug(ctx, pool, id, hostAttr)
|
|
}
|
|
|
|
// classifyAndGate is the shared classify→execute-or-queue path for every
|
|
// mutating command, used by both the general `run` tool and docker_exec.
|
|
// Since Phase 4 of the hexagonal refactor this is a thin rendering shell:
|
|
// the decision pipeline lives in app.PolicyService, the recording +
|
|
// dispatch in app.ExecutionService; this maps the result onto the
|
|
// agent-facing text.
|
|
func classifyAndGate(ctx context.Context, pool *db.Pool, execSvc *app.ExecutionService, agentID, targetID uuid.UUID, targetSlug, command, purpose, declaredRisk, sessionID string) *mcp.CallToolResult {
|
|
res := execSvc.Submit(ctx, app.ExecutionSubmitCmd{
|
|
AgentID: domain.UUID(agentID.String()),
|
|
TargetID: domain.UUID(targetID.String()),
|
|
TargetSlug: targetSlug,
|
|
Command: command,
|
|
Purpose: purpose,
|
|
DeclaredRisk: declaredRisk,
|
|
SessionID: sessionID,
|
|
Async: app.IsLongRunningCommand(command),
|
|
SinkFactory: func(ctx context.Context, execID domain.UUID, correlationID string) (func(string, []byte), func()) {
|
|
return db.NewExecutionLog(ctx, pool, uuid.MustParse(string(execID)), correlationID)
|
|
},
|
|
})
|
|
return renderSubmit(targetSlug, command, res)
|
|
}
|
|
|
|
// renderSubmit maps an ExecutionSubmitResult onto the agent-facing text,
|
|
// preserving the exact pre-service message shapes.
|
|
func renderSubmit(targetSlug, command string, res app.ExecutionSubmitResult) *mcp.CallToolResult {
|
|
d := res.Decision
|
|
switch d.Action {
|
|
case app.DecisionRefuse:
|
|
return textResult(d.Message)
|
|
|
|
case app.DecisionQueue:
|
|
confirmNote := ""
|
|
if d.RiskClass == policy.RiskDestructive {
|
|
confirmNote = " This is classified DESTRUCTIVE — flag that clearly to the operator; it needs explicit confirmation, not just a casual \"go ahead\"."
|
|
}
|
|
return textResult(fmt.Sprintf("run on %s requires approval (risk: %s) — execution %s queued.%s Present the command and purpose to the operator and wait; do not re-request.",
|
|
targetSlug, d.RiskClass, res.ExecutionID, confirmNote))
|
|
|
|
case app.DecisionAuto:
|
|
label := d.RiskClass
|
|
switch d.AutoViaWindow {
|
|
case "assent":
|
|
label = "config_mutation"
|
|
case "destructive":
|
|
label = "destructive"
|
|
}
|
|
if res.AsyncStarted {
|
|
via := ""
|
|
switch d.AutoViaWindow {
|
|
case "assent":
|
|
via = ", async via assent window"
|
|
case "destructive":
|
|
via = ", async via confirmed-target window"
|
|
default:
|
|
via = ", async"
|
|
}
|
|
return textResult(fmt.Sprintf("run on %s (%s%s): started — execution %s. Poll with get_execution_status(%s) for result.",
|
|
targetSlug, label, via, res.ExecutionID, res.ExecutionID))
|
|
}
|
|
if res.Err != nil {
|
|
return textResult(fmt.Sprintf("run on %s: ERROR %v\n%s", targetSlug, res.Err, res.Output))
|
|
}
|
|
via := ", auto"
|
|
switch d.AutoViaWindow {
|
|
case "assent":
|
|
via = ", auto via assent window"
|
|
case "destructive":
|
|
via = ", auto via confirmed-target window"
|
|
}
|
|
return textResult(fmt.Sprintf("run on %s (%s%s): %s", targetSlug, label, via, res.Output))
|
|
}
|
|
return textResult(fmt.Sprintf("run on %s: unknown decision %q", targetSlug, d.Action))
|
|
}
|
|
|
|
// autoApprove updates the approval + execution status in the DB to approved,
|
|
// mirroring what DecideApproval does. Returns true on success. This is used
|
|
// by the assent-window path to skip the operator-approval queue when the
|
|
// operator already approved the overall plan via chat assent.
|
|
// executeApprovedViaAPI calls the HTTP API's approval-decision endpoint to
|
|
// trigger the actual execution. The API server (phase3.executeApprovedAction)
|
|
// handles the real SSH work (pct create, apt upgrade, etc.) in a goroutine.
|
|
// We POST to the decision endpoint to reuse the exact same execution path
|
|
// as a manual Approve-button click, ensuring the audit trail is consistent.
|
|
func executeApprovedViaAPI(ctx context.Context, execID uuid.UUID, targetSlug, actionStr string) {
|
|
apiBase := os.Getenv("OIKOS_API_BASE")
|
|
if apiBase == "" {
|
|
apiBase = "http://api:8090"
|
|
}
|
|
body, _ := json.Marshal(map[string]string{"decision": "approve"})
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
|
apiBase+"/api/v1/approvals/"+execID.String()+"/decision", bytes.NewReader(body))
|
|
if err != nil {
|
|
slog.Error("mcp: executeApprovedViaAPI request", "error", err)
|
|
return
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
slog.Error("mcp: executeApprovedViaAPI call", "error", err)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
// A non-200 here means the real SSH work was never dispatched — this
|
|
// is the call that actually triggers executeApprovedAction via
|
|
// DecideApproval. (A previous version of this comment claimed a
|
|
// non-200 was fine because a since-removed "autoApprove" step had
|
|
// already triggered execution via a raw DB update — it hadn't; that
|
|
// was the bug where auto-approved pct_create/apt_upgrade never
|
|
// actually ran. There is no other path that dispatches the work.)
|
|
slog.Error("mcp: executeApprovedViaAPI non-200 — execution was NOT dispatched", "status", resp.StatusCode, "execution", execID)
|
|
}
|
|
}
|
|
|
|
// knowledgeSlugRe strips a title down to a slug segment.
|
|
var knowledgeSlugRe = regexp.MustCompile(`[^a-z0-9]+`)
|
|
|
|
func knowledgeSlug(kind, title string) string {
|
|
s := strings.ToLower(strings.TrimSpace(title))
|
|
s = knowledgeSlugRe.ReplaceAllString(s, "-")
|
|
s = strings.Trim(s, "-")
|
|
if s == "" {
|
|
s = "note"
|
|
}
|
|
if len(s) > 80 {
|
|
s = s[:80]
|
|
}
|
|
return kind + ":nomos/" + s
|
|
}
|
|
|
|
// upsertKnowledge is the agent's write-back path — the missing half of the
|
|
// knowledge loop (search_knowledge/get_entity_knowledge could only read).
|
|
// Without this, everything the agent learned lived only in an ephemeral chat
|
|
// message and was lost; the system could never actually "get better." A
|
|
// knowledge doc IS an entity (type document/investigation/runbook) with a row
|
|
// in knowledge_entities; re-titling the same thing updates in place rather
|
|
// than duplicating. Optionally linked to the entity it's about so
|
|
// get_entity_knowledge surfaces it there.
|
|
func upsertKnowledge(ctx context.Context, pool *db.Pool, args map[string]any) (*mcp.CallToolResult, error) {
|
|
title, _ := args["title"].(string)
|
|
content, _ := args["content"].(string)
|
|
tagsRaw, _ := args["tags"].(string)
|
|
kind, _ := args["kind"].(string)
|
|
|
|
// Normalize about: accept a single string slug or an array of slugs.
|
|
var aboutSlugs []string
|
|
switch v := args["about"].(type) {
|
|
case string:
|
|
if s := strings.TrimSpace(v); s != "" {
|
|
aboutSlugs = []string{s}
|
|
}
|
|
case []interface{}:
|
|
for _, item := range v {
|
|
if s, ok := item.(string); ok {
|
|
if s = strings.TrimSpace(s); s != "" {
|
|
aboutSlugs = append(aboutSlugs, s)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
title = strings.TrimSpace(title)
|
|
content = strings.TrimSpace(content)
|
|
if title == "" || content == "" {
|
|
return textResult("error: title and content are required"), nil
|
|
}
|
|
switch kind {
|
|
case "document", "investigation", "runbook":
|
|
case "":
|
|
kind = "investigation"
|
|
default:
|
|
return textResult(fmt.Sprintf("error: kind must be document, investigation, or runbook (got %q)", kind)), nil
|
|
}
|
|
|
|
var tags []string
|
|
for _, t := range strings.Split(tagsRaw, ",") {
|
|
if t = strings.TrimSpace(t); t != "" {
|
|
tags = append(tags, t)
|
|
}
|
|
}
|
|
|
|
slug := knowledgeSlug(kind, title)
|
|
|
|
// Upsert the knowledge-doc entity, getting its id whether it already
|
|
// existed or we just created it.
|
|
docID, _ := uuid.NewV7()
|
|
err := pool.QueryRow(ctx, `
|
|
INSERT INTO entities (id, slug, type, name, attributes)
|
|
VALUES ($1, $2, $3, $4, '{}')
|
|
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, updated_at = now()
|
|
RETURNING id`, docID, slug, kind, title).Scan(&docID)
|
|
if err != nil {
|
|
return textResult(fmt.Sprintf("error creating knowledge entity: %v", err)), nil
|
|
}
|
|
|
|
// Upsert the knowledge content (search column is generated, don't set it).
|
|
_, err = pool.Exec(ctx, `
|
|
INSERT INTO knowledge_entities (entity_id, title, content, source, tags, updated_at)
|
|
VALUES ($1, $2, $3, 'nomos-agent', $4, now())
|
|
ON CONFLICT (entity_id) DO UPDATE
|
|
SET title = EXCLUDED.title, content = EXCLUDED.content,
|
|
tags = EXCLUDED.tags, updated_at = now()`,
|
|
docID, title, content, tags)
|
|
if err != nil {
|
|
return textResult(fmt.Sprintf("error writing knowledge: %v", err)), nil
|
|
}
|
|
|
|
// Link it to the entity(s) it's about, if given and not already linked.
|
|
linked := ""
|
|
if len(aboutSlugs) > 0 {
|
|
var linkedSlugs []string
|
|
for _, slug := range aboutSlugs {
|
|
var targetID uuid.UUID
|
|
if qerr := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", slug).Scan(&targetID); qerr == nil {
|
|
pool.Exec(ctx, `
|
|
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
|
|
SELECT $1, $2, 'documents', '{"by":"nomos"}'::jsonb, now()
|
|
WHERE NOT EXISTS (
|
|
SELECT 1 FROM relationships
|
|
WHERE source_id = $1 AND target_id = $2 AND type = 'documents' AND valid_to IS NULL)`,
|
|
docID, targetID)
|
|
linkedSlugs = append(linkedSlugs, slug)
|
|
}
|
|
}
|
|
if len(linkedSlugs) == 1 {
|
|
linked = " and linked to " + linkedSlugs[0]
|
|
} else if len(linkedSlugs) > 1 {
|
|
linked = fmt.Sprintf(" and linked to %d entities", len(linkedSlugs))
|
|
}
|
|
}
|
|
|
|
_ = observability.Event(ctx, sqlcgen.New(pool), "knowledge.upserted", &docID, "info", "mcp", "",
|
|
map[string]any{"slug": slug, "title": title, "kind": kind})
|
|
|
|
return textResult(fmt.Sprintf("Saved knowledge %q as %s%s. It's now searchable via search_knowledge and will surface in future sessions.", title, slug, linked)), nil
|
|
}
|
|
|
|
// inspectPathAcrossTargets is the bulk fact-gathering helper behind the
|
|
// inspect_path MCP tool (plans/2026-07-18-session-review-three-sessions.md
|
|
// P1.5). For each target slug, it runs a single read-only shell command
|
|
// producing mount/df/ls/stat output for the given path, and returns the
|
|
// results as a map keyed by target slug.
|
|
//
|
|
// Why this exists: sessions 1e9c7691 and 55927f0a each spent ~15 `run`
|
|
// calls gathering identical facts (`mount | grep`, `df`, `ls -la`, `stat`)
|
|
// across hosts and LXCs to trace where a path lives, who mounts it, and
|
|
// what permissions it has. One call here replaces that fan-out. All
|
|
// commands are read-only — the tool bypasses classifyAndGate and runs
|
|
// directly via sshExec against resolveExecTarget's host/wrap. Failures
|
|
// (unresolvable target, SSH error) are reported per-target in the result
|
|
// map, not as a single tool-level error, so one bad target doesn't lose
|
|
// the others.
|
|
//
|
|
// The per-target command is intentionally compact: one combined shell
|
|
// invocation that prints mount source/dest, df, ls -la of the path's
|
|
// parent + the path itself, and stat. Output is truncated to 4KB per
|
|
// target to keep the total result reasonable for an 8-target call.
|
|
func inspectPathAcrossTargets(ctx context.Context, pool *db.Pool, path string, targets []string) map[string]any {
|
|
results := make(map[string]any, len(targets))
|
|
path = strings.TrimSpace(path)
|
|
|
|
var wg sync.WaitGroup
|
|
var mu sync.Mutex
|
|
wg.Add(len(targets))
|
|
|
|
for _, tgt := range targets {
|
|
go func(target string) {
|
|
defer wg.Done()
|
|
entry := inspectOneTarget(ctx, pool, path, target)
|
|
mu.Lock()
|
|
results[target] = entry
|
|
mu.Unlock()
|
|
}(tgt)
|
|
}
|
|
wg.Wait()
|
|
return results
|
|
}
|
|
|
|
// inspectOneTarget runs the read-only inspection for one target. Returns a
|
|
// map with keys: "ok" (bool), "output" (string, on success), "error"
|
|
// (string, on failure). Kept small so the JSON shape is stable across the
|
|
// parallel-call path.
|
|
func inspectOneTarget(ctx context.Context, pool *db.Pool, path, target string) map[string]any {
|
|
host, user, wrap, rerr := resolveExecTarget(ctx, pool, target)
|
|
if rerr != nil {
|
|
return map[string]any{"ok": false, "error": fmt.Sprintf("resolve target: %v", rerr)}
|
|
}
|
|
// One shell invocation, four sections, each guarded by `2>&1 || true`
|
|
// so a missing path doesn't kill the rest. Stat with -c gives a
|
|
// stable machine-readable line for ownership/perms; ls -la gives the
|
|
// human-readable listing of the path and its parent (so we can see
|
|
// both "what's in here" and "how the parent is laid out" — useful for
|
|
// NFS-root-vs-subdir permission mismatches, the exact issue in
|
|
// session 1e9c7691).
|
|
cmd := fmt.Sprintf(
|
|
`echo "=== mount ==="; mount 2>/dev/null | grep -- "%[1]s" || echo "(not a mount point)";
|
|
echo "=== df ==="; df -h "%[1]s" 2>&1 || true;
|
|
echo "=== stat ==="; stat -c '%%a %%U:%%G (size=%%s, type=%%F)' "%[1]s" 2>&1 || true;
|
|
echo "=== ls -la path ==="; ls -la "%[1]s" 2>&1 | head -40 || true;
|
|
echo "=== ls -la parent ==="; ls -la "$(dirname "%[1]s")" 2>&1 | head -20 || true`,
|
|
path)
|
|
out, xerr := sshExec(ctx, host, user, wrap(cmd))
|
|
if xerr != nil {
|
|
return map[string]any{"ok": false, "error": fmt.Sprintf("ssh: %v: %s", xerr, out)}
|
|
}
|
|
// Truncate per-target output to keep an 8-target call's total under
|
|
// ~32KB. 4KB per target is enough for the head -40/head -20 listings
|
|
// above; if a directory is enormous, the truncation keeps the result
|
|
// usable without flooding the model's context.
|
|
const maxPerTarget = 4096
|
|
if len(out) > maxPerTarget {
|
|
out = out[:maxPerTarget] + fmt.Sprintf("\n...truncated (%d bytes total)", len(out))
|
|
}
|
|
return map[string]any{"ok": true, "output": out}
|
|
}
|