Files
hexis/internal/api/handler.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

400 lines
13 KiB
Go

package api
import (
"crypto/subtle"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
"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
authToken string
}
// 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
// carrying X-Hexis-Version set to anything else is rejected — clients that
// don't send the header at all are allowed through unversioned, to avoid
// breaking callers mid-rollout.
const SupportedAPIVersion = "v1"
func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("/health", h.health)
mux.HandleFunc("/ready", h.ready)
api := http.NewServeMux()
api.HandleFunc("/api/v1/capabilities", h.handleCapabilities)
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/", 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 {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if v := r.Header.Get("X-Hexis-Version"); v != "" && v != SupportedAPIVersion {
writeJSON(w, http.StatusPreconditionFailed, map[string]string{
"error": "unsupported API version",
"requested_version": v,
"supported_version": SupportedAPIVersion,
})
return
}
next.ServeHTTP(w, r)
})
}
func (h *Handler) health(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (h *Handler) ready(w http.ResponseWriter, r *http.Request) {
_, err := h.store.LatestSequence()
if err != nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"status": "not_ready"})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
}
func (h *Handler) handleCapabilities(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
h.listCapabilities(w, r)
case http.MethodPost:
h.createCapability(w, r)
default:
writeJSON(w, http.StatusMethodNotAllowed, errorResponse("method not allowed"))
}
}
func (h *Handler) listCapabilities(w http.ResponseWriter, r *http.Request) {
entityID := r.URL.Query().Get("entity_id")
caps, err := h.store.ListCapabilities(entityID)
if err != nil {
writeJSON(w, http.StatusInternalServerError, errorResponse(err.Error()))
return
}
// 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"`
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"))
return
}
if req.Name == "" || req.Provider == "" || req.Operation == "" {
writeJSON(w, http.StatusBadRequest, errorResponse("name, provider, and operation are required"))
return
}
// `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()
cap := &domain.Capability{
ID: domain.NewCapabilityID(),
Name: req.Name,
Description: req.Description,
TargetTypes: req.TargetTypes,
TargetEntityID: req.TargetEntityID,
Provider: req.Provider,
Operation: req.Operation,
Risk: req.Risk,
ReadOnly: req.ReadOnly,
ExpectedSideEffects: req.ExpectedSideEffects,
RequiresConfirmation: requiresConfirmation,
Enabled: enabled,
TimeoutSeconds: timeoutSeconds,
Attributes: req.Attributes,
CreatedAt: now,
UpdatedAt: now,
Version: 1,
}
if cap.TargetTypes == nil {
cap.TargetTypes = []string{}
}
if cap.Attributes == nil {
cap.Attributes = map[string]any{}
}
if err := h.store.CreateCapability(cap); err != nil {
writeJSON(w, http.StatusInternalServerError, errorResponse(err.Error()))
return
}
h.store.AppendEvent(&domain.Event{
ID: domain.NewEventID(),
Type: domain.EventCapabilityRegistered,
Timestamp: now,
Payload: map[string]any{"capability_id": cap.ID, "name": cap.Name},
})
writeJSON(w, http.StatusCreated, cap)
}
func (h *Handler) handleCapabilityByID(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/api/v1/capabilities/")
if id == "" {
writeJSON(w, http.StatusBadRequest, errorResponse("id required"))
return
}
switch r.Method {
case http.MethodGet:
h.getCapability(w, r, id)
case http.MethodDelete:
h.deleteCapability(w, r, id)
default:
writeJSON(w, http.StatusMethodNotAllowed, errorResponse("method not allowed"))
}
}
func (h *Handler) getCapability(w http.ResponseWriter, r *http.Request, id string) {
cap, err := h.store.GetCapability(id)
if err != nil {
writeJSON(w, http.StatusNotFound, errorResponse(err.Error()))
return
}
writeJSON(w, http.StatusOK, cap)
}
func (h *Handler) deleteCapability(w http.ResponseWriter, r *http.Request, id string) {
if err := h.store.DeleteCapability(id); err != nil {
writeJSON(w, http.StatusNotFound, errorResponse(err.Error()))
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
func (h *Handler) handleExecute(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, errorResponse("method not allowed"))
return
}
var req domain.ExecuteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, errorResponse("invalid JSON"))
return
}
if req.CapabilityID == "" || req.TargetEntityID == "" {
writeJSON(w, http.StatusBadRequest, errorResponse("capability_id and target_entity_id are required"))
return
}
if req.CorrelationID == "" {
req.CorrelationID = r.Header.Get("X-Correlation-ID")
}
if req.CausationID == "" {
req.CausationID = r.Header.Get("X-Causation-ID")
}
result, err := h.engine.Execute(&req)
if err != nil {
writeJSON(w, executeErrorStatus(err), errorResponse(err.Error()))
return
}
writeJSON(w, http.StatusOK, result.Execution)
}
func executeErrorStatus(err error) int {
switch {
case errors.Is(err, domain.ErrExecutionInFlight):
return http.StatusConflict
case errors.Is(err, domain.ErrCapabilityNotFound),
errors.Is(err, execution.ErrTargetNotFound):
return http.StatusNotFound
case errors.Is(err, execution.ErrTargetMalformed):
return http.StatusBadRequest
case errors.Is(err, execution.ErrTargetTypeMismatch),
errors.Is(err, execution.ErrTargetNotActive):
return http.StatusUnprocessableEntity
// Nexus is the only authority on whether a target is real. If it cannot
// be reached we refuse the execution rather than accepting the target on
// trust — 503, because retrying later is the correct client behaviour.
case errors.Is(err, execution.ErrTargetUnverifiable):
return http.StatusServiceUnavailable
case errors.Is(err, domain.ErrConfirmationRequired),
errors.Is(err, domain.ErrConfirmationInvalid),
errors.Is(err, domain.ErrConfirmationExpired),
errors.Is(err, domain.ErrConfirmationConsumed),
errors.Is(err, domain.ErrConfirmationNotFound),
errors.Is(err, domain.ErrCapabilityDisabled),
errors.Is(err, domain.ErrCapabilityNotBound):
return http.StatusForbidden
default:
return http.StatusBadRequest
}
}
func (h *Handler) handleConfirmations(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, errorResponse("method not allowed"))
return
}
var req struct {
CapabilityID string `json:"capability_id"`
TargetEntityID string `json:"target_entity_id"`
Arguments map[string]any `json:"arguments,omitempty"`
Requester string `json:"requester,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, errorResponse("invalid JSON"))
return
}
if req.CapabilityID == "" || req.TargetEntityID == "" {
writeJSON(w, http.StatusBadRequest, errorResponse("capability_id and target_entity_id are required"))
return
}
conf, err := h.engine.CreateConfirmation(req.CapabilityID, req.TargetEntityID, req.Requester, req.Arguments)
if err != nil {
status := http.StatusBadRequest
if errors.Is(err, domain.ErrCapabilityNotFound) {
status = http.StatusNotFound
} else if errors.Is(err, domain.ErrCapabilityNotBound) {
status = http.StatusForbidden
}
writeJSON(w, status, errorResponse(err.Error()))
return
}
writeJSON(w, http.StatusCreated, conf)
}
func (h *Handler) handleExecutionByID(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, errorResponse("method not allowed"))
return
}
id := strings.TrimPrefix(r.URL.Path, "/api/v1/executions/")
if id == "" {
writeJSON(w, http.StatusBadRequest, errorResponse("id required"))
return
}
exec, err := h.store.GetExecution(id)
if err != nil {
writeJSON(w, http.StatusNotFound, errorResponse(err.Error()))
return
}
writeJSON(w, http.StatusOK, exec)
}
func (h *Handler) handleChanges(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, errorResponse("method not allowed"))
return
}
seqStr := r.URL.Query().Get("since")
var since int64
if seqStr != "" {
parsed, err := strconv.ParseInt(seqStr, 10, 64)
if err != nil {
writeJSON(w, http.StatusBadRequest, errorResponse("since must be an integer sequence"))
return
}
since = parsed
}
events, err := h.store.EventsAfter(since, 100)
if err != nil {
writeJSON(w, http.StatusInternalServerError, errorResponse(err.Error()))
return
}
if events == nil {
events = []*domain.Event{}
}
writeJSON(w, http.StatusOK, events)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func errorResponse(msg string) map[string]string {
return map[string]string{"error": msg}
}