Files
hexis/cmd/hexisd/main.go
T
kami 47be24c4cc Validate execution targets against Nexus instead of accepting free text
Finding 3 of REVIEW-2026-07-30.md. target_entity_id was accepted as any
non-empty string; the engine only compared it against a pinned TargetEntityID,
which is empty for every registered capability. Spec §4.3: "Hexis never
accepts a free-text target. Ever."

Targets are now checked in order: ent_ shape (free, never touches the
network), pinned target, existence in Nexus, entity still active, and a match
against the capability's TargetTypes. Validation runs before a confirmation is
consumed, so a bad target cannot burn one, and at confirmation-mint time too,
since a confirmation binds a target.

Two deliberate calls:

Nexus unreachable fails closed (503, ErrTargetUnverifiable). Failing open
would reinstate exactly this hole the moment Nexus blips, and hand it to
anyone able to degrade Nexus. Hexis holds no entity table, so "unreachable"
and "I cannot tell if this target is real" are the same statement. The cost is
that executes now require Nexus liveness; the lookup is bounded at 5s so a
hung Nexus fails fast rather than consuming the capability timeout.

An empty TargetTypes means no type constraint, not a bypass — the entity must
still exist, be canonical and be active. Rejecting empty outright would
disable 16 of the 19 registered capabilities, since only the docker.* entries
declare a target type.

The spec's stronger blessing guard is not implementable: Nexus has no blessing
concept at all. This is the achievable guard, and strictly weaker.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uea55zaiWuEByEDC4UBSdd
2026-07-30 23:39:40 +04:00

174 lines
5.5 KiB
Go

package main
import (
"context"
"flag"
"log"
"os"
"os/signal"
"path/filepath"
"slices"
"syscall"
"time"
"github.com/kami/hexis/internal/api"
"github.com/kami/hexis/internal/domain"
"github.com/kami/hexis/internal/execution"
"github.com/kami/hexis/internal/mcp"
"github.com/kami/hexis/internal/nexusclient"
"github.com/kami/hexis/internal/provider"
"github.com/kami/hexis/internal/storage"
)
func main() {
var httpAddr string
var dataDir string
var mcpMode bool
var workspaceURL string
var workspaceAllowlist string
var nexusURL string
flag.StringVar(&httpAddr, "http", "", "HTTP listen address (default localhost:9741)")
flag.StringVar(&dataDir, "data", "", "Data directory for SQLite database")
flag.BoolVar(&mcpMode, "mcp", false, "Run in MCP stdio mode")
flag.StringVar(&workspaceURL, "workspace-url", "", "Workspace MCP HTTP API URL (e.g. http://localhost:9930)")
flag.StringVar(&workspaceAllowlist, "workspace-allowlist", "", "Path to workspace tool allowlist YAML")
flag.StringVar(&nexusURL, "nexus", "", "Nexus base URL for hexis.resolve_target (default http://localhost:9740)")
flag.Parse()
if dataDir == "" {
dataDir = filepath.Join(os.Getenv("HOME"), ".local", "share", "hexis")
}
if httpAddr == "" {
httpAddr = "localhost:9741"
}
if workspaceAllowlist == "" {
workspaceAllowlist = filepath.Join(dataDir, "workspace-allowlist.yaml")
}
if workspaceURL == "" {
workspaceURL = os.Getenv("WORKSPACE_MCP_URL")
}
if nexusURL == "" {
nexusURL = os.Getenv("HEXIS_NEXUS_URL")
}
if nexusURL == "" {
nexusURL = "http://localhost:9740"
}
dbPath := filepath.Join(dataDir, "hexis.db")
store, err := storage.Open(dbPath)
if err != nil {
log.Fatalf("open storage: %v", err)
}
defer store.Close()
reg := provider.NewRegistry()
// Register workspace MCP provider if configured
if workspaceURL != "" {
allowlist, err := provider.LoadToolAllowlist(workspaceAllowlist)
if err != nil {
log.Fatalf("load workspace allowlist: %v", err)
}
wsProvider := provider.NewWorkspaceMCPProvider(workspaceURL, allowlist)
reg.Register(wsProvider)
tools, err := wsProvider.DiscoverTools()
if err != nil {
log.Printf("warning: workspace MCP discovery failed: %v", err)
} else {
log.Printf("discovered %d workspace tools, %d in allowlist", len(tools), len(allowlist.Tools))
}
// Register workspace capabilities, reconciling existing rows so that
// allowlist edits (risk tier, target type, enabled) reach a database
// that was populated by an earlier build.
for _, cap := range wsProvider.BuildCapabilities() {
existing, err := store.GetCapability(cap.ID)
if err == nil && existing != nil {
if !capabilityMatches(existing, &cap) {
merged := cap
merged.CreatedAt = existing.CreatedAt
merged.UpdatedAt = time.Now().UTC()
merged.Attributes = existing.Attributes
// UpdateCapability is optimistically concurrent: it matches
// on the current version and bumps it.
merged.Version = existing.Version
if err := store.UpdateCapability(&merged); err != nil {
log.Printf("warning: reconcile capability %s: %v", cap.Name, err)
} else {
log.Printf("reconciled capability: %s -> %s (version %d)", merged.Name, merged.Operation, merged.Version)
}
}
continue
}
if err := store.CreateCapability(&cap); err != nil {
log.Printf("warning: register capability %s: %v", cap.Name, err)
} else {
log.Printf("registered capability: %s -> %s", cap.Name, cap.Operation)
}
}
}
// Nexus is the sole authority on entity identity: Hexis refuses any
// target it cannot confirm exists there (ECOSYSTEM-SPEC.md §4.3, "Hexis
// never accepts a free-text target. Ever."). If Nexus is down, executes
// fail closed with 503 rather than accepting the target on trust.
nexus := nexusclient.New(nexusURL)
engine := execution.New(store, reg, execution.WithEntityLookup(nexus))
if mcpMode {
log.Printf("starting MCP stdio adapter")
adapter := mcp.New(store, engine, nexus)
if err := adapter.ServeStdio(); err != nil {
log.Fatalf("MCP error: %v", err)
}
return
}
// Shared bearer token for /api/v1/. Required: the HTTP surface can start
// and stop containers, and it is reverse-proxied on a public hostname.
apiToken := os.Getenv("HEXIS_API_TOKEN")
if apiToken == "" {
log.Fatalf("HEXIS_API_TOKEN is not set: refusing to serve /api/v1/ unauthenticated")
}
srv := api.NewServer(store, engine, apiToken)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
log.Printf("listening on http %s", httpAddr)
if err := srv.ListenHTTP(httpAddr); err != nil {
log.Printf("http error: %v", err)
}
}()
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
log.Println("shutting down...")
srv.Shutdown(ctx)
log.Println("stopped")
}
// capabilityMatches reports whether the stored capability already carries every
// derived field, ignoring bookkeeping (timestamps, version, attributes).
func capabilityMatches(stored, derived *domain.Capability) bool {
return stored.Name == derived.Name &&
stored.Description == derived.Description &&
slices.Equal(stored.TargetTypes, derived.TargetTypes) &&
stored.TargetEntityID == derived.TargetEntityID &&
stored.Provider == derived.Provider &&
stored.Operation == derived.Operation &&
stored.Risk == derived.Risk &&
stored.ReadOnly == derived.ReadOnly &&
stored.ExpectedSideEffects == derived.ExpectedSideEffects &&
stored.RequiresConfirmation == derived.RequiresConfirmation &&
stored.Enabled == derived.Enabled &&
stored.TimeoutSeconds == derived.TimeoutSeconds
}