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
This commit is contained in:
kami
2026-07-30 23:39:40 +04:00
parent d4285607af
commit 47be24c4cc
6 changed files with 476 additions and 22 deletions
+114 -15
View File
@@ -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) {