Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,17 @@ jobs:
shell: pwsh
run: ./scripts/smoke_test_repokit_check.ps1

smoke-privacy-guard:
name: Smoke-test privacy-guard installer (pwsh on Linux)
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v7

- name: Prove the installer self-tests and the hook blocks each leak class
shell: pwsh
run: ./scripts/smoke_test_privacy_guard.ps1

smoke-living-docs:
name: Smoke-test living-docs add-on (pwsh on Linux)
runs-on: ubuntu-latest
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
material is expected while still private. Recorded in ADR-0001, no variance row needed: the
standard's *Promotion path* now states an early licence is the promotion path, not a
deviation. Default stays no; forever-private repos keep zero ceremony.
- Core ships a **tested privacy-guard pre-commit hook** (#34, ADR-0012):
`scripts/install-privacy-guard.ps1` writes an untracked `.git/hooks/` guard that blocks
staged additions, staged filenames, and non-noreply commit identities matching
locally-entered patterns (case-insensitive literals, not regexes — the hand-rolled guard
this replaces died on a BRE quoting subtlety), refuses a configured `core.hooksPath`, and
finishes every install with an automatic negative test that removes the hook on failure.
Documented in the standard's *Author identity* and the pre-commit checklist ("a guard that
has never failed a negative test is not yet a guard"); CI smoke-tests the template
end-to-end through real `git commit` runs.

### Fixed

Expand Down
50 changes: 50 additions & 0 deletions docs/adr/0012-privacy-guard-pre-commit-hook.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# ADR-0012: A shipped, self-testing privacy-guard pre-commit hook

- **Status:** accepted
- **Date:** 2026-08-13

## Context

Field report #34: the standard's *Author identity* section explains why personal-name guard
patterns must stay out of the tree, but left the actual guard for each repo to hand-roll — and
hand-rolled guards fail silently. The real case: a pre-commit hook filtered diff noise with
`grep -v '^\+\+\+'` without `-E`; in GNU BRE `\+` is a quantifier, so the filter matched every
added line and `-v` discarded them all — the guard passed everything, including a staged test
leak. Only a deliberate negative test caught it.

## Decision

Core ships `scripts/install-privacy-guard.ps1` — an installer, not a hook — with four design
choices:

- **Patterns are case-insensitive literals, not regexes.** The failure that motivated this ADR
was a regex quoting subtlety; a literal cannot fail that way, and a literal is exactly what a
name is. Comma-separated on the CLI (so the flag survives every calling shell), prompted
interactively otherwise.
- **The installer is tracked; the guard is not.** The patterns are written only into
`.git/hooks/` (an sh shim exec-ing a generated pwsh guard), which git never commits and never
clones. A configured `core.hooksPath` refuses the install outright — it could point at a
tracked directory, which would commit the very patterns the guard exists to keep out. Block
messages name patterns by index only, so a pasted transcript never spells the secret.
- **Every install ends with a mandatory negative test**, run in a throwaway fixture repo so the
user's index is never touched: a clean stage must pass, and a synthetic content leak, filename
leak, and real-name commit identity must each be blocked. Any failed assertion removes the
hook again and exits nonzero — a guard that has never failed a negative test is not yet a
guard. The fixture starts on an unborn branch, so the first-commit diff base is exercised on
every install.
- **Scope: staged additions, staged filenames, and the commit identity** (handle + noreply, the
same rule `repokit-check.ps1` enforces; `-SkipIdentityCheck` for repos with a declared
identity variance).

CI proves the template end-to-end (`scripts/smoke_test_privacy_guard.ps1`): real `git commit`
invocations through the sh shim on Linux, covering every block class, the clean-commit path,
`-SkipIdentityCheck`, foreign-hook refusal, `-Force`, and the `core.hooksPath` refusal.

## Consequences

Repos get a tested guard for the price of one command per clone, and the pre-commit checklist's
"no private identity" item gains a mechanical backstop. The known limits are stated where the
guard is documented: `git commit --no-verify` bypasses any pre-commit hook, the hook does not
travel with clones (re-install per machine), and a pattern containing a comma cannot be
expressed on the CLI (use the interactive prompt). The guard scans staged additions only — a
pattern already committed before install is out of scope; that remains the audit's job.
4 changes: 3 additions & 1 deletion plugins/repokit/skills/new-repo/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,9 @@ prototype script). That is supported — scaffold *around* the content instead o
run an issue board.

7. **Print the summary:** the resolved tier × type, the file tree, the START-HERE map, and next
steps — "review locally; you publish when ready."
steps — "review locally; you publish when ready." If the repo may ever go public, include
the next step "run `pwsh scripts/install-privacy-guard.ps1` (once per clone — the hook it
installs stays in `.git/hooks/` and does not travel)".

## Boundaries

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ merge two templates by hand):

- `templates/core/**` → `AGENTS.md`, `CLAUDE.md`, `README.md`, `CONTRIBUTING.md`, `CHANGELOG.md`,
`.gitignore`, `.editorconfig`, `.gitattributes`, `docs/adr/0000-template.md`,
`docs/CHECKPOINT.md`, `scripts/repokit-check.ps1`
`docs/CHECKPOINT.md`, `scripts/repokit-check.ps1`, `scripts/install-privacy-guard.ps1`
- `templates/types/powershell-module/core/**` → `MyModule.psd1`, `MyModule.psm1`,
`Public/.gitkeep`, `Private/.gitkeep`, `Tests/MyModule.Tests.ps1`, **`README.md`** *(overrides
the base README — type wins)*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
#!/usr/bin/env pwsh
#Requires -Version 7.0
# install-privacy-guard.ps1 — install an untracked pre-commit hook that blocks a private
# pattern (typically a real personal name) in staged additions, staged filenames, or the
# commit identity. See the RepoKit standard, "Author identity".
#
# Why an installer instead of a tracked hook: the guard patterns are the secret — they must
# never enter the tree. This script is clean and trackable; the patterns live only in
# .git/hooks/, which git never commits and never clones.
#
# Patterns are case-insensitive LITERAL substrings, not regexes — the hand-rolled guard this
# replaces failed silently on a regex quoting subtlety (GNU BRE "\+"), passing everything it
# was meant to block. Literals cannot fail that way, and a literal is exactly what a name is.
#
# Every install ends with an automatic negative test in a throwaway fixture repo: a clean
# stage must pass, and a synthetic content leak, filename leak, and real-name identity must
# each be blocked. If any assertion fails the hook is removed again — a guard that has never
# failed a negative test is not yet a guard.
#
# The hook does not travel with clones — re-run this installer on every clone/machine. And
# `git commit --no-verify` bypasses any pre-commit hook, so keep the CI-side guards too.
#
# Usage:
# pwsh scripts/install-privacy-guard.ps1 # prompts for patterns
# pwsh scripts/install-privacy-guard.ps1 -Pattern 'Ann,Doe' # non-interactive, comma-separated
# pwsh scripts/install-privacy-guard.ps1 -SkipIdentityCheck # repo has a declared identity variance
# pwsh scripts/install-privacy-guard.ps1 -Force # replace a foreign pre-commit hook
# A comma always separates patterns (so the flag survives every calling shell); a comma can
# therefore not be part of a pattern.
# Exit code: 0 = installed and negative test passed, 1 = refused or negative test failed.

[CmdletBinding()]
param(
[string[]]$Pattern,
[switch]$SkipIdentityCheck,
[switch]$Force,
[string]$RepoRoot = '.'
)

$ErrorActionPreference = 'Stop'

function Die([string]$msg) { Write-Host "privacy-guard: $msg"; exit 1 }

# --- Locate the repo and its hooks directory -------------------------------------------------
$RepoRoot = (Resolve-Path -LiteralPath $RepoRoot).Path
git -C $RepoRoot rev-parse --git-dir *> $null
if ($LASTEXITCODE -ne 0) { Die "not a git repository: $RepoRoot" }

# A configured core.hooksPath may point INSIDE the working tree (e.g. a tracked .husky/) —
# installing there would commit the very patterns this guard exists to keep out of the tree.
$customHooks = git -C $RepoRoot config --get core.hooksPath
if ($customHooks) {
Die "core.hooksPath is set ('$customHooks') - refusing to install where the patterns could be tracked. Unset it, or extend that hook chain by hand."
}

$hooksDir = git -C $RepoRoot rev-parse --git-path hooks
if (-not [IO.Path]::IsPathRooted($hooksDir)) { $hooksDir = Join-Path $RepoRoot $hooksDir }
New-Item -ItemType Directory -Force -Path $hooksDir | Out-Null

# --- Gather the patterns (never written anywhere but .git/hooks/) ----------------------------
if (-not $Pattern -or $Pattern.Count -eq 0) {
Write-Host 'Enter the private patterns to block (case-insensitive literal text - a first name,'
Write-Host 'a last name, a personal username). They are stored only in .git/hooks/, never in the tree.'
$Pattern = @()
while ($true) {
$p = Read-Host "Pattern $($Pattern.Count + 1) (blank to finish)"
if ([string]::IsNullOrWhiteSpace($p)) { break }
$Pattern += $p
}
}
$Pattern = @($Pattern | ForEach-Object { $_ -split ',' } | ForEach-Object { $_.Trim() } |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique)
if ($Pattern.Count -eq 0) { Die 'no patterns given - nothing to guard.' }

# --- Refuse to clobber a hook that is not ours -----------------------------------------------
$marker = 'repokit privacy-guard'
$shimPath = Join-Path $hooksDir 'pre-commit'
$guardPath = Join-Path $hooksDir 'pre-commit-privacy-guard.ps1'

if ((Test-Path -LiteralPath $shimPath) -and -not $Force) {
if ((Get-Content -LiteralPath $shimPath -Raw) -notmatch [regex]::Escape($marker)) {
Die "a pre-commit hook already exists at $shimPath and is not this guard - merge by hand, or re-run with -Force to replace it."
}
}

# --- Compose the guard -----------------------------------------------------------------------
# Block messages name the pattern by INDEX only, so a pasted hook transcript never spells the
# secret it guards.
$guardTemplate = @'
# repokit privacy-guard — generated by scripts/install-privacy-guard.ps1. UNTRACKED on
# purpose: the patterns below are exactly what the tree must never contain. Never commit
# this file; re-run the installer on each clone.
$ErrorActionPreference = 'Stop'
Set-Location (git rev-parse --show-toplevel)
$patterns = @(__PATTERNS__)

$script:hits = 0
function Hit([string]$msg) { Write-Host "privacy-guard: $msg"; $script:hits++ }

# On an unborn branch (first commit) diff against the empty tree.
git rev-parse --verify --quiet HEAD *> $null
if ($LASTEXITCODE -eq 0) {
$base = 'HEAD'
} else {
$base = @() | git mktree
if ($LASTEXITCODE -ne 0 -or -not $base) { $base = '4b825dc642cb6eb9a060e54bf8d69288fbee4904' }
}

# 1. Staged content — added lines only ("+" but not the "+++" file header).
$file = ''
foreach ($line in @(git diff --cached $base --no-color --unified=0 --diff-filter=ACMR)) {
if ($line.StartsWith('+++')) { $file = $line -replace '^\+\+\+ (b/)?', ''; continue }
if (-not $line.StartsWith('+')) { continue }
for ($i = 0; $i -lt $patterns.Count; $i++) {
if ($line.IndexOf($patterns[$i], [StringComparison]::OrdinalIgnoreCase) -ge 0) {
Hit "staged addition in '$file' matches blocked pattern $($i + 1)"
}
}
}

# 2. Staged paths — a leak can live in a filename, not just in content.
foreach ($name in @(git diff --cached $base --name-only --diff-filter=ACMR)) {
for ($i = 0; $i -lt $patterns.Count; $i++) {
if ($name.IndexOf($patterns[$i], [StringComparison]::OrdinalIgnoreCase) -ge 0) {
Hit "staged path '$name' matches blocked pattern $($i + 1)"
}
}
}
__IDENTITY_CHECK__
if ($script:hits -gt 0) {
Write-Host "privacy-guard: commit blocked ($script:hits finding(s))."
Write-Host 'privacy-guard: unstage the leak first; --no-verify bypasses this hook if you are certain.'
exit 1
}
exit 0
'@

$identityBlock = @'

# 3. Commit identity — handle + noreply in both fields (the standard, "Author identity").
$idName = git config user.name
$idEmail = git config user.email
$idOk = $false
if ($idEmail -eq 'noreply@github.com') { $idOk = ($idName -eq 'GitHub') }
elseif ($idEmail -match '^(\d+\+)?(?<login>[^@]+)@users\.noreply\.github\.com$') { $idOk = ($idName -eq $Matches['login']) }
if (-not $idOk) { Hit "commit identity is not handle + noreply: $idName <$idEmail>" }
'@
if ($SkipIdentityCheck) { $identityBlock = '' }

$patternList = ($Pattern | ForEach-Object { "'" + ($_ -replace "'", "''") + "'" }) -join ', '
$guardContent = $guardTemplate.Replace('__PATTERNS__', $patternList).Replace('__IDENTITY_CHECK__', $identityBlock)

# The shim must be LF-only and executable — git runs it through sh.
$shimContent = "#!/bin/sh`n# $marker shim - do not commit; installed by scripts/install-privacy-guard.ps1`nexec pwsh -NoProfile -File `"`$(git rev-parse --git-path hooks)/pre-commit-privacy-guard.ps1`"`n"

[IO.File]::WriteAllText($guardPath, $guardContent)
[IO.File]::WriteAllText($shimPath, $shimContent)
if (-not $IsWindows) { chmod +x $shimPath }

# --- Negative test: the guard must fail before it may pass -----------------------------------
# Runs in a throwaway fixture repo so the user's index is never touched. The fixture starts
# with no commits, which also exercises the unborn-branch diff base.
function Invoke-Guard([string]$Dir) {
Push-Location $Dir
try { & pwsh -NoProfile -File $guardPath *> $null; return $LASTEXITCODE }
finally { Pop-Location }
}

$sample = $Pattern[0]
$fixture = Join-Path ([IO.Path]::GetTempPath()) ("repokit-privacy-guard-selftest-" + [guid]::NewGuid())
New-Item -ItemType Directory -Path $fixture | Out-Null
try {
git -C $fixture init -q -b main 2>$null
if ($LASTEXITCODE -ne 0) { git -C $fixture init -q; git -C $fixture branch -m main } # git < 2.28
git -C $fixture config core.autocrlf false
git -C $fixture config commit.gpgsign false
git -C $fixture config user.name 'octocat'
git -C $fixture config user.email 'octocat@users.noreply.github.com'

# (a) A clean stage must PASS — a guard that always blocks proves nothing.
Set-Content -LiteralPath (Join-Path $fixture 'clean.txt') -Value 'nothing private here'
git -C $fixture add -A
if ((Invoke-Guard $fixture) -ne 0) { throw 'the guard blocked a clean stage' }

# (b) A staged content leak must be BLOCKED.
Set-Content -LiteralPath (Join-Path $fixture 'leak.txt') -Value "synthetic leak: $sample"
git -C $fixture add -A
if ((Invoke-Guard $fixture) -eq 0) { throw 'the guard passed a staged content leak' }
git -C $fixture rm -q --cached leak.txt
Remove-Item -LiteralPath (Join-Path $fixture 'leak.txt')

# (c) A leak in a staged FILENAME must be blocked (testable when the pattern is filename-safe).
if ($sample -match '^[A-Za-z0-9 ._-]+$') {
$leakName = "about-$sample.txt"
Set-Content -LiteralPath (Join-Path $fixture $leakName) -Value 'content clean, name leaks'
git -C $fixture add -A
if ((Invoke-Guard $fixture) -eq 0) { throw 'the guard passed a staged filename leak' }
git -C $fixture rm -q --cached -- $leakName
Remove-Item -LiteralPath (Join-Path $fixture $leakName)
} else {
Write-Host 'privacy-guard: pattern 1 is not filename-safe - the filename check was exercised by the content case only'
}

# (d) With identity enforcement on, a real-name identity must be BLOCKED.
if (-not $SkipIdentityCheck) {
git -C $fixture config user.name 'Ann Example'
if ((Invoke-Guard $fixture) -eq 0) { throw 'the guard passed a real-name commit identity' }
git -C $fixture config user.name 'octocat'
}
} catch {
Remove-Item -Force -ErrorAction SilentlyContinue $shimPath, $guardPath
Die "NEGATIVE TEST FAILED - $($_.Exception.Message). The hook has been removed; do not trust it, investigate before re-installing."
} finally {
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue $fixture
}

# --- Summary ---------------------------------------------------------------------------------
$identityNote = if ($SkipIdentityCheck) { 'off' } else { 'on' }
Write-Host "privacy-guard: negative test passed - the hook blocked every synthetic leak before passing."
Write-Host "privacy-guard: installed at $shimPath ($($Pattern.Count) pattern(s), identity check $identityNote)."
Write-Host 'privacy-guard: hooks do not travel with clones - re-run this installer on each machine.'
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ Run through this before every commit.
- [ ] **Every changed line traces to the task.** No drive-by edits; surgical changes only.
- [ ] **No secrets, tokens, or private identity** in the diff — no email addresses, no real
personal names: the author is the GitHub handle unless this repo declares otherwise (see
`the-standard.md`, *Author identity*).
`the-standard.md`, *Author identity*). Core ships `scripts/install-privacy-guard.ps1` to
enforce this locally with an untracked hook — and a guard that has never failed a negative
test is not yet a guard, which is why the installer runs one on every install.
- [ ] **`CHANGELOG.md` updated** under `## [Unreleased]` if the change is user-visible.
- [ ] **Docs move together** *(living-docs repos)* — if the change alters anything a doc states
(status, resources, counts, dates), update `docs/STATE.json` in the same commit, run
Expand All @@ -20,4 +22,5 @@ Run through this before every commit.

> Enforcement note: Claude Code hooks only catch commands run *in-session*; a native `git commit`
> from your terminal bypasses them. For out-of-session safety, lean on git hooks + CI — that's why
> the Public tier ships a validation workflow.
> the Public tier ships a validation workflow, and why Core ships the privacy-guard installer
> (re-run it per clone; `.git/hooks/` does not travel).
14 changes: 14 additions & 0 deletions plugins/repokit/skills/repo-standard/standard/the-standard.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,20 @@ name-to-handle association is already public and the goal is *no new occurrences
association itself is the secret, keep the pattern out of the tree (a repository secret works)
and rely on the identity check plus review.

**The shipped local guard.** Hand-rolled pre-commit guards fail silently — a field case: a GNU
BRE quoting subtlety (`\+` as a quantifier) made a diff filter discard every line it was meant
to scan, so the guard passed everything, including a staged test leak; only a deliberate
negative test caught it. Core therefore ships `scripts/install-privacy-guard.ps1`: run it once
per clone, and it prompts for the patterns locally (case-insensitive **literals**, not regexes)
and writes an **untracked** hook into `.git/hooks/` that blocks staged additions, staged
filenames, and a non-noreply commit identity — the patterns never touch the tree, while the
installer itself stays clean and trackable. Every install ends with an automatic negative test
in a throwaway fixture (a clean stage must pass; a synthetic content leak, filename leak, and
real-name identity must each be blocked) and removes the hook again if any assertion fails: **a
guard that has never failed a negative test is not yet a guard** (ADR-0012). The hook does not
travel with clones — re-install per machine — and `git commit --no-verify` bypasses it, so the
CI-side options above stay worthwhile.

## Promotion path

Private → public → published just **switches on the next layer** over the *same* structure. Moving
Expand Down
Loading