diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 79501a2..865bc1c 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f1cd60..f40f8b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/adr/0012-privacy-guard-pre-commit-hook.md b/docs/adr/0012-privacy-guard-pre-commit-hook.md new file mode 100644 index 0000000..a0d431b --- /dev/null +++ b/docs/adr/0012-privacy-guard-pre-commit-hook.md @@ -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. diff --git a/plugins/repokit/skills/new-repo/SKILL.md b/plugins/repokit/skills/new-repo/SKILL.md index c930077..93fd1ae 100644 --- a/plugins/repokit/skills/new-repo/SKILL.md +++ b/plugins/repokit/skills/new-repo/SKILL.md @@ -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 diff --git a/plugins/repokit/skills/new-repo/references/file-set-resolution.md b/plugins/repokit/skills/new-repo/references/file-set-resolution.md index 028081d..e27409f 100644 --- a/plugins/repokit/skills/new-repo/references/file-set-resolution.md +++ b/plugins/repokit/skills/new-repo/references/file-set-resolution.md @@ -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)* diff --git a/plugins/repokit/skills/new-repo/templates/core/scripts/install-privacy-guard.ps1 b/plugins/repokit/skills/new-repo/templates/core/scripts/install-privacy-guard.ps1 new file mode 100644 index 0000000..21847f8 --- /dev/null +++ b/plugins/repokit/skills/new-repo/templates/core/scripts/install-privacy-guard.ps1 @@ -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+\+)?(?[^@]+)@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.' diff --git a/plugins/repokit/skills/repo-standard/standard/pre-commit-checklist.md b/plugins/repokit/skills/repo-standard/standard/pre-commit-checklist.md index 4770fe7..20aca24 100644 --- a/plugins/repokit/skills/repo-standard/standard/pre-commit-checklist.md +++ b/plugins/repokit/skills/repo-standard/standard/pre-commit-checklist.md @@ -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 @@ -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). diff --git a/plugins/repokit/skills/repo-standard/standard/the-standard.md b/plugins/repokit/skills/repo-standard/standard/the-standard.md index c2dd98a..29c8d9b 100644 --- a/plugins/repokit/skills/repo-standard/standard/the-standard.md +++ b/plugins/repokit/skills/repo-standard/standard/the-standard.md @@ -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 diff --git a/scripts/smoke_test_privacy_guard.ps1 b/scripts/smoke_test_privacy_guard.ps1 new file mode 100644 index 0000000..23124d4 --- /dev/null +++ b/scripts/smoke_test_privacy_guard.ps1 @@ -0,0 +1,121 @@ +#!/usr/bin/env pwsh +#Requires -Version 7.0 +# Smoke test for the bundled install-privacy-guard.ps1 template (refs #34). +# +# Proves, end-to-end through real `git commit` invocations (so the sh shim, line endings, and +# the executable bit are exercised, not just the guard logic): the installer succeeds and +# self-tests, a staged content leak and a staged filename leak are blocked, a clean commit +# passes, a real-name identity is blocked unless -SkipIdentityCheck, a foreign pre-commit hook +# is refused without -Force, and a configured core.hooksPath refuses the install outright. + +$ErrorActionPreference = 'Stop' + +$installer = Resolve-Path (Join-Path $PSScriptRoot '..' ` + 'plugins/repokit/skills/new-repo/templates/core/scripts/install-privacy-guard.ps1') + +function New-Fixture { + $d = Join-Path ([IO.Path]::GetTempPath()) ("privacy-guard-smoke-" + [guid]::NewGuid()) + New-Item -ItemType Directory -Path $d | Out-Null + git -C $d init -q -b main 2>$null + if ($LASTEXITCODE -ne 0) { git -C $d init -q; git -C $d branch -m main } # git < 2.28 + git -C $d config core.autocrlf false + git -C $d config commit.gpgsign false + git -C $d config user.name 'octocat' + git -C $d config user.email 'octocat@users.noreply.github.com' + Set-Content -LiteralPath (Join-Path $d 'README.md') -Value '# fixture' + git -C $d add -A + git -C $d commit -q -m 'chore: fixture' | Out-Null + return $d +} + +$script:failed = 0 +function Assert([bool]$Cond, [string]$Name) { + if ($Cond) { Write-Host "PASS: $Name" } else { Write-Host "FAIL: $Name"; $script:failed++ } +} + +# 1. Install with -Pattern (non-interactive): exits 0 — which also means the installer's own +# internal negative test passed — and both hook files exist. +$d = New-Fixture +& pwsh -NoProfile -File $installer -RepoRoot $d -Pattern 'Ann,Example' > $null +Assert ($LASTEXITCODE -eq 0) 'installer succeeds with -Pattern (internal negative test passed)' +Assert (Test-Path (Join-Path $d '.git/hooks/pre-commit')) 'pre-commit shim written' +Assert (Test-Path (Join-Path $d '.git/hooks/pre-commit-privacy-guard.ps1')) 'guard script written' + +# 2. A commit with a staged content leak is blocked, end-to-end through git. +Set-Content -LiteralPath (Join-Path $d 'notes.txt') -Value 'ask Ann about the release' +git -C $d add -A +git -C $d commit -q -m 'chore: leak' 2>$null +Assert ($LASTEXITCODE -ne 0) 'commit with a staged content leak is blocked' +git -C $d rm -q --cached notes.txt +Remove-Item -LiteralPath (Join-Path $d 'notes.txt') + +# 3. A commit whose staged FILENAME leaks is blocked (content is clean). +Set-Content -LiteralPath (Join-Path $d 'for-Example.txt') -Value 'clean content' +git -C $d add -A +git -C $d commit -q -m 'chore: filename leak' 2>$null +Assert ($LASTEXITCODE -ne 0) 'commit with a leaking staged filename is blocked' +git -C $d rm -q --cached -- for-Example.txt +Remove-Item -LiteralPath (Join-Path $d 'for-Example.txt') + +# 4. Matching is case-insensitive. +Set-Content -LiteralPath (Join-Path $d 'shout.txt') -Value 'ANN was here' +git -C $d add -A +git -C $d commit -q -m 'chore: case leak' 2>$null +Assert ($LASTEXITCODE -ne 0) 'case-insensitive match is blocked' +git -C $d rm -q --cached shout.txt +Remove-Item -LiteralPath (Join-Path $d 'shout.txt') + +# 5. A clean commit passes — the guard blocks leaks, not work. +Set-Content -LiteralPath (Join-Path $d 'work.txt') -Value 'ordinary change' +git -C $d add -A +git -C $d commit -q -m 'chore: clean' 2>$null +Assert ($LASTEXITCODE -eq 0) 'clean commit passes' + +# 6. A real-name commit identity is blocked even with a clean diff. +git -C $d config user.name 'Octo Cat' +Set-Content -LiteralPath (Join-Path $d 'more.txt') -Value 'another ordinary change' +git -C $d add -A +git -C $d commit -q -m 'chore: identity' 2>$null +Assert ($LASTEXITCODE -ne 0) 'real-name commit identity is blocked' +git -C $d config user.name 'octocat' +Remove-Item -Recurse -Force $d + +# 7. -SkipIdentityCheck: the same real-name identity commits fine, content is still guarded. +$d = New-Fixture +& pwsh -NoProfile -File $installer -RepoRoot $d -Pattern 'Ann' -SkipIdentityCheck > $null +Assert ($LASTEXITCODE -eq 0) 'installer succeeds with -SkipIdentityCheck' +git -C $d config user.name 'Octo Cat' +Set-Content -LiteralPath (Join-Path $d 'work.txt') -Value 'ordinary change' +git -C $d add -A +git -C $d commit -q -m 'chore: variance identity' 2>$null +Assert ($LASTEXITCODE -eq 0) 'declared-variance identity passes with -SkipIdentityCheck' +Set-Content -LiteralPath (Join-Path $d 'leak.txt') -Value 'Ann appears' +git -C $d add -A +git -C $d commit -q -m 'chore: leak' 2>$null +Assert ($LASTEXITCODE -ne 0) 'content leak still blocked with -SkipIdentityCheck' +Remove-Item -Recurse -Force $d + +# 8. A foreign pre-commit hook is refused without -Force and left untouched; -Force replaces it. +$d = New-Fixture +$foreign = Join-Path $d '.git/hooks/pre-commit' +[IO.File]::WriteAllText($foreign, "#!/bin/sh`necho someone else's hook`n") +& pwsh -NoProfile -File $installer -RepoRoot $d -Pattern 'Ann' > $null +Assert ($LASTEXITCODE -ne 0) 'foreign pre-commit hook refuses the install without -Force' +Assert ((Get-Content -LiteralPath $foreign -Raw) -match 'someone') 'foreign hook left untouched on refusal' +& pwsh -NoProfile -File $installer -RepoRoot $d -Pattern 'Ann' -Force > $null +Assert ($LASTEXITCODE -eq 0) '-Force replaces the foreign hook' +Assert ((Get-Content -LiteralPath $foreign -Raw) -match 'privacy-guard') '-Force wrote the guard shim' +Remove-Item -Recurse -Force $d + +# 9. A configured core.hooksPath refuses the install (the patterns could land in the tree). +$d = New-Fixture +git -C $d config core.hooksPath .husky +& pwsh -NoProfile -File $installer -RepoRoot $d -Pattern 'Ann' > $null +Assert ($LASTEXITCODE -ne 0) 'configured core.hooksPath refuses the install' +Remove-Item -Recurse -Force $d + +if ($script:failed -gt 0) { Write-Host "smoke_test_privacy_guard: $script:failed failure(s)"; exit 1 } +Write-Host 'smoke_test_privacy_guard: all cases passed' +# Explicit success exit: the last Assert may leave $LASTEXITCODE nonzero from a git call, and +# GitHub's pwsh shell wrapper would otherwise propagate it as the job's exit code. +exit 0