Skip to content

Commit 233e31f

Browse files
committed
feat(gatekeeper): add 3-tier decision system and all-tool coverage
- Rename DENY_RULES to HARD_DENY_RULES, add SOFT_DENY_RULES for Bash - Add classifyWriteEdit() with path-based soft_deny for .env, .claude/settings, CI configs - Add classifyWebFetch() with URL-based soft_deny for paste services, script downloads - Add SAFE_TOOLS instant-allow list (Read, Glob, Grep, etc.) - Refactor evaluate() to dispatch by tool_name to per-tool classifiers - soft_deny returns null (passthrough to PermissionRequest AI hook) - Add comprehensive tests: 246 tests, 835 assertions, 0 failures Closes: #135
1 parent d787722 commit 233e31f

2 files changed

Lines changed: 440 additions & 51 deletions

File tree

plugins/gatekeeper/src/pre-tool-use.test.ts

Lines changed: 226 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type {
44
SyncHookJSONOutput,
55
} from '@anthropic-ai/claude-agent-sdk'
66
import { describe, expect, test } from 'bun:test'
7-
import { evaluate, evaluateSingleCommand, isGitPushNonForce, splitChainedCommands } from './pre-tool-use'
7+
import { classifyWebFetch, classifyWriteEdit, evaluate, evaluateSingleCommand, isGitPushNonForce, splitChainedCommands } from './pre-tool-use'
88

99
const STUB_BASE = {
1010
session_id: 'test-session',
@@ -211,38 +211,38 @@ describe('evaluateSingleCommand', () => {
211211
expect(evaluateSingleCommand('\t')).toBeNull()
212212
})
213213

214-
test('should return deny for DENY rule matches', () => {
214+
test('should return hard_deny for HARD_DENY rule matches', () => {
215215
const result = evaluateSingleCommand('rm -rf /')
216216
expect(result).not.toBeNull()
217-
expect(result!.decision).toBe('deny')
217+
expect(result!.decision).toBe('hard_deny')
218218
expect(result!.reason).toBe('Filesystem root deletion blocked')
219219
})
220220

221-
test('should return deny for rm -rf ~ (home directory)', () => {
221+
test('should return hard_deny for rm -rf ~ (home directory)', () => {
222222
const result = evaluateSingleCommand('rm -rf ~')
223223
expect(result).not.toBeNull()
224-
expect(result!.decision).toBe('deny')
224+
expect(result!.decision).toBe('hard_deny')
225225
expect(result!.reason).toBe('Home directory deletion blocked')
226226
})
227227

228-
test('should return deny for node -e (inline code execution)', () => {
228+
test('should return hard_deny for node -e (inline code execution)', () => {
229229
const result = evaluateSingleCommand('node -e "require(\'child_process\').exec(\'evil\')"')
230230
expect(result).not.toBeNull()
231-
expect(result!.decision).toBe('deny')
231+
expect(result!.decision).toBe('hard_deny')
232232
expect(result!.reason).toBe('Inline interpreter code execution blocked')
233233
})
234234

235-
test('should return deny for python3 -c (inline code execution)', () => {
235+
test('should return hard_deny for python3 -c (inline code execution)', () => {
236236
const result = evaluateSingleCommand('python3 -c "import os; os.system(\'rm -rf /\')"')
237237
expect(result).not.toBeNull()
238-
expect(result!.decision).toBe('deny')
238+
expect(result!.decision).toBe('hard_deny')
239239
expect(result!.reason).toBe('Inline interpreter code execution blocked')
240240
})
241241

242-
test('should return deny for find -exec', () => {
242+
test('should return hard_deny for find -exec', () => {
243243
const result = evaluateSingleCommand('find / -name "*.sh" -exec sh {} \\;')
244244
expect(result).not.toBeNull()
245-
expect(result!.decision).toBe('deny')
245+
expect(result!.decision).toBe('hard_deny')
246246
expect(result!.reason).toBe('find -exec/-execdir/-delete blocked: potential arbitrary command execution or recursive deletion')
247247
})
248248

@@ -278,31 +278,43 @@ describe('evaluateSingleCommand', () => {
278278
expect(evaluateSingleCommand(' npm test')).toBeNull()
279279
})
280280

281-
test('deny takes priority over allow in evaluateSingleCommand', () => {
282-
// node -e matches DENY (inline execution) before ALLOW (build/runtime)
281+
test('hard_deny takes priority over allow in evaluateSingleCommand', () => {
282+
// node -e matches HARD_DENY (inline execution) before ALLOW (build/runtime)
283283
const result = evaluateSingleCommand('node -e "code"')
284-
expect(result!.decision).toBe('deny')
285-
// find -exec matches DENY before ALLOW (file inspection)
284+
expect(result!.decision).toBe('hard_deny')
285+
// find -exec matches HARD_DENY before ALLOW (file inspection)
286286
const findResult = evaluateSingleCommand('find . -exec cat {} \\;')
287-
expect(findResult!.decision).toBe('deny')
287+
expect(findResult!.decision).toBe('hard_deny')
288288
})
289289
})
290290

291291
// ─── Passthrough (non-Bash, empty) ───────────────────────────────────────────
292292

293293
describe('passthrough', () => {
294-
test('should passthrough non-Bash tools', () => {
295-
expectPassthrough({
294+
test('should allow safe tools (Read, Glob, Grep)', () => {
295+
expectAllow({
296296
...STUB_BASE,
297297
tool_name: 'Read',
298-
tool_input: { command: 'ls' },
299-
})
298+
tool_input: { file_path: '/tmp/test.ts' },
299+
}, 'Safe tool: Read')
300+
expectAllow({
301+
...STUB_BASE,
302+
tool_name: 'Glob',
303+
tool_input: { pattern: '*.ts' },
304+
}, 'Safe tool: Glob')
305+
expectAllow({
306+
...STUB_BASE,
307+
tool_name: 'Grep',
308+
tool_input: { pattern: 'foo' },
309+
}, 'Safe tool: Grep')
310+
})
311+
312+
test('should passthrough unknown tools', () => {
300313
expectPassthrough({
301314
...STUB_BASE,
302-
tool_name: 'Write',
303-
tool_input: { command: 'echo' },
315+
tool_name: 'SomeUnknownTool',
316+
tool_input: {},
304317
})
305-
expectPassthrough({ ...STUB_BASE, tool_name: 'Edit', tool_input: {} })
306318
})
307319

308320
test('should passthrough when command is empty', () => {
@@ -519,13 +531,17 @@ describe('allow: git write operations', () => {
519531

520532
// ─── ALLOW: Git push ─────────────────────────────────────────────────────────
521533

522-
describe('allow: git push (non-force)', () => {
523-
test('should allow git push', () => {
534+
describe('git push decisions', () => {
535+
test('should allow git push (no target)', () => {
524536
expectAllow(bash('git push'), 'Safe git push (non-force)')
525537
})
526538

527-
test('should allow git push origin main', () => {
528-
expectAllow(bash('git push origin main'), 'Safe git push (non-force)')
539+
test('should soft_deny git push origin main (push to default branch)', () => {
540+
expectPassthrough(bash('git push origin main'))
541+
})
542+
543+
test('should soft_deny git push origin master (push to default branch)', () => {
544+
expectPassthrough(bash('git push origin master'))
529545
})
530546

531547
test('should allow git push -u origin feature', () => {
@@ -535,15 +551,19 @@ describe('allow: git push (non-force)', () => {
535551
)
536552
})
537553

538-
test('should passthrough git push --force', () => {
554+
test('should soft_deny git push --force (force push)', () => {
539555
expectPassthrough(bash('git push --force origin main'))
540556
})
541557

542-
test('should passthrough git push -f', () => {
558+
test('should soft_deny git push --force-with-lease', () => {
559+
expectPassthrough(bash('git push --force-with-lease origin feature'))
560+
})
561+
562+
test('should soft_deny git push -f', () => {
543563
expectPassthrough(bash('git push -f origin main'))
544564
})
545565

546-
test('should passthrough git push with combined short flags containing f', () => {
566+
test('should soft_deny git push with combined short flags containing f', () => {
547567
expectPassthrough(bash('git push -vf origin feature'))
548568
})
549569

@@ -830,3 +850,179 @@ describe('edge cases', () => {
830850
expectDeny(bash('\trm -rf ~'))
831851
})
832852
})
853+
854+
// ─── Soft deny: Bash commands ───────────────────────────────────────────────
855+
856+
describe('soft_deny: bash commands', () => {
857+
const SOFT_DENY_COMMANDS = [
858+
'git push --force origin main',
859+
'git push -f origin main',
860+
'git push origin main',
861+
'git push origin master',
862+
'git reset --hard',
863+
'git reset --hard HEAD~1',
864+
'git clean -fd',
865+
'git clean -f',
866+
'git branch -D feature',
867+
'npm publish',
868+
'npm publish --access public',
869+
'terraform apply',
870+
'pulumi apply',
871+
'kubectl apply -f deploy.yaml',
872+
'kubectl delete pod my-pod',
873+
'chmod 777 /tmp/dir',
874+
'git commit --no-verify -m "skip hooks"',
875+
'nc -l 8080',
876+
'python3 -m http.server 8080',
877+
'crontab -e',
878+
'systemctl enable myservice',
879+
]
880+
881+
for (const cmd of SOFT_DENY_COMMANDS) {
882+
test(`should soft_deny (passthrough): ${cmd}`, () => {
883+
expectPassthrough(bash(cmd))
884+
})
885+
}
886+
887+
test('evaluateSingleCommand returns soft_deny for git push --force', () => {
888+
const result = evaluateSingleCommand('git push --force origin main')
889+
expect(result).not.toBeNull()
890+
expect(result!.decision).toBe('soft_deny')
891+
})
892+
893+
test('evaluateSingleCommand returns soft_deny for npm publish', () => {
894+
const result = evaluateSingleCommand('npm publish')
895+
expect(result).not.toBeNull()
896+
expect(result!.decision).toBe('soft_deny')
897+
})
898+
899+
test('chain with soft_deny part should passthrough', () => {
900+
expectPassthrough(bash('npm test && git push --force origin main'))
901+
expectPassthrough(bash('npm test && npm publish'))
902+
})
903+
})
904+
905+
// ─── Write/Edit classifier ──────────────────────────────────────────────────
906+
907+
describe('Write/Edit classifier', () => {
908+
function writeInput(filePath: string): PreToolUseHookInput {
909+
return { ...STUB_BASE, tool_name: 'Write', tool_input: { file_path: filePath } }
910+
}
911+
912+
function editInput(filePath: string): PreToolUseHookInput {
913+
return { ...STUB_BASE, tool_name: 'Edit', tool_input: { file_path: filePath, old_string: 'a', new_string: 'b' } }
914+
}
915+
916+
test('should soft_deny Write to .env file', () => {
917+
expectPassthrough(writeInput('.env'))
918+
expectPassthrough(writeInput('.env.local'))
919+
expectPassthrough(writeInput('path/to/.env'))
920+
expectPassthrough(writeInput('/home/user/project/.env.production'))
921+
})
922+
923+
test('should soft_deny Write to .claude/settings', () => {
924+
expectPassthrough(writeInput('.claude/settings.json'))
925+
expectPassthrough(writeInput('/home/user/.claude/settings'))
926+
})
927+
928+
test('should soft_deny Write to CLAUDE.md', () => {
929+
expectPassthrough(writeInput('CLAUDE.md'))
930+
expectPassthrough(writeInput('/project/CLAUDE.md'))
931+
})
932+
933+
test('should soft_deny Write to CI/CD configs', () => {
934+
expectPassthrough(writeInput('.github/workflows/ci.yml'))
935+
expectPassthrough(writeInput('.gitlab-ci.yml'))
936+
expectPassthrough(writeInput('Jenkinsfile'))
937+
expectPassthrough(writeInput('.circleci/config.yml'))
938+
})
939+
940+
test('should allow Write to project-relative paths', () => {
941+
expectAllow(writeInput('src/index.ts'), 'Safe project file write')
942+
expectAllow(writeInput('package.json'), 'Safe project file write')
943+
expectAllow(writeInput('tests/test.ts'), 'Safe project file write')
944+
})
945+
946+
test('should passthrough Write to absolute paths outside project', () => {
947+
expectPassthrough(writeInput('/etc/hosts'))
948+
expectPassthrough(writeInput('/usr/local/bin/script'))
949+
})
950+
951+
test('should soft_deny Edit to .env file', () => {
952+
expectPassthrough(editInput('.env'))
953+
})
954+
955+
test('should allow Edit to project-relative paths', () => {
956+
expectAllow(editInput('src/index.ts'), 'Safe project file write')
957+
})
958+
959+
test('classifyWriteEdit returns correct decisions', () => {
960+
expect(classifyWriteEdit('.env')).toEqual({ decision: 'soft_deny', reason: 'Writing to .env file needs user intent verification' })
961+
expect(classifyWriteEdit('src/index.ts')).toEqual({ decision: 'allow', reason: 'Safe project file write' })
962+
expect(classifyWriteEdit('/etc/hosts')).toBeNull()
963+
expect(classifyWriteEdit('')).toBeNull()
964+
})
965+
})
966+
967+
// ─── WebFetch classifier ────────────────────────────────────────────────────
968+
969+
describe('WebFetch classifier', () => {
970+
function webfetchInput(url: string): PreToolUseHookInput {
971+
return { ...STUB_BASE, tool_name: 'WebFetch', tool_input: { url } }
972+
}
973+
974+
test('should soft_deny fetch to paste services', () => {
975+
expectPassthrough(webfetchInput('https://pastebin.com/raw/abc123'))
976+
expectPassthrough(webfetchInput('https://hastebin.com/raw/abc'))
977+
expectPassthrough(webfetchInput('https://dpaste.org/abc'))
978+
})
979+
980+
test('should soft_deny fetch to file sharing services', () => {
981+
expectPassthrough(webfetchInput('https://transfer.sh/abc/file.txt'))
982+
expectPassthrough(webfetchInput('https://file.io/abc'))
983+
expectPassthrough(webfetchInput('https://0x0.st/abc'))
984+
})
985+
986+
test('should soft_deny script downloads', () => {
987+
expectPassthrough(webfetchInput('https://example.com/install.sh'))
988+
expectPassthrough(webfetchInput('https://example.com/setup.bash'))
989+
expectPassthrough(webfetchInput('https://raw.githubusercontent.com/org/repo/main/script.sh'))
990+
})
991+
992+
test('should allow localhost requests', () => {
993+
expectAllow(webfetchInput('http://localhost:3000/api/data'), 'Safe localhost request')
994+
expectAllow(webfetchInput('http://127.0.0.1:8080/health'), 'Safe localhost request')
995+
})
996+
997+
test('should passthrough other URLs to AI', () => {
998+
expectPassthrough(webfetchInput('https://api.example.com/data'))
999+
expectPassthrough(webfetchInput('https://github.com/org/repo'))
1000+
})
1001+
1002+
test('classifyWebFetch returns correct decisions', () => {
1003+
expect(classifyWebFetch('https://pastebin.com/raw/abc')).toEqual({ decision: 'soft_deny', reason: 'Paste service needs user intent verification' })
1004+
expect(classifyWebFetch('http://localhost:3000')).toEqual({ decision: 'allow', reason: 'Safe localhost request' })
1005+
expect(classifyWebFetch('https://example.com')).toBeNull()
1006+
expect(classifyWebFetch('')).toBeNull()
1007+
})
1008+
})
1009+
1010+
// ─── Safe tools allowlist ───────────────────────────────────────────────────
1011+
1012+
describe('safe tools allowlist', () => {
1013+
const SAFE_TOOL_NAMES = ['Read', 'Glob', 'Grep', 'LS', 'Search', 'TaskCreate', 'TaskUpdate', 'TaskList', 'TaskGet', 'TodoRead', 'TodoWrite', 'NotebookRead']
1014+
1015+
for (const toolName of SAFE_TOOL_NAMES) {
1016+
test(`should instant-allow: ${toolName}`, () => {
1017+
expectAllow(
1018+
{ ...STUB_BASE, tool_name: toolName, tool_input: {} },
1019+
`Safe tool: ${toolName}`,
1020+
)
1021+
})
1022+
}
1023+
1024+
test('should passthrough unknown tools (fail-open)', () => {
1025+
expectPassthrough({ ...STUB_BASE, tool_name: 'CustomTool', tool_input: {} })
1026+
expectPassthrough({ ...STUB_BASE, tool_name: 'Agent', tool_input: {} })
1027+
})
1028+
})

0 commit comments

Comments
 (0)