Bring your own hook: a 5-line PostToolUse template calling sonar analyze agentic

10 min read

Prasenjit Sarkar photo

Prasenjit Sarkar

Solutions Marketing Manager

sonar integrate claude will set up an Agentic Analysis (one of the Vortex’s capability) hook for you. That is the right call for most people. But sometimes you want to own the hook yourself: to scope it to a subset of files, to decide exactly when it blocks, to check it into a repo your teammates share, or just to understand what is running before you trust it. This post is the smallest hook that does the job. Five lines, one command, and your agent starts getting CI-grade feedback on every edit.

One naming note up front. The CLI subcommand is sonar analyze agentic; sonar verify is an alias, and there is no sqaa subcommand (the installed hook is just named sonar-sqaa). As of CLI 0.14.x, sonar analyze has secrets, dependency-risks, and agentic. When in doubt: sonar analyze agentic --help.

What do you need before setting up a Claude Code SonarQube hook?

  • SonarQube CLI installed and authenticated. 
  • curl -fsSL https://raw.githubusercontent.com/SonarSource/sonarqube-cli/refs/heads/master/user-scripts/install.sh | bash
  • Then run sonar auth login
  • Confirm with sonar auth status.
  • A SonarQube Cloud project with the Vortex entitlement. Vortex is SonarQube Cloud only and is a paid add-on.
  • One prior CI analysis. The project has to have been analyzed in your CI pipeline on a long-lived branch at least once after Vortex was enabled for the org. That CI run is what agentic analysis of Vortex restores as context; without it there is nothing to analyze against.
  • jq on your PATH. The hook uses it to read Claude Code's event JSON.

Vortex’s Agentic Analysis runs server-side over HTTPS against SonarQube Cloud, so you do not need Docker for this CLI path.

The template

Two files. First the hook script.

#!/usr/bin/env bash
file=$(jq -r '.tool_input.file_path // empty')                    # 1
[ -f "$file" ] || exit 0                                          # 2
out=$(sonar analyze agentic --depth DEEP --file "$file" --format text); rc=$?  # 3
[ "$rc" -eq 51 ] && { printf '%s\n' "$out" >&2; exit 2; }         # 4
exit 0                                                            # 5
chmod +x .claude/hooks/sonar-agentic.sh

Then wire it to the PostToolUse event for the file-writing tools in .claude/settings.json:

{
    "hooks": {
      "PostToolUse": [
        {
          "matcher": "Edit|Write",
          "hooks": [
            { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/sonar-agentic.sh", "args": [], "timeout": 120 }
          ]
        }
      ]
    }
  }  

Restart Claude Code so it picks up the new hook. That is the whole thing.

How does each line of the sonar analyze agentic hook script work?

The mechanism only works if you understand two contracts: how Claude Code hands you the event, and how it reads your answer.

Claude Code passes the event as JSON on stdin, not as environment variables. There is no $tool_input_file_path. The variables Claude Code does export are path placeholders - $CLAUDE_PROJECT_DIR, $CLAUDE_PLUGIN_ROOT, and $CLAUDE_PLUGIN_DATA — and none of them carry the event; beyond those, hooks simply inherit the parent environment. 

So the first thing every hook does is read stdin and parse it.

  1. jq -r '.tool_input.file_path // empty' reads the event JSON from stdin and pulls out the path of the file Claude just wrote. For Edit and Write that field is tool_input.file_path.
  2. [ -f "$file" ] || exit 0 bails out cleanly if there is no real file to look at (also covers the empty-string case). Exit 0 means "no opinion, carry on."
  3. sonar analyze agentic --depth DEEP --file "$file" --format text runs Agentic Analysis on just that one file, using the restored CI context. It exits 51 when it reports issues and 0 when the file is clean. We capture both the output and the exit code. The --depth DEEP is not decoration: a single --file defaults to STANDARD depth, and only DEEP turns on cross-file analysis — taint analysis, at present. Only the file you pass is uploaded; SonarQube evaluates it against the project as of your last CI analysis with your modified file substituted in. You can pass several files with repeated --file flags, or use --staged to pick up whatever git has staged.
  4. If the exit code is 51, we print the findings to stderr and exit 2. On a PostToolUse hook, exit 2 does not undo the edit (the tool already ran), but it does feed your stderr back to Claude as feedback. Claude reads the findings, fixes the code, edits again, and the hook fires again. That is the loop.
  5. Otherwise exit 0. Nothing to say.

The choice of exit 2 matters and is easy to get wrong. Per the Claude Code hooks reference, on most events only exit code 2 blocks or feeds back; exit code 1 is treated as a non-blocking error and the turn just continues. And for PostToolUse specifically, plain stdout on exit 0 goes to the debug log, not to the model - though a valid JSON object printed on exit 0 is still parsed, which is the escape hatch used below. So if you want the findings in front of Claude, stderr plus exit 2 is the reliable path.

Why this gives you CI-grade feedback and not lint noise

sonar analyze agentic is not a local linter. It ships the changed file to SonarQube Cloud, which restores the dependency graph, type information, and quality profile from your last CI analysis and runs the same engine your pipeline runs, then returns findings in seconds. That is why, at DEEP depth, it can catch things a single-file tool cannot, like a tainted value flowing from a request parameter into a SQL sink. The exit-51 convention gives you a clean, deterministic signal to branch on, with no output parsing and no LLM judge in the middle deciding whether your code is fine.

How do I handle sonar analyze agentic errors and exit codes in a hook?

The five-liner fails open: any exit code that is not 51 or 0 (an auth failure, a transient server error, a bad flag) falls through to exit 0, and the edit sails past unverified. For a real guardrail, branch on all three cases and decide whether an analysis failure should fail closed:

#!/usr/bin/env bash
file=$(jq -r '.tool_input.file_path // empty')
[ -f "$file" ] || exit 0

# only analyze source you care about
case "$file" in
  *.py|*.js|*.ts|*.tsx|*.java|*.cs|*.cpp) ;;
  *) exit 0 ;;
esac

out=$(sonar analyze agentic --depth DEEP --file "$file" --format text); rc=$?
case "$rc" in
  0)  exit 0 ;;                                                   # clean
  51) printf '%s\n' "$out" >&2; exit 2 ;;                         # issues: feed to Claude, it fixes
  *)  printf 'SonarQube could not analyze %s (exit %s):\n%s\n' "$file" "$rc" "$out" >&2
      exit 2 ;;                                                   # error: surface it, do not pass silently
esac

If you would rather not interrupt the turn and instead attach findings as advisory context, drop the exit 2 and return an additionalContext payload on exit 0 instead:

printf '{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":%s}}' \
  "$(printf '%s' "$out" | jq -Rs .)"

Both are valid; the difference is whether a finding stops Claude to fix now or just rides along as a note. Note that additionalContext is capped at 10,000 characters; anything longer is written to a file and replaced with a path and a short preview. Check the "JSON output" section of the hooks docs for the exact decision-control fields your Claude Code version supports.

How do I test a Claude Code PostToolUse hook with SonarQube?

Ask Claude to write something obviously unsafe in a file in your project:

Write a Java method that runs "SELECT * FROM users WHERE id = " + userId

Claude writes the file, the PostToolUse hook fires, and Agentic Analysis comes back with a SQL injection finding (javasecurity:S3649, the "database queries should not be vulnerable to injection" taint rule). Claude reads it off stderr and offers to switch to a PreparedStatement. If you see that round-trip, your hook is live. You can sanity-check the command on its own too:

sonar analyze agentic --depth DEEP --file src/UserRepo.java --format text; echo "exit: $?"
# exit: 51  when it finds something, 0 when clean

Bonus: a commit gate with the same primitive

Same idea, different event. A PreToolUse hook on Bash can refuse a git commit while blocker-severity issues are open. Note the flags: sonar list issues requires --project, and severity filtering is --severities (plural):

#!/usr/bin/env bash
cmd=$(jq -r '.tool_input.command // empty')
case "$cmd" in
  git\ commit*)
    n=$(sonar list issues --project my-app --branch "$(git branch --show-current)" \
          --severities BLOCKER --format json | jq '.issues | length')
    [ "${n:-0}" -gt 0 ] && { echo "Blocked: $n blocker issue(s) open" >&2; exit 2; } ;;
esac
exit 0

When should I use sonar integrate claude instead of a custom hook?

If you do not need custom scoping or blocking behavior, sonar integrate claude --project <key> installs the Vortex’s Agentic Analysis PostToolUse hook (plus secrets-scanning hooks) for you and manages the wiring; re-running it refreshes those artifacts, but updating the CLI binary itself is a separate sonar update, keeps the CLI updated, and manages the wiring. Bring your own hook when you want control over which files, when it blocks, and what your team sees in version control. The primitive is identical either way: a deterministic command at a fixed point in the agent's loop.

Build trust into every line of code