Claude Code Hooks: Gate Risky Shell Commands Before CI Runs

Claude Code Hooks: Gate Risky Shell Commands Before CI Runs

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

Last updated on September 11, 2026


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply