// 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 }