Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 15 additions & 7 deletions ts/docs/architecture/workflows/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -356,13 +356,21 @@ PowerShell supports two creation paths:
The `powershellRunner.mts` module spawns a PowerShell child process
running `scriptHost.ps1`. Arguments are passed via command-line flags:

| Flag | Value |
| --------------------- | ------------------------------------- |
| `-ScriptBody` | The PowerShell script text |
| `-ParametersJson` | JSON-serialized parameter values |
| `-AllowedCmdletsJson` | JSON array of permitted cmdlet names |
| `-TimeoutSeconds` | Maximum execution time |
| `-AllowedPathsJson` | JSON array of permitted path patterns |
| Flag | Value |
| --------------------- | --------------------------------------------------------------------------------------- |
| `-ScriptBody` | The PowerShell script text |
| `-ParametersJson` | JSON-serialized parameter values |
| `-ParameterRolesJson` | Path and executable parameter roles derived from the flow's typed parameter definitions |
| `-AllowedCmdletsJson` | JSON array of permitted cmdlet names |
| `-TimeoutSeconds` | Maximum execution time |
| `-AllowedPathsJson` | JSON array of permitted path patterns |

PowerShell recipes do not persist a separate `parameterRoles` property.
`scriptParameters[].type` is the source of truth: `path` parameters are
canonicalized as filesystem paths, while `executable` parameters resolve bare
application names through PowerShell command resolution before the resulting
file path is checked against `allowedPaths`. Other string parameters, including
file content, URLs, patterns, and command arguments, are not path-validated.

### Sandbox: constrained runspace

Expand Down
7 changes: 7 additions & 0 deletions ts/packages/agentSdk/src/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ export type SerializedError = {
export type ActionResultError = {
error: string;
fallbackToReasoning?: boolean | undefined;
// Stable machine-readable code for callers that need policy or retry
// decisions without parsing the display message.
errorCode?: string | undefined;
// Whether the caller may safely retry after changing the action.
retryable?: boolean | undefined;
// True when the failed action may already have changed external state.
mayHaveSideEffects?: boolean | undefined;
// Rich display to show in place of the plain `error` text (e.g. setup
// instructions with a config snippet, which need markdown to survive
// rendering). Optional — clients fall back to `error` when absent.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
{
"id": "dev-route-02",
"category": "dev-actions-routing",
"description": "PowerShell schema family includes the root flow schema",
"description": "PowerShell schema family includes the files namespace",
"required": true,
"setup": {
"requiredFlows": ["listFiles"]
Expand All @@ -51,7 +51,7 @@
"disposition": {
"status": "handled",
"path": "action",
"schemas": ["powershell"]
"schemas": ["powershell.powershell-files"]
}
}
}
Expand Down
12 changes: 10 additions & 2 deletions ts/packages/agents/powershell/scripts/compileRecipes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,10 @@ function buildTsType(recipe) {
const paramLines = params
.map((p) => {
const opt = p.required === false ? "?" : "";
const tsType = p.type === "path" ? "string" : p.type;
const tsType =
p.type === "path" || p.type === "executable"
? "string"
: p.type;
const comment = p.description
? ` // ${p.description}\n`
: "";
Expand All @@ -97,7 +100,12 @@ function buildTsType(recipe) {
function buildFlowJson(recipe) {
const params = {};
for (const p of recipe.parameters || []) {
const def = { type: p.type === "path" ? "string" : p.type };
const def = {
type:
p.type === "path" || p.type === "executable"
? "string"
: p.type,
};
if (p.required !== undefined) def.required = p.required;
if (p.default !== undefined) def.default = p.default;
if (p.description) def.description = p.description;
Expand Down
175 changes: 136 additions & 39 deletions ts/packages/agents/powershell/scripts/scriptHost.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ param(
[Parameter(Mandatory=$true)]
[string]$ParametersJson,

[string]$ParameterRolesJson = '{}',

[Parameter(Mandatory=$true)]
[string]$AllowedCmdletsJson,

Expand All @@ -27,9 +29,89 @@ param(

$ErrorActionPreference = 'Stop'

function Remove-TrailingDirectorySeparator {
param([string]$Path)

$root = [System.IO.Path]::GetPathRoot($Path)
if ($Path.Equals($root, [System.StringComparison]::OrdinalIgnoreCase)) {
return $root
}
return $Path.TrimEnd('\', '/')
}

function Get-CanonicalFileSystemPath {
param([string]$Path)

$fullPath = [System.IO.Path]::GetFullPath($Path)
if (Test-Path -LiteralPath $fullPath) {
$item = Get-Item -LiteralPath $fullPath -Force
return Remove-TrailingDirectorySeparator $item.FullName
}

$missingSegments = [System.Collections.Generic.List[string]]::new()
$existingPath = $fullPath
while (-not (Test-Path -LiteralPath $existingPath)) {
$leaf = Split-Path -Leaf $existingPath
$parent = Split-Path -Parent $existingPath
if (-not $leaf -or -not $parent -or $parent -eq $existingPath) {
throw "Unable to resolve path '$Path'."
}
$missingSegments.Insert(0, $leaf)
$existingPath = $parent
}

$canonicalPath = (Get-Item -LiteralPath $existingPath -Force).FullName
foreach ($segment in $missingSegments) {
$canonicalPath = Join-Path $canonicalPath $segment
}
return Remove-TrailingDirectorySeparator ([System.IO.Path]::GetFullPath($canonicalPath))
}

function Get-CanonicalExecutablePath {
param([string]$Path)

if (
[System.IO.Path]::IsPathRooted($Path) -or
$Path.Contains('\') -or
$Path.Contains('/') -or
$Path.StartsWith('.')
) {
return Get-CanonicalFileSystemPath $Path
}

$commands = @(Get-Command -Name $Path -CommandType Application -ErrorAction Stop)
if ($commands.Count -ne 1 -or -not $commands[0].Path) {
throw "Unable to resolve executable '$Path' to one application."
}
return Get-CanonicalFileSystemPath $commands[0].Path
}

function Test-AllowedFileSystemPath {
param(
[string]$Path,
[string[]]$AllowedPaths
)

foreach ($allowedPath in $AllowedPaths) {
if (
$Path.Equals($allowedPath, [System.StringComparison]::OrdinalIgnoreCase) -or
$Path.StartsWith("$allowedPath\", [System.StringComparison]::OrdinalIgnoreCase) -or
$Path.StartsWith("$allowedPath/", [System.StringComparison]::OrdinalIgnoreCase)
) {
return $true
}
}
return $false
}

try {
$allowedCmdlets = $AllowedCmdletsJson | ConvertFrom-Json
$params = $ParametersJson | ConvertFrom-Json
$parameterRoles = $ParameterRolesJson | ConvertFrom-Json
if ($null -eq $parameterRoles -or $parameterRoles -isnot [pscustomobject]) {
Write-Error "Parameter roles must be a JSON object."
exit 1
}
# Parse allowed paths - must handle array properly to avoid PowerShell array unwrapping issues
$parsedPaths = $AllowedPathsJson | ConvertFrom-Json
if ($parsedPaths -is [array]) {
Expand All @@ -50,50 +132,65 @@ try {
$expandedAllowedPaths = @()
foreach ($ap in $AllowedPaths) {
try {
$expandedAllowedPaths += $ExecutionContext.InvokeCommand.ExpandString($ap)
$expandedPath = $ExecutionContext.InvokeCommand.ExpandString($ap)
$expandedAllowedPaths += Get-CanonicalFileSystemPath $expandedPath
} catch {
$expandedAllowedPaths += $ap
Write-Error "Invalid allowed path '$ap': $_"
exit 1
}
}

# Validate path parameters against allowed paths
# NOTE: Only validate paths that look like absolute or relative file paths.
# Skip short single-word strings (like "videos", "downloads") that might be
# library names - let the script handle those with its own resolution logic.
if ($expandedAllowedPaths.Count -gt 0) {
foreach ($prop in $params.PSObject.Properties) {
$val = $prop.Value
if ($val -is [string]) {
# Skip empty values
if (-not $val -or $val -match '^\s*$') { continue }

# Skip short single-word values that look like library names
# (no slashes, no drive letter, not starting with dot)
if ($val -notmatch '[/\\]' -and $val -notmatch '^[a-zA-Z]:' -and $val -notmatch '^\.' -and $val.Length -lt 50) {
continue
}

$isValidPath = $false
try { $isValidPath = Test-Path $val -IsValid } catch { }
if ($isValidPath) {
$resolvedPath = $null
try { $resolvedPath = (Resolve-Path $val -ErrorAction SilentlyContinue).Path } catch {}
if ($resolvedPath) {
$pathAllowed = $false
foreach ($ap in $expandedAllowedPaths) {
if ($resolvedPath -like "$ap*") {
$pathAllowed = $true
break
}
}
# ENFORCEMENT: Block execution if path not allowed
if (-not $pathAllowed) {
Write-Error "Path access denied: '$resolvedPath' is not in allowedPaths. Allowed paths: $($expandedAllowedPaths -join ', ')"
exit 1
}
}
}
$roleProperties = @($parameterRoles.PSObject.Properties)
if ($roleProperties.Count -gt 0 -and $expandedAllowedPaths.Count -eq 0) {
Write-Error "Path parameter roles require at least one allowed path."
exit 1
}

foreach ($roleProperty in $roleProperties) {
$role = [string]$roleProperty.Value
if ($role -ne 'path' -and $role -ne 'executable') {
Write-Error "Unsupported parameter role '$role' for '$($roleProperty.Name)'."
exit 1
}

$parameterProperty = @(
$params.PSObject.Properties |
Where-Object { $_.Name -ieq $roleProperty.Name }
) | Select-Object -First 1
if ($null -eq $parameterProperty) {
continue
}

$value = $parameterProperty.Value
if ($null -eq $value -or $value -eq '') {
continue
}
if ($value -isnot [string]) {
Write-Error "Parameter '$($parameterProperty.Name)' with role '$role' must be a string."
exit 1
}
if ([System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters($value)) {
Write-Error "Parameter '$($parameterProperty.Name)' with role '$role' cannot contain wildcard characters."
exit 1
}
if ($value -match '^[a-zA-Z][a-zA-Z0-9-]*:' -and $value -notmatch '^[a-zA-Z]:[\\/]') {
Write-Error "Parameter '$($parameterProperty.Name)' uses an unsupported provider or URI path."
exit 1
}

try {
$resolvedPath = if ($role -eq 'executable') {
Get-CanonicalExecutablePath $value
} else {
Get-CanonicalFileSystemPath $value
}
} catch {
Write-Error "Invalid $role parameter '$($parameterProperty.Name)': $_"
exit 1
}
if (-not (Test-AllowedFileSystemPath $resolvedPath $expandedAllowedPaths)) {
Write-Error "Path access denied: '$resolvedPath' is not in allowedPaths. Allowed paths: $($expandedAllowedPaths -join ', ')"
exit 1
}
}

Expand Down
Loading
Loading