@@ -4,7 +4,7 @@ import type {
44 SyncHookJSONOutput ,
55} from '@anthropic-ai/claude-agent-sdk'
66import { 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
99const 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
293293describe ( '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