Files
hexis/internal/provider/workspace_mcp.go
T
kami c7325a20d4 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
2026-07-30 23:39:13 +04:00

266 lines
6.9 KiB
Go

package provider
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/kami/hexis/internal/domain"
"gopkg.in/yaml.v3"
)
func LoadToolAllowlist(path string) (ToolAllowlist, error) {
data, err := os.ReadFile(path)
if err != nil {
return ToolAllowlist{}, fmt.Errorf("read allowlist: %w", err)
}
var allowlist ToolAllowlist
if err := yaml.Unmarshal(data, &allowlist); err != nil {
return ToolAllowlist{}, fmt.Errorf("parse allowlist: %w", err)
}
if allowlist.Tools == nil {
allowlist.Tools = map[string]ToolMapping{}
}
return allowlist, nil
}
type WorkspaceMCPProvider struct {
mu sync.RWMutex
baseURL string
httpClient *http.Client
tools []WorkspaceTool
allowlist ToolAllowlist
}
type WorkspaceTool struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema any `json:"inputSchema"`
}
type ToolAllowlist struct {
Tools map[string]ToolMapping `yaml:"tools" json:"tools"`
}
type ToolMapping struct {
Capability string `yaml:"capability" json:"capability"`
Risk string `yaml:"risk" json:"risk"`
TargetType string `yaml:"target_type,omitempty" json:"target_type,omitempty"`
ReadOnly bool `yaml:"read_only" json:"read_only"`
SideEffects string `yaml:"side_effects,omitempty" json:"side_effects,omitempty"`
Enabled *bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
AllowParams []string `yaml:"allow_params,omitempty" json:"allow_params,omitempty"`
}
func (r *ToolAllowlist) IsEnabled(name string) bool {
m, ok := r.Tools[name]
if !ok {
return false
}
if m.Enabled != nil && !*m.Enabled {
return false
}
return true
}
func (r *ToolAllowlist) Mapping(name string) (ToolMapping, bool) {
m, ok := r.Tools[name]
return m, ok
}
func NewWorkspaceMCPProvider(baseURL string, allowlist ToolAllowlist) *WorkspaceMCPProvider {
return &WorkspaceMCPProvider{
baseURL: strings.TrimRight(baseURL, "/"),
httpClient: &http.Client{
Timeout: 60 * time.Second,
},
allowlist: allowlist,
}
}
func (p *WorkspaceMCPProvider) Name() string {
return "workspace_mcp"
}
func (p *WorkspaceMCPProvider) DiscoverTools() ([]WorkspaceTool, error) {
resp, err := p.httpClient.Get(fmt.Sprintf("%s/api/tools", p.baseURL))
if err != nil {
return nil, fmt.Errorf("discover workspace tools: %w", err)
}
defer resp.Body.Close()
var result struct {
Tools []WorkspaceTool `json:"tools"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode workspace tools: %w", err)
}
p.mu.Lock()
p.tools = result.Tools
p.mu.Unlock()
return result.Tools, nil
}
func (p *WorkspaceMCPProvider) DiscoveredTools() []WorkspaceTool {
p.mu.RLock()
defer p.mu.RUnlock()
return p.tools
}
func (p *WorkspaceMCPProvider) Execute(capability *domain.Capability, req *domain.ExecuteRequest) (map[string]any, error) {
toolName := p.capabilityToTool(capability.Name)
if toolName == "" {
return nil, fmt.Errorf("no workspace tool mapped for capability %q", capability.Name)
}
mapping, ok := p.allowlist.Mapping(toolName)
if !ok {
return nil, fmt.Errorf("tool %q not in allowlist", toolName)
}
if !p.allowlist.IsEnabled(toolName) {
return nil, fmt.Errorf("tool %q is disabled in allowlist", toolName)
}
args := map[string]any{}
if req.Arguments != nil {
if len(mapping.AllowParams) > 0 {
for k, v := range req.Arguments {
for _, allowed := range mapping.AllowParams {
if k == allowed {
args[k] = v
break
}
}
}
} else {
args = req.Arguments
}
}
body, _ := json.Marshal(args)
resp, err := p.httpClient.Post(
fmt.Sprintf("%s/api/tool/%s", p.baseURL, toolName),
"application/json",
bytes.NewReader(body),
)
if err != nil {
return nil, fmt.Errorf("call workspace tool %q: %w", toolName, err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read workspace tool response: %w", err)
}
if resp.StatusCode >= 400 {
return map[string]any{
"error": string(respBody),
"http_status": resp.StatusCode,
}, fmt.Errorf("workspace tool %q returned %d: %s", toolName, resp.StatusCode, string(respBody))
}
var result any
if err := json.Unmarshal(respBody, &result); err != nil {
return map[string]any{
"raw": string(respBody),
}, nil
}
// workspace-mcp catches tool-handler exceptions and reports them as an
// ERROR-coded warning inside a 200 response envelope rather than an HTTP
// error status, so a plain status-code check would report every such
// failure as a successful execution. Surface it as an error here so it
// maps to Hexis's failed execution status instead.
if envelopeErr := workspaceEnvelopeError(result); envelopeErr != "" {
return map[string]any{"result": result}, fmt.Errorf("workspace tool %q reported an error: %s", toolName, envelopeErr)
}
return map[string]any{
"result": result,
}, nil
}
func workspaceEnvelopeError(result any) string {
envelope, ok := result.(map[string]any)
if !ok {
return ""
}
warnings, ok := envelope["warnings"].([]any)
if !ok {
return ""
}
for _, w := range warnings {
warning, ok := w.(map[string]any)
if !ok {
continue
}
if code, _ := warning["code"].(string); code == "ERROR" {
message, _ := warning["message"].(string)
return message
}
}
return ""
}
func (p *WorkspaceMCPProvider) capabilityToTool(capName string) string {
for toolName, mapping := range p.allowlist.Tools {
if mapping.Capability == capName {
return toolName
}
}
return ""
}
func (p *WorkspaceMCPProvider) BuildCapabilities() []domain.Capability {
var caps []domain.Capability
for toolName, mapping := range p.allowlist.Tools {
if !p.allowlist.IsEnabled(toolName) {
continue
}
readOnly := mapping.ReadOnly
if mapping.Risk == "read" {
readOnly = true
}
now := time.Now().UTC()
caps = append(caps, domain.Capability{
ID: fmt.Sprintf("cap_ws_%s", strings.ReplaceAll(mapping.Capability, ".", "_")),
Name: mapping.Capability,
Description: fmt.Sprintf("Workspace tool: %s", toolName),
TargetTypes: ifString(mapping.TargetType != "", []string{mapping.TargetType}, nil),
TargetEntityID: "",
Provider: "workspace_mcp",
Operation: toolName,
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,
})
}
return caps
}
func ifString(cond bool, a, b []string) []string {
if cond {
return a
}
return b
}