workflows/default/systems/runtime/modules/ManifestCondition.psm1

function Test-ManifestCondition {
    <#
    .SYNOPSIS
    Evaluate a gitignore-style path condition against the project root.

    .DESCRIPTION
    Conditions are path patterns resolved from the project root (parent of .bot/).
    - Path present = must exist: ".bot/workspace/product/mission.md"
    - ! prefix = must NOT exist: "!.bot/workspace/product/mission.md"
    - Glob * = directory has matching files: ".git/refs/heads/*"
    - Single string = one condition. Array = AND (all must match).
    - Legacy file_exists: prefix = backward-compat alias (resolves under .bot/).
    #>

    param(
        [Parameter(Mandatory)]
        [string]$ProjectRoot,

        [Parameter()]
        [object]$Condition
    )

    if (-not $Condition) { return $true }

    # Normalize to array
    $rules = if ($Condition -is [array]) { $Condition }
             elseif ($Condition -is [string]) { @($Condition) }
             else { return $true }

    $resolvedRoot = [System.IO.Path]::GetFullPath($ProjectRoot).TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)
    $rootWithSep = $resolvedRoot + [System.IO.Path]::DirectorySeparatorChar
    # Windows/macOS are case-insensitive on paths; Linux is case-sensitive.
    $pathComparison = if ($IsLinux) { [System.StringComparison]::Ordinal } else { [System.StringComparison]::OrdinalIgnoreCase }

    foreach ($rule in $rules) {
        $rule = "$rule".Trim()
        if (-not $rule) { continue }

        # Legacy compat: strip file_exists: prefix -> resolve under .bot/
        if ($rule -match '^file_exists:(.+)$') {
            $rule = ".bot/$($Matches[1])"
        }

        $negate = $rule.StartsWith('!')
        if ($negate) { $rule = $rule.Substring(1) }

        $fullPath = Join-Path $ProjectRoot $rule

        # Path traversal guard: resolved path must stay within project root.
        # Use boundary-safe comparison (root + separator) with OS-appropriate casing
        # so sibling paths like "C:\projX" can't bypass a "C:\proj" root.
        $resolvedFull = [System.IO.Path]::GetFullPath($fullPath)
        $insideRoot = $resolvedFull.Equals($resolvedRoot, $pathComparison) -or `
                      $resolvedFull.StartsWith($rootWithSep, $pathComparison)
        if (-not $insideRoot) {
            if (Get-Command Write-BotLog -ErrorAction SilentlyContinue) {
                Write-BotLog -Level Warn -Message "[ManifestCondition] Path traversal blocked: '$rule' resolves outside project root."
            }
            return $false
        }

        $exists = if ($rule -match '\*') {
            @(Resolve-Path $fullPath -ErrorAction SilentlyContinue).Count -gt 0
        } else {
            Test-Path $fullPath
        }

        if ($negate -eq $exists) { return $false }
    }

    return $true
}

Export-ModuleMember -Function 'Test-ManifestCondition'