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,50 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/kami/hexis/internal/domain"
|
||||
)
|
||||
|
||||
type Provider interface {
|
||||
Name() string
|
||||
Execute(capability *domain.Capability, req *domain.ExecuteRequest) (map[string]any, error)
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
providers map[string]Provider
|
||||
}
|
||||
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{
|
||||
providers: make(map[string]Provider),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Registry) Register(p Provider) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.providers[p.Name()] = p
|
||||
}
|
||||
|
||||
func (r *Registry) Get(name string) (Provider, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
p, ok := r.providers[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("provider %q not found", name)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (r *Registry) List() []string {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
var names []string
|
||||
for n := range r.providers {
|
||||
names = append(names, n)
|
||||
}
|
||||
return names
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/kami/hexis/internal/domain"
|
||||
)
|
||||
|
||||
type SystemdProvider struct{}
|
||||
|
||||
func NewSystemdProvider() *SystemdProvider {
|
||||
return &SystemdProvider{}
|
||||
}
|
||||
|
||||
func (p *SystemdProvider) Name() string { return "systemd" }
|
||||
|
||||
func (p *SystemdProvider) Execute(capability *domain.Capability, req *domain.ExecuteRequest) (map[string]any, error) {
|
||||
unitName := req.TargetEntityID
|
||||
if unitName == "" {
|
||||
unitName = capability.TargetEntityID
|
||||
}
|
||||
|
||||
if !isValidUnitName(unitName) {
|
||||
return nil, fmt.Errorf("invalid unit name: %q", unitName)
|
||||
}
|
||||
|
||||
var cmd *exec.Cmd
|
||||
switch capability.Operation {
|
||||
case "restart":
|
||||
cmd = exec.Command("systemctl", "restart", unitName)
|
||||
case "start":
|
||||
cmd = exec.Command("systemctl", "start", unitName)
|
||||
case "stop":
|
||||
cmd = exec.Command("systemctl", "stop", unitName)
|
||||
case "status":
|
||||
cmd = exec.Command("systemctl", "status", unitName)
|
||||
case "reload":
|
||||
cmd = exec.Command("systemctl", "reload", unitName)
|
||||
case "enable":
|
||||
cmd = exec.Command("systemctl", "enable", unitName)
|
||||
case "disable":
|
||||
cmd = exec.Command("systemctl", "disable", unitName)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported systemd operation: %s", capability.Operation)
|
||||
}
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return map[string]any{
|
||||
"stdout": string(output),
|
||||
"exit_code": cmd.ProcessState.ExitCode(),
|
||||
}, fmt.Errorf("systemctl %s failed: %w\n%s", capability.Operation, err, string(output))
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"stdout": string(output),
|
||||
"exit_code": 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func isValidUnitName(name string) bool {
|
||||
if name == "" || strings.Contains(name, "..") || strings.Contains(name, "/") || strings.Contains(name, ";") || strings.Contains(name, "|") || strings.Contains(name, "$") || strings.Contains(name, "`") || strings.Contains(name, "'") || strings.Contains(name, `"`) || strings.Contains(name, "\\") {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -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