@@ -66,8 +66,8 @@ def load_hook_commands():
6666 return commands
6767
6868
69- def run_hook_test (script_name , tool_input , description , should_block ):
70- """Run a single hook test case."""
69+ def run_hook_test (script_name , tool_input , description , expected ):
70+ """Run a single hook test case. expected is one of 'BLOCK', 'ASK', 'ALLOW'. """
7171 hook_input = json .dumps ({"tool_input" : tool_input })
7272 script_path = os .path .join (SCRIPT_DIR , script_name )
7373
@@ -78,21 +78,28 @@ def run_hook_test(script_name, tool_input, description, should_block):
7878 text = True ,
7979 )
8080
81- was_blocked = result .returncode == 2
82- passed = was_blocked == should_block
83-
84- status = "PASS" if passed else "FAIL"
85- expected = "BLOCK" if should_block else "ALLOW"
86- actual = "BLOCK" if was_blocked else "ALLOW"
87-
81+ actual = "ALLOW"
8882 detail = ""
89- if was_blocked and result .stdout .strip ():
83+ if result .returncode == 2 :
84+ actual = "BLOCK"
85+ if result .stdout .strip ():
86+ try :
87+ resp = json .loads (result .stdout .strip ())
88+ detail = f" -- { resp .get ('reason' , '' )} "
89+ except json .JSONDecodeError :
90+ detail = f" -- { result .stdout .strip ()} "
91+ elif result .returncode == 0 and result .stdout .strip ():
9092 try :
9193 resp = json .loads (result .stdout .strip ())
92- detail = f" -- { resp .get ('reason' , '' )} "
94+ hso = resp .get ("hookSpecificOutput" ) or {}
95+ if hso .get ("permissionDecision" ) == "ask" :
96+ actual = "ASK"
97+ detail = f" -- { hso .get ('permissionDecisionReason' , '' )} "
9398 except json .JSONDecodeError :
94- detail = f" -- { result . stdout . strip () } "
99+ pass
95100
101+ passed = actual == expected
102+ status = "PASS" if passed else "FAIL"
96103 print (f" [{ status } ] { description :45s} expected={ expected } actual={ actual } { detail } " )
97104 return passed
98105
@@ -231,7 +238,106 @@ def tally(result):
231238 "check-dangerous-commands.py" ,
232239 {"command" : cmd },
233240 desc ,
234- should_block ,
241+ "BLOCK" if should_block else "ALLOW" ,
242+ ))
243+
244+ # =========================================================================
245+ print ()
246+ print ("--- check-dangerous-commands.py git-ask patterns ---" )
247+ print ()
248+
249+ GIT_ASK_TESTS = [
250+ # ASK: git commit variants
251+ ("git commit (bare)" , "git commit" , "ASK" ),
252+ ("git commit -m" , "git commit -m 'msg'" , "ASK" ),
253+ ("git commit -am" , "git commit -am 'msg'" , "ASK" ),
254+ ("git commit --amend" , "git commit --amend" , "ASK" ),
255+ ("git commit --allow-empty" , "git commit --allow-empty -m hi" , "ASK" ),
256+
257+ # ASK: git push variants
258+ ("git push (bare)" , "git push" , "ASK" ),
259+ ("git push origin main" , "git push origin main" , "ASK" ),
260+ ("git push --force" , "git push --force origin main" , "ASK" ),
261+ ("git push --force-with-lease" , "git push --force-with-lease" , "ASK" ),
262+ ("git push -f" , "git push -f origin main" , "ASK" ),
263+
264+ # ASK: git reset --hard
265+ ("git reset --hard" , "git reset --hard" , "ASK" ),
266+ ("git reset --hard HEAD~1" , "git reset --hard HEAD~1" , "ASK" ),
267+ ("git reset --hard origin/main" , "git reset --hard origin/main" , "ASK" ),
268+
269+ # ASK: git branch -D (force delete)
270+ ("git branch -D" , "git branch -D feature/foo" , "ASK" ),
271+
272+ # ASK: branch creation/reset variants beyond the basic -b / -c
273+ ("git checkout -B (force create/reset)" , "git checkout -B foo" , "ASK" ),
274+ ("git checkout -B with start point" , "git checkout -B foo origin/foo" , "ASK" ),
275+ ("git switch --create (long form)" , "git switch --create foo" , "ASK" ),
276+ ("git switch --force-create (long force)" , "git switch --force-create foo origin/foo" , "ASK" ),
277+ ("git branch -t (track + create)" , "git branch -t newname origin/main" , "ASK" ),
278+ ("git branch --track (long form)" , "git branch --track newname origin/main" , "ASK" ),
279+ ("git branch -m (rename)" , "git branch -m oldname newname" , "ASK" ),
280+ ("git branch -M (force rename)" , "git branch -M oldname newname" , "ASK" ),
281+ ("git branch -c (copy)" , "git branch -c oldname newname" , "ASK" ),
282+ ("git branch -C (force copy)" , "git branch -C oldname newname" , "ASK" ),
283+ ("git branch --move (long rename)" , "git branch --move oldname newname" , "ASK" ),
284+ ("git branch --copy (long copy)" , "git branch --copy oldname newname" , "ASK" ),
285+ ("git branch -f (force reset existing)" , "git branch -f existing HEAD~1" , "ASK" ),
286+ ("git branch --force (long force)" , "git branch --force existing HEAD~1" , "ASK" ),
287+ ("git branch -f bare" , "git branch -f newname" , "ASK" ),
288+
289+ # ASK: gh pr write actions
290+ ("gh pr create" , "gh pr create --title foo --body bar" , "ASK" ),
291+ ("gh pr edit" , "gh pr edit 123 --body foo" , "ASK" ),
292+ ("gh pr merge" , "gh pr merge 123 --squash" , "ASK" ),
293+ ("gh pr close" , "gh pr close 123" , "ASK" ),
294+
295+ # ASK: compound commands should surface every matched op
296+ ("compound: commit && push" , "git commit -m hi && git push" , "ASK" ),
297+ ("compound: force-push && commit" , "git push --force && git commit -m hi" , "ASK" ),
298+
299+ # ASK: dangerous flag on a later command in a compound. The leading git verb still triggers
300+ # ASK via its own pattern (plain push); the regression is that the trailing -f must NOT be
301+ # attributed to the push and reported as a force-push.
302+ ("compound: push then unrelated -f" , "git push origin main && gradle test -f" , "ASK" ),
303+
304+ # ALLOW: a dangerous-looking flag on an UNRELATED later command must not cross the shell
305+ # separator and false-positive on the leading git verb. Before the [^\n;&|] fix these
306+ # incorrectly matched reset --hard / branch -D.
307+ ("compound: reset HEAD then unrelated --hard" , "git reset HEAD && other --hard" , "ALLOW" ),
308+ ("compound: branch list then unrelated -D" , "git branch && other -D" , "ALLOW" ),
309+
310+ # ALLOW: dotted git-config keys must not match the bare-verb patterns. `\bpush\b` etc.
311+ # treat `.` as a word boundary, so `(?=\s|$)` after each verb is what excludes these.
312+ ("git config push.default" , "git config push.default simple" , "ALLOW" ),
313+ ("git config commit.gpgsign" , "git config commit.gpgsign true" , "ALLOW" ),
314+ ("git config reset.quiet" , "git config reset.quiet true" , "ALLOW" ),
315+ ("git log --since quoted 'commit'" , "git log --since=\" last commit\" " , "ALLOW" ),
316+ ("git log --grep commit" , "git log --grep=commit" , "ALLOW" ),
317+
318+ # ALLOW: read-only or non-destructive git/gh ops should pass through
319+ ("git log" , "git log --oneline" , "ALLOW" ),
320+ ("git diff" , "git diff HEAD~1" , "ALLOW" ),
321+ ("git fetch" , "git fetch origin" , "ALLOW" ),
322+ ("git pull" , "git pull origin main" , "ALLOW" ),
323+ ("git branch -d (lowercase, soft delete)" , "git branch -d feature/foo" , "ALLOW" ),
324+ ("git reset --soft" , "git reset --soft HEAD~1" , "ALLOW" ),
325+ ("git reset HEAD~1 (no --hard)" , "git reset HEAD~1" , "ALLOW" ),
326+ ("git stash" , "git stash" , "ALLOW" ),
327+ ("git checkout main" , "git checkout main" , "ALLOW" ),
328+ ("git switch --no-track (no create flag)" , "git switch --no-track foo" , "ALLOW" ),
329+ ("git switch existing branch" , "git switch main" , "ALLOW" ),
330+ ("gh pr view" , "gh pr view 123" , "ALLOW" ),
331+ ("gh pr diff" , "gh pr diff 123" , "ALLOW" ),
332+ ("gh pr list" , "gh pr list" , "ALLOW" ),
333+ ]
334+
335+ for desc , cmd , expected in GIT_ASK_TESTS :
336+ tally (run_hook_test (
337+ "check-dangerous-commands.py" ,
338+ {"command" : cmd },
339+ desc ,
340+ expected ,
235341 ))
236342
237343 hook_commands = load_hook_commands ()
@@ -328,7 +434,7 @@ def tally(result):
328434 "check-secrets-file.py" ,
329435 tool_input ,
330436 desc ,
331- should_block ,
437+ "BLOCK" if should_block else "ALLOW" ,
332438 ))
333439
334440 # =========================================================================
0 commit comments