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>
202 lines
6.8 KiB
Go
202 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
|
|
}
|