Private/Policy.ps1

# The shared development policy, status checkpoints, and the audit of how a repo
# refers to them (Test-SLPolicyReference).
#
# A reviewed policy copy starts with a header that makes it verifiable offline:
# <!-- SecondLine-Policy: version=1 sha256=<hex> -->
# The hash covers the lines after the header, joined with LF, so a CRLF checkout of
# the same text verifies too. Entry points (CLAUDE.md, AGENTS.md) name the copy and
# the checkpoint with marker lines:
# SecondLine-Policy: 1 docs/shared-development-policy.md
# SecondLine-Status: docs/status/current.md

$script:SLPolicyHeader = '^<!--\s*SecondLine-Policy:\s*version=(?<version>\d+)\s+sha256=(?<hash>[0-9a-fA-F]{64}|PENDING)\s*-->\s*$'
$script:SLPolicyMarker = '(?m)^\s*SecondLine-Policy:\s*(?<version>\S+)\s+(?<path>\S+)\s*$'
$script:SLStatusMarker = '(?m)^\s*SecondLine-Status:\s*(?<path>\S+)\s*$'
$script:SLEntryPoints = @('CLAUDE.md', 'AGENTS.md')
$script:SLStatusSections = @('Position', 'Completed', 'Remaining', 'Changed files', 'Verification', 'Blockers', 'Next action')
# Shapes of real secrets. A match is reported by kind, never echoed.
$script:SLCredentialPatterns = [ordered]@{
    'private key'          = '-----BEGIN [A-Z ]*PRIVATE KEY-----'
    'GitHub token'         = '\b(ghp|gho|ghu|ghs|ghr|github_pat)_[A-Za-z0-9_]{20,}'
    'NuGet/Gallery key'    = '\boy2[a-z0-9]{43}\b'
    'AWS access key'       = '\bAKIA[0-9A-Z]{16}\b'
    'connection password'  = '(?i)(password|pwd)\s*=\s*[^;\s]{4,}'
    'assigned secret'      = '(?i)\b(api[_-]?key|secret|token)\b\s*[:=]\s*["'']?[A-Za-z0-9+/_\-]{16,}'
    'credentials in a URL' = '(?i)\b[a-z][a-z0-9+.-]*://[^/\s:@]+:[^/\s@]+@'
}

# The policy text shipped with this module: Policy\ in the package, docs\ in a checkout.
function Get-SLShippedPolicyPath {
    $base = Split-Path $PSScriptRoot -Parent
    foreach ($candidate in @((Join-Path $base 'Policy\shared-development-policy.md'), (Join-Path (Split-Path $base -Parent) 'docs\shared-development-policy.md'))) {
        if (Test-Path -LiteralPath $candidate -PathType Leaf) { return $candidate }
    }
    return $null
}

function Get-SLStatusTemplatePath { return (Join-Path (Split-Path $PSScriptRoot -Parent) 'Policy\status-template.md') }

# SHA-256 (lowercase hex) of policy body lines, joined with LF and ending with LF.
function Get-SLPolicyHash {
    param([AllowEmptyCollection()] [string[]] $BodyLines)
    $text = (@($BodyLines) -join "`n") + "`n"
    $sha = [Security.Cryptography.SHA256]::Create()
    try { $bytes = $sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($text)) } finally { $sha.Dispose() }
    return (($bytes | ForEach-Object { $_.ToString('x2') }) -join '')
}

# A policy file's Version, declared Hash, ActualHash, HasHeader and Modified.
function Read-SLPolicyFile {
    param([Parameter(Mandatory)] [string] $Path)
    $lines = @(Read-SLTextLines -Path $Path)
    $result = [pscustomobject]@{ Path = $Path; HasHeader = $false; Version = $null; Hash = $null; ActualHash = $null; Modified = $false }
    if ($lines.Count -eq 0 -or $lines[0] -notmatch $script:SLPolicyHeader) { return $result }
    $result.HasHeader = $true
    $result.Version = [int] $Matches['version']
    $result.Hash = $Matches['hash'].ToLowerInvariant()
    $result.ActualHash = Get-SLPolicyHash -BodyLines @($lines | Select-Object -Skip 1)
    $result.Modified = $result.Hash -ne $result.ActualHash
    return $result
}

function New-SLPolicyFinding {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Returns a new in-memory object; changes no state.')]
    param([string] $Severity, [string] $Kind, [string] $File, [string] $Detail)
    return [pscustomobject]@{ Severity = $Severity; Kind = $Kind; File = $File; Detail = $Detail }
}

# True for paths that only work on one machine: rooted, home-relative, or leaving the repo.
function Test-SLNonPortablePath {
    param([string] $Value)
    return ([IO.Path]::IsPathRooted($Value) -or $Value -match '^(~|\$|%)' -or $Value -match '(^|[\\/])\.\.([\\/]|$)' -or $Value -match '^[A-Za-z]:')
}

# Audits one repo's entry points, policy copy, checkpoint, bootstrap copies and imports.
function Get-SLPolicyAudit {
    param([Parameter(Mandatory)] [string] $RepoPath)
    $findings = New-Object System.Collections.Generic.List[object]
    $shipped = $null
    $shippedPath = Get-SLShippedPolicyPath
    if ($shippedPath) { $shipped = Read-SLPolicyFile -Path $shippedPath }

    $markers = @{}
    foreach ($name in $script:SLEntryPoints) {
        $file = Join-Path $RepoPath $name
        if (-not (Test-Path -LiteralPath $file -PathType Leaf)) {
            $findings.Add((New-SLPolicyFinding 'Error' 'EntryMissing' $name "$name is missing; Claude and Codex need equivalent entry points."))
            continue
        }
        $text = (Read-SLTextLines -Path $file) -join "`n"
        $policy = [regex]::Match($text, $script:SLPolicyMarker)
        $status = [regex]::Match($text, $script:SLStatusMarker)
        $markers[$name] = [pscustomobject]@{
            Policy  = $(if ($policy.Success) { "$($policy.Groups['version'].Value) $($policy.Groups['path'].Value)" } else { $null })
            Version = $policy.Groups['version'].Value
            Path    = $policy.Groups['path'].Value
            Status  = $(if ($status.Success) { $status.Groups['path'].Value } else { $null })
        }
        if (-not $policy.Success) { $findings.Add((New-SLPolicyFinding 'Error' 'PolicyReferenceMissing' $name "No 'SecondLine-Policy: <version> <path>' line.")) }
        if (-not $status.Success) { $findings.Add((New-SLPolicyFinding 'Error' 'StatusReferenceMissing' $name "No 'SecondLine-Status: docs/status/current.md' line.")) }
        foreach ($m in [regex]::Matches($text, '(?im)([A-Za-z]:\\|/(Users|home)/)[^\s`"'')]*shared-development-policy[^\s`"'')]*')) {
            $findings.Add((New-SLPolicyFinding 'Error' 'PolicyReferenceNotPortable' $name "Refers to a machine-specific policy path ($($m.Value)); use a reviewed copy in the repo."))
        }
    }
    $present = @($script:SLEntryPoints | Where-Object { $markers.ContainsKey($_) })
    if ($present.Count -eq 2) {
        $a = $markers[$present[0]]; $b = $markers[$present[1]]
        if ($a.Policy -ne $b.Policy -or $a.Status -ne $b.Status) {
            $findings.Add((New-SLPolicyFinding 'Error' 'EntryMismatch' "$($present -join ', ')" "Entry points differ: $($present[0]) has '$($a.Policy)' / '$($a.Status)', $($present[1]) has '$($b.Policy)' / '$($b.Status)'."))
        }
    }

    # The policy copy each distinct reference points at.
    foreach ($ref in @($markers.Values | Where-Object Policy | Group-Object Policy)) {
        $m = $ref.Group[0]
        if (Test-SLNonPortablePath $m.Path) {
            $findings.Add((New-SLPolicyFinding 'Error' 'PolicyReferenceNotPortable' $m.Path 'The policy path must be relative and inside the repo, so CI and other machines can read it.'))
            continue
        }
        $copy = Join-Path $RepoPath $m.Path
        if (-not (Test-Path -LiteralPath $copy -PathType Leaf)) {
            $findings.Add((New-SLPolicyFinding 'Error' 'PolicyReferenceBroken' $m.Path 'The referenced policy file does not exist. Export a reviewed copy with Export-SLPolicy.'))
            continue
        }
        $info = Read-SLPolicyFile -Path $copy
        if (-not $info.HasHeader) {
            $findings.Add((New-SLPolicyFinding 'Error' 'PolicyHeaderMissing' $m.Path 'No SecondLine-Policy version header; it cannot be verified. Export it with Export-SLPolicy.'))
            continue
        }
        if ("$($info.Version)" -ne $m.Version) {
            $findings.Add((New-SLPolicyFinding 'Error' 'PolicyVersionMismatch' $m.Path "Entry points reference version $($m.Version); the copy is version $($info.Version)."))
        }
        if ($info.Modified) {
            $findings.Add((New-SLPolicyFinding 'Warning' 'PolicyModified' $m.Path 'The copy differs from its reviewed text (hash mismatch). Put local rules in the entry points, not the shared policy.'))
        }
        if ($shipped -and $shipped.HasHeader -and $info.Version -lt $shipped.Version) {
            $findings.Add((New-SLPolicyFinding 'Warning' 'PolicyOutdated' $m.Path "Version $($info.Version); this SecondLine ships version $($shipped.Version). Review it, then update with Export-SLPolicy."))
        }
    }

    # The checkpoint.
    foreach ($statusPath in @($markers.Values | Where-Object Status | ForEach-Object { $_.Status } | Sort-Object -Unique)) {
        if (Test-SLNonPortablePath $statusPath) {
            $findings.Add((New-SLPolicyFinding 'Error' 'StatusReferenceNotPortable' $statusPath 'The checkpoint path must be relative and inside the repo.'))
            continue
        }
        $file = Join-Path $RepoPath $statusPath
        if (-not (Test-Path -LiteralPath $file -PathType Leaf)) {
            $findings.Add((New-SLPolicyFinding 'Error' 'StatusMissing' $statusPath 'The checkpoint does not exist. Create it with New-SLStatusCheckpoint.'))
            continue
        }
        foreach ($f in @(Test-SLStatusCheckpoint -Path $file -Label $statusPath)) { $findings.Add($f) }
    }

    # Bootstrap copies and hardcoded imports, in tracked PowerShell and CI files.
    $tracked = @()
    if (Test-SLGitRepo $RepoPath) {
        $r = Invoke-SLGit -RepoPath $RepoPath -Arguments @('ls-files', '-z', '--', '*.ps1', '*.psm1', '*.yml', '*.yaml')
        if ($r.ExitCode -eq 0) { $tracked = @($r.Output -split "`0" | Where-Object { $_ }) }
    }
    foreach ($rel in $tracked) {
        $full = Join-Path $RepoPath $rel
        if (-not (Test-Path -LiteralPath $full -PathType Leaf)) { continue }
        $text = [IO.File]::ReadAllText($full)
        $boot = [regex]::Match($text, 'SecondLine-Bootstrap:\s*(\d+)')
        if ($boot.Success -and [int] $boot.Groups[1].Value -lt $script:SLBootstrapVersion) {
            $findings.Add((New-SLPolicyFinding 'Warning' 'BootstrapOutdated' $rel "Bootstrap format $($boot.Groups[1].Value); this SecondLine ships $script:SLBootstrapVersion."))
        }
        foreach ($m in [regex]::Matches($text, '(?im)^[^#\r\n]*Import-Module\s+(-Name\s+)?[''"]?SecondLine\b[^\r\n]*-(RequiredVersion|MinimumVersion|MaximumVersion)\b[^\r\n]*')) {
            $findings.Add((New-SLPolicyFinding 'Warning' 'HardcodedVersion' $rel "Pins SecondLine in the script ($($m.Value.Trim())). Move the version to ModuleVersions in the standard and import with the bootstrap."))
        }
    }

    $list = $findings.ToArray()
    return [pscustomobject]@{
        PSTypeName = 'SecondLine.PolicyCheck'
        Repo       = Split-Path $RepoPath -Leaf
        Path       = $RepoPath
        Findings   = $list
        Ok         = @($list | Where-Object Severity -eq 'Error').Count -eq 0
    }
}

# Checks a checkpoint's structure and looks for credentials.
function Test-SLStatusCheckpoint {
    param([Parameter(Mandatory)] [string] $Path, [string] $Label)
    if (-not $Label) { $Label = $Path }
    $lines = @(Read-SLTextLines -Path $Path)
    $text = $lines -join "`n"
    $headings = @($lines | Where-Object { $_ -match '^##\s+' } | ForEach-Object { ($_ -replace '^##\s+', '').Trim() })
    $missing = @($script:SLStatusSections | Where-Object { $headings -notcontains $_ })
    if ($missing.Count -gt 0) {
        New-SLPolicyFinding 'Error' 'StatusIncomplete' $Label "Missing section(s): $($missing -join ', ')."
    }
    foreach ($field in 'Branch', 'Base commit') {
        if ($text -notmatch "(?im)^[ \t]*-[ \t]*$([regex]::Escape($field)):[ \t]*\S") {
            New-SLPolicyFinding 'Error' 'StatusIncomplete' $Label "Position has no '$($field):' value."
        }
    }
    if ($text -notmatch '(?im)^\s*-\s*(Verified|Planned)\b') {
        New-SLPolicyFinding 'Warning' 'StatusUnlabelled' $Label "Verification items should start with 'Verified' or 'Planned', so results and plans aren't confused."
    }
    foreach ($kind in $script:SLCredentialPatterns.Keys) {
        if ($text -match $script:SLCredentialPatterns[$kind]) {
            New-SLPolicyFinding 'Error' 'StatusCredential' $Label "Looks like it contains a $kind. Remove it; checkpoints must never hold credentials."
        }
    }
}