diff --git a/cmd/hexisd/main.go b/cmd/hexisd/main.go index 4d845c1..472b546 100644 --- a/cmd/hexisd/main.go +++ b/cmd/hexisd/main.go @@ -33,7 +33,7 @@ func main() { 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.StringVar(&nexusURL, "nexus", "", "Nexus base URL for hexis.resolve_target (default http://localhost:9740)") flag.Parse() if dataDir == "" { @@ -52,7 +52,7 @@ func main() { nexusURL = os.Getenv("HEXIS_NEXUS_URL") } if nexusURL == "" { - nexusURL = "http://localhost:8987" + nexusURL = "http://localhost:9740" } dbPath := filepath.Join(dataDir, "hexis.db") @@ -64,7 +64,6 @@ func main() { defer store.Close() reg := provider.NewRegistry() - reg.Register(provider.NewSystemdProvider()) // Register workspace MCP provider if configured if workspaceURL != "" { @@ -112,18 +111,30 @@ func main() { } } - engine := execution.New(store, reg) + // Nexus is the sole authority on entity identity: Hexis refuses any + // target it cannot confirm exists there (ECOSYSTEM-SPEC.md §4.3, "Hexis + // never accepts a free-text target. Ever."). If Nexus is down, executes + // fail closed with 503 rather than accepting the target on trust. + nexus := nexusclient.New(nexusURL) + engine := execution.New(store, reg, execution.WithEntityLookup(nexus)) if mcpMode { log.Printf("starting MCP stdio adapter") - adapter := mcp.New(store, engine, nexusclient.New(nexusURL)) + adapter := mcp.New(store, engine, nexus) if err := adapter.ServeStdio(); err != nil { log.Fatalf("MCP error: %v", err) } return } - srv := api.NewServer(store, engine) + // Shared bearer token for /api/v1/. Required: the HTTP surface can start + // and stop containers, and it is reverse-proxied on a public hostname. + apiToken := os.Getenv("HEXIS_API_TOKEN") + if apiToken == "" { + log.Fatalf("HEXIS_API_TOKEN is not set: refusing to serve /api/v1/ unauthenticated") + } + + srv := api.NewServer(store, engine, apiToken) ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/internal/api/handler.go b/internal/api/handler.go index e6e4428..85d130f 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -278,8 +278,19 @@ func executeErrorStatus(err error) int { switch { case errors.Is(err, domain.ErrExecutionInFlight): return http.StatusConflict - case errors.Is(err, domain.ErrCapabilityNotFound): + 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), diff --git a/internal/api/target_validation_test.go b/internal/api/target_validation_test.go new file mode 100644 index 0000000..bbd36b9 --- /dev/null +++ b/internal/api/target_validation_test.go @@ -0,0 +1,112 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/kami/hexis/internal/domain" + "github.com/kami/hexis/internal/execution" + "github.com/kami/hexis/internal/nexusclient" + "github.com/kami/hexis/internal/provider" + "github.com/kami/hexis/internal/storage" +) + +type targetTestProvider struct{} + +func (targetTestProvider) Name() string { return "fake" } +func (targetTestProvider) Execute(ctx context.Context, c *domain.Capability, r *domain.ExecuteRequest) (map[string]any, error) { + return map[string]any{"ok": true}, nil +} + +// newTargetValidationServer wires a Handler against a stub Nexus that knows +// exactly one container entity, plus one capability scoped to containers. +func newTargetValidationServer(t *testing.T) (*http.ServeMux, *domain.Capability) { + t.Helper() + + nexus := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch strings.TrimPrefix(r.URL.Path, "/api/v1/entities/") { + case "ent_container_maven": + json.NewEncoder(w).Encode(map[string]any{ + "id": "ent_container_maven", "type": "container", "state": "active", + }) + case "ent_host_homesrv": + json.NewEncoder(w).Encode(map[string]any{ + "id": "ent_host_homesrv", "type": "host", "state": "active", + }) + default: + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{"error": "entity not found"}) + } + })) + t.Cleanup(nexus.Close) + + store, err := storage.Open(filepath.Join(t.TempDir(), "hexis.db")) + if err != nil { + t.Fatalf("open store: %v", err) + } + t.Cleanup(func() { store.Close() }) + + reg := provider.NewRegistry() + reg.Register(targetTestProvider{}) + + now := time.Now().UTC() + cap := &domain.Capability{ + ID: domain.NewCapabilityID(), + Name: "workspace.docker.restart", + Provider: "fake", + Operation: "noop", + Risk: "low", + Enabled: true, + TargetTypes: []string{"container"}, + Attributes: map[string]any{}, + CreatedAt: now, + UpdatedAt: now, + Version: 1, + } + if err := store.CreateCapability(cap); err != nil { + t.Fatalf("create capability: %v", err) + } + + engine := execution.New(store, reg, execution.WithEntityLookup(nexusclient.New(nexus.URL))) + mux := http.NewServeMux() + NewHandler(store, engine, testToken).Register(mux) + return mux, cap +} + +// TestExecuteHTTP_TargetValidation pins the HTTP status codes for each way a +// target can be refused (ECOSYSTEM-SPEC.md §4.3). +func TestExecuteHTTP_TargetValidation(t *testing.T) { + mux, cap := newTargetValidationServer(t) + + cases := []struct { + name string + target string + want int + }{ + {"free text", "the maven container", http.StatusBadRequest}, + {"bare name", "maven", http.StatusBadRequest}, + {"unknown entity", "ent_not_real", http.StatusNotFound}, + {"wrong type", "ent_host_homesrv", http.StatusUnprocessableEntity}, + {"valid target", "ent_container_maven", http.StatusOK}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + body := `{"capability_id":"` + cap.ID + `","target_entity_id":"` + tc.target + `"}` + 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 != tc.want { + t.Fatalf("target %q: expected %d, got %d: %s", tc.target, tc.want, w.Code, w.Body.String()) + } + }) + } +} diff --git a/internal/execution/engine.go b/internal/execution/engine.go index cbc03a4..ea60394 100644 --- a/internal/execution/engine.go +++ b/internal/execution/engine.go @@ -2,24 +2,124 @@ package execution import ( "context" - "encoding/json" + "errors" "fmt" + "regexp" "time" "github.com/kami/hexis/internal/domain" + "github.com/kami/hexis/internal/nexusclient" "github.com/kami/hexis/internal/provider" "github.com/kami/hexis/internal/storage" ) const defaultTimeout = 30 * time.Second +// targetLookupTimeout bounds the Nexus round-trip performed before an +// execution is created. It is short on purpose: the check sits in front of +// every execute, and a slow Nexus must surface as a fast 503, not a hang. +const targetLookupTimeout = 5 * time.Second + +// Target validation failures. ECOSYSTEM-SPEC.md §4.3: "Hexis never accepts a +// free-text target. Ever." A target is acceptable only if it is a canonical +// `ent_` ID that Nexus confirms exists, is active, and whose type the +// capability declares it can act on. +var ( + ErrTargetMalformed = errors.New("target_entity_id is not a canonical Nexus entity id") + ErrTargetNotFound = errors.New("target entity does not exist in Nexus") + ErrTargetNotActive = errors.New("target entity is retired, merged or deleted") + ErrTargetTypeMismatch = errors.New("target entity type is not accepted by this capability") + // ErrTargetUnverifiable means Nexus could not be reached. Hexis fails + // CLOSED here: an unreachable identity service must not degrade into + // accepting arbitrary strings, which is precisely the hole this check + // exists to close. + ErrTargetUnverifiable = errors.New("target entity could not be verified: nexus unreachable") +) + +// entityIDPattern is the canonical Nexus entity ID shape. Anything else is +// free text by definition and is rejected before any network call. +var entityIDPattern = regexp.MustCompile(`^ent_[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$`) + +// EntityLookup is the slice of Nexus that Hexis needs in order to refuse +// free-text targets. +type EntityLookup interface { + GetEntity(ctx context.Context, id string) (*nexusclient.Entity, error) +} + type Engine struct { store storage.Interface registry *provider.Registry + entities EntityLookup } -func New(store storage.Interface, registry *provider.Registry) *Engine { - return &Engine{store: store, registry: registry} +type Option func(*Engine) + +// WithEntityLookup wires the Nexus entity check. Production always supplies +// it (see cmd/hexisd). When it is absent — only in tests and in tooling that +// never executes — targets are still shape-checked, but existence and type +// cannot be verified. +func WithEntityLookup(lookup EntityLookup) Option { + return func(e *Engine) { e.entities = lookup } +} + +func New(store storage.Interface, registry *provider.Registry, opts ...Option) *Engine { + e := &Engine{store: store, registry: registry} + for _, opt := range opts { + opt(e) + } + return e +} + +// validateTarget enforces spec §4.3's "no free-text target" rule. +// +// Ordering matters: the shape check is free and runs first, so a garbage +// string never reaches Nexus. Existence and type are then checked against +// Nexus itself — Hexis holds no entity table of its own, so this is the only +// authority available. +// +// An empty capability.TargetTypes is NOT "anything goes by accident": it +// means the capability declares no type constraint (most workspace-MCP tools +// are global — `docker.list_containers` acts on the host, not on a typed +// entity). The existence and active-state checks still apply, so the target +// is always a real, canonical, live entity; only the type filter is skipped. +func (e *Engine) validateTarget(capability *domain.Capability, targetEntityID string) error { + if !entityIDPattern.MatchString(targetEntityID) { + return ErrTargetMalformed + } + + // A capability pinned to one entity accepts nothing else, regardless of + // what Nexus says. + if capability.TargetEntityID != "" && capability.TargetEntityID != targetEntityID { + return domain.ErrCapabilityNotBound + } + + if e.entities == nil { + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), targetLookupTimeout) + defer cancel() + + entity, err := e.entities.GetEntity(ctx, targetEntityID) + if err != nil { + if errors.Is(err, nexusclient.ErrEntityNotFound) { + return ErrTargetNotFound + } + return fmt.Errorf("%w: %v", ErrTargetUnverifiable, err) + } + if entity.State != "" && entity.State != "active" { + return fmt.Errorf("%w (state %q)", ErrTargetNotActive, entity.State) + } + + if len(capability.TargetTypes) == 0 { + return nil + } + for _, t := range capability.TargetTypes { + if t == entity.Type { + return nil + } + } + return fmt.Errorf("%w: entity is %q, capability accepts %v", ErrTargetTypeMismatch, entity.Type, capability.TargetTypes) } type ExecuteResult struct { @@ -43,8 +143,10 @@ func (e *Engine) Execute(req *domain.ExecuteRequest) (*ExecuteResult, error) { return nil, domain.ErrCapabilityDisabled } - if capability.TargetEntityID != "" && capability.TargetEntityID != req.TargetEntityID { - return nil, domain.ErrCapabilityNotBound + // Validate the target before anything with a side effect — including + // before consuming a confirmation, so a bad target never burns one. + if err := e.validateTarget(capability, req.TargetEntityID); err != nil { + return nil, err } if capability.RequiresConfirmation { @@ -215,8 +317,12 @@ func (e *Engine) CreateConfirmation(capabilityID, targetEntityID, requester stri if err != nil { return nil, fmt.Errorf("capability: %w", err) } - if capability.TargetEntityID != "" && capability.TargetEntityID != targetEntityID { - return nil, domain.ErrCapabilityNotBound + // A confirmation binds a target, so the target must be real here too — + // otherwise a confirmation could be minted for a free-text target and the + // execute-time check would be the only thing standing between it and a + // side effect. + if err := e.validateTarget(capability, targetEntityID); err != nil { + return nil, err } normalized, err := domain.NormalizeArgs(args) @@ -248,14 +354,7 @@ func (e *Engine) ValidateTarget(capabilityID, entityID string) error { if err != nil { return err } - if cap.TargetEntityID != "" && cap.TargetEntityID != entityID { - return domain.ErrCapabilityNotBound - } - return nil -} - -func (e *Engine) emitEvent(evtType domain.HexisEventType, entityID string, payload map[string]any) { - e.emitEventCorrelated(evtType, entityID, "", "", payload) + return e.validateTarget(cap, entityID) } func (e *Engine) emitEventCorrelated(evtType domain.HexisEventType, entityID, correlationID, causationID string, payload map[string]any) { diff --git a/internal/execution/target_test.go b/internal/execution/target_test.go new file mode 100644 index 0000000..d3fd52a --- /dev/null +++ b/internal/execution/target_test.go @@ -0,0 +1,177 @@ +package execution_test + +import ( + "context" + "errors" + "path/filepath" + "testing" + + "github.com/kami/hexis/internal/domain" + "github.com/kami/hexis/internal/execution" + "github.com/kami/hexis/internal/nexusclient" + "github.com/kami/hexis/internal/provider" + "github.com/kami/hexis/internal/storage" +) + +// fakeNexus stands in for the Nexus entity registry: whatever is in entities +// exists, anything else is a 404, and err simulates Nexus being unreachable. +type fakeNexus struct { + entities map[string]*nexusclient.Entity + err error + calls int +} + +func (f *fakeNexus) GetEntity(ctx context.Context, id string) (*nexusclient.Entity, error) { + f.calls++ + if f.err != nil { + return nil, f.err + } + if e, ok := f.entities[id]; ok { + return e, nil + } + return nil, nexusclient.ErrEntityNotFound +} + +func newTargetEngine(t *testing.T, nexus execution.EntityLookup) (*execution.Engine, *storage.Store) { + t.Helper() + store, err := storage.Open(filepath.Join(t.TempDir(), "hexis.db")) + if err != nil { + t.Fatalf("open storage: %v", err) + } + t.Cleanup(func() { store.Close() }) + + reg := provider.NewRegistry() + reg.Register(&fakeProvider{name: "fake"}) + + return execution.New(store, reg, execution.WithEntityLookup(nexus)), store +} + +func activeNexus() *fakeNexus { + return &fakeNexus{entities: map[string]*nexusclient.Entity{ + "ent_container_maven": {ID: "ent_container_maven", Type: "container", State: "active"}, + "ent_host_homesrv": {ID: "ent_host_homesrv", Type: "host", State: "active"}, + "ent_retired_thing": {ID: "ent_retired_thing", Type: "container", State: "retired"}, + }} +} + +// TestExecute_RejectsFreeTextTarget pins ECOSYSTEM-SPEC.md §4.3: a target +// that is not a canonical ent_ ID is refused without even asking Nexus. +func TestExecute_RejectsFreeTextTarget(t *testing.T) { + nexus := activeNexus() + eng, store := newTargetEngine(t, nexus) + cap := mustCreateCapability(t, store, func(c *domain.Capability) { + c.TargetTypes = []string{"container"} + }) + + for _, target := range []string{"maven", "the maven container", "container_maven", "ent_", "ent_bad id", ""} { + _, err := eng.Execute(&domain.ExecuteRequest{CapabilityID: cap.ID, TargetEntityID: target}) + if !errors.Is(err, execution.ErrTargetMalformed) { + t.Errorf("target %q: expected ErrTargetMalformed, got %v", target, err) + } + } + if nexus.calls != 0 { + t.Errorf("malformed targets must not reach Nexus, got %d calls", nexus.calls) + } +} + +func TestExecute_RejectsUnknownTarget(t *testing.T) { + eng, store := newTargetEngine(t, activeNexus()) + cap := mustCreateCapability(t, store, func(c *domain.Capability) { + c.TargetTypes = []string{"container"} + }) + + _, err := eng.Execute(&domain.ExecuteRequest{CapabilityID: cap.ID, TargetEntityID: "ent_does_not_exist"}) + if !errors.Is(err, execution.ErrTargetNotFound) { + t.Fatalf("expected ErrTargetNotFound, got %v", err) + } +} + +func TestExecute_RejectsWrongTargetType(t *testing.T) { + eng, store := newTargetEngine(t, activeNexus()) + cap := mustCreateCapability(t, store, func(c *domain.Capability) { + c.TargetTypes = []string{"container"} + }) + + _, err := eng.Execute(&domain.ExecuteRequest{CapabilityID: cap.ID, TargetEntityID: "ent_host_homesrv"}) + if !errors.Is(err, execution.ErrTargetTypeMismatch) { + t.Fatalf("expected ErrTargetTypeMismatch, got %v", err) + } +} + +func TestExecute_RejectsRetiredTarget(t *testing.T) { + eng, store := newTargetEngine(t, activeNexus()) + cap := mustCreateCapability(t, store, func(c *domain.Capability) { + c.TargetTypes = []string{"container"} + }) + + _, err := eng.Execute(&domain.ExecuteRequest{CapabilityID: cap.ID, TargetEntityID: "ent_retired_thing"}) + if !errors.Is(err, execution.ErrTargetNotActive) { + t.Fatalf("expected ErrTargetNotActive, got %v", err) + } +} + +func TestExecute_AcceptsMatchingTarget(t *testing.T) { + eng, store := newTargetEngine(t, activeNexus()) + cap := mustCreateCapability(t, store, func(c *domain.Capability) { + c.TargetTypes = []string{"container"} + }) + + res, err := eng.Execute(&domain.ExecuteRequest{CapabilityID: cap.ID, TargetEntityID: "ent_container_maven"}) + if err != nil { + t.Fatalf("expected success, got %v", err) + } + if res.Execution.Status != domain.ExecutionSucceeded { + t.Fatalf("expected succeeded, got %q (%s)", res.Execution.Status, res.Execution.Error) + } +} + +// TestExecute_EmptyTargetTypesStillRequiresRealEntity documents the deliberate +// meaning of an empty TargetTypes: no *type* constraint, but the entity must +// still exist and be active. It is not a bypass. +func TestExecute_EmptyTargetTypesStillRequiresRealEntity(t *testing.T) { + eng, store := newTargetEngine(t, activeNexus()) + cap := mustCreateCapability(t, store, func(c *domain.Capability) { + c.TargetTypes = nil + }) + + if _, err := eng.Execute(&domain.ExecuteRequest{CapabilityID: cap.ID, TargetEntityID: "ent_nope"}); !errors.Is(err, execution.ErrTargetNotFound) { + t.Fatalf("expected ErrTargetNotFound, got %v", err) + } + if _, err := eng.Execute(&domain.ExecuteRequest{CapabilityID: cap.ID, TargetEntityID: "ent_host_homesrv"}); err != nil { + t.Fatalf("any existing type should be accepted, got %v", err) + } +} + +// TestExecute_FailsClosedWhenNexusUnreachable is the whole point of the +// guard: a Nexus outage must not degrade into accepting arbitrary targets. +func TestExecute_FailsClosedWhenNexusUnreachable(t *testing.T) { + eng, store := newTargetEngine(t, &fakeNexus{err: errors.New("connection refused")}) + cap := mustCreateCapability(t, store, func(c *domain.Capability) { + c.TargetTypes = []string{"container"} + }) + + _, err := eng.Execute(&domain.ExecuteRequest{CapabilityID: cap.ID, TargetEntityID: "ent_container_maven"}) + if !errors.Is(err, execution.ErrTargetUnverifiable) { + t.Fatalf("expected ErrTargetUnverifiable, got %v", err) + } +} + +// TestCreateConfirmation_ValidatesTarget stops a confirmation being minted +// for a target that execute would later refuse. +func TestCreateConfirmation_ValidatesTarget(t *testing.T) { + eng, store := newTargetEngine(t, activeNexus()) + cap := mustCreateCapability(t, store, func(c *domain.Capability) { + c.TargetTypes = []string{"container"} + c.RequiresConfirmation = true + }) + + if _, err := eng.CreateConfirmation(cap.ID, "just some words", "test", nil); !errors.Is(err, execution.ErrTargetMalformed) { + t.Fatalf("expected ErrTargetMalformed, got %v", err) + } + if _, err := eng.CreateConfirmation(cap.ID, "ent_host_homesrv", "test", nil); !errors.Is(err, execution.ErrTargetTypeMismatch) { + t.Fatalf("expected ErrTargetTypeMismatch, got %v", err) + } + if _, err := eng.CreateConfirmation(cap.ID, "ent_container_maven", "test", nil); err != nil { + t.Fatalf("expected valid target to be accepted, got %v", err) + } +} diff --git a/internal/nexusclient/client.go b/internal/nexusclient/client.go index 4c44683..001d0d0 100644 --- a/internal/nexusclient/client.go +++ b/internal/nexusclient/client.go @@ -8,15 +8,27 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "net/http" + "net/url" "time" ) +// ErrEntityNotFound is returned by GetEntity when Nexus answers 404 — the +// entity ID is well-formed but no such entity exists. It is deliberately +// distinct from a transport error, so callers can tell "this target is not +// real" from "I could not reach Nexus to find out". +var ErrEntityNotFound = errors.New("entity not found") + type Entity struct { ID string `json:"id"` Type string `json:"type"` DisplayName string `json:"display_name"` + // State is "active", "merged", "retired" or "deleted". Empty when the + // value comes from a /resolve response, which does not carry it. + State string `json:"state,omitempty"` + MergedInto string `json:"merged_into,omitempty"` } type Candidate struct { @@ -34,6 +46,10 @@ type ResolveResult struct { type Client interface { Resolve(ctx context.Context, query string, types []string) (*ResolveResult, error) + // GetEntity fetches a single entity by canonical ID. It returns + // ErrEntityNotFound if Nexus reports 404, and a transport/protocol error + // otherwise. + GetEntity(ctx context.Context, id string) (*Entity, error) } type httpClient struct { @@ -78,3 +94,31 @@ func (c *httpClient) Resolve(ctx context.Context, query string, types []string) } return &result, nil } + +func (c *httpClient) GetEntity(ctx context.Context, id string) (*Entity, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api/v1/entities/"+url.PathEscape(id), nil) + if err != nil { + return nil, err + } + req.Header.Set("X-Nexus-Version", "v1") + + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusOK: + case http.StatusNotFound: + return nil, ErrEntityNotFound + default: + return nil, fmt.Errorf("nexus get entity: %s", http.StatusText(resp.StatusCode)) + } + + var entity Entity + if err := json.NewDecoder(resp.Body).Decode(&entity); err != nil { + return nil, fmt.Errorf("decode nexus entity response: %w", err) + } + return &entity, nil +}