diff --git a/cmd/hexisctl/main.go b/cmd/hexisctl/main.go index 8c000d2..89bb1d4 100644 --- a/cmd/hexisctl/main.go +++ b/cmd/hexisctl/main.go @@ -28,7 +28,7 @@ func main() { os.Args = append(os.Args[:1], os.Args[3:]...) } - cli = client.New(baseURL) + cli = client.New(baseURL).WithToken(os.Getenv("HEXIS_API_TOKEN")) cmd := os.Args[1] args := os.Args[2:] diff --git a/cmd/hexisd/main.go b/cmd/hexisd/main.go index a97e347..4d845c1 100644 --- a/cmd/hexisd/main.go +++ b/cmd/hexisd/main.go @@ -7,9 +7,12 @@ import ( "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" @@ -79,10 +82,26 @@ func main() { log.Printf("discovered %d workspace tools, %d in allowlist", len(tools), len(allowlist.Tools)) } - // Register workspace capabilities + // 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 { @@ -124,3 +143,20 @@ func main() { 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 +} diff --git a/internal/api/handler.go b/internal/api/handler.go index 8a5b284..e6e4428 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -1,6 +1,7 @@ package api import ( + "crypto/subtle" "encoding/json" "errors" "net/http" @@ -11,15 +12,20 @@ import ( "github.com/kami/hexis/internal/domain" "github.com/kami/hexis/internal/execution" "github.com/kami/hexis/internal/storage" + "github.com/kami/hexis/internal/wire" ) type Handler struct { - store storage.Interface - engine *execution.Engine + store storage.Interface + engine *execution.Engine + authToken string } -func NewHandler(store storage.Interface, engine *execution.Engine) *Handler { - return &Handler{store: store, engine: engine} +// NewHandler builds the HTTP handler. authToken is the shared bearer token +// required on every /api/v1/ request; if it is empty the API refuses all +// /api/v1/ traffic rather than serving it unauthenticated. +func NewHandler(store storage.Interface, engine *execution.Engine, authToken string) *Handler { + return &Handler{store: store, engine: engine, authToken: authToken} } // SupportedAPIVersion is the version this server implements. A request @@ -37,10 +43,42 @@ func (h *Handler) Register(mux *http.ServeMux) { api.HandleFunc("/api/v1/capabilities/", h.handleCapabilityByID) api.HandleFunc("/api/v1/execute", h.handleExecute) api.HandleFunc("/api/v1/confirmations", h.handleConfirmations) + api.HandleFunc("/api/v1/executions", h.handleExecutions) api.HandleFunc("/api/v1/executions/", h.handleExecutionByID) api.HandleFunc("/api/v1/changes", h.handleChanges) - mux.Handle("/api/v1/", versionCheck(api)) + mux.Handle("/api/v1/", h.requireAuth(versionCheck(api))) +} + +// requireAuth enforces the shared bearer token on the whole /api/v1/ surface. +// /health and /ready stay open so probes keep working. +func (h *Handler) requireAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if h.authToken == "" { + // Fail closed: an unset token must never mean "no auth required". + writeJSON(w, http.StatusServiceUnavailable, errorResponse("server misconfigured: HEXIS_API_TOKEN is not set")) + return + } + presented, ok := bearerToken(r) + if !ok || subtle.ConstantTimeCompare([]byte(presented), []byte(h.authToken)) != 1 { + w.Header().Set("WWW-Authenticate", `Bearer realm="hexis"`) + writeJSON(w, http.StatusUnauthorized, errorResponse("unauthorized")) + return + } + next.ServeHTTP(w, r) + }) +} + +func bearerToken(r *http.Request) (string, bool) { + h := r.Header.Get("Authorization") + if h == "" { + return "", false + } + const prefix = "bearer " + if len(h) <= len(prefix) || !strings.EqualFold(h[:len(prefix)], prefix) { + return "", false + } + return strings.TrimSpace(h[len(prefix):]), true } func versionCheck(next http.Handler) http.Handler { @@ -88,45 +126,25 @@ func (h *Handler) listCapabilities(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusInternalServerError, errorResponse(err.Error())) return } - if caps == nil { - caps = []*domain.Capability{} - } - - var apiCaps []map[string]any - for _, c := range caps { - apiCaps = append(apiCaps, map[string]any{ - "capability_id": c.ID, - "id": c.ID, - "name": c.Name, - "description": c.Description, - "target_types": c.TargetTypes, - "target_entity_id": c.TargetEntityID, - "provider": c.Provider, - "operation": c.Operation, - "risk": c.Risk, - "read_only": c.ReadOnly, - "expected_side_effects": c.ExpectedSideEffects, - }) - } - - writeJSON(w, http.StatusOK, apiCaps) + // wire.Capabilities is the single serializer shared with the MCP adapter + // and pkg/client; it also carries the server-derived `enabled` and + // `requires_confirmation` fields this endpoint previously omitted. + writeJSON(w, http.StatusOK, wire.Capabilities(caps)) } func (h *Handler) createCapability(w http.ResponseWriter, r *http.Request) { var req struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - TargetTypes []string `json:"target_types"` - TargetEntityID string `json:"target_entity_id,omitempty"` - Provider string `json:"provider"` - Operation string `json:"operation"` - Risk string `json:"risk,omitempty"` - ReadOnly bool `json:"read_only"` - ExpectedSideEffects string `json:"expected_side_effects,omitempty"` - RequiresConfirmation bool `json:"requires_confirmation,omitempty"` - Enabled *bool `json:"enabled,omitempty"` - TimeoutSeconds int `json:"timeout_seconds,omitempty"` - Attributes map[string]any `json:"attributes,omitempty"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + TargetTypes []string `json:"target_types"` + TargetEntityID string `json:"target_entity_id,omitempty"` + Provider string `json:"provider"` + Operation string `json:"operation"` + Risk string `json:"risk,omitempty"` + ReadOnly bool `json:"read_only"` + ExpectedSideEffects string `json:"expected_side_effects,omitempty"` + TimeoutSeconds int `json:"timeout_seconds,omitempty"` + Attributes map[string]any `json:"attributes,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, errorResponse("invalid JSON")) @@ -137,11 +155,16 @@ func (h *Handler) createCapability(w http.ResponseWriter, r *http.Request) { return } - // Destructive capabilities are disabled by default and must be turned on - // explicitly (ECOSYSTEM-SPEC.md §4.3). - enabled := req.Risk != "destructive" - if req.Enabled != nil { - enabled = *req.Enabled + // `enabled` and `requires_confirmation` are derived server-side from the + // risk tier and are deliberately NOT settable from the request body — a + // caller-supplied override would make the ECOSYSTEM-SPEC.md §4.3 guards + // opt-out for the untrusted caller they exist to constrain. + enabled := domain.EnabledForRisk(req.Risk) + requiresConfirmation := domain.RequiresConfirmationForRisk(req.Risk) + + timeoutSeconds := req.TimeoutSeconds + if timeoutSeconds <= 0 { + timeoutSeconds = domain.DefaultCapabilityTimeoutSeconds } now := time.Now().UTC() @@ -156,9 +179,9 @@ func (h *Handler) createCapability(w http.ResponseWriter, r *http.Request) { Risk: req.Risk, ReadOnly: req.ReadOnly, ExpectedSideEffects: req.ExpectedSideEffects, - RequiresConfirmation: req.RequiresConfirmation, + RequiresConfirmation: requiresConfirmation, Enabled: enabled, - TimeoutSeconds: req.TimeoutSeconds, + TimeoutSeconds: timeoutSeconds, Attributes: req.Attributes, CreatedAt: now, UpdatedAt: now, diff --git a/internal/api/handler_test.go b/internal/api/handler_test.go index a764e63..f1dcd55 100644 --- a/internal/api/handler_test.go +++ b/internal/api/handler_test.go @@ -4,7 +4,9 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" "path/filepath" + "strings" "testing" "time" @@ -23,9 +25,11 @@ func newTestHandler(t *testing.T) (*Handler, *storage.Store) { } t.Cleanup(func() { store.Close() }) engine := execution.New(store, provider.NewRegistry()) - return NewHandler(store, engine), store + return NewHandler(store, engine, testToken), store } +const testToken = "test-token" + // TestHandleChanges_SinceFiltersEvents verifies the `since` query param // actually filters to events after that sequence number, rather than being // silently reset to 0 (the bug: `since = 0` unconditionally, ignoring the @@ -78,3 +82,211 @@ func itoa(n int64) string { b, _ := json.Marshal(n) return string(b) } + +// TestAPIV1_RequiresBearerToken pins finding 2: /api/v1/ must not be reachable +// without the shared token, while /health stays open for probes. +func TestAPIV1_RequiresBearerToken(t *testing.T) { + h, _ := newTestHandler(t) + mux := http.NewServeMux() + h.Register(mux) + + body := `{"capability_id":"cap_x","target_entity_id":"ent_x"}` + + cases := []struct { + name string + header string + want int + }{ + {"no header", "", http.StatusUnauthorized}, + {"wrong token", "Bearer nope", http.StatusUnauthorized}, + {"not bearer", "Basic " + testToken, http.StatusUnauthorized}, + {"correct token", "Bearer " + testToken, http.StatusNotFound}, // past auth: capability missing + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/v1/execute", strings.NewReader(body)) + if tc.header != "" { + req.Header.Set("Authorization", tc.header) + } + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != tc.want { + t.Fatalf("expected %d, got %d: %s", tc.want, w.Code, w.Body.String()) + } + }) + } + + // /health must remain unauthenticated. + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/health", nil)) + if w.Code != http.StatusOK { + t.Fatalf("expected /health to stay open, got %d", w.Code) + } +} + +// TestAPIV1_UnsetTokenFailsClosed: an empty configured token must reject the +// API outright, never serve it openly. +func TestAPIV1_UnsetTokenFailsClosed(t *testing.T) { + path := filepath.Join(t.TempDir(), "hexis.db") + store, err := storage.Open(path) + if err != nil { + t.Fatalf("open store: %v", err) + } + t.Cleanup(func() { store.Close() }) + h := NewHandler(store, execution.New(store, provider.NewRegistry()), "") + mux := http.NewServeMux() + h.Register(mux) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/capabilities", nil) + req.Header.Set("Authorization", "Bearer anything") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("expected 503 when no token is configured, got %d: %s", w.Code, w.Body.String()) + } +} + +// TestCreateCapability_GuardsAreServerDerived pins finding 2: a caller cannot +// enable a destructive capability or opt out of confirmation via the body. +func TestCreateCapability_GuardsAreServerDerived(t *testing.T) { + h, _ := newTestHandler(t) + + cases := []struct { + name string + body string + wantEnabled bool + wantRequiresConfirmation bool + }{ + { + name: "destructive cannot be enabled from the body", + body: `{"name":"prune","provider":"workspace_mcp","operation":"docker.prune","risk":"destructive","enabled":true}`, + wantEnabled: false, wantRequiresConfirmation: true, + }, + { + name: "medium risk gets confirmation even when the body says otherwise", + body: `{"name":"stop","provider":"workspace_mcp","operation":"docker.stop_container","risk":"medium","requires_confirmation":false}`, + wantEnabled: true, wantRequiresConfirmation: true, + }, + { + name: "read risk stays confirmation-free", + body: `{"name":"list","provider":"workspace_mcp","operation":"docker.list_containers","risk":"read"}`, + wantEnabled: true, wantRequiresConfirmation: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/v1/capabilities", strings.NewReader(tc.body)) + w := httptest.NewRecorder() + h.createCapability(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) + } + var cap domain.Capability + if err := json.Unmarshal(w.Body.Bytes(), &cap); err != nil { + t.Fatalf("decode: %v", err) + } + if cap.Enabled != tc.wantEnabled { + t.Errorf("enabled = %v, want %v", cap.Enabled, tc.wantEnabled) + } + if cap.RequiresConfirmation != tc.wantRequiresConfirmation { + t.Errorf("requires_confirmation = %v, want %v", cap.RequiresConfirmation, tc.wantRequiresConfirmation) + } + if cap.TimeoutSeconds != domain.DefaultCapabilityTimeoutSeconds { + t.Errorf("timeout_seconds = %d, want %d", cap.TimeoutSeconds, domain.DefaultCapabilityTimeoutSeconds) + } + }) + } +} + +// TestExecute_WorkspaceCapabilityEndToEnd pins finding 1: a capability +// registered by BuildCapabilities must actually execute, not 403 as disabled. +func TestExecute_WorkspaceCapabilityEndToEnd(t *testing.T) { + ws := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/tool/docker.list_containers" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"containers":["hexis"]}`)) + })) + t.Cleanup(ws.Close) + + allowlistPath := filepath.Join(t.TempDir(), "allowlist.yaml") + allowlistYAML := "tools:\n" + + " docker.list_containers:\n" + + " capability: workspace.docker.list\n" + + " risk: read\n" + + " read_only: true\n" + if err := os.WriteFile(allowlistPath, []byte(allowlistYAML), 0o600); err != nil { + t.Fatalf("write allowlist: %v", err) + } + allowlist, err := provider.LoadToolAllowlist(allowlistPath) + if err != nil { + t.Fatalf("load allowlist: %v", err) + } + + store, err := storage.Open(filepath.Join(t.TempDir(), "hexis.db")) + if err != nil { + t.Fatalf("open store: %v", err) + } + t.Cleanup(func() { store.Close() }) + + wsProvider := provider.NewWorkspaceMCPProvider(ws.URL, allowlist) + reg := provider.NewRegistry() + reg.Register(wsProvider) + + caps := wsProvider.BuildCapabilities() + if len(caps) != 1 { + t.Fatalf("expected 1 built capability, got %d", len(caps)) + } + built := caps[0] + if !built.Enabled { + t.Fatalf("BuildCapabilities produced a disabled capability: %+v", built) + } + if built.TimeoutSeconds <= 0 { + t.Fatalf("BuildCapabilities produced timeout_seconds=%d", built.TimeoutSeconds) + } + if err := store.CreateCapability(&built); err != nil { + t.Fatalf("create capability: %v", err) + } + + h := NewHandler(store, execution.New(store, reg), testToken) + mux := http.NewServeMux() + h.Register(mux) + + body := `{"capability_id":"` + built.ID + `","target_entity_id":"ent_host_homesrv"}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/execute", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+testToken) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + var exec domain.Execution + if err := json.Unmarshal(w.Body.Bytes(), &exec); err != nil { + t.Fatalf("decode execution: %v", err) + } + if exec.Status != domain.ExecutionSucceeded { + t.Fatalf("expected succeeded, got %q (error: %s)", exec.Status, exec.Error) + } +} + +// TestBuildCapabilities_RiskDerivation pins that mutating workspace tools are +// registered enabled but confirmation-gated. +func TestBuildCapabilities_RiskDerivation(t *testing.T) { + allowlist := provider.ToolAllowlist{Tools: map[string]provider.ToolMapping{ + "docker.stop_container": {Capability: "workspace.docker.stop", Risk: "medium", TargetType: "container"}, + }} + caps := provider.NewWorkspaceMCPProvider("http://unused", allowlist).BuildCapabilities() + if len(caps) != 1 { + t.Fatalf("expected 1 capability, got %d", len(caps)) + } + if !caps[0].Enabled { + t.Errorf("medium-risk capability should be enabled") + } + if !caps[0].RequiresConfirmation { + t.Errorf("medium-risk capability must require confirmation") + } +} diff --git a/internal/api/server.go b/internal/api/server.go index 0711144..a80d3d0 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -2,11 +2,7 @@ package api import ( "context" - "fmt" - "net" "net/http" - "os" - "path/filepath" "github.com/kami/hexis/internal/execution" "github.com/kami/hexis/internal/storage" @@ -15,40 +11,15 @@ import ( type Server struct { handler *Handler httpSrv *http.Server - socket string } -func NewServer(store *storage.Store, engine *execution.Engine) *Server { - handler := NewHandler(store, engine) +// NewServer builds the HTTP server. authToken is the shared bearer token +// required on /api/v1/; see Handler.requireAuth. +func NewServer(store *storage.Store, engine *execution.Engine, authToken string) *Server { + handler := NewHandler(store, engine, authToken) return &Server{handler: handler} } -func (s *Server) ListenUnix(socketPath string) error { - dir := filepath.Dir(socketPath) - if err := os.MkdirAll(dir, 0755); err != nil { - return fmt.Errorf("create socket directory: %w", err) - } - - os.Remove(socketPath) - - listener, err := net.Listen("unix", socketPath) - if err != nil { - return fmt.Errorf("listen unix: %w", err) - } - - if err := os.Chmod(socketPath, 0660); err != nil { - listener.Close() - return fmt.Errorf("chmod socket: %w", err) - } - - s.socket = socketPath - - mux := http.NewServeMux() - s.handler.Register(mux) - - return http.Serve(listener, mux) -} - func (s *Server) ListenHTTP(addr string) error { mux := http.NewServeMux() s.handler.Register(mux) diff --git a/internal/domain/capability.go b/internal/domain/capability.go index 82187ea..4ab8c56 100644 --- a/internal/domain/capability.go +++ b/internal/domain/capability.go @@ -36,10 +36,40 @@ type Capability struct { Version int64 `json:"version"` } -// IsDestructive reports whether the capability's risk tier requires it to be -// disabled by default per ECOSYSTEM-SPEC.md §4.3. -func (c *Capability) IsDestructive() bool { - return c.Risk == "destructive" +// Risk tiers. ECOSYSTEM-SPEC.md §4.1 names read/low/protected/destructive; the +// workspace allowlist additionally uses medium/high, which map onto the same +// two guard decisions below. +const ( + RiskRead = "read" + RiskLow = "low" + RiskMedium = "medium" + RiskProtected = "protected" + RiskHigh = "high" + RiskDestructive = "destructive" +) + +// DefaultCapabilityTimeoutSeconds is the per-capability wall-clock timeout +// applied when a registration does not specify one (ECOSYSTEM-SPEC.md §4.3). +const DefaultCapabilityTimeoutSeconds = 30 + +// EnabledForRisk reports the server-derived `enabled` value for a risk tier. +// Destructive capabilities are disabled by default and must be turned on +// explicitly out of band (ECOSYSTEM-SPEC.md §4.3). This is never overridable +// by an API caller. +func EnabledForRisk(risk string) bool { + return risk != RiskDestructive +} + +// RequiresConfirmationForRisk reports the server-derived +// `requires_confirmation` value for a risk tier. Only genuinely read-only and +// low-risk tiers skip confirmation; unrecognised tiers fail closed. +func RequiresConfirmationForRisk(risk string) bool { + switch risk { + case "", RiskRead, RiskLow: + return false + default: + return true + } } type ExecutionStatus string diff --git a/internal/provider/workspace_mcp.go b/internal/provider/workspace_mcp.go index 576dc35..54053c8 100644 --- a/internal/provider/workspace_mcp.go +++ b/internal/provider/workspace_mcp.go @@ -243,9 +243,15 @@ func (p *WorkspaceMCPProvider) BuildCapabilities() []domain.Capability { Risk: mapping.Risk, ReadOnly: readOnly, ExpectedSideEffects: mapping.SideEffects, - CreatedAt: now, - UpdatedAt: now, - Version: 1, + // Derived server-side from the risk tier, exactly as the HTTP + // registration path does (ECOSYSTEM-SPEC.md §4.3). Leaving these + // zero-valued registered every workspace capability disabled. + Enabled: domain.EnabledForRisk(mapping.Risk), + RequiresConfirmation: domain.RequiresConfirmationForRisk(mapping.Risk), + TimeoutSeconds: domain.DefaultCapabilityTimeoutSeconds, + CreatedAt: now, + UpdatedAt: now, + Version: 1, }) } return caps