feat(gitleaks): hygiene guard for private project identifiers - #98
Merged
Conversation
Add the hygiene guard specs/dev-services Task 1 places ahead of the rest of the bundle, so every later commit ships under it rather than being audited afterwards (D-8). scripts/gitleaks-identifier-rules.sh derives the private-project-identifier rule block from an untracked machine-local file and splices it into .gitleaks.toml between markers, leaving the rest of the config byte for byte intact. The generated block is committed, so the guard works on a fresh checkout and in CI; the source file is only needed to regenerate it (D-9). An absent, empty, or unparseable source is a visible refusal with a non-zero exit rather than a narrower rule set (REQ-D1.4): a guard that quietly matches less than intended is worse than one that refuses to run. Output is case-folded and sorted, so a given set produces the same block on any machine whatever order it is listed in (REQ-D1.5), which is what makes --check meaningful. The derived allowlist is path-scoped to the identifiers pre-existing tracked occurrences, plus .gitleaks.toml itself, which otherwise flags its own patterns; it carries its removal condition inline (REQ-D1.3). Assertions extend the existing custom-rule harness rather than starting a parallel one, since this is the same rule class an earlier bundle already built one for. They run the guard in --staged mode, which is how lefthook invokes it and the only mode where rule-level path allowlists see the repo-relative paths they are written against. The rules are not live yet: generating them needs the identifier set, which by REQ-D1.1 and D-9 is deliberately absent from every artifact an unattended worker can read. Recorded in the bundle Awaiting input with the one-time operator action. Planwright-Task: dev-services/1
Planwright-Task: dev-services/1
Three findings from the convergence pass over Task 1. --help printed its header with a hard-coded `sed -n 2,30p`, which had already drifted past the end of the header and was emitting `set -euo pipefail` as if it were documentation. Replaced with a shape-based extraction that stops at the first non-comment line, so the help text cannot drift again as the header grows. Pinned by a harness assertion confirmed to fail against the old range and pass against the fix. shellcheck SC2209 flagged `mode=write` and its three siblings in the argument parser: an unquoted bareword that is also a command name reads as a missing command substitution. Quoted; no behaviour change. shellcheck now exits clean on both scripts. Added an assertion that gitleaks can actually load the generated config. --check compares the config against a regeneration of itself, so it would certify a block the scanner cannot parse, and the failure would surface as a broken hook for everyone rather than as a test failure here. Confirmed the assertion discriminates: gitleaks exits non-zero on a malformed config and zero on the generated one. Planwright-Task: dev-services/1
Planwright-Task: dev-services/1
Task 1 Done-when requires the machine-local identifier file to be mode 0600, but nothing checked it: the generator would happily read a world-readable file and emit rules from it, leaving the requirement true only for as long as whoever created the file remembered. Checked now, with the same shape scripts/ssh-lan-config-sync.sh already uses for the machine-local file it reads: GNU stat with a BSD fallback, 600 or 400 accepted, anything else refused with a message naming the actual mode and the chmod that fixes it. That script is the precedent this repo already set for a machine-local file holding something private, so the generator follows it rather than inventing a second posture. Mode failures get their own exit code (6) rather than being folded into the usage error, keeping the documented exit contract one-fault-per-code. The harness fixtures are created 0600 to match, and a new case asserts both directions. Planwright-Task: dev-services/1
inkatze
marked this pull request as ready for review
August 6, 2026 20:41
This was referenced Aug 7, 2026
inkatze
added a commit
that referenced
this pull request
Aug 7, 2026
Task 7 is the bundle's one manual unit -- a run-and-observe loop on
hardware that produces no repo artifact a worker can generate -- so this
empty commit is the artifact: it carries the trailer the derivation
reads, and the evidence below is the record.
All four Done-when clauses, verified on the physical Linux host:
Two consecutive runs converge
Confirmed by the operator: the second run reported zero changed
tasks and zero failures.
Both services running and reachable after a reboot
scripts/dev-services-runtime-test.sh passes 6/6 after a genuine
reboot: PostgreSQL and Valkey each active, enabled, and accepting a
connection on their declared 127.0.0.1 ports. The units'
ActiveEnterTimestamp reads 2026-04-15 11:33 -- this host's pre-NTP
RTC value -- which places their start inside early boot rather than
a later manual `systemctl start`. Worth stating because the previous
check looked identical and was not: the services had been installed
six minutes AFTER that boot, so "active and enabled" proved nothing
about surviving one.
An interrupted run is followed by one that converges, no manual cleanup
Reached by accident rather than by design, which makes it a fair
test. Verified after the fact: `dpkg --audit` reports nothing, so no
package was left half-configured, and both harnesses pass --
including postgresql-access-test.sh creating, migrating and dropping
a scratch database and asserting port 5432 is loopback-only.
The role's macOS-only content absent, ~/.my.cnf specifically
Absent, and absent across a reboot. It was NOT absent when this
clause was first checked: a symlink dated Jul 24 predated the
platform split, and the Darwin guard stops the file being recreated
without removing one already there. Removed by hand on the host, by
explicit operator choice over declaring it absent in the role -- so
another host carrying the same leftover would still fail this clause
with no guard to catch it.
Not closing the bundle. Task 1's mechanism shipped in #98, but
REQ-D1.2 and REQ-D1.3 stay unmet until the machine-local identifier file
exists and the generated block lands in .gitleaks.toml, which is empty
today. Status therefore stays as it is, and the Awaiting-input entry for
Task 1 stays with it.
Planwright-Task: dev-services/7
Claude-Session: https://claude.ai/code/session_01F8oKrXASMQSXMVjSvuJpn9
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds the secret-scanner hygiene guard
specs/dev-servicesplaces first in its bundle, so every later commit in the bundle ships under it rather than being audited afterwards (D-8). The guard derives its rules from an untracked machine-local identifier file and splices a generated block into.gitleaks.toml; the block is committed, so the guard works on a fresh checkout and in CI, while the identifier set itself stays off this public repo (D-9, REQ-D1.1).The guard is not live yet, and cannot be made live unattended. Generating the rules needs the identifier set, which by REQ-D1.1 and D-9 is deliberately absent from every artifact a worker can read. Inventing one would produce exactly the silently-narrower rule set REQ-D1.4 forbids, so the generator refuses instead — the behaviour the tests assert.
.gitleaks.tomlis therefore unchanged by this PR. The one-time operator action is recorded in the bundle's## Awaiting inputand repeated at the bottom of this summary.How to review:
scripts/gitleaks-identifier-rules.shis the whole mechanism;scripts/gitleaks-rules-test.shis how each clause of the task's Done-when is pinned. Runscripts/gitleaks-rules-test.shto see all sixteen assertions.specs/dev-services/kickoff-brief.mdfecce5e40273806bfd25dc3f23b3e192e79de088recomputed againstorigin/mainand matched at pre-flight.scripts/gitleaks-rules-test.sh— extended, not duplicated, per kickoff finding L4. The generator refuses visibly for a source that is absent, empty, unparseable, or looser than 0600; its output is order- and case-independent; a staged new occurrence is blocked while a staged edit to an already-tracked path passes (both directions of Done-when, run in the--stagedmode lefthook actually uses);--writeconverges and preserves the config's other rules;--checkrejects a hand edit inside the generated block; the generated config is one gitleaks can actually load. The byte-for-byte check against the committed block skips visibly on a machine without the source file.Operator action, once, to make the guard live: create
~/.config/dotfiles/private-identifiers(mode 0600, one identifier per line), runscripts/gitleaks-identifier-rules.sh --write, and commit the generated block. Verified forward-compatible against the currentorigin/main.gitleaks.toml, including the filename allowlist entries added by #97.Audit record
Lens coverage (final pass)
Walked inline rather than fanned out: this session does not spawn sub-agents absent an explicit request. Declared rather than silently substituted, per the same scoping the kickoff brief recorded.
--helpprinted code past its hard-coded line range; the marker/sed delimiter collision found by probing the untested--writepath.escaping'''would break the generated TOML; verified the splice against main's advanced configAuto-applicable
mode=writeand three siblings read as a missing command substitutiond34b7aaAgent-resolvable
--helpprintedset -euo pipefailas documentation: the hard-codedsed -n '2,30p'had drifted past the header's endgitleaks-rules-test.shcase 13help-header-onlymise run lintcleand34b7aa--checkcompares the file against a regeneration of itself and would certify a block gitleaks cannot parsegitleaks-rules-test.shcase 12gen-config-loadsd34b7aagitleaks-rules-test.shcase 14gen-loose-mode/gen-accepts-0600mise run lintcleanscripts/ssh-lan-config-sync.sh732a050Needs sign-off
Needs human judgment
scripts/ssh-lan-config-sync.shrefuses its machine-local file unless 600 or 400tasks.md## Awaiting input--write(the D-9 design, and what the parked note describes)Declined log
mktempandmvleaves a.gitleaks.toml.XXXXXXtemp in the repo rootgit statusas untracked and matches no tracked glob, so it cannot be silently committed; adding an untestable trap for an already-loud failure is out of proportion'''would terminate the generated TOML literal earlyscripts/ssh-lan-config-sync.shspecs/_observations/entries/2026-08-05-shellcheck-not-wired-into-guards-a7880e73.mdPending sign-off
Final pass summary
Iteration 3 of the
--nestedpolish loop produced zero new dispositions, which is convergence. Six findings across three iterations: one applied from a tool citation, three resolved with evidence, two declined with rationale, one irreducible fork queued.dispatch_isolationresolved toper-step; this backend hosts steps in one session, so it degraded toper-unithosting — capability only, with state-safety preserved (no step wrote placement state, and PR creation stayed the terminal step).Tooling:
scripts/gitleaks-rules-test.sh16/16 with one visible skip · shellcheck clean on both scripts ·mise run lint(yamllint, ansible-lint, syntax-check) clean ·lefthook run pre-commitclean.One environment note:
mise run lintfails in an unattended worker because the session runs withLC_ALL=Cand ansible-lint aborts on a non-UTF-8 locale before linting anything. Re-run with a UTF-8 locale it passes. Unrelated to this diff, which touches no YAML; recorded as an observation.