From 458ce4c958e17ad038663e24d6569e4b28804ad5 Mon Sep 17 00:00:00 2001 From: David Turner Date: Sat, 15 Aug 2026 20:02:52 -0400 Subject: [PATCH 1/2] fix(hooks): detect git branch force-delete variants Signed-off-by: David Turner --- src/hooks/src/hooks/dangerous-actions/patterns.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/hooks/src/hooks/dangerous-actions/patterns.ts b/src/hooks/src/hooks/dangerous-actions/patterns.ts index ab4da926f..183c1fa12 100644 --- a/src/hooks/src/hooks/dangerous-actions/patterns.ts +++ b/src/hooks/src/hooks/dangerous-actions/patterns.ts @@ -94,6 +94,16 @@ const GIT_FORCE_FLAG_LA = String.raw`(?=(?:\s+\S+)*\s+(?:-f\b|--force(?!-with-le // argument, not a refspec) is left alone and handled separately. const GIT_FORCE_REFSPEC_LA = String.raw`(?=(?:\s+-\S+)*\s+(?!-)(?!['"]?\+)\S+(?:\s+\S+)*\s+['"]?\+\S)`; +// `git branch` force-delete detection. `-D` is shorthand for `--delete --force`. +// Git also accepts delete + force as separate short/long flags, in either order, +// and as combined short flags (`-df` / `-fd`). Keep the delete and force checks +// independent so ordinary `-d` / `--delete` remains outside this guardrail. +// Lookaheads stop at common shell command separators so a later command cannot +// accidentally supply the missing force/delete flag for an earlier branch command. +const GIT_BRANCH = String.raw`\bgit\s+branch\b`; +const GIT_BRANCH_DELETE_LA = String.raw`(?=[^;&|\r\n]*(?:\s--delete\b|\s-[a-zA-Z]*[dD][a-zA-Z]*\b))`; +const GIT_BRANCH_FORCE_LA = String.raw`(?=[^;&|\r\n]*(?:\s--force\b|\s-[a-zA-Z]*[fD][a-zA-Z]*\b))`; + export const DANGEROUS_BASH: readonly DangerPattern[] = [ { id: 'rm-rf-root', re: new RegExp(String.raw`\brm\b` + RM_RF_GUARD + RM_ROOT_TARGET), label: 'rm -rf /', reason: REASON.FILE_DELETION, policy: 'reconsider' }, { id: 'rm-rf-home', re: new RegExp(String.raw`\brm\b` + RM_RF_GUARD + RM_HOME_TARGET), label: 'rm -rf $HOME', reason: REASON.FILE_DELETION, policy: 'reconsider' }, @@ -107,7 +117,7 @@ export const DANGEROUS_BASH: readonly DangerPattern[] = [ { id: 'git-force-push', re: new RegExp(GIT_PUSH + `(?:${GIT_FORCE_FLAG_LA}|${GIT_FORCE_REFSPEC_LA})`), label: 'git push --force', reason: REASON.GIT_HISTORY_REWRITE, policy: 'reconsider' }, { id: 'git-reset-hard', re: /\bgit\s+reset\s+--hard\b/, label: 'git reset --hard', reason: REASON.GIT_HISTORY_REWRITE, policy: 'reconsider' }, { id: 'git-clean-force', re: /\bgit\s+clean\s+-[a-z]*[fd]/, label: 'git clean -fd', reason: REASON.FILE_DELETION, policy: 'reconsider' }, - { id: 'git-branch-delete', re: /\bgit\s+branch\s+-D\b/, label: 'git branch -D', reason: REASON.GIT_HISTORY_REWRITE, policy: 'reconsider' }, + { id: 'git-branch-delete', re: new RegExp(GIT_BRANCH + GIT_BRANCH_DELETE_LA + GIT_BRANCH_FORCE_LA), label: 'git branch -D', reason: REASON.GIT_HISTORY_REWRITE, policy: 'reconsider' }, { id: 'aws-s3-rm-recursive', re: /\baws\s+s3\s+rm\b.*--recursive\b/, label: 'aws s3 rm --recursive', reason: REASON.FILE_DELETION, policy: 'reconsider' }, { id: 'kubectl-delete-prod', re: /\bkubectl\s+delete\b.*--all\b/, label: 'kubectl mass delete', reason: REASON.INFRA_OPERATION, policy: 'reconsider' }, { id: 'dropdb', re: /\b(?:dropdb\b|psql\b[^"']*\bdrop\s+(?:table|database|schema)\b)/i, label: 'DB drop CLI', reason: REASON.SCHEMA_MODIFICATION, policy: 'reconsider' }, From ac811835a9f9623aabf0c186dbc376c4fd430515 Mon Sep 17 00:00:00 2001 From: David Turner Date: Sat, 15 Aug 2026 20:03:09 -0400 Subject: [PATCH 2/2] test(hooks): cover git branch force-delete variants Signed-off-by: David Turner --- src/hooks/tests/git-branch-delete.test.ts | 68 +++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 src/hooks/tests/git-branch-delete.test.ts diff --git a/src/hooks/tests/git-branch-delete.test.ts b/src/hooks/tests/git-branch-delete.test.ts new file mode 100644 index 000000000..6067f50aa --- /dev/null +++ b/src/hooks/tests/git-branch-delete.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from 'vitest'; +import { DANGEROUS_BASH } from '../src/hooks/dangerous-actions/patterns'; +import { evaluateDangerous } from '../src/hooks/dangerous-actions/evaluate'; +import type { HookContext } from '../src/runtime/types'; + +const branchDelete = DANGEROUS_BASH.find((pattern) => pattern.id === 'git-branch-delete')!.re; + +const bashCtx = (command: string): HookContext => ({ + ide: 'claude-code', + event: 'PreToolUse', + toolKind: 'bash', + toolName: 'Bash', + filePath: '', + cwd: '/proj', + sessionId: null, + toolInput: { command }, +}); + +const forceDeleteVariants = [ + 'git branch -D throwaway-test', + 'git branch -d -f throwaway-test', + 'git branch -f -d throwaway-test', + 'git branch -fd throwaway-test', + 'git branch -df throwaway-test', + 'git branch --delete --force throwaway-test', + 'git branch --force --delete throwaway-test', + 'git branch -d --force throwaway-test', + 'git branch --delete -f throwaway-test', +] as const; + +describe('git-branch-delete dangerous-action guard', () => { + for (const command of forceDeleteVariants) { + test(`${command} matches the guard`, () => { + expect(branchDelete.test(command)).toBe(true); + }); + + test(`${command} is reconsidered without the review marker`, () => { + const result = evaluateDangerous(bashCtx(command)); + expect(result?.kind).toBe('deny'); + expect((result as { kind: 'deny'; reason: string }).reason).toContain('git-branch-delete'); + }); + + test(`${command} is allowed with the review marker`, () => { + expect( + evaluateDangerous(bashCtx(`${command} # Rosetta-AI-reviewed`)), + ).toBeNull(); + }); + } + + test.each([ + 'git branch -d throwaway-test', + 'git branch --delete throwaway-test', + 'git branch -f throwaway-test', + 'git branch --force throwaway-test', + 'git branch --list', + 'git branch --show-current', + ])('%s stays outside the force-delete guard', (command) => { + expect(branchDelete.test(command)).toBe(false); + }); + + test('a later shell command cannot supply the missing force flag', () => { + expect(branchDelete.test('git branch -d throwaway-test && echo --force')).toBe(false); + }); + + test('a later shell command cannot supply the missing delete flag', () => { + expect(branchDelete.test('git branch -f throwaway-test; echo --delete')).toBe(false); + }); +});