mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-12 04:12:09 +00:00
bdb60de7ae
- Update go.mod and Dockerfile to Go 1.26 - Apply `go fix ./...` stdlib modernizations across 170+ files - Add `go fix` to post-implementation checklist in CLAUDE.md - Fix go fix misapplied rewrite in loop_history.go
208 lines
5.0 KiB
Go
208 lines
5.0 KiB
Go
package providers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// BuildCLIHooksConfig generates a Claude CLI settings file with PreToolUse hooks
|
|
// that enforce GoClaw's security policies (shell deny patterns, path restrictions).
|
|
// Returns settings file path and a cleanup function.
|
|
func BuildCLIHooksConfig(workspace string, restrictToWorkspace bool) (string, func(), error) {
|
|
tmpDir := filepath.Join(os.TempDir(), "goclaw-cli-hooks")
|
|
if err := os.MkdirAll(tmpDir, 0755); err != nil {
|
|
return "", nil, fmt.Errorf("create hooks dir: %w", err)
|
|
}
|
|
|
|
id := uuid.New().String()[:8]
|
|
|
|
// Write the hook script
|
|
hookScript := generateHookScript(workspace, restrictToWorkspace)
|
|
hookPath := filepath.Join(tmpDir, fmt.Sprintf("hook-%s.sh", id))
|
|
if err := os.WriteFile(hookPath, []byte(hookScript), 0755); err != nil {
|
|
return "", nil, fmt.Errorf("write hook script: %w", err)
|
|
}
|
|
|
|
// Write settings JSON
|
|
settings := generateSettingsJSON(hookPath)
|
|
settingsPath := filepath.Join(tmpDir, fmt.Sprintf("settings-%s.json", id))
|
|
if err := os.WriteFile(settingsPath, settings, 0600); err != nil {
|
|
os.Remove(hookPath)
|
|
return "", nil, fmt.Errorf("write settings: %w", err)
|
|
}
|
|
|
|
cleanup := func() {
|
|
os.Remove(hookPath)
|
|
os.Remove(settingsPath)
|
|
}
|
|
|
|
return settingsPath, cleanup, nil
|
|
}
|
|
|
|
// generateSettingsJSON creates Claude CLI settings with PreToolUse hooks.
|
|
func generateSettingsJSON(hookPath string) []byte {
|
|
settings := map[string]any{
|
|
"hooks": map[string]any{
|
|
"PreToolUse": []map[string]any{
|
|
{
|
|
"matcher": "Bash",
|
|
"hooks": []map[string]any{
|
|
{"type": "command", "command": hookPath},
|
|
},
|
|
},
|
|
{
|
|
"matcher": "Write",
|
|
"hooks": []map[string]any{
|
|
{"type": "command", "command": hookPath},
|
|
},
|
|
},
|
|
{
|
|
"matcher": "Edit",
|
|
"hooks": []map[string]any{
|
|
{"type": "command", "command": hookPath},
|
|
},
|
|
},
|
|
{
|
|
"matcher": "Read",
|
|
"hooks": []map[string]any{
|
|
{"type": "command", "command": hookPath},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
data, _ := json.MarshalIndent(settings, "", " ")
|
|
return data
|
|
}
|
|
|
|
// generateHookScript creates a bash script that enforces GoClaw security policies.
|
|
func generateHookScript(workspace string, restrictToWorkspace bool) string {
|
|
var sb strings.Builder
|
|
|
|
sb.WriteString(`#!/bin/bash
|
|
set -euo pipefail
|
|
|
|
# GoClaw security hook for Claude CLI PreToolUse.
|
|
# Checks shell deny patterns and workspace path restrictions.
|
|
|
|
INPUT=$(cat)
|
|
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
|
|
TOOL_INPUT=$(echo "$INPUT" | jq -c '.tool_input // {}')
|
|
|
|
allow() {
|
|
echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}'
|
|
exit 0
|
|
}
|
|
|
|
deny() {
|
|
local reason="$1"
|
|
echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"$reason\"}}"
|
|
exit 0
|
|
}
|
|
|
|
`)
|
|
|
|
// Shell deny patterns check
|
|
sb.WriteString(`# === Shell command deny patterns ===
|
|
check_shell_deny() {
|
|
local cmd="$1"
|
|
local patterns=(
|
|
`)
|
|
|
|
for _, p := range ShellDenyPatterns {
|
|
// Escape single quotes for bash
|
|
escaped := strings.ReplaceAll(p, `'`, `'\''`)
|
|
fmt.Fprintf(&sb, " '%s'\n", escaped)
|
|
}
|
|
|
|
sb.WriteString(` )
|
|
|
|
for pattern in "${patterns[@]}"; do
|
|
if echo "$cmd" | grep -qE "$pattern" 2>/dev/null; then
|
|
deny "security: shell command blocked by deny pattern"
|
|
fi
|
|
done
|
|
}
|
|
|
|
`)
|
|
|
|
// Path restriction check
|
|
if restrictToWorkspace && workspace != "" {
|
|
// Escape workspace path for safe bash embedding (single quotes + quote escaping)
|
|
safeWorkspace := strings.ReplaceAll(workspace, `'`, `'\''`)
|
|
fmt.Fprintf(&sb, `# === Workspace path restriction ===
|
|
WORKSPACE='%s'
|
|
|
|
check_path_restriction() {
|
|
local file_path="$1"
|
|
# Resolve all paths (including relative) to absolute for proper checking
|
|
local resolved
|
|
resolved=$(realpath -m "$file_path" 2>/dev/null || echo "$file_path")
|
|
if [[ "$resolved" != "$WORKSPACE"* ]]; then
|
|
deny "security: path outside workspace boundary"
|
|
fi
|
|
}
|
|
|
|
`, safeWorkspace)
|
|
}
|
|
|
|
// Main dispatch
|
|
sb.WriteString(`# === Main ===
|
|
case "$TOOL_NAME" in
|
|
Bash)
|
|
CMD=$(echo "$TOOL_INPUT" | jq -r '.command // empty')
|
|
if [ -n "$CMD" ]; then
|
|
check_shell_deny "$CMD"
|
|
fi
|
|
;;
|
|
Write)
|
|
FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.file_path // empty')
|
|
`)
|
|
|
|
if restrictToWorkspace && workspace != "" {
|
|
sb.WriteString(` if [ -n "$FILE_PATH" ]; then
|
|
check_path_restriction "$FILE_PATH"
|
|
fi
|
|
`)
|
|
}
|
|
|
|
sb.WriteString(` ;;
|
|
Edit)
|
|
FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.file_path // empty')
|
|
`)
|
|
|
|
if restrictToWorkspace && workspace != "" {
|
|
sb.WriteString(` if [ -n "$FILE_PATH" ]; then
|
|
check_path_restriction "$FILE_PATH"
|
|
fi
|
|
`)
|
|
}
|
|
|
|
sb.WriteString(` ;;
|
|
Read)
|
|
FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.file_path // empty')
|
|
`)
|
|
|
|
if restrictToWorkspace && workspace != "" {
|
|
sb.WriteString(` if [ -n "$FILE_PATH" ]; then
|
|
check_path_restriction "$FILE_PATH"
|
|
fi
|
|
`)
|
|
}
|
|
|
|
sb.WriteString(` ;;
|
|
esac
|
|
|
|
# Default: allow
|
|
allow
|
|
`)
|
|
|
|
return sb.String()
|
|
}
|