Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .claude/agents/jmh-benchmarks.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ When the user asks you (directly or via a command) to run or analyze JMH:

3. Plan the JMH command
- For Gradle:
- Default: `./gradlew jmh --no-daemon`
- Default: `./.claude/commands/build-and-summarize jmh --no-daemon`. Never run
`gradlew` directly; a hook denies it. Read the output from the log path that
`build-and-summarize` prints.
- Apply filters via `-PjmhIncludes=*pattern*` or the project’s conventions if
they already exist.
- For Maven:
Expand Down
2 changes: 1 addition & 1 deletion .claude/commands/jmh.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
description: Run and analyze JMH benchmarks via the jmh-benchmarks subagent.
argument-hint: "[filter or 'all'] [mode: quick|full] [baseline-path(optional)]"
allowed-tools: Bash(./gradlew:*), Bash(gradlew:*), Bash(./mvnw:*), Bash(mvn:*), Bash(java:*), Read, Glob, Grep, Write
allowed-tools: Bash(./.claude/commands/build-and-summarize:*), Bash(./mvnw:*), Bash(mvn:*), Bash(java:*), Read, Glob, Grep, Write
---

You MUST route this request through the `jmh-benchmarks` subagent for all heavy lifting,
Expand Down
49 changes: 49 additions & 0 deletions .claude/hooks/require-gradle-wrapper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
#!/usr/bin/env python3
# Copyright 2026, Datadog, Inc.
"""Claude Code PreToolUse hook: block commands that run gradlew directly instead of
through .claude/commands/build-and-summarize. The policy is utils/gradle-command-policy.py."""

import json
import os
import subprocess
import sys

TRUSTED_REF = "refs/remotes/origin/main" # qualified: a tag named origin/main must not win
# git_out and the trusted-ref loading mirror require-fresh-review.py; change both together.


def git_out(args, cwd):
res = subprocess.run(["git", *args], cwd=cwd, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL)
return res.stdout.decode().strip() if res.returncode == 0 else None


def main():
try:
payload = json.load(sys.stdin)
except ValueError:
return 0
command = payload.get("tool_input", {}).get("command", "")
if "gradlew" not in command:
return 0 # most commands: skip the git calls below
project = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
top = git_out(["rev-parse", "--show-toplevel"], payload.get("cwd") or os.getcwd())
common = top and git_out(["rev-parse", "--path-format=absolute", "--git-common-dir"], top)
if not common or common != git_out(["rev-parse", "--path-format=absolute",
"--git-common-dir"], project):
return 0 # another repository, or not a git checkout: not this repo's policy
# origin/main's policy, never the checkout's: the branch may be untrusted (a fork PR).
source = git_out(["show", TRUSTED_REF + ":utils/gradle-command-policy.py"], top)
if source is None:
return 0 # the policy is not on the default branch yet
# -I keeps the checkout root off sys.path, so planted stdlib-named files are not imported.
res = subprocess.run([sys.executable, "-I", "-c", source], cwd=top, input=command.encode(),
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
if res.returncode != 2:
return 0
print(res.stderr.decode(errors="replace").strip(), file=sys.stderr)
return 2


if __name__ == "__main__":
sys.exit(main())
6 changes: 5 additions & 1 deletion .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"Bash(awk:*)",
"Bash(sed:*)",
"Bash(python3:*)",
"Bash(./gradlew:*)",
"Bash(./.claude/commands/build-and-summarize:*)",
"Bash(sh:*)",
"Bash(ls:*)",
"Bash(date:*)",
Expand All @@ -28,6 +28,10 @@
{
"type": "command",
"command": "h=\"$CLAUDE_PROJECT_DIR/.claude/hooks/require-fresh-review.py\"; [ -f \"$h\" ] || exit 0; exec python3 \"$h\""
},
{
"type": "command",
"command": "h=\"$CLAUDE_PROJECT_DIR/.claude/hooks/require-gradle-wrapper.py\"; [ -f \"$h\" ] || exit 0; exec python3 \"$h\""
}
]
}
Expand Down
25 changes: 25 additions & 0 deletions .pi/extensions/require-gradle-wrapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Copyright 2026, Datadog, Inc.
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { join } from "node:path";

// Pi adapter for .claude/hooks/require-gradle-wrapper.py: same JSON payload, and the
// hook's exit 2 blocks the bash call. Any other outcome allows it, as in Claude Code.
export default function (pi: ExtensionAPI) {
pi.on("tool_call", async (event, ctx) => {
if (event.toolName !== "bash") return undefined;
const command = String(event.input.command ?? "");
if (!command.includes("gradlew")) return undefined; // most commands: skip the python3 spawn
const hook = join(ctx.cwd, ".claude", "hooks", "require-gradle-wrapper.py");
if (!existsSync(hook)) return undefined; // a checkout from before the hook
const payload = JSON.stringify({ cwd: ctx.cwd, tool_input: { command } });
const res = spawnSync("python3", [hook], {
input: payload,
encoding: "utf8",
env: { ...process.env, CLAUDE_PROJECT_DIR: ctx.cwd },
timeout: 60_000,
});
return res.status === 2 ? { block: true, reason: res.stderr.trim() } : undefined;
});
}
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,9 @@ dependency-free leak-demo app; not published).
`--console=plain -i`, captures the full output into `build/logs/<timestamp>-<task>.log`,
and prints only the status line. On failure, read the wrapper's extracted
"What went wrong" section and the failing task/test lines; grep the log for anything
rarer. If no log path is given, use the newest `build/logs/*.log`.
rarer. If no log path is given, use the newest `build/logs/*.log`. In Claude Code and
Pi the `require-gradle-wrapper` hook denies direct `gradlew` calls, except
`--stop` and `--version`.
- Never paste large log chunks into chat. If more than ~30 lines are needed, write to a
file and point at it.
- If a step fails, print the failing command and a one-line hint, then stop.
Expand Down
15 changes: 15 additions & 0 deletions utils/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,21 @@ git show refs/remotes/origin/main:utils/review-marker.py | python3 -I - install-
bash utils/tests/test_review_marker.sh # tests
```

### `gradle-command-policy.py`

Denies shell commands that run `gradlew` directly instead of through `.claude/commands/build-and-summarize`, except `gradlew --stop` and `gradlew --version`. Reads the command on stdin; exit 2 with the reason on stderr denies it. The `require-gradle-wrapper` hook runs it in Claude Code (`.claude/hooks/`) and, through `.pi/extensions/require-gradle-wrapper.ts`, in Pi.

- The hook loads the policy from `refs/remotes/origin/main` with `python3 -I`, like `review-marker.py`, and allows everything when that ref lacks it.
- Deny by default: the command is split into simple commands at `;`, `&`, `|`, parentheses, braces, backticks and newlines, and any simple command that names `gradlew` is denied unless it starts with a read-only tool (`cat`, `grep`, `git`, `ls`, `find`, `echo`, ...). Quoted scripts such as `bash -c '...'` are checked the same way. So `if ./gradlew test`, `env -u X ./gradlew build` and `> log ./gradlew build` are denied without listing each prefix.
- Accepted false blocks: a non-reader command that only names the file, such as `mv gradlew gradlew.old`, and heredoc lines that start with `./gradlew`.
- Not covered: indirection such as `G=./gradlew; $G build`, a system `gradle`, and allowed tools that run their arguments, such as `find . -exec ./gradlew build \;`, an `awk -f` script that calls `system()`, or a git alias. The hook stops accidental direct runs, not deliberate ones.

**Usage:**
```bash
echo './gradlew build' | python3 utils/gradle-command-policy.py # exit 2
bash utils/tests/test_gradle_hook.sh # tests
```

---

## CI / Ops
Expand Down
73 changes: 73 additions & 0 deletions utils/gradle-command-policy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
#!/usr/bin/env python3
# Copyright 2026, Datadog, Inc.
"""Agent-hook policy: deny shell commands that run gradlew directly instead of through
.claude/commands/build-and-summarize. Reads the command text on stdin; exit 2 with the
reason on stderr denies the command, exit 0 allows it."""

import itertools
import os
import re
import shlex
import sys

# Deny by default: every simple command that names gradlew is denied unless it starts
# with a read-only tool, so new prefixes (`if`, `env -u X`, `> log`) cannot slip through.
SEPARATOR = re.compile(r"[;&|(){}`\n]")
READERS = frozenset({"cat", "less", "more", "head", "tail", "grep", "egrep", "fgrep", "rg",
"ls", "find", "file", "stat", "wc", "diff", "git", "sed", "awk", "echo",
"printf", "chmod", "which", "type"})
# Daemon housekeeping that builds nothing, so the wrapper's log capture adds nothing.
ALLOWED_ARGS = ({"--stop"}, {"--version"})
REDIRECT = re.compile(r"^\d*[<>]")
REDIRECT_OP = re.compile(r"^\d*[<>]+$") # operator alone: its target is the next word
ASSIGNMENT = re.compile(r"^\w+=")


def words(segment):
"""Words of one simple command, redirections dropped. A quote that the separator split
left open (`bash -c "cd x; ./gradlew build"`) falls back to whitespace splitting."""
try:
raw = iter(shlex.split(segment))
except ValueError:
raw = iter(segment.split())
kept = []
for w in raw:
if REDIRECT_OP.match(w):
next(raw, None)
elif not REDIRECT.match(w):
kept.append(w)
return kept


def direct_call(command):
"""The first denied gradlew call in command, or None."""
for segment in SEPARATOR.split(command):
ws = list(itertools.dropwhile(ASSIGNMENT.match, words(segment)))
if not ws or os.path.basename(ws[0]) in READERS:
continue
for i, w in enumerate(ws):
if os.path.basename(w.strip("'\"")) in ("gradlew", "gradlew.bat"):
if set(ws[i + 1:]) in ALLOWED_ARGS:
break
return " ".join(ws[i:]).strip("'\"")
if any(c.isspace() for c in w): # a quoted script, as in `bash -c '...'`
nested = direct_call(w)
if nested:
return nested
return None


def main():
# Join backslash-continued lines so `./gradlew \<newline> build` is one command.
call = direct_call(sys.stdin.read().replace("\\\n", " "))
if call:
print("Blocked `%s`: run Gradle through `./.claude/commands/build-and-summarize "
"<task...>`, which keeps the full log in build/logs/ (AGENTS.md, Operating "
"Rules). Only `gradlew --stop` and `gradlew --version` may run directly."
% call, file=sys.stderr)
return 2
return 0


if __name__ == "__main__":
sys.exit(main())
147 changes: 147 additions & 0 deletions utils/tests/test_gradle_hook.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
#!/usr/bin/env bash

# Copyright 2026, Datadog, Inc.

# Tests for utils/gradle-command-policy.py, the Claude Code hook
# .claude/hooks/require-gradle-wrapper.py and its Pi adapter, plus the agent
# configuration that must not grant or run gradlew directly. Hook cases run in a
# throwaway repo with a bare origin; global git config is ignored.
# Run with: bash utils/tests/test_gradle_hook.sh

set -euo pipefail

SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
ROOT=$(cd "$SCRIPT_DIR/../.." && pwd)
TESTS=0
FAILED=0
export PYTHONDONTWRITEBYTECODE=1 GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1

TEMP_DIR=$(mktemp -d)
trap 'rm -rf "$TEMP_DIR"' EXIT

pass() { TESTS=$((TESTS + 1)); }
fail() { TESTS=$((TESTS + 1)); FAILED=$((FAILED + 1)); echo "FAIL: $*" >&2; }

# expect <name> <expected-exit> <output-substring-or-empty> <command...>
expect() {
local name=$1 want=$2 needle=$3; shift 3
local rc=0
"$@" >"$TEMP_DIR/out" 2>"$TEMP_DIR/err" || rc=$?
if [ "$rc" -ne "$want" ]; then
fail "$name: exit $rc, want $want ($(cat "$TEMP_DIR/err"))"
elif [ -n "$needle" ] && ! cat "$TEMP_DIR/out" "$TEMP_DIR/err" | grep -qF -- "$needle"; then
fail "$name: output lacks '$needle': $(cat "$TEMP_DIR/out" "$TEMP_DIR/err")"
else
pass
fi
}

BLOCK_MSG="build-and-summarize"

# --- utils/gradle-command-policy.py ---
policy() { printf '%s' "$1" | python3 -I "$ROOT/utils/gradle-command-policy.py"; }
for c in './gradlew build' 'gradlew build' '/abs/path/gradlew build' './gradlew.bat build' \
'./gradlew' 'cd x && ./gradlew build' 'true; ./gradlew build' 'true || ./gradlew build' \
'ls | ./gradlew build' '(./gradlew build)' 'echo $(./gradlew -q properties)' \
'bash -c "./gradlew build"' "sh -c './gradlew build'" 'sh gradlew tasks' \
'FOO=1 ./gradlew build' 'env JAVA_HOME=/x ./gradlew build' 'timeout 60 ./gradlew test' \
'nohup ./gradlew build' $'./gradlew \\\nbuild' './gradlew --stop && ./gradlew build' \
'./gradlew --stop build' $'ls\n./gradlew build' './gradlew --stop "build"' \
"./gradlew --version 'build'" './gradlew build \' 'if ./gradlew test; then :; fi' \
'while ./gradlew test; do :; done' 'until ./gradlew test; do :; done' \
'if false; then :; elif ./gradlew test; then :; fi' 'env -u JAVA_TOOL_OPTIONS ./gradlew build' \
'> /tmp/log ./gradlew build' '2>&1 ./gradlew build' 'nice -n 5 ./gradlew build' \
'! ./gradlew build' 'bash -c "cd x; ./gradlew build"' 'xargs ./gradlew' \
'cat x && ./gradlew build' 'bash -c "gradlew build; ls"'; do
expect "policy denies: $c" 2 "$BLOCK_MSG" policy "$c"
done
for c in './.claude/commands/build-and-summarize buildDebug' './gradlew --stop' \
'./gradlew --version' './gradlew --stop 2>/dev/null' './gradlew --stop > /dev/null' \
'./gradlew --version >/dev/null' \
$'./gradlew --stop \\\n' './gradlewx build' 'cat gradlew' \
'git diff -- gradlew gradlew.bat' 'grep -n gradlew AGENTS.md' 'ls gradle/wrapper' '' \
'echo "./gradlew build"' 'git commit -m "run ./gradlew build"' "bash -c './gradlew --stop'" \
'`./gradlew --version`' 'find . -name gradlew' '/usr/bin/grep gradlew x' \
'LC_ALL=C grep gradlew x'; do
expect "policy allows: $c" 0 "" policy "$c"
done
expect "policy blocks a non-reader naming gradlew (accepted false positive)" 2 "$BLOCK_MSG" \
policy 'mv gradlew gradlew.old'
expect "policy names the blocked command" 2 'Blocked `./gradlew build`' policy 'cd x && ./gradlew build'
expect "policy names a nested blocked command" 2 'Blocked `./gradlew build`' policy "sh -c './gradlew build'"

# --- agent configuration (no direct gradlew grant or call) ---
if python3 -c 'import json,sys; a=json.load(open(sys.argv[1]))["permissions"]["allow"]; sys.exit(any("gradlew" in r for r in a))' \
"$ROOT/.claude/settings.json"; then pass; else fail "settings.json allows gradlew"; fi
for f in .claude/commands/jmh.md .claude/agents/jmh-benchmarks.md; do
if grep -nE '\./gradlew|Bash\(gradlew' "$ROOT/$f"; then fail "$f grants or runs gradlew"; else pass; fi
done
HOOK_CMD=$(python3 -c 'import json,sys
for e in json.load(open(sys.argv[1]))["hooks"]["PreToolUse"]:
for h in e["hooks"]:
if e.get("matcher") == "Bash" and "require-gradle-wrapper.py" in h["command"]: print(h["command"])' \
"$ROOT/.claude/settings.json")
if [ -n "$HOOK_CMD" ]; then pass; else fail "settings.json does not register the hook for Bash"; fi
mkdir -p "$TEMP_DIR/nohook"
expect "settings hook command allows everything when the hook file is missing" 0 "" \
sh -c "echo '{\"tool_input\":{\"command\":\"./gradlew build\"}}' | CLAUDE_PROJECT_DIR=$TEMP_DIR/nohook sh -c '$HOOK_CMD'"

# --- .claude/hooks/require-gradle-wrapper.py ---
git init -q --bare "$TEMP_DIR/origin.git"
mkdir -p "$TEMP_DIR/repo" && cd "$TEMP_DIR/repo"
git init -q -b main
git config user.email t@example.com && git config user.name t
mkdir -p utils .claude/hooks .pi/extensions
cp "$ROOT/.claude/hooks/require-gradle-wrapper.py" .claude/hooks/
cp "$ROOT/.pi/extensions/require-gradle-wrapper.ts" .pi/extensions/
git add . && git commit -qm "hook without policy"
git remote add origin "$TEMP_DIR/origin.git" && git push -q origin main

hook() {
python3 -I -c 'import json,sys; print(json.dumps({"cwd": sys.argv[2], "tool_input": {"command": sys.argv[1]}}))' \
"$1" "${2:-$PWD}" | CLAUDE_PROJECT_DIR=$PWD python3 .claude/hooks/require-gradle-wrapper.py
}
expect "hook allows everything while origin/main lacks the policy" 0 "" hook "./gradlew build"

cp "$ROOT/utils/gradle-command-policy.py" utils/
git add utils && git commit -qm policy && git push -q origin main
expect "hook ignores bad payload" 0 "" sh -c 'echo nope | python3 .claude/hooks/require-gradle-wrapper.py'
expect "hook allows unrelated command" 0 "" hook "ls -la"
expect "hook denies direct gradlew" 2 "$BLOCK_MSG" hook "./gradlew build"
expect "hook allows gradlew --stop" 0 "" hook "./gradlew --stop"
mkdir -p "$TEMP_DIR/elsewhere" && (cd "$TEMP_DIR/elsewhere" && git init -q)
expect "hook ignores commands run in another repository" 0 "" hook "./gradlew build" "$TEMP_DIR/elsewhere"

# The checkout's policy may be untrusted: the hook must run origin/main's, unimported.
git checkout -qb feature
printf 'import sys\nopen("%s/pwned", "w").close()\nsys.exit(0)\n' "$TEMP_DIR" > utils/gradle-command-policy.py
cp utils/gradle-command-policy.py re.py
git add utils && git commit -qm tamper
expect "hook runs origin/main's policy, not the checkout's" 2 "$BLOCK_MSG" hook "./gradlew build"
if [ -e "$TEMP_DIR/pwned" ]; then fail "hook ran checkout code"; else pass; fi
rm -f re.py

# --- .pi/extensions/require-gradle-wrapper.ts (driven through a stub Pi API) ---
if node --experimental-strip-types --no-warnings -e '' >/dev/null 2>&1; then
cat > "$TEMP_DIR/pi-driver.mjs" <<'EOF'
const [ext, cwd, toolName, command] = process.argv.slice(2);
const mod = await import(ext);
let handler;
mod.default({ on: (name, fn) => { if (name === "tool_call") handler = fn; } });
const result = await handler({ toolName, input: { command } }, { cwd });
if (result?.block) { console.error(result.reason); process.exit(2); }
EOF
pi_hook() {
node --experimental-strip-types --no-warnings "$TEMP_DIR/pi-driver.mjs" \
"$PWD/.pi/extensions/require-gradle-wrapper.ts" "${3:-$PWD}" "$1" "$2"
}
expect "pi adapter ignores other tools" 0 "" pi_hook read "./gradlew build"
expect "pi adapter allows unrelated bash" 0 "" pi_hook bash "ls -la"
expect "pi adapter denies direct gradlew" 2 "$BLOCK_MSG" pi_hook bash "./gradlew build"
expect "pi adapter allows a checkout without the hook" 0 "" pi_hook bash "./gradlew build" "$TEMP_DIR/nohook"
else
echo "SKIP: pi adapter tests (node with --experimental-strip-types not found)"
fi

echo "$TESTS tests, $FAILED failed"
[ "$FAILED" -eq 0 ]
Loading