mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 00:14:33 +00:00
130 lines
3.9 KiB
Go
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
|
|
}
|