__tests__/Test-AgentFileBoundary.ps1
|
# Real filesystem/tool integration checks. No API requests or mocked commands. [CmdletBinding()] param() $ErrorActionPreference = 'Stop' Import-Module "$PSScriptRoot/../PSAISuite.psd1" -Force $module = Get-Module PSAISuite $tempBase = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()) $fixture = Join-Path $tempBase ('psaisuite-boundary-' + [guid]::NewGuid()) $root = Join-Path $fixture 'allowed' $outside = Join-Path $fixture 'allowed-sibling' $empty = Join-Path $fixture 'empty' $junction = Join-Path $root 'escape-link' $driveName = 'Boundary' + [guid]::NewGuid().ToString('N').Substring(0, 8) $sentinel = [guid]::NewGuid().ToString() $schemas = @{} foreach ($name in @('Get-ChildItem', 'Get-Content', 'Get-Date')) { $schemas[$name] = (Register-Tool $name).function.parameters $schemas[$name].required = @() # Let the real parameter-set guard select Path or LiteralPath. } # Register-Tool describes a command's default parameter set. Include the real # alternate/dynamic parameters exercised by these explicit schema checks. foreach ($name in @('Get-ChildItem', 'Get-Content')) { $schemas[$name].properties['LiteralPath'] = @{type='array';items=@{type='string'}} } $schemas['Get-ChildItem'].properties['FollowSymlink'] = @{type='boolean'} $schemas['Get-Content'].properties['Raw'] = @{type='boolean'} $schemas['Get-Content'].properties['Wait'] = @{type='boolean'} $schemas['gci'] = $schemas['Get-ChildItem'] function Invoke-FileProbe { param([string]$Name, [hashtable]$Arguments, [string]$AllowedRoot = $root, [hashtable]$Options) & $module { param($Name, $Json, $Schemas, $Root, $Options) $policy = New-AgentToolPolicy -Root $Root -Options $Options Invoke-RegisteredAgentTool -Name $Name -Arguments $Json -RegisteredTools $Schemas -Policy $policy } $Name ($Arguments | ConvertTo-Json -Depth 10 -Compress) $schemas $AllowedRoot $Options } function Assert-Probe { param($Result, [bool]$Failed, [string]$Label) if ($Result.Failed -ne $Failed -or ($Failed -and $Result.Output.Contains($sentinel))) { throw "Boundary check failed: $Label ($($Result.Output))" } Write-Output "PASS: $Label" } try { New-Item -ItemType Directory -Path $root, $outside, $empty, (Join-Path $root 'sub') | Out-Null Set-Content -LiteralPath (Join-Path $outside 'secret.txt') -Value $sentinel Set-Content -LiteralPath (Join-Path $root 'data.csv') -Value 'inside' Set-Content -LiteralPath (Join-Path $root 'literal[1].txt') -Value 'literal' Set-Content -LiteralPath (Join-Path $root 'sub/nested.txt') -Value 'nested' Set-Content -LiteralPath (Join-Path $root 'long.txt') -Value ('x' * 30000) [System.IO.File]::WriteAllText((Join-Path $root 'big.bin'), ('x' * (1MB + 1))) $root = & $module { param($Path) Resolve-AgentToolRoot -Path $Path } $root Assert-Probe (Invoke-FileProbe Get-ChildItem @{}) $false 'omitted path stays inside root' $inside = Invoke-FileProbe Get-Content @{Path='./data.csv'} Assert-Probe $inside $false 'relative file path' if ($inside.Output.Trim() -ne 'inside') { throw 'Unexpected file output.' } Assert-Probe (Invoke-FileProbe Get-Content @{Path='./*.csv'}) $false 'leaf wildcard inside root' Assert-Probe (Invoke-FileProbe Get-Content @{LiteralPath='./literal[1].txt'}) $false 'literal wildcard characters' Assert-Probe (Invoke-FileProbe Get-Content @{Path='./sub/nested.txt'}) $false 'nested file inside root' Assert-Probe (Invoke-FileProbe Get-ChildItem @{Recurse=$true}) $false 'ordinary recursion inside root' Assert-Probe (Invoke-FileProbe Get-ChildItem @{Path='..'}) $true 'parent traversal denied' Assert-Probe (Invoke-FileProbe Get-Content @{Path=('../allowed-sibling/secret.txt')}) $true 'relative sibling read denied' Assert-Probe (Invoke-FileProbe Get-Content @{LiteralPath=(Join-Path $outside 'secret.txt')}) $true 'absolute sibling and prefix collision denied' Assert-Probe (Invoke-FileProbe gci @{Path=$outside}) $true 'alias cannot bypass boundary' Assert-Probe (Invoke-FileProbe Get-Content @{Path='Env:PATH'}) $true 'other PowerShell provider denied' Assert-Probe (Invoke-FileProbe Get-Content @{Path='../allowed-*/secret.txt'}) $true 'wildcard directory escape denied' Assert-Probe (Invoke-FileProbe Get-ChildItem @{Filter='../allowed-sibling/*'}) $true 'filter traversal denied' Assert-Probe (Invoke-FileProbe Get-ChildItem @{FollowSymlink=$true}) $true 'following links denied' Assert-Probe (Invoke-FileProbe Get-Content @{Path='./data.csv';Wait=$true}) $true 'unbounded waiting denied' Assert-Probe (Invoke-FileProbe Get-Content @{Path='./big.bin'}) $true 'oversized read denied' $bounded = Invoke-FileProbe Get-Content @{Path='./long.txt';Raw=$true} Assert-Probe $bounded $false 'large output safely bounded' if ($bounded.Output.Length -gt 20200 -or $bounded.Output -notmatch 'truncated') { throw 'Output limit was not applied.' } $unlimited = Invoke-FileProbe Get-Content @{Path='./long.txt';Raw=$true} -Options @{MaxOutputCharacters=0} Assert-Probe $unlimited $false 'output limit can be disabled by the caller' if ($unlimited.Output.Length -le 20000 -or $unlimited.Output -match 'truncated') { throw 'Unlimited output option was ignored.' } Assert-Probe (Invoke-FileProbe Get-Content @{Path='./big.bin';Raw=$true} -Options @{MaxReadBytes=2MB;MaxOutputCharacters=100}) $false 'read budget can be increased' $limitedListing = Invoke-FileProbe Get-ChildItem @{} -Options @{MaxListedItems=1} Assert-Probe $limitedListing $false 'listing budget is configurable' if ($limitedListing.Output -notmatch 'limited to 1 items') { throw 'Listing policy was ignored.' } Assert-Probe (Invoke-FileProbe Get-ChildItem @{Recurse=$true} -Options @{MaxScanEntries=1}) $true 'scan budget is configurable' Assert-Probe (Invoke-FileProbe Get-Content @{Path=@('./data.csv','./sub/nested.txt')} -Options @{MaxFilePaths=1}) $true 'file count budget is configurable' Assert-Probe (Invoke-FileProbe Get-Content @{Path=@('./data.csv') * 101} -Options @{MaxFilePaths=0}) $false 'file count budget can be disabled' Assert-Probe (Invoke-FileProbe Get-Date @{} -Options @{BeforeInvoke={ param($call) $call.Name -ne 'Get-Date' }}) $true 'generic callback can deny a non-file tool' Assert-Probe (Invoke-FileProbe Get-Date @{} -Options @{BeforeInvoke={ param($call) $call.Name -eq 'Get-Date' }}) $false 'generic callback can allow a non-file tool' Assert-Probe (Invoke-FileProbe Get-Date @{} -Options @{BeforeInvoke={ 'allow' }}) $true 'non-Boolean policy decisions fail closed' Assert-Probe (Invoke-FileProbe gci @{} -Options @{BeforeInvoke={ param($call) $call.Command.Name -ne 'Get-ChildItem' }}) $true 'policy callback sees the resolved command behind an alias' $emptyResult = Invoke-FileProbe Get-ChildItem @{} $empty Assert-Probe $emptyResult $false 'empty directory reported without searching elsewhere' if ($emptyResult.Output -notmatch 'No matching items') { throw 'Empty directory result is unclear.' } New-PSDrive -Name $driveName -PSProvider FileSystem -Root $outside -Scope Global | Out-Null Assert-Probe (Invoke-FileProbe Get-Content @{Path=($driveName + ':\secret.txt')}) $true 'PSDrive mapping cannot bypass boundary' $linkType = if ([System.IO.Path]::DirectorySeparatorChar -eq '\') { 'Junction' } else { 'SymbolicLink' } New-Item -ItemType $linkType -Path $junction -Target $outside | Out-Null Assert-Probe (Invoke-FileProbe Get-Content @{Path='./escape-link/secret.txt'}) $true 'junction or symlink traversal denied' Assert-Probe (Invoke-FileProbe Get-ChildItem @{Recurse=$true}) $true 'recursive link escape denied' $listing = Invoke-FileProbe Get-ChildItem @{} Assert-Probe $listing $false 'nonrecursive listing omits links' if ($listing.Output -match 'escape-link') { throw 'Directory listing exposed link metadata.' } $rejectedRoot = $false try { & $module { param($Path) Resolve-AgentToolRoot -Path $Path } $junction | Out-Null } catch { $rejectedRoot = $true } if (-not $rejectedRoot) { throw 'Link-based root was accepted.' } Write-Output 'PASS: link-based root denied' } finally { Remove-PSDrive -Name $driveName -Scope Global -ErrorAction SilentlyContinue if (Test-Path -LiteralPath $junction) { Remove-Item -LiteralPath $junction -Force } $resolved = [System.IO.Path]::GetFullPath($fixture) if ($resolved -eq $fixture -and (Split-Path -Path $resolved -Parent) -eq $tempBase.TrimEnd([char[]]@('/', '\')) -and (Split-Path -Path $resolved -Leaf) -like 'psaisuite-boundary-*') { Remove-Item -LiteralPath $resolved -Recurse -Force } } |