commit 957a875a8227e402bba871eae1931886c168f112
parent cc6ab431037cba77c880c91dec92916c900b42c7
Author: DAEMON <zer0sec.xp@icloud.com>
Date: Thu, 8 Oct 2026 18:33:11 +0100
feat(pentest): spec and implementation plan for the offensive toolkit
Diffstat:
2 files changed, 882 insertions(+), 0 deletions(-)
diff --git a/docs/superpowers/plans/2026-10-08-pentest-toolkit.md b/docs/superpowers/plans/2026-10-08-pentest-toolkit.md
@@ -0,0 +1,503 @@
+# Pentest Toolkit Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Build a Kali-equivalent, modular, toggleable offensive toolkit into the NixDaemon flake, with an HTB workflow layer (`htbtarget` / `htbvpn` / `htbtime`) and a multi-arch payload staging tree.
+
+**Architecture:** Dendritic flake-parts. Each tool category declares one `flake.pentestPackages.<cat>` function (`pkgs -> [package]`); a `mkCategory` helper in `modules/features/pentest/_sets.nix` derives the gated `flake.nixosModules.pentest-<cat>`, a `perSystem.devShells.pentest-<cat>`, and a `perSystem.checks.pentest-<cat>` smoke test from that single list. Tools install to `environment.systemPackages` so they work under `sudo`. Privileged user commands follow the repo's existing `fan-ec` / `fan` split: a fixed store script plus a scoped NOPASSWD sudo rule.
+
+**Tech Stack:** Nix (flakes, flake-parts, import-tree), NixOS 26.11, home-manager, `pkgsCross.mingwW64` for Windows binaries, zsh `precmd` for cross-terminal state, systemd template units for VPN.
+
+**Spec:** `docs/superpowers/specs/2026-10-08-pentest-toolkit-design.md`
+
+## Global Constraints
+
+- Every `modules/**/*.nix` is a flake-parts module; any path containing `/_` is **not** auto-imported (use for plain helper/derivation files).
+- Modules reference each other **by name** through `self.nixosModules.*` / `self.homeModules.*`, never by path.
+- Tool packages go to `environment.systemPackages`, never `home.packages` (spec D1 — `sudo nmap -sS` must work).
+- Use native NixOS options where they exist: `programs.wireshark.enable`, `programs.proxychains.enable` (spec D5).
+- Master switch `daemon.pentest.enable`, default `false`. Default-on categories: `recon ad bloodhound web pivot crack shells wordlists payloads python gui`. Default-off: `dfir reversing wireless cloud osint mobile`.
+- System is `x86_64-linux`; the only host is `nixosConfigurations.nixos`.
+- Every file opens with a `# modules/path/file.nix — <purpose>` comment block in the existing house style (see `modules/home/fan.nix`).
+- Commit messages end with `Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`.
+- Verification command for any module task: `nix build .#checks.x86_64-linux.pentest-<cat>` then `nix flake check`.
+
+## Review Focus
+
+Five failure modes the spec implies but does not pin to a test. Each has a test added to the owning task.
+
+1. **impacket suffix-free aliases shadowing real commands** (Task 3). `impacket` ships `split.py`, `ping.py`, `net.py`, `reg.py`, `services.py`, `attrib.py`, `smbclient.py`. Stripping `.py` would make `split -l 1000 hosts` invoke an SMB tool, and `net`/`smbclient` collide with samba once Task 5 lands. Collisions must be detected against the assembled closure at build time and exposed as `impacket-<name>` instead.
+2. **`payload-serve` binding every interface when `tun0` is absent** (Task 12). Must fail closed rather than silently publishing the payload tree to the local network.
+3. **`htbtime off` with a missing or corrupt state file** (Task 14). Must restore the NixOS default (NTP enabled) rather than leaving the clock unmanaged — including when `off` is run without a preceding `htbtime`.
+4. **`htbtarget` writing unvalidated input to `/etc/hosts`** (Task 15). Anything that is not an IPv4/IPv6 address or a DNS label must be rejected before the privileged helper runs, so no arbitrary line can land in `/etc/hosts`.
+5. **`htbvpn up` while another profile is active** (Task 13). Must stop the running unit first; two concurrent `tun` devices must be impossible.
+
+---
+
+### Task 1: Category harness, options tree, and `core`
+
+**Files:**
+- Create: `modules/features/pentest/_sets.nix` (helper, not auto-imported)
+- Create: `modules/features/pentest/options.nix`
+- Create: `modules/features/pentest/core.nix`
+- Modify: `modules/hosts/laptop/configuration.nix` (import `pentest-options` + `pentest-core`, set `daemon.pentest.enable = true`)
+
+**Interfaces:**
+- Consumes: nothing.
+- Produces:
+ - `mkCategory :: { name : String, description : String, packages : pkgs -> [package], expectedBins : [String], extraModule ? : module, default ? : Bool } -> flakePartsModule` — returns a module declaring `flake.pentestPackages.<name>`, `flake.nixosModules.pentest-<name>`, `perSystem.checks.pentest-<name>`, `perSystem.devShells.pentest-<name>`.
+ - `daemon.pentest.enable`, `daemon.pentest.<name>.enable`, `daemon.pentest.payloads.windowsArches`, `daemon.pentest.htb.vpnDir`, `daemon.pentest.htb.promptTarget`.
+ - Env: `$PAYLOADS`, `$WORDLISTS` (set by `core.nix`, values filled in by Tasks 2 and 12).
+
+- [ ] **Step 1: Write the failing check**
+
+In `_sets.nix`, `mkCategory` must produce a check derivation that fails when any expected binary is absent. Write `core.nix` declaring the category with its binaries, so the check exists before the module works:
+
+```nix
+# core.nix declares, via mkCategory:
+# name = "core";
+# packages = pkgs: with pkgs; [ ncat socat samba krb5 openldap sshpass rlwrap ];
+# expectedBins = [ "ncat" "socat" "smbclient" "kinit" "ldapsearch" "sshpass" ];
+```
+
+The check runs, for each name in `expectedBins`, `command -v <name>` inside a derivation whose `buildInputs` are the category's packages, and fails naming every binary it could not find.
+
+- [ ] **Step 2: Run the check to verify it fails**
+
+Run: `nix build .#checks.x86_64-linux.pentest-core -L`
+Expected: FAIL — `error: flake output attribute 'checks.x86_64-linux.pentest-core' does not exist` (before `_sets.nix` is written), then a missing-binary failure naming `smbclient` (samba's binary is not in `pkgs.samba`'s default output path until the correct attribute is used).
+
+- [ ] **Step 3: Implement `mkCategory` in `modules/features/pentest/_sets.nix`**
+
+A function taking the attrset above and returning a flake-parts module. The gated nixosModule body is `config = lib.mkIf (cfg.enable && cfg.${name}.enable) { environment.systemPackages = packages pkgs; }` merged with `extraModule`. Resolve `smbclient` from `pkgs.samba` (it is not a top-level attribute — confirmed absent).
+
+- [ ] **Step 4: Implement `options.nix` and `core.nix`**
+
+`options.nix` declares `flake.nixosModules.pentest-options` with the option tree from Global Constraints, plus an assertion that no `daemon.pentest.<cat>.enable` is true while `daemon.pentest.enable` is false. `core.nix` uses `mkCategory` and additionally sets `environment.sessionVariables.PAYLOADS` and `WORDLISTS` to `lib.mkDefault` placeholders that Tasks 2 and 12 override.
+
+- [ ] **Step 5: Wire into the host**
+
+Add `pentest-options` and `pentest-core` to the `imports` list in `modules/hosts/laptop/configuration.nix`, and set `daemon.pentest.enable = true;` beside the existing `daemon.desktop` block.
+
+- [ ] **Step 6: Verify the check passes and the system builds**
+
+Run: `nix build .#checks.x86_64-linux.pentest-core -L && nix build .#nixosConfigurations.nixos.config.system.build.toplevel`
+Expected: both succeed.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add modules/features/pentest/ modules/hosts/laptop/configuration.nix
+git commit -m "feat(pentest): category harness, option tree, and core module"
+```
+
+---
+
+### Task 2: Wordlists
+
+**Files:**
+- Create: `modules/features/pentest/wordlists.nix`
+
+**Interfaces:**
+- Consumes: `mkCategory` (Task 1).
+- Produces: `$WORDLISTS` pointing at a directory containing `rockyou.txt` **decompressed**; `flake.pentestPackages.wordlists`.
+
+- [ ] **Step 1: Write the failing check**
+
+The check asserts the decompressed file exists and is plain text:
+
+```nix
+expectedPaths = [ "$WORDLISTS/rockyou.txt" "$WORDLISTS/seclists" ];
+# plus: test "$(head -c2 $WORDLISTS/rockyou.txt)" != "$(printf '\037\213')" # not gzip magic
+```
+
+- [ ] **Step 2: Run the check to verify it fails**
+
+Run: `nix build .#checks.x86_64-linux.pentest-wordlists -L`
+Expected: FAIL — attribute does not exist.
+
+- [ ] **Step 3: Implement `wordlists.nix`**
+
+`packages = pkgs: [ seclists wordlists exploitdb ]`. Add a derivation that `gunzip`s `${pkgs.rockyou}`'s gzipped payload into `$out/rockyou.txt` and symlinks `${pkgs.seclists}/share/seclists` to `$out/seclists`. Set `environment.sessionVariables.WORDLISTS` to that derivation with `lib.mkForce`.
+
+- [ ] **Step 4: Verify**
+
+Run: `nix build .#checks.x86_64-linux.pentest-wordlists -L`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add modules/features/pentest/wordlists.nix
+git commit -m "feat(pentest): wordlists with decompressed rockyou and \$WORDLISTS"
+```
+
+---
+
+### Task 3: Offensive Python environment and impacket aliases
+
+**Files:**
+- Create: `modules/features/pentest/python.nix`
+- Create: `modules/features/pentest/_aliases.nix` (helper)
+
+**Interfaces:**
+- Consumes: `mkCategory` (Task 1).
+- Produces:
+ - `pentest-python` — a `python3.withPackages` interpreter with every offensive library importable.
+ - `mkSuffixFreeAliases :: { package, reserved : [String], prefix : String } -> derivation` — symlinks `bin/foo.py` to `bin/foo`, **except** names in `reserved`, which become `bin/<prefix>-foo`.
+ - `impacket` — a discovery command: no args lists all scripts via fzf with `--help` preview; `impacket <name>` execs that script.
+
+- [ ] **Step 1: Write the failing collision test**
+
+This is Review Focus #1. The check must prove that no alias shadows a command provided by any other enabled category, computed from the closure rather than hardcoded:
+
+```nix
+# checks.pentest-python asserts:
+# 1. `pentest-python -c "import impacket, certipy, pypykatz, bloodyad, lsassy, scapy, pwn"` exits 0
+# 2. `secretsdump --help` and `GetUserSPNs --help` exit 0 (aliased)
+# 3. `command -v split` resolves into coreutils, NOT impacket (collision avoided)
+# 4. `command -v ping` resolves into iputils, NOT impacket
+# 5. `impacket-split --help` and `impacket-net --help` exit 0 (prefixed instead)
+# 6. the alias derivation's build FAILS if a new upstream script
+# collides with a reserved name that is not in the prefix list
+```
+
+- [ ] **Step 2: Run the check to verify it fails**
+
+Run: `nix build .#checks.x86_64-linux.pentest-python -L`
+Expected: FAIL — attribute does not exist.
+
+- [ ] **Step 3: Implement `mkSuffixFreeAliases` in `_aliases.nix`**
+
+Glob `${package}/bin/*.py`. For each, strip `.py`; if the stripped name appears in `reserved`, emit `${prefix}-${name}` instead, otherwise emit the bare name. Build `reserved` at eval time from the binaries of `coreutils`, `iputils`, `samba`, `util-linux` and `systemd` so it tracks the real closure instead of a hand-maintained list. Fail the build listing any name that is reserved **and** absent from the emitted prefixed set.
+
+- [ ] **Step 4: Implement `python.nix`**
+
+`pentestPython = pkgs.python3.withPackages (ps: with ps; [ impacket certipy pywerview dploot masky bloodhound ldapdomaindump pypykatz bloodyad lsassy minikerberos aiowinreg ldap3 dnspython scapy pwntools pycryptodomex requests rich ])`, exposed as `pentest-python`. Per spec C2, if `withPackages` fails to resolve, fall back to listing the conflicting tools as standalone packages and keep the shared env for the libraries only — record which path was taken in the file's header comment.
+
+- [ ] **Step 5: Verify**
+
+Run: `nix build .#checks.x86_64-linux.pentest-python -L`
+Expected: PASS — including assertions 3 and 4, which prove `split` and `ping` still resolve to coreutils and iputils.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add modules/features/pentest/python.nix modules/features/pentest/_aliases.nix
+git commit -m "feat(pentest): offensive python env, impacket aliases with collision guard"
+```
+
+---
+
+### Task 4: `recon`
+
+**Files:** Create `modules/features/pentest/recon.nix`
+
+**Interfaces:** Consumes `mkCategory`. Produces `flake.pentestPackages.recon`.
+
+- [ ] **Step 1: Write the failing check** — `expectedBins = [ "nmap" "masscan" "rustscan" "naabu" "fscan" "nbtscan" "enum4linux-ng" "snmp-check" "onesixtyone" "dnsrecon" "subfinder" "amass" "httpx" "dnsx" "katana" "gowitness" "whatweb" ]`.
+- [ ] **Step 2: Run to verify it fails** — `nix build .#checks.x86_64-linux.pentest-recon -L`, expect attribute-missing.
+- [ ] **Step 3: Implement** — `packages` per spec C1 recon list, plus `thc-ipv6` and `eyewitness`. Note `snmpcheck`'s binary is `snmp-check`; resolve the real binary names rather than assuming they match attribute names.
+- [ ] **Step 4: Verify** — same command, expect PASS.
+- [ ] **Step 5: Commit** — `feat(pentest): recon and scanning module`
+
+---
+
+### Task 5: `ad`
+
+**Files:** Create `modules/features/pentest/ad.nix`
+
+**Interfaces:** Consumes `mkCategory`, Task 3's `pentest-python`. Produces `flake.pentestPackages.ad`.
+
+- [ ] **Step 1: Write the failing check** — `expectedBins = [ "nxc" "certipy" "bloodhound-python" "kerbrute" "responder" "mitm6" "coercer" "donpapi" "adidnsdump" "ldapdomaindump" "smbmap" "evil-winrm" "pypykatz" "bloodyAD" "lsassy" "dploot" "masky" "pywerview" "secretsdump" "ntlmrelayx" "GetUserSPNs" ]`. The last three come from Task 3's aliases and prove the two tasks compose.
+- [ ] **Step 2: Run to verify it fails.**
+- [ ] **Step 3: Implement** — spec C1 `ad` list. `netexec`'s binary is `nxc`; `bloodyad`'s is `bloodyAD`. Custom `krbrelayx` and `sprayhound` are deferred to Task 11 and must **not** appear in `expectedBins` yet.
+- [ ] **Step 4: Verify** — expect PASS, and additionally run `sudo -n true 2>/dev/null; sudo nmap -sS -p22 127.0.0.1` on the built system to prove spec D1 (root PATH).
+- [ ] **Step 5: Commit** — `feat(pentest): active directory module`
+
+---
+
+### Task 6: `web`
+
+**Files:** Create `modules/features/pentest/web.nix`
+
+- [ ] **Step 1: Write the failing check** — `expectedBins = [ "ffuf" "gobuster" "feroxbuster" "dirb" "nikto" "sqlmap" "commix" "wfuzz" "nuclei" "wpscan" "joomscan" "dalfox" "arjun" "jwt-cli" "jwt-hack" "mitmproxy" ]`. GUI tools (`burpsuite`, `zap`) belong to Task 10, not here.
+- [ ] **Step 2: Run to verify it fails.**
+- [ ] **Step 3: Implement** — spec C1 `web` list minus the GUI entries. Set `NUCLEI_TEMPLATES_DIR` to `${pkgs.nuclei-templates}` so `nuclei` does not try to write to `$HOME` on first run.
+- [ ] **Step 4: Verify** — expect PASS.
+- [ ] **Step 5: Commit** — `feat(pentest): web application module`
+
+---
+
+### Task 7: `pivot`, `crack`, `shells`
+
+Grouped: three identical-shape package lists over the Task 1 helper. The only non-trivial part is `programs.proxychains.enable`.
+
+**Files:** Create `modules/features/pentest/{pivot,crack,shells}.nix`
+
+- [ ] **Step 1: Write the three failing checks**
+ - pivot: `[ "ligolo-proxy" "ligolo-agent" "chisel" "socat" "proxychains4" "sshuttle" "gost" "frpc" "iodine" "stunnel" "wg" "openvpn" ]`
+ - crack: `[ "hashcat" "john" "hydra" "medusa" "crowbar" "crunch" "cewl" "hashid" "nth" ]`
+ - shells: `[ "msfconsole" "msfvenom" "pwsh" "pwncat" "rlwrap" "updog" "miniserve" "xfreerdp" "upx" ]`
+- [ ] **Step 2: Run all three to verify they fail.**
+- [ ] **Step 3: Implement the three modules** — spec C1 lists. In `pivot.nix`'s `extraModule`, set `programs.proxychains.enable = true` with `proxyDNS = true` and a `socks5 127.0.0.1 1080` default, because `/etc/proxychains.conf` is unwritable on NixOS (spec D5). Verify `ligolo-ng`'s actual binary names and `name-that-hash`'s (`nth`) before asserting them.
+- [ ] **Step 4: Verify** — all three checks PASS.
+- [ ] **Step 5: Commit** — `feat(pentest): pivoting, cracking, and shell modules`
+
+---
+
+### Task 8: Optional categories (`dfir`, `reversing`, `wireless`, `cloud`, `osint`, `mobile`)
+
+Grouped: six package lists, all `default = false`.
+
+**Files:** Create `modules/features/pentest/{dfir,reversing,wireless,cloud,osint,mobile}.nix`
+
+- [ ] **Step 1: Write the six failing checks** — binaries per spec C1. Checks must build even when the category is disabled, since they test the package list, not the system closure.
+- [ ] **Step 2: Run to verify they fail.**
+- [ ] **Step 3: Implement the six modules** — `default = false` for each. `wireless.nix`'s `extraModule` sets `programs.wireshark.enable = true` with `package = pkgs.wireshark` and adds `daemonsec` to the `wireshark` group, because the package alone cannot capture without root (spec D5). `velociraptor` is deferred to Task 11.
+- [ ] **Step 4: Verify** — `nix flake check` passes with all six disabled.
+- [ ] **Step 5: Commit** — `feat(pentest): dfir, reversing, wireless, cloud, osint, mobile modules`
+
+---
+
+### Task 9: BloodHound CE with neo4j and postgresql
+
+Separate task: the only category needing stateful services, so a reviewer could reject this while accepting Task 5.
+
+**Files:** Create `modules/features/pentest/bloodhound.nix`
+
+**Interfaces:** Produces `flake.nixosModules.pentest-bloodhound`; a `bloodhound-up` / `bloodhound-down` pair so the databases are not running on every boot.
+
+- [ ] **Step 1: Write the failing test**
+
+A NixOS VM test (`pkgs.nixosTest`) asserting that with `daemon.pentest.bloodhound.enable = true` the `neo4j` and `postgresql` units reach active, and port 7474 answers. A VM test rather than a binary check, because the deliverable is running services.
+
+- [ ] **Step 2: Run to verify it fails** — `nix build .#checks.x86_64-linux.pentest-bloodhound-vm -L`, expect attribute-missing.
+- [ ] **Step 3: Implement** — `services.neo4j.enable` and `services.postgresql.enable` (with a `bloodhound` database and user) gated on the category, plus `bloodhound-ce`. Both services set `wantedBy = []` so they start only via `bloodhound-up`, which is a `writeShellScriptBin` running `systemctl start` through a scoped NOPASSWD rule as in `fan-cli.nix`.
+- [ ] **Step 4: Verify** — VM test PASSES.
+- [ ] **Step 5: Commit** — `feat(pentest): bloodhound-ce with neo4j and postgresql`
+
+---
+
+### Task 10: GUI tools and desktop entries
+
+**Files:** Create `modules/features/pentest/gui.nix`
+
+- [ ] **Step 1: Write the failing check** — `expectedBins = [ "burpsuite" "zap" "ghidra" "wireshark" "autopsy" "cutter" "sqlitebrowser" ]`, plus an assertion that each ships a `share/applications/*.desktop` file so they appear in the launcher like Kali's menu.
+- [ ] **Step 2: Run to verify it fails.**
+- [ ] **Step 3: Implement** — the GUI set; `allowUnfree` already holds for `burpsuite`. Gate on `daemon.pentest.gui.enable` (default true per D4). Do not duplicate `wireshark`'s native option here — depend on `wireless.nix`'s and assert in the check that enabling `gui` without `wireless` still yields a usable `wireshark` by setting `programs.wireshark.enable` in whichever module is enabled, using `lib.mkDefault` to avoid a conflict.
+- [ ] **Step 4: Verify** — check PASSES; `nix flake check` passes with both `gui` and `wireless` enabled (proving no option conflict).
+- [ ] **Step 5: Commit** — `feat(pentest): gui tools with desktop entries`
+
+---
+
+### Task 11: Missing-tool derivations
+
+**Files:**
+- Create: `modules/features/pentest/_pkgs/default.nix` (an attrset the categories consume)
+- Create: `modules/features/pentest/_pkgs/{krbrelayx,sprayhound,velociraptor,ropper,lse,peass,sharpcollection,potato,printer}.nix`
+- Modify: `ad.nix`, `dfir.nix`, `reversing.nix` to pull from `_pkgs` and extend their `expectedBins`
+
+**Interfaces:**
+- Produces: `pentestPkgs :: attrset` of derivations, consumed by Task 12's payload tree and by the category modules.
+
+- [ ] **Step 1: Write the failing check**
+
+`checks.pentest-pkgs` asserts each derivation builds and, for script collections, that a named file exists in the output (e.g. `${peass}/linpeas.sh`, `${sharpcollection}/NetFramework_4.7_Any/Rubeus.exe`). Prebuilt Windows artefacts must additionally report as PE: `file` output contains `PE32`.
+
+- [ ] **Step 2: Run to verify it fails.**
+- [ ] **Step 3: Obtain real hashes, then implement**
+
+Hashes cannot be guessed. For each source run `nix-prefetch-url --unpack <url>` (or `nurl <repo>`) and paste the result. Linux/Python tools (`krbrelayx`, `sprayhound`, `ropper`, `lse`, `velociraptor`) are `fetchFromGitHub` + `buildPythonApplication` or `writeShellApplication` wrappers. Prebuilt sets (`peass`, `sharpcollection`, the potato family, the printer family) are `fetchFromGitHub` / `fetchurl` of release assets installed verbatim — per spec D3, building .NET offline is out of scope.
+
+- [ ] **Step 4: Extend the consuming categories** — add `krbrelayx`/`sprayhound` to `ad.nix`, `velociraptor` to `dfir.nix`, `ropper` to `reversing.nix`, with their binaries appended to those `expectedBins`.
+- [ ] **Step 5: Verify** — `nix build .#checks.x86_64-linux.pentest-pkgs -L` PASSES, and the three amended category checks still pass.
+- [ ] **Step 6: Commit** — `feat(pentest): derivations for tools absent from nixpkgs`
+
+---
+
+### Task 12: Payload tree, cross-compiled binaries, and `payload-serve`
+
+**Files:**
+- Create: `modules/features/pentest/payloads.nix`
+- Create: `modules/features/pentest/_payloads.nix` (the tree-assembly function)
+
+**Interfaces:**
+- Consumes: `pentestPkgs` (Task 11).
+- Produces: `$PAYLOADS` (overriding Task 1's placeholder with `lib.mkForce`); `payload-serve` with flags `[port]`, `--smb`, `--list`.
+
+- [ ] **Step 1: Write the failing checks**
+
+Two assertions, the second being Review Focus #2:
+
+```nix
+# 1. structure: every path in the spec C3 tree exists, and
+# file $PAYLOADS/windows/amd64/creds/mimikatz-2.2.0.exe contains "PE32+"
+# file $PAYLOADS/linux/amd64/agents/ligolo-agent contains "ELF 64-bit"
+# file $PAYLOADS/linux/arm64/agents/ligolo-agent contains "ARM aarch64"
+# 2. payload-serve refuses to start when no tun interface exists:
+# ip link show tun0 absent => exit 2, message naming htbvpn,
+# and NOTHING listening on the requested port afterwards
+```
+
+- [ ] **Step 2: Run to verify it fails.**
+- [ ] **Step 3: Implement the cross-builds**
+
+In `_payloads.nix`, build Windows artefacts as `pkgs.pkgsCross.mingwW64.<tool>` (verified available for `mimikatz`, `ligolo-ng`, `netexec`) and Linux arm64 as `pkgs.pkgsCross.aarch64-multiplatform.<tool>`. Assemble the spec C3 tree with `runCommand` + `lib.fileset`, naming versioned duplicates by version (`mimikatz-2.2.0.exe`) rather than letting them contend for one name.
+
+- [ ] **Step 4: Implement `payload-serve`**
+
+Resolve the serve address from the first `tun*` interface. If none exists, exit 2 naming `htbvpn up` — never fall back to `0.0.0.0`, which would publish the tree to the LAN. `--smb` runs impacket's `smbserver` bound to the same address; `--list` prints `tree $PAYLOADS`.
+
+- [ ] **Step 5: Verify** — both checks PASS. Confirm assertion 2 by running `payload-serve 8000` with no VPN up and checking `ss -tlnp` shows nothing new.
+- [ ] **Step 6: Commit** — `feat(pentest): multi-arch payload tree and payload-serve`
+
+---
+
+### Task 13: `htbvpn`
+
+**Files:**
+- Create: `modules/features/pentest/vpn.nix`
+
+**Interfaces:**
+- Produces: `htbvpn@.service` (systemd template); `htbvpn` with subcommands `list|up|down|status`; `htbip`.
+
+- [ ] **Step 1: Write the failing test**
+
+A `nixosTest` with a dummy OpenVPN profile, asserting the Review Focus #5 behaviour:
+
+```
+1. htbvpn up profileA => htbvpn@profileA active, tun0 has an address
+2. htbvpn up profileB => profileA STOPPED first; exactly one tun device exists
+3. htbvpn down => no htbvpn@* unit active, tun0 gone
+4. htbvpn up missingProfile => exit 2, lists available profiles, no unit started
+```
+
+- [ ] **Step 2: Run to verify it fails.**
+- [ ] **Step 3: Implement**
+
+`systemd.services."htbvpn@"` runs `openvpn --config ${vpnDir}/%i.ovpn` with `Restart=no`. `htbvpn up` resolves the profile, stops any active `htbvpn@*` instance, then starts the new one through a NOPASSWD sudo rule scoped to `systemctl {start,stop,restart} htbvpn@*` only — mirroring `fan-cli.nix`. Create `${vpnDir}` via `systemd.tmpfiles` and leave profiles un-managed (spec C5).
+
+- [ ] **Step 4: Verify** — the VM test PASSES, all four assertions.
+- [ ] **Step 5: Commit** — `feat(pentest): htbvpn via systemd template unit`
+
+---
+
+### Task 14: `htbtime`
+
+**Files:**
+- Create: `modules/features/pentest/time.nix`
+
+**Interfaces:**
+- Produces: `htb-time` (root helper, `environment.systemPackages` + scoped NOPASSWD rule); subcommands `<target>|off|status`.
+
+- [ ] **Step 1: Write the failing test**
+
+A `nixosTest` covering the round trip and Review Focus #3:
+
+```
+1. record timedatectl output
+2. htb-time 10.0.0.1 => NTP disabled, timesyncd inactive, state file written
+3. htb-time off => timedatectl output matches step 1 exactly
+4. rm the state file; htb-time off
+ => NTP ENABLED (the NixOS default), exit 0, warning printed
+ -- must never leave the clock unmanaged
+5. htb-time <unreachable>
+ => exit nonzero, warning naming `htbtime off`, NTP still disabled
+ (deliberate: spec C6 says do not silently half-apply)
+```
+
+- [ ] **Step 2: Run to verify it fails.**
+- [ ] **Step 3: Implement**
+
+`ntpdate` comes from `${pkgs.ntp}/bin/ntpdate` — it is **not** a top-level package (confirmed). Record prior state to `/var/lib/htb-time/state`: whether `systemd-timesyncd` was active, `timedatectl show -p NTP`, and whether `chrony`/`ntpd` were running. Sync with `ntpdate -u <target>`, falling back to `${pkgs.chrony}/bin/chronyd -q` when the DC refuses. On `off` with no or unparseable state, default to `timedatectl set-ntp true` and say so. Print the resulting offset and warn that a large skew breaks TLS, so `nix` and `git` may fail.
+
+- [ ] **Step 4: Verify** — the VM test PASSES, all five assertions, especially 4.
+- [ ] **Step 5: Commit** — `feat(pentest): htbtime, conflict-aware clock skew control`
+
+---
+
+### Task 15: `htbtarget`, cross-terminal state, and engagement scaffolding
+
+**Files:**
+- Create: `modules/home/htb.nix`
+- Create: `modules/home/htb-shell.nix`
+- Create: `modules/features/pentest/hosts.nix` (the `/etc/hosts` root helper)
+- Modify: `modules/home/default.nix` (import `htb` and `htb-shell` by name)
+
+**Interfaces:**
+- Consumes: `htbvpn`/`htbip` (Task 13), `htbtime` (Task 14).
+- Produces: `htbtarget [IP [FQDN]] | clear`; exported `$TARGET`, `$RHOST`, `$IP`, `$BOX`; `htb new <box>`; `htb ls`; `htb-hosts` root helper.
+
+- [ ] **Step 1: Write the failing tests**
+
+Two parts. First the cross-terminal guarantee from spec C4:
+
+```
+1. shell A: htbtarget 10.10.11.5
+2. shell B (already running, new prompt): $TARGET == 10.10.11.5
+```
+
+Second, Review Focus #4 — validation before the privileged helper runs:
+
+```
+3. htbtarget "10.10.11.5" => accepted
+4. htbtarget "not an ip" => exit 2, /etc/hosts unchanged
+5. htbtarget "10.10.11.5" $'x\n1.2.3.4 evil' => exit 2, /etc/hosts unchanged
+6. htbtarget 10.10.11.5 dc01.vintage.htb => exactly one marked block in
+ /etc/hosts; running twice does
+ not duplicate it
+7. htbtarget clear; htbtime => exit 2, message naming `htbtarget`
+ as the command that sets a target
+```
+
+- [ ] **Step 2: Run to verify they fail.**
+- [ ] **Step 3: Implement the state file and shell hook**
+
+State in `$XDG_STATE_HOME/htb/{target,name,vpn}`. In `htb-shell.nix`, add a zsh `precmd` function that re-reads those files and exports `$TARGET`, `$RHOST`, `$IP`, `$BOX`. Wire it through `programs.zsh.initContent` so it coexists with the dotfiles' ZDOTDIR tree (`modules/home/shell.nix`) rather than overwriting it. When `daemon.pentest.htb.promptTarget`, add a starship custom module showing `$TARGET`.
+
+- [ ] **Step 4: Implement validation and the `/etc/hosts` helper**
+
+Validate the IP with a strict IPv4/IPv6 match and the FQDN against `^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$` **before** invoking `htb-hosts`. `htb-hosts` rewrites only the region between `# BEGIN htb` and `# END htb` markers, so repeated calls replace rather than append. Scoped NOPASSWD rule as in `fan-cli.nix`.
+
+- [ ] **Step 5: Implement `htb new` and `htb ls`**
+
+`htb new <box>` creates `~/htb/<box>/{nmap,loot,creds,www,exploit}` plus `notes.md` from a template, sets the target when given one, and prints the path. `htb ls` lists `~/htb/*` newest first.
+
+- [ ] **Step 6: Verify** — all six assertions pass; confirm assertion 2 by hand in two live terminals.
+- [ ] **Step 7: Commit** — `feat(pentest): htbtarget cross-terminal state and engagement scaffolding`
+
+---
+
+### Task 16: Cheat card, `pentest-update`, devshells, and docs
+
+**Files:**
+- Create: `modules/home/cheats/pentest.md`
+- Modify: `modules/home/cheats.nix` (add `"pentest"` to `cards`)
+- Create: `modules/features/pentest/devshells.nix`
+- Create: `modules/features/pentest/update.nix`
+- Modify: `README.md` (a `Pentest` row in the "What runs" table and a tree entry)
+
+- [ ] **Step 1: Write the failing checks**
+
+`pentest-cheat --list` prints exactly `recon ad pivot transfer crack web dfir htb`; `nix develop .#pentest --command nxc --version` exits 0; `pentest-update --dry-run` exits 0 and changes no file.
+
+- [ ] **Step 2: Run to verify they fail.**
+- [ ] **Step 3: Implement the cheat card** — sections per the check, in the existing `modules/home/cheats/*.md` format. Document the two spec limitations explicitly: `$TARGET` refreshes at the next prompt, and `htbtime` skew breaks TLS.
+- [ ] **Step 4: Implement `devshells.nix`** — `devShells.pentest` as the union of every `flake.pentestPackages.*`, plus the per-category shells already produced by `mkCategory`.
+- [ ] **Step 5: Implement `pentest-update`** — re-pins every `_pkgs/` derivation via `nix-prefetch-url`/`nurl`, rewrites hashes in place, prints a diff, never commits. `--dry-run` only reports.
+- [ ] **Step 6: Verify** — all three checks PASS; `nix flake check` passes whole-flake.
+- [ ] **Step 7: Commit** — `feat(pentest): cheat card, devshells, pentest-update, README`
+
+---
+
+## Done when
+
+- `nix flake check` passes with every default-on category enabled.
+- `sudo nmap -sS` works (spec D1).
+- `split` and `ping` still resolve to coreutils and iputils (Review Focus #1).
+- `payload-serve` refuses to bind without a tun device (Review Focus #2).
+- `htbtime off` restores the clock from a missing state file (Review Focus #3).
+- `htbtarget` rejects malformed input before touching `/etc/hosts` (Review Focus #4).
+- `htbvpn up` never leaves two tun devices (Review Focus #5).
+- `$TARGET` set in one terminal appears in another at its next prompt.
diff --git a/docs/superpowers/specs/2026-10-08-pentest-toolkit-design.md b/docs/superpowers/specs/2026-10-08-pentest-toolkit-design.md
@@ -0,0 +1,379 @@
+# Pentest toolkit for NixDaemon — design
+
+**Date:** 2026-10-08
+**Status:** awaiting review
+**Author:** daemon-sec, with Claude
+
+## Intent
+
+Turn this laptop into a Kali-equivalent offensive workstation, declaratively,
+for **HackTheBox CPTS prep**. Everything comes from the flake: no `curl | bash`,
+no tools living outside the store except the ones that must be mutable
+(VPN profiles, loot).
+
+Success criteria:
+
+1. `nh os switch` is the only install step. A fresh machine reaches the same kit.
+2. Tools are grouped into independently toggleable modules (`daemon.pentest.*`),
+ so a category can be dropped in one line.
+3. Tools are reachable with no ceremony: `nxc`, `ffuf`, `secretsdump` work in any
+ terminal, including under `sudo`.
+4. Windows/Linux/macOS agent binaries and offensive payloads are staged in one
+ predictable tree, servable to a target in one command.
+5. The HTB loop — connect VPN, set target, fix clock skew, enumerate — is
+ driven by a handful of commands that share state across terminals.
+
+Non-goals: C2 infrastructure beyond what ships in nixpkgs; anything targeting
+hosts outside HTB/authorised labs; Kali's full 600-package catalogue (curated
+instead); a second non-NixOS VM.
+
+## Decisions
+
+These were delegated to me with "whatever is best for NixOS". Recorded with
+reasoning so they can be revisited.
+
+### D1 — Tools install to `environment.systemPackages`, not `home.packages`
+
+**The single most important NixOS-specific decision.** Half this toolkit needs
+root: `nmap -sS` (raw sockets), `responder`, `mitm6`, `bettercap`, `tcpdump`,
+`masscan`. home-manager packages are on the *user's* PATH only, so
+`sudo nmap -sS` would fail with `command not found` — the classic
+Kali-refugee-on-NixOS trap. System packages behave like Kali's `/usr/bin`.
+
+Consequence: modules live in `modules/features/pentest/` as
+`flake.nixosModules.pentest-*`. Only genuinely user-scoped things (the `htb*`
+commands, the cheat card, shell integration) are home modules.
+
+### D2 — Everything on PATH, per-category toggles, devshells as a bonus
+
+Friction matters more than PATH tidiness during CPTS. 1.8TB free, and the kit
+comes from the binary cache rather than compiling. Toggles are the escape hatch.
+
+`perSystem.devShells.pentest` (plus one per category) is declared anyway — ~10
+lines, and it makes the kit portable: `nix develop github:…#pentest` on any
+machine with Nix.
+
+### D3 — Missing tools become pinned Nix derivations; a script re-pins them
+
+~20 tools aren't in nixpkgs. Each gets a derivation in `_pkgs/` with a pinned
+`rev`/`hash`, so the toolkit rolls back with the system generation.
+`pentest-update` re-pins to latest on demand.
+
+Where a tool declares Windows support in nixpkgs, build it **from source** with
+`pkgsCross.mingwW64` rather than downloading a release blob. Verified available
+for `mimikatz`, `ligolo-ng`, `netexec`. Genuine provenance, no trusting strangers.
+
+Prebuilt .NET binaries (SharpCollection, Ghostpack, potato family) are the
+exception: building .NET offline in Nix is brittle, so those are pinned release
+assets with recorded hashes.
+
+### D4 — Defaults: CPTS core + heavy GUI on; the rest written but off
+
+On: `recon ad bloodhound web pivot crack shells wordlists payloads python gui`.
+Off: `dfir reversing wireless cloud osint mobile`.
+Keeps the first build finishable in one sitting; each is one line to enable.
+
+### D5 — Native NixOS options over bare packages, wherever they exist
+
+Not cosmetic — on NixOS the package alone is often *unusable*:
+
+| Option | Why the package alone is not enough |
+|---|---|
+| `programs.wireshark.enable` | Creates the `wireshark` group and the `dumpcap` capability wrapper. Without it you cannot capture without full root. |
+| `programs.proxychains.enable` | Manages `/etc/proxychains.conf`. `/etc` is read-only on NixOS, so the Kali habit of editing that file by hand simply fails. This module is what makes `proxychains` work at all. |
+| `security.wrappers` | Capabilities (`cap_net_raw`) instead of setuid, for raw-socket tools. |
+| `services.neo4j` + `services.postgresql` | BloodHound CE needs both; there is no `services.bloodhound` in nixpkgs, so this is wired by hand. |
+
+`rockyou.txt` ships gzipped in the store, so `wordlists.nix` produces a
+decompressed derivation and exports `$WORDLISTS`.
+
+## Architecture
+
+Dendritic, matching the existing repo: every file under `modules/` is a
+flake-parts module declaring the outputs it owns, cross-referenced by **name**
+through `self`, never by path. Switches live in one `options.nix`; each category
+is gated with `lib.mkIf`.
+
+```text
+modules/features/pentest/
+├── options.nix daemon.pentest.{enable,<cat>.enable} + assertions
+├── core.nix always-on base + $PAYLOADS/$WORDLISTS env + /etc/hosts helper
+├── python.nix the combined offensive Python env + suffix-free aliases
+├── recon.nix ad.nix bloodhound.nix web.nix pivot.nix crack.nix
+├── shells.nix wordlists.nix payloads.nix gui.nix
+├── dfir.nix reversing.nix wireless.nix cloud.nix osint.nix mobile.nix
+├── vpn.nix htbvpn@.service template + scoped sudo rule
+├── time.nix htb-time privileged helper + scoped sudo rule
+├── devshells.nix perSystem.devShells.pentest and per-category
+└── _pkgs/ ~20 pinned derivations (paths with /_ are skipped by import-tree)
+
+modules/home/
+├── htb.nix htbtarget htbvpn htbtime htbip htb new — the user-facing CLI
+├── htb-shell.nix zsh precmd integration: $TARGET/$BOX live in every terminal
+└── cheats/pentest.md `pentest-cheat`, in the existing cheat-card style
+```
+
+### Option tree
+
+```nix
+daemon.pentest = {
+ enable = mkEnableOption "the offensive toolkit"; # master switch, default false
+ <category>.enable = ... ; # per D4 defaults
+ payloads.windowsArches = [ "amd64" "x86" ]; # which .exe variants to build
+ htb.vpnDir = "~/.config/htb/vpn"; # where .ovpn profiles live
+ htb.promptTarget = true; # show $TARGET in the prompt
+};
+```
+
+Every category also implies `core`, so `$PAYLOADS`/`$WORDLISTS` always exist.
+An assertion fires if a category is enabled while `daemon.pentest.enable` is false.
+
+## Components
+
+### C1 — Tool modules
+
+Curated from what was verified present in the pinned nixpkgs (2026-10-08).
+
+- **recon** — nmap masscan rustscan naabu fscan nbtscan enum4linux-ng snmpcheck
+ onesixtyone thc-ipv6 dnsrecon subfinder amass httpx dnsx katana gowitness
+ eyewitness whatweb
+- **ad** — netexec impacket certipy bloodhound-py kerbrute responder mitm6
+ coercer donpapi adidnsdump ldapdomaindump smbmap evil-winrm pypykatz bloodyad
+ lsassy dploot masky pywerview krb5 openldap samba(smbclient) sshpass
+ · custom: krbrelayx sprayhound
+- **bloodhound** — bloodhound-ce with neo4j + postgresql wired up
+- **web** — ffuf gobuster feroxbuster dirb nikto sqlmap commix wfuzz nuclei
+ nuclei-templates wpscan joomscan dalfox arjun jwt-cli jwt-hack mitmproxy
+ burpsuite zap
+- **pivot** — ligolo-ng chisel socat proxychains-ng(native opt) sshuttle gost
+ frp iodine pingtunnel stunnel wireguard-tools openvpn
+- **crack** — hashcat hashcat-utils john hydra medusa crowbar crunch cewl hashid
+ name-that-hash
+- **shells** — metasploit msfpc powershell pwncat rlwrap updog miniserve
+ freerdp remmina rdesktop upx
+- **wordlists** — seclists wordlists rockyou(decompressed) exploitdb
+- **dfir** — volatility3 sleuthkit autopsy yara capa chainsaw hayabusa exiftool
+ foremost testdisk chntpw binwalk · custom: velociraptor
+- **reversing** — ghidra radare2 rizin cutter gef pwntools one_gadget pwninit
+ checksec flare-floss retdec patchelf
+- **wireless** — aircrack-ng hcxtools bettercap wireshark(native opt) termshark tcpdump
+- **cloud** — awscli2 azure-cli google-cloud-sdk kubectl trivy kube-hunter pacu
+- **osint** — theharvester recon-ng sherlock maigret holehe
+- **mobile** — apktool jadx dex2jar frida-tools objection scrcpy
+
+### C2 — The Python toolkit (`python.nix`)
+
+Answers "a script built into the nixos config that can set up all the python tools".
+
+Three parts:
+
+1. **One combined interpreter.** `pentest-python` = `python3.withPackages` with
+ every offensive library: impacket certipy pywerview dploot masky bloodhound
+ ldapdomaindump pypykatz bloodyad lsassy minikerberos aiowinreg ldap3 dnspython
+ scapy pwntools pycryptodomex requests rich. Any offensive script you download
+ runs with `pentest-python foo.py` and its imports already resolve — no venv,
+ no `pip install`.
+
+ *Risk:* a version conflict inside one env (impacket is built against
+ python3.14 here). Mitigation: if `withPackages` fails to resolve, fall back to
+ per-tool standalone packages and a slimmer shared env. Decided at build time,
+ not guessed.
+
+2. **Suffix-free aliases.** impacket installs 70 scripts as `secretsdump.py`,
+ `psexec.py`, `GetUserSPNs.py`. A `mkSuffixFreeAliases` helper globs a package's
+ `bin/*.py` and symlinks `secretsdump` → `secretsdump.py`, so both spellings
+ work. Applied to impacket and any other `.py`-suffixed toolkit.
+
+3. **A discovery command.** `impacket` with no arguments lists all 70 scripts
+ with fzf selection and `--help` preview; `impacket <name>` runs one. Same for
+ `pentest-python --list`.
+
+### C3 — Payload tree (`payloads.nix`)
+
+A derivation of symlinks at a stable path, exported as `$PAYLOADS`:
+
+```text
+$PAYLOADS/
+├── windows/{amd64,x86}/
+│ ├── privesc/potato/ JuicyPotato JuicyPotatoNG RoguePotato SweetPotato
+│ │ GodPotato PrintSpoofer SharpEfsPotato EfsPotato
+│ │ LocalPotato DeadPotato
+│ ├── privesc/printer/ SpoolSample SharpPrintNightmare PrintSpoofer
+│ ├── privesc/ winPEASx64 winPEASx86 Seatbelt SharpUp
+│ ├── ad/ Rubeus SharpHound Certify Whisker SharpView
+│ │ ADCSPwn StandIn
+│ ├── creds/ mimikatz-2.2.0 mimikatz-2.1.1 LaZagne SharpDPAPI
+│ ├── agents/ ligolo-agent chisel nc
+│ └── misc/ SharpCollection (the full upstream set)
+├── linux/{amd64,arm64}/ ligolo-agent chisel linpeas pspy socat nc
+├── macos/{amd64,arm64}/ ligolo-agent chisel
+└── scripts/
+ ├── ad/ PowerView PowerUp SharpView.ps1 Invoke-Mimikatz
+ │ PowerSploit nishang Invoke-Kerberoast
+ ├── printer/ printerbug.py CVE-2021-1675.py Invoke-Nightmare.ps1
+ └── privesc/ linux-smart-enumeration linux-exploit-suggester
+ PowerUp.ps1 Sherlock.ps1
+```
+
+Multi-arch comes from `pkgsCross.mingwW64` (Windows) and `pkgsCross.aarch64-multiplatform`
+(Linux arm64) over the same source. Versioned duplicates live here as distinct
+filenames (`mimikatz-2.2.0.exe`, `mimikatz-2.1.1.exe`) rather than contending
+for one PATH name — the reason payloads are files, not commands.
+
+`payload-serve [port]` HTTP-serves the tree bound to `tun0`;
+`payload-serve --smb` runs the impacket `smbserver` equivalent;
+`payload-serve --list` prints the tree.
+
+### C4 — Shared target state (`htbtarget`)
+
+The requirement is a target IP visible **across terminals**. Environment
+variables cannot be pushed into already-running processes, so:
+
+- **State** lives in `$XDG_STATE_HOME/htb/` (`target`, `name`, `vpn`), surviving
+ reboots so a box can be resumed next day.
+- **Propagation** is a zsh `precmd` hook that re-reads the state file and exports
+ `$TARGET`, `$RHOST`, `$IP`, `$BOX`. Every terminal picks up a change made in
+ any other terminal **at its next prompt**.
+
+```sh
+htbtarget 10.10.11.5 vintage.htb # set IP (+ optional FQDN)
+htbtarget # print current
+htbtarget clear
+```
+
+With a FQDN, it also manages a marked block in `/etc/hosts` through a privileged
+helper — necessary because Kerberos in CPTS AD boxes needs working name
+resolution, and `/etc` is read-only on NixOS.
+
+**Honest limitation, to be documented in the cheat card:** a shell that is
+mid-command when the target changes keeps the old value until its next prompt.
+Interactive use is unaffected.
+
+### C5 — VPN control (`htbvpn`)
+
+Implemented as a **systemd service template** `htbvpn@.service`, not a
+backgrounded process — it survives closing the terminal, logs to the journal,
+and `down` reliably kills it.
+
+```sh
+htbvpn list # profiles in ~/.config/htb/vpn, active one marked
+htbvpn up lab_eu_free # sudo systemctl start htbvpn@lab_eu_free
+htbvpn down # stop whatever is up
+htbvpn status # unit state + tun0 address
+htbip # just the tun0 IP, for pasting into payloads
+```
+
+Profiles are **not** Nix-managed: they are per-account files that rotate, and
+`~/.config/htb/vpn/` is created for them. A sops-encrypted variant is available
+later if wanted. A scoped `NOPASSWD` sudo rule covers only
+`systemctl {start,stop,restart} htbvpn@*`.
+
+### C6 — Clock skew control (`htbtime`)
+
+The Kerberos-skew problem: AD authentication rejects a clock more than ~5
+minutes off the DC, and on NixOS `systemd-timesyncd` is enabled by default and
+will **undo** any manual adjustment.
+
+`htbtime $TARGET`:
+1. Record prior state (is `timesyncd` active? what does `timedatectl` report for
+ NTP? are `chrony`/`ntpd` running?) into `/var/lib/htb-time/state`.
+2. Disable the conflicts: `timedatectl set-ntp false`, stop `chrony`/`ntpd` if up.
+3. Sync to the target: `ntpdate -u <target>` (from `pkgs.ntp`, confirmed to ship
+ `ntpdate` and `sntp`), falling back to `chronyd -q` if the DC refuses NTP.
+4. Report the resulting offset.
+
+`htbtime off` reads the state file and restores **exactly** what was there, then
+re-syncs to real time. `htbtime status` shows the current offset and mode.
+
+Two safeguards:
+
+- **A skew warning.** A large offset breaks TLS certificate validation, so `nix`,
+ `git` and HTTPS may fail while skewed. `htbtime` says so explicitly rather than
+ letting it become a confusing debugging session.
+- **Self-healing.** State lives in `/var/lib`, but a reboot re-applies the NixOS
+ default anyway, so a forgotten `htbtime off` cannot permanently break the clock.
+
+Root side is `htb-time`, a fixed store script with a scoped `NOPASSWD` rule for
+`wheel` — mirroring the existing `fan-ec` / `fan` split in
+`modules/hosts/laptop/fan-cli.nix` and `modules/home/fan.nix`.
+
+### C7 — Engagement scaffolding and cheat card
+
+```sh
+htb new escape # ~/htb/escape/{nmap,loot,creds,www,exploit}/ + notes.md template,
+ # sets the target if given, cds in
+htb ls # boxes worked, newest first
+pentest-cheat # recon | ad | pivot | transfer | crack | web | dfir | htb
+```
+
+`pentest-cheat` follows the existing card mechanism exactly
+(`modules/home/cheats/*.md` + `cheat-render`), so it joins `nix-cheat`,
+`gpg-cheat` and `niri-cheat` with no new machinery.
+
+### C8 — `pentest-update`
+
+Re-pins every `_pkgs/` derivation to its latest upstream `rev`/`hash` using
+`nix-prefetch-url`/`nurl`, rewrites the hashes in place, and prints a diff for
+review. Never auto-commits and never runs during a rebuild — pinning stays
+deliberate.
+
+## Error handling
+
+- **Missing VPN profile** — `htbvpn up` lists what is available and exits 2.
+- **No target set** — any command needing `$TARGET` says which command sets it.
+- **`htbtime` without a reachable DC** — reports the failure and leaves NTP
+ *disabled* with a clear warning plus the `htbtime off` hint, rather than
+ silently half-applying.
+- **Unbuildable `_pkgs` entry** (upstream retagged, asset renamed) — that one
+ derivation fails with the URL that broke; it must not take the rebuild down, so
+ payload entries are assembled so a single failure is diagnosable in isolation.
+- **Python env conflict** — falls back to standalone packages (C2).
+
+## Testing
+
+No unit-test framework applies to a package set, so verification is build- and
+behaviour-based, **per module as it lands**, not in one pass at the end:
+
+1. `nix flake check` and `nix build .#nixosConfigurations.nixos.config.system.build.toplevel`
+ after each module — eval errors and missing attributes surface immediately.
+2. **Smoke test per category**: one `--version`/`--help` per tool, scripted, so a
+ package that installs but cannot run is caught.
+3. **Payload tree**: assert every declared path exists and Windows artefacts
+ report as PE binaries (`file`), Linux ELF ones as ELF.
+4. **`htbtarget` cross-terminal**: set in terminal A, confirm `$TARGET` appears in
+ terminal B at its next prompt.
+5. **`htbvpn`**: unit starts, `tun0` gains an address, `down` removes it.
+6. **`htbtime`**: capture `timedatectl` before, run `htbtime` against a test NTP
+ source, confirm NTP disabled and offset applied, run `htbtime off`, confirm
+ `timedatectl` matches the original state byte for byte. This round-trip is the
+ one that must not be hand-waved.
+7. `sudo nmap -sS` and `sudo responder -h` — the D1 guarantee, explicitly tested.
+
+## Risks
+
+| Risk | Mitigation |
+|---|---|
+| First build is 40–60GB / a couple of hours | Mostly cache downloads. Phased: core + AD first, GUI last. |
+| Upstream release assets move, breaking `_pkgs` | Hashes pinned; `pentest-update` re-pins; failures isolated per derivation. |
+| `pkgsCross.mingwW64` fails for a given tool | Verified for mimikatz/ligolo-ng/netexec. Anything else falls back to a pinned release asset. |
+| Combined Python env conflicts | C2 fallback to standalone packages. |
+| Clock skew breaks TLS mid-engagement | Explicit warning + reboot self-heal (C6). |
+| 200 new names shadow an existing command | Smoke test diffs `PATH` collisions against the pre-change profile and reports them. |
+
+## Phasing
+
+Each phase ends with a working, committed system.
+
+1. **Foundation** — `options.nix`, `core.nix`, `wordlists.nix`, `python.nix`. Proves the pattern.
+2. **CPTS core** — `recon`, `ad`, `web`, `pivot`, `crack`, `shells`.
+3. **HTB workflow** — `vpn.nix`, `time.nix`, `htb.nix`, `htb-shell.nix`. The commands.
+4. **Payloads** — `_pkgs/` derivations, cross-builds, the staging tree, `payload-serve`.
+5. **Heavy and optional** — `bloodhound`, `gui`, then `dfir`/`reversing`/`wireless`/`cloud`/`osint`/`mobile` (off by default).
+6. **Polish** — `pentest-cheat`, `pentest-update`, `devshells.nix`, README.
+
+## Scope note
+
+Written for authorised lab use: HackTheBox CPTS preparation. The toolkit is
+ordinary defensive-security coursework equipment; it carries no targeting
+configuration and nothing here points at infrastructure the operator does not
+own or have explicit permission to test.