Initial commit: Hexis capability registry + execution service baseline
Go daemon (hexisd/hexisctl) implementing capability registry, guarded execution (confirmations, blessed-entity checks), systemd/workspace-mcp providers per ECOSYSTEM-SPEC.md. Snapshotting existing working state before further development.
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
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
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"result": result,
|
||||
}, nil
|
||||
}
|
||||
|
||||
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,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Version: 1,
|
||||
})
|
||||
}
|
||||
return caps
|
||||
}
|
||||
|
||||
func ifString(cond bool, a, b []string) []string {
|
||||
if cond {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
Reference in New Issue
Block a user