/ utils / powershell / parser.ts
parser.ts
   1  import { execa } from 'execa'
   2  import { logForDebugging } from '../debug.js'
   3  import { memoizeWithLRU } from '../memoize.js'
   4  import { getCachedPowerShellPath } from '../shell/powershellDetection.js'
   5  import { jsonParse } from '../slowOperations.js'
   6  
   7  // ---------------------------------------------------------------------------
   8  // Public types describing the parsed output returned to callers.
   9  // These map to System.Management.Automation.Language AST classes.
  10  // Raw internal types (RawParsedOutput etc.) are defined further below.
  11  // ---------------------------------------------------------------------------
  12  
  13  /**
  14   * The PowerShell AST element type for pipeline elements.
  15   * Maps directly to CommandBaseAst derivatives in System.Management.Automation.Language.
  16   */
  17  type PipelineElementType =
  18    | 'CommandAst'
  19    | 'CommandExpressionAst'
  20    | 'ParenExpressionAst'
  21  
  22  /**
  23   * The AST node type for individual command elements (arguments, expressions).
  24   * Used to classify each element during the AST walk so TypeScript can derive
  25   * security flags without extra Find-AstNodes calls in PowerShell.
  26   */
  27  type CommandElementType =
  28    | 'ScriptBlock'
  29    | 'SubExpression'
  30    | 'ExpandableString'
  31    | 'MemberInvocation'
  32    | 'Variable'
  33    | 'StringConstant'
  34    | 'Parameter'
  35    | 'Other'
  36  
  37  /**
  38   * A child node of a command element (one level deep). Populated for
  39   * CommandParameterAst → .Argument (colon-bound parameters like
  40   * `-InputObject:$env:SECRET`). Consumers check `child.type` to classify
  41   * the bound value (Variable, StringConstant, Other) without parsing text.
  42   */
  43  export type CommandElementChild = {
  44    type: CommandElementType
  45    text: string
  46  }
  47  
  48  /**
  49   * The PowerShell AST statement type.
  50   * Maps directly to StatementAst derivatives in System.Management.Automation.Language.
  51   */
  52  type StatementType =
  53    | 'PipelineAst'
  54    | 'PipelineChainAst'
  55    | 'AssignmentStatementAst'
  56    | 'IfStatementAst'
  57    | 'ForStatementAst'
  58    | 'ForEachStatementAst'
  59    | 'WhileStatementAst'
  60    | 'DoWhileStatementAst'
  61    | 'DoUntilStatementAst'
  62    | 'SwitchStatementAst'
  63    | 'TryStatementAst'
  64    | 'TrapStatementAst'
  65    | 'FunctionDefinitionAst'
  66    | 'DataStatementAst'
  67    | 'UnknownStatementAst'
  68  
  69  /**
  70   * A command invocation within a pipeline segment.
  71   */
  72  export type ParsedCommandElement = {
  73    /** The command/cmdlet name (e.g., "Get-ChildItem", "git") */
  74    name: string
  75    /** The command name type: cmdlet, application (exe), or unknown */
  76    nameType: 'cmdlet' | 'application' | 'unknown'
  77    /** The AST element type from PowerShell's parser */
  78    elementType: PipelineElementType
  79    /** All arguments as strings (includes flags like "-Recurse") */
  80    args: string[]
  81    /** The full text of this command element */
  82    text: string
  83    /** AST node types for each element in this command (arguments, expressions, etc.) */
  84    elementTypes?: CommandElementType[]
  85    /**
  86     * Child nodes of each argument, aligned with `args[]` (so
  87     * `children[i]` ↔ `args[i]` ↔ `elementTypes[i+1]`). Only populated for
  88     * Parameter elements with a colon-bound argument. Undefined for elements
  89     * with no children. Lets consumers check `children[i].some(c => c.type
  90     * !== 'StringConstant')` instead of parsing the arg text for `:` + `$`.
  91     */
  92    children?: (CommandElementChild[] | undefined)[]
  93    /** Redirections on this command element (from nested commands in && / || chains) */
  94    redirections?: ParsedRedirection[]
  95  }
  96  
  97  /**
  98   * A redirection found in the command.
  99   */
 100  type ParsedRedirection = {
 101    /** The redirection operator */
 102    operator: '>' | '>>' | '2>' | '2>>' | '*>' | '*>>' | '2>&1'
 103    /** The target (file path or stream number) */
 104    target: string
 105    /** Whether this is a merging redirection like 2>&1 */
 106    isMerging: boolean
 107  }
 108  
 109  /**
 110   * A parsed statement from PowerShell.
 111   * Can be a pipeline, assignment, control flow statement, etc.
 112   */
 113  type ParsedStatement = {
 114    /** The AST statement type from PowerShell's parser */
 115    statementType: StatementType
 116    /** Individual commands in this statement (for pipelines) */
 117    commands: ParsedCommandElement[]
 118    /** Redirections on this statement */
 119    redirections: ParsedRedirection[]
 120    /** Full text of the statement */
 121    text: string
 122    /**
 123     * For control flow statements (if, for, foreach, while, try, etc.),
 124     * commands found recursively inside the body blocks.
 125     * Uses FindAll() to extract ALL nested CommandAst nodes at any depth.
 126     */
 127    nestedCommands?: ParsedCommandElement[]
 128    /**
 129     * Security-relevant AST patterns found via FindAll() on the entire statement,
 130     * regardless of statement type. This catches patterns that elementTypes may
 131     * miss (e.g. member invocations inside assignments, subexpressions in
 132     * non-pipeline statements). Computed in the PS1 script using instanceof
 133     * checks against the PowerShell AST type system.
 134     */
 135    securityPatterns?: {
 136      hasMemberInvocations?: boolean
 137      hasSubExpressions?: boolean
 138      hasExpandableStrings?: boolean
 139      hasScriptBlocks?: boolean
 140    }
 141  }
 142  
 143  /**
 144   * A variable reference found in the command.
 145   */
 146  type ParsedVariable = {
 147    /** The variable path (e.g., "HOME", "env:PATH", "global:x") */
 148    path: string
 149    /** Whether this variable uses splatting (@var instead of $var) */
 150    isSplatted: boolean
 151  }
 152  
 153  /**
 154   * A parse error from PowerShell's parser.
 155   */
 156  type ParseError = {
 157    message: string
 158    errorId: string
 159  }
 160  
 161  /**
 162   * The complete parsed result from the PowerShell AST parser.
 163   */
 164  export type ParsedPowerShellCommand = {
 165    /** Whether the command parsed successfully (no syntax errors) */
 166    valid: boolean
 167    /** Parse errors, if any */
 168    errors: ParseError[]
 169    /** Top-level statements, separated by ; or newlines */
 170    statements: ParsedStatement[]
 171    /** All variable references found */
 172    variables: ParsedVariable[]
 173    /** Whether the token stream contains a stop-parsing (--%) token */
 174    hasStopParsing: boolean
 175    /** The original command text */
 176    originalCommand: string
 177    /**
 178     * All .NET type literals found anywhere in the AST (TypeExpressionAst +
 179     * TypeConstraintAst). TypeName.FullName — the literal text as written, NOT
 180     * the resolved .NET type (e.g. [int] → "int", not "System.Int32").
 181     * Consumed by the CLM-allowlist check in powershellSecurity.ts.
 182     */
 183    typeLiterals?: string[]
 184    /**
 185     * Whether the command contains `using module` or `using assembly` statements.
 186     * These load external code (modules/assemblies) and execute their top-level
 187     * script body or module initializers. The using statement is a sibling of
 188     * the named blocks on ScriptBlockAst, not a child, so it is not visible
 189     * to Process-BlockStatements or any downstream command walker.
 190     */
 191    hasUsingStatements?: boolean
 192    /**
 193     * Whether the command contains `#Requires` directives (ScriptRequirements).
 194     * `#Requires -Modules <name>` triggers module loading from PSModulePath.
 195     */
 196    hasScriptRequirements?: boolean
 197  }
 198  
 199  // ---------------------------------------------------------------------------
 200  
 201  // Default 5s is fine for interactive use (warm pwsh spawn is ~450ms). Windows
 202  // CI under Defender/AMSI load can exceed 5s on consecutive spawns even after
 203  // CAN_SPAWN_PARSE_SCRIPT() warms the JIT (run 23574701241 windows-shard-5:
 204  // attackVectors F1 hit 2×5s timeout → valid:false → 'ask' instead of 'deny').
 205  // Override via env for tests. Read inside parsePowerShellCommandImpl, not
 206  // top-level, per CLAUDE.md (globalSettings.env ordering).
 207  const DEFAULT_PARSE_TIMEOUT_MS = 5_000
 208  function getParseTimeoutMs(): number {
 209    const env = process.env.CLAUDE_CODE_PWSH_PARSE_TIMEOUT_MS
 210    if (env) {
 211      const parsed = parseInt(env, 10)
 212      if (!isNaN(parsed) && parsed > 0) return parsed
 213    }
 214    return DEFAULT_PARSE_TIMEOUT_MS
 215  }
 216  // MAX_COMMAND_LENGTH is derived from PARSE_SCRIPT_BODY.length below (after the
 217  // script body is defined) so it cannot go stale as the script grows.
 218  
 219  /**
 220   * The PowerShell parse script inlined as a string constant.
 221   * This avoids needing to read from disk at runtime (the file may not exist
 222   * in bundled builds). The script uses the native PowerShell AST parser to
 223   * analyze a command and output structured JSON.
 224   */
 225  // Raw types describing PS script JSON output (exported for testing)
 226  export type RawCommandElement = {
 227    type: string // .GetType().Name e.g. "StringConstantExpressionAst"
 228    text: string // .Extent.Text
 229    value?: string // .Value if available (resolves backtick escapes)
 230    expressionType?: string // .Expression.GetType().Name for CommandExpressionAst
 231    children?: { type: string; text: string }[] // CommandParameterAst.Argument, one level
 232  }
 233  
 234  export type RawRedirection = {
 235    type: string // "FileRedirectionAst" or "MergingRedirectionAst"
 236    append?: boolean // .Append (FileRedirectionAst only)
 237    fromStream?: string // .FromStream.ToString() e.g. "Output", "Error", "All"
 238    locationText?: string // .Location.Extent.Text (FileRedirectionAst only)
 239  }
 240  
 241  export type RawPipelineElement = {
 242    type: string // .GetType().Name e.g. "CommandAst", "CommandExpressionAst"
 243    text: string // .Extent.Text
 244    commandElements?: RawCommandElement[]
 245    redirections?: RawRedirection[]
 246    expressionType?: string // for CommandExpressionAst: .Expression.GetType().Name
 247  }
 248  
 249  export type RawStatement = {
 250    type: string // .GetType().Name e.g. "PipelineAst", "IfStatementAst", "TrapStatementAst"
 251    text: string // .Extent.Text
 252    elements?: RawPipelineElement[] // for PipelineAst: the pipeline elements
 253    nestedCommands?: RawPipelineElement[] // commands found via FindAll (all statement types)
 254    redirections?: RawRedirection[] // FileRedirectionAst found via FindAll (non-PipelineAst only)
 255    securityPatterns?: {
 256      // Security-relevant AST node types found via FindAll on the statement
 257      hasMemberInvocations?: boolean
 258      hasSubExpressions?: boolean
 259      hasExpandableStrings?: boolean
 260      hasScriptBlocks?: boolean
 261    }
 262  }
 263  
 264  type RawParsedOutput = {
 265    valid: boolean
 266    errors: { message: string; errorId: string }[]
 267    statements: RawStatement[]
 268    variables: { path: string; isSplatted: boolean }[]
 269    hasStopParsing: boolean
 270    originalCommand: string
 271    typeLiterals?: string[]
 272    hasUsingStatements?: boolean
 273    hasScriptRequirements?: boolean
 274  }
 275  
 276  // This is the canonical copy of the parse script. There is no separate .ps1 file.
 277  /**
 278   * The core parse logic.
 279   * The command is passed via Base64-encoded $EncodedCommand variable
 280   * to avoid here-string injection attacks.
 281   *
 282   * SECURITY — top-level ParamBlock: ScriptBlockAst.ParamBlock is a SIBLING of
 283   * the named blocks (Begin/Process/End/Clean/DynamicParam), not nested inside
 284   * them, so Process-BlockStatements never reaches it. Commands inside param()
 285   * default-value expressions and attribute arguments (e.g. [ValidateScript({...})])
 286   * were invisible to every downstream check. PoC:
 287   *   param($x = (Remove-Item /)); Get-Process   → only Get-Process surfaced
 288   *   param([ValidateScript({rm /;$true})]$x='t') → rm invisible, runs on bind
 289   * Function-level param() IS covered: FindAll on the FunctionDefinitionAst
 290   * statement recurses into its descendants. The gap was only the script-level
 291   * ParamBlock. ParamBlockAst has .Parameters (not .Statements) so we FindAll
 292   * on it directly rather than reusing Process-BlockStatements. We only emit a
 293   * statement if there is something to report, to avoid noise for plain
 294   * param($x) declarations. (Kept compact in-script to preserve argv budget.)
 295   */
 296  /**
 297   * PS1 parse script. Comments live here (not inline) — every char inside the
 298   * backticks eats into WINDOWS_MAX_COMMAND_LENGTH (argv budget).
 299   *
 300   * Structure:
 301   * - Get-RawCommandElements: extract CommandAst element data (type, text, value,
 302   *   expressionType, children for colon-bound param .Argument)
 303   * - Get-RawRedirections: extract FileRedirectionAst operator+target
 304   * - Get-SecurityPatterns: FindAll for security flags (hasSubExpressions via
 305   *   Sub/Array/ParenExpressionAst, hasScriptBlocks, etc.)
 306   * - Type literals: emit TypeExpressionAst names for CLM allowlist check
 307   * - --% token: PS7 MinusMinus, PS5.1 Generic kind
 308   * - CommandExpressionAst.Redirections: inherits from CommandBaseAst —
 309   *   `1 > /tmp/x` statement has FileRedirectionAst that element-iteration misses
 310   * - Nested commands: FindAll for ALL statement types (if/for/foreach/while/
 311   *   switch/try/function/assignment/PipelineChainAst) — skip direct pipeline
 312   *   elements already in the loop
 313   */
 314  // exported for testing
 315  export const PARSE_SCRIPT_BODY = `
 316  if (-not $EncodedCommand) {
 317      Write-Output '{"valid":false,"errors":[{"message":"No command provided","errorId":"NoInput"}],"statements":[],"variables":[],"hasStopParsing":false,"originalCommand":""}'
 318      exit 0
 319  }
 320  
 321  $Command = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($EncodedCommand))
 322  
 323  $tokens = $null
 324  $parseErrors = $null
 325  $ast = [System.Management.Automation.Language.Parser]::ParseInput(
 326      $Command,
 327      [ref]$tokens,
 328      [ref]$parseErrors
 329  )
 330  
 331  $allVariables = [System.Collections.ArrayList]::new()
 332  
 333  function Get-RawCommandElements {
 334      param([System.Management.Automation.Language.CommandAst]$CmdAst)
 335      $elems = [System.Collections.ArrayList]::new()
 336      foreach ($ce in $CmdAst.CommandElements) {
 337          $ceData = @{ type = $ce.GetType().Name; text = $ce.Extent.Text }
 338          if ($ce.PSObject.Properties['Value'] -and $null -ne $ce.Value -and $ce.Value -is [string]) {
 339              $ceData.value = $ce.Value
 340          }
 341          if ($ce -is [System.Management.Automation.Language.CommandExpressionAst]) {
 342              $ceData.expressionType = $ce.Expression.GetType().Name
 343          }
 344          $a=$ce.Argument;if($a){$ceData.children=@(@{type=$a.GetType().Name;text=$a.Extent.Text})}
 345          [void]$elems.Add($ceData)
 346      }
 347      return $elems
 348  }
 349  
 350  function Get-RawRedirections {
 351      param($Redirections)
 352      $result = [System.Collections.ArrayList]::new()
 353      foreach ($redir in $Redirections) {
 354          $redirData = @{ type = $redir.GetType().Name }
 355          if ($redir -is [System.Management.Automation.Language.FileRedirectionAst]) {
 356              $redirData.append = [bool]$redir.Append
 357              $redirData.fromStream = $redir.FromStream.ToString()
 358              $redirData.locationText = $redir.Location.Extent.Text
 359          }
 360          [void]$result.Add($redirData)
 361      }
 362      return $result
 363  }
 364  
 365  function Get-SecurityPatterns($A) {
 366      $p = @{}
 367      foreach ($n in $A.FindAll({ param($x)
 368          $x -is [System.Management.Automation.Language.MemberExpressionAst] -or
 369          $x -is [System.Management.Automation.Language.SubExpressionAst] -or
 370          $x -is [System.Management.Automation.Language.ArrayExpressionAst] -or
 371          $x -is [System.Management.Automation.Language.ExpandableStringExpressionAst] -or
 372          $x -is [System.Management.Automation.Language.ScriptBlockExpressionAst] -or
 373          $x -is [System.Management.Automation.Language.ParenExpressionAst]
 374      }, $true)) { switch ($n.GetType().Name) {
 375          'InvokeMemberExpressionAst' { $p.hasMemberInvocations = $true }
 376          'MemberExpressionAst' { $p.hasMemberInvocations = $true }
 377          'SubExpressionAst' { $p.hasSubExpressions = $true }
 378          'ArrayExpressionAst' { $p.hasSubExpressions = $true }
 379          'ParenExpressionAst' { $p.hasSubExpressions = $true }
 380          'ExpandableStringExpressionAst' { $p.hasExpandableStrings = $true }
 381          'ScriptBlockExpressionAst' { $p.hasScriptBlocks = $true }
 382      }}
 383      if ($p.Count -gt 0) { return $p }
 384      return $null
 385  }
 386  
 387  $varExprs = $ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.VariableExpressionAst] }, $true)
 388  foreach ($v in $varExprs) {
 389      [void]$allVariables.Add(@{
 390          path = $v.VariablePath.ToString()
 391          isSplatted = [bool]$v.Splatted
 392      })
 393  }
 394  
 395  $typeLiterals = [System.Collections.ArrayList]::new()
 396  foreach ($t in $ast.FindAll({ param($n)
 397      $n -is [System.Management.Automation.Language.TypeExpressionAst] -or
 398      $n -is [System.Management.Automation.Language.TypeConstraintAst]
 399  }, $true)) { [void]$typeLiterals.Add($t.TypeName.FullName) }
 400  
 401  $hasStopParsing = $false
 402  $tk = [System.Management.Automation.Language.TokenKind]
 403  foreach ($tok in $tokens) {
 404      if ($tok.Kind -eq $tk::MinusMinus) { $hasStopParsing = $true; break }
 405      if ($tok.Kind -eq $tk::Generic -and ($tok.Text -replace '[\u2013\u2014\u2015]','-') -eq '--%') {
 406          $hasStopParsing = $true; break
 407      }
 408  }
 409  
 410  $statements = [System.Collections.ArrayList]::new()
 411  
 412  function Process-BlockStatements {
 413      param($Block)
 414      if (-not $Block) { return }
 415  
 416      foreach ($stmt in $Block.Statements) {
 417          $statement = @{
 418              type = $stmt.GetType().Name
 419              text = $stmt.Extent.Text
 420          }
 421  
 422          if ($stmt -is [System.Management.Automation.Language.PipelineAst]) {
 423              $elements = [System.Collections.ArrayList]::new()
 424              foreach ($element in $stmt.PipelineElements) {
 425                  $elemData = @{
 426                      type = $element.GetType().Name
 427                      text = $element.Extent.Text
 428                  }
 429  
 430                  if ($element -is [System.Management.Automation.Language.CommandAst]) {
 431                      $elemData.commandElements = @(Get-RawCommandElements -CmdAst $element)
 432                      $elemData.redirections = @(Get-RawRedirections -Redirections $element.Redirections)
 433                  } elseif ($element -is [System.Management.Automation.Language.CommandExpressionAst]) {
 434                      $elemData.expressionType = $element.Expression.GetType().Name
 435                      $elemData.redirections = @(Get-RawRedirections -Redirections $element.Redirections)
 436                  }
 437  
 438                  [void]$elements.Add($elemData)
 439              }
 440              $statement.elements = @($elements)
 441  
 442              $allNestedCmds = $stmt.FindAll(
 443                  { param($node) $node -is [System.Management.Automation.Language.CommandAst] },
 444                  $true
 445              )
 446              $nestedCmds = [System.Collections.ArrayList]::new()
 447              foreach ($cmd in $allNestedCmds) {
 448                  if ($cmd.Parent -eq $stmt) { continue }
 449                  $nested = @{
 450                      type = $cmd.GetType().Name
 451                      text = $cmd.Extent.Text
 452                      commandElements = @(Get-RawCommandElements -CmdAst $cmd)
 453                      redirections = @(Get-RawRedirections -Redirections $cmd.Redirections)
 454                  }
 455                  [void]$nestedCmds.Add($nested)
 456              }
 457              if ($nestedCmds.Count -gt 0) {
 458                  $statement.nestedCommands = @($nestedCmds)
 459              }
 460              $r = $stmt.FindAll({param($n) $n -is [System.Management.Automation.Language.FileRedirectionAst]}, $true)
 461              if ($r.Count -gt 0) {
 462                  $rr = @(Get-RawRedirections -Redirections $r)
 463                  $statement.redirections = if ($statement.redirections) { @($statement.redirections) + $rr } else { $rr }
 464              }
 465          } else {
 466              $nestedCmdAsts = $stmt.FindAll(
 467                  { param($node) $node -is [System.Management.Automation.Language.CommandAst] },
 468                  $true
 469              )
 470              $nested = [System.Collections.ArrayList]::new()
 471              foreach ($cmd in $nestedCmdAsts) {
 472                  [void]$nested.Add(@{
 473                      type = 'CommandAst'
 474                      text = $cmd.Extent.Text
 475                      commandElements = @(Get-RawCommandElements -CmdAst $cmd)
 476                      redirections = @(Get-RawRedirections -Redirections $cmd.Redirections)
 477                  })
 478              }
 479              if ($nested.Count -gt 0) {
 480                  $statement.nestedCommands = @($nested)
 481              }
 482              $r = $stmt.FindAll({param($n) $n -is [System.Management.Automation.Language.FileRedirectionAst]}, $true)
 483              if ($r.Count -gt 0) { $statement.redirections = @(Get-RawRedirections -Redirections $r) }
 484          }
 485  
 486          $sp = Get-SecurityPatterns $stmt
 487          if ($sp) { $statement.securityPatterns = $sp }
 488  
 489          [void]$statements.Add($statement)
 490      }
 491  
 492      if ($Block.Traps) {
 493          foreach ($trap in $Block.Traps) {
 494              $statement = @{
 495                  type = 'TrapStatementAst'
 496                  text = $trap.Extent.Text
 497              }
 498              $nestedCmdAsts = $trap.FindAll(
 499                  { param($node) $node -is [System.Management.Automation.Language.CommandAst] },
 500                  $true
 501              )
 502              $nestedCmds = [System.Collections.ArrayList]::new()
 503              foreach ($cmd in $nestedCmdAsts) {
 504                  $nested = @{
 505                      type = $cmd.GetType().Name
 506                      text = $cmd.Extent.Text
 507                      commandElements = @(Get-RawCommandElements -CmdAst $cmd)
 508                      redirections = @(Get-RawRedirections -Redirections $cmd.Redirections)
 509                  }
 510                  [void]$nestedCmds.Add($nested)
 511              }
 512              if ($nestedCmds.Count -gt 0) {
 513                  $statement.nestedCommands = @($nestedCmds)
 514              }
 515              $r = $trap.FindAll({param($n) $n -is [System.Management.Automation.Language.FileRedirectionAst]}, $true)
 516              if ($r.Count -gt 0) { $statement.redirections = @(Get-RawRedirections -Redirections $r) }
 517              $sp = Get-SecurityPatterns $trap
 518              if ($sp) { $statement.securityPatterns = $sp }
 519              [void]$statements.Add($statement)
 520          }
 521      }
 522  }
 523  
 524  Process-BlockStatements -Block $ast.BeginBlock
 525  Process-BlockStatements -Block $ast.ProcessBlock
 526  Process-BlockStatements -Block $ast.EndBlock
 527  Process-BlockStatements -Block $ast.CleanBlock
 528  Process-BlockStatements -Block $ast.DynamicParamBlock
 529  
 530  if ($ast.ParamBlock) {
 531    $pb = $ast.ParamBlock
 532    $pn = [System.Collections.ArrayList]::new()
 533    foreach ($c in $pb.FindAll({param($n) $n -is [System.Management.Automation.Language.CommandAst]}, $true)) {
 534      [void]$pn.Add(@{type='CommandAst';text=$c.Extent.Text;commandElements=@(Get-RawCommandElements -CmdAst $c);redirections=@(Get-RawRedirections -Redirections $c.Redirections)})
 535    }
 536    $pr = $pb.FindAll({param($n) $n -is [System.Management.Automation.Language.FileRedirectionAst]}, $true)
 537    $ps = Get-SecurityPatterns $pb
 538    if ($pn.Count -gt 0 -or $pr.Count -gt 0 -or $ps) {
 539      $st = @{type='ParamBlockAst';text=$pb.Extent.Text}
 540      if ($pn.Count -gt 0) { $st.nestedCommands = @($pn) }
 541      if ($pr.Count -gt 0) { $st.redirections = @(Get-RawRedirections -Redirections $pr) }
 542      if ($ps) { $st.securityPatterns = $ps }
 543      [void]$statements.Add($st)
 544    }
 545  }
 546  
 547  $hasUsingStatements = $ast.UsingStatements -and $ast.UsingStatements.Count -gt 0
 548  $hasScriptRequirements = $ast.ScriptRequirements -ne $null
 549  
 550  $output = @{
 551      valid = ($parseErrors.Count -eq 0)
 552      errors = @($parseErrors | ForEach-Object {
 553          @{
 554              message = $_.Message
 555              errorId = $_.ErrorId
 556          }
 557      })
 558      statements = @($statements)
 559      variables = @($allVariables)
 560      hasStopParsing = $hasStopParsing
 561      originalCommand = $Command
 562      typeLiterals = @($typeLiterals)
 563      hasUsingStatements = [bool]$hasUsingStatements
 564      hasScriptRequirements = [bool]$hasScriptRequirements
 565  }
 566  
 567  $output | ConvertTo-Json -Depth 10 -Compress
 568  `
 569  
 570  // ---------------------------------------------------------------------------
 571  // Windows CreateProcess has a 32,767 char command-line limit. The encoding
 572  // chain is:
 573  //   command (N UTF-8 bytes) → Base64 (~4N/3 chars) → $EncodedCommand = '...'\n
 574  //   → full script (wrapper + PARSE_SCRIPT_BODY) → UTF-16LE (2× bytes)
 575  //   → Base64 (4/3× chars) → -EncodedCommand argv
 576  // Final cmdline ≈ argv_overhead + (wrapper + 4N/3 + body) × 8/3
 577  //
 578  // Solving for N (UTF-8 bytes) with a 32,767 cap:
 579  //   script_budget   = (32767 - argv_overhead) × 3/8
 580  //   cmd_b64_budget  = script_budget - PARSE_SCRIPT_BODY.length - wrapper
 581  //   N               = cmd_b64_budget × 3/4 - safety_margin
 582  //
 583  // SECURITY: N is a UTF-8 BYTE budget, not a UTF-16 code-unit budget. The
 584  // length gate MUST measure Buffer.byteLength(command, 'utf8'), not
 585  // command.length. A BMP character in U+0800–U+FFFF (CJK ideographs, most
 586  // non-Latin scripts) is 1 UTF-16 code unit but 3 UTF-8 bytes. With
 587  // PARSE_SCRIPT_BODY ≈ 10.6K, N ≈ 1,092 bytes. Comparing against .length
 588  // permits a 1,092-code-unit pure-CJK command (≈3,276 UTF-8 bytes) → inner
 589  // base64 ≈ 4,368 chars → final argv ≈ 40K chars, overflowing 32,767 by
 590  // ~7.4K. CreateProcess fails → valid:false → parse-fail degradation (deny
 591  // rules silently downgrade to ask). Finding #36.
 592  //
 593  // COMPUTED from PARSE_SCRIPT_BODY.length so it cannot drift. The prior
 594  // hardcoded value (4,500) was derived from a ~6K body estimate; the body is
 595  // actually ~11K chars, so the real ceiling was ~1,850. Commands in the
 596  // 1,850–4,500 range passed this gate but then failed CreateProcess on
 597  // Windows, returning valid=false and skipping all AST-based security checks.
 598  //
 599  // Unix argv limits are typically 2MB+ (ARG_MAX) with ~128KB per-argument
 600  // limit (MAX_ARG_STRLEN on Linux; macOS has no per-arg limit below ARG_MAX).
 601  // At MAX=4,500 the -EncodedCommand argument is ~45KB — well under either.
 602  // Applying the Windows-derived limit on Unix would REGRESS: commands in the
 603  // ~1K–4.5K range previously parsed successfully and reached the sub-command
 604  // deny loop at powershellPermissions.ts; rejecting them pre-spawn degrades
 605  // user-configured deny rules from deny→ask for compound commands with a
 606  // denied cmdlet buried mid-script. So the Windows limit is platform-gated.
 607  //
 608  // If the Windows limit becomes too restrictive, switch to -File with a temp
 609  // file for large inputs.
 610  // ---------------------------------------------------------------------------
 611  const WINDOWS_ARGV_CAP = 32_767
 612  // pwsh path + " -NoProfile -NonInteractive -NoLogo -EncodedCommand " +
 613  // argv quoting. A long Windows pwsh path (C:\Program Files\PowerShell\7\
 614  // pwsh.exe) + flags is ~95 chars; 200 leaves headroom for unusual installs.
 615  const FIXED_ARGV_OVERHEAD = 200
 616  // "$EncodedCommand = '" + "'\n" wrapper around the user command's base64
 617  const ENCODED_CMD_WRAPPER = `$EncodedCommand = ''\n`.length
 618  // Margin for base64 padding rounding (≤4 chars at each of 2 levels) and minor
 619  // estimation drift. Multibyte expansion is NOT absorbed here — the gate
 620  // measures actual UTF-8 bytes (Buffer.byteLength), not code units.
 621  const SAFETY_MARGIN = 100
 622  const SCRIPT_CHARS_BUDGET = ((WINDOWS_ARGV_CAP - FIXED_ARGV_OVERHEAD) * 3) / 8
 623  const CMD_B64_BUDGET =
 624    SCRIPT_CHARS_BUDGET - PARSE_SCRIPT_BODY.length - ENCODED_CMD_WRAPPER
 625  // Exported for drift-guard tests (the drift-prone value is the Windows one).
 626  // Unit: UTF-8 BYTES. Compare against Buffer.byteLength, not .length.
 627  export const WINDOWS_MAX_COMMAND_LENGTH = Math.max(
 628    0,
 629    Math.floor((CMD_B64_BUDGET * 3) / 4) - SAFETY_MARGIN,
 630  )
 631  // Pre-existing value, known to work on Unix. See comment above re: why the
 632  // Windows derivation must NOT be applied here. Unit: UTF-8 BYTES — for ASCII
 633  // commands (the common case) bytes==chars so no regression; for multibyte
 634  // commands this is slightly tighter but still far below Unix ARG_MAX (~128KB
 635  // per-arg), so the argv spawn cannot overflow.
 636  const UNIX_MAX_COMMAND_LENGTH = 4_500
 637  // Unit: UTF-8 BYTES (see SECURITY note above).
 638  export const MAX_COMMAND_LENGTH =
 639    process.platform === 'win32'
 640      ? WINDOWS_MAX_COMMAND_LENGTH
 641      : UNIX_MAX_COMMAND_LENGTH
 642  
 643  const INVALID_RESULT_BASE: Omit<
 644    ParsedPowerShellCommand,
 645    'errors' | 'originalCommand'
 646  > = {
 647    valid: false,
 648    statements: [],
 649    variables: [],
 650    hasStopParsing: false,
 651  }
 652  
 653  function makeInvalidResult(
 654    command: string,
 655    message: string,
 656    errorId: string,
 657  ): ParsedPowerShellCommand {
 658    return {
 659      ...INVALID_RESULT_BASE,
 660      errors: [{ message, errorId }],
 661      originalCommand: command,
 662    }
 663  }
 664  
 665  /**
 666   * Base64-encode a string as UTF-16LE, which is the encoding required by
 667   * PowerShell's -EncodedCommand parameter.
 668   */
 669  function toUtf16LeBase64(text: string): string {
 670    if (typeof Buffer !== 'undefined') {
 671      return Buffer.from(text, 'utf16le').toString('base64')
 672    }
 673    // Fallback for non-Node environments
 674    const bytes: number[] = []
 675    for (let i = 0; i < text.length; i++) {
 676      const code = text.charCodeAt(i)
 677      bytes.push(code & 0xff, (code >> 8) & 0xff)
 678    }
 679    return btoa(bytes.map(b => String.fromCharCode(b)).join(''))
 680  }
 681  
 682  /**
 683   * Build the full PowerShell script that parses a command.
 684   * The user command is Base64-encoded (UTF-8) and embedded in a variable
 685   * to prevent injection attacks.
 686   */
 687  function buildParseScript(command: string): string {
 688    const encoded =
 689      typeof Buffer !== 'undefined'
 690        ? Buffer.from(command, 'utf8').toString('base64')
 691        : btoa(
 692            new TextEncoder()
 693              .encode(command)
 694              .reduce((s, b) => s + String.fromCharCode(b), ''),
 695          )
 696    return `$EncodedCommand = '${encoded}'\n${PARSE_SCRIPT_BODY}`
 697  }
 698  
 699  /**
 700   * Ensure a value is an array. PowerShell 5.1's ConvertTo-Json may unwrap
 701   * single-element arrays into plain objects.
 702   */
 703  function ensureArray<T>(value: T | T[] | undefined | null): T[] {
 704    if (value === undefined || value === null) {
 705      return []
 706    }
 707    return Array.isArray(value) ? value : [value]
 708  }
 709  
 710  /** Map raw .NET AST type name to our StatementType union */
 711  // exported for testing
 712  export function mapStatementType(rawType: string): StatementType {
 713    switch (rawType) {
 714      case 'PipelineAst':
 715        return 'PipelineAst'
 716      case 'PipelineChainAst':
 717        return 'PipelineChainAst'
 718      case 'AssignmentStatementAst':
 719        return 'AssignmentStatementAst'
 720      case 'IfStatementAst':
 721        return 'IfStatementAst'
 722      case 'ForStatementAst':
 723        return 'ForStatementAst'
 724      case 'ForEachStatementAst':
 725        return 'ForEachStatementAst'
 726      case 'WhileStatementAst':
 727        return 'WhileStatementAst'
 728      case 'DoWhileStatementAst':
 729        return 'DoWhileStatementAst'
 730      case 'DoUntilStatementAst':
 731        return 'DoUntilStatementAst'
 732      case 'SwitchStatementAst':
 733        return 'SwitchStatementAst'
 734      case 'TryStatementAst':
 735        return 'TryStatementAst'
 736      case 'TrapStatementAst':
 737        return 'TrapStatementAst'
 738      case 'FunctionDefinitionAst':
 739        return 'FunctionDefinitionAst'
 740      case 'DataStatementAst':
 741        return 'DataStatementAst'
 742      default:
 743        return 'UnknownStatementAst'
 744    }
 745  }
 746  
 747  /** Map raw .NET AST type name to our CommandElementType union */
 748  // exported for testing
 749  export function mapElementType(
 750    rawType: string,
 751    expressionType?: string,
 752  ): CommandElementType {
 753    switch (rawType) {
 754      case 'ScriptBlockExpressionAst':
 755        return 'ScriptBlock'
 756      case 'SubExpressionAst':
 757      case 'ArrayExpressionAst':
 758        // SECURITY: ArrayExpressionAst (@()) is a sibling of SubExpressionAst,
 759        // not a subclass. Both evaluate arbitrary pipelines with side effects:
 760        // Get-ChildItem @(Remove-Item ./data) runs Remove-Item inside @().
 761        // Map both to SubExpression so hasSubExpressions fires and isReadOnlyCommand
 762        // rejects (it doesn't check nestedCommands, only pipeline.commands[]).
 763        return 'SubExpression'
 764      case 'ExpandableStringExpressionAst':
 765        return 'ExpandableString'
 766      case 'InvokeMemberExpressionAst':
 767      case 'MemberExpressionAst':
 768        return 'MemberInvocation'
 769      case 'VariableExpressionAst':
 770        return 'Variable'
 771      case 'StringConstantExpressionAst':
 772      case 'ConstantExpressionAst':
 773        // ConstantExpressionAst covers numeric literals (5, 3.14). For
 774        // permission purposes a numeric literal is as safe as a string
 775        // literal — it's an inert value, not code. Without this mapping,
 776        // `-Seconds:5` produced children[0].type='Other' and consumers
 777        // checking `children.some(c => c.type !== 'StringConstant')` would
 778        // false-positive ask on harmless numeric args.
 779        return 'StringConstant'
 780      case 'CommandParameterAst':
 781        return 'Parameter'
 782      case 'ParenExpressionAst':
 783        return 'SubExpression'
 784      case 'CommandExpressionAst':
 785        // Delegate to the wrapped expression type so we catch SubExpressionAst,
 786        // ExpandableStringExpressionAst, ScriptBlockExpressionAst, etc.
 787        // without maintaining a manual list. Falls through to 'Other' if the
 788        // inner type is unrecognised.
 789        if (expressionType) {
 790          return mapElementType(expressionType)
 791        }
 792        return 'Other'
 793      default:
 794        return 'Other'
 795    }
 796  }
 797  
 798  /** Classify command name as cmdlet, application, or unknown */
 799  // exported for testing
 800  export function classifyCommandName(
 801    name: string,
 802  ): 'cmdlet' | 'application' | 'unknown' {
 803    if (/^[A-Za-z]+-[A-Za-z][A-Za-z0-9_]*$/.test(name)) {
 804      return 'cmdlet'
 805    }
 806    if (/[.\\/]/.test(name)) {
 807      return 'application'
 808    }
 809    return 'unknown'
 810  }
 811  
 812  /** Strip module prefix from command name (e.g. "Microsoft.PowerShell.Utility\\Invoke-Expression" -> "Invoke-Expression") */
 813  // exported for testing
 814  export function stripModulePrefix(name: string): string {
 815    const idx = name.lastIndexOf('\\')
 816    if (idx < 0) return name
 817    // Don't strip file paths: drive letters (C:\...), UNC paths (\\server\...), or relative paths (.\, ..\)
 818    if (
 819      /^[A-Za-z]:/.test(name) ||
 820      name.startsWith('\\\\') ||
 821      name.startsWith('.\\') ||
 822      name.startsWith('..\\')
 823    )
 824      return name
 825    return name.substring(idx + 1)
 826  }
 827  
 828  /** Transform a raw CommandAst pipeline element into ParsedCommandElement */
 829  // exported for testing
 830  export function transformCommandAst(
 831    raw: RawPipelineElement,
 832  ): ParsedCommandElement {
 833    const cmdElements = ensureArray(raw.commandElements)
 834    let name = ''
 835    const args: string[] = []
 836    const elementTypes: CommandElementType[] = []
 837    const children: (CommandElementChild[] | undefined)[] = []
 838    let hasChildren = false
 839  
 840    // SECURITY: nameType MUST be computed from the raw name (before
 841    // stripModulePrefix). classifyCommandName('scripts\\Get-Process') returns
 842    // 'application' (contains \\) — the correct answer, since PowerShell resolves
 843    // this as a file path. After stripping it becomes 'Get-Process' which
 844    // classifies as 'cmdlet' — wrong, and allowlist checks would trust it.
 845    // Auto-allow paths gate on nameType !== 'application' to catch this.
 846    // name (stripped) is still used for deny-rule matching symmetry, which is
 847    // fail-safe: deny rules over-match (Module\\Remove-Item still hits a
 848    // Remove-Item deny), allow rules are separately gated by nameType.
 849    let nameType: 'cmdlet' | 'application' | 'unknown' = 'unknown'
 850    if (cmdElements.length > 0) {
 851      const first = cmdElements[0]!
 852      // SECURITY: only trust .value for string-literal element types with a
 853      // string-typed value. Numeric ConstantExpressionAst (e.g. `& 1`) emits an
 854      // integer .value that crashes stripModulePrefix() → parser falls through
 855      // to passthrough. For non-string-literal or non-string .value, use .text.
 856      const isFirstStringLiteral =
 857        first.type === 'StringConstantExpressionAst' ||
 858        first.type === 'ExpandableStringExpressionAst'
 859      const rawNameUnstripped =
 860        isFirstStringLiteral && typeof first.value === 'string'
 861          ? first.value
 862          : first.text
 863      // SECURITY: strip surrounding quotes from the command name. When .value is
 864      // unavailable (no StaticType on the raw node), .text preserves quotes —
 865      // `& 'Invoke-Expression' 'x'` yields "'Invoke-Expression'". Stripping here
 866      // at the source means every downstream reader of element.name (deny-rule
 867      // matching, GIT_SAFETY_WRITE_CMDLETS lookup, resolveToCanonical, etc.)
 868      // sees the bare cmdlet name. No-op when .value already stripped.
 869      const rawName = rawNameUnstripped.replace(/^['"]|['"]$/g, '')
 870      // SECURITY: PowerShell built-in cmdlet names are ASCII-only. Non-ASCII
 871      // characters in cmdlet position are inherently suspicious — .NET
 872      // OrdinalIgnoreCase folds U+017F (ſ) → S and U+0131 (ı) → I per
 873      // UnicodeData.txt SimpleUppercaseMapping, so PowerShell resolves
 874      // `ſtart-proceſſ` → Start-Process at runtime. JS .toLowerCase() does NOT
 875      // fold these (ſ is already lowercase), so every downstream name
 876      // comparison (NEVER_SUGGEST, deny-rule strEquals, resolveToCanonical,
 877      // security validators) misses. Force 'application' to gate auto-allow
 878      // (blocks at the nameType !== 'application' checks). Finding #31.
 879      // Verified on Windows (pwsh 7.x, 2026-03): ſtart-proceſſ does NOT resolve.
 880      // Retained as defense-in-depth against future .NET/PS behavior changes
 881      // or module-provided command resolution hooks.
 882      if (/[\u0080-\uFFFF]/.test(rawName)) {
 883        nameType = 'application'
 884      } else {
 885        nameType = classifyCommandName(rawName)
 886      }
 887      name = stripModulePrefix(rawName)
 888      elementTypes.push(mapElementType(first.type, first.expressionType))
 889  
 890      for (let i = 1; i < cmdElements.length; i++) {
 891        const ce = cmdElements[i]!
 892        // Use resolved .value for string constants (strips quotes, resolves
 893        // backtick escapes like `n -> newline) but keep raw .text for parameters
 894        // (where .value loses the dash prefix, e.g. '-Path' -> 'Path'),
 895        // variables, and other non-string types.
 896        const isStringLiteral =
 897          ce.type === 'StringConstantExpressionAst' ||
 898          ce.type === 'ExpandableStringExpressionAst'
 899        args.push(isStringLiteral && ce.value != null ? ce.value : ce.text)
 900        elementTypes.push(mapElementType(ce.type, ce.expressionType))
 901        // Map raw children (CommandParameterAst.Argument) through
 902        // mapElementType so consumers see 'Variable', 'StringConstant', etc.
 903        const rawChildren = ensureArray(ce.children)
 904        if (rawChildren.length > 0) {
 905          hasChildren = true
 906          children.push(
 907            rawChildren.map(c => ({
 908              type: mapElementType(c.type),
 909              text: c.text,
 910            })),
 911          )
 912        } else {
 913          children.push(undefined)
 914        }
 915      }
 916    }
 917  
 918    const result: ParsedCommandElement = {
 919      name,
 920      nameType,
 921      elementType: 'CommandAst',
 922      args,
 923      text: raw.text,
 924      elementTypes,
 925      ...(hasChildren ? { children } : {}),
 926    }
 927  
 928    // Preserve redirections from nested commands (e.g., in && / || chains)
 929    const rawRedirs = ensureArray(raw.redirections)
 930    if (rawRedirs.length > 0) {
 931      result.redirections = rawRedirs.map(transformRedirection)
 932    }
 933  
 934    return result
 935  }
 936  
 937  /** Transform a non-CommandAst pipeline element into ParsedCommandElement */
 938  // exported for testing
 939  export function transformExpressionElement(
 940    raw: RawPipelineElement,
 941  ): ParsedCommandElement {
 942    const elementType: PipelineElementType =
 943      raw.type === 'ParenExpressionAst'
 944        ? 'ParenExpressionAst'
 945        : 'CommandExpressionAst'
 946    const elementTypes: CommandElementType[] = [
 947      mapElementType(raw.type, raw.expressionType),
 948    ]
 949  
 950    return {
 951      name: raw.text,
 952      nameType: 'unknown',
 953      elementType,
 954      args: [],
 955      text: raw.text,
 956      elementTypes,
 957    }
 958  }
 959  
 960  /** Map raw redirection to ParsedRedirection */
 961  // exported for testing
 962  export function transformRedirection(raw: RawRedirection): ParsedRedirection {
 963    if (raw.type === 'MergingRedirectionAst') {
 964      return { operator: '2>&1', target: '', isMerging: true }
 965    }
 966  
 967    const append = raw.append ?? false
 968    const fromStream = raw.fromStream ?? 'Output'
 969  
 970    let operator: ParsedRedirection['operator']
 971    if (append) {
 972      switch (fromStream) {
 973        case 'Error':
 974          operator = '2>>'
 975          break
 976        case 'All':
 977          operator = '*>>'
 978          break
 979        default:
 980          operator = '>>'
 981          break
 982      }
 983    } else {
 984      switch (fromStream) {
 985        case 'Error':
 986          operator = '2>'
 987          break
 988        case 'All':
 989          operator = '*>'
 990          break
 991        default:
 992          operator = '>'
 993          break
 994      }
 995    }
 996  
 997    return { operator, target: raw.locationText ?? '', isMerging: false }
 998  }
 999  
1000  /** Transform a raw statement into ParsedStatement */
1001  // exported for testing
1002  export function transformStatement(raw: RawStatement): ParsedStatement {
1003    const statementType = mapStatementType(raw.type)
1004    const commands: ParsedCommandElement[] = []
1005    const redirections: ParsedRedirection[] = []
1006  
1007    if (raw.elements) {
1008      // PipelineAst: walk pipeline elements
1009      for (const elem of ensureArray(raw.elements)) {
1010        if (elem.type === 'CommandAst') {
1011          commands.push(transformCommandAst(elem))
1012          for (const redir of ensureArray(elem.redirections)) {
1013            redirections.push(transformRedirection(redir))
1014          }
1015        } else {
1016          commands.push(transformExpressionElement(elem))
1017          // SECURITY: CommandExpressionAst also carries .Redirections (inherited
1018          // from CommandBaseAst). `1 > /tmp/evil.txt` is a CommandExpressionAst
1019          // with a FileRedirectionAst. Must extract here or getFileRedirections()
1020          // misses it and compound commands like `Get-ChildItem; 1 > /tmp/x`
1021          // auto-allow at step 5 (only Get-ChildItem is checked).
1022          for (const redir of ensureArray(elem.redirections)) {
1023            redirections.push(transformRedirection(redir))
1024          }
1025        }
1026      }
1027      // SECURITY: The PS1 PipelineAst branch does a deep FindAll for
1028      // FileRedirectionAst to catch redirections hidden inside:
1029      //  - colon-bound ParenExpressionAst args: -Name:('payload' > file)
1030      //  - hashtable value statements: @{k='payload' > ~/.bashrc}
1031      // Both are invisible at the element level — the redirection's parent
1032      // is a child of CommandParameterAst / CommandExpressionAst, not a
1033      // separate pipeline element. Merge into statement-level redirections.
1034      //
1035      // The FindAll ALSO re-discovers direct-element redirections already
1036      // captured in the per-element loop above. Dedupe by (operator, target)
1037      // so tests and consumers see the real count.
1038      const seen = new Set(redirections.map(r => `${r.operator}\0${r.target}`))
1039      for (const redir of ensureArray(raw.redirections)) {
1040        const r = transformRedirection(redir)
1041        const key = `${r.operator}\0${r.target}`
1042        if (!seen.has(key)) {
1043          seen.add(key)
1044          redirections.push(r)
1045        }
1046      }
1047    } else {
1048      // Non-pipeline statement: add synthetic command entry with full text
1049      commands.push({
1050        name: raw.text,
1051        nameType: 'unknown',
1052        elementType: 'CommandExpressionAst',
1053        args: [],
1054        text: raw.text,
1055      })
1056      // SECURITY: The PS1 else-branch does a direct recursive FindAll on
1057      // FileRedirectionAst to catch expression redirections inside control flow
1058      // (if/for/foreach/while/switch/try/trap/&& and ||). The CommandAst FindAll
1059      // above CANNOT see these: in if ($x) { 1 > /tmp/evil }, the literal 1 with
1060      // its attached redirection is a CommandExpressionAst — a SIBLING of
1061      // CommandAst in the type hierarchy, not a subclass. So nestedCommands never
1062      // contains it, and without this hoist the redirection is invisible to
1063      // getFileRedirections → step 4.6 misses it → compound commands like
1064      // `Get-Process && 1 > /tmp/evil` auto-allow at step 5 (only Get-Process
1065      // is checked, allowlisted).
1066      //
1067      // Finding FileRedirectionAst DIRECTLY (rather than finding CommandExpressionAst
1068      // and extracting .Redirections) is both simpler and more robust: it catches
1069      // redirections on any node type, including ones we don't know about yet.
1070      //
1071      // Double-counts redirections already on nested CommandAst commands (those are
1072      // extracted at line ~395 into nestedCommands[i].redirections AND found again
1073      // here). Harmless: step 4.6 only checks fileRedirections.length > 0, not
1074      // the exact count. No code does arithmetic on redirection counts.
1075      //
1076      // PS1 SIZE NOTE: The full rationale lives here (TS), not in the PS1 script,
1077      // because PS1 comments bloat the -EncodedCommand payload and push the
1078      // Windows CreateProcess 32K limit. Keep PS1 comments terse; point them here.
1079      for (const redir of ensureArray(raw.redirections)) {
1080        redirections.push(transformRedirection(redir))
1081      }
1082    }
1083  
1084    let nestedCommands: ParsedCommandElement[] | undefined
1085    const rawNested = ensureArray(raw.nestedCommands)
1086    if (rawNested.length > 0) {
1087      nestedCommands = rawNested.map(transformCommandAst)
1088    }
1089  
1090    const result: ParsedStatement = {
1091      statementType,
1092      commands,
1093      redirections,
1094      text: raw.text,
1095      nestedCommands,
1096    }
1097  
1098    if (raw.securityPatterns) {
1099      result.securityPatterns = raw.securityPatterns
1100    }
1101  
1102    return result
1103  }
1104  
1105  /** Transform the complete raw PS output into ParsedPowerShellCommand */
1106  function transformRawOutput(raw: RawParsedOutput): ParsedPowerShellCommand {
1107    const result: ParsedPowerShellCommand = {
1108      valid: raw.valid,
1109      errors: ensureArray(raw.errors),
1110      statements: ensureArray(raw.statements).map(transformStatement),
1111      variables: ensureArray(raw.variables),
1112      hasStopParsing: raw.hasStopParsing,
1113      originalCommand: raw.originalCommand,
1114    }
1115    const tl = ensureArray(raw.typeLiterals)
1116    if (tl.length > 0) {
1117      result.typeLiterals = tl
1118    }
1119    if (raw.hasUsingStatements) {
1120      result.hasUsingStatements = true
1121    }
1122    if (raw.hasScriptRequirements) {
1123      result.hasScriptRequirements = true
1124    }
1125    return result
1126  }
1127  
1128  /**
1129   * Parse a PowerShell command using the native AST parser.
1130   * Spawns pwsh to parse the command and returns structured results.
1131   * Results are memoized by command string.
1132   *
1133   * @param command - The PowerShell command to parse
1134   * @returns Parsed command structure, or a result with valid=false on failure
1135   */
1136  async function parsePowerShellCommandImpl(
1137    command: string,
1138  ): Promise<ParsedPowerShellCommand> {
1139    // SECURITY: MAX_COMMAND_LENGTH is a UTF-8 BYTE budget (see derivation at the
1140    // constant definition). command.length counts UTF-16 code units; a CJK
1141    // character is 1 code unit but 3 UTF-8 bytes, so .length under-reports by
1142    // up to 3× and allows argv overflow on Windows → CreateProcess fails →
1143    // valid:false → deny rules degrade to ask. Finding #36.
1144    const commandBytes = Buffer.byteLength(command, 'utf8')
1145    if (commandBytes > MAX_COMMAND_LENGTH) {
1146      logForDebugging(
1147        `PowerShell parser: command too long (${commandBytes} bytes, max ${MAX_COMMAND_LENGTH})`,
1148      )
1149      return makeInvalidResult(
1150        command,
1151        `Command too long for parsing (${commandBytes} bytes). Maximum supported length is ${MAX_COMMAND_LENGTH} bytes.`,
1152        'CommandTooLong',
1153      )
1154    }
1155  
1156    const pwshPath = await getCachedPowerShellPath()
1157    if (!pwshPath) {
1158      return makeInvalidResult(
1159        command,
1160        'PowerShell is not available',
1161        'NoPowerShell',
1162      )
1163    }
1164  
1165    const script = buildParseScript(command)
1166  
1167    // Pass the script to PowerShell via -EncodedCommand.
1168    // -EncodedCommand takes a Base64-encoded UTF-16LE string and executes it,
1169    // which avoids: (1) stdin interactive-mode issues where -File - produces
1170    // PS prompts and ANSI escapes in stdout, (2) command-line escaping issues,
1171    // (3) temp files. The script itself is large but well within OS arg limits
1172    // (Windows: 32K chars, Unix: typically 2MB+).
1173    const encodedScript = toUtf16LeBase64(script)
1174    const args = [
1175      '-NoProfile',
1176      '-NonInteractive',
1177      '-NoLogo',
1178      '-EncodedCommand',
1179      encodedScript,
1180    ]
1181  
1182    // Spawn pwsh with one retry on timeout. On loaded CI runners (Windows
1183    // especially), pwsh spawn + .NET JIT + ParseInput occasionally exceeds 5s
1184    // even after CAN_SPAWN_PARSE_SCRIPT() warms the JIT. execa kills the process
1185    // but exitCode is undefined, which the old code reported as the misleading
1186    // "pwsh exited with code 1:" with empty stderr. A single retry absorbs
1187    // transient load spikes; a double timeout is reported as PwshTimeout.
1188    const parseTimeoutMs = getParseTimeoutMs()
1189    let stdout = ''
1190    let stderr = ''
1191    let code: number | null = null
1192    let timedOut = false
1193    for (let attempt = 0; attempt < 2; attempt++) {
1194      try {
1195        const result = await execa(pwshPath, args, {
1196          timeout: parseTimeoutMs,
1197          reject: false,
1198        })
1199        stdout = result.stdout
1200        stderr = result.stderr
1201        timedOut = result.timedOut
1202        code = result.failed ? (result.exitCode ?? 1) : 0
1203      } catch (e: unknown) {
1204        logForDebugging(
1205          `PowerShell parser: failed to spawn pwsh: ${e instanceof Error ? e.message : e}`,
1206        )
1207        return makeInvalidResult(
1208          command,
1209          `Failed to spawn PowerShell: ${e instanceof Error ? e.message : e}`,
1210          'PwshSpawnError',
1211        )
1212      }
1213      if (!timedOut) break
1214      logForDebugging(
1215        `PowerShell parser: pwsh timed out after ${parseTimeoutMs}ms (attempt ${attempt + 1})`,
1216      )
1217    }
1218  
1219    if (timedOut) {
1220      return makeInvalidResult(
1221        command,
1222        `pwsh timed out after ${parseTimeoutMs}ms (2 attempts)`,
1223        'PwshTimeout',
1224      )
1225    }
1226  
1227    if (code !== 0) {
1228      logForDebugging(
1229        `PowerShell parser: pwsh exited with code ${code}, stderr: ${stderr}`,
1230      )
1231      return makeInvalidResult(
1232        command,
1233        `pwsh exited with code ${code}: ${stderr}`,
1234        'PwshError',
1235      )
1236    }
1237  
1238    const trimmed = stdout.trim()
1239    if (!trimmed) {
1240      logForDebugging('PowerShell parser: empty stdout from pwsh')
1241      return makeInvalidResult(
1242        command,
1243        'No output from PowerShell parser',
1244        'EmptyOutput',
1245      )
1246    }
1247  
1248    try {
1249      const raw = jsonParse(trimmed) as RawParsedOutput
1250      return transformRawOutput(raw)
1251    } catch {
1252      logForDebugging(
1253        `PowerShell parser: invalid JSON output: ${trimmed.slice(0, 200)}`,
1254      )
1255      return makeInvalidResult(
1256        command,
1257        'Invalid JSON from PowerShell parser',
1258        'InvalidJson',
1259      )
1260    }
1261  }
1262  
1263  // Error IDs from makeInvalidResult that represent transient process failures.
1264  // These should be evicted from the cache so subsequent calls can retry.
1265  // Deterministic failures (CommandTooLong, syntax errors from successful parses)
1266  // should stay cached since retrying would produce the same result.
1267  const TRANSIENT_ERROR_IDS = new Set([
1268    'PwshSpawnError',
1269    'PwshError',
1270    'PwshTimeout',
1271    'EmptyOutput',
1272    'InvalidJson',
1273  ])
1274  
1275  const parsePowerShellCommandCached = memoizeWithLRU(
1276    (command: string) => {
1277      const promise = parsePowerShellCommandImpl(command)
1278      // Evict transient failures after resolution so they can be retried.
1279      // The current caller still receives the cached promise for this call,
1280      // ensuring concurrent callers share the same result.
1281      void promise.then(result => {
1282        if (
1283          !result.valid &&
1284          TRANSIENT_ERROR_IDS.has(result.errors[0]?.errorId ?? '')
1285        ) {
1286          parsePowerShellCommandCached.cache.delete(command)
1287        }
1288      })
1289      return promise
1290    },
1291    (command: string) => command,
1292    256,
1293  )
1294  export { parsePowerShellCommandCached as parsePowerShellCommand }
1295  
1296  // ---------------------------------------------------------------------------
1297  // Analysis helpers — derived from the parsed AST structure.
1298  // ---------------------------------------------------------------------------
1299  
1300  /**
1301   * Security-relevant flags derived from the parsed AST.
1302   */
1303  type SecurityFlags = {
1304    /** Contains $(...) subexpression */
1305    hasSubExpressions: boolean
1306    /** Contains { ... } script block expressions */
1307    hasScriptBlocks: boolean
1308    /** Contains @variable splatting */
1309    hasSplatting: boolean
1310    /** Contains expandable strings with embedded expressions ("...$()...") */
1311    hasExpandableStrings: boolean
1312    /** Contains .NET method invocations ([Type]::Method or $obj.Method()) */
1313    hasMemberInvocations: boolean
1314    /** Contains variable assignments ($x = ...) */
1315    hasAssignments: boolean
1316    /** Uses stop-parsing token (--%) */
1317    hasStopParsing: boolean
1318  }
1319  
1320  /**
1321   * Common PowerShell aliases mapped to their canonical cmdlet names.
1322   * Uses Object.create(null) to prevent prototype-chain pollution — attacker-controlled
1323   * command names like 'constructor' or '__proto__' must return undefined, not inherited
1324   * Object.prototype properties.
1325   */
1326  export const COMMON_ALIASES: Record<string, string> = Object.assign(
1327    Object.create(null) as Record<string, string>,
1328    {
1329      // Directory listing
1330      ls: 'Get-ChildItem',
1331      dir: 'Get-ChildItem',
1332      gci: 'Get-ChildItem',
1333      // Content
1334      cat: 'Get-Content',
1335      type: 'Get-Content',
1336      gc: 'Get-Content',
1337      // Navigation
1338      cd: 'Set-Location',
1339      sl: 'Set-Location',
1340      chdir: 'Set-Location',
1341      pushd: 'Push-Location',
1342      popd: 'Pop-Location',
1343      pwd: 'Get-Location',
1344      gl: 'Get-Location',
1345      // Items
1346      gi: 'Get-Item',
1347      gp: 'Get-ItemProperty',
1348      ni: 'New-Item',
1349      mkdir: 'New-Item',
1350      // `md` is PowerShell's built-in alias for `mkdir`. resolveToCanonical is
1351      // single-hop (no md→mkdir→New-Item chaining), so it needs its own entry
1352      // or `md /etc/x` falls through while `mkdir /etc/x` is caught.
1353      md: 'New-Item',
1354      ri: 'Remove-Item',
1355      del: 'Remove-Item',
1356      rd: 'Remove-Item',
1357      rmdir: 'Remove-Item',
1358      rm: 'Remove-Item',
1359      erase: 'Remove-Item',
1360      mi: 'Move-Item',
1361      mv: 'Move-Item',
1362      move: 'Move-Item',
1363      ci: 'Copy-Item',
1364      cp: 'Copy-Item',
1365      copy: 'Copy-Item',
1366      cpi: 'Copy-Item',
1367      si: 'Set-Item',
1368      rni: 'Rename-Item',
1369      ren: 'Rename-Item',
1370      // Process
1371      ps: 'Get-Process',
1372      gps: 'Get-Process',
1373      kill: 'Stop-Process',
1374      spps: 'Stop-Process',
1375      start: 'Start-Process',
1376      saps: 'Start-Process',
1377      sajb: 'Start-Job',
1378      ipmo: 'Import-Module',
1379      // Output
1380      echo: 'Write-Output',
1381      write: 'Write-Output',
1382      sleep: 'Start-Sleep',
1383      // Help
1384      help: 'Get-Help',
1385      man: 'Get-Help',
1386      gcm: 'Get-Command',
1387      // Service
1388      gsv: 'Get-Service',
1389      // Variables
1390      gv: 'Get-Variable',
1391      sv: 'Set-Variable',
1392      // History
1393      h: 'Get-History',
1394      history: 'Get-History',
1395      // Invoke
1396      iex: 'Invoke-Expression',
1397      iwr: 'Invoke-WebRequest',
1398      irm: 'Invoke-RestMethod',
1399      icm: 'Invoke-Command',
1400      ii: 'Invoke-Item',
1401      // PSSession — remote code execution surface
1402      nsn: 'New-PSSession',
1403      etsn: 'Enter-PSSession',
1404      exsn: 'Exit-PSSession',
1405      gsn: 'Get-PSSession',
1406      rsn: 'Remove-PSSession',
1407      // Misc
1408      cls: 'Clear-Host',
1409      clear: 'Clear-Host',
1410      select: 'Select-Object',
1411      where: 'Where-Object',
1412      foreach: 'ForEach-Object',
1413      '%': 'ForEach-Object',
1414      '?': 'Where-Object',
1415      measure: 'Measure-Object',
1416      ft: 'Format-Table',
1417      fl: 'Format-List',
1418      fw: 'Format-Wide',
1419      oh: 'Out-Host',
1420      ogv: 'Out-GridView',
1421      // SECURITY: The following aliases are deliberately omitted because PS Core 6+
1422      // removed them (they collide with native executables). Our allowlist logic
1423      // resolves aliases BEFORE checking safety — if we map 'sort' → 'Sort-Object'
1424      // but PowerShell 7/Windows actually runs sort.exe, we'd auto-allow the wrong
1425      // program.
1426      //   'sc'   → sc.exe (Service Controller) — e.g. `sc config Svc binpath= ...`
1427      //   'sort' → sort.exe — e.g. `sort /O C:\evil.txt` (arbitrary file write)
1428      //   'curl' → curl.exe (shipped with Windows 10 1803+)
1429      //   'wget' → wget.exe (if installed)
1430      // Prefer to leave ambiguous aliases unmapped — users can write the full name.
1431      // If adding aliases that resolve to SAFE_OUTPUT_CMDLETS or
1432      // ACCEPT_EDITS_ALLOWED_CMDLETS, verify no native .exe collision on PS Core.
1433      ac: 'Add-Content',
1434      clc: 'Clear-Content',
1435      // Write/export: tee-object/export-csv are in
1436      // CMDLET_PATH_CONFIG so path-level Edit denies fire on the full cmdlet name,
1437      // but PowerShell's built-in aliases fell through to ask-then-approve because
1438      // resolveToCanonical couldn't resolve them). Neither tee-object nor
1439      // export-csv is in SAFE_OUTPUT_CMDLETS or ACCEPT_EDITS_ALLOWED_CMDLETS, so
1440      // the native-exe collision warning above doesn't apply — on Linux PS Core
1441      // where `tee` runs /usr/bin/tee, that binary also writes to its positional
1442      // file arg and we correctly extract+check it.
1443      tee: 'Tee-Object',
1444      epcsv: 'Export-Csv',
1445      sp: 'Set-ItemProperty',
1446      rp: 'Remove-ItemProperty',
1447      cli: 'Clear-Item',
1448      epal: 'Export-Alias',
1449      // Text search
1450      sls: 'Select-String',
1451    },
1452  )
1453  
1454  const DIRECTORY_CHANGE_CMDLETS = new Set([
1455    'set-location',
1456    'push-location',
1457    'pop-location',
1458  ])
1459  
1460  const DIRECTORY_CHANGE_ALIASES = new Set(['cd', 'sl', 'chdir', 'pushd', 'popd'])
1461  
1462  /**
1463   * Get all command names across all statements, pipeline segments, and nested commands.
1464   * Returns lowercased names for case-insensitive comparison.
1465   */
1466  // exported for testing
1467  export function getAllCommandNames(parsed: ParsedPowerShellCommand): string[] {
1468    const names: string[] = []
1469    for (const statement of parsed.statements) {
1470      for (const cmd of statement.commands) {
1471        names.push(cmd.name.toLowerCase())
1472      }
1473      if (statement.nestedCommands) {
1474        for (const cmd of statement.nestedCommands) {
1475          names.push(cmd.name.toLowerCase())
1476        }
1477      }
1478    }
1479    return names
1480  }
1481  
1482  /**
1483   * Get all pipeline segments as flat list of commands.
1484   * Useful for checking each command independently.
1485   */
1486  export function getAllCommands(
1487    parsed: ParsedPowerShellCommand,
1488  ): ParsedCommandElement[] {
1489    const commands: ParsedCommandElement[] = []
1490    for (const statement of parsed.statements) {
1491      for (const cmd of statement.commands) {
1492        commands.push(cmd)
1493      }
1494      if (statement.nestedCommands) {
1495        for (const cmd of statement.nestedCommands) {
1496          commands.push(cmd)
1497        }
1498      }
1499    }
1500    return commands
1501  }
1502  
1503  /**
1504   * Get all redirections across all statements.
1505   */
1506  // exported for testing
1507  export function getAllRedirections(
1508    parsed: ParsedPowerShellCommand,
1509  ): ParsedRedirection[] {
1510    const redirections: ParsedRedirection[] = []
1511    for (const statement of parsed.statements) {
1512      for (const redir of statement.redirections) {
1513        redirections.push(redir)
1514      }
1515      // Include redirections from nested commands (e.g., from && / || chains)
1516      if (statement.nestedCommands) {
1517        for (const cmd of statement.nestedCommands) {
1518          if (cmd.redirections) {
1519            for (const redir of cmd.redirections) {
1520              redirections.push(redir)
1521            }
1522          }
1523        }
1524      }
1525    }
1526    return redirections
1527  }
1528  
1529  /**
1530   * Get all variables, optionally filtered by scope (e.g., 'env').
1531   * Variable paths in PowerShell can have scopes like "env:PATH", "global:x".
1532   */
1533  export function getVariablesByScope(
1534    parsed: ParsedPowerShellCommand,
1535    scope: string,
1536  ): ParsedVariable[] {
1537    const prefix = scope.toLowerCase() + ':'
1538    return parsed.variables.filter(v => v.path.toLowerCase().startsWith(prefix))
1539  }
1540  
1541  /**
1542   * Check if any command in the parsed result matches a given name (case-insensitive).
1543   * Handles common aliases too.
1544   */
1545  export function hasCommandNamed(
1546    parsed: ParsedPowerShellCommand,
1547    name: string,
1548  ): boolean {
1549    const lowerName = name.toLowerCase()
1550    const canonicalFromAlias = COMMON_ALIASES[lowerName]?.toLowerCase()
1551  
1552    for (const cmdName of getAllCommandNames(parsed)) {
1553      if (cmdName === lowerName) {
1554        return true
1555      }
1556      // Check if the command is an alias that resolves to the requested name
1557      const canonical = COMMON_ALIASES[cmdName]?.toLowerCase()
1558      if (canonical === lowerName) {
1559        return true
1560      }
1561      // Check if the requested name is an alias and the command is its canonical form
1562      if (canonicalFromAlias && cmdName === canonicalFromAlias) {
1563        return true
1564      }
1565      // Check if both resolve to the same canonical cmdlet (alias-to-alias match)
1566      if (canonical && canonicalFromAlias && canonical === canonicalFromAlias) {
1567        return true
1568      }
1569    }
1570    return false
1571  }
1572  
1573  /**
1574   * Check if the command contains any directory-changing commands.
1575   * (Set-Location, cd, sl, chdir, Push-Location, pushd, Pop-Location, popd)
1576   */
1577  // exported for testing
1578  export function hasDirectoryChange(parsed: ParsedPowerShellCommand): boolean {
1579    for (const cmdName of getAllCommandNames(parsed)) {
1580      if (
1581        DIRECTORY_CHANGE_CMDLETS.has(cmdName) ||
1582        DIRECTORY_CHANGE_ALIASES.has(cmdName)
1583      ) {
1584        return true
1585      }
1586    }
1587    return false
1588  }
1589  
1590  /**
1591   * Check if the command is a single simple command (no pipes, no semicolons, no operators).
1592   */
1593  // exported for testing
1594  export function isSingleCommand(parsed: ParsedPowerShellCommand): boolean {
1595    const stmt = parsed.statements[0]
1596    return (
1597      parsed.statements.length === 1 &&
1598      stmt !== undefined &&
1599      stmt.commands.length === 1 &&
1600      (!stmt.nestedCommands || stmt.nestedCommands.length === 0)
1601    )
1602  }
1603  
1604  /**
1605   * Check if a specific command has a given argument/flag (case-insensitive).
1606   * Useful for checking "-EncodedCommand", "-Recurse", etc.
1607   */
1608  export function commandHasArg(
1609    command: ParsedCommandElement,
1610    arg: string,
1611  ): boolean {
1612    const lowerArg = arg.toLowerCase()
1613    return command.args.some(a => a.toLowerCase() === lowerArg)
1614  }
1615  
1616  /**
1617   * Tokenizer-level dash characters that PowerShell's parser accepts as
1618   * parameter prefixes. SpecialCharacters.IsDash (CharTraits.cs) accepts exactly
1619   * these four: ASCII hyphen-minus, en-dash, em-dash, horizontal bar. These are
1620   * tokenizer-level — they apply to ALL cmdlet parameters, not just argv to
1621   * powershell.exe (contrast with `/` which is an argv-parser quirk of
1622   * powershell.exe 5.1 only; see PS_ALT_PARAM_PREFIXES in powershellSecurity.ts).
1623   *
1624   * Extent.Text preserves the raw character; transformCommandAst uses ce.text
1625   * for CommandParameterAst elements, so these reach callers unchanged.
1626   */
1627  export const PS_TOKENIZER_DASH_CHARS = new Set([
1628    '-', // U+002D hyphen-minus (ASCII)
1629    '\u2013', // en-dash
1630    '\u2014', // em-dash
1631    '\u2015', // horizontal bar
1632  ])
1633  
1634  /**
1635   * Determines if an argument is a PowerShell parameter (flag), using the AST
1636   * element type as ground truth when available.
1637   *
1638   * The parser maps CommandParameterAst → 'Parameter' regardless of which dash
1639   * character the user typed — PowerShell's tokenizer handles that. So when
1640   * elementType is available, it's authoritative:
1641   *   - 'Parameter' → true (covers `-Path`, `–Path`, `—Path`, `―Path`)
1642   *   - anything else → false (a quoted "-Path" is StringConstant, not a param)
1643   *
1644   * When elementType is unavailable (backward compat / no AST detail), fall back
1645   * to a char check against PS_TOKENIZER_DASH_CHARS.
1646   */
1647  export function isPowerShellParameter(
1648    arg: string,
1649    elementType?: CommandElementType,
1650  ): boolean {
1651    if (elementType !== undefined) {
1652      return elementType === 'Parameter'
1653    }
1654    return arg.length > 0 && PS_TOKENIZER_DASH_CHARS.has(arg[0]!)
1655  }
1656  
1657  /**
1658   * Check if any argument on a command is an unambiguous abbreviation of a PowerShell parameter.
1659   * PowerShell allows parameter abbreviation as long as the prefix is unambiguous.
1660   * The minPrefix is the shortest unambiguous prefix for the parameter.
1661   * For example, minPrefix '-en' for fullParam '-encodedcommand' matches '-en', '-enc', '-enco', etc.
1662   */
1663  export function commandHasArgAbbreviation(
1664    command: ParsedCommandElement,
1665    fullParam: string,
1666    minPrefix: string,
1667  ): boolean {
1668    const lowerFull = fullParam.toLowerCase()
1669    const lowerMin = minPrefix.toLowerCase()
1670    return command.args.some(a => {
1671      // Strip colon-bound value (e.g., -en:base64value -> -en)
1672      const colonIndex = a.indexOf(':', 1)
1673      const paramPart = colonIndex > 0 ? a.slice(0, colonIndex) : a
1674      // Strip backtick escapes — PowerShell resolves `-Member`Name` to
1675      // `-MemberName` but Extent.Text preserves the backtick, causing
1676      // prefix-comparison misses on the raw text.
1677      const lower = paramPart.replace(/`/g, '').toLowerCase()
1678      return (
1679        lower.startsWith(lowerMin) &&
1680        lowerFull.startsWith(lower) &&
1681        lower.length <= lowerFull.length
1682      )
1683    })
1684  }
1685  
1686  /**
1687   * Split a parsed command into its pipeline segments for per-segment permission checking.
1688   * Returns each pipeline's commands separately.
1689   */
1690  export function getPipelineSegments(
1691    parsed: ParsedPowerShellCommand,
1692  ): ParsedStatement[] {
1693    return parsed.statements
1694  }
1695  
1696  /**
1697   * True if a redirection target is PowerShell's `$null` automatic variable.
1698   * `> $null` discards output (like /dev/null) — not a filesystem write.
1699   * `$null` cannot be reassigned, so this is safe to treat as a no-op sink.
1700   * `${null}` is the same automatic variable via curly-brace syntax. Spaces
1701   * inside the braces (`${ null }`) name a different variable, so no regex.
1702   */
1703  export function isNullRedirectionTarget(target: string): boolean {
1704    const t = target.trim().toLowerCase()
1705    return t === '$null' || t === '${null}'
1706  }
1707  
1708  /**
1709   * Get output redirections (file redirections, not merging redirections).
1710   * Returns only redirections that write to files.
1711   */
1712  // exported for testing
1713  export function getFileRedirections(
1714    parsed: ParsedPowerShellCommand,
1715  ): ParsedRedirection[] {
1716    return getAllRedirections(parsed).filter(
1717      r => !r.isMerging && !isNullRedirectionTarget(r.target),
1718    )
1719  }
1720  
1721  /**
1722   * Derive security-relevant flags from the parsed command structure.
1723   * This replaces the previous approach of computing flags in PowerShell via
1724   * separate Find-AstNodes calls. Instead, the PS1 script tags each element
1725   * with its AST node type, and this function walks those types.
1726   */
1727  // exported for testing
1728  export function deriveSecurityFlags(
1729    parsed: ParsedPowerShellCommand,
1730  ): SecurityFlags {
1731    const flags: SecurityFlags = {
1732      hasSubExpressions: false,
1733      hasScriptBlocks: false,
1734      hasSplatting: false,
1735      hasExpandableStrings: false,
1736      hasMemberInvocations: false,
1737      hasAssignments: false,
1738      hasStopParsing: parsed.hasStopParsing,
1739    }
1740  
1741    function checkElements(cmd: ParsedCommandElement): void {
1742      if (!cmd.elementTypes) {
1743        return
1744      }
1745      for (const et of cmd.elementTypes) {
1746        switch (et) {
1747          case 'ScriptBlock':
1748            flags.hasScriptBlocks = true
1749            break
1750          case 'SubExpression':
1751            flags.hasSubExpressions = true
1752            break
1753          case 'ExpandableString':
1754            flags.hasExpandableStrings = true
1755            break
1756          case 'MemberInvocation':
1757            flags.hasMemberInvocations = true
1758            break
1759        }
1760      }
1761    }
1762  
1763    for (const stmt of parsed.statements) {
1764      if (stmt.statementType === 'AssignmentStatementAst') {
1765        flags.hasAssignments = true
1766      }
1767      for (const cmd of stmt.commands) {
1768        checkElements(cmd)
1769      }
1770      if (stmt.nestedCommands) {
1771        for (const cmd of stmt.nestedCommands) {
1772          checkElements(cmd)
1773        }
1774      }
1775      // securityPatterns provides a belt-and-suspenders check that catches
1776      // patterns elementTypes may miss (e.g. member invocations inside
1777      // assignments, subexpressions in non-pipeline statements).
1778      if (stmt.securityPatterns) {
1779        if (stmt.securityPatterns.hasMemberInvocations) {
1780          flags.hasMemberInvocations = true
1781        }
1782        if (stmt.securityPatterns.hasSubExpressions) {
1783          flags.hasSubExpressions = true
1784        }
1785        if (stmt.securityPatterns.hasExpandableStrings) {
1786          flags.hasExpandableStrings = true
1787        }
1788        if (stmt.securityPatterns.hasScriptBlocks) {
1789          flags.hasScriptBlocks = true
1790        }
1791      }
1792    }
1793  
1794    for (const v of parsed.variables) {
1795      if (v.isSplatted) {
1796        flags.hasSplatting = true
1797        break
1798      }
1799    }
1800  
1801    return flags
1802  }
1803  
1804  // Raw types exported for testing (function exports are inline above)