Require auth on /api/v1/ and derive capability guards server-side
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
This commit is contained in:
@@ -28,7 +28,7 @@ func main() {
|
||||
os.Args = append(os.Args[:1], os.Args[3:]...)
|
||||
}
|
||||
|
||||
cli = client.New(baseURL)
|
||||
cli = client.New(baseURL).WithToken(os.Getenv("HEXIS_API_TOKEN"))
|
||||
|
||||
cmd := os.Args[1]
|
||||
args := os.Args[2:]
|
||||
|
||||
+37
-1
@@ -7,9 +7,12 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/kami/hexis/internal/api"
|
||||
"github.com/kami/hexis/internal/domain"
|
||||
"github.com/kami/hexis/internal/execution"
|
||||
"github.com/kami/hexis/internal/mcp"
|
||||
"github.com/kami/hexis/internal/nexusclient"
|
||||
@@ -79,10 +82,26 @@ func main() {
|
||||
log.Printf("discovered %d workspace tools, %d in allowlist", len(tools), len(allowlist.Tools))
|
||||
}
|
||||
|
||||
// Register workspace capabilities
|
||||
// Register workspace capabilities, reconciling existing rows so that
|
||||
// allowlist edits (risk tier, target type, enabled) reach a database
|
||||
// that was populated by an earlier build.
|
||||
for _, cap := range wsProvider.BuildCapabilities() {
|
||||
existing, err := store.GetCapability(cap.ID)
|
||||
if err == nil && existing != nil {
|
||||
if !capabilityMatches(existing, &cap) {
|
||||
merged := cap
|
||||
merged.CreatedAt = existing.CreatedAt
|
||||
merged.UpdatedAt = time.Now().UTC()
|
||||
merged.Attributes = existing.Attributes
|
||||
// UpdateCapability is optimistically concurrent: it matches
|
||||
// on the current version and bumps it.
|
||||
merged.Version = existing.Version
|
||||
if err := store.UpdateCapability(&merged); err != nil {
|
||||
log.Printf("warning: reconcile capability %s: %v", cap.Name, err)
|
||||
} else {
|
||||
log.Printf("reconciled capability: %s -> %s (version %d)", merged.Name, merged.Operation, merged.Version)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := store.CreateCapability(&cap); err != nil {
|
||||
@@ -124,3 +143,20 @@ func main() {
|
||||
srv.Shutdown(ctx)
|
||||
log.Println("stopped")
|
||||
}
|
||||
|
||||
// capabilityMatches reports whether the stored capability already carries every
|
||||
// derived field, ignoring bookkeeping (timestamps, version, attributes).
|
||||
func capabilityMatches(stored, derived *domain.Capability) bool {
|
||||
return stored.Name == derived.Name &&
|
||||
stored.Description == derived.Description &&
|
||||
slices.Equal(stored.TargetTypes, derived.TargetTypes) &&
|
||||
stored.TargetEntityID == derived.TargetEntityID &&
|
||||
stored.Provider == derived.Provider &&
|
||||
stored.Operation == derived.Operation &&
|
||||
stored.Risk == derived.Risk &&
|
||||
stored.ReadOnly == derived.ReadOnly &&
|
||||
stored.ExpectedSideEffects == derived.ExpectedSideEffects &&
|
||||
stored.RequiresConfirmation == derived.RequiresConfirmation &&
|
||||
stored.Enabled == derived.Enabled &&
|
||||
stored.TimeoutSeconds == derived.TimeoutSeconds
|
||||
}
|
||||
|
||||
+57
-34
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
@@ -11,15 +12,20 @@ import (
|
||||
"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
|
||||
}
|
||||
|
||||
func NewHandler(store storage.Interface, engine *execution.Engine) *Handler {
|
||||
return &Handler{store: store, engine: engine}
|
||||
// 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
|
||||
@@ -37,10 +43,42 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
||||
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/", versionCheck(api))
|
||||
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 {
|
||||
@@ -88,28 +126,10 @@ func (h *Handler) listCapabilities(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusInternalServerError, errorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
if caps == nil {
|
||||
caps = []*domain.Capability{}
|
||||
}
|
||||
|
||||
var apiCaps []map[string]any
|
||||
for _, c := range caps {
|
||||
apiCaps = append(apiCaps, map[string]any{
|
||||
"capability_id": c.ID,
|
||||
"id": c.ID,
|
||||
"name": c.Name,
|
||||
"description": c.Description,
|
||||
"target_types": c.TargetTypes,
|
||||
"target_entity_id": c.TargetEntityID,
|
||||
"provider": c.Provider,
|
||||
"operation": c.Operation,
|
||||
"risk": c.Risk,
|
||||
"read_only": c.ReadOnly,
|
||||
"expected_side_effects": c.ExpectedSideEffects,
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, apiCaps)
|
||||
// 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) {
|
||||
@@ -123,8 +143,6 @@ func (h *Handler) createCapability(w http.ResponseWriter, r *http.Request) {
|
||||
Risk string `json:"risk,omitempty"`
|
||||
ReadOnly bool `json:"read_only"`
|
||||
ExpectedSideEffects string `json:"expected_side_effects,omitempty"`
|
||||
RequiresConfirmation bool `json:"requires_confirmation,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
|
||||
Attributes map[string]any `json:"attributes,omitempty"`
|
||||
}
|
||||
@@ -137,11 +155,16 @@ func (h *Handler) createCapability(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Destructive capabilities are disabled by default and must be turned on
|
||||
// explicitly (ECOSYSTEM-SPEC.md §4.3).
|
||||
enabled := req.Risk != "destructive"
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
// `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()
|
||||
@@ -156,9 +179,9 @@ func (h *Handler) createCapability(w http.ResponseWriter, r *http.Request) {
|
||||
Risk: req.Risk,
|
||||
ReadOnly: req.ReadOnly,
|
||||
ExpectedSideEffects: req.ExpectedSideEffects,
|
||||
RequiresConfirmation: req.RequiresConfirmation,
|
||||
RequiresConfirmation: requiresConfirmation,
|
||||
Enabled: enabled,
|
||||
TimeoutSeconds: req.TimeoutSeconds,
|
||||
TimeoutSeconds: timeoutSeconds,
|
||||
Attributes: req.Attributes,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -23,9 +25,11 @@ func newTestHandler(t *testing.T) (*Handler, *storage.Store) {
|
||||
}
|
||||
t.Cleanup(func() { store.Close() })
|
||||
engine := execution.New(store, provider.NewRegistry())
|
||||
return NewHandler(store, engine), store
|
||||
return NewHandler(store, engine, testToken), store
|
||||
}
|
||||
|
||||
const testToken = "test-token"
|
||||
|
||||
// TestHandleChanges_SinceFiltersEvents verifies the `since` query param
|
||||
// actually filters to events after that sequence number, rather than being
|
||||
// silently reset to 0 (the bug: `since = 0` unconditionally, ignoring the
|
||||
@@ -78,3 +82,211 @@ func itoa(n int64) string {
|
||||
b, _ := json.Marshal(n)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// TestAPIV1_RequiresBearerToken pins finding 2: /api/v1/ must not be reachable
|
||||
// without the shared token, while /health stays open for probes.
|
||||
func TestAPIV1_RequiresBearerToken(t *testing.T) {
|
||||
h, _ := newTestHandler(t)
|
||||
mux := http.NewServeMux()
|
||||
h.Register(mux)
|
||||
|
||||
body := `{"capability_id":"cap_x","target_entity_id":"ent_x"}`
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
header string
|
||||
want int
|
||||
}{
|
||||
{"no header", "", http.StatusUnauthorized},
|
||||
{"wrong token", "Bearer nope", http.StatusUnauthorized},
|
||||
{"not bearer", "Basic " + testToken, http.StatusUnauthorized},
|
||||
{"correct token", "Bearer " + testToken, http.StatusNotFound}, // past auth: capability missing
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/execute", strings.NewReader(body))
|
||||
if tc.header != "" {
|
||||
req.Header.Set("Authorization", tc.header)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
if w.Code != tc.want {
|
||||
t.Fatalf("expected %d, got %d: %s", tc.want, w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// /health must remain unauthenticated.
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/health", nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected /health to stay open, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPIV1_UnsetTokenFailsClosed: an empty configured token must reject the
|
||||
// API outright, never serve it openly.
|
||||
func TestAPIV1_UnsetTokenFailsClosed(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "hexis.db")
|
||||
store, err := storage.Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { store.Close() })
|
||||
h := NewHandler(store, execution.New(store, provider.NewRegistry()), "")
|
||||
mux := http.NewServeMux()
|
||||
h.Register(mux)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/capabilities", nil)
|
||||
req.Header.Set("Authorization", "Bearer anything")
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("expected 503 when no token is configured, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateCapability_GuardsAreServerDerived pins finding 2: a caller cannot
|
||||
// enable a destructive capability or opt out of confirmation via the body.
|
||||
func TestCreateCapability_GuardsAreServerDerived(t *testing.T) {
|
||||
h, _ := newTestHandler(t)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantEnabled bool
|
||||
wantRequiresConfirmation bool
|
||||
}{
|
||||
{
|
||||
name: "destructive cannot be enabled from the body",
|
||||
body: `{"name":"prune","provider":"workspace_mcp","operation":"docker.prune","risk":"destructive","enabled":true}`,
|
||||
wantEnabled: false, wantRequiresConfirmation: true,
|
||||
},
|
||||
{
|
||||
name: "medium risk gets confirmation even when the body says otherwise",
|
||||
body: `{"name":"stop","provider":"workspace_mcp","operation":"docker.stop_container","risk":"medium","requires_confirmation":false}`,
|
||||
wantEnabled: true, wantRequiresConfirmation: true,
|
||||
},
|
||||
{
|
||||
name: "read risk stays confirmation-free",
|
||||
body: `{"name":"list","provider":"workspace_mcp","operation":"docker.list_containers","risk":"read"}`,
|
||||
wantEnabled: true, wantRequiresConfirmation: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/capabilities", strings.NewReader(tc.body))
|
||||
w := httptest.NewRecorder()
|
||||
h.createCapability(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var cap domain.Capability
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &cap); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if cap.Enabled != tc.wantEnabled {
|
||||
t.Errorf("enabled = %v, want %v", cap.Enabled, tc.wantEnabled)
|
||||
}
|
||||
if cap.RequiresConfirmation != tc.wantRequiresConfirmation {
|
||||
t.Errorf("requires_confirmation = %v, want %v", cap.RequiresConfirmation, tc.wantRequiresConfirmation)
|
||||
}
|
||||
if cap.TimeoutSeconds != domain.DefaultCapabilityTimeoutSeconds {
|
||||
t.Errorf("timeout_seconds = %d, want %d", cap.TimeoutSeconds, domain.DefaultCapabilityTimeoutSeconds)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecute_WorkspaceCapabilityEndToEnd pins finding 1: a capability
|
||||
// registered by BuildCapabilities must actually execute, not 403 as disabled.
|
||||
func TestExecute_WorkspaceCapabilityEndToEnd(t *testing.T) {
|
||||
ws := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/tool/docker.list_containers" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"containers":["hexis"]}`))
|
||||
}))
|
||||
t.Cleanup(ws.Close)
|
||||
|
||||
allowlistPath := filepath.Join(t.TempDir(), "allowlist.yaml")
|
||||
allowlistYAML := "tools:\n" +
|
||||
" docker.list_containers:\n" +
|
||||
" capability: workspace.docker.list\n" +
|
||||
" risk: read\n" +
|
||||
" read_only: true\n"
|
||||
if err := os.WriteFile(allowlistPath, []byte(allowlistYAML), 0o600); err != nil {
|
||||
t.Fatalf("write allowlist: %v", err)
|
||||
}
|
||||
allowlist, err := provider.LoadToolAllowlist(allowlistPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load allowlist: %v", err)
|
||||
}
|
||||
|
||||
store, err := storage.Open(filepath.Join(t.TempDir(), "hexis.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { store.Close() })
|
||||
|
||||
wsProvider := provider.NewWorkspaceMCPProvider(ws.URL, allowlist)
|
||||
reg := provider.NewRegistry()
|
||||
reg.Register(wsProvider)
|
||||
|
||||
caps := wsProvider.BuildCapabilities()
|
||||
if len(caps) != 1 {
|
||||
t.Fatalf("expected 1 built capability, got %d", len(caps))
|
||||
}
|
||||
built := caps[0]
|
||||
if !built.Enabled {
|
||||
t.Fatalf("BuildCapabilities produced a disabled capability: %+v", built)
|
||||
}
|
||||
if built.TimeoutSeconds <= 0 {
|
||||
t.Fatalf("BuildCapabilities produced timeout_seconds=%d", built.TimeoutSeconds)
|
||||
}
|
||||
if err := store.CreateCapability(&built); err != nil {
|
||||
t.Fatalf("create capability: %v", err)
|
||||
}
|
||||
|
||||
h := NewHandler(store, execution.New(store, reg), testToken)
|
||||
mux := http.NewServeMux()
|
||||
h.Register(mux)
|
||||
|
||||
body := `{"capability_id":"` + built.ID + `","target_entity_id":"ent_host_homesrv"}`
|
||||
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 != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var exec domain.Execution
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &exec); err != nil {
|
||||
t.Fatalf("decode execution: %v", err)
|
||||
}
|
||||
if exec.Status != domain.ExecutionSucceeded {
|
||||
t.Fatalf("expected succeeded, got %q (error: %s)", exec.Status, exec.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildCapabilities_RiskDerivation pins that mutating workspace tools are
|
||||
// registered enabled but confirmation-gated.
|
||||
func TestBuildCapabilities_RiskDerivation(t *testing.T) {
|
||||
allowlist := provider.ToolAllowlist{Tools: map[string]provider.ToolMapping{
|
||||
"docker.stop_container": {Capability: "workspace.docker.stop", Risk: "medium", TargetType: "container"},
|
||||
}}
|
||||
caps := provider.NewWorkspaceMCPProvider("http://unused", allowlist).BuildCapabilities()
|
||||
if len(caps) != 1 {
|
||||
t.Fatalf("expected 1 capability, got %d", len(caps))
|
||||
}
|
||||
if !caps[0].Enabled {
|
||||
t.Errorf("medium-risk capability should be enabled")
|
||||
}
|
||||
if !caps[0].RequiresConfirmation {
|
||||
t.Errorf("medium-risk capability must require confirmation")
|
||||
}
|
||||
}
|
||||
|
||||
+4
-33
@@ -2,11 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/kami/hexis/internal/execution"
|
||||
"github.com/kami/hexis/internal/storage"
|
||||
@@ -15,40 +11,15 @@ import (
|
||||
type Server struct {
|
||||
handler *Handler
|
||||
httpSrv *http.Server
|
||||
socket string
|
||||
}
|
||||
|
||||
func NewServer(store *storage.Store, engine *execution.Engine) *Server {
|
||||
handler := NewHandler(store, engine)
|
||||
// NewServer builds the HTTP server. authToken is the shared bearer token
|
||||
// required on /api/v1/; see Handler.requireAuth.
|
||||
func NewServer(store *storage.Store, engine *execution.Engine, authToken string) *Server {
|
||||
handler := NewHandler(store, engine, authToken)
|
||||
return &Server{handler: handler}
|
||||
}
|
||||
|
||||
func (s *Server) ListenUnix(socketPath string) error {
|
||||
dir := filepath.Dir(socketPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("create socket directory: %w", err)
|
||||
}
|
||||
|
||||
os.Remove(socketPath)
|
||||
|
||||
listener, err := net.Listen("unix", socketPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen unix: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Chmod(socketPath, 0660); err != nil {
|
||||
listener.Close()
|
||||
return fmt.Errorf("chmod socket: %w", err)
|
||||
}
|
||||
|
||||
s.socket = socketPath
|
||||
|
||||
mux := http.NewServeMux()
|
||||
s.handler.Register(mux)
|
||||
|
||||
return http.Serve(listener, mux)
|
||||
}
|
||||
|
||||
func (s *Server) ListenHTTP(addr string) error {
|
||||
mux := http.NewServeMux()
|
||||
s.handler.Register(mux)
|
||||
|
||||
@@ -36,10 +36,40 @@ type Capability struct {
|
||||
Version int64 `json:"version"`
|
||||
}
|
||||
|
||||
// IsDestructive reports whether the capability's risk tier requires it to be
|
||||
// disabled by default per ECOSYSTEM-SPEC.md §4.3.
|
||||
func (c *Capability) IsDestructive() bool {
|
||||
return c.Risk == "destructive"
|
||||
// Risk tiers. ECOSYSTEM-SPEC.md §4.1 names read/low/protected/destructive; the
|
||||
// workspace allowlist additionally uses medium/high, which map onto the same
|
||||
// two guard decisions below.
|
||||
const (
|
||||
RiskRead = "read"
|
||||
RiskLow = "low"
|
||||
RiskMedium = "medium"
|
||||
RiskProtected = "protected"
|
||||
RiskHigh = "high"
|
||||
RiskDestructive = "destructive"
|
||||
)
|
||||
|
||||
// DefaultCapabilityTimeoutSeconds is the per-capability wall-clock timeout
|
||||
// applied when a registration does not specify one (ECOSYSTEM-SPEC.md §4.3).
|
||||
const DefaultCapabilityTimeoutSeconds = 30
|
||||
|
||||
// EnabledForRisk reports the server-derived `enabled` value for a risk tier.
|
||||
// Destructive capabilities are disabled by default and must be turned on
|
||||
// explicitly out of band (ECOSYSTEM-SPEC.md §4.3). This is never overridable
|
||||
// by an API caller.
|
||||
func EnabledForRisk(risk string) bool {
|
||||
return risk != RiskDestructive
|
||||
}
|
||||
|
||||
// RequiresConfirmationForRisk reports the server-derived
|
||||
// `requires_confirmation` value for a risk tier. Only genuinely read-only and
|
||||
// low-risk tiers skip confirmation; unrecognised tiers fail closed.
|
||||
func RequiresConfirmationForRisk(risk string) bool {
|
||||
switch risk {
|
||||
case "", RiskRead, RiskLow:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
type ExecutionStatus string
|
||||
|
||||
@@ -243,6 +243,12 @@ func (p *WorkspaceMCPProvider) BuildCapabilities() []domain.Capability {
|
||||
Risk: mapping.Risk,
|
||||
ReadOnly: readOnly,
|
||||
ExpectedSideEffects: mapping.SideEffects,
|
||||
// Derived server-side from the risk tier, exactly as the HTTP
|
||||
// registration path does (ECOSYSTEM-SPEC.md §4.3). Leaving these
|
||||
// zero-valued registered every workspace capability disabled.
|
||||
Enabled: domain.EnabledForRisk(mapping.Risk),
|
||||
RequiresConfirmation: domain.RequiresConfirmationForRisk(mapping.Risk),
|
||||
TimeoutSeconds: domain.DefaultCapabilityTimeoutSeconds,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Version: 1,
|
||||
|
||||
Reference in New Issue
Block a user