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>
This commit is contained in:
parent
336f1f27bb
commit
58690da69f
8 changed files with 885 additions and 1 deletions
202
internal/safety/safety.go
Normal file
202
internal/safety/safety.go
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
// 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue