203 lines
6.8 KiB
Go
203 lines
6.8 KiB
Go
|
|
// Package safety deploys the PreToolUse hook that gates destructive tool calls
|
||
|
|
// on Claude Code agents launched by claude-failover. It is the in-repo source
|
||
|
|
// of truth for the hook script and the settings wiring; dispatcher and
|
||
|
|
// account_switcher call EnsureHookDeployed before every claude invocation so
|
||
|
|
// the hook is present and writable only by the launcher, not by the agent.
|
||
|
|
//
|
||
|
|
// Rationale: sessions started with --dangerously-skip-permissions bypass the
|
||
|
|
// interactive confirmation prompt. Combined with MCP sessions.send, a prompt
|
||
|
|
// injection could otherwise execute arbitrary destructive commands. This hook
|
||
|
|
// is the second line of defence (input sanitization on the MCP side is the
|
||
|
|
// first — see FNDG-04 in secuaas-mcp).
|
||
|
|
package safety
|
||
|
|
|
||
|
|
import (
|
||
|
|
_ "embed"
|
||
|
|
"encoding/json"
|
||
|
|
"fmt"
|
||
|
|
"os"
|
||
|
|
"path/filepath"
|
||
|
|
)
|
||
|
|
|
||
|
|
//go:embed hook.sh
|
||
|
|
var hookScript []byte
|
||
|
|
|
||
|
|
// HookScriptVersion is bumped whenever hook.sh changes semantics so older
|
||
|
|
// deployments are overwritten. Surfaced via the marker file so operators can
|
||
|
|
// grep for outdated deployments without diffing binary contents.
|
||
|
|
const HookScriptVersion = "1"
|
||
|
|
|
||
|
|
// RelativeHookPath is where the hook script is placed inside a project dir.
|
||
|
|
const RelativeHookPath = ".claude/hooks/claude-safety-hook.sh"
|
||
|
|
|
||
|
|
// RelativeSettingsPath is the per-project Claude Code settings file that wires
|
||
|
|
// the hook into the PreToolUse event.
|
||
|
|
const RelativeSettingsPath = ".claude/settings.json"
|
||
|
|
|
||
|
|
// versionMarker sits next to the hook script and lets us detect stale copies
|
||
|
|
// without re-reading the whole script.
|
||
|
|
const relativeVersionMarker = ".claude/hooks/.claude-safety-hook.version"
|
||
|
|
|
||
|
|
// HookCommand is the shell snippet wired into settings.json. It resolves the
|
||
|
|
// hook relative to the session's $CLAUDE_PROJECT_DIR (Claude Code sets this
|
||
|
|
// env var at launch) so moving the project doesn't break the hook.
|
||
|
|
const HookCommand = `"$CLAUDE_PROJECT_DIR/.claude/hooks/claude-safety-hook.sh"`
|
||
|
|
|
||
|
|
// settingsFragment is the PreToolUse hook config merged into settings.json.
|
||
|
|
var settingsFragment = map[string]any{
|
||
|
|
"hooks": map[string]any{
|
||
|
|
"PreToolUse": []any{
|
||
|
|
map[string]any{
|
||
|
|
"matcher": "Bash|Edit|Write|MultiEdit|NotebookEdit",
|
||
|
|
"hooks": []any{
|
||
|
|
map[string]any{
|
||
|
|
"type": "command",
|
||
|
|
"command": HookCommand,
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
}
|
||
|
|
|
||
|
|
// EnsureHookDeployed writes the hook script and wires it into the project's
|
||
|
|
// .claude/settings.json. It is idempotent and cheap to call before every
|
||
|
|
// session launch. It never deletes existing settings keys — only the hook
|
||
|
|
// entry is merged/refreshed.
|
||
|
|
//
|
||
|
|
// The script is written with mode 0555 (read+execute, no write) so an agent
|
||
|
|
// with an Edit tool cannot silently tamper with the hook body during the
|
||
|
|
// session. (Anti-tamper is also enforced inside the hook itself, which blocks
|
||
|
|
// writes to .claude/hooks/ — defense in depth.)
|
||
|
|
func EnsureHookDeployed(projectDir string) error {
|
||
|
|
if projectDir == "" {
|
||
|
|
return fmt.Errorf("safety: projectDir is empty")
|
||
|
|
}
|
||
|
|
abs, err := filepath.Abs(projectDir)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("safety: resolve projectDir: %w", err)
|
||
|
|
}
|
||
|
|
if st, err := os.Stat(abs); err != nil || !st.IsDir() {
|
||
|
|
return fmt.Errorf("safety: projectDir %q not a directory: %v", abs, err)
|
||
|
|
}
|
||
|
|
|
||
|
|
hookPath := filepath.Join(abs, RelativeHookPath)
|
||
|
|
if err := os.MkdirAll(filepath.Dir(hookPath), 0o755); err != nil {
|
||
|
|
return fmt.Errorf("safety: mkdir hooks dir: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := writeHookScript(hookPath); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
markerPath := filepath.Join(abs, relativeVersionMarker)
|
||
|
|
if err := os.WriteFile(markerPath, []byte(HookScriptVersion+"\n"), 0o444); err != nil {
|
||
|
|
// Non-fatal — script is in place, marker is advisory.
|
||
|
|
_ = err
|
||
|
|
}
|
||
|
|
|
||
|
|
return mergeSettings(filepath.Join(abs, RelativeSettingsPath))
|
||
|
|
}
|
||
|
|
|
||
|
|
// writeHookScript atomically replaces the hook file. We chmod 0200 the old
|
||
|
|
// file (if any) to force replacement, since a prior 0555 deployment refuses
|
||
|
|
// direct overwrite. Write to a temp file then rename.
|
||
|
|
func writeHookScript(hookPath string) error {
|
||
|
|
tmp := hookPath + ".tmp"
|
||
|
|
if err := os.WriteFile(tmp, hookScript, 0o755); err != nil {
|
||
|
|
return fmt.Errorf("safety: write hook: %w", err)
|
||
|
|
}
|
||
|
|
if err := os.Chmod(tmp, 0o555); err != nil {
|
||
|
|
_ = os.Remove(tmp)
|
||
|
|
return fmt.Errorf("safety: chmod hook: %w", err)
|
||
|
|
}
|
||
|
|
// Clear any read-only flag on the destination so rename can overwrite.
|
||
|
|
_ = os.Chmod(hookPath, 0o644)
|
||
|
|
if err := os.Rename(tmp, hookPath); err != nil {
|
||
|
|
_ = os.Remove(tmp)
|
||
|
|
return fmt.Errorf("safety: rename hook: %w", err)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// mergeSettings ensures the PreToolUse hook entry is present in settings.json
|
||
|
|
// without disturbing unrelated keys. If the file doesn't exist, it is created
|
||
|
|
// with just the hook config.
|
||
|
|
func mergeSettings(settingsPath string) error {
|
||
|
|
var current map[string]any
|
||
|
|
if data, err := os.ReadFile(settingsPath); err == nil {
|
||
|
|
if err := json.Unmarshal(data, ¤t); err != nil {
|
||
|
|
return fmt.Errorf("safety: parse %s: %w", settingsPath, err)
|
||
|
|
}
|
||
|
|
} else if !os.IsNotExist(err) {
|
||
|
|
return fmt.Errorf("safety: read %s: %w", settingsPath, err)
|
||
|
|
}
|
||
|
|
if current == nil {
|
||
|
|
current = map[string]any{}
|
||
|
|
}
|
||
|
|
|
||
|
|
mergeHookInto(current, settingsFragment)
|
||
|
|
|
||
|
|
out, err := json.MarshalIndent(current, "", " ")
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("safety: marshal settings: %w", err)
|
||
|
|
}
|
||
|
|
out = append(out, '\n')
|
||
|
|
|
||
|
|
if err := os.MkdirAll(filepath.Dir(settingsPath), 0o755); err != nil {
|
||
|
|
return fmt.Errorf("safety: mkdir settings parent: %w", err)
|
||
|
|
}
|
||
|
|
tmp := settingsPath + ".tmp"
|
||
|
|
if err := os.WriteFile(tmp, out, 0o644); err != nil {
|
||
|
|
return fmt.Errorf("safety: write settings: %w", err)
|
||
|
|
}
|
||
|
|
if err := os.Rename(tmp, settingsPath); err != nil {
|
||
|
|
_ = os.Remove(tmp)
|
||
|
|
return fmt.Errorf("safety: rename settings: %w", err)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// mergeHookInto ensures dst["hooks"]["PreToolUse"] contains an entry whose
|
||
|
|
// hook command matches our hook. If one exists with the same command, leave
|
||
|
|
// it alone. If an entry with our command exists but with a stale matcher,
|
||
|
|
// update the matcher. Other PreToolUse entries (e.g., security-gate.sh from
|
||
|
|
// CLAUDE.md) are preserved.
|
||
|
|
func mergeHookInto(dst, fragment map[string]any) {
|
||
|
|
hooks, _ := dst["hooks"].(map[string]any)
|
||
|
|
if hooks == nil {
|
||
|
|
hooks = map[string]any{}
|
||
|
|
dst["hooks"] = hooks
|
||
|
|
}
|
||
|
|
pre, _ := hooks["PreToolUse"].([]any)
|
||
|
|
|
||
|
|
ourFragment := fragment["hooks"].(map[string]any)["PreToolUse"].([]any)[0].(map[string]any)
|
||
|
|
ourMatcher := ourFragment["matcher"].(string)
|
||
|
|
|
||
|
|
found := false
|
||
|
|
for i, entry := range pre {
|
||
|
|
e, ok := entry.(map[string]any)
|
||
|
|
if !ok {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
inner, _ := e["hooks"].([]any)
|
||
|
|
for _, h := range inner {
|
||
|
|
hm, ok := h.(map[string]any)
|
||
|
|
if !ok {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if cmd, _ := hm["command"].(string); cmd == HookCommand {
|
||
|
|
// Refresh matcher in case we extended the tool list.
|
||
|
|
e["matcher"] = ourMatcher
|
||
|
|
pre[i] = e
|
||
|
|
found = true
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if !found {
|
||
|
|
pre = append(pre, ourFragment)
|
||
|
|
}
|
||
|
|
hooks["PreToolUse"] = pre
|
||
|
|
}
|