diff --git a/AGENTS.md b/AGENTS.md index af8e395..373c328 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,10 +62,12 @@ plugin-name/ **Forbidden fields**: `bugs` (unrecognized by Claude Code runtime) **Common mistakes**: + - `author` as string instead of object - `skills`/`commands`/`agents` as arrays of objects with name/description instead of string paths **Example**: + ```json { "name": "plugin-name", @@ -114,6 +116,40 @@ plugin-name/ - Provide actionable error messages - Follow `noun-verb` naming pattern +### Hook Implementation Language Selection + +Choose the implementation language based on complexity: + +**Use Shell (bash/zsh) for**: + +- Simple git command checks (10-30 lines) +- File existence/path validation +- Basic string matching and JSON field extraction +- Exit code based logic +- Repository-aware file checks + +**Use Python for**: + +- Complex JSON manipulation beyond simple field extraction +- Multi-step conditional logic (>50 lines) +- Cross-platform compatibility requirements +- Integration with Python-specific tools + +**Dependencies**: + +- Shell scripts may use: `jq`, `git`, standard Unix tools +- Prefer fewer dependencies when possible +- Always fail-open when dependencies unavailable + +**Examples**: + +- ✅ Shell: main-branch-guard (~60 lines, extracts JSON field, runs git commands from file's directory) +- ✅ Python: git-permission-guard (command pattern matching and blocking) +- ✅ Python: Complex AST parsing or multi-stage file transformation +- ❌ Python: Simple branch checks (overkill, harder to maintain) + +**Key principle**: Simplicity beats sophistication. Use the simplest tool that solves the problem correctly. + ## CI/CD ### Workflows diff --git a/content-guards/README.md b/content-guards/README.md index cf3392d..6f2e944 100644 --- a/content-guards/README.md +++ b/content-guards/README.md @@ -1,158 +1,13 @@ -# Content Guards +# content-guards -Combined validation and guard plugin that enforces content quality and safety rules in Claude Code. +Content validation and guard hooks via PostToolUse. -## What This Plugin Does +## Features -This plugin merges four validation hooks into a single package: - -1. **Token Validator** - Blocks file writes that exceed token limits -2. **Markdown Validator** - Validates markdown with markdownlint and cspell -3. **WebFetch Guard** - Prevents outdated year references in web queries -4. **Issue Limiter** - Prevents GitHub issue backlog overflow - -## Token Validator - -### How It Works - -Intercepts `Write` and `Edit` tools and counts tokens using the `atc` CLI tool. -If the file would exceed the configured limit, the operation is blocked with detailed resolution guidance. - -### Configuration - -Create a `.token-limits.yaml` file at your repository root: - -```yaml -defaults: - max_tokens: 2000 - -limits: - # Pattern-based overrides (glob matching) - "*.md": 3000 - "docs/**/*.md": 4000 - "CLAUDE.md": 6000 -``` - -The hook searches upward from the current directory (like git does with `.git/`) to find the config file. - -### Resolving Token Limit Violations - -When you hit the hard limit, you'll see this error with resolution steps: - -```text -❌ Token limit violation: - Tokens: (limit: , excess: +) - -HOW TO RESOLVE — follow these steps in order: - -1. REFACTOR INTO MULTIPLE FILES (most common fix) - - Split the file by logical concern (one responsibility per file) - - Use imports/includes to compose the pieces back together - - Example: large Nix module → split into options.nix, config.nix, services.nix - -2. EXTRACT EMBEDDED CODE TO SEPARATE FILES - - NEVER embed shell scripts inside Nix files — use a .sh file and reference it - - NEVER embed Python scripts inside YAML (GitHub Actions, etc.) — use a .py file - - NEVER inline large configs — put them in their own file with correct extension - - Each file should contain ONE language/format only - -3. REEVALUATE THE DIRECTORY STRUCTURE - - If a file is large because it handles many concerns, rethink the structure - - Create subdirectories to group related smaller files - - Example: monolithic default.nix → directory with focused modules - -4. REVIEW FOR DEAD/DUPLICATE CODE - - Remove unused imports, dead code, and duplicated logic - - But NEVER remove comments — comments are always valuable - -IMPORTANT — DO NOT: - ✗ Remove or reduce comments to save tokens (comments are ALWAYS worth keeping) - ✗ Compress code onto fewer lines to fit the limit - ✗ Increase the token limit in .token-limits.yaml to paper over the issue - ✗ Remove documentation strings or docstrings - -The goal is SMALLER, FOCUSED FILES — not less-documented code. -``` - -**Key principle**: Comments are always valuable. Never remove them to reduce file size. Refactor instead. - -### Behavior - -- **Success** (under limit): Silent pass (exit 0) -- **Failure** (exceeds limit): Block with detailed error (exit 2) -- **Unavailable** (`atc` not found): Fail open, allow operation (exit 0) - -Binary files (`.png`, `.jpg`, `.pdf`, `.bin`, `.zip`) are automatically skipped. - -## Markdown Validator - -Validates markdown files using `markdownlint-cli2` and `cspell` on `Write` and `Edit` operations. - -### Markdown Validator - How It Works - -- Runs only on files that appear to be Markdown (typically `*.md`, `*.markdown`) -- Invokes `markdownlint-cli2` with the repository's markdownlint configuration (if present) -- Invokes `cspell` to spell‑check prose and code blocks using the repo's cspell configuration (if present) -- Both tools run against the *post‑edit* content so you see issues from the current change - -The exact rule set and dictionaries are controlled by your local markdownlint/cspell config files; -the hook does not alter those rules, it only enforces them on every write/edit. - -### Markdown Validator - Behavior - -- **Success** (no lint/spell errors): Silent pass (exit 0) -- **Failure** (lint/spell errors found): Operation blocked, tool output shown (non‑zero exit) -- **Unavailable** (tools not found): Fail open, allow operation to proceed (exit 0) - -Binary and non‑markdown files are automatically skipped by this validator. - -## WebFetch Guard - -Blocks `WebFetch` and `WebSearch` operations that contain outdated year references -(e.g., requesting "2024" data when the current year is 2026). - -### WebFetch Guard - How It Works - -- Intercepts `WebFetch` and `WebSearch` tools before the request is sent -- Scans the query text/URL for explicit Gregorian years (e.g., `2023`, `2024`, `2025`) -- Compares any detected years against the current calendar year -- If the query targets a year older than the current year, treats it as a stale‑data request -- A hard‑coded grace period around New Year allows queries for the just‑previous year - (e.g., early in January) to reduce false positives - -The exact grace‑period duration and comparison rules are currently hard‑coded in the hook -implementation and are not user‑configurable; refer to the hook source for precise values. - -### WebFetch Guard - Behavior - -- **Allowed**: Queries without explicit year literals, or that target the current year -- **Blocked**: Queries with only outdated years outside the grace period; guard explains - why blocked and suggests updating or generalizing the query -- **Unavailable** (tooling/environment not available): Fails open, allows request (exit 0) - -## Issue Limiter - -Prevents creating GitHub issues when the backlog exceeds a hard‑coded threshold -(to avoid unbounded issue growth). - -### Issue Limiter - How It Works - -- Intercepts commands that create new GitHub issues (typically `gh issue create`) -- Uses the `gh` CLI to query the current backlog of open issues for the target repository -- Compares the number of open issues against a backlog limit -- The backlog limit is currently a fixed, hard‑coded value inside the hook - (not configurable via settings file yet); consult the hook implementation for the exact threshold - -Behavior is deterministic across runs: once the open‑issue count reaches or exceeds -the built‑in threshold, additional issue‑creation attempts through the guarded path -will be blocked until the backlog is reduced. - -### Issue Limiter - Behavior - -- **Below threshold**: New issues allowed to be created normally (exit 0) -- **At/above threshold**: Issue creation blocked with message indicating backlog limit reached - and suggesting triage/cleanup before adding more issues (non‑zero exit) -- **Unavailable** (`gh` not found, or unable to query issues): Fails open, allows creation (exit 0) +- **markdown-validator**: Validates markdown with markdownlint and cspell +- **token-validator**: Enforces configurable file token limits +- **webfetch-guard**: Blocks outdated year references in web queries +- **issue-limiter**: Prevents GitHub issue backlog overflow ## Installation @@ -162,11 +17,12 @@ claude plugins add jacobpevans-cc-plugins/content-guards ## Dependencies -- `jq` - JSON processing (used by validation hooks) -- `atc` - Token counting tool (for token-validator) -- `markdownlint-cli2` - Markdown linting (for markdown-validator) -- `cspell` - Spell checking (for markdown-validator) -- `gh` - GitHub CLI (for issue-limiter) +- `jq` - JSON processing +- `atc` - Token counting tool +- `markdownlint-cli2` - Markdown linting +- `cspell` - Spell checking +- `gh` - GitHub CLI + +## License -Hooks rely on these external tools; if a dependency is unavailable, the affected hook -may be skipped or may surface an error, and behavior is not guaranteed to fail open. +Apache-2.0 diff --git a/git-guards/README.md b/git-guards/README.md new file mode 100644 index 0000000..d57e366 --- /dev/null +++ b/git-guards/README.md @@ -0,0 +1,18 @@ +# git-guards + +Git security and workflow protection via PreToolUse hooks. + +## Features + +- **git-permission-guard**: Blocks dangerous git/gh commands (force push, hard reset, destructive operations) +- **main-branch-guard**: Prevents file edits on main branch (enforces worktree workflow) + +## Installation + +```bash +claude plugins add jacobpevans-cc-plugins/git-guards +``` + +## License + +Apache-2.0 diff --git a/git-guards/hooks/hooks.json b/git-guards/hooks/hooks.json index 084a8cc..5fd32ed 100644 --- a/git-guards/hooks/hooks.json +++ b/git-guards/hooks/hooks.json @@ -16,7 +16,7 @@ "hooks": [ { "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/scripts/main-branch-guard.py", + "command": "${CLAUDE_PLUGIN_ROOT}/scripts/main-branch-guard.sh", "timeout": 5 } ] diff --git a/git-guards/scripts/main-branch-guard.py b/git-guards/scripts/main-branch-guard.py deleted file mode 100755 index 08f2879..0000000 --- a/git-guards/scripts/main-branch-guard.py +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env python3 -""" -main-branch-guard.py - PreToolUse hook to prevent Edit/Write/NotebookEdit on main branch - -Blocks file editing operations when: -1. The file path contains '/main/' as a directory segment (worktree check) -2. The current branch is 'main' (fallback branch check) - -Follows fail-open philosophy: any errors allow the operation to proceed. -Exit codes: 0=allow or error, 2=deny (unused, we exit 0 with JSON) -""" - -import json -import sys -import subprocess -from pathlib import Path - - -def main(): - try: - # Parse input from stdin - input_data = json.loads(sys.stdin.read()) - tool_name = input_data.get("tool_name", "") - tool_input = input_data.get("tool_input", {}) - - # Early exit: only care about Edit, Write, NotebookEdit - if tool_name not in ["Edit", "Write", "NotebookEdit"]: - sys.exit(0) - - # Extract file_path based on tool type - if tool_name == "NotebookEdit": - file_path = tool_input.get("notebook_path") - else: - file_path = tool_input.get("file_path") - - if not file_path: - # No file path provided, allow (fail-open) - sys.exit(0) - - # Path check: use git to find worktree root - try: - abs_path = Path(file_path).resolve() - - # Get the worktree root directory using git - result = subprocess.run( - ["git", "rev-parse", "--show-toplevel"], - cwd=abs_path.parent if abs_path.is_file() else abs_path, - capture_output=True, - text=True, - timeout=2, - check=False, - ) - - if result.returncode == 0: - worktree_root = Path(result.stdout.strip()) - # Check if the worktree root directory is named 'main' - if worktree_root.name == "main": - deny_operation( - f"File '{file_path}' is in the main worktree. " - "Editing files on the main branch is not allowed." - ) - except Exception: - # Git command failed, continue to branch check - pass - - # Branch check (fallback): check current git branch - try: - result = subprocess.run( - ["git", "branch", "--show-current"], - capture_output=True, - text=True, - timeout=2, - check=False, - ) - - if result.returncode == 0: - current_branch = result.stdout.strip() - if current_branch == "main": - deny_operation( - f"Current branch is 'main'. " - "Editing files on the main branch is not allowed." - ) - except Exception: - # Git command failed, fail-open - pass - - # All checks passed or failed-open, allow operation - sys.exit(0) - - except Exception: - # Any unexpected error: fail-open (allow operation) - sys.exit(0) - - -def deny_operation(message): - """Output deny decision and exit""" - output = { - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "permissionDecision": "deny", - "permissionDecisionReason": f"BLOCKED: {message}\n\nRun `/init-worktree` to create a feature branch.", - } - } - print(json.dumps(output)) - sys.exit(0) - - -if __name__ == "__main__": - main() diff --git a/git-guards/scripts/main-branch-guard.sh b/git-guards/scripts/main-branch-guard.sh new file mode 100755 index 0000000..c3a7e48 --- /dev/null +++ b/git-guards/scripts/main-branch-guard.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# main-branch-guard.sh - PreToolUse hook to prevent Edit/Write/NotebookEdit on main branch +# +# Blocks file editing operations when the file is in a git repository on the main branch. +# Ignores files outside git repositories (like ~/.claude/plans/). +# +# Exit codes: 0=allow, 2=deny + +set -euo pipefail + +# Read JSON input from stdin and extract file path (handles both file_path and notebook_path) +file_path=$(jq -r '.tool_input.file_path // .tool_input.notebook_path // empty') + +# If no file path found, allow operation (fail-open) +if [[ -z "$file_path" ]]; then + exit 0 +fi + +# Get the directory containing the file +file_dir=$(dirname "$file_path") + +# Check if the file is tracked within a git repository. +# This correctly handles untracked files and non-repo directories. +if ! (cd "$file_dir" 2>/dev/null && git ls-files --error-unmatch "$(basename "$file_path")" >/dev/null 2>&1); then + # Not in a git repo OR file is not tracked. Allow operation. + exit 0 +fi + +# Get worktree root from file's directory context +worktree_root=$(cd "$file_dir" && git rev-parse --show-toplevel 2>/dev/null || echo "") + +# Check if worktree directory is named 'main' +if [[ -n "$worktree_root" ]] && [[ "$(basename "$worktree_root")" == "main" ]]; then + jq -n --arg path "$file_path" '{ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: ("BLOCKED: File '\''\($path)'\'' is in the main worktree. Editing files on the main branch is not allowed.\n\nRun `/init-worktree` to create a feature branch.") + } + }' >&2 + exit 2 +fi + +# Fallback: check current branch from file's directory +current_branch=$(cd "$file_dir" && git branch --show-current 2>/dev/null || echo "") + +if [[ "$current_branch" == "main" ]]; then + jq -n --arg path "$file_path" '{ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: ("BLOCKED: Current branch is '\''main'\''. Editing files on the main branch is not allowed.\n\nRun `/init-worktree` to create a feature branch.") + } + }' >&2 + exit 2 +fi + +# All checks passed, allow operation +exit 0