diff --git a/agentic-ai/Claude/README.md b/agentic-ai/Claude/README.md index 1916022..72439db 100644 --- a/agentic-ai/Claude/README.md +++ b/agentic-ai/Claude/README.md @@ -1,8 +1,8 @@ # Claude Code Config Version-controlled source of truth for `~/.claude/` settings, hooks, rules, and -on-demand docs. Running `install.sh` copies the settings template and links the -rest of the configuration. +on-demand docs, plus the hook scripts shared with Codex. Running `install.sh` +copies the Claude settings template and links the shared configuration. ## Activation @@ -24,18 +24,27 @@ This will: that harness's config dir exists) - Symlink each `Claude/output-styles/*.md` into `~/.claude/output-styles/` - Symlink this `railguard.yaml` → `~/.railguard.yaml` -- Symlink each `hooks/*.sh` script into `~/.claude/hooks/` +- Symlink each `hooks/*.sh` script into both `~/.claude/hooks/` and + `~/.codex/hooks/` +- Merge the custom hook registrations into `~/.codex/hooks.json` while + preserving unrelated hooks - Install or update the Railguard fork with `cargo install --git https://github.com/ulises-c/railguard` (requires Rust/cargo; an existing binary is kept with a warning when cargo is unavailable) - Run `railguard install` to register it as a global PreToolUse hook -Restart Claude Code after running. +Restart both Claude Code and Codex after running so each reloads its hook +configuration. > **Note:** `settings.json` sets `bypassPermissions` at the user level, so it applies to **all projects**, not just this repo. ## What this configures +The hook scripts below are deployed for both Claude Code and Codex. Claude hook +registration lives in `settings.json`; the installer idempotently merges the +custom registrations into `~/.codex/hooks.json` after Railguard registers its +own hooks, preserving unrelated entries. + ### `bypassPermissions` Claude auto-approves all tool calls without prompting. The hooks below act as the safety gate. @@ -66,17 +75,17 @@ Blocks dangerous or escalation-prone shell commands: - `sudo` (escalation must be explicit — run yourself) - `git add -A`, `git add --all`, `git add .` (bulk staging can silently include secrets) -### PreToolUse: `validate-write.sh` (Write / Edit / MultiEdit) +### PreToolUse: `validate-write.sh` (Write / Edit / MultiEdit / apply_patch) Blocks writes to sensitive file paths: - `~/.ssh/`, `~/.aws/`, `~/.gnupg/`, `~/.config/gh` - `/etc/`, `/usr/`, `/boot/`, `/sys/`, `/proc/` -### PostToolUse: `post-edit-shellcheck.sh` (Write / Edit / MultiEdit) +### PostToolUse: `post-edit-shellcheck.sh` (Write / Edit / MultiEdit / apply_patch) After any shell script edit, runs `shellcheck --severity=error`. Exits 2 if errors are found, forcing Claude to fix them before continuing. Skips gracefully if `shellcheck` is not installed. -### PostToolUse: `post-test-runner.sh` (Write / Edit / MultiEdit) +### PostToolUse: `post-test-runner.sh` (Write / Edit / MultiEdit / apply_patch) After any source file edit, auto-detects and runs the project test suite. Detection order: `.claude/test-cmd` override → `Cargo.toml` → `go.mod` → `pyproject.toml`/`pytest.ini` → `package.json` → `Makefile`. Skips non-source extensions (md, json, yaml, etc.) and projects with no recognized test suite. @@ -169,6 +178,34 @@ submodule) and their origin plus refresh steps are recorded in ## Testing the hooks +Run the repeatable Codex benchmark first. It exercises fixed Railguard and +custom-hook protocol cases in a disposable Git repository, emits TAP with +per-case timings, and exits nonzero on any regression. It does not execute the +dangerous commands in its fixtures or modify live Codex configuration. + +```bash +# Benchmark the installed binary +bash agentic-ai/Claude/benchmark-codex-hooks.sh + +# Run the identical cases against a development build +RAILGUARD_BIN=/path/to/railguard/target/debug/railguard \ + bash agentic-ai/Claude/benchmark-codex-hooks.sh + +# Validate deployed hook links and registrations separately +bash agentic-ai/Claude/validate.sh +``` + +Registration alone does not make Codex run the hooks: Codex also requires +per-hook trust, which it records in `config.toml` the first time an interactive +session encounters each hook. Until then `codex exec` silently skips them (the +`hooks` feature is on by default in current Codex; only an explicit +`hooks = false` under `[features]` disables the engine outright). For +non-interactive verification, `codex exec --dangerously-bypass-hook-trust` +runs registered hooks without persisted trust — use it only for hooks you +authored. + +The commands below remain useful for quick, individual hook probes: + ```bash # Should exit 2 (blocked) echo '{"tool_input":{"command":"rm -rf /"}}' | bash agentic-ai/Claude/hooks/validate-bash.sh diff --git a/agentic-ai/Claude/benchmark-codex-hooks.sh b/agentic-ai/Claude/benchmark-codex-hooks.sh new file mode 100755 index 0000000..25180d0 --- /dev/null +++ b/agentic-ai/Claude/benchmark-codex-hooks.sh @@ -0,0 +1,263 @@ +#!/usr/bin/env bash +# Deterministic Codex hook benchmark. Runs fixed protocol cases in disposable state. +set -uo pipefail + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +HOOKS_DIR="$SCRIPT_DIR/hooks" +RAILGUARD_BIN=${RAILGUARD_BIN:-railguard} +CASE_INDEX=0 +FAILURES=0 +LAST_STATUS=0 +LAST_OUTPUT="" +LAST_ERROR="" +LAST_DETAIL="" + +resolve_railguard() { + if [[ "$RAILGUARD_BIN" == */* ]]; then + [[ -x "$RAILGUARD_BIN" ]] || return 1 + else + RAILGUARD_BIN=$(command -v "$RAILGUARD_BIN") || return 1 + fi +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || { + printf 'Bail out! required command is unavailable: %s\n' "$1" + exit 2 + } +} + +resolve_railguard || { + printf 'Bail out! Railguard binary is unavailable: %s\n' "$RAILGUARD_BIN" + exit 2 +} +require_command bash +require_command git +require_command jq +require_command shellcheck + +BENCH_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/codex-hook-benchmark.XXXXXX") +PROJECT_DIR="$BENCH_ROOT/project" +OUTSIDE_DIR="$BENCH_ROOT/outside" +RAILGUARD_STATE="$BENCH_ROOT/railguard-state" +# Railguard's self-integrity check reads $HOME/.codex/hooks.json and denies +# everything when hooks exist there without a railguard entry. Point HOME at a +# bench-local copy registered the way `railguard install` leaves it, so results +# never depend on the live machine's Codex configuration. +BENCH_HOME="$BENCH_ROOT/home" + +cleanup() { + rm -rf -- "$BENCH_ROOT" +} +trap cleanup EXIT + +mkdir -p "$PROJECT_DIR/.claude" "$PROJECT_DIR/src" "$OUTSIDE_DIR" "$RAILGUARD_STATE" "$BENCH_HOME/.codex" +printf '{"hooks":{"PreToolUse":[{"matcher":"","hooks":[{"type":"command","command":"railguard hook --event PreToolUse"}]}]}}\n' \ + > "$BENCH_HOME/.codex/hooks.json" +git init --quiet "$PROJECT_DIR" +printf 'true\n' > "$PROJECT_DIR/.claude/test-cmd" +printf '#!/usr/bin/env bash\nprintf "benchmark\\n"\n' > "$PROJECT_DIR/valid.sh" +printf '#!/usr/bin/env bash\nlocal value=1\nprintf "%%s\\n" "$value"\n' > "$PROJECT_DIR/invalid.sh" +printf 'pub fn benchmark_probe() {}\n' > "$PROJECT_DIR/src/probe.rs" +printf 'version: 1\nblocklist: []\nfence:\n enabled: true\n allowed_paths:\n - "%s"\n' \ + "$PROJECT_DIR" > "$PROJECT_DIR/railguard.yaml" + +now_ms() { + local ms + ms=$(date +%s%3N 2>/dev/null) + if [[ -z "$ms" || "$ms" == *N* ]]; then + if command -v python3 >/dev/null 2>&1; then + ms=$(python3 -c 'import time; print(int(time.time() * 1000))') + else + ms=$(( $(date +%s) * 1000 )) + fi + fi + printf '%s\n' "$ms" +} + +fail_case() { + LAST_DETAIL=$1 + return 1 +} + +capture_hook() { + local hook=$1 + local input=$2 + local stdout_file="$BENCH_ROOT/hook.stdout" + local stderr_file="$BENCH_ROOT/hook.stderr" + + printf '%s\n' "$input" | bash "$hook" > "$stdout_file" 2> "$stderr_file" + LAST_STATUS=$? + LAST_OUTPUT=$(< "$stdout_file") + LAST_ERROR=$(< "$stderr_file") +} + +capture_railguard() { + local tool_name=$1 + local tool_input=$2 + local stdout_file="$BENCH_ROOT/railguard.stdout" + local stderr_file="$BENCH_ROOT/railguard.stderr" + local input + + input=$(jq -nc \ + --arg session_id "codex-hook-benchmark-$$-$CASE_INDEX" \ + --arg cwd "$PROJECT_DIR" \ + --arg tool_name "$tool_name" \ + --argjson tool_input "$tool_input" \ + '{session_id: $session_id, cwd: $cwd, hook_event_name: "PreToolUse", tool_name: $tool_name, tool_input: $tool_input, tool_use_id: "benchmark"}') + + printf '%s\n' "$input" | env \ + HOME="$BENCH_HOME" \ + RAILGUARD_HOME="$RAILGUARD_STATE" \ + RAILGUARD_NO_KILL=1 \ + "$RAILGUARD_BIN" hook --client codex --event PreToolUse \ + > "$stdout_file" 2> "$stderr_file" + LAST_STATUS=$? + LAST_OUTPUT=$(< "$stdout_file") + LAST_ERROR=$(< "$stderr_file") +} + +expect_status() { + local expected=$1 + local context=$2 + [[ $LAST_STATUS -eq $expected ]] || fail_case "$context: expected exit $expected, got $LAST_STATUS" +} + +expect_output() { + local filter=$1 + local context=$2 + jq -e "$filter" <<< "$LAST_OUTPUT" >/dev/null 2>&1 \ + || fail_case "$context: unexpected output: $LAST_OUTPUT" +} + +case_railguard_safe_noop() { + capture_railguard Bash "$(jq -nc '{command: "git status --short"}')" + expect_status 0 "Railguard safe response" || return 1 + expect_output 'type == "object" and length == 0' "Codex safe response must omit permissionDecision" +} + +case_railguard_hard_deny() { + capture_railguard Bash "$(jq -nc '{command: "rm -rf /"}')" + expect_status 0 "Railguard hard deny" || return 1 + expect_output '.hookSpecificOutput.permissionDecision == "deny"' "destructive command must be denied" +} + +case_railguard_approval_becomes_deny() { + capture_railguard Bash "$(jq -nc '{command: "npm publish"}')" + expect_status 0 "Railguard approval response" || return 1 + expect_output \ + '.hookSpecificOutput.permissionDecision == "deny" and (.hookSpecificOutput.permissionDecisionReason | ascii_downcase | contains("requires human approval"))' \ + "Codex approval-required response must be an actionable deny" +} + +case_railguard_outside_fence() { + capture_railguard Write "$(jq -nc --arg path "$OUTSIDE_DIR/probe.txt" '{file_path: $path, content: "probe"}')" + expect_status 0 "Railguard path fence response" || return 1 + expect_output '.hookSpecificOutput.permissionDecision == "deny"' "write outside project fence must be denied" +} + +case_validate_bash_safe() { + capture_hook "$HOOKS_DIR/validate-bash.sh" \ + "$(jq -nc '{tool_name: "Bash", tool_input: {command: "git status --short"}}')" + expect_status 0 "safe custom Bash hook" +} + +case_validate_bash_bulk_stage() { + capture_hook "$HOOKS_DIR/validate-bash.sh" \ + "$(jq -nc '{tool_name: "Bash", tool_input: {command: "git add --all"}}')" + expect_status 2 "bulk staging custom Bash hook" +} + +case_validate_bash_patch_text() { + capture_hook "$HOOKS_DIR/validate-bash.sh" \ + "$(jq -nc --arg cwd "$PROJECT_DIR" --arg command $'*** Begin Patch\n*** Update File: README.md\n@@\n+sudo --version\n*** End Patch' '{cwd: $cwd, tool_name: "apply_patch", tool_input: {command: $command}}')" + expect_status 0 "apply_patch text must not be parsed as a shell command" +} + +case_validate_write_sensitive_patch() { + capture_hook "$HOOKS_DIR/validate-write.sh" \ + "$(jq -nc --arg cwd "$PROJECT_DIR" --arg command $'*** Begin Patch\n*** Update File: /etc/benchmark-probe\n@@\n+probe\n*** End Patch' '{cwd: $cwd, tool_name: "apply_patch", tool_input: {command: $command}}')" + expect_status 2 "sensitive Codex patch path" +} + +case_shellcheck_valid_patch() { + capture_hook "$HOOKS_DIR/post-edit-shellcheck.sh" \ + "$(jq -nc --arg cwd "$PROJECT_DIR" --arg command $'*** Begin Patch\n*** Update File: valid.sh\n*** End Patch' '{cwd: $cwd, tool_name: "apply_patch", tool_input: {command: $command}}')" + expect_status 0 "valid shell patch" +} + +case_shellcheck_invalid_patch() { + capture_hook "$HOOKS_DIR/post-edit-shellcheck.sh" \ + "$(jq -nc --arg cwd "$PROJECT_DIR" --arg command $'*** Begin Patch\n*** Update File: invalid.sh\n*** End Patch' '{cwd: $cwd, tool_name: "apply_patch", tool_input: {command: $command}}')" + expect_status 2 "invalid shell patch" +} + +case_test_runner_patch() { + capture_hook "$HOOKS_DIR/post-test-runner.sh" \ + "$(jq -nc --arg cwd "$PROJECT_DIR" --arg command $'*** Begin Patch\n*** Update File: src/probe.rs\n*** End Patch' '{cwd: $cwd, tool_name: "apply_patch", tool_input: {command: $command}}')" + expect_status 0 "Codex source patch test runner" || return 1 + [[ "$LAST_ERROR" == *'post-test-runner: true passed'* ]] \ + || fail_case "test runner did not report the isolated test command: $LAST_ERROR" +} + +run_case() { + local name=$1 + local function_name=$2 + local start_ms end_ms elapsed_ms + + CASE_INDEX=$(( CASE_INDEX + 1 )) + LAST_DETAIL="" + start_ms=$(now_ms) + if "$function_name"; then + end_ms=$(now_ms) + elapsed_ms=$(( end_ms - start_ms )) + printf 'ok %d - %s # time=%dms\n' "$CASE_INDEX" "$name" "$elapsed_ms" + else + end_ms=$(now_ms) + elapsed_ms=$(( end_ms - start_ms )) + FAILURES=$(( FAILURES + 1 )) + printf 'not ok %d - %s # time=%dms\n' "$CASE_INDEX" "$name" "$elapsed_ms" + printf '# %s\n' "${LAST_DETAIL:-case failed without diagnostics}" + fi +} + +CASE_NAMES=( + 'Railguard safe Codex response is a no-op' + 'Railguard hard block is a deny' + 'Railguard approval requirement becomes a Codex deny' + 'Railguard rejects a write outside the project fence' + 'custom Bash hook allows a safe command' + 'custom Bash hook blocks bulk staging' + 'custom Bash hook ignores apply_patch body text' + 'custom write hook blocks a sensitive patch path' + 'post-edit ShellCheck accepts a valid patch' + 'post-edit ShellCheck catches an invalid patch' + 'post-edit test runner executes for a Codex source patch' +) +CASE_FUNCTIONS=( + case_railguard_safe_noop + case_railguard_hard_deny + case_railguard_approval_becomes_deny + case_railguard_outside_fence + case_validate_bash_safe + case_validate_bash_bulk_stage + case_validate_bash_patch_text + case_validate_write_sensitive_patch + case_shellcheck_valid_patch + case_shellcheck_invalid_patch + case_test_runner_patch +) +TOTAL_CASES=${#CASE_NAMES[@]} +RAILGUARD_VERSION=$("$RAILGUARD_BIN" --version 2>/dev/null || printf 'unknown') +printf 'TAP version 13\n' +printf '1..%d\n' "$TOTAL_CASES" +printf '# railguard=%s\n' "$RAILGUARD_BIN" +printf '# version=%s\n' "$RAILGUARD_VERSION" +printf '# workspace=%s\n' "$BENCH_ROOT" + +for (( case_offset = 0; case_offset < TOTAL_CASES; case_offset++ )); do + run_case "${CASE_NAMES[$case_offset]}" "${CASE_FUNCTIONS[$case_offset]}" +done + +printf '# result=%d passed, %d failed\n' "$(( TOTAL_CASES - FAILURES ))" "$FAILURES" +[[ $FAILURES -eq 0 ]] diff --git a/agentic-ai/Claude/hooks/post-edit-shellcheck.sh b/agentic-ai/Claude/hooks/post-edit-shellcheck.sh index 39c93f1..dc1ffbd 100755 --- a/agentic-ai/Claude/hooks/post-edit-shellcheck.sh +++ b/agentic-ai/Claude/hooks/post-edit-shellcheck.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# PostToolUse hook for Write/Edit/MultiEdit: runs shellcheck on edited .sh files. +# PostToolUse hook for Write/Edit/MultiEdit/apply_patch: runs shellcheck on edited .sh files. # Exit 2 = block (Claude sees stderr and must fix before continuing). set -euo pipefail trap 'exit 2' ERR @@ -7,15 +7,32 @@ trap 'exit 2' ERR command -v shellcheck &>/dev/null || exit 0 INPUT=$(cat) -FILE=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // ""') +CWD=$(jq -r '.cwd // ""' <<< "$INPUT") +FILES=$(jq -r ' + if (.tool_input.file_path // "") != "" then + .tool_input.file_path + else + (.tool_input.command // "") + | split("\n")[] + | select(test("^\\*\\*\\* (Add|Update|Delete) File: |^\\*\\*\\* Move to: ")) + | sub("^\\*\\*\\* (Add|Update|Delete) File: "; "") + | sub("^\\*\\*\\* Move to: "; "") + end +' <<< "$INPUT") -[[ "$FILE" == *.sh ]] || exit 0 -[[ -f "$FILE" ]] || exit 0 +while IFS= read -r file; do + [[ -n "$file" ]] || continue + if [[ "$file" != /* && -n "$CWD" ]]; then + file="$CWD/$file" + fi + [[ "$file" == *.sh ]] || continue + [[ -f "$file" ]] || continue -# Skip zsh shebangs — only sh/bash/dash/ksh/busybox are supported (SC1071). -[[ "$(head -n1 "$FILE")" == *zsh* ]] && exit 0 + # Skip zsh shebangs — only sh/bash/dash/ksh/busybox are supported (SC1071). + [[ "$(head -n1 "$file")" == *zsh* ]] && continue -if ! shellcheck --severity=error "$FILE" >&2; then - printf '\npost-edit-shellcheck.sh: shellcheck errors in %s — fix before continuing.\n' "$FILE" >&2 - exit 2 -fi + if ! shellcheck --severity=error "$file" >&2; then + printf '\npost-edit-shellcheck.sh: shellcheck errors in %s — fix before continuing.\n' "$file" >&2 + exit 2 + fi +done <<< "$FILES" diff --git a/agentic-ai/Claude/hooks/post-test-runner.sh b/agentic-ai/Claude/hooks/post-test-runner.sh index b15fdb1..fad7be3 100755 --- a/agentic-ai/Claude/hooks/post-test-runner.sh +++ b/agentic-ai/Claude/hooks/post-test-runner.sh @@ -1,17 +1,35 @@ #!/usr/bin/env bash -# PostToolUse hook for Write/Edit/MultiEdit: runs the project test suite after source edits. +# PostToolUse hook for Write/Edit/MultiEdit/apply_patch: runs the project test suite after source edits. # Exit 2 = Claude sees failure output (warn). Exit 0 = passed (timing shown) or no suite found. set -uo pipefail INPUT=$(cat) || exit 0 -FILE=$(jq -r '.tool_input.file_path // ""' <<< "$INPUT" 2>/dev/null) || exit 0 +CWD=$(jq -r '.cwd // ""' <<< "$INPUT" 2>/dev/null) || exit 0 +FILES=$(jq -r ' + if (.tool_input.file_path // "") != "" then + .tool_input.file_path + else + (.tool_input.command // "") + | split("\n")[] + | select(test("^\\*\\*\\* (Add|Update|Delete) File: |^\\*\\*\\* Move to: ")) + | sub("^\\*\\*\\* (Add|Update|Delete) File: "; "") + | sub("^\\*\\*\\* Move to: "; "") + end +' <<< "$INPUT" 2>/dev/null) || exit 0 +FILE="" +while IFS= read -r candidate; do + [[ -n "$candidate" ]] || continue + if [[ "$candidate" != /* && -n "$CWD" ]]; then + candidate="$CWD/$candidate" + fi + case "${candidate##*.}" in + md|txt|json|yaml|yml|toml|lock|rst|svg|png|jpg|jpeg|gif|pdf|ico) continue ;; + esac + FILE="$candidate" + break +done <<< "$FILES" [[ -n "$FILE" ]] || exit 0 -# Skip non-source extensions -case "${FILE##*.}" in - md|txt|json|yaml|yml|toml|lock|rst|svg|png|jpg|jpeg|gif|pdf|ico) exit 0 ;; -esac - # Resolve project root via git; no repo = no test suite ROOT=$(git -C "$(dirname "$FILE")" rev-parse --show-toplevel 2>/dev/null) || exit 0 diff --git a/agentic-ai/Claude/hooks/validate-bash.sh b/agentic-ai/Claude/hooks/validate-bash.sh index e406c9b..59befce 100755 --- a/agentic-ai/Claude/hooks/validate-bash.sh +++ b/agentic-ai/Claude/hooks/validate-bash.sh @@ -4,7 +4,10 @@ set -euo pipefail trap 'exit 2' ERR -COMMAND=$(jq -r '.tool_input.command // ""') +INPUT=$(cat) +TOOL_NAME=$(jq -r '.tool_name // ""' <<< "$INPUT") +COMMAND=$(jq -r '.tool_input.command // ""' <<< "$INPUT") +[[ "$TOOL_NAME" == "apply_patch" || "$TOOL_NAME" == "functions.apply_patch" ]] && exit 0 # Heredoc bodies are message text, not executed code — policy checks use only the first line. FIRST_LINE=$(head -1 <<< "$COMMAND") diff --git a/agentic-ai/Claude/hooks/validate-write.sh b/agentic-ai/Claude/hooks/validate-write.sh index 96d3c63..9a0fa37 100755 --- a/agentic-ai/Claude/hooks/validate-write.sh +++ b/agentic-ai/Claude/hooks/validate-write.sh @@ -1,20 +1,28 @@ #!/usr/bin/env bash -# PreToolUse hook for Write/Edit/MultiEdit: blocks writes to sensitive file paths. +# PreToolUse hook for Write/Edit/MultiEdit/apply_patch: blocks writes to sensitive file paths. # Exit 2 = block the tool call (stderr is shown to Claude as the reason). set -euo pipefail trap 'exit 2' ERR INPUT=$(cat) -FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // ""') +CWD=$(jq -r '.cwd // ""' <<< "$INPUT") +FILE_PATHS=$(jq -r ' + if (.tool_input.file_path // "") != "" then + .tool_input.file_path + else + (.tool_input.command // "") + | split("\n")[] + | select(test("^\\*\\*\\* (Add|Update|Delete) File: |^\\*\\*\\* Move to: ")) + | sub("^\\*\\*\\* (Add|Update|Delete) File: "; "") + | sub("^\\*\\*\\* Move to: "; "") + end +' <<< "$INPUT") block() { printf 'validate-write.sh blocked: %s\n' "$1" >&2 exit 2 } -# Expand leading ~ to $HOME for comparison -EXPANDED="${FILE_PATH/#\~/$HOME}" - SENSITIVE_PREFIXES=( "$HOME/.ssh" "$HOME/.aws" @@ -27,10 +35,17 @@ SENSITIVE_PREFIXES=( "/proc" ) -for prefix in "${SENSITIVE_PREFIXES[@]}"; do - if [[ "$EXPANDED" == "$prefix"* ]]; then - block "write to sensitive path: $FILE_PATH" +while IFS= read -r file_path; do + [[ -n "$file_path" ]] || continue + expanded="${file_path/#\~/$HOME}" + if [[ "$expanded" != /* && -n "$CWD" ]]; then + expanded="$CWD/$expanded" fi -done + for prefix in "${SENSITIVE_PREFIXES[@]}"; do + if [[ "$expanded" == "$prefix" || "$expanded" == "$prefix/"* ]]; then + block "write to sensitive path: $file_path" + fi + done +done <<< "$FILE_PATHS" exit 0 diff --git a/agentic-ai/Claude/install.sh b/agentic-ai/Claude/install.sh index 58e5abd..e8b90b7 100755 --- a/agentic-ai/Claude/install.sh +++ b/agentic-ai/Claude/install.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Idempotent setup: deploys this repo's Claude config into ~/.claude/ -# (settings.json is copied, everything else symlinked). +# Idempotent setup: deploys this repo's Claude config and shared Codex hooks. +# Claude settings are copied; instructions, docs, policies, and hooks are symlinked. # Safe to re-run. Backs up any existing settings.json before replacing it. set -euo pipefail @@ -8,7 +8,7 @@ set -euo pipefail REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" AGENTIC_DIR="$(cd "$REPO_DIR/.." && pwd)" CLAUDE_DIR="$HOME/.claude" -HOOKS_DIR="$CLAUDE_DIR/hooks" +HOOKS_DIRS=("$CLAUDE_DIR/hooks" "$HOME/.codex/hooks") SETTINGS="$CLAUDE_DIR/settings.json" if [[ -d "$CLAUDE_DIR/docs" && ! -L "$CLAUDE_DIR/docs" ]]; then @@ -18,6 +18,8 @@ fi printf 'Installing from: %s\n' "$REPO_DIR" +mkdir -p "$CLAUDE_DIR" + # settings.json is COPIED, not symlinked: Claude Code rewrites its user # settings at runtime (model switches, plugin installs re-serialize the file), # and a symlink funnels that machine state into the repo as permanent dirt. @@ -138,14 +140,16 @@ fi ln -sf "$REPO_DIR/railguard.yaml" "$HOME/.railguard.yaml" printf 'Linked: railguard.yaml → ~/.railguard.yaml\n' -# Create hooks dir if it doesn't exist -mkdir -p "$HOOKS_DIR" +# Create hook dirs if they don't exist +mkdir -p "${HOOKS_DIRS[@]}" # Symlink each hook script and ensure it's executable for hook in "$REPO_DIR/hooks/"*.sh; do chmod +x "$hook" - ln -sf "$hook" "$HOOKS_DIR/$(basename "$hook")" - printf 'Linked: hooks/%s\n' "$(basename "$hook")" + for hooks_dir in "${HOOKS_DIRS[@]}"; do + ln -sf "$hook" "$hooks_dir/$(basename "$hook")" + printf 'Linked: %s/%s\n' "$hooks_dir" "$(basename "$hook")" + done done # Install (or migrate) the railguard binary from the GitHub source. @@ -181,6 +185,58 @@ fi # absolute paths; redeploy the template afterwards so portable ~ paths win. printf 'Configuring railguard...\n' "$RAILGUARD_BIN" install + +CODEX_HOOKS="$HOME/.codex/hooks.json" +# Codex only writes hooks.json once the user configures hooks; seed an empty +# object so registration works on a fresh install instead of aborting the script. +[[ -f "$CODEX_HOOKS" ]] || printf '{}\n' > "$CODEX_HOOKS" +CODEX_HOOKS_TMP=$(mktemp "$HOME/.codex/hooks.json.tmp.XXXXXX") +trap 'rm -f "$CODEX_HOOKS_TMP"' EXIT +jq \ + --arg managed '/(validate-bash|validate-write|post-edit-shellcheck|post-test-runner|driftcheck)\.sh' \ + --arg validate_bash "bash \"$HOME/.codex/hooks/validate-bash.sh\"" \ + --arg validate_write "bash \"$HOME/.codex/hooks/validate-write.sh\"" \ + --arg post_shellcheck "bash \"$HOME/.codex/hooks/post-edit-shellcheck.sh\"" \ + --arg post_test "bash \"$HOME/.codex/hooks/post-test-runner.sh\"" \ + --arg driftcheck "bash \"$HOME/.codex/hooks/driftcheck.sh\"" ' + def strip_managed: + map( + .hooks = [ + (.hooks // [])[] + | select((((.command // "") | test($managed))) | not) + ] + ) + | map(select((.hooks | length) > 0)); + .hooks //= {} | + .hooks.PreToolUse = ((.hooks.PreToolUse // [] | strip_managed) + [{ + "matcher": "", + "hooks": [ + {"type": "command", "command": $validate_bash, "timeout": 5}, + {"type": "command", "command": $validate_write, "timeout": 5} + ] + }]) | + .hooks.PostToolUse = ((.hooks.PostToolUse // [] | strip_managed) + [{ + "matcher": "", + "hooks": [ + {"type": "command", "command": $post_shellcheck, "timeout": 5}, + {"type": "command", "command": $post_test, "timeout": 75} + ] + }]) | + .hooks.Stop = ((.hooks.Stop // [] | strip_managed) + [{ + "matcher": "", + "hooks": [{"type": "command", "command": $driftcheck, "timeout": 5}] + }]) +' "$CODEX_HOOKS" > "$CODEX_HOOKS_TMP" +if cmp -s "$CODEX_HOOKS" "$CODEX_HOOKS_TMP"; then + rm -f "$CODEX_HOOKS_TMP" + printf 'Codex custom hooks already registered\n' +else + CODEX_HOOKS_BACKUP="$CODEX_HOOKS.bak.$(date +%Y%m%d%H%M%S)" + cp "$CODEX_HOOKS" "$CODEX_HOOKS_BACKUP" + mv "$CODEX_HOOKS_TMP" "$CODEX_HOOKS" + printf 'Registered Codex custom hooks (backup: %s)\n' "$CODEX_HOOKS_BACKUP" +fi +trap - EXIT cp "$REPO_DIR/settings.json" "$SETTINGS" # Warn on Ubuntu 24.04+ if the bwrap AppArmor profile isn't set up @@ -190,4 +246,4 @@ if grep -qi 'ubuntu' /etc/os-release 2>/dev/null && ! [[ -f /etc/apparmor.d/bwra printf ' bash %s/setup-linux-sandbox.sh\n' "$REPO_DIR" fi -printf '\nDone. Restart Claude Code for changes to take effect.\n' +printf '\nDone. Restart Claude Code and Codex for changes to take effect.\n' diff --git a/agentic-ai/Claude/validate.sh b/agentic-ai/Claude/validate.sh index a8906ba..33f278c 100755 --- a/agentic-ai/Claude/validate.sh +++ b/agentic-ai/Claude/validate.sh @@ -8,7 +8,7 @@ set -uo pipefail REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" AGENTIC_DIR="$(cd "$REPO_DIR/.." && pwd)" CLAUDE_DIR="$HOME/.claude" -HOOKS_DIR="$CLAUDE_DIR/hooks" +HOOKS_DIRS=("$CLAUDE_DIR/hooks" "$HOME/.codex/hooks") ERRORS=0 pass() { printf ' [ OK ] %s\n' "$1"; } @@ -73,22 +73,45 @@ done section "Hooks" for hook in "$REPO_DIR/hooks/"*.sh; do name="$(basename "$hook")" - linked="$HOOKS_DIR/$name" - if ! [[ -L "$linked" && "$(readlink "$linked")" == "$hook" ]]; then - fail "hooks/$name: not linked in $HOOKS_DIR" - continue - fi if ! [[ -x "$hook" ]]; then fail "hooks/$name: not executable (run: chmod +x $hook)" - continue + else + pass "hooks/$name: source is executable" fi if ! bash -n "$hook" 2>/dev/null; then fail "hooks/$name: bash syntax error" - continue + else + pass "hooks/$name: source syntax" fi - pass "hooks/$name" + for hooks_dir in "${HOOKS_DIRS[@]}"; do + check_symlink "$hooks_dir/$name" "$hook" + done done +section "Codex hook registration" +CODEX_HOOKS="$HOME/.codex/hooks.json" +if ! jq empty "$CODEX_HOOKS" >/dev/null 2>&1; then + fail "$CODEX_HOOKS: missing or invalid JSON" +else + for registration in \ + "PreToolUse:validate-bash.sh" \ + "PreToolUse:validate-write.sh" \ + "PostToolUse:post-edit-shellcheck.sh" \ + "PostToolUse:post-test-runner.sh" \ + "Stop:driftcheck.sh"; do + event="${registration%%:*}" + name="${registration#*:}" + if jq -e --arg event "$event" --arg name "$name" ' + [.hooks[$event][]?.hooks[]?.command // empty | select(contains("/" + $name))] + | length > 0 + ' "$CODEX_HOOKS" >/dev/null 2>&1; then + pass "Codex $event hook registered: $name" + else + fail "Codex $event hook missing: $name" + fi + done +fi + # ── Skills and output styles ────────────────────────────────────────────────── # Each vendored skill must be linked into every installed harness's global skill # root, so a writing task gets the same instructions in Claude Code, Codex, @@ -218,6 +241,18 @@ else fail "validate-bash.sh: incorrectly blocked a safe command" fi +if run_hook validate-bash.sh '{"tool_name":"apply_patch","tool_input":{"command":"*** Begin Patch\n*** Add File: script.sh\n+sudo --version\n*** End Patch"}}'; then + pass "validate-bash.sh: ignores Codex patch contents" +else + fail "validate-bash.sh: scanned Codex patch contents as shell commands" +fi + +if run_hook validate-bash.sh '{"tool_name":"Bash","tool_input":{"command":"sudo --version"}}'; then + fail "validate-bash.sh: trusted a sudo Bash command" +else + pass "validate-bash.sh: still blocks sudo Bash commands" +fi + # validate-write.sh: must block writes to sensitive paths if run_hook validate-write.sh '{"tool_input":{"file_path":"/etc/passwd"}}'; then fail "validate-write.sh: did not block write to /etc/passwd" @@ -238,6 +273,12 @@ else fail "validate-write.sh: incorrectly blocked /tmp/test.txt" fi +if run_hook validate-write.sh '{"cwd":"/tmp","tool_input":{"command":"*** Begin Patch\n*** Update File: /etc/passwd\n@@\n-old\n+new\n*** End Patch"}}'; then + fail "validate-write.sh: did not block a sensitive Codex patch" +else + pass "validate-write.sh: blocks sensitive Codex patches" +fi + # post-edit-shellcheck.sh: must pass on a valid script if printf '{"tool_input":{"file_path":"%s"}}' "$REPO_DIR/hooks/validate-bash.sh" \ | bash "$REPO_DIR/hooks/post-edit-shellcheck.sh" &>/dev/null; then @@ -250,11 +291,11 @@ fi _TMPSH=$(mktemp /tmp/bad-XXXXXX.sh) trap 'rm -f "$_TMPSH"' EXIT printf '#!/usr/bin/env bash\nFOO=$(\n' > "$_TMPSH" -if printf '{"tool_input":{"file_path":"%s"}}' "$_TMPSH" \ +if printf '{"cwd":"/tmp","tool_input":{"command":"*** Begin Patch\\n*** Update File: %s\\n@@\\n-old\\n+new\\n*** End Patch"}}' "$_TMPSH" \ | bash "$REPO_DIR/hooks/post-edit-shellcheck.sh" &>/dev/null; then - fail "post-edit-shellcheck.sh: failed to catch a syntax error" + fail "post-edit-shellcheck.sh: failed to catch a Codex patch syntax error" else - pass "post-edit-shellcheck.sh: catches shell syntax errors" + pass "post-edit-shellcheck.sh: catches shell syntax errors from Codex patches" fi rm -f "$_TMPSH"; trap - EXIT diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index dab43bd..17de02b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -6,6 +6,30 @@ Format loosely follows [Keep a Changelog](https://keepachangelog.com). Remaining work lives in [TODO.md](TODO.md); the design rationale for the unified layout is in [UNIFICATION.md](UNIFICATION.md). +## Unreleased — Codex hook parity + +### Added +- `agentic-ai/Claude/benchmark-codex-hooks.sh` provides a deterministic TAP + benchmark for the installed or development Railguard binary plus the shared + Codex hook payloads. Every case runs in disposable state and reports timing. +- `install.sh` now deploys the shared hook scripts to `~/.codex/hooks/` and + idempotently merges their registrations into `~/.codex/hooks.json` without + replacing Railguard or unrelated user hooks. `validate.sh` checks the links, + registrations, and Codex-specific payload behavior. + +### Fixed +- Write, ShellCheck, and test-runner hooks now extract every file from Codex + `apply_patch` payloads while retaining Claude's `file_path` behavior. The Bash + hook ignores patch content only when the tool identity is actually + `apply_patch`, so a patch-like shell command cannot bypass command checks. +- `install.sh` seeds an empty `~/.codex/hooks.json` on machines where Codex has + never written one, instead of aborting before hook registration. +- The Codex benchmark runs Railguard under a bench-local `$HOME`, so results no + longer depend on the live machine's `~/.codex/hooks.json` (Railguard's + self-integrity check denies everything when that file lacks its entry). +- Linux desktop dry-runs no longer execute `pipx ensurepath`; planned pipx + installs are still printed even when pipx is absent. + ## Unreleased — driftcheck as a nudge ### Changed diff --git a/lib/core.sh b/lib/core.sh index 3a8d101..7970699 100755 --- a/lib/core.sh +++ b/lib/core.sh @@ -819,8 +819,8 @@ ghostty_deploy_linux() { desktop_pipx_section() { printf '\n==> Installing pipx packages...\n' - if command -v pipx &>/dev/null; then - pipx ensurepath + if [[ "$DRY_RUN" == true ]] || command -v pipx &>/dev/null; then + [[ "$DRY_RUN" == false ]] && pipx ensurepath pipx_install_tier "medium" else printf ' pipx not found — skipping\n'