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
125 lines
3.5 KiB
Go
125 lines
3.5 KiB
Go
// Package nexusclient is the minimal Nexus resolve client used by Hexis's
|
|
// hexis.resolve_target MCP tool to turn a free-text query into a canonical
|
|
// entity ID, matching the /api/v1/resolve contract Nexus and Praxis already
|
|
// speak.
|
|
package nexusclient
|
|
|
|
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 {
|
|
EntityID string `json:"entity_id"`
|
|
DisplayName string `json:"display_name"`
|
|
Score float64 `json:"score"`
|
|
}
|
|
|
|
type ResolveResult struct {
|
|
Status string `json:"status"`
|
|
Entity *Entity `json:"entity,omitempty"`
|
|
Score float64 `json:"score,omitempty"`
|
|
Candidates []Candidate `json:"candidates,omitempty"`
|
|
}
|
|
|
|
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 {
|
|
baseURL string
|
|
http *http.Client
|
|
}
|
|
|
|
func New(baseURL string) Client {
|
|
return &httpClient{baseURL: baseURL, http: &http.Client{Timeout: 10 * time.Second}}
|
|
}
|
|
|
|
func (c *httpClient) Resolve(ctx context.Context, query string, types []string) (*ResolveResult, error) {
|
|
body := map[string]any{"query": query}
|
|
if len(types) > 0 {
|
|
body["types"] = types
|
|
}
|
|
data, err := json.Marshal(body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/resolve", bytes.NewReader(data))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-Nexus-Version", "v1")
|
|
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("nexus resolve: %s", http.StatusText(resp.StatusCode))
|
|
}
|
|
|
|
var result ResolveResult
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil, fmt.Errorf("decode nexus resolve response: %w", err)
|
|
}
|
|
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
|
|
}
|