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
102 changes: 62 additions & 40 deletions .claude/hooks/pre-push-main-blocker.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,71 +7,93 @@
# - Commits on main: ALLOWED (may commit)
# - Push to non-main branches: ALLOWED
# - Push to main: BLOCKED
#
# No jq dependency: the two fields this hook reads (.tool_name,
# .tool_input.command) are both flat JSON strings, so the field-scoped sed
# idiom used by branch-pr-discipline.sh extracts them precisely — never by
# substring-matching the raw JSON blob. This also closes the old fail-open
# gap where an absent jq skipped the whole hook, including the implicit
# "bare `git push` while on main" case that permissions.deny cannot express
# (it has no literal branch name in a bare push to pattern-match against).

INPUT=$(cat)
payload="$(cat 2>/dev/null || true)"
[ -z "$payload" ] && exit 0

# jq is required to parse tool input; fail open if unavailable (hooks are
# guardrails, not a security boundary)
command -v jq >/dev/null 2>&1 || exit 0
# extract_json_field: prints the string value of a flat "field":"value" pair
# in $payload (e.g. tool_name, or tool_input's leaf key "command"), or empty
# if absent. Handles JSON-escaped characters inside the value via the
# (...|\\.) alternation — same field-scoped idea as branch-pr-discipline.sh's
# command extraction, generalized to a field name and written with `sed -E`
# (extended regex) rather than backslash-escaped BRE groups/alternation:
# BSD/macOS sed's BRE mode does not support `\|` as alternation (it free
# passes as a literal pipe), so the exact BRE form silently extracts nothing
# there — `-E` is portable across both BSD and GNU sed.
extract_json_field() { # $1=field name
printf '%s' "$payload" | sed -nE 's/.*"'"$1"'"[[:space:]]*:[[:space:]]*"(([^"\\]|\\.)*)".*/\1/p' | head -n1
}

TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null || true)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null || true)
TOOL_NAME=$(extract_json_field tool_name)
COMMAND=$(extract_json_field command)

# Only process Bash tool
if [ "$TOOL_NAME" != "Bash" ]; then
exit 0
fi
[ "$TOOL_NAME" != "Bash" ] && exit 0

# Only check git push commands
if ! echo "$COMMAND" | grep -qE '\bgit\s+push\b'; then
exit 0
fi

# Get current branch
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
echo "$COMMAND" | grep -qE '\bgit\s+push\b' || exit 0

# Check if pushing to main
# Patterns to detect:
# - git push (on main branch, pushes to upstream main)
# - git push origin main
# - git push origin main:main
# - git push -u origin main
# - git push --set-upstream origin main
# Current branch — -C defaults to "." (a real invocation's cwd is already the
# project dir); the explicit path lets tests point this at a throwaway
# fixture repo without changing this process's own cwd.
CURRENT_BRANCH=$(git -C "${CLAUDE_PROJECT_DIR:-.}" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")

IS_PUSH_TO_MAIN=false
DENY_REASON=""

# Check for explicit main/master in push command
if echo "$COMMAND" | grep -qE '\bgit\s+push\b.*\b(main|master)\b'; then
IS_PUSH_TO_MAIN=true
fi
# Trailing positional token after `git push` — the destination-branch
# position in ordinary `git push [opts] [remote [refspec]]` usage — compared
# for EQUALITY, never substring/word-boundary, against main/master. A
# word-boundary match (the previous approach) also matches "main" inside
# branch names like `feature/main-cleanup` or `domain-master-list`, which is
# the reproduced false positive this rewrite fixes.
push_args="${COMMAND#*git push}"
read -ra _tokens <<< "$push_args"
positional=()
for tok in "${_tokens[@]}"; do
case "$tok" in
-*) continue ;;
*) positional+=("$tok") ;;
esac
done

# Check for push without explicit branch while on main
# This catches: git push, git push origin, git push -u origin
if [ "$CURRENT_BRANCH" = "main" ] || [ "$CURRENT_BRANCH" = "master" ]; then
# If no branch specified in push command, it will push current branch
if ! echo "$COMMAND" | grep -qE '\bgit\s+push\b.*\s+[a-zA-Z0-9_-]+\s+[a-zA-Z0-9_/-]+'; then
# No explicit remote/branch pair - will push current branch
# Check if it's just "git push" or "git push origin" without branch
if echo "$COMMAND" | grep -qE '\bgit\s+push\s*$' || \
echo "$COMMAND" | grep -qE '\bgit\s+push\s+(--[a-z-]+\s+)*[a-zA-Z0-9_-]+\s*$'; then
IS_PUSH_TO_MAIN=true
fi
if [ "${#positional[@]}" -ge 2 ]; then
# Explicit remote + ref given: the ref is the destination. A `src:dst`
# refspec names the destination after the colon.
dest="${positional[${#positional[@]}-1]}"
case "$dest" in
*:*) dest="${dest##*:}" ;;
esac
if [ "$dest" = "main" ] || [ "$dest" = "master" ]; then
IS_PUSH_TO_MAIN=true
DENY_REASON="explicit push to $dest"
fi
elif [ "$CURRENT_BRANCH" = "main" ] || [ "$CURRENT_BRANCH" = "master" ]; then
# Bare/implicit push (no explicit remote+ref pair): git pushes the
# current branch via its configured upstream (or push.default=simple).
IS_PUSH_TO_MAIN=true
DENY_REASON="implicit push of current branch '$CURRENT_BRANCH'"
fi

# Block if pushing to main
if [ "$IS_PUSH_TO_MAIN" = true ]; then
cat << EOF
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "BLOCKED: Cannot push directly to main branch. Trunk-based development requires:\\n\\n1. Create a feature branch: git checkout -b feature/your-change\\n2. Commit your changes on the branch\\n3. Push the branch: git push -u origin feature/your-change\\n4. Create a PR for review\\n\\nCurrent branch: $CURRENT_BRANCH"
"permissionDecisionReason": "BLOCKED: Cannot push directly to main branch ($DENY_REASON). Trunk-based development requires:\\n\\n1. Create a feature branch: git checkout -b feature/your-change\\n2. Commit your changes on the branch\\n3. Push the branch: git push -u origin feature/your-change\\n4. Create a PR for review\\n\\nCurrent branch: $CURRENT_BRANCH"
}
}
EOF
exit 0
fi

# Allow all other push commands
exit 0
132 changes: 92 additions & 40 deletions .claude/hooks/pre-tool-use-validator.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,83 @@ TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null || true)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty' 2>/dev/null || true)
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty' 2>/dev/null || true)

# detect_secret: shared secret-shape scan for both Write/Edit content (below)
# and Bash commands that redirect/heredoc content into a file. Sets
# SECRET_DETECTED / SECRET_TYPE as a side effect rather than echoing a
# classification back through a subshell.
detect_secret() {
local text="$1"
SECRET_DETECTED=false
SECRET_TYPE=""

# Generic secrets (API keys, passwords, tokens)
# `-e` on every pattern below (not just this one) is deliberate: a
# pattern beginning with a literal `-` (the private-key one, below) gets
# parsed as an option by BSD grep without it, erroring out instead of
# matching — `-e` marks the argument as a pattern unconditionally, so
# this holds regardless of what a pattern happens to start with.
if echo "$text" | grep -qiE -e '(api[_-]?key|secret|password|token|credential).*[=:][[:space:]]*["\x27]?[a-zA-Z0-9+/]{20,}'; then
SECRET_DETECTED=true
SECRET_TYPE="generic secret"
fi

# AWS access keys (AKIA followed by 16 alphanumeric chars)
if echo "$text" | grep -qE -e 'AKIA[0-9A-Z]{16}'; then
SECRET_DETECTED=true
SECRET_TYPE="AWS access key"
fi

# JWT tokens (three base64 segments separated by dots)
if echo "$text" | grep -qE -e 'eyJ[a-zA-Z0-9_-]+\.eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+'; then
SECRET_DETECTED=true
SECRET_TYPE="JWT token"
fi

# Environment variable exports with secrets
if echo "$text" | grep -qiE -e 'export\s+(API_KEY|SECRET|PASSWORD|TOKEN|CREDENTIAL|AWS_|PRIVATE_KEY)=["\x27]?[a-zA-Z0-9+/]{20,}'; then
SECRET_DETECTED=true
SECRET_TYPE="exported secret"
fi

# GitHub personal access tokens
if echo "$text" | grep -qE -e 'ghp_[a-zA-Z0-9]{36}'; then
SECRET_DETECTED=true
SECRET_TYPE="GitHub personal access token"
fi

# Private keys (PEM format) — this pattern starts with a literal `-----`;
# without `-e` above, BSD grep (macOS) treats that as option flags and
# errors out instead of matching, silently never detecting a real
# private key on those systems.
if echo "$text" | grep -qE -e '-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----'; then
SECRET_DETECTED=true
SECRET_TYPE="private key"
fi
}

# Bash secret-write scan: a redirect or heredoc can write secret-shaped
# content straight to disk without ever going through Write/Edit, bypassing
# the detection below entirely (e.g. a heredoc'd .env write). Ask — never
# deny, this hook can't tell a real secret from a placeholder — when the
# command both writes to a file and contains secret-shaped content. This
# covers the pre-commit, redirect-syntax path only; Trivy's CI secret-scan
# job remains the backstop for anything this and the Write/Edit scan below
# both miss (see docs/hooks.md's Secret Detection section).
if [ "$TOOL_NAME" = "Bash" ]; then
# Named TOOL_COMMAND, not BASH_COMMAND: the latter is bash's own special
# variable (always reflects "the command currently being executed") —
# assigning to it here would silently get clobbered by the shell on the
# very next statement, not hold the extracted value.
TOOL_COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null || true)
if echo "$TOOL_COMMAND" | grep -qE '(>>|<<|>)'; then
detect_secret "$TOOL_COMMAND"
if [ "$SECRET_DETECTED" = true ]; then
echo "{\"hookSpecificOutput\": {\"hookEventName\": \"PreToolUse\", \"permissionDecision\": \"ask\", \"permissionDecisionReason\": \"Potential $SECRET_TYPE detected in a Bash command that writes to a file (redirect/heredoc). Please verify this is not sensitive data.\"}}"
fi
fi
exit 0
fi

# Exit if not a file operation
if [ -z "$FILE_PATH" ]; then
exit 0
Expand Down Expand Up @@ -46,7 +123,6 @@ if [ -f "$LOCK_FILE" ]; then
fi

# Block edits to critical system files
# Note: .claude/settings.json and .claude/rules/ are user-configurable
PROTECTED_PATTERNS=(
".git/"
".env"
Expand All @@ -60,6 +136,20 @@ for pattern in "${PROTECTED_PATTERNS[@]}"; do
fi
done

# Config-write ask-gate: these paths carry /tailor's propose-only contract
# (.claude/skills/tailor/SKILL.md — "/tailor proposes only, it never
# silently writes to .claude/rules/, .claude/settings.json, CLAUDE.md, or
# any other tracked config file"). A direct Write/Edit bypasses that review
# step, so ask rather than silently proceeding — deliberately "ask", not
# "deny": a legitimate direct edit (including this hardening plan's own
# commits) should still be able to proceed once confirmed.
case "$REL_PATH" in
.claude/settings.json|.claude/rules/*|CLAUDE.md)
echo "{\"hookSpecificOutput\": {\"hookEventName\": \"PreToolUse\", \"permissionDecision\": \"ask\", \"permissionDecisionReason\": \"Editing $REL_PATH changes tracked framework configuration that /tailor normally proposes for review rather than writing directly (see tailor/SKILL.md's Output Contract). Confirm this direct edit is intentional.\"}}"
exit 0
;;
esac

# Skip secret detection for test files
if [[ "$REL_PATH" == *.test.ts ]] || [[ "$REL_PATH" == *.spec.ts ]] || \
[[ "$REL_PATH" == *.test.tsx ]] || [[ "$REL_PATH" == *.spec.tsx ]] || \
Expand All @@ -69,45 +159,7 @@ if [[ "$REL_PATH" == *.test.ts ]] || [[ "$REL_PATH" == *.spec.ts ]] || \
elif [[ "$TOOL_NAME" == "Write" || "$TOOL_NAME" == "Edit" ]]; then
CONTENT=$(echo "$INPUT" | jq -r '.tool_input.content // .tool_input.new_string // empty' 2>/dev/null || true)

# Check for potential secrets - multiple patterns
SECRET_DETECTED=false
SECRET_TYPE=""

# Generic secrets (API keys, passwords, tokens)
if echo "$CONTENT" | grep -qiE '(api[_-]?key|secret|password|token|credential).*[=:][[:space:]]*["\x27]?[a-zA-Z0-9+/]{20,}'; then
SECRET_DETECTED=true
SECRET_TYPE="generic secret"
fi

# AWS access keys (AKIA followed by 16 alphanumeric chars)
if echo "$CONTENT" | grep -qE 'AKIA[0-9A-Z]{16}'; then
SECRET_DETECTED=true
SECRET_TYPE="AWS access key"
fi

# JWT tokens (three base64 segments separated by dots)
if echo "$CONTENT" | grep -qE 'eyJ[a-zA-Z0-9_-]+\.eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+'; then
SECRET_DETECTED=true
SECRET_TYPE="JWT token"
fi

# Environment variable exports with secrets
if echo "$CONTENT" | grep -qiE 'export\s+(API_KEY|SECRET|PASSWORD|TOKEN|CREDENTIAL|AWS_|PRIVATE_KEY)=["\x27]?[a-zA-Z0-9+/]{20,}'; then
SECRET_DETECTED=true
SECRET_TYPE="exported secret"
fi

# GitHub personal access tokens
if echo "$CONTENT" | grep -qE 'ghp_[a-zA-Z0-9]{36}'; then
SECRET_DETECTED=true
SECRET_TYPE="GitHub personal access token"
fi

# Private keys (PEM format)
if echo "$CONTENT" | grep -qE '-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----'; then
SECRET_DETECTED=true
SECRET_TYPE="private key"
fi
detect_secret "$CONTENT"

if [ "$SECRET_DETECTED" = true ]; then
echo "{\"hookSpecificOutput\": {\"hookEventName\": \"PreToolUse\", \"permissionDecision\": \"ask\", \"permissionDecisionReason\": \"Potential $SECRET_TYPE detected in content. Please verify this is not sensitive data.\"}}"
Expand Down
35 changes: 32 additions & 3 deletions .claude/hooks/session-start-loader.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,25 @@

INPUT=$(cat)

# jq is required to parse tool input; fail open if unavailable (hooks are
# guardrails, not a security boundary)
command -v jq >/dev/null 2>&1 || exit 0
# jq is required to parse tool input past this point. Rather than failing
# silently when it's missing (hooks are guardrails, not a security boundary
# — but a silent gap is worse than a visible one), surface exactly what
# degrades so the current session knows what it can't rely on.
if ! command -v jq >/dev/null 2>&1; then
cat << 'EOF'

[HOOK DEGRADATION]
jq is not installed — the following guardrails are degraded for this session:
- Secret detection & file-lock coordination (pre-tool-use-validator.sh): skipped entirely
- Dangerous-command warnings (dangerous-command-guard.sh): skipped entirely
- Pre-commit verification reminders (pre-commit-verification.sh): skipped entirely
Unaffected: pre-push-main-blocker.sh's branch-block does not depend on jq and
keeps working either way; permissions.deny (.claude/settings.json) is
enforced at the permission layer regardless of jq or any hook.
Install jq to restore full hook coverage.
EOF
exit 0
fi

SOURCE=$(echo "$INPUT" | jq -r '.source // "startup"' 2>/dev/null || echo "startup")
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty' 2>/dev/null || true)
Expand All @@ -31,6 +47,19 @@ echo "{\"session_id\": \"$SESSION_ID\", \"started\": \"$(date -Iseconds)\", \"so
# Build context message
CONTEXT=""

# Post-compaction / resume re-orientation: on "compact", prior context was
# just summarized away; on "resume", this is picking up a session from
# scratch. Either way, don't trust what's already "known" — re-check state
# before continuing (see debugging-protocol.md's Stale Context Check).
if [ "$SOURCE" = "compact" ] || [ "$SOURCE" = "resume" ]; then
CONTEXT="$CONTEXT

[POST-COMPACTION RE-ORIENTATION]
- Check the native task list for in-flight work before starting anything new
- If a plan artifact is active (artifacts/plan_*.md), re-read it before continuing
- Re-read any file before editing it — do not trust memory of its contents (Stale Context Check, .claude/rules/debugging-protocol.md)"
fi

# Check for active swarm agents
ACTIVE_AGENTS=$(find "$STATE_DIR" -maxdepth 1 -name 'session_*.json' -type f 2>/dev/null | wc -l | tr -d ' ')
if [ "$ACTIVE_AGENTS" -gt 1 ]; then
Expand Down
36 changes: 36 additions & 0 deletions .claude/hooks/stop-validator.sh
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,42 @@ if [ -d "$PROJECT_DIR/.git" ]; then
- Or track remaining work in the task tracker / GitHub Issues
---"
fi

# Check for unpushed commits — remote-aware. A repo with zero remotes
# configured skips this section entirely: counting "commits not on any
# remote" without that guard warns on every commit in every local-only
# repo, which is noise, not a reminder.
REMOTES=$(git -C "$PROJECT_DIR" remote 2>/dev/null)
if [ -n "$REMOTES" ]; then
UPSTREAM_REF=$(git -C "$PROJECT_DIR" rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null || true)
if [ -n "$UPSTREAM_REF" ]; then
# Has a configured upstream: count commits it doesn't have yet.
AHEAD=$(git -C "$PROJECT_DIR" rev-list --count '@{upstream}..HEAD' 2>/dev/null || echo 0)
[[ "$AHEAD" =~ ^[0-9]+$ ]] || AHEAD=0
if [ "$AHEAD" -gt 0 ]; then
echo "
---
[UNPUSHED WORK REMINDER]
- $AHEAD commit(s) ahead of $UPSTREAM_REF
- Push before ending: git push
---"
fi
else
# No upstream tracking branch configured at all: count commits
# unreachable from any remote-tracking ref.
CURRENT_BRANCH=$(git -C "$PROJECT_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "your-branch")
UNPUSHED=$(git -C "$PROJECT_DIR" rev-list --count HEAD --not --remotes 2>/dev/null || echo 0)
[[ "$UNPUSHED" =~ ^[0-9]+$ ]] || UNPUSHED=0
if [ "$UNPUSHED" -gt 0 ]; then
echo "
---
[UNPUSHED WORK REMINDER]
- $UNPUSHED commit(s) with no upstream tracking branch configured
- Push and set upstream: git push -u origin $CURRENT_BRANCH
---"
fi
fi
fi
fi

exit 0
3 changes: 3 additions & 0 deletions .claude/rules/debugging-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ Long sessions degrade the reliability of what you remember about a file's conten
- If more than roughly 20 tool calls have passed since you last read a file, re-read it before editing it. Do not trust your memory of its current state.
- After any context compaction event, re-read any file you are about to modify — compaction can silently drop the details you were relying on.
- When in doubt about whether context is stale, the cost of re-reading is always lower than the cost of editing blind.
- This applies at the plan level too: after compaction or on resume, re-check task-list state and re-read the active plan artifact before continuing multi-step work, not just the next file you touch.
- Externalize any plan spanning more than a few steps to a file before starting long work, so there is something durable to re-read after compaction instead of relying on conversation history.
- Delegate bulk exploration to workers rather than accumulating it in the orchestrator's own context — that context is exactly what compaction has to compress away first.

## Red Flags

Expand Down
Loading
Loading