c7325a20d4
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
389 lines
12 KiB
Go
389 lines
12 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):
|
|
return http.StatusNotFound
|
|
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}
|
|
}
|