Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion src/hooks/src/hooks/dangerous-actions/patterns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand All @@ -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' },
Expand Down
68 changes: 68 additions & 0 deletions src/hooks/tests/git-branch-delete.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading