Files

130 lines
3.9 KiB
Go

// Package handlers provides hook handler implementations for the agent hook system.
package handlers
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"runtime"
"github.com/nextlevelbuilder/goclaw/internal/edition"
"github.com/nextlevelbuilder/goclaw/internal/hooks"
)
// CommandHandler executes a local shell command with event data on stdin.
// stdout is parsed as a JSON decision payload; stderr is captured for audit.
// Exit 2 → block; exit 0 → allow; other → error.
// Command handler is only permitted on the Lite edition (C2 drop).
type CommandHandler struct {
Edition edition.Edition
}
// Execute implements hooks.Handler.
func (h *CommandHandler) Execute(ctx context.Context, cfg hooks.HookConfig, ev hooks.Event) (hooks.Decision, error) {
// Defense-in-depth: re-check edition at dispatch even if edition gate
// already filtered at config validation time.
if ok, reason := (hooks.HookEditionPolicy{}).Allow(hooks.HandlerCommand, cfg.Scope, h.Edition); !ok {
return hooks.DecisionError, fmt.Errorf("hook: command handler not allowed: %s", reason)
}
// Command and allowed env vars are stored in cfg.Config map.
cmd, _ := cfg.Config["command"].(string)
if cmd == "" {
return hooks.DecisionError, fmt.Errorf("hook: command handler: missing 'command' in config")
}
var allowedVars []string
if raw, ok := cfg.Config["allowed_env_vars"]; ok {
if arr, ok := raw.([]any); ok {
for _, v := range arr {
if s, ok := v.(string); ok {
allowedVars = append(allowedVars, s)
}
}
}
}
// Serialize event to JSON for stdin.
eventJSON, err := json.Marshal(ev)
if err != nil {
return hooks.DecisionError, fmt.Errorf("hook: command handler: marshal event: %w", err)
}
sh, args, err := shellCommand(cmd)
if err != nil {
return hooks.DecisionError, fmt.Errorf("hook: command handler: %w", err)
}
//nolint:gosec // Command comes from admin-configured hooks stored in DB, not user input.
c := exec.CommandContext(ctx, sh, args...)
c.Stdin = bytes.NewReader(eventJSON)
c.Env = buildAllowedEnv(allowedVars)
var stderr bytes.Buffer
c.Stderr = &stderr
out, execErr := c.Output()
if execErr != nil {
if exitErr, ok := execErr.(*exec.ExitError); ok {
switch exitErr.ExitCode() {
case 2:
return hooks.DecisionBlock, nil
default:
return hooks.DecisionError, fmt.Errorf("hook: command exited %d: %s", exitErr.ExitCode(), stderr.String())
}
}
return hooks.DecisionError, fmt.Errorf("hook: command exec: %w", execErr)
}
// Exit 0: parse stdout as JSON decision payload.
if len(out) == 0 {
return hooks.DecisionAllow, nil
}
var resp struct {
Continue *bool `json:"continue"`
}
if err := json.Unmarshal(out, &resp); err != nil {
// Non-JSON stdout on exit 0 is treated as allow.
return hooks.DecisionAllow, nil
}
if resp.Continue != nil && !*resp.Continue {
return hooks.DecisionBlock, nil
}
return hooks.DecisionAllow, nil
}
// shellCommand returns the platform shell invocation for an admin-configured command.
func shellCommand(cmd string) (string, []string, error) {
if runtime.GOOS == "windows" {
for _, shell := range []string{"pwsh", "powershell"} {
if p, err := exec.LookPath(shell); err == nil {
return p, []string{"-NoProfile", "-NonInteractive", "-Command", cmd}, nil
}
}
return "", nil, fmt.Errorf("PowerShell shell not found")
}
if p, err := exec.LookPath("sh"); err == nil {
return p, []string{"-c", cmd}, nil
}
return "/bin/sh", []string{"-c", cmd}, nil
}
// buildAllowedEnv constructs an env slice containing only the listed keys from
// the current process environment. If allowedVars is empty, env is isolated
// (security-safe default: hooks cannot exfiltrate process secrets).
func buildAllowedEnv(allowedVars []string) []string {
if len(allowedVars) == 0 {
return nil
}
env := make([]string, 0, len(allowedVars))
for _, key := range allowedVars {
if val, ok := os.LookupEnv(key); ok {
env = append(env, key+"="+val)
}
}
return env
}