Skip to Main Content
October 15, 2026

Two Config Files, Two Release Tracks, and One Very Bad Commit

Written by Justin Bollinger
Password Audits Penetration Testing Artificial Intelligence (AI)

Part 3 of 3. Part 1 is the reference for every attack and feature added to hate_crack since 2.0. Part 2 is the deep dive into HashcatRosetta. This post is the plumbing: where configuration lives now and why it moved, how releases get cut, and the afternoon I pushed my private notes to a public repository.

Story Time

On July 30, a prek pre-push hook corrupted my git index.

The mechanism is a stash and restore cycle. The hook stashes your working tree so the checks run against what you're actually pushing, then restores it afterward. When that cycle went wrong, the index it handed back was not the index I'd built. The commit that came out of it deleted 85,280 lines across roughly 165 files and staged a pile of gitignored, local-only paths: CLAUDE.md, .claude/, docs/plans/, docs/superpowers/.

Those paths were not gitignored by accident. I purged them from this repository's history on July 25, five days earlier, specifically because trustedsec/hate_crack is public and those files are my working notes.

It reached the public remote before I force-pushed it away.

The Call is Coming From Inside the House

The part that stings is how little was standing in the way. detect-private-key was the only secret-scanning gate in the whole project, and it does not look at any of this. CI had nothing at all. The publication boundary existed in a .gitignore file and in my head, and git add -f walks straight past the first one.

So, the boundary is enforced at commit time now, by two scripts in .github/scripts/.

check-publication-boundary.sh refuses any commit that adds or modifies one of those paths. The important detail is that it reads the staged changeset out of the index with git diff --cached --name-only --diff-filter=ACMRT rather than consulting .gitignore, because forcing past .gitignore is precisely the failure mode it exists to catch. Deletions stay allowed, so an accidentally tracked boundary file can still be removed. There is no override, and that's deliberate.

check-mass-deletion.sh aborts any commit deleting more than 50 tracked files. No commit in the last 400 in this repository's history deleted more than one, so 50 sits two orders of magnitude above real work while still catching a whole-tree wipe. When you genuinely mean it, HATE_CRACK_ALLOW_MASS_DELETE=1 is the escape hatch, and it's the right one to reach for instead of --no-verify, which would also skip the boundary check.

Both are pre-commit hooks, not pre-push, and that placement is the whole lesson. By pre-push time, the bad commit already exists in your local history, and the pre-push stash is itself the thing that corrupts the index. A guard that runs after the damage is a report, not a guard.

The failure messages carry the actual recovery, because the symptom is fatal: this operation must be run in a work tree and that tells you nothing about what to do. git config core.bare false, then git reset HEAD, then git status --short and expect it empty. Nothing on disk is ever lost in this failure. It just looks like everything is.

tests/test_commit_guards.py runs both scripts as real hooks against throwaway repositories and asserts on whether git commit actually succeeded, rather than grepping prek.toml for hook IDs. Neuter either script to exit 0 and its tests fail. That distinction is not academic as it already bit this project once, where deleted code got reinstated with the whole suite still green.

The Purge that Didn't Purge

Here's the part I got wrong, and I only found it while auditing for something else.

A .gitignore plus a history rewrite does not unpublish anything on a repository that takes pull requests. GitHub's refs/pull/N/head is server-side and immutable. The July 25 filter-repo rewrote every branch and left every PR head exactly where it was, so those files stayed fetchable from PRs #78 through #137 the entire time I believed they were gone. Nothing bad came of it, and the only reason nothing bad came of it is that the content turned out to be clean.

Five Days too Late to Matter, and a Month too Late to Learn From it

The lesson is not "purge faster." It's that the gate has to be never commit it, because after that, you are negotiating with a service that keeps its own copies. Which is, incidentally, the same conclusion the pre-commit hooks above arrived at by a different route.

Then, I went the other way with it. As of 2.33.0, CLAUDE.md and .claude/ are published on purpose. Keeping them out meant this project's branching, release, tagging, and config policies were documented nowhere a contributor could read them. You could see that main only ever receives work through a batch integration merge, but not why, nor that history has to stay linear, nor that a Closes #NNN in a work-branch commit closes exactly nothing.

The boundary narrowed rather than disappeared. Still refused: .claude/plans/, .claude/specs/, docs/plans/, docs/superpowers/, and .claude/settings.local.json. Plans and specs are per-task scratch written in the future tense, stale the moment the work lands, and a reader can't tell a shipped design from an abandoned one. Four of them from April were dropped rather than published. One announced itself as superseded by another in the same batch, and between them they referenced two test files that no longer exist.

All four enforcement layers narrowed together, and the guard tests now assert the narrowing in both directions, so a revert that re-forbids CLAUDE.md fails the suite instead of quietly un-publishing it.

Publishing .claude/ bought a new problem, though, and tests/test_agent_instruction_integrity.py exists for it. A pull request can now propose changes to files a maintainer's coding agent reads as instructions and, for hooks, executes as code, without touching hate_crack/ at all. Every hook command, hook script, subagent definition, skill, and shell script under .claude/ is pinned to a literal approved set. A permissions key in the shared settings.json is refused outright, since that proposes auto-approval rules for everyone who trusts the workspace rather than just its author. Any network verb in a .claude/ script is refused too, because a hook that reaches the network can exfiltrate whatever the session has read.

The marker scan for crude instruction-override phrasing is a lint, not a boundary, and the module docstring says so, because no parser settles the intent of natural language. What the file actually buys is that a change to that surface can't be silent. All six vectors got mutation-tested, and each one fails the suite.

Your Config is Now Two Files

The other big change since 2.17 is that configuration split in two, and this one is breaking.

config.json keeps operational settings: paths, wordlists, and candidate caps, which attacks pass -O. A new .env holds third-party integration settings which, in practice, means every credential: Hashview, Hashmob, Pushover, and the Ollama connection details.

The rule that makes it work is that every key has exactly one home. A key is read from its home file and nowhere else. Put hashview_api_key in config.json and it is ignored, with a warning naming the file it belongs in:

Config key 'hashview_api_key' in /path/to/config.json is ignored: it belongs
in the .env file, as HASHVIEW_API_KEY. Remove it from /path/to/config.json.
It Will Tell You. Every Run

Precedence has three layers and no cross-file fallback:

  1. The real process environment
  2. That key's own home file, and only that file
  3. The schema default

The process environment is the one exception to the one-home rule, because it isn't a home. It's an ephemeral override, which is what keeps HASHVIEW_URL=... hate_crack working for a single run against a dev stack without touching persisted config.

Both files resolve independently through the same search order: repo root, then the installed package directory, then ~/.hate_crack. They're allowed to live in different directories.

One empty-string subtlety is worth knowing, because it will look like a bug otherwise. In .env, an empty value is a deliberate statement: PIPAL_PATH= means "no pipal" and does not fall through to the default. In the real environment, an empty value is treated as unset and does fall through. This matches how the old code read, os.environ, and keeps a stray export from meaning something different than a written line.

What Happens to Your Existing Config

Nothing gets deleted behind your back. On first run after the upgrade, if you have a config.json with integration keys in it and no .env yet, a one-shot migration runs. Then, it copies those keys into a fresh .env created 0600, backs the original up to config.json.pre-split.bak, and rewrites config.json without the keys it copied. Everything else in that file survives verbatim, in its original key order, so the diff is reviewable.

A value with the wrong type doesn't get copied. The .env gets the schema default instead and the migration says so, rather than carrying a broken value into the new file quietly.

If the rewrite of config.json fails, the migration still counts as done and tells you to finish by hand:

Could not rewrite /path/to/config.json to drop the copied keys; they are now
read from .env only, so delete them by hand to stop the warnings.

Two lines print at the top of every run naming exactly which files are in effect:

[*] config.json: /path/to/config.json
[*] .env:        /path/to/.env (created this run, from built-in defaults)

That output exists because of two facts that are otherwise invisible. The search order prefers a repo checkout over ~/.hate_crack, so a stray .env in any checkout silently outranks the real one. A .env in your current working directory is never read at all, which is deliberate. Engagement directories are full of files nobody intended as config.

A dangling .env or config.json symlink is now fatal instead of being ignored. os.path.isfile() reports a broken symlink as absent, so before that fix a broken symlink meant silently falling back to schema defaults: wrong wordlists, wrong potfile, and no indication anything was off.

The Sixteen Keys that Moved

These are the ones that changed file. If any of them are in your config.json, they are no longer being read from it.

.env key

Default

Purpose

HASHVIEW_URL

http://localhost:8443

Hashview server

HASHVIEW_API_KEY

""

Hashview API key; also gates menu option 94

HASHMOB_API_KEY

""

Hashmob API key

LLM_BACKEND

ollama

Backend to talk to: ollama, vllm, or openai

LLM_API_KEY

ollama

Bearer token for the backend; the literal string ollama is the default because Ollama ignores it

OLLAMA_HOST

localhost:11434

Ollama endpoint; bare host:port or a full URL

OLLAMA_MODEL

qwen3:4b-instruct

LLM attack model

OLLAMA_NO_CLOUD

false

Refuse Ollama cloud-hosted models

OLLAMA_NUM_CTX

8192

Context window; was 2048

OLLAMA_TIMEOUT

300

Seconds before giving up on a generation request

OLLAMA_MAX_SAMPLE_LINES

500

Threshold for including literal plaintexts in a prompt

OLLAMA_AUTO_RESEARCH

true

Target-mode industry and location pre-fill

NOTIFY_PUSHOVER_TOKEN

""

Pushover credential

NOTIFY_PUSHOVER_USER

""

Pushover credential

PIPAL_PATH

/path/to/pipal

Path to pipal

PIPAL_COUNT

10

Base words to read out of pipal output

Four of those (HASHVIEW_API_KEY, HASHMOB_API_KEY, and both Pushover credentials) are marked as secrets in the schema and get redacted out of any error message. LLM_API_KEY is not one of them, which is worth knowing if you point it at a backend where the token is real.

The other 41 keys stay in config.json and are unchanged in location. The full set with defaults is config.json.example, and a test asserts that file's key set is exactly the JSON-homed half of the schema, with no allowlist, so it can't drift from the code. .env.example is generated from the same schema by uv run python -m hate_crack.config_writer.

Two tests are doing more work than their names suggest. One walks main.py to find where each documented key is actually read and fails if a key has no read site, which is how three keys that had been dead for a while got caught. omenTrainingList, hcatCombinator3Wordlist, and hcatCombinatorXWordlist were all documented, all settable, and all read by nothing. They're gone now. hcatPrinceLing came out of the optimizedKernelAttacks default for the same reason: PRINCE-LING delegates to PRINCE's own check, so the separate name was recognized and never consulted.

The second is the mirror of the first for the .env half, and it exists because the original only covered keys documented in config.json.example. That gap shipped a real bug, which is the next section.

The Local Model that Wasn't Local

Ollama will proxy a cloud-hosted model for you. Tag a model gpt-oss:120b-cloud or deepseek-v3.1:671b-cloud and requests to it go to ollama.com, through the same localhost endpoint a local model uses. Nothing about the request shape tells you the prompt is leaving the host.

The prompts hate_crack's LLM attack sends carry recovered plaintexts, corpus statistics, and the client's name, industry, and location.

Same Endpoint. Different Continent

The model name was the only thing I checked at first, because it's the only thing you can look at before the data is already gone. Set OLLAMA_NO_CLOUD=1 in .env and a -cloud-tagged model is refused before a client is constructed or a request assembled:

'gpt-oss:120b-cloud' is an Ollama cloud model and OLLAMA_NO_CLOUD is set.
Prompts carry client corpus and target details, so this request was not sent.
Pick a locally-hosted model, or unset OLLAMA_NO_CLOUD in your .env.

It defaults to off, because anyone deliberately using a cloud model shouldn't have their tool argue with them. Turn it on if your engagement paperwork means what it says. The check lives at the three hate_crack.llm entry points rather than at their call sites, and its no_cloud parameter is keyword-only with no default. Therefore, a new call site has to state a policy instead of inheriting a permissive one by omission.

Checking the name was never good enough, though, and once vLLM and OpenAI-compatible backends landed it stopped being defensible at all. A model tag tells you nothing about where the request goes when the endpoint itself is the thing pointed offsite. So OLLAMA_NO_CLOUD guards the destination now, through llm.is_offsite_url(), for all three backends.

The rules it applies are the ones you'd write yourself, plus two you might not. localhost, loopback, private, and link-local are local, as is anything ending in .local, .internal, .lan, or .localdomain. A hostname resolving to a mix of private and global addresses counts as offsite, because one global address is enough for the prompt to leave. A hostname that fails to resolve is treated as offsite, which is the fail-closed call. Somebody who set OLLAMA_NO_CLOUD asked for "nothing leaves this host," and refusing a destination the function can't verify honors that better than waving it through.

The two you might not write are as follows. One is CGNAT (100.64.0.0/10) because it is treated as local, because Tailscale hands out tailnet addresses from that range, and a GPU box reached over Tailscale is an ordinary remote-but-private setup rather than cloud egress. The other is 6to4 (2002::/16) because it is always offsite, since it encodes a global IPv4 address by definition no matter what it looks like.

While I was in there, I found that OLLAMA_HOST had never worked from .env. It was read straight off os.environ, but the loader parses .env with python-dotenv's dotenv_values(), which returns a mapping and deliberately does not export into the process environment. So, an OLLAMA_HOST was written into .env, which is where every other Ollama setting lives and where .env.example implies it belongs. This was reported as an unrecognized key, ignored, and the run talked to localhost:11434 while I believed it was talking to my GPU box. It's a real schema key now. A genuinely exported OLLAMA_HOST still overrides it for a single run.

That bug is exactly what the missing read-site test would have caught, which is why that test exists now.

Eight Flags that Remember

Eight command-line preferences used to have to be restated every single run. They're schema-backed defaults now, and the flag is a per-run override: --debug, --rank, --nightly, --restore-potfile, --potfile-path, --no-optimized-kernel, --rule-debug-mode, and --coverage.

The last two arrived with the features behind them. rule_debug_mode_enabled (default true) decides whether rule attacks write the hashcat debug log that the Rosetta Attack later reads. coverage_enabled (default true) decides whether hate_crack records what it already ran against a hash file. Both default on, and both are things you'd want off in exactly one situation: A run where you don't want anything about it written to disk.

Precedence is the flag, then the environment, then the file, then the default. The Booleans moved off store_true onto argparse.BooleanOptionalAction with default=None, because store_true cannot express "explicitly off" because absent and deliberately-false both read as False. None now means defer to config, and each has a real negative form, so --no-restore-potfile genuinely overrides a config that enables it.

--nightly is the one you'll notice, since the update channel is a persistent setting now (update_channel, default main, and it validates against a closed set of main and nightly-dev).

Two of these got renamed within a single release cycle, and the reason is worth stealing. DEBUG and UPDATE_CHANNEL became HATE_CRACK_DEBUG and HATE_CRACK_UPDATE_CHANNEL, because an ambient export DEBUG=1 in somebody's shell profile would otherwise silently enable debug logging, and debug logging writes cracked plaintexts to disk. Namespace your environment variables. A generic name is a loaded gun in a shared shell.

Can You Trust the Tags?

If you contribute, or you just want to know whether a release is worth pulling:

CI runs ruff, ty, and pytest on Python 3.13 for every pull request and every push to main or nightly-dev, plus bandit against a baseline so only new findings fail, and pip-audit for known CVEs. The nightly-dev trigger is not decoration. Without it, pushes to the branch that cuts every release candidate would run no CI at all, and the nightly tag workflow gates on it. Tagging is gated on it passing, so a release can only be cut from a commit CI validated, regardless of whether the author had local hooks installed.

The suite is 3,246 tests across 136 files. A pile of it is opt-in behind environment variables, because it wants real binaries, real API credentials, or Docker: HATE_CRACK_RUN_E2E=1 for the non-interactive subcommands end to end, HASHVIEW_TEST_LOCAL=1 with HASHVIEW_REPO=<path> to spin up a real Hashview docker stack and tear it down after, HASHMOB_TEST_REAL=1 and friends for live API calls, HATE_CRACK_RUN_LIMA_TESTS=1 for a macOS VM install. CI runs none of those, and it doesn't install hashcat either, so a few of the best tests in the project are local-only by construction.

The Part Where Auto-Tagging Got Away from Me

hate_crack is the first project where I wired up automatic version bumping. I should be straight about how that went, because the tidy description above reads better than living with it did.

The original scheme derived the bump from Conventional Commit subjects. fix: cuts a patch, feat: cuts a minor, CI gates the tag, nobody has to remember anything. The logic is sound. What I hadn't thought through is that it does exactly what I specified, every single time, with no sense of proportion whatsoever.

On July 28, I sat down and worked through a batch of PCFG and config-loading bugs. Every one was real and every fix honestly earned a patch bump under the rules I'd written. Eight releases came out of that afternoon, v2.14.4 through v2.15.1, and the 2.14 line went all the way to .9.

The dates tell the story. Weeks of nothing, then six tags on the 24, three on the 25, eight on the 28. That isn't a release cadence, it's a commit log with version numbers stapled to it. Anyone watching the repo got eight notifications for what was, from outside, one guy cleaning up after himself. Worse, ‘Which version should I be running?’ stopped having a useful answer, because the answer was always, ‘The newest one, there's a new one every ninety minutes.’ If you go count, it got worse before it got better. The 29 has 46 tags on it, but 41 of those are release candidates, because the 29 is the day candidates started existing at all. That they were candidates in the wrong format is the next section.

I'd been asking the tags to do two different jobs. One is marking a commit CI validated, so it's addressable and someone can bisect to it. The other is announcing that something is worth pulling. Those look identical right up until you cut eight in a day, at which point the second job is broken and you've trained everyone to ignore the first.

Now There are Two Tracks

Development lands on nightly-dev and gets tagged there. It merges to main cut releases. A plain git pull on main gets you releases, and --nightly or the branch gets you what's landed since.

That scheme has since been rewritten twice, and if you read the tags, you're looking at the third version of it.

Nightly tags are release candidates aimed one version forward. The tags now look like v2.33.1rc1, v2.33.1rc2, and so on, then v2.33.1 when the batch merges down. They're real PEP 440 pre-releases, so they sort where you'd want:

2.33.0  <  2.33.1rc1  <  2.33.1rc12  <  2.33.1  <  2.34.0rc1  <  2.34.0

Getting that ordering right took two failed attempts, and both failures are ones you could reproduce. The first scheme tagged v2.19.0-rc.N, aiming candidates at the version the branch was currently on rather than the one it was heading for, so every candidate sorted below the release it was a candidate for. The second scheme fixed that by dropping the pre-release marker entirely and cutting ordinary final versions, which fixed the sorting by making every nightly outrank the last real release. The answer wasn't to abandon pre-releases. It was to aim them one version forward.

The target can move mid-cycle. The first feat to land shifts the batch from X.Y.(Z+1) to X.(Y+1).0, and candidate numbering restarts. That's intended, because the number is supposed to name what the batch would ship as today, not what it looked like when it opened.

The bump comes from what's actually in the batch, and the policy lives in Python.nightly-dev cuts candidates, main promotes the same target to its final. Any feat since the last release means the batch is heading for a minor, and a batch of nothing but fixes, docs, and chores is heading for a patch. Roughly 70 lines of cut -d. and $((minor + 1)) duplicated across two workflows went in the bin, replaced by tools/next_version.py, which both workflows call. Everything above the git boundary in it is pure and unit-tested. Release policy expressed in YAML is policy that cannot be tested, and it was wrong twice.

Which means a docs-only merge to main still cuts a release, and I'm at peace with that. A merge to main is an explicit release event, and I'd rather have a boring version number than a special case.

It Ain't Much

The major component is never bumped automatically. A ! subject or a BREAKING CHANGE: footer counts as a feature and cuts a minor, because an automatic major is an irreversible published mistake waiting on one mistyped subject line. A major stays a human tagging and pushing it by hand.

One non-obvious detail in there that took a bug to find. The baseline is the highest final tag in the whole repository, deliberately not restricted to tags reachable from HEAD. main's release tag can sit on a commit that the nightly-dev tip doesn't contain, and a reachability-restricted lookup then computes the next nightly from a stale baseline and hands out a version below the release that already shipped.

Two traps in here are worth stealing if you build something similar. A workflow_run trigger checks out the tip of the default branch by default, not the commit whose CI run just finished, so a naive version of this validates one commit and tags a different one. Pin ref to github.event.workflow_run.head_sha. The nightly workflow has to exist as a byte-identical copy on both branches, because GitHub only dispatches workflow_run for workflows present on the default branch. I verified that one the hard way. The CI passed on nightly-dev and the workflow simply did not run.

There's also a guaranteed red window baked into the design. cz bump fails with [NO_VERSION_SPECIFIED] on any nightly-dev commit whose pyproject.toml lacks the [tool.commitizen] block, which is exactly the gap between a workflow change landing on main and the next merge down. The error says nothing about the cause. If you see it, merge main into nightly-dev.

Reconciling Tags Across Parallel Worktrees

Here's the part that took me longest to understand, and it only shows up if you work the way I do. I run several git worktrees at once, one per task, so a few changes are in flight in parallel rather than queued behind each other.

No version string is hardcoded anywhere in the source. pyproject.toml declares dynamic = ["version"] and hands the job to setuptools-scm, which derives it from git describe at build time. That's the right design and I'd do it again. It also means the version is not a property of your code. It's a property of your refs.

Those two facts collide in a specific way. Tags live in the shared common git directory, so every worktree sees the same tag set, but git describe answers from whichever tags are reachable from that worktree's HEAD. Two worktrees branched from different points on main, building at the same moment, legitimately derive different versions.

Then, add eight tags in an afternoon. A worktree I created before lunch derives its base version from a tag three releases have since superseded, and nothing in that worktree knows it. The fix is git fetch --tags, which is easy once you know and invisible until you don't. The sharp edge is the shared ref store cutting the other way. A fetch in one worktree changes the version every other worktree derives, without touching a single file in any of them. Watching a version number change between two builds of a byte-identical tree is a genuinely disorienting thing to debug the first time.

However, there's a wrinkle that makes it easier to miss. hate_crack/__init__.py reads the version out of installed package metadata and deliberately strips the suffixes for display, so 2.5.1.post1.dev0 prints as 2.5.1. That's the correct call for a user-facing banner, because nobody wants build metadata in their terminal. It also throws away exactly the part that distinguishes two builds from two different worktrees. Both announce the same clean version while containing different code.

None of that is a setuptools-scm bug, and splitting the tracks didn't fix it. It's what you sign up for when the version comes from git instead of a file. Refs need reconciling the same way code does. What I do about it is fetch tags before trusting any version a worktree reports, and never compare .dev distances across two branches, because those numbers only mean anything within a single line of history.

The Name on PyPI is a Trap

pip install hate-crack does not install hate_crack, and it never will.

The name is a placeholder I published to keep it from being squatted, because a package squatted under that name would land on hosts holding client hash material and API credentials. There's a real threat model there and it isn't hypothetical.

The implementation amused me enough to write down. packaging/pypi-placeholder/ holds a PEP 517 backend that delegates build_sdist to setuptools and raises from build_wheel, prepare_metadata_for_build_wheel, and both editable variants. Since pip reaches one of those on every install, everyone who tries gets the message:

hate_crack is not installable from PyPI. The name on the index is a
placeholder held to keep it from being squatted; there is no package behind
it. Install from source instead:
    git clone https://github.com/trustedsec/hate_crack
    cd hate_crack
    make install

It's version 0.0.0, classified Development Status :: 7 - Inactive, declares no scripts, and ships no modules but the backend itself. It deliberately does not set requires-python, because pinning that makes pip report "no matching distribution" on older interpreters, which reads like a version problem instead of "this package does not exist."

Publishing is manual workflow_dispatch only, over PyPI Trusted Publishing with no stored token, and a test globs every workflow in the repo to assert none of the others reference it. An upload guard checks the artifact before it goes: Exactly one sdist, the right filename, Version: 0.0.0 and the word "placeholder" in PKG-INFO, and no .py file in the archive other than the backend.

Tests That Could Not Fail

Two rounds of test work since 2.17 were really the same lesson twice, so they belong together.

Four Hashview tests were passing without doing anything. Each took a fixture that holds patch("requests.Session") open for the whole test body, then built its own client inside the test, so that client's session was a MagicMock and no request ever left the process. The worst of them, test_get_hashfiles_by_type_success, asserted isinstance(result, list) against a method whose own except Exception: return [] satisfies that assertion perfectly. Total failure and complete success were indistinguishable.

Four of Them.

The fix was splitting each into a mocked test and a real one gated behind HASHVIEW_TEST_REAL, with credentials from the environment only, asserting the session is a genuine requests.Session. I deliberately did not keep the old "real if possible, otherwise mock" fallback, because a green dot never said which branch ran, and that is what hid this for as long as it did. The helper that read config.json to build a client got deleted with its last caller, since a plain pytest run could otherwise reach a developer's real Hashview instance.

The guard on the live helper then needed its own fix. It checked isinstance(session, requests.Session) first, but patch("requests.Session") replaces the class too, so at the exact moment the guard should fire, it instead raised TypeError: isinstance() arg 2 must be a type. It checks against Mock first now and says what's actually wrong.

The other round was the mask generator, and the same shape shows up. Every existing test compared _mask()'s output against a hand-written expected string, which only proves the function agrees with my own belief about what ?l and ?d and ?s mean. I made hashcat the judge instead. I hashed a short synthetic plaintext, handed hashcat the mask _mask() produced, and asserted hashcat to recover it. A negative control asserts a deliberately wrong mask does not crack, so a potfile hit or an output-format change can't make the positive cases pass vacuously, and --potfile-disable is passed for the same reason.

That oracle immediately found a real bug. Masks were character-oriented and hashcat's are byte-oriented, so ab²x is four characters and five UTF-8 bytes, and no four-position mask can describe it under any charset. Non-ASCII passwords produced masks that were wrong in both charset and length, and those masks went straight into an LLM prompt as if they were usable. Non-ASCII passwords are excluded from the mask statistics now, with the excluded count reported alongside the eligible one so a corpus that's 30% non-ASCII doesn't quietly understate every share.

That oracle immediately found a real bug. Masks were character-oriented and hashcat's are byte-oriented, so ab²x is four characters and five UTF-8 bytes, and no four-position mask can describe it under any charset. Non-ASCII passwords produced masks that were wrong in both charset and length, and those masks went straight into an LLM prompt as if they were usable. Non-ASCII passwords are excluded from the mask statistics now, with the excluded count reported alongside the eligible one so a corpus that's 30% non-ASCII doesn't quietly understate every share.

One of those tests exists purely to pin a fact the fix rests on: it runs hashcat --stdout -a 3 ?a, counts the lines, and asserts 95. If ?a ever covers more than the printable ASCII set, the whole non-ASCII mask problem changes shape and somebody needs to revisit this.

There's a third one now, and it's the worst of the three because the damage escaped the test that caused it.

hate_crack/main.py sets its own _path_ so it can be treated as a package when loaded standalone. Harmless-looking, right? The side effect is that a string-target patch through it, mock.patch("hate_crack.main.llm.generate_candidates", ...), gets resolved with pkgutil.resolve_name, and resolve_name imports each dotted prefix as a real module instead of following the attribute main.py already bound. So, that patch imports hate_crack.main.llm as a second, independent execution of llm.py: a distinct module object, with distinct classes. Then the import machinery rebinds hate_crack.main's llm attribute to the duplicate.

Mock's teardown restores the patched attribute on the duplicate. It has no way to know the rebinding happened, so the duplicate stays bolted to hate_crack.main.llm for every test that runs after it. This was confirmed live before the fix: hate_crack.main.llm.CloudDestinationRefused is not hate_crack.llm.CloudDestinationRefused, which means an except llm.CloudDestinationRefused somewhere else in main.py silently stops catching. One test's setup quietly breaks exception identity for the rest of the session, and nothing fails at the point of damage.

If you take one thing from this section, make it the differential shape. Both fixes replaced "assert the code agrees with what I wrote down" with "assert the code agrees with something that isn't me." That's the same instinct that runs through Part 2, where the only thing that ever settled an argument about hashcat rule semantics was hashcat.

Where to Start

If you're upgrading and you only do five things:

•Run it once and read the two config-source lines it prints. That tells you which files are actually in effect, which is not always the ones you think.

• Move your credentials to .env, or let the migration do it and then read the .pre-split.bak diff. The warnings will nag you every run until you do.

• Diff your config.json against config.json.example. Three dead keys came out and defaults moved, including the context window (now OLLAMA_NUM_CTX in .env) from 2048 to 8192, and hcatPotfilePath to auto.

• Set OLLAMA_NO_CLOUD=1 if you touch the LLM attack on client data. It costs you nothing unless you meant to send it offsite, and it checks the destination now, not just the model name.

• Set update_channel once instead of typing --nightly forever.

That's the pair of posts this one hangs off. Part 1 is the attacks. Part 2 is the rule engine.  This half is the stuff nobody reads until it's already wrong.

And if you've got a pre-push hook that stashes your working tree: your guard rails run after the commit exists. Mine did too, right up until the afternoon they didn't.