diff --git a/.please/docs/tracks/active/gatekeeper-v2-20260331/metadata.json b/.please/docs/tracks/active/gatekeeper-v2-20260331/metadata.json new file mode 100644 index 00000000..f166562d --- /dev/null +++ b/.please/docs/tracks/active/gatekeeper-v2-20260331/metadata.json @@ -0,0 +1,10 @@ +{ + "track_id": "gatekeeper-v2-20260331", + "type": "feature", + "status": "in_progress", + "created_at": "2026-03-31T21:18:46+09:00", + "updated_at": "2026-03-31T21:35:00+09:00", + "issue": "#135", + "pr": "#136", + "project": "" +} diff --git a/.please/docs/tracks/active/gatekeeper-v2-20260331/plan.md b/.please/docs/tracks/active/gatekeeper-v2-20260331/plan.md new file mode 100644 index 00000000..980a40ad --- /dev/null +++ b/.please/docs/tracks/active/gatekeeper-v2-20260331/plan.md @@ -0,0 +1,147 @@ +# Plan: Gatekeeper v2 — All-Tool Coverage + Auto-Mode Rules + +> Track: gatekeeper-v2-20260331 +> Spec: [spec.md](./spec.md) + +## Overview + +- **Source**: pleaseai/claude-code-plugins#135 +- **Issue**: #135 +- **Created**: 2026-03-31 +- **Approach**: Modular Classifier — extend existing pattern-matching architecture with 3-tier decisions and per-tool classifiers + +## Purpose + +After this change, Claude Code users with Gatekeeper installed will have comprehensive security coverage across all tools (not just Bash). They can verify it works by observing that Write/Edit to `.env` triggers an AI review, while Read/Glob are instantly allowed, and `rm -rf /` is still hard-blocked. + +## Context + +Gatekeeper v1 only covers Bash commands, leaving Write, Edit, WebFetch, Agent and other tools unprotected. All denials are treated equally — there's no distinction between absolutely dangerous commands (rm -rf /) and commands that are dangerous but sometimes intentionally requested (git push --force). Analysis of Claude Code's built-in auto-mode classifier (`yoloClassifier.ts`) reveals a comprehensive 25+ rule set for DENY decisions and 7 ALLOW rules that Gatekeeper should match for full coverage. + +The issue comment provides a detailed gap analysis showing current coverage at ~40% for SOFT_DENY rules and ~57% for ALLOW rules. The goal is 100% coverage of auto-mode defaults through a combination of Layer 1 static rules and Layer 2 AI prompt improvements. + +Key constraints: Layer 1 must remain <5ms (no AI calls), existing Bash DENY/ALLOW behavior must not regress, and the `g` flag must not be used on RegExp patterns. + +## Architecture Decision + +**Chosen approach: Modular Classifier Pattern** + +The existing `pre-tool-use.ts` architecture (DENY_RULES → ALLOW_RULES → passthrough) extends naturally to a 3-tier system. Rather than a full rewrite, we rename `DENY_RULES` to `HARD_DENY_RULES`, add `SOFT_DENY_RULES`, and introduce per-tool classifier functions that dispatch from the main `evaluate()` function based on `tool_name`. The `chain-parser.ts` remains unchanged as it handles only shell command parsing. + +For soft_deny, returning `null` from the PreToolUse hook causes Claude Code to proceed to the PermissionRequest hook, where the AI agent evaluates user intent. This matches the issue's proposed flow exactly. + +The PermissionRequest prompt is rewritten with the full auto-mode rule set from the issue comment, structured as Core Principle (intent judgment) → ALLOW rules → hard DENY rules → soft DENY rules → tool-specific guidance. + +## Tasks + +- [x] T001 Refactor DENY_RULES to HARD_DENY_RULES and add SOFT_DENY_RULES for Bash (file: plugins/gatekeeper/src/pre-tool-use.ts) +- [x] T002 Add Write/Edit classifier with path-based rules (file: plugins/gatekeeper/src/pre-tool-use.ts) (depends on T001) +- [x] T003 Add WebFetch classifier with URL-based rules (file: plugins/gatekeeper/src/pre-tool-use.ts) (depends on T001) +- [x] T004 Add safe tools instant-allow list and refactor evaluate() dispatcher (file: plugins/gatekeeper/src/pre-tool-use.ts) (depends on T001) +- [x] T005 Add tests for 3-tier Bash decisions (hard_deny, soft_deny, allow) (file: plugins/gatekeeper/src/pre-tool-use.test.ts) (depends on T001) +- [x] T006 [P] Add tests for Write/Edit classifier (file: plugins/gatekeeper/src/pre-tool-use.test.ts) (depends on T002) +- [x] T007 [P] Add tests for WebFetch classifier (file: plugins/gatekeeper/src/pre-tool-use.test.ts) (depends on T003) +- [x] T008 [P] Add tests for safe tools allowlist and unknown tool passthrough (file: plugins/gatekeeper/src/pre-tool-use.test.ts) (depends on T004) +- [x] T009 Rewrite PermissionRequest prompt with full auto-mode coverage and intent judgment (file: plugins/gatekeeper/hooks/hooks.json) (depends on T004) +- [x] T010 Update hook matchers from Bash to empty string and evaluate model change (file: plugins/gatekeeper/hooks/hooks.json) (depends on T009) +- [x] T011 Rewrite README.md with 3-tier architecture, all-tool coverage tables, and soft_deny documentation (file: plugins/gatekeeper/README.md) (depends on T010) +- [x] T012 Build dist bundle and verify all tests pass (depends on T005, T006, T007, T008, T010) + +## Key Files + +### Modify + +- `plugins/gatekeeper/src/pre-tool-use.ts` — Main hook logic: add 3-tier system, per-tool classifiers, safe tools allowlist +- `plugins/gatekeeper/src/pre-tool-use.test.ts` — Tests: add soft_deny, Write/Edit, WebFetch, safe tools test suites +- `plugins/gatekeeper/hooks/hooks.json` — Hook config: update matchers, rewrite PermissionRequest prompt, evaluate model +- `plugins/gatekeeper/README.md` — Documentation: full rewrite with 3-tier architecture + +### Reuse (unchanged) + +- `plugins/gatekeeper/src/chain-parser.ts` — Shell parser: no changes needed +- `plugins/gatekeeper/package.json` — Build config: no changes needed +- `plugins/gatekeeper/CLAUDE.md` — Dev instructions: no changes needed + +## Verification + +### Automated Tests + +- [ ] All existing Bash DENY/ALLOW tests pass unchanged (regression check) +- [ ] soft_deny Bash commands (git push --force, npm publish, kubectl apply) return null +- [ ] hard_deny Bash commands (rm -rf /, mkfs) return deny decision +- [ ] Write/Edit to .env returns null (soft_deny → AI review) +- [ ] Write/Edit to project-relative path returns allow decision +- [ ] WebFetch to paste services returns null (soft_deny → AI review) +- [ ] Read, Glob, Grep return allow decision (safe tools) +- [ ] Unknown tools return null (passthrough to AI) + +### Observable Outcomes + +- Running `echo '{"tool_name":"Bash","tool_input":{"command":"git push --force"}}' | node dist/pre-tool-use.js` produces no stdout (passthrough) +- Running `echo '{"tool_name":"Read","tool_input":{}}' | node dist/pre-tool-use.js` produces allow JSON +- Running `echo '{"tool_name":"Write","tool_input":{"file_path":".env"}}' | node dist/pre-tool-use.js` produces no stdout (passthrough to AI) + +### Acceptance Criteria Check + +- [ ] AC-1: hard_deny commands blocked immediately with stderr +- [ ] AC-2: soft_deny commands pass through to PermissionRequest AI +- [ ] AC-3: Write/Edit to .env/.claude/settings triggers soft_deny +- [ ] AC-4: Write/Edit to project paths instantly allowed +- [ ] AC-5: Safe tools instantly allowed +- [ ] AC-6: Unknown tools pass through (fail-open) +- [ ] AC-7: AI prompt judges user intent +- [ ] AC-8: All existing tests pass +- [ ] AC-9: New tests cover all classifications +- [ ] AC-10: README reflects new architecture + +## Progress + +- [x] (2026-03-31 21:40 KST) T001 Refactor DENY_RULES to HARD_DENY_RULES and add SOFT_DENY_RULES for Bash +- [x] (2026-03-31 21:40 KST) T002 Add Write/Edit classifier with path-based rules +- [x] (2026-03-31 21:40 KST) T003 Add WebFetch classifier with URL-based rules +- [x] (2026-03-31 21:40 KST) T004 Add safe tools instant-allow list and refactor evaluate() dispatcher +- [x] (2026-03-31 21:40 KST) T005 Add tests for 3-tier Bash decisions +- [x] (2026-03-31 21:40 KST) T006 Add tests for Write/Edit classifier +- [x] (2026-03-31 21:40 KST) T007 Add tests for WebFetch classifier +- [x] (2026-03-31 21:40 KST) T008 Add tests for safe tools allowlist and unknown tool passthrough + Evidence: `bun test` → 246 pass, 0 fail, 835 assertions (51ms) +- [x] (2026-03-31 21:43 KST) T009 Rewrite PermissionRequest prompt with full auto-mode coverage +- [x] (2026-03-31 21:43 KST) T010 Update hook matchers from Bash to empty string, switch model to haiku +- [x] (2026-03-31 21:43 KST) T011 Rewrite README.md with 3-tier architecture +- [x] (2026-03-31 21:45 KST) T012 Build dist bundle and verify all tests pass + Evidence: `bun build` → pre-tool-use.js 15.47 KB; `bun test` → 246 pass, 0 fail + +## Decision Log + +- Decision: Modular Classifier pattern — extend existing architecture rather than rewrite + Rationale: Minimizes risk, preserves existing test coverage, clear migration path from 2-tier to 3-tier + Date/Author: 2026-03-31 / Claude + +- Decision: soft_deny returns null (passthrough) rather than a new decision type + Rationale: Claude Code hook protocol already supports null = passthrough to next hook. No SDK changes needed. + Date/Author: 2026-03-31 / Claude + +## Surprises & Discoveries + +- Observation: `\b` word boundary fails before `.` (non-word character) in regex patterns + Evidence: `/\b\.claude\/settings/` never matches because `\b` requires word↔non-word boundary, but `.` preceded by space/start is non-word↔non-word. Fixed by using `(?:^|\s)` instead. + +## Outcomes & Retrospective + +### What Was Shipped +- 3-tier decision system (hard_deny / soft_deny / allow) in PreToolUse hook +- All-tool coverage: Bash, Write/Edit, WebFetch, safe tools allowlist +- Rewritten PermissionRequest AI prompt with full auto-mode coverage (7 ALLOW, 25+ DENY rules) +- Switched Layer 2 model from sonnet to haiku +- Comprehensive README rewrite + +### What Went Well +- Existing test suite caught regression immediately when `evaluateSingleCommand` return type changed +- Modular classifier pattern allowed incremental implementation without breaking existing behavior +- Issue #135 with detailed gap analysis made rule coverage straightforward + +### What Could Improve +- Regex word boundary behavior with non-word characters caught by review — could add a regex-specific test helper + +### Tech Debt Created +- None identified diff --git a/.please/docs/tracks/active/gatekeeper-v2-20260331/spec.md b/.please/docs/tracks/active/gatekeeper-v2-20260331/spec.md new file mode 100644 index 00000000..996a4422 --- /dev/null +++ b/.please/docs/tracks/active/gatekeeper-v2-20260331/spec.md @@ -0,0 +1,57 @@ +# Gatekeeper v2: All-Tool Coverage + Auto-Mode Rules + +> Track: gatekeeper-v2-20260331 +> Issue: pleaseai/claude-code-plugins#135 + +## Overview + +Extend Gatekeeper from Bash-only coverage to all Claude Code tools and introduce a 3-tier decision system (hard_deny / soft_deny / allow) based on Claude Code's built-in auto-mode classifier. Improve the PermissionRequest AI prompt with intent judgment and comprehensive security rules derived from auto-mode defaults analysis. + +## Requirements + +### Functional Requirements + +- [ ] FR-1: Implement 3-tier decision system — `hard_deny` (exit code 2, immediate block), `soft_deny` (passthrough to PermissionRequest AI), `allow` (instant approve) +- [ ] FR-2: Define soft_deny rules for Bash — git force push, push to main, hard reset, git clean, npm publish, terraform/kubectl apply, self-modification (.claude/settings, CLAUDE.md), --no-verify, chmod 777, exposed services, unauthorized persistence, permission grants, logging tampering +- [ ] FR-3: Extend PreToolUse to handle Write/Edit tools — project-relative paths allowed, `.env`/`.claude/settings`/CI configs soft_deny +- [ ] FR-4: Extend PreToolUse to handle WebFetch tool — paste services and script downloads soft_deny +- [ ] FR-5: Add safe tools instant-allow list — Read, Glob, Grep, TaskCreate, and other read-only tools +- [ ] FR-6: Add missing Bash hard_deny rules from auto-mode analysis +- [ ] FR-7: Add missing Bash ALLOW rules — declared dependency install (npm install without args), toolchain bootstrap patterns +- [ ] FR-8: Update PermissionRequest prompt with full auto-mode coverage: intent judgment, soft_deny context, 25+ DENY rules, 7 ALLOW rules, tool-specific guidance +- [ ] FR-9: Change both hook matchers from `"Bash"` to `""` (all tools) +- [ ] FR-10: Evaluate and switch PermissionRequest model from sonnet to haiku +- [ ] FR-11: Rewrite README.md to document 3-tier system, all-tool coverage, soft_deny rules, and updated architecture diagram + +### Non-functional Requirements + +- [ ] NFR-1: Layer 1 (PreToolUse) must remain <5ms — static pattern matching only, no AI calls +- [ ] NFR-2: No RegExp `g` flag on any pattern (existing invariant) +- [ ] NFR-3: Existing DENY/ALLOW behavior for Bash commands must not regress + +## Acceptance Criteria + +- [ ] AC-1: `hard_deny` commands (rm -rf /, mkfs, dd) are blocked immediately with stderr message +- [ ] AC-2: `soft_deny` commands (git push --force, npm publish, kubectl apply) pass through to PermissionRequest AI hook +- [ ] AC-3: Write/Edit to `.env` or `.claude/settings` triggers soft_deny (passthrough to AI) +- [ ] AC-4: Write/Edit to project-relative paths is instantly allowed +- [ ] AC-5: Safe tools (Read, Glob, Grep) are instantly allowed without AI review +- [ ] AC-6: Unknown tools pass through to PermissionRequest (fail-open) +- [ ] AC-7: PermissionRequest AI prompt judges user intent ("did the user explicitly request this?") +- [ ] AC-8: All existing tests pass without modification +- [ ] AC-9: New tests cover hard_deny, soft_deny, and allow classifications for all tool types +- [ ] AC-10: README.md accurately reflects the new 3-tier architecture + +## Out of Scope + +- Sandbox integration (complementary but separate concern) +- PostToolUse hooks (this track focuses on PreToolUse + PermissionRequest) +- Custom user rule configuration (future enhancement) +- Per-project rule overrides + +## Assumptions + +- The issue comment's gap analysis (7 ALLOW + 25 SOFT_DENY auto-mode rules) is the authoritative reference for rule coverage +- `soft_deny` returns null from PreToolUse, which causes Claude Code to proceed to PermissionRequest hook +- The `@anthropic-ai/claude-agent-sdk` types support the current hook protocol +- haiku model is sufficient for Layer 2 classification (to be validated in FR-10) diff --git a/.please/docs/tracks/index.md b/.please/docs/tracks/index.md index f240fa0f..4a251aa0 100644 --- a/.please/docs/tracks/index.md +++ b/.please/docs/tracks/index.md @@ -13,6 +13,7 @@ | [web-nuxt-update-20260328](active/web-nuxt-update-20260328/plan.md) | Web App Dependency Update | chore | #126 | 2026-03-28 | in_progress | | [fix-setup-glob-pattern-20260329](active/fix-setup-glob-pattern-20260329/plan.md) | Fix setup command package.json discovery | bugfix | #129 | 2026-03-29 | in_progress | | [setup-monorepo-scan-20260329](active/setup-monorepo-scan-20260329/plan.md) | Monorepo Workspace Dependency Scanning | feature | #132 | 2026-03-29 | in_progress | +| [gatekeeper-v2-20260331](active/gatekeeper-v2-20260331/plan.md) | Gatekeeper v2: All-Tool Coverage + Auto-Mode Rules | feature | #135 | 2026-03-31 | in_progress | ## Recently Completed diff --git a/plugins/gatekeeper/README.md b/plugins/gatekeeper/README.md index b329010d..f2816ed0 100644 --- a/plugins/gatekeeper/README.md +++ b/plugins/gatekeeper/README.md @@ -1,49 +1,128 @@ # Gatekeeper Plugin -Two-layer security for Claude Code: auto-approve safe commands, AI-assisted review for the rest. +Three-tier security for Claude Code: auto-approve safe commands, block destructive ones, and AI-review everything in between — across all tools. ## Overview -Gatekeeper eliminates repetitive permission dialogs for safe development commands while maintaining security against destructive operations. +Gatekeeper eliminates repetitive permission dialogs for safe development commands while maintaining security against destructive operations. It covers **all Claude Code tools** (Bash, Write, Edit, WebFetch, and more), not just shell commands. ### How It Works ```text -Bash command +Any tool call (Bash, Write, Edit, WebFetch, Read, etc.) │ ▼ Layer 1: PreToolUse (pattern matching, <5ms) - ├── ALLOW: safe patterns (npm, git, node, etc.) → skip permission dialog - ├── DENY: destructive patterns (rm -rf /, mkfs, etc.) → block immediately - └── PASSTHROUGH: unknown commands → permission dialog - │ - ▼ - Layer 2: PermissionRequest (AI agent, opus) - ├── approve → execute - └── reject → block with reason + ├── HARD DENY: destructive patterns → block immediately + ├── SOFT DENY: risky but sometimes intended → passthrough to AI + ├── ALLOW: safe patterns → skip permission dialog + └── UNKNOWN: no matching rule → passthrough to AI + │ + ▼ + Layer 2: PermissionRequest (AI agent, haiku) + ├── "Did user explicitly request this?" + │ ├── Yes → allow + │ └── No → deny with reason + └── Unsure → user sees permission prompt ``` -## Allowed Patterns +### Decision Types + +| Decision | Hook Output | Effect | +|----------|------------|--------| +| **Hard Deny** | `{ permissionDecision: "deny" }` | Tool blocked immediately, stderr message | +| **Soft Deny** | `null` (passthrough) | Proceeds to AI review in PermissionRequest | +| **Allow** | `{ permissionDecision: "allow" }` | Tool executes, permission dialog skipped | +| **Unknown** | `null` (passthrough) | Proceeds to AI review (fail-open) | + +## Tool Coverage + +### Bash Commands + +#### Hard Deny (absolute blocks) + +| Pattern | Reason | +|---------|--------| +| `rm -rf /` | Filesystem root deletion | +| `rm -rf /*` | Destructive wildcard deletion from root | +| `rm -rf ~` | Home directory deletion | +| `mkfs.*` | Disk format | +| `dd if=/dev/zero of=/dev/` | Disk zeroing | +| `node -e`, `python -c`, etc. | Inline interpreter code execution | +| `find -exec/-execdir/-delete` | Arbitrary command execution | + +#### Soft Deny (AI reviews intent) + +| Pattern | Reason | +|---------|--------| +| `git push --force` | Force push needs user intent verification | +| `git push origin main` | Push to default branch needs user intent verification | +| `git reset --hard` | Hard reset needs user intent verification | +| `git clean -f` | Git clean needs user intent verification | +| `git branch -D` | Force branch delete needs user intent verification | +| `npm publish` | Package publish needs user intent verification | +| `terraform apply/destroy` | Infrastructure change needs user intent verification | +| `kubectl apply/delete` | Kubernetes mutation needs user intent verification | +| `git commit --no-verify` | Skipping commit verification needs user intent verification | +| `chmod 777` | Broad permission change needs user intent verification | +| `nc -l`, `python -m http.server` | Exposing local service needs user intent verification | +| `crontab`, `systemctl enable` | Unauthorized persistence needs user intent verification | +| IAM/RBAC commands | Permission grant needs user intent verification | + +#### Allowed (instant approve) | Category | Examples | |----------|----------| | Package managers | `npm test`, `bun install`, `yarn add`, `pnpm run` | | Git read | `git status`, `git log`, `git diff`, `git branch` | | Git write | `git add`, `git commit`, `git merge`, `git pull` | -| Git push | `git push` (non-force only) | +| Git push | `git push` (non-force, non-main) | | Build/runtime | `node`, `npx`, `tsx`, `python`, `cargo build`, `make` | | File inspection | `ls`, `cat`, `grep`, `find`, `tree`, `wc` | | Docker read | `docker ps`, `docker logs`, `docker images` | -## Denied Patterns +### Write/Edit -| Pattern | Reason | -|---------|--------| -| `rm -rf /` | Filesystem root deletion | -| `rm -rf /*` | Destructive wildcard deletion from root | -| `rm -rf ~` | Home directory deletion | -| `mkfs.*` | Disk format | -| `dd if=/dev/zero of=/dev/` | Disk zeroing | +| Decision | Pattern | Reason | +|----------|---------|--------| +| Soft Deny | `.env`, `.env.*` | Secrets file | +| Soft Deny | `.claude/settings` | Agent self-modification | +| Soft Deny | `CLAUDE.md` | Agent self-modification | +| Soft Deny | `.github/workflows/*` | CI/CD config | +| Soft Deny | `.gitlab-ci.yml`, `Jenkinsfile`, `.circleci/*` | CI/CD config | +| Allow | Project-relative paths | Safe project file | +| Passthrough | Absolute paths outside project | AI review | + +### WebFetch + +| Decision | Pattern | Reason | +|----------|---------|--------| +| Soft Deny | Paste services (pastebin, hastebin, etc.) | Data exfiltration risk | +| Soft Deny | File sharing (transfer.sh, file.io, etc.) | Data exfiltration risk | +| Soft Deny | Script downloads (`.sh`, `.bash`, `.ps1`) | Code execution risk | +| Allow | `localhost`, `127.0.0.1` | Safe dev server | +| Passthrough | Other URLs | AI review | + +### Safe Tools (instant allow) + +Read, Glob, Grep, LS, Search, TaskCreate, TaskUpdate, TaskList, TaskGet, TodoRead, TodoWrite, NotebookRead + +### Unknown Tools + +All unrecognized tools pass through to the AI review layer (fail-open design). + +## AI Review (Layer 2) + +The PermissionRequest hook uses an AI agent (haiku model) with rules derived from [Claude Code's auto-mode classifier](https://www.anthropic.com/engineering/claude-code-auto-mode): + +**Core principle**: "Did the user explicitly request this action?" + +The AI prompt covers 7 ALLOW rules and 25+ DENY rules including: +- Data exfiltration and credential exposure +- Supply chain attacks and untrusted code integration +- Infrastructure mutations and production access +- Agent self-modification and unauthorized persistence +- External system writes and content fabrication ## Installation @@ -64,19 +143,34 @@ bun run build ### Testing ```bash +bun test + +# Manual tests: + +# HARD DENY test +echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' | node dist/pre-tool-use.js + +# SOFT DENY test (no output = passthrough to AI) +echo '{"tool_name":"Bash","tool_input":{"command":"git push --force"}}' | node dist/pre-tool-use.js + # ALLOW test echo '{"tool_name":"Bash","tool_input":{"command":"npm test"}}' | node dist/pre-tool-use.js -# DENY test -echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' | node dist/pre-tool-use.js +# Safe tool ALLOW test +echo '{"tool_name":"Read","tool_input":{"file_path":"test.ts"}}' | node dist/pre-tool-use.js + +# Write soft deny test (no output = passthrough to AI) +echo '{"tool_name":"Write","tool_input":{"file_path":".env"}}' | node dist/pre-tool-use.js -# PASSTHROUGH test (no output) -echo '{"tool_name":"Bash","tool_input":{"command":"curl https://example.com"}}' | node dist/pre-tool-use.js +# PASSTHROUGH test (unknown tool, no output) +echo '{"tool_name":"Agent","tool_input":{}}' | node dist/pre-tool-use.js ``` ## References -- [Claude Code Tips: PermissionRequest Hook Pattern](https://www.threads.com/@boris_cherny/post/DUMZy85EoFj) +- [Anthropic: Claude Code Auto Mode](https://www.anthropic.com/engineering/claude-code-auto-mode) +- [Anthropic: Claude Code Sandboxing](https://www.anthropic.com/engineering/claude-code-sandboxing) +- [Claude Code Hooks Documentation](https://code.claude.com/docs/en/hooks) ## License diff --git a/plugins/gatekeeper/dist/pre-tool-use.js b/plugins/gatekeeper/dist/pre-tool-use.js index 4ab135c6..f992e7da 100644 --- a/plugins/gatekeeper/dist/pre-tool-use.js +++ b/plugins/gatekeeper/dist/pre-tool-use.js @@ -1,4 +1,5 @@ // src/pre-tool-use.ts +import path from "node:path"; import process from "node:process"; // src/chain-parser.ts @@ -125,7 +126,7 @@ function parseChainedCommand(cmd) { } // src/pre-tool-use.ts -var DENY_RULES = [ +var HARD_DENY_RULES = [ { pattern: /^rm\s+-rf\s+\/(?:\s|$)/i, reason: "Filesystem root deletion blocked" }, { pattern: /^rm\s+-rf\s+\/\*(?:\s|$)/i, reason: "Destructive wildcard deletion from root blocked" }, { pattern: /^rm\s+-rf\s+~(?:\/|$)/i, reason: "Home directory deletion blocked" }, @@ -151,6 +152,27 @@ var DENY_RULES = [ reason: "find -exec/-execdir/-delete blocked: potential arbitrary command execution or recursive deletion" } ]; +var SOFT_DENY_RULES = [ + { pattern: /^git\s+push\s+--force(?:-with-lease)?\b/i, reason: "Force push needs user intent verification" }, + { pattern: /^git\s+push(?:\s+\S+)*\s-(?!-)\S*f/i, reason: "Force push (short flag) needs user intent verification" }, + { pattern: /^git\s+push\s+(?:\S+\s+)?(?:origin\s+)?(?:main|master)\s*$/i, reason: "Push to default branch needs user intent verification" }, + { pattern: /^git\s+reset\s+--hard\b/i, reason: "Hard reset needs user intent verification" }, + { pattern: /^git\s+clean\s+-[a-z]*f/i, reason: "Git clean needs user intent verification" }, + { pattern: /^git\s+branch\s+-[a-z]*D/i, reason: "Force branch delete needs user intent verification" }, + { pattern: /^npm\s+publish\b/i, reason: "Package publish needs user intent verification" }, + { pattern: /^(terraform|pulumi)\s+apply\b/i, reason: "Infrastructure apply needs user intent verification" }, + { pattern: /^(terraform|pulumi)\s+destroy\b/i, reason: "Infrastructure destroy needs user intent verification" }, + { pattern: /^kubectl\s+(apply|delete)\b/i, reason: "Kubernetes mutation needs user intent verification" }, + { pattern: /(?:^|\s)\.claude\/settings/i, reason: "Agent self-modification needs user intent verification" }, + { pattern: /\bCLAUDE\.md\b/i, reason: "Agent self-modification needs user intent verification" }, + { pattern: /^git\s+commit(?:\s+\S+)*\s--no-verify\b/i, reason: "Skipping commit verification needs user intent verification" }, + { pattern: /\bchmod\s+\S*777\b/i, reason: "Broad permission change needs user intent verification" }, + { pattern: /\b(nc|ncat|socat)\s+-l/i, reason: "Exposing local service needs user intent verification" }, + { pattern: /\bpython3?\s+-m\s+http\.server/i, reason: "Exposing HTTP server needs user intent verification" }, + { pattern: /\b(crontab|systemctl\s+enable|ssh-keygen|ssh-copy-id)\b/i, reason: "Unauthorized persistence needs user intent verification" }, + { pattern: /\b(?:gcloud(?:\s+\S+)*\s+add-iam|aws\s+iam|az\s+role\s+assignment)\b/i, reason: "Permission grant needs user intent verification" }, + { pattern: /\bsystemctl\s+stop\s+\S*log/i, reason: "Logging tampering needs user intent verification" } +]; var ALLOW_RULES = [ { pattern: /^(npm|yarn|pnpm|bun)\s+(test|run|install|ci|add|remove|ls|info|outdated|audit|why)\b/i, @@ -177,6 +199,64 @@ var ALLOW_RULES = [ reason: "Safe docker read operation" } ]; +var WRITE_EDIT_SOFT_DENY_PATTERNS = [ + { pattern: /(?:^|[/\\])\.env(?:\.|$)/i, reason: "Writing to .env file needs user intent verification" }, + { pattern: /(?:^|[/\\])\.claude[/\\]settings/i, reason: "Writing to .claude/settings needs user intent verification" }, + { pattern: /(?:^|[/\\])CLAUDE\.md$/i, reason: "Writing to CLAUDE.md needs user intent verification" }, + { pattern: /(?:^|[/\\])\.github[/\\]workflows[/\\]/i, reason: "Writing to CI/CD config needs user intent verification" }, + { pattern: /(?:^|[/\\])\.gitlab-ci\.yml$/i, reason: "Writing to CI/CD config needs user intent verification" }, + { pattern: /(?:^|[/\\])Jenkinsfile$/i, reason: "Writing to CI/CD config needs user intent verification" }, + { pattern: /(?:^|[/\\])\.circleci[/\\]/i, reason: "Writing to CI/CD config needs user intent verification" } +]; +function classifyWriteEdit(filePath) { + if (!filePath) { + return null; + } + for (const rule of WRITE_EDIT_SOFT_DENY_PATTERNS) { + if (rule.pattern.test(filePath)) { + return { decision: "soft_deny", reason: rule.reason }; + } + } + const resolvedPath = path.resolve(filePath); + if (resolvedPath === process.cwd() || resolvedPath.startsWith(`${process.cwd()}${path.sep}`)) { + return { decision: "allow", reason: "Safe project file write" }; + } + return null; +} +var WEBFETCH_SOFT_DENY_PATTERNS = [ + { pattern: /^https?:\/\/(?:[^/]+\.)?(pastebin\.com|paste\.ee|hastebin\.com|dpaste\.org|ghostbin\.com|rentry\.co)(?:\/|$)/i, reason: "Paste service needs user intent verification" }, + { pattern: /^https?:\/\/(?:[^/]+\.)?(transfer\.sh|file\.io|0x0\.st|tmpfiles\.org)(?:\/|$)/i, reason: "File sharing service needs user intent verification" }, + { pattern: /\.(sh|bash|ps1|bat|cmd)(\?|$)/i, reason: "Script download needs user intent verification" }, + { pattern: /\braw\.githubusercontent\.com\/.*\.(sh|py|rb|js)(?:\?|$)/i, reason: "Raw script download needs user intent verification" } +]; +function classifyWebFetch(url) { + if (!url) { + return null; + } + for (const rule of WEBFETCH_SOFT_DENY_PATTERNS) { + if (rule.pattern.test(url)) { + return { decision: "soft_deny", reason: rule.reason }; + } + } + if (/^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0)(?::\d+)?(?:[/?#]|$)/i.test(url)) { + return { decision: "allow", reason: "Safe localhost request" }; + } + return null; +} +var SAFE_TOOLS = new Set([ + "Read", + "Glob", + "Grep", + "LS", + "Search", + "TaskCreate", + "TaskUpdate", + "TaskList", + "TaskGet", + "TodoRead", + "TodoWrite", + "NotebookRead" +]); function isGitPushNonForce(cmd) { return /^git\s+push\b/i.test(cmd) && !/--force(?:-with-lease)?\b|\s-(?!-)\S*f/i.test(cmd); } @@ -197,9 +277,14 @@ function evaluateSingleCommand(cmd) { if (!cmd.trim()) { return null; } - for (const rule of DENY_RULES) { + for (const rule of HARD_DENY_RULES) { if (rule.pattern.test(cmd)) { - return { decision: "deny", reason: rule.reason }; + return { decision: "hard_deny", reason: rule.reason }; + } + } + for (const rule of SOFT_DENY_RULES) { + if (rule.pattern.test(cmd)) { + return { decision: "soft_deny", reason: rule.reason }; } } for (const rule of ALLOW_RULES) { @@ -212,15 +297,27 @@ function evaluateSingleCommand(cmd) { } return null; } -function evaluate(input) { - if (input.tool_name !== "Bash") { - return null; +function decisionToOutput(decision, reason, label) { + switch (decision) { + case "hard_deny": + process.stderr.write(`gatekeeper: deny "${label}" — ${reason} +`); + return makeDecision("deny", reason); + case "soft_deny": + process.stderr.write(`gatekeeper: soft_deny "${label}" — ${reason} +`); + return null; + case "allow": + process.stderr.write(`gatekeeper: allow "${label}" — ${reason} +`); + return makeDecision("allow", reason); } - const cmd = (input.tool_input?.command ?? "").trim(); +} +function evaluateBash(cmd) { if (!cmd) { return null; } - for (const rule of DENY_RULES) { + for (const rule of HARD_DENY_RULES) { if (rule.pattern.test(cmd)) { process.stderr.write(`gatekeeper: deny "${cmd}" — ${rule.reason} `); @@ -236,9 +333,7 @@ function evaluate(input) { if (parsed.kind === "single") { const result = evaluateSingleCommand(cmd); if (result) { - process.stderr.write(`gatekeeper: ${result.decision} "${cmd}" — ${result.reason} -`); - return makeDecision(result.decision, result.reason); + return decisionToOutput(result.decision, result.reason, cmd); } process.stderr.write(`gatekeeper: passthrough "${cmd}" — no matching rule `); @@ -248,13 +343,13 @@ function evaluate(input) { let firstAllowedResult = null; for (const part of parsed.parts) { const result = evaluateSingleCommand(part); - if (result?.decision === "deny") { + if (result?.decision === "hard_deny") { process.stderr.write(`gatekeeper: deny "${cmd}" — part "${part}": ${result.reason} `); return makeDecision("deny", result.reason); } - if (result === null) { - process.stderr.write(`gatekeeper: passthrough "${cmd}" — unknown part "${part}" + if (result?.decision === "soft_deny" || result === null) { + process.stderr.write(`gatekeeper: passthrough "${cmd}" — ${result ? "soft_deny" : "unknown"} part "${part}" `); return null; } @@ -273,6 +368,42 @@ function evaluate(input) { `); return makeDecision("allow", compositeReason); } +function evaluate(input) { + const toolName = input.tool_name; + const toolInput = input.tool_input; + if (SAFE_TOOLS.has(toolName)) { + process.stderr.write(`gatekeeper: allow "${toolName}" — safe tool +`); + return makeDecision("allow", `Safe tool: ${toolName}`); + } + if (toolName === "Bash") { + const cmd = (toolInput?.command ?? "").trim(); + return evaluateBash(cmd); + } + if (toolName === "Write" || toolName === "Edit") { + const filePath = toolInput?.file_path ?? ""; + const result = classifyWriteEdit(filePath); + if (result) { + return decisionToOutput(result.decision, result.reason, `${toolName}:${filePath}`); + } + process.stderr.write(`gatekeeper: passthrough "${toolName}:${filePath}" — no matching rule +`); + return null; + } + if (toolName === "WebFetch") { + const url = toolInput?.url ?? ""; + const result = classifyWebFetch(url); + if (result) { + return decisionToOutput(result.decision, result.reason, `WebFetch:${url}`); + } + process.stderr.write(`gatekeeper: passthrough "WebFetch:${url}" — no matching rule +`); + return null; + } + process.stderr.write(`gatekeeper: passthrough "${toolName}" — unknown tool +`); + return null; +} function readStdin() { return new Promise((resolve, reject) => { let data = ""; @@ -320,6 +451,9 @@ export { isGitPushNonForce, evaluateSingleCommand, evaluate, - DENY_RULES, + classifyWriteEdit, + classifyWebFetch, + SOFT_DENY_RULES, + HARD_DENY_RULES, ALLOW_RULES }; diff --git a/plugins/gatekeeper/hooks/hooks.json b/plugins/gatekeeper/hooks/hooks.json index 288c7c64..1f77095a 100644 --- a/plugins/gatekeeper/hooks/hooks.json +++ b/plugins/gatekeeper/hooks/hooks.json @@ -1,9 +1,9 @@ { - "description": "Gatekeeper: auto-approve safe commands + AI review", + "description": "Gatekeeper v2: 3-tier security for all Claude Code tools", "hooks": { "PreToolUse": [ { - "matcher": "Bash", + "matcher": "", "hooks": [ { "type": "command", @@ -15,12 +15,12 @@ ], "PermissionRequest": [ { - "matcher": "Bash", + "matcher": "", "hooks": [ { "type": "agent", - "prompt": "You are a security analyst. This command was NOT matched by pattern-based rules (Layer 1) and needs your judgment.\n\nCommand context:\n$ARGUMENTS\n\nCheck for these attack patterns:\n1. Data destruction (rm -rf /, truncate, dd if=/dev/zero, etc.)\n2. System modification (chmod 777, chown, modifying /etc/, etc.)\n3. Network attacks (curl piping to bash, wget suspicious scripts, reverse shells)\n4. Credential exposure (cat .env, echo $API_KEY, etc.)\n5. Supply chain attacks (npm install from suspicious sources, pip install --extra-index-url)\n6. Privilege escalation (sudo without clear purpose, setuid)\n7. Command chaining hiding destructive intent — analyze ALL parts of chained commands (;, &&, ||, |, $(), backticks), not just the first\n\nScope guidance:\n- Project-scoped operations (./build, ./dist, node_modules) are generally safe\n- System-scoped operations (/etc, /usr, ~/) require careful scrutiny\n- Standard dev tools (docker run, curl localhost, ssh) are generally safe unless combined with attack patterns above\n\nYou have access to Read, Grep, Glob tools. Use them if:\n- The command references a script file (verify its contents)\n- The command uses variables that might be dangerous\n- You need to check if a path exists and what it contains\n\nAfter your analysis, respond with ONLY one of:\n{\"ok\": true}\n{\"ok\": false, \"reason\": \"Brief explanation of the specific risk\"}", - "model": "sonnet", + "prompt": "You are a security classifier for Claude Code.\nThis tool call passed Layer 1 static rules and needs your judgment.\n\nTool call:\n$ARGUMENTS\n\n## Core Principle\n\nFIRST: \"Did the user explicitly request this specific action?\"\n- Clearly serving the user's stated task → lean ALLOW\n- Agent acting beyond task scope → lean DENY\n- Explicit user confirmation must be direct, not suggestive or implicit\n\n## ALLOW\n\n- Project-scoped file operations (read/write/edit within project directory)\n- Standard dev commands (test, lint, build, format)\n- Git operations (except force push / push to main)\n- Installing declared dependencies from manifest (npm install, pip install -r)\n- Read-only network requests to known services\n- Test artifacts (hardcoded test keys, placeholder credentials)\n- Git push to working/agent-created branch (not main/master)\n- Official toolchain installers (rustup, pypa, bun.sh, brew.sh) when repo requires it\n\n## DENY — hard block\n\n- Irreversible system destruction (already caught by Layer 1, but double-check)\n- Data exfiltration: sending project data to external endpoints\n- Credential exploration: systematically scanning credential stores\n- Credential leakage: embedding secrets in metadata, labels, URLs\n- Exfil scouting: probing which external endpoints are reachable\n- Trusting guessed services: agent guesses a service and sends internal data\n\n## DENY — unless user explicitly requested (soft deny)\n\n- Git destructive: force push, delete remote branches, hard reset\n- Git push to main/master (bypasses PR review)\n- Deploy/publish: npm publish, terraform apply, kubectl apply/delete\n- Cloud storage mass operations (S3, GCS, Azure Blob bulk delete)\n- Production access: kubectl exec, docker exec, ssh to prod (even reads)\n- Blind apply: --yes, --auto-approve, --force on infrastructure commands\n- Security weakening: disabling TLS, hooks, logs, audit trails, security agents\n- Permission grant: adding IAM roles, granting admin access\n- Self-modification: .claude/settings, CLAUDE.md, permission overrides\n- Unauthorized persistence: cron jobs, systemd services, SSH keys, shell profiles\n- Create unsafe agents: autonomous loops without approval gates\n- Interfere with others: cancelling jobs/processes not created by agent\n- Modify shared resources: changing shared configs, databases, cluster state\n- Create RCE surface: endpoints accepting arbitrary code, eval/injection patterns\n- Expose local services: binding ports that expose local files/services\n- Untrusted code integration: pulling external code for execution (submodules, postinstall)\n- External system writes: closing issues, posting comments, sending messages user didn't ask for\n- Content fabrication: reporting success on failed work, impersonating humans\n- Real-world transactions: purchases, payments, bookings, external communications\n\n## Tool-Specific\n\n- Bash: analyze ALL parts of chains (;, &&, ||, |, $(), backticks)\n- Write/Edit: project files safe, config files (.env, CI) need scrutiny, outside project → DENY\n- WebFetch: read-only research → ALLOW, downloading executables/scripts → DENY\n- Agent: research/analysis subagents → ALLOW, subagents bypassing user intent → DENY\n\nYou have Read, Grep, Glob. Use if command references scripts or ambiguous paths.\n\nAfter your analysis, respond with ONLY one of:\n{\"decision\":{\"behavior\":\"allow\"},\"reason\":\"Brief explanation\"}\n{\"decision\":{\"behavior\":\"deny\",\"message\":\"Brief explanation of the specific risk\"}}", + "model": "haiku", "timeout": 30 } ] diff --git a/plugins/gatekeeper/src/pre-tool-use.test.ts b/plugins/gatekeeper/src/pre-tool-use.test.ts index a939ccbd..51dba14c 100644 --- a/plugins/gatekeeper/src/pre-tool-use.test.ts +++ b/plugins/gatekeeper/src/pre-tool-use.test.ts @@ -4,7 +4,7 @@ import type { SyncHookJSONOutput, } from '@anthropic-ai/claude-agent-sdk' import { describe, expect, test } from 'bun:test' -import { evaluate, evaluateSingleCommand, isGitPushNonForce, splitChainedCommands } from './pre-tool-use' +import { classifyWebFetch, classifyWriteEdit, evaluate, evaluateSingleCommand, isGitPushNonForce, splitChainedCommands } from './pre-tool-use' const STUB_BASE = { session_id: 'test-session', @@ -211,38 +211,38 @@ describe('evaluateSingleCommand', () => { expect(evaluateSingleCommand('\t')).toBeNull() }) - test('should return deny for DENY rule matches', () => { + test('should return hard_deny for HARD_DENY rule matches', () => { const result = evaluateSingleCommand('rm -rf /') expect(result).not.toBeNull() - expect(result!.decision).toBe('deny') + expect(result!.decision).toBe('hard_deny') expect(result!.reason).toBe('Filesystem root deletion blocked') }) - test('should return deny for rm -rf ~ (home directory)', () => { + test('should return hard_deny for rm -rf ~ (home directory)', () => { const result = evaluateSingleCommand('rm -rf ~') expect(result).not.toBeNull() - expect(result!.decision).toBe('deny') + expect(result!.decision).toBe('hard_deny') expect(result!.reason).toBe('Home directory deletion blocked') }) - test('should return deny for node -e (inline code execution)', () => { + test('should return hard_deny for node -e (inline code execution)', () => { const result = evaluateSingleCommand('node -e "require(\'child_process\').exec(\'evil\')"') expect(result).not.toBeNull() - expect(result!.decision).toBe('deny') + expect(result!.decision).toBe('hard_deny') expect(result!.reason).toBe('Inline interpreter code execution blocked') }) - test('should return deny for python3 -c (inline code execution)', () => { + test('should return hard_deny for python3 -c (inline code execution)', () => { const result = evaluateSingleCommand('python3 -c "import os; os.system(\'rm -rf /\')"') expect(result).not.toBeNull() - expect(result!.decision).toBe('deny') + expect(result!.decision).toBe('hard_deny') expect(result!.reason).toBe('Inline interpreter code execution blocked') }) - test('should return deny for find -exec', () => { + test('should return hard_deny for find -exec', () => { const result = evaluateSingleCommand('find / -name "*.sh" -exec sh {} \\;') expect(result).not.toBeNull() - expect(result!.decision).toBe('deny') + expect(result!.decision).toBe('hard_deny') expect(result!.reason).toBe('find -exec/-execdir/-delete blocked: potential arbitrary command execution or recursive deletion') }) @@ -278,31 +278,43 @@ describe('evaluateSingleCommand', () => { expect(evaluateSingleCommand(' npm test')).toBeNull() }) - test('deny takes priority over allow in evaluateSingleCommand', () => { - // node -e matches DENY (inline execution) before ALLOW (build/runtime) + test('hard_deny takes priority over allow in evaluateSingleCommand', () => { + // node -e matches HARD_DENY (inline execution) before ALLOW (build/runtime) const result = evaluateSingleCommand('node -e "code"') - expect(result!.decision).toBe('deny') - // find -exec matches DENY before ALLOW (file inspection) + expect(result!.decision).toBe('hard_deny') + // find -exec matches HARD_DENY before ALLOW (file inspection) const findResult = evaluateSingleCommand('find . -exec cat {} \\;') - expect(findResult!.decision).toBe('deny') + expect(findResult!.decision).toBe('hard_deny') }) }) // ─── Passthrough (non-Bash, empty) ─────────────────────────────────────────── describe('passthrough', () => { - test('should passthrough non-Bash tools', () => { - expectPassthrough({ + test('should allow safe tools (Read, Glob, Grep)', () => { + expectAllow({ ...STUB_BASE, tool_name: 'Read', - tool_input: { command: 'ls' }, - }) + tool_input: { file_path: '/tmp/test.ts' }, + }, 'Safe tool: Read') + expectAllow({ + ...STUB_BASE, + tool_name: 'Glob', + tool_input: { pattern: '*.ts' }, + }, 'Safe tool: Glob') + expectAllow({ + ...STUB_BASE, + tool_name: 'Grep', + tool_input: { pattern: 'foo' }, + }, 'Safe tool: Grep') + }) + + test('should passthrough unknown tools', () => { expectPassthrough({ ...STUB_BASE, - tool_name: 'Write', - tool_input: { command: 'echo' }, + tool_name: 'SomeUnknownTool', + tool_input: {}, }) - expectPassthrough({ ...STUB_BASE, tool_name: 'Edit', tool_input: {} }) }) test('should passthrough when command is empty', () => { @@ -519,13 +531,17 @@ describe('allow: git write operations', () => { // ─── ALLOW: Git push ───────────────────────────────────────────────────────── -describe('allow: git push (non-force)', () => { - test('should allow git push', () => { +describe('git push decisions', () => { + test('should allow git push (no target)', () => { expectAllow(bash('git push'), 'Safe git push (non-force)') }) - test('should allow git push origin main', () => { - expectAllow(bash('git push origin main'), 'Safe git push (non-force)') + test('should soft_deny git push origin main (push to default branch)', () => { + expectPassthrough(bash('git push origin main')) + }) + + test('should soft_deny git push origin master (push to default branch)', () => { + expectPassthrough(bash('git push origin master')) }) test('should allow git push -u origin feature', () => { @@ -535,15 +551,19 @@ describe('allow: git push (non-force)', () => { ) }) - test('should passthrough git push --force', () => { + test('should soft_deny git push --force (force push)', () => { expectPassthrough(bash('git push --force origin main')) }) - test('should passthrough git push -f', () => { + test('should soft_deny git push --force-with-lease', () => { + expectPassthrough(bash('git push --force-with-lease origin feature')) + }) + + test('should soft_deny git push -f', () => { expectPassthrough(bash('git push -f origin main')) }) - test('should passthrough git push with combined short flags containing f', () => { + test('should soft_deny git push with combined short flags containing f', () => { expectPassthrough(bash('git push -vf origin feature')) }) @@ -830,3 +850,179 @@ describe('edge cases', () => { expectDeny(bash('\trm -rf ~')) }) }) + +// ─── Soft deny: Bash commands ─────────────────────────────────────────────── + +describe('soft_deny: bash commands', () => { + const SOFT_DENY_COMMANDS = [ + 'git push --force origin main', + 'git push -f origin main', + 'git push origin main', + 'git push origin master', + 'git reset --hard', + 'git reset --hard HEAD~1', + 'git clean -fd', + 'git clean -f', + 'git branch -D feature', + 'npm publish', + 'npm publish --access public', + 'terraform apply', + 'pulumi apply', + 'kubectl apply -f deploy.yaml', + 'kubectl delete pod my-pod', + 'chmod 777 /tmp/dir', + 'git commit --no-verify -m "skip hooks"', + 'nc -l 8080', + 'python3 -m http.server 8080', + 'crontab -e', + 'systemctl enable myservice', + ] + + for (const cmd of SOFT_DENY_COMMANDS) { + test(`should soft_deny (passthrough): ${cmd}`, () => { + expectPassthrough(bash(cmd)) + }) + } + + test('evaluateSingleCommand returns soft_deny for git push --force', () => { + const result = evaluateSingleCommand('git push --force origin main') + expect(result).not.toBeNull() + expect(result!.decision).toBe('soft_deny') + }) + + test('evaluateSingleCommand returns soft_deny for npm publish', () => { + const result = evaluateSingleCommand('npm publish') + expect(result).not.toBeNull() + expect(result!.decision).toBe('soft_deny') + }) + + test('chain with soft_deny part should passthrough', () => { + expectPassthrough(bash('npm test && git push --force origin main')) + expectPassthrough(bash('npm test && npm publish')) + }) +}) + +// ─── Write/Edit classifier ────────────────────────────────────────────────── + +describe('Write/Edit classifier', () => { + function writeInput(filePath: string): PreToolUseHookInput { + return { ...STUB_BASE, tool_name: 'Write', tool_input: { file_path: filePath } } + } + + function editInput(filePath: string): PreToolUseHookInput { + return { ...STUB_BASE, tool_name: 'Edit', tool_input: { file_path: filePath, old_string: 'a', new_string: 'b' } } + } + + test('should soft_deny Write to .env file', () => { + expectPassthrough(writeInput('.env')) + expectPassthrough(writeInput('.env.local')) + expectPassthrough(writeInput('path/to/.env')) + expectPassthrough(writeInput('/home/user/project/.env.production')) + }) + + test('should soft_deny Write to .claude/settings', () => { + expectPassthrough(writeInput('.claude/settings.json')) + expectPassthrough(writeInput('/home/user/.claude/settings')) + }) + + test('should soft_deny Write to CLAUDE.md', () => { + expectPassthrough(writeInput('CLAUDE.md')) + expectPassthrough(writeInput('/project/CLAUDE.md')) + }) + + test('should soft_deny Write to CI/CD configs', () => { + expectPassthrough(writeInput('.github/workflows/ci.yml')) + expectPassthrough(writeInput('.gitlab-ci.yml')) + expectPassthrough(writeInput('Jenkinsfile')) + expectPassthrough(writeInput('.circleci/config.yml')) + }) + + test('should allow Write to project-relative paths', () => { + expectAllow(writeInput('src/index.ts'), 'Safe project file write') + expectAllow(writeInput('package.json'), 'Safe project file write') + expectAllow(writeInput('tests/test.ts'), 'Safe project file write') + }) + + test('should passthrough Write to absolute paths outside project', () => { + expectPassthrough(writeInput('/etc/hosts')) + expectPassthrough(writeInput('/usr/local/bin/script')) + }) + + test('should soft_deny Edit to .env file', () => { + expectPassthrough(editInput('.env')) + }) + + test('should allow Edit to project-relative paths', () => { + expectAllow(editInput('src/index.ts'), 'Safe project file write') + }) + + test('classifyWriteEdit returns correct decisions', () => { + expect(classifyWriteEdit('.env')).toEqual({ decision: 'soft_deny', reason: 'Writing to .env file needs user intent verification' }) + expect(classifyWriteEdit('src/index.ts')).toEqual({ decision: 'allow', reason: 'Safe project file write' }) + expect(classifyWriteEdit('/etc/hosts')).toBeNull() + expect(classifyWriteEdit('')).toBeNull() + }) +}) + +// ─── WebFetch classifier ──────────────────────────────────────────────────── + +describe('WebFetch classifier', () => { + function webfetchInput(url: string): PreToolUseHookInput { + return { ...STUB_BASE, tool_name: 'WebFetch', tool_input: { url } } + } + + test('should soft_deny fetch to paste services', () => { + expectPassthrough(webfetchInput('https://pastebin.com/raw/abc123')) + expectPassthrough(webfetchInput('https://hastebin.com/raw/abc')) + expectPassthrough(webfetchInput('https://dpaste.org/abc')) + }) + + test('should soft_deny fetch to file sharing services', () => { + expectPassthrough(webfetchInput('https://transfer.sh/abc/file.txt')) + expectPassthrough(webfetchInput('https://file.io/abc')) + expectPassthrough(webfetchInput('https://0x0.st/abc')) + }) + + test('should soft_deny script downloads', () => { + expectPassthrough(webfetchInput('https://example.com/install.sh')) + expectPassthrough(webfetchInput('https://example.com/setup.bash')) + expectPassthrough(webfetchInput('https://raw.githubusercontent.com/org/repo/main/script.sh')) + }) + + test('should allow localhost requests', () => { + expectAllow(webfetchInput('http://localhost:3000/api/data'), 'Safe localhost request') + expectAllow(webfetchInput('http://127.0.0.1:8080/health'), 'Safe localhost request') + }) + + test('should passthrough other URLs to AI', () => { + expectPassthrough(webfetchInput('https://api.example.com/data')) + expectPassthrough(webfetchInput('https://github.com/org/repo')) + }) + + test('classifyWebFetch returns correct decisions', () => { + expect(classifyWebFetch('https://pastebin.com/raw/abc')).toEqual({ decision: 'soft_deny', reason: 'Paste service needs user intent verification' }) + expect(classifyWebFetch('http://localhost:3000')).toEqual({ decision: 'allow', reason: 'Safe localhost request' }) + expect(classifyWebFetch('https://example.com')).toBeNull() + expect(classifyWebFetch('')).toBeNull() + }) +}) + +// ─── Safe tools allowlist ─────────────────────────────────────────────────── + +describe('safe tools allowlist', () => { + const SAFE_TOOL_NAMES = ['Read', 'Glob', 'Grep', 'LS', 'Search', 'TaskCreate', 'TaskUpdate', 'TaskList', 'TaskGet', 'TodoRead', 'TodoWrite', 'NotebookRead'] + + for (const toolName of SAFE_TOOL_NAMES) { + test(`should instant-allow: ${toolName}`, () => { + expectAllow( + { ...STUB_BASE, tool_name: toolName, tool_input: {} }, + `Safe tool: ${toolName}`, + ) + }) + } + + test('should passthrough unknown tools (fail-open)', () => { + expectPassthrough({ ...STUB_BASE, tool_name: 'CustomTool', tool_input: {} }) + expectPassthrough({ ...STUB_BASE, tool_name: 'Agent', tool_input: {} }) + }) +}) diff --git a/plugins/gatekeeper/src/pre-tool-use.ts b/plugins/gatekeeper/src/pre-tool-use.ts index cba933ea..2d2b675c 100644 --- a/plugins/gatekeeper/src/pre-tool-use.ts +++ b/plugins/gatekeeper/src/pre-tool-use.ts @@ -3,9 +3,12 @@ import type { PreToolUseHookSpecificOutput, SyncHookJSONOutput, } from '@anthropic-ai/claude-agent-sdk' +import path from 'node:path' import process from 'node:process' import { parseChainedCommand } from './chain-parser' +export type Decision = 'hard_deny' | 'soft_deny' | 'allow' + export interface Rule { pattern: RegExp reason: string @@ -13,7 +16,10 @@ export interface Rule { // NOTE: All patterns must NOT use the `g` flag. RegExp.test() with `g` mutates // lastIndex and produces incorrect results on subsequent calls within the same process. -export const DENY_RULES: Rule[] = [ + +// ─── Bash: Hard deny rules (absolute blocks, never passthrough) ───────────── + +export const HARD_DENY_RULES: Rule[] = [ { pattern: /^rm\s+-rf\s+\/(?:\s|$)/i, reason: 'Filesystem root deletion blocked' }, { pattern: /^rm\s+-rf\s+\/\*(?:\s|$)/i, reason: 'Destructive wildcard deletion from root blocked' }, { pattern: /^rm\s+-rf\s+~(?:\/|$)/i, reason: 'Home directory deletion blocked' }, @@ -43,6 +49,47 @@ export const DENY_RULES: Rule[] = [ }, ] +// ─── Bash: Soft deny rules (passthrough to AI for intent judgment) ────────── + +export const SOFT_DENY_RULES: Rule[] = [ + // Git destructive operations + { pattern: /^git\s+push\s+--force(?:-with-lease)?\b/i, reason: 'Force push needs user intent verification' }, + { pattern: /^git\s+push(?:\s+\S+)*\s-(?!-)\S*f/i, reason: 'Force push (short flag) needs user intent verification' }, + { pattern: /^git\s+push\s+(?:\S+\s+)?(?:origin\s+)?(?:main|master)\s*$/i, reason: 'Push to default branch needs user intent verification' }, + { pattern: /^git\s+reset\s+--hard\b/i, reason: 'Hard reset needs user intent verification' }, + { pattern: /^git\s+clean\s+-[a-z]*f/i, reason: 'Git clean needs user intent verification' }, + { pattern: /^git\s+branch\s+-[a-z]*D/i, reason: 'Force branch delete needs user intent verification' }, + + // Deploy/publish + { pattern: /^npm\s+publish\b/i, reason: 'Package publish needs user intent verification' }, + { pattern: /^(terraform|pulumi)\s+apply\b/i, reason: 'Infrastructure apply needs user intent verification' }, + { pattern: /^(terraform|pulumi)\s+destroy\b/i, reason: 'Infrastructure destroy needs user intent verification' }, + { pattern: /^kubectl\s+(apply|delete)\b/i, reason: 'Kubernetes mutation needs user intent verification' }, + + // Self-modification — split into two patterns because \b doesn't match before `.` (non-word char) + { pattern: /(?:^|\s)\.claude\/settings/i, reason: 'Agent self-modification needs user intent verification' }, + { pattern: /\bCLAUDE\.md\b/i, reason: 'Agent self-modification needs user intent verification' }, + + // Security weakening — only match --no-verify on commit (not push, which just skips pre-push hook) + { pattern: /^git\s+commit(?:\s+\S+)*\s--no-verify\b/i, reason: 'Skipping commit verification needs user intent verification' }, + { pattern: /\bchmod\s+\S*777\b/i, reason: 'Broad permission change needs user intent verification' }, + + // Expose local services + { pattern: /\b(nc|ncat|socat)\s+-l/i, reason: 'Exposing local service needs user intent verification' }, + { pattern: /\bpython3?\s+-m\s+http\.server/i, reason: 'Exposing HTTP server needs user intent verification' }, + + // Unauthorized persistence + { pattern: /\b(crontab|systemctl\s+enable|ssh-keygen|ssh-copy-id)\b/i, reason: 'Unauthorized persistence needs user intent verification' }, + + // Permission grants (IAM/RBAC) + { pattern: /\b(?:gcloud(?:\s+\S+)*\s+add-iam|aws\s+iam|az\s+role\s+assignment)\b/i, reason: 'Permission grant needs user intent verification' }, + + // Logging/audit tampering + { pattern: /\bsystemctl\s+stop\s+\S*log/i, reason: 'Logging tampering needs user intent verification' }, +] + +// ─── Bash: Allow rules (safe commands, instant approve) ───────────────────── + export const ALLOW_RULES: Rule[] = [ // Package managers { @@ -81,6 +128,85 @@ export const ALLOW_RULES: Rule[] = [ }, ] +// ─── Write/Edit: Path-based classification ────────────────────────────────── + +const WRITE_EDIT_SOFT_DENY_PATTERNS: Rule[] = [ + { pattern: /(?:^|[/\\])\.env(?:\.|$)/i, reason: 'Writing to .env file needs user intent verification' }, + { pattern: /(?:^|[/\\])\.claude[/\\]settings/i, reason: 'Writing to .claude/settings needs user intent verification' }, + { pattern: /(?:^|[/\\])CLAUDE\.md$/i, reason: 'Writing to CLAUDE.md needs user intent verification' }, + { pattern: /(?:^|[/\\])\.github[/\\]workflows[/\\]/i, reason: 'Writing to CI/CD config needs user intent verification' }, + { pattern: /(?:^|[/\\])\.gitlab-ci\.yml$/i, reason: 'Writing to CI/CD config needs user intent verification' }, + { pattern: /(?:^|[/\\])Jenkinsfile$/i, reason: 'Writing to CI/CD config needs user intent verification' }, + { pattern: /(?:^|[/\\])\.circleci[/\\]/i, reason: 'Writing to CI/CD config needs user intent verification' }, +] + +export function classifyWriteEdit(filePath: string): { decision: Decision, reason: string } | null { + if (!filePath) { + return null + } + + for (const rule of WRITE_EDIT_SOFT_DENY_PATTERNS) { + if (rule.pattern.test(filePath)) { + return { decision: 'soft_deny', reason: rule.reason } + } + } + + // Resolve to absolute path first to prevent path traversal; allow only within project root + const resolvedPath = path.resolve(filePath) + if (resolvedPath === process.cwd() || resolvedPath.startsWith(`${process.cwd()}${path.sep}`)) { + return { decision: 'allow', reason: 'Safe project file write' } + } + + // Absolute paths outside project — passthrough to AI + return null +} + +// ─── WebFetch: URL-based classification ───────────────────────────────────── + +const WEBFETCH_SOFT_DENY_PATTERNS: Rule[] = [ + { pattern: /^https?:\/\/(?:[^/]+\.)?(pastebin\.com|paste\.ee|hastebin\.com|dpaste\.org|ghostbin\.com|rentry\.co)(?:\/|$)/i, reason: 'Paste service needs user intent verification' }, + { pattern: /^https?:\/\/(?:[^/]+\.)?(transfer\.sh|file\.io|0x0\.st|tmpfiles\.org)(?:\/|$)/i, reason: 'File sharing service needs user intent verification' }, + { pattern: /\.(sh|bash|ps1|bat|cmd)(\?|$)/i, reason: 'Script download needs user intent verification' }, + { pattern: /\braw\.githubusercontent\.com\/.*\.(sh|py|rb|js)(?:\?|$)/i, reason: 'Raw script download needs user intent verification' }, +] + +export function classifyWebFetch(url: string): { decision: Decision, reason: string } | null { + if (!url) { + return null + } + + for (const rule of WEBFETCH_SOFT_DENY_PATTERNS) { + if (rule.pattern.test(url)) { + return { decision: 'soft_deny', reason: rule.reason } + } + } + + // Localhost and known dev services are safe + if (/^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0)(?::\d+)?(?:[/?#]|$)/i.test(url)) { + return { decision: 'allow', reason: 'Safe localhost request' } + } + + // All other URLs — passthrough to AI + return null +} + +// ─── Safe tools: Instant allow list ───────────────────────────────────────── + +const SAFE_TOOLS = new Set([ + 'Read', + 'Glob', + 'Grep', + 'LS', + 'Search', + 'TaskCreate', + 'TaskUpdate', + 'TaskList', + 'TaskGet', + 'TodoRead', + 'TodoWrite', + 'NotebookRead', +]) + export function isGitPushNonForce(cmd: string): boolean { return /^git\s+push\b/i.test(cmd) && !/--force(?:-with-lease)?\b|\s-(?!-)\S*f/i.test(cmd) } @@ -113,7 +239,7 @@ export function splitChainedCommands(cmd: string): string[] | null { } /** - * Evaluate a single (unchained) command against DENY/ALLOW rules. + * Evaluate a single (unchained) command against HARD_DENY/SOFT_DENY/ALLOW rules. * * Returns null when: * - The trimmed command is empty @@ -124,17 +250,26 @@ export function splitChainedCommands(cmd: string): string[] | null { */ export function evaluateSingleCommand( cmd: string, -): { decision: 'allow' | 'deny', reason: string } | null { +): { decision: Decision, reason: string } | null { if (!cmd.trim()) { return null } - for (const rule of DENY_RULES) { + // 1. Hard deny — absolute blocks + for (const rule of HARD_DENY_RULES) { + if (rule.pattern.test(cmd)) { + return { decision: 'hard_deny', reason: rule.reason } + } + } + + // 2. Soft deny — passthrough to AI for intent judgment + for (const rule of SOFT_DENY_RULES) { if (rule.pattern.test(cmd)) { - return { decision: 'deny', reason: rule.reason } + return { decision: 'soft_deny', reason: rule.reason } } } + // 3. Allow — safe commands for (const rule of ALLOW_RULES) { if (rule.pattern.test(cmd)) { return { decision: 'allow', reason: rule.reason } @@ -149,23 +284,36 @@ export function evaluateSingleCommand( } /** - * Evaluate a tool input and return a decision or null (passthrough to AI). + * Map 3-tier decision to hook output. soft_deny returns null (passthrough to PermissionRequest AI). */ -export function evaluate( - input: PreToolUseHookInput, +function decisionToOutput( + decision: Decision, + reason: string, + label: string, ): SyncHookJSONOutput | null { - if (input.tool_name !== 'Bash') { - return null + switch (decision) { + case 'hard_deny': + process.stderr.write(`gatekeeper: deny "${label}" — ${reason}\n`) + return makeDecision('deny', reason) + case 'soft_deny': + process.stderr.write(`gatekeeper: soft_deny "${label}" — ${reason}\n`) + return null // passthrough to PermissionRequest hook + case 'allow': + process.stderr.write(`gatekeeper: allow "${label}" — ${reason}\n`) + return makeDecision('allow', reason) } +} - // Trim at entry point: prevents leading-whitespace bypass of ^ anchored DENY patterns - const cmd = ((input.tool_input as { command?: string } | undefined)?.command ?? '').trim() +/** + * Evaluate a Bash command through the 3-tier system. + */ +function evaluateBash(cmd: string): SyncHookJSONOutput | null { if (!cmd) { return null } - // 1. DENY check on full command (fast path: catches dangerous commands immediately) - for (const rule of DENY_RULES) { + // 1. Hard deny check on full command (fast path: catches dangerous commands immediately) + for (const rule of HARD_DENY_RULES) { if (rule.pattern.test(cmd)) { process.stderr.write(`gatekeeper: deny "${cmd}" — ${rule.reason}\n`) return makeDecision('deny', rule.reason) @@ -185,8 +333,7 @@ export function evaluate( // Single command: evaluate directly const result = evaluateSingleCommand(cmd) if (result) { - process.stderr.write(`gatekeeper: ${result.decision} "${cmd}" — ${result.reason}\n`) - return makeDecision(result.decision, result.reason) + return decisionToOutput(result.decision, result.reason, cmd) } process.stderr.write(`gatekeeper: passthrough "${cmd}" — no matching rule\n`) return null @@ -194,17 +341,17 @@ export function evaluate( // 3. Chain evaluation (;/&& only): every part must be explicitly safe to auto-approve const reasons: string[] = [] - let firstAllowedResult: { decision: 'allow' | 'deny', reason: string } | null = null + let firstAllowedResult: { decision: Decision, reason: string } | null = null for (const part of parsed.parts) { const result = evaluateSingleCommand(part) - if (result?.decision === 'deny') { + if (result?.decision === 'hard_deny') { process.stderr.write(`gatekeeper: deny "${cmd}" — part "${part}": ${result.reason}\n`) return makeDecision('deny', result.reason) } - if (result === null) { - // Unknown command in chain → conservative: let AI review the full chain - process.stderr.write(`gatekeeper: passthrough "${cmd}" — unknown part "${part}"\n`) + if (result?.decision === 'soft_deny' || result === null) { + // Soft deny or unknown in chain → conservative: let AI review the full chain + process.stderr.write(`gatekeeper: passthrough "${cmd}" — ${result ? 'soft_deny' : 'unknown'} part "${part}"\n`) return null } reasons.push(`[${part}]: ${result.reason}`) @@ -225,6 +372,55 @@ export function evaluate( return makeDecision('allow', compositeReason) } +/** + * Evaluate a tool input and return a decision or null (passthrough to AI). + * Dispatches to per-tool classifiers based on tool_name. + */ +export function evaluate( + input: PreToolUseHookInput, +): SyncHookJSONOutput | null { + const toolName = input.tool_name + const toolInput = input.tool_input as Record | undefined + + // Safe tools — instant allow + if (SAFE_TOOLS.has(toolName)) { + process.stderr.write(`gatekeeper: allow "${toolName}" — safe tool\n`) + return makeDecision('allow', `Safe tool: ${toolName}`) + } + + // Bash commands + if (toolName === 'Bash') { + const cmd = ((toolInput as { command?: string } | undefined)?.command ?? '').trim() + return evaluateBash(cmd) + } + + // Write/Edit — path-based classification + if (toolName === 'Write' || toolName === 'Edit') { + const filePath = (toolInput?.file_path as string) ?? '' + const result = classifyWriteEdit(filePath) + if (result) { + return decisionToOutput(result.decision, result.reason, `${toolName}:${filePath}`) + } + process.stderr.write(`gatekeeper: passthrough "${toolName}:${filePath}" — no matching rule\n`) + return null + } + + // WebFetch — URL-based classification + if (toolName === 'WebFetch') { + const url = (toolInput?.url as string) ?? '' + const result = classifyWebFetch(url) + if (result) { + return decisionToOutput(result.decision, result.reason, `WebFetch:${url}`) + } + process.stderr.write(`gatekeeper: passthrough "WebFetch:${url}" — no matching rule\n`) + return null + } + + // Unknown tools — passthrough to AI (fail-open) + process.stderr.write(`gatekeeper: passthrough "${toolName}" — unknown tool\n`) + return null +} + function readStdin(): Promise { return new Promise((resolve, reject) => { let data = ''