Public/AgentFileBoundary.ps1
|
# File-tool boundaries, not an OS sandbox for arbitrary user-supplied functions. function Resolve-AgentToolRoot { [OutputType([string])] [CmdletBinding()] param([Parameter(Mandatory)][string]$Path) $item = Microsoft.PowerShell.Management\Get-Item -LiteralPath $Path -Force -ErrorAction Stop if ($item.PSProvider.Name -ne 'FileSystem' -or -not $item.PSIsContainer) { throw 'ToolRoot must be an existing FileSystem directory.' } $root = [System.IO.Path]::GetFullPath($item.FullName) $volumeRoot = [System.IO.Path]::GetPathRoot($root) if ($root.Length -gt $volumeRoot.Length) { $root = $root.TrimEnd([char[]]@('/', '\')) } Assert-AgentFilePath -Path $root -Root $root return $root } function Assert-AgentFilePath { [CmdletBinding()] param( [Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$Root ) $comparison = if ([System.IO.Path]::DirectorySeparatorChar -eq '\') { [System.StringComparison]::OrdinalIgnoreCase } else { [System.StringComparison]::Ordinal } $prefix = $Root.TrimEnd([char[]]@('/', '\')) + [System.IO.Path]::DirectorySeparatorChar if (-not $Path.Equals($Root, $comparison) -and -not $Path.StartsWith($prefix, $comparison)) { throw 'Path is outside ToolRoot. Stay inside the allowed directory; ask the user to choose a different ToolRoot if necessary.' } # Inspect each existing ancestor without traversing a link's contents. $ancestor = $Path while ($ancestor) { $item = $null try { $item = Microsoft.PowerShell.Management\Get-Item -LiteralPath $ancestor -Force -ErrorAction Stop } catch [System.Management.Automation.ItemNotFoundException] { $item = $null } if ($item -and ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { throw 'Links and junctions are not permitted by the file-tool boundary.' } $parent = [System.IO.Directory]::GetParent($ancestor) if ($null -eq $parent -or $parent.FullName -eq $ancestor) { break } $ancestor = $parent.FullName } } function Assert-AgentFileTree { [CmdletBinding()] param([Parameter(Mandatory)][string]$Path, [int]$MaxEntries) $pending = New-Object 'System.Collections.Generic.Queue[string]' $pending.Enqueue($Path) $count = 0 while ($pending.Count -gt 0) { $directory = $pending.Dequeue() foreach ($entry in Microsoft.PowerShell.Management\Get-ChildItem -LiteralPath $directory -Force -ErrorAction Stop) { $count++ if ($MaxEntries -gt 0 -and $count -gt $MaxEntries) { throw "Recursive listing exceeds the policy's $MaxEntries entries. Select a smaller directory or adjust ToolPolicy.MaxScanEntries." } if ($entry.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { throw 'Recursive listing would encounter a link or junction. Select an explicit directory without links.' } if ($entry.PSIsContainer) { $pending.Enqueue($entry.FullName) } } } } function Invoke-AgentFileTool { [CmdletBinding()] param( [Parameter(Mandatory)][System.Management.Automation.CommandInfo]$Command, [Parameter(Mandatory)][hashtable]$Arguments, [Parameter(Mandatory)][hashtable]$Policy ) $Root = $Policy.Root if ($Arguments.FollowSymlink) { throw 'Following links is not permitted by the file-tool boundary.' } if ($Arguments.Wait) { throw 'Get-Content -Wait is not permitted in an agent request.' } if ($Arguments.Stream) { throw 'Alternate data streams require a purpose-built tool.' } foreach ($key in @('Filter', 'Include', 'Exclude')) { foreach ($pattern in $Arguments[$key]) { if ([string]$pattern -match '[/\\:]') { throw 'File filters must contain filename patterns only, without directories or providers.' } } } $literal = $Arguments.ContainsKey('LiteralPath') $paths = if ($literal) { @($Arguments.LiteralPath) } elseif ($Arguments.ContainsKey('Path')) { @($Arguments.Path) } else { @($Root) } if ($paths.Count -eq 0) { throw 'Supply at least one file path.' } if ($Policy.MaxFilePaths -gt 0 -and $paths.Count -gt $Policy.MaxFilePaths) { throw 'Too many file paths for ToolPolicy.MaxFilePaths.' } $resolvedPaths = @() $readBytes = 0L foreach ($path in $paths) { if ([string]::IsNullOrWhiteSpace([string]$path)) { throw 'Empty file paths are not permitted.' } $candidate = [string]$path if (-not [System.IO.Path]::IsPathRooted($candidate) -and $candidate -notmatch '^([^:]+:|~)') { $candidate = Join-Path -Path $Root -ChildPath $candidate } $provider = $null $drive = $null $full = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($candidate, [ref]$provider, [ref]$drive) if ($provider.Name -ne 'FileSystem') { throw 'File tools may access only the FileSystem provider.' } $full = [System.IO.Path]::GetFullPath($full) if ([System.IO.Path]::DirectorySeparatorChar -eq '\' -and $full.Substring([System.IO.Path]::GetPathRoot($full).Length).Contains(':')) { throw 'Alternate data streams are not permitted in file paths.' } $hasWildcard = -not $literal -and [System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters($full) if ($hasWildcard) { $directory = [System.IO.Path]::GetDirectoryName($full) if ([System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters($directory)) { throw 'Wildcards are permitted only in the final filename. Select an explicit directory inside ToolRoot.' } Assert-AgentFilePath -Path $directory -Root $Root $resolvedMatches = @(Microsoft.PowerShell.Management\Resolve-Path -Path $full -ErrorAction Stop) } else { Assert-AgentFilePath -Path $full -Root $Root $resolvedMatches = @(Microsoft.PowerShell.Management\Resolve-Path -LiteralPath $full -ErrorAction Stop) } foreach ($match in $resolvedMatches) { $target = [System.IO.Path]::GetFullPath($match.ProviderPath) Assert-AgentFilePath -Path $target -Root $Root if ($Policy.MaxFilePaths -gt 0 -and $resolvedPaths.Count -ge $Policy.MaxFilePaths) { throw 'Too many matching paths for ToolPolicy.MaxFilePaths. Select fewer files or adjust the policy.' } $item = Microsoft.PowerShell.Management\Get-Item -LiteralPath $target -Force -ErrorAction Stop if ($Command.Name -eq 'Get-Content') { if ($item.PSIsContainer) { throw 'Get-Content requires a file.' } $readBytes += $item.Length if ($Policy.MaxReadBytes -gt 0 -and $readBytes -gt $Policy.MaxReadBytes) { throw "The selected files exceed ToolPolicy.MaxReadBytes ($($Policy.MaxReadBytes)). Select smaller files or adjust the policy." } } elseif (($Arguments.Recurse -or $Arguments.ContainsKey('Depth')) -and $item.PSIsContainer) { Assert-AgentFileTree -Path $target -MaxEntries $Policy.MaxScanEntries } $resolvedPaths += $target } } $bound = @{} foreach ($key in $Arguments.Keys) { $bound[$key] = $Arguments[$key] } $bound.Remove('Path') $bound.Remove('LiteralPath') $bound.LiteralPath = $resolvedPaths if ($Command.Name -eq 'Get-ChildItem') { $listing = { & $Command @bound 2>&1 | Where-Object { $_ -isnot [System.IO.FileSystemInfo] -or -not ($_.Attributes -band [System.IO.FileAttributes]::ReparsePoint) } } $values = if ($Policy.MaxListedItems -gt 0) { @(& $listing | Select-Object -First ($Policy.MaxListedItems + 1)) } else { @(& $listing) } } else { $values = @(& $Command @bound 2>&1) } $errors = @($values | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] }) if ($errors.Count -gt 0) { return [pscustomobject]@{ Output = 'Error: ' + (($errors | ForEach-Object { $_.Exception.Message }) -join '; '); Failed = $true } } if ($values.Count -eq 0) { $text = if ($Command.Name -eq 'Get-ChildItem') { 'No matching items in the allowed directory. Do not search outside ToolRoot; if there are no relevant files, tell the user.' } else { '(no output)' } } else { $text = if ($Command.Name -eq 'Get-ChildItem' -and $Policy.MaxListedItems -gt 0) { ($values | Select-Object -First $Policy.MaxListedItems | Out-String -Width 4096).TrimEnd() } else { ($values | Out-String -Width 4096).TrimEnd() } } if ($Command.Name -eq 'Get-ChildItem' -and $Policy.MaxListedItems -gt 0 -and $values.Count -gt $Policy.MaxListedItems) { $text += "`n[Listing limited to $($Policy.MaxListedItems) items by ToolPolicy; select a smaller query or adjust the policy.]" } return [pscustomobject]@{ Output = $text; Failed = $false } } |