Skip to content

feat(gitleaks): hygiene guard for private project identifiers - #98

Merged
inkatze merged 5 commits into
mainfrom
planwright/dev-services/task-1
Aug 6, 2026
Merged

feat(gitleaks): hygiene guard for private project identifiers#98
inkatze merged 5 commits into
mainfrom
planwright/dev-services/task-1

Conversation

@inkatze

@inkatze inkatze commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Summary

Adds the secret-scanner hygiene guard specs/dev-services places 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.toml is therefore unchanged by this PR. The one-time operator action is recorded in the bundle's ## Awaiting input and repeated at the bottom of this summary.

How to review: scripts/gitleaks-identifier-rules.sh is the whole mechanism; scripts/gitleaks-rules-test.sh is how each clause of the task's Done-when is pinned. Run scripts/gitleaks-rules-test.sh to see all sixteen assertions.

  • Tasks: dev-services/1 · REQs: REQ-D1.1, REQ-D1.2, REQ-D1.3, REQ-D1.4, REQ-D1.5 · Decisions: D-8, D-9
  • Brief: specs/dev-services/kickoff-brief.md
  • Freshness gate: anchor fecce5e40273806bfd25dc3f23b3e192e79de088 recomputed against origin/main and matched at pre-flight.
  • Tests: 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 --staged mode lefthook actually uses); --write converges and preserves the config's other rules; --check rejects 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.
  • Pending sign-off: none.

Operator action, once, to make the guard live: create ~/.config/dotfiles/private-identifiers (mode 0600, one identifier per line), run scripts/gitleaks-identifier-rules.sh --write, and commit the generated block. Verified forward-compatible against the current origin/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.

Lens Findings Notes
Correctness, logic, edge cases 2 F2 --help printed code past its hard-coded line range; the marker/sed delimiter collision found by probing the untested --write path
Security 1 F4 the source file's documented 0600 was never enforced; regex/TOML injection closed by the narrow identifier charset plus . escaping
Error handling and failure modes 1 F5 (declined) a failed write can leave a temp file; the three degraded-source refusals are enumerated and tested separately
Performance n/a On-demand generator over a small file; no hot path
Concurrency / state none Single-shot script; the config write is temp-plus-rename
Naming, readability, structure none Extends the existing harness's established shape; no new abstraction introduced
Documentation 1 The repo guide's machine-local table gained the file, its reader, the regeneration commands, and the enforced mode
Tests / verification 1 F3 nothing asserted the generated config was loadable, only that it was stable
Cross-file consistency 1 F6 (declined) a tracked path containing ''' would break the generated TOML; verified the splice against main's advanced config

Auto-applicable

# Finding Tool + rule Fix Commit
1 Unquoted mode=write and three siblings read as a missing command substitution shellcheck SC2209 quoted the four assignments; shellcheck now exits clean on both scripts d34b7aa

Agent-resolvable

# Finding Test Before → after CI Brief alignment Commit
1 --help printed set -euo pipefail as documentation: the hard-coded sed -n '2,30p' had drifted past the header's end gitleaks-rules-test.sh case 13 help-header-only FAIL against the old range → ok after the shape-based extraction harness 16/16, shellcheck clean, mise run lint clean REQ-D1.5's generator must be re-runnable, so its own usage output must be correct d34b7aa
2 Nothing asserted the generated config was loadable; --check compares the file against a regeneration of itself and would certify a block gitleaks cannot parse gitleaks-rules-test.sh case 12 gen-config-loads discrimination confirmed: gitleaks exits 1 on a malformed config, 0 on the generated one harness 16/16 REQ-D1.2 — a guard that cannot load is not a guard d34b7aa
3 The source file's mode was documented and required by Done-when but never checked; a world-readable file would be read happily gitleaks-rules-test.sh case 14 gen-loose-mode / gen-accepts-0600 FAIL (generator exited 0 on a 0644 source) → ok, refuses with exit 6 naming the mode harness 16/16, shellcheck clean, mise run lint clean Task 1 Done-when "mode 0600"; ladder rung 3, precedent scripts/ssh-lan-config-sync.sh 732a050

Needs sign-off

# Finding Fix applied Route reason Commit Checklist ID
none

Needs human judgment

# Fork Ladder record Outcome Options
1 Should the generator enforce the source file's mode, or trust the operator? rung 1: Done-when requires 0600 but assigns no enforcer; rung 2 not needed; rung 3: scripts/ssh-lan-config-sync.sh refuses its machine-local file unless 600 or 400 resolved at rung 3; re-routed to Agent-resolvable #3
2 The identifier set itself, which no artifact a worker may read contains rung 1: REQ-D1.1 and D-9 deliberately exclude it from the bundle; rung 2: unanswerable, the values are private to the operator; rung 3: no precedent supplies them irreducible — parked in tasks.md ## Awaiting input Create the machine-local file and run --write (the D-9 design, and what the parked note describes)

Declined log

# Finding Validation summary Rationale Where re-raisable
1 A write failing between mktemp and mv leaves a .gitleaks.toml.XXXXXX temp in the repo root Reproduced only by forcing a write failure, which is itself loud The stray file surfaces immediately in git status as 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 This PR
2 A tracked path containing ''' would terminate the generated TOML literal early Pathological input drawn from the operator's own tracked filenames It fails loudly rather than silently — case 12 asserts the generated config loads, so gitleaks rejects such a config and the harness catches it This PR
3 shellcheck SC2015 in scripts/ssh-lan-config-sync.sh Confirmed by running shellcheck repo-wide Pre-existing and unrelated to this diff; flagging it here would be scope creep. Recorded as an observation instead, together with the fact that nothing runs shellcheck specs/_observations/entries/2026-08-05-shellcheck-not-wired-into-guards-a7880e73.md

Pending sign-off

# Item
none

Final pass summary

Iteration 3 of the --nested polish 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_isolation resolved to per-step; this backend hosts steps in one session, so it degraded to per-unit hosting — capability only, with state-safety preserved (no step wrote placement state, and PR creation stayed the terminal step).

Tooling: scripts/gitleaks-rules-test.sh 16/16 with one visible skip · shellcheck clean on both scripts · mise run lint (yamllint, ansible-lint, syntax-check) clean · lefthook run pre-commit clean.

One environment note: mise run lint fails in an unattended worker because the session runs with LC_ALL=C and 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.

inkatze added 5 commits August 5, 2026 16:34
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
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
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
inkatze marked this pull request as ready for review August 6, 2026 20:41
@inkatze
inkatze merged commit f68147a into main Aug 6, 2026
1 of 2 checks passed
@inkatze
inkatze deleted the planwright/dev-services/task-1 branch August 6, 2026 20:41
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant