claude-failover/internal/safety/safety.go

203 lines
6.8 KiB
Go
Raw Normal View History

feat(safety): PreToolUse hook gating destructive tool calls (FNDG-04b, Option A) Adds internal/safety/ — the in-repo source of truth for the PreToolUse hook deployed into every project before a Claude Code agent is launched. The hook blocks destructive Bash/Edit/Write patterns on sessions running with --dangerously-skip-permissions, closing the exploitation path where a prompt injection via MCP sessions.send could otherwise trigger arbitrary destruction without interactive confirmation. Wire-up: - internal/dispatcher/dispatcher.go launchAgent: deploys hook before claude launch; fail-closed if deployment fails. - internal/switcher/account_switcher.go relaunchDedicatedSessions: redeploys hook before --resume after account failover; fail-open (log + continue) since the initial deployment is still in place. Blocks (exit 2, stderr shown to model): - rm -rf targeting /, ~, $HOME, /etc, /var, /usr, /boot - dd of=/dev/{sd,nvme,disk,hd,mmcblk}*, mkfs* - git push --force (but allows --force-with-lease) - git reset --hard on main|master|production - sudo outside short allowlist (systemctl, journalctl, cp, install, apt*) - curl|sh, bash <(curl ...), eval "$(curl ...)", fork bomb, crontab -e - chmod 777 on system paths / home - Writes to .claude/settings*.json, .claude/hooks/, ~/.ssh/authorized_keys, shell rc files, /etc/sudoers*, /etc/systemd/* Warn-only (logged, not blocked): - kubectl delete, helm uninstall, terraform destroy - DROP TABLE, TRUNCATE TABLE, DELETE FROM ... WHERE 1=1 Hook script is embedded via //go:embed so a single binary release carries the authoritative copy. Every launch rewrites the deployed file with mode 0555 (anti-tamper); the hook itself also blocks writes to .claude/hooks/ for defense in depth. Decision: Olivier, 2026-04-19 — Option A now, Option C (two pools) tracked separately. Complements FNDG-04 input sanitization in secuaas-mcp. Tests: 8 unit/integration tests in internal/safety/, plus a dispatcher-level test verifying the hook is written before launch. go vet clean, go test ./... all pass. Refs: FNDG-04 audit (secuaas-mcp branch audit/mcp-stdio-2026-04-18) Task: .agent-queue/inbox/20260418-211102-fndg-04b-*.md Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 17:48:27 +00:00
// 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, &current); 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
}