Files
hexis/cmd/hexisd/main.go
T
kami c7325a20d4 Require auth on /api/v1/ and derive capability guards server-side
Findings 1 and 2 of REVIEW-2026-07-30.md, which must land together: every
workspace capability registered with enabled=false, so the only working
provider could never execute. Fixing that alone would have turned a dead
execution path into a reachable one on an unauthenticated port.

Auth: a shared bearer token (HEXIS_API_TOKEN) is now required on the whole
/api/v1/ surface, compared with crypto/subtle.ConstantTimeCompare. /health
and /ready stay open for probes. It fails closed twice over — hexisd refuses
to start with an empty token, and the middleware returns 503 rather than ever
serving unauthenticated.

Guards: `enabled` and `requires_confirmation` are no longer readable from the
request body at all. Previously the handler derived the correct §4.3 default
and then let the caller override it, which is worse than no guard because it
reads as enforced. Both are now derived from the risk tier by shared helpers
in domain, used by the HTTP and provider registration paths alike;
unrecognised tiers fail closed to requiring confirmation.

BuildCapabilities sets Enabled, RequiresConfirmation and TimeoutSeconds
explicitly, and hexisd reconciles drifted rows on startup instead of skipping
any capability whose ID already exists — without that, allowlist edits never
reach an existing database.

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

163 lines
5.0 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:8987)")
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:8987"
}
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()
reg.Register(provider.NewSystemdProvider())
// 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)
}
}
}
engine := execution.New(store, reg)
if mcpMode {
log.Printf("starting MCP stdio adapter")
adapter := mcp.New(store, engine, nexusclient.New(nexusURL))
if err := adapter.ServeStdio(); err != nil {
log.Fatalf("MCP error: %v", err)
}
return
}
srv := api.NewServer(store, engine)
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
}