Skip to content

Commit 95fa45e

Browse files
authored
feat: add gatekeeper plugin for auto-approve safe commands (#30)
* feat: add gatekeeper plugin for auto-approve safe commands Two-layer security plugin for Claude Code: - Layer 1 (PreToolUse): pattern matching for instant allow/deny - Layer 2 (PermissionRequest): AI agent review for unknown commands * chore: include gatekeeper dist in git The pre-tool-use.js build output is needed at runtime by the PreToolUse hook, so it must be committed. * chore: add gatekeeper to release-please config * fix: tighten deny patterns to prevent false positives - Anchor all DENY rules with ^ to prevent matching in echo/comments - rm -rf / now only matches exact root (not /var, /tmp, /home/...) - rm -rf ~ now only matches current user home (not ~ubuntu, ~admin) - Add regression tests for subdirectory paths and embedded patterns * fix(gatekeeper): handle invalid JSON input and unhandled rejections - Fix crash on valid JSON non-object input (e.g. null, 42, []) - Report invalid JSON to stderr with exit code 1 instead of silent exit 0 - Add .catch() handler to main() for unhandled promise rejections - Document empty stdin passthrough intent with comment * fix(gatekeeper): apply security improvements from AI code review - Add command chaining/substitution detection before DENY/ALLOW checks - Remove exec/x from package manager allowlist (arbitrary code execution risk) - Improve isGitPushNonForce to detect combined short flags (e.g., -vf) - Add rm -rf /* to DENY_RULES for root wildcard deletion - Fix .gitignore to also unignore dist/** contents - Align AI agent prompt with documented non-force-push policy - Update tests for all security changes * chore(gatekeeper): upgrade PermissionRequest agent model to opus * chore(gatekeeper): revert PermissionRequest agent model to haiku * chore(gatekeeper): enable eslint cache for turbo caching support * fix(gatekeeper): improve Layer 2 security prompt and upgrade to opus - Replace permissive auto-approve prompt with attack-pattern-focused prompt - Add command chaining analysis guidance (pattern #7) - Add project-scope vs system-scope distinction - Upgrade PermissionRequest agent model to opus per Claude Code team recommendation * docs(gatekeeper): update README with current config and add reference link * fix(gatekeeper): run DENY before chaining check and fix git push regex - Move DENY check before command chaining detection so destructive commands like `rm -rf / ; echo` are still blocked - Fix isGitPushNonForce regex to use negative lookahead (?!-) to exclude long flags like --follow-tags and --no-verify from false positive force detection - Add tests for deny-before-chaining priority and git push edge cases
1 parent e9aedb9 commit 95fa45e

16 files changed

Lines changed: 1416 additions & 3 deletions

.claude-plugin/marketplace.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,11 @@
175175
"name": "web-design",
176176
"description": "Review UI code for Web Interface Guidelines compliance",
177177
"source": "./plugins/web-design"
178+
},
179+
{
180+
"name": "gatekeeper",
181+
"description": "Auto-approve safe commands, AI-assisted review for the rest",
182+
"source": "./plugins/gatekeeper"
178183
}
179184
]
180185
}

.release-please-manifest.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@
33
"apps/web": "1.2.0",
44
"packages/eslint-config": "0.0.0",
55
"packages/typescript-config": "0.0.0",
6-
"packages/vitest-config": "0.0.0"
6+
"packages/vitest-config": "0.0.0",
7+
"plugins/gatekeeper": "1.0.0"
78
}

bun.lock

Lines changed: 468 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,13 @@
4646
"url": "https://github.com/pleaseai/claude-code-plugins/issues"
4747
},
4848
"homepage": "https://github.com/pleaseai/claude-code-plugins#readme",
49-
"packageManager": "bun@1.2.22",
49+
"packageManager": "bun@1.3.7",
5050
"engines": {
5151
"node": "22.x"
5252
},
5353
"workspaces": [
5454
"packages/*",
55-
"apps/*"
55+
"apps/*",
56+
"plugins/*"
5657
]
5758
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"name": "gatekeeper",
3+
"version": "1.0.0",
4+
"description": "Two-layer security for Claude Code: auto-approve safe commands, AI-assisted review for the rest",
5+
"author": {
6+
"name": "Minsu Lee",
7+
"url": "https://github.com/amondnet"
8+
},
9+
"repository": "https://github.com/pleaseai/claude-code-plugins",
10+
"license": "MIT",
11+
"keywords": ["security", "permissions", "gatekeeper", "hook"],
12+
"hooks": "./hooks/hooks.json"
13+
}

plugins/gatekeeper/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
!dist/
2+
!dist/**

plugins/gatekeeper/README.md

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# Gatekeeper Plugin
2+
3+
Two-layer security for Claude Code: auto-approve safe commands, AI-assisted review for the rest.
4+
5+
## Overview
6+
7+
Gatekeeper eliminates repetitive permission dialogs for safe development commands while maintaining security against destructive operations.
8+
9+
### How It Works
10+
11+
```
12+
Bash command
13+
14+
15+
Layer 1: PreToolUse (pattern matching, <5ms)
16+
├── ALLOW: safe patterns (npm, git, node, etc.) → skip permission dialog
17+
├── DENY: destructive patterns (rm -rf /, mkfs, etc.) → block immediately
18+
└── PASSTHROUGH: unknown commands → permission dialog
19+
20+
21+
Layer 2: PermissionRequest (AI agent, opus)
22+
├── approve → execute
23+
└── reject → block with reason
24+
```
25+
26+
## Allowed Patterns
27+
28+
| Category | Examples |
29+
|----------|----------|
30+
| Package managers | `npm test`, `bun install`, `yarn add`, `pnpm run` |
31+
| Git read | `git status`, `git log`, `git diff`, `git branch` |
32+
| Git write | `git add`, `git commit`, `git merge`, `git pull` |
33+
| Git push | `git push` (non-force only) |
34+
| Build/runtime | `node`, `npx`, `tsx`, `python`, `cargo build`, `make` |
35+
| File inspection | `ls`, `cat`, `grep`, `find`, `tree`, `wc` |
36+
| Docker read | `docker ps`, `docker logs`, `docker images` |
37+
38+
## Denied Patterns
39+
40+
| Pattern | Reason |
41+
|---------|--------|
42+
| `rm -rf /` | Filesystem root deletion |
43+
| `rm -rf /*` | Destructive wildcard deletion from root |
44+
| `rm -rf ~` | Home directory deletion |
45+
| `mkfs.*` | Disk format |
46+
| `dd if=/dev/zero of=/dev/` | Disk zeroing |
47+
48+
## Installation
49+
50+
```sh
51+
claude
52+
/plugin marketplace add pleaseai/claude-code-plugins
53+
/plugin install gatekeeper@pleaseai
54+
```
55+
56+
## Development
57+
58+
```bash
59+
cd plugins/gatekeeper
60+
bun install
61+
bun run build
62+
```
63+
64+
### Testing
65+
66+
```bash
67+
# ALLOW test
68+
echo '{"tool_name":"Bash","tool_input":{"command":"npm test"}}' | node dist/pre-tool-use.js
69+
70+
# DENY test
71+
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' | node dist/pre-tool-use.js
72+
73+
# PASSTHROUGH test (no output)
74+
echo '{"tool_name":"Bash","tool_input":{"command":"curl https://example.com"}}' | node dist/pre-tool-use.js
75+
```
76+
77+
## References
78+
79+
- [Claude Code Tips: PermissionRequest Hook Pattern](https://www.threads.com/@boris_cherny/post/DUMZy85EoFj)
80+
81+
## License
82+
83+
MIT

plugins/gatekeeper/bun.lock

Lines changed: 50 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
// src/pre-tool-use.ts
2+
import process from "node:process";
3+
var DENY_RULES = [
4+
{ pattern: /^rm\s+-rf\s+\/(?:\s|$)/i, reason: "Filesystem root deletion blocked" },
5+
{ pattern: /^rm\s+-rf\s+\/\*(?:\s|$)/i, reason: "Destructive wildcard deletion from root blocked" },
6+
{ pattern: /^rm\s+-rf\s+~(?:\/|$)/i, reason: "Home directory deletion blocked" },
7+
{ pattern: /^mkfs\./i, reason: "Disk format command blocked" },
8+
{
9+
pattern: /^dd\s+if=\/dev\/zero\s+of=\/dev\//i,
10+
reason: "Disk zeroing blocked"
11+
}
12+
];
13+
var ALLOW_RULES = [
14+
{
15+
pattern: /^(npm|yarn|pnpm|bun)\s+(test|run|install|ci|add|remove|ls|info|outdated|audit|why)\b/i,
16+
reason: "Safe package manager command"
17+
},
18+
{
19+
pattern: /^git\s+(status|log|diff|branch|fetch|remote|tag|show|stash\s+list|rev-parse)\b/i,
20+
reason: "Safe git read operation"
21+
},
22+
{
23+
pattern: /^git\s+(add|commit|checkout|switch|merge|rebase|stash|pull|cherry-pick)\b/i,
24+
reason: "Safe git write operation"
25+
},
26+
{
27+
pattern: /^(node|npx|tsx|python3?|ruby|go\s+run|cargo\s+(build|run|test|check|clippy)|make|gradle|mvn)\b/i,
28+
reason: "Safe build/runtime command"
29+
},
30+
{
31+
pattern: /^(ls|pwd|cat|head|tail|wc|file|which|type|env|echo|printf|grep|find|rg|fd|ag|tree)\b/i,
32+
reason: "Safe file inspection command"
33+
},
34+
{
35+
pattern: /^docker\s+(ps|logs|images|inspect|version)\b/i,
36+
reason: "Safe docker read operation"
37+
}
38+
];
39+
function isGitPushNonForce(cmd) {
40+
return /^git\s+push\b/i.test(cmd) && !/--force(?:-with-lease)?\b|\s-(?!-)\S*f/i.test(cmd);
41+
}
42+
function makeDecision(decision, reason) {
43+
return {
44+
hookSpecificOutput: {
45+
hookEventName: "PreToolUse",
46+
permissionDecision: decision,
47+
permissionDecisionReason: reason
48+
}
49+
};
50+
}
51+
function evaluate(input) {
52+
if (input.tool_name !== "Bash") {
53+
return null;
54+
}
55+
const cmd = input.tool_input?.command ?? "";
56+
if (!cmd) {
57+
return null;
58+
}
59+
for (const rule of DENY_RULES) {
60+
if (rule.pattern.test(cmd)) {
61+
return makeDecision("deny", rule.reason);
62+
}
63+
}
64+
if (/[;&|`\n]|\$\(/.test(cmd)) {
65+
return null;
66+
}
67+
for (const rule of ALLOW_RULES) {
68+
if (rule.pattern.test(cmd)) {
69+
return makeDecision("allow", rule.reason);
70+
}
71+
}
72+
if (isGitPushNonForce(cmd)) {
73+
return makeDecision("allow", "Safe git push (non-force)");
74+
}
75+
return null;
76+
}
77+
function readStdin() {
78+
return new Promise((resolve, reject) => {
79+
let data = "";
80+
process.stdin.setEncoding("utf8");
81+
process.stdin.on("data", (chunk) => {
82+
data += chunk;
83+
});
84+
process.stdin.on("end", () => resolve(data));
85+
process.stdin.on("error", reject);
86+
});
87+
}
88+
async function main() {
89+
const raw = await readStdin();
90+
if (!raw.trim()) {
91+
process.exit(0);
92+
}
93+
let parsed;
94+
try {
95+
parsed = JSON.parse(raw);
96+
} catch (err) {
97+
process.stderr.write(`gatekeeper: invalid JSON input: ${err instanceof Error ? err.message : String(err)}
98+
`);
99+
process.exit(1);
100+
}
101+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
102+
process.stderr.write(`gatekeeper: expected JSON object, got ${parsed === null ? "null" : typeof parsed}
103+
`);
104+
process.exit(1);
105+
}
106+
const input = parsed;
107+
const decision = evaluate(input);
108+
if (decision) {
109+
process.stdout.write(JSON.stringify(decision));
110+
}
111+
process.exit(0);
112+
}
113+
main().catch((err) => {
114+
process.stderr.write(`gatekeeper: unexpected error: ${err instanceof Error ? err.message : String(err)}
115+
`);
116+
process.exit(1);
117+
});
118+
export {
119+
makeDecision,
120+
isGitPushNonForce,
121+
evaluate,
122+
DENY_RULES,
123+
ALLOW_RULES
124+
};
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import antfu from '@antfu/eslint-config'
2+
3+
export default antfu({
4+
type: 'lib',
5+
})

0 commit comments

Comments
 (0)