GitHub now refuses a tag-pinned action in every Core-vendoring repo, and make fleet-protection goes red if one stops. sha_pinning_required was on in only dotfiles-core and dotfiles-MacBook. It is now on in all eleven repos the script audits, which makes GitHub enforce rule 3 of the CI floor server-side: on sibling repos' own workflows, and on anything that reaches main without the text gate. The moving major survives. GitHub exempts reusable workflows ("can still be referenced by tag"), and a re-run of dotfiles-Alpine's lint-call.yml@v7 passed under enforcement. The new --require-sha-pin mode turns it on idempotently, keeps each repo's allowed_actions, and re-reads the server before it reports success. The admin report now fails a repo whose setting is off or cannot be read. It also reports allowed_actions and the count of Actions execution-protection policies, without gating either. CI's --rulesets-only run still skips the block, because it has no repo admin. Composite actions are not exempt, so dotfiles-nvim first SHA-pinned its setup-core-tools@v7 references (dotgibson/dotfiles-nvim#9) and then turned the setting on too (#1226).
What’s new
One reverse-chronological feed across every repo, parsed straight from eachCHANGELOG.md at build time — not a hand-kept mirror. Each change is classified so you can filter to perf, config, orsecurity at a glance. ACorechange fans out to every OS repo on Core’s next release.
A Core PR is checked against the real sibling repos before it merges. The new fleet-gates job in ci.yml clones the fleet beside Core and runs the audit's fleet-wide gates (§5f, §9m-§9p, theme and desktop-parity drift) that every other leg skips because it checks out Core alone. That gap let #1210 merge green and then refuse the v7.14.0 fan-out on dotfiles-Debian's stale TOOLS_OPTIN (#1239). The job is meant to be a required check, so it judges a delta: scripts/fleet-gate-delta.sh audits the PR's base and head against the same clones and fails only on a failure the PR adds. A sibling that drifts on its own therefore cannot block unrelated Core PRs. A failure already on main is a warning. A red means the sibling fix lands first, which is the fleet ratchets' existing order. The job holds no token, because the gates run sibling code (#1240).
/release-readiness actually checks the editor pin. The scheduled job allows ./scripts/check-nvim-freshness.sh as a literal command, but the routine only named the script loosely. The first run (#1245) ran neither form: it reached for gh release list, was refused, and reported a current nvim.lock as unverified. Step 4 now lists the three fleet-state commands exactly as the job grants them, says no other tool is needed for the editor row, and spells out the exit codes. Exit 0 with ✓ means current, exit 0 with a SKIP means unverified, exit 2 means behind, and exit 1 means the lock is broken.
/release-readiness checks the fleet-wide gates against the sibling repos before a tag. A Core PR's CI checks out Core alone, so every gate that reads a sibling (§9m-§9p, theme and desktop-parity drift) skips there. Until now the first run that could see a sibling break was sync-fanout's pre-fan-out audit, after the tag was cut. That is how #1210's correct Kali matrix cells merged green and then redded the v7.14.0 fan-out on dotfiles-Debian's stale TOOLS_OPTIN (#1239). The weekly job now checks Core out beside the fleet and runs audit-core.sh --scope none before the routine, with both tokens withheld from that step because the gates run sibling code. Any ✗ there is a HOLD, and a gate that could not see its sibling is reported as unverified, not as a pass. This is the fallback half of #1240. The PR-time check is still open there.
RELEASE-STRATEGY.md no longer lists making audit-arch and audit-alpine required checks as future work. The main ruleset already requires both, alongside the Ubuntu and macOS audit legs, and it binds admins too. The "Still worth doing" item still pointed at the classic branch-protection settings page that the fleet retired. The CI bullet now says what the ruleset enforces.
CI refuses a pull_request_target trigger. That trigger runs a fork's pull request with this repo's secrets and a write-capable token. The new ci-floor.yml workflow checks out dotfiles-core@v7 and runs its check-modern.sh --banned-triggers over this repo's workflows, the same rule the OS repos get through Core's lint-call.yml (dotgibson/dotfiles-core#1215). Nothing here uses the trigger today. The check passes with a warning until the Core release that ships the mode moves the v7 tag.
Engagement-data write guard. note, logshell, bhce and nmapsweep used to fall back to $PWD when $ENGAGEMENT was unset, so running them inside a checkout wrote client data into that repo. They now resolve their root through _eng_writeroot, which refuses any $PWD inside a git work tree.
The field references open read-only. htp/xdev/evade/ipp are symlinks to tracked files, and hacktheplanet's "target fill" recipe told you to substitute the real client IP/hostname/domain into the buffer — one :w from publishing engagement data. They now open with -R; htp -w edits deliberately, and the fill recipe writes a copy under $ENGAGEMENT.
.gitignore backstop repaired. *.xml carried a trailing comment, which gitignore does not support — the pattern was the whole line and matched nothing, leaving nmap -oX output unguarded. The ignore list also described the *template's* directory names rather than the ones mkengagement creates, so scope/, recon/, scans/, web/, screenshots/, exploit/ and notes.md were all unblocked.
Pinned + verified tool installs. The five curl | sh installers are gone. install/tool-versions.env pins each tool's version and the SHA-256 of its release asset; bootstrap.sh verifies before installing and fails closed. starship moved to apt, which packages it.
Secret scanning in CI — gitleaks over the working tree and full history.
hethttp refuses to serve a git work tree on 0.0.0.0.
bhce can take credentials off argv — op://… resolves through 1Password, - prompts with echo off.
The core_branch fallback in the two core.lock readers. core_branch was renamed core_ref in dotfiles-core#453, and reading both names was correct while it shipped: this repo vendors Core on its own schedule, so locks of both vintages existed in the wild and reading only the new name would have killed sync-core.sh on any repo that had not yet synced. That window is closed — Core declares the field gone as of v5 in VENDORING.md, and no core.lock in the fleet carries it, this repo's included. Gone with it: migrate_branch_to_ref, which rewrote the old key in place so set_field (which replaces, never inserts) would have a line to hit. Nothing needs that any more — sync-core.sh now dies naming core_ref alone, before the pull, if the lock has no such line, which is what licenses the never-insert rule downstream. test/check-core-freshness.sh keeps its soft branch=main default: it is a watcher, not a writer. The CORE_BRANCH env override is untouched — it is that script's own knob, not a lock field (#271).
aliases.md now gives an override location that works (#348). It told you to override $ENGAGEMENTS_DIR and the other data paths in 99-local.zsh. But this stage applies their := defaults at band 85, and 99-local.zsh loads at band 95, so the override came too late for anything the stage had already used. The doc now names ~/.zshenv or the environment. It also offered hethttp as its example of an unguarded helper, yet hethttp checks for python3 and refuses to serve from inside a git work tree unless HETHTTP_FORCE=1 is set. Its row now says so, and the example is lhost/ttyup. CLAUDE.md says twelve repos, not eleven.
Four manifest annotations and redup's ledger caught up with a fast-moving week — the act-on-it half of #339. All comment-only; no package added or removed, so the parsed manifest is byte-identical. - chisel's premise flipped. apt moved to 1.12.1-0kali2 (migrated 2026-09-16), so the "apt ships a pre-release, two RCs ahead of stable" reading is dead — apt is level with, or a step past, upstream's newest tag (a v1.12.1 tag exists but no release object yet, so releases/latest still reads v1.12.0). The breaking-flag list is now framed as live on the installed binary rather than hypothetical. - evil-winrm's gap closed, and one sub-note became a trap. apt is 4.1-0kali1 (migrated 2026-09-14), at upstream's v4.1 head. The old note told operators to expect the apt build to drop idle shells; v4.1's keepalive and download path-traversal fix are now present, so a dropped idle shell now *is* a real network signal. Kept as a closed gap, not deleted (the ffuf shape). - BloodHound CE lost its named head. The 9.7.0~rc4 head rotted in eight days (upstream cut v9.7.1); currency is now stated purely as a habit — check the server's own version against SpecterOps' releases — with no number left to rot. The 9.6.0 migration fact and the bloodhound-ce-python "going quiet" note are re-verified and unchanged. - AdaptixC2's branch ledger dropped a dead branch. dev-v1.3 no longer exists; the note now names the live branches (a v1.2 branch and testing-v2.0). - redup's ledger records pipx as the third evaluated-and-deferred candidate. roadrecon/roadtx/bbot are pipx-owned and apt cannot touch them, but adopting pipx upgrade would widen redup's contract from "run each tool's own updater" to "drive a package manager" — the ownership line redup exists to respect. go_fast_movers stays empty by reason.
check-packages.sh named a suite it had not checked against. The label exists so a local run is interpretable — the script's own comment says an unresolvable name "prints the suite it was checked against" — but it took the first real archive from apt-cache policy, and apt lists every configured source. Third-party repos routinely label themselves a=stable, so on a Kali box carrying one (observed: Yazi's o=Yazi,a=stable sorting ahead of four o=Kali lines) it printed apt suite in view: stable while actually resolving against kali-last-snapshot. That inverts the label's purpose: an operator reads "does NOT resolve against stable", assumes a Debian-stable false alarm, and dismisses a real drift signal. It now prefers the archive of the Kali-origin source and falls back to the old first-real-archive rule only where no Kali source is configured. Verified across five cases: Kali behind a third-party repo, Kali alone, a non-Kali Debian box (fallback unchanged), a release line with no o=, and empty policy output.
The packages workflow contradicted the manifest about rustscan. .github/workflows/packages.yml's #260 paragraph explains why the job emits ::warning:: annotations, citing rustscan as an example of "an apt name that resolves in NO Kali component". That was true when written, but #315 restored rustscan to a real apt line after it migrated to kali-rolling on 2026-08-31 — so the repo shipped a workflow comment calling a name unresolvable while the manifest beside it installs that same name. The paragraph is now marked as the history of why annotations exist, with the current status of both examples it cites (rustscan resolves again; snmp-check was a package/binary split all along — the apt name is snmpcheck). Comment only; no job behaviour changes.
bootstrap.sh's PATH is not the shell's PATH — adopt blib_user_bindirs_on_path (dotgibson/dotfiles-core#748). Replaces the hand-rolled export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH" prelude, which also moves it below the source core/lib/bootstrap-lib.sh line. ~/.local/bin, ~/.cargo/bin and $GOBIN reach PATH only through the zsh layer, i.e. only inside a Core shell — which does not exist while bootstrap.sh runs. So every command -v <tool> guard here was answered by the PATH of whatever shell launched the bootstrap: on a fresh box, bash, with none of them. That is wasted work when the guard picks whether to reinstall, and a wrong answer when it picks a branch — dotfiles-openSUSE probed command -v mise for a mise mise.run had written to ~/.local/bin moments earlier, both arms of its Go fallback missed, and the run exited 2 on every bootstrap. No stubbed CI leg can see that: a stub installs nothing, so "is the tool present afterwards" can never fail under one. Core has shipped blib_user_bindirs_on_path for exactly this since dotgibson/dotfiles-core#425 — it resolves CARGO_HOME and GOBIN/GOPATH rather than hard-coding them, and adds only directories that exist, so it is called again after an installer creates one. The directory --install writes into is mkdir -p'd before the helper runs: the helper adds only directories that already exist, so a straight swap for the old unconditional export would have dropped ~/.local/bin for the whole first run and sent the probe/report phase back to reporting a tool it watched get installed as missing. audit-core.sh used to exempt the Role repos from this helper on the reasoning that a role layer installs no packages; that was never true of an --install that does pipx and go install into ~/.local/bin, which is why the prelude was hand-rolled here in the first place. The exemption is gone.
sync-core.sh --help printed set -euo pipefail. The header is rendered with sed -n '2,35p' "$0", and the comment block it means to print ends at line 33 — so every --help run trailed the closing ─── rule with the first two lines of actual code. Pre-existing, and found by the --help render check while retiring the core_branch fallback above; the range now stops at the rule.
Two more targets had the same guard defect, found by the new gate rather than by eye. make shellcheck and make secrets each announced a skip and then ran the missing tool, exiting 127 — the same shape as markdown below, in targets nobody had thought to check. Both collapsed into one recipe line. _core_make_gate_hits (dotgibson/dotfiles-core#775) found them the first time it was pointed at this repo, having been written from the markdown case alone.
make markdown announced a skip and then ran anyway. Each make recipe line runs in its own shell, so the guard's exit 0 only ended that line: without npx it printed "npx not available — skipping markdown" and then ran npx, exiting 127. Collapsed into one recipe line, so the skip is a real skip (dotgibson/dotfiles-core#775 — the same defect in six other fleet repos). MD_FILES was already correct here, including the offensive/companion exclude that matches the gate's, so only the guard needed fixing. An unreadable MARKDOWNLINT_VERSION now fails rather than silently linting unpinned — "same version as CI" is this target's whole claim.
.markdownlint.jsonc's header claimed this config was "the local check for the README" and that "CI here gates this repo's own code, not its Markdown". Both were true when written; dotgibson/dotfiles-core#592 made the markdown leg blocking and it covers all 17 repo-owned files, not just the README.
Seven tools carried claims that were incomplete, imprecise, or absent — the annotation half of #275 (item 9). No package added or removed; the parsed set is byte-identical at 85 names. - httpx-toolkit's warning was right in conclusion, wrong in mechanism. It said the "bare 'httpx' apt pkg is the python lib, not this". There is no binary package named httpx at all — apt-cache show httpx returns E: No packages found; the source package builds python3-httpx. So the bare name does not install the wrong tool, it resolves to nothing — which makes this an instance of the *hexyl* rule (a name this manifest must never carry), not of the package/binary split it was filed under. Currency added: apt runs about one minor behind. - wpscan changed under you. kali-rolling jumped 3.8.28 → 4.1.0 in Aug 2026 after 3.8.28 sat since Mar 2025, so an apt upgrade since then swapped the tool, not the patch level. v4.0.0 requires Ruby 3.3+, no longer scans plugins by default (-e ap), moved config/cache to XDG dirs, and removed --timthumbs-detection, --config-backups-detection, --db-exports-detection and --medias-detection. Verified that nothing shipped breaks: hacktheplanet passes --enumerate u,vp,vt explicitly, so the plugin-default change never reaches it. - nikto is alive and current, recorded so it is not re-suspected (upstream pushed 2026-08-28; 2.6.1 released Jul 2026). It was flagged as a likely-stale "old Perl scanner" and is not. Its 2.6.x line does change the tool's network signature — a static Chrome User-Agent by default instead of one rotating per request — which is worth knowing when reasoning about what a defender saw. - snmpcheck and smtp-user-enum were the enum block's last two unmarked freezes. Both upstreams are frozen (nothink.org 1.9, 2015; pentestmonkey v1.2), and in both cases apt is at that version, not behind it — there is nothing to chase. Kept for the reason mitm6 and PrintSpoofer are kept: SNMP community strings and SMTP VRFY/EXPN are protocol behaviours, not bugs anyone will patch out. Deliberately no year for smtp-user-enum — its page states no release date, so any date here would be invented. - bloodyad ships BadSuccessor, which the README does not mention (it is in the wiki: add badSuccessor, msldap badsuccessor_check, msldap dmsas), so the dMSA escalation path is already on the box. Annotated with the target-state caveat the mitm6 note draws on the same axis: Microsoft patched it 2025-08-12 (Server 2025 DCs from build 26100.4946), and the post-patch variant needs a second primitive plus SharpSuccessor/Rubeus — neither of which this layer ships, and neither added. - PKINITtools is going quiet — not archived, but last push 2025-01-03, the stalest live pointer in the AD block. Dated note only. The note explicitly refuses to claim certipy supersedes it: that could not be confirmed from a primary source, and says so rather than leaving a plausible guess in the manifest. - GodPotato was the last unmarked freeze in the target-dropped block once PrintSpoofer got its ARCHIVED note. Static since 2023-11-24 and kept — the RPCSS OXID abuse survived the DCOM activation-hardening waves. SigmaPotato is named as the in-memory .NET fork but gets no pointer: itself untouched since 2024, so a mention rather than a successor.
PACK is in Kali apt, and the manifest sent you to a dead Python-2 repo instead. The pointer read → UPSTREAM (github.com/iphelix/pack) … Python 2 era — run from the clone, no apt package, and both halves were wrong. This is the fifth instance of the error class this file already records fixing for caldera, name-that-hash, evilginx2 and sliver: a manifest that routes you upstream for something apt ships. Confirmed against apt's own index rather than a report — pack (0.0.4+git20191128.fd779b2-0kali3, arch all) Depends: python3, python3-enchant, and the package's Homepage field is github.com/Hydraze/pack, the maintained Python-3 fork (last push 2024-07-28). iphelix's original is dead (last push 2019-12-10). Now a plain apt line in Credential attacks. The membership rule does not block it the way it blocks trufflehog — offensive/hacktheplanet:580 invokes statsgen and maskgen directly. The shipped binaries were read off the package rather than guessed: statsgen, maskgen, policygen, rulegen and dictstat, a fifth legacy binary the old note did not know about. The kwp-is-not-in-PACK note on the next line depends on this block naming iphelix/pack and is left intact. #275 item 1.
chisel had no annotation at all, and apt ships a pre-release of it. kali-rolling is 1.12.0~rc2-0kali1; upstream cut v1.12.0 final on 2026-08-29, two RCs ahead — the opposite of the ffuf case, where apt trails a live upstream. 1.12.0 breaks flags: --auth now requires <user>:<pass> and *fails startup* on a missing colon, SOCKS5 users need an authfile entry matching socks, truncated MD5 fingerprints are rejected, and a client exhausting --max-retry-count exits non-zero. Nothing shipped here breaks — the hacktheplanet lines and the corpus' reverse-tunnel-chisel entry were checked and both use the bare chisel server --reverse form; the exposure is an operator adding --auth from memory. The pspy split is recorded too: apt's build is for your box, the static release binary is what you upload, and mixing is safe because the wire protocol is unchanged. #275 item 2.
kubectl is outside Kubernetes' documented support skew, which the line did not say. Its provenance note was correct; its silence on currency was the problem. kali-rolling is 1.33.4+ds-1 (imported 2025-10-02) against upstream stable v1.37.0. kubectl is supported within ±1 minor of the apiserver, so a 1.33 client is unsupported against 1.35/1.36/1.37 — a documented window, not a version-number aesthetic, and it degrades the corpus' k8s-* entries sitting under peirates. The line now points at pkgs.k8s.io when a cluster's version matters, the same shape as the google-cloud-cli/gh/vault pointers in the same block. #275 item 3.
The covert-egress header excepted ptunnel-ng as "the CURRENT upstream" — an annotation this changelog added two entries below, wrong within three weeks. Its *version* claim holds (kali 1.43-2 is upstream v1.43); its *vitality* claim did not. v1.43's own release note says "due to time constraints, there will be no further publications in the near future" (tagged 2024-11-27) and master has not moved since 2024-04-07. It froze at a version apt happens to have. All five tools in that block are frozen, dead, or behind — which is the honest state of covert-channel egress and more useful than implying one live option exists. ptunnel-ng is still the right choice of the two ptunnels; it is the maintained-*er* fork, not a live project. #275 item 4.
dnsenum's name lands you on the wrong repo. The line carried no upstream pointer, and fwaeytens/dnsenum — what you find searching the name, 702 stars — has not moved since 2019-10-08. Kali ships 1.3.2-1, whose Homepage names SparrowOchon/dnsenum2 ("officially mainlined in Kali"). Same use-the-fork trap the kwp-vs-iphelix/pack and ConfuserEx-vs-mkaring notes exist to prevent — and the same shape as the pack fix above, where apt's Homepage also named a fork the note did not know about. Honest status: frozen fork, and apt is on it. dnsrecon is the maintained analogue and is in apt, but no doc or corpus entry names it, so it stays out on this file's membership rule. #275 item 5.
PowerUpSQL was named in the payload-build block's "absent" list but was the one member of six with no status line. Verified: not archived, but no release has ever been cut and the last functional commits are Aug 2024 — quiet, not dead, the sRDI shape. Recorded as low impact here, because the Linux-native half is already on the box: impacket-mssqlclient (enum_links/use_link) and nxc mssql cover linked-server hopping and are both already listed. skahwah/SQLRecon is named as the maintained analogue with no pointer of its own — the pretender treatment. The block's reference to that tool in offensive/evasion had also drifted, and is corrected from :124 to :126. #275 item 6.
sliver's note ran one release ahead of the facts. It said upstream "has kept releasing (past 1.7.6 by Aug 2026)"; v1.7.6 shipped 2026-08-28 and is the head, so "past" was wrong — now "through 1.7.6". The durable phrasing the last cycle introduced (check sliver-server version before an op, never a patch count) is unchanged and still right. What 1.7.6 contains sharpens it: it bounds mTLS/WireGuard envelope and pivot-frame allocation from the length prefix and fixes DNS varint boundary handling — memory exhaustion on network-facing paths, not cosmetic stability. #275 item 7.
proxychains4 carried no annotation, and the bare proxychains name is a live trap. apt is at upstream's head (4.17-3.1 = v4.17; rofl0r alive but slow to release — fixes landed 2026-08-27 against a 2024 tag). The addition is the trap: a real proxychains package exists in Kali and Debian sid at 3.1-9 — proxychains 3.1, from 2007. It escapes the usual apt-file search '/usr/bin/proxychains$' check because it ships /usr/bin/proxychains3, a third binary name, so apt install proxychains *succeeds* and silently hands you an 18-year-old tool. A sharper reason to name proxychains4 than "the bare name is only a virtual Provides:". #275 item 8.
redup's katana step would have inherited the nuclei miscount on migration. It ran katana -update unconditionally — the exact shape the nuclei engine step had before it was fixed below. katana 1.7.0-0kali1 landed in kali-dev on 2026-08-27 and has not migrated to kali-rolling; apt owning a binary is precisely when Kali patches its self-updater out, as it already did to nuclei. On the day katana migrates and is patched, the step would have started printing ✗ katana update failed and tallying it on every run of a healthy box. The flag is now probed before use, reusing the nuclei step's whole-token regex byte-for-byte — the match has to be whole-token here too, since katana's help carries -duc, -disable-update-check, which a bare grep -- -update would match on exactly the patched build the probe exists to catch. A flagless build now degrades to a skip that tallies nothing. Preventive: no behaviour change on today's go install build, where the probe passes. redup -h, the redup header comment, aliases.md and install/tools.lst all gave nuclei a build hedge and katana none; all four now match. #260 item 5.
The Cloud / SaaS / CI-CD block claimed Terraform Cloud entries are "pure REST — curl + a token, nothing to install." The tfc-agent entry lower in the same file already said the opposite — that it "corrects this file's older claim that the Terraform Cloud entries are pure REST" — so the correction was written at one end and never applied at the other, leaving the two halves of one file contradicting each other. tfc-agent-hijack creates the agent pool over REST and then runs tfc-agent, a HashiCorp release binary on infrastructure you control; only tfc-token-backdoor and tfc-var-injection are curl-only. Checking the rest of the sentence while correcting it found it loose for two more of the five services it named: Snowflake's three entries are SQL (``sql fences, which is why the corpus gate never sees a command in them) and slack-2fa-disable` is a console toggle with no command at all. "Nothing to install" still holds for both — "curl + a token" did not. Okta and GitLab were accurate as claimed.
katana's manifest pointer said "not in apt", which is no longer true. Initial Kali packaging (1.7.0-0kali1) was committed to kali-dev on 2026-08-27. It has not migrated, so go install is still the only route on any box today — but the pointer now states the kali-dev version and the "has not migrated" qualifier, mirroring the shape rustscan already carries in the same file, and names pkg.kali.org/pkg/katana as the re-check. It also records what a migration would bring: the kali-dev packaging carries no debian/patches directory yet, so -update survives there for now. #260 item 5.
mitm6's freeze note claimed "there is no maintained successor to move to." Too strong, and the near-miss has a name: RedTeamPentesting's pretender (Go, v1.4.1, Jul 2026) is maintained and does mitm6's exact DHCPv6/DNS takeover plus mDNS/LLMNR/NBT-NS. But it is a spoofer only — no listener, no capture, no relay — so it replaces neither mitm6 nor responder, and the note's conclusion (keep mitm6, frozen because finished) is unchanged. It gets no → UPSTREAM pointer of its own: no doc and no corpus entry invokes it. The same note now records a second axis the old text conflated with it — whether the coercion fires is not whether the relay yields. On fully-patched Server 2025 / Win11 24H2, SMB signing is required by default and LDAP channel binding ships Enabled-When-Supported (MSRC, Dec 2024): the trigger still fires, the SMB and plain-LDAP relay legs close, and value shifts toward krbrelayx and the AD CS / PKINIT path. #260 item 6.
redup counted every successful searchsploit -u as a failure. The step ran if searchsploit -u; then …, but searchsploit exits 6, not 0, after any successful update — its own header documents it ("Exit code '6' means updated packages (APT, brew or Git)") and its update routine ends in a bare exit 6 on every route (apt, brew and git alike). So a completely successful refresh printed ✗ searchsploit -u failed and was tallied, making the summary read red on a healthy box. This is the same miscount as the nuclei engine step below, one step further down the same function, and it survived that fix. The exit status is now captured and both 0 and 6 count as success; anything else still reports, and now prints the code. Found while correcting the step's prose for #260 item 8 — the report called this a comment-only fix.
redup's searchsploit comment described a code path that does not run on Kali. It explained the sudo escalation as a permissions problem on a root-owned git checkout under /usr/share/exploitdb. On a deb install searchsploit -u never reaches its git pull: it probes apt-cache search "^exploitdb$" first and, on a hit, runs sudo apt update && sudo apt -y install exploitdb, escalating on its own. The writability probe is kept — it is still correct for a user-local or /opt checkout and on non-Kali — but it is now documented as inert on the deb route. The consequence strengthens the never-mid-engagement warning rather than softening it: the step can move apt state, not just refresh a data directory.
Five more annotations routed you around a package apt already ships — the same error class as caldera below, found by re-verifying every → UPSTREAM name in the manifest against apt rather than by any report. None of the five appears in #260: - evilginx2 was annotated → UPSTREAM (go install or release binary) and filed under the block headed "Operator-side tooling, not in apt", whose preamble says outright that these "have no Kali package". kali-rolling ships evilginx2 (3.3.0+ds1-0kali1), which *is* upstream's latest release (v3.3.0, Apr 2024). Now a plain apt line in Credential attacks, ROE warning intact. - name-that-hash was annotated → UPSTREAM (pip install name-that-hash). Kali ships it (1.11.0-0kali1). The decision to leave it uninstalled stands on its own merits; only the packaging pointer was wrong. - pspy was annotated → UPSTREAM (release binary) in a block whose premise is "per-engagement downloads rather than apt packages". Kali ships pspy (1.2.1-0kali1). Here the conclusion survives for a sharper reason than the block gave: what apt ships is a host-arch, dynamically-linked Debian Go build (Depends: libc6), while what you upload to a target is upstream's *static* pspy32/pspy64. So the release binary really is the per-engagement download — just not because "there is no package". - PowerUp.ps1 was annotated → UPSTREAM (PowerSploit …). Kali packages it: the powersploit package (3.0.0+git20200817-0kali1) drops the script at /usr/share/windows-resources/powersploit/Privesc/PowerUp.ps1 — the same pattern as mimikatz, a Linux package whose payload is Windows content you copy to the target. It is pulled in by kali-linux-headless, so it is already present on a default box. Still not an apt line of its own, but "fetch it from GitHub" was wrong. - The evasion payload-build paragraph asserted "None is in Kali apt" of its five tools. donut is packaged (1.1-0kali3+b1, /usr/bin/donut) and is the one member whose generator runs natively on Linux, so the paragraph's "all run operator-side on WINDOWS" was wrong about it too. It stays unlisted as a judgement, not because apt cannot supply it. macro_pack, PowerUpSQL, sRDI, ConfuserEx and ScareCrow are genuinely absent, as claimed.
caldera is in kali-linux-large. The note added with the caldera fix below claimed it is "in NO kali-linux-* metapackage"; apt-cache rdepends caldera says otherwise. It is absent from kali-linux-default, which is what the line was reaching for, so the conclusion (a default box needs this line) is unchanged.
redup's nuclei engine step could never succeed on Kali. It ran nuclei -update unconditionally, but Kali patches that flag out of its packaged nuclei — apt owns the binary, so self-updating it is not nuclei's job there. Kali's -h UPDATE section carries only -update-templates, -update-template-dir and -disable-update-check. So the step failed on every run on the primary target platform and tallied a failure, making the summary read red on a completely healthy box — the exact miscount the function's own comments exist to prevent. The engine step is now probed and the templates step (the daily-moving half) stays unconditional, so a go install-provided nuclei on non-Kali Debian still self-updates. The probe matches -update/-up as a whole TOKEN: a bare substring grep matches -update-templates, -update-template-dir and -disable-update-check, three hits on the very help text that proves the flag is absent.
Three apt names in install/offensive-packages.txt resolved against nothing. Verified against kali-rolling's own binary index, not a local box: - bbot is packaged in no Kali component and never has been (pkg.kali.org 404s) — now an UPSTREAM/pipx comment. The old line's "(pipx/upstream if the repo build lags)" hedge implied a repo build that does not exist. - snmp-check is the binary name; the package is snmpcheck, which ships /usr/bin/snmp-check. Same package/binary split the file already documents for httpx-toolkit and python3-ldapdomaindump. hacktheplanet's command was always right; only the manifest was wrong. - rustscan is absent from main, contrib and non-free alike — Kali's packaging sits in kali-dev at 2.4.1 and has not migrated. The line also claimed it "ships in kali-linux-default", whose Depends does not name it, so both halves were wrong. Now an UPSTREAM (cargo) comment. test/check-packages.sh had been reporting all three for weeks; see the packages.yml note under Changed for why nobody saw it.
Caldera was routed to Docker for nothing. install/offensive-packages.txt carried it as → UPSTREAM/docker and offensive/offensive.zsh justified the missing probe with "Caldera ships no caldera binary". Both false: kali-rolling ships caldera (5.3.0-0kali1) and it installs /usr/bin/caldera. Now a plain apt line, noting the ~70 MB Python chain and that no kali-linux-* metapackage carries it. The decision not to add HAVE_CALDERA stands, but for the real reason — nothing in offensive.zsh invokes it, which is install/tools.lst's actual membership rule.
The corpus-coverage counts went stale again, exactly as recorded below for the v2.10.0 sync. A later v2.10.1 sync added entries/blue/smb-enum-5145.md and projected the smb-enum pair into both views; no header moved. The version notes in those files now point at companion.lock for the exact revision instead of hardcoding a commit count, which rots the same way the counts do. Actual is 103 red / 102 blue with 19 and 24 blocks projected. Fixed in hacktheplanet, PURPLE-TEAM.md, OFFENSIVE-METHODOLOGY.md and — found while verifying, reported by neither audit — CONTRIBUTING.md and the Makefile. hacktheplanet also listed smb-enum-nxc among the entries "covered as richer prose below" while generating a block for it seven paragraphs later, so its own accounting summed to 102 rather than 103.
CLAUDE.md described --install as apt-only on Kali. _install_apt_absent pipx-installs ROADtools on both routes, which bootstrap.sh and install/offensive-packages.txt both state plainly. "Where things are" also documented 2 of the 4 install/ manifests; corpus-commands.lst and impacket-binaries.lst are now listed, the latter being the file whose entire purpose is making impacket-petitpotam fail (#208).
OFFENSIVE-METHODOLOGY.md dated Caldera's Apache move to "May 2026", contradicting the manifest's already-corrected 2025-12-19 donation date (#211 landed that fix in the manifest only).
hacktheplanet's escalation-primitives index restated certipy-ad find … -vulnerable without -stdout, so a copy-paste wrote to a file instead of the terminal. The canonical AD CS section and the corpus entry both carry the flag.
The corpus-coverage counts were stale in three files (found while verifying #212, which had reported them as correct). hacktheplanet and PURPLE-TEAM.md claimed 92 red / 90 blue entries; the htpx v2.10.0 sync added 11 of each and the headers were never updated — actual is 103 red / 101 blue. The decomposition went stale with them: the cloud/SaaS/CI-CD bucket is 56 (not ~55), C2-egress/Impact is 13 (not ~12), and 7 Linux persistence/privesc/credential-access entries had no bucket at all.
Two red entries and their blue pairs are projected nowhere and belong to no category. bloodhound-collect and ldap-recon are both Active Directory — discovery, squarely inside the "richer prose here" subject area but absent from its list; their pairs bloodhound-collect-4662 / ldap-recon-4662 key off event 4662, which is PURPLE-TEAM.md's own criterion for projecting. hacktheplanet's claim that an unprojected entry is "not a gap in the generator" was therefore false. Both files now name the gap instead of implying it cannot exist.
**rdp-hijack-tscon was listed as "covered better below"; it is covered *equally*.** Its two commands are byte-identical to the prose ones. Noted rather than silently kept.
OFFENSIVE-METHODOLOGY.md's "roughly two-thirds of the corpus" replaced with the measured figure — 69/103 red (67%) and 76/101 blue (75%). All three files now carry the same caveat: these counts are hand-maintained, they go stale on every companion-sync, and the corpus is authoritative when they disagree.
kwp was attributed to the wrong project (#213). The manifest filed it under PACK (kwp, statsgen, maskgen) → github.com/iphelix/pack. PACK ships statsgen/maskgen/policygen/rulegen and no kwp — kwp is hashcat's kwprocessor, and hacktheplanet's invocation is verbatim kwprocessor. Following the old pointer landed you in a repo that does not contain the tool. Split onto its own UPSTREAM line.
exploitdev's Linux toolchain was unmanifested (#212, #213). gdb, nasm and objdump are invoked by that reference and appeared nowhere in the package list. Resolved by checking a real kali-rolling box rather than guessing: nasm (via metasploit-framework) and binutils are already pulled transitively, so they go in the accounting block, while gdb is genuinely absent — gcc only *suggests* it — so it joins the "Kali does NOT ship by default" block, whose stated test it meets exactly.
nc was named only inside another package's comment (#212). netcat is the primary command of the reverse-shell fold and was listed nowhere. Added as netcat-traditional, not netcat-openbsd as the audit suggested: Kali installs traditional and points the nc alternative at it, and the documented nc -lvnp form is a traditional idiom — OpenBSD's nc rejects -p alongside -l, so that variant could have flipped the alternative and broken the very line it was meant to support.
Four field-reference commands could not run as written (#213, #212). hacktheplanet invoked nmap --script=msrpc-dcom-interface-activation, which is not a script nmap ships — verified against nmap 7.99 on kali-rolling, where the only msrpc NSE is msrpc-enum (already the line directly above). Dropped rather than replaced: there is nothing to replace it with. exploitdev invoked !mona egghunter, which is not a mona command — egg is, and -c (NtAccessCheckAndAuditAlarm) is one of *its* options; the two lines collapse into one. hacktheplanet also credited --dc to impacket/certipy when it is kerbrute's idiom — impacket and certipy use -dc-ip, as every impacket line in that file already does. The same misattribution in this file's #187 entry is corrected with it.
exploitdev presented hexyl as installed when no fleet layer ships it. The note claimed it was "Kali-only in this stack (not in Core)"; it is in Core, Kali apt (no such package exists) and install/offensive-packages.txt alike — nowhere. offensive.zsh probes HAVE_HEXYL but nothing installs it, so the bad-char *verification* step silently needed a tool the operator did not have. Now says so, with an xxd fallback, and points at the dotfiles-core#395 deferral.
Two hacktheplanet commands could not run as written (#187). rusthound-ce was invoked with --dc <ip_address>; RustHound-CE has no such flag — that is kerbrute's idiom (impacket/certipy use -dc-ip) — and takes -i/--ldapip for the DC IP or -f/--ldapfqdn for its FQDN. And two pivot lines invoked bare proxychains, which is not a binary on this layer's own box: the manifest ships proxychains4, that package installs only /usr/bin/proxychains4, and its Provides: proxychains is a virtual-package relation, so apt-file search '/usr/bin/proxychains$' matches nothing. The audit that filed this guessed the second one was "probably fine … one command -v settles it"; it was run, and it isn't. Both lines now carry the reasoning inline, since --dc is right for kerbrute two folds up and the next reader will otherwise "fix" it back.
cifs-utils was missing from the manifest (#187). hacktheplanet mounts a share with mount -t cifs twice — once in the SMB fold, once on SYSVOL inside the GPP-cpassword block — and nothing in offensive-packages.txt provided mount.cifs. smbclient *browses* a share; mounting one is a separate package. This was the only real gap of the six the audit alleged: samba-common-bin and gcc-mingw-w64-i686 were false (smbclient ships /usr/bin/rpcclient; mingw-w64 provides i686-w64-mingw32-gcc), and the rest had already landed with #186.
The manifest's own accounting claim was false again (#187). The target-dropped block claims it "accounts for every tool the DOCS *and* the COMPANION CORPUS name", and seven doc-named tools were unaccounted for. pspy joins the block properly — it is genuinely target-dropped, and ippsec names it in the same breath as linpeas. The other six (macro_pack, PowerUpSQL, and the Donut/sRDI/ConfuserEx/ScareCrow loaders from evasion) get a stated exclusion instead of a listing, because they are operator-side *payload-build* tooling that runs on Windows: not target-dropped, not in Kali apt, and not something a Linux apt list should imply it can install. Either a tool is listed or the manifest says in one line why it isn't — which is what makes the claim checkable.
ldapdomaindump was installed twice and invoked never (#187). It arrives by apt (python3-ldapdomaindump) *and* by pipx on the non-Kali route, and OFFENSIVE-METHODOLOGY.md lists it — but no command anywhere under offensive/ ran it, making it the only installed AD-enum tool with no copy-paste line. It now has one in the AD fold, writing to loot/ldd to match the methodology table. The manifest records the apt-name/binary-name split, the same dual-name trap already documented for impacket and certipy. Not acted on from #187: the bloodhound-python finding was already fixed at HEAD (the audit ran against a pre-b294258 tree — every line number in it is stale by 11–15, and it cites os/kali.conf, deleted 2026-08-18). The -M wmi-event finding is real but worse than filed — that NetExec module does not exist in *either* spelling — and lives in generated content, so it was fixed upstream in htpx#73 and arrives here on the next companion sync.
Seven packages, behind eight commands hacktheplanet invokes, had no manifest line (#186) — ftp, showmount, dig, nslookup, mysql, psql, redis-cli and i686-w64-mingw32-gcc. The Service-enumeration block states its own rule — *every fold's primary command in PATH* — and five folds were not honouring it. ftp, nfs-common and bind9-dnsutils join that block; the other four get a new block of their own, because checking kali-meta's debian/control showed the audit's framing was too generous: no Kali metapackage names mariadb-client, postgresql-client, redis-tools or mingw-w64, so those four lines in hacktheplanet fail on a stock box, not just a slim one. mingw-w64 is the sharpest — build-essential gives you native gcc only, so nothing else on the box covers the cross-compile. - Note the DNS name: it is bind9-dnsutils, not the dnsutils the audit proposed. dnsutils is a transitional binary off the same bind9 source, gone from trixie and back only in sid; the sole Kali metapackage still naming it is kali-linux-wsl. Since test/check-packages.sh resolves every name against kali-rolling, the durable name is the only safe one to pin. - No install/tools.lst change: that file's header restricts it to commands offensive/offensive.zsh probes or invokes by bare name, and none of these are. Adding them would make bootstrap's report cry wolf.
gcc-multilib was the eighth package, spotted during that pass and deferred (#186). hacktheplanet:212 runs gcc -m32 two lines below the i686-w64-mingw32-gcc line above, and fails for the identical reason: build-essential's gcc is native x86-64 with no 32-bit libs, so rebuilding an old PoC dies on <bits/libc-header-start.h>. No Kali metapackage names it either, so it joins the *does-not-ship-by-default* block rather than the slim-install one.
PrintSpoofer64.exe and GodPotato were the target-dropped block's one blind spot (#186). That block promises to account for *every* tool the docs and the corpus name; these two arrive from the corpus inside hacktheplanet's companion:gen potato-seimpersonate block, which is how they slipped it. Two UPSTREAM → lines now, matching the linpeas/winPEAS treatment.
redup's help advertised a step that always no-ops (#186). Both help strings and aliases.md promised a refresh of "the go-installed tools", but go_fast_movers has been () since kerbrute was dropped as upstream-frozen. The strings now describe what the function does; the block comment still records why the array is empty and how to re-populate it. aliases.md also gains katana, which it had missed since redup started driving it.
doggo, carapace and sesh never installed on a fresh box. mise lands in ~/.local/bin, which is not on PATH during bootstrap, so the go install fallback's command -v mise always missed. A PATH prelude fixes this and the related re-install-every-run behaviour of atuin.
A symlink cycle in the .zshrc wiring. bootstrap.sh re-did a link the library already makes, bypassing the ELOOP guard in _blib_seed_zdotdir_rc.
bootstrap.sh no longer silently installs nothing when install/packages.txt is missing.
apt_install's per-package retry keeps --no-install-recommends.
The bootstrap workflow's path filter omitted install/ and wsl/, so package-list edits never re-ran the bootstrap test. Filters removed.
dotsync hardcoded ~/dotfiles-Offense; it now resolves this checkout.
The offensive tmux binding shipped even when its script was not linked, and hardcoded ~/.config against an XDG-aware bootstrap.
@batt_enable was unconditionally off "because WSL has no battery" — now detected, so bare-metal laptops keep the widget.
ssh/config pinned modern-only crypto on Host *, which refuses to negotiate with the legacy targets an offensive box exists to reach. Scoped to your own infrastructure.
pseudo-shell.py proxied through Burp by default, so every request failed opaquely when Burp was not running; now opt-in. Its requests dependency documents a PEP 668-compatible install path.
redup printed "go not installed" for an intentionally empty tool list, and ran searchsploit -u without the privilege its root-owned checkout needs.
The README opens with a rendered terminal hero (dotgibson/dotfiles-core#948). assets/demo.gif is filmed from assets/demo.tape, which dotfiles-core generates from one shared template for all nine OS and role repos — the same tour everywhere, plus the one command that is this repo's own: core status showing the role layer live over the OS layer. The tape is generated (edit dotfiles-core's assets/hero.tape.in, not the tape); re-render with vhs assets/demo.tape on a Debian box with this role layered on top after a prompt or tooling change, then gifsicle -O3 --lossy=80 --colors 64 — the raw render is over Core's 2 MiB ceiling, the optimised one is not.
core-verify asks the integrity question again, and core-check gets the freshness one back (dotgibson/dotfiles-core#691). Adopting the fleet vocabulary pointed the canonical core-verify at test/check-core-freshness.sh and demoted core-check to an alias of it. Those are two different questions: freshness is *is there a NEWER Core upstream?*, integrity is *is THIS core/ the tree core.lock pins?* — and Core's scripts/make-vocabulary.txt defines the canonical verb as the second. So the register read green on a target answering something else, while this repo still had no local integrity check at all: core-integrity.yml ran one in CI and nothing ran one here. core-check is a real target again, with help text naming its question, and core-verify delegates to Core's own scripts/core-integrity.sh from a CORE_REPO checkout — the same invocation CI uses, and the only implementation that knows how the fan-out filters the vendored subtree. Verified both ways against a sibling clone at core v6.1.0: core-verify reports pristine, core-check reports current.
Three tool decisions recorded so the next scout cycle doesn't re-raise them. All three were proposed by #260 and all three were declined, on stated grounds rather than by omission: - gh in redup's go_fast_movers (item 9). It has the profile the machinery was kept for — go-only, apt-absent since kali-rolling dropped 2.46.0-3 on 2025-12-10, and genuinely fast — but upstream supports the release binary and GitHub's own apt repo, not go install, and a bare go install build reports an unset/dev version string. The entry would replace a correct build with one that cannot report its own version. Recorded in the comment beside the katana rejection already there, so the array is now empty for two stated reasons rather than one. - trufflehog. In kali apt (3.94.3-0kali1) and a good fit for what the Cloud / SaaS / CI-CD block is for — "find the leaked key" is the missing first step of most of the gh-*/npm-*/pypi-* supply-chain entries. Held out on this file's own membership rule, not on merit: no doc and no corpus entry invokes it. The doc edit is the prerequisite; the note says so, and says it becomes a plain apt line once one does. - PrivescCheck — same rule, same note, beside the PowerUp.ps1 entry it would complement. The report conceded the prerequisite for this one and not for trufflehog; the rule applies to both identically.
make view-counts / test/check-view-counts.sh — a gate on the hand-typed corpus counts in hacktheplanet, PURPLE-TEAM.md and OFFENSIVE-METHODOLOGY.md. Two of those files already carried a caveat saying the numbers go stale on every companion-sync; this executes it. It exists because the drift above is a repeat — the same fix is recorded for the v2.10.0 sync — and nothing could see it: gen-views.sh --check byte-compares block *contents* and has no opinion on how many blocks exist, and markdownlint cannot tell 101 from 102. It is repo-owned rather than an extension of gen-views.sh because offensive/companion/ is a vendored subtree and an edit there is lost on the next sync. It checks only what is mechanically derivable (entry totals, projected-block counts, and the sums of those); the semantic buckets — 56 cloud/SaaS/ CI-CD, 13 C2-egress/Impact, 7 Linux, 69/76, the percentages — are deliberately ungated, because nothing in entries/*.md marks an entry "cloud". Exit 2 means a stale count; exit 1 means an anchored sentence was rewritten and needs re-anchoring — two different failures, so a maintainer is never told the wrong one.
The SMB enum fold and its detection are now entry-backed. companion.lock bumps to htpx b80741f, which pairs smb-enum-nxc with a new smb-enum-5145 blue entry (htpx#97), and both sides are wrapped in companion:gen markers: the nxc commands in hacktheplanet's SMB fold, and the detection in PURPLE-TEAM.md's recon section. The hacktheplanet block is the notable half. Those five nxc smb lines have been hand-written since the file existed, and the entry upstream carried only three of them — so wrapping them would have silently deleted --loggedon-users and the /24 spray. htpx#100 widened the entry to the full fold with its inline comments matched, which makes render_red reproduce the existing lines byte for byte: the diff to hacktheplanet here is the two marker lines and nothing else. That is the bar for putting a marker around prose that was already good — the tempting shortcut, wrap it and let the generator win, loses content nobody notices for months. This sync also brings pair_note: (htpx#98), which the vendored copy predated: an entry carrying pair: null must now say why, and upstream CI rejects one that does not.
ROADtools is now installed by --install, on every route (#231). Entra/M365 tooling in this layer was entirely Windows-side (AADInternals, TeamFiltration, MSOLSpray); the corpus even cited dirkjanm's ROADtools as device-code-phish's source: while handing you Windows-only PowerShell. bootstrap.sh grew an _install_apt_absent step — a THIRD install category for tools no route can apt-install — that pipx-installs roadrecon and roadtx on the Kali path too, not just the portable subset, so the Entra corpus entries finally run from the attacker box. hacktheplanet's M365 fold documents the Linux commands and install/offensive-packages.txt carries the annotation. The corpus entry's own platform:/source: fix lands upstream in dotgibson/htpx.
SCCM/MECM is now covered — it was a total blank (#230). grep -ri sccm over the repo used to return nothing, despite site-server takeover and Network Access Account extraction being mainstream AD attack surface. Added a SCCM / MECM fold to hacktheplanet (discovery -> NAA over the network or from a compromised client -> site takeover -> PXE boot-media creds), a Cred access row in the methodology map, and sccmhunter + pxethiefy UPSTREAM annotations in install/offensive-packages.txt (whose corpus-only block widened to admit doc-named operator tooling). sccmhunter is documented, not wired into --install: it is not on PyPI, its git install carries a Python 3.13 floor and an ldap3 fork pin pip drops silently, so a documented manual step beats a best-effort loop that fails quietly. Every command verified against upstream.
corpus commands resolve — a gate for the question nothing asked (#208). Two entries in coerce-petitpotam once invoked impacket-petitpotam and dfscoerce. Neither is a real command, both shipped in a released corpus, and a human reading the file found them — because every existing gate looks somewhere else: gen-views.sh --check byte-compares the 18 *projected* red blocks (85 of 103 are unprojected), check-packages.sh reads the manifest and never the corpus, companion-integrity checks provenance rather than content, and htpx's own CI checks pairing and slots rather than existence. test/check-corpus-commands.sh resolves the first token of every command line in every red entry against the manifest, an impacket-binaries.lst roster, and the classifications in install/corpus-commands.lst. Offline and deterministic, so unlike packages-check it can be required; wired into make test and its own workflow. - Its --self-test rebuilds the pre-v2.8.0 coerce-petitpotam at run time and asserts the gate still reddens on it — a regression guard for the gate itself, since one that quietly stopped catching its own motivating bug would pass forever. - install/corpus-commands.lst requires a line of prose on every classification, so "whatever the allowlist excuses, it says so" is enforced rather than hoped for. It also fails on a classification no entry uses any more, so a companion-sync that drops an entry surfaces its dead excuse. - The roster spans two packages: impacket-scripts (57 wrappers) and python3-impacket (5 more, including impacket-secretsdump and impacket-wmiexec). A roster built from impacket-scripts alone would have failed the two most-used commands in the corpus. packages.yml gained an advisory step that re-derives it from kali-rolling and diffs, so the checked-in copy cannot rot unnoticed.
Five tools the corpus invokes and nothing accounted for, all surfaced by the new gate on its first run: ldap-utils (ldapsearch, a real apt package, now installed), plus UPSTREAM entries for evilginx2, MSOLSpray and tfc-agent in a new "Corpus-only operator tooling" block. tfc-agent also corrects this file's older claim that the Terraform Cloud entries are pure REST. The legacy bloodhound-python binary is now named in the BloodHound block, with the warning that the entry invoking it is wrong for a CE stack — that fix routes upstream to htpx.
A gate against leaked RETURN traps (#198) — test/check-return-traps.sh, wired into make lint as make trap-guard and into CI as the return-traps job in checks.yml. A bash RETURN trap is a global slot, not a function-scoped one: armed inside a function it survives into the *caller's* frame and fires a second time when the caller returns, where the local it cleans up is out of scope and set -u makes that fatal. In dotfiles-Debian that aborted provision() after every package had installed but before wire_links ran — the whole stack on the box, and not one symlink. Nothing else can see it: the broken line is valid bash, so shellcheck and bash -n both pass it, and no CI job in this fleet exercises a real install path (every bootstrap run is --links-only). Hence a grep. The correct form is trap 'trap - RETURN; rm -rf "$tmp"' RETURN. This is prevention, not a fix. #198 reported the bug in this repo's verified_install(), but that function — and the entire SHA-pinned out-of-band install block around it — left in the layer split (6d641d2), which moved it to dotfiles-Debian; the trap went with it. No repo-owned shell here arms a RETURN trap today. The guard exists so none ever does again. zsh is out of scope: it has no RETURN signal at all.
Makefile — the entry point (make lint, test, core-sync, packages-check, …). Makes core.lock's make core-lock instruction true for the first time.
scripts/sync-core.sh, test/check-core-freshness.sh and a freshness workflow — the consumer-side core-sync line, which three files already referenced and none provided.
test/check-companion-integrity.sh — tamper detection for the second vendored subtree, mirroring core-integrity.
test/check-packages.sh + a packages workflow resolving every manifest name against kali-rolling.
markdownlint in CI, against the .markdownlint.jsonc that had been sitting unused.
SECURITY.md, CODEOWNERS, issue and PR templates, CONTRIBUTING.md, .shellcheckrc, .editorconfig, .gitattributes.
bootstrap.sh --dry-run and --no-upgrade.
companion_version / companion_tag in companion.lock, for symmetry with core.lock.
bootstrap.sh runs on Core's bootstrap driver, blib_main (dotgibson/dotfiles-core#986). The shared half — the flag loop, the Core symlink surface, the band-85 role stage, the managed ~/.zshrc, the closing report — now runs from one definition in core/lib/bootstrap-lib.sh. This file declares what it is (BOOTSTRAP_ROLE=offensive, BOOTSTRAP_LOGIN_SHELL=0, and BOOTSTRAP_SU=lazy: the driver resolves no escalator and primes no keepalive, because only --install's Kali apt route needs them and install_offensive does both itself at the point of need) and keeps only what is offensive: the host-tool probe as bootstrap_check (which also runs the installer's own dry-run preview under --dry-run --install), the opt-in stack as bootstrap_provision (install_offensive unchanged), the pre-role-layer migration, the prefix + e popup script and the ~/ field references as bootstrap_wire_pre_loader, the engagement note plus the login-shell guard — now Core's blib_login_shell_hint, which Defense carried the same copy of — as bootstrap_closing, and --install / --no-check plus the two deprecation shims through bootstrap_flag. The --links-only + --install contradiction is refused in bootstrap_guard as before. 619 → 551 lines. One convention change: an unknown flag exits 2 (usage error), not 1. Same links, same routes, same exit codes otherwise; a plain run still never prompts for sudo.
bootstrap.sh --install runs on Core's escalation, sudo-keepalive and failure-tally helpers instead of bare sudo (dotgibson/dotfiles-core#973). The Kali apt route hard-coded sudo three times and primed it with a one-shot sudo -v whose own comment promised to "keep the timestamp warm" — one line before "go get coffee". It now resolves the escalator once with blib_resolve_su (root runs directly, else sudo, else doas, by absolute path), runs the three apt calls through blib_priv, and keeps the timestamp warm for real with blib_sudo_keepalive_start / _stop. Every best-effort install — the apt per-package fallback, each pipx install, each go install — now records a miss via blib_note_fail instead of an indented echo, blib_failures_report prints them together after the wiring, and a new --strict turns a non-empty tally into exit 1. Without it the exit code is unchanged: the offensive tools are optional; zsh is not. Closes this repo's rows in Core's audit-core.sh §5f ledger, including the keepalive row it had been exempt from on the theory that a role repo installs no long package sets — the apt route is that set.
BREAKING — make core-sync no longer pulls; Core arrives by fan-out (dotfiles-core#676). scripts/sync-core.sh is now report-only: it says how far behind core/ is and how Core actually gets here, and writes nothing. This repo was the fleet's one sanctioned second writer into core/. That sanction rested on a specific property, spelled out in VENDORING.md: the pull stamped core.lock from *what it actually pulled* — core_sha from the squash commit's git-subtree-split trailer, core_version from the tree on disk — so the lock could not describe a commit its own core/ did not contain. A filtered vendor removes exactly that property. Core stopped vendoring its whole tree: core/ is now core.manifest ∪ core.vendor, roughly two thirds of it. A git subtree pull merges the whole upstream tree and has no way to apply that filter, so "what it actually pulled" is by construction no longer what a vendored core/ should contain. The first pull after this repo's lock moved to a filtering commit would land every upstream file against an expectation of the subset, and core-integrity would report TAMPERED — correctly, with no hand-edit anywhere. Teaching it to filter was the alternative and is worse: it would make this repo a second producer of Core's format, which the sanction never extended to. Two implementations of one filter is the failure dotfiles-core#556 exists to prevent. In practice this changes nothing about how Core actually arrives. Every one of the last ten core.lock writes in this repo came from the fan-out, not from make core-sync. The independent cadence being given up was already not in use. make core-sync and make core-lock survive as the place that explains this — they are what someone types when asking "how do I move Core?", and that question still has an answer. --check is accepted and ignored, since every run is now what it meant.
offensive/companion/ is unaffected and still pulls. htpx vendors its whole tree and has no allowlist, so make companion-sync keeps working exactly as before. The asymmetry between the two subtrees is deliberate and is now stated in CONTRIBUTING.md, CLAUDE.md and the Makefile header — do not "fix" companion-sync to match.
ptunnel-ng added beside ptunnel, not instead of it. ptunnel-ng (utoni/ptunnel-ng, redirected from lnslbrty) is the maintained fork and kali's 1.43-2 *is* its latest upstream release, so it is the one to reach for. The original ptunnel line stays for one mechanical reason: the corpus entry entries/red/icmp-tunnel-c2.md invokes the bare ptunnel binary, and test/check-corpus-commands.sh — offline and required — resolves that name against the manifest, so dropping the line reds a blocking gate. ptunnel-ng cannot cover for it either: it ships only /usr/bin/ptunnel-ng, with no ptunnel binary and no alternatives symlink. Retargeting the entry is an upstream change in htpx — entries/ is a vendored subtree — and it is a rewrite, not a rename: the two are not flag-compatible, -lp/-da/-dp having become -l/-r/-R.
The covert-egress block now carries a status per tool. It read as though all four were current upstreams; not one is. iodine is a full release behind (kali 0.7.0-13 vs upstream v0.8.0); dnscat2 is frozen at its own last release (v0.07, 2016 — so apt is *not* behind, there is simply nothing newer); ptunnel is frozen at 0.72; icmpsh is dead (last push 2018, never released).
sliver's currency note no longer hardcodes a patch count. "TWO patches behind" was accurate when written and wrong within five months. It now states the shape — apt froze at 1.7.1-0kali4 while upstream kept releasing — and points at sliver-server version instead of a number that rots.
Freeze and archive statuses added where the file asserted none. PrintSpoofer (archived Sep 2024) was the last unmarked freeze in the target-dropped block; hashid is frozen at Jun 2022 while the line calls it "the cracking entrypoint"; the evasion payload-build paragraph carried no status for any of its five tools (macro_pack and ScareCrow archived, Donut alive, sRDI static since 2023). Each is kept — these target behaviours and formats that have not moved — with the reason stated.
The ConfuserEx pointer now names the mkaring fork. Canonical yck1509/ConfuserEx is archived and has not moved since 2019, so a bare "ConfuserEx" landed a reader in a dead repo — the failure the kwp-vs-iphelix/pack note already guards against elsewhere in the file.
ligolo-ng loses its "(upstream if repo build lags)" hedge. Kali tracks it closely (0.9.1-0kali1 within ~2 weeks of upstream v0.9.1), so the hedge invited a hand-built pivot that is almost never warranted.
The hexyl note's reason is narrower than it claimed. "There is no hexyl package in Kali at all" is false: the rust-hexyl source package, which builds a hexyl binary package, has been imported into and removed from kali-rolling repeatedly (0.4.0, 0.5.1, 0.7.0, 0.8.0, most recently 0.16.0-4), following Debian testing. It is out right now, so the conclusion — keep it out, it would hand check-packages.sh an unresolvable name — is unchanged, but it can return without warning.
rusthound-ce's version pair replaced with its cadence. The quoted v2.4.91 -> v2.5.2 in seven weeks had itself gone stale (seven releases shipped in the eight weeks to Aug 2026). The mechanical reason it stays out of redup — cargo, no self-updater, go_fast_movers is go-only — is unchanged.
redup's ffuf/gobuster note now separates ownership from currency. "apt-packaged ones (gobuster/ffuf) update via up" implied apt keeps them current. apt *owns* them, so up is the only correct route and neither belongs in go_fast_movers — but kali's ffuf is 2.1.0 (Jan 2024) against an upstream that resumed releasing at 2.2.x. The routing claim stands; the currency implication does not.
packages.yml now emits ::warning:: annotations as well as its job summary. It stays advisory — Kali is rolling, and a package that vanishes mid-migration must not red an unrelated PR — but summary-only proved to be the same as silent: three unresolvable apt names sat in the manifest while the job reported them into a page nobody opened and exited 0 every week. Annotations surface on the Checks and Files tabs without changing any exit code. Its header also claimed you could make the job blocking by dropping a || true that does not exist in the file; the real lever is the exit 0 at the end of the resolve step.
Five currency annotations corrected (#211). adaptixc2's said upstream publishes zero releases so there is "no tag to judge staleness by" and that it rolls on main — both false: v1.0/v1.1/v1.2 are tagged, main last moved 2026-03-04, and work is on version branches. That premise was load-bearing for the fingerprinted-default-profiles warning above it. sliver's apt lag is two patches, not "~a patch". caldera entered the Apache Incubator 2025-12-19, not May 2026. rusthound-ce's "collectors aren't daily-churn" reasoning is dead (v2.4.91 → v2.5.2 in seven weeks) — the conclusion survives for a mechanical reason instead: cargo, no self-updater, and redup's loop is go-only. And mitm6 finally gets the FROZEN note that kerbrute and havoc already carried.
hexyl's absence from the manifest is now recorded there. It is not a Kali package at all, so listing it would hand check-packages.sh an unresolvable name; the deferral to dotgibson/dotfiles-core#395 is written down instead, so the packages list and install/tools.lst agree.
Three of the four field references now point at the corpus. exploitdev, ippsec and evasion listed their sibling references but omitted ~/companion (htpx), which hacktheplanet has always carried. evasion was the sharpest case: its "Network-filter & egress bypass (C2 channels)" fold is prose-only by design, and the six entries holding the actual commands (dns-tunnel-c2, icmp-tunnel-c2, domain-fronting-cdn, https-beacon-sliver, mtls-c2-sliver, web-service-c2-telegram) live only in the corpus — with no route to them from the doc that needed them most. Its footer is also restyled to match the other three (~/name + alias, not offensive/name).
evasion opened with bare vim. It told you to run vim ~/evasion, bypassing the read-only opener that exists so an errant :w cannot publish engagement data — the one reference of the four that did. Now leads with evade, matching exploitdev.
hacktheplanet gained the two commands the corpus had and it did not (#212). The coercion fold described "many vectors" but never showed the MS-DFSNM one, and the pivot fold described ligolo-ng in prose with no command line at all. Both are now present, so the header's claim that these entries are "covered better below" holds again.
The last OS-layer file is gone, and the role wiring is Core's now. os/kali.conf carried the prefix + e engagement popup as *role* config living in an *OS* overlay ($CONFIG/tmux/os.conf), because Core had exactly one tmux overlay hook when it was written. Vendoring Core v4.13.1 brings the second hook, so the binding moves to offensive/offensive.conf → $CONFIG/tmux/role.conf, and os/ is deleted outright. Two consequences worth stating plainly: - role.conf is sourced last by Core's tmux.conf, after Core's own bindings. os.conf is sourced before them, so a future Core bind e could have silently taken the key back. That ordering is the actual reason the hook exists. - dotfiles-Debian and this repo no longer race for $CONFIG/tmux/os.conf. Until now whichever bootstrap ran last won it; the OS repo owns band 80 alone again. The battery and net-speed status probes did not move here — they are OS-native and dotfiles-Debian's os/debian.conf already carries them.
bootstrap.sh calls blib_link_role_layer instead of hand-rolling three links. The block it replaces had already drifted from dotfiles-Defense's copy of the same wiring: Defense honoured BLIB_DRY when dropping the stale pre-v4 link and this repo did not, so --dry-run mutated the box here and not there. One shared definition ends that class of drift.
Templates moved to $CONFIG/offensive/templates (from $CONFIG/kali/templates) — named for the role rather than the distro, matching Defense's $CONFIG/defense/. The two shipped docs that quote the path by hand, offensive/hacktheplanet and offensive/ippsec, are updated in the same change. Core deliberately declined a compat symlink, since it would preserve a ~/.config/kali/ on a repo no longer called Kali.
Bootstrap now cleans up after the old wiring. A box bootstrapped before this change carries $CONFIG/tmux/os.conf and $CONFIG/kali/templates pointing into this checkout; both dangle afterwards. Each is removed only when it is a symlink resolving inside this repo, so a box also running dotfiles-Debian never has that repo's live os.conf touched, and --dry-run only reports.
This repo is now a pure Role layer. It used to be both the OS-native layer for Kali *and* the offensive role on top. dotfiles-Debian now covers the Debian family properly and accepts ID=kali as a first-class target, so the OS half moved there and what is left here is the role. Concretely: - Removed: os/kali.zsh, os/kali.gitconfig, install/packages.txt, install/tool-versions.env, scripts/update-tool-checksums.sh, wsl/, ssh/config. Every one of them has an equivalent in dotfiles-Debian, whose package list carries the Kali tier as # only:kali annotations. - bootstrap.sh is distro-agnostic and installs nothing by default. The ID=kali gate, the apt base install, the full-upgrade, the SHA-pinned verified_install block, the carapace .deb, the 1Password repo and the /etc/wsl.conf write are all gone — they belong to the OS-native layer. What replaces them is a report: a three-state host-tool probe (on $PATH / present-but-unreachable / missing), modelled on dotfiles-Defense. - --install is the new opt-in. On Kali it apt-installs install/offensive-packages.txt as before. On any other Debian-family box it installs a small portable subset via pipx (impacket, certipy-ad, netexec, bloodyAD, ldapdomaindump) and go (nuclei, gobuster, ffuf, kerbrute). On anything else it refuses and says why rather than guessing at a package manager. - --no-offensive and --no-upgrade are accepted but inert, with a note — the behaviour they asked for is now the default, so aborting on them would be worse than honouring them. - --links-only with --install is refused: one wires symlinks only, the other installs packages.
install/tools.lst is new — the host-tool probe list, and the one place it is written. Twin of dotfiles-Defense's. A command belongs there only if offensive/offensive.zsh probes or invokes it by bare name.
offsync replaces this repo's half of dotsync. dotsync came from os/kali.zsh and now belongs to the OS-native layer (band 80). offensive.zsh exports $DOTFILES_OFFENSE and binds offsync to it — a distinct verb, because reusing dotsync at band 85 would silently shadow the OS layer's.
test/check-packages.sh and make packages-check now check one manifest (install/offensive-packages.txt); make tool-checksums is gone with the pins.
The gating workflows (lint, bootstrap, companion, routine-filter) no longer use trigger-level path filters: a paths:-skipped workflow produces no check run, so requiring one would hang every non-matching PR.
os/kali.gitconfig no longer duplicates Core's init.defaultBranch, and os/kali.zsh no longer duplicates Core's ~/.local/bin PATH prepend.
offensive/templates/engagement.md documents the layout mkengagement actually creates.
pipx installs different binary names than Kali does. PyPI's impacket ships secretsdump.py, not Kali's impacket-secretsdump wrapper; certipy-ad ships certipy. offensive.zsh probes the Kali names, so those HAVE_* flags do not fire on a pipx box. The bootstrap's probe recognises both names, so the report is honest; teaching the shell layer to resolve both is a separate change.
The WSL Git-Credential-Manager note that lived in os/kali.gitconfig (how to point credential.helper at the Windows host's GCM) did not travel with the file. It belongs in dotfiles-Debian's git overlay now that that repo owns WSL.
github_credential_backdoor now also selects personal_access_token.request_created (#348). This follows htpx's gh-cred-audit (htpx#139). GitHub logs the request only in orgs that require approval for fine-grained PATs. There it is the moment an attacker-minted token can still be denied, and a denied request never produces the access_granted the rule already selected. Neither PAT action had a fixture before. Each now has its own single-event TP row, because zircolite passes a TP file when any event in it matches, so a shared file would hide a typo in either action name.
npm_publish_2fa_disabled and slack_2fa_enforcement_disabled are retagged from T1685 to T1556.006 (#346). Both fire when an MFA requirement is weakened, which is an authentication-process change rather than tampering with a security tool. Upstream htpx retagged the paired entries the same way in htpx#134, and okta_mfa_factor_reset already uses T1556.006 for this shape. The tactic stays defense-impairment, which T1556.006 carries in ATT&CK v19.2 and which matches upstream's TA0112. Detection logic and fixtures are unchanged.
htpx.pin moves to htpx v3.3.0, and both DPAPI rules name dpapi-backupkey-4662 (#336). Upstream retired entries/blue/dpapi-backupkey-5145 in favour of dpapi-backupkey-4662, which keys the theft on the 4662 LSA secret read and keeps protected_storage 5145 as its secondary arm. The 5145 rule's references: URL was a live 404 on /blob/main/ while the gate, still reading the old pin, stayed green. Both rules now reference the 4662 entry: - dpapi_backupkey_5145: the MS-BKRP hunt. - dpapi_backupkey_secret_read_4662: the theft alert. So HTPX-COVERAGE.md lists the pair under the 4662 entry. The regenerated report also shows upstream's npm/slack 2FA retag to T1556.006. The two rules here still carry T1685, and whether to follow is a separate review call.
Detection review 3, section C: the two findings that needed lab or estate data. - dpapi_backupkey_secret_read_4662 keeps SYSTEM in scope, on purpose. Running the extraction on the DC from a SYSTEM context (psexec to the DC, then mimikatz lsadump::backupkeys or SharpDPAPI backupkey) records S-1-5-18 as the subject, so excluding SYSTEM would hide common post-compromise tradecraft. A new TP covers that read. The rule says what to do if a lab capture shows BackuprKey servicing logging as SYSTEM: split it into a lower-level rule, don't exclude it. - The GPP User-preferences sweep threshold (10) is now DEPLOY-REQUIRED, with a README deploy-time row. The count is of distinct paths, and each path carries its GPO's GUID, so the threshold has to clear what one user reads across all their linked GPOs. The TP sweep now spans four GPOs, and a new logon TN reads six files from three.
Detection review 3, sections A and B: defects in the review-2 rewrite, and older claims the rewrite carried forward. Each fix carries a fixture that the old rule gets wrong. - lsass_handle_access: - The lab Sysmon config still dropped MsMpEng.exe/MsSense.exe by bare file name (condition="image"). A dumper renamed to either never reached the rule, whatever its filter said. The exclude now uses Defender's install directories. - The "PROCESS_VM_READ bit" match covered only low nibbles 0/8/A, so 0x1411 got through. All 128 read-capable suffixes are now listed, plus 0x40 (PROCESS_DUP_HANDLE). - The description no longer claims this is SigmaHQ's list. - registry_run_key_suspicious_target_sysmon_13: the generic ~1\/~2\ entries matched PROGRA~1/PROGRA~2 (Program Files), which the rule deliberately leaves alone. They are replaced with \PROGRA~3\, \APPDAT~1\ and \LOCALS~1\. - wmi_event_subscription_consumer: - The Destination allowlist gated only one of three arms, so an SCCM cmd.exe /c consumer could not be exempted. It now covers every arm. - Consumer deletions no longer alert (Operation: Created). - pwsh is added. - suid_bit_set: the /usr/bin/yum and /usr/bin/dnf entries are removed. Both tools run under Python, so the entries never matched. Review 2 removed the same entries from the cron and systemd rules and missed this file. - vault_approle_backdoor: secret-id lookup and destroy are routine CI housekeeping, and they alerted even for the allowlisted orchestrator. They are now excluded by exact suffix (/secret-id/lookup, /secret-id-accessor/destroy, ...). A contains: '/secret-id/' would also have hidden a mint written with a trailing slash (.../secret-id/, which Vault routes as a mint) and every role write on a mount named secret-id. Both lists are pinned to the AppRole mount (auth/approle/role/, DEPLOY-REQUIRED if yours differs). A bare suffix also exempted vault auth enable -path=x/secret-id/lookup, a /role/ substring exempted an LDAP group named role/secret-id/lookup, and the orchestrator's mint exemption covered creating a role named secret-id. - snowflake_data_unload: each allowlisted stage now takes two entries, @STAGE/ and @STAGE followed by a space (a root unload). A bare @MY_SANCTIONED_STAGE had also exempted @MY_SANCTIONED_STAGE_EVIL. The rule now says to write stages fully qualified, since an unqualified name resolves in the caller's own schema, and its example entries are qualified. - scheduled_task_suspicious_4698: it now matches every prefix of -EncodedCommand behind one or two of the switch characters PowerShell accepts (-, /, en dash, em dash, horizontal bar), with quotes or cmd carets allowed before and inside the flag (-"e"c, -e^c, ^-e), then a space or tab, then a payload: 20 base64 characters that may be split by quotes or carets, a quoted payload with spaces inside, or a variable (%p%, !p!, $p); behind / only for /ec and longer, since robocopy's /E %OPTS% is common. It also covers pwsh tasks. Quotes and carets are not accepted before the dash: they already count as a boundary, and allowing both made the pattern quadratic on a long run of quotes. Requiring the payload keeps -Encoding utf8, URLs, robocopy's /E and a script's own short -e prod argument out; a long base64-looking -e value still matches. Elastic needs the rewritten regex given in the rule. - Splunk: two rules could never run. When a regex sits inside an OR, the Splunk backend emits a search with no base (search = | rex ...), which Splunk rejects. That killed service_creation_psexec_7045 (since #337) and the new 4698 regex. gen-siem.sh now lifts the final search's source=... EventCode=N into the base. It fails the build instead of guessing when the shape differs: anything other than | rex/| eval before the final search, an EventCode that is an OR operand (lifting it would narrow the rule), or an empty or pipe-first search = it does not recognise. - spoolss_pipe_impersonation_sysmon_17: - The description no longer names GodPotato, which stands up an epmapper pipe. The sibling rule already says so. - A TP with PrintSpoofer's real nested pipe name (\<guid>\pipe\spoolss) is added. - unconstrained_delegation_4624: - The description no longer claims the "machine account" variant. When that account's host is krbrelayx on Linux, no Windows host writes a 4624. - The DC filter uses exact FQDNs. startswith: 'DC1' exempted DC10. - systemd_unit_persistence: the documented auditd watches now include /lib/systemd/system/ (split-/usr) and the global user-unit directories, each on a line that pastes straight into an audit rules file.
Detection review 2, section E: pairing and methodology bookkeeping that misstated coverage. No detection logic changes. - HTPX-COVERAGE.md under-claimed three pairs that have detections. It listed unconstrained-deleg-4624, cryptomine-pool-detect and dga-nxdomain-entropy under "nothing here claims". Each detection named its pair in prose the claim regex could not parse: "PURPLE-TEAM … row", "companion pair", or "htpx pairs (". Each now carries the htpx blue-entry URL. - dns-tunnel-sysmon-22 stays unclaimed on purpose. Its source is Sysmon DnsQuery alone, which the lab config does not collect, and dns-c2.zeek is its wire twin rather than an implementation of it. The script now says so. - DEFENSE-METHODOLOGY.md table rows no longer name planes that have nothing in them. - Lateral Movement claimed "Zeek SMB", but no such script exists. - Recon claimed Zeek, but the only Zeek Kerberos script is credential access, so it moved to that row. - The Responder and LOLBAS validation cells pointed at folds with no detection behind them. They now say "no detection yet". - Sources are corrected to the events the rules actually read. - An Initial Access paragraph had drifted. It sat after the Sysmon-13 RegistryEvent paragraph, so "the *registry* plane … Both rules" read as the two registry rules. It is back under the Initial Access paragraph it explains. - The Entra count is split out. "Six pairs of Entra/M365 identity coverage sat in detections/sigma/cloud/" now says three are Sigma rules there and three are Sentinel joins.
Detection review 2, section B: rules that could not see the attack they claim. From the 2026-09-28 follow-up review. Each fix was checked against the tool's own source where one exists, and each carries a fixture the old rule gets wrong. Host validation is 120/120 with 92 true negatives (was 114/114 with 84); cloud stays 44/44. - Kerberoasting was RC4-only. GetUserSPNs, nxc and Rubeus do not force RC4, so an AES-only service account yielded a 0x11/0x12 ticket that kerberoasting_rc4_tgs never saw. New kerberoast_spn_burst_4769 counts distinct user SPNs per source address, 5 in 10 minutes, in any encryption type. The RC4 rule stays as the single-event fast path, and its description no longer says the tools force RC4. - DPAPI backup-key theft was watched on the wrong channel. SharpDPAPI backupkey, dpapi.py backupkeys and mimikatz read the G$BCKUPKEY_* LSA secret over LSARPC (verified in SharpDPAPI lib/Backup.cs). New dpapi_backupkey_secret_read_4662 covers the DC-side 4662 SecretObject read (T1003.004). dpapi_backupkey_5145 is demoted to a low-severity MS-BKRP hunt and says what protected_storage actually carries. - scheduled_task_suspicious_4698 missed impacket atexec. atexec's cmd.exe /C … > %windir%\Temp\<x>.tmp 2>&1 is now matched (checked against atexec.py), along with PowerShell's -e/-ec/-w 1/-win h and a path-less mshta. The atsvc pipe rule's claim that the 4698 rule catches atexec is now true, and it notes the -silentcommand exception. - Allowlists keyed on a name the attacker can take: - spoolss_pipe_impersonation_sysmon_17 now requires the exact C:\Windows\System32\spoolsv.exe; PrintSpoofer renamed to spoolsv.exe had been filtered. - cron_persistence, systemd_unit_persistence and ssh_authorized_keys_write now use exact exe paths instead of basename endswith (/tmp/dpkg). - Their dnf, yum, cloud-init, ansible, salt, puppet and chef entries are removed. Those tools run under a Python or Ruby interpreter, so auditd records the interpreter as exe and the entries never matched. The same applies in suid_bit_set. - lsass_handle_access now matches on the PROCESS_VM_READ bit (SigmaHQ's suffix list) instead of five exact masks. 0x1f0fff and 0x410 got through before. - k8s_pod_exec_attach: - It matched create only. WebSocket exec from kubectl 1.30+ is audited as get, so both verbs are now matched. This is an assumption to confirm against a real apiserver. - It dropped every service account, including a token stolen from a pod. It now allowlists named controllers only. - vault_approle_backdoor: - It covered only role/ paths. It now also covers users/, groups/, certs/ and map/: vault write auth/userpass/users/backdoor policies=root did not fire, and the description's cert claim did not match. - It fired on every CI secret-id mint. Those are now exempt only for allowlisted orchestrator entities. - rdp_hijack_tscon_4688 now also fires on tscon <id> run as SYSTEM with no /dest, the classic hijack. That arm is Sysmon-only, and the rule says what a 4688 deployment substitutes. - wmi_event_subscription_consumer alerted on a Command Line consumer only when it named an interpreter. A consumer pointing at a dropped binary now alerts, less a Destination allowlist. - registry_run_key_suspicious_target_sysmon_13 now also matches %APPDATA%-style unexpanded values and 8.3 short names. - New service_binary_windows_root_7045 (medium) covers a service binary directly in %SystemRoot%. It catches psexec -r <name> and impacket -service-name, which beat the PsExec rule's name-keyed branches. It is a separate rule with a vendor allowlist, not a branch of the high PsExec rule, because the PsExec rule's own TN is a vendor binary in exactly that place. - gpp_cpassword_sysvol_5145: - Users read their GPOs' User\Preferences at every logon, so a single read of those was noise. The single-event alert is now scoped to Machine\Preferences. - User preference files move to a new per-user distinct-file fan-out correlation (10 in 10 minutes). - Drives.xml, one of the six cpassword-bearing files, is added. - detections/README.md: the rule, deploy-time and backend-work rows are updated, and the header count is now 124 rules / 148 documents.
Detection review, section C (8-13): six rules that missed the attacker's variant, one mislabelled technique, and a new Kerberos enumeration rule. Each fix carries a fixture that the old rule gets wrong. Host validation is 114/114 with 83 true negatives (was 80); cloud validation stays 44/44 with more lines per fixture. - wmiexec_wmiprvse_child_4688: it required the ADMIN$ share, but impacket takes the share from -share, so -share C$ evaded it. NetExec, which the rule names, never used the loopback share at all: it redirects to \Windows\Temp\<6> or, fileless, to \\<attacker-ip>\<share>\. The rule now matches the redirect shape both tools build (/Q, /c, 1> , 2>&1) wherever it points, and gains its first TN (a WmiPrvSE-run script with no redirect). -nooutput leaves no redirect and is recorded as a gap, because a bare WmiPrvSE → cmd /Q /c is too generic to alert on. - passthehash_4624_fanout: machine accounts ($) and ANONYMOUS LOGON fan out across hosts as routine traffic and could trip the correlation on their own. The base now drops both; Kerberos stays in scope. A new TN of six WS07$ logons fires the old rule and not the new one. - asrep_roast_probing_4771 is a Kerberos password spray, not AS-REP roasting. 4771 0x18 is a bad password at pre-authentication. It is retagged T1110.003 (credential-access) and retitled; filename and ids are kept. T1558.004 stays covered by asrep_roast_4768. - New kerberos_user_enum_4768 takes over the enumeration claim the 4771 rule used to make: 4768 status 0x6 (unknown principal), distinct names per IpAddress, 10 in 10 minutes (kerbrute userenum, GetNPUsers with a user list). Tagged T1087.002 (discovery), with TP, TN and outside-window fixtures. - k8s_clusteradmin_binding: a binding's roleRef is immutable, so the realistic escalation is a patch/update adding a subject to the existing cluster-admin binding, and that patch carries no roleRef. A new arm matches it. - k8s_privileged_pod_created: a privileged initContainer, or a privileged ephemeral container attached through the ephemeralcontainers subresource, got past it. It now has arms for both, and the array-traversal DEPLOY-REQUIRED note names the new arrays. - vault_bulk_secret_read: it grouped by auth.entity_id, which Vault omits for root and orphan tokens, so a leaked root token's sweep was never counted. It now groups by auth.accessor, while the allowlist stays on the stable entity id. The secret/ prefix is only the dev-server default, so it is now a DEPLOY-REQUIRED list of KV mounts. The group-by change is proven by the compiled Splunk form (by auth.accessor). The cloud plane cannot run correlations. - snowflake_data_unload: the internal-stage filter was contains '@~'/'@%', so a stage reference in a SQL comment (COPY INTO 's3://…' … /* @~ */) suppressed an external unload. It is now anchored with startswith 'COPY INTO @~'/'COPY INTO @%', which fails toward alerting. A regex was avoided because the cloud evaluator does not run |re and Elastic would need a rewrite. COPY INTO @~ followed by GET is noted in the rule as a future correlation. The DEPLOY-REQUIRED filter_known_stages had the same contains bypass (/* MY_SANCTIONED_STAGE */) and is anchored the same way. - detections/README.md: rows updated, and the stale sigma/ header count is corrected to 121 rules / 142 documents.
Detection review, section C (1-7): seven rules with a dead term, an easy evasion, or an allowlist keyed on a name the attacker picks. Each fix carries a fixture that the old rule gets wrong: it misses the new TP or fires on the new TN. Host validation is 111/111 with 80 true negatives (was 77). - potato_seimpersonate_4688: 4688 renders NETWORK SERVICE as the computer account and an app pool as its bare pool name, so the NETWORK SERVICE term never matched and APPPOOL caught only pools with the word in their name. It now keys on SIDs (S-1-5-82-*, S-1-5-19, S-1-5-20) with name fallbacks. The fixture's IIS APPPOOL\DefaultAppPool SubjectUserName was not what Windows writes; it is now corrected, and a SYSTEM TN was added. - recovery_inhibition_process (test): it adds vssadmin resize shadowstorage, PowerShell Win32_ShadowCopy removal, and wbadmin delete systemstatebackup/backup, and recoveryenabled no longer requires the literal no. The old rule caught 1 of the 6 forms; the new rule catches all 6, and read-only near-misses stay silent. - suid_bit_set: /usr/bin/install (install -m 4755 /bin/bash /tmp/.x) was allowlisted, and every entry was a basename suffix, so /tmp/dpkg passed too. The allowlist is now full paths. - shadow_file_read, ssh_private_key_read: the allowlists keyed on comm, which a renamed copy or prctl sets. They now key on full exe paths, and cron is added to the shadow allowlist. The SSH rule no longer fires on *.pub. - lsass_handle_access, mass_file_read_4663: a dumper or collector renamed to MsMpEng.exe / SearchIndexer.exe anywhere on disk was filtered. They now use full paths, with Defender matched by its protected Platform directory. - data_destruction_wipe: diskpart never carries clean on its own command line. The branch now matches the shell that pipes it in; diskpart /s is deliberately left unmatched. - archive_staging_utility: it fired at high on extraction (7z x -p…, rar x -p-, tar -xf … -C %TEMP%). It now requires a create verb per archiver. Sigma's case-insensitive ' -c' also matched tar's -C <dir>, so tar uses its named create clusters. The Splunk precedence sign-off was re-traced for the new condition.
Detection review, section B: two unread telemetry planes, an AD CS row that over-claimed, and a PsExec rule that missed both PsExecs. - Sysmon registry events had no reader. The lab config collects Run keys and WDigest UseLogonCredential (Sysmon 12/13), and the methodology lists Sysmon 13 as a source, but no rule read either, and T1547.001 / T1112 were in neither the coverage nor the known-absent ledger. New: wdigest_uselogoncredential_enabled_sysmon_13 (value set to 1, no filter) and registry_run_key_suspicious_target_sysmon_13 (a Run/RunOnce value launching from a user-writable path or through a script host, with a DEPLOY-REQUIRED per-user-app allowlist). Its Splunk form is recorded in the precedence allowlist. - New dcsync_non_dc_machine_account_4662 covers DCSync by a machine account that is not a DC. dcsync_replication_4662 drops every $ subject by design, so this is the replication-rights ACL backdoor on a computer object, or a MachineAccountQuota account used for DCSync. experimental, with a DEPLOY-REQUIRED DC list. It does not see DCSync as a real DC's account from an attacker host (the ESC8 tail): 4662 carries no source address. That is now recorded as an open gap in DEFENSE-METHODOLOGY.md rather than implied as covered. - The AD CS methodology row claimed a "4886 SAN" check on the siem plane. Nothing performs one: adcs_esc1_san_mismatch_4886 surfaces every request for analyst triage, and its description now says the filename names that question, not a comparison it makes. The row now reads what exists (sigma, siem: 5145 pipes, 4886/4887 requests with SAN compared at triage, 4624 relay mismatch). - service_creation_psexec_7045 described impacket-psexec and PsExec but matched only smbexec. It now adds Sysinternals PSEXESVC and impacket-psexec's %systemroot%\<8 letters>.exe behind a 4-letter service name (shapes taken from impacket's serviceinstall.py). Its fixture had paired PSEXESVC with a %COMSPEC% path, which no real PsExec does; it now carries one event per shape plus a near-miss TN. The old rule fires on one of the three. This is the corpus's first |re rule; Lucene cannot take the anchored, (?i) pattern, so the Elastic form carries a DEPLOY-REQUIRED rewrite and a README backend-work row.
Detection review, section A: thirteen rules that could not fire (wholly or in one arm), or whose allowlist could never suppress, because rule and fixture shared a guess about the vendor's log. Every corrected name below was checked against a primary source (vendor docs or source code), and each fixture was rebuilt in that shape. Run against the new fixtures, the old rules fail ten validation cases; the new ones pass 44/44 cloud and 108/108 host. - GitHub: github_self_hosted_runner_registered keyed on self_hosted_runner.created, which GitHub does not emit; it now matches *.register_self_hosted_runner and *.configure_self_hosted_jit_runner. github_credential_backdoor's deploy-key arm used repo.create_deploy_key; GitHub logs a deploy key as public_key.create (github/docs audit-log data). - Slack: slack_external_shared_channel keyed on two action names Slack does not have; it now uses external_shared_channel_invite_created / _accepted / _approved / external_shared_channel_connected. - Harbor: a push is audited as operation: create (there is no push operation), and the actor is username, not operator. So harbor_image_pushed_trusted_tag never fired, and none of the three Harbor allowlists suppressed anything. The trusted-tag rule now also catches a tag re-pointed at an existing digest (create on tag). - PyPI: the collaborator rule missed the invite / accepted path (the takeover path) and now names project-creation's add Owner as a false positive. The token-upload rule's filter keyed on a publisher_type column that does not exist; it now reads project:release:add with additional.uploaded_via_trusted_publisher, so a trusted-publisher upload is finally suppressed. The trusted-publisher rule looked for a journal entry Warehouse never writes; it now keys on the project:oidc:publisher-added event. - Google Workspace: gws_admin_role_grant missed GRANT_ADMIN_PRIVILEGE, the actual super-admin grant, and mislabelled GRANT_DELEGATED_ADMIN_PRIVILEGES as one. - GCP: gcp_iam_policy_backdoor required a leading-dot, exact-case .SetIamPolicy, missing Resource Manager's …projects.setIamPolicy, bucket storage.setIamPermissions and the SetIAMPolicy spelling. Each casing is now listed. - AD: ldap_recon_property_reads_4662 used legacyExchangeDN's GUID for servicePrincipalName (now f3a64788-…, MS-ADA3), so its Kerberoast half never fired. ldap_recon_search_filter_1644 matched the bitwise-OID form a DC never logs (1644 rewrites it to userAccountControl&<bits>) and trustedForDelegation, which is a PowerShell pseudo-property rather than an LDAP attribute. - Jenkins: the Audit Trail plugin's default pattern logs neither /script nor generateNewToken. Both rules now carry a DEPLOY-REQUIRED note and a README deploy-time row, and jenkins_script_console drops to experimental. npm_publish_2fa_disabled (#149) and snowflake_network_policy_change also drop to experimental while their fields are unconfirmed. 17 fixtures move to vendor-documented, each citing its source.
The host validation gate now tests correlation timespan, and runs the current SQL backend. #333 pinned pysigma-backend-sqlite to 1.2.4 because 2.0.0 windows each correlation on the event time and the fixtures had none. The 34 correlation fixtures now carry Event.System.TimeCreated (one second apart, inside the shortest 5m window), and each of the 16 correlation rules gains an -outside-window case: the same TP events re-timed so no window holds the threshold (a burst that is simply too slow). Those 16 cases fail on 1.2.4, which ignores the window, and pass on 2.0.0, so the pin moves to ==2.0.0 rather than floating: the backend version decides whether half of every correlation rule is tested. 108/108 on a fresh install.
The host validation plane went red overnight with nothing in the repo changed: every correlation rule stopped firing. zircolite 3.7.6 floats pysigma-backend-sqlite>=0.1.1, and 2.0.0 (2026-09-26) started enforcing correlation timespan on a timestamp column that the synthetic fixtures do not carry, where 1.2.x compiled a bare GROUP BY/HAVING and ignored the window. All 20 value_count/event_count cases went silent (72/92), bisected to that one package. zircolite 4.1.0, which requires sqlite >=2.0.0, fails the same 20, so the fix is a pin, not an engine bump: zircolite-constraints.txt now holds pysigma-backend-sqlite==1.2.4 (92/92), reversing its old "backends stay zircolite's business" line with the reason. Lifting the pin means timestamp-aware fixtures, so the gate exercises the windows it currently never tested.
Weekly detection review (#324): one rule contradicted its own description, five scoping fixes, two status corrections. The review found no coverage holes and no unpaired red attacks. Each change below that has a new near-miss fixture was checked against the pre-change rule first, so the fixtures are regression tests, not assertions. - ccache_theft_staging claimed tool-name independence it did not have. Its description said it keyed on the handling process "not a tool name", but the condition ANDed an Image|endswith list of 25 tools. A copied or renamed cp, or any static exfil binary not on the list, walked past it. The ccache path in argv is now the whole signal. The only exclusion is filter_krb5_clients, the six krb5 utilities that name a ccache legitimately with -c. The description no longer says those never do. The trade is stated in the rule: a binary renamed to klist evades an Image filter, which is one attacker-chosen name out of six where the old gate let through every name not among 25. The renamed-binary TP did not fire on the old rule. - jenkins_script_console paged on /scriptApproval. uri|contains: '/script' also matched the In-Process Script Approval page, which is routine admin activity, and it fired at high. That page is now excluded with filter_script_approval, not by anchoring with endswith: nothing pins whether uri holds a parsed path or the Audit Trail line it came from, and an anchored match silently dies on the second shape. The redundant /scriptText entry is gone, since /script is its prefix. - service_stop_burst had nowhere to put the allowlist its prose asked for. The base event gains filter_maintenance_parent (DEPLOY-REQUIRED), with a comment saying never to list a deployment agent (ccmexec, PSEXESVC, gpscript, RMM) or a host, because that is how the real teardown gets pushed. Its TN is six distinct stops from the allowlisted parent, past the gte: 5 threshold. The generated Splunk search mixes OR and NOT the same way the BitLocker rule's does, and is recorded in the precedence allowlist with the same reading. - ntds_dump_ntdsutil_vss_4688: selection_shadow put the binary name inside its CommandLine tokens ('vssadmin create shadow'), so C:\Windows\System32\vssadmin.exe create shadow did not match. It now uses the verbs alone, with a new regression fixture. The bare-Image selection_diskshadow branch is kept on purpose and now says why: diskshadow runs from a .dsh script, so a verb gate would miss the real attack. - wmi_event_subscription_consumer: the bare -enc keyword also matched -encoding. It is now anchored as '-enc ', '-ec ' and -EncodedCommand. - entra_sp_credential_backdoor gains filter_app_automation (DEPLOY-REQUIRED), keyed on the rotation principal's object id via InitiatedBy|contains. That is the entra_directory_role_grant shape, for the same reasons. Its own top false positive is CI/CD secret rotation, and it was the one credential-backdoor rule with neither a filter nor a reason for lacking one. The hand-written Sentinel form is unchanged, matching its entra_illicit_consent_grant sibling, which also leaves the allowlist to the Sigma rule. - asrep_roast_4768 and kerberoasting_rc4_tgs are now status: test. Both are invariant tripwires with committed TP and TN fixtures and no placeholder, which is what test means here.
detections/README.md's tables had drifted from the corpus (#314). Adds the ten missing substitution rows — aws_snapshot_share_external, azure_vm_run_command, azure_keyvault_bulk_secret_read, both remaining gcp_* rules, host_enum_srvsvc_wkssvc_5145, local_group_enum_sweep_4798_4799, gws_illicit_oauth_grant, k8s_pod_exec_attach, harbor_image_pushed_trusted_tag — and the three missing rule-table rows: gcp_iam_policy_backdoor, gcp_audit_log_sink_deleted, asrep_roast_4768. The last of those is easy to miss because a differently-named sibling is listed (asrep_roast_probing_4771, the correlation); they are separate rules. Two of the twelve markers were not substitutions at all, which is why they had no home and is recorded as its own finding rather than papered over. k8s_privileged_pod_created's marker is about how your backend expands JSON arrays; slack_2fa_enforcement_disabled's is about writing a collector mapping so the rule can tell an enable from a disable. Neither has a placeholder to replace, so neither fits a table headed *Substitutions* with Substitute / Until you do columns. They get a second table — Deploy-time backend work — rather than a marker of their own, because deploy-required.sh is the one checklist an operator runs before deploying and both belong on it: skip either and the rule ships subtly wrong rather than merely noisy, which is the harder failure to notice.
service_stop_protected_services matched its keywords anywhere on the command line, and Set-Service did not require the disable (#307). Two matching defects underneath the level: high the #302 review raised; the level itself is defensible and is unchanged. protected_services is ANDed with a stop/disable verb but matched against the WHOLE line, so the short keywords collided with path and flag arguments: sc stop Spooler >> C:\Backup\logs\svc.txt is a Spooler stop that paged as a backup-agent teardown, and the analyst could not tell from the alert why it fired. That is the kind of false positive that erodes trust faster than volume does — and the rule's own advice to *extend* the list made it worse rather than better, which is why the advice now says how. Keywords are now split by whether they can collide. A distinctive product or service name goes in bare (veeam, msexchange, backupexec, windefend, mssql, sqlserveragent, swprv, …); a short or generic token is guarded by the character that precedes a service name — a space or an opening quote, where a path component is preceded by a backslash (' backup' / '"backup', ' vss', ' sql', ' sentinel', ' defend', ' sense'). The guard idiom is the one service_stop_burst already uses for taskkill's '/f '. Set-Service moves into its own selection requiring Disabled alongside it, because Set-Service is equally how you start a service, rename it, or edit its description — Set-Service -Name MSSQLSERVER -Description "nightly maintenance window" used to page at high for a text edit. Matched on Disabled rather than -StartupType Disabled so the colon form and PowerShell's parameter abbreviations still match. This makes the arm consistent with its own net/sc sibling, which already insists on start= disabled rather than firing on any sc config. The rule gains its first true-negative fixture, carrying one line per defect — and both lines were checked against the pre-change rule and did fire there, so it is a regression test rather than an assertion. Eighteen realistic teardown forms were checked the other way for lost coverage: none, and sc stop swprv and sc stop Sense are newly caught, because naming the real service short names covers cases the generic tokens missed. No filter_* block: the defect is positional, and a filter can only exclude named benign shapes, not express "the keyword is in the service-name slot". It also keeps a NOT away from the rule's existing top-level OR, so the Splunk precedence allowlist is untouched. The third item this issue was filed with — that ' stop ' needs surrounding spaces and so misses sc stop svc — was retracted on the issue before any work started, and is correct as written.
vault_secret_read had no allowlist, while its own prose told you to use one (#306). The correlation's description said "batch jobs that read many secrets should be allowlisted" and its falsepositives said "allowlist the entity" — and there was no filter_* block on either document to allowlist it in. Both cloud twins ship that list on the base rule for exactly this reason (aws_s3_bulk_exfil → filter_bulk_readers, azure_keyvault_bulk_secret_read → filter_secret_automation), and the Azure rule names Vault as its model twice while offering the remedy Vault could not. A deploy identity hydrating config reads 20+ distinct secret/ paths in ten minutes and clears the gte: 20 threshold on its own, so the rule fired on routine automation with nowhere to send it — the S3 rule's stated failure mode, where someone mutes it and the mute is the blind spot. Adds filter_secret_automation to the base document, keyed on auth.entity_id: the field the correlation already groups by, so allowlist and aggregation agree by construction, and the only stable Vault identity — the entity id survives token renewal, re-auth and rename, where auth.display_name is a mutable label and auth.accessor changes with every token. The filter sits on the base event rather than the threshold because raising the threshold to clear your largest batch job blunts the rule for every other identity. Both fixtures gain the field so the exclusion is actually proven: the TN's first line is a secret/ read by the allowlisted entity, and swapping that id back to the TP's makes the line fire, so the filter is demonstrably the only thing silencing it.
entra_directory_role_grant allowlisted a display name (#306). filter_iga keyed on Identity, the AuditLogs actor display-name column — mutable and non-unique, so the allowlist stops matching the day IGA renames the service principal. It fails *open* here, which means noise rather than a miss, and that is the direction that ends in a mute. Its two Azure siblings go out of their way to warn against precisely this (azure_keyvault_bulk_secret_read: "The value is an object id (oid), NOT a name"; azure_vm_run_command: a name-shaped entry for a service principal "matches nothing and leaves the rule as noisy as an empty allowlist, while looking filled in"); this was the one rule in the set that had not had that treatment. Now matches the actor object id with InitiatedBy|contains. The path is not the same for every actor — InitiatedBy.user.id for a human, InitiatedBy.app.servicePrincipalId for an app, never both, which is why this repo's own hand-written Sentinel queries coalesce the two arms. Sigma cannot coalesce, so a single dotted key would silently cover only one kind of actor, and automation — the case the filter exists for — is the kind it would miss. Matching the column covers both, and keeps the field flat, which is what lets the rule stay in the EVTX validation plane instead of being rewritten into the cloud one. The comment now also warns against InitiatedBy.app.appId: that is the *application*, so allowlisting it exempts every principal using it. The fixtures carry one arm each — user on the TP, app on the TN — and the TN's appId is deliberately a different guid from its servicePrincipalId so the pair shows the two are not interchangeable. Finding 3 of #302 (okta_mfa_factor_reset missing a filter_helpdesk scaffold) stays declined: every Okta sibling is filter-free, as is the cross-platform twin gws_admin_role_grant, so the absence is the SaaS-audit convention rather than an outlier.
DEFENSE-METHODOLOGY.md claimed GCP had reached its resource plane. It had not (#305). The plane-axis passage read "AWS and GCP both reached their resource planes" — written while the Azure gap was being closed, with GCP asserted rather than checked. The error was load-bearing rather than cosmetic: a ledger saying a plane is covered is a reason for the weekly /coverage-gap sweep not to look there, so the false sentence hid the gap it described, which is why correcting it is listed as the prerequisite on the issue and would have been worth doing even with no rule attached. CHANGELOG.md's own #280 entry carries the same claim; that entry is left as the historical record it is, and this line is the correction. T1530 on GCS is declined in the same pass, with its reopen condition: storage.objects.get is Data Access telemetry, off by default, so a GCS twin of aws_s3_bulk_exfil would be inert on most estates — the argument already used to decline T1526/T1580/T1069.003. It carries no known-absent marker id, and both the methodology and detections/README.md now say why: the marker gate reads the corpus, not the corpus per provider, and aws_s3_bulk_exfil already covers T1530, so listing the id would fail the build in the other direction. A decline scoped to a provider rather than a technique can only live in prose.
htpx pin bumped to 7f369ee37ee5 (3.2.0+3), the first pin on an untagged commit (#297). Upstream's four new entries are two GCP pairs — gcp-gce-startup-script-exec ↔ gcp-gce-metadata-audit (T1651, Compute Admin Activity) and gcp-gcs-mass-exfil ↔ gcp-gcs-exfil-audit (T1530, GCS Data Access) — and no release tag has reached them, so version/tag now record 3.2.0+3 and say "none" in words rather than naming a ref that does not exist. The pin file documents that shape for the next time. Nothing was renamed or retired upstream, so every claim still resolves and the gate was green either side: 103 reference URLs, 99 blue entries named, back-refs intact. The report diff is the whole content of the bump — 105 blue entries to 107, with the 99 claimed here unchanged and both new entries landing in "htpx blue entries nothing here claims". That GCP resource-plane gap is recorded, not accepted by default: filed separately so this bump's diff stays the pin and the generated report, the same split #277/#280 used for Azure.
systemd_unit_persistence allowlisted the binary an attacker writes units with (#302). filter_provisioning carried /systemd and /systemctl by exe|endswith under a comment claiming it was the "same list as cron_persistence, same reason" — and cron's list has neither. The claim was false and the two entries were an evasion: systemctl edit --force --full evil.service and systemctl link /tmp/evil.service both attribute the unit-file write to /systemctl, so the rule was silent on two first-class variants of the T1543.002 it exists to catch. Neither entry was justified anywhere — #210 folded them into the package-manager list without a measured reason. Dropping them outright would have reintroduced the systemctl enable symlink noise they were presumably there for, so the suppression is now scoped by path instead of by binary: filter_systemctl_wants matches only the <target>.wants/ symlink farm that carries the volume, and filter_systemd_runtime only PID 1 writing under /run. A unit written at the top level of a watched directory alerts again, whoever wrote it. filter_provisioning is now cron's eleven entries exactly, so the comment is true. Proved rather than asserted: the two new manifest rows (linux-systemd-persist-systemctl-edit, linux-systemd-persist-runtime-generated) fail on the pre-change rule and pass on this one, and each carries the near-miss true negative showing the narrowed suppression still suppresses. The transient-unit path (systemd-run, StartTransientUnit) is written by PID 1 under /run and is therefore still suppressed — recorded in the rule as the accepted cost of not muting it on every reboot's generator output.
core-verify asks the integrity question again, and core-check gets the freshness one back (dotgibson/dotfiles-core#691). Adopting the fleet vocabulary pointed the canonical core-verify at this repo's upstream-tag query and demoted core-check to an alias of it. Those are two different questions: freshness is *is there a NEWER Core upstream?*, integrity is *is THIS core/ the tree core.lock pins?* — and Core's scripts/make-vocabulary.txt defines the canonical verb as the second. So the register read green on a target answering something else, while this repo still had no local integrity check at all: core-integrity.yml ran one in CI and nothing ran one here. core-check is a real target again, with help text naming its question, and core-verify delegates to Core's own scripts/core-integrity.sh from a CORE_REPO checkout — the same invocation CI uses, and the only implementation that knows how the fan-out filters the vendored subtree. It probes with -f plus bash … rather than -x, so a checkout that lost the exec bit does not fail it spuriously. Verified both ways against a sibling clone at core v6.1.0: core-verify reports pristine, core-check reports current.
The SUID tripwire tested the wrong argument for the one chmod that matters, and the history rule watched a syscall that could never be recorded. Both are documented auditd rules rather than Sigma logic, which is why neither fixture nor gate could see them. suid_bit_set shipped -S chmod,fchmod,fchmodat -F a1&04000, but the mode is a1 only for chmod(path, mode) and fchmod(fd, mode); fchmodat(dirfd, path, mode, flags) carries it in a2, so for every fchmodat the kernel ANDed a pathname pointer against 04000 and recorded or dropped the event depending on where the string happened to sit in memory. That is not a corner case: fchmodat is what coreutils and glibc's -at paths call, and it is the only path-based chmod that exists on arm64, which has no chmod syscall at all — so on an arm64 host the whole rule rested on the broken predicate. Now split by argument position, four lines instead of two, with the arm64 load failure and fchmodat2 both written down. history_clearing had the twin defect one layer along: it listed ftruncate, which takes a descriptor and emits no PATH record, so under -F dir= it was never recorded — dead in the list rather than merely noisy — while : > ~/.bash_history, the canonical clear, is an open with O_TRUNC and was not in the list at all. Replaced with open/openat O_TRUNC lines (flags in a1 and a2 respectively, the same split), and coreutils truncate -s0 is now named as genuinely unreached from the file plane rather than silently missed. Neither rule's Sigma changed; both descriptions now predict what a lab run must show, including the truncate -s0 non-result.
Three auditd rules assumed an ingestion model nothing stated. ssh_authorized_keys_write, ssh_private_key_read and history_clearing match key (SYSCALL record) and name (PATH record) in one selection, which only works where the pipeline coalesces the records of one auditd event into a single document — auditbeat/Elastic and the Splunk auditd TA do, a raw line parser does not, and there the three are inert while the key-only rules beside them keep working. Stated in each rule and in detections/README.md, alongside a note that syscall argument positions are per syscall rather than per family.
The potato rules cited the pre-re-measurement figures, and one of them argued the opposite of the record it cites. 2026-08-token-mismatch-sysmon-1.md was re-measured over the full corpus on 2026-08-30 — 147 Sysmon-1 records became 1491, and four recognised potato captures became six — but the rules reading it were not updated with it. Seven hand-written locations still said 147 / four captures / 4-of-4, across token_theft_parent_child_mismatch_sysmon_1.yml, potato_seimpersonate_sysmon_1.yml, DEFENSE-METHODOLOGY.md, fixture-provenance.tsv and docker/LAB-VALIDATION-PLAN.md. Corrected to 5 of 6 over 1491, with the sixth (PrintSpoofer) named and explained rather than absorbed into a count: its operator was already an interactive admin, so no service identity appears on that event and neither rule can reach it. The count of payloads the pair's Image list drops was also stale at two — it is three (notepad.exe, whoami.exe, nc64.exe), which is a third of the rule's own detections rather than a quarter. The ParentUser-availability note in both rules said "only the one from a 2022 host carries ParentUser"; four of the 1491 do, two of them the - placeholder. The one that mattered beyond bookkeeping is the rejected negated-filter variant. The rule defended rejecting it on the premise that it "finds the same 4 of 4 on this corpus and nothing more, so it buys no coverage here" — i.e. rejected *despite* being equivalent, purely on the Splunk NOT-on-missing-field conversion hazard. The record says the opposite: over 1491 records it matches 8, not 5, and two of the three extra are arguably wanted, so the decisive objection is the third — a ParentUser of -, which does not contain SYSTEM and so passes a negated filter, in a corpus where 401 of 1491 records resolve no parent at all. The rule now makes both arguments instead of the weaker one. No detection logic changed; the Splunk deploy form is regenerated.
The potato runbook now says what it predicts, and four things in it were wrong. #246 is host-bound and stays open — every one of its three items needs a Windows host running a real potato, and no second public corpus fills the gap (OTRF/Security-Datasets carries no potato or SeImpersonate capture at all). But the runbook is what decides whether that eventual run is worth anything, and it named a fixture that does not exist (potato_sysmon1.jsonl; it is potato_sysmon1_tp.jsonl), specified a target OS its own tool table cannot run on — JuicyPotato's DCOM route was fixed in Windows 10 1809 / Server 2019, which is why RoguePotato exists, so on any build modern enough to guarantee 4688 event version 2 the row meant to settle the CreateProcessAsUser vs CreateProcessWithTokenW question would silently not run — and asked for no attack producing a true negative, so a run following it would have promoted each TP to captured and stranded its TN at vendor-documented: a mixed-tier pair asserting a discrimination only half of it can support. It also never stated check_near_miss's identical-key-set contract at the capture step, which cannot be satisfied once the host is gone. The firing table is now six rows, the three open items carry pre-committed predictions (the convention 2026-08-sysmon18-remote-pipe.md reports against, so the record cannot be composed to fit the log), and a Filing the result section states per fixture what a result can promote — including that #246's own claim that closing it moves token_theft_sysmon1_* is wrong by citation, because that TP is a synthetic host (WEB01) and reaches captured only if replaced. Adds the --placeholder outcome the runbook did not anticipate despite 401 of 1491 swept records hitting it, the missing evtx-to-fixture.sh stdout redirects, and the zircolite replay commands its sibling runbook already carried.
The potato field semantics are now settled on a cross-channel join, and both fixtures are captured records. Extends the corpus work in #244: the sweep now covers all 278 EVTX rather than the 170 matching sysmon|proc, which adds 42 Security 4688 records and two more potato captures (RoguePotato, PrintSpoofer — six, not four), and the run record is one account rather than two. Three things follow. The User-is-the-child reading no longer rests on Sysmon-internal inference: the corpus holds one sample carrying both a Sysmon 1 and a Security 4688 for the same process creation, and there Sysmon's User matches 4688's *Target* while its LogonId matches TargetLogonId rather than SubjectLogonId — a numeric identifier carried independently by two providers. A question a ParentUser rule quietly depends on is now measured: CreateProcessWithTokenW is serviced by the Secondary Logon service in svchost.exe, so had seclogon created the payload rather than the tool, ParentUser would read SYSTEM and the rule would be inert — on all six captures the parent is the tool, with no reparenting, which also rules out the seclogon route to the Creator Subject caveat in token_theft_process_target_subject_4688.yml. And both potato fixtures are now captured records rather than hand-authored ones: the TP is the cmd.exe RogueWinRM spawned as SYSTEM from a LOCAL SERVICE context, the TN is a genuine PrintSpoofer run whose operator was already an interactive admin, so it changes the ParentUser value and nothing else about the event shape. ParentUser itself is still derived on both, and still says so (dotgibson/dotfiles-Defense#239).
**potato_security_4688.jsonl was a three-key skeleton, and token_theft_4688_* were never checked against a real event. The 4688 potato fixture carried NewProcessName, SubjectUserName and SubjectUserSid and omitted the twelve other fields every real 4688 carries; it is rebuilt on the captured event-version-2 key set and field order, with the Target Subject block left null on purpose so it does not pre-judge #230. The token_theft 4688 fixtures needed no change — their key set proved identical to six captured 4688s — and that rule fired on real telemetry for the first time**, on a genuine token swap. Its pre-Windows-10 falsepositives note is measured now too: captured event-version-1 4688s carry no Target Subject block at all and the rule is correctly silent on them. One observation recorded rather than fixed — a captured 4688 can populate TargetUserName/TargetDomainName/ TargetLogonId while TargetUserSid reads the null SID, and TargetUserSid is the only one of the four that rule can see. Five provenance rows move to vendor-documented; none reaches captured, because none of this is first-party.
The "a rule naming both channels' fields nulls under either pipeline" claim was false, in all six places it appeared. Repeated by the potato_seimpersonate pair, the T1486 mass_file_encryption pair, host_recon_command_burst, and twice in detections/README.md — where it is cited as precedent — the claim was that a pysigma pipeline unable to resolve a field drops the whole rule. Measured with the CI pins (sigma-cli 3.0.2, pysigma 1.5.0): it does not. Such a rule compiles under both pipelines with the unresolvable field passed through unmapped, and an OR of the two field sets fires correctly on each channel. What actually makes one rule insufficient is that the logsource resolves to one EventID per pipeline — category: process_creation becomes EventID=1 under sysmon and 4688 under windows-audit, whichever rule you compile — so a single compiled search covers a single channel regardless of the fields it names. The per-channel split is still correct; only the stated reason was wrong, and the corrected reason is now what each rule gives.
The correlation rules' grouping rationale re-derived (host_recon_command_burst, service_stop_burst). Their reason for grouping by Computer inherited the same false mechanism, plus a second error #239 exposed: it named Security-4688 SubjectUserName and Sysmon-1 User as two spellings of one actor. They are different principals — SubjectUserName is the creator, User is the new process — so the fields were never counterparts. Measured behaviour is worse than the claim: group-by fields are passed through completely unmapped by both pipelines (SubjectUserName survives verbatim under sysmon) while detection fields *are* mapped, so an account-keyed correlation would compile and then bucket every event under one null key, silently voiding the threshold rather than failing visibly. Computer is now justified on its merits rather than as a workaround: a burst is a host-level phenomenon, one operator's burst spans identities (land → recon → escalate → recon, the sequence in the RottenPotato webshell capture), and an account key would split it into sub-threshold buckets while handing the operator a trivial evasion. Actor grouping is reachable post-#239 (SubjectUserName / ParentUser) but would gate the rules on Sysmon 13+ for a reason unrelated to what they detect. service_stop_burst, which carried no rationale at all, now states one.
check-fixture-provenance.sh no longer claims every fixture is unverified. Stale since aa02c28; the ledger has carried vendor-documented rows since. Rewritten to describe why the distribution is deliberately not gated, without a count that goes stale again.
potato_seimpersonate_sysmon_1 was keyed on the wrong field and had never fired on a potato (#239). The rule and detections/README.md described it as the Sysmon half of a per-channel pair with potato_seimpersonate_4688, one shape on two channels. It was not. 4688's SubjectUserName is the account that *created* the process; Sysmon 1's User is the account of the *new* process. The two agree on an ordinary service-spawns-shell event, which is why the pair looked like a twin and why its fixture passed — and they diverge on a successful potato, which is the entire technique. So the rule went silent at exactly the moment its twin fired, and a host forwarding only Sysmon read as covered for T1134.001 in both detections/README.md and detections/navigator/COVERAGE.md while seeing nothing. Measured rather than argued: replayed against four real potato captures on three hosts (RogueWinRM, NetworkServiceExploit, RottenPotato from an IIS webshell, EfsPotato) from the pinned EVTX corpus, the shipped rule fired on none of them. The selection moves to ParentUser, which is SubjectUserName's actual counterpart, and the corrected rule fires on the two captures whose payload is a named shell. The pairing claim in both rules and in detections/README.md is now true rather than merely stated. ParentUser needs Sysmon 13.00+, which is a real ingestion constraint and is written into the rule instead of assumed — on an older build the field is absent and the selection is silently unsatisfiable rather than noisy. Full measurement, and what it does not settle, in docker/validation/labruns/2026-08-potato-sysmon1-user-semantics.md. The 4688 half is untouched and still unconfirmed on a real potato; the first-party run is tracked as dotgibson/dotfiles-Defense#246. Fixture corrected to the measured shape (it carried no ParentUser key at all) and renamed to potato_sysmon1_tp.jsonl, and the pair gains its first true negative.
make core-check reported fleet drift from an empty variable. It printed "gh not installed — cannot query upstream tags" and then queried anyway; gh failing left upstream_ver empty, which is never equal to local_ver, so it announced • vendored core is 5.4.1, upstream is — a sync from dotfiles-core is owed. A confidently wrong answer about drift, which is worse than the 127 the same defect causes elsewhere. The guard and the query are now one recipe line, and an empty upstream is its own branch: it reports drift UNKNOWN, not current and exits 1, rather than guessing. Found by _core_make_gate_hits (dotgibson/dotfiles-core#775), not by eye.
make markdown announced a skip and then ran anyway. Each make recipe line runs in its own shell, so the guard's exit 0 only ended that line: without npx it printed "npx not available — skipping markdown" and then ran npx, exiting 127. Collapsed into one recipe line, so the skip is a real skip (dotgibson/dotfiles-core#775 — the same defect in six other fleet repos). MD_FILES was already correct here, so only the guard needed fixing, not the scope. An unreadable MARKDOWNLINT_VERSION now fails rather than silently linting unpinned — "same version as CI" is this target's whole claim.
.markdownlint.jsonc's header claimed this config was "the local README check" and that "CI in this repo gates its own code, not its Markdown". Both were true when written; dotgibson/dotfiles-core#592 made the markdown leg blocking and it covers all 21 repo-owned files, not just the README.
The Sigma toolchain pins moved where Renovate can see them (#322). pySigma, pyparsing, sigma-cli and the three backends were strings inside sigma.yml's run: line, so nothing noticed when they aged. They now live in detections/requirements.txt, which the sigma gate installs with -r, the cloud validation plane with -c, and the README's local block with -r. check-readme-gates.sh gains a fourth assertion that the block reads the same file as CI, because the block no longer shows versions inline. renovate.json also watches docker/validation/zircolite-constraints.txt and groups every bump into one ci(deps) PR, since the packages are coupled. A bump that turns the hard gate red is that PR's intended outcome.
bootstrap.sh is the fleet's pilot of Core's bootstrap driver, blib_main (dotgibson/dotfiles-core#976). The shared half of every bootstrap — the flag loop, the escalator, the Core symlink surface, the band-85 role stage, the managed ~/.zshrc, the closing report — now runs from one definition in core/lib/bootstrap-lib.sh; this file declares what it is (BOOTSTRAP_ROLE=defense, BOOTSTRAP_LOGIN_SHELL=0) and keeps only what is genuinely Defense's: the forensics host-tool probe (bootstrap_check, behind --no-check via bootstrap_flag) and the closing case-data note plus the login-shell guard, which is now Core's blib_login_shell_hint (Offense carried the same one). The hand-rolled band-85 stage is gone — blib_link_role_layer wires 85-defense.zsh and defense/templates and drops the stale pre-v4 link, exactly as before. Three things the driver gives this repo for free: --only zsh,git in the space form (the old for a in "$@" loop could only parse --only=zsh,git), -n for --dry-run, and the local core/ pre-commit guard on a fresh clone. One convention change: an unknown flag exits 2 (usage error), not 1, which stays for real failures. Everything else on the box is byte-identical: same links, same loader, same closing lines, same exit codes.
bootstrap.sh closes on Core's failure tally instead of over it (dotgibson/dotfiles-core#973). This bootstrap installs nothing and escalates nothing, so it had no failure ledger — and none of its own steps needs one; the tool probe is report-only by design. But the shared scaffold it calls records *its* failures (a tpm clone behind a proxy) into the lib's array as it goes, and this script then printed "Defense bootstrap complete" regardless. The closing block now runs blib_failures_report, says "finished WITH the misses above" when there were any, and a new --strict turns that into exit 1; without it the exit code is unchanged, as befits a report-only script. Core's §5f ledger exempts this repo from blib_note_fail and blib_resolve_su with those reasons, so this is the last row the fleet ratchet had open.
detections/htpx.pin -> v3.2.0 (6358a8df8661). A plain bump, on the drift bot's weekly report (#277). Upstream's v3.2.0 closes the corpus's Azure asymmetry — every Azure pair before it sat on the Entra/M365 identity plane, none on the resource plane — with two new pairs: azure-vm-runcommand <-> azure-runcommand-activity (T1651, Activity Log) and azure-keyvault-secret-dump <-> azure-keyvault-audit (T1555.006, Key Vault AuditEvent). No rule here changed and none needed to: nothing this repo names was renamed or retired, so every claim still resolves and the claim gate was green before and after. The report diff is the whole content of the bump — the two entries land in *"htpx blue entries nothing here claims"*, moving the boundary from 103 blue entries to 105 with the 96 claimed here unchanged. That two-row gap is recorded, not accepted by default. This repo covers the AWS and GCP resource planes (aws_snapshot_share_external, gcp_service_account_key_created, and their siblings) and the Azure *identity* plane, so the missing Azure resource-plane telemetry — AzureActivity, and Key Vault AuditEvent, which is a diagnostic setting an estate has to switch on — is the same asymmetry upstream just fixed, seen from this side. It is a coverage candidate, filed as #280 rather than smuggled into a pin bump: a bump whose diff is only the pin and the generated report is reviewable at a glance, and one that also ships two new Azure rules is not.
Five rules matched one spelling of an action that has more than one. Each was evadable by a caller who reached the same outcome through the sibling API, and the corpus review (#261) found them together. gcp_service_account_key_created selected only CreateServiceAccountKey, missing UploadServiceAccountKey — the caller brings their own public key, so the private half never transits Google at all, which is the version an attacker prefers. okta_mfa_factor_reset did not select user.mfa.factor.suspend, Okta's own suspected-compromise action, which takes a factor out of use without deactivating it. (The review also proposed a singular user.mfa.factor.reset; Okta defines no such event type, a single-factor reset emits deactivate, and the rule now records that so it is not re-raised.) github_credential_backdoor did not see integration_installation.create — installing a GitHub App mints an installation token for as long as the install stands, the quietest durable credential of the three the rule now covers. gitlab_token_backdoor was missing the two group-scoped token events, the widest blast radius of the set. vault_approle_backdoor matched auth/approle/role/ literally, so a role minted under an already-enabled jwt, kubernetes, aws or token backend — the same durable machine identity, one path over — walked past it; it now matches the role path under every backend.
The Kubernetes escape rule's hostPath branch matched only the literal /. A mount of /var/run/docker.sock, /run/containerd, /proc, /etc, /root or /var/lib/kubelet is a node takeover on its own and produced no alert. Now the root mount plus a prefix list, with hostIPC added as a scalar branch beside hostPID. hostNetwork was considered and declined in the rule: it is not an escape by itself and it is precisely the CNI/node-agent request the rule's own false positives name. The documented array-traversal caveat is unchanged and still governs the two array-keyed branches.
snowflake_network_policy_change fired on statements that read a policy. It excluded SHOW but not DESCRIBE/DESC. filter_show is now filter_readonly and covers both. GRANT ... ON NETWORK POLICY stays selected on purpose — it changes no allowlist, but a GRANT OWNERSHIP on the enforced policy is the step before an attacker can alter it.
Four proposals from the same review were declined, in the rules themselves. A decline that lives only in an issue gets re-proposed next cycle. sudo_root_shell keeps comm and its interpreter list: exe resolves symlinks, so an exe|endswith list silently loses hosts where /bin/sh is dash or python3 is python3.14, and dropping the list turns a detection into a log of every command run through sudo — the GTFOBins escapes it is accused of missing exec a listed shell as a child and are caught there. k8s_clusteradmin_binding does not gain verb: [escalate, bind]: those are RBAC authorization verbs, never the verb of an audit event, so the selection would match nothing while reading as coverage. jenkins_job_backdoor does not add config.xml: the Audit Trail plugin's default pattern does not log it, and the logged line carries no HTTP method, so the branch could not separate the malicious POST from the routine GET. jenkins_api_token_created keeps no filter block: this corpus has no verified Jenkins actor field, and inventing one produces a filter that passes its own fixture and matches nothing in production — the hazard fixture-provenance.tsv exists to expose.
check-readme-tables.sh — the README's rule and deploy-time tables are now gated (#314). Same argument check-readme-gates.sh makes about the other half of the file: *"the README is load-bearing documentation, so it gets a gate like the rest of the load-bearing artifacts."* Nothing tied these tables to detections/sigma/, so they drifted — twelve rules carried a DEPLOY-REQUIRED marker with no row in the deploy-time table, and three had no row in any per-directory table. Four assertions, both directions on both tables. The deploy direction is the expensive one: the table's own preamble says the marker *"isn't enforcement, so this is the discoverable checklist instead"*, so a rule missing from it ships with an unfilled placeholder nobody was told about — the failure half those rules' comments describe at length. A phantom row is worse, sending an operator to fill a placeholder that is not there. The rule-table direction is about honesty of scale: the cloud/ table listed one of three GCP rules, so GCP read as a third of what shipped. It locates the tables by heading text and fails rather than passing if a heading moves, because a silent pass would report a clean bill for a table it never read — the same posture as keying splunk-precedence-allowlist.tsv on stanza title. Each of the four assertions plus the heading guard was verified to fire by mutating the README, so the gate is demonstrated rather than assumed. Wired into make methodology, make sigma and sigma.yml (thirteen hard checks become fourteen), and check-readme-gates.sh required it to document itself.
gcp_gce_metadata_startup_script — the first rule on GCP's resource plane (T1651, #305). Every GCP rule here was identity or logging plane (T1098, T1098.001, T1685.002); the resource plane had nothing, the same hole #280 closed for Azure. A startup-script key written into an instance's metadata is executed by the guest environment agent as root or SYSTEM at the next boot, so a stolen Compute Instance Admin token is remote code execution with no SSH key, no open port and no guest credential — and Compute Admin Activity logs are always on and cannot be disabled, which is the same cheapest-telemetry argument that put azure_vm_run_command first on Azure. Two shape decisions worth recording. The rule reads the key-name delta (added_metadata_keys / modified_metadata_keys) rather than the request, because Google redacts this call's metadata body by design — so the payload is not in the log and the key name is the only surviving signal. And the six key spellings are enumerated rather than matched on a startup substring: the -url forms fetch the script at boot, so its bytes never enter the log at all. instances.reset is deliberately *not* a second arm — benign alone, and requiring it would turn a patient attacker into a miss; it is the triage pivot instead, the same treatment azure_vm_run_command gives runCommands/delete. project.setCommonInstanceMetadata is named as a known adjacent path rather than guessed at. The metadata-delta field path is the soft spot and the fixtures say so: a captured Admin Activity entry spells it instanceMetadataDelta.addedMetadataKeys while Security Command Center's own sample filter for the same detection writes instanceMetaData.addedMetadataKey. The rule takes the captured spelling, snake_cased to the repo's method_name/principal_email convention, and the provenance rows stay unverified for exactly that reason. gcp-gce-metadata-audit leaves HTPX-COVERAGE.md's unclaimed table (99 → 100 claimed).
Targeted LDAP recon has a detection (#284). T1087.002 / T1069.002 read as covered, but only through sharphound_ldap_sweep_4662.yml, a fan-out detector — a handful of revealing filters (servicePrincipalName=*, the userAccountControl bitfield match, adminCount=1) never trips it, and ldap-recon-4662 was the one htpx blue entry nothing here claimed and nothing in the methodology declined. Two rules close it: detections/sigma/discovery/ldap_recon_search_filter_1644.yml keys on the search-filter content, which only 1644 carries, and says that 1644 is off by default; ldap_recon_property_reads_4662.yml is the fallback where it is not collected, counting the SPN / UAC property GUIDs those filters read. They are two files because the Sentinel / Elastic deploy forms mark a whole file unsupported when it holds a correlation, which would have dropped the primary arm with the fallback. Both fire on their fixtures under zircolite, the 4662 arm with a true negative past its threshold.
The README opens with a rendered terminal hero (dotgibson/dotfiles-core#948). assets/demo.gif is filmed from assets/demo.tape, which dotfiles-core generates from one shared template for all nine OS and role repos — the same tour everywhere, plus the one command that is this repo's own: core status showing the role layer live over the OS layer. The tape is generated (edit dotfiles-core's assets/hero.tape.in, not the tape); re-render with vhs assets/demo.tape on a Debian box with this role layered on top after a prompt or tooling change, then gifsicle -O3 --lossy=80 --colors 64 — the raw render is over Core's 2 MiB ceiling, the optimised one is not.
T1555.006 Cloud Secrets Management Stores — Key Vault bulk secret read, closing the Azure resource plane (#280). detections/sigma/cloud/azure_keyvault_bulk_secret_read.yml is a base rule plus a value_count correlation: one identity reading many *distinct* secrets from a Key Vault inside a 15-minute bucket. The cloud twin of vault_bulk_secret_read, and the same argument holds — a healthy application re-reads its own small set, an attacker with a stolen token sweeps across unrelated ones. Breadth per identity, not read rate: a flat "more than N reads" is wrong in both directions at once, letting the automation principal that legitimately reads 200 secrets a run stay silent while a targeted grab of the ten highest-value secrets sails under the floor. It ships at the same threshold as its HashiCorp sibling so an analyst tuning one credential store starts from the same number in the other, and both say the flat floor is the starting point rather than the destination. The identity claims are the part worth reading. The correlation groups by identity_claim_oid_g, the objectidentifier claim, because it is the only caller field present for *both* a human and a service principal: identity_claim_upn_s is null for an SP, and identity_claim_appid_g is the APPLICATION — allowlist the Azure CLI's shared app id and you have exempted every CLI user in the tenant. The raw event from Microsoft's logging reference also shows why the resource-specific schema is a trap rather than a rename: in AZKVAuditLogs the flat columns collapse into one dynamic Identity, whose claims are keyed by their full XML-schema URIs (http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn), so Identity.claim.upn silently resolves to nothing. Only appid is a short key. The rule ships the AzureDiagnostics spelling — what the paired entry queries, and already flat enough for a correlation to group by — and names the translation. Scoped to SecretGet, and unfiltered on result. SecretList/SecretListVersions return names, not values; counting them into a breadth measure would let one legitimate inventory call inflate the score, so they stay out of the rule and stay in the triage guidance, where a List followed by a run of Gets is the drain in its clearest form. There is no ResultType filter for the same reason the Run Command rule carries no status arm: a burst of *denied* SecretGets across many secrets is a principal sweeping a vault it has no permission on, which is the sharper finding, and filtering to Success would drop exactly that. Counting requestUri_s is an approximation, and the direction of its error is stated. The paired entry's KQL recovers the secret name with split(requestUri_s, "/")[4]; Sigma has no such transform, so this counts distinct request URIs. The two differ when one caller reads the same secret at several explicit versions in a window — which inflates. It is sound because of the group-by: within one identity the client library and api-version are constant. The error runs toward more sensitivity, never less: it can produce a false positive, it cannot hide a sweep. Six fixture lines at vendor-documented, each verified individually — TP: two distinct secrets under one oid plus a Forbidden read; TN: the allowlisted oid, a SecretList, and a KeyGet. The Sentinel gap is named rather than papered over: the kusto backend cannot express a Sigma correlation, so rules.generated.kql carries this as UNSUPPORTED like its two siblings — and for this rule that points somewhere useful, because the paired htpx entry *is* the Sentinel implementation. The telemetry caveat is recorded too: unlike Activity Log, Key Vault AuditEvent is a diagnostic setting an estate has to switch on, so this rule is inert until it is, which is a collection gap rather than a detection one.
T1651 Cloud Administration Command — Azure Run Command, the first rule on this repo's Azure resource plane (#280). detections/sigma/cloud/azure_vm_run_command.yml reads the Azure Activity Log for Run Command, the ARM call that executes an arbitrary script in a VM's guest as SYSTEM or root. A stolen Contributor token is therefore remote code execution with no guest credential, no RDP or SSH, and no packet the guest's own sensors can see. The gap it closes was invisible to a per-tactic coverage map: Azure was six pairs deep on the Entra/M365 *identity* plane and empty on the resource plane, while AWS and GCP reached both, so the provider read as covered until you sorted by plane. The htpx v3.2.0 bump is what surfaced it — upstream closed the same asymmetry from the red side, and both new blue entries landed in HTPX-COVERAGE.md's unclaimed table. Both operation paths, because they are one capability under two names. runCommand/action is the classic invoke; runCommands/write is the managed path that creates a child resource on the VM, and it is the stealthier half — a rule keyed on the invoke alone misses its own paired red, which is the mistake htpx fixed in kerberoasting-4769. Arc machines (Microsoft.HybridCompute) and scale-set instances are selected for the same reason: Run Command onto an on-prem host that happens to be Azure-joined is the case a defender is least expecting an Azure control plane to reach. runCommands/delete is deliberately NOT selected — that is the attacker's cleanup, not the execution, and alerting on it would fire on every routine teardown and invert what the rule means. It stays the triage pivot: a write and a delete against the same _ResourceId minutes apart is a script that ran and was tidied. No status arm, diverging from the paired entry's KQL, and the divergence is the point. That query filters ActivityStatusValue to Start/Started/Succeeded/Success. Microsoft documents the column as "Started, In Progress, Succeeded, Failed, Active, Resolved" while its own sample queries for the table use the short forms Start and Success — so the vocabulary is inconsistent in Microsoft's own references, and an allowlist over it is a guess that fails silently. Failed also belongs in the alert: an attacker denied by RBAC is a finding you can still get ahead of. The operation is the invariant, so the operation is the whole selection; deduplicate in the SIEM by Caller and _ResourceId, where it cannot cost a detection. The fixtures reached vendor-documented, and the checking changed the rule twice. All five AzureActivity columns are confirmed against the Azure Monitor table reference, and the UPPERCASE operation casing is settled by that table's own sample page: the sample using the case-*sensitive* == writes MICROSOFT.COMPUTE/VIRTUALMACHINES/WRITE, while every mixed-case sample reaches for the case-insensitive has. Casing is load-bearing for exactly one deploy form — sigma convert compiles this to in~ for Sentinel and IN for Splunk, both case-insensitive, but Lucene against a keyword field is not. Four of the five operation strings are confirmed verbatim (Compute RBAC permissions page; the documented RBAC requirement of az vm run-command create; the scale-set and Arc REST references, which print the resource type in their sample responses); the scale-set *invoke* is flagged in the rule as the one line this repo has not confirmed. Checking also corrected the filter_ops_automation guidance: Microsoft documents Caller as a GUID, and the table's own sample filters humans with Caller has "@" — so an automation identity must be allowlisted by object GUID, and a UPN-shaped entry for a service principal matches nothing while looking filled in. Seven fixture lines, each proving one decision: the TP covers the invoke, the managed write under a non-Started status, a *failed* invoke, and the Arc path; the TN covers the allowlisted caller, the delete, and the read. Every line verified individually against the rule. make check, make attack-tags, and make htpx green — the pairing gate now resolves 99 reference URLs across 97 blue entries, and azure-runcommand-activity moves out of HTPX-COVERAGE.md's unclaimed table. DEFENSE-METHODOLOGY.md gains the resource-plane axis and names Azure Activity Log as a data source; the Key Vault half of #280 stays open, second because its telemetry is a diagnostic setting rather than on by default.
T1537 Transfer Data to Cloud Account — detections/sigma/cloud/aws_snapshot_share_external.yml (#262). The corpus had no detection for exfil that never crosses an egress boundary. An attacker snapshots a volume, grants restore rights to an account they control, and copies it from there; the bytes move inside AWS's own address space over AWS's own APIs, so the wire plane, DLP, and aws_s3_bulk_exfil's object-read volume signal all stay quiet. Both existing Exfiltration rules key on crossing an external boundary — slack_external_shared_channel on an invite out, snowflake_data_unload on COPY INTO an external location — and T1537 is definitionally the case that does not, so this is the one exfil family that had no representative at all rather than thin coverage of a covered one. It is the inverse of the two CloudTrail rules beside it. ModifySnapshotAttribute, ModifyImageAttribute and the RDS pair are MANAGEMENT events, in every trail by default, where aws_s3_bulk_exfil and aws_data_destruction both go half-blind without S3 data-event logging. So it needs no new telemetry — which is why it was authorable at all, and what separates it from every entry in the declined ledger, each of which is blocked on telemetry the estate lacks. Two tiers, because only one needs tuning. A grant to group: all is public, fires with no allowlist, and is correct on day one; a grant to a named account is a finding only once filter_own_accounts holds your own account IDs. The condition puts the public arm OUTSIDE the filter deliberately, which is recorded in detections/siem/splunk-precedence-allowlist.tsv because the compiled SPL relies on search-command precedence to bind it — traced, not assumed. Keyed on the .add. path rather than the bare verb, which scopes out two non-events at once: ModifySnapshotAttribute also sets a description, and the remove half of the same call is the attacker's cleanup. Both are proven inert by the true-negative fixture, alongside a share to an allowlisted account. Its blind spot is stated in the rule: the attacker's CopySnapshot runs in THEIR account and never reaches the victim's trail — the same geometry that keeps CopyObject out of the S3 rule — and a share -> copy -> un-share sequence leaves a clean permission list, so a posture sweep over currently-shared snapshots is not a substitute for the event stream.
Two backend-typing fixes on the T1537 rule, and the gate divergence they exposed (review feedback on #268). The allowlist's AWS account IDs were unquoted, which is the convention every EventID in this corpus follows — but an account ID is a STRING that happens to be all digits, so the Sentinel form compiled == 111122223333 against a string field and the allowlist silently suppressed nothing. Splunk and Lucene are untyped and were unaffected, which is what made it easy to miss. They are quoted now, and the number_as_string validator that objects is excluded for this one rule id in detections/sigma-validation-config.yml rather than corpus-wide, so a genuinely mis-typed EventID elsewhere still fails. The '*' wildcard standing in for "field is present" was the second: it compiles to startswith "" in KQL, a tautology for any string and wrong against valuesToAdd, which CloudTrail emits as a JSON array. |exists: true gives each backend its real existence test — isnotempty(), exists:, =*. docker/validation/sigma_eval.py gained SigmaExists support to match, since it raised rather than guessing. Both were invisible to the rule's own true-negative fixture, because that fixture runs through the Python evaluator and not through a backend — precisely the hazard fixture-provenance.tsv's header describes, reached from a new direction. detections/check-attack-tags.sh ran sigma check with NO config, so it enabled every validator and honoured no exclusions. A per-rule exclusion therefore passed the hermetic lint and failed here, reported as an ATT&CK-tag error it was not. It now reuses sigma-validation-config.yml with only the -attacktag line stripped — that line exists to keep the hermetic lint off the network, and this gate has the pinned bundle injected already. Verified the tag validator still fires by tagging a rule attack.t9999.
detections/htpx.pin -> v3.1.0 (7ea71779365c). Carries the aws-snapshot-share-exfil <-> aws-snapshot-share-cloudtrail pair the rule above names. Authored red-first upstream (dotgibson/htpx#115) so the purple loop closed before the rule existed, rather than shipping the corpus's only unpaired rule.
Seven rules that named a benign false positive in prose now carry the filter block to suppress it. Each documented the noise and left the reader to hand-edit the rule: shadow_credentials_keycredentiallink_5136 (filter_whfb — the rule prescribed a Windows Hello allowlist its detection never implemented, so in any WHfB tenant this high rule fired on every legitimate key enrollment; the on-prem key-trust self-write, where Subject equals the object, is named as needing a SIEM-side comparison Sigma cannot express), gcp_service_account_key_created (filter_iac, for parity with its GCP siblings), harbor_robot_account_created (filter_provisioning), entra_directory_role_grant (filter_iga — PIM/IGA automation, with a note NOT to allowlist the PIM service whose operations the rule deliberately selects), bitlocker_abuse_encryption (filter_provisioning on the imaging task-sequence parent), ldap_recon_explicit_creds_4648 (filter_sweep_principals, present in its sibling discovery rules but not here — the correlation's threshold is no substitute, a scanner clears it every cycle), and github_self_hosted_runner_registered (filter_runner_provisioning). Every one is a DEPLOY-REQUIRED stub with a true-negative fixture proving the exclusion works, so the validation advisory that listed rules with a filter and no true negative is now empty. The BitLocker rule's generated Splunk form mixes a top-level OR with the new NOT; the binding was traced and recorded in splunk-precedence-allowlist.tsv rather than left to precedence.
\pipe\srvsvc and \pipe\epmapper are read at last; EfsPotato and RoguePotato are watched on the mechanism plane. srvsvc_epmapper_pipe_impersonation_sysmon_17.yml (id 563f2958-0d44-4138-884f-14d338d37cd9) selects EventType: CreatePipe on a PipeName whose name NESTS either endpoint, closing the telemetry-ahead-of-detection hole #225 closed for spoolss and #229 for atsvc/svcctl/efsrpc. Two names, one rule: they share the invariant, the EventType pin, the absence of an Image key, the technique and both its tactics, and the false-positive story, which is the svcctl/atsvc case rather than the efsrpc one, where the split existed because the invariant genuinely differed. With this, every name the shipped config collects is either read or declined in writing. The three questions #240 carried were re-derived, not inherited, and the sweep was re-run from scratch because the original figures lived only in a config comment — a rule resting on a number recorded nowhere a reader could check is the circularity fixture-provenance.tsv exists to make visible. docker/validation/labruns/2026-08-srvsvc-epmapper-pipe-creation.md is the record: all 278 EVTX of sbousseaden/EVTX-ATTACK-SAMPLES @4ceed2f4, chainsaw 2.16.4, 61 PipeEvent records — the figure reproduces exactly. *Creation, not connection*, and for a third distinct reason, so neither sibling's argument carries across. Every nested pipe in the corpus produces exactly one 17 and one 18, tens of milliseconds apart: the 17 carries the tool's own Image, the 18 is the coerced service binding back as Image=System ProcessId=4, naming the victim. Dropping the pin takes the rule from 2 matches to 4 and adds no attack it did not already see — only a misattributed second copy of each. So the pin buys deduplication and attribution and costs no coverage, where coercion_efsrpc_pipe_sysmon_18's identical-looking pin is load-bearing against *volume* because lsass creates \efsrpc every boot. Both siblings now say so in their own descriptions. *The generic \<x>\pipe\<y> shape is rejected*, and the decisive objection turned out to be measured rather than argued. It is expressible without regex (PipeName|contains: '\pipe\', since Sysmon renders one leading backslash and no \Device\NamedPipe\ prefix) and on the create half it matches 3 of 61, all attacks — but the third is PrintSpoofer's nested spoolss pipe, which spoolss_pipe_impersonation_sysmon_17 already fires on, so its marginal coverage over this corpus is zero. The ingestion objection stands separately: it needs unfiltered PipeEvent collection, so under the shipped onmatch="include" baseline it would see only the seven collected names — a much larger decision than a rule may make on the config's behalf. And 3-of-3 measures an attack-sample corpus, not an estate. It still misses GodPotato. Reopen condition recorded in the rule: if collection is ever widened past an include list, the generic form should *replace* this rule rather than sit beside it. For the same reason spoolss is deliberately absent from the name list — that rule's contains: 'spoolss' already matches the nested form, verified against the record, so adding it here would double-alert one attack. *No filter_* block, and the omission is the argument.* The legitimate creators make the bare \srvsvc and \epmapper, which the selection does not contain, so the narrowing does the work an exclusion would. #240's proposed filter_legit: Image|endswith: '\svchost.exe' is confirmed dead: the one legitimate \epmapper record is Image=System. A cosmetic filter would be that defect written twice. Consequently no check-splunk-precedence.sh row is needed — the compiled Splunk form is EventType="CreatePipe" PipeName IN ("*\\pipe\\srvsvc*", "*\\pipe\\epmapper*"), an OR list with no NOT beside it. The fixtures are a provenance first for this repo. srvsvc_epmapper_pipe_17_tp.jsonl is the two real create records transcribed verbatim from the corpus — the first pipe fixtures here whose *values*, not merely whose key set, come from the provider. They stay vendor-documented, not captured: labruns/README.md reserves that for first-party capture, and these are 2020–2021 third-party builds. A true negative ships despite the rule having no filter_* — not required by any gate, but the \pipe\ narrowing is the rule's entire thesis and nothing else would prove it discriminates; it changes exactly one value per line, PipeName nested to bare, holding Image at the TP's so the nesting is isolated as the sole discriminator. Validation: sigma efficacy 84/84 passed (42 with a true-negative), run against the pinned zircolite v3.7.6 — the new row fires on the transcribed EfsPotato and RoguePotato records and stays silent on the near-miss. Two claims this repo was making are corrected. The bare-srvsvc traffic was described as "five … ordinary share enumeration … from Image=System" — four of the five are Image=System and the fifth is wmiprvse.exe, and they come from three different capture types plus a kekeo capture. And "3 of 61 records" for the nested shape counts *pipes*; it is 6 records over 3 pipes. The load-bearing half — that no legitimate bind takes the nested form — holds exactly. Drive-by: docker/validation/README.md said 81 manifest rows / 38 true negatives against a real 84 / 42, and runbook-sysmon18-remote-pipe.md said "two things still need a host" while listing three. What is not settled: volume on a live host, in either direction. No legitimate *create* of either name appears anywhere in the corpus and none of the captures spans a boot, so the claim that a legitimate create would carry the bare name is inference. That half carries to #235, whose runbook gains it as item 4 with a pre-committed prediction and EfsPotato/RoguePotato rows in its firing table. Closes dotgibson/dotfiles-Defense#248. Ledger: corpus 110 -> 111 rules, 128 -> 129 documents, techniques unchanged at 81. Privilege Escalation TA0004 9 | 14 -> 9 | 15, Stealth TA0005 3 | 5 -> 3 | 6, T1134.001 5 -> 6 rules. No new technique or tactic row, and no new Splunk precedence allowlist row.
\pipe\srvsvc and \pipe\epmapper join the PipeEvent include list; EfsPotato and RoguePotato stop being unwatched on the mechanism plane. Measured while gathering evidence for #240: every potato in the pinned sbousseaden/EVTX-ATTACK-SAMPLES corpus that stands up its own pipe uses the nested \<something>\pipe\<endpoint> shape, and two of the three do it on names nothing collected — EfsPotato on \dd4c18dc-…\pipe\srvsvc, RoguePotato on \RoguePotato\pipe\epmapper, both carrying their own Image on the create. detections/sysmon/sysmonconfig-detection-lab.xml dropped both events before Sigma ever saw them, which is the blocking constraint #240 records. The entries are deliberately \pipe\srvsvc and \pipe\epmapper rather than the bare names, and the measurement is the same shape twice: 7 of the 61 PipeEvent records match bare srvsvc and five are ordinary share enumeration (NetShareEnum, NetSessionEnum) arriving as \srvsvc from Image=System; 3 match bare epmapper and one is the legitimate endpoint mapper arriving as \epmapper. The narrow forms collect 2 apiece and leave the routine traffic out. Wanting that traffic is a separate decision and a separate rule. Worth recording on the epmapper side: that legitimate record is Image=System, not svchost.exe, so the filter_legit: Image|endswith: '\svchost.exe' #240 proposed would not have filtered it. Narrowing the collection is what does the job the Image filter was expected to do. No rule reads either name yet, which is the telemetry-ahead-of-detection hole #225 and #229 each closed one name at a time. Both are filed with the collection rather than after it, as dotgibson/dotfiles-Defense#248, and the config's comment block — which tracks which rule reads which name and why any name is unread — now distinguishes the two kinds of unread name: lsarpc because a rule there would be unfilterable volume and that is a decision, \pipe\srvsvc and \pipe\epmapper because the rule is owed. Closes dotgibson/dotfiles-Defense#240, whose three open questions carry to #248 — the volume one in particular, since no legitimate *create* of either name was observed and none of these captures spans a boot. Ledger: unchanged — collection only, no rule, no tactic or technique count moves.
A Sysmon-plane rule for the token swap itself, and the Stealth row widens a third time (follow-on to #239). #239 established that Sysmon 1's User is the new process and ParentUser the creator, then used only the creator half to repair potato_seimpersonate_sysmon_1. token_theft_parent_child_mismatch_sysmon_1.yml (id b7f135f0-c066-4b0b-ac45-3f6bb433be38) uses both: a child running as SYSTEM whose creator was an app-pool / NETWORK SERVICE / LOCAL SERVICE identity. That is token theft stated as two fields on one event — a service identity cannot spawn SYSTEM without holding a token it did not start with — so unlike the potato pair it observes the outcome rather than the shape before it, and it takes the TA0005 half of T1134.001 on the reopen condition #223 wrote. Stealth TA0005 goes 3 | 4 → 3 | 5; the technique count, which is what measures the gap, does not move. It carries no Image constraint on purpose: measured against the four real potato captures in the pinned corpus it fires 4/4 where the potato pair fires 2/4, the difference being payloads (notepad.exe, whoami.exe) that no shell list catches, and the shell list removes no noise — its only matches across all 147 Sysmon-1 records swept are those four swaps. The tidier variant mirroring the 4688 rule's filter_same_context was rejected on evidence: it compiles to Splunk as NOT ParentUser="*SYSTEM*", where NOT on an absent field matches, so on a pre-Sysmon-13 host it would fire on every SYSTEM process creation while the zircolite backend the gate runs stays silent — a defect the gate is structurally unable to see. Measurement in docker/validation/labruns/2026-08-token-mismatch-sysmon-1.md, including what it does not settle: ParentUser was still never observed on a real potato (all four captures predate Sysmon 13, so it was derived by ProcessGuid linkage), the corpus contains no EDR or RMM agent so the zero-false-positive result is "not yet met" rather than "does not occur", and the locale exposure is reasoned rather than measured.
The token-context half of T1134.001 closes, on a different event than #230 asked for (#230). #223 reserved the Stealth half of T1134.001 for a rule that observes the impersonation rather than the shape around it, and named two candidates; #225 answered the spoolss one. The other candidate was "a token-context anomaly on 4624/4672", and it is declined on evidence: neither event is written by this attack. 4624 generates when a logon session is created and 4672 when privileges are assigned to a new one, and no live potato variant creates one — the pipe-impersonation majority never authenticates at all, the local-relay minority rides NTLM's local-call short circuit instead of calling LsaLogonUser, and DuplicateTokenEx preserves AuthenticationId so the resulting SYSTEM process reuses logon session 0x3e7. Only HotPotato and GhostPotato ever produced that shape, and both died with MS16-075 and CVE-2019-1384. Such a rule would have passed its own true positive and its own true negative and then sat inert, which is #149 with a Windows event id on it. What answers it is detections/sigma/privilege_escalation/token_theft_process_target_subject_4688.yml, on the plane where the telemetry exists. Event version 2 of 4688 carries a Target Subject block that Windows populates only when the creator and target "do not share the same logon", so a process whose Target Subject is SYSTEM was created with a token that is not its creator's — the theft stated as a field. It carries no Image constraint, so unlike the potato pair it survives a payload that is not a named shell; and because every variant ends in CreateProcessWithTokenW or CreateProcessAsUserW, it sits downstream of the DCOM-coerced variants that leave the spoolss rule silent. It takes both tactic tags on its own evidence. One assumption travels with it and is written into the rule rather than buried: whether the audit reads Creator Subject from the calling process's token or its impersonating thread token is inference from Microsoft's documentation, not a capture. Ledger: Stealth TA0005 3 | 3 -> 3 | 4, Privilege Escalation TA0004 9 | 12 -> 9 | 13, T1134.001 3 -> 4 rules. Tactic TECHNIQUE counts do not move, exactly as #230 predicted — the spoolss rule already took the row.
The rest of the Sysmon PipeEvent block gets read (#229). #225 closed one of the five pipe names detections/sysmon/sysmonconfig-detection-lab.xml collects and left four collected-and-unread — the same telemetry-ahead-of-detection hole, one size smaller. Two rules close three of them: svcctl_atsvc_remote_pipe_sysmon_18 (Sysmon 18 / ConnectPipe on \svcctl and \atsvc with Image = System) and coercion_efsrpc_pipe_sysmon_18 (Sysmon 18 / ConnectPipe on \efsrpc). This is corroboration, not new coverage, and the ledger should not be read as wider than it is. The service smbexec/psexec installs over svcctl is already caught by service_creation_psexec_7045; the task atexec schedules over atsvc by scheduled_task_suspicious_4698; efsrpc coercion by coercion_named_pipes_5145 and the Suricata coercion.rules. What the pair adds is a second, independent plane: the bind is visible on the host where those audit subcategories are never forwarded, and it is the request rather than the result, so it fires earlier in the chain. Only T1021.002 is a genuinely new technique row, and only because nothing here previously named the admin-share access itself. The invariant was re-derived, not copied. #225's shape was ownership on pipe *creation*, and it transfers to none of these: svcctl, atsvc, efsrpc and lsarpc are all created once at boot by the OS, and every tool in scope *connects* to a pipe already there — so the creation half detects nothing on those names and the new rules take the connect half, the opposite trade for the opposite reason. Origin replaces ownership: a remote SMB client's pipe open is serviced by the kernel SMB server, so Image reads System rather than a local sc.exe/schtasks.exe, and that is what separates impacket from administration. lsarpc deliberately ships no rule. Sysmon PipeEvent carries no authenticating principal, so coercion_named_pipes_5145's filter_machine — the block that makes that pipe survivable on 5145 — has no counterpart here, and a coercion bind and a routine domain bind arrive as byte-identical events. The decline is argued in DEFENSE-METHODOLOGY.md and recorded in a form that runs: the efsrpc rule's true negative *is* an lsarpc bind. The cost is stated rather than buried — PetitPotam's default endpoint is lsarpc, so 5145 stays the primary for coercion. Both rules ship with unverified fixture provenance on purpose. That Sysmon emits an 18 for a remote SMB pipe open, attributed to System, is derived from where the kernel services that open rather than observed — the purple-team run that would confirm it has not been done, and until it has, the rules' silence means nothing.
spoolss_pipe_impersonation_sysmon_17 — the one hole where the telemetry was already enabled and no rule consumed it (#225). detections/sysmon/sysmonconfig-detection-lab.xml has collected Sysmon PipeEvent 17/18 on five pipe names since it was written, with a comment saying it exists to corroborate potato_seimpersonate — and no Sigma rule selected it. Both potato rules told the analyst to "confirm the SYSTEM outcome with Sysmon 17/18 on the spoolss/DCOM named pipe", which was an instruction to go read telemetry the corpus collected and had no detection for. The ingestion was done ahead of the detection and the detection never followed. The new rule closes that. Its invariant is ownership rather than content: legitimate spoolss pipes are created by the print spooler, and PrintSpoofer-class tools stand up their *own* pipe and coerce the spooler into connecting to it, so a non-spooler process creating one is the anomaly. That is a shape, not an IOC — it survives renaming the binary. It is pinned to EventType: CreatePipe deliberately: category: pipe_created resolves to 17 *and* 18, but the only connect the attack generates is the spooler binding back, which the rule's own spoolsv.exe filter removes — so everything surviving the filter on 18 would be ordinary print traffic. Signal on 17, noise on 18.
This reverses the standing decision in #223/#224, on the terms that decision set. Those issues declined the TA0005 (Stealth) half of T1134.001 for the potato_seimpersonate pair because what the pair selects is a service identity spawning a shell — the shape *before* the token is stolen — and recorded a reopen condition: a rule that actually detects the impersonation "earns the TA0005 half on its own evidence, and should take it". This is that rule, and it takes both tactic tags. The pair still declines, and DEFENSE-METHODOLOGY.md now records the argument as settled rather than pending; the 4624/4672 token-context half of the reopen condition remains open. Ledger: Stealth TA0005 2 | 2 -> 3 | 3, Privilege Escalation TA0004 9 | 11 -> 9 | 12.
htpx-drift.yml — a weekly question about the pinned corpus, and a correction to what #202 claimed. Pinning htpx (detections/htpx.pin) is what makes check-htpx-pairing.sh reproducible, and it opens one specific hole: the gate reads the pinned sha, but the references: URLs the rules write point at /blob/main/. So if upstream removes or renames an entry this repo names, the link is a live 404 for anyone who clicks it while CI stays green. Nothing watched for that. This does, weekly, and the report leads with exactly those entries. The correction. #202's commit message and PR said bloodhound-sharphound "was renamed bloodhound-collect upstream". That is wrong, and the distinction matters. Checked against htpx's full history: bloodhound-sharphound, bloodhound-sharphound-4662, archive-staging-rar, local-data-collection and rogue-account have never existed in that repo — not as a file, not as an id:, in any commit. All eight dead references were ids written *here* that were never right, not upstream drift. The fix was the same either way, and check-htpx-pairing.sh catches that class permanently at author time. But the rename story would have made this workflow look like a response to something that had already happened, when the hole it covers has not bitten yet. It is a cheap weekly question, not a reaction. It reports on corpus changes, not commits. A pin behind by a README edit or a .gitignore commit is behind by nothing — only two gates read this corpus and both read entries/ alone. Verified against real history: two consecutive upstream commits that touched no entry produce no issue. Filing for those is the nagging core-drift.yml's header says it refuses to do, and a weekly nag becomes a weekly ignored issue. Report-only, like core-drift.yml: one deduplicated issue via the existing file-routine-issue.sh, nothing changed. Bumping the pin stays deliberate, because a bump moves HTPX-COVERAGE.md and that diff is the point. check-htpx-pairing.sh gained --list-claims, a data query printing every entry this repo names. The workflow uses it rather than re-implementing "what counts as a claim" in YAML — that definition already has two readers, and a third would be how they start disagreeing.
The Defense↔htpx link is checked, not just asserted — check-htpx-pairing.sh plus a drift-gated HTPX-COVERAGE.md. detections/README.md opens by promising that each rule names the exact Offense fold and htpx pair that reproduces it, "so the purple loop is closed in the file itself". Rules kept that promise in three places — ~84 references: URLs, an htpx pair <id> in each validation note, and 82 cells of the Validate with (Offense fold · htpx pair) tables — and nothing verified any of it. htpx is a separate repo on its own release cycle, so an entry renamed there left a rule here pointing at a 404, and the only way to find out was for someone to click the link. The split is the design decision #196 asked for, and it is deliberate: - Claims are a hard gate. Naming an entry is a factual assertion about another repo — it is true or it is false. check-htpx-pairing.sh resolves every named entry against the pinned corpus and checks the blue ones still pair back to a red entry that points at them. - Gaps are a report. HTPX-COVERAGE.md renders which blue entries this repo claims, which it does not, which techniques here the corpus has no attack for, and which entries htpx declares unpaired. It never fails on a gap and it is drift-gated, so a change in the shape of the boundary arrives as a reviewable diff. htpx spans Okta, Workspace, GitHub Actions, GitLab, Jenkins, Harbor, Vault, Terraform Cloud, Snowflake, Cloudflare, npm and PyPI; a bidirectional foreign key would be red forever and silenced within a week. The issue predicted exactly that. Declared holes are excluded by field, not by an allowlist. htpx now requires pair_note: on any entry carrying pair: null (htpx#98), so the report reads the upstream reason verbatim instead of keeping a local copy of someone else's decision — which is the thing that goes stale. The corpus comes from a pinned commit (detections/htpx.pin), fetched by htpx-corpus.sh. Same argument as attack-data.pin: a check whose answer depends on what upstream did this morning is not a gate. No sha256 field, because the pin names a git commit and git's own object hashing already binds it — verifying HEAD against the pin *is* the digest check. This repo deliberately does not vendor htpx as a subtree the way Offense does; two gates read it, and a ~200-file subtree plus a second sync obligation is a steep price for that. Adopted after fixing what it found, in the same PR — the standard check-rule-coverage and the Splunk precedence gate were held to. Eight dead names: - bloodhound-sharphound / bloodhound-sharphound-4662 were renamed bloodhound-collect upstream — a dead references: URL, a dead validation note, and a dead table cell. - archive-staging-rar, local-data-collection and rogue-account named entries htpx has never had — the corpus's Collection and Exfiltration entries are all SaaS-side and it has no account-creation entry at all. Those rules now say so in prose rather than naming a phantom, which reads as a working cross-reference. Half of those were in the README table, which is why the gate reads that column too and not just the rules.
host_enum_srvsvc_wkssvc_5145 now cites its htpx blue entry. The rule's validation note has named the red side (smb-enum-nxc) since it was written, but the corpus had no detection to put beside it — htpx#97 tracked that hole for exactly this rule. entries/blue/smb-enum-5145.md is now upstream, ported from this rule, and the rule references it. It is the first edge the new gate was built to verify, so the gate ships with a live cross-boundary link rather than an empty set.
The repo hygiene surface: Makefile, CONTRIBUTING.md, .editorconfig, .gitattributes, and the PR/issue templates. This repo and dotfiles-Offense are deliberate mirrors — equal CI weight at 14 workflows each, and Defense carries *more* tracked files — but Defense was the only repo in the fleet missing all of these at once. The Makefile is the one with immediate value: every gate here was already real and already scripted, but there was no front door, so reproducing CI locally meant reading .github/workflows/ and reconstructing the command list by hand. It wires what exists — tests/lint-shell.sh, tests/test-defense.sh, the four --check drift gates, the methodology and validation-coverage gates, lab-smoke.sh — and reads MARKDOWNLINT_VERSION from the vendored Core pins so a local run matches CI exactly. Offense's target list was not ported wholesale, because that would have recreated in reverse the exact bug Offense's Makefile was written to fix: core-sync, core-lock and the companion-* targets name scripts this repo does not have (Core is pushed *into* here by dotfiles-core's own make sync, and this repo vendors no htpx companion). They are absent rather than stubbed. .gitattributes is the one with teeth. dotfiles-Arch/CLAUDE.md documents what its absence costs when a repo is touched from Windows over a UNC share: a .sh that picks up CRLF gets a shebang of …/env bash\r and dies with *"bad interpreter"* — text that looks perfectly valid and that both shellcheck and bash -n accept. It also pins *.rules, *.zeek and *.pcap, which are this repo's product and are parsed by tools that are not uniformly CRLF-tolerant. Adding this file also makes Defense visible to dotfiles-web's changelog feed, which derives from each repo's CHANGELOG.md and had been skipping this one for want of the file. (dotgibson/dotfiles-Defense#196)
dnf repoquery (43) had been red on every run since the version floors landed (#193), and never because of a floor. The version probe in test/check-packages.sh ended its options with --. dnf5 only treats -- as end-of-options from 5.4 onwards. The 5.2.x that fedora:43 ships rejects it as an unknown argument, and the probe hid that error (stderr went to /dev/null). So every floored name came back with no version, and the check reported neovim and tree-sitter-cli as "virtual capabilities" on F43 only. The -- is gone; manifest names never start with -. A floored name that resolves by name but yields no version now reports a probe failure instead of blaming the manifest entry. packages.yml's path filter now includes test/check-packages.sh, because a change to the probe is only exercised on that workflow's Fedora containers.
The tmux auto-attach honours DOTFILES_NO_AUTOTMUX, the fleet's one opt-out name (dotgibson/dotfiles-core#877). MacBook, openSUSE and Gentoo already read it; this layer attached unconditionally for any interactive TTY, which is how dotfiles-core's README hero render — a vhs session that sources this layer — typed its whole tour into a fresh main session. Core's gen-hero-tape.sh now refuses to render a hero on a layer that does not honour the knob. Export DOTFILES_NO_AUTOTMUX=1 for any harness that drives an interactive zsh and must not land in tmux.
make markdown probed for a global, unpinned markdownlint-cli2 — so on a normal box it never linted anything (dotgibson/dotfiles-core#873). Nothing in this repo's bootstrap installs markdownlint-cli2 globally; it is npm-only. So unless the operator had separately run npm i -g markdownlint-cli2, the guard fired on every invocation and the target skipped, cleanly and with exit 0, forever. That is a correct guard doing exactly what it says — and a local mirror of a blocking CI gate that has never mirrored anything. dotgibson/dotfiles-core#775 fixed this target's skip guard and its file scope; neither defect could bite while the target never ran at all. And where the binary *was* installed it was whatever version npm last put there, while lint-call.yml installs the pinned MARKDOWNLINT_VERSION — so a rule that changes across a bump reds a required check against a green local run. It now runs the pinned version through npx, reading the number from the vendored core/scripts/tool-versions.env rather than restating it, and refuses rather than guess if that pin is unreadable — a silently-unpinned lint being the thing this fixes. npx needs only node, which is far likelier present than a global markdownlint install, and still self-skips without it so make lint works on a bare box. Converges on the shape dotfiles-Offense and dotfiles-Defense already run.
bootstrap.sh's PATH is not the shell's PATH — adopt blib_user_bindirs_on_path (dotgibson/dotfiles-core#748). Replaces the hand-rolled export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$HOME/.atuin/bin:$PATH" prelude. ~/.local/bin, ~/.cargo/bin and $GOBIN reach PATH only through the zsh layer, i.e. only inside a Core shell — which does not exist while bootstrap.sh runs. So every command -v <tool> guard here was answered by the PATH of whatever shell launched the bootstrap: on a fresh box, bash, with none of them. That is wasted work when the guard picks whether to reinstall, and a wrong answer when it picks a branch — dotfiles-openSUSE probed command -v mise for a mise mise.run had written to ~/.local/bin moments earlier, both arms of its Go fallback missed, and the run exited 2 on every bootstrap. No stubbed CI leg can see that: a stub installs nothing, so "is the tool present afterwards" can never fail under one. Core has shipped blib_user_bindirs_on_path for exactly this since dotgibson/dotfiles-core#425 — it resolves CARGO_HOME and GOBIN/GOPATH rather than hard-coding them, and adds only directories that exist, so it is called again after an installer creates one. The directory this script installs into is mkdir -p'd before the helper runs: the helper adds only directories that already exist, so a straight swap for the old unconditional export would have dropped ~/.local/bin for the whole first run and made command -v atuin miss the binary the atuin block had just linked there, skipping the systemd user unit. A second call now runs after the mise.run install, so _dotfiles_go_install's command -v mise arm sees the mise this script just installed.
make check was not hermetic, and wrote Core into your real config dir (dotgibson/dotfiles-core#852). The target promises "a hermetic --links-only run against a throwaway HOME" and redirected only HOME — but bootstrap.sh resolves its target as CONFIG="${XDG_CONFIG_HOME:-$HOME/.config}", and core/lib/bootstrap-lib.sh defaults XDG_CONFIG_HOME, XDG_STATE_HOME, XDG_CACHE_HOME, XDG_DATA_HOME and ZDOTDIR the same way. A :-/:= default applies only when the variable is unset, so for anyone who exports XDG_CONFIG_HOME the run wired Core into their live config tree and then failed its own assertions, which look under the temp dir bootstrap never touched. Reproduced on Fedora 44: zsh/, nvim, starship.toml, tmux, git, mise, lazygit, atuin, jj, sesh and tealdeer all landed in the exported XDG_CONFIG_HOME, while the target reported MISSING symlink on a clean checkout — a gate that mutates the box it was only supposed to inspect, then blames the tree. env -u for the five variables the bootstrap path actually consults fixes both halves; verified with XDG_CONFIG_HOME *and* ZDOTDIR pointed at a decoy, which now stays empty while the check passes. dotfiles-openSUSE had already reached the same fix locally.
make check's mktemp -d was unguarded, so a failure left $tmp empty and the next line ran mkdir -p "$tmp/.config/tmux/plugins/tpm" — /.config/… on the real filesystem. It now refuses, and a trap replaces the two hand-placed rm -rfs so an interrupted run cleans up too.
make check accepted a commented-out loader line. grep -q "source .*loader.zsh" matched # source ~/.config/zsh/loader.zsh, and its unescaped . also matched loaderXzsh. Now grep -qE '^[^#]*source .*loader\.zsh', and both ~/.zshrc greps are 2>/dev/null so a missing file reports once instead of twice.
make zsh-syntax and make markdown announced a skip and then ran anyway. Each make recipe line runs in its own shell, so each guard's exit 0 only ended that line: with zsh absent, zsh-syntax printed "zsh not installed — skipping" and then ran zsh -n (Error 1); with no global markdownlint-cli2, markdown printed its own skip and then ran the linter (Error 127). Both collapsed into one recipe line, so a skip is a real skip. This is the defect dotfiles-Debian recorded and fixed for its zsh-syntax, noting the shape survived in the other OS repos' Makefiles — this is that repo, and it had both (dotgibson/dotfiles-core#775).
make markdown also scanned the wrong files. It globbed '*.md', which is top-level only, while the reusable gate's markdown leg — blocking since dotgibson/dotfiles-core#592 — lints git ls-files '*.md' ':!:core/**', recursively. The three .github/ markdown files were therefore enforced by a required check and invisible locally, so this target could read green against a red PR. Now uses the same pathspec via a new MD_FILES. All nine files lint clean, so nothing was hiding.
.markdownlint.jsonc's header claimed the rules were "mirrored from Core rather than CI-enforced" and that "lint.yml skips **.md entirely". Both were true when written and neither survived dotgibson/dotfiles-core#592.
bootstrap.sh no longer fails on a machine without sudo. The escalator is now resolved once (BLIB_SU: empty as root, else sudo, else doas) and used everywhere, instead of a hard-coded sudo at a dozen call sites. A container, a WSL first boot, or a minimal Server image previously died at the first dnf line with sudo: command not found (exit 127) before doing anything.
bootstrap.sh can no longer stall on an invisible password prompt. The sudo timestamp is primed up front and refreshed in the background for the life of the run, and privileged calls no longer discard stderr. Previously, calls placed after the multi-minute cargo/go builds outlived the 5-minute timestamp and blocked on a prompt written to /dev/null — indistinguishable from a hang.
Re-running bootstrap.sh no longer rebuilds the Rust/Go tools from source. The presence guards probed PATH, but ~/.cargo/bin and ~/.local/bin are only added by os/fedora.zsh — i.e. only inside a Core *zsh* — so a run from bash rebuilt all six crates plus yazi every time. provision() now puts both bindirs on PATH first.
A failed step is now reported. Best-effort failures are collected and printed as a closing summary instead of being swallowed, so a box missing carapace, op, lazygit and every cargo tool no longer reports bootstrap complete. --strict exits non-zero.
/etc/wsl.conf is backed up before it is overwritten (.pre-dotfiles.<epoch>, matching every other managed file). It was the one destructive write with no backup.
**OS detection no longer matches Fedora-*like* distros by accident.** ID=/ID_LIKE= are parsed as keys; the old grep -qi fedora /etc/os-release also matched Rocky, Alma, CentOS Stream, Nobara, and any incidental substring such as a URL. Fedora-like distros are now an explicit --force-os opt-in.
--help no longer drifts. It was sed -n '2,17p' "$0", coupled to the header's line numbers — the exact trap core/scripts/sync-core.sh documents. It is a heredoc now.
.gitignore no longer ignores the tracked core/.claude/ files. The .claude/ pattern was unanchored, so it matched at any depth — a hazard for a vendored tree whose git tree SHA must match core.lock.
Three availability claims in bootstrap.sh that stopped being true. The section header, the dust spinner label and the viddy comment all said dust is not packaged on Fedora; du-dust has shipped continuously (F43 1.2.4-2, F44 1.2.4-5, rawhide 1.2.5-1.fc46). They now name only the tools that genuinely need a source build.
install/packages.txt dates the wget virtualisation to F40, not F42. Fedora's Wget2asWget change targeted Fedora Linux 40 — the note was two releases late. The rest of it (no package literally named wget, default provider wget2-wget, pin wget1-wget for classic semantics) was already correct.
jc is installed from dnf (dotgibson/dotfiles-core#1208). jc converts the output of common commands (ps, df, dig, ip, ...) to JSON for jq/yq. Fedora packages it on every supported release — jc 1.26.0 on F43, F44, F45 and rawhide (F46), shipping /usr/bin/jc and pulling python3-jc — so it is one line in install/packages.txt, and the atomic edition layers it from the same list with no change. This is the OS half of a fleet ratchet: Core's core-doctor probe and PORTING-MATRIX.md row land after the OS repos install it.
make lint stops warning about dnf on every package verb (dotgibson/dotfiles-core#1087, dotgibson/dotfiles-core#1104). Core's capability cross-check warns when a PKG_* verb's leading binary is absent from install/packages.txt. Both declarations here run base-system binaries the list deliberately does not name — that file records what this repo ADDS to a box — so the check fired on 8 verbs in each, burying the one case it exists to catch: a verb naming a tool nothing installs. PKG_UNLISTED_TOOLS declares the exceptions (dnf on the workstation edition, rpm-ostree dnf rpm systemctl on the atomic one) and both declarations now validate with zero warnings. Kept honest from both ends, so it cannot rot into a blanket silencer: a name no declared verb runs is a FAILURE, and so is a name packages.txt actually installs. Needs Core ≥ 7.10.0 vendored — an older validator rejects the key outright.
The atomic edition — Silverblue, Kinoite, any bootc host — as a variant of the same bootstrap (#186; runbook step 3 of dotgibson/dotfiles-core's NON-MUTABLE-HOST-PROPOSAL.md §4.6, the R4 patch measured end to end on a booted fedora-bootc:42 guest, runs 34893435585 and 34895922848). On a booted ostree image dnf install resolves the whole transaction and then refuses ("configured to be read-only"), rpm --import cannot lock the rpmdb, and nothing installed is live until a reboot — so the old script died at its first dnf line there. The marker is /run/ostree-booted, not VARIANT_ID (the bootc image reports ID=fedora and no variant), and everything hangs off that one flag: the RPM Fusion release RPMs, the package list, the lazygit COPR (a repo file into /etc/yum.repos.d — dnf copr needs a plugin that is not live until a reboot), the carapace RPM and 1password-cli all layer with rpm-ostree install --idempotent into the next deployment; the package list is filtered to the names dnf repoquery resolves and rpm -q does not already answer, because one base-provided name refuses the whole layer even under --idempotent (measured — it took the first 38-package layer down); 1Password's fingerprint-verified key is installed under /etc/pki/rpm-gpg and the repo's gpgkey points at it. A second declaration, os/fedora.atomic.capabilities (PROVISIONER=atomic, the rpm-ostree verbs, PKG_APPLY_PENDING=rpm-ostree status --pending-exit-77, PKG_APPLY=sudo systemctl reboot, no count verb — that question is root-only there), is relinked by bootstrap_wire_pre_loader the way dotfiles-openSUSE selects Leap's, so Core's up, nudge and maint runner (Core v7.6.0) see the staged host. The closing line says "N package(s) layered — reboot to apply, then re-run once": the cargo/go tools behind command -v cargo guards are skipped on the first run and picked up on the second, which is the edition's measured cost (a full second deployment plus the from-source builds). 118 code lines in bootstrap.sh, an 18-line declaration delta, no install/ fork and no os/*.zsh fork — the atomic edition's packages *are* Fedora's packages. BOOTSTRAP_PROVISIONER=atomic forces the marker, which is how a container reaches the staging path at all.
test/check-flavors.sh, on dotfiles-openSUSE's model, and make suite. Two hand-maintained declarations that are mostly identical by design are the most likely place for this variant to drift, and Core's schema validator checks each file alone. The test asserts the DELTA: PROVISIONER on the atomic file only; the five verbs dnf on one side and rpm-ostree on the other (--idempotent on the install, rpm-ostree upgrade — never bootc upgrade, which refuses a host with a layered package); the staged pair (PKG_APPLY_PENDING / _EXIT agreeing on 77, PKG_APPLY) present only there and the count keys present only on the dnf file; every other key identical both ways; and that the split is still reachable — the marker probe, the CI seam, the relink and the "reboot to apply" closing line survive in bootstrap.sh, and os/fedora.zsh keeps both dnfi arms. Runs anywhere (it reads the repo), so the new test workflow runs it on a plain runner on every PR; make test now runs the suite.
bootstrap.yml gains a fedora-bootc:42 leg with provisioner: atomic (Core v7.7.0's reusable input, dotgibson/dotfiles-core#1050). A container is not the host — the image has no /run/ostree-booted and a writable /usr — so without the seam every container leg walked the dnf branch and a green tick tested the wrong code (R6, measured). The forced run shims rpm-ostree, makes the rpm shim answer -q with 1 so the base-image filter keeps its names, and fails unless the run prints "reboot to apply"; packages_check runs the same dnf -q provides (38 of 38 in that image). It is not a required check, and the weekly unstubbed sweep skips it by design; .github/core-gates.txt declares real-bootstrap none … with the reason, so the coverage register says VM-only rather than reading green.
dnfi knows the edition. sudo dnf install resolves and then refuses on an atomic host; the alias now reads the declaration bootstrap.sh linked (_core_cap PROVISIONER, band 02 read it first) and expands to sudo rpm-ostree install --idempotent there. The other dnf aliases are unchanged: search / provides / history are read-only and answer on both editions, and upgrading is Core's up, which dispatches through the same declaration.
The README opens with a rendered terminal hero (dotgibson/dotfiles-core#948). assets/demo.gif is filmed from assets/demo.tape, which dotfiles-core generates from one shared template for all nine OS and role repos — the same tour everywhere, plus the one command that is this repo's own: up -n resolving to sudo dnf upgrade --refresh. The tape is generated (edit dotfiles-core's assets/hero.tape.in, not the tape); re- render with vhs assets/demo.tape on a Fedora box after a prompt or tooling change, then gifsicle -O3 --lossy=80 --colors 64 — the raw render is over Core's 2 MiB ceiling, the optimised one is not.
os/fedora.capabilities — this repo's Core v5 capability declaration (dotgibson/dotfiles-core#663, #667). Core's up, maint runner and core-doctor now dispatch through it rather than through package-manager branches inside portable Core modules. Fedora is the repo core/examples/os.capabilities.example was written from, so this is that example made real. MAINT_UNATTENDED_UPGRADE=1 — a versioned, non-rolling distro whose stable updates are what an unattended nightly is for, and what this box already did; the operator's MAINT_SYSTEM_UPGRADE=1 is still the first of the two gates.
make capabilities — validates os/*.capabilities against Core's schema via the vendored core/scripts/check-capabilities.sh, and runs as part of make lint.
bootstrap.sh --dry-run — previews the whole plan (packages *and* the symlink graph) and changes nothing, via the shared lib's BLIB_DRY; prints the wiring tally.
bootstrap.sh --strict and --force-os; a preflight that checks for the commands the script assumes and fails once with the full list.
bootstrap.sh now installs the core/ pre-commit guard on a fresh clone (blib_install_core_guard), which the shared lib always intended but was never called.
1Password's signing key is fingerprint-verified before rpm --import; a mismatch fails closed. The three upstream install scripts are downloaded, sanity-checked, then run — never curl | sh — and starship installs to ~/.local/bin, needing no root.
Root repo scaffolding that GitHub can actually see (it previously existed only under core/, where GitHub ignores it): CONTRIBUTING.md, SECURITY.md, CODEOWNERS, PR and issue templates, .editorconfig, .shellcheckrc, .gitattributes, .pre-commit-config.yaml, this changelog, and a thin Makefile (make lint / check / dry-run / integrity / hooks).
packages workflow — resolves every name in install/packages.txt against a matrix of supported Fedora releases, replacing hand-maintained availability prose with a check.
du-dust to install/packages.txt — dust is an RPM, not a from-source build. Fedora packages it under the same name Debian does (du-dust, binary /usr/bin/dust), and every fresh box was instead spending minutes on cargo install --locked du-dust for a tool dnf already had. The cargo block stays as a presence-guarded fallback: it is a no-op once the RPM is in, and it is what catches dnf --skip-unavailable silently dropping the name if du-dust ever follows sd/gron out of the repos.
bootstrap.sh runs on Core's bootstrap driver, blib_main (dotgibson/dotfiles-core#986). The shared half — the flag loop, the escalator, the sudo keepalive, the Core symlink surface, the OS overlays, the managed ~/.zshrc, the login shell, the closing report — now runs from one definition in core/lib/bootstrap-lib.sh (vendored since v7.4.0). This file declares what it is (BOOTSTRAP_OS=fedora) and keeps only what is Fedora's: the OS guard and preflight as bootstrap_guard, the dnf provisioning as bootstrap_provision (its body is unchanged), the dry-run preview as bootstrap_check, and --no-flatpak / --force-os through bootstrap_flag. 753 → 660 lines. What the driver gives for free: one --help (this repo's half, then the shared flags), and blib_user_bindirs_on_path running before anything probes rather than only inside provision(). One convention change: an unknown flag exits 2 (usage error), not 1, which stays for real failures. Same links, same loader, same exit codes otherwise; make check (the vendored links gate) ran green through the driver on a Fedora box, and a real --dry-run printed the provisioning plan and wrote nothing.
make check runs Core's vendored check-links.sh instead of its own copy of the hermetic --links-only gate (dotgibson/dotfiles-core#975, #852). The recipe carried one of four near-identical copies of that block across the fleet, and they drifted the way copies do: #852 found the same non-hermetic-HOME defect in three of them at once and had to fix it three times by hand. The script has been vendored as core/scripts/check-links.sh since then with its consumer named "as intent rather than as a file" — nine releases later no Makefile had followed. Now this one calls it: the Core graph is the script's own default, and --require adds the four things this repo's OS layer wires on top (80-os.zsh, os.capabilities, tmux os.conf, git os.gitconfig). Exit 2 is the drift signal, 1 means the check could not run.
bootstrap.sh runs on Core's escalation, sudo-keepalive and failure-tally helpers instead of private copies (dotgibson/dotfiles-core#867). blib_resolve_su replaces the hand-rolled root/sudo/doas probe — the same $EUID string compare, plus an absolute path for the escalator, and --require only when packages will actually be installed, so --dry-run no longer demands one. blib_sudo_keepalive_start / _stop replace the private refresher loop and its trap. note_fail is now a one-line shim over blib_note_fail, and the closing report comes from blib_failures_report — which means the failures the shared lib records itself (the tpm clone, blib_install_system_file) finally appear in it instead of being dropped. Output and --strict semantics are unchanged; the one visible difference is that hint lines print the escalator's full path (/usr/bin/sudo dnf remove …). Closes this repo's four rows in Core's audit-core.sh §5f ledger, which had read 1/9 (Gentoo only) since dotgibson/dotfiles-core#748.
A stale 4.5 MB orphaned worktree copy under .claude/worktrees/, and the obsolete zsh/local.zsh ignore entry (host overrides have lived at ~/.config/zsh/99-local.zsh since v4).
jc is installed (dotgibson/dotfiles-core#1208). extra/jc converts the output of ps, ss, dig and friends to JSON (ss -tlnp | jc --ss | jq), next to gron in install/packages.txt. This is the OS-first step of the fleet ratchet: Core adds the detection line, the core-doctor data / net row and the PORTING-MATRIX row once the OS repos install it.
make lint stops warning about pacman and checkupdates on every package verb (dotgibson/dotfiles-core#1087, dotgibson/dotfiles-core#1104). Core's capability cross-check warns when a PKG_* verb's leading binary is absent from install/packages.txt, and it fired on 7 verbs here for two different reasons. pacman is base-system, and that file records what this repo ADDS to a box. checkupdates is installed — just under another name, from pacman-contrib, which the list does carry; the check matches package names, not the binaries inside them. PKG_UNLISTED_TOOLS=pacman checkupdates declares both, and the declaration now validates with zero warnings. Kept honest from both ends: a name no declared verb runs is a FAILURE, and so is a name packages.txt actually installs. Needs Core ≥ 7.10.0 vendored — an older validator rejects the key outright.
The README opens with a rendered terminal hero (dotgibson/dotfiles-core#948). assets/demo.gif is filmed from assets/demo.tape, which dotfiles-core generates from one shared template for all nine OS and role repos — the same tour everywhere, plus the one command that is this repo's own: up -n resolving to sudo pacman -Syu. The tape is generated (edit dotfiles-core's assets/hero.tape.in, not the tape); re-render with vhs assets/demo.tape on an Arch box after a prompt or tooling change, then gifsicle -O3 --lossy=80 --colors 64 — the raw render is over Core's 2 MiB ceiling, the optimised one is not.
The fleet make vocabulary, and a test/ suite (dotgibson/dotfiles-core#691, reported by dotgibson/dotfiles-core#846). Core declares one canonical set of verbs for every repo that vendors it — help, lint, check, dry-run, packages-check, core-verify, test — so the same word means the same thing in nine repos. This repo was missing three of them. - make dry-run is the canonical spelling of what was make bootstrap-dry. bootstrap-dry is kept as a .PHONY alias: the requirement is that the canonical name *exists*, not that the old one dies. - make check is the full local gate — lint + test + a bootstrap.sh --dry-run that proves the whole plan still builds, provision() included, which no workflow ever executes. Deliberately *not* the hermetic HOME=$(mktemp -d) --links-only run that Debian's and Fedora's check use: that is only hermetic in $HOME, since wire_links ends in blib_set_login_shell, which appends to /etc/shells and runs chsh under sudo. --dry-run reaches the same code with BLIB_DRY=1 and writes nothing. - make test runs test/*.sh, and refuses rather than passing when there is nothing to run — a test: that runs no suite renders as a no-op in the fleet register. - test/check-packages.sh is the suite, modelled on dotfiles-Debian/test/check-packages.sh. It is the old inline packages-check recipe promoted to a real script — same parser (blib_read_pkgs_into, so it reads exactly what bootstrap.sh feeds pacman), same pacman -Si resolution, now shellcheck-visible and invokable by path — plus a duplicate-name check, which needs no package manager and so runs off-Arch too, and a clean skip instead of a hard "pacman not found" failure. It carries no version floors: Debian needs them because a frozen archive resolves neovim at 0.9.5 and calls that healthy, while on a rolling release the repos carry current upstream by construction — the drift risk here is names *moving* (doggo went AUR→extra in 2025) or disappearing. - .github/workflows/packages.yml now runs make test rather than make packages-check, and its path filter gained test/**. The floor requires a workflow that runs the *suite*; a workflow pinned to one target inside it would silently skip the second script anyone adds. make packages-check still runs exactly that script for anyone who types the verb.
make markdown, wired into make lint. lint-call.yml's markdown leg has been blocking since dotgibson/dotfiles-core#592, but this repo had no local target for it — a required check nobody could run before pushing, and a .markdownlint.jsonc only CI ever read. MD_FILES uses the gate's own pathspec (git ls-files '*.md' ':!:core/**'), so the local run scans exactly what CI scans, recursively — a '*.md' glob would be top-level only and miss pull_request_template.md. All seven files already pass, so nothing rides along. It skips when markdownlint-cli2 is absent rather than failing like the shellcheck arm: shellcheck is a pacman package, so missing means a box to fix, while markdownlint-cli2 is npm-only. Part of the fleet sweep in dotgibson/dotfiles-core#775.
os/arch.capabilities — this repo's Core v5 capability declaration (dotgibson/dotfiles-core#663, #667). Core's up, maint runner and core-doctor now dispatch through it rather than through package-manager branches inside portable Core modules. PKG_COUNT_PENDING is checkupdates (from pacman-contrib, already in install/packages.txt), which syncs a copy of the database in user space and never touches the real sync DB. PKG_ASSUME_YES, PKG_UPGRADE_PARTIAL and MAINT_UNATTENDED_UPGRADE are deliberately absent: each omission is a safety statement Core honours exactly, so up -i refuses a partial upgrade and the scheduled runner refuses to upgrade this rolling distro unattended. Do not add them.
make capabilities — validates os/*.capabilities against Core's schema via the vendored core/scripts/check-capabilities.sh, and runs as part of make lint.
bootstrap.sh --dry-run — previews the entire run (package plan + symlink plan + /etc/wsl.conf handling) and changes nothing. The shared library has supported BLIB_DRY end-to-end all along; this layer simply never exposed it.
Root Makefile — lint, bootstrap-dry, packages-check, secrets, core-lock, core-verify. lint reproduces the CI gate exactly, so a failure is visible before pushing.
packages workflow — resolves every install/packages.txt name against the Arch repos on PR and weekly, without installing. Nothing previously checked the package list, on a rolling release where renames are routine.
Root .gitattributes, .editorconfig, .shellcheckrc — Core ships all three, but EditorConfig/shellcheck/gitattributes resolution is directory-scoped, so they governed core/** only and this repo's own files had no policy.
CODEOWNERS, pull_request_template.md, SECURITY.md, and this file.
The tmux auto-attach honours DOTFILES_NO_AUTOTMUX, the fleet's one opt-out name (dotgibson/dotfiles-core#877). MacBook, openSUSE and Gentoo already read it; this layer attached unconditionally for any interactive TTY, which is how dotfiles-core's README hero render — a vhs session that sources this layer — typed its whole tour into a fresh main session. Core's gen-hero-tape.sh now refuses to render a hero on a layer that does not honour the knob. Export DOTFILES_NO_AUTOTMUX=1 for any harness that drives an interactive zsh and must not land in tmux.
make markdown probed for a global, unpinned markdownlint-cli2 — so on a normal box it never linted anything (dotgibson/dotfiles-core#873). Nothing in this repo's bootstrap installs markdownlint-cli2 globally; it is npm-only. So unless the operator had separately run npm i -g markdownlint-cli2, the guard fired on every invocation and the target skipped, cleanly and with exit 0, forever. That is a correct guard doing exactly what it says — and a local mirror of a blocking CI gate that has never mirrored anything. dotgibson/dotfiles-core#775 fixed this target's skip guard and its file scope; neither defect could bite while the target never ran at all. And where the binary *was* installed it was whatever version npm last put there, while lint-call.yml installs the pinned MARKDOWNLINT_VERSION — so a rule that changes across a bump reds a required check against a green local run. It now runs the pinned version through npx, reading the number from the vendored core/scripts/tool-versions.env rather than restating it, and refuses rather than guess if that pin is unreadable — a silently-unpinned lint being the thing this fixes. npx needs only node, which is far likelier present than a global markdownlint install, and still self-skips without it so make lint works on a bare box. Converges on the shape dotfiles-Offense and dotfiles-Defense already run.
bootstrap.sh's PATH is not the shell's PATH — adopt blib_user_bindirs_on_path (dotgibson/dotfiles-core#748). go install pins GOBIN to ~/.local/bin, and ~/.local/bin, ~/.cargo/bin and $GOBIN reach PATH only through the zsh layer, i.e. only inside a Core shell — which does not exist while bootstrap.sh runs. So every command -v <tool> guard here was answered by the PATH of whatever shell launched the bootstrap: on a fresh box, bash, with none of them. That is wasted work when the guard picks whether to reinstall, and a wrong answer when it picks a branch — dotfiles-openSUSE probed command -v mise for a mise mise.run had written to ~/.local/bin moments earlier, both arms of its Go fallback missed, and the run exited 2 on every bootstrap. No stubbed CI leg can see that: a stub installs nothing, so "is the tool present afterwards" can never fail under one. Core has shipped blib_user_bindirs_on_path for exactly this since dotgibson/dotfiles-core#425 — it resolves CARGO_HOME and GOBIN/GOPATH rather than hard-coding them, and adds only directories that exist, so it is called again after an installer creates one. Arch escapes the worse half of this by luck — pacman puts mise in /usr/bin, so the fallback arm resolves — but _dotfiles_go_install's presence guard could never see a tool an earlier run had installed, so every bootstrap re-ran every go install.
bootstrap.sh could exit 0 having installed nothing. blib_read_pkgs' exit status is lost inside the < <(…) process substitution, so a missing or empty install/packages.txt produced an empty array, a failed pacman -S, a zero-iteration fallback loop, and a success message. It now refuses to continue.
bootstrap.sh silently swallowed per-package install failures. The fallback loop discarded every error, so a handful of renamed packages yielded a green run and a half-provisioned machine. Failures are now collected, reported at the end, and produce a non-zero exit — after wiring completes, so the box is still usable.
bootstrap.sh clobbered an existing /etc/wsl.conf. Every other mutation in this system backs up first (blib_link → .pre-dotfiles.<epoch>); this one overwrote, losing any local [automount] / [boot] / [network] settings on a re-run of a script documented as idempotent. It now no-ops when already correct and backs up otherwise.
pacorphans passed all orphans to pacman as a single argument. zsh does not word-split unquoted parameters, so pacman -Rns $orphans handed over one newline-joined string; now ${(f)orphans}. zsh -n cannot catch this — the syntax is valid.
--help was coupled to the file's header line numbers (sed -n '2,17p' "$0"), so editing the banner silently drifted the help text. Replaced with a usage() heredoc, matching the fix core/scripts/sync-core.sh already documents.
Stale .gitignore entry zsh/local.zsh — a pre-v4 path that does not exist in this repo. Added credential, .envrc/.direnv and key-material patterns (direnv is installed by packages.txt and hooked into every shell).
bootstrap.sh runs on Core's bootstrap driver (dotgibson/dotfiles-core#976, #986; vendored here since v7.4.0). The file now declares what it is (BOOTSTRAP_OS=arch, BOOTSTRAP_STRICT_DEFAULT=1 — a package that did not install is exit 1, always, as before) and defines the hooks that are Arch's: the Arch check and the --only/--skip note as bootstrap_guard, the pacman phase as bootstrap_provision (body unchanged: -Syu first, bulk --needed then per-package, the Go builds, the AUR hints, wsl.conf, Flathub), the dry-run preview as bootstrap_check, --no-flatpak as bootstrap_flag, the rolling-release hints as bootstrap_closing. The flag loop, the escalator, the sudo keepalive, the symlink surface, the managed ~/.zshrc, the login shell and the closing report come from core/lib/bootstrap-lib.sh :: blib_main. The -E ERR trap stays and now steps aside for the driver's own return verdicts. 446 → 421 lines. Two visible changes: an unknown flag exits 2 (the driver's usage-error code; it was 1), and --strict is accepted as a no-op spelling of the default.
bootstrap.sh runs on Core's escalation, sudo-keepalive and failure-tally helpers (dotgibson/dotfiles-core#973). It had no root check at all — it leaned on the lib's default of sudo through _blib_priv, an underscore-private symbol — and its ledger held one kind of miss (a pacman package). Now blib_resolve_su pins the escalator up front (root runs directly, else sudo, else doas; an explicit BLIB_SU= still wins, which is what CI sets), every privileged line goes through the lib's public blib_priv, blib_sudo_keepalive_start / _stop keep the timestamp warm across the go builds, and blib_note_fail records the per-package misses, the go installs and the Flathub remote alongside what the shared lib records itself. blib_failures_report prints the tally and the exit code stays 1 when there was anything in it — Arch's contract, unchanged. The carapace / viddy / op lines stay as the deliberate manual-step hints they are. Closes this repo's four rows in Core's audit-core.sh §5f ledger.
make core-lock no longer regenerates core.lock; it explains why and points at the fan-out. (dotgibson/dotfiles-core#593) The target was added here to satisfy core.lock's own header instruction, “Regenerate … with: make core-lock”. That instruction was the bug — Core removed it in dotfiles-core#454, because core.lock is written by sync-core.sh in the same commit as the subtree pull and was never meant to have a second writer. Keeping the generator meant this repo owned a second definition of a format Core owns, and it had already drifted from it in three ways: it hardcoded core_branch=main, so regenerating a lock that had been pinned to a released commit silently replaced that provenance with a branch name; it re-emitted the removed header line, reintroducing the very instruction it existed to satisfy; and it predates dotfiles-core#453, which renamed the field to core_ref — so running it now would emit a lock the rest of the fleet disagrees with. core-verify is unchanged and remains the way to check this repo's vendored core/.
Privilege escalation goes through the library's _blib_priv, honouring BLIB_SU, instead of a hardcoded sudo. This makes bootstrap.sh work as root and on doas-only boxes — and makes provision() runnable in a container, which Arch base images (no sudo) previously prevented.
Arch derivatives are accepted with a warning rather than refused: the guard now falls back to ID_LIKE=…arch…, so EndeavourOS/Manjaro/CachyOS work.
go install for sesh logs its errors to a file instead of /dev/null, and the module version is overridable via SESH_VERSION (still defaulting to latest — see the note in provision()). carapace is a printed paru hint and takes no version override, per the analysis in #89: go install cannot work for any version of it. That error logging is what makes such a failure visible in the first place — the old /dev/null form is precisely why the carapace call could fail on every bootstrap without anyone noticing.
A failed run now says where it failed (ERR trap), and a successful one prints the wiring tally and points at core-doctor.
bootstrap.sh installs the local core/ pre-commit guard on a fresh clone (blib_install_core_guard), catching a hand-edit at commit time rather than waiting for core-integrity.yml at PR time.
jc joins the apt base stack (dotgibson/dotfiles-core#1208) — kellyjonbrazil's command-output-to-JSON converter, next to gron in install/packages.txt. Untiered, because every target resolves it: noble 1.25.1 (universe), 26.04 1.25.5 (universe), trixie 1.25.4 and kali-rolling 1.25.7 (main). This is the OS-repo half of the fleet ratchet; Core's detection line and core-doctor row follow once the fleet installs it. The name counts quoted in the packages and bootstrap workflow comments move to 33 untiered and 45 on Kali. The Kali figure had drifted: it read 47 while the list resolved 44 there.
make lint stops warning about apt-get, apt-cache and dpkg on every package verb (dotgibson/dotfiles-core#1087, dotgibson/dotfiles-core#1104). Core's capability cross-check warns when a PKG_* verb's leading binary is absent from install/packages.txt. All three are base-system on any Debian box, and that file records what this repo ADDS — so the check fired on 10 verbs per declaration, the loudest in the fleet, because the verbs here split across three binaries rather than one. That buried the one case it exists to catch: a verb naming a tool nothing installs. PKG_UNLISTED_TOOLS=apt-get apt-cache dpkg declares the exceptions in both the Debian and Kali declarations, and both now validate with zero warnings. Kept honest from both ends: a name no declared verb runs is a FAILURE, and so is a name packages.txt actually installs. Needs Core ≥ 7.10.0 vendored — an older validator rejects the key outright.
The README opens with a rendered terminal hero (dotgibson/dotfiles-core#948). assets/demo.gif is filmed from assets/demo.tape, which dotfiles-core generates from one shared template for all nine OS and role repos — the same tour everywhere, plus the one command that is this repo's own: up -n resolving to sudo apt-get full-upgrade. The tape is generated (edit dotfiles-core's assets/hero.tape.in, not the tape); re-render with vhs assets/demo.tape on a Debian box after a prompt or tooling change, then gifsicle -O3 --lossy=80 --colors 64 — the raw render is over Core's 2 MiB ceiling, the optimised one is not.
make test and make core-verify — the two canonical fleet verbs this repo was missing (dotgibson/dotfiles-core#691, reported by dotgibson/dotfiles-core#846's register). Core now declares one make vocabulary for every repo that vendors it — help, lint, check, dry-run, packages-check, core-verify, test — after nine repos turned out to speak nine dialects, with "verify core" alone spelled five ways. test runs the repo's own suite, which today is exactly test/check-packages.sh, so packages-check is its only prerequisite rather than a copied command line; it is declared .PHONY because it names the test/ directory it runs and an undeclared test: would report "up to date" and run nothing. The requirement is that the canonical name exists, not that the old one dies, so integrity stays as a .PHONY alias of core-verify and muscle memory survives. Verify from a Core checkout beside this one with make fleet-vocabulary.
os/debian.capabilities and os/debian.kali.capabilities — this repo's Core v5 capability declarations (dotgibson/dotfiles-core#663, #667). Core's up, maint runner and core-doctor now dispatch through them. The apt verbs are identical on all three targets, so almost everything is shared; exactly one key differs, and it is why there are two files: Kali does not declare MAINT_UNATTENDED_UPGRADE, because engagement boxes are updated by hand between ops and a background full-upgrade mid-engagement destroys the reproducibility findings rest on. bootstrap.sh relinks the Kali file over the default, keyed on the same $OS_ID that scripts/pkg-filter.sh tiers install/packages.txt on — so the declaration and the package list agree by construction rather than by coincidence, and a future tier is one new file with no code change.
TOOLS_OPTIN declares jj and ast-grep opt-in on this family — they are — on noble and trixie and cargo-only on Kali. Core's single list could not say "opt-in there, expected here", so core-doctor reported them as missing-and-expected on every box (dotgibson/dotfiles-core#666). dust is deliberately not in that list: it is present here, just installed out of band under its plain name.
make capabilities — validates os/*.capabilities against Core's schema via the vendored core/scripts/check-capabilities.sh, and runs as part of make lint.
Initial dotfiles-Debian OS-native layer: the Debian-family repo the fleet had planned, cancelled, and left a note against in dotfiles-core/scripts/os-repos.txt. Targets Ubuntu 24.04 LTS on headless SSH-only machines; debian:trixie is a blocking CI lane because the repo is named for the family, not one distro.
install/tool-versions.env — version + SHA-256 pins for the twelve tools a frozen LTS cannot supply, with scripts/update-tool-checksums.sh to refresh them (--check to assert, --latest to report newer upstream releases).
test/check-packages.sh — resolves every manifest name with apt-get install --simulate (apt's real resolver, unlike apt-cache, which exits 0 on unknown names and non-zero on installable virtual ones) and enforces the # min:X.Y.Z version floors declared in install/packages.txt. Wired to make packages-check and the packages workflow.
verified_tree_install alongside verified_install in bootstrap.sh. Neovim ships a directory tree (bin/, lib/, share/nvim/runtime), not a lone binary — copying just bin/nvim yields an editor whose $VIMRUNTIME points at nothing.
verified_install handles all three shapes upstreams actually ship — .tar.gz, plain .gz (tree-sitter) and .zip (procs) — and takes an optional inner-binary name for assets whose executable is named differently from the command.
bootstrap.sh runs on Core's bootstrap driver, blib_main (dotgibson/dotfiles-core#986). The shared half — the flag loop, the escalator, the sudo keepalive, the Core symlink surface, the OS overlays, the managed ~/.zshrc, the login shell, the closing report — now runs from one definition in core/lib/bootstrap-lib.sh (vendored since v7.4.0). This file declares what it is (BOOTSTRAP_OS=debian) and keeps only what is Debian's: the three-distro OS guard and preflight as bootstrap_guard, the apt provisioning with its tiered package list and pinned out-of-band installs as bootstrap_provision (body unchanged), the dry-run preview as bootstrap_check, the distro tier's capability re-link as bootstrap_wire_pre_loader (the slot exists for exactly this: it must land before the managed ~/.zshrc is written), the shadowed-tools report as bootstrap_closing, and --no-upgrade / --no-unattended / --force-os through bootstrap_flag. 962 → 876 lines. One convention change: an unknown flag exits 2 (usage error), not 1, which stays for real failures. Same links, same loader, same exit codes otherwise.
make check runs Core's vendored check-links.sh instead of its own copy of the hermetic --links-only gate (dotgibson/dotfiles-core#975, #852). The recipe carried one of four near-identical copies of that block across the fleet, and they drifted the way copies do: #852 found the same non-hermetic-HOME defect in three of them at once and had to fix it three times by hand. The script has been vendored as core/scripts/check-links.sh since then with its consumer named "as intent rather than as a file" — nine releases later no Makefile had followed. Now this one calls it: the Core graph is the script's own default, and --require adds the four things this repo's OS layer wires on top (80-os.zsh, os.capabilities, tmux os.conf, git os.gitconfig). Exit 2 is the drift signal, 1 means the check could not run.
bootstrap.sh runs on Core's escalation, sudo-keepalive and failure-tally helpers instead of private copies (dotgibson/dotfiles-core#867). blib_resolve_su replaces the hand-rolled root/sudo/doas probe — the same $EUID string compare, plus an absolute path for the escalator, and --require only when packages will actually be installed, so --dry-run no longer demands one. blib_sudo_keepalive_start / _stop replace the private refresher loop and its trap. note_fail is now a one-line shim over blib_note_fail, and the closing report comes from blib_failures_report — which means the failures the shared lib records itself (the tpm clone, blib_install_system_file) finally appear in it instead of being dropped. Output and --strict semantics are unchanged; the one visible difference is that hint lines print the escalator's full path (/usr/bin/sudo apt-get purge …). Closes this repo's four rows in Core's audit-core.sh §5f ledger, which had read 1/9 (Gentoo only) since dotgibson/dotfiles-core#748.
neovim and tree-sitter-cli are not in install/packages.txt. Ubuntu 24.04 resolves both (0.9.5 and 0.20.8) and both are far below what Core requires (0.12 and 0.26.1) — which is exactly why the version-floor check exists; resolution alone would have called that box healthy.
yq in either spelling: noble's yq is kislyuk's Python tool, and the Go yq-go this stack means is sid-only. Installed via go install instead.
cargo, and therefore yazi/ast-grep/viddy: noble's rustc is 1.75, too old to build them. All are HAVE_*-guarded in Core, so the shell degrades cleanly.
wl-clipboard/xclip: no display on these headless ubuntu/debian boxes. Tiered in for Kali (# only:kali), which runs under WSL2 with WSLg. Copying still works regardless: Core's clip falls back to OSC 52 when it finds no local backend. Pasting does not, deliberately — see the README's *Headless clipboard* note.
PPAs, in any form. They are keyed to an Ubuntu series and would break the Debian lane; vendor-signed apt repos (Charm, 1Password) are used instead.
TOOLS_OPTIN opts yazi and viddy in on both editions (dotgibson/dotfiles-core#1239). dotgibson/dotfiles-core#1210 corrected PORTING-MATRIX.md's Kali cells for both tools from cargo³ to cargo²¹: this repo installs Kali's cargo but cargo-installs neither. That made them cell-level opt-ins, and without them core-doctor rendered a false ✗ for each on every Kali box. Core's fan-out audit caught the stale declaration and held the v7.14.0 sync back until this landed.
The tmux auto-attach honours DOTFILES_NO_AUTOTMUX, the fleet's one opt-out name (dotgibson/dotfiles-core#877). MacBook, openSUSE and Gentoo already read it; this layer attached unconditionally for any interactive TTY, which is how dotfiles-core's README hero render — a vhs session that sources this layer — typed its whole tour into a fresh main session. Core's gen-hero-tape.sh now refuses to render a hero on a layer that does not honour the knob. Export DOTFILES_NO_AUTOTMUX=1 for any harness that drives an interactive zsh and must not land in tmux. DEBIAN_NO_TMUX keeps working alongside it.
make markdown probed for a global, unpinned markdownlint-cli2 — so on a normal box it never linted anything (dotgibson/dotfiles-core#873). Nothing in this repo's bootstrap installs markdownlint-cli2 globally; it is npm-only. So unless the operator had separately run npm i -g markdownlint-cli2, the guard fired on every invocation and the target skipped, cleanly and with exit 0, forever. That is a correct guard doing exactly what it says — and a local mirror of a blocking CI gate that has never mirrored anything. dotgibson/dotfiles-core#775 fixed this target's skip guard and its file scope; neither defect could bite while the target never ran at all. And where the binary *was* installed it was whatever version npm last put there, while lint-call.yml installs the pinned MARKDOWNLINT_VERSION — so a rule that changes across a bump reds a required check against a green local run. It now runs the pinned version through npx, reading the number from the vendored core/scripts/tool-versions.env rather than restating it, and refuses rather than guess if that pin is unreadable — a silently-unpinned lint being the thing this fixes. npx needs only node, which is far likelier present than a global markdownlint install, and still self-skips without it so make lint works on a bare box. Converges on the shape dotfiles-Offense and dotfiles-Defense already run.
bootstrap.sh's PATH is not the shell's PATH — adopt blib_user_bindirs_on_path (dotgibson/dotfiles-core#748). Replaces the hand-rolled export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH" prelude. ~/.local/bin, ~/.cargo/bin and $GOBIN reach PATH only through the zsh layer, i.e. only inside a Core shell — which does not exist while bootstrap.sh runs. So every command -v <tool> guard here was answered by the PATH of whatever shell launched the bootstrap: on a fresh box, bash, with none of them. That is wasted work when the guard picks whether to reinstall, and a wrong answer when it picks a branch — dotfiles-openSUSE probed command -v mise for a mise mise.run had written to ~/.local/bin moments earlier, both arms of its Go fallback missed, and the run exited 2 on every bootstrap. No stubbed CI leg can see that: a stub installs nothing, so "is the tool present afterwards" can never fail under one. Core has shipped blib_user_bindirs_on_path for exactly this since dotgibson/dotfiles-core#425 — it resolves CARGO_HOME and GOBIN/GOPATH rather than hard-coding them, and adds only directories that exist, so it is called again after an installer creates one. The directory this script installs into is mkdir -p'd before the helper runs, and the second refresh sits immediately after the verified_install block: the helper adds only directories that already exist, so a straight swap for the old unconditional export would have dropped ~/.local/bin for the whole first run and made the command -v uv (ty route) and command -v atuin (daemon unit) probes miss binaries this script had just written. The literal list was a fork of Core's helper and was missing GOBIN — harmless only because this script sets it to ~/.local/bin itself. A second call now runs after the verified_install block, which on a first run is what creates ~/.local/bin.
make check was not hermetic, and wrote Core into your real config dir (dotgibson/dotfiles-core#852). The target promises "a hermetic --links-only run against a throwaway HOME" and redirected only HOME — but bootstrap.sh resolves its target as CONFIG="${XDG_CONFIG_HOME:-$HOME/.config}", and core/lib/bootstrap-lib.sh defaults XDG_CONFIG_HOME, XDG_STATE_HOME, XDG_CACHE_HOME, XDG_DATA_HOME and ZDOTDIR the same way. A :-/:= default applies only when the variable is unset, so for anyone who exports XDG_CONFIG_HOME the run wired Core into their live config tree and then failed its own assertions, which look under the temp dir bootstrap never touched — a gate that mutates the box it was only supposed to inspect, then blames the tree. Reproduced against the byte-identical recipe in dotfiles-Fedora (dotgibson/dotfiles-Fedora#153); only the package manager differs between the two repos here, and the --links-only path is the same Core code. Fixed with env -u for the five variables the bootstrap path consults — the fix dotfiles-openSUSE had already reached locally.
make check's mktemp -d was unguarded, so a failure left $tmp empty and the next line ran mkdir -p "$tmp/.config/tmux/plugins/tpm" — /.config/… on the real filesystem. It now refuses, and a trap replaces the two hand-placed rm -rfs so an interrupted run cleans up too.
make check accepted a commented-out loader line. grep -q "source .*loader.zsh" matched # source ~/.config/zsh/loader.zsh, and its unescaped . also matched loaderXzsh. Now grep -qE '^[^#]*source .*loader\.zsh', and both ~/.zshrc greps are 2>/dev/null so a missing file reports once instead of twice.
make zsh-syntax printed "zsh not installed — skipping" and then ran zsh -n anyway. Each make recipe line runs in its own shell, so the guard's exit 0 only ended that line. The same shape is still present in the other OS repos' Makefiles.
make markdown had the same defect, and it was still live here. Without a global markdownlint-cli2 it printed "not installed — skipping" and then ran the linter anyway, failing with Error 127. Collapsed into one recipe line, so the skip is a real skip. Of the fleet, dotfiles-Fedora carries this target byte-for-byte (also live); dotfiles-Offense and dotfiles-Defense have the same shape guarding npx instead (latent — it only bites where npx is absent); dotfiles-MacBook already fixed its copy; Alpine, Arch, Gentoo and openSUSE have no markdown target at all.
make markdown also scanned the wrong files. It globbed '*.md', which is top-level only, while the reusable gate's markdown leg — blocking since dotgibson/dotfiles-core#592 — lints git ls-files '*.md' ':!:core/**', recursively. The three .github/ markdown files were therefore enforced by a required check and invisible locally, so this target could read green against a red PR. It now uses the same pathspec via a new MD_FILES. All nine files lint clean, so nothing was hiding.
.markdownlint.jsonc's header claimed the rules were "mirrored from Core rather than CI-enforced" and that "lint.yml skips **.md entirely". Both were true when written and neither survived dotgibson/dotfiles-core#592.
The CI floor bans the pull_request_target trigger. It runs a fork's pull request in the base repo's context, with its secrets and a write-capable token, which is the precondition for a "pwn request". GitHub starts blocking it by default on 2026-11-02 for repos on the default policy, and rule 10 of scripts/modern-baseline.yml makes that permanent whatever a policy later allows. check-modern.sh reads the on: block itself: the scalar, flow and block forms, quoted or bare, and first-level entries only. A comment, a branch filter or an env: value that names the trigger does not fire. That is why it is not a banned_patterns entry, which would red the baseline's own rule 1 rationale. It was free to add: no repo in the org declared the trigger on its default branch (#1215).
The pull_request_target ban now reaches every repo that calls lint-call.yml@v7. The reusable workflow's actionlint job gains a step that runs check-modern.sh --banned-triggers caller: rule 10 alone, from the Core checkout, over the caller's own workflows, including ones not yet committed. It blocks from the start, because every caller was measured clean first. Rule 10's walker is now one function that both the floor and the new mode call, so the fleet check cannot drift from Core's. A caller that pins the workflow to a SHA newer than the v7 tag gets a warning, not a false red. dotfiles-Windows, dotfiles-web and htpx do not call lint-call.yml, so they are not covered yet. All three are clean today (#1215).
The openSUSE Tumbleweed tree-sitter-cli row moves to 0.27.0. Tumbleweed's tree-sitter went 0.26.8 → 0.27.0 (checked 2026-09-29; 0.26.8 has left the index), and both Leap 16.x lanes stay at 0.26.8. All three still clear the ≥ 0.26.1 floor. Footnote ⁵ also named the split-off library libtree-sitter0_26 for every openSUSE lane. That library is named for its soname, so Tumbleweed now ships libtree-sitter0_27. Reported by dotgibson/dotfiles-openSUSE#217.
The relink fix now names which checkout to run. core-doctor's relink row and the "relink pending" nudge used to say run ./bootstrap.sh --links-only. On a box with both an OS checkout and a role checkout, running the wrong one moved the whole Core surface onto that repo's vendored Core, possibly an older one, and the doctor then read current. The line now names the loaded checkout's own script, for example run ~/dotfiles-Offense/bootstrap.sh --links-only, quoted when the path needs it (#1213). The other state no longer stops at "last relinked from another checkout". It appears after a partial --only/--skip run of the other checkout, or a moved one. It now names both fixes, since either checkout's full --links-only run re-stamps the box. Which one should own Core stays an open question in #1211 (#1218).
make audit from a fleet shell no longer reds four cases CI calls green. The behavioral suite's host scrub now also drops BROWSER and every ATUIN_* variable. Core's own 00-tools.zsh exports BROWSER=w3m on a headless box (WSL included), and an operator who opted into the atuin daemon exports ATUIN_DAEMON__ENABLED=true. Both leaked into the cases that pin the unset state: the GUI and macOS browser cases, and the daemon guard's never-opted-in pair. That is how the v7.13.0 cut went red locally on a tree CI had passed. Every case that wants one of these variables sets it explicitly.
An operator who exports a Core knob no longer reds make audit either. Exporting CORE_SHADOW_CLASSICS=0 in ~/.zshenv, as the v7.13.0 notes suggest, failed the suite's "knob unset" shadow case. The host scrub now unsets every CORE_* variable that zsh/ reads, including one read arithmetically as ((CORE_X)) with no $. The list is derived from the sources, ignoring comments, so a new knob is covered without an edit. core-doctor's unknown relink row also drops its "live check" hint. That check proved the capability contract was live, not that this box had relinked, so a pass read as "you're fine" when it wasn't (#1204).
The weekly /freshness-triage routine can now check the CLI tool pins it reports on (#1203). Its "CLI tool pins" row asks for each scripts/tool-versions.env pin against upstream, but neither the routine's allowed-tools nor the job's mirrored --allowedTools granted a release lookup, so the row came back "not checked" (#1193). Both lists now grant the read-only gh release view and npm view, and the routine says which to use for which pin.
A bootstrap no longer downgrades Core on a box with two checkouts. An OS repo and a role repo stacked on it each vendor Core, and each bootstrap linked the whole Core surface from its own copy. So running the OS repo's bootstrap after the role repo's could quietly swap in an older Core, and core-doctor then read current. The shared driver now checks the Core already linked. If it comes from another checkout with a newer core.version, the run leaves every Core link alone, still wires its own layer, says why, and leaves the relink stamp as it was. --force-core overrides. An unreadable version or an equal one relinks as before (#1211).
The maintenance bots' Claude Code CLI pin rolls forward, 2.1.281 → 2.1.285. The weekly freshness review (#1193) found it the only scripts/tool-versions.env pin behind upstream that is not deliberately held. It is an npm install, so there is no *_SHA256 to refresh. shfmt stays held at 3.13.1 (#813).
CI audits on Ubuntu 26.04 ahead of the ubuntu-latest switch. ci.yml's audit matrix gains a temporary ubuntu-26.04 leg, because ubuntu-latest rolls to 26.04 between 2026-10-19 and 2026-11-19 and the new image changes or removes tools. The leg is not a required check, so a 26.04 break shows up before the switch without blocking a merge. It comes out once the rollout completes. The luacheck cache key now includes the matrix OS so the two Ubuntu legs do not restore each other's natively built tree. .github/actionlint.yaml returns to declare the label, because the pinned actionlint 1.7.12 does not know it yet and would red the audit on every leg (#1200).
The fleet's pinned shfmt moves 3.13.1 → 3.14.1, ending the #813 hold (#1217). The hold assumed the bump would add ::warning:: nags to consumer repos that pass today. Measured against every sibling's main, none of the ten lint-call.yml consumers passes today, and 3.14.1 leaves each one's count of drifting files unchanged. The one consumer where shfmt blocks is dotfiles-MacBook's make fmt-check, and it was rewritten first to a form both versions agree on (dotfiles-MacBook#275). SHFMT_SHA256 is refreshed, and the tool-versions.env note now says where the next output-changing bump can bite. Core's own scripts are unaffected, since Core does not run shfmt.
make fleet-protection now reports each repo's Actions execution settings. After the ruleset rows, the default run lists whether GitHub itself refuses a tag-pinned action (sha_pinning_required, the server-side twin of check-modern.sh rule 3) and the allowed_actions policy. The rows are reported, not gated: they never change the exit code until the fleet decides whether to enforce them. An unreadable setting prints as ?, not as "not required". They need repo admin, so --rulesets-only, the CI mode, skips them and says so. The first run shows SHA pinning required on 2 of the 11 repos it covers: dotfiles-core and dotfiles-MacBook. --help now prints the whole header instead of a fixed line range that had fallen out of date (#1226).
jc is part of the stack: it turns command output into JSON. ps aux | jc --ps, jc dig example.com and a few hundred other parsers hand jq something to transform. Before this, Core's JSON tools could transform, grep and explore JSON but could not produce it from ps, ss or dig. It is its own command with no alias, probed by zsh/00-tools.zsh and listed in core-doctor's data / net group. Every OS repo now installs it. PORTING-MATRIX.md gains a jc row and footnote ⁴⁰, which records the two exceptions: openSUSE Leap 16.x has no package, so it is declared opt-in there, and Gentoo's dev-python/jc is testing-keyworded only (#1208).
/modernize now re-checks its standing watches on every run. Some floor changes wait on an upstream event, so the routine gains a Standing watches list and reports each one as still watching or trigger met. It starts with two. One is workflow dependency locking (#1223), which would extend the SHA-pin rule to an action's transitive and composite uses: once GitHub ships a public preview. Rule 3 in scripts/modern-baseline.yml now names that gap. The other is the retirement of the macos-15 and windows-2022 runners (#1222).
The README's install steps now put the OS layer before a role repo. Offense used to be shown cloned and bootstrapped on its own, but it ships no OS layer: Kali needs dotfiles-Debian first, and Defense needs whichever OS repo the box runs. The WSL mirrored-networking note now points at dotfiles-Debian/wsl/windows.wslconfig.example, because Offense no longer carries that file.
PORTING-MATRIX.md no longer promises installs that don't happen. Kali's yazi and viddy cells read cargo²¹ (available, not installed): dotfiles-Debian installs Kali's cargo but cargo-builds nothing with it. Footnote ¹³ says Arch only hints the AUR 1password-cli, and that the vendor-repo setup is key-first rather than rolled back. Footnote ¹⁵ drops a go install fallback for glow/gum that never existed. Footnote ¹⁸ counts four openSUSE installers and two curl | sh routes now that yazi's is gone. The lineage notes name NixOS as its own lineage and Offense as a role layer on dotfiles-Debian. Found by the weekly doc-audit (#1192).
PORTING-MATRIX.md footnotes ⁵ and ³³ stop saying dotfiles-Fedora has no version floors (dotfiles-Fedora#203). dotfiles-Fedora#193 landed both floors on 2026-09-17: the # min: pair on neovim and tree-sitter-cli, a warn-only NEOVIM_FLOOR, a tree-sitter cargo fallback that runs only below TREESITTER_FLOOR, and a floor-agreement gate. Both footnotes now describe that, and ³³ drops its claim that Fedora was the last non-rolling target without a floor.
The README opens with a rendered terminal hero (dotgibson/dotfiles-core#948). Every other public repo in the fleet got one this week from dotfiles-core's shared tape generator, and this repo was the one it could not serve: the host layer is PowerShell and nothing under core/ is vendored, so there is no zsh to drive and no .zshrc to source. assets/demo.tape is therefore hand-authored — the same font, palette, framerate and beat rhythm as the nine generated tapes, the same three marquee moments (ll, cat README.md, glog -8), then this repo's own two: up -n, the fleet's one verb resolving here to scoop status + winget upgrade, and core-version. It is filmed from a WSL distro with vhs driving the Windows pwsh.exe through interop (assets/README.md has the recipe and the knobs: PSMUX_NO_AUTOLAUNCH, DOTFILES_UPDATE_CHECK, PSReadLine predictions off), and assets/demo.gif is gifsicle-optimised under the 2 MiB ceiling Core enforces for its own. Re-render after any prompt or tooling change; the gif is committed beside the tape.
task.ps1 — the fleet's make verbs, for a host without make (dotfiles-core#855). dotfiles-core declares the seven canonical verbs once (help lint check dry-run packages-check core-verify test, its scripts/make-vocabulary.txt, dotfiles-core#691) so a contributor moving between repos re-learns nothing — and this repo was the one that still answered to none of them: no Makefile, no runner, only bare scripts, in the fleet's most-tested repo, where "reproduce the CI gate locally" has the most to offer. make is not a given on a Windows host and just would be a new dependency, so the verbs are spelled in the language the repo is written in: .\task.ps1 <verb>. It is a dispatcher, not a second implementation: lint is tests/Invoke-Validation.ps1 plus gen-theme.ps1 -Check (ci.yml's dependency-free legs), test is tests/Invoke-Tests.ps1 (the gated suite, exactly as CI runs it), dry-run is install.ps1 -DryRun, check is lint plus the links-only bootstrap previewed (Windows has no throwaway HOME to run it in), packages-check is packages/Check-PackageFreshness.ps1 and core-verify is the three tests/Assert-*Parity.ps1 gates over the mirrored assets. Each step runs in a child pwsh -File, so a script's exit ends its own process and the first failing step's code is the verb's. Anything after a single-step verb passes through (.\task.ps1 test -NoGate). Core's fleet-vocabulary.sh register reads the verb table statically — the quoted keys of Get-TaskVerbs, and whether test names the suite runner by path — so tests/Task.Tests.ps1 pins that shape, the exact verb set in the contract's order, and that every step names a script that exists.
remote-install runs the host sshd as a service that restarts itself (#266). The box's front door was a bare sshd.exe with nothing supervising it (sc.exe query sshd returned 1060), so every crash and every reboot needed a human. remote-install now registers sshd as an Automatic service with recovery actions (restart after 5s / 10s / 30s, failure count reset daily), and remote-status reports the same plan while changing nothing. Two traps the planner exists for: registration is not health — scoop's install-sshd.ps1 registers with "take no action" recovery, so recovery is probed separately via sc.exe qfailure — and the ImagePath goes through scoop's current junction, never its resolved target, because a version-pinned path dies silently on the next scoop update openssh (the Get-DotStablePwshPath trap). It also warns when openssh is not held, since a running service keeps the daily scoop update * from replacing its binaries. This narrows 34-remote.ps1's standing rule rather than reversing it: ports, keys, firewall rules and DefaultShell still belong to the runbook; only *supervision* moved into the verbs, which stay explicit and refuse without a token. (powershell/os/34-remote.ps1, powershell/Dotfiles/Remote.Helpers.ps1, tests/Remote.Tests.ps1, docs/REMOTE-ACCESS.md)
Alt+C opens PSFzf's directory picker (#246). PARITY.md listed Alt+C as aligned for years while neither shell bound it. It is not a second key for Alt+Z: Alt+Z is a zoxide frecency jump to anywhere, Alt+C is "cd into a subdirectory of here". It follows the lazy-load shape of the Ctrl+T and Ctrl+R stubs, so PSFzf's import stays off the render path. The loader resolves Invoke-FzfPsReadlineHandlerSetLocation with Get-Command rather than calling it blind, because that name was never verified against the pinned PSFzf. If it is missing, the first press is swallowed and the next one works, rather than printing an error at the prompt. (powershell/core/10-tools.ps1)
desktop/PARITY.md's generated block takes the canonical marker, and the marker now says who writes it (dotgibson/dotfiles-core#1129). The pair became <!-- core:desktop-parity:gen parity --> … <!-- core:desktop-parity:end parity -->, matching the core:<ns>:gen <id> grammar every other generated region in the fleet uses. Not a tidy-up. core: is *provenance* — it names the repo whose generator owns the region — and this file is precisely where that matters: nothing in this repo writes the block, nothing here gates it, and until now its only clue that dotfiles-core rewrites it was a marker that did not say so. The block id is the other half; the old pair carried none, so the file format could hold exactly one generated region, forever. The bytes between the markers did not move. Core renders whichever marker form the target file carries, echoed back verbatim, so this repo and dotfiles-MacBook can be renamed in separate commits without the weekly parity-check sweep reding in between — which is the whole reason Core learned to accept both forms first (dotgibson/dotfiles-core#1143) rather than changing the string in one place and breaking two others. Core drops the legacy arm once both copies carry the new pair. Nothing here reads these markers — no Pester test, no workflow, no gate — so this is a two-line change in one file.
nvim/ is vendored from dotfiles-nvim directly, and its pin is a root-level nvim.lock (dotgibson/dotfiles-core#1124). dotfiles-core's NVIM-SPLIT-PROPOSAL.md extracted the editor into its own repo with its own gate — headless startup, :checkhealth, luacheck against a real Neovim, none of which Core's runners could do — and its own release line. This repo had always consumed the editor alone through a bespoke mirror, pinned to a Core ref that had nothing to do with when the editor changed; §3.1 called that "the tell". So nvim-sync.ps1 now points at dotgibson/dotfiles-nvim, and the side channel becomes the front door: a first-class second consumer of a real release line, on the same pin shape Core uses for its own copy. The re-point moved no editor bytes — the vendored tree at dotfiles-nvim v1.0.0 is byte-identical to the Core tree it replaced, which is what the extraction promised. - A bare nvim-sync.ps1 run now pins the newest vX.Y.Z release rather than tracking a branch tip. Releases are the unit the upstream gate signs off on, and they are what makes the Windows and Core pins comparable. -FollowBranch keeps the old behaviour, -Ref pins an exact revision, and the release picker orders numerically (so v1.10.0 beats v1.9.0) while dropping prereleases and the moving v1 major alias — a pin must never name a tag that moves. - The marker moved out of the tree it describes, from nvim/.core-ref to nvim.lock at the repo root, and that retires three workarounds: the parity gate compares nvim/ with no exclusion set (so it is byte-identical to upstream's), the sync bot judges drift on a plain git status -- nvim, and auto-tag.yml's nvim/ trigger cannot see a pin-only change, so a re-pin that moves no editor bytes cuts no release tag. nvim.lock carries dotfiles-core's field names on purpose, so fleet-drift.sh can compare the two pins directly. - The gate's clone target is now an owner/name slug** rather than a URL. The allowlist no longer has to enumerate every spelling of the same repo, and the URL CI dials is built from a value already matched against it. - dotfiles-doctor's nvim vendor row leads with the editor release (vendored nvim v1.0.0 (7ba9457)), because a tag answers "which editor is this?" on sight where a bare sha needed a lookup. starship/ and theme/ are unchanged: they are still vendored from dotfiles-core and still record a .core-ref. - The CI job keeps the name nvim Core parity deliberately, though the editor no longer comes from Core. It is one of the seven contexts the main ruleset requires; renaming it without editing branch protection first would leave every PR BLOCKED. That rename is its own change.
notify-web.yml passes client-id, not the deprecated app-id, and reads the new FLEET_APP_CLIENT_ID org variable (#251, dotfiles-core #831). The pinned create-github-app-token (v3.2.0) deprecates app-id, and this repo's inline notifier — it does not consume Core's reusable — passed it. The variable is a new one because FLEET_APP_ID holds the App ID and the new input wants the Client ID, a different value; the if: guard and the ::warning:: that is this repo's only signal of a silently skipped refresh move in the same commit. Precondition: the org variable must exist before this merges, or the mint skips and the showcase stops refreshing with only that warning to say so.
remote-status / remote-install guard sshd's DefaultShell, which could lock you out of the box (#267). A healthy sshd refuses *every* login when HKLM\SOFTWARE\OpenSSH\DefaultShell names a shell it cannot launch, and it rejects before authentication completes, so the client sees only Permission denied (publickey,keyboard-interactive) — every symptom points at keys. It happened here when the Store pwsh moved 7.6.5.0 → 7.6.6.0 and Windows deleted the old package directory. This is the third consumer of a version-pinned pwsh, after scheduled tasks and the sshd ImagePath (#266). Get-DotSshdShellVerdict grades the value against two independent disqualifiers, because the obvious fix for one trips the other: - A version-pinned Store directory is graded fragile while it still exists. - The per-user app alias is version-stable, but it is a reparse point, which sshd (0x105 lineage) reports as "does not exist" exactly like the deleted directory. remote-status reports the value next to the service. remote-install repoints it at the first machine-wide real file it finds, preferring the MSI pwsh, and says so plainly when it has to fall back to Windows PowerShell 5.1. The docs note that winget's Microsoft.PowerShell manifest can be msix-only: the MSI has to come from the GitHub release, and installing it retires this failure for all three consumers. (powershell/os/34-remote.ps1, powershell/Dotfiles/Remote.Helpers.ps1, tests/Remote.Tests.ps1, docs/REMOTE-ACCESS.md)
The daily maintenance run reports a per-app scoop upgrade failure instead of logging ok (#263). scoop update * exits 0 even when one app fails, so the step's exit-code check could not see it. tailscale failed every day from 2026-08-20 to 2026-09-16, re-downloading a 36.6 MB MSI each time, while the log said ok scoop upgrade (apps). Get-DotScoopUpgradeOutcome reads the result per app out of the step's output. It keys on the *block*: an app has failed when its Updating '<app>' block never reaches "was installed successfully!", and an ERROR line inside the block is kept as the reason. The step sets $LASTEXITCODE itself, so the runner's existing "FAIL … — continuing" path makes one bad app visible without aborting the run. The root cause is not fixed here: tailscale's pre_uninstall needs admin and the daily task runs unelevated, so it needs a scoop hold or the elevated-task route. (powershell/Dotfiles/Maint.Helpers.ps1, tests/Maint.Tests.ps1)
A sync bot that loses its App token now says so, instead of quietly reverting to a PR nobody can merge (#269). The mint step added in #268 carries continue-on-error: true, which is right — a fork, or a repo the App was never installed on, must not go red — and was also silent. When the mint *failed* rather than being skipped (the installation pulled, so create-github-app-token 404s on /installation) the token came back empty, ${{ steps.app.outputs.token || github.token }} fell back to GITHUB_TOKEN, and the job still concluded success. That is the #265 failure re-entering through its own fallback: the PR opens, ci.yml never fires on it, no required context arrives, and it sits BLOCKED until a human closes and reopens it. It happened the day #268 landed — theme-sync run 35223874896, all three bots degraded for most of a day — and what noticed was a *different* repo's weekly sweep (dotgibson/dotfiles-core#1116), not this one's own green run. All three bots now carry an identical step between the mint and the checkout, gated on steps.app.outputs.token == '', which is the one test that covers both branches: a skipped step and a continue-on-error failure leave the output empty for the same reason, where steps.app.outcome == 'failure' would miss the skip and steps.app.conclusion is success in both. It raises a ::warning:: and a $GITHUB_STEP_SUMMARY note that name the *consequence* rather than the cause — the cause is the part a reader can already see — and pass steps.app.outcome through, so skipped (no variable or secret reached the run) reads differently from failure (the App could not mint for this repo; go look at the installation). Deliberately still not a failure: a fork must not go red, and a real drift from Core is worth landing behind a manual nudge rather than not landing at all. New tests/SyncWorkflows.Tests.ps1 pins the block byte-identical across the three files and ordered *before* the checkout (a bare if: implicitly ANDs success(), so after it a checkout failure would swallow the warning) — nothing else read these workflows at all, which is how a fix applied to two of three would have shipped green. (.github/workflows/nvim-sync.yml, starship-sync.yml, theme-sync.yml, tests/SyncWorkflows.Tests.ps1)
The three sync bots open their PRs as the fleet App, so CI actually runs on them (#265). nvim-sync, starship-sync and theme-sync all pushed the branch and opened the PR with GH_TOKEN: ${{ github.token }}, and GitHub deliberately starts no workflow run from an event the default GITHUB_TOKEN created. So ci.yml's pull_request trigger never fired, not one of the six contexts the main ruleset requires ever arrived, and every sync PR sat BLOCKED indefinitely — #260 and #264 were each unblocked by a human closing and reopening them, which re-fires pull_request, and both merged minutes later. That manual step was being paid weekly, and it was not merely an annoyance: Core's fleet-drift.yml sweeps Mondays while these bots run Tue/Wed/Thu, so a sync PR waiting on a human reads as a Core drift red that is nothing of the kind (dotgibson/dotfiles-core#1058). Each bot now mints a short-lived, repo-scoped token from the dotgibson-fleet-sync App (actions/create-github-app-token, at the same SHA pin notify-web.yml already carries) and both pushes and opens the PR with it, so the PR is App-authored and its CI runs unattended — the same problem, and the same fix, as dotfiles-core's freshness.yml on its own self-PRs. Narrow on both axes: no owner:/repositories:, which is what scopes the token to *this* repository, and only contents + pull-requests: write — deliberately not workflows: write, so a bot that ever tried to rewrite CI fails loudly instead of having quietly been able to all along. It degrades rather than fails: with no App configured the mint is skipped and the bot falls back to GITHUB_TOKEN, so the PR still opens and merely needs the old manual nudge. GH_TOKEN moves from the job env: to the PR step's, because a job-level env: cannot read the steps context. The two App-free options weighed in #265 do not actually work — the recursion guard covers a push and a bot's own close+reopen exactly as it covers pull_request, so neither would have produced a single check. Depends on live infrastructure: the App is installed on this repo today, but Core's GITHUB-APP-AUTH.md still lists it under "does not need installing" and scripts/fleet-app-scope.sh reports it as an install nothing writes to; a Core-side issue moves it to a justified entry beside dotfiles-core's own. (.github/workflows/nvim-sync.yml, starship-sync.yml, theme-sync.yml)
**The package-freshness check now compares *directionally* — a lock that runs ahead of its source is no longer reported as behind (#234, #250). Check-PackageFreshness.ps1 gated every row on Test-PackageVersionMatch, which answers "same release?" and says nothing about which side is newer. So winget still advertising TranslucentTB 2026.1 under a 2026.2.0.0 lock filed as an update every week, and re-pinning (#241, #254) could never clear it. New pure helper Compare-PackageVersion in packages/PackageLock.ps1 orders the shapes the lock actually carries — dotted numerics, scoop's 8.22.0_1 / 25.0.2-10 / 10.0.0.0p2 suffixes, the 20260812 and 2026.08.19 date stamps, and pre-release words — and returns $null rather than guessing when it cannot. The checker's single verdict function (Get-FreshnessVerdict) routes each row: behind is the only finding; ahead becomes a one-line "lock ahead of source" note in the report, visible but not actionable; unordered** lands under "Skipped (could not compare)" with both versions and *still files a report*, so a shape the comparer does not read surfaces instead of vanishing into a green run. The padding tolerance (2.7.10.0 == 2.7.10) is unchanged. (packages/PackageLock.ps1, packages/Check-PackageFreshness.ps1, tests/Packages.Tests.ps1)
Each box now records which Core it last relinked against, and says when it is behind. core.lock records what a repo vendored, and nothing recorded what a box had relinked. So the fallback deletion in #763 had to guess when the fleet had re-bootstrapped, and the only check was a one-liner typed on each host. bootstrap.sh (via blib_main, or blib_write_relink_stamp for a bootstrap off the driver) now writes ${XDG_STATE_HOME:-~/.local/state}/dotfiles-core/bootstrap.lock, host state in core.lock's read-never-sourced shape. It holds core_sha, core_tag, the lib that ran, the mode and when. A dry run, an --only/--skip partial wiring or an aborted run leaves the previous stamp. An identical re-run touches nothing, so the scaffolded check-links.sh idempotency witness stays silent. core-doctor gains a relink row and a .relink key in --json, which print relinked at v7.11.0 — repo vendors v7.12.0, run ./bootstrap.sh --links-only. A "relink pending" line fires at shell start only on a real mismatch (CORE_RELINK_NUDGE=0 silences it). A box bootstrapped before this reads unknown, never red. bootstrap-test.yml's links-only leg asserts the stamp wherever the vendored lib knows it (#1154).
CORE_SHADOW_CLASSICS=0 stops Core taking over standard command names. An operator who works on other people's machines trains habits on Core that fail elsewhere. cd→z jumps by frecency where cd would have errored on a typo, and rm -i teaches you to expect a prompt that no foreign box gives. Exported before the shell loads Core (in ~/.zshenv), the knob skips every alias in zsh/20-aliases.zsh that takes over a standard name: ls, cat, cd, vim, diff, rm/cp/mv, mkdir, tree, du, ps, top/htop, watch, df, ping and help. The names that collide with nothing (ll, la, lt, llt, catp, cdi, bat) stay. It is unset by default, so nothing changes for anyone who does not set it. In aliases.md each governed row now has a Note that starts with shadow, and the suite reads the shadow set from those comments, so a new shadow without the gate fails. Removing the shadows outright was decided against until a real incident is recorded, the same bar as #692 (#1155).
The bash 3.2 floor stays, and PORTABILITY.md §1 now says why. Retiring it for lib/*.sh behind a re-exec stage-0 was costed first (#1153). It would shed about 30 lines of lib/. The injection guard stays because namerefs still expand a subscript, the §5k gate stays re-scoped, and a blib_main stage-0 misses dotfiles-MacBook, the only host on 3.2. RELEASE-STRATEGY.md §2 records it as a candidate declined before it reached the Breaking Backlog.
The CI floor's template-injection rule now covers push-trigger ref names and workflow inputs. Rule 7 of scripts/modern-baseline.yml bans an attacker-influenced ${{ }} expression inside a run: body, and it named github.head_ref — the fork branch on a pull request — but not github.ref_name, which on a push or tag event is the same attacker-chosen string by another trigger (git refnames allow $ ; & | ( ) { }). It now bans github.ref_name and, for uniformity, github.base_ref. And its inputs. exemption, earned by the composite setup-core-tools/action.yml, was applied to every gated file, which left bare inputs.* ungated in the workflows — where it is workflow_dispatch free text or a value a sibling repo feeds one of Core's *-call.yml@vN workflows. A new rule 7b (banned_run_interpolation_contexts_workflow_only) bans it under .github/workflows/ alone. Both were free: every occurrence in the tree was already routed through env: (#1160).
The CI floor bans secrets: inherit, and Core stops documenting it. The caller example at the top of claude-routines-call.yml, the shape the seven OS repos were told to copy, passed secrets: inherit. That hands the called @v7 workflow every secret the caller repo holds, declared or not, at a moving tag the caller does not pin. The seven live callers had already moved to the explicit CLAUDE_CODE_OAUTH_TOKEN: mapping, so only the comment was wrong, and it now shows the mapping. A new rule 9 in scripts/modern-baseline.yml (banned_call_secrets) reads the value the way rule 5b reads write-all: anchored to the key, bare or quoted, a trailing comment tolerated. It was green on arrival, and no workflow in the fleet passes it (#1160).
Two zsh plugin pins roll forward in zsh/45-plugins.zsh (#1156, the freshness bot): zsh-history-substring-search 14c8d2e0ffae → a0bdb0d47dba and zsh-syntax-highlighting 2fc57d63067c → 0bfcb582e71d. This is the one change in the release that reaches a host, so it is recorded here even though a bot landed it — CONTRIBUTING.md has no carve-out for automation. Both ranges were read against the upstream compare: the first is two commits touching only README.md (+2/-2, a zplug snippet fix), the second one commit adding an Arch install path to INSTALL.md (+8/-0). So the pins move and no plugin code does; the other six pins were already current.
The scaffold's README is now linted for real, and held to Core's. The suite drove the scaffolded repo's markdown leg through a shim that records argv and exits 0, so the README new-os-repo.sh writes was never judged by the .markdownlint.jsonc it writes beside it, and its hand-copied shield block could drift from Core's unnoticed (#1162). 35-new-os-repo.sh now runs the real markdownlint-cli2 on it wherever the tool is installed (CI's main leg installs the pinned one), and asserts the header and every shield link definition match Core's README.md with the repo name swapped. dotgibson-* (Core's release, on purpose) and ci-url (lint.yml, not ci.yml) are the stated exceptions.
new-os-repo.sh writes the fleet's shield row into the README it scaffolds. A checkup that fetched every badge and link target across the fifteen public READMEs found the two newest repos opening with no shield row at all — dotfiles-NixOS because this scaffold wrote none, and its generated .markdownlint.jsonc deliberately left out the MD033 allowance the row needs ("a fresh OS repo has no showcase page"). The scaffold now writes the eight-badge row above the title, the link definitions at the end, the CI badge pointing at the lint.yml it also writes, and the scoped MD033 allowed_elements stanza the siblings carry — so the next repo starts where dotfiles-NixOS had to be brought to by hand (dotfiles-NixOS#11). The same checkup fixed a dead bash link here (#1151), two logo slugs simple-icons no longer ships in each of dotfiles-Windows and dotfiles-Defense, and dotfiles-Offense's Python badge, which read CPython's GitHub releases — it publishes tags — and rendered "no releases or repo not found".
Two tool pins roll forward: markdownlint-cli2 0.23.2 → 0.23.3 and the maintenance bots' Claude Code CLI 2.1.273 → 2.1.281. The weekly freshness review (#1159) found them the only pins behind upstream that are not deliberately held. markdownlint-cli2 0.23.3 only updates dependencies, and both versions pin the markdownlint library at 0.41.1, so no rule is added, renamed or given a new default in Core or in the lint-call.yml consumers. The pre-commit hook's rev moves with it, because §9 keeps the two in step. Both are registry installs, so there is no *_SHA256 to refresh. shfmt stays held at 3.13.1 (#813).
Both atuin guard premises re-measured against 18.23.0; both VERIFIED_AGAINST anchors move (#1158, run 35956975589). Upstream released 18.23.0 on 2026-09-22, one minor past the 18.22.0 the anchors in zsh/00-tools.zsh carried. One atuin-guard-verify dispatch, checksum and build-provenance verified: silent discard holds (its report job skipped), and autostart self-healing is moved exactly as it was on 18.22.0. absent and stale spawn a daemon and land their row, and wedged blocks on the pidfile lock and loses it (upstream atuinsh/atuin#4114, still open). That is the shape #1102 already answers by probing and warning rather than standing down, so the auto-filed #1177 asks nothing new of Core. Editing an anchor is a claim that the premise was re-measured at that version, so this is that claim and not a version bump. 18.23.0 continues 18.22.0's direction, and the block now says so: an FTS index over captured command output and a sync engine replacing the event bus both sit behind the socket, with no client-side spool or direct-write fallback (the one PR that would have changed the client's connect shape, atuin #4168, closed unmerged). A dead socket still discards, and atuinsh/atuin#3382 (accept-but-silent) is still open, so the steer away from socket activation stays.
The test suite no longer writes to the developer's real ~/.config/zsh/.zshrc. Every fixture moves HOME into the sandbox, but an interactive fleet shell also exports ZDOTDIR=~/.config/zsh, and the driver defaults ZDOTDIR only when it is unset. So a make audit (or make release) run from such a shell had the driver and scaffold fixtures re-point the real $ZDOTDIR/.zshrc into a temp dir, deleted at exit, and the next shell started bare. The same inherited mise activation state made sandboxed shells error on an untrusted mise/config.toml, so the audit went red locally while CI stayed green. test-core.sh now unsets ZDOTDIR, the four XDG_*HOME dirs and every MISE*/__MISE_* variable before any fragment runs, and 05-suite-shape.sh asserts that none survive. The test/check-links.sh that new-os-repo.sh scaffolds clears the same variables itself.
The fan-out count gate no longer reads binary files. _core_fanout_count_hits ran its awk over every tracked file, assets/demo.gif included, so every make audit printed a gawk Invalid multibyte data detected warning under a UTF-8 locale. It now skips any file containing a NUL byte. It does not use grep -I, because BusyBox grep accepts that flag and ignores it. The verdict never changed (a GIF makes no fan-out claim); the audit's output is quieter.
new-os-repo.sh writes the LICENSE its README shield advertises. The shield row added above carries an MIT License badge linking blob/main/LICENSE, but nothing wrote that file (it is in neither core.manifest nor core.vendor), so a new repo's first push would show license | not identified and a link that 404s. The scaffold now writes Core's LICENSE (the whole fleet's, byte for byte) with the birth year, and the generated .markdownlint.jsonc names the real reason MD041 is off: the README opens with the back-to-top anchor and shield row, not an H1.
PORTING-MATRIX.md and the README stop contradicting the fleet (#1174, from the #1157 sweep). The sesh row's Arch cell asserted AUR⁹, but dotfiles-Arch go-installs sesh and says the AUR sesh-bin is not needed; it is now go⁹, and footnote ⁹ names Arch among the go-install consumers. Footnote ³³ gave Gentoo's ~arch neovim range two ways eleven lines apart (0.12.5 and 0.12.3); both now read 0.12.5, as the TSV does. Footnote ³⁴ said jq 1.8.2 reached "all three" supported Alpine stable branches; Alpine carries four, and 3.21 is still on 1.7.1. The README's install steps gain the Defense clone and name Debian and NixOS among the Linux distros, matching the "all ten bootstraps" line beneath them.
Every registered README hero is now filmed — the tenth, dotfiles-NixOS's, landed as dotfiles-NixOS#8, so the "still to be filmed" prose in CLAUDE.md, assets/hero-repos.txt and assets/README.md is retired. It was the one row no rootless chroot could film — its host guard asserts sudo nixos-rebuild switch --upgrade and up -n probes $PATH for it — so it was filmed on NixOS-WSL, and assets/README.md gains "Filming the NixOS hero": the two-rebuild install, nixpkgs' own render kit (vhs 0.11.0, not the 0.12.0 that writes no gif), the Adwaita Mono rejection the ❖ fallback needs, and the trap that is NixOS-WSL's alone — a nixos-rebuild switch resets the kernel-global binfmt_misc table, which every distro on the machine shares, so the distro you drive from loses wsl.exe until it is re-registered (the recipe is there). The install also surfaced dotfiles-NixOS#6: home.nix named two attributes nixpkgs does not have, invisible to that repo's parse-only gate. §9k's rule stands: a registered tape with no gif is a skip, so a re-render that has not landed never blocks the gate.
PORTING-MATRIX.md gains an eza fleet-version table (footnote ³⁹), against a 0.23.5 floor. 0.23.5 added --hyperlink=auto and lines-of-code counting, and an older eza rejects the flag outright. The /tool-scout scan in #1158 held it on a watch that ends only when every lane is at or above that version, and until now nothing recorded where the lanes sit. scripts/fleet-package-versions.tsv now carries seventeen rows, each read from the distro's own index. Seven are at or above; Alpine edge/3.24/3.23 and Gentoo stable are one patch short on 0.23.4; Ubuntu 24.04 (0.18.2), both Leap backports (0.20.4), Debian 13 (0.21.0) and Alpine 3.22/3.21 are further back. gen-porting-matrix.sh registers the block and marks the eza row. Core enforces no eza floor, and zsh/ passes no flag that needs one.
windows-terminal/settings.json's colour scheme is generated from theme/palette.toml (#230). The twenty hexes in the Tokyo Night scheme were the largest hand-typed colour block left after #228 — deliberately skipped that round, because the file is APP-OWNED: install.ps1 symlinks it into all three Windows Terminal flavours' LocalState, the app rewrites *this copy* on every settings change, and its JSON writer strips comments, so a // core:theme:gen marker would not survive the first toggle. gen-theme.ps1 therefore grew a second emitter kind. json-scheme finds the scheme object by its own "name" (the app re-sorts the array alphabetically, so an index is not stable), scoped to the schemes array so a profile sharing that name cannot be hit, and rewrites the hex values on the lines they already occupy — indentation, key order and trailing commas preserved byte for byte. Deliberately not a ConvertFrom-Json | ConvertTo-Json round trip, which would reformat all 227 lines every run and fight tests/Format-AppJson.ps1's no-re-indent rule. It emits UPPERCASE hex because the app does; lowercase would make every GUI round-trip a twenty-line colour diff. Registering the file also turns on the residual-hex scan over it for free, so a hex the palette does not define can no longer be dropped anywhere in that file.
The last two night-style hexes are gone. background was #1A1B26 and selectionBackground #28344A — tokyonight *night* values in a palette pinned to *storm*, and the same pair #229 had already replaced twice elsewhere (ListPredictionSelected, Get-DotAnsiSgr's Black). They now resolve to color_bg #24283B and color_bg_visual #2E3C64, so the terminal's selection highlight finally matches PSReadLine's prediction bar and its canvas matches psmux's @tn_bg. The other eighteen already agreed with Core and did not move.
The core front door reaches the second family: core update check and core maint <install|run|log|status|uninstall> (dotgibson/dotfiles-core#684). Core's zsh dispatcher grew the same arms in the same change, and its PARITY.md pins them as aligned rows enforced by parity-check.sh — so this lands FIRST, or Core's gate would assert a pwsh half that did not exist yet. Additive only: up, update-check and the five maint-* verbs keep working exactly as before, and core update -y still belongs to up — only the literal word check in first position is intercepted. A bare core maint (or -h/--help) prints the family's usage line rather than erroring, the way a bare core is the index; an unknown sub-verb gets its own did-you-mean (core maint stauts → status). Every arm is an explicit call, never & "maint-$v", because the load contract is derived from literal command names and an indirect call would hide the dependency. Core.Tests.ps1 stubs the six new leaves and asserts routing, arg forwarding, the bare-namespace usage, and that a typo dispatches nothing.
Get-DotAccentSpec — the accent half of theme parity, which this host simply did not have. Core's zsh/05-ui.zsh has _CORE_ACCENT_SPEC / _CORE_MUTED_SPEC, the one place $COLORTERM is interpreted, with a truecolor tier and a hand-picked 256-colour fallback. grep -rnE 'CORE_ACCENT|AccentSpec' powershell/ returned nothing here, so PARITY.md's accent row was a genuine gap rather than a drift. The pwsh twin lives in powershell/core/05-lib.ps1 beside Get-DotAnsiSgr and is generated from the same palette. The two fallback forms deliberately disagree (SGR 111/103 vs spec 75/244); both are carried verbatim and neither is derived from the other.
A theme vendor row in dotfiles-doctor. The sibling of the nvim vendor and starship vendor rows, and the one that matters most on a host: the palette is an *input*, so a stale copy leaves every generated block perfectly self-consistent and quietly a version behind the fleet, with nothing else on screen to say so.
A drift gate on the maintenance runner's own step list. Maintenance.ps1 states its steps in three places — the file header, the -Help block, and the actual Step calls — and nothing kept them honest, so both prose copies had silently lost the scoop junction step (the header had also lost navi). tests/Maint.Tests.ps1 now discovers the steps from the AST and fails when one is not documented in both, with a bidirectional map assertion so a renamed or removed step cannot leave a stale entry that passes forever. Both prose blocks are corrected.
The scoop junction repair now covers every junction scoop makes, and runs on a schedule. #218 established the mechanism — Redirection Guard refuses a junction *created* by a non-admin, trust is stamped at creation, so re-creating it elevated is the only lever — and fixed apps\<app>\current. Two gaps remained. Scope. scoop also wires persisted state back out of an app dir with junctions into scoop\persist\<app>\... (bat\themes, bat\syntaxes, btop-lhm\themes, composer\cache, php\cli, syncthing\config, the yt-dlp plugin dirs), and scoop\modules\gsudoModule points into apps\gsudo\current from outside apps\ entirely — 15 further junctions on this host, created by the same non-admin scoop process and untrusted for the same reason. Re-stamp only current and bat --list-themes is still broken over ssh. The sweep is now every directory reparse point under the scoop root; -Recurse does not descend *through* a reparse point, so each physical junction is reported exactly once under its canonical path. It never actually ran. The step is gated on elevation, and dotfiles-maint is registered RunLevel = Limited — which is the open question issue #217 asked to resolve first. maint-install now also registers dotfiles-maint-scoop-junctions, running as SYSTEM an hour after the daily job. SYSTEM rather than the interactive user at RunLevel Highest, because an Interactive task only runs while someone is logged on and the case this fixes is nobody being; -ScoopRoot and -LogPath are baked into the action since SYSTEM's profile paths are not the user's. Sequencing is a time offset rather than an event trigger on the first task completing, because Microsoft-Windows-TaskScheduler/Operational is disabled by default and that subscription would never fire. The daily task deliberately stays unelevated — scoop update * must not run as admin. The sweep moved out of Maintenance.ps1 into maint/Repair-ScoopJunctions.ps1 so the elevated task has an entry point; the policy behind it is Get-DotScoopJunctionPlan, pure and unit-tested. Registering an elevated task itself needs an elevated shell, so maint-install run unelevated installs the daily task and says plainly that it skipped the other one. maint-status reports both tasks with their run level; maint-uninstall removes both.
wsl-ssh-config — the client-side ssh_config for the distros behind this host. docs/REMOTE-ACCESS.md §4 told you to hand-write the Host <distro> block that Format-DotWslSshConfig could already generate: the renderer, the port allocator and the alias slug all shipped as tested module exports with nothing calling them. powershell/os/34-remote.ps1 is that caller. Ports are allocated from the sorted distro list, because wsl --list reorders on install / unregister / re-default and a port that moves is worse than no port — it is already baked into ssh_config, into firewall rules and into muscle memory. -HostPort reserves the port the Windows sshd actually answers on instead of assuming 22; assume otherwise and a distro is handed the host's port, a collision that stays invisible until that distro silently fails to bind. -JumpHost emits the ProxyJump shape — one LAN port, distro ports on the host's loopback — rather than a network-facing listener per distro. It prints and never writes: the file this output belongs in lives on the machine you ssh *from*, which by definition is not this one. Reading the distro list back out of wsl.exe is the one impure step, and it is impure in a way that bites — wsl --list --quiet emits UTF-16LE, which lands in PowerShell as a string with a NUL between every character, so a naive reader finds no distros on a box that has several. WSL_UTF8=1 is set on the child only (and restored, including the unset case, which must be removed rather than blanked), with the NUL strip as the belt for builds that ignore the variable. Still deliberately absent, and still a runbook rather than a script: standing sshd up. The service, the firewall rules, the HKLM DefaultShell key, the boot task and the power settings are machine-global state that varies per box.
Get-DotfilesStubContent and Test-StubIntoRepo (powershell/core/05-lib.ps1, exported from the Dotfiles module) — the stub body renderer and the "is this wired?" predicate for Kind='Stub' rows. Test-StubIntoRepo is a *reference* check, not an equality check, so a user's own added lines survive a re-install; it compares with forward slashes and uses String.Contains rather than -like, because it gates a delete in uninstall.ps1 and a [ read as a wildcard would be a false positive.
powershell/Dotfiles/Remote.Helpers.ps1 (+ tests/Remote.Tests.ps1) — pure logic for reaching this host and the distros behind it: Get-DotWslSshPlan (a stable distro->port map, sorted by name so a port never moves when you install or unregister a distro), Format-DotWslSshConfig (the client-side ssh_config, direct or via ProxyJump), ConvertTo-DotSshAlias, and Get-DotRemoteWiringResult — the triage that says whether a wired config survives an ssh session. Get-DotWslSshPlan takes the Windows sshd's port as a parameter rather than assuming 22. A host that moved sshd off 22 is common, and assuming otherwise produces a confident "port collision" diagnosis that is simply wrong. Deliberately absent: anything that touches the service, registry, firewall, scheduled tasks, or power settings. That is machine-global state which varies per box and is better done by hand with eyes on it — the runbook is in docs/REMOTE-ACCESS.md.
A Remote (ssh) configs row in dotfiles-doctor — probes whether Redirection Guard is enforced in the current session and reports any stub-kind config still wired as a symlink. Only the actionable rows are listed: plain symlinks are also unreadable over ssh under enforcement, but that has no fix at this layer, and listing all eleven every run would bury the one you can do something about.
docs/REMOTE-ACCESS.md — the full diagnosis, the one-command way to confirm Redirection Guard in a broken session, the list of fixes that do *not* work, the scoop limitation, and a corrected WSL section (banner-grabbing to identify which daemon is on which port; who rather than ss for per-distro attribution, since networkingMode=mirrored makes ss show the host's whole peer list).
desktop/PARITY.md's shared half is now generated and gated, and the "identical copy" claim in desktop/README.md is corrected (dotgibson/dotfiles-core#693). This file and dotfiles-MacBook/sketchybar/PARITY.md were an admitted verbatim pair held together by nothing but the sentence *"Edit both together"* — and they had drifted 3.5 KB apart. Most of that was a one-sided Markdown reformat with no semantic content; the real divergence was 947 bytes of Windows-only prose — the psmux battery-scale note — that had never been marked as a deliberate divergence. The shared contract now lives between the <!-- desktop-parity:gen --> and <!-- desktop-parity:end --> markers, rendered from dotfiles-core/desktop/PARITY.shared.md by make gen-desktop-parity; Core's scripts/gen-desktop-parity.sh --check fails the weekly parity-check run if either copy is edited alone. The psmux note is unchanged in substance and stays here, below the markers where the generator does not touch it, now labelled deliberate — so TERMINAL_WORKFLOW_GUIDE.md's pointer at the 40–60 % disagreement still resolves. Edit the Core source, not this file's generated block.
The module step no longer re-downloads modules it already has, which is what had module update: PSReadLine failing on every run. Save-Module -Force rewrites <Path>\<Name>\<Version> wholesale even when that exact version is already on disk, and for a module whose assemblies are mapped into a running PowerShell that overwrite cannot succeed — the files are locked and it fails with *"Access to the path … is denied"*. PSReadLine is loaded by every PowerShell session, so on any box with a shell open it failed every time. Confirmed on this host rather than guessed: Microsoft.PowerShell.PSReadLine.dll and its Polyfiller were held open, with six other pwsh processes running. The module was not out of date. Neither were the other three — all four already matched the gallery exactly, so the step was re-downloading four modules a day to achieve nothing, and failing on one of them for the privilege. It now looks up the gallery version and saves only when there is something newer. This removes the failure rather than hiding it: a genuinely new version installs into its own version directory and never touches the locked one, so the update path that matters is untouched. Find-Module failing (offline, gallery down) deliberately falls *through* to Save-Module rather than skipping — an unknown latest version must not read as "up to date", or the step would go quiet on exactly the days it cannot check. Test-DotModuleUpToDate (powershell/Dotfiles/Modules.Helpers.ps1) is the pure decision, conservative by construction: nothing installed, an unparseable version on either side, or an unknown gallery version all return "save anyway".
The theme is generated now, and three colours had already drifted. Core made theme/palette.toml the one place a colour is authored (dotfiles-core#679) and renders it into every consumer with scripts/gen-theme.sh. This repo was the last in the fleet still typing the numbers — and the hand-copied fzf block in powershell/core/10-tools.ps1 carried a comment claiming it was "kept byte-for-byte in step with Core's zsh fzf.zsh" while three of its fourteen values were wrong: border and scrollbar on #27a1b9 against Core's #29a4bd, and gutter on #16161e against #1d202f. Core's parity-check.sh stayed green throughout, because it only compares the query: colour. The palette is now vendored like nvim/ and starship/ — theme-sync.ps1 + theme/.core-ref, hash-gated against Core by tests/Assert-ThemeParity.ps1 — and gen-theme.ps1 renders it into nine marked # core:theme:gen <id> blocks across powershell/core/ and psmux/. gen-theme.ps1 -Check gates every PR. Four values changed, all of them corrections to Core's: the three fzf colours above, plus ListPredictionSelected and Get-DotAnsiSgr's Black, which were carrying #28344a and #1a1b26 — colours from a *different* tokyonight style that appear nowhere in the storm palette. Everything else regenerated byte-identical, which is the useful part of the diff: it shows the other 50-odd literals were already right.
-Check also rejects a hex the palette does not define. psmux.conf keeps seven colours outside its @tn_* table (pane borders, status style, the @vpn_fg and @pwr_fg seeds) that psmux cannot express as #{@tn_*} lookups, and wrapping each in its own marker pair would be more noise than gate. Instead the checker asserts every remaining hex in a registered file is still a value the palette defines. That is the check that would have caught #16161e and #27a1b9 years earlier, and it is what catches those seven the day Core flips style.
install.ps1 grows Write-StubItem, dispatching on the plan row's Kind. It mirrors Link-Item's contract deliberately — idempotent skip, back up before overwrite, honour -DryRun, same stats — and the install summary gains a stubbed line (emitted only when the caller tracks it, so an older stats bag renders unchanged).
uninstall.ps1 now treats *either* shape as ours (symlink into the repo, or a stub that references it), so it can no longer orphan the files install.ps1 wrote.
dotfiles-doctor is Kind-aware: a stub row reports stub -> repo, and a stub-kind row still wired as a symlink is flagged *"will not resolve over ssh"* with the re-install fix. A symlinked profile is a warn, not a fail — nothing is broken until you ssh in.
auto-tag.yml's Core pin moved from v4.12.0 to v5.0.2 — a major and eight minors in one step, because nothing advances it automatically. This repo vendors no core/, so it is absent from scripts/os-repos.txt: the fan-out never opens a PR here and make fleet-drift cannot see it. The pin advances when a human moves it, and between 2026-08-16 and 2026-08-26 nobody did. Behaviourally this is a no-op, and it is worth being precise about why. auto-tag-call.yml and scripts/auto-tag.sh are byte-identical at v4.12.0 and v5.0.2 (git rev-parse on both blobs agrees), and the workflow re-checks-out dotfiles-core at a moving alias for the scripts it runs — so this host was already executing the same code as every "current" repo. What changes is that the recorded pin now matches what actually runs, instead of reporting eighteen releases of drift that were not real. The genuine defect the audit surfaced is upstream and is being fixed there: the reusable workflows fetched their scripts from v4 while every caller ran at @v5 (dotgibson/dotfiles-core#672). The comment above the pin now records that a SHA pin freezes the workflow body but not the scripts it pulls, so the next reader does not over-trust it.
The navi repo update maintenance step, which was never a real command. navi repo takes only add / browse / help, so the step exited 2 with *"unrecognized subcommand 'update'"* on every run it has ever made — invisible until the exit-code fix above made it reportable. Nothing is lost: navi has no "refresh my repos" verb to re-point it at, and updating imported cheatsheets means git-pulling each one under navi info cheats-path by hand. On this host that path does not exist at all, so there was nothing to refresh either way. Worth adding back the day cheat repos are actually imported (navi repo add). Removed from all four places that named it — the runner, its file header, its -Help block, and TERMINAL_WORKFLOW_GUIDE.md — with the step-documentation gate in tests/Maint.Tests.ps1 keeping those honest. Separately, and on the host rather than in the repo: the scoop install of navi was broken — navi.exe was absent from the app directory, leaving only config.yaml, install.json and manifest.json, so the shim reported *"Could not create process"*. Not a Redirection Guard problem; the junction traverses fine. A reinstall of the same version (2.24.0, matching the bucket, and navi is unpinned) restored it with the persisted config.yaml intact. Two independent faults were stacked behind one silently-passing step.
Ctrl+←/→ word movement was never actually bound — it was PSReadLine's default showing through. 10-tools.ps1's # Ctrl+arrow word movement; Tab = menu complete comment sat above a line that bound only Tab, so the motion came from the built-in key table and no user would ever have noticed. It mattered to the parity contract: Core's PARITY.md marks the Word nav row aligned, but parity-check.sh had to give the pwsh half a - sentinel and report it as a SKIP — the only row in the table resting on a framework default rather than on something this repo states, and so the only one a future PSReadLine or -EditMode change could move without any config moving with it. The four new Set-PSReadLineKeyHandler lines are behaviour-preserving by construction: NextWord, not ForwardWord is both PSReadLine's own default under -EditMode Vi *and* Windows, and the match for zsh's forward-word — both land the cursor on the start of the next word, where ForwardWord lands on the end of the current one. Both Vi tables are pinned, since a bare -Key reaches the insert table only and Core's zsh/40-bindings.zsh binds viins and vicmd both. They are deliberately *not* column-aligned like the Tab/arrow lines above them: parity-check.sh matches its needles with grep -F, so padding here would make the Word nav row whitespace-brittle from another repo. (powershell/core/10-tools.ps1, tests/Repo.Tests.ps1, TERMINAL_WORKFLOW_GUIDE.md; #231 → #238. Follow-up: dotgibson/dotfiles-core#849)
The nvim maintenance step had never done anything, and the log was built so it could not say so. Found by reading maint.log to check whether the nvim wiring fix had taken effect, and discovering the log could never have answered that question. Three compounding bugs in maint/Maintenance.ps1, each hiding the next: 1. The arguments were mangled. Start-Process -ArgumentList @(...) JOINS the array with spaces and does not quote the elements, so `` @('--headless', '+Lazy! sync', '+silent! TSUpdateSync', '+silent! MasonUpdate', '+qa!') ` reached nvim as --headless +Lazy! sync +silent! TSUpdateSync …. nvim read sync, TSUpdateSync and MasonUpdate as filenames rather than as parts of the preceding +command, opened three empty buffers, hit +qa! and exited 0. Measured side by side on a real host: Start-Process 0.2s / 0 lines, ProcessStartInfo.ArgumentList (which quotes each element) 5.9s / 398 lines. Invoke-WithTimeout now uses ProcessStartInfo, reading both pipes concurrently so a child that fills one while the parent blocks on the other cannot deadlock. 2. The output was discarded. Step runs its body under & $Body *>> $Log, which holds the log open; Invoke-WithTimeout then appended the child's output to that same path with Add-Content and Windows refused — *"The process cannot access the file … because it is being used by another process"*. Non-terminating, so nothing failed. Reading the pipes directly retires the temp-file dance entirely and the output is emitted, letting Step's existing redirect capture it with one handle on the log instead of two. 3. Failure was unreportable. Step only ever caught *exceptions*, and a native command that exits non-zero does not throw in PowerShell — so every one of them was graded ok. Live example from the same run: navi repo update printed Shim: Could not create process and was recorded as a success. Step now checks $LASTEXITCODE, and Invoke-WithTimeout surfaces its child's code into it (neither Start-Process -PassThru nor [Process]::Start sets it). $LASTEXITCODE is nulled before each body on purpose: it is session state, not step state, so a cmdlet-only step would otherwise inherit the previous step's code and be blamed for it. Known limit, stated in the code: a body chaining several native commands only reports the last one's code. After the fix, on this host: the nvim step runs ~6s and lands 398 lines of real Lazy! sync output in the log, and navi repo update is correctly reported as FAIL … exited 1 — a genuinely broken scoop shim that had been passing silently. The tests lift Step and Invoke-WithTimeout` out of the AST and execute them — both are nested inside the runner's lock block and cannot be dot-sourced without triggering a real maintenance pass. Verified to be worth having: 6 of them fail against the pre-fix runner.
psmux, jj and mise were all invisible over ssh too. The follow-up #225 named: every remaining config this repo wired was a symlink into the repo, and under Redirection Guard a Windows process cannot read any of them. Measured on this host before the change — jj config get ui.default-command answered *"Value not found"* and mise config ls outside a project listed nothing, so both tools had been silently running on their own defaults rather than erroring. Three different config systems needed three different answers, and finding out which was the actual work: - psmux turned out to already have an include directive. Its syntax is tmux-compatible, so source-file is the mechanism — the repo's own psmux.conf opens with one. Both psmux.conf and psmux.reset.conf become Kind = 'Stub', and the repo conf's existing source-file ~/.config/psmux/psmux.reset.conf line simply resolves to the second stub. No change to the config itself. - ~/.config/psmux/scripts is a *directory* of eight pwsh popup helpers, and a directory has no include form. New Kind = 'StubDir': a real directory of one-line forwarders, one per script, each &-invoking the repo copy with @args. That leaves psmux.conf's eight display-popup binds untouched — they still name ~/.config/psmux/scripts, which is real now — and & rather than dot-sourcing keeps $PSScriptRoot pointing at the repo, so a script that resolves a sibling still finds it. - jj and mise are TOML, which has no include directive at all. Neither can be stubbed, so they are not wired as files any more: Get-DotfilesEnvPlan sets JJ_CONFIG and MISE_GLOBAL_CONFIG_FILE as persistent User-scope variables pointing into the repo — both verified against the installed versions before being committed to. User scope is what reaches an ssh session and a scheduled task, the same mechanism DOTFILES_WIN already uses. A forwarder directory does not track the repo by itself, and the drift runs both ways. Checking only that every script has a forwarder let the other direction survive indefinitely: a script deleted upstream leaves a forwarder pointing at nothing, that forwarder is not *missing*, so install returned "already wired" and its own stale-sweep never ran — leaving a psmux bind that opens a popup and immediately errors. Test-StubDirIntoRepo now fails on both directions, which is what makes the sweep reachable. Two honesty problems came with the env-var mechanism, both handled rather than documented away. Nothing exists at ~/.config/mise/config.toml any more, so the wiring is invisible at the conventional path — hence explicit env: rows in dotfiles-doctor, because a variable that goes missing looks exactly like a tool with no config. And a shell already open when install.ps1 runs keeps the old environment block, so the doctor checks the process value as well as the registry and reports *"set for new sessions, but missing from THIS one"* instead of grading it ok — the same false-ok shape the nvim row taught us to look for. install.ps1 retires the superseded jj/mise symlinks (identified by Test-SymlinkCurrent against the plan's Target, so a link belonging to another checkout is left alone), and uninstall.ps1 clears the two variables — but only while they still point into this repo, and never DOTFILES_WIN, which is how bootstrap.ps1 finds an existing checkout. Still a symlink, and correctly so: .gitignore_global (global ignores reach ssh via the .gitconfig stub's core.excludesfile override) and the interactive-only desktop configs — Windows Terminal, GlazeWM, Zebar.
nvim started bare over ssh — netrw and a black background. %LOCALAPPDATA%\nvim was a directory symlink onto the repo's nvim/ tree, which is exactly the shape Redirection Guard refuses, so the editor never read its own init.lua. #215 fixed the three configs it named and wrote the rest off as "a documented limitation with no fix at this layer" — but nvim *does* have an include mechanism, and this is it: %LOCALAPPDATA%\nvim is now a real directory holding a real init.lua that prepends <repo>/nvim to runtimepath and dofile()s it. Same single source of truth, no reparse point, no elevation. Confirmed on this host, where the reading was rtp=5 netrw=nil theme=nil and cat on the symlinked path returned *Permission denied*. This row is the one where the reparse point sits on the parent of the wired path, which has two consequences the rest of the change is about. dotfiles-doctor asked whether the wired path itself was a link, which for nvim resolved straight *through* the old symlink and graded a broken box ok; it now walks the whole path (Test-DotPathViaReparsePoint). And install.ps1 had to learn to retire such a parent *before* writing anything (Clear-StubParent) — otherwise every Test-Path and Move-Item in Write-StubItem resolves through the old link, the "existing file" backed up is the repo's own init.lua, and the shim lands inside the tree nvim-sync.ps1 mirrors byte-for-byte from Core. tests/Integration.Tests.ps1 drives the real Write-StubItem over a real symlink and asserts the repo comes out byte-identical. That retirement is gated on the plan row naming the directory as its LegacyLink, and the gate is the whole safety story: applied to whatever directory a stub happens to live in, the staleness test matched $HOME for ~/.gitconfig (which contains .gitignore_global, a real sibling of that row's target in the repo) and a -DryRun offered to retire the user's home directory. Caught before anything ran for real; a test now pins it. Prepending runtimepath turned out not to be enough, which is the part worth knowing before editing the shim: lazy.nvim's performance.rtp.reset defaults to true, so lazy.setup() replaces runtimepath wholesale from stdpath('config') — the shim directory — and the prepend is gone before an eagerly-loaded spec runs. It surfaced as a config that clearly loaded (netrw=1) and then died with Failed to run 'config' for tokyonight.nvim … module 'gerrrt.utils.ui-highlights' not found. performance.rtp.paths is the supported answer and is set in nvim/, which is Core-owned, so the shim survives the reset on its own: an appended package.loaders searcher for <repo>/nvim/lua (setting package.path does nothing — Neovim replaces the stock path searcher, and vim.loader inserts its cached loaders at 2 and 3, so it is never consulted), plus a re-prepend of runtimepath after the config returns, since a searcher only answers require() and runtime *file* lookups still need the path. Two knock-ons, both stated in docs/REMOTE-ACCESS.md: stdpath('config') is the shim directory now, so the shim seeds lazy.nvim's lockfile from the repo itself (Core's lazy.lua seeds from stdpath('config'), which no longer holds one) and <leader>rc opens the shim rather than the repo's init.lua — unfixable here, since nvim/ is Core-owned. And the daily dotfiles-maint task runs under the same enforcement, so nvim --headless +Lazy! sync had been doing nothing at all. uninstall.ps1 retires the legacy symlink and drops the shim's directory when the stub was its only occupant, so a box that changes shape is not left with a husk nothing owns. Re-wire with install.ps1 -SkipPackages.
The maintenance tasks were pointing at a pwsh that Windows deletes. maint-install baked (Get-Command pwsh).Source into both scheduled tasks, and a Store-installed pwsh resolves to a *version-pinned* package directory (…\WindowsApps\Microsoft.PowerShell_<ver>_…\pwsh.exe). When Windows cleans up a superseded package the task fails with 0x80070002 — silently, because a task that never launches writes nothing to maint.log. Measured on this host: dotfiles-maint sat at 0x80070002 with no completed run between 2026-08-26 and 2026-08-29, which also meant the scoop junction sweep from #217/#221 was not happening. The feature was correct and simply never got to run. maint-install now prefers a version-stable path — MSI install, machine-wide app alias, per-user app alias, scoop shim — and only falls back to the resolved version-pinned path, warning when it does. The daily task runs as you and takes the per-user alias; the SYSTEM task cannot, since SYSTEM's %LOCALAPPDATA% is under config\systemprofile, so with a Store-only pwsh it stays pinned and says so. Installing the MSI build gives both a stable path. Get-DotStablePwshPath is the pure, unit-tested policy; the probing stays in the fragment. Detection, so this cannot rot silently again: maint-status now shows each task's Execute and attaches a verdict — a missing executable or a 0x80070002 last result is a fail, and the informational SCHED_S_* codes are not. dotfiles-doctor carries a Maint tasks row. Both are careful about what they can actually see: Task Scheduler ACLs a SYSTEM-principal registration to SYSTEM and BUILTIN\Administrators, so an unelevated shell cannot distinguish a broken junction task from a healthy one and reports not visible rather than guessing not installed. (A self-check inside the daily run was tried and removed for exactly this reason: it would have reported a confident false failure every day.)
hostip returned an address no other machine could reach. It took the first non-link-local IPv4 Windows reported, and on any box with a Hyper-V or WSL virtual switch that is vEthernet (Default Switch) — a Manual 172.x address that exists only inside the host. Measured here: hostip answered 172.26.80.1 while the LAN address was 10.0.50.90. Nothing about the address itself says which is which, so it read as correct right up until the connection timed out. What separates them is the routing table: the LAN interface carries a default route and a virtual switch has none. hostip now reads the default routes and the addresses and hands both to Select-DotHostAddress, a new pure module export that makes the choice — preferring a default-route interface in the caller's metric order, and falling back to the old SkipAsSource ordering on an offline box with no default route at all, rather than returning nothing. Surfaced by wsl-ssh-config above, which prints this address into a config you paste on another machine — the one place the wrong answer is guaranteed to bite.
Configs are unreadable over ssh, and it was never an execution-policy problem. On a host running OpenSSH Server, the PowerShell profile failed to load in an ssh session with *"untrusted source"* while loading fine in Windows Terminal. The cause is Redirection Guard (ProcessRedirectionTrustPolicy), which Windows enforces across the whole service / session-0 lineage: a process with it enforced refuses to traverse a reparse point whose target sits under a non-admin-owned directory — i.e. every symlink this repo wired into a repo under C:\Users\<you>. The error is ERROR_UNTRUSTED_MOUNT_POINT. Measured, not theorised: explorer / WindowsTerminal / glazewm report 0x100 (not enforced), while services.exe / wslservice / Task Scheduler's svchost / sshd all report 0x105. sshd inherits it, and every ssh session inherits it from sshd. That split is the whole symptom. It cannot be configured away. fsutil ... R2L:1, deleting the sshd.exe IFEO MitigationOptions, setting that value to REDIRECTION_TRUST_ALWAYS_OFF, and changing the symlink's owner were each tried on a real host and each did nothing — the policy is inherited and non-relaxable. Running sshd as a real Windows service would not help either (services.exe is 0x105 too). docs/REMOTE-ACCESS.md records all of it so nobody re-runs the experiment. The fix is to stop using reparse points for the configs that have to work over ssh. Get-DotfilesLinkPlan rows now carry a Kind ('Symlink' | 'Stub'), and the three that matter are wired as real files that pull in the repo copy through the config format's own include mechanism — same single source of truth, no reparse point: | Config | Mechanism | | --- | --- | | $PROFILE | a real .ps1 that dot-sources powershell/profile.ps1 | | ~/.gitconfig | [include] path = <repo>/git/.gitconfig | | ~/.ssh/config | Include <repo>\ssh\config, first line | ~/.gitignore_global deliberately stays a symlink — a .gitignore has nothing to include — so the .gitconfig stub overrides core.excludesfile to the repo copy instead, *after* the include, because last value wins for a single-valued key. ~/.ssh/config bit twice: as a symlink it also stalled the ssh client on the host, because ssh.exe reads it at startup and inherits the same enforcement. A plain ssh hung while ssh -F NUL returned instantly. Re-wire an existing box with .\install.ps1 -SkipPackages. Not fixed, and called out honestly in the doc: scoop. Its current junctions have the same problem (77 of 78 apps unreachable over ssh on the host this was found on), and there is no include trick for a junction — only taking ownership of the app directories, which scoop undoes on every update.
.core-ref recorded tag = v4-19-g10ad221 — the moving major alias, not the release it describes. (#202) Every Core cut writes the specific vX.Y.Z and then force-repoints the major alias v4 onto the same commit (tag-release.sh, git tag -fa, alias second). Both tags are annotated and both sit on the release commit, so git describe breaks the tie by tagger time and picks the alias. That is a provenance field naming a target that is deliberately moved on the next release: the recorded string silently reinterprets itself, and re-running the same command against the same commit today returns v4.15.1-19-g10ad221. commit was always authoritative — only tag lied, and it lied in the direction that looks fine until you check. Both sync scripts now filter describe to the vX.Y.Z shape (--match 'v[0-9]*.[0-9]*.[0-9]*'), which excludes bare-major aliases by construction — the identical fix Core shipped for core.lock in dotgibson/dotfiles-core#515, so the Windows row and the Unix repos' core_tag agree on what a release name means. When only an alias exists, describe finds nothing and the tag line is omitted: an absent tag is honest where v4 was not, and the SHA stays the source of truth either way. nvim/.core-ref is corrected in place; starship/.core-ref already read v4.9.0 because its last sync was a pinned -Ref landing exactly on a release commit — the same latent bug, just not yet visible. The filter also immunizes -CoreLocal runs against a locally stale alias, since a plain git fetch never force-updates an existing tag (this box's own Core clone has v4 frozen at v4.7.0's commit). The describe call also moved above each script's *_LIBONLY hook as Get-CoreDescribeTag, which is the part that keeps it fixed: the old inline call sat below the hook and was structurally unreachable from Pester, which is exactly why a wrong value shipped unnoticed. The new fixture (New-DotCoreTagFixture in tests/_TestHelpers.ps1) tags one commit v9.9.9 then v9 in release order, and a companion assertion proves a bare describe --tags still gets that fixture *wrong* — so if the reproduction ever stops reproducing, the suite says so instead of going quietly green. (nvim-sync.ps1, starship-sync.ps1, nvim/.core-ref, tests/_TestHelpers.ps1, tests/NvimSync.Tests.ps1, tests/StarshipSync.Tests.ps1)
Why Mason can't install ruby-lsp on this host, written down where package decisions live. The winget package the box runs, RubyInstallerTeam.Ruby.4.0, ships no MSYS2 DevKit, so every gem with a C extension fails to build — ruby-lsp and rubocop both die on prism. The error names neither ruby nor the DevKit (No rule to make target '/C/Ruby40-x64/include/ruby-4.0.0/ruby.h'): with no msys64, gem falls back to scoop's mingw make, which has no rm and mangles the drive-letter path into an MSYS-style one. docs/PACKAGE-OWNERSHIP.md now carries the symptom, the mechanism, and the one-line elevated fix (ridk install 1 3), next to the existing ruby-ownership reasoning. Confirmed fixed on the reference host on 2026-08-24: ridk install 1 3 (elevated) populated C:\Ruby40-x64\msys64, and gem install ruby-lsp now builds both native extensions clean. That is host state, not repo state — a rebuilt box gets plain Ruby.4.0 again and needs the same command. It also records why ruby stays out of winget.json rather than being declared like node was: the lock-drift gate only accepts ids Update-PackageLock.ps1 can resolve to an installed version, so declaring RubyWithDevKit.4.0 on a box running plain Ruby.4.0 would sit permanently unlockable and red. (docs/PACKAGE-OWNERSHIP.md)
psmux power pill — the battery segment the macOS tmux bar has. New psmux/scripts/psmux-power.ps1, rendered right-most in status-right, which is where Core puts it too (its last slot is #{@status_right_os}, the hook each OS repo fills). It is the Windows port of Core's tmux/scripts/tmux-battery.sh and uses that scale, so the two terminal bars agree: green ≥60 / yellow ≥20 / red <20, with the level glyph swapped for a charging bolt on AC and the colour still tracking the level. One deliberate divergence — Core prints nothing when there's no battery, so its segment vanishes on a desktop; here it falls back to Zebar's AC placeholder, a lone green md-power-plug , since an empty segment reads as a broken pill on a desktop-first host. Power state comes from SystemInformation.PowerStatus (one in-process GetSystemPowerStatus read), not Win32_Battery — a desktop returns nothing from the latter, so "no battery" and "the query failed" would be indistinguishable. Refreshed by the existing in-session timer alongside the VPN pill, so nothing new touches psmux's synchronous render path. psmux.conf seeds @pwr_pill with set -og (only-if-unset) so the desktop plug is right before the first tick, while a prefix + r reload can't clobber a live laptop reading. (psmux/, powershell/os/33-psmux-pill.ps1) Note this means the psmux bar and Zebar disagree between 40 and 60 % — deliberately. The bars are matched terminal-to-terminal (psmux ↔ Core tmux) and desktop-to-desktop (Zebar ↔ sketchybar), and those two references use different scales.
Test coverage for the power pill's every state. The dev box is a desktop, so the laptop branches would otherwise ship unexecuted. psmux-power.ps1 takes a -SimulateState testing seam (no host read, no poke) and tests/Repo.Tests.ps1 asserts each colour and glyph threshold — including that a charging 15 % battery stays red, which is the case a naive "on AC → blue" reading would silently hide.
The package-freshness check now validates its own inputs — a wedged scoop bucket is a finding, not a silent green. A bucket is a git clone, and a stuck clone keeps serving manifests from whatever commit it froze at. Those stale versions still parse and still compare as matching, so the check reported "everything's current" on data months old — wrong in the reassuring direction, the worst way for a check to fail. That is not hypothetical: on 2026-08-04 the local extras clone had been stuck mid-merge on an upstream rename (UD bucket/pycharm.json) since mid-July, so scoop status called lazygit and tailscale "latest version" while the CI bot correctly had them behind. The box contradicted CI and the box was wrong. Check-PackageFreshness.ps1 now checks every bucket it reads manifests from — present, a real clone, not stuck on a merge/rebase/cherry-pick, clean tree — and writes a report even when nothing looks outdated, since that silent-green case is the entire point. The warning leads the issue body, because it invalidates every row under it. Also catches a bucket the scoop bucket add loop failed to create (its catch is empty), which today degrades quietly into a "no manifest version" skip for every app in it. Unit-tested via a new DOTFILES_PKGFRESH_LIBONLY hook, matching the *_LIBONLY idiom the sync scripts use. (packages/Check-PackageFreshness.ps1, tests/Packages.Tests.ps1)
dotfiles-doctor now checks scoop bucket health too, because CI structurally can't. The guard above lives in a script whose CI runs on a fresh runner, where buckets are added moments earlier and are always clean — so it protects the local-run path but can never observe the box this actually happened on. The wedge was a local condition that made the machine disagree with the bot for three weeks, and the doctor is where "is this box healthy" belongs. New Scoop buckets row under Health & toolchain: 6 bucket(s) clean and pullable when fine, and on a fault it names the bucket, says why (stuck mid-merge (MERGE_HEAD), dirty tree, missing directory, not a clone) and hints the exact unwedge. warn, not fail — nothing is broken and no tool is missing; the box just can't be trusted to tell you what's current. The detector is reused from packages/Check-PackageFreshness.ps1 through its DOTFILES_PKGFRESH_LIBONLY hook rather than reimplemented, so there's one definition of "this bucket can't be trusted"; the dependency deliberately only points this way, since the freshness bot must stay self-contained for CI, where the Dotfiles module isn't installed. The whole probe is wrapped so a bucket check can never take down a doctor run. (powershell/Dotfiles/Doctor.Helpers.ps1, powershell/os/45-doctor.ps1, tests/Doctor.Tests.ps1)
The load-budget perf test was measuring the runner, not the code. Perf.Tests.ps1's "dot-sources the tool-independent fragments quickly" timed a single cold dot-source, so it also charged the fragments for PowerShell's one-time parse/compile and module autoload — work they don't do. On a shared GitHub runner that noise is unbounded, and on 2026-08-05 it landed a CI run at 3012 ms against the 3000 ms budget: a 0.4 % overshoot on a body whose real cost is roughly 100× under the gate. A re-run passed untouched, which is the tell. A red CI that actually means "the runner was busy" is worse than no gate at all, because it teaches you to re-run instead of read. Now: one untimed warm-up, then the fastest of three timed runs. Noise only ever adds time, so the minimum is the closest estimate of true load cost — while the regression this exists to catch (a network or subprocess call added to a load path) is slow on every run and still trips it. The 3000 ms budget is deliberately unchanged; raising it would have hidden the flake instead of removing it. (tests/Perf.Tests.ps1)
The VPN/IP pill never rendered — a PowerShell splatting bug. psmux-netinfo.ps1 poked the bar with psmux set -g @vpn_pill $text. In argument position a bare @name is PowerShell's splatting operator, so the undefined $vpn_pill expanded to nothing and the option name was dropped from the command line entirely; psmux received a single positional and silently discarded the whole command — exit 0, nothing on stderr, option never set. Every other layer (detection, cache file, timer, psmux.conf) was working, which is why it survived so long. Fixed by quoting '@vpn_pill' / '@vpn_fg'. (psmux/scripts/psmux-netinfo.ps1)
A config reload repainted a live pill in the wrong colour. @vpn_fg was defaulted with a plain set -g, so every prefix + r overwrote whatever the refresher last poked. Because the pill's text is never defaulted, the two halves then disagreed until the next tick — up to a full refresh interval — and these pills encode their state in the colour: an active tunnel kept showing its address in the no-tunnel green, losing the orange that is the entire signal. Both colour options now use set -og (only-if-unset), which still guarantees a non-empty colour on first paint. Caught on review of the same mistake in @pwr_fg, where it would paint a 15 % battery healthy-green. psmux set -g @vpn_pill '', but an empty-string argument is dropped on the way to the exe and the set no-ops exactly like the splat above. Clearing now uses set -gu (unset). psmux-pill-disable clears the segment too, instead of only stopping the timer.
Holding the prefix key shoved the IP pill two columns right. The prefix/mode indicator sits between #S and the pill with each branch padded to the same width — but the idle branch was three literal spaces, which psmux's parser collapsed to one (see the next entry), against a prefix branch of space + glyph + space that survived as three. The branches are now spaced with #{p<n>:} and are five rendered cells each, verified in both directions on a real terminal. A test asserts the three branches stay equal width, since eyeballing this is exactly what failed before.
Multi-space gaps in the status bar were rendering as a single space. psmux parses option values as split_whitespace() + join(" "), so every run of spaces collapses to one, quoted or not — which means the twelve-space cwd→clock gap added in #163 had never actually widened anything. Bar gaps now use #{p<n>:}, which pads an empty body at render time — after the parser has had its way — and is the same idiom Core's tmux.conf already uses (#{p19:}), so the two configs now read the same. The session→IP gap is wider as a result, and a test forbids multi-space runs in status-left/status-right so this can't silently regress. Note #{p<n>:} only works written directly in the config: a format arriving via a user option is not re-expanded, which is the same rule that keeps a #[…] style run from working inside @vpn_pill.
Two stale psmux config tests. They asserted the pill was read via #(cmd /c type %LOCALAPPDATA%…) and passed only because that string still appeared in the comment block describing the retired transport — they had stopped testing anything real. Repointed at the live @vpn_pill / @pwr_pill segments, plus a static guard that every psmux set in the repo quotes its @option name, since the splatting bug above is invisible at runtime. (tests/Repo.Tests.ps1)
Security
- ConfigGitHub now refuses a tag-pinned action in every Core-vendoring repo, and make fleet-protection goes red if one stops. sha_pinning_required was on in only dotfiles-core and dotfiles-MacBook. It is now on in all eleven repos the script audits, which makes GitHub enforce rule 3 of the CI floor server-side: on sibling repos' own workflows, and on anything that reaches main without the text gate. The moving major survives. GitHub exempts reusable workflows ("can still be referenced by tag"), and a re-run of dotfiles-Alpine's lint-call.yml@v7 passed under enforcement. The new --require-sha-pin mode turns it on idempotently, keeps each repo's allowed_actions, and re-reads the server before it reports success. The admin report now fails a repo whose setting is off or cannot be read. It also reports allowed_actions and the count of Actions execution-protection policies, without gating either. CI's --rulesets-only run still skips the block, because it has no repo admin. Composite actions are not exempt, so dotfiles-nvim first SHA-pinned its setup-core-tools@v7 references (dotgibson/dotfiles-nvim#9) and then turned the setting on too (#1226).
Added
- FixA Core PR is checked against the real sibling repos before it merges. The new fleet-gates job in ci.yml clones the fleet beside Core and runs the audit's fleet-wide gates (§5f, §9m-§9p, theme and desktop-parity drift) that every other leg skips because it checks out Core alone. That gap let #1210 merge green and then refuse the v7.14.0 fan-out on dotfiles-Debian's stale TOOLS_OPTIN (#1239). The job is meant to be a required check, so it judges a delta: scripts/fleet-gate-delta.sh audits the PR's base and head against the same clones and fails only on a failure the PR adds. A sibling that drifts on its own therefore cannot block unrelated Core PRs. A failure already on main is a warning. A red means the sibling fix lands first, which is the fleet ratchets' existing order. The job holds no token, because the gates run sibling code (#1240).
Changed
- Fix/release-readiness actually checks the editor pin. The scheduled job allows ./scripts/check-nvim-freshness.sh as a literal command, but the routine only named the script loosely. The first run (#1245) ran neither form: it reached for gh release list, was refused, and reported a current nvim.lock as unverified. Step 4 now lists the three fleet-state commands exactly as the job grants them, says no other tool is needed for the editor row, and spells out the exit codes. Exit 0 with ✓ means current, exit 0 with a SKIP means unverified, exit 2 means behind, and exit 1 means the lock is broken.
- Fix/release-readiness checks the fleet-wide gates against the sibling repos before a tag. A Core PR's CI checks out Core alone, so every gate that reads a sibling (§9m-§9p, theme and desktop-parity drift) skips there. Until now the first run that could see a sibling break was sync-fanout's pre-fan-out audit, after the tag was cut. That is how #1210's correct Kali matrix cells merged green and then redded the v7.14.0 fan-out on dotfiles-Debian's stale TOOLS_OPTIN (#1239). The weekly job now checks Core out beside the fleet and runs audit-core.sh --scope none before the routine, with both tokens withheld from that step because the gates run sibling code. Any ✗ there is a HOLD, and a gate that could not see its sibling is reported as unverified, not as a pass. This is the fallback half of #1240. The PR-time check is still open there.
- ConfigRELEASE-STRATEGY.md no longer lists making audit-arch and audit-alpine required checks as future work. The main ruleset already requires both, alongside the Ubuntu and macOS audit legs, and it binds admins too. The "Still worth doing" item still pointed at the classic branch-protection settings page that the fleet retired. The CI bullet now says what the ruleset enforces.
Security
- ConfigThe CI floor bans the pull_request_target trigger. It runs a fork's pull request in the base repo's context, with its secrets and a write-capable token, which is the precondition for a "pwn request". GitHub starts blocking it by default on 2026-11-02 for repos on the default policy, and rule 10 of scripts/modern-baseline.yml makes that permanent whatever a policy later allows. check-modern.sh reads the on: block itself: the scalar, flow and block forms, quoted or bare, and first-level entries only. A comment, a branch filter or an env: value that names the trigger does not fire. That is why it is not a banned_patterns entry, which would red the baseline's own rule 1 rationale. It was free to add: no repo in the org declared the trigger on its default branch (#1215).
- FeatureThe pull_request_target ban now reaches every repo that calls lint-call.yml@v7. The reusable workflow's actionlint job gains a step that runs check-modern.sh --banned-triggers caller: rule 10 alone, from the Core checkout, over the caller's own workflows, including ones not yet committed. It blocks from the start, because every caller was measured clean first. Rule 10's walker is now one function that both the floor and the new mode call, so the fleet check cannot drift from Core's. A caller that pins the workflow to a SHA newer than the v7 tag gets a warning, not a false red. dotfiles-Windows, dotfiles-web and htpx do not call lint-call.yml, so they are not covered yet. All three are clean today (#1215).
Fixed
- FixThe openSUSE Tumbleweed tree-sitter-cli row moves to 0.27.0. Tumbleweed's tree-sitter went 0.26.8 → 0.27.0 (checked 2026-09-29; 0.26.8 has left the index), and both Leap 16.x lanes stay at 0.26.8. All three still clear the ≥ 0.26.1 floor. Footnote ⁵ also named the split-off library libtree-sitter0_26 for every openSUSE lane. That library is named for its soname, so Tumbleweed now ships libtree-sitter0_27. Reported by dotgibson/dotfiles-openSUSE#217.
- FixThe relink fix now names which checkout to run. core-doctor's relink row and the "relink pending" nudge used to say run ./bootstrap.sh --links-only. On a box with both an OS checkout and a role checkout, running the wrong one moved the whole Core surface onto that repo's vendored Core, possibly an older one, and the doctor then read current. The line now names the loaded checkout's own script, for example run ~/dotfiles-Offense/bootstrap.sh --links-only, quoted when the path needs it (#1213). The other state no longer stops at "last relinked from another checkout". It appears after a partial --only/--skip run of the other checkout, or a moved one. It now names both fixes, since either checkout's full --links-only run re-stamps the box. Which one should own Core stays an open question in #1211 (#1218).
- Fixmake audit from a fleet shell no longer reds four cases CI calls green. The behavioral suite's host scrub now also drops BROWSER and every ATUIN_* variable. Core's own 00-tools.zsh exports BROWSER=w3m on a headless box (WSL included), and an operator who opted into the atuin daemon exports ATUIN_DAEMON__ENABLED=true. Both leaked into the cases that pin the unset state: the GUI and macOS browser cases, and the daemon guard's never-opted-in pair. That is how the v7.13.0 cut went red locally on a tree CI had passed. Every case that wants one of these variables sets it explicitly.
- FeatureAn operator who exports a Core knob no longer reds make audit either. Exporting CORE_SHADOW_CLASSICS=0 in ~/.zshenv, as the v7.13.0 notes suggest, failed the suite's "knob unset" shadow case. The host scrub now unsets every CORE_* variable that zsh/ reads, including one read arithmetically as ((CORE_X)) with no $. The list is derived from the sources, ignoring comments, so a new knob is covered without an edit. core-doctor's unknown relink row also drops its "live check" hint. That check proved the capability contract was live, not that this box had relinked, so a pass read as "you're fine" when it wasn't (#1204).
- FixThe weekly /freshness-triage routine can now check the CLI tool pins it reports on (#1203). Its "CLI tool pins" row asks for each scripts/tool-versions.env pin against upstream, but neither the routine's allowed-tools nor the job's mirrored --allowedTools granted a release lookup, so the row came back "not checked" (#1193). Both lists now grant the read-only gh release view and npm view, and the routine says which to use for which pin.
Changed
- ConfigA bootstrap no longer downgrades Core on a box with two checkouts. An OS repo and a role repo stacked on it each vendor Core, and each bootstrap linked the whole Core surface from its own copy. So running the OS repo's bootstrap after the role repo's could quietly swap in an older Core, and core-doctor then read current. The shared driver now checks the Core already linked. If it comes from another checkout with a newer core.version, the run leaves every Core link alone, still wires its own layer, says why, and leaves the relink stamp as it was. --force-core overrides. An unreadable version or an equal one relinks as before (#1211).
- ConfigThe maintenance bots' Claude Code CLI pin rolls forward, 2.1.281 → 2.1.285. The weekly freshness review (#1193) found it the only scripts/tool-versions.env pin behind upstream that is not deliberately held. It is an npm install, so there is no *_SHA256 to refresh. shfmt stays held at 3.13.1 (#813).
- PerfCI audits on Ubuntu 26.04 ahead of the ubuntu-latest switch. ci.yml's audit matrix gains a temporary ubuntu-26.04 leg, because ubuntu-latest rolls to 26.04 between 2026-10-19 and 2026-11-19 and the new image changes or removes tools. The leg is not a required check, so a 26.04 break shows up before the switch without blocking a merge. It comes out once the rollout completes. The luacheck cache key now includes the matrix OS so the two Ubuntu legs do not restore each other's natively built tree. .github/actionlint.yaml returns to declare the label, because the pinned actionlint 1.7.12 does not know it yet and would red the audit on every leg (#1200).
- FeatureThe fleet's pinned shfmt moves 3.13.1 → 3.14.1, ending the #813 hold (#1217). The hold assumed the bump would add ::warning:: nags to consumer repos that pass today. Measured against every sibling's main, none of the ten lint-call.yml consumers passes today, and 3.14.1 leaves each one's count of drifting files unchanged. The one consumer where shfmt blocks is dotfiles-MacBook's make fmt-check, and it was rewritten first to a form both versions agree on (dotfiles-MacBook#275). SHFMT_SHA256 is refreshed, and the tool-versions.env note now says where the next output-changing bump can bite. Core's own scripts are unaffected, since Core does not run shfmt.
- Fixmake fleet-protection now reports each repo's Actions execution settings. After the ruleset rows, the default run lists whether GitHub itself refuses a tag-pinned action (sha_pinning_required, the server-side twin of check-modern.sh rule 3) and the allowed_actions policy. The rows are reported, not gated: they never change the exit code until the fleet decides whether to enforce them. An unreadable setting prints as ?, not as "not required". They need repo admin, so --rulesets-only, the CI mode, skips them and says so. The first run shows SHA pinning required on 2 of the 11 repos it covers: dotfiles-core and dotfiles-MacBook. --help now prints the whole header instead of a fixed line range that had fallen out of date (#1226).
Added
- Featurejc is part of the stack: it turns command output into JSON. ps aux | jc --ps, jc dig example.com and a few hundred other parsers hand jq something to transform. Before this, Core's JSON tools could transform, grep and explore JSON but could not produce it from ps, ss or dig. It is its own command with no alias, probed by zsh/00-tools.zsh and listed in core-doctor's data / net group. Every OS repo now installs it. PORTING-MATRIX.md gains a jc row and footnote ⁴⁰, which records the two exceptions: openSUSE Leap 16.x has no package, so it is declared opt-in there, and Gentoo's dev-python/jc is testing-keyworded only (#1208).
Documentation
- Change/modernize now re-checks its standing watches on every run. Some floor changes wait on an upstream event, so the routine gains a Standing watches list and reports each one as still watching or trigger met. It starts with two. One is workflow dependency locking (#1223), which would extend the SHA-pin rule to an action's transitive and composite uses: once GitHub ships a public preview. Rule 3 in scripts/modern-baseline.yml now names that gap. The other is the retirement of the macos-15 and windows-2022 runners (#1222).
- ChangeThe README's install steps now put the OS layer before a role repo. Offense used to be shown cloned and bootstrapped on its own, but it ships no OS layer: Kali needs dotfiles-Debian first, and Defense needs whichever OS repo the box runs. The WSL mirrored-networking note now points at dotfiles-Debian/wsl/windows.wslconfig.example, because Offense no longer carries that file.
- ChangePORTING-MATRIX.md no longer promises installs that don't happen. Kali's yazi and viddy cells read cargo²¹ (available, not installed): dotfiles-Debian installs Kali's cargo but cargo-builds nothing with it. Footnote ¹³ says Arch only hints the AUR 1password-cli, and that the vendor-repo setup is key-first rather than rolled back. Footnote ¹⁵ drops a go install fallback for glow/gum that never existed. Footnote ¹⁸ counts four openSUSE installers and two curl | sh routes now that yazi's is gone. The lineage notes name NixOS as its own lineage and Offense as a role layer on dotfiles-Debian. Found by the weekly doc-audit (#1192).
- ChangePORTING-MATRIX.md footnotes ⁵ and ³³ stop saying dotfiles-Fedora has no version floors (dotfiles-Fedora#203). dotfiles-Fedora#193 landed both floors on 2026-09-17: the # min: pair on neovim and tree-sitter-cli, a warn-only NEOVIM_FLOOR, a tree-sitter cargo fallback that runs only below TREESITTER_FLOOR, and a floor-agreement gate. Both footnotes now describe that, and ³³ drops its claim that Fedora was the last non-rolling target without a floor.
Added
- FeatureEach box now records which Core it last relinked against, and says when it is behind. core.lock records what a repo vendored, and nothing recorded what a box had relinked. So the fallback deletion in #763 had to guess when the fleet had re-bootstrapped, and the only check was a one-liner typed on each host. bootstrap.sh (via blib_main, or blib_write_relink_stamp for a bootstrap off the driver) now writes ${XDG_STATE_HOME:-~/.local/state}/dotfiles-core/bootstrap.lock, host state in core.lock's read-never-sourced shape. It holds core_sha, core_tag, the lib that ran, the mode and when. A dry run, an --only/--skip partial wiring or an aborted run leaves the previous stamp. An identical re-run touches nothing, so the scaffolded check-links.sh idempotency witness stays silent. core-doctor gains a relink row and a .relink key in --json, which print relinked at v7.11.0 — repo vendors v7.12.0, run ./bootstrap.sh --links-only. A "relink pending" line fires at shell start only on a real mismatch (CORE_RELINK_NUDGE=0 silences it). A box bootstrapped before this reads unknown, never red. bootstrap-test.yml's links-only leg asserts the stamp wherever the vendored lib knows it (#1154).
- ConfigCORE_SHADOW_CLASSICS=0 stops Core taking over standard command names. An operator who works on other people's machines trains habits on Core that fail elsewhere. cd→z jumps by frecency where cd would have errored on a typo, and rm -i teaches you to expect a prompt that no foreign box gives. Exported before the shell loads Core (in ~/.zshenv), the knob skips every alias in zsh/20-aliases.zsh that takes over a standard name: ls, cat, cd, vim, diff, rm/cp/mv, mkdir, tree, du, ps, top/htop, watch, df, ping and help. The names that collide with nothing (ll, la, lt, llt, catp, cdi, bat) stay. It is unset by default, so nothing changes for anyone who does not set it. In aliases.md each governed row now has a Note that starts with shadow, and the suite reads the shadow set from those comments, so a new shadow without the gate fails. Removing the shadows outright was decided against until a real incident is recorded, the same bar as #692 (#1155).
Documentation
- FixThe bash 3.2 floor stays, and PORTABILITY.md §1 now says why. Retiring it for lib/*.sh behind a re-exec stage-0 was costed first (#1153). It would shed about 30 lines of lib/. The injection guard stays because namerefs still expand a subscript, the §5k gate stays re-scoped, and a blib_main stage-0 misses dotfiles-MacBook, the only host on 3.2. RELEASE-STRATEGY.md §2 records it as a candidate declined before it reached the Breaking Backlog.
Security
- FeatureThe CI floor's template-injection rule now covers push-trigger ref names and workflow inputs. Rule 7 of scripts/modern-baseline.yml bans an attacker-influenced ${{ }} expression inside a run: body, and it named github.head_ref — the fork branch on a pull request — but not github.ref_name, which on a push or tag event is the same attacker-chosen string by another trigger (git refnames allow $ ; & | ( ) { }). It now bans github.ref_name and, for uniformity, github.base_ref. And its inputs. exemption, earned by the composite setup-core-tools/action.yml, was applied to every gated file, which left bare inputs.* ungated in the workflows — where it is workflow_dispatch free text or a value a sibling repo feeds one of Core's *-call.yml@vN workflows. A new rule 7b (banned_run_interpolation_contexts_workflow_only) bans it under .github/workflows/ alone. Both were free: every occurrence in the tree was already routed through env: (#1160).
- SecurityThe CI floor bans secrets: inherit, and Core stops documenting it. The caller example at the top of claude-routines-call.yml, the shape the seven OS repos were told to copy, passed secrets: inherit. That hands the called @v7 workflow every secret the caller repo holds, declared or not, at a moving tag the caller does not pin. The seven live callers had already moved to the explicit CLAUDE_CODE_OAUTH_TOKEN: mapping, so only the comment was wrong, and it now shows the mapping. A new rule 9 in scripts/modern-baseline.yml (banned_call_secrets) reads the value the way rule 5b reads write-all: anchored to the key, bare or quoted, a trailing comment tolerated. It was green on arrival, and no workflow in the fleet passes it (#1160).
Changed
- FixTwo zsh plugin pins roll forward in zsh/45-plugins.zsh (#1156, the freshness bot): zsh-history-substring-search 14c8d2e0ffae → a0bdb0d47dba and zsh-syntax-highlighting 2fc57d63067c → 0bfcb582e71d. This is the one change in the release that reaches a host, so it is recorded here even though a bot landed it — CONTRIBUTING.md has no carve-out for automation. Both ranges were read against the upstream compare: the first is two commits touching only README.md (+2/-2, a zplug snippet fix), the second one commit adding an Arch install path to INSTALL.md (+8/-0). So the pins move and no plugin code does; the other six pins were already current.
- FeatureThe scaffold's README is now linted for real, and held to Core's. The suite drove the scaffolded repo's markdown leg through a shim that records argv and exits 0, so the README new-os-repo.sh writes was never judged by the .markdownlint.jsonc it writes beside it, and its hand-copied shield block could drift from Core's unnoticed (#1162). 35-new-os-repo.sh now runs the real markdownlint-cli2 on it wherever the tool is installed (CI's main leg installs the pinned one), and asserts the header and every shield link definition match Core's README.md with the repo name swapped. dotgibson-* (Core's release, on purpose) and ci-url (lint.yml, not ci.yml) are the stated exceptions.
- Fixnew-os-repo.sh writes the fleet's shield row into the README it scaffolds. A checkup that fetched every badge and link target across the fifteen public READMEs found the two newest repos opening with no shield row at all — dotfiles-NixOS because this scaffold wrote none, and its generated .markdownlint.jsonc deliberately left out the MD033 allowance the row needs ("a fresh OS repo has no showcase page"). The scaffold now writes the eight-badge row above the title, the link definitions at the end, the CI badge pointing at the lint.yml it also writes, and the scoped MD033 allowed_elements stanza the siblings carry — so the next repo starts where dotfiles-NixOS had to be brought to by hand (dotfiles-NixOS#11). The same checkup fixed a dead bash link here (#1151), two logo slugs simple-icons no longer ships in each of dotfiles-Windows and dotfiles-Defense, and dotfiles-Offense's Python badge, which read CPython's GitHub releases — it publishes tags — and rendered "no releases or repo not found".
- ConfigTwo tool pins roll forward: markdownlint-cli2 0.23.2 → 0.23.3 and the maintenance bots' Claude Code CLI 2.1.273 → 2.1.281. The weekly freshness review (#1159) found them the only pins behind upstream that are not deliberately held. markdownlint-cli2 0.23.3 only updates dependencies, and both versions pin the markdownlint library at 0.41.1, so no rule is added, renamed or given a new default in Core or in the lint-call.yml consumers. The pre-commit hook's rev moves with it, because §9 keeps the two in step. Both are registry installs, so there is no *_SHA256 to refresh. shfmt stays held at 3.13.1 (#813).
- FixBoth atuin guard premises re-measured against 18.23.0; both VERIFIED_AGAINST anchors move (#1158, run 35956975589). Upstream released 18.23.0 on 2026-09-22, one minor past the 18.22.0 the anchors in zsh/00-tools.zsh carried. One atuin-guard-verify dispatch, checksum and build-provenance verified: silent discard holds (its report job skipped), and autostart self-healing is moved exactly as it was on 18.22.0. absent and stale spawn a daemon and land their row, and wedged blocks on the pidfile lock and loses it (upstream atuinsh/atuin#4114, still open). That is the shape #1102 already answers by probing and warning rather than standing down, so the auto-filed #1177 asks nothing new of Core. Editing an anchor is a claim that the premise was re-measured at that version, so this is that claim and not a version bump. 18.23.0 continues 18.22.0's direction, and the block now says so: an FTS index over captured command output and a sync engine replacing the event bus both sit behind the socket, with no client-side spool or direct-write fallback (the one PR that would have changed the client's connect shape, atuin #4168, closed unmerged). A dead socket still discards, and atuinsh/atuin#3382 (accept-but-silent) is still open, so the steer away from socket activation stays.
Fixed
- FixThe test suite no longer writes to the developer's real ~/.config/zsh/.zshrc. Every fixture moves HOME into the sandbox, but an interactive fleet shell also exports ZDOTDIR=~/.config/zsh, and the driver defaults ZDOTDIR only when it is unset. So a make audit (or make release) run from such a shell had the driver and scaffold fixtures re-point the real $ZDOTDIR/.zshrc into a temp dir, deleted at exit, and the next shell started bare. The same inherited mise activation state made sandboxed shells error on an untrusted mise/config.toml, so the audit went red locally while CI stayed green. test-core.sh now unsets ZDOTDIR, the four XDG_*HOME dirs and every MISE*/__MISE_* variable before any fragment runs, and 05-suite-shape.sh asserts that none survive. The test/check-links.sh that new-os-repo.sh scaffolds clears the same variables itself.
- FixThe fan-out count gate no longer reads binary files. _core_fanout_count_hits ran its awk over every tracked file, assets/demo.gif included, so every make audit printed a gawk Invalid multibyte data detected warning under a UTF-8 locale. It now skips any file containing a NUL byte. It does not use grep -I, because BusyBox grep accepts that flag and ignores it. The verdict never changed (a GIF makes no fan-out claim); the audit's output is quieter.
- Confignew-os-repo.sh writes the LICENSE its README shield advertises. The shield row added above carries an MIT License badge linking blob/main/LICENSE, but nothing wrote that file (it is in neither core.manifest nor core.vendor), so a new repo's first push would show license | not identified and a link that 404s. The scaffold now writes Core's LICENSE (the whole fleet's, byte for byte) with the birth year, and the generated .markdownlint.jsonc names the real reason MD041 is off: the README opens with the back-to-top anchor and shield row, not an H1.
Documentation
- ChangePORTING-MATRIX.md and the README stop contradicting the fleet (#1174, from the #1157 sweep). The sesh row's Arch cell asserted AUR⁹, but dotfiles-Arch go-installs sesh and says the AUR sesh-bin is not needed; it is now go⁹, and footnote ⁹ names Arch among the go-install consumers. Footnote ³³ gave Gentoo's ~arch neovim range two ways eleven lines apart (0.12.5 and 0.12.3); both now read 0.12.5, as the TSV does. Footnote ³⁴ said jq 1.8.2 reached "all three" supported Alpine stable branches; Alpine carries four, and 3.21 is still on 1.7.1. The README's install steps gain the Defense clone and name Debian and NixOS among the Linux distros, matching the "all ten bootstraps" line beneath them.
- FixEvery registered README hero is now filmed — the tenth, dotfiles-NixOS's, landed as dotfiles-NixOS#8, so the "still to be filmed" prose in CLAUDE.md, assets/hero-repos.txt and assets/README.md is retired. It was the one row no rootless chroot could film — its host guard asserts sudo nixos-rebuild switch --upgrade and up -n probes $PATH for it — so it was filmed on NixOS-WSL, and assets/README.md gains "Filming the NixOS hero": the two-rebuild install, nixpkgs' own render kit (vhs 0.11.0, not the 0.12.0 that writes no gif), the Adwaita Mono rejection the ❖ fallback needs, and the trap that is NixOS-WSL's alone — a nixos-rebuild switch resets the kernel-global binfmt_misc table, which every distro on the machine shares, so the distro you drive from loses wsl.exe until it is re-registered (the recipe is there). The install also surfaced dotfiles-NixOS#6: home.nix named two attributes nixpkgs does not have, invisible to that repo's parse-only gate. §9k's rule stands: a registered tape with no gif is a skip, so a re-render that has not landed never blocks the gate.
- FeaturePORTING-MATRIX.md gains an eza fleet-version table (footnote ³⁹), against a 0.23.5 floor. 0.23.5 added --hyperlink=auto and lines-of-code counting, and an older eza rejects the flag outright. The /tool-scout scan in #1158 held it on a watch that ends only when every lane is at or above that version, and until now nothing recorded where the lanes sit. scripts/fleet-package-versions.tsv now carries seventeen rows, each read from the distro's own index. Seven are at or above; Alpine edge/3.24/3.23 and Gentoo stable are one patch short on 0.23.4; Ubuntu 24.04 (0.18.2), both Leap backports (0.20.4), Debian 13 (0.21.0) and Alpine 3.22/3.21 are further back. gen-porting-matrix.sh registers the block and marks the eza row. Core enforces no eza floor, and zsh/ passes no flag that needs one.
Security
- FeatureCI refuses a pull_request_target trigger. That trigger runs a fork's pull request with this repo's secrets and a write-capable token. The new ci-floor.yml workflow checks out dotfiles-core@v7 and runs its check-modern.sh --banned-triggers over this repo's workflows, the same rule the OS repos get through Core's lint-call.yml (dotgibson/dotfiles-core#1215). Nothing here uses the trigger today. The check passes with a warning until the Core release that ships the mode moves the v7 tag.
Added
- PerfThe README opens with a rendered terminal hero (dotgibson/dotfiles-core#948). Every other public repo in the fleet got one this week from dotfiles-core's shared tape generator, and this repo was the one it could not serve: the host layer is PowerShell and nothing under core/ is vendored, so there is no zsh to drive and no .zshrc to source. assets/demo.tape is therefore hand-authored — the same font, palette, framerate and beat rhythm as the nine generated tapes, the same three marquee moments (ll, cat README.md, glog -8), then this repo's own two: up -n, the fleet's one verb resolving here to scoop status + winget upgrade, and core-version. It is filmed from a WSL distro with vhs driving the Windows pwsh.exe through interop (assets/README.md has the recipe and the knobs: PSMUX_NO_AUTOLAUNCH, DOTFILES_UPDATE_CHECK, PSReadLine predictions off), and assets/demo.gif is gifsicle-optimised under the 2 MiB ceiling Core enforces for its own. Re-render after any prompt or tooling change; the gif is committed beside the tape.
- Featuretask.ps1 — the fleet's make verbs, for a host without make (dotfiles-core#855). dotfiles-core declares the seven canonical verbs once (help lint check dry-run packages-check core-verify test, its scripts/make-vocabulary.txt, dotfiles-core#691) so a contributor moving between repos re-learns nothing — and this repo was the one that still answered to none of them: no Makefile, no runner, only bare scripts, in the fleet's most-tested repo, where "reproduce the CI gate locally" has the most to offer. make is not a given on a Windows host and just would be a new dependency, so the verbs are spelled in the language the repo is written in: .\task.ps1 <verb>. It is a dispatcher, not a second implementation: lint is tests/Invoke-Validation.ps1 plus gen-theme.ps1 -Check (ci.yml's dependency-free legs), test is tests/Invoke-Tests.ps1 (the gated suite, exactly as CI runs it), dry-run is install.ps1 -DryRun, check is lint plus the links-only bootstrap previewed (Windows has no throwaway HOME to run it in), packages-check is packages/Check-PackageFreshness.ps1 and core-verify is the three tests/Assert-*Parity.ps1 gates over the mirrored assets. Each step runs in a child pwsh -File, so a script's exit ends its own process and the first failing step's code is the verb's. Anything after a single-step verb passes through (.\task.ps1 test -NoGate). Core's fleet-vocabulary.sh register reads the verb table statically — the quoted keys of Get-TaskVerbs, and whether test names the suite runner by path — so tests/Task.Tests.ps1 pins that shape, the exact verb set in the contract's order, and that every step names a script that exists.
- Featureremote-install runs the host sshd as a service that restarts itself (#266). The box's front door was a bare sshd.exe with nothing supervising it (sc.exe query sshd returned 1060), so every crash and every reboot needed a human. remote-install now registers sshd as an Automatic service with recovery actions (restart after 5s / 10s / 30s, failure count reset daily), and remote-status reports the same plan while changing nothing. Two traps the planner exists for: registration is not health — scoop's install-sshd.ps1 registers with "take no action" recovery, so recovery is probed separately via sc.exe qfailure — and the ImagePath goes through scoop's current junction, never its resolved target, because a version-pinned path dies silently on the next scoop update openssh (the Get-DotStablePwshPath trap). It also warns when openssh is not held, since a running service keeps the daily scoop update * from replacing its binaries. This narrows 34-remote.ps1's standing rule rather than reversing it: ports, keys, firewall rules and DefaultShell still belong to the runbook; only *supervision* moved into the verbs, which stay explicit and refuse without a token. (powershell/os/34-remote.ps1, powershell/Dotfiles/Remote.Helpers.ps1, tests/Remote.Tests.ps1, docs/REMOTE-ACCESS.md)
- PerfAlt+C opens PSFzf's directory picker (#246). PARITY.md listed Alt+C as aligned for years while neither shell bound it. It is not a second key for Alt+Z: Alt+Z is a zoxide frecency jump to anywhere, Alt+C is "cd into a subdirectory of here". It follows the lazy-load shape of the Ctrl+T and Ctrl+R stubs, so PSFzf's import stays off the render path. The loader resolves Invoke-FzfPsReadlineHandlerSetLocation with Get-Command rather than calling it blind, because that name was never verified against the pinned PSFzf. If it is missing, the first press is swallowed and the next one works, rather than printing an error at the prompt. (powershell/core/10-tools.ps1)
Changed
- Configdesktop/PARITY.md's generated block takes the canonical marker, and the marker now says who writes it (dotgibson/dotfiles-core#1129). The pair became <!-- core:desktop-parity:gen parity --> … <!-- core:desktop-parity:end parity -->, matching the core:<ns>:gen <id> grammar every other generated region in the fleet uses. Not a tidy-up. core: is *provenance* — it names the repo whose generator owns the region — and this file is precisely where that matters: nothing in this repo writes the block, nothing here gates it, and until now its only clue that dotfiles-core rewrites it was a marker that did not say so. The block id is the other half; the old pair carried none, so the file format could hold exactly one generated region, forever. The bytes between the markers did not move. Core renders whichever marker form the target file carries, echoed back verbatim, so this repo and dotfiles-MacBook can be renamed in separate commits without the weekly parity-check sweep reding in between — which is the whole reason Core learned to accept both forms first (dotgibson/dotfiles-core#1143) rather than changing the string in one place and breaking two others. Core drops the legacy arm once both copies carry the new pair. Nothing here reads these markers — no Pester test, no workflow, no gate — so this is a two-line change in one file.
- Perfnvim/ is vendored from dotfiles-nvim directly, and its pin is a root-level nvim.lock (dotgibson/dotfiles-core#1124). dotfiles-core's NVIM-SPLIT-PROPOSAL.md extracted the editor into its own repo with its own gate — headless startup, :checkhealth, luacheck against a real Neovim, none of which Core's runners could do — and its own release line. This repo had always consumed the editor alone through a bespoke mirror, pinned to a Core ref that had nothing to do with when the editor changed; §3.1 called that "the tell". So nvim-sync.ps1 now points at dotgibson/dotfiles-nvim, and the side channel becomes the front door: a first-class second consumer of a real release line, on the same pin shape Core uses for its own copy. The re-point moved no editor bytes — the vendored tree at dotfiles-nvim v1.0.0 is byte-identical to the Core tree it replaced, which is what the extraction promised. - A bare nvim-sync.ps1 run now pins the newest vX.Y.Z release rather than tracking a branch tip. Releases are the unit the upstream gate signs off on, and they are what makes the Windows and Core pins comparable. -FollowBranch keeps the old behaviour, -Ref pins an exact revision, and the release picker orders numerically (so v1.10.0 beats v1.9.0) while dropping prereleases and the moving v1 major alias — a pin must never name a tag that moves. - The marker moved out of the tree it describes, from nvim/.core-ref to nvim.lock at the repo root, and that retires three workarounds: the parity gate compares nvim/ with no exclusion set (so it is byte-identical to upstream's), the sync bot judges drift on a plain git status -- nvim, and auto-tag.yml's nvim/ trigger cannot see a pin-only change, so a re-pin that moves no editor bytes cuts no release tag. nvim.lock carries dotfiles-core's field names on purpose, so fleet-drift.sh can compare the two pins directly. - The gate's clone target is now an owner/name slug** rather than a URL. The allowlist no longer has to enumerate every spelling of the same repo, and the URL CI dials is built from a value already matched against it. - dotfiles-doctor's nvim vendor row leads with the editor release (vendored nvim v1.0.0 (7ba9457)), because a tag answers "which editor is this?" on sight where a bare sha needed a lookup. starship/ and theme/ are unchanged: they are still vendored from dotfiles-core and still record a .core-ref. - The CI job keeps the name nvim Core parity deliberately, though the editor no longer comes from Core. It is one of the seven contexts the main ruleset requires; renaming it without editing branch protection first would leave every PR BLOCKED. That rename is its own change.
- Fixnotify-web.yml passes client-id, not the deprecated app-id, and reads the new FLEET_APP_CLIENT_ID org variable (#251, dotfiles-core #831). The pinned create-github-app-token (v3.2.0) deprecates app-id, and this repo's inline notifier — it does not consume Core's reusable — passed it. The variable is a new one because FLEET_APP_ID holds the App ID and the new input wants the Client ID, a different value; the if: guard and the ::warning:: that is this repo's only signal of a silently skipped refresh move in the same commit. Precondition: the org variable must exist before this merges, or the mint skips and the showcase stops refreshing with only that warning to say so.
Fixed
- Fixremote-status / remote-install guard sshd's DefaultShell, which could lock you out of the box (#267). A healthy sshd refuses *every* login when HKLM\SOFTWARE\OpenSSH\DefaultShell names a shell it cannot launch, and it rejects before authentication completes, so the client sees only Permission denied (publickey,keyboard-interactive) — every symptom points at keys. It happened here when the Store pwsh moved 7.6.5.0 → 7.6.6.0 and Windows deleted the old package directory. This is the third consumer of a version-pinned pwsh, after scheduled tasks and the sshd ImagePath (#266). Get-DotSshdShellVerdict grades the value against two independent disqualifiers, because the obvious fix for one trips the other: - A version-pinned Store directory is graded fragile while it still exists. - The per-user app alias is version-stable, but it is a reparse point, which sshd (0x105 lineage) reports as "does not exist" exactly like the deleted directory. remote-status reports the value next to the service. remote-install repoints it at the first machine-wide real file it finds, preferring the MSI pwsh, and says so plainly when it has to fall back to Windows PowerShell 5.1. The docs note that winget's Microsoft.PowerShell manifest can be msix-only: the MSI has to come from the GitHub release, and installing it retires this failure for all three consumers. (powershell/os/34-remote.ps1, powershell/Dotfiles/Remote.Helpers.ps1, tests/Remote.Tests.ps1, docs/REMOTE-ACCESS.md)
- FixThe daily maintenance run reports a per-app scoop upgrade failure instead of logging ok (#263). scoop update * exits 0 even when one app fails, so the step's exit-code check could not see it. tailscale failed every day from 2026-08-20 to 2026-09-16, re-downloading a 36.6 MB MSI each time, while the log said ok scoop upgrade (apps). Get-DotScoopUpgradeOutcome reads the result per app out of the step's output. It keys on the *block*: an app has failed when its Updating '<app>' block never reaches "was installed successfully!", and an ERROR line inside the block is kept as the reason. The step sets $LASTEXITCODE itself, so the runner's existing "FAIL … — continuing" path makes one bad app visible without aborting the run. The root cause is not fixed here: tailscale's pre_uninstall needs admin and the daily task runs unelevated, so it needs a scoop hold or the elevated-task route. (powershell/Dotfiles/Maint.Helpers.ps1, tests/Maint.Tests.ps1)
- SecurityA sync bot that loses its App token now says so, instead of quietly reverting to a PR nobody can merge (#269). The mint step added in #268 carries continue-on-error: true, which is right — a fork, or a repo the App was never installed on, must not go red — and was also silent. When the mint *failed* rather than being skipped (the installation pulled, so create-github-app-token 404s on /installation) the token came back empty, ${{ steps.app.outputs.token || github.token }} fell back to GITHUB_TOKEN, and the job still concluded success. That is the #265 failure re-entering through its own fallback: the PR opens, ci.yml never fires on it, no required context arrives, and it sits BLOCKED until a human closes and reopens it. It happened the day #268 landed — theme-sync run 35223874896, all three bots degraded for most of a day — and what noticed was a *different* repo's weekly sweep (dotgibson/dotfiles-core#1116), not this one's own green run. All three bots now carry an identical step between the mint and the checkout, gated on steps.app.outputs.token == '', which is the one test that covers both branches: a skipped step and a continue-on-error failure leave the output empty for the same reason, where steps.app.outcome == 'failure' would miss the skip and steps.app.conclusion is success in both. It raises a ::warning:: and a $GITHUB_STEP_SUMMARY note that name the *consequence* rather than the cause — the cause is the part a reader can already see — and pass steps.app.outcome through, so skipped (no variable or secret reached the run) reads differently from failure (the App could not mint for this repo; go look at the installation). Deliberately still not a failure: a fork must not go red, and a real drift from Core is worth landing behind a manual nudge rather than not landing at all. New tests/SyncWorkflows.Tests.ps1 pins the block byte-identical across the three files and ordered *before* the checkout (a bare if: implicitly ANDs success(), so after it a checkout failure would swallow the warning) — nothing else read these workflows at all, which is how a fix applied to two of three would have shipped green. (.github/workflows/nvim-sync.yml, starship-sync.yml, theme-sync.yml, tests/SyncWorkflows.Tests.ps1)
- FixThe three sync bots open their PRs as the fleet App, so CI actually runs on them (#265). nvim-sync, starship-sync and theme-sync all pushed the branch and opened the PR with GH_TOKEN: ${{ github.token }}, and GitHub deliberately starts no workflow run from an event the default GITHUB_TOKEN created. So ci.yml's pull_request trigger never fired, not one of the six contexts the main ruleset requires ever arrived, and every sync PR sat BLOCKED indefinitely — #260 and #264 were each unblocked by a human closing and reopening them, which re-fires pull_request, and both merged minutes later. That manual step was being paid weekly, and it was not merely an annoyance: Core's fleet-drift.yml sweeps Mondays while these bots run Tue/Wed/Thu, so a sync PR waiting on a human reads as a Core drift red that is nothing of the kind (dotgibson/dotfiles-core#1058). Each bot now mints a short-lived, repo-scoped token from the dotgibson-fleet-sync App (actions/create-github-app-token, at the same SHA pin notify-web.yml already carries) and both pushes and opens the PR with it, so the PR is App-authored and its CI runs unattended — the same problem, and the same fix, as dotfiles-core's freshness.yml on its own self-PRs. Narrow on both axes: no owner:/repositories:, which is what scopes the token to *this* repository, and only contents + pull-requests: write — deliberately not workflows: write, so a bot that ever tried to rewrite CI fails loudly instead of having quietly been able to all along. It degrades rather than fails: with no App configured the mint is skipped and the bot falls back to GITHUB_TOKEN, so the PR still opens and merely needs the old manual nudge. GH_TOKEN moves from the job env: to the PR step's, because a job-level env: cannot read the steps context. The two App-free options weighed in #265 do not actually work — the recursion guard covers a push and a bot's own close+reopen exactly as it covers pull_request, so neither would have produced a single check. Depends on live infrastructure: the App is installed on this repo today, but Core's GITHUB-APP-AUTH.md still lists it under "does not need installing" and scripts/fleet-app-scope.sh reports it as an install nothing writes to; a Core-side issue moves it to a justified entry beside dotfiles-core's own. (.github/workflows/nvim-sync.yml, starship-sync.yml, theme-sync.yml)
- Feature**The package-freshness check now compares *directionally* — a lock that runs ahead of its source is no longer reported as behind (#234, #250). Check-PackageFreshness.ps1 gated every row on Test-PackageVersionMatch, which answers "same release?" and says nothing about which side is newer. So winget still advertising TranslucentTB 2026.1 under a 2026.2.0.0 lock filed as an update every week, and re-pinning (#241, #254) could never clear it. New pure helper Compare-PackageVersion in packages/PackageLock.ps1 orders the shapes the lock actually carries — dotted numerics, scoop's 8.22.0_1 / 25.0.2-10 / 10.0.0.0p2 suffixes, the 20260812 and 2026.08.19 date stamps, and pre-release words — and returns $null rather than guessing when it cannot. The checker's single verdict function (Get-FreshnessVerdict) routes each row: behind is the only finding; ahead becomes a one-line "lock ahead of source" note in the report, visible but not actionable; unordered** lands under "Skipped (could not compare)" with both versions and *still files a report*, so a shape the comparer does not read surfaces instead of vanishing into a green run. The padding tolerance (2.7.10.0 == 2.7.10) is unchanged. (packages/PackageLock.ps1, packages/Check-PackageFreshness.ps1, tests/Packages.Tests.ps1)
Added
- Configwindows-terminal/settings.json's colour scheme is generated from theme/palette.toml (#230). The twenty hexes in the Tokyo Night scheme were the largest hand-typed colour block left after #228 — deliberately skipped that round, because the file is APP-OWNED: install.ps1 symlinks it into all three Windows Terminal flavours' LocalState, the app rewrites *this copy* on every settings change, and its JSON writer strips comments, so a // core:theme:gen marker would not survive the first toggle. gen-theme.ps1 therefore grew a second emitter kind. json-scheme finds the scheme object by its own "name" (the app re-sorts the array alphabetically, so an index is not stable), scoped to the schemes array so a profile sharing that name cannot be hit, and rewrites the hex values on the lines they already occupy — indentation, key order and trailing commas preserved byte for byte. Deliberately not a ConvertFrom-Json | ConvertTo-Json round trip, which would reformat all 227 lines every run and fight tests/Format-AppJson.ps1's no-re-indent rule. It emits UPPERCASE hex because the app does; lowercase would make every GUI round-trip a twenty-line colour diff. Registering the file also turns on the residual-hex scan over it for free, so a hex the palette does not define can no longer be dropped anywhere in that file.
- FeatureThe last two night-style hexes are gone. background was #1A1B26 and selectionBackground #28344A — tokyonight *night* values in a palette pinned to *storm*, and the same pair #229 had already replaced twice elsewhere (ListPredictionSelected, Get-DotAnsiSgr's Black). They now resolve to color_bg #24283B and color_bg_visual #2E3C64, so the terminal's selection highlight finally matches PSReadLine's prediction bar and its canvas matches psmux's @tn_bg. The other eighteen already agreed with Core and did not move.
- FeatureThe core front door reaches the second family: core update check and core maint <install|run|log|status|uninstall> (dotgibson/dotfiles-core#684). Core's zsh dispatcher grew the same arms in the same change, and its PARITY.md pins them as aligned rows enforced by parity-check.sh — so this lands FIRST, or Core's gate would assert a pwsh half that did not exist yet. Additive only: up, update-check and the five maint-* verbs keep working exactly as before, and core update -y still belongs to up — only the literal word check in first position is intercepted. A bare core maint (or -h/--help) prints the family's usage line rather than erroring, the way a bare core is the index; an unknown sub-verb gets its own did-you-mean (core maint stauts → status). Every arm is an explicit call, never & "maint-$v", because the load contract is derived from literal command names and an indirect call would hide the dependency. Core.Tests.ps1 stubs the six new leaves and asserts routing, arg forwarding, the bare-namespace usage, and that a typo dispatches nothing.
- FeatureGet-DotAccentSpec — the accent half of theme parity, which this host simply did not have. Core's zsh/05-ui.zsh has _CORE_ACCENT_SPEC / _CORE_MUTED_SPEC, the one place $COLORTERM is interpreted, with a truecolor tier and a hand-picked 256-colour fallback. grep -rnE 'CORE_ACCENT|AccentSpec' powershell/ returned nothing here, so PARITY.md's accent row was a genuine gap rather than a drift. The pwsh twin lives in powershell/core/05-lib.ps1 beside Get-DotAnsiSgr and is generated from the same palette. The two fallback forms deliberately disagree (SGR 111/103 vs spec 75/244); both are carried verbatim and neither is derived from the other.
- PerfA theme vendor row in dotfiles-doctor. The sibling of the nvim vendor and starship vendor rows, and the one that matters most on a host: the palette is an *input*, so a stale copy leaves every generated block perfectly self-consistent and quietly a version behind the fleet, with nothing else on screen to say so.
- FixA drift gate on the maintenance runner's own step list. Maintenance.ps1 states its steps in three places — the file header, the -Help block, and the actual Step calls — and nothing kept them honest, so both prose copies had silently lost the scoop junction step (the header had also lost navi). tests/Maint.Tests.ps1 now discovers the steps from the AST and fails when one is not documented in both, with a bidirectional map assertion so a renamed or removed step cannot leave a stale entry that passes forever. Both prose blocks are corrected.
- PerfThe scoop junction repair now covers every junction scoop makes, and runs on a schedule. #218 established the mechanism — Redirection Guard refuses a junction *created* by a non-admin, trust is stamped at creation, so re-creating it elevated is the only lever — and fixed apps\<app>\current. Two gaps remained. Scope. scoop also wires persisted state back out of an app dir with junctions into scoop\persist\<app>\... (bat\themes, bat\syntaxes, btop-lhm\themes, composer\cache, php\cli, syncthing\config, the yt-dlp plugin dirs), and scoop\modules\gsudoModule points into apps\gsudo\current from outside apps\ entirely — 15 further junctions on this host, created by the same non-admin scoop process and untrusted for the same reason. Re-stamp only current and bat --list-themes is still broken over ssh. The sweep is now every directory reparse point under the scoop root; -Recurse does not descend *through* a reparse point, so each physical junction is reported exactly once under its canonical path. It never actually ran. The step is gated on elevation, and dotfiles-maint is registered RunLevel = Limited — which is the open question issue #217 asked to resolve first. maint-install now also registers dotfiles-maint-scoop-junctions, running as SYSTEM an hour after the daily job. SYSTEM rather than the interactive user at RunLevel Highest, because an Interactive task only runs while someone is logged on and the case this fixes is nobody being; -ScoopRoot and -LogPath are baked into the action since SYSTEM's profile paths are not the user's. Sequencing is a time offset rather than an event trigger on the first task completing, because Microsoft-Windows-TaskScheduler/Operational is disabled by default and that subscription would never fire. The daily task deliberately stays unelevated — scoop update * must not run as admin. The sweep moved out of Maintenance.ps1 into maint/Repair-ScoopJunctions.ps1 so the elevated task has an entry point; the policy behind it is Get-DotScoopJunctionPlan, pure and unit-tested. Registering an elevated task itself needs an elevated shell, so maint-install run unelevated installs the daily task and says plainly that it skipped the other one. maint-status reports both tasks with their run level; maint-uninstall removes both.
- Configwsl-ssh-config — the client-side ssh_config for the distros behind this host. docs/REMOTE-ACCESS.md §4 told you to hand-write the Host <distro> block that Format-DotWslSshConfig could already generate: the renderer, the port allocator and the alias slug all shipped as tested module exports with nothing calling them. powershell/os/34-remote.ps1 is that caller. Ports are allocated from the sorted distro list, because wsl --list reorders on install / unregister / re-default and a port that moves is worse than no port — it is already baked into ssh_config, into firewall rules and into muscle memory. -HostPort reserves the port the Windows sshd actually answers on instead of assuming 22; assume otherwise and a distro is handed the host's port, a collision that stays invisible until that distro silently fails to bind. -JumpHost emits the ProxyJump shape — one LAN port, distro ports on the host's loopback — rather than a network-facing listener per distro. It prints and never writes: the file this output belongs in lives on the machine you ssh *from*, which by definition is not this one. Reading the distro list back out of wsl.exe is the one impure step, and it is impure in a way that bites — wsl --list --quiet emits UTF-16LE, which lands in PowerShell as a string with a NUL between every character, so a naive reader finds no distros on a box that has several. WSL_UTF8=1 is set on the child only (and restored, including the unset case, which must be removed rather than blanked), with the NUL strip as the belt for builds that ignore the variable. Still deliberately absent, and still a runbook rather than a script: standing sshd up. The service, the firewall rules, the HKLM DefaultShell key, the boot task and the power settings are machine-global state that varies per box.
- FeatureGet-DotfilesStubContent and Test-StubIntoRepo (powershell/core/05-lib.ps1, exported from the Dotfiles module) — the stub body renderer and the "is this wired?" predicate for Kind='Stub' rows. Test-StubIntoRepo is a *reference* check, not an equality check, so a user's own added lines survive a re-install; it compares with forward slashes and uses String.Contains rather than -like, because it gates a delete in uninstall.ps1 and a [ read as a wildcard would be a false positive.
- Configpowershell/Dotfiles/Remote.Helpers.ps1 (+ tests/Remote.Tests.ps1) — pure logic for reaching this host and the distros behind it: Get-DotWslSshPlan (a stable distro->port map, sorted by name so a port never moves when you install or unregister a distro), Format-DotWslSshConfig (the client-side ssh_config, direct or via ProxyJump), ConvertTo-DotSshAlias, and Get-DotRemoteWiringResult — the triage that says whether a wired config survives an ssh session. Get-DotWslSshPlan takes the Windows sshd's port as a parameter rather than assuming 22. A host that moved sshd off 22 is common, and assuming otherwise produces a confident "port collision" diagnosis that is simply wrong. Deliberately absent: anything that touches the service, registry, firewall, scheduled tasks, or power settings. That is machine-global state which varies per box and is better done by hand with eyes on it — the runbook is in docs/REMOTE-ACCESS.md.
- FixA Remote (ssh) configs row in dotfiles-doctor — probes whether Redirection Guard is enforced in the current session and reports any stub-kind config still wired as a symlink. Only the actionable rows are listed: plain symlinks are also unreadable over ssh under enforcement, but that has no fix at this layer, and listing all eleven every run would bury the one you can do something about.
- Fixdocs/REMOTE-ACCESS.md — the full diagnosis, the one-command way to confirm Redirection Guard in a broken session, the list of fixes that do *not* work, the scoop limitation, and a corrected WSL section (banner-grabbing to identify which daemon is on which port; who rather than ss for per-distro attribution, since networkingMode=mirrored makes ss show the host's whole peer list).
Changed
- Fixdesktop/PARITY.md's shared half is now generated and gated, and the "identical copy" claim in desktop/README.md is corrected (dotgibson/dotfiles-core#693). This file and dotfiles-MacBook/sketchybar/PARITY.md were an admitted verbatim pair held together by nothing but the sentence *"Edit both together"* — and they had drifted 3.5 KB apart. Most of that was a one-sided Markdown reformat with no semantic content; the real divergence was 947 bytes of Windows-only prose — the psmux battery-scale note — that had never been marked as a deliberate divergence. The shared contract now lives between the <!-- desktop-parity:gen --> and <!-- desktop-parity:end --> markers, rendered from dotfiles-core/desktop/PARITY.shared.md by make gen-desktop-parity; Core's scripts/gen-desktop-parity.sh --check fails the weekly parity-check run if either copy is edited alone. The psmux note is unchanged in substance and stays here, below the markers where the generator does not touch it, now labelled deliberate — so TERMINAL_WORKFLOW_GUIDE.md's pointer at the 40–60 % disagreement still resolves. Edit the Core source, not this file's generated block.
- FeatureThe module step no longer re-downloads modules it already has, which is what had module update: PSReadLine failing on every run. Save-Module -Force rewrites <Path>\<Name>\<Version> wholesale even when that exact version is already on disk, and for a module whose assemblies are mapped into a running PowerShell that overwrite cannot succeed — the files are locked and it fails with *"Access to the path … is denied"*. PSReadLine is loaded by every PowerShell session, so on any box with a shell open it failed every time. Confirmed on this host rather than guessed: Microsoft.PowerShell.PSReadLine.dll and its Polyfiller were held open, with six other pwsh processes running. The module was not out of date. Neither were the other three — all four already matched the gallery exactly, so the step was re-downloading four modules a day to achieve nothing, and failing on one of them for the privilege. It now looks up the gallery version and saves only when there is something newer. This removes the failure rather than hiding it: a genuinely new version installs into its own version directory and never touches the locked one, so the update path that matters is untouched. Find-Module failing (offline, gallery down) deliberately falls *through* to Save-Module rather than skipping — an unknown latest version must not read as "up to date", or the step would go quiet on exactly the days it cannot check. Test-DotModuleUpToDate (powershell/Dotfiles/Modules.Helpers.ps1) is the pure decision, conservative by construction: nothing installed, an unparseable version on either side, or an unknown gallery version all return "save anyway".
- FixThe theme is generated now, and three colours had already drifted. Core made theme/palette.toml the one place a colour is authored (dotfiles-core#679) and renders it into every consumer with scripts/gen-theme.sh. This repo was the last in the fleet still typing the numbers — and the hand-copied fzf block in powershell/core/10-tools.ps1 carried a comment claiming it was "kept byte-for-byte in step with Core's zsh fzf.zsh" while three of its fourteen values were wrong: border and scrollbar on #27a1b9 against Core's #29a4bd, and gutter on #16161e against #1d202f. Core's parity-check.sh stayed green throughout, because it only compares the query: colour. The palette is now vendored like nvim/ and starship/ — theme-sync.ps1 + theme/.core-ref, hash-gated against Core by tests/Assert-ThemeParity.ps1 — and gen-theme.ps1 renders it into nine marked # core:theme:gen <id> blocks across powershell/core/ and psmux/. gen-theme.ps1 -Check gates every PR. Four values changed, all of them corrections to Core's: the three fzf colours above, plus ListPredictionSelected and Get-DotAnsiSgr's Black, which were carrying #28344a and #1a1b26 — colours from a *different* tokyonight style that appear nowhere in the storm palette. Everything else regenerated byte-identical, which is the useful part of the diff: it shows the other 50-odd literals were already right.
- Config-Check also rejects a hex the palette does not define. psmux.conf keeps seven colours outside its @tn_* table (pane borders, status style, the @vpn_fg and @pwr_fg seeds) that psmux cannot express as #{@tn_*} lookups, and wrapping each in its own marker pair would be more noise than gate. Instead the checker asserts every remaining hex in a registered file is still a value the palette defines. That is the check that would have caught #16161e and #27a1b9 years earlier, and it is what catches those seven the day Core flips style.
- Configinstall.ps1 grows Write-StubItem, dispatching on the plan row's Kind. It mirrors Link-Item's contract deliberately — idempotent skip, back up before overwrite, honour -DryRun, same stats — and the install summary gains a stubbed line (emitted only when the caller tracks it, so an older stats bag renders unchanged).
- Configuninstall.ps1 now treats *either* shape as ours (symlink into the repo, or a stub that references it), so it can no longer orphan the files install.ps1 wrote.
- Fixdotfiles-doctor is Kind-aware: a stub row reports stub -> repo, and a stub-kind row still wired as a symlink is flagged *"will not resolve over ssh"* with the re-install fix. A symlinked profile is a warn, not a fail — nothing is broken until you ssh in.
- Fixauto-tag.yml's Core pin moved from v4.12.0 to v5.0.2 — a major and eight minors in one step, because nothing advances it automatically. This repo vendors no core/, so it is absent from scripts/os-repos.txt: the fan-out never opens a PR here and make fleet-drift cannot see it. The pin advances when a human moves it, and between 2026-08-16 and 2026-08-26 nobody did. Behaviourally this is a no-op, and it is worth being precise about why. auto-tag-call.yml and scripts/auto-tag.sh are byte-identical at v4.12.0 and v5.0.2 (git rev-parse on both blobs agrees), and the workflow re-checks-out dotfiles-core at a moving alias for the scripts it runs — so this host was already executing the same code as every "current" repo. What changes is that the recorded pin now matches what actually runs, instead of reporting eighteen releases of drift that were not real. The genuine defect the audit surfaced is upstream and is being fixed there: the reusable workflows fetched their scripts from v4 while every caller ran at @v5 (dotgibson/dotfiles-core#672). The comment above the pin now records that a SHA pin freezes the workflow body but not the scripts it pulls, so the next reader does not over-trust it.
Removed
- FixThe navi repo update maintenance step, which was never a real command. navi repo takes only add / browse / help, so the step exited 2 with *"unrecognized subcommand 'update'"* on every run it has ever made — invisible until the exit-code fix above made it reportable. Nothing is lost: navi has no "refresh my repos" verb to re-point it at, and updating imported cheatsheets means git-pulling each one under navi info cheats-path by hand. On this host that path does not exist at all, so there was nothing to refresh either way. Worth adding back the day cheat repos are actually imported (navi repo add). Removed from all four places that named it — the runner, its file header, its -Help block, and TERMINAL_WORKFLOW_GUIDE.md — with the step-documentation gate in tests/Maint.Tests.ps1 keeping those honest. Separately, and on the host rather than in the repo: the scoop install of navi was broken — navi.exe was absent from the app directory, leaving only config.yaml, install.json and manifest.json, so the shim reported *"Could not create process"*. Not a Redirection Guard problem; the junction traverses fine. A reinstall of the same version (2.24.0, matching the bucket, and navi is unpinned) restored it with the persisted config.yaml intact. Two independent faults were stacked behind one silently-passing step.
Fixed
- ConfigCtrl+←/→ word movement was never actually bound — it was PSReadLine's default showing through. 10-tools.ps1's # Ctrl+arrow word movement; Tab = menu complete comment sat above a line that bound only Tab, so the motion came from the built-in key table and no user would ever have noticed. It mattered to the parity contract: Core's PARITY.md marks the Word nav row aligned, but parity-check.sh had to give the pwsh half a - sentinel and report it as a SKIP — the only row in the table resting on a framework default rather than on something this repo states, and so the only one a future PSReadLine or -EditMode change could move without any config moving with it. The four new Set-PSReadLineKeyHandler lines are behaviour-preserving by construction: NextWord, not ForwardWord is both PSReadLine's own default under -EditMode Vi *and* Windows, and the match for zsh's forward-word — both land the cursor on the start of the next word, where ForwardWord lands on the end of the current one. Both Vi tables are pinned, since a bare -Key reaches the insert table only and Core's zsh/40-bindings.zsh binds viins and vicmd both. They are deliberately *not* column-aligned like the Tab/arrow lines above them: parity-check.sh matches its needles with grep -F, so padding here would make the Word nav row whitespace-brittle from another repo. (powershell/core/10-tools.ps1, tests/Repo.Tests.ps1, TERMINAL_WORKFLOW_GUIDE.md; #231 → #238. Follow-up: dotgibson/dotfiles-core#849)
- PerfThe nvim maintenance step had never done anything, and the log was built so it could not say so. Found by reading maint.log to check whether the nvim wiring fix had taken effect, and discovering the log could never have answered that question. Three compounding bugs in maint/Maintenance.ps1, each hiding the next: 1. The arguments were mangled. Start-Process -ArgumentList @(...) JOINS the array with spaces and does not quote the elements, so `` @('--headless', '+Lazy! sync', '+silent! TSUpdateSync', '+silent! MasonUpdate', '+qa!') ` reached nvim as --headless +Lazy! sync +silent! TSUpdateSync …. nvim read sync, TSUpdateSync and MasonUpdate as filenames rather than as parts of the preceding +command, opened three empty buffers, hit +qa! and exited 0. Measured side by side on a real host: Start-Process 0.2s / 0 lines, ProcessStartInfo.ArgumentList (which quotes each element) 5.9s / 398 lines. Invoke-WithTimeout now uses ProcessStartInfo, reading both pipes concurrently so a child that fills one while the parent blocks on the other cannot deadlock. 2. The output was discarded. Step runs its body under & $Body *>> $Log, which holds the log open; Invoke-WithTimeout then appended the child's output to that same path with Add-Content and Windows refused — *"The process cannot access the file … because it is being used by another process"*. Non-terminating, so nothing failed. Reading the pipes directly retires the temp-file dance entirely and the output is emitted, letting Step's existing redirect capture it with one handle on the log instead of two. 3. Failure was unreportable. Step only ever caught *exceptions*, and a native command that exits non-zero does not throw in PowerShell — so every one of them was graded ok. Live example from the same run: navi repo update printed Shim: Could not create process and was recorded as a success. Step now checks $LASTEXITCODE, and Invoke-WithTimeout surfaces its child's code into it (neither Start-Process -PassThru nor [Process]::Start sets it). $LASTEXITCODE is nulled before each body on purpose: it is session state, not step state, so a cmdlet-only step would otherwise inherit the previous step's code and be blamed for it. Known limit, stated in the code: a body chaining several native commands only reports the last one's code. After the fix, on this host: the nvim step runs ~6s and lands 398 lines of real Lazy! sync output in the log, and navi repo update is correctly reported as FAIL … exited 1 — a genuinely broken scoop shim that had been passing silently. The tests lift Step and Invoke-WithTimeout` out of the AST and execute them — both are nested inside the runner's lock block and cannot be dot-sourced without triggering a real maintenance pass. Verified to be worth having: 6 of them fail against the pre-fix runner.
- Fixpsmux, jj and mise were all invisible over ssh too. The follow-up #225 named: every remaining config this repo wired was a symlink into the repo, and under Redirection Guard a Windows process cannot read any of them. Measured on this host before the change — jj config get ui.default-command answered *"Value not found"* and mise config ls outside a project listed nothing, so both tools had been silently running on their own defaults rather than erroring. Three different config systems needed three different answers, and finding out which was the actual work: - psmux turned out to already have an include directive. Its syntax is tmux-compatible, so source-file is the mechanism — the repo's own psmux.conf opens with one. Both psmux.conf and psmux.reset.conf become Kind = 'Stub', and the repo conf's existing source-file ~/.config/psmux/psmux.reset.conf line simply resolves to the second stub. No change to the config itself. - ~/.config/psmux/scripts is a *directory* of eight pwsh popup helpers, and a directory has no include form. New Kind = 'StubDir': a real directory of one-line forwarders, one per script, each &-invoking the repo copy with @args. That leaves psmux.conf's eight display-popup binds untouched — they still name ~/.config/psmux/scripts, which is real now — and & rather than dot-sourcing keeps $PSScriptRoot pointing at the repo, so a script that resolves a sibling still finds it. - jj and mise are TOML, which has no include directive at all. Neither can be stubbed, so they are not wired as files any more: Get-DotfilesEnvPlan sets JJ_CONFIG and MISE_GLOBAL_CONFIG_FILE as persistent User-scope variables pointing into the repo — both verified against the installed versions before being committed to. User scope is what reaches an ssh session and a scheduled task, the same mechanism DOTFILES_WIN already uses. A forwarder directory does not track the repo by itself, and the drift runs both ways. Checking only that every script has a forwarder let the other direction survive indefinitely: a script deleted upstream leaves a forwarder pointing at nothing, that forwarder is not *missing*, so install returned "already wired" and its own stale-sweep never ran — leaving a psmux bind that opens a popup and immediately errors. Test-StubDirIntoRepo now fails on both directions, which is what makes the sweep reachable. Two honesty problems came with the env-var mechanism, both handled rather than documented away. Nothing exists at ~/.config/mise/config.toml any more, so the wiring is invisible at the conventional path — hence explicit env: rows in dotfiles-doctor, because a variable that goes missing looks exactly like a tool with no config. And a shell already open when install.ps1 runs keeps the old environment block, so the doctor checks the process value as well as the registry and reports *"set for new sessions, but missing from THIS one"* instead of grading it ok — the same false-ok shape the nvim row taught us to look for. install.ps1 retires the superseded jj/mise symlinks (identified by Test-SymlinkCurrent against the plan's Target, so a link belonging to another checkout is left alone), and uninstall.ps1 clears the two variables — but only while they still point into this repo, and never DOTFILES_WIN, which is how bootstrap.ps1 finds an existing checkout. Still a symlink, and correctly so: .gitignore_global (global ignores reach ssh via the .gitconfig stub's core.excludesfile override) and the interactive-only desktop configs — Windows Terminal, GlazeWM, Zebar.
- Perfnvim started bare over ssh — netrw and a black background. %LOCALAPPDATA%\nvim was a directory symlink onto the repo's nvim/ tree, which is exactly the shape Redirection Guard refuses, so the editor never read its own init.lua. #215 fixed the three configs it named and wrote the rest off as "a documented limitation with no fix at this layer" — but nvim *does* have an include mechanism, and this is it: %LOCALAPPDATA%\nvim is now a real directory holding a real init.lua that prepends <repo>/nvim to runtimepath and dofile()s it. Same single source of truth, no reparse point, no elevation. Confirmed on this host, where the reading was rtp=5 netrw=nil theme=nil and cat on the symlinked path returned *Permission denied*. This row is the one where the reparse point sits on the parent of the wired path, which has two consequences the rest of the change is about. dotfiles-doctor asked whether the wired path itself was a link, which for nvim resolved straight *through* the old symlink and graded a broken box ok; it now walks the whole path (Test-DotPathViaReparsePoint). And install.ps1 had to learn to retire such a parent *before* writing anything (Clear-StubParent) — otherwise every Test-Path and Move-Item in Write-StubItem resolves through the old link, the "existing file" backed up is the repo's own init.lua, and the shim lands inside the tree nvim-sync.ps1 mirrors byte-for-byte from Core. tests/Integration.Tests.ps1 drives the real Write-StubItem over a real symlink and asserts the repo comes out byte-identical. That retirement is gated on the plan row naming the directory as its LegacyLink, and the gate is the whole safety story: applied to whatever directory a stub happens to live in, the staleness test matched $HOME for ~/.gitconfig (which contains .gitignore_global, a real sibling of that row's target in the repo) and a -DryRun offered to retire the user's home directory. Caught before anything ran for real; a test now pins it. Prepending runtimepath turned out not to be enough, which is the part worth knowing before editing the shim: lazy.nvim's performance.rtp.reset defaults to true, so lazy.setup() replaces runtimepath wholesale from stdpath('config') — the shim directory — and the prepend is gone before an eagerly-loaded spec runs. It surfaced as a config that clearly loaded (netrw=1) and then died with Failed to run 'config' for tokyonight.nvim … module 'gerrrt.utils.ui-highlights' not found. performance.rtp.paths is the supported answer and is set in nvim/, which is Core-owned, so the shim survives the reset on its own: an appended package.loaders searcher for <repo>/nvim/lua (setting package.path does nothing — Neovim replaces the stock path searcher, and vim.loader inserts its cached loaders at 2 and 3, so it is never consulted), plus a re-prepend of runtimepath after the config returns, since a searcher only answers require() and runtime *file* lookups still need the path. Two knock-ons, both stated in docs/REMOTE-ACCESS.md: stdpath('config') is the shim directory now, so the shim seeds lazy.nvim's lockfile from the repo itself (Core's lazy.lua seeds from stdpath('config'), which no longer holds one) and <leader>rc opens the shim rather than the repo's init.lua — unfixable here, since nvim/ is Core-owned. And the daily dotfiles-maint task runs under the same enforcement, so nvim --headless +Lazy! sync had been doing nothing at all. uninstall.ps1 retires the legacy symlink and drops the shim's directory when the stub was its only occupant, so a box that changes shape is not left with a husk nothing owns. Re-wire with install.ps1 -SkipPackages.
- FixThe maintenance tasks were pointing at a pwsh that Windows deletes. maint-install baked (Get-Command pwsh).Source into both scheduled tasks, and a Store-installed pwsh resolves to a *version-pinned* package directory (…\WindowsApps\Microsoft.PowerShell_<ver>_…\pwsh.exe). When Windows cleans up a superseded package the task fails with 0x80070002 — silently, because a task that never launches writes nothing to maint.log. Measured on this host: dotfiles-maint sat at 0x80070002 with no completed run between 2026-08-26 and 2026-08-29, which also meant the scoop junction sweep from #217/#221 was not happening. The feature was correct and simply never got to run. maint-install now prefers a version-stable path — MSI install, machine-wide app alias, per-user app alias, scoop shim — and only falls back to the resolved version-pinned path, warning when it does. The daily task runs as you and takes the per-user alias; the SYSTEM task cannot, since SYSTEM's %LOCALAPPDATA% is under config\systemprofile, so with a Store-only pwsh it stays pinned and says so. Installing the MSI build gives both a stable path. Get-DotStablePwshPath is the pure, unit-tested policy; the probing stays in the fragment. Detection, so this cannot rot silently again: maint-status now shows each task's Execute and attaches a verdict — a missing executable or a 0x80070002 last result is a fail, and the informational SCHED_S_* codes are not. dotfiles-doctor carries a Maint tasks row. Both are careful about what they can actually see: Task Scheduler ACLs a SYSTEM-principal registration to SYSTEM and BUILTIN\Administrators, so an unelevated shell cannot distinguish a broken junction task from a healthy one and reports not visible rather than guessing not installed. (A self-check inside the daily run was tried and removed for exactly this reason: it would have reported a confident false failure every day.)
- Fixhostip returned an address no other machine could reach. It took the first non-link-local IPv4 Windows reported, and on any box with a Hyper-V or WSL virtual switch that is vEthernet (Default Switch) — a Manual 172.x address that exists only inside the host. Measured here: hostip answered 172.26.80.1 while the LAN address was 10.0.50.90. Nothing about the address itself says which is which, so it read as correct right up until the connection timed out. What separates them is the routing table: the LAN interface carries a default route and a virtual switch has none. hostip now reads the default routes and the addresses and hands both to Select-DotHostAddress, a new pure module export that makes the choice — preferring a default-route interface in the caller's metric order, and falling back to the old SkipAsSource ordering on an offline box with no default route at all, rather than returning nothing. Surfaced by wsl-ssh-config above, which prints this address into a config you paste on another machine — the one place the wrong answer is guaranteed to bite.
- PerfConfigs are unreadable over ssh, and it was never an execution-policy problem. On a host running OpenSSH Server, the PowerShell profile failed to load in an ssh session with *"untrusted source"* while loading fine in Windows Terminal. The cause is Redirection Guard (ProcessRedirectionTrustPolicy), which Windows enforces across the whole service / session-0 lineage: a process with it enforced refuses to traverse a reparse point whose target sits under a non-admin-owned directory — i.e. every symlink this repo wired into a repo under C:\Users\<you>. The error is ERROR_UNTRUSTED_MOUNT_POINT. Measured, not theorised: explorer / WindowsTerminal / glazewm report 0x100 (not enforced), while services.exe / wslservice / Task Scheduler's svchost / sshd all report 0x105. sshd inherits it, and every ssh session inherits it from sshd. That split is the whole symptom. It cannot be configured away. fsutil ... R2L:1, deleting the sshd.exe IFEO MitigationOptions, setting that value to REDIRECTION_TRUST_ALWAYS_OFF, and changing the symlink's owner were each tried on a real host and each did nothing — the policy is inherited and non-relaxable. Running sshd as a real Windows service would not help either (services.exe is 0x105 too). docs/REMOTE-ACCESS.md records all of it so nobody re-runs the experiment. The fix is to stop using reparse points for the configs that have to work over ssh. Get-DotfilesLinkPlan rows now carry a Kind ('Symlink' | 'Stub'), and the three that matter are wired as real files that pull in the repo copy through the config format's own include mechanism — same single source of truth, no reparse point: | Config | Mechanism | | --- | --- | | $PROFILE | a real .ps1 that dot-sources powershell/profile.ps1 | | ~/.gitconfig | [include] path = <repo>/git/.gitconfig | | ~/.ssh/config | Include <repo>\ssh\config, first line | ~/.gitignore_global deliberately stays a symlink — a .gitignore has nothing to include — so the .gitconfig stub overrides core.excludesfile to the repo copy instead, *after* the include, because last value wins for a single-valued key. ~/.ssh/config bit twice: as a symlink it also stalled the ssh client on the host, because ssh.exe reads it at startup and inherits the same enforcement. A plain ssh hung while ssh -F NUL returned instantly. Re-wire an existing box with .\install.ps1 -SkipPackages. Not fixed, and called out honestly in the doc: scoop. Its current junctions have the same problem (77 of 78 apps unreachable over ssh on the host this was found on), and there is no include trick for a junction — only taking ownership of the app directories, which scoop undoes on every update.
- Fix.core-ref recorded tag = v4-19-g10ad221 — the moving major alias, not the release it describes. (#202) Every Core cut writes the specific vX.Y.Z and then force-repoints the major alias v4 onto the same commit (tag-release.sh, git tag -fa, alias second). Both tags are annotated and both sit on the release commit, so git describe breaks the tie by tagger time and picks the alias. That is a provenance field naming a target that is deliberately moved on the next release: the recorded string silently reinterprets itself, and re-running the same command against the same commit today returns v4.15.1-19-g10ad221. commit was always authoritative — only tag lied, and it lied in the direction that looks fine until you check. Both sync scripts now filter describe to the vX.Y.Z shape (--match 'v[0-9]*.[0-9]*.[0-9]*'), which excludes bare-major aliases by construction — the identical fix Core shipped for core.lock in dotgibson/dotfiles-core#515, so the Windows row and the Unix repos' core_tag agree on what a release name means. When only an alias exists, describe finds nothing and the tag line is omitted: an absent tag is honest where v4 was not, and the SHA stays the source of truth either way. nvim/.core-ref is corrected in place; starship/.core-ref already read v4.9.0 because its last sync was a pinned -Ref landing exactly on a release commit — the same latent bug, just not yet visible. The filter also immunizes -CoreLocal runs against a locally stale alias, since a plain git fetch never force-updates an existing tag (this box's own Core clone has v4 frozen at v4.7.0's commit). The describe call also moved above each script's *_LIBONLY hook as Get-CoreDescribeTag, which is the part that keeps it fixed: the old inline call sat below the hook and was structurally unreachable from Pester, which is exactly why a wrong value shipped unnoticed. The new fixture (New-DotCoreTagFixture in tests/_TestHelpers.ps1) tags one commit v9.9.9 then v9 in release order, and a companion assertion proves a bare describe --tags still gets that fixture *wrong* — so if the reproduction ever stops reproducing, the suite says so instead of going quietly green. (nvim-sync.ps1, starship-sync.ps1, nvim/.core-ref, tests/_TestHelpers.ps1, tests/NvimSync.Tests.ps1, tests/StarshipSync.Tests.ps1)
Docs
- FixWhy Mason can't install ruby-lsp on this host, written down where package decisions live. The winget package the box runs, RubyInstallerTeam.Ruby.4.0, ships no MSYS2 DevKit, so every gem with a C extension fails to build — ruby-lsp and rubocop both die on prism. The error names neither ruby nor the DevKit (No rule to make target '/C/Ruby40-x64/include/ruby-4.0.0/ruby.h'): with no msys64, gem falls back to scoop's mingw make, which has no rm and mangles the drive-letter path into an MSYS-style one. docs/PACKAGE-OWNERSHIP.md now carries the symptom, the mechanism, and the one-line elevated fix (ridk install 1 3), next to the existing ruby-ownership reasoning. Confirmed fixed on the reference host on 2026-08-24: ridk install 1 3 (elevated) populated C:\Ruby40-x64\msys64, and gem install ruby-lsp now builds both native extensions clean. That is host state, not repo state — a rebuilt box gets plain Ruby.4.0 again and needs the same command. It also records why ruby stays out of winget.json rather than being declared like node was: the lock-drift gate only accepts ids Update-PackageLock.ps1 can resolve to an installed version, so declaring RubyWithDevKit.4.0 on a box running plain Ruby.4.0 would sit permanently unlockable and red. (docs/PACKAGE-OWNERSHIP.md)
Added
- Fixpsmux power pill — the battery segment the macOS tmux bar has. New psmux/scripts/psmux-power.ps1, rendered right-most in status-right, which is where Core puts it too (its last slot is #{@status_right_os}, the hook each OS repo fills). It is the Windows port of Core's tmux/scripts/tmux-battery.sh and uses that scale, so the two terminal bars agree: green ≥60 / yellow ≥20 / red <20, with the level glyph swapped for a charging bolt on AC and the colour still tracking the level. One deliberate divergence — Core prints nothing when there's no battery, so its segment vanishes on a desktop; here it falls back to Zebar's AC placeholder, a lone green md-power-plug , since an empty segment reads as a broken pill on a desktop-first host. Power state comes from SystemInformation.PowerStatus (one in-process GetSystemPowerStatus read), not Win32_Battery — a desktop returns nothing from the latter, so "no battery" and "the query failed" would be indistinguishable. Refreshed by the existing in-session timer alongside the VPN pill, so nothing new touches psmux's synchronous render path. psmux.conf seeds @pwr_pill with set -og (only-if-unset) so the desktop plug is right before the first tick, while a prefix + r reload can't clobber a live laptop reading. (psmux/, powershell/os/33-psmux-pill.ps1) Note this means the psmux bar and Zebar disagree between 40 and 60 % — deliberately. The bars are matched terminal-to-terminal (psmux ↔ Core tmux) and desktop-to-desktop (Zebar ↔ sketchybar), and those two references use different scales.
- FeatureTest coverage for the power pill's every state. The dev box is a desktop, so the laptop branches would otherwise ship unexecuted. psmux-power.ps1 takes a -SimulateState testing seam (no host read, no poke) and tests/Repo.Tests.ps1 asserts each colour and glyph threshold — including that a charging 15 % battery stays red, which is the case a naive "on AC → blue" reading would silently hide.
- FixThe package-freshness check now validates its own inputs — a wedged scoop bucket is a finding, not a silent green. A bucket is a git clone, and a stuck clone keeps serving manifests from whatever commit it froze at. Those stale versions still parse and still compare as matching, so the check reported "everything's current" on data months old — wrong in the reassuring direction, the worst way for a check to fail. That is not hypothetical: on 2026-08-04 the local extras clone had been stuck mid-merge on an upstream rename (UD bucket/pycharm.json) since mid-July, so scoop status called lazygit and tailscale "latest version" while the CI bot correctly had them behind. The box contradicted CI and the box was wrong. Check-PackageFreshness.ps1 now checks every bucket it reads manifests from — present, a real clone, not stuck on a merge/rebase/cherry-pick, clean tree — and writes a report even when nothing looks outdated, since that silent-green case is the entire point. The warning leads the issue body, because it invalidates every row under it. Also catches a bucket the scoop bucket add loop failed to create (its catch is empty), which today degrades quietly into a "no manifest version" skip for every app in it. Unit-tested via a new DOTFILES_PKGFRESH_LIBONLY hook, matching the *_LIBONLY idiom the sync scripts use. (packages/Check-PackageFreshness.ps1, tests/Packages.Tests.ps1)
- Fixdotfiles-doctor now checks scoop bucket health too, because CI structurally can't. The guard above lives in a script whose CI runs on a fresh runner, where buckets are added moments earlier and are always clean — so it protects the local-run path but can never observe the box this actually happened on. The wedge was a local condition that made the machine disagree with the bot for three weeks, and the doctor is where "is this box healthy" belongs. New Scoop buckets row under Health & toolchain: 6 bucket(s) clean and pullable when fine, and on a fault it names the bucket, says why (stuck mid-merge (MERGE_HEAD), dirty tree, missing directory, not a clone) and hints the exact unwedge. warn, not fail — nothing is broken and no tool is missing; the box just can't be trusted to tell you what's current. The detector is reused from packages/Check-PackageFreshness.ps1 through its DOTFILES_PKGFRESH_LIBONLY hook rather than reimplemented, so there's one definition of "this bucket can't be trusted"; the dependency deliberately only points this way, since the freshness bot must stay self-contained for CI, where the Dotfiles module isn't installed. The whole probe is wrapped so a bucket check can never take down a doctor run. (powershell/Dotfiles/Doctor.Helpers.ps1, powershell/os/45-doctor.ps1, tests/Doctor.Tests.ps1)
Fixed
- PerfThe load-budget perf test was measuring the runner, not the code. Perf.Tests.ps1's "dot-sources the tool-independent fragments quickly" timed a single cold dot-source, so it also charged the fragments for PowerShell's one-time parse/compile and module autoload — work they don't do. On a shared GitHub runner that noise is unbounded, and on 2026-08-05 it landed a CI run at 3012 ms against the 3000 ms budget: a 0.4 % overshoot on a body whose real cost is roughly 100× under the gate. A re-run passed untouched, which is the tell. A red CI that actually means "the runner was busy" is worse than no gate at all, because it teaches you to re-run instead of read. Now: one untimed warm-up, then the fastest of three timed runs. Noise only ever adds time, so the minimum is the closest estimate of true load cost — while the regression this exists to catch (a network or subprocess call added to a load path) is slow on every run and still trips it. The 3000 ms budget is deliberately unchanged; raising it would have hidden the flake instead of removing it. (tests/Perf.Tests.ps1)
- PerfThe VPN/IP pill never rendered — a PowerShell splatting bug. psmux-netinfo.ps1 poked the bar with psmux set -g @vpn_pill $text. In argument position a bare @name is PowerShell's splatting operator, so the undefined $vpn_pill expanded to nothing and the option name was dropped from the command line entirely; psmux received a single positional and silently discarded the whole command — exit 0, nothing on stderr, option never set. Every other layer (detection, cache file, timer, psmux.conf) was working, which is why it survived so long. Fixed by quoting '@vpn_pill' / '@vpn_fg'. (psmux/scripts/psmux-netinfo.ps1)
- ConfigA config reload repainted a live pill in the wrong colour. @vpn_fg was defaulted with a plain set -g, so every prefix + r overwrote whatever the refresher last poked. Because the pill's text is never defaulted, the two halves then disagreed until the next tick — up to a full refresh interval — and these pills encode their state in the colour: an active tunnel kept showing its address in the no-tunnel green, losing the orange that is the entire signal. Both colour options now use set -og (only-if-unset), which still guarantees a non-empty colour on first paint. Caught on review of the same mistake in @pwr_fg, where it would paint a 15 % battery healthy-green. psmux set -g @vpn_pill '', but an empty-string argument is dropped on the way to the exe and the set no-ops exactly like the splat above. Clearing now uses set -gu (unset). psmux-pill-disable clears the segment too, instead of only stopping the timer.
- FixHolding the prefix key shoved the IP pill two columns right. The prefix/mode indicator sits between #S and the pill with each branch padded to the same width — but the idle branch was three literal spaces, which psmux's parser collapsed to one (see the next entry), against a prefix branch of space + glyph + space that survived as three. The branches are now spaced with #{p<n>:} and are five rendered cells each, verified in both directions on a real terminal. A test asserts the three branches stay equal width, since eyeballing this is exactly what failed before.
- ConfigMulti-space gaps in the status bar were rendering as a single space. psmux parses option values as split_whitespace() + join(" "), so every run of spaces collapses to one, quoted or not — which means the twelve-space cwd→clock gap added in #163 had never actually widened anything. Bar gaps now use #{p<n>:}, which pads an empty body at render time — after the parser has had its way — and is the same idiom Core's tmux.conf already uses (#{p19:}), so the two configs now read the same. The session→IP gap is wider as a result, and a test forbids multi-space runs in status-left/status-right so this can't silently regress. Note #{p<n>:} only works written directly in the config: a format arriving via a user option is not re-expanded, which is the same rule that keeps a #[…] style run from working inside @vpn_pill.
- FixTwo stale psmux config tests. They asserted the pill was read via #(cmd /c type %LOCALAPPDATA%…) and passed only because that string still appeared in the comment block describing the retired transport — they had stopped testing anything real. Repointed at the live @vpn_pill / @pwr_pill segments, plus a static guard that every psmux set in the repo quotes its @option name, since the splatting bug above is invisible at runtime. (tests/Repo.Tests.ps1)
Security
- FixEngagement-data write guard. note, logshell, bhce and nmapsweep used to fall back to $PWD when $ENGAGEMENT was unset, so running them inside a checkout wrote client data into that repo. They now resolve their root through _eng_writeroot, which refuses any $PWD inside a git work tree.
- SecurityThe field references open read-only. htp/xdev/evade/ipp are symlinks to tracked files, and hacktheplanet's "target fill" recipe told you to substitute the real client IP/hostname/domain into the buffer — one :w from publishing engagement data. They now open with -R; htp -w edits deliberately, and the fill recipe writes a copy under $ENGAGEMENT.
- Security.gitignore backstop repaired. *.xml carried a trailing comment, which gitignore does not support — the pattern was the whole line and matched nothing, leaving nmap -oX output unguarded. The ignore list also described the *template's* directory names rather than the ones mkengagement creates, so scope/, recon/, scans/, web/, screenshots/, exploit/ and notes.md were all unblocked.
- SecurityPinned + verified tool installs. The five curl | sh installers are gone. install/tool-versions.env pins each tool's version and the SHA-256 of its release asset; bootstrap.sh verifies before installing and fails closed. starship moved to apt, which packages it.
- SecuritySecret scanning in CI — gitleaks over the working tree and full history.
- Securityhethttp refuses to serve a git work tree on 0.0.0.0.
- Securitybhce can take credentials off argv — op://… resolves through 1Password, - prompts with echo off.
Removed
- FixThe core_branch fallback in the two core.lock readers. core_branch was renamed core_ref in dotfiles-core#453, and reading both names was correct while it shipped: this repo vendors Core on its own schedule, so locks of both vintages existed in the wild and reading only the new name would have killed sync-core.sh on any repo that had not yet synced. That window is closed — Core declares the field gone as of v5 in VENDORING.md, and no core.lock in the fleet carries it, this repo's included. Gone with it: migrate_branch_to_ref, which rewrote the old key in place so set_field (which replaces, never inserts) would have a line to hit. Nothing needs that any more — sync-core.sh now dies naming core_ref alone, before the pull, if the lock has no such line, which is what licenses the never-insert rule downstream. test/check-core-freshness.sh keeps its soft branch=main default: it is a watcher, not a writer. The CORE_BRANCH env override is untouched — it is that script's own knob, not a lock field (#271).
Fixed
- Configaliases.md now gives an override location that works (#348). It told you to override $ENGAGEMENTS_DIR and the other data paths in 99-local.zsh. But this stage applies their := defaults at band 85, and 99-local.zsh loads at band 95, so the override came too late for anything the stage had already used. The doc now names ~/.zshenv or the environment. It also offered hethttp as its example of an unguarded helper, yet hethttp checks for python3 and refuses to serve from inside a git work tree unless HETHTTP_FORCE=1 is set. Its row now says so, and the example is lhost/ttyup. CLAUDE.md says twelve repos, not eleven.
- PerfFour manifest annotations and redup's ledger caught up with a fast-moving week — the act-on-it half of #339. All comment-only; no package added or removed, so the parsed manifest is byte-identical. - chisel's premise flipped. apt moved to 1.12.1-0kali2 (migrated 2026-09-16), so the "apt ships a pre-release, two RCs ahead of stable" reading is dead — apt is level with, or a step past, upstream's newest tag (a v1.12.1 tag exists but no release object yet, so releases/latest still reads v1.12.0). The breaking-flag list is now framed as live on the installed binary rather than hypothetical. - evil-winrm's gap closed, and one sub-note became a trap. apt is 4.1-0kali1 (migrated 2026-09-14), at upstream's v4.1 head. The old note told operators to expect the apt build to drop idle shells; v4.1's keepalive and download path-traversal fix are now present, so a dropped idle shell now *is* a real network signal. Kept as a closed gap, not deleted (the ffuf shape). - BloodHound CE lost its named head. The 9.7.0~rc4 head rotted in eight days (upstream cut v9.7.1); currency is now stated purely as a habit — check the server's own version against SpecterOps' releases — with no number left to rot. The 9.6.0 migration fact and the bloodhound-ce-python "going quiet" note are re-verified and unchanged. - AdaptixC2's branch ledger dropped a dead branch. dev-v1.3 no longer exists; the note now names the live branches (a v1.2 branch and testing-v2.0). - redup's ledger records pipx as the third evaluated-and-deferred candidate. roadrecon/roadtx/bbot are pipx-owned and apt cannot touch them, but adopting pipx upgrade would widen redup's contract from "run each tool's own updater" to "drive a package manager" — the ownership line redup exists to respect. go_fast_movers stays empty by reason.
- Perfcheck-packages.sh named a suite it had not checked against. The label exists so a local run is interpretable — the script's own comment says an unresolvable name "prints the suite it was checked against" — but it took the first real archive from apt-cache policy, and apt lists every configured source. Third-party repos routinely label themselves a=stable, so on a Kali box carrying one (observed: Yazi's o=Yazi,a=stable sorting ahead of four o=Kali lines) it printed apt suite in view: stable while actually resolving against kali-last-snapshot. That inverts the label's purpose: an operator reads "does NOT resolve against stable", assumes a Debian-stable false alarm, and dismisses a real drift signal. It now prefers the archive of the Kali-origin source and falls back to the old first-real-archive rule only where no Kali source is configured. Verified across five cases: Kali behind a third-party repo, Kali alone, a non-Kali Debian box (fallback unchanged), a release line with no o=, and empty policy output.
- ConfigThe packages workflow contradicted the manifest about rustscan. .github/workflows/packages.yml's #260 paragraph explains why the job emits ::warning:: annotations, citing rustscan as an example of "an apt name that resolves in NO Kali component". That was true when written, but #315 restored rustscan to a real apt line after it migrated to kali-rolling on 2026-08-31 — so the repo shipped a workflow comment calling a name unresolvable while the manifest beside it installs that same name. The paragraph is now marked as the history of why annotations exist, with the current status of both examples it cites (rustscan resolves again; snmp-check was a package/binary split all along — the apt name is snmpcheck). Comment only; no job behaviour changes.
- Fixbootstrap.sh's PATH is not the shell's PATH — adopt blib_user_bindirs_on_path (dotgibson/dotfiles-core#748). Replaces the hand-rolled export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH" prelude, which also moves it below the source core/lib/bootstrap-lib.sh line. ~/.local/bin, ~/.cargo/bin and $GOBIN reach PATH only through the zsh layer, i.e. only inside a Core shell — which does not exist while bootstrap.sh runs. So every command -v <tool> guard here was answered by the PATH of whatever shell launched the bootstrap: on a fresh box, bash, with none of them. That is wasted work when the guard picks whether to reinstall, and a wrong answer when it picks a branch — dotfiles-openSUSE probed command -v mise for a mise mise.run had written to ~/.local/bin moments earlier, both arms of its Go fallback missed, and the run exited 2 on every bootstrap. No stubbed CI leg can see that: a stub installs nothing, so "is the tool present afterwards" can never fail under one. Core has shipped blib_user_bindirs_on_path for exactly this since dotgibson/dotfiles-core#425 — it resolves CARGO_HOME and GOBIN/GOPATH rather than hard-coding them, and adds only directories that exist, so it is called again after an installer creates one. The directory --install writes into is mkdir -p'd before the helper runs: the helper adds only directories that already exist, so a straight swap for the old unconditional export would have dropped ~/.local/bin for the whole first run and sent the probe/report phase back to reporting a tool it watched get installed as missing. audit-core.sh used to exempt the Role repos from this helper on the reasoning that a role layer installs no packages; that was never true of an --install that does pipx and go install into ~/.local/bin, which is why the prelude was hand-rolled here in the first place. The exemption is gone.
- Fixsync-core.sh --help printed set -euo pipefail. The header is rendered with sed -n '2,35p' "$0", and the comment block it means to print ends at line 33 — so every --help run trailed the closing ─── rule with the first two lines of actual code. Pre-existing, and found by the --help render check while retiring the core_branch fallback above; the range now stops at the rule.
- FixTwo more targets had the same guard defect, found by the new gate rather than by eye. make shellcheck and make secrets each announced a skip and then ran the missing tool, exiting 127 — the same shape as markdown below, in targets nobody had thought to check. Both collapsed into one recipe line. _core_make_gate_hits (dotgibson/dotfiles-core#775) found them the first time it was pointed at this repo, having been written from the markdown case alone.
- Fixmake markdown announced a skip and then ran anyway. Each make recipe line runs in its own shell, so the guard's exit 0 only ended that line: without npx it printed "npx not available — skipping markdown" and then ran npx, exiting 127. Collapsed into one recipe line, so the skip is a real skip (dotgibson/dotfiles-core#775 — the same defect in six other fleet repos). MD_FILES was already correct here, including the offensive/companion exclude that matches the gate's, so only the guard needed fixing. An unreadable MARKDOWNLINT_VERSION now fails rather than silently linting unpinned — "same version as CI" is this target's whole claim.
- Config.markdownlint.jsonc's header claimed this config was "the local check for the README" and that "CI here gates this repo's own code, not its Markdown". Both were true when written; dotgibson/dotfiles-core#592 made the markdown leg blocking and it covers all 17 repo-owned files, not just the README.
- SecuritySeven tools carried claims that were incomplete, imprecise, or absent — the annotation half of #275 (item 9). No package added or removed; the parsed set is byte-identical at 85 names. - httpx-toolkit's warning was right in conclusion, wrong in mechanism. It said the "bare 'httpx' apt pkg is the python lib, not this". There is no binary package named httpx at all — apt-cache show httpx returns E: No packages found; the source package builds python3-httpx. So the bare name does not install the wrong tool, it resolves to nothing — which makes this an instance of the *hexyl* rule (a name this manifest must never carry), not of the package/binary split it was filed under. Currency added: apt runs about one minor behind. - wpscan changed under you. kali-rolling jumped 3.8.28 → 4.1.0 in Aug 2026 after 3.8.28 sat since Mar 2025, so an apt upgrade since then swapped the tool, not the patch level. v4.0.0 requires Ruby 3.3+, no longer scans plugins by default (-e ap), moved config/cache to XDG dirs, and removed --timthumbs-detection, --config-backups-detection, --db-exports-detection and --medias-detection. Verified that nothing shipped breaks: hacktheplanet passes --enumerate u,vp,vt explicitly, so the plugin-default change never reaches it. - nikto is alive and current, recorded so it is not re-suspected (upstream pushed 2026-08-28; 2.6.1 released Jul 2026). It was flagged as a likely-stale "old Perl scanner" and is not. Its 2.6.x line does change the tool's network signature — a static Chrome User-Agent by default instead of one rotating per request — which is worth knowing when reasoning about what a defender saw. - snmpcheck and smtp-user-enum were the enum block's last two unmarked freezes. Both upstreams are frozen (nothink.org 1.9, 2015; pentestmonkey v1.2), and in both cases apt is at that version, not behind it — there is nothing to chase. Kept for the reason mitm6 and PrintSpoofer are kept: SNMP community strings and SMTP VRFY/EXPN are protocol behaviours, not bugs anyone will patch out. Deliberately no year for smtp-user-enum — its page states no release date, so any date here would be invented. - bloodyad ships BadSuccessor, which the README does not mention (it is in the wiki: add badSuccessor, msldap badsuccessor_check, msldap dmsas), so the dMSA escalation path is already on the box. Annotated with the target-state caveat the mitm6 note draws on the same axis: Microsoft patched it 2025-08-12 (Server 2025 DCs from build 26100.4946), and the post-patch variant needs a second primitive plus SharpSuccessor/Rubeus — neither of which this layer ships, and neither added. - PKINITtools is going quiet — not archived, but last push 2025-01-03, the stalest live pointer in the AD block. Dated note only. The note explicitly refuses to claim certipy supersedes it: that could not be confirmed from a primary source, and says so rather than leaving a plausible guess in the manifest. - GodPotato was the last unmarked freeze in the target-dropped block once PrintSpoofer got its ARCHIVED note. Static since 2023-11-24 and kept — the RPCSS OXID abuse survived the DCOM activation-hardening waves. SigmaPotato is named as the in-memory .NET fork but gets no pointer: itself untouched since 2024, so a mention rather than a successor.
- SecurityPACK is in Kali apt, and the manifest sent you to a dead Python-2 repo instead. The pointer read → UPSTREAM (github.com/iphelix/pack) … Python 2 era — run from the clone, no apt package, and both halves were wrong. This is the fifth instance of the error class this file already records fixing for caldera, name-that-hash, evilginx2 and sliver: a manifest that routes you upstream for something apt ships. Confirmed against apt's own index rather than a report — pack (0.0.4+git20191128.fd779b2-0kali3, arch all) Depends: python3, python3-enchant, and the package's Homepage field is github.com/Hydraze/pack, the maintained Python-3 fork (last push 2024-07-28). iphelix's original is dead (last push 2019-12-10). Now a plain apt line in Credential attacks. The membership rule does not block it the way it blocks trufflehog — offensive/hacktheplanet:580 invokes statsgen and maskgen directly. The shipped binaries were read off the package rather than guessed: statsgen, maskgen, policygen, rulegen and dictstat, a fifth legacy binary the old note did not know about. The kwp-is-not-in-PACK note on the next line depends on this block naming iphelix/pack and is left intact. #275 item 1.
- Perfchisel had no annotation at all, and apt ships a pre-release of it. kali-rolling is 1.12.0~rc2-0kali1; upstream cut v1.12.0 final on 2026-08-29, two RCs ahead — the opposite of the ffuf case, where apt trails a live upstream. 1.12.0 breaks flags: --auth now requires <user>:<pass> and *fails startup* on a missing colon, SOCKS5 users need an authfile entry matching socks, truncated MD5 fingerprints are rejected, and a client exhausting --max-retry-count exits non-zero. Nothing shipped here breaks — the hacktheplanet lines and the corpus' reverse-tunnel-chisel entry were checked and both use the bare chisel server --reverse form; the exposure is an operator adding --auth from memory. The pspy split is recorded too: apt's build is for your box, the static release binary is what you upload, and mixing is safe because the wire protocol is unchanged. #275 item 2.
- Fixkubectl is outside Kubernetes' documented support skew, which the line did not say. Its provenance note was correct; its silence on currency was the problem. kali-rolling is 1.33.4+ds-1 (imported 2025-10-02) against upstream stable v1.37.0. kubectl is supported within ±1 minor of the apiserver, so a 1.33 client is unsupported against 1.35/1.36/1.37 — a documented window, not a version-number aesthetic, and it degrades the corpus' k8s-* entries sitting under peirates. The line now points at pkgs.k8s.io when a cluster's version matters, the same shape as the google-cloud-cli/gh/vault pointers in the same block. #275 item 3.
- ConfigThe covert-egress header excepted ptunnel-ng as "the CURRENT upstream" — an annotation this changelog added two entries below, wrong within three weeks. Its *version* claim holds (kali 1.43-2 is upstream v1.43); its *vitality* claim did not. v1.43's own release note says "due to time constraints, there will be no further publications in the near future" (tagged 2024-11-27) and master has not moved since 2024-04-07. It froze at a version apt happens to have. All five tools in that block are frozen, dead, or behind — which is the honest state of covert-channel egress and more useful than implying one live option exists. ptunnel-ng is still the right choice of the two ptunnels; it is the maintained-*er* fork, not a live project. #275 item 4.
- Fixdnsenum's name lands you on the wrong repo. The line carried no upstream pointer, and fwaeytens/dnsenum — what you find searching the name, 702 stars — has not moved since 2019-10-08. Kali ships 1.3.2-1, whose Homepage names SparrowOchon/dnsenum2 ("officially mainlined in Kali"). Same use-the-fork trap the kwp-vs-iphelix/pack and ConfuserEx-vs-mkaring notes exist to prevent — and the same shape as the pack fix above, where apt's Homepage also named a fork the note did not know about. Honest status: frozen fork, and apt is on it. dnsrecon is the maintained analogue and is in apt, but no doc or corpus entry names it, so it stays out on this file's membership rule. #275 item 5.
- FixPowerUpSQL was named in the payload-build block's "absent" list but was the one member of six with no status line. Verified: not archived, but no release has ever been cut and the last functional commits are Aug 2024 — quiet, not dead, the sRDI shape. Recorded as low impact here, because the Linux-native half is already on the box: impacket-mssqlclient (enum_links/use_link) and nxc mssql cover linked-server hopping and are both already listed. skahwah/SQLRecon is named as the maintained analogue with no pointer of its own — the pretender treatment. The block's reference to that tool in offensive/evasion had also drifted, and is corrected from :124 to :126. #275 item 6.
- Fixsliver's note ran one release ahead of the facts. It said upstream "has kept releasing (past 1.7.6 by Aug 2026)"; v1.7.6 shipped 2026-08-28 and is the head, so "past" was wrong — now "through 1.7.6". The durable phrasing the last cycle introduced (check sliver-server version before an op, never a patch count) is unchanged and still right. What 1.7.6 contains sharpens it: it bounds mTLS/WireGuard envelope and pivot-frame allocation from the length prefix and fixes DNS varint boundary handling — memory exhaustion on network-facing paths, not cosmetic stability. #275 item 7.
- Fixproxychains4 carried no annotation, and the bare proxychains name is a live trap. apt is at upstream's head (4.17-3.1 = v4.17; rofl0r alive but slow to release — fixes landed 2026-08-27 against a 2024 tag). The addition is the trap: a real proxychains package exists in Kali and Debian sid at 3.1-9 — proxychains 3.1, from 2007. It escapes the usual apt-file search '/usr/bin/proxychains$' check because it ships /usr/bin/proxychains3, a third binary name, so apt install proxychains *succeeds* and silently hands you an 18-year-old tool. A sharper reason to name proxychains4 than "the bare name is only a virtual Provides:". #275 item 8.
- Fixredup's katana step would have inherited the nuclei miscount on migration. It ran katana -update unconditionally — the exact shape the nuclei engine step had before it was fixed below. katana 1.7.0-0kali1 landed in kali-dev on 2026-08-27 and has not migrated to kali-rolling; apt owning a binary is precisely when Kali patches its self-updater out, as it already did to nuclei. On the day katana migrates and is patched, the step would have started printing ✗ katana update failed and tallying it on every run of a healthy box. The flag is now probed before use, reusing the nuclei step's whole-token regex byte-for-byte — the match has to be whole-token here too, since katana's help carries -duc, -disable-update-check, which a bare grep -- -update would match on exactly the patched build the probe exists to catch. A flagless build now degrades to a skip that tallies nothing. Preventive: no behaviour change on today's go install build, where the probe passes. redup -h, the redup header comment, aliases.md and install/tools.lst all gave nuclei a build hedge and katana none; all four now match. #260 item 5.
- FixThe Cloud / SaaS / CI-CD block claimed Terraform Cloud entries are "pure REST — curl + a token, nothing to install." The tfc-agent entry lower in the same file already said the opposite — that it "corrects this file's older claim that the Terraform Cloud entries are pure REST" — so the correction was written at one end and never applied at the other, leaving the two halves of one file contradicting each other. tfc-agent-hijack creates the agent pool over REST and then runs tfc-agent, a HashiCorp release binary on infrastructure you control; only tfc-token-backdoor and tfc-var-injection are curl-only. Checking the rest of the sentence while correcting it found it loose for two more of the five services it named: Snowflake's three entries are SQL (``sql fences, which is why the corpus gate never sees a command in them) and slack-2fa-disable` is a console toggle with no command at all. "Nothing to install" still holds for both — "curl + a token" did not. Okta and GitLab were accurate as claimed.
- Configkatana's manifest pointer said "not in apt", which is no longer true. Initial Kali packaging (1.7.0-0kali1) was committed to kali-dev on 2026-08-27. It has not migrated, so go install is still the only route on any box today — but the pointer now states the kali-dev version and the "has not migrated" qualifier, mirroring the shape rustscan already carries in the same file, and names pkg.kali.org/pkg/katana as the re-check. It also records what a migration would bring: the kali-dev packaging carries no debian/patches directory yet, so -update survives there for now. #260 item 5.
- Configmitm6's freeze note claimed "there is no maintained successor to move to." Too strong, and the near-miss has a name: RedTeamPentesting's pretender (Go, v1.4.1, Jul 2026) is maintained and does mitm6's exact DHCPv6/DNS takeover plus mDNS/LLMNR/NBT-NS. But it is a spoofer only — no listener, no capture, no relay — so it replaces neither mitm6 nor responder, and the note's conclusion (keep mitm6, frozen because finished) is unchanged. It gets no → UPSTREAM pointer of its own: no doc and no corpus entry invokes it. The same note now records a second axis the old text conflated with it — whether the coercion fires is not whether the relay yields. On fully-patched Server 2025 / Win11 24H2, SMB signing is required by default and LDAP channel binding ships Enabled-When-Supported (MSRC, Dec 2024): the trigger still fires, the SMB and plain-LDAP relay legs close, and value shifts toward krbrelayx and the AD CS / PKINIT path. #260 item 6.
- Fixredup counted every successful searchsploit -u as a failure. The step ran if searchsploit -u; then …, but searchsploit exits 6, not 0, after any successful update — its own header documents it ("Exit code '6' means updated packages (APT, brew or Git)") and its update routine ends in a bare exit 6 on every route (apt, brew and git alike). So a completely successful refresh printed ✗ searchsploit -u failed and was tallied, making the summary read red on a healthy box. This is the same miscount as the nuclei engine step below, one step further down the same function, and it survived that fix. The exit status is now captured and both 0 and 6 count as success; anything else still reports, and now prints the code. Found while correcting the step's prose for #260 item 8 — the report called this a comment-only fix.
- Perfredup's searchsploit comment described a code path that does not run on Kali. It explained the sudo escalation as a permissions problem on a root-owned git checkout under /usr/share/exploitdb. On a deb install searchsploit -u never reaches its git pull: it probes apt-cache search "^exploitdb$" first and, on a hit, runs sudo apt update && sudo apt -y install exploitdb, escalating on its own. The writability probe is kept — it is still correct for a user-local or /opt checkout and on non-Kali — but it is now documented as inert on the deb route. The consequence strengthens the never-mid-engagement warning rather than softening it: the step can move apt state, not just refresh a data directory.
- SecurityFive more annotations routed you around a package apt already ships — the same error class as caldera below, found by re-verifying every → UPSTREAM name in the manifest against apt rather than by any report. None of the five appears in #260: - evilginx2 was annotated → UPSTREAM (go install or release binary) and filed under the block headed "Operator-side tooling, not in apt", whose preamble says outright that these "have no Kali package". kali-rolling ships evilginx2 (3.3.0+ds1-0kali1), which *is* upstream's latest release (v3.3.0, Apr 2024). Now a plain apt line in Credential attacks, ROE warning intact. - name-that-hash was annotated → UPSTREAM (pip install name-that-hash). Kali ships it (1.11.0-0kali1). The decision to leave it uninstalled stands on its own merits; only the packaging pointer was wrong. - pspy was annotated → UPSTREAM (release binary) in a block whose premise is "per-engagement downloads rather than apt packages". Kali ships pspy (1.2.1-0kali1). Here the conclusion survives for a sharper reason than the block gave: what apt ships is a host-arch, dynamically-linked Debian Go build (Depends: libc6), while what you upload to a target is upstream's *static* pspy32/pspy64. So the release binary really is the per-engagement download — just not because "there is no package". - PowerUp.ps1 was annotated → UPSTREAM (PowerSploit …). Kali packages it: the powersploit package (3.0.0+git20200817-0kali1) drops the script at /usr/share/windows-resources/powersploit/Privesc/PowerUp.ps1 — the same pattern as mimikatz, a Linux package whose payload is Windows content you copy to the target. It is pulled in by kali-linux-headless, so it is already present on a default box. Still not an apt line of its own, but "fetch it from GitHub" was wrong. - The evasion payload-build paragraph asserted "None is in Kali apt" of its five tools. donut is packaged (1.1-0kali3+b1, /usr/bin/donut) and is the one member whose generator runs natively on Linux, so the paragraph's "all run operator-side on WINDOWS" was wrong about it too. It stays unlisted as a judgement, not because apt cannot supply it. macro_pack, PowerUpSQL, sRDI, ConfuserEx and ScareCrow are genuinely absent, as claimed.
- Perfcaldera is in kali-linux-large. The note added with the caldera fix below claimed it is "in NO kali-linux-* metapackage"; apt-cache rdepends caldera says otherwise. It is absent from kali-linux-default, which is what the line was reaching for, so the conclusion (a default box needs this line) is unchanged.
- Fixredup's nuclei engine step could never succeed on Kali. It ran nuclei -update unconditionally, but Kali patches that flag out of its packaged nuclei — apt owns the binary, so self-updating it is not nuclei's job there. Kali's -h UPDATE section carries only -update-templates, -update-template-dir and -disable-update-check. So the step failed on every run on the primary target platform and tallied a failure, making the summary read red on a completely healthy box — the exact miscount the function's own comments exist to prevent. The engine step is now probed and the templates step (the daily-moving half) stays unconditional, so a go install-provided nuclei on non-Kali Debian still self-updates. The probe matches -update/-up as a whole TOKEN: a bare substring grep matches -update-templates, -update-template-dir and -disable-update-check, three hits on the very help text that proves the flag is absent.
- ConfigThree apt names in install/offensive-packages.txt resolved against nothing. Verified against kali-rolling's own binary index, not a local box: - bbot is packaged in no Kali component and never has been (pkg.kali.org 404s) — now an UPSTREAM/pipx comment. The old line's "(pipx/upstream if the repo build lags)" hedge implied a repo build that does not exist. - snmp-check is the binary name; the package is snmpcheck, which ships /usr/bin/snmp-check. Same package/binary split the file already documents for httpx-toolkit and python3-ldapdomaindump. hacktheplanet's command was always right; only the manifest was wrong. - rustscan is absent from main, contrib and non-free alike — Kali's packaging sits in kali-dev at 2.4.1 and has not migrated. The line also claimed it "ships in kali-linux-default", whose Depends does not name it, so both halves were wrong. Now an UPSTREAM (cargo) comment. test/check-packages.sh had been reporting all three for weeks; see the packages.yml note under Changed for why nobody saw it.
- FeatureCaldera was routed to Docker for nothing. install/offensive-packages.txt carried it as → UPSTREAM/docker and offensive/offensive.zsh justified the missing probe with "Caldera ships no caldera binary". Both false: kali-rolling ships caldera (5.3.0-0kali1) and it installs /usr/bin/caldera. Now a plain apt line, noting the ~70 MB Python chain and that no kali-linux-* metapackage carries it. The decision not to add HAVE_CALDERA stands, but for the real reason — nothing in offensive.zsh invokes it, which is install/tools.lst's actual membership rule.
- FixThe corpus-coverage counts went stale again, exactly as recorded below for the v2.10.0 sync. A later v2.10.1 sync added entries/blue/smb-enum-5145.md and projected the smb-enum pair into both views; no header moved. The version notes in those files now point at companion.lock for the exact revision instead of hardcoding a commit count, which rots the same way the counts do. Actual is 103 red / 102 blue with 19 and 24 blocks projected. Fixed in hacktheplanet, PURPLE-TEAM.md, OFFENSIVE-METHODOLOGY.md and — found while verifying, reported by neither audit — CONTRIBUTING.md and the Makefile. hacktheplanet also listed smb-enum-nxc among the entries "covered as richer prose below" while generating a block for it seven paragraphs later, so its own accounting summed to 102 rather than 103.
- FixCLAUDE.md described --install as apt-only on Kali. _install_apt_absent pipx-installs ROADtools on both routes, which bootstrap.sh and install/offensive-packages.txt both state plainly. "Where things are" also documented 2 of the 4 install/ manifests; corpus-commands.lst and impacket-binaries.lst are now listed, the latter being the file whose entire purpose is making impacket-petitpotam fail (#208).
- FixOFFENSIVE-METHODOLOGY.md dated Caldera's Apache move to "May 2026", contradicting the manifest's already-corrected 2025-12-19 donation date (#211 landed that fix in the manifest only).
- Securityhacktheplanet's escalation-primitives index restated certipy-ad find … -vulnerable without -stdout, so a copy-paste wrote to a file instead of the terminal. The canonical AD CS section and the corpus entry both carry the flag.
- SecurityThe corpus-coverage counts were stale in three files (found while verifying #212, which had reported them as correct). hacktheplanet and PURPLE-TEAM.md claimed 92 red / 90 blue entries; the htpx v2.10.0 sync added 11 of each and the headers were never updated — actual is 103 red / 101 blue. The decomposition went stale with them: the cloud/SaaS/CI-CD bucket is 56 (not ~55), C2-egress/Impact is 13 (not ~12), and 7 Linux persistence/privesc/credential-access entries had no bucket at all.
- FixTwo red entries and their blue pairs are projected nowhere and belong to no category. bloodhound-collect and ldap-recon are both Active Directory — discovery, squarely inside the "richer prose here" subject area but absent from its list; their pairs bloodhound-collect-4662 / ldap-recon-4662 key off event 4662, which is PURPLE-TEAM.md's own criterion for projecting. hacktheplanet's claim that an unprojected entry is "not a gap in the generator" was therefore false. Both files now name the gap instead of implying it cannot exist.
- Fix**rdp-hijack-tscon was listed as "covered better below"; it is covered *equally*.** Its two commands are byte-identical to the prose ones. Noted rather than silently kept.
- FixOFFENSIVE-METHODOLOGY.md's "roughly two-thirds of the corpus" replaced with the measured figure — 69/103 red (67%) and 76/101 blue (75%). All three files now carry the same caveat: these counts are hand-maintained, they go stale on every companion-sync, and the corpus is authoritative when they disagree.
- Configkwp was attributed to the wrong project (#213). The manifest filed it under PACK (kwp, statsgen, maskgen) → github.com/iphelix/pack. PACK ships statsgen/maskgen/policygen/rulegen and no kwp — kwp is hashcat's kwprocessor, and hacktheplanet's invocation is verbatim kwprocessor. Following the old pointer landed you in a repo that does not contain the tool. Split onto its own UPSTREAM line.
- Configexploitdev's Linux toolchain was unmanifested (#212, #213). gdb, nasm and objdump are invoked by that reference and appeared nowhere in the package list. Resolved by checking a real kali-rolling box rather than guessing: nasm (via metasploit-framework) and binutils are already pulled transitively, so they go in the accounting block, while gdb is genuinely absent — gcc only *suggests* it — so it joins the "Kali does NOT ship by default" block, whose stated test it meets exactly.
- Fixnc was named only inside another package's comment (#212). netcat is the primary command of the reverse-shell fold and was listed nowhere. Added as netcat-traditional, not netcat-openbsd as the audit suggested: Kali installs traditional and points the nc alternative at it, and the documented nc -lvnp form is a traditional idiom — OpenBSD's nc rejects -p alongside -l, so that variant could have flipped the alternative and broken the very line it was meant to support.
- FixFour field-reference commands could not run as written (#213, #212). hacktheplanet invoked nmap --script=msrpc-dcom-interface-activation, which is not a script nmap ships — verified against nmap 7.99 on kali-rolling, where the only msrpc NSE is msrpc-enum (already the line directly above). Dropped rather than replaced: there is nothing to replace it with. exploitdev invoked !mona egghunter, which is not a mona command — egg is, and -c (NtAccessCheckAndAuditAlarm) is one of *its* options; the two lines collapse into one. hacktheplanet also credited --dc to impacket/certipy when it is kerbrute's idiom — impacket and certipy use -dc-ip, as every impacket line in that file already does. The same misattribution in this file's #187 entry is corrected with it.
- Perfexploitdev presented hexyl as installed when no fleet layer ships it. The note claimed it was "Kali-only in this stack (not in Core)"; it is in Core, Kali apt (no such package exists) and install/offensive-packages.txt alike — nowhere. offensive.zsh probes HAVE_HEXYL but nothing installs it, so the bad-char *verification* step silently needed a tool the operator did not have. Now says so, with an xxd fallback, and points at the dotfiles-core#395 deferral.
- FixTwo hacktheplanet commands could not run as written (#187). rusthound-ce was invoked with --dc <ip_address>; RustHound-CE has no such flag — that is kerbrute's idiom (impacket/certipy use -dc-ip) — and takes -i/--ldapip for the DC IP or -f/--ldapfqdn for its FQDN. And two pivot lines invoked bare proxychains, which is not a binary on this layer's own box: the manifest ships proxychains4, that package installs only /usr/bin/proxychains4, and its Provides: proxychains is a virtual-package relation, so apt-file search '/usr/bin/proxychains$' matches nothing. The audit that filed this guessed the second one was "probably fine … one command -v settles it"; it was run, and it isn't. Both lines now carry the reasoning inline, since --dc is right for kerbrute two folds up and the next reader will otherwise "fix" it back.
- Configcifs-utils was missing from the manifest (#187). hacktheplanet mounts a share with mount -t cifs twice — once in the SMB fold, once on SYSVOL inside the GPP-cpassword block — and nothing in offensive-packages.txt provided mount.cifs. smbclient *browses* a share; mounting one is a separate package. This was the only real gap of the six the audit alleged: samba-common-bin and gcc-mingw-w64-i686 were false (smbclient ships /usr/bin/rpcclient; mingw-w64 provides i686-w64-mingw32-gcc), and the rest had already landed with #186.
- ConfigThe manifest's own accounting claim was false again (#187). The target-dropped block claims it "accounts for every tool the DOCS *and* the COMPANION CORPUS name", and seven doc-named tools were unaccounted for. pspy joins the block properly — it is genuinely target-dropped, and ippsec names it in the same breath as linpeas. The other six (macro_pack, PowerUpSQL, and the Donut/sRDI/ConfuserEx/ScareCrow loaders from evasion) get a stated exclusion instead of a listing, because they are operator-side *payload-build* tooling that runs on Windows: not target-dropped, not in Kali apt, and not something a Linux apt list should imply it can install. Either a tool is listed or the manifest says in one line why it isn't — which is what makes the claim checkable.
- Fixldapdomaindump was installed twice and invoked never (#187). It arrives by apt (python3-ldapdomaindump) *and* by pipx on the non-Kali route, and OFFENSIVE-METHODOLOGY.md lists it — but no command anywhere under offensive/ ran it, making it the only installed AD-enum tool with no copy-paste line. It now has one in the AD fold, writing to loot/ldd to match the methodology table. The manifest records the apt-name/binary-name split, the same dual-name trap already documented for impacket and certipy. Not acted on from #187: the bloodhound-python finding was already fixed at HEAD (the audit ran against a pre-b294258 tree — every line number in it is stale by 11–15, and it cites os/kali.conf, deleted 2026-08-18). The -M wmi-event finding is real but worse than filed — that NetExec module does not exist in *either* spelling — and lives in generated content, so it was fixed upstream in htpx#73 and arrives here on the next companion sync.
- ConfigSeven packages, behind eight commands hacktheplanet invokes, had no manifest line (#186) — ftp, showmount, dig, nslookup, mysql, psql, redis-cli and i686-w64-mingw32-gcc. The Service-enumeration block states its own rule — *every fold's primary command in PATH* — and five folds were not honouring it. ftp, nfs-common and bind9-dnsutils join that block; the other four get a new block of their own, because checking kali-meta's debian/control showed the audit's framing was too generous: no Kali metapackage names mariadb-client, postgresql-client, redis-tools or mingw-w64, so those four lines in hacktheplanet fail on a stock box, not just a slim one. mingw-w64 is the sharpest — build-essential gives you native gcc only, so nothing else on the box covers the cross-compile. - Note the DNS name: it is bind9-dnsutils, not the dnsutils the audit proposed. dnsutils is a transitional binary off the same bind9 source, gone from trixie and back only in sid; the sole Kali metapackage still naming it is kali-linux-wsl. Since test/check-packages.sh resolves every name against kali-rolling, the durable name is the only safe one to pin. - No install/tools.lst change: that file's header restricts it to commands offensive/offensive.zsh probes or invokes by bare name, and none of these are. Adding them would make bootstrap's report cry wolf.
- Perfgcc-multilib was the eighth package, spotted during that pass and deferred (#186). hacktheplanet:212 runs gcc -m32 two lines below the i686-w64-mingw32-gcc line above, and fails for the identical reason: build-essential's gcc is native x86-64 with no 32-bit libs, so rebuilding an old PoC dies on <bits/libc-header-start.h>. No Kali metapackage names it either, so it joins the *does-not-ship-by-default* block rather than the slim-install one.
- FixPrintSpoofer64.exe and GodPotato were the target-dropped block's one blind spot (#186). That block promises to account for *every* tool the docs and the corpus name; these two arrive from the corpus inside hacktheplanet's companion:gen potato-seimpersonate block, which is how they slipped it. Two UPSTREAM → lines now, matching the linpeas/winPEAS treatment.
- Fixredup's help advertised a step that always no-ops (#186). Both help strings and aliases.md promised a refresh of "the go-installed tools", but go_fast_movers has been () since kerbrute was dropped as upstream-frozen. The strings now describe what the function does; the block comment still records why the array is empty and how to re-populate it. aliases.md also gains katana, which it had missed since redup started driving it.
- Fixdoggo, carapace and sesh never installed on a fresh box. mise lands in ~/.local/bin, which is not on PATH during bootstrap, so the go install fallback's command -v mise always missed. A PATH prelude fixes this and the related re-install-every-run behaviour of atuin.
- FixA symlink cycle in the .zshrc wiring. bootstrap.sh re-did a link the library already makes, bypassing the ELOOP guard in _blib_seed_zdotdir_rc.
- Fixbootstrap.sh no longer silently installs nothing when install/packages.txt is missing.
- Fixapt_install's per-package retry keeps --no-install-recommends.
- FixThe bootstrap workflow's path filter omitted install/ and wsl/, so package-list edits never re-ran the bootstrap test. Filters removed.
- Fixdotsync hardcoded ~/dotfiles-Offense; it now resolves this checkout.
- ConfigThe offensive tmux binding shipped even when its script was not linked, and hardcoded ~/.config against an XDG-aware bootstrap.
- Fix@batt_enable was unconditionally off "because WSL has no battery" — now detected, so bare-metal laptops keep the widget.
- Configssh/config pinned modern-only crypto on Host *, which refuses to negotiate with the legacy targets an offensive box exists to reach. Scoped to your own infrastructure.
- Configpseudo-shell.py proxied through Burp by default, so every request failed opaquely when Burp was not running; now opt-in. Its requests dependency documents a PEP 668-compatible install path.
- Fixredup printed "go not installed" for an intentionally empty tool list, and ran searchsploit -u without the privilege its root-owned checkout needs.
Added
- PerfThe README opens with a rendered terminal hero (dotgibson/dotfiles-core#948). assets/demo.gif is filmed from assets/demo.tape, which dotfiles-core generates from one shared template for all nine OS and role repos — the same tour everywhere, plus the one command that is this repo's own: core status showing the role layer live over the OS layer. The tape is generated (edit dotfiles-core's assets/hero.tape.in, not the tape); re-render with vhs assets/demo.tape on a Debian box with this role layered on top after a prompt or tooling change, then gifsicle -O3 --lossy=80 --colors 64 — the raw render is over Core's 2 MiB ceiling, the optimised one is not.
- Featurecore-verify asks the integrity question again, and core-check gets the freshness one back (dotgibson/dotfiles-core#691). Adopting the fleet vocabulary pointed the canonical core-verify at test/check-core-freshness.sh and demoted core-check to an alias of it. Those are two different questions: freshness is *is there a NEWER Core upstream?*, integrity is *is THIS core/ the tree core.lock pins?* — and Core's scripts/make-vocabulary.txt defines the canonical verb as the second. So the register read green on a target answering something else, while this repo still had no local integrity check at all: core-integrity.yml ran one in CI and nothing ran one here. core-check is a real target again, with help text naming its question, and core-verify delegates to Core's own scripts/core-integrity.sh from a CORE_REPO checkout — the same invocation CI uses, and the only implementation that knows how the fan-out filters the vendored subtree. Verified both ways against a sibling clone at core v6.1.0: core-verify reports pristine, core-check reports current.
- FixThree tool decisions recorded so the next scout cycle doesn't re-raise them. All three were proposed by #260 and all three were declined, on stated grounds rather than by omission: - gh in redup's go_fast_movers (item 9). It has the profile the machinery was kept for — go-only, apt-absent since kali-rolling dropped 2.46.0-3 on 2025-12-10, and genuinely fast — but upstream supports the release binary and GitHub's own apt repo, not go install, and a bare go install build reports an unset/dev version string. The entry would replace a correct build with one that cannot report its own version. Recorded in the comment beside the katana rejection already there, so the array is now empty for two stated reasons rather than one. - trufflehog. In kali apt (3.94.3-0kali1) and a good fit for what the Cloud / SaaS / CI-CD block is for — "find the leaked key" is the missing first step of most of the gh-*/npm-*/pypi-* supply-chain entries. Held out on this file's own membership rule, not on merit: no doc and no corpus entry invokes it. The doc edit is the prerequisite; the note says so, and says it becomes a plain apt line once one does. - PrivescCheck — same rule, same note, beside the PowerUp.ps1 entry it would complement. The report conceded the prerequisite for this one and not for trufflehog; the rule applies to both identically.
- Fixmake view-counts / test/check-view-counts.sh — a gate on the hand-typed corpus counts in hacktheplanet, PURPLE-TEAM.md and OFFENSIVE-METHODOLOGY.md. Two of those files already carried a caveat saying the numbers go stale on every companion-sync; this executes it. It exists because the drift above is a repeat — the same fix is recorded for the v2.10.0 sync — and nothing could see it: gen-views.sh --check byte-compares block *contents* and has no opinion on how many blocks exist, and markdownlint cannot tell 101 from 102. It is repo-owned rather than an extension of gen-views.sh because offensive/companion/ is a vendored subtree and an edit there is lost on the next sync. It checks only what is mechanically derivable (entry totals, projected-block counts, and the sums of those); the semantic buckets — 56 cloud/SaaS/ CI-CD, 13 C2-egress/Impact, 7 Linux, 69/76, the percentages — are deliberately ungated, because nothing in entries/*.md marks an entry "cloud". Exit 2 means a stale count; exit 1 means an anchored sentence was rewritten and needs re-anchoring — two different failures, so a maintainer is never told the wrong one.
- FeatureThe SMB enum fold and its detection are now entry-backed. companion.lock bumps to htpx b80741f, which pairs smb-enum-nxc with a new smb-enum-5145 blue entry (htpx#97), and both sides are wrapped in companion:gen markers: the nxc commands in hacktheplanet's SMB fold, and the detection in PURPLE-TEAM.md's recon section. The hacktheplanet block is the notable half. Those five nxc smb lines have been hand-written since the file existed, and the entry upstream carried only three of them — so wrapping them would have silently deleted --loggedon-users and the /24 spray. htpx#100 widened the entry to the full fold with its inline comments matched, which makes render_red reproduce the existing lines byte for byte: the diff to hacktheplanet here is the two marker lines and nothing else. That is the bar for putting a marker around prose that was already good — the tempting shortcut, wrap it and let the generator win, loses content nobody notices for months. This sync also brings pair_note: (htpx#98), which the vendored copy predated: an entry carrying pair: null must now say why, and upstream CI rejects one that does not.
- FixROADtools is now installed by --install, on every route (#231). Entra/M365 tooling in this layer was entirely Windows-side (AADInternals, TeamFiltration, MSOLSpray); the corpus even cited dirkjanm's ROADtools as device-code-phish's source: while handing you Windows-only PowerShell. bootstrap.sh grew an _install_apt_absent step — a THIRD install category for tools no route can apt-install — that pipx-installs roadrecon and roadtx on the Kali path too, not just the portable subset, so the Entra corpus entries finally run from the attacker box. hacktheplanet's M365 fold documents the Linux commands and install/offensive-packages.txt carries the annotation. The corpus entry's own platform:/source: fix lands upstream in dotgibson/htpx.
- FeatureSCCM/MECM is now covered — it was a total blank (#230). grep -ri sccm over the repo used to return nothing, despite site-server takeover and Network Access Account extraction being mainstream AD attack surface. Added a SCCM / MECM fold to hacktheplanet (discovery -> NAA over the network or from a compromised client -> site takeover -> PXE boot-media creds), a Cred access row in the methodology map, and sccmhunter + pxethiefy UPSTREAM annotations in install/offensive-packages.txt (whose corpus-only block widened to admit doc-named operator tooling). sccmhunter is documented, not wired into --install: it is not on PyPI, its git install carries a Python 3.13 floor and an ldap3 fork pin pip drops silently, so a documented manual step beats a best-effort loop that fails quietly. Every command verified against upstream.
- Fixcorpus commands resolve — a gate for the question nothing asked (#208). Two entries in coerce-petitpotam once invoked impacket-petitpotam and dfscoerce. Neither is a real command, both shipped in a released corpus, and a human reading the file found them — because every existing gate looks somewhere else: gen-views.sh --check byte-compares the 18 *projected* red blocks (85 of 103 are unprojected), check-packages.sh reads the manifest and never the corpus, companion-integrity checks provenance rather than content, and htpx's own CI checks pairing and slots rather than existence. test/check-corpus-commands.sh resolves the first token of every command line in every red entry against the manifest, an impacket-binaries.lst roster, and the classifications in install/corpus-commands.lst. Offline and deterministic, so unlike packages-check it can be required; wired into make test and its own workflow. - Its --self-test rebuilds the pre-v2.8.0 coerce-petitpotam at run time and asserts the gate still reddens on it — a regression guard for the gate itself, since one that quietly stopped catching its own motivating bug would pass forever. - install/corpus-commands.lst requires a line of prose on every classification, so "whatever the allowlist excuses, it says so" is enforced rather than hoped for. It also fails on a classification no entry uses any more, so a companion-sync that drops an entry surfaces its dead excuse. - The roster spans two packages: impacket-scripts (57 wrappers) and python3-impacket (5 more, including impacket-secretsdump and impacket-wmiexec). A roster built from impacket-scripts alone would have failed the two most-used commands in the corpus. packages.yml gained an advisory step that re-derives it from kali-rolling and diffs, so the checked-in copy cannot rot unnoticed.
- FixFive tools the corpus invokes and nothing accounted for, all surfaced by the new gate on its first run: ldap-utils (ldapsearch, a real apt package, now installed), plus UPSTREAM entries for evilginx2, MSOLSpray and tfc-agent in a new "Corpus-only operator tooling" block. tfc-agent also corrects this file's older claim that the Terraform Cloud entries are pure REST. The legacy bloodhound-python binary is now named in the BloodHound block, with the warning that the entry invoking it is wrong for a CE stack — that fix routes upstream to htpx.
- FixA gate against leaked RETURN traps (#198) — test/check-return-traps.sh, wired into make lint as make trap-guard and into CI as the return-traps job in checks.yml. A bash RETURN trap is a global slot, not a function-scoped one: armed inside a function it survives into the *caller's* frame and fires a second time when the caller returns, where the local it cleans up is out of scope and set -u makes that fatal. In dotfiles-Debian that aborted provision() after every package had installed but before wire_links ran — the whole stack on the box, and not one symlink. Nothing else can see it: the broken line is valid bash, so shellcheck and bash -n both pass it, and no CI job in this fleet exercises a real install path (every bootstrap run is --links-only). Hence a grep. The correct form is trap 'trap - RETURN; rm -rf "$tmp"' RETURN. This is prevention, not a fix. #198 reported the bug in this repo's verified_install(), but that function — and the entire SHA-pinned out-of-band install block around it — left in the layer split (6d641d2), which moved it to dotfiles-Debian; the trap went with it. No repo-owned shell here arms a RETURN trap today. The guard exists so none ever does again. zsh is out of scope: it has no RETURN signal at all.
- FeatureMakefile — the entry point (make lint, test, core-sync, packages-check, …). Makes core.lock's make core-lock instruction true for the first time.
- Featurescripts/sync-core.sh, test/check-core-freshness.sh and a freshness workflow — the consumer-side core-sync line, which three files already referenced and none provided.
- Featuretest/check-companion-integrity.sh — tamper detection for the second vendored subtree, mirroring core-integrity.
- Configtest/check-packages.sh + a packages workflow resolving every manifest name against kali-rolling.
- Featuremarkdownlint in CI, against the .markdownlint.jsonc that had been sitting unused.
- SecuritySECURITY.md, CODEOWNERS, issue and PR templates, CONTRIBUTING.md, .shellcheckrc, .editorconfig, .gitattributes.
- Featurebootstrap.sh --dry-run and --no-upgrade.
- Featurecompanion_version / companion_tag in companion.lock, for symmetry with core.lock.
Changed
- Perfbootstrap.sh runs on Core's bootstrap driver, blib_main (dotgibson/dotfiles-core#986). The shared half — the flag loop, the Core symlink surface, the band-85 role stage, the managed ~/.zshrc, the closing report — now runs from one definition in core/lib/bootstrap-lib.sh. This file declares what it is (BOOTSTRAP_ROLE=offensive, BOOTSTRAP_LOGIN_SHELL=0, and BOOTSTRAP_SU=lazy: the driver resolves no escalator and primes no keepalive, because only --install's Kali apt route needs them and install_offensive does both itself at the point of need) and keeps only what is offensive: the host-tool probe as bootstrap_check (which also runs the installer's own dry-run preview under --dry-run --install), the opt-in stack as bootstrap_provision (install_offensive unchanged), the pre-role-layer migration, the prefix + e popup script and the ~/ field references as bootstrap_wire_pre_loader, the engagement note plus the login-shell guard — now Core's blib_login_shell_hint, which Defense carried the same copy of — as bootstrap_closing, and --install / --no-check plus the two deprecation shims through bootstrap_flag. The --links-only + --install contradiction is refused in bootstrap_guard as before. 619 → 551 lines. One convention change: an unknown flag exits 2 (usage error), not 1. Same links, same routes, same exit codes otherwise; a plain run still never prompts for sudo.
- Featurebootstrap.sh --install runs on Core's escalation, sudo-keepalive and failure-tally helpers instead of bare sudo (dotgibson/dotfiles-core#973). The Kali apt route hard-coded sudo three times and primed it with a one-shot sudo -v whose own comment promised to "keep the timestamp warm" — one line before "go get coffee". It now resolves the escalator once with blib_resolve_su (root runs directly, else sudo, else doas, by absolute path), runs the three apt calls through blib_priv, and keeps the timestamp warm for real with blib_sudo_keepalive_start / _stop. Every best-effort install — the apt per-package fallback, each pipx install, each go install — now records a miss via blib_note_fail instead of an indented echo, blib_failures_report prints them together after the wiring, and a new --strict turns a non-empty tally into exit 1. Without it the exit code is unchanged: the offensive tools are optional; zsh is not. Closes this repo's rows in Core's audit-core.sh §5f ledger, including the keepalive row it had been exempt from on the theory that a role repo installs no long package sets — the apt route is that set.
- FixBREAKING — make core-sync no longer pulls; Core arrives by fan-out (dotfiles-core#676). scripts/sync-core.sh is now report-only: it says how far behind core/ is and how Core actually gets here, and writes nothing. This repo was the fleet's one sanctioned second writer into core/. That sanction rested on a specific property, spelled out in VENDORING.md: the pull stamped core.lock from *what it actually pulled* — core_sha from the squash commit's git-subtree-split trailer, core_version from the tree on disk — so the lock could not describe a commit its own core/ did not contain. A filtered vendor removes exactly that property. Core stopped vendoring its whole tree: core/ is now core.manifest ∪ core.vendor, roughly two thirds of it. A git subtree pull merges the whole upstream tree and has no way to apply that filter, so "what it actually pulled" is by construction no longer what a vendored core/ should contain. The first pull after this repo's lock moved to a filtering commit would land every upstream file against an expectation of the subset, and core-integrity would report TAMPERED — correctly, with no hand-edit anywhere. Teaching it to filter was the alternative and is worse: it would make this repo a second producer of Core's format, which the sanction never extended to. Two implementations of one filter is the failure dotfiles-core#556 exists to prevent. In practice this changes nothing about how Core actually arrives. Every one of the last ten core.lock writes in this repo came from the fan-out, not from make core-sync. The independent cadence being given up was already not in use. make core-sync and make core-lock survive as the place that explains this — they are what someone types when asking "how do I move Core?", and that question still has an answer. --check is accepted and ignored, since every run is now what it meant.
- Fixoffensive/companion/ is unaffected and still pulls. htpx vendors its whole tree and has no allowlist, so make companion-sync keeps working exactly as before. The asymmetry between the two subtrees is deliberate and is now stated in CONTRIBUTING.md, CLAUDE.md and the Makefile header — do not "fix" companion-sync to match.
- Configptunnel-ng added beside ptunnel, not instead of it. ptunnel-ng (utoni/ptunnel-ng, redirected from lnslbrty) is the maintained fork and kali's 1.43-2 *is* its latest upstream release, so it is the one to reach for. The original ptunnel line stays for one mechanical reason: the corpus entry entries/red/icmp-tunnel-c2.md invokes the bare ptunnel binary, and test/check-corpus-commands.sh — offline and required — resolves that name against the manifest, so dropping the line reds a blocking gate. ptunnel-ng cannot cover for it either: it ships only /usr/bin/ptunnel-ng, with no ptunnel binary and no alternatives symlink. Retargeting the entry is an upstream change in htpx — entries/ is a vendored subtree — and it is a rewrite, not a rename: the two are not flag-compatible, -lp/-da/-dp having become -l/-r/-R.
- ConfigThe covert-egress block now carries a status per tool. It read as though all four were current upstreams; not one is. iodine is a full release behind (kali 0.7.0-13 vs upstream v0.8.0); dnscat2 is frozen at its own last release (v0.07, 2016 — so apt is *not* behind, there is simply nothing newer); ptunnel is frozen at 0.72; icmpsh is dead (last push 2018, never released).
- Configsliver's currency note no longer hardcodes a patch count. "TWO patches behind" was accurate when written and wrong within five months. It now states the shape — apt froze at 1.7.1-0kali4 while upstream kept releasing — and points at sliver-server version instead of a number that rots.
- FeatureFreeze and archive statuses added where the file asserted none. PrintSpoofer (archived Sep 2024) was the last unmarked freeze in the target-dropped block; hashid is frozen at Jun 2022 while the line calls it "the cracking entrypoint"; the evasion payload-build paragraph carried no status for any of its five tools (macro_pack and ScareCrow archived, Donut alive, sRDI static since 2023). Each is kept — these target behaviours and formats that have not moved — with the reason stated.
- ConfigThe ConfuserEx pointer now names the mkaring fork. Canonical yck1509/ConfuserEx is archived and has not moved since 2019, so a bare "ConfuserEx" landed a reader in a dead repo — the failure the kwp-vs-iphelix/pack note already guards against elsewhere in the file.
- Configligolo-ng loses its "(upstream if repo build lags)" hedge. Kali tracks it closely (0.9.1-0kali1 within ~2 weeks of upstream v0.9.1), so the hedge invited a hand-built pivot that is almost never warranted.
- ConfigThe hexyl note's reason is narrower than it claimed. "There is no hexyl package in Kali at all" is false: the rust-hexyl source package, which builds a hexyl binary package, has been imported into and removed from kali-rolling repeatedly (0.4.0, 0.5.1, 0.7.0, 0.8.0, most recently 0.16.0-4), following Debian testing. It is out right now, so the conclusion — keep it out, it would hand check-packages.sh an unresolvable name — is unchanged, but it can return without warning.
- Configrusthound-ce's version pair replaced with its cadence. The quoted v2.4.91 -> v2.5.2 in seven weeks had itself gone stale (seven releases shipped in the eight weeks to Aug 2026). The mechanical reason it stays out of redup — cargo, no self-updater, go_fast_movers is go-only — is unchanged.
- Fixredup's ffuf/gobuster note now separates ownership from currency. "apt-packaged ones (gobuster/ffuf) update via up" implied apt keeps them current. apt *owns* them, so up is the only correct route and neither belongs in go_fast_movers — but kali's ffuf is 2.1.0 (Jan 2024) against an upstream that resumed releasing at 2.2.x. The routing claim stands; the currency implication does not.
- Configpackages.yml now emits ::warning:: annotations as well as its job summary. It stays advisory — Kali is rolling, and a package that vanishes mid-migration must not red an unrelated PR — but summary-only proved to be the same as silent: three unresolvable apt names sat in the manifest while the job reported them into a page nobody opened and exited 0 every week. Annotations surface on the Checks and Files tabs without changing any exit code. Its header also claimed you could make the job blocking by dropping a || true that does not exist in the file; the real lever is the exit 0 at the end of the resolve step.
- FixFive currency annotations corrected (#211). adaptixc2's said upstream publishes zero releases so there is "no tag to judge staleness by" and that it rolls on main — both false: v1.0/v1.1/v1.2 are tagged, main last moved 2026-03-04, and work is on version branches. That premise was load-bearing for the fingerprinted-default-profiles warning above it. sliver's apt lag is two patches, not "~a patch". caldera entered the Apache Incubator 2025-12-19, not May 2026. rusthound-ce's "collectors aren't daily-churn" reasoning is dead (v2.4.91 → v2.5.2 in seven weeks) — the conclusion survives for a mechanical reason instead: cargo, no self-updater, and redup's loop is go-only. And mitm6 finally gets the FROZEN note that kerbrute and havoc already carried.
- Perfhexyl's absence from the manifest is now recorded there. It is not a Kali package at all, so listing it would hand check-packages.sh an unresolvable name; the deferral to dotgibson/dotfiles-core#395 is written down instead, so the packages list and install/tools.lst agree.
- ConfigThree of the four field references now point at the corpus. exploitdev, ippsec and evasion listed their sibling references but omitted ~/companion (htpx), which hacktheplanet has always carried. evasion was the sharpest case: its "Network-filter & egress bypass (C2 channels)" fold is prose-only by design, and the six entries holding the actual commands (dns-tunnel-c2, icmp-tunnel-c2, domain-fronting-cdn, https-beacon-sliver, mtls-c2-sliver, web-service-c2-telegram) live only in the corpus — with no route to them from the doc that needed them most. Its footer is also restyled to match the other three (~/name + alias, not offensive/name).
- Configevasion opened with bare vim. It told you to run vim ~/evasion, bypassing the read-only opener that exists so an errant :w cannot publish engagement data — the one reference of the four that did. Now leads with evade, matching exploitdev.
- Confighacktheplanet gained the two commands the corpus had and it did not (#212). The coercion fold described "many vectors" but never showed the MS-DFSNM one, and the pivot fold described ligolo-ng in prose with no command line at all. Both are now present, so the header's claim that these entries are "covered better below" holds again.
- PerfThe last OS-layer file is gone, and the role wiring is Core's now. os/kali.conf carried the prefix + e engagement popup as *role* config living in an *OS* overlay ($CONFIG/tmux/os.conf), because Core had exactly one tmux overlay hook when it was written. Vendoring Core v4.13.1 brings the second hook, so the binding moves to offensive/offensive.conf → $CONFIG/tmux/role.conf, and os/ is deleted outright. Two consequences worth stating plainly: - role.conf is sourced last by Core's tmux.conf, after Core's own bindings. os.conf is sourced before them, so a future Core bind e could have silently taken the key back. That ordering is the actual reason the hook exists. - dotfiles-Debian and this repo no longer race for $CONFIG/tmux/os.conf. Until now whichever bootstrap ran last won it; the OS repo owns band 80 alone again. The battery and net-speed status probes did not move here — they are OS-native and dotfiles-Debian's os/debian.conf already carries them.
- Configbootstrap.sh calls blib_link_role_layer instead of hand-rolling three links. The block it replaces had already drifted from dotfiles-Defense's copy of the same wiring: Defense honoured BLIB_DRY when dropping the stale pre-v4 link and this repo did not, so --dry-run mutated the box here and not there. One shared definition ends that class of drift.
- ConfigTemplates moved to $CONFIG/offensive/templates (from $CONFIG/kali/templates) — named for the role rather than the distro, matching Defense's $CONFIG/defense/. The two shipped docs that quote the path by hand, offensive/hacktheplanet and offensive/ippsec, are updated in the same change. Core deliberately declined a compat symlink, since it would preserve a ~/.config/kali/ on a repo no longer called Kali.
- ConfigBootstrap now cleans up after the old wiring. A box bootstrapped before this change carries $CONFIG/tmux/os.conf and $CONFIG/kali/templates pointing into this checkout; both dangle afterwards. Each is removed only when it is a symlink resolving inside this repo, so a box also running dotfiles-Debian never has that repo's live os.conf touched, and --dry-run only reports.
- ConfigThis repo is now a pure Role layer. It used to be both the OS-native layer for Kali *and* the offensive role on top. dotfiles-Debian now covers the Debian family properly and accepts ID=kali as a first-class target, so the OS half moved there and what is left here is the role. Concretely: - Removed: os/kali.zsh, os/kali.gitconfig, install/packages.txt, install/tool-versions.env, scripts/update-tool-checksums.sh, wsl/, ssh/config. Every one of them has an equivalent in dotfiles-Debian, whose package list carries the Kali tier as # only:kali annotations. - bootstrap.sh is distro-agnostic and installs nothing by default. The ID=kali gate, the apt base install, the full-upgrade, the SHA-pinned verified_install block, the carapace .deb, the 1Password repo and the /etc/wsl.conf write are all gone — they belong to the OS-native layer. What replaces them is a report: a three-state host-tool probe (on $PATH / present-but-unreachable / missing), modelled on dotfiles-Defense. - --install is the new opt-in. On Kali it apt-installs install/offensive-packages.txt as before. On any other Debian-family box it installs a small portable subset via pipx (impacket, certipy-ad, netexec, bloodyAD, ldapdomaindump) and go (nuclei, gobuster, ffuf, kerbrute). On anything else it refuses and says why rather than guessing at a package manager. - --no-offensive and --no-upgrade are accepted but inert, with a note — the behaviour they asked for is now the default, so aborting on them would be worse than honouring them. - --links-only with --install is refused: one wires symlinks only, the other installs packages.
- Featureinstall/tools.lst is new — the host-tool probe list, and the one place it is written. Twin of dotfiles-Defense's. A command belongs there only if offensive/offensive.zsh probes or invokes it by bare name.
- Configoffsync replaces this repo's half of dotsync. dotsync came from os/kali.zsh and now belongs to the OS-native layer (band 80). offensive.zsh exports $DOTFILES_OFFENSE and binds offsync to it — a distinct verb, because reusing dotsync at band 85 would silently shadow the OS layer's.
- Configtest/check-packages.sh and make packages-check now check one manifest (install/offensive-packages.txt); make tool-checksums is gone with the pins.
- ConfigThe gating workflows (lint, bootstrap, companion, routine-filter) no longer use trigger-level path filters: a paths:-skipped workflow produces no check run, so requiring one would hang every non-matching PR.
- Configos/kali.gitconfig no longer duplicates Core's init.defaultBranch, and os/kali.zsh no longer duplicates Core's ~/.local/bin PATH prepend.
- Configoffensive/templates/engagement.md documents the layout mkengagement actually creates.
Known gaps
- Changepipx installs different binary names than Kali does. PyPI's impacket ships secretsdump.py, not Kali's impacket-secretsdump wrapper; certipy-ad ships certipy. offensive.zsh probes the Kali names, so those HAVE_* flags do not fire on a pipx box. The bootstrap's probe recognises both names, so the report is honest; teaching the shell layer to resolve both is a separate change.
- SecurityThe WSL Git-Credential-Manager note that lived in os/kali.gitconfig (how to point credential.helper at the Windows host's GCM) did not travel with the file. It belongs in dotfiles-Debian's git overlay now that that repo owns WSL.
Fixed
- Fixgithub_credential_backdoor now also selects personal_access_token.request_created (#348). This follows htpx's gh-cred-audit (htpx#139). GitHub logs the request only in orgs that require approval for fine-grained PATs. There it is the moment an attacker-minted token can still be denied, and a denied request never produces the access_granted the rule already selected. Neither PAT action had a fixture before. Each now has its own single-event TP row, because zircolite passes a TP file when any event in it matches, so a shared file would hide a typo in either action name.
- Securitynpm_publish_2fa_disabled and slack_2fa_enforcement_disabled are retagged from T1685 to T1556.006 (#346). Both fire when an MFA requirement is weakened, which is an authentication-process change rather than tampering with a security tool. Upstream htpx retagged the paired entries the same way in htpx#134, and okta_mfa_factor_reset already uses T1556.006 for this shape. The tactic stays defense-impairment, which T1556.006 carries in ATT&CK v19.2 and which matches upstream's TA0112. Detection logic and fixtures are unchanged.
- Securityhtpx.pin moves to htpx v3.3.0, and both DPAPI rules name dpapi-backupkey-4662 (#336). Upstream retired entries/blue/dpapi-backupkey-5145 in favour of dpapi-backupkey-4662, which keys the theft on the 4662 LSA secret read and keeps protected_storage 5145 as its secondary arm. The 5145 rule's references: URL was a live 404 on /blob/main/ while the gate, still reading the old pin, stayed green. Both rules now reference the 4662 entry: - dpapi_backupkey_5145: the MS-BKRP hunt. - dpapi_backupkey_secret_read_4662: the theft alert. So HTPX-COVERAGE.md lists the pair under the 4662 entry. The regenerated report also shows upstream's npm/slack 2FA retag to T1556.006. The two rules here still carry T1685, and whether to follow is a separate review call.
- FeatureDetection review 3, section C: the two findings that needed lab or estate data. - dpapi_backupkey_secret_read_4662 keeps SYSTEM in scope, on purpose. Running the extraction on the DC from a SYSTEM context (psexec to the DC, then mimikatz lsadump::backupkeys or SharpDPAPI backupkey) records S-1-5-18 as the subject, so excluding SYSTEM would hide common post-compromise tradecraft. A new TP covers that read. The rule says what to do if a lab capture shows BackuprKey servicing logging as SYSTEM: split it into a lower-level rule, don't exclude it. - The GPP User-preferences sweep threshold (10) is now DEPLOY-REQUIRED, with a README deploy-time row. The count is of distinct paths, and each path carries its GPO's GUID, so the threshold has to clear what one user reads across all their linked GPOs. The TP sweep now spans four GPOs, and a new logon TN reads six files from three.
- SecurityDetection review 3, sections A and B: defects in the review-2 rewrite, and older claims the rewrite carried forward. Each fix carries a fixture that the old rule gets wrong. - lsass_handle_access: - The lab Sysmon config still dropped MsMpEng.exe/MsSense.exe by bare file name (condition="image"). A dumper renamed to either never reached the rule, whatever its filter said. The exclude now uses Defender's install directories. - The "PROCESS_VM_READ bit" match covered only low nibbles 0/8/A, so 0x1411 got through. All 128 read-capable suffixes are now listed, plus 0x40 (PROCESS_DUP_HANDLE). - The description no longer claims this is SigmaHQ's list. - registry_run_key_suspicious_target_sysmon_13: the generic ~1\/~2\ entries matched PROGRA~1/PROGRA~2 (Program Files), which the rule deliberately leaves alone. They are replaced with \PROGRA~3\, \APPDAT~1\ and \LOCALS~1\. - wmi_event_subscription_consumer: - The Destination allowlist gated only one of three arms, so an SCCM cmd.exe /c consumer could not be exempted. It now covers every arm. - Consumer deletions no longer alert (Operation: Created). - pwsh is added. - suid_bit_set: the /usr/bin/yum and /usr/bin/dnf entries are removed. Both tools run under Python, so the entries never matched. Review 2 removed the same entries from the cron and systemd rules and missed this file. - vault_approle_backdoor: secret-id lookup and destroy are routine CI housekeeping, and they alerted even for the allowlisted orchestrator. They are now excluded by exact suffix (/secret-id/lookup, /secret-id-accessor/destroy, ...). A contains: '/secret-id/' would also have hidden a mint written with a trailing slash (.../secret-id/, which Vault routes as a mint) and every role write on a mount named secret-id. Both lists are pinned to the AppRole mount (auth/approle/role/, DEPLOY-REQUIRED if yours differs). A bare suffix also exempted vault auth enable -path=x/secret-id/lookup, a /role/ substring exempted an LDAP group named role/secret-id/lookup, and the orchestrator's mint exemption covered creating a role named secret-id. - snowflake_data_unload: each allowlisted stage now takes two entries, @STAGE/ and @STAGE followed by a space (a root unload). A bare @MY_SANCTIONED_STAGE had also exempted @MY_SANCTIONED_STAGE_EVIL. The rule now says to write stages fully qualified, since an unqualified name resolves in the caller's own schema, and its example entries are qualified. - scheduled_task_suspicious_4698: it now matches every prefix of -EncodedCommand behind one or two of the switch characters PowerShell accepts (-, /, en dash, em dash, horizontal bar), with quotes or cmd carets allowed before and inside the flag (-"e"c, -e^c, ^-e), then a space or tab, then a payload: 20 base64 characters that may be split by quotes or carets, a quoted payload with spaces inside, or a variable (%p%, !p!, $p); behind / only for /ec and longer, since robocopy's /E %OPTS% is common. It also covers pwsh tasks. Quotes and carets are not accepted before the dash: they already count as a boundary, and allowing both made the pattern quadratic on a long run of quotes. Requiring the payload keeps -Encoding utf8, URLs, robocopy's /E and a script's own short -e prod argument out; a long base64-looking -e value still matches. Elastic needs the rewritten regex given in the rule. - Splunk: two rules could never run. When a regex sits inside an OR, the Splunk backend emits a search with no base (search = | rex ...), which Splunk rejects. That killed service_creation_psexec_7045 (since #337) and the new 4698 regex. gen-siem.sh now lifts the final search's source=... EventCode=N into the base. It fails the build instead of guessing when the shape differs: anything other than | rex/| eval before the final search, an EventCode that is an OR operand (lifting it would narrow the rule), or an empty or pipe-first search = it does not recognise. - spoolss_pipe_impersonation_sysmon_17: - The description no longer names GodPotato, which stands up an epmapper pipe. The sibling rule already says so. - A TP with PrintSpoofer's real nested pipe name (\<guid>\pipe\spoolss) is added. - unconstrained_delegation_4624: - The description no longer claims the "machine account" variant. When that account's host is krbrelayx on Linux, no Windows host writes a 4624. - The DC filter uses exact FQDNs. startswith: 'DC1' exempted DC10. - systemd_unit_persistence: the documented auditd watches now include /lib/systemd/system/ (split-/usr) and the global user-unit directories, each on a line that pastes straight into an audit rules file.
- SecurityDetection review 2, section E: pairing and methodology bookkeeping that misstated coverage. No detection logic changes. - HTPX-COVERAGE.md under-claimed three pairs that have detections. It listed unconstrained-deleg-4624, cryptomine-pool-detect and dga-nxdomain-entropy under "nothing here claims". Each detection named its pair in prose the claim regex could not parse: "PURPLE-TEAM … row", "companion pair", or "htpx pairs (". Each now carries the htpx blue-entry URL. - dns-tunnel-sysmon-22 stays unclaimed on purpose. Its source is Sysmon DnsQuery alone, which the lab config does not collect, and dns-c2.zeek is its wire twin rather than an implementation of it. The script now says so. - DEFENSE-METHODOLOGY.md table rows no longer name planes that have nothing in them. - Lateral Movement claimed "Zeek SMB", but no such script exists. - Recon claimed Zeek, but the only Zeek Kerberos script is credential access, so it moved to that row. - The Responder and LOLBAS validation cells pointed at folds with no detection behind them. They now say "no detection yet". - Sources are corrected to the events the rules actually read. - An Initial Access paragraph had drifted. It sat after the Sysmon-13 RegistryEvent paragraph, so "the *registry* plane … Both rules" read as the two registry rules. It is back under the Initial Access paragraph it explains. - The Entra count is split out. "Six pairs of Entra/M365 identity coverage sat in detections/sigma/cloud/" now says three are Sigma rules there and three are Sentinel joins.
- SecurityDetection review 2, section B: rules that could not see the attack they claim. From the 2026-09-28 follow-up review. Each fix was checked against the tool's own source where one exists, and each carries a fixture the old rule gets wrong. Host validation is 120/120 with 92 true negatives (was 114/114 with 84); cloud stays 44/44. - Kerberoasting was RC4-only. GetUserSPNs, nxc and Rubeus do not force RC4, so an AES-only service account yielded a 0x11/0x12 ticket that kerberoasting_rc4_tgs never saw. New kerberoast_spn_burst_4769 counts distinct user SPNs per source address, 5 in 10 minutes, in any encryption type. The RC4 rule stays as the single-event fast path, and its description no longer says the tools force RC4. - DPAPI backup-key theft was watched on the wrong channel. SharpDPAPI backupkey, dpapi.py backupkeys and mimikatz read the G$BCKUPKEY_* LSA secret over LSARPC (verified in SharpDPAPI lib/Backup.cs). New dpapi_backupkey_secret_read_4662 covers the DC-side 4662 SecretObject read (T1003.004). dpapi_backupkey_5145 is demoted to a low-severity MS-BKRP hunt and says what protected_storage actually carries. - scheduled_task_suspicious_4698 missed impacket atexec. atexec's cmd.exe /C … > %windir%\Temp\<x>.tmp 2>&1 is now matched (checked against atexec.py), along with PowerShell's -e/-ec/-w 1/-win h and a path-less mshta. The atsvc pipe rule's claim that the 4698 rule catches atexec is now true, and it notes the -silentcommand exception. - Allowlists keyed on a name the attacker can take: - spoolss_pipe_impersonation_sysmon_17 now requires the exact C:\Windows\System32\spoolsv.exe; PrintSpoofer renamed to spoolsv.exe had been filtered. - cron_persistence, systemd_unit_persistence and ssh_authorized_keys_write now use exact exe paths instead of basename endswith (/tmp/dpkg). - Their dnf, yum, cloud-init, ansible, salt, puppet and chef entries are removed. Those tools run under a Python or Ruby interpreter, so auditd records the interpreter as exe and the entries never matched. The same applies in suid_bit_set. - lsass_handle_access now matches on the PROCESS_VM_READ bit (SigmaHQ's suffix list) instead of five exact masks. 0x1f0fff and 0x410 got through before. - k8s_pod_exec_attach: - It matched create only. WebSocket exec from kubectl 1.30+ is audited as get, so both verbs are now matched. This is an assumption to confirm against a real apiserver. - It dropped every service account, including a token stolen from a pod. It now allowlists named controllers only. - vault_approle_backdoor: - It covered only role/ paths. It now also covers users/, groups/, certs/ and map/: vault write auth/userpass/users/backdoor policies=root did not fire, and the description's cert claim did not match. - It fired on every CI secret-id mint. Those are now exempt only for allowlisted orchestrator entities. - rdp_hijack_tscon_4688 now also fires on tscon <id> run as SYSTEM with no /dest, the classic hijack. That arm is Sysmon-only, and the rule says what a 4688 deployment substitutes. - wmi_event_subscription_consumer alerted on a Command Line consumer only when it named an interpreter. A consumer pointing at a dropped binary now alerts, less a Destination allowlist. - registry_run_key_suspicious_target_sysmon_13 now also matches %APPDATA%-style unexpanded values and 8.3 short names. - New service_binary_windows_root_7045 (medium) covers a service binary directly in %SystemRoot%. It catches psexec -r <name> and impacket -service-name, which beat the PsExec rule's name-keyed branches. It is a separate rule with a vendor allowlist, not a branch of the high PsExec rule, because the PsExec rule's own TN is a vendor binary in exactly that place. - gpp_cpassword_sysvol_5145: - Users read their GPOs' User\Preferences at every logon, so a single read of those was noise. The single-event alert is now scoped to Machine\Preferences. - User preference files move to a new per-user distinct-file fan-out correlation (10 in 10 minutes). - Drives.xml, one of the six cpassword-bearing files, is added. - detections/README.md: the rule, deploy-time and backend-work rows are updated, and the header count is now 124 rules / 148 documents.
- SecurityDetection review, section C (8-13): six rules that missed the attacker's variant, one mislabelled technique, and a new Kerberos enumeration rule. Each fix carries a fixture that the old rule gets wrong. Host validation is 114/114 with 83 true negatives (was 80); cloud validation stays 44/44 with more lines per fixture. - wmiexec_wmiprvse_child_4688: it required the ADMIN$ share, but impacket takes the share from -share, so -share C$ evaded it. NetExec, which the rule names, never used the loopback share at all: it redirects to \Windows\Temp\<6> or, fileless, to \\<attacker-ip>\<share>\. The rule now matches the redirect shape both tools build (/Q, /c, 1> , 2>&1) wherever it points, and gains its first TN (a WmiPrvSE-run script with no redirect). -nooutput leaves no redirect and is recorded as a gap, because a bare WmiPrvSE → cmd /Q /c is too generic to alert on. - passthehash_4624_fanout: machine accounts ($) and ANONYMOUS LOGON fan out across hosts as routine traffic and could trip the correlation on their own. The base now drops both; Kerberos stays in scope. A new TN of six WS07$ logons fires the old rule and not the new one. - asrep_roast_probing_4771 is a Kerberos password spray, not AS-REP roasting. 4771 0x18 is a bad password at pre-authentication. It is retagged T1110.003 (credential-access) and retitled; filename and ids are kept. T1558.004 stays covered by asrep_roast_4768. - New kerberos_user_enum_4768 takes over the enumeration claim the 4771 rule used to make: 4768 status 0x6 (unknown principal), distinct names per IpAddress, 10 in 10 minutes (kerbrute userenum, GetNPUsers with a user list). Tagged T1087.002 (discovery), with TP, TN and outside-window fixtures. - k8s_clusteradmin_binding: a binding's roleRef is immutable, so the realistic escalation is a patch/update adding a subject to the existing cluster-admin binding, and that patch carries no roleRef. A new arm matches it. - k8s_privileged_pod_created: a privileged initContainer, or a privileged ephemeral container attached through the ephemeralcontainers subresource, got past it. It now has arms for both, and the array-traversal DEPLOY-REQUIRED note names the new arrays. - vault_bulk_secret_read: it grouped by auth.entity_id, which Vault omits for root and orphan tokens, so a leaked root token's sweep was never counted. It now groups by auth.accessor, while the allowlist stays on the stable entity id. The secret/ prefix is only the dev-server default, so it is now a DEPLOY-REQUIRED list of KV mounts. The group-by change is proven by the compiled Splunk form (by auth.accessor). The cloud plane cannot run correlations. - snowflake_data_unload: the internal-stage filter was contains '@~'/'@%', so a stage reference in a SQL comment (COPY INTO 's3://…' … /* @~ */) suppressed an external unload. It is now anchored with startswith 'COPY INTO @~'/'COPY INTO @%', which fails toward alerting. A regex was avoided because the cloud evaluator does not run |re and Elastic would need a rewrite. COPY INTO @~ followed by GET is noted in the rule as a future correlation. The DEPLOY-REQUIRED filter_known_stages had the same contains bypass (/* MY_SANCTIONED_STAGE */) and is anchored the same way. - detections/README.md: rows updated, and the stale sigma/ header count is corrected to 121 rules / 142 documents.
- FixDetection review, section C (1-7): seven rules with a dead term, an easy evasion, or an allowlist keyed on a name the attacker picks. Each fix carries a fixture that the old rule gets wrong: it misses the new TP or fires on the new TN. Host validation is 111/111 with 80 true negatives (was 77). - potato_seimpersonate_4688: 4688 renders NETWORK SERVICE as the computer account and an app pool as its bare pool name, so the NETWORK SERVICE term never matched and APPPOOL caught only pools with the word in their name. It now keys on SIDs (S-1-5-82-*, S-1-5-19, S-1-5-20) with name fallbacks. The fixture's IIS APPPOOL\DefaultAppPool SubjectUserName was not what Windows writes; it is now corrected, and a SYSTEM TN was added. - recovery_inhibition_process (test): it adds vssadmin resize shadowstorage, PowerShell Win32_ShadowCopy removal, and wbadmin delete systemstatebackup/backup, and recoveryenabled no longer requires the literal no. The old rule caught 1 of the 6 forms; the new rule catches all 6, and read-only near-misses stay silent. - suid_bit_set: /usr/bin/install (install -m 4755 /bin/bash /tmp/.x) was allowlisted, and every entry was a basename suffix, so /tmp/dpkg passed too. The allowlist is now full paths. - shadow_file_read, ssh_private_key_read: the allowlists keyed on comm, which a renamed copy or prctl sets. They now key on full exe paths, and cron is added to the shadow allowlist. The SSH rule no longer fires on *.pub. - lsass_handle_access, mass_file_read_4663: a dumper or collector renamed to MsMpEng.exe / SearchIndexer.exe anywhere on disk was filtered. They now use full paths, with Defender matched by its protected Platform directory. - data_destruction_wipe: diskpart never carries clean on its own command line. The branch now matches the shell that pipes it in; diskpart /s is deliberately left unmatched. - archive_staging_utility: it fired at high on extraction (7z x -p…, rar x -p-, tar -xf … -C %TEMP%). It now requires a create verb per archiver. Sigma's case-insensitive ' -c' also matched tar's -C <dir>, so tar uses its named create clusters. The Splunk precedence sign-off was re-traced for the new condition.
- PerfDetection review, section B: two unread telemetry planes, an AD CS row that over-claimed, and a PsExec rule that missed both PsExecs. - Sysmon registry events had no reader. The lab config collects Run keys and WDigest UseLogonCredential (Sysmon 12/13), and the methodology lists Sysmon 13 as a source, but no rule read either, and T1547.001 / T1112 were in neither the coverage nor the known-absent ledger. New: wdigest_uselogoncredential_enabled_sysmon_13 (value set to 1, no filter) and registry_run_key_suspicious_target_sysmon_13 (a Run/RunOnce value launching from a user-writable path or through a script host, with a DEPLOY-REQUIRED per-user-app allowlist). Its Splunk form is recorded in the precedence allowlist. - New dcsync_non_dc_machine_account_4662 covers DCSync by a machine account that is not a DC. dcsync_replication_4662 drops every $ subject by design, so this is the replication-rights ACL backdoor on a computer object, or a MachineAccountQuota account used for DCSync. experimental, with a DEPLOY-REQUIRED DC list. It does not see DCSync as a real DC's account from an attacker host (the ESC8 tail): 4662 carries no source address. That is now recorded as an open gap in DEFENSE-METHODOLOGY.md rather than implied as covered. - The AD CS methodology row claimed a "4886 SAN" check on the siem plane. Nothing performs one: adcs_esc1_san_mismatch_4886 surfaces every request for analyst triage, and its description now says the filename names that question, not a comparison it makes. The row now reads what exists (sigma, siem: 5145 pipes, 4886/4887 requests with SAN compared at triage, 4624 relay mismatch). - service_creation_psexec_7045 described impacket-psexec and PsExec but matched only smbexec. It now adds Sysinternals PSEXESVC and impacket-psexec's %systemroot%\<8 letters>.exe behind a 4-letter service name (shapes taken from impacket's serviceinstall.py). Its fixture had paired PSEXESVC with a %COMSPEC% path, which no real PsExec does; it now carries one event per shape plus a near-miss TN. The old rule fires on one of the three. This is the corpus's first |re rule; Lucene cannot take the anchored, (?i) pattern, so the Elastic form carries a DEPLOY-REQUIRED rewrite and a README backend-work row.
- FixDetection review, section A: thirteen rules that could not fire (wholly or in one arm), or whose allowlist could never suppress, because rule and fixture shared a guess about the vendor's log. Every corrected name below was checked against a primary source (vendor docs or source code), and each fixture was rebuilt in that shape. Run against the new fixtures, the old rules fail ten validation cases; the new ones pass 44/44 cloud and 108/108 host. - GitHub: github_self_hosted_runner_registered keyed on self_hosted_runner.created, which GitHub does not emit; it now matches *.register_self_hosted_runner and *.configure_self_hosted_jit_runner. github_credential_backdoor's deploy-key arm used repo.create_deploy_key; GitHub logs a deploy key as public_key.create (github/docs audit-log data). - Slack: slack_external_shared_channel keyed on two action names Slack does not have; it now uses external_shared_channel_invite_created / _accepted / _approved / external_shared_channel_connected. - Harbor: a push is audited as operation: create (there is no push operation), and the actor is username, not operator. So harbor_image_pushed_trusted_tag never fired, and none of the three Harbor allowlists suppressed anything. The trusted-tag rule now also catches a tag re-pointed at an existing digest (create on tag). - PyPI: the collaborator rule missed the invite / accepted path (the takeover path) and now names project-creation's add Owner as a false positive. The token-upload rule's filter keyed on a publisher_type column that does not exist; it now reads project:release:add with additional.uploaded_via_trusted_publisher, so a trusted-publisher upload is finally suppressed. The trusted-publisher rule looked for a journal entry Warehouse never writes; it now keys on the project:oidc:publisher-added event. - Google Workspace: gws_admin_role_grant missed GRANT_ADMIN_PRIVILEGE, the actual super-admin grant, and mislabelled GRANT_DELEGATED_ADMIN_PRIVILEGES as one. - GCP: gcp_iam_policy_backdoor required a leading-dot, exact-case .SetIamPolicy, missing Resource Manager's …projects.setIamPolicy, bucket storage.setIamPermissions and the SetIAMPolicy spelling. Each casing is now listed. - AD: ldap_recon_property_reads_4662 used legacyExchangeDN's GUID for servicePrincipalName (now f3a64788-…, MS-ADA3), so its Kerberoast half never fired. ldap_recon_search_filter_1644 matched the bitwise-OID form a DC never logs (1644 rewrites it to userAccountControl&<bits>) and trustedForDelegation, which is a PowerShell pseudo-property rather than an LDAP attribute. - Jenkins: the Audit Trail plugin's default pattern logs neither /script nor generateNewToken. Both rules now carry a DEPLOY-REQUIRED note and a README deploy-time row, and jenkins_script_console drops to experimental. npm_publish_2fa_disabled (#149) and snowflake_network_policy_change also drop to experimental while their fields are unconfirmed. 17 fixtures move to vendor-documented, each citing its source.
- FixThe host validation gate now tests correlation timespan, and runs the current SQL backend. #333 pinned pysigma-backend-sqlite to 1.2.4 because 2.0.0 windows each correlation on the event time and the fixtures had none. The 34 correlation fixtures now carry Event.System.TimeCreated (one second apart, inside the shortest 5m window), and each of the 16 correlation rules gains an -outside-window case: the same TP events re-timed so no window holds the threshold (a burst that is simply too slow). Those 16 cases fail on 1.2.4, which ignores the window, and pass on 2.0.0, so the pin moves to ==2.0.0 rather than floating: the backend version decides whether half of every correlation rule is tested. 108/108 on a fresh install.
- FixThe host validation plane went red overnight with nothing in the repo changed: every correlation rule stopped firing. zircolite 3.7.6 floats pysigma-backend-sqlite>=0.1.1, and 2.0.0 (2026-09-26) started enforcing correlation timespan on a timestamp column that the synthetic fixtures do not carry, where 1.2.x compiled a bare GROUP BY/HAVING and ignored the window. All 20 value_count/event_count cases went silent (72/92), bisected to that one package. zircolite 4.1.0, which requires sqlite >=2.0.0, fails the same 20, so the fix is a pin, not an engine bump: zircolite-constraints.txt now holds pysigma-backend-sqlite==1.2.4 (92/92), reversing its old "backends stay zircolite's business" line with the reason. Lifting the pin means timestamp-aware fixtures, so the gate exercises the windows it currently never tested.
- SecurityWeekly detection review (#324): one rule contradicted its own description, five scoping fixes, two status corrections. The review found no coverage holes and no unpaired red attacks. Each change below that has a new near-miss fixture was checked against the pre-change rule first, so the fixtures are regression tests, not assertions. - ccache_theft_staging claimed tool-name independence it did not have. Its description said it keyed on the handling process "not a tool name", but the condition ANDed an Image|endswith list of 25 tools. A copied or renamed cp, or any static exfil binary not on the list, walked past it. The ccache path in argv is now the whole signal. The only exclusion is filter_krb5_clients, the six krb5 utilities that name a ccache legitimately with -c. The description no longer says those never do. The trade is stated in the rule: a binary renamed to klist evades an Image filter, which is one attacker-chosen name out of six where the old gate let through every name not among 25. The renamed-binary TP did not fire on the old rule. - jenkins_script_console paged on /scriptApproval. uri|contains: '/script' also matched the In-Process Script Approval page, which is routine admin activity, and it fired at high. That page is now excluded with filter_script_approval, not by anchoring with endswith: nothing pins whether uri holds a parsed path or the Audit Trail line it came from, and an anchored match silently dies on the second shape. The redundant /scriptText entry is gone, since /script is its prefix. - service_stop_burst had nowhere to put the allowlist its prose asked for. The base event gains filter_maintenance_parent (DEPLOY-REQUIRED), with a comment saying never to list a deployment agent (ccmexec, PSEXESVC, gpscript, RMM) or a host, because that is how the real teardown gets pushed. Its TN is six distinct stops from the allowlisted parent, past the gte: 5 threshold. The generated Splunk search mixes OR and NOT the same way the BitLocker rule's does, and is recorded in the precedence allowlist with the same reading. - ntds_dump_ntdsutil_vss_4688: selection_shadow put the binary name inside its CommandLine tokens ('vssadmin create shadow'), so C:\Windows\System32\vssadmin.exe create shadow did not match. It now uses the verbs alone, with a new regression fixture. The bare-Image selection_diskshadow branch is kept on purpose and now says why: diskshadow runs from a .dsh script, so a verb gate would miss the real attack. - wmi_event_subscription_consumer: the bare -enc keyword also matched -encoding. It is now anchored as '-enc ', '-ec ' and -EncodedCommand. - entra_sp_credential_backdoor gains filter_app_automation (DEPLOY-REQUIRED), keyed on the rotation principal's object id via InitiatedBy|contains. That is the entra_directory_role_grant shape, for the same reasons. Its own top false positive is CI/CD secret rotation, and it was the one credential-backdoor rule with neither a filter nor a reason for lacking one. The hand-written Sentinel form is unchanged, matching its entra_illicit_consent_grant sibling, which also leaves the allowlist to the Sigma rule. - asrep_roast_4768 and kerberoasting_rc4_tgs are now status: test. Both are invariant tripwires with committed TP and TN fixtures and no placeholder, which is what test means here.
- Featuredetections/README.md's tables had drifted from the corpus (#314). Adds the ten missing substitution rows — aws_snapshot_share_external, azure_vm_run_command, azure_keyvault_bulk_secret_read, both remaining gcp_* rules, host_enum_srvsvc_wkssvc_5145, local_group_enum_sweep_4798_4799, gws_illicit_oauth_grant, k8s_pod_exec_attach, harbor_image_pushed_trusted_tag — and the three missing rule-table rows: gcp_iam_policy_backdoor, gcp_audit_log_sink_deleted, asrep_roast_4768. The last of those is easy to miss because a differently-named sibling is listed (asrep_roast_probing_4771, the correlation); they are separate rules. Two of the twelve markers were not substitutions at all, which is why they had no home and is recorded as its own finding rather than papered over. k8s_privileged_pod_created's marker is about how your backend expands JSON arrays; slack_2fa_enforcement_disabled's is about writing a collector mapping so the rule can tell an enable from a disable. Neither has a placeholder to replace, so neither fits a table headed *Substitutions* with Substitute / Until you do columns. They get a second table — Deploy-time backend work — rather than a marker of their own, because deploy-required.sh is the one checklist an operator runs before deploying and both belong on it: skip either and the rule ships subtly wrong rather than merely noisy, which is the harder failure to notice.
- Perfservice_stop_protected_services matched its keywords anywhere on the command line, and Set-Service did not require the disable (#307). Two matching defects underneath the level: high the #302 review raised; the level itself is defensible and is unchanged. protected_services is ANDed with a stop/disable verb but matched against the WHOLE line, so the short keywords collided with path and flag arguments: sc stop Spooler >> C:\Backup\logs\svc.txt is a Spooler stop that paged as a backup-agent teardown, and the analyst could not tell from the alert why it fired. That is the kind of false positive that erodes trust faster than volume does — and the rule's own advice to *extend* the list made it worse rather than better, which is why the advice now says how. Keywords are now split by whether they can collide. A distinctive product or service name goes in bare (veeam, msexchange, backupexec, windefend, mssql, sqlserveragent, swprv, …); a short or generic token is guarded by the character that precedes a service name — a space or an opening quote, where a path component is preceded by a backslash (' backup' / '"backup', ' vss', ' sql', ' sentinel', ' defend', ' sense'). The guard idiom is the one service_stop_burst already uses for taskkill's '/f '. Set-Service moves into its own selection requiring Disabled alongside it, because Set-Service is equally how you start a service, rename it, or edit its description — Set-Service -Name MSSQLSERVER -Description "nightly maintenance window" used to page at high for a text edit. Matched on Disabled rather than -StartupType Disabled so the colon form and PowerShell's parameter abbreviations still match. This makes the arm consistent with its own net/sc sibling, which already insists on start= disabled rather than firing on any sc config. The rule gains its first true-negative fixture, carrying one line per defect — and both lines were checked against the pre-change rule and did fire there, so it is a regression test rather than an assertion. Eighteen realistic teardown forms were checked the other way for lost coverage: none, and sc stop swprv and sc stop Sense are newly caught, because naming the real service short names covers cases the generic tokens missed. No filter_* block: the defect is positional, and a filter can only exclude named benign shapes, not express "the keyword is in the service-name slot". It also keeps a NOT away from the rule's existing top-level OR, so the Splunk precedence allowlist is untouched. The third item this issue was filed with — that ' stop ' needs surrounding spaces and so misses sc stop svc — was retracted on the issue before any work started, and is correct as written.
- Securityvault_secret_read had no allowlist, while its own prose told you to use one (#306). The correlation's description said "batch jobs that read many secrets should be allowlisted" and its falsepositives said "allowlist the entity" — and there was no filter_* block on either document to allowlist it in. Both cloud twins ship that list on the base rule for exactly this reason (aws_s3_bulk_exfil → filter_bulk_readers, azure_keyvault_bulk_secret_read → filter_secret_automation), and the Azure rule names Vault as its model twice while offering the remedy Vault could not. A deploy identity hydrating config reads 20+ distinct secret/ paths in ten minutes and clears the gte: 20 threshold on its own, so the rule fired on routine automation with nowhere to send it — the S3 rule's stated failure mode, where someone mutes it and the mute is the blind spot. Adds filter_secret_automation to the base document, keyed on auth.entity_id: the field the correlation already groups by, so allowlist and aggregation agree by construction, and the only stable Vault identity — the entity id survives token renewal, re-auth and rename, where auth.display_name is a mutable label and auth.accessor changes with every token. The filter sits on the base event rather than the threshold because raising the threshold to clear your largest batch job blunts the rule for every other identity. Both fixtures gain the field so the exclusion is actually proven: the TN's first line is a secret/ read by the allowlisted entity, and swapping that id back to the TP's makes the line fire, so the filter is demonstrably the only thing silencing it.
- Fixentra_directory_role_grant allowlisted a display name (#306). filter_iga keyed on Identity, the AuditLogs actor display-name column — mutable and non-unique, so the allowlist stops matching the day IGA renames the service principal. It fails *open* here, which means noise rather than a miss, and that is the direction that ends in a mute. Its two Azure siblings go out of their way to warn against precisely this (azure_keyvault_bulk_secret_read: "The value is an object id (oid), NOT a name"; azure_vm_run_command: a name-shaped entry for a service principal "matches nothing and leaves the rule as noisy as an empty allowlist, while looking filled in"); this was the one rule in the set that had not had that treatment. Now matches the actor object id with InitiatedBy|contains. The path is not the same for every actor — InitiatedBy.user.id for a human, InitiatedBy.app.servicePrincipalId for an app, never both, which is why this repo's own hand-written Sentinel queries coalesce the two arms. Sigma cannot coalesce, so a single dotted key would silently cover only one kind of actor, and automation — the case the filter exists for — is the kind it would miss. Matching the column covers both, and keeps the field flat, which is what lets the rule stay in the EVTX validation plane instead of being rewritten into the cloud one. The comment now also warns against InitiatedBy.app.appId: that is the *application*, so allowlisting it exempts every principal using it. The fixtures carry one arm each — user on the TP, app on the TN — and the TN's appId is deliberately a different guid from its servicePrincipalId so the pair shows the two are not interchangeable. Finding 3 of #302 (okta_mfa_factor_reset missing a filter_helpdesk scaffold) stays declined: every Okta sibling is filter-free, as is the cross-platform twin gws_admin_role_grant, so the absence is the SaaS-audit convention rather than an outlier.
- FixDEFENSE-METHODOLOGY.md claimed GCP had reached its resource plane. It had not (#305). The plane-axis passage read "AWS and GCP both reached their resource planes" — written while the Azure gap was being closed, with GCP asserted rather than checked. The error was load-bearing rather than cosmetic: a ledger saying a plane is covered is a reason for the weekly /coverage-gap sweep not to look there, so the false sentence hid the gap it described, which is why correcting it is listed as the prerequisite on the issue and would have been worth doing even with no rule attached. CHANGELOG.md's own #280 entry carries the same claim; that entry is left as the historical record it is, and this line is the correction. T1530 on GCS is declined in the same pass, with its reopen condition: storage.objects.get is Data Access telemetry, off by default, so a GCS twin of aws_s3_bulk_exfil would be inert on most estates — the argument already used to decline T1526/T1580/T1069.003. It carries no known-absent marker id, and both the methodology and detections/README.md now say why: the marker gate reads the corpus, not the corpus per provider, and aws_s3_bulk_exfil already covers T1530, so listing the id would fail the build in the other direction. A decline scoped to a provider rather than a technique can only live in prose.
- Perfhtpx pin bumped to 7f369ee37ee5 (3.2.0+3), the first pin on an untagged commit (#297). Upstream's four new entries are two GCP pairs — gcp-gce-startup-script-exec ↔ gcp-gce-metadata-audit (T1651, Compute Admin Activity) and gcp-gcs-mass-exfil ↔ gcp-gcs-exfil-audit (T1530, GCS Data Access) — and no release tag has reached them, so version/tag now record 3.2.0+3 and say "none" in words rather than naming a ref that does not exist. The pin file documents that shape for the next time. Nothing was renamed or retired upstream, so every claim still resolves and the gate was green either side: 103 reference URLs, 99 blue entries named, back-refs intact. The report diff is the whole content of the bump — 105 blue entries to 107, with the 99 claimed here unchanged and both new entries landing in "htpx blue entries nothing here claims". That GCP resource-plane gap is recorded, not accepted by default: filed separately so this bump's diff stays the pin and the generated report, the same split #277/#280 used for Azure.
- Configsystemd_unit_persistence allowlisted the binary an attacker writes units with (#302). filter_provisioning carried /systemd and /systemctl by exe|endswith under a comment claiming it was the "same list as cron_persistence, same reason" — and cron's list has neither. The claim was false and the two entries were an evasion: systemctl edit --force --full evil.service and systemctl link /tmp/evil.service both attribute the unit-file write to /systemctl, so the rule was silent on two first-class variants of the T1543.002 it exists to catch. Neither entry was justified anywhere — #210 folded them into the package-manager list without a measured reason. Dropping them outright would have reintroduced the systemctl enable symlink noise they were presumably there for, so the suppression is now scoped by path instead of by binary: filter_systemctl_wants matches only the <target>.wants/ symlink farm that carries the volume, and filter_systemd_runtime only PID 1 writing under /run. A unit written at the top level of a watched directory alerts again, whoever wrote it. filter_provisioning is now cron's eleven entries exactly, so the comment is true. Proved rather than asserted: the two new manifest rows (linux-systemd-persist-systemctl-edit, linux-systemd-persist-runtime-generated) fail on the pre-change rule and pass on this one, and each carries the near-miss true negative showing the narrowed suppression still suppresses. The transient-unit path (systemd-run, StartTransientUnit) is written by PID 1 under /run and is therefore still suppressed — recorded in the rule as the accepted cost of not muting it on every reboot's generator output.
- Featurecore-verify asks the integrity question again, and core-check gets the freshness one back (dotgibson/dotfiles-core#691). Adopting the fleet vocabulary pointed the canonical core-verify at this repo's upstream-tag query and demoted core-check to an alias of it. Those are two different questions: freshness is *is there a NEWER Core upstream?*, integrity is *is THIS core/ the tree core.lock pins?* — and Core's scripts/make-vocabulary.txt defines the canonical verb as the second. So the register read green on a target answering something else, while this repo still had no local integrity check at all: core-integrity.yml ran one in CI and nothing ran one here. core-check is a real target again, with help text naming its question, and core-verify delegates to Core's own scripts/core-integrity.sh from a CORE_REPO checkout — the same invocation CI uses, and the only implementation that knows how the fan-out filters the vendored subtree. It probes with -f plus bash … rather than -x, so a checkout that lost the exec bit does not fail it spuriously. Verified both ways against a sibling clone at core v6.1.0: core-verify reports pristine, core-check reports current.
- FixThe SUID tripwire tested the wrong argument for the one chmod that matters, and the history rule watched a syscall that could never be recorded. Both are documented auditd rules rather than Sigma logic, which is why neither fixture nor gate could see them. suid_bit_set shipped -S chmod,fchmod,fchmodat -F a1&04000, but the mode is a1 only for chmod(path, mode) and fchmod(fd, mode); fchmodat(dirfd, path, mode, flags) carries it in a2, so for every fchmodat the kernel ANDed a pathname pointer against 04000 and recorded or dropped the event depending on where the string happened to sit in memory. That is not a corner case: fchmodat is what coreutils and glibc's -at paths call, and it is the only path-based chmod that exists on arm64, which has no chmod syscall at all — so on an arm64 host the whole rule rested on the broken predicate. Now split by argument position, four lines instead of two, with the arm64 load failure and fchmodat2 both written down. history_clearing had the twin defect one layer along: it listed ftruncate, which takes a descriptor and emits no PATH record, so under -F dir= it was never recorded — dead in the list rather than merely noisy — while : > ~/.bash_history, the canonical clear, is an open with O_TRUNC and was not in the list at all. Replaced with open/openat O_TRUNC lines (flags in a1 and a2 respectively, the same split), and coreutils truncate -s0 is now named as genuinely unreached from the file plane rather than silently missed. Neither rule's Sigma changed; both descriptions now predict what a lab run must show, including the truncate -s0 non-result.
- FixThree auditd rules assumed an ingestion model nothing stated. ssh_authorized_keys_write, ssh_private_key_read and history_clearing match key (SYSCALL record) and name (PATH record) in one selection, which only works where the pipeline coalesces the records of one auditd event into a single document — auditbeat/Elastic and the Splunk auditd TA do, a raw line parser does not, and there the three are inert while the key-only rules beside them keep working. Stated in each rule and in detections/README.md, alongside a note that syscall argument positions are per syscall rather than per family.
- FixThe potato rules cited the pre-re-measurement figures, and one of them argued the opposite of the record it cites. 2026-08-token-mismatch-sysmon-1.md was re-measured over the full corpus on 2026-08-30 — 147 Sysmon-1 records became 1491, and four recognised potato captures became six — but the rules reading it were not updated with it. Seven hand-written locations still said 147 / four captures / 4-of-4, across token_theft_parent_child_mismatch_sysmon_1.yml, potato_seimpersonate_sysmon_1.yml, DEFENSE-METHODOLOGY.md, fixture-provenance.tsv and docker/LAB-VALIDATION-PLAN.md. Corrected to 5 of 6 over 1491, with the sixth (PrintSpoofer) named and explained rather than absorbed into a count: its operator was already an interactive admin, so no service identity appears on that event and neither rule can reach it. The count of payloads the pair's Image list drops was also stale at two — it is three (notepad.exe, whoami.exe, nc64.exe), which is a third of the rule's own detections rather than a quarter. The ParentUser-availability note in both rules said "only the one from a 2022 host carries ParentUser"; four of the 1491 do, two of them the - placeholder. The one that mattered beyond bookkeeping is the rejected negated-filter variant. The rule defended rejecting it on the premise that it "finds the same 4 of 4 on this corpus and nothing more, so it buys no coverage here" — i.e. rejected *despite* being equivalent, purely on the Splunk NOT-on-missing-field conversion hazard. The record says the opposite: over 1491 records it matches 8, not 5, and two of the three extra are arguably wanted, so the decisive objection is the third — a ParentUser of -, which does not contain SYSTEM and so passes a negated filter, in a corpus where 401 of 1491 records resolve no parent at all. The rule now makes both arguments instead of the weaker one. No detection logic changed; the Splunk deploy form is regenerated.
- SecurityThe potato runbook now says what it predicts, and four things in it were wrong. #246 is host-bound and stays open — every one of its three items needs a Windows host running a real potato, and no second public corpus fills the gap (OTRF/Security-Datasets carries no potato or SeImpersonate capture at all). But the runbook is what decides whether that eventual run is worth anything, and it named a fixture that does not exist (potato_sysmon1.jsonl; it is potato_sysmon1_tp.jsonl), specified a target OS its own tool table cannot run on — JuicyPotato's DCOM route was fixed in Windows 10 1809 / Server 2019, which is why RoguePotato exists, so on any build modern enough to guarantee 4688 event version 2 the row meant to settle the CreateProcessAsUser vs CreateProcessWithTokenW question would silently not run — and asked for no attack producing a true negative, so a run following it would have promoted each TP to captured and stranded its TN at vendor-documented: a mixed-tier pair asserting a discrimination only half of it can support. It also never stated check_near_miss's identical-key-set contract at the capture step, which cannot be satisfied once the host is gone. The firing table is now six rows, the three open items carry pre-committed predictions (the convention 2026-08-sysmon18-remote-pipe.md reports against, so the record cannot be composed to fit the log), and a Filing the result section states per fixture what a result can promote — including that #246's own claim that closing it moves token_theft_sysmon1_* is wrong by citation, because that TP is a synthetic host (WEB01) and reaches captured only if replaced. Adds the --placeholder outcome the runbook did not anticipate despite 401 of 1491 swept records hitting it, the missing evtx-to-fixture.sh stdout redirects, and the zircolite replay commands its sibling runbook already carried.
- SecurityThe potato field semantics are now settled on a cross-channel join, and both fixtures are captured records. Extends the corpus work in #244: the sweep now covers all 278 EVTX rather than the 170 matching sysmon|proc, which adds 42 Security 4688 records and two more potato captures (RoguePotato, PrintSpoofer — six, not four), and the run record is one account rather than two. Three things follow. The User-is-the-child reading no longer rests on Sysmon-internal inference: the corpus holds one sample carrying both a Sysmon 1 and a Security 4688 for the same process creation, and there Sysmon's User matches 4688's *Target* while its LogonId matches TargetLogonId rather than SubjectLogonId — a numeric identifier carried independently by two providers. A question a ParentUser rule quietly depends on is now measured: CreateProcessWithTokenW is serviced by the Secondary Logon service in svchost.exe, so had seclogon created the payload rather than the tool, ParentUser would read SYSTEM and the rule would be inert — on all six captures the parent is the tool, with no reparenting, which also rules out the seclogon route to the Creator Subject caveat in token_theft_process_target_subject_4688.yml. And both potato fixtures are now captured records rather than hand-authored ones: the TP is the cmd.exe RogueWinRM spawned as SYSTEM from a LOCAL SERVICE context, the TN is a genuine PrintSpoofer run whose operator was already an interactive admin, so it changes the ParentUser value and nothing else about the event shape. ParentUser itself is still derived on both, and still says so (dotgibson/dotfiles-Defense#239).
- Fix**potato_security_4688.jsonl was a three-key skeleton, and token_theft_4688_* were never checked against a real event. The 4688 potato fixture carried NewProcessName, SubjectUserName and SubjectUserSid and omitted the twelve other fields every real 4688 carries; it is rebuilt on the captured event-version-2 key set and field order, with the Target Subject block left null on purpose so it does not pre-judge #230. The token_theft 4688 fixtures needed no change — their key set proved identical to six captured 4688s — and that rule fired on real telemetry for the first time**, on a genuine token swap. Its pre-Windows-10 falsepositives note is measured now too: captured event-version-1 4688s carry no Target Subject block at all and the rule is correctly silent on them. One observation recorded rather than fixed — a captured 4688 can populate TargetUserName/TargetDomainName/ TargetLogonId while TargetUserSid reads the null SID, and TargetUserSid is the only one of the four that rule can see. Five provenance rows move to vendor-documented; none reaches captured, because none of this is first-party.
- FixThe "a rule naming both channels' fields nulls under either pipeline" claim was false, in all six places it appeared. Repeated by the potato_seimpersonate pair, the T1486 mass_file_encryption pair, host_recon_command_burst, and twice in detections/README.md — where it is cited as precedent — the claim was that a pysigma pipeline unable to resolve a field drops the whole rule. Measured with the CI pins (sigma-cli 3.0.2, pysigma 1.5.0): it does not. Such a rule compiles under both pipelines with the unresolvable field passed through unmapped, and an OR of the two field sets fires correctly on each channel. What actually makes one rule insufficient is that the logsource resolves to one EventID per pipeline — category: process_creation becomes EventID=1 under sysmon and 4688 under windows-audit, whichever rule you compile — so a single compiled search covers a single channel regardless of the fields it names. The per-channel split is still correct; only the stated reason was wrong, and the corrected reason is now what each rule gives.
- SecurityThe correlation rules' grouping rationale re-derived (host_recon_command_burst, service_stop_burst). Their reason for grouping by Computer inherited the same false mechanism, plus a second error #239 exposed: it named Security-4688 SubjectUserName and Sysmon-1 User as two spellings of one actor. They are different principals — SubjectUserName is the creator, User is the new process — so the fields were never counterparts. Measured behaviour is worse than the claim: group-by fields are passed through completely unmapped by both pipelines (SubjectUserName survives verbatim under sysmon) while detection fields *are* mapped, so an account-keyed correlation would compile and then bucket every event under one null key, silently voiding the threshold rather than failing visibly. Computer is now justified on its merits rather than as a workaround: a burst is a host-level phenomenon, one operator's burst spans identities (land → recon → escalate → recon, the sequence in the RottenPotato webshell capture), and an account key would split it into sub-threshold buckets while handing the operator a trivial evasion. Actor grouping is reachable post-#239 (SubjectUserName / ParentUser) but would gate the rules on Sysmon 13+ for a reason unrelated to what they detect. service_stop_burst, which carried no rationale at all, now states one.
- Fixcheck-fixture-provenance.sh no longer claims every fixture is unverified. Stale since aa02c28; the ledger has carried vendor-documented rows since. Rewritten to describe why the distribution is deliberately not gated, without a count that goes stale again.
- Fixpotato_seimpersonate_sysmon_1 was keyed on the wrong field and had never fired on a potato (#239). The rule and detections/README.md described it as the Sysmon half of a per-channel pair with potato_seimpersonate_4688, one shape on two channels. It was not. 4688's SubjectUserName is the account that *created* the process; Sysmon 1's User is the account of the *new* process. The two agree on an ordinary service-spawns-shell event, which is why the pair looked like a twin and why its fixture passed — and they diverge on a successful potato, which is the entire technique. So the rule went silent at exactly the moment its twin fired, and a host forwarding only Sysmon read as covered for T1134.001 in both detections/README.md and detections/navigator/COVERAGE.md while seeing nothing. Measured rather than argued: replayed against four real potato captures on three hosts (RogueWinRM, NetworkServiceExploit, RottenPotato from an IIS webshell, EfsPotato) from the pinned EVTX corpus, the shipped rule fired on none of them. The selection moves to ParentUser, which is SubjectUserName's actual counterpart, and the corrected rule fires on the two captures whose payload is a named shell. The pairing claim in both rules and in detections/README.md is now true rather than merely stated. ParentUser needs Sysmon 13.00+, which is a real ingestion constraint and is written into the rule instead of assumed — on an older build the field is absent and the selection is silently unsatisfiable rather than noisy. Full measurement, and what it does not settle, in docker/validation/labruns/2026-08-potato-sysmon1-user-semantics.md. The 4688 half is untouched and still unconfirmed on a real potato; the first-party run is tracked as dotgibson/dotfiles-Defense#246. Fixture corrected to the measured shape (it carried no ParentUser key at all) and renamed to potato_sysmon1_tp.jsonl, and the pair gains its first true negative.
- Fixmake core-check reported fleet drift from an empty variable. It printed "gh not installed — cannot query upstream tags" and then queried anyway; gh failing left upstream_ver empty, which is never equal to local_ver, so it announced • vendored core is 5.4.1, upstream is — a sync from dotfiles-core is owed. A confidently wrong answer about drift, which is worse than the 127 the same defect causes elsewhere. The guard and the query are now one recipe line, and an empty upstream is its own branch: it reports drift UNKNOWN, not current and exits 1, rather than guessing. Found by _core_make_gate_hits (dotgibson/dotfiles-core#775), not by eye.
- Fixmake markdown announced a skip and then ran anyway. Each make recipe line runs in its own shell, so the guard's exit 0 only ended that line: without npx it printed "npx not available — skipping markdown" and then ran npx, exiting 127. Collapsed into one recipe line, so the skip is a real skip (dotgibson/dotfiles-core#775 — the same defect in six other fleet repos). MD_FILES was already correct here, so only the guard needed fixing, not the scope. An unreadable MARKDOWNLINT_VERSION now fails rather than silently linting unpinned — "same version as CI" is this target's whole claim.
- Config.markdownlint.jsonc's header claimed this config was "the local README check" and that "CI in this repo gates its own code, not its Markdown". Both were true when written; dotgibson/dotfiles-core#592 made the markdown leg blocking and it covers all 21 repo-owned files, not just the README.
Changed
- ConfigThe Sigma toolchain pins moved where Renovate can see them (#322). pySigma, pyparsing, sigma-cli and the three backends were strings inside sigma.yml's run: line, so nothing noticed when they aged. They now live in detections/requirements.txt, which the sigma gate installs with -r, the cloud validation plane with -c, and the README's local block with -r. check-readme-gates.sh gains a fourth assertion that the block reads the same file as CI, because the block no longer shows versions inline. renovate.json also watches docker/validation/zircolite-constraints.txt and groups every bump into one ci(deps) PR, since the packages are coupled. A bump that turns the hard gate red is that PR's intended outcome.
- Fixbootstrap.sh is the fleet's pilot of Core's bootstrap driver, blib_main (dotgibson/dotfiles-core#976). The shared half of every bootstrap — the flag loop, the escalator, the Core symlink surface, the band-85 role stage, the managed ~/.zshrc, the closing report — now runs from one definition in core/lib/bootstrap-lib.sh; this file declares what it is (BOOTSTRAP_ROLE=defense, BOOTSTRAP_LOGIN_SHELL=0) and keeps only what is genuinely Defense's: the forensics host-tool probe (bootstrap_check, behind --no-check via bootstrap_flag) and the closing case-data note plus the login-shell guard, which is now Core's blib_login_shell_hint (Offense carried the same one). The hand-rolled band-85 stage is gone — blib_link_role_layer wires 85-defense.zsh and defense/templates and drops the stale pre-v4 link, exactly as before. Three things the driver gives this repo for free: --only zsh,git in the space form (the old for a in "$@" loop could only parse --only=zsh,git), -n for --dry-run, and the local core/ pre-commit guard on a fresh clone. One convention change: an unknown flag exits 2 (usage error), not 1, which stays for real failures. Everything else on the box is byte-identical: same links, same loader, same closing lines, same exit codes.
- Featurebootstrap.sh closes on Core's failure tally instead of over it (dotgibson/dotfiles-core#973). This bootstrap installs nothing and escalates nothing, so it had no failure ledger — and none of its own steps needs one; the tool probe is report-only by design. But the shared scaffold it calls records *its* failures (a tpm clone behind a proxy) into the lib's array as it goes, and this script then printed "Defense bootstrap complete" regardless. The closing block now runs blib_failures_report, says "finished WITH the misses above" when there were any, and a new --strict turns that into exit 1; without it the exit code is unchanged, as befits a report-only script. Core's §5f ledger exempts this repo from blib_note_fail and blib_resolve_su with those reasons, so this is the last row the fleet ratchet had open.
- Securitydetections/htpx.pin -> v3.2.0 (6358a8df8661). A plain bump, on the drift bot's weekly report (#277). Upstream's v3.2.0 closes the corpus's Azure asymmetry — every Azure pair before it sat on the Entra/M365 identity plane, none on the resource plane — with two new pairs: azure-vm-runcommand <-> azure-runcommand-activity (T1651, Activity Log) and azure-keyvault-secret-dump <-> azure-keyvault-audit (T1555.006, Key Vault AuditEvent). No rule here changed and none needed to: nothing this repo names was renamed or retired, so every claim still resolves and the claim gate was green before and after. The report diff is the whole content of the bump — the two entries land in *"htpx blue entries nothing here claims"*, moving the boundary from 103 blue entries to 105 with the 96 claimed here unchanged. That two-row gap is recorded, not accepted by default. This repo covers the AWS and GCP resource planes (aws_snapshot_share_external, gcp_service_account_key_created, and their siblings) and the Azure *identity* plane, so the missing Azure resource-plane telemetry — AzureActivity, and Key Vault AuditEvent, which is a diagnostic setting an estate has to switch on — is the same asymmetry upstream just fixed, seen from this side. It is a coverage candidate, filed as #280 rather than smuggled into a pin bump: a bump whose diff is only the pin and the generated report is reviewable at a glance, and one that also ships two new Azure rules is not.
- SecurityFive rules matched one spelling of an action that has more than one. Each was evadable by a caller who reached the same outcome through the sibling API, and the corpus review (#261) found them together. gcp_service_account_key_created selected only CreateServiceAccountKey, missing UploadServiceAccountKey — the caller brings their own public key, so the private half never transits Google at all, which is the version an attacker prefers. okta_mfa_factor_reset did not select user.mfa.factor.suspend, Okta's own suspected-compromise action, which takes a factor out of use without deactivating it. (The review also proposed a singular user.mfa.factor.reset; Okta defines no such event type, a single-factor reset emits deactivate, and the rule now records that so it is not re-raised.) github_credential_backdoor did not see integration_installation.create — installing a GitHub App mints an installation token for as long as the install stands, the quietest durable credential of the three the rule now covers. gitlab_token_backdoor was missing the two group-scoped token events, the widest blast radius of the set. vault_approle_backdoor matched auth/approle/role/ literally, so a role minted under an already-enabled jwt, kubernetes, aws or token backend — the same durable machine identity, one path over — walked past it; it now matches the role path under every backend.
- FeatureThe Kubernetes escape rule's hostPath branch matched only the literal /. A mount of /var/run/docker.sock, /run/containerd, /proc, /etc, /root or /var/lib/kubelet is a node takeover on its own and produced no alert. Now the root mount plus a prefix list, with hostIPC added as a scalar branch beside hostPID. hostNetwork was considered and declined in the rule: it is not an escape by itself and it is precisely the CNI/node-agent request the rule's own false positives name. The documented array-traversal caveat is unchanged and still governs the two array-keyed branches.
- Configsnowflake_network_policy_change fired on statements that read a policy. It excluded SHOW but not DESCRIBE/DESC. filter_show is now filter_readonly and covers both. GRANT ... ON NETWORK POLICY stays selected on purpose — it changes no allowlist, but a GRANT OWNERSHIP on the enforced policy is the step before an attacker can alter it.
- FixFour proposals from the same review were declined, in the rules themselves. A decline that lives only in an issue gets re-proposed next cycle. sudo_root_shell keeps comm and its interpreter list: exe resolves symlinks, so an exe|endswith list silently loses hosts where /bin/sh is dash or python3 is python3.14, and dropping the list turns a detection into a log of every command run through sudo — the GTFOBins escapes it is accused of missing exec a listed shell as a child and are caught there. k8s_clusteradmin_binding does not gain verb: [escalate, bind]: those are RBAC authorization verbs, never the verb of an audit event, so the selection would match nothing while reading as coverage. jenkins_job_backdoor does not add config.xml: the Audit Trail plugin's default pattern does not log it, and the logged line carries no HTTP method, so the branch could not separate the malicious POST from the routine GET. jenkins_api_token_created keeps no filter block: this corpus has no verified Jenkins actor field, and inventing one produces a filter that passes its own fixture and matches nothing in production — the hazard fixture-provenance.tsv exists to expose.
Added
- Fixcheck-readme-tables.sh — the README's rule and deploy-time tables are now gated (#314). Same argument check-readme-gates.sh makes about the other half of the file: *"the README is load-bearing documentation, so it gets a gate like the rest of the load-bearing artifacts."* Nothing tied these tables to detections/sigma/, so they drifted — twelve rules carried a DEPLOY-REQUIRED marker with no row in the deploy-time table, and three had no row in any per-directory table. Four assertions, both directions on both tables. The deploy direction is the expensive one: the table's own preamble says the marker *"isn't enforcement, so this is the discoverable checklist instead"*, so a rule missing from it ships with an unfilled placeholder nobody was told about — the failure half those rules' comments describe at length. A phantom row is worse, sending an operator to fill a placeholder that is not there. The rule-table direction is about honesty of scale: the cloud/ table listed one of three GCP rules, so GCP read as a third of what shipped. It locates the tables by heading text and fails rather than passing if a heading moves, because a silent pass would report a clean bill for a table it never read — the same posture as keying splunk-precedence-allowlist.tsv on stanza title. Each of the four assertions plus the heading guard was verified to fire by mutating the README, so the gate is demonstrated rather than assumed. Wired into make methodology, make sigma and sigma.yml (thirteen hard checks become fourteen), and check-readme-gates.sh required it to document itself.
- Securitygcp_gce_metadata_startup_script — the first rule on GCP's resource plane (T1651, #305). Every GCP rule here was identity or logging plane (T1098, T1098.001, T1685.002); the resource plane had nothing, the same hole #280 closed for Azure. A startup-script key written into an instance's metadata is executed by the guest environment agent as root or SYSTEM at the next boot, so a stolen Compute Instance Admin token is remote code execution with no SSH key, no open port and no guest credential — and Compute Admin Activity logs are always on and cannot be disabled, which is the same cheapest-telemetry argument that put azure_vm_run_command first on Azure. Two shape decisions worth recording. The rule reads the key-name delta (added_metadata_keys / modified_metadata_keys) rather than the request, because Google redacts this call's metadata body by design — so the payload is not in the log and the key name is the only surviving signal. And the six key spellings are enumerated rather than matched on a startup substring: the -url forms fetch the script at boot, so its bytes never enter the log at all. instances.reset is deliberately *not* a second arm — benign alone, and requiring it would turn a patient attacker into a miss; it is the triage pivot instead, the same treatment azure_vm_run_command gives runCommands/delete. project.setCommonInstanceMetadata is named as a known adjacent path rather than guessed at. The metadata-delta field path is the soft spot and the fixtures say so: a captured Admin Activity entry spells it instanceMetadataDelta.addedMetadataKeys while Security Command Center's own sample filter for the same detection writes instanceMetaData.addedMetadataKey. The rule takes the captured spelling, snake_cased to the repo's method_name/principal_email convention, and the provenance rows stay unverified for exactly that reason. gcp-gce-metadata-audit leaves HTPX-COVERAGE.md's unclaimed table (99 → 100 claimed).
- FixTargeted LDAP recon has a detection (#284). T1087.002 / T1069.002 read as covered, but only through sharphound_ldap_sweep_4662.yml, a fan-out detector — a handful of revealing filters (servicePrincipalName=*, the userAccountControl bitfield match, adminCount=1) never trips it, and ldap-recon-4662 was the one htpx blue entry nothing here claimed and nothing in the methodology declined. Two rules close it: detections/sigma/discovery/ldap_recon_search_filter_1644.yml keys on the search-filter content, which only 1644 carries, and says that 1644 is off by default; ldap_recon_property_reads_4662.yml is the fallback where it is not collected, counting the SPN / UAC property GUIDs those filters read. They are two files because the Sentinel / Elastic deploy forms mark a whole file unsupported when it holds a correlation, which would have dropped the primary arm with the fallback. Both fire on their fixtures under zircolite, the 4662 arm with a true negative past its threshold.
- PerfThe README opens with a rendered terminal hero (dotgibson/dotfiles-core#948). assets/demo.gif is filmed from assets/demo.tape, which dotfiles-core generates from one shared template for all nine OS and role repos — the same tour everywhere, plus the one command that is this repo's own: core status showing the role layer live over the OS layer. The tape is generated (edit dotfiles-core's assets/hero.tape.in, not the tape); re-render with vhs assets/demo.tape on a Debian box with this role layered on top after a prompt or tooling change, then gifsicle -O3 --lossy=80 --colors 64 — the raw render is over Core's 2 MiB ceiling, the optimised one is not.
- SecurityT1555.006 Cloud Secrets Management Stores — Key Vault bulk secret read, closing the Azure resource plane (#280). detections/sigma/cloud/azure_keyvault_bulk_secret_read.yml is a base rule plus a value_count correlation: one identity reading many *distinct* secrets from a Key Vault inside a 15-minute bucket. The cloud twin of vault_bulk_secret_read, and the same argument holds — a healthy application re-reads its own small set, an attacker with a stolen token sweeps across unrelated ones. Breadth per identity, not read rate: a flat "more than N reads" is wrong in both directions at once, letting the automation principal that legitimately reads 200 secrets a run stay silent while a targeted grab of the ten highest-value secrets sails under the floor. It ships at the same threshold as its HashiCorp sibling so an analyst tuning one credential store starts from the same number in the other, and both say the flat floor is the starting point rather than the destination. The identity claims are the part worth reading. The correlation groups by identity_claim_oid_g, the objectidentifier claim, because it is the only caller field present for *both* a human and a service principal: identity_claim_upn_s is null for an SP, and identity_claim_appid_g is the APPLICATION — allowlist the Azure CLI's shared app id and you have exempted every CLI user in the tenant. The raw event from Microsoft's logging reference also shows why the resource-specific schema is a trap rather than a rename: in AZKVAuditLogs the flat columns collapse into one dynamic Identity, whose claims are keyed by their full XML-schema URIs (http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn), so Identity.claim.upn silently resolves to nothing. Only appid is a short key. The rule ships the AzureDiagnostics spelling — what the paired entry queries, and already flat enough for a correlation to group by — and names the translation. Scoped to SecretGet, and unfiltered on result. SecretList/SecretListVersions return names, not values; counting them into a breadth measure would let one legitimate inventory call inflate the score, so they stay out of the rule and stay in the triage guidance, where a List followed by a run of Gets is the drain in its clearest form. There is no ResultType filter for the same reason the Run Command rule carries no status arm: a burst of *denied* SecretGets across many secrets is a principal sweeping a vault it has no permission on, which is the sharper finding, and filtering to Success would drop exactly that. Counting requestUri_s is an approximation, and the direction of its error is stated. The paired entry's KQL recovers the secret name with split(requestUri_s, "/")[4]; Sigma has no such transform, so this counts distinct request URIs. The two differ when one caller reads the same secret at several explicit versions in a window — which inflates. It is sound because of the group-by: within one identity the client library and api-version are constant. The error runs toward more sensitivity, never less: it can produce a false positive, it cannot hide a sweep. Six fixture lines at vendor-documented, each verified individually — TP: two distinct secrets under one oid plus a Forbidden read; TN: the allowlisted oid, a SecretList, and a KeyGet. The Sentinel gap is named rather than papered over: the kusto backend cannot express a Sigma correlation, so rules.generated.kql carries this as UNSUPPORTED like its two siblings — and for this rule that points somewhere useful, because the paired htpx entry *is* the Sentinel implementation. The telemetry caveat is recorded too: unlike Activity Log, Key Vault AuditEvent is a diagnostic setting an estate has to switch on, so this rule is inert until it is, which is a collection gap rather than a detection one.
- SecurityT1651 Cloud Administration Command — Azure Run Command, the first rule on this repo's Azure resource plane (#280). detections/sigma/cloud/azure_vm_run_command.yml reads the Azure Activity Log for Run Command, the ARM call that executes an arbitrary script in a VM's guest as SYSTEM or root. A stolen Contributor token is therefore remote code execution with no guest credential, no RDP or SSH, and no packet the guest's own sensors can see. The gap it closes was invisible to a per-tactic coverage map: Azure was six pairs deep on the Entra/M365 *identity* plane and empty on the resource plane, while AWS and GCP reached both, so the provider read as covered until you sorted by plane. The htpx v3.2.0 bump is what surfaced it — upstream closed the same asymmetry from the red side, and both new blue entries landed in HTPX-COVERAGE.md's unclaimed table. Both operation paths, because they are one capability under two names. runCommand/action is the classic invoke; runCommands/write is the managed path that creates a child resource on the VM, and it is the stealthier half — a rule keyed on the invoke alone misses its own paired red, which is the mistake htpx fixed in kerberoasting-4769. Arc machines (Microsoft.HybridCompute) and scale-set instances are selected for the same reason: Run Command onto an on-prem host that happens to be Azure-joined is the case a defender is least expecting an Azure control plane to reach. runCommands/delete is deliberately NOT selected — that is the attacker's cleanup, not the execution, and alerting on it would fire on every routine teardown and invert what the rule means. It stays the triage pivot: a write and a delete against the same _ResourceId minutes apart is a script that ran and was tidied. No status arm, diverging from the paired entry's KQL, and the divergence is the point. That query filters ActivityStatusValue to Start/Started/Succeeded/Success. Microsoft documents the column as "Started, In Progress, Succeeded, Failed, Active, Resolved" while its own sample queries for the table use the short forms Start and Success — so the vocabulary is inconsistent in Microsoft's own references, and an allowlist over it is a guess that fails silently. Failed also belongs in the alert: an attacker denied by RBAC is a finding you can still get ahead of. The operation is the invariant, so the operation is the whole selection; deduplicate in the SIEM by Caller and _ResourceId, where it cannot cost a detection. The fixtures reached vendor-documented, and the checking changed the rule twice. All five AzureActivity columns are confirmed against the Azure Monitor table reference, and the UPPERCASE operation casing is settled by that table's own sample page: the sample using the case-*sensitive* == writes MICROSOFT.COMPUTE/VIRTUALMACHINES/WRITE, while every mixed-case sample reaches for the case-insensitive has. Casing is load-bearing for exactly one deploy form — sigma convert compiles this to in~ for Sentinel and IN for Splunk, both case-insensitive, but Lucene against a keyword field is not. Four of the five operation strings are confirmed verbatim (Compute RBAC permissions page; the documented RBAC requirement of az vm run-command create; the scale-set and Arc REST references, which print the resource type in their sample responses); the scale-set *invoke* is flagged in the rule as the one line this repo has not confirmed. Checking also corrected the filter_ops_automation guidance: Microsoft documents Caller as a GUID, and the table's own sample filters humans with Caller has "@" — so an automation identity must be allowlisted by object GUID, and a UPN-shaped entry for a service principal matches nothing while looking filled in. Seven fixture lines, each proving one decision: the TP covers the invoke, the managed write under a non-Started status, a *failed* invoke, and the Arc path; the TN covers the allowlisted caller, the delete, and the read. Every line verified individually against the rule. make check, make attack-tags, and make htpx green — the pairing gate now resolves 99 reference URLs across 97 blue entries, and azure-runcommand-activity moves out of HTPX-COVERAGE.md's unclaimed table. DEFENSE-METHODOLOGY.md gains the resource-plane axis and names Azure Activity Log as a data source; the Key Vault half of #280 stays open, second because its telemetry is a diagnostic setting rather than on by default.
- FixT1537 Transfer Data to Cloud Account — detections/sigma/cloud/aws_snapshot_share_external.yml (#262). The corpus had no detection for exfil that never crosses an egress boundary. An attacker snapshots a volume, grants restore rights to an account they control, and copies it from there; the bytes move inside AWS's own address space over AWS's own APIs, so the wire plane, DLP, and aws_s3_bulk_exfil's object-read volume signal all stay quiet. Both existing Exfiltration rules key on crossing an external boundary — slack_external_shared_channel on an invite out, snowflake_data_unload on COPY INTO an external location — and T1537 is definitionally the case that does not, so this is the one exfil family that had no representative at all rather than thin coverage of a covered one. It is the inverse of the two CloudTrail rules beside it. ModifySnapshotAttribute, ModifyImageAttribute and the RDS pair are MANAGEMENT events, in every trail by default, where aws_s3_bulk_exfil and aws_data_destruction both go half-blind without S3 data-event logging. So it needs no new telemetry — which is why it was authorable at all, and what separates it from every entry in the declined ledger, each of which is blocked on telemetry the estate lacks. Two tiers, because only one needs tuning. A grant to group: all is public, fires with no allowlist, and is correct on day one; a grant to a named account is a finding only once filter_own_accounts holds your own account IDs. The condition puts the public arm OUTSIDE the filter deliberately, which is recorded in detections/siem/splunk-precedence-allowlist.tsv because the compiled SPL relies on search-command precedence to bind it — traced, not assumed. Keyed on the .add. path rather than the bare verb, which scopes out two non-events at once: ModifySnapshotAttribute also sets a description, and the remove half of the same call is the attacker's cleanup. Both are proven inert by the true-negative fixture, alongside a share to an allowlisted account. Its blind spot is stated in the rule: the attacker's CopySnapshot runs in THEIR account and never reaches the victim's trail — the same geometry that keeps CopyObject out of the S3 rule — and a share -> copy -> un-share sequence leaves a clean permission list, so a posture sweep over currently-shared snapshots is not a substitute for the event stream.
- FixTwo backend-typing fixes on the T1537 rule, and the gate divergence they exposed (review feedback on #268). The allowlist's AWS account IDs were unquoted, which is the convention every EventID in this corpus follows — but an account ID is a STRING that happens to be all digits, so the Sentinel form compiled == 111122223333 against a string field and the allowlist silently suppressed nothing. Splunk and Lucene are untyped and were unaffected, which is what made it easy to miss. They are quoted now, and the number_as_string validator that objects is excluded for this one rule id in detections/sigma-validation-config.yml rather than corpus-wide, so a genuinely mis-typed EventID elsewhere still fails. The '*' wildcard standing in for "field is present" was the second: it compiles to startswith "" in KQL, a tautology for any string and wrong against valuesToAdd, which CloudTrail emits as a JSON array. |exists: true gives each backend its real existence test — isnotempty(), exists:, =*. docker/validation/sigma_eval.py gained SigmaExists support to match, since it raised rather than guessing. Both were invisible to the rule's own true-negative fixture, because that fixture runs through the Python evaluator and not through a backend — precisely the hazard fixture-provenance.tsv's header describes, reached from a new direction. detections/check-attack-tags.sh ran sigma check with NO config, so it enabled every validator and honoured no exclusions. A per-rule exclusion therefore passed the hermetic lint and failed here, reported as an ATT&CK-tag error it was not. It now reuses sigma-validation-config.yml with only the -attacktag line stripped — that line exists to keep the hermetic lint off the network, and this gate has the pinned bundle injected already. Verified the tag validator still fires by tagging a rule attack.t9999.
- Featuredetections/htpx.pin -> v3.1.0 (7ea71779365c). Carries the aws-snapshot-share-exfil <-> aws-snapshot-share-cloudtrail pair the rule above names. Authored red-first upstream (dotgibson/htpx#115) so the purple loop closed before the rule existed, rather than shipping the corpus's only unpaired rule.
- FixSeven rules that named a benign false positive in prose now carry the filter block to suppress it. Each documented the noise and left the reader to hand-edit the rule: shadow_credentials_keycredentiallink_5136 (filter_whfb — the rule prescribed a Windows Hello allowlist its detection never implemented, so in any WHfB tenant this high rule fired on every legitimate key enrollment; the on-prem key-trust self-write, where Subject equals the object, is named as needing a SIEM-side comparison Sigma cannot express), gcp_service_account_key_created (filter_iac, for parity with its GCP siblings), harbor_robot_account_created (filter_provisioning), entra_directory_role_grant (filter_iga — PIM/IGA automation, with a note NOT to allowlist the PIM service whose operations the rule deliberately selects), bitlocker_abuse_encryption (filter_provisioning on the imaging task-sequence parent), ldap_recon_explicit_creds_4648 (filter_sweep_principals, present in its sibling discovery rules but not here — the correlation's threshold is no substitute, a scanner clears it every cycle), and github_self_hosted_runner_registered (filter_runner_provisioning). Every one is a DEPLOY-REQUIRED stub with a true-negative fixture proving the exclusion works, so the validation advisory that listed rules with a filter and no true negative is now empty. The BitLocker rule's generated Splunk form mixes a top-level OR with the new NOT; the binding was traced and recorded in splunk-precedence-allowlist.tsv rather than left to precedence.
- Fix\pipe\srvsvc and \pipe\epmapper are read at last; EfsPotato and RoguePotato are watched on the mechanism plane. srvsvc_epmapper_pipe_impersonation_sysmon_17.yml (id 563f2958-0d44-4138-884f-14d338d37cd9) selects EventType: CreatePipe on a PipeName whose name NESTS either endpoint, closing the telemetry-ahead-of-detection hole #225 closed for spoolss and #229 for atsvc/svcctl/efsrpc. Two names, one rule: they share the invariant, the EventType pin, the absence of an Image key, the technique and both its tactics, and the false-positive story, which is the svcctl/atsvc case rather than the efsrpc one, where the split existed because the invariant genuinely differed. With this, every name the shipped config collects is either read or declined in writing. The three questions #240 carried were re-derived, not inherited, and the sweep was re-run from scratch because the original figures lived only in a config comment — a rule resting on a number recorded nowhere a reader could check is the circularity fixture-provenance.tsv exists to make visible. docker/validation/labruns/2026-08-srvsvc-epmapper-pipe-creation.md is the record: all 278 EVTX of sbousseaden/EVTX-ATTACK-SAMPLES @4ceed2f4, chainsaw 2.16.4, 61 PipeEvent records — the figure reproduces exactly. *Creation, not connection*, and for a third distinct reason, so neither sibling's argument carries across. Every nested pipe in the corpus produces exactly one 17 and one 18, tens of milliseconds apart: the 17 carries the tool's own Image, the 18 is the coerced service binding back as Image=System ProcessId=4, naming the victim. Dropping the pin takes the rule from 2 matches to 4 and adds no attack it did not already see — only a misattributed second copy of each. So the pin buys deduplication and attribution and costs no coverage, where coercion_efsrpc_pipe_sysmon_18's identical-looking pin is load-bearing against *volume* because lsass creates \efsrpc every boot. Both siblings now say so in their own descriptions. *The generic \<x>\pipe\<y> shape is rejected*, and the decisive objection turned out to be measured rather than argued. It is expressible without regex (PipeName|contains: '\pipe\', since Sysmon renders one leading backslash and no \Device\NamedPipe\ prefix) and on the create half it matches 3 of 61, all attacks — but the third is PrintSpoofer's nested spoolss pipe, which spoolss_pipe_impersonation_sysmon_17 already fires on, so its marginal coverage over this corpus is zero. The ingestion objection stands separately: it needs unfiltered PipeEvent collection, so under the shipped onmatch="include" baseline it would see only the seven collected names — a much larger decision than a rule may make on the config's behalf. And 3-of-3 measures an attack-sample corpus, not an estate. It still misses GodPotato. Reopen condition recorded in the rule: if collection is ever widened past an include list, the generic form should *replace* this rule rather than sit beside it. For the same reason spoolss is deliberately absent from the name list — that rule's contains: 'spoolss' already matches the nested form, verified against the record, so adding it here would double-alert one attack. *No filter_* block, and the omission is the argument.* The legitimate creators make the bare \srvsvc and \epmapper, which the selection does not contain, so the narrowing does the work an exclusion would. #240's proposed filter_legit: Image|endswith: '\svchost.exe' is confirmed dead: the one legitimate \epmapper record is Image=System. A cosmetic filter would be that defect written twice. Consequently no check-splunk-precedence.sh row is needed — the compiled Splunk form is EventType="CreatePipe" PipeName IN ("*\\pipe\\srvsvc*", "*\\pipe\\epmapper*"), an OR list with no NOT beside it. The fixtures are a provenance first for this repo. srvsvc_epmapper_pipe_17_tp.jsonl is the two real create records transcribed verbatim from the corpus — the first pipe fixtures here whose *values*, not merely whose key set, come from the provider. They stay vendor-documented, not captured: labruns/README.md reserves that for first-party capture, and these are 2020–2021 third-party builds. A true negative ships despite the rule having no filter_* — not required by any gate, but the \pipe\ narrowing is the rule's entire thesis and nothing else would prove it discriminates; it changes exactly one value per line, PipeName nested to bare, holding Image at the TP's so the nesting is isolated as the sole discriminator. Validation: sigma efficacy 84/84 passed (42 with a true-negative), run against the pinned zircolite v3.7.6 — the new row fires on the transcribed EfsPotato and RoguePotato records and stays silent on the near-miss. Two claims this repo was making are corrected. The bare-srvsvc traffic was described as "five … ordinary share enumeration … from Image=System" — four of the five are Image=System and the fifth is wmiprvse.exe, and they come from three different capture types plus a kekeo capture. And "3 of 61 records" for the nested shape counts *pipes*; it is 6 records over 3 pipes. The load-bearing half — that no legitimate bind takes the nested form — holds exactly. Drive-by: docker/validation/README.md said 81 manifest rows / 38 true negatives against a real 84 / 42, and runbook-sysmon18-remote-pipe.md said "two things still need a host" while listing three. What is not settled: volume on a live host, in either direction. No legitimate *create* of either name appears anywhere in the corpus and none of the captures spans a boot, so the claim that a legitimate create would carry the bare name is inference. That half carries to #235, whose runbook gains it as item 4 with a pre-committed prediction and EfsPotato/RoguePotato rows in its firing table. Closes dotgibson/dotfiles-Defense#248. Ledger: corpus 110 -> 111 rules, 128 -> 129 documents, techniques unchanged at 81. Privilege Escalation TA0004 9 | 14 -> 9 | 15, Stealth TA0005 3 | 5 -> 3 | 6, T1134.001 5 -> 6 rules. No new technique or tactic row, and no new Splunk precedence allowlist row.
- Config\pipe\srvsvc and \pipe\epmapper join the PipeEvent include list; EfsPotato and RoguePotato stop being unwatched on the mechanism plane. Measured while gathering evidence for #240: every potato in the pinned sbousseaden/EVTX-ATTACK-SAMPLES corpus that stands up its own pipe uses the nested \<something>\pipe\<endpoint> shape, and two of the three do it on names nothing collected — EfsPotato on \dd4c18dc-…\pipe\srvsvc, RoguePotato on \RoguePotato\pipe\epmapper, both carrying their own Image on the create. detections/sysmon/sysmonconfig-detection-lab.xml dropped both events before Sigma ever saw them, which is the blocking constraint #240 records. The entries are deliberately \pipe\srvsvc and \pipe\epmapper rather than the bare names, and the measurement is the same shape twice: 7 of the 61 PipeEvent records match bare srvsvc and five are ordinary share enumeration (NetShareEnum, NetSessionEnum) arriving as \srvsvc from Image=System; 3 match bare epmapper and one is the legitimate endpoint mapper arriving as \epmapper. The narrow forms collect 2 apiece and leave the routine traffic out. Wanting that traffic is a separate decision and a separate rule. Worth recording on the epmapper side: that legitimate record is Image=System, not svchost.exe, so the filter_legit: Image|endswith: '\svchost.exe' #240 proposed would not have filtered it. Narrowing the collection is what does the job the Image filter was expected to do. No rule reads either name yet, which is the telemetry-ahead-of-detection hole #225 and #229 each closed one name at a time. Both are filed with the collection rather than after it, as dotgibson/dotfiles-Defense#248, and the config's comment block — which tracks which rule reads which name and why any name is unread — now distinguishes the two kinds of unread name: lsarpc because a rule there would be unfilterable volume and that is a decision, \pipe\srvsvc and \pipe\epmapper because the rule is owed. Closes dotgibson/dotfiles-Defense#240, whose three open questions carry to #248 — the volume one in particular, since no legitimate *create* of either name was observed and none of these captures spans a boot. Ledger: unchanged — collection only, no rule, no tactic or technique count moves.
- FeatureA Sysmon-plane rule for the token swap itself, and the Stealth row widens a third time (follow-on to #239). #239 established that Sysmon 1's User is the new process and ParentUser the creator, then used only the creator half to repair potato_seimpersonate_sysmon_1. token_theft_parent_child_mismatch_sysmon_1.yml (id b7f135f0-c066-4b0b-ac45-3f6bb433be38) uses both: a child running as SYSTEM whose creator was an app-pool / NETWORK SERVICE / LOCAL SERVICE identity. That is token theft stated as two fields on one event — a service identity cannot spawn SYSTEM without holding a token it did not start with — so unlike the potato pair it observes the outcome rather than the shape before it, and it takes the TA0005 half of T1134.001 on the reopen condition #223 wrote. Stealth TA0005 goes 3 | 4 → 3 | 5; the technique count, which is what measures the gap, does not move. It carries no Image constraint on purpose: measured against the four real potato captures in the pinned corpus it fires 4/4 where the potato pair fires 2/4, the difference being payloads (notepad.exe, whoami.exe) that no shell list catches, and the shell list removes no noise — its only matches across all 147 Sysmon-1 records swept are those four swaps. The tidier variant mirroring the 4688 rule's filter_same_context was rejected on evidence: it compiles to Splunk as NOT ParentUser="*SYSTEM*", where NOT on an absent field matches, so on a pre-Sysmon-13 host it would fire on every SYSTEM process creation while the zircolite backend the gate runs stays silent — a defect the gate is structurally unable to see. Measurement in docker/validation/labruns/2026-08-token-mismatch-sysmon-1.md, including what it does not settle: ParentUser was still never observed on a real potato (all four captures predate Sysmon 13, so it was derived by ProcessGuid linkage), the corpus contains no EDR or RMM agent so the zero-false-positive result is "not yet met" rather than "does not occur", and the locale exposure is reasoned rather than measured.
- SecurityThe token-context half of T1134.001 closes, on a different event than #230 asked for (#230). #223 reserved the Stealth half of T1134.001 for a rule that observes the impersonation rather than the shape around it, and named two candidates; #225 answered the spoolss one. The other candidate was "a token-context anomaly on 4624/4672", and it is declined on evidence: neither event is written by this attack. 4624 generates when a logon session is created and 4672 when privileges are assigned to a new one, and no live potato variant creates one — the pipe-impersonation majority never authenticates at all, the local-relay minority rides NTLM's local-call short circuit instead of calling LsaLogonUser, and DuplicateTokenEx preserves AuthenticationId so the resulting SYSTEM process reuses logon session 0x3e7. Only HotPotato and GhostPotato ever produced that shape, and both died with MS16-075 and CVE-2019-1384. Such a rule would have passed its own true positive and its own true negative and then sat inert, which is #149 with a Windows event id on it. What answers it is detections/sigma/privilege_escalation/token_theft_process_target_subject_4688.yml, on the plane where the telemetry exists. Event version 2 of 4688 carries a Target Subject block that Windows populates only when the creator and target "do not share the same logon", so a process whose Target Subject is SYSTEM was created with a token that is not its creator's — the theft stated as a field. It carries no Image constraint, so unlike the potato pair it survives a payload that is not a named shell; and because every variant ends in CreateProcessWithTokenW or CreateProcessAsUserW, it sits downstream of the DCOM-coerced variants that leave the spoolss rule silent. It takes both tactic tags on its own evidence. One assumption travels with it and is written into the rule rather than buried: whether the audit reads Creator Subject from the calling process's token or its impersonating thread token is inference from Microsoft's documentation, not a capture. Ledger: Stealth TA0005 3 | 3 -> 3 | 4, Privilege Escalation TA0004 9 | 12 -> 9 | 13, T1134.001 3 -> 4 rules. Tactic TECHNIQUE counts do not move, exactly as #230 predicted — the spoolss rule already took the row.
- FixThe rest of the Sysmon PipeEvent block gets read (#229). #225 closed one of the five pipe names detections/sysmon/sysmonconfig-detection-lab.xml collects and left four collected-and-unread — the same telemetry-ahead-of-detection hole, one size smaller. Two rules close three of them: svcctl_atsvc_remote_pipe_sysmon_18 (Sysmon 18 / ConnectPipe on \svcctl and \atsvc with Image = System) and coercion_efsrpc_pipe_sysmon_18 (Sysmon 18 / ConnectPipe on \efsrpc). This is corroboration, not new coverage, and the ledger should not be read as wider than it is. The service smbexec/psexec installs over svcctl is already caught by service_creation_psexec_7045; the task atexec schedules over atsvc by scheduled_task_suspicious_4698; efsrpc coercion by coercion_named_pipes_5145 and the Suricata coercion.rules. What the pair adds is a second, independent plane: the bind is visible on the host where those audit subcategories are never forwarded, and it is the request rather than the result, so it fires earlier in the chain. Only T1021.002 is a genuinely new technique row, and only because nothing here previously named the admin-share access itself. The invariant was re-derived, not copied. #225's shape was ownership on pipe *creation*, and it transfers to none of these: svcctl, atsvc, efsrpc and lsarpc are all created once at boot by the OS, and every tool in scope *connects* to a pipe already there — so the creation half detects nothing on those names and the new rules take the connect half, the opposite trade for the opposite reason. Origin replaces ownership: a remote SMB client's pipe open is serviced by the kernel SMB server, so Image reads System rather than a local sc.exe/schtasks.exe, and that is what separates impacket from administration. lsarpc deliberately ships no rule. Sysmon PipeEvent carries no authenticating principal, so coercion_named_pipes_5145's filter_machine — the block that makes that pipe survivable on 5145 — has no counterpart here, and a coercion bind and a routine domain bind arrive as byte-identical events. The decline is argued in DEFENSE-METHODOLOGY.md and recorded in a form that runs: the efsrpc rule's true negative *is* an lsarpc bind. The cost is stated rather than buried — PetitPotam's default endpoint is lsarpc, so 5145 stays the primary for coercion. Both rules ship with unverified fixture provenance on purpose. That Sysmon emits an 18 for a remote SMB pipe open, attributed to System, is derived from where the kernel services that open rather than observed — the purple-team run that would confirm it has not been done, and until it has, the rules' silence means nothing.
- Featurespoolss_pipe_impersonation_sysmon_17 — the one hole where the telemetry was already enabled and no rule consumed it (#225). detections/sysmon/sysmonconfig-detection-lab.xml has collected Sysmon PipeEvent 17/18 on five pipe names since it was written, with a comment saying it exists to corroborate potato_seimpersonate — and no Sigma rule selected it. Both potato rules told the analyst to "confirm the SYSTEM outcome with Sysmon 17/18 on the spoolss/DCOM named pipe", which was an instruction to go read telemetry the corpus collected and had no detection for. The ingestion was done ahead of the detection and the detection never followed. The new rule closes that. Its invariant is ownership rather than content: legitimate spoolss pipes are created by the print spooler, and PrintSpoofer-class tools stand up their *own* pipe and coerce the spooler into connecting to it, so a non-spooler process creating one is the anomaly. That is a shape, not an IOC — it survives renaming the binary. It is pinned to EventType: CreatePipe deliberately: category: pipe_created resolves to 17 *and* 18, but the only connect the attack generates is the spooler binding back, which the rule's own spoolsv.exe filter removes — so everything surviving the filter on 18 would be ordinary print traffic. Signal on 17, noise on 18.
- FeatureThis reverses the standing decision in #223/#224, on the terms that decision set. Those issues declined the TA0005 (Stealth) half of T1134.001 for the potato_seimpersonate pair because what the pair selects is a service identity spawning a shell — the shape *before* the token is stolen — and recorded a reopen condition: a rule that actually detects the impersonation "earns the TA0005 half on its own evidence, and should take it". This is that rule, and it takes both tactic tags. The pair still declines, and DEFENSE-METHODOLOGY.md now records the argument as settled rather than pending; the 4624/4672 token-context half of the reopen condition remains open. Ledger: Stealth TA0005 2 | 2 -> 3 | 3, Privilege Escalation TA0004 9 | 11 -> 9 | 12.
- Fixhtpx-drift.yml — a weekly question about the pinned corpus, and a correction to what #202 claimed. Pinning htpx (detections/htpx.pin) is what makes check-htpx-pairing.sh reproducible, and it opens one specific hole: the gate reads the pinned sha, but the references: URLs the rules write point at /blob/main/. So if upstream removes or renames an entry this repo names, the link is a live 404 for anyone who clicks it while CI stays green. Nothing watched for that. This does, weekly, and the report leads with exactly those entries. The correction. #202's commit message and PR said bloodhound-sharphound "was renamed bloodhound-collect upstream". That is wrong, and the distinction matters. Checked against htpx's full history: bloodhound-sharphound, bloodhound-sharphound-4662, archive-staging-rar, local-data-collection and rogue-account have never existed in that repo — not as a file, not as an id:, in any commit. All eight dead references were ids written *here* that were never right, not upstream drift. The fix was the same either way, and check-htpx-pairing.sh catches that class permanently at author time. But the rename story would have made this workflow look like a response to something that had already happened, when the hole it covers has not bitten yet. It is a cheap weekly question, not a reaction. It reports on corpus changes, not commits. A pin behind by a README edit or a .gitignore commit is behind by nothing — only two gates read this corpus and both read entries/ alone. Verified against real history: two consecutive upstream commits that touched no entry produce no issue. Filing for those is the nagging core-drift.yml's header says it refuses to do, and a weekly nag becomes a weekly ignored issue. Report-only, like core-drift.yml: one deduplicated issue via the existing file-routine-issue.sh, nothing changed. Bumping the pin stays deliberate, because a bump moves HTPX-COVERAGE.md and that diff is the point. check-htpx-pairing.sh gained --list-claims, a data query printing every entry this repo names. The workflow uses it rather than re-implementing "what counts as a claim" in YAML — that definition already has two readers, and a third would be how they start disagreeing.
- FixThe Defense↔htpx link is checked, not just asserted — check-htpx-pairing.sh plus a drift-gated HTPX-COVERAGE.md. detections/README.md opens by promising that each rule names the exact Offense fold and htpx pair that reproduces it, "so the purple loop is closed in the file itself". Rules kept that promise in three places — ~84 references: URLs, an htpx pair <id> in each validation note, and 82 cells of the Validate with (Offense fold · htpx pair) tables — and nothing verified any of it. htpx is a separate repo on its own release cycle, so an entry renamed there left a rule here pointing at a 404, and the only way to find out was for someone to click the link. The split is the design decision #196 asked for, and it is deliberate: - Claims are a hard gate. Naming an entry is a factual assertion about another repo — it is true or it is false. check-htpx-pairing.sh resolves every named entry against the pinned corpus and checks the blue ones still pair back to a red entry that points at them. - Gaps are a report. HTPX-COVERAGE.md renders which blue entries this repo claims, which it does not, which techniques here the corpus has no attack for, and which entries htpx declares unpaired. It never fails on a gap and it is drift-gated, so a change in the shape of the boundary arrives as a reviewable diff. htpx spans Okta, Workspace, GitHub Actions, GitLab, Jenkins, Harbor, Vault, Terraform Cloud, Snowflake, Cloudflare, npm and PyPI; a bidirectional foreign key would be red forever and silenced within a week. The issue predicted exactly that. Declared holes are excluded by field, not by an allowlist. htpx now requires pair_note: on any entry carrying pair: null (htpx#98), so the report reads the upstream reason verbatim instead of keeping a local copy of someone else's decision — which is the thing that goes stale. The corpus comes from a pinned commit (detections/htpx.pin), fetched by htpx-corpus.sh. Same argument as attack-data.pin: a check whose answer depends on what upstream did this morning is not a gate. No sha256 field, because the pin names a git commit and git's own object hashing already binds it — verifying HEAD against the pin *is* the digest check. This repo deliberately does not vendor htpx as a subtree the way Offense does; two gates read it, and a ~200-file subtree plus a second sync obligation is a steep price for that. Adopted after fixing what it found, in the same PR — the standard check-rule-coverage and the Splunk precedence gate were held to. Eight dead names: - bloodhound-sharphound / bloodhound-sharphound-4662 were renamed bloodhound-collect upstream — a dead references: URL, a dead validation note, and a dead table cell. - archive-staging-rar, local-data-collection and rogue-account named entries htpx has never had — the corpus's Collection and Exfiltration entries are all SaaS-side and it has no account-creation entry at all. Those rules now say so in prose rather than naming a phantom, which reads as a working cross-reference. Half of those were in the README table, which is why the gate reads that column too and not just the rules.
- Featurehost_enum_srvsvc_wkssvc_5145 now cites its htpx blue entry. The rule's validation note has named the red side (smb-enum-nxc) since it was written, but the corpus had no detection to put beside it — htpx#97 tracked that hole for exactly this rule. entries/blue/smb-enum-5145.md is now upstream, ported from this rule, and the rule references it. It is the first edge the new gate was built to verify, so the gate ships with a live cross-boundary link rather than an empty set.
- PerfThe repo hygiene surface: Makefile, CONTRIBUTING.md, .editorconfig, .gitattributes, and the PR/issue templates. This repo and dotfiles-Offense are deliberate mirrors — equal CI weight at 14 workflows each, and Defense carries *more* tracked files — but Defense was the only repo in the fleet missing all of these at once. The Makefile is the one with immediate value: every gate here was already real and already scripted, but there was no front door, so reproducing CI locally meant reading .github/workflows/ and reconstructing the command list by hand. It wires what exists — tests/lint-shell.sh, tests/test-defense.sh, the four --check drift gates, the methodology and validation-coverage gates, lab-smoke.sh — and reads MARKDOWNLINT_VERSION from the vendored Core pins so a local run matches CI exactly. Offense's target list was not ported wholesale, because that would have recreated in reverse the exact bug Offense's Makefile was written to fix: core-sync, core-lock and the companion-* targets name scripts this repo does not have (Core is pushed *into* here by dotfiles-core's own make sync, and this repo vendors no htpx companion). They are absent rather than stubbed. .gitattributes is the one with teeth. dotfiles-Arch/CLAUDE.md documents what its absence costs when a repo is touched from Windows over a UNC share: a .sh that picks up CRLF gets a shebang of …/env bash\r and dies with *"bad interpreter"* — text that looks perfectly valid and that both shellcheck and bash -n accept. It also pins *.rules, *.zeek and *.pcap, which are this repo's product and are parsed by tools that are not uniformly CRLF-tolerant. Adding this file also makes Defense visible to dotfiles-web's changelog feed, which derives from each repo's CHANGELOG.md and had been skipping this one for want of the file. (dotgibson/dotfiles-Defense#196)
Fixed
- Configdnf repoquery (43) had been red on every run since the version floors landed (#193), and never because of a floor. The version probe in test/check-packages.sh ended its options with --. dnf5 only treats -- as end-of-options from 5.4 onwards. The 5.2.x that fedora:43 ships rejects it as an unknown argument, and the probe hid that error (stderr went to /dev/null). So every floored name came back with no version, and the check reported neovim and tree-sitter-cli as "virtual capabilities" on F43 only. The -- is gone; manifest names never start with -. A floored name that resolves by name but yields no version now reports a probe failure instead of blaming the manifest entry. packages.yml's path filter now includes test/check-packages.sh, because a change to the probe is only exercised on that workflow's Fedora containers.
- FixThe tmux auto-attach honours DOTFILES_NO_AUTOTMUX, the fleet's one opt-out name (dotgibson/dotfiles-core#877). MacBook, openSUSE and Gentoo already read it; this layer attached unconditionally for any interactive TTY, which is how dotfiles-core's README hero render — a vhs session that sources this layer — typed its whole tour into a fresh main session. Core's gen-hero-tape.sh now refuses to render a hero on a layer that does not honour the knob. Export DOTFILES_NO_AUTOTMUX=1 for any harness that drives an interactive zsh and must not land in tmux.
- Fixmake markdown probed for a global, unpinned markdownlint-cli2 — so on a normal box it never linted anything (dotgibson/dotfiles-core#873). Nothing in this repo's bootstrap installs markdownlint-cli2 globally; it is npm-only. So unless the operator had separately run npm i -g markdownlint-cli2, the guard fired on every invocation and the target skipped, cleanly and with exit 0, forever. That is a correct guard doing exactly what it says — and a local mirror of a blocking CI gate that has never mirrored anything. dotgibson/dotfiles-core#775 fixed this target's skip guard and its file scope; neither defect could bite while the target never ran at all. And where the binary *was* installed it was whatever version npm last put there, while lint-call.yml installs the pinned MARKDOWNLINT_VERSION — so a rule that changes across a bump reds a required check against a green local run. It now runs the pinned version through npx, reading the number from the vendored core/scripts/tool-versions.env rather than restating it, and refuses rather than guess if that pin is unreadable — a silently-unpinned lint being the thing this fixes. npx needs only node, which is far likelier present than a global markdownlint install, and still self-skips without it so make lint works on a bare box. Converges on the shape dotfiles-Offense and dotfiles-Defense already run.
- Fixbootstrap.sh's PATH is not the shell's PATH — adopt blib_user_bindirs_on_path (dotgibson/dotfiles-core#748). Replaces the hand-rolled export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$HOME/.atuin/bin:$PATH" prelude. ~/.local/bin, ~/.cargo/bin and $GOBIN reach PATH only through the zsh layer, i.e. only inside a Core shell — which does not exist while bootstrap.sh runs. So every command -v <tool> guard here was answered by the PATH of whatever shell launched the bootstrap: on a fresh box, bash, with none of them. That is wasted work when the guard picks whether to reinstall, and a wrong answer when it picks a branch — dotfiles-openSUSE probed command -v mise for a mise mise.run had written to ~/.local/bin moments earlier, both arms of its Go fallback missed, and the run exited 2 on every bootstrap. No stubbed CI leg can see that: a stub installs nothing, so "is the tool present afterwards" can never fail under one. Core has shipped blib_user_bindirs_on_path for exactly this since dotgibson/dotfiles-core#425 — it resolves CARGO_HOME and GOBIN/GOPATH rather than hard-coding them, and adds only directories that exist, so it is called again after an installer creates one. The directory this script installs into is mkdir -p'd before the helper runs: the helper adds only directories that already exist, so a straight swap for the old unconditional export would have dropped ~/.local/bin for the whole first run and made command -v atuin miss the binary the atuin block had just linked there, skipping the systemd user unit. A second call now runs after the mise.run install, so _dotfiles_go_install's command -v mise arm sees the mise this script just installed.
- Fixmake check was not hermetic, and wrote Core into your real config dir (dotgibson/dotfiles-core#852). The target promises "a hermetic --links-only run against a throwaway HOME" and redirected only HOME — but bootstrap.sh resolves its target as CONFIG="${XDG_CONFIG_HOME:-$HOME/.config}", and core/lib/bootstrap-lib.sh defaults XDG_CONFIG_HOME, XDG_STATE_HOME, XDG_CACHE_HOME, XDG_DATA_HOME and ZDOTDIR the same way. A :-/:= default applies only when the variable is unset, so for anyone who exports XDG_CONFIG_HOME the run wired Core into their live config tree and then failed its own assertions, which look under the temp dir bootstrap never touched. Reproduced on Fedora 44: zsh/, nvim, starship.toml, tmux, git, mise, lazygit, atuin, jj, sesh and tealdeer all landed in the exported XDG_CONFIG_HOME, while the target reported MISSING symlink on a clean checkout — a gate that mutates the box it was only supposed to inspect, then blames the tree. env -u for the five variables the bootstrap path actually consults fixes both halves; verified with XDG_CONFIG_HOME *and* ZDOTDIR pointed at a decoy, which now stays empty while the check passes. dotfiles-openSUSE had already reached the same fix locally.
- Configmake check's mktemp -d was unguarded, so a failure left $tmp empty and the next line ran mkdir -p "$tmp/.config/tmux/plugins/tpm" — /.config/… on the real filesystem. It now refuses, and a trap replaces the two hand-placed rm -rfs so an interrupted run cleans up too.
- Configmake check accepted a commented-out loader line. grep -q "source .*loader.zsh" matched # source ~/.config/zsh/loader.zsh, and its unescaped . also matched loaderXzsh. Now grep -qE '^[^#]*source .*loader\.zsh', and both ~/.zshrc greps are 2>/dev/null so a missing file reports once instead of twice.
- Fixmake zsh-syntax and make markdown announced a skip and then ran anyway. Each make recipe line runs in its own shell, so each guard's exit 0 only ended that line: with zsh absent, zsh-syntax printed "zsh not installed — skipping" and then ran zsh -n (Error 1); with no global markdownlint-cli2, markdown printed its own skip and then ran the linter (Error 127). Both collapsed into one recipe line, so a skip is a real skip. This is the defect dotfiles-Debian recorded and fixed for its zsh-syntax, noting the shape survived in the other OS repos' Makefiles — this is that repo, and it had both (dotgibson/dotfiles-core#775).
- Featuremake markdown also scanned the wrong files. It globbed '*.md', which is top-level only, while the reusable gate's markdown leg — blocking since dotgibson/dotfiles-core#592 — lints git ls-files '*.md' ':!:core/**', recursively. The three .github/ markdown files were therefore enforced by a required check and invisible locally, so this target could read green against a red PR. Now uses the same pathspec via a new MD_FILES. All nine files lint clean, so nothing was hiding.
- Fix.markdownlint.jsonc's header claimed the rules were "mirrored from Core rather than CI-enforced" and that "lint.yml skips **.md entirely". Both were true when written and neither survived dotgibson/dotfiles-core#592.
- Fixbootstrap.sh no longer fails on a machine without sudo. The escalator is now resolved once (BLIB_SU: empty as root, else sudo, else doas) and used everywhere, instead of a hard-coded sudo at a dozen call sites. A container, a WSL first boot, or a minimal Server image previously died at the first dnf line with sudo: command not found (exit 127) before doing anything.
- Fixbootstrap.sh can no longer stall on an invisible password prompt. The sudo timestamp is primed up front and refreshed in the background for the life of the run, and privileged calls no longer discard stderr. Previously, calls placed after the multi-minute cargo/go builds outlived the 5-minute timestamp and blocked on a prompt written to /dev/null — indistinguishable from a hang.
- FeatureRe-running bootstrap.sh no longer rebuilds the Rust/Go tools from source. The presence guards probed PATH, but ~/.cargo/bin and ~/.local/bin are only added by os/fedora.zsh — i.e. only inside a Core *zsh* — so a run from bash rebuilt all six crates plus yazi every time. provision() now puts both bindirs on PATH first.
- FixA failed step is now reported. Best-effort failures are collected and printed as a closing summary instead of being swallowed, so a box missing carapace, op, lazygit and every cargo tool no longer reports bootstrap complete. --strict exits non-zero.
- Fix/etc/wsl.conf is backed up before it is overwritten (.pre-dotfiles.<epoch>, matching every other managed file). It was the one destructive write with no backup.
- Fix**OS detection no longer matches Fedora-*like* distros by accident.** ID=/ID_LIKE= are parsed as keys; the old grep -qi fedora /etc/os-release also matched Rocky, Alma, CentOS Stream, Nobara, and any incidental substring such as a URL. Fedora-like distros are now an explicit --force-os opt-in.
- Fix--help no longer drifts. It was sed -n '2,17p' "$0", coupled to the header's line numbers — the exact trap core/scripts/sync-core.sh documents. It is a heredoc now.
- Fix.gitignore no longer ignores the tracked core/.claude/ files. The .claude/ pattern was unanchored, so it matched at any depth — a hazard for a vendored tree whose git tree SHA must match core.lock.
- FixThree availability claims in bootstrap.sh that stopped being true. The section header, the dust spinner label and the viddy comment all said dust is not packaged on Fedora; du-dust has shipped continuously (F43 1.2.4-2, F44 1.2.4-5, rawhide 1.2.5-1.fc46). They now name only the tools that genuinely need a source build.
- Fixinstall/packages.txt dates the wget virtualisation to F40, not F42. Fedora's Wget2asWget change targeted Fedora Linux 40 — the note was two releases late. The rest of it (no package literally named wget, default provider wget2-wget, pin wget1-wget for classic semantics) was already correct.
Added
- Featurejc is installed from dnf (dotgibson/dotfiles-core#1208). jc converts the output of common commands (ps, df, dig, ip, ...) to JSON for jq/yq. Fedora packages it on every supported release — jc 1.26.0 on F43, F44, F45 and rawhide (F46), shipping /usr/bin/jc and pulling python3-jc — so it is one line in install/packages.txt, and the atomic edition layers it from the same list with no change. This is the OS half of a fleet ratchet: Core's core-doctor probe and PORTING-MATRIX.md row land after the OS repos install it.
- Featuremake lint stops warning about dnf on every package verb (dotgibson/dotfiles-core#1087, dotgibson/dotfiles-core#1104). Core's capability cross-check warns when a PKG_* verb's leading binary is absent from install/packages.txt. Both declarations here run base-system binaries the list deliberately does not name — that file records what this repo ADDS to a box — so the check fired on 8 verbs in each, burying the one case it exists to catch: a verb naming a tool nothing installs. PKG_UNLISTED_TOOLS declares the exceptions (dnf on the workstation edition, rpm-ostree dnf rpm systemctl on the atomic one) and both declarations now validate with zero warnings. Kept honest from both ends, so it cannot rot into a blanket silencer: a name no declared verb runs is a FAILURE, and so is a name packages.txt actually installs. Needs Core ≥ 7.10.0 vendored — an older validator rejects the key outright.
- ConfigThe atomic edition — Silverblue, Kinoite, any bootc host — as a variant of the same bootstrap (#186; runbook step 3 of dotgibson/dotfiles-core's NON-MUTABLE-HOST-PROPOSAL.md §4.6, the R4 patch measured end to end on a booted fedora-bootc:42 guest, runs 34893435585 and 34895922848). On a booted ostree image dnf install resolves the whole transaction and then refuses ("configured to be read-only"), rpm --import cannot lock the rpmdb, and nothing installed is live until a reboot — so the old script died at its first dnf line there. The marker is /run/ostree-booted, not VARIANT_ID (the bootc image reports ID=fedora and no variant), and everything hangs off that one flag: the RPM Fusion release RPMs, the package list, the lazygit COPR (a repo file into /etc/yum.repos.d — dnf copr needs a plugin that is not live until a reboot), the carapace RPM and 1password-cli all layer with rpm-ostree install --idempotent into the next deployment; the package list is filtered to the names dnf repoquery resolves and rpm -q does not already answer, because one base-provided name refuses the whole layer even under --idempotent (measured — it took the first 38-package layer down); 1Password's fingerprint-verified key is installed under /etc/pki/rpm-gpg and the repo's gpgkey points at it. A second declaration, os/fedora.atomic.capabilities (PROVISIONER=atomic, the rpm-ostree verbs, PKG_APPLY_PENDING=rpm-ostree status --pending-exit-77, PKG_APPLY=sudo systemctl reboot, no count verb — that question is root-only there), is relinked by bootstrap_wire_pre_loader the way dotfiles-openSUSE selects Leap's, so Core's up, nudge and maint runner (Core v7.6.0) see the staged host. The closing line says "N package(s) layered — reboot to apply, then re-run once": the cargo/go tools behind command -v cargo guards are skipped on the first run and picked up on the second, which is the edition's measured cost (a full second deployment plus the from-source builds). 118 code lines in bootstrap.sh, an 18-line declaration delta, no install/ fork and no os/*.zsh fork — the atomic edition's packages *are* Fedora's packages. BOOTSTRAP_PROVISIONER=atomic forces the marker, which is how a container reaches the staging path at all.
- Featuretest/check-flavors.sh, on dotfiles-openSUSE's model, and make suite. Two hand-maintained declarations that are mostly identical by design are the most likely place for this variant to drift, and Core's schema validator checks each file alone. The test asserts the DELTA: PROVISIONER on the atomic file only; the five verbs dnf on one side and rpm-ostree on the other (--idempotent on the install, rpm-ostree upgrade — never bootc upgrade, which refuses a host with a layered package); the staged pair (PKG_APPLY_PENDING / _EXIT agreeing on 77, PKG_APPLY) present only there and the count keys present only on the dnf file; every other key identical both ways; and that the split is still reachable — the marker probe, the CI seam, the relink and the "reboot to apply" closing line survive in bootstrap.sh, and os/fedora.zsh keeps both dnfi arms. Runs anywhere (it reads the repo), so the new test workflow runs it on a plain runner on every PR; make test now runs the suite.
- Featurebootstrap.yml gains a fedora-bootc:42 leg with provisioner: atomic (Core v7.7.0's reusable input, dotgibson/dotfiles-core#1050). A container is not the host — the image has no /run/ostree-booted and a writable /usr — so without the seam every container leg walked the dnf branch and a green tick tested the wrong code (R6, measured). The forced run shims rpm-ostree, makes the rpm shim answer -q with 1 so the base-image filter keeps its names, and fails unless the run prints "reboot to apply"; packages_check runs the same dnf -q provides (38 of 38 in that image). It is not a required check, and the weekly unstubbed sweep skips it by design; .github/core-gates.txt declares real-bootstrap none … with the reason, so the coverage register says VM-only rather than reading green.
- Featurednfi knows the edition. sudo dnf install resolves and then refuses on an atomic host; the alias now reads the declaration bootstrap.sh linked (_core_cap PROVISIONER, band 02 read it first) and expands to sudo rpm-ostree install --idempotent there. The other dnf aliases are unchanged: search / provides / history are read-only and answer on both editions, and upgrading is Core's up, which dispatches through the same declaration.
- PerfThe README opens with a rendered terminal hero (dotgibson/dotfiles-core#948). assets/demo.gif is filmed from assets/demo.tape, which dotfiles-core generates from one shared template for all nine OS and role repos — the same tour everywhere, plus the one command that is this repo's own: up -n resolving to sudo dnf upgrade --refresh. The tape is generated (edit dotfiles-core's assets/hero.tape.in, not the tape); re- render with vhs assets/demo.tape on a Fedora box after a prompt or tooling change, then gifsicle -O3 --lossy=80 --colors 64 — the raw render is over Core's 2 MiB ceiling, the optimised one is not.
- Featureos/fedora.capabilities — this repo's Core v5 capability declaration (dotgibson/dotfiles-core#663, #667). Core's up, maint runner and core-doctor now dispatch through it rather than through package-manager branches inside portable Core modules. Fedora is the repo core/examples/os.capabilities.example was written from, so this is that example made real. MAINT_UNATTENDED_UPGRADE=1 — a versioned, non-rolling distro whose stable updates are what an unattended nightly is for, and what this box already did; the operator's MAINT_SYSTEM_UPGRADE=1 is still the first of the two gates.
- Featuremake capabilities — validates os/*.capabilities against Core's schema via the vendored core/scripts/check-capabilities.sh, and runs as part of make lint.
- Featurebootstrap.sh --dry-run — previews the whole plan (packages *and* the symlink graph) and changes nothing, via the shared lib's BLIB_DRY; prints the wiring tally.
- Featurebootstrap.sh --strict and --force-os; a preflight that checks for the commands the script assumes and fails once with the full list.
- Fixbootstrap.sh now installs the core/ pre-commit guard on a fresh clone (blib_install_core_guard), which the shared lib always intended but was never called.
- Feature1Password's signing key is fingerprint-verified before rpm --import; a mismatch fails closed. The three upstream install scripts are downloaded, sanity-checked, then run — never curl | sh — and starship installs to ~/.local/bin, needing no root.
- SecurityRoot repo scaffolding that GitHub can actually see (it previously existed only under core/, where GitHub ignores it): CONTRIBUTING.md, SECURITY.md, CODEOWNERS, PR and issue templates, .editorconfig, .shellcheckrc, .gitattributes, .pre-commit-config.yaml, this changelog, and a thin Makefile (make lint / check / dry-run / integrity / hooks).
- Featurepackages workflow — resolves every name in install/packages.txt against a matrix of supported Fedora releases, replacing hand-maintained availability prose with a check.
- Featuredu-dust to install/packages.txt — dust is an RPM, not a from-source build. Fedora packages it under the same name Debian does (du-dust, binary /usr/bin/dust), and every fresh box was instead spending minutes on cargo install --locked du-dust for a tool dnf already had. The cargo block stays as a presence-guarded fallback: it is a no-op once the RPM is in, and it is what catches dnf --skip-unavailable silently dropping the name if du-dust ever follows sd/gron out of the repos.
Changed
- Fixbootstrap.sh runs on Core's bootstrap driver, blib_main (dotgibson/dotfiles-core#986). The shared half — the flag loop, the escalator, the sudo keepalive, the Core symlink surface, the OS overlays, the managed ~/.zshrc, the login shell, the closing report — now runs from one definition in core/lib/bootstrap-lib.sh (vendored since v7.4.0). This file declares what it is (BOOTSTRAP_OS=fedora) and keeps only what is Fedora's: the OS guard and preflight as bootstrap_guard, the dnf provisioning as bootstrap_provision (its body is unchanged), the dry-run preview as bootstrap_check, and --no-flatpak / --force-os through bootstrap_flag. 753 → 660 lines. What the driver gives for free: one --help (this repo's half, then the shared flags), and blib_user_bindirs_on_path running before anything probes rather than only inside provision(). One convention change: an unknown flag exits 2 (usage error), not 1, which stays for real failures. Same links, same loader, same exit codes otherwise; make check (the vendored links gate) ran green through the driver on a Fedora box, and a real --dry-run printed the provisioning plan and wrote nothing.
- Fixmake check runs Core's vendored check-links.sh instead of its own copy of the hermetic --links-only gate (dotgibson/dotfiles-core#975, #852). The recipe carried one of four near-identical copies of that block across the fleet, and they drifted the way copies do: #852 found the same non-hermetic-HOME defect in three of them at once and had to fix it three times by hand. The script has been vendored as core/scripts/check-links.sh since then with its consumer named "as intent rather than as a file" — nine releases later no Makefile had followed. Now this one calls it: the Core graph is the script's own default, and --require adds the four things this repo's OS layer wires on top (80-os.zsh, os.capabilities, tmux os.conf, git os.gitconfig). Exit 2 is the drift signal, 1 means the check could not run.
- Configbootstrap.sh runs on Core's escalation, sudo-keepalive and failure-tally helpers instead of private copies (dotgibson/dotfiles-core#867). blib_resolve_su replaces the hand-rolled root/sudo/doas probe — the same $EUID string compare, plus an absolute path for the escalator, and --require only when packages will actually be installed, so --dry-run no longer demands one. blib_sudo_keepalive_start / _stop replace the private refresher loop and its trap. note_fail is now a one-line shim over blib_note_fail, and the closing report comes from blib_failures_report — which means the failures the shared lib records itself (the tpm clone, blib_install_system_file) finally appear in it instead of being dropped. Output and --strict semantics are unchanged; the one visible difference is that hint lines print the escalator's full path (/usr/bin/sudo dnf remove …). Closes this repo's four rows in Core's audit-core.sh §5f ledger, which had read 1/9 (Gentoo only) since dotgibson/dotfiles-core#748.
Removed
- ConfigA stale 4.5 MB orphaned worktree copy under .claude/worktrees/, and the obsolete zsh/local.zsh ignore entry (host overrides have lived at ~/.config/zsh/99-local.zsh since v4).
Added
- Featurejc is installed (dotgibson/dotfiles-core#1208). extra/jc converts the output of ps, ss, dig and friends to JSON (ss -tlnp | jc --ss | jq), next to gron in install/packages.txt. This is the OS-first step of the fleet ratchet: Core adds the detection line, the core-doctor data / net row and the PORTING-MATRIX row once the OS repos install it.
- Featuremake lint stops warning about pacman and checkupdates on every package verb (dotgibson/dotfiles-core#1087, dotgibson/dotfiles-core#1104). Core's capability cross-check warns when a PKG_* verb's leading binary is absent from install/packages.txt, and it fired on 7 verbs here for two different reasons. pacman is base-system, and that file records what this repo ADDS to a box. checkupdates is installed — just under another name, from pacman-contrib, which the list does carry; the check matches package names, not the binaries inside them. PKG_UNLISTED_TOOLS=pacman checkupdates declares both, and the declaration now validates with zero warnings. Kept honest from both ends: a name no declared verb runs is a FAILURE, and so is a name packages.txt actually installs. Needs Core ≥ 7.10.0 vendored — an older validator rejects the key outright.
- PerfThe README opens with a rendered terminal hero (dotgibson/dotfiles-core#948). assets/demo.gif is filmed from assets/demo.tape, which dotfiles-core generates from one shared template for all nine OS and role repos — the same tour everywhere, plus the one command that is this repo's own: up -n resolving to sudo pacman -Syu. The tape is generated (edit dotfiles-core's assets/hero.tape.in, not the tape); re-render with vhs assets/demo.tape on an Arch box after a prompt or tooling change, then gifsicle -O3 --lossy=80 --colors 64 — the raw render is over Core's 2 MiB ceiling, the optimised one is not.
- FeatureThe fleet make vocabulary, and a test/ suite (dotgibson/dotfiles-core#691, reported by dotgibson/dotfiles-core#846). Core declares one canonical set of verbs for every repo that vendors it — help, lint, check, dry-run, packages-check, core-verify, test — so the same word means the same thing in nine repos. This repo was missing three of them. - make dry-run is the canonical spelling of what was make bootstrap-dry. bootstrap-dry is kept as a .PHONY alias: the requirement is that the canonical name *exists*, not that the old one dies. - make check is the full local gate — lint + test + a bootstrap.sh --dry-run that proves the whole plan still builds, provision() included, which no workflow ever executes. Deliberately *not* the hermetic HOME=$(mktemp -d) --links-only run that Debian's and Fedora's check use: that is only hermetic in $HOME, since wire_links ends in blib_set_login_shell, which appends to /etc/shells and runs chsh under sudo. --dry-run reaches the same code with BLIB_DRY=1 and writes nothing. - make test runs test/*.sh, and refuses rather than passing when there is nothing to run — a test: that runs no suite renders as a no-op in the fleet register. - test/check-packages.sh is the suite, modelled on dotfiles-Debian/test/check-packages.sh. It is the old inline packages-check recipe promoted to a real script — same parser (blib_read_pkgs_into, so it reads exactly what bootstrap.sh feeds pacman), same pacman -Si resolution, now shellcheck-visible and invokable by path — plus a duplicate-name check, which needs no package manager and so runs off-Arch too, and a clean skip instead of a hard "pacman not found" failure. It carries no version floors: Debian needs them because a frozen archive resolves neovim at 0.9.5 and calls that healthy, while on a rolling release the repos carry current upstream by construction — the drift risk here is names *moving* (doggo went AUR→extra in 2025) or disappearing. - .github/workflows/packages.yml now runs make test rather than make packages-check, and its path filter gained test/**. The floor requires a workflow that runs the *suite*; a workflow pinned to one target inside it would silently skip the second script anyone adds. make packages-check still runs exactly that script for anyone who types the verb.
- Fixmake markdown, wired into make lint. lint-call.yml's markdown leg has been blocking since dotgibson/dotfiles-core#592, but this repo had no local target for it — a required check nobody could run before pushing, and a .markdownlint.jsonc only CI ever read. MD_FILES uses the gate's own pathspec (git ls-files '*.md' ':!:core/**'), so the local run scans exactly what CI scans, recursively — a '*.md' glob would be top-level only and miss pull_request_template.md. All seven files already pass, so nothing rides along. It skips when markdownlint-cli2 is absent rather than failing like the shellcheck arm: shellcheck is a pacman package, so missing means a box to fix, while markdownlint-cli2 is npm-only. Part of the fleet sweep in dotgibson/dotfiles-core#775.
- Featureos/arch.capabilities — this repo's Core v5 capability declaration (dotgibson/dotfiles-core#663, #667). Core's up, maint runner and core-doctor now dispatch through it rather than through package-manager branches inside portable Core modules. PKG_COUNT_PENDING is checkupdates (from pacman-contrib, already in install/packages.txt), which syncs a copy of the database in user space and never touches the real sync DB. PKG_ASSUME_YES, PKG_UPGRADE_PARTIAL and MAINT_UNATTENDED_UPGRADE are deliberately absent: each omission is a safety statement Core honours exactly, so up -i refuses a partial upgrade and the scheduled runner refuses to upgrade this rolling distro unattended. Do not add them.
- Featuremake capabilities — validates os/*.capabilities against Core's schema via the vendored core/scripts/check-capabilities.sh, and runs as part of make lint.
- Featurebootstrap.sh --dry-run — previews the entire run (package plan + symlink plan + /etc/wsl.conf handling) and changes nothing. The shared library has supported BLIB_DRY end-to-end all along; this layer simply never exposed it.
- FeatureRoot Makefile — lint, bootstrap-dry, packages-check, secrets, core-lock, core-verify. lint reproduces the CI gate exactly, so a failure is visible before pushing.
- Configpackages workflow — resolves every install/packages.txt name against the Arch repos on PR and weekly, without installing. Nothing previously checked the package list, on a rolling release where renames are routine.
- FeatureRoot .gitattributes, .editorconfig, .shellcheckrc — Core ships all three, but EditorConfig/shellcheck/gitattributes resolution is directory-scoped, so they governed core/** only and this repo's own files had no policy.
- SecurityCODEOWNERS, pull_request_template.md, SECURITY.md, and this file.
Fixed
- FixThe tmux auto-attach honours DOTFILES_NO_AUTOTMUX, the fleet's one opt-out name (dotgibson/dotfiles-core#877). MacBook, openSUSE and Gentoo already read it; this layer attached unconditionally for any interactive TTY, which is how dotfiles-core's README hero render — a vhs session that sources this layer — typed its whole tour into a fresh main session. Core's gen-hero-tape.sh now refuses to render a hero on a layer that does not honour the knob. Export DOTFILES_NO_AUTOTMUX=1 for any harness that drives an interactive zsh and must not land in tmux.
- Fixmake markdown probed for a global, unpinned markdownlint-cli2 — so on a normal box it never linted anything (dotgibson/dotfiles-core#873). Nothing in this repo's bootstrap installs markdownlint-cli2 globally; it is npm-only. So unless the operator had separately run npm i -g markdownlint-cli2, the guard fired on every invocation and the target skipped, cleanly and with exit 0, forever. That is a correct guard doing exactly what it says — and a local mirror of a blocking CI gate that has never mirrored anything. dotgibson/dotfiles-core#775 fixed this target's skip guard and its file scope; neither defect could bite while the target never ran at all. And where the binary *was* installed it was whatever version npm last put there, while lint-call.yml installs the pinned MARKDOWNLINT_VERSION — so a rule that changes across a bump reds a required check against a green local run. It now runs the pinned version through npx, reading the number from the vendored core/scripts/tool-versions.env rather than restating it, and refuses rather than guess if that pin is unreadable — a silently-unpinned lint being the thing this fixes. npx needs only node, which is far likelier present than a global markdownlint install, and still self-skips without it so make lint works on a bare box. Converges on the shape dotfiles-Offense and dotfiles-Defense already run.
- Fixbootstrap.sh's PATH is not the shell's PATH — adopt blib_user_bindirs_on_path (dotgibson/dotfiles-core#748). go install pins GOBIN to ~/.local/bin, and ~/.local/bin, ~/.cargo/bin and $GOBIN reach PATH only through the zsh layer, i.e. only inside a Core shell — which does not exist while bootstrap.sh runs. So every command -v <tool> guard here was answered by the PATH of whatever shell launched the bootstrap: on a fresh box, bash, with none of them. That is wasted work when the guard picks whether to reinstall, and a wrong answer when it picks a branch — dotfiles-openSUSE probed command -v mise for a mise mise.run had written to ~/.local/bin moments earlier, both arms of its Go fallback missed, and the run exited 2 on every bootstrap. No stubbed CI leg can see that: a stub installs nothing, so "is the tool present afterwards" can never fail under one. Core has shipped blib_user_bindirs_on_path for exactly this since dotgibson/dotfiles-core#425 — it resolves CARGO_HOME and GOBIN/GOPATH rather than hard-coding them, and adds only directories that exist, so it is called again after an installer creates one. Arch escapes the worse half of this by luck — pacman puts mise in /usr/bin, so the fallback arm resolves — but _dotfiles_go_install's presence guard could never see a tool an earlier run had installed, so every bootstrap re-ran every go install.
- Fixbootstrap.sh could exit 0 having installed nothing. blib_read_pkgs' exit status is lost inside the < <(…) process substitution, so a missing or empty install/packages.txt produced an empty array, a failed pacman -S, a zero-iteration fallback loop, and a success message. It now refuses to continue.
- Configbootstrap.sh silently swallowed per-package install failures. The fallback loop discarded every error, so a handful of renamed packages yielded a green run and a half-provisioned machine. Failures are now collected, reported at the end, and produce a non-zero exit — after wiring completes, so the box is still usable.
- Fixbootstrap.sh clobbered an existing /etc/wsl.conf. Every other mutation in this system backs up first (blib_link → .pre-dotfiles.<epoch>); this one overwrote, losing any local [automount] / [boot] / [network] settings on a re-run of a script documented as idempotent. It now no-ops when already correct and backs up otherwise.
- Fixpacorphans passed all orphans to pacman as a single argument. zsh does not word-split unquoted parameters, so pacman -Rns $orphans handed over one newline-joined string; now ${(f)orphans}. zsh -n cannot catch this — the syntax is valid.
- Fix--help was coupled to the file's header line numbers (sed -n '2,17p' "$0"), so editing the banner silently drifted the help text. Replaced with a usage() heredoc, matching the fix core/scripts/sync-core.sh already documents.
- SecurityStale .gitignore entry zsh/local.zsh — a pre-v4 path that does not exist in this repo. Added credential, .envrc/.direnv and key-material patterns (direnv is installed by packages.txt and hooked into every shell).
Changed
- Configbootstrap.sh runs on Core's bootstrap driver (dotgibson/dotfiles-core#976, #986; vendored here since v7.4.0). The file now declares what it is (BOOTSTRAP_OS=arch, BOOTSTRAP_STRICT_DEFAULT=1 — a package that did not install is exit 1, always, as before) and defines the hooks that are Arch's: the Arch check and the --only/--skip note as bootstrap_guard, the pacman phase as bootstrap_provision (body unchanged: -Syu first, bulk --needed then per-package, the Go builds, the AUR hints, wsl.conf, Flathub), the dry-run preview as bootstrap_check, --no-flatpak as bootstrap_flag, the rolling-release hints as bootstrap_closing. The flag loop, the escalator, the sudo keepalive, the symlink surface, the managed ~/.zshrc, the login shell and the closing report come from core/lib/bootstrap-lib.sh :: blib_main. The -E ERR trap stays and now steps aside for the driver's own return verdicts. 446 → 421 lines. Two visible changes: an unknown flag exits 2 (the driver's usage-error code; it was 1), and --strict is accepted as a no-op spelling of the default.
- Configbootstrap.sh runs on Core's escalation, sudo-keepalive and failure-tally helpers (dotgibson/dotfiles-core#973). It had no root check at all — it leaned on the lib's default of sudo through _blib_priv, an underscore-private symbol — and its ledger held one kind of miss (a pacman package). Now blib_resolve_su pins the escalator up front (root runs directly, else sudo, else doas; an explicit BLIB_SU= still wins, which is what CI sets), every privileged line goes through the lib's public blib_priv, blib_sudo_keepalive_start / _stop keep the timestamp warm across the go builds, and blib_note_fail records the per-package misses, the go installs and the Flathub remote alongside what the shared lib records itself. blib_failures_report prints the tally and the exit code stays 1 when there was anything in it — Arch's contract, unchanged. The carapace / viddy / op lines stay as the deliberate manual-step hints they are. Closes this repo's four rows in Core's audit-core.sh §5f ledger.
- Fixmake core-lock no longer regenerates core.lock; it explains why and points at the fan-out. (dotgibson/dotfiles-core#593) The target was added here to satisfy core.lock's own header instruction, “Regenerate … with: make core-lock”. That instruction was the bug — Core removed it in dotfiles-core#454, because core.lock is written by sync-core.sh in the same commit as the subtree pull and was never meant to have a second writer. Keeping the generator meant this repo owned a second definition of a format Core owns, and it had already drifted from it in three ways: it hardcoded core_branch=main, so regenerating a lock that had been pinned to a released commit silently replaced that provenance with a branch name; it re-emitted the removed header line, reintroducing the very instruction it existed to satisfy; and it predates dotfiles-core#453, which renamed the field to core_ref — so running it now would emit a lock the rest of the fleet disagrees with. core-verify is unchanged and remains the way to check this repo's vendored core/.
- ConfigPrivilege escalation goes through the library's _blib_priv, honouring BLIB_SU, instead of a hardcoded sudo. This makes bootstrap.sh work as root and on doas-only boxes — and makes provision() runnable in a container, which Arch base images (no sudo) previously prevented.
- FixArch derivatives are accepted with a warning rather than refused: the guard now falls back to ID_LIKE=…arch…, so EndeavourOS/Manjaro/CachyOS work.
- Configgo install for sesh logs its errors to a file instead of /dev/null, and the module version is overridable via SESH_VERSION (still defaulting to latest — see the note in provision()). carapace is a printed paru hint and takes no version override, per the analysis in #89: go install cannot work for any version of it. That error logging is what makes such a failure visible in the first place — the old /dev/null form is precisely why the carapace call could fail on every bootstrap without anyone noticing.
- ConfigA failed run now says where it failed (ERR trap), and a successful one prints the wiring tally and points at core-doctor.
- Fixbootstrap.sh installs the local core/ pre-commit guard on a fresh clone (blib_install_core_guard), catching a hand-edit at commit time rather than waiting for core-integrity.yml at PR time.
Added
- Featurejc joins the apt base stack (dotgibson/dotfiles-core#1208) — kellyjonbrazil's command-output-to-JSON converter, next to gron in install/packages.txt. Untiered, because every target resolves it: noble 1.25.1 (universe), 26.04 1.25.5 (universe), trixie 1.25.4 and kali-rolling 1.25.7 (main). This is the OS-repo half of the fleet ratchet; Core's detection line and core-doctor row follow once the fleet installs it. The name counts quoted in the packages and bootstrap workflow comments move to 33 untiered and 45 on Kali. The Kali figure had drifted: it read 47 while the list resolved 44 there.
- Perfmake lint stops warning about apt-get, apt-cache and dpkg on every package verb (dotgibson/dotfiles-core#1087, dotgibson/dotfiles-core#1104). Core's capability cross-check warns when a PKG_* verb's leading binary is absent from install/packages.txt. All three are base-system on any Debian box, and that file records what this repo ADDS — so the check fired on 10 verbs per declaration, the loudest in the fleet, because the verbs here split across three binaries rather than one. That buried the one case it exists to catch: a verb naming a tool nothing installs. PKG_UNLISTED_TOOLS=apt-get apt-cache dpkg declares the exceptions in both the Debian and Kali declarations, and both now validate with zero warnings. Kept honest from both ends: a name no declared verb runs is a FAILURE, and so is a name packages.txt actually installs. Needs Core ≥ 7.10.0 vendored — an older validator rejects the key outright.
- PerfThe README opens with a rendered terminal hero (dotgibson/dotfiles-core#948). assets/demo.gif is filmed from assets/demo.tape, which dotfiles-core generates from one shared template for all nine OS and role repos — the same tour everywhere, plus the one command that is this repo's own: up -n resolving to sudo apt-get full-upgrade. The tape is generated (edit dotfiles-core's assets/hero.tape.in, not the tape); re-render with vhs assets/demo.tape on a Debian box after a prompt or tooling change, then gifsicle -O3 --lossy=80 --colors 64 — the raw render is over Core's 2 MiB ceiling, the optimised one is not.
- Featuremake test and make core-verify — the two canonical fleet verbs this repo was missing (dotgibson/dotfiles-core#691, reported by dotgibson/dotfiles-core#846's register). Core now declares one make vocabulary for every repo that vendors it — help, lint, check, dry-run, packages-check, core-verify, test — after nine repos turned out to speak nine dialects, with "verify core" alone spelled five ways. test runs the repo's own suite, which today is exactly test/check-packages.sh, so packages-check is its only prerequisite rather than a copied command line; it is declared .PHONY because it names the test/ directory it runs and an undeclared test: would report "up to date" and run nothing. The requirement is that the canonical name exists, not that the old one dies, so integrity stays as a .PHONY alias of core-verify and muscle memory survives. Verify from a Core checkout beside this one with make fleet-vocabulary.
- Configos/debian.capabilities and os/debian.kali.capabilities — this repo's Core v5 capability declarations (dotgibson/dotfiles-core#663, #667). Core's up, maint runner and core-doctor now dispatch through them. The apt verbs are identical on all three targets, so almost everything is shared; exactly one key differs, and it is why there are two files: Kali does not declare MAINT_UNATTENDED_UPGRADE, because engagement boxes are updated by hand between ops and a background full-upgrade mid-engagement destroys the reproducibility findings rest on. bootstrap.sh relinks the Kali file over the default, keyed on the same $OS_ID that scripts/pkg-filter.sh tiers install/packages.txt on — so the declaration and the package list agree by construction rather than by coincidence, and a future tier is one new file with no code change.
- FeatureTOOLS_OPTIN declares jj and ast-grep opt-in on this family — they are — on noble and trixie and cargo-only on Kali. Core's single list could not say "opt-in there, expected here", so core-doctor reported them as missing-and-expected on every box (dotgibson/dotfiles-core#666). dust is deliberately not in that list: it is present here, just installed out of band under its plain name.
- Featuremake capabilities — validates os/*.capabilities against Core's schema via the vendored core/scripts/check-capabilities.sh, and runs as part of make lint.
- FeatureInitial dotfiles-Debian OS-native layer: the Debian-family repo the fleet had planned, cancelled, and left a note against in dotfiles-core/scripts/os-repos.txt. Targets Ubuntu 24.04 LTS on headless SSH-only machines; debian:trixie is a blocking CI lane because the repo is named for the family, not one distro.
- Featureinstall/tool-versions.env — version + SHA-256 pins for the twelve tools a frozen LTS cannot supply, with scripts/update-tool-checksums.sh to refresh them (--check to assert, --latest to report newer upstream releases).
- Perftest/check-packages.sh — resolves every manifest name with apt-get install --simulate (apt's real resolver, unlike apt-cache, which exits 0 on unknown names and non-zero on installable virtual ones) and enforces the # min:X.Y.Z version floors declared in install/packages.txt. Wired to make packages-check and the packages workflow.
- Featureverified_tree_install alongside verified_install in bootstrap.sh. Neovim ships a directory tree (bin/, lib/, share/nvim/runtime), not a lone binary — copying just bin/nvim yields an editor whose $VIMRUNTIME points at nothing.
- Featureverified_install handles all three shapes upstreams actually ship — .tar.gz, plain .gz (tree-sitter) and .zip (procs) — and takes an optional inner-binary name for assets whose executable is named differently from the command.
Changed
- Fixbootstrap.sh runs on Core's bootstrap driver, blib_main (dotgibson/dotfiles-core#986). The shared half — the flag loop, the escalator, the sudo keepalive, the Core symlink surface, the OS overlays, the managed ~/.zshrc, the login shell, the closing report — now runs from one definition in core/lib/bootstrap-lib.sh (vendored since v7.4.0). This file declares what it is (BOOTSTRAP_OS=debian) and keeps only what is Debian's: the three-distro OS guard and preflight as bootstrap_guard, the apt provisioning with its tiered package list and pinned out-of-band installs as bootstrap_provision (body unchanged), the dry-run preview as bootstrap_check, the distro tier's capability re-link as bootstrap_wire_pre_loader (the slot exists for exactly this: it must land before the managed ~/.zshrc is written), the shadowed-tools report as bootstrap_closing, and --no-upgrade / --no-unattended / --force-os through bootstrap_flag. 962 → 876 lines. One convention change: an unknown flag exits 2 (usage error), not 1, which stays for real failures. Same links, same loader, same exit codes otherwise.
- Fixmake check runs Core's vendored check-links.sh instead of its own copy of the hermetic --links-only gate (dotgibson/dotfiles-core#975, #852). The recipe carried one of four near-identical copies of that block across the fleet, and they drifted the way copies do: #852 found the same non-hermetic-HOME defect in three of them at once and had to fix it three times by hand. The script has been vendored as core/scripts/check-links.sh since then with its consumer named "as intent rather than as a file" — nine releases later no Makefile had followed. Now this one calls it: the Core graph is the script's own default, and --require adds the four things this repo's OS layer wires on top (80-os.zsh, os.capabilities, tmux os.conf, git os.gitconfig). Exit 2 is the drift signal, 1 means the check could not run.
- Configbootstrap.sh runs on Core's escalation, sudo-keepalive and failure-tally helpers instead of private copies (dotgibson/dotfiles-core#867). blib_resolve_su replaces the hand-rolled root/sudo/doas probe — the same $EUID string compare, plus an absolute path for the escalator, and --require only when packages will actually be installed, so --dry-run no longer demands one. blib_sudo_keepalive_start / _stop replace the private refresher loop and its trap. note_fail is now a one-line shim over blib_note_fail, and the closing report comes from blib_failures_report — which means the failures the shared lib records itself (the tpm clone, blib_install_system_file) finally appear in it instead of being dropped. Output and --strict semantics are unchanged; the one visible difference is that hint lines print the escalator's full path (/usr/bin/sudo apt-get purge …). Closes this repo's four rows in Core's audit-core.sh §5f ledger, which had read 1/9 (Gentoo only) since dotgibson/dotfiles-core#748.
Notes on what is deliberately absent
- Changeneovim and tree-sitter-cli are not in install/packages.txt. Ubuntu 24.04 resolves both (0.9.5 and 0.20.8) and both are far below what Core requires (0.12 and 0.26.1) — which is exactly why the version-floor check exists; resolution alone would have called that box healthy.
- Changeyq in either spelling: noble's yq is kislyuk's Python tool, and the Go yq-go this stack means is sid-only. Installed via go install instead.
- Changecargo, and therefore yazi/ast-grep/viddy: noble's rustc is 1.75, too old to build them. All are HAVE_*-guarded in Core, so the shell degrades cleanly.
- Changewl-clipboard/xclip: no display on these headless ubuntu/debian boxes. Tiered in for Kali (# only:kali), which runs under WSL2 with WSLg. Copying still works regardless: Core's clip falls back to OSC 52 when it finds no local backend. Pasting does not, deliberately — see the README's *Headless clipboard* note.
- ChangePPAs, in any form. They are keyed to an Ubuntu series and would break the Debian lane; vendor-signed apt repos (Charm, 1Password) are used instead.
Fixed
- FixTOOLS_OPTIN opts yazi and viddy in on both editions (dotgibson/dotfiles-core#1239). dotgibson/dotfiles-core#1210 corrected PORTING-MATRIX.md's Kali cells for both tools from cargo³ to cargo²¹: this repo installs Kali's cargo but cargo-installs neither. That made them cell-level opt-ins, and without them core-doctor rendered a false ✗ for each on every Kali box. Core's fan-out audit caught the stale declaration and held the v7.14.0 sync back until this landed.
- FixThe tmux auto-attach honours DOTFILES_NO_AUTOTMUX, the fleet's one opt-out name (dotgibson/dotfiles-core#877). MacBook, openSUSE and Gentoo already read it; this layer attached unconditionally for any interactive TTY, which is how dotfiles-core's README hero render — a vhs session that sources this layer — typed its whole tour into a fresh main session. Core's gen-hero-tape.sh now refuses to render a hero on a layer that does not honour the knob. Export DOTFILES_NO_AUTOTMUX=1 for any harness that drives an interactive zsh and must not land in tmux. DEBIAN_NO_TMUX keeps working alongside it.
- Fixmake markdown probed for a global, unpinned markdownlint-cli2 — so on a normal box it never linted anything (dotgibson/dotfiles-core#873). Nothing in this repo's bootstrap installs markdownlint-cli2 globally; it is npm-only. So unless the operator had separately run npm i -g markdownlint-cli2, the guard fired on every invocation and the target skipped, cleanly and with exit 0, forever. That is a correct guard doing exactly what it says — and a local mirror of a blocking CI gate that has never mirrored anything. dotgibson/dotfiles-core#775 fixed this target's skip guard and its file scope; neither defect could bite while the target never ran at all. And where the binary *was* installed it was whatever version npm last put there, while lint-call.yml installs the pinned MARKDOWNLINT_VERSION — so a rule that changes across a bump reds a required check against a green local run. It now runs the pinned version through npx, reading the number from the vendored core/scripts/tool-versions.env rather than restating it, and refuses rather than guess if that pin is unreadable — a silently-unpinned lint being the thing this fixes. npx needs only node, which is far likelier present than a global markdownlint install, and still self-skips without it so make lint works on a bare box. Converges on the shape dotfiles-Offense and dotfiles-Defense already run.
- Fixbootstrap.sh's PATH is not the shell's PATH — adopt blib_user_bindirs_on_path (dotgibson/dotfiles-core#748). Replaces the hand-rolled export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH" prelude. ~/.local/bin, ~/.cargo/bin and $GOBIN reach PATH only through the zsh layer, i.e. only inside a Core shell — which does not exist while bootstrap.sh runs. So every command -v <tool> guard here was answered by the PATH of whatever shell launched the bootstrap: on a fresh box, bash, with none of them. That is wasted work when the guard picks whether to reinstall, and a wrong answer when it picks a branch — dotfiles-openSUSE probed command -v mise for a mise mise.run had written to ~/.local/bin moments earlier, both arms of its Go fallback missed, and the run exited 2 on every bootstrap. No stubbed CI leg can see that: a stub installs nothing, so "is the tool present afterwards" can never fail under one. Core has shipped blib_user_bindirs_on_path for exactly this since dotgibson/dotfiles-core#425 — it resolves CARGO_HOME and GOBIN/GOPATH rather than hard-coding them, and adds only directories that exist, so it is called again after an installer creates one. The directory this script installs into is mkdir -p'd before the helper runs, and the second refresh sits immediately after the verified_install block: the helper adds only directories that already exist, so a straight swap for the old unconditional export would have dropped ~/.local/bin for the whole first run and made the command -v uv (ty route) and command -v atuin (daemon unit) probes miss binaries this script had just written. The literal list was a fork of Core's helper and was missing GOBIN — harmless only because this script sets it to ~/.local/bin itself. A second call now runs after the verified_install block, which on a first run is what creates ~/.local/bin.
- Fixmake check was not hermetic, and wrote Core into your real config dir (dotgibson/dotfiles-core#852). The target promises "a hermetic --links-only run against a throwaway HOME" and redirected only HOME — but bootstrap.sh resolves its target as CONFIG="${XDG_CONFIG_HOME:-$HOME/.config}", and core/lib/bootstrap-lib.sh defaults XDG_CONFIG_HOME, XDG_STATE_HOME, XDG_CACHE_HOME, XDG_DATA_HOME and ZDOTDIR the same way. A :-/:= default applies only when the variable is unset, so for anyone who exports XDG_CONFIG_HOME the run wired Core into their live config tree and then failed its own assertions, which look under the temp dir bootstrap never touched — a gate that mutates the box it was only supposed to inspect, then blames the tree. Reproduced against the byte-identical recipe in dotfiles-Fedora (dotgibson/dotfiles-Fedora#153); only the package manager differs between the two repos here, and the --links-only path is the same Core code. Fixed with env -u for the five variables the bootstrap path consults — the fix dotfiles-openSUSE had already reached locally.
- Configmake check's mktemp -d was unguarded, so a failure left $tmp empty and the next line ran mkdir -p "$tmp/.config/tmux/plugins/tpm" — /.config/… on the real filesystem. It now refuses, and a trap replaces the two hand-placed rm -rfs so an interrupted run cleans up too.
- Configmake check accepted a commented-out loader line. grep -q "source .*loader.zsh" matched # source ~/.config/zsh/loader.zsh, and its unescaped . also matched loaderXzsh. Now grep -qE '^[^#]*source .*loader\.zsh', and both ~/.zshrc greps are 2>/dev/null so a missing file reports once instead of twice.
- Fixmake zsh-syntax printed "zsh not installed — skipping" and then ran zsh -n anyway. Each make recipe line runs in its own shell, so the guard's exit 0 only ended that line. The same shape is still present in the other OS repos' Makefiles.
- Fixmake markdown had the same defect, and it was still live here. Without a global markdownlint-cli2 it printed "not installed — skipping" and then ran the linter anyway, failing with Error 127. Collapsed into one recipe line, so the skip is a real skip. Of the fleet, dotfiles-Fedora carries this target byte-for-byte (also live); dotfiles-Offense and dotfiles-Defense have the same shape guarding npx instead (latent — it only bites where npx is absent); dotfiles-MacBook already fixed its copy; Alpine, Arch, Gentoo and openSUSE have no markdown target at all.
- Featuremake markdown also scanned the wrong files. It globbed '*.md', which is top-level only, while the reusable gate's markdown leg — blocking since dotgibson/dotfiles-core#592 — lints git ls-files '*.md' ':!:core/**', recursively. The three .github/ markdown files were therefore enforced by a required check and invisible locally, so this target could read green against a red PR. It now uses the same pathspec via a new MD_FILES. All nine files lint clean, so nothing was hiding.
- Fix.markdownlint.jsonc's header claimed the rules were "mirrored from Core rather than CI-enforced" and that "lint.yml skips **.md entirely". Both were true when written and neither survived dotgibson/dotfiles-core#592.
No changes match this filter.
Only repos with a CHANGELOG.md appear here. The distro layers track Core through their vendored core/ and don’t keep their own yet — they’ll show up automatically once they do.