47be24c4cc
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
400 lines
13 KiB
Go
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}
|
|
}
|