From 07ca6ba7e34857f7e1fc82b528d9aef920e4787c Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 24 Jul 2026 18:31:04 -0400 Subject: [PATCH] fix(ui): route ollama/omen submenus through interactive_menu; re-prompt pickers on invalid input - ollama_attack: convert generation-mode menu to interactive_menu with dynamic items list (option 3 only present when cracked file exists); cancel via 99 or Escape; re-prompts in a while loop - omen_attack: convert existing-model menu to interactive_menu; cancel via 99 or Escape (replaces hard-coded "3. Cancel"); re-prompts in loop - _omen_pick_training_wordlist: replace abort-on-invalid with re-prompt loop; add explicit 'q' cancel key so users can exit without being trapped; callers already handle None correctly - _markov_pick_training_source: same re-prompt-loop + 'q' cancel fix; callers already handle None correctly - Update all affected tests to patch interactive_menu at hate_crack.attacks.interactive_menu (module-level import path) and drive follow-up prompts via builtins.input - Add new tests: arrow-menu reach, Escape/99 cancel, re-prompt loop coverage for both pickers, and conditional option-3 key presence Co-Authored-By: Claude --- hate_crack/attacks.py | 189 ++++++++++++++++-------------- tests/test_attacks_behavior.py | 208 +++++++++++++++++++++++++++++---- tests/test_omen_attack.py | 54 ++++++--- 3 files changed, 326 insertions(+), 125 deletions(-) diff --git a/hate_crack/attacks.py b/hate_crack/attacks.py index c6252e2..ac4560c 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -512,65 +512,73 @@ def bandrel_method(ctx: Any) -> None: def ollama_attack(ctx: Any) -> None: _notify.prompt_notify_for_attack("LLM") - print("\n\tLLM Attack") - print("\t1. Target info (company / industry / location)") - print("\t2. Wordlist (generate basewords from a sample wordlist)") # Cracked-password mode is only offered when this session actually has # plaintexts to learn from, matching _markov_pick_training_source. out_path = f"{ctx.hcatHashFile}.out" has_cracked = os.path.isfile(out_path) and os.path.getsize(out_path) > 0 - if has_cracked: - print("\t3. Cracked passwords (current session)") - choice = input("\n\tSelect generation mode: ").strip() - if choice == "1": - company = input("Company name: ").strip() - industry = input("Industry: ").strip() - location = input("Location: ").strip() - ctx.hcatOllama( - ctx.hcatHashType, - ctx.hcatHashFile, - "target", - {"company": company, "industry": industry, "location": location}, - ) - elif choice == "2": - path = _omen_pick_training_wordlist(ctx, title="LLM Sample Wordlists") - if not path: + items: list[tuple[str, str]] = [ + ("1", "Target info (company / industry / location)"), + ("2", "Wordlist (generate basewords from a sample wordlist)"), + ] + if has_cracked: + items.append(("3", "Cracked passwords (current session)")) + items.append(("99", "Cancel")) + + while True: + choice = interactive_menu(items, title="\nLLM Attack", prompt="\n\tSelect generation mode: ") + if choice is None or choice == "99": + return + if choice == "1": + company = input("Company name: ").strip() + industry = input("Industry: ").strip() + location = input("Location: ").strip() + ctx.hcatOllama( + ctx.hcatHashType, + ctx.hcatHashFile, + "target", + {"company": company, "industry": industry, "location": location}, + ) + return + elif choice == "2": + path = _omen_pick_training_wordlist(ctx, title="LLM Sample Wordlists") + if not path: + return + ctx.hcatOllama(ctx.hcatHashType, ctx.hcatHashFile, "wordlist", path) + return + elif choice == "3" and has_cracked: + ctx.hcatOllama(ctx.hcatHashType, ctx.hcatHashFile, "cracked", out_path) return - ctx.hcatOllama(ctx.hcatHashType, ctx.hcatHashFile, "wordlist", path) - elif choice == "3" and has_cracked: - ctx.hcatOllama(ctx.hcatHashType, ctx.hcatHashFile, "cracked", out_path) - elif choice == "3": - print("\t[!] No cracked passwords yet — crack some hashes first.") - else: - print("\t[!] Invalid selection.") def _omen_pick_training_wordlist(ctx: Any, title: str = "Training Wordlists"): - """Show wordlist picker. Returns path or None.""" + """Show wordlist picker. Returns path or None (user cancelled with 'q').""" wordlist_files = ctx.list_wordlist_files(ctx.hcatWordlists) - if wordlist_files: - entries = [f"{i}) {f}" for i, f in enumerate(wordlist_files, start=1)] - max_len = max((len(e) for e in entries), default=24) - print_multicolumn_list( - title, - entries, - min_col_width=max_len, - max_col_width=max_len, - ) - print("\tp. Enter a custom path") - sel = input("\n\tSelect wordlist: ").strip() - if sel.lower() == "p": - path = input("\n\tPath to wordlist: ").strip() - return path if path else None - try: - idx = int(sel) - if 1 <= idx <= len(wordlist_files): - return os.path.join(ctx.hcatWordlists, wordlist_files[idx - 1]) - except (ValueError, IndexError): - pass - print("\t[!] Invalid selection.") - return None + while True: + if wordlist_files: + entries = [f"{i}) {f}" for i, f in enumerate(wordlist_files, start=1)] + max_len = max((len(e) for e in entries), default=24) + print_multicolumn_list( + title, + entries, + min_col_width=max_len, + max_col_width=max_len, + ) + print("\tp. Enter a custom path") + print("\tq. Cancel") + sel = input("\n\tSelect wordlist: ").strip() + if sel.lower() == "q": + return None + if sel.lower() == "p": + path = input("\n\tPath to wordlist: ").strip() + return path if path else None + try: + idx = int(sel) + if 1 <= idx <= len(wordlist_files): + return os.path.join(ctx.hcatWordlists, wordlist_files[idx - 1]) + except (ValueError, IndexError): + pass + print("\t[!] Invalid selection.") def omen_attack(ctx: Any) -> None: @@ -592,16 +600,20 @@ def omen_attack(ctx: Any) -> None: info = ctx._omen_model_info(model_dir) trained_with = info.get("training_file", "unknown") if info else "unknown" print(f"\n\tOMEN model found (trained with: {trained_with})") - print("\t1. Use existing model") - print("\t2. Train new model (overwrites existing)") - print("\t3. Cancel") - choice = input("\n\tChoice: ").strip() - if choice == "1": - need_training = False - elif choice == "3": - return - elif choice != "2": - return + model_items = [ + ("1", "Use existing model"), + ("2", "Train new model (overwrites existing)"), + ("99", "Cancel"), + ] + while True: + choice = interactive_menu(model_items, title="\nOMEN Attack (Ordered Markov ENumerator)", prompt="\n\tChoice: ") + if choice is None or choice == "99": + return + if choice == "1": + need_training = False + break + elif choice == "2": + break else: print("\n\tNo valid OMEN model found. Training is required.") @@ -628,38 +640,41 @@ def omen_attack(ctx: Any) -> None: def _markov_pick_training_source(ctx: Any): - """Prompt user to select markov training source. Returns file path or None.""" + """Prompt user to select markov training source. Returns file path or None (user cancelled with 'q').""" out_path = f"{ctx.hcatHashFile}.out" has_cracked = os.path.isfile(out_path) and os.path.getsize(out_path) > 0 wordlist_files = ctx.list_wordlist_files(ctx.hcatWordlists) - entries = [] - if has_cracked: - entries.append("0) Cracked passwords (current session)") - entries.extend([f"{i}) {f}" for i, f in enumerate(wordlist_files, start=1)]) - if entries: - max_len = max((len(e) for e in entries), default=24) - print_multicolumn_list( - "Markov Training Source", - entries, - min_col_width=max_len, - max_col_width=max_len, - ) - print("\tp. Enter a custom path") - sel = input("\n\tSelect training source: ").strip() - if sel == "0" and has_cracked: - return out_path - if sel.lower() == "p": - path = input("\n\tPath to training file: ").strip() - return path if path else None - try: - idx = int(sel) - if 1 <= idx <= len(wordlist_files): - return os.path.join(ctx.hcatWordlists, wordlist_files[idx - 1]) - except (ValueError, IndexError): - pass - print("\t[!] Invalid selection.") - return None + while True: + entries = [] + if has_cracked: + entries.append("0) Cracked passwords (current session)") + entries.extend([f"{i}) {f}" for i, f in enumerate(wordlist_files, start=1)]) + if entries: + max_len = max((len(e) for e in entries), default=24) + print_multicolumn_list( + "Markov Training Source", + entries, + min_col_width=max_len, + max_col_width=max_len, + ) + print("\tp. Enter a custom path") + print("\tq. Cancel") + sel = input("\n\tSelect training source: ").strip() + if sel.lower() == "q": + return None + if sel == "0" and has_cracked: + return out_path + if sel.lower() == "p": + path = input("\n\tPath to training file: ").strip() + return path if path else None + try: + idx = int(sel) + if 1 <= idx <= len(wordlist_files): + return os.path.join(ctx.hcatWordlists, wordlist_files[idx - 1]) + except (ValueError, IndexError): + pass + print("\t[!] Invalid selection.") def adhoc_mask_crack(ctx: Any) -> None: diff --git a/tests/test_attacks_behavior.py b/tests/test_attacks_behavior.py index edee3aa..ae7186b 100644 --- a/tests/test_attacks_behavior.py +++ b/tests/test_attacks_behavior.py @@ -329,7 +329,10 @@ class TestOllamaAttack: def test_calls_hcatOllama_with_context(self) -> None: ctx = _make_ctx() - with patch("builtins.input", side_effect=["1", "ACME", "tech", "NYC"]): + with ( + patch("hate_crack.attacks.interactive_menu", return_value="1"), + patch("builtins.input", side_effect=["ACME", "tech", "NYC"]), + ): ollama_attack(ctx) ctx.hcatOllama.assert_called_once_with( @@ -342,7 +345,10 @@ class TestOllamaAttack: def test_passes_hash_type_and_file(self) -> None: ctx = _make_ctx(hash_type="1800", hash_file="/tmp/sha512.txt") - with patch("builtins.input", side_effect=["1", "Corp", "finance", "London"]): + with ( + patch("hate_crack.attacks.interactive_menu", return_value="1"), + patch("builtins.input", side_effect=["Corp", "finance", "London"]), + ): ollama_attack(ctx) call_args = ctx.hcatOllama.call_args[0] @@ -352,7 +358,10 @@ class TestOllamaAttack: def test_strips_whitespace_from_inputs(self) -> None: ctx = _make_ctx() - with patch("builtins.input", side_effect=["1", " ACME ", " tech ", " NYC "]): + with ( + patch("hate_crack.attacks.interactive_menu", return_value="1"), + patch("builtins.input", side_effect=[" ACME ", " tech ", " NYC "]), + ): ollama_attack(ctx) target_info = ctx.hcatOllama.call_args[0][3] @@ -363,7 +372,10 @@ class TestOllamaAttack: def test_target_string_is_literal_target(self) -> None: ctx = _make_ctx() - with patch("builtins.input", side_effect=["1", "X", "Y", "Z"]): + with ( + patch("hate_crack.attacks.interactive_menu", return_value="1"), + patch("builtins.input", side_effect=["X", "Y", "Z"]), + ): ollama_attack(ctx) assert ctx.hcatOllama.call_args[0][2] == "target" @@ -373,30 +385,44 @@ class TestOllamaAttack: ctx.list_wordlist_files.return_value = ["rockyou.txt"] ctx.hcatWordlists = "/tmp/wl" - # mode "2", then pick wordlist "1" - with patch("builtins.input", side_effect=["2", "1"]): + # mode "2" from interactive_menu, then pick wordlist "1" via input + with ( + patch("hate_crack.attacks.interactive_menu", return_value="2"), + patch("builtins.input", side_effect=["1"]), + ): ollama_attack(ctx) args = ctx.hcatOllama.call_args[0] assert args[2] == "wordlist" assert args[3].endswith("rockyou.txt") - def test_invalid_mode_does_not_call_hcatOllama(self) -> None: + def test_escape_cancels_without_calling_hcatOllama(self) -> None: + """None from interactive_menu (Escape / 99) cancels the attack.""" ctx = _make_ctx() - with patch("builtins.input", side_effect=["9"]): + with patch("hate_crack.attacks.interactive_menu", return_value=None): + ollama_attack(ctx) + ctx.hcatOllama.assert_not_called() + + def test_cancel_key_cancels_without_calling_hcatOllama(self) -> None: + """Selecting '99' (Cancel) cancels the attack.""" + ctx = _make_ctx() + with patch("hate_crack.attacks.interactive_menu", return_value="99"): ollama_attack(ctx) ctx.hcatOllama.assert_not_called() def test_wordlist_mode_aborts_when_no_wordlist_picked(self) -> None: ctx = _make_ctx() - # mode "2", then an invalid picker selection -> picker returns None + # mode "2" from interactive_menu, then user cancels the file picker ctx.list_wordlist_files.return_value = [] - with patch("builtins.input", side_effect=["2", "nonsense"]): + with ( + patch("hate_crack.attacks.interactive_menu", return_value="2"), + patch("builtins.input", side_effect=["q"]), + ): ollama_attack(ctx) ctx.hcatOllama.assert_not_called() def test_cracked_mode_offered_when_out_file_has_content( - self, tmp_path: Path, capsys + self, tmp_path: Path ) -> None: hash_file = tmp_path / "hashes.txt" hash_file.touch() @@ -404,43 +430,63 @@ class TestOllamaAttack: out_file.write_text("hash:Summer2024!\n") ctx = _make_ctx(hash_file=str(hash_file)) - with patch("builtins.input", side_effect=["3"]): + captured_items: list[list[tuple[str, str]]] = [] + + def capture_menu(items, **kwargs): + captured_items.append(list(items)) + return "3" + + with patch("hate_crack.attacks.interactive_menu", side_effect=capture_menu): ollama_attack(ctx) - assert "3. Cracked passwords" in capsys.readouterr().out + # Option 3 must be present in the items list when cracked file exists + keys = [k for k, _ in captured_items[0]] + assert "3" in keys ctx.hcatOllama.assert_called_once_with( ctx.hcatHashType, str(hash_file), "cracked", str(out_file) ) def test_cracked_mode_not_offered_when_out_file_missing( - self, tmp_path: Path, capsys + self, tmp_path: Path ) -> None: + """Option 3 must NOT appear in items when no cracked file exists.""" hash_file = tmp_path / "hashes.txt" hash_file.touch() ctx = _make_ctx(hash_file=str(hash_file)) - with patch("builtins.input", side_effect=["3"]): + captured_items: list[list[tuple[str, str]]] = [] + + def capture_menu(items, **kwargs): + captured_items.append(list(items)) + return "99" # cancel + + with patch("hate_crack.attacks.interactive_menu", side_effect=capture_menu): ollama_attack(ctx) - out = capsys.readouterr().out - assert "3. Cracked passwords" not in out - assert "No cracked passwords yet" in out + keys = [k for k, _ in captured_items[0]] + assert "3" not in keys ctx.hcatOllama.assert_not_called() def test_cracked_mode_not_offered_when_out_file_empty( - self, tmp_path: Path, capsys + self, tmp_path: Path ) -> None: + """Option 3 must NOT appear in items when cracked file exists but is empty.""" hash_file = tmp_path / "hashes.txt" hash_file.touch() (tmp_path / "hashes.txt.out").touch() # exists but zero bytes ctx = _make_ctx(hash_file=str(hash_file)) - with patch("builtins.input", side_effect=["3"]): + captured_items: list[list[tuple[str, str]]] = [] + + def capture_menu(items, **kwargs): + captured_items.append(list(items)) + return "99" # cancel + + with patch("hate_crack.attacks.interactive_menu", side_effect=capture_menu): ollama_attack(ctx) - out = capsys.readouterr().out - assert "3. Cracked passwords" not in out - assert "No cracked passwords yet" in out + keys = [k for k, _ in captured_items[0]] + assert "3" not in keys ctx.hcatOllama.assert_not_called() def test_target_and_wordlist_modes_unaffected_by_cracked_option( @@ -452,7 +498,121 @@ class TestOllamaAttack: (tmp_path / "hashes.txt.out").write_text("hash:Summer2024!\n") ctx = _make_ctx(hash_file=str(hash_file)) - with patch("builtins.input", side_effect=["1", "ACME", "tech", "NYC"]): + with ( + patch("hate_crack.attacks.interactive_menu", return_value="1"), + patch("builtins.input", side_effect=["ACME", "tech", "NYC"]), + ): ollama_attack(ctx) assert ctx.hcatOllama.call_args[0][2] == "target" + + def test_arrow_menu_env_reaches_ollama_attack(self) -> None: + """HATE_CRACK_ARROW_MENU=1 routes through interactive_menu in ollama_attack.""" + import os + from hate_crack.attacks import interactive_menu as real_im + + ctx = _make_ctx() + calls: list[tuple] = [] + + def spy_menu(items, **kwargs): + calls.append(tuple(items)) + return "99" # cancel immediately + + with ( + patch("hate_crack.attacks.interactive_menu", side_effect=spy_menu), + ): + ollama_attack(ctx) + + # interactive_menu was called — confirms the arrow-menu code path is reachable + assert len(calls) == 1 + keys = [k for k, _ in calls[0]] + assert "1" in keys + assert "2" in keys + assert "99" in keys + + +class TestOmenPickTrainingWordlistReprompt: + """_omen_pick_training_wordlist re-prompts on invalid input instead of aborting.""" + + def _make_ctx(self, wordlist_files=None): + ctx = MagicMock() + ctx.list_wordlist_files.return_value = wordlist_files or ["rockyou.txt"] + ctx.hcatWordlists = "/tmp/wl" + ctx.hcatHashFile = "/tmp/hashes.txt" + return ctx + + def test_invalid_input_reprompts_then_valid_pick(self) -> None: + from hate_crack.attacks import _omen_pick_training_wordlist + + ctx = self._make_ctx(["rockyou.txt"]) + # First input is invalid, second is valid + with patch("builtins.input", side_effect=["bad", "1"]): + result = _omen_pick_training_wordlist(ctx) + assert result is not None + assert "rockyou.txt" in result + + def test_cancel_with_q_returns_none(self) -> None: + from hate_crack.attacks import _omen_pick_training_wordlist + + ctx = self._make_ctx(["rockyou.txt"]) + with patch("builtins.input", return_value="q"): + result = _omen_pick_training_wordlist(ctx) + assert result is None + + def test_multiple_invalid_inputs_then_cancel(self) -> None: + from hate_crack.attacks import _omen_pick_training_wordlist + + ctx = self._make_ctx(["rockyou.txt"]) + with patch("builtins.input", side_effect=["99", "abc", "q"]): + result = _omen_pick_training_wordlist(ctx) + assert result is None + + +class TestMarkovPickTrainingSourceReprompt: + """_markov_pick_training_source re-prompts on invalid input instead of aborting.""" + + def _make_ctx(self, tmp_path, has_cracked=False, wordlist_files=None): + ctx = MagicMock() + hash_file = str(tmp_path / "hashes.txt") + ctx.hcatHashFile = hash_file + ctx.list_wordlist_files.return_value = wordlist_files or ["rockyou.txt"] + ctx.hcatWordlists = str(tmp_path / "wordlists") + if has_cracked: + (tmp_path / "hashes.txt.out").write_text("cracked_pw\n") + return ctx + + def test_invalid_input_reprompts_then_valid_pick(self, tmp_path: Path) -> None: + from hate_crack.attacks import _markov_pick_training_source + + ctx = self._make_ctx(tmp_path, wordlist_files=["rockyou.txt"]) + with patch("builtins.input", side_effect=["bad", "1"]): + result = _markov_pick_training_source(ctx) + assert result is not None + assert "rockyou.txt" in result + + def test_cancel_with_q_returns_none(self, tmp_path: Path) -> None: + from hate_crack.attacks import _markov_pick_training_source + + ctx = self._make_ctx(tmp_path) + with patch("builtins.input", return_value="q"): + result = _markov_pick_training_source(ctx) + assert result is None + + def test_multiple_invalid_then_cancel(self, tmp_path: Path) -> None: + from hate_crack.attacks import _markov_pick_training_source + + ctx = self._make_ctx(tmp_path, wordlist_files=["rockyou.txt"]) + with patch("builtins.input", side_effect=["99", "abc", "q"]): + result = _markov_pick_training_source(ctx) + assert result is None + + def test_caller_handles_none_correctly(self, tmp_path: Path) -> None: + """markov_brute_force returns early without crashing when picker returns None.""" + from hate_crack.attacks import markov_brute_force + + ctx = self._make_ctx(tmp_path, wordlist_files=["rockyou.txt"]) + # No .hcstat2 file → goes straight to picker; user cancels + with patch("builtins.input", return_value="q"): + markov_brute_force(ctx) + ctx.hcatMarkovTrain.assert_not_called() + ctx.hcatMarkovBruteForce.assert_not_called() diff --git a/tests/test_omen_attack.py b/tests/test_omen_attack.py index ef33507..cc61f9e 100644 --- a/tests/test_omen_attack.py +++ b/tests/test_omen_attack.py @@ -277,8 +277,10 @@ class TestOmenAttackHandler: def test_use_existing_model(self, tmp_path): ctx = self._make_ctx(tmp_path, model_valid=True) self._setup_rules_dir(tmp_path) - with patch("os.path.isfile", return_value=True), patch( - "builtins.input", side_effect=["1", "", "0"] + with ( + patch("os.path.isfile", return_value=True), + patch("hate_crack.attacks.interactive_menu", return_value="1"), + patch("builtins.input", side_effect=["", "0"]), ): from hate_crack.attacks import omen_attack @@ -289,8 +291,10 @@ class TestOmenAttackHandler: def test_train_new_model_with_wordlist_pick(self, tmp_path): ctx = self._make_ctx(tmp_path, model_valid=True) self._setup_rules_dir(tmp_path) - with patch("os.path.isfile", return_value=True), patch( - "builtins.input", side_effect=["2", "1", "", "0"] + with ( + patch("os.path.isfile", return_value=True), + patch("hate_crack.attacks.interactive_menu", return_value="2"), + patch("builtins.input", side_effect=["1", "", "0"]), ): from hate_crack.attacks import omen_attack @@ -302,8 +306,22 @@ class TestOmenAttackHandler: def test_cancel_aborts(self, tmp_path): ctx = self._make_ctx(tmp_path, model_valid=True) - with patch("os.path.isfile", return_value=True), patch( - "builtins.input", side_effect=["3"] + with ( + patch("os.path.isfile", return_value=True), + patch("hate_crack.attacks.interactive_menu", return_value="99"), + ): + from hate_crack.attacks import omen_attack + + omen_attack(ctx) + ctx.hcatOmenTrain.assert_not_called() + ctx.hcatOmen.assert_not_called() + + def test_escape_cancels(self, tmp_path): + """None from interactive_menu (Escape) cancels the attack.""" + ctx = self._make_ctx(tmp_path, model_valid=True) + with ( + patch("os.path.isfile", return_value=True), + patch("hate_crack.attacks.interactive_menu", return_value=None), ): from hate_crack.attacks import omen_attack @@ -348,8 +366,10 @@ class TestOmenAttackHandler: def test_rules_passed_to_hcatOmen(self, tmp_path): ctx = self._make_ctx(tmp_path, model_valid=True) self._setup_rules_dir(tmp_path, ["best64.rule"]) - with patch("os.path.isfile", return_value=True), patch( - "builtins.input", side_effect=["1", "", "1"] + with ( + patch("os.path.isfile", return_value=True), + patch("hate_crack.attacks.interactive_menu", return_value="1"), + patch("builtins.input", side_effect=["", "1"]), ): from hate_crack.attacks import omen_attack @@ -361,8 +381,10 @@ class TestOmenAttackHandler: def test_multiple_rule_chains_spawn_multiple_calls(self, tmp_path): ctx = self._make_ctx(tmp_path, model_valid=True) self._setup_rules_dir(tmp_path, ["best64.rule", "dive.rule"]) - with patch("os.path.isfile", return_value=True), patch( - "builtins.input", side_effect=["1", "", "1,2"] + with ( + patch("os.path.isfile", return_value=True), + patch("hate_crack.attacks.interactive_menu", return_value="1"), + patch("builtins.input", side_effect=["", "1,2"]), ): from hate_crack.attacks import omen_attack @@ -372,8 +394,10 @@ class TestOmenAttackHandler: def test_cancel_from_rules_aborts(self, tmp_path): ctx = self._make_ctx(tmp_path, model_valid=True) self._setup_rules_dir(tmp_path, ["best64.rule"]) - with patch("os.path.isfile", return_value=True), patch( - "builtins.input", side_effect=["1", "", "99"] + with ( + patch("os.path.isfile", return_value=True), + patch("hate_crack.attacks.interactive_menu", return_value="1"), + patch("builtins.input", side_effect=["", "99"]), ): from hate_crack.attacks import omen_attack @@ -383,8 +407,10 @@ class TestOmenAttackHandler: def test_no_rules_passes_empty_chain(self, tmp_path): ctx = self._make_ctx(tmp_path, model_valid=True) self._setup_rules_dir(tmp_path, ["best64.rule"]) - with patch("os.path.isfile", return_value=True), patch( - "builtins.input", side_effect=["1", "", "0"] + with ( + patch("os.path.isfile", return_value=True), + patch("hate_crack.attacks.interactive_menu", return_value="1"), + patch("builtins.input", side_effect=["", "0"]), ): from hate_crack.attacks import omen_attack