Claude Code can run the shell. That is where productivity and production risk collide. Hooks are the control plane: deny destructive git and cloud CLIs by default, require human approval for IAM and network mutations, redact secrets from tool logs, and keep an audit trail you can replay in a postmortem. Treat every shell line as untrusted until a hook says otherwise.
⚡ TL;DR: Install PreToolUse / permission hooks that match on argv patterns (
git push --force,aws iam,kubectl delete,rm -rf). Fail closed on secrets in stdout. Log every allow/deny with hash of the command, cwd, and git SHA. Pair hooks with CI so local agent freedom never exceeds what main will accept — see AI Code Review Bots and LLM Coding Agents on AWS.
Hook architecture that fails closed
Model the hook as a pure function: (tool, input, ctx) → allow | deny | ask. Default deny for elevated classes; allow only explicitly listed safe commands.
// .claude/hooks/pre-tool-use.ts
export type Decision = { action: "allow" | "deny" | "ask"; reason: string };
const DENY = [
/git\s+push\s+.*--force/,
/git\s+reset\s+--hard/,
/aws\s+iam\b/,
/aws\s+organizations\b/,
/kubectl\s+delete\b/,
/terraform\s+apply\b/,
/rm\s+-rf\s+[\/~]/,
/curl\s+.*169\.254\.169\.254/,
];
const ASK = [
/aws\s+s3\s+(rb|rm)\b/,
/gh\s+secret\b/,
/pnpm\s+publish\b/,
];
export function gateShell(argv: string, ctx: { branch: string }): Decision {
const line = argv.trim();
// ❌ Never: allow everything on feature branches
if (DENY.some((re) => re.test(line))) {
return { action: "deny", reason: `deny_pattern:${line.slice(0, 80)}` };
}
if (ASK.some((re) => re.test(line))) {
return { action: "ask", reason: "destructive_needs_human" };
}
if (ctx.branch === "main" || ctx.branch === "master") {
return { action: "deny", reason: "no_shell_on_main" };
}
// ✅ Allowlist-friendly default for read-only / test cmds
return { action: "allow", reason: "default_allow" };
}
Redact secrets before anything hits disk
Tool logs are a leak surface. Strip AWS keys, JWTs, PEM blocks, and .env values in the hook’s PostToolUse path.
const SECRET_RES = [
/AKIA[0-9A-Z]{16}/g,
/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g,
/-----BEGIN (RSA |EC )?PRIVATE KEY-----[\s\S]*?-----END/g,
/(password|secret|token|api[_-]?key)\s*[:=]\s*\S+/gi,
];
export function redact(text: string): string {
let out = text;
for (const re of SECRET_RES) out = out.replace(re, "[REDACTED]");
return out;
}
// ✅ Persist only redacted transcripts
export function audit(event: {
ts: string; decision: string; cmdHash: string; cwd: string; sha: string; snippet: string;
}) {
appendFileSync(".claude/audit.jsonl", JSON.stringify({
...event,
snippet: redact(event.snippet),
}) + "\n");
}
# ❌ Wrong: tee raw agent output into shared Slack / CI artifacts
claude --print | tee /tmp/agent.log && curl -F file=@/tmp/agent.log $WEBHOOK
Human approval for IAM and identity mutations
Anything that changes who can do what in the cloud is dual-control. Hook returns ask; the human pastes a change ticket ID before continue.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node .claude/hooks/pre-tool-use.mjs"
}
]
}
]
}
}
// Illustrative ask flow
if (decision.action === "ask") {
const ticket = await prompt("Change ticket ID required for: " + argv);
if (!/^CHG-\d{4,}$/.test(ticket)) {
return { action: "deny", reason: "invalid_change_ticket" };
}
audit({
ts: new Date().toISOString(),
decision: "ask-allow",
cmdHash: sha256(argv),
cwd: process.cwd(),
sha: gitSha(),
snippet: argv + " ticket=" + ticket,
});
}
Keep CI as the outer gate
Hooks protect the laptop; CI protects main. Mirror deny patterns in a workflow that fails PRs containing dangerous scripted steps.
# .github/workflows/forbid-dangerous-scripts.yml
- name: Scan PR scripts
run: |
if git diff origin/main...HEAD | grep -E 'git push --force|aws iam|terraform apply'; then
echo "::error::Dangerous command in diff — blocked"
exit 1
fi
Complement with sandboxing patterns from LLM Coding Agents on AWS: Safe Tool Sandboxes with Lambda when the agent runs remotely.
Durable audit trails worth replaying
Store cmdHash, git SHA, decision, and redacted snippet. Ninety days is a useful default for incident review; encrypt at rest if the repo is shared widely.
| Field | Why |
|---|---|
cmdHash |
Dedupe + integrity without storing raw secrets |
gitSha |
Correlate with the tree the agent saw |
decision |
allow/deny/ask-allow |
ticket |
Change management proof |
snippet |
Redacted argv only |
Closing checklist
✅ Dos
– ✅ Fail closed on force-push, IAM, kubectl delete, metadata curl
– ✅ Redact secrets in every PostToolUse log
– ✅ Require change tickets for ask-class commands
– ✅ Mirror hook denies in CI diff scans
– ✅ Retain hashed audit JSONL for postmortems
❌ Don’ts
– ❌ Don’t allow shell on main locally via the agent
– ❌ Don’t tee raw tool output to chat webhooks
– ❌ Don’t rely on “the model will be careful”
– ❌ Don’t skip hooks in CI agent runners
– ❌ Don’t store PEM material in audit files
Related reading
- LLM Coding Agents on AWS: Safe Tool Sandboxes with Lambda
- AI Code Review Bots: IAM, Secrets, and Least-Privilege Pipelines
- Cursor Rules for TypeScript Monorepos: Make AI Edits Stick
- Amazon Bedrock Agents: Tool Use, Memory, and Production Guardrails
Last updated on September 11, 2026
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.
