diff --git a/.github/workflows/commit-signing.yml b/.github/workflows/commit-signing.yml
index 249ba108..fd2a92a5 100644
--- a/.github/workflows/commit-signing.yml
+++ b/.github/workflows/commit-signing.yml
@@ -8,6 +8,7 @@ on:
paths:
- ".github/workflows/commit-signing.yml"
- "scripts/commit-signing-check.ps1"
+ - "tests/commit_signing_check.rs"
- "docs/ops/commit-signing.md"
- "docs/adr/0004-commit-signing-policy.md"
- "SECURITY.md"
@@ -17,6 +18,7 @@ on:
paths:
- ".github/workflows/commit-signing.yml"
- "scripts/commit-signing-check.ps1"
+ - "tests/commit_signing_check.rs"
- "docs/ops/commit-signing.md"
- "docs/adr/0004-commit-signing-policy.md"
- "SECURITY.md"
@@ -46,6 +48,10 @@ jobs:
shell: pwsh
run: ./scripts/commit-signing-check.ps1 -Ref main -Count 30
+ - name: commit signing bounded header SelfCheck
+ shell: pwsh
+ run: ./scripts/commit-signing-check.ps1 -SelfCheck
+
- name: branch protection checklist (soft / docs-only)
shell: pwsh
run: ./scripts/commit-signing-check.ps1 -BranchProtectionChecklist
diff --git a/CHANGELOG.md b/CHANGELOG.md
index de45885f..ad516f5a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/); versioning is [SemVer](
### Fixed
+- Commit signing header scan (C04 L34): `commit-signing-check.ps1` reads bounded commit headers via line-scanner (no unbounded `git cat-file` buffers or `(?ms)` regex); `-SelfCheck` + `tests/commit_signing_check.rs`.
+
- Loom permutation CI timeout (P0 stability): split blocking `loom-permutation.yml` into core + per-daemon `loom_model` jobs with `LOOM_MAX_PREEMPTIONS` on broadcast/pipeline/shutdown; mirror in soft `loom-smoke.yml` so Wave-40 tokio-shaped daemon graph tests no longer exceed single-job ceilings.
### Added
diff --git a/docs/ops/commit-signing.md b/docs/ops/commit-signing.md
index bb8cf44a..6131c957 100644
--- a/docs/ops/commit-signing.md
+++ b/docs/ops/commit-signing.md
@@ -68,6 +68,9 @@ Run locally or in CI:
# Blocking tip check + recent-history report (default CI mode)
pwsh -NoProfile -File scripts/commit-signing-check.ps1
+# Hermetic bounded header scanner + fixtures (no signed main required)
+pwsh -NoProfile -File scripts/commit-signing-check.ps1 -SelfCheck
+
# Branch-protection checklist (soft-fail / docs-only without admin API)
pwsh -NoProfile -File scripts/commit-signing-check.ps1 -BranchProtectionChecklist
diff --git a/scripts/commit-signing-check.ps1 b/scripts/commit-signing-check.ps1
index 41d1124e..ee44c685 100644
--- a/scripts/commit-signing-check.ps1
+++ b/scripts/commit-signing-check.ps1
@@ -1,134 +1,363 @@
+<#
+.SYNOPSIS
+ Verify GPG/SSH commit signatures on main and optional branch-protection checklist.
+
+.DESCRIPTION
+ Reads commit object headers via bounded line-scanner (no unbounded git cat-file
+ buffers or multiline regex over whole objects). Classifies gpg/ssh/unsigned.
+
+.PARAMETER SelfCheck
+ Hermetic policy anchor check + classification fixtures. No signed main required.
+
+.EXAMPLE
+ pwsh ./scripts/commit-signing-check.ps1 -Ref main -Count 30
+ pwsh ./scripts/commit-signing-check.ps1 -SelfCheck
+#>
[CmdletBinding()]
param(
[string]$Ref = "main",
[int]$Count = 30,
[switch]$Strict,
[switch]$BranchProtectionChecklist,
- [string]$Repo = "KooshaPari/SessionLedger"
+ [switch]$SelfCheck,
+ [string]$Repo = "KooshaPari/SessionLedger",
+ [int]$MaxCommitObjectBytes = 1048576,
+ [int]$MaxHeaderLines = 256
)
$ErrorActionPreference = "Stop"
$RepoRoot = Split-Path -Parent $PSScriptRoot
-Push-Location $RepoRoot
-try {
- function Get-CommitSignatureKind {
- param([string]$Sha)
- $Raw = (& git cat-file -p $Sha 2>$null | Out-String)
- if ([string]::IsNullOrWhiteSpace($Raw)) {
- return "missing"
- }
- if ($Raw -notmatch '(?ms)^gpgsig ') {
- return "unsigned"
- }
- if ($Raw -match 'BEGIN SSH SIGNATURE') {
- return "ssh"
- }
- if ($Raw -match 'BEGIN PGP SIGNATURE') {
- return "gpg"
- }
- return "malformed"
+
+function Write-Check {
+ param([string]$Label, [bool]$Ok)
+ $mark = if ($Ok) { "PASS" } else { "FAIL" }
+ Write-Host " [$mark] $Label"
+ return $Ok
+}
+
+function Assert-File {
+ param(
+ [Parameter(Mandatory = $true)][string]$Path,
+ [Parameter(Mandatory = $true)][string]$Label
+ )
+ if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
+ throw "Missing $Label at '$Path'."
}
+}
- function Test-CommitVerifier {
- param([string]$Sha)
- $Output = (& git verify-commit $Sha 2>&1 | Out-String).Trim()
- if ($LASTEXITCODE -eq 0) {
- return @{ Ok = $true; Detail = $Output }
- }
- if ($Output -match 'No signature') {
- return @{ Ok = $false; Detail = "no signature" }
+function Assert-Contains {
+ param(
+ [Parameter(Mandatory = $true)][string]$Doc,
+ [Parameter(Mandatory = $true)][string]$Needle,
+ [Parameter(Mandatory = $true)][string]$Label,
+ [string]$Context = "document"
+ )
+ $ok = $Doc.Contains($Needle)
+ [void](Write-Check -Label $Label -Ok $ok)
+ if (-not $ok) {
+ throw "$Context missing required anchor: '$Needle'"
+ }
+}
+
+function Get-CommitHeaderLines {
+ param(
+ [Parameter(Mandatory = $true)][string]$Sha,
+ [int]$ObjectByteCap = 1048576,
+ [int]$HeaderLineCap = 256
+ )
+
+ $sizeRaw = (& git cat-file -s $Sha 2>$null | Out-String).Trim()
+ if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($sizeRaw)) {
+ return $null
+ }
+ if ($sizeRaw -notmatch '^\d+$') {
+ throw "Unexpected git cat-file -s output for ${Sha}: $sizeRaw"
+ }
+ $objectSize = [long]::Parse($sizeRaw)
+ if ($objectSize -gt $ObjectByteCap) {
+ throw "Commit object $Sha size $objectSize exceeds cap $ObjectByteCap bytes"
+ }
+
+ $psi = New-Object System.Diagnostics.ProcessStartInfo
+ $psi.FileName = "git"
+ $psi.Arguments = "cat-file -p $Sha"
+ $psi.RedirectStandardOutput = $true
+ $psi.RedirectStandardError = $true
+ $psi.UseShellExecute = $false
+ $psi.CreateNoWindow = $true
+ $proc = [System.Diagnostics.Process]::Start($psi)
+ if (-not $proc) {
+ throw "Failed to start git cat-file -p $Sha"
+ }
+
+ $lines = [System.Collections.Generic.List[string]]::new()
+ $bytesRead = 0
+ try {
+ while ($true) {
+ $line = $proc.StandardOutput.ReadLine()
+ if ($null -eq $line) {
+ break
+ }
+ $bytesRead += [System.Text.Encoding]::UTF8.GetByteCount($line) + 1
+ if ($bytesRead -gt $ObjectByteCap) {
+ throw "Commit object $Sha exceeded byte cap while streaming header"
+ }
+ if ([string]::IsNullOrWhiteSpace($line)) {
+ break
+ }
+ $lines.Add($line)
+ if ($lines.Count -gt $HeaderLineCap) {
+ throw "Commit object $Sha header exceeds $HeaderLineCap lines"
+ }
}
- if ($Output -match 'gpg:\s+Signature made|Good signature|not certified|Can''t check signature|no public key') {
- return @{ Ok = $true; Detail = $Output }
+ }
+ finally {
+ if (-not $proc.HasExited) {
+ $proc.Kill()
}
- return @{ Ok = $false; Detail = $Output }
+ $proc.Dispose()
}
- function Write-ChecklistItem {
- param([string]$Text, [bool]$Done = $false)
- $Mark = if ($Done) { "[x]" } else { "[ ]" }
- Write-Host "$Mark $Text"
+ if ($proc.ExitCode -ne 0 -and $lines.Count -eq 0) {
+ return $null
}
- function Resolve-GitRef {
- param(
- [string]$RefName,
- [int]$FetchDepth
- )
+ return ,@($lines.ToArray())
+}
+
+function Get-CommitSignatureKindFromHeader {
+ param([string[]]$HeaderLines)
+
+ if (-not $HeaderLines -or $HeaderLines.Count -eq 0) {
+ return "missing"
+ }
- $Candidates = @($RefName)
- if ($RefName -eq "main") {
- $Candidates += "origin/main"
+ $hasGpgsig = $false
+ $joined = [string]::Join("`n", $HeaderLines)
+ foreach ($line in $HeaderLines) {
+ if ($line.StartsWith("gpgsig ")) {
+ $hasGpgsig = $true
+ break
}
+ }
+ if (-not $hasGpgsig) {
+ return "unsigned"
+ }
+ if ($joined.Contains("BEGIN SSH SIGNATURE")) {
+ return "ssh"
+ }
+ if ($joined.Contains("BEGIN PGP SIGNATURE")) {
+ return "gpg"
+ }
+ return "malformed"
+}
- foreach ($Candidate in $Candidates) {
- & git rev-parse --verify "$Candidate^{commit}" *> $null
- if ($LASTEXITCODE -eq 0) {
- return $Candidate
- }
+function Get-CommitSignatureKind {
+ param(
+ [string]$Sha,
+ [int]$ObjectByteCap,
+ [int]$HeaderLineCap
+ )
+ $header = Get-CommitHeaderLines -Sha $Sha -ObjectByteCap $ObjectByteCap -HeaderLineCap $HeaderLineCap
+ if ($null -eq $header) {
+ return "missing"
+ }
+ return Get-CommitSignatureKindFromHeader -HeaderLines $header
+}
+
+function Test-CommitVerifier {
+ param([string]$Sha)
+ $Output = (& git verify-commit $Sha 2>&1 | Out-String).Trim()
+ if ($LASTEXITCODE -eq 0) {
+ return @{ Ok = $true; Detail = $Output }
+ }
+ if ($Output -match 'No signature') {
+ return @{ Ok = $false; Detail = "no signature" }
+ }
+ if ($Output -match 'gpg:\s+Signature made|Good signature|not certified|Can''t check signature|no public key') {
+ return @{ Ok = $true; Detail = $Output }
+ }
+ return @{ Ok = $false; Detail = $Output }
+}
+
+function Write-ChecklistItem {
+ param([string]$Text, [bool]$Done = $false)
+ $Mark = if ($Done) { "[x]" } else { "[ ]" }
+ Write-Host "$Mark $Text"
+}
+
+function Resolve-GitRef {
+ param(
+ [string]$RefName,
+ [int]$FetchDepth
+ )
+
+ $Candidates = @($RefName)
+ if ($RefName -eq "main") {
+ $Candidates += "origin/main"
+ }
+
+ foreach ($Candidate in $Candidates) {
+ & git rev-parse --verify "$Candidate^{commit}" *> $null
+ if ($LASTEXITCODE -eq 0) {
+ return $Candidate
}
+ }
- if ($RefName -eq "main") {
- Write-Host "Local main not found; fetching origin/main (depth $FetchDepth)..."
- & git fetch --no-tags --depth $FetchDepth origin main 2>&1 | Out-Host
+ if ($RefName -eq "main") {
+ Write-Host "Local main not found; fetching origin/main (depth $FetchDepth)..."
+ & git fetch --no-tags --depth $FetchDepth origin main 2>&1 | Out-Host
+ if ($LASTEXITCODE -eq 0) {
+ & git rev-parse --verify "origin/main^{commit}" *> $null
if ($LASTEXITCODE -eq 0) {
- & git rev-parse --verify "origin/main^{commit}" *> $null
- if ($LASTEXITCODE -eq 0) {
- return "origin/main"
- }
+ return "origin/main"
}
}
+ }
+
+ throw "Ref not found: $RefName (tried: $($Candidates -join ', '))"
+}
+
+function Invoke-BranchProtectionChecklist {
+ $Items = @(
+ "Require signed commits on branch main",
+ "Require a pull request before merging (recommended)",
+ "Do not allow bypassing the above settings (recommended)"
+ )
- throw "Ref not found: $RefName (tried: $($Candidates -join ', '))"
+ Write-Host "Branch protection checklist for $Repo (main):"
+ foreach ($Item in $Items) {
+ Write-ChecklistItem $Item
}
- function Invoke-BranchProtectionChecklist {
- $Items = @(
- "Require signed commits on branch main",
- "Require a pull request before merging (recommended)",
- "Do not allow bypassing the above settings (recommended)"
- )
+ $Gh = Get-Command gh -ErrorAction SilentlyContinue
+ if (-not $Gh) {
+ Write-Host ""
+ Write-Host "WARN: gh CLI not found; branch protection is not machine-verifiable in OSS."
+ Write-Host "See docs/ops/commit-signing.md for manual Settings -> Branches steps."
+ return 0
+ }
- Write-Host "Branch protection checklist for $Repo (main):"
- foreach ($Item in $Items) {
- Write-ChecklistItem $Item
+ try {
+ $Json = & gh api "repos/$Repo/branches/main/protection" 2>&1
+ if ($LASTEXITCODE -ne 0) {
+ throw ($Json | Out-String)
}
-
- $Gh = Get-Command gh -ErrorAction SilentlyContinue
- if (-not $Gh) {
- Write-Host ""
- Write-Host "WARN: gh CLI not found; branch protection is not machine-verifiable in OSS."
- Write-Host "See docs/ops/commit-signing.md for manual Settings -> Branches steps."
+ $Protection = $Json | ConvertFrom-Json
+ $Signed = [bool]$Protection.required_signatures
+ Write-Host ""
+ Write-ChecklistItem "Require signed commits on branch main" $Signed
+ if (-not $Signed) {
+ Write-Host "WARN: GitHub API reports required_signatures=false (or unset)."
+ Write-Host "Enable 'Require signed commits' in repository branch protection."
+ Write-Host "Exiting soft (docs-only) because OSS CI cannot enforce admin settings."
return 0
}
+ Write-Host "Branch protection reports required_signatures=true."
+ return 0
+ }
+ catch {
+ Write-Host ""
+ Write-Host "WARN: Could not query branch protection via gh api (admin scope required)."
+ Write-Host $_.Exception.Message
+ Write-Host "Exiting soft (docs-only). See docs/ops/commit-signing.md."
+ return 0
+ }
+}
- try {
- $Json = & gh api "repos/$Repo/branches/main/protection" 2>&1
- if ($LASTEXITCODE -ne 0) {
- throw ($Json | Out-String)
- }
- $Protection = $Json | ConvertFrom-Json
- $Signed = [bool]$Protection.required_signatures
- Write-Host ""
- Write-ChecklistItem "Require signed commits on branch main" $Signed
- if (-not $Signed) {
- Write-Host "WARN: GitHub API reports required_signatures=false (or unset)."
- Write-Host "Enable 'Require signed commits' in repository branch protection."
- Write-Host "Exiting soft (docs-only) because OSS CI cannot enforce admin settings."
- return 0
- }
- Write-Host "Branch protection reports required_signatures=true."
- return 0
+function Test-SignatureClassificationFixtures {
+ $cases = @(
+ @{
+ Name = "unsigned header"
+ Header = @("tree abc", "parent def", "author A 0 +0000", "committer A 0 +0000")
+ Expected = "unsigned"
+ },
+ @{
+ Name = "gpg header"
+ Header = @(
+ "tree abc",
+ "gpgsig -----BEGIN PGP SIGNATURE-----",
+ " ",
+ " -----END PGP SIGNATURE-----"
+ )
+ Expected = "gpg"
+ },
+ @{
+ Name = "ssh header"
+ Header = @(
+ "tree abc",
+ "gpgsig -----BEGIN SSH SIGNATURE-----",
+ " -----END SSH SIGNATURE-----"
+ )
+ Expected = "ssh"
+ },
+ @{
+ Name = "malformed gpgsig"
+ Header = @("tree abc", "gpgsig not-a-known-format")
+ Expected = "malformed"
}
- catch {
- Write-Host ""
- Write-Host "WARN: Could not query branch protection via gh api (admin scope required)."
- Write-Host $_.Exception.Message
- Write-Host "Exiting soft (docs-only). See docs/ops/commit-signing.md."
- return 0
+ )
+
+ foreach ($case in $cases) {
+ $actual = Get-CommitSignatureKindFromHeader -HeaderLines $case.Header
+ if ($actual -ne $case.Expected) {
+ throw "Fixture '$($case.Name)' expected $($case.Expected) got $actual"
}
+ [void](Write-Check -Label "fixture: $($case.Name)" -Ok $true)
+ }
+}
+
+function Invoke-SelfCheck {
+ Write-Host "Commit signing check policy (C04 L34)"
+ Write-Host "Mode: SelfCheck (bounded header scanner + fixtures; no signed main required)"
+
+ $docPath = Join-Path $RepoRoot "docs/ops/commit-signing.md"
+ $workflowPath = Join-Path $RepoRoot ".github/workflows/commit-signing.yml"
+ $selfPath = Join-Path $RepoRoot "scripts/commit-signing-check.ps1"
+
+ Assert-File -Path $docPath -Label "commit-signing doc"
+ Assert-File -Path $workflowPath -Label "commit-signing workflow"
+ Assert-File -Path $selfPath -Label "commit-signing check script"
+
+ $doc = Get-Content -LiteralPath $docPath -Raw
+ $workflow = Get-Content -LiteralPath $workflowPath -Raw
+ $selfText = Get-Content -LiteralPath $selfPath -Raw
+
+ Assert-Contains -Doc $doc -Needle "scripts/commit-signing-check.ps1" `
+ -Label "script reference" -Context "docs/ops/commit-signing.md"
+ Assert-Contains -Doc $doc -Needle "-SelfCheck" `
+ -Label "SelfCheck invocation" -Context "docs/ops/commit-signing.md"
+ Assert-Contains -Doc $workflow -Needle "commit-signing-check.ps1" `
+ -Label "workflow script invocation" -Context ".github/workflows/commit-signing.yml"
+
+ if ($selfText -match '\(\?ms\)\^') {
+ throw "commit-signing-check.ps1 must not use multiline (?ms) regex over commit objects."
}
+ [void](Write-Check -Label "no multiline regex anchors in script" -Ok $true)
+ if ($selfText -notmatch 'MaxCommitObjectBytes') {
+ throw "commit-signing-check.ps1 missing MaxCommitObjectBytes cap."
+ }
+ [void](Write-Check -Label "MaxCommitObjectBytes cap present" -Ok $true)
+
+ if ($selfText -notmatch 'Get-CommitHeaderLines') {
+ throw "commit-signing-check.ps1 missing bounded header line scanner."
+ }
+ [void](Write-Check -Label "bounded header line scanner present" -Ok $true)
+
+ Write-Host "Classification fixtures:"
+ Test-SignatureClassificationFixtures
+
+ Write-Host "Commit signing check SelfCheck passed (C04 L34 bounded git cat-file header scan; branch protection remains soft)."
+ exit 0
+}
+
+if ($SelfCheck) {
+ Invoke-SelfCheck
+}
+
+Push-Location $RepoRoot
+try {
if ($BranchProtectionChecklist) {
exit (Invoke-BranchProtectionChecklist)
}
@@ -153,7 +382,7 @@ try {
$Problems = [System.Collections.Generic.List[string]]::new()
foreach ($Sha in $Shas) {
- $Kind = Get-CommitSignatureKind $Sha
+ $Kind = Get-CommitSignatureKind -Sha $Sha -ObjectByteCap $MaxCommitObjectBytes -HeaderLineCap $MaxHeaderLines
if ($Stats.ContainsKey($Kind)) {
$Stats[$Kind]++
}
@@ -177,7 +406,7 @@ try {
}
}
- $TipKind = Get-CommitSignatureKind $Tip
+ $TipKind = Get-CommitSignatureKind -Sha $Tip -ObjectByteCap $MaxCommitObjectBytes -HeaderLineCap $MaxHeaderLines
Write-Host "Commit signing report for $ResolvedRef (tip $Tip, last $($Shas.Count) commits)"
Write-Host " gpg: $($Stats.gpg)"
Write-Host " ssh: $($Stats.ssh)"
diff --git a/tests/commit_signing_check.rs b/tests/commit_signing_check.rs
new file mode 100644
index 00000000..e0a150fc
--- /dev/null
+++ b/tests/commit_signing_check.rs
@@ -0,0 +1,32 @@
+//! Hermetic `SelfCheck` for bounded commit-signing header scan (C04 L34).
+//!
+//! Local: `pwsh ./scripts/commit-signing-check.ps1 -SelfCheck`
+
+use std::path::PathBuf;
+use std::process::Command;
+
+fn repo_root() -> PathBuf {
+ PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+}
+
+#[test]
+fn commit_signing_check_self_check_validates_bounded_header_scan() {
+ let script = repo_root().join("scripts/commit-signing-check.ps1");
+ assert!(script.is_file(), "expected commit-signing check script at {}", script.display());
+
+ let output = Command::new("pwsh")
+ .args(["-NoProfile", "-File", script.to_str().expect("utf-8 script path"), "-SelfCheck"])
+ .output()
+ .unwrap_or_else(|error| panic!("failed to spawn pwsh for SelfCheck: {error}"));
+
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ assert!(
+ output.status.success(),
+ "commit-signing-check.ps1 -SelfCheck failed\nstdout:\n{stdout}\nstderr:\n{stderr}"
+ );
+ assert!(
+ stdout.contains("Commit signing check SelfCheck passed"),
+ "expected SelfCheck success line, got:\n{stdout}"
+ );
+}