/ tools / PowerShellTool / powershellPermissions.ts
powershellPermissions.ts
   1  /**
   2   * PowerShell-specific permission checking, adapted from bashPermissions.ts
   3   * for case-insensitive cmdlet matching.
   4   */
   5  
   6  import { resolve } from 'path'
   7  import type { ToolPermissionContext, ToolUseContext } from '../../Tool.js'
   8  import type {
   9    PermissionDecisionReason,
  10    PermissionResult,
  11  } from '../../types/permissions.js'
  12  import { getCwd } from '../../utils/cwd.js'
  13  import { isCurrentDirectoryBareGitRepo } from '../../utils/git.js'
  14  import type { PermissionRule } from '../../utils/permissions/PermissionRule.js'
  15  import type { PermissionUpdate } from '../../utils/permissions/PermissionUpdateSchema.js'
  16  import {
  17    createPermissionRequestMessage,
  18    getRuleByContentsForToolName,
  19  } from '../../utils/permissions/permissions.js'
  20  import {
  21    matchWildcardPattern,
  22    parsePermissionRule,
  23    type ShellPermissionRule,
  24    suggestionForExactCommand as sharedSuggestionForExactCommand,
  25  } from '../../utils/permissions/shellRuleMatching.js'
  26  import {
  27    classifyCommandName,
  28    deriveSecurityFlags,
  29    getAllCommandNames,
  30    getFileRedirections,
  31    type ParsedCommandElement,
  32    type ParsedPowerShellCommand,
  33    PS_TOKENIZER_DASH_CHARS,
  34    parsePowerShellCommand,
  35    stripModulePrefix,
  36  } from '../../utils/powershell/parser.js'
  37  import { containsVulnerableUncPath } from '../../utils/shell/readOnlyCommandValidation.js'
  38  import { isDotGitPathPS, isGitInternalPathPS } from './gitSafety.js'
  39  import {
  40    checkPermissionMode,
  41    isSymlinkCreatingCommand,
  42  } from './modeValidation.js'
  43  import {
  44    checkPathConstraints,
  45    dangerousRemovalDeny,
  46    isDangerousRemovalRawPath,
  47  } from './pathValidation.js'
  48  import { powershellCommandIsSafe } from './powershellSecurity.js'
  49  import {
  50    argLeaksValue,
  51    isAllowlistedCommand,
  52    isCwdChangingCmdlet,
  53    isProvablySafeStatement,
  54    isReadOnlyCommand,
  55    isSafeOutputCommand,
  56    resolveToCanonical,
  57  } from './readOnlyValidation.js'
  58  import { POWERSHELL_TOOL_NAME } from './toolName.js'
  59  
  60  // Matches `$var = `, `$var += `, `$env:X = `, `$x ??= ` etc. Used to strip
  61  // nested assignment prefixes in the parse-failed fallback path.
  62  const PS_ASSIGN_PREFIX_RE = /^\$[\w:]+\s*(?:[+\-*/%]|\?\?)?\s*=\s*/
  63  
  64  /**
  65   * Cmdlets that can place a file at a caller-specified path. The
  66   * git-internal-paths guard checks whether any arg is a git-internal path
  67   * (hooks/, refs/, objects/, HEAD). Non-creating writers (remove-item,
  68   * clear-content) are intentionally absent — they can't plant new hooks.
  69   */
  70  const GIT_SAFETY_WRITE_CMDLETS = new Set([
  71    'new-item',
  72    'set-content',
  73    'add-content',
  74    'out-file',
  75    'copy-item',
  76    'move-item',
  77    'rename-item',
  78    'expand-archive',
  79    'invoke-webrequest',
  80    'invoke-restmethod',
  81    'tee-object',
  82    'export-csv',
  83    'export-clixml',
  84  ])
  85  
  86  /**
  87   * External archive-extraction applications that write files to cwd with
  88   * archive-controlled paths. `tar -xf payload.tar; git status` defeats
  89   * isCurrentDirectoryBareGitRepo (TOCTOU): the check runs at
  90   * permission-eval time, tar extracts HEAD/hooks/refs/ AFTER the check and
  91   * BEFORE git runs. Unlike GIT_SAFETY_WRITE_CMDLETS (where we can inspect
  92   * args for git-internal paths), archive contents are opaque — any
  93   * extraction preceding git must ask. Matched by name only (lowercase,
  94   * with and without .exe).
  95   */
  96  const GIT_SAFETY_ARCHIVE_EXTRACTORS = new Set([
  97    'tar',
  98    'tar.exe',
  99    'bsdtar',
 100    'bsdtar.exe',
 101    'unzip',
 102    'unzip.exe',
 103    '7z',
 104    '7z.exe',
 105    '7za',
 106    '7za.exe',
 107    'gzip',
 108    'gzip.exe',
 109    'gunzip',
 110    'gunzip.exe',
 111    'expand-archive',
 112  ])
 113  
 114  /**
 115   * Extract the command name from a PowerShell command string.
 116   * Uses the parser to get the first command name from the AST.
 117   */
 118  async function extractCommandName(command: string): Promise<string> {
 119    const trimmed = command.trim()
 120    if (!trimmed) {
 121      return ''
 122    }
 123    const parsed = await parsePowerShellCommand(trimmed)
 124    const names = getAllCommandNames(parsed)
 125    return names[0] ?? ''
 126  }
 127  
 128  /**
 129   * Parse a permission rule string into a structured rule object.
 130   * Delegates to shared parsePermissionRule.
 131   */
 132  export function powershellPermissionRule(
 133    permissionRule: string,
 134  ): ShellPermissionRule {
 135    return parsePermissionRule(permissionRule)
 136  }
 137  
 138  /**
 139   * Generate permission update suggestion for exact command match.
 140   *
 141   * Skip exact-command suggestion for commands that can't round-trip cleanly:
 142   * - Multi-line: newlines don't survive normalization, rule would never match
 143   * - Literal *: storing `Remove-Item * -Force` verbatim re-parses as a wildcard
 144   *   rule via hasWildcards() (matches `^Remove-Item .* -Force$`). Escaping to
 145   *   `\*` creates a dead rule — parsePermissionRule's exact branch returns the
 146   *   raw string with backslash intact, so `Remove-Item \* -Force` never matches
 147   *   the incoming `Remove-Item * -Force`. Globs are unsafe to exact-auto-allow
 148   *   anyway; prefix suggestion still offered. (finding #12)
 149   */
 150  function suggestionForExactCommand(command: string): PermissionUpdate[] {
 151    if (command.includes('\n') || command.includes('*')) {
 152      return []
 153    }
 154    return sharedSuggestionForExactCommand(POWERSHELL_TOOL_NAME, command)
 155  }
 156  
 157  /**
 158   * PowerShell input schema type - simplified for initial implementation
 159   */
 160  type PowerShellInput = {
 161    command: string
 162    timeout?: number
 163  }
 164  
 165  /**
 166   * Filter rules by contents matching an input command.
 167   * PowerShell-specific: uses case-insensitive matching throughout.
 168   * Follows the same structure as BashTool's local filterRulesByContentsMatchingInput.
 169   */
 170  function filterRulesByContentsMatchingInput(
 171    input: PowerShellInput,
 172    rules: Map<string, PermissionRule>,
 173    matchMode: 'exact' | 'prefix',
 174    behavior: 'deny' | 'ask' | 'allow',
 175  ): PermissionRule[] {
 176    const command = input.command.trim()
 177  
 178    function strEquals(a: string, b: string): boolean {
 179      return a.toLowerCase() === b.toLowerCase()
 180    }
 181    function strStartsWith(str: string, prefix: string): boolean {
 182      return str.toLowerCase().startsWith(prefix.toLowerCase())
 183    }
 184    // SECURITY: stripModulePrefix on RULE names widens the
 185    // secondary-canonical match — a deny rule `Module\Remove-Item:*` blocking
 186    // `rm` is the intent (fail-safe over-match), but an allow rule
 187    // `ModuleA\Get-Thing:*` also matching `ModuleB\Get-Thing` is fail-OPEN.
 188    // Deny/ask over-match is fine; allow must never over-match.
 189    function stripModulePrefixForRule(name: string): string {
 190      if (behavior === 'allow') {
 191        return name
 192      }
 193      return stripModulePrefix(name)
 194    }
 195  
 196    // Extract the first word (command name) from the input for canonical matching.
 197    // Keep both raw (for slicing the original `command` string) and stripped
 198    // (for canonical resolution) versions. For module-qualified inputs like
 199    // `Microsoft.PowerShell.Utility\Invoke-Expression foo`, rawCmdName holds the
 200    // full token so `command.slice(rawCmdName.length)` yields the correct rest.
 201    const rawCmdName = command.split(/\s+/)[0] ?? ''
 202    const inputCmdName = stripModulePrefix(rawCmdName)
 203    const inputCanonical = resolveToCanonical(inputCmdName)
 204  
 205    // Build a version of the command with the canonical name substituted
 206    // e.g., 'rm foo.txt' -> 'remove-item foo.txt' so deny rules on Remove-Item also block rm.
 207    // SECURITY: Normalize the whitespace separator between name and args to a
 208    // single space. PowerShell accepts any whitespace (tab, etc.) as separator,
 209    // but prefix rule matching uses `prefix + ' '` (literal space). Without this,
 210    // `rm\t./x` canonicalizes to `remove-item\t./x` and misses the deny rule
 211    // `Remove-Item:*`, while acceptEdits auto-allow (using AST cmd.name) still
 212    // matches — a deny-rule bypass. Build unconditionally (not just when the
 213    // canonical differs) so non-space-separated raw commands are also normalized.
 214    const rest = command.slice(rawCmdName.length).replace(/^\s+/, ' ')
 215    const canonicalCommand = inputCanonical + rest
 216  
 217    return Array.from(rules.entries())
 218      .filter(([ruleContent]) => {
 219        const rule = powershellPermissionRule(ruleContent)
 220  
 221        // Also resolve the rule's command name to canonical for cross-matching
 222        // e.g., a deny rule for 'rm' should also block 'Remove-Item'
 223        function matchesCommand(cmd: string): boolean {
 224          switch (rule.type) {
 225            case 'exact':
 226              return strEquals(rule.command, cmd)
 227            case 'prefix':
 228              switch (matchMode) {
 229                case 'exact':
 230                  return strEquals(rule.prefix, cmd)
 231                case 'prefix': {
 232                  if (strEquals(cmd, rule.prefix)) {
 233                    return true
 234                  }
 235                  return strStartsWith(cmd, rule.prefix + ' ')
 236                }
 237              }
 238              break
 239            case 'wildcard':
 240              if (matchMode === 'exact') {
 241                return false
 242              }
 243              return matchWildcardPattern(rule.pattern, cmd, true)
 244          }
 245        }
 246  
 247        // Check against the original command
 248        if (matchesCommand(command)) {
 249          return true
 250        }
 251  
 252        // Also check against the canonical form of the command
 253        // This ensures 'deny Remove-Item' also blocks 'rm', 'del', 'ri', etc.
 254        if (matchesCommand(canonicalCommand)) {
 255          return true
 256        }
 257  
 258        // Also resolve the rule's command name to canonical and compare
 259        // This ensures 'deny rm' also blocks 'Remove-Item'
 260        // SECURITY: stripModulePrefix applied to DENY/ASK rule command
 261        // names too, not just input. Otherwise a deny rule written as
 262        // `Microsoft.PowerShell.Management\Remove-Item:*` is bypassed by `rm`,
 263        // `del`, or plain `Remove-Item` — resolveToCanonical won't match the
 264        // module-qualified form against COMMON_ALIASES.
 265        if (rule.type === 'exact') {
 266          const rawRuleCmdName = rule.command.split(/\s+/)[0] ?? ''
 267          const ruleCanonical = resolveToCanonical(
 268            stripModulePrefixForRule(rawRuleCmdName),
 269          )
 270          if (ruleCanonical === inputCanonical) {
 271            // Rule and input resolve to same canonical cmdlet
 272            // SECURITY: use normalized `rest` not a raw re-slice
 273            // from `command`. The raw slice preserves tab separators so
 274            // `Remove-Item\t./secret.txt` vs deny rule `rm ./secret.txt` misses.
 275            // Normalize both sides identically.
 276            const ruleRest = rule.command
 277              .slice(rawRuleCmdName.length)
 278              .replace(/^\s+/, ' ')
 279            const inputRest = rest
 280            if (strEquals(ruleRest, inputRest)) {
 281              return true
 282            }
 283          }
 284        } else if (rule.type === 'prefix') {
 285          const rawRuleCmdName = rule.prefix.split(/\s+/)[0] ?? ''
 286          const ruleCanonical = resolveToCanonical(
 287            stripModulePrefixForRule(rawRuleCmdName),
 288          )
 289          if (ruleCanonical === inputCanonical) {
 290            const ruleRest = rule.prefix
 291              .slice(rawRuleCmdName.length)
 292              .replace(/^\s+/, ' ')
 293            const canonicalPrefix = inputCanonical + ruleRest
 294            if (matchMode === 'exact') {
 295              if (strEquals(canonicalPrefix, canonicalCommand)) {
 296                return true
 297              }
 298            } else {
 299              if (
 300                strEquals(canonicalCommand, canonicalPrefix) ||
 301                strStartsWith(canonicalCommand, canonicalPrefix + ' ')
 302              ) {
 303                return true
 304              }
 305            }
 306          }
 307        } else if (rule.type === 'wildcard') {
 308          // Resolve the wildcard pattern's command name to canonical and re-match
 309          // This ensures 'deny rm *' also blocks 'Remove-Item secret.txt'
 310          const rawRuleCmdName = rule.pattern.split(/\s+/)[0] ?? ''
 311          const ruleCanonical = resolveToCanonical(
 312            stripModulePrefixForRule(rawRuleCmdName),
 313          )
 314          if (ruleCanonical === inputCanonical && matchMode !== 'exact') {
 315            // Rebuild the pattern with the canonical cmdlet name
 316            // Normalize separator same as exact and prefix branches.
 317            // Without this, a wildcard rule `rm\t*` produces canonicalPattern
 318            // with a literal tab that never matches the space-normalized
 319            // canonicalCommand.
 320            const ruleRest = rule.pattern
 321              .slice(rawRuleCmdName.length)
 322              .replace(/^\s+/, ' ')
 323            const canonicalPattern = inputCanonical + ruleRest
 324            if (matchWildcardPattern(canonicalPattern, canonicalCommand, true)) {
 325              return true
 326            }
 327          }
 328        }
 329  
 330        return false
 331      })
 332      .map(([, rule]) => rule)
 333  }
 334  
 335  /**
 336   * Get matching rules for input across all rule types (deny, ask, allow)
 337   */
 338  function matchingRulesForInput(
 339    input: PowerShellInput,
 340    toolPermissionContext: ToolPermissionContext,
 341    matchMode: 'exact' | 'prefix',
 342  ) {
 343    const denyRuleByContents = getRuleByContentsForToolName(
 344      toolPermissionContext,
 345      POWERSHELL_TOOL_NAME,
 346      'deny',
 347    )
 348    const matchingDenyRules = filterRulesByContentsMatchingInput(
 349      input,
 350      denyRuleByContents,
 351      matchMode,
 352      'deny',
 353    )
 354  
 355    const askRuleByContents = getRuleByContentsForToolName(
 356      toolPermissionContext,
 357      POWERSHELL_TOOL_NAME,
 358      'ask',
 359    )
 360    const matchingAskRules = filterRulesByContentsMatchingInput(
 361      input,
 362      askRuleByContents,
 363      matchMode,
 364      'ask',
 365    )
 366  
 367    const allowRuleByContents = getRuleByContentsForToolName(
 368      toolPermissionContext,
 369      POWERSHELL_TOOL_NAME,
 370      'allow',
 371    )
 372    const matchingAllowRules = filterRulesByContentsMatchingInput(
 373      input,
 374      allowRuleByContents,
 375      matchMode,
 376      'allow',
 377    )
 378  
 379    return { matchingDenyRules, matchingAskRules, matchingAllowRules }
 380  }
 381  
 382  /**
 383   * Check if the command is an exact match for a permission rule.
 384   */
 385  export function powershellToolCheckExactMatchPermission(
 386    input: PowerShellInput,
 387    toolPermissionContext: ToolPermissionContext,
 388  ): PermissionResult {
 389    const trimmedCommand = input.command.trim()
 390    const { matchingDenyRules, matchingAskRules, matchingAllowRules } =
 391      matchingRulesForInput(input, toolPermissionContext, 'exact')
 392  
 393    if (matchingDenyRules[0] !== undefined) {
 394      return {
 395        behavior: 'deny',
 396        message: `Permission to use ${POWERSHELL_TOOL_NAME} with command ${trimmedCommand} has been denied.`,
 397        decisionReason: { type: 'rule', rule: matchingDenyRules[0] },
 398      }
 399    }
 400  
 401    if (matchingAskRules[0] !== undefined) {
 402      return {
 403        behavior: 'ask',
 404        message: createPermissionRequestMessage(POWERSHELL_TOOL_NAME),
 405        decisionReason: { type: 'rule', rule: matchingAskRules[0] },
 406      }
 407    }
 408  
 409    if (matchingAllowRules[0] !== undefined) {
 410      return {
 411        behavior: 'allow',
 412        updatedInput: input,
 413        decisionReason: { type: 'rule', rule: matchingAllowRules[0] },
 414      }
 415    }
 416  
 417    const decisionReason: PermissionDecisionReason = {
 418      type: 'other' as const,
 419      reason: 'This command requires approval',
 420    }
 421    return {
 422      behavior: 'passthrough',
 423      message: createPermissionRequestMessage(
 424        POWERSHELL_TOOL_NAME,
 425        decisionReason,
 426      ),
 427      decisionReason,
 428      suggestions: suggestionForExactCommand(trimmedCommand),
 429    }
 430  }
 431  
 432  /**
 433   * Check permission for a PowerShell command including prefix matches.
 434   */
 435  export function powershellToolCheckPermission(
 436    input: PowerShellInput,
 437    toolPermissionContext: ToolPermissionContext,
 438  ): PermissionResult {
 439    const command = input.command.trim()
 440  
 441    // 1. Check exact match first
 442    const exactMatchResult = powershellToolCheckExactMatchPermission(
 443      input,
 444      toolPermissionContext,
 445    )
 446  
 447    // 1a. Deny/ask if exact command has a rule
 448    if (
 449      exactMatchResult.behavior === 'deny' ||
 450      exactMatchResult.behavior === 'ask'
 451    ) {
 452      return exactMatchResult
 453    }
 454  
 455    // 2. Find all matching rules (prefix or exact)
 456    const { matchingDenyRules, matchingAskRules, matchingAllowRules } =
 457      matchingRulesForInput(input, toolPermissionContext, 'prefix')
 458  
 459    // 2a. Deny if command has a deny rule
 460    if (matchingDenyRules[0] !== undefined) {
 461      return {
 462        behavior: 'deny',
 463        message: `Permission to use ${POWERSHELL_TOOL_NAME} with command ${command} has been denied.`,
 464        decisionReason: {
 465          type: 'rule',
 466          rule: matchingDenyRules[0],
 467        },
 468      }
 469    }
 470  
 471    // 2b. Ask if command has an ask rule
 472    if (matchingAskRules[0] !== undefined) {
 473      return {
 474        behavior: 'ask',
 475        message: createPermissionRequestMessage(POWERSHELL_TOOL_NAME),
 476        decisionReason: {
 477          type: 'rule',
 478          rule: matchingAskRules[0],
 479        },
 480      }
 481    }
 482  
 483    // 3. Allow if command had an exact match allow
 484    if (exactMatchResult.behavior === 'allow') {
 485      return exactMatchResult
 486    }
 487  
 488    // 4. Allow if command has an allow rule
 489    if (matchingAllowRules[0] !== undefined) {
 490      return {
 491        behavior: 'allow',
 492        updatedInput: input,
 493        decisionReason: {
 494          type: 'rule',
 495          rule: matchingAllowRules[0],
 496        },
 497      }
 498    }
 499  
 500    // 5. Passthrough since no rules match, will trigger permission prompt
 501    const decisionReason = {
 502      type: 'other' as const,
 503      reason: 'This command requires approval',
 504    }
 505    return {
 506      behavior: 'passthrough',
 507      message: createPermissionRequestMessage(
 508        POWERSHELL_TOOL_NAME,
 509        decisionReason,
 510      ),
 511      decisionReason,
 512      suggestions: suggestionForExactCommand(command),
 513    }
 514  }
 515  
 516  /**
 517   * Information about a sub-command for permission checking.
 518   */
 519  type SubCommandInfo = {
 520    text: string
 521    element: ParsedCommandElement
 522    statement: ParsedPowerShellCommand['statements'][number] | null
 523    isSafeOutput: boolean
 524  }
 525  
 526  /**
 527   * Extract sub-commands that need independent permission checking from a parsed command.
 528   * Safe output cmdlets (Format-Table, Select-Object, etc.) are flagged but NOT
 529   * filtered out — step 4.4 still checks deny rules against them (deny always
 530   * wins), step 5 skips them for approval collection (they inherit the permission
 531   * of the preceding command).
 532   *
 533   * Also includes nested commands from control flow statements (if, for, foreach, etc.)
 534   * to ensure commands hidden inside control flow are checked.
 535   *
 536   * Returns sub-command info including both text and the parsed element for accurate
 537   * suggestion generation.
 538   */
 539  async function getSubCommandsForPermissionCheck(
 540    parsed: ParsedPowerShellCommand,
 541    originalCommand: string,
 542  ): Promise<SubCommandInfo[]> {
 543    if (!parsed.valid) {
 544      // Return a fallback element for unparsed commands
 545      return [
 546        {
 547          text: originalCommand,
 548          element: {
 549            name: await extractCommandName(originalCommand),
 550            nameType: 'unknown',
 551            elementType: 'CommandAst',
 552            args: [],
 553            text: originalCommand,
 554          },
 555          statement: null,
 556          isSafeOutput: false,
 557        },
 558      ]
 559    }
 560  
 561    const subCommands: SubCommandInfo[] = []
 562  
 563    // Check direct commands in pipelines
 564    for (const statement of parsed.statements) {
 565      for (const cmd of statement.commands) {
 566        // Only check actual commands (CommandAst), not expressions
 567        if (cmd.elementType !== 'CommandAst') {
 568          continue
 569        }
 570        subCommands.push({
 571          text: cmd.text,
 572          element: cmd,
 573          statement,
 574          // SECURITY: nameType gate — scripts\\Out-Null strips to Out-Null and
 575          // would match SAFE_OUTPUT_CMDLETS, but PowerShell runs the .ps1 file.
 576          // isSafeOutput: true causes step 5 to filter this command out of the
 577          // approval list, so it would silently execute. See isAllowlistedCommand.
 578          // SECURITY: args.length === 0 gate — Out-Null -InputObject:(1 > /etc/x)
 579          // was filtered as safe-output (name-only) → step-5 subCommands empty →
 580          // auto-allow → redirection inside paren writes file. Only zero-arg
 581          // Out-String/Out-Null/Out-Host invocations are provably safe.
 582          isSafeOutput:
 583            cmd.nameType !== 'application' &&
 584            isSafeOutputCommand(cmd.name) &&
 585            cmd.args.length === 0,
 586        })
 587      }
 588  
 589      // Also check nested commands from control flow statements
 590      if (statement.nestedCommands) {
 591        for (const cmd of statement.nestedCommands) {
 592          subCommands.push({
 593            text: cmd.text,
 594            element: cmd,
 595            statement,
 596            isSafeOutput:
 597              cmd.nameType !== 'application' &&
 598              isSafeOutputCommand(cmd.name) &&
 599              cmd.args.length === 0,
 600          })
 601        }
 602      }
 603    }
 604  
 605    if (subCommands.length > 0) {
 606      return subCommands
 607    }
 608  
 609    // Fallback for commands with no sub-commands
 610    return [
 611      {
 612        text: originalCommand,
 613        element: {
 614          name: await extractCommandName(originalCommand),
 615          nameType: 'unknown',
 616          elementType: 'CommandAst',
 617          args: [],
 618          text: originalCommand,
 619        },
 620        statement: null,
 621        isSafeOutput: false,
 622      },
 623    ]
 624  }
 625  
 626  /**
 627   * Main permission check function for PowerShell tool.
 628   *
 629   * This function implements the full permission flow:
 630   * 1. Check exact match against deny/ask/allow rules
 631   * 2. Check prefix match against rules
 632   * 3. Run security check via powershellCommandIsSafe()
 633   * 4. Return appropriate PermissionResult
 634   *
 635   * @param input - The PowerShell tool input
 636   * @param context - The tool use context (for abort signal and session info)
 637   * @returns Promise resolving to PermissionResult
 638   */
 639  export async function powershellToolHasPermission(
 640    input: PowerShellInput,
 641    context: ToolUseContext,
 642  ): Promise<PermissionResult> {
 643    const toolPermissionContext = context.getAppState().toolPermissionContext
 644    const command = input.command.trim()
 645  
 646    // Empty command check
 647    if (!command) {
 648      return {
 649        behavior: 'allow',
 650        updatedInput: input,
 651        decisionReason: {
 652          type: 'other',
 653          reason: 'Empty command is safe',
 654        },
 655      }
 656    }
 657  
 658    // Parse the command once and thread through all sub-functions
 659    const parsed = await parsePowerShellCommand(command)
 660  
 661    // SECURITY: Check deny/ask rules BEFORE parse validity check.
 662    // Deny rules operate on the raw command string and don't need the parsed AST.
 663    // This ensures explicit deny rules still block commands even when parsing fails.
 664    // 1. Check exact match first
 665    const exactMatchResult = powershellToolCheckExactMatchPermission(
 666      input,
 667      toolPermissionContext,
 668    )
 669  
 670    // Exact command was denied
 671    if (exactMatchResult.behavior === 'deny') {
 672      return exactMatchResult
 673    }
 674  
 675    // 2. Check prefix/wildcard rules
 676    const { matchingDenyRules, matchingAskRules } = matchingRulesForInput(
 677      input,
 678      toolPermissionContext,
 679      'prefix',
 680    )
 681  
 682    // 2a. Deny if command has a deny rule
 683    if (matchingDenyRules[0] !== undefined) {
 684      return {
 685        behavior: 'deny',
 686        message: `Permission to use ${POWERSHELL_TOOL_NAME} with command ${command} has been denied.`,
 687        decisionReason: {
 688          type: 'rule',
 689          rule: matchingDenyRules[0],
 690        },
 691      }
 692    }
 693  
 694    // 2b. Ask if command has an ask rule — DEFERRED into decisions[].
 695    // Previously this early-returned before sub-command deny checks ran, so
 696    // `Get-Process; Invoke-Expression evil` with ask(Get-Process:*) +
 697    // deny(Invoke-Expression:*) would show the ask dialog and the deny never
 698    // fired. Now: store the ask, push into decisions[] after parse succeeds.
 699    // If parse fails, returned before the parse-error ask (preserves the
 700    // rule-attributed decisionReason when pwsh is unavailable).
 701    let preParseAskDecision: PermissionResult | null = null
 702    if (matchingAskRules[0] !== undefined) {
 703      preParseAskDecision = {
 704        behavior: 'ask',
 705        message: createPermissionRequestMessage(POWERSHELL_TOOL_NAME),
 706        decisionReason: {
 707          type: 'rule',
 708          rule: matchingAskRules[0],
 709        },
 710      }
 711    }
 712  
 713    // Block UNC paths — reading from UNC paths can trigger network requests
 714    // and leak NTLM/Kerberos credentials. DEFERRED into decisions[].
 715    // The raw-string UNC check must not early-return before sub-command deny
 716    // (step 4+). Same fix as 2b above.
 717    if (preParseAskDecision === null && containsVulnerableUncPath(command)) {
 718      preParseAskDecision = {
 719        behavior: 'ask',
 720        message:
 721          'Command contains a UNC path that could trigger network requests',
 722      }
 723    }
 724  
 725    // 2c. Exact allow rules short-circuit here ONLY when parsing failed AND
 726    // no pre-parse ask (2b prefix or UNC) is pending. Converting 2b/UNC from
 727    // early-return to deferred-assign meant 2c
 728    // fired before L648 consumed preParseAskDecision — silently overriding the
 729    // ask with allow. Parse-succeeded path enforces ask > allow via the reduce
 730    // (L917); without this guard, parse-failed was inconsistent.
 731    // This ensures user-configured exact allow rules work even when pwsh is
 732    // unavailable. When parsing succeeds, the exact allow check is deferred to
 733    // after step 4.4 (sub-command deny/ask) — matching BashTool's ordering where
 734    // the main-flow exact allow at bashPermissions.ts:1520 runs after sub-command
 735    // deny checks (1442-1458). Without this, an exact allow on a compound command
 736    // would bypass deny rules on sub-commands.
 737    //
 738    // SECURITY (parse-failed branch): the nameType guard in step 5 lives
 739    // inside the sub-command loop, which only runs when parsed.valid.
 740    // This is the !parsed.valid escape hatch. Input-side stripModulePrefix
 741    // is unconditional — `scripts\build.exe --flag` strips to `build.exe`,
 742    // canonicalCommand matches exact allow, and without this guard we'd
 743    // return allow here and execute the local script. classifyCommandName
 744    // is a pure string function (no AST needed). `scripts\build.exe` →
 745    // 'application' (has `\`). Same tradeoff as step 5: `build.exe` alone
 746    // also classifies 'application' (has `.`) so legitimate executable
 747    // exact-allows downgrade to ask when pwsh is degraded — fail-safe.
 748    // Module-qualified cmdlets (Module\Cmdlet) also classify 'application'
 749    // (same `\`); same fail-safe over-fire.
 750    if (
 751      exactMatchResult.behavior === 'allow' &&
 752      !parsed.valid &&
 753      preParseAskDecision === null &&
 754      classifyCommandName(command.split(/\s+/)[0] ?? '') !== 'application'
 755    ) {
 756      return exactMatchResult
 757    }
 758  
 759    // 0. Check if command can be parsed - if not, require approval but don't suggest persisting
 760    // This matches Bash behavior: invalid syntax triggers a permission prompt but we don't
 761    // recommend saving invalid commands to settings
 762    // NOTE: This check is intentionally AFTER deny/ask rules so explicit rules still work
 763    // even when the parser fails (e.g., pwsh unavailable).
 764    if (!parsed.valid) {
 765      // SECURITY: Fallback sub-command deny scan for parse-failed path.
 766      // The sub-command deny loop at L851+ needs the AST; when parsing fails
 767      // (command exceeds MAX_COMMAND_LENGTH, pwsh unavailable, timeout, bad
 768      // JSON), we'd return 'ask' without ever checking sub-command deny rules.
 769      // Attack: `Get-ChildItem # <~2000 chars padding> ; Invoke-Expression evil`
 770      // → padding forces valid=false → generic ask prompt, deny(iex:*) never
 771      // fires. This fallback splits on PowerShell separators/grouping and runs
 772      // each fragment through the SAME rule matcher as step 2a (prefix deny).
 773      // Conservative: fragments inside string literals/comments may false-positive
 774      // deny — safe here (parse-failed is already a degraded state, and this is
 775      // a deny-DOWNGRADE fix). Match against full fragment (not just first token)
 776      // so multi-word rules like `Remove-Item foo:*` still fire; the matcher's
 777      // canonical resolution handles aliases (`iex` → `Invoke-Expression`).
 778      //
 779      // SECURITY: backtick is PS escape/line-continuation, NOT a separator.
 780      // Splitting on it would fragment `Invoke-Ex`pression` into non-matching
 781      // pieces. Instead: collapse backtick-newline (line continuation) so
 782      // `Invoke-Ex`<nl>pression` rejoins, strip remaining backticks (escape
 783      // chars — ``x → x), then split on actual statement/grouping separators.
 784      const backtickStripped = command
 785        .replace(/`[\r\n]+\s*/g, '')
 786        .replace(/`/g, '')
 787      for (const fragment of backtickStripped.split(/[;|\n\r{}()&]+/)) {
 788        const trimmedFrag = fragment.trim()
 789        if (!trimmedFrag) continue // skip empty fragments
 790        // Skip the full command ONLY if it starts with a cmdlet name (no
 791        // assignment prefix). The full command was already checked at 2a, but
 792        // 2a uses the raw text — $x %= iex as first token `$x` misses the
 793        // deny(iex:*) rule. If normalization would change the fragment
 794        // (assignment prefix, dot-source), don't skip — let it be re-checked
 795        // after normalization. (bug #10/#24)
 796        if (
 797          trimmedFrag === command &&
 798          !/^\$[\w:]/.test(trimmedFrag) &&
 799          !/^[&.]\s/.test(trimmedFrag)
 800        ) {
 801          continue
 802        }
 803        // SECURITY: Normalize invocation-operator and assignment prefixes before
 804        // rule matching (findings #5/#22). The splitter gives us the raw fragment
 805        // text; matchingRulesForInput extracts the first token as the cmdlet name.
 806        // Without normalization:
 807        //   `$x = Invoke-Expression 'p'` → first token `$x` → deny(iex:*) misses
 808        //   `. Invoke-Expression 'p'`    → first token `.`  → deny(iex:*) misses
 809        //   `& 'Invoke-Expression' 'p'`  → first token `&` removed by split but
 810        //                                  `'Invoke-Expression'` retains quotes
 811        //                                  → deny(iex:*) misses
 812        // The parse-succeeded path handles these via AST (parser.ts:839 strips
 813        // quotes from rawNameUnstripped; invocation operators are separate AST
 814        // nodes). This fallback mirrors that normalization.
 815        // Loop strips nested assignments: $x = $y = iex → $y = iex → iex
 816        let normalized = trimmedFrag
 817        let m: RegExpMatchArray | null
 818        while ((m = normalized.match(PS_ASSIGN_PREFIX_RE))) {
 819          normalized = normalized.slice(m[0].length)
 820        }
 821        normalized = normalized.replace(/^[&.]\s+/, '') // & cmd, . cmd (dot-source)
 822        const rawFirst = normalized.split(/\s+/)[0] ?? ''
 823        const firstTok = rawFirst.replace(/^['"]|['"]$/g, '')
 824        const normalizedFrag = firstTok + normalized.slice(rawFirst.length)
 825        // SECURITY: parse-independent dangerous-removal hard-deny. The
 826        // isDangerousRemovalPath check in checkPathConstraintsForStatement
 827        // requires a valid AST; when pwsh times out or is unavailable,
 828        // `Remove-Item /` degrades from hard-deny to generic ask. Check
 829        // raw positional args here so root/home/system deletion is denied
 830        // regardless of parser availability. Conservative: only positional
 831        // args (skip -Param tokens); over-deny in degraded state is safe
 832        // (same deny-downgrade rationale as the sub-command scan above).
 833        if (resolveToCanonical(firstTok) === 'remove-item') {
 834          for (const arg of normalized.split(/\s+/).slice(1)) {
 835            if (PS_TOKENIZER_DASH_CHARS.has(arg[0] ?? '')) continue
 836            if (isDangerousRemovalRawPath(arg)) {
 837              return dangerousRemovalDeny(arg)
 838            }
 839          }
 840        }
 841        const { matchingDenyRules: fragDenyRules } = matchingRulesForInput(
 842          { command: normalizedFrag },
 843          toolPermissionContext,
 844          'prefix',
 845        )
 846        if (fragDenyRules[0] !== undefined) {
 847          return {
 848            behavior: 'deny',
 849            message: `Permission to use ${POWERSHELL_TOOL_NAME} with command ${command} has been denied.`,
 850            decisionReason: { type: 'rule', rule: fragDenyRules[0] },
 851          }
 852        }
 853      }
 854      // Preserve pre-parse ask messaging when parse fails. The deferred ask
 855      // (2b prefix rule or UNC) carries a better decisionReason than the
 856      // generic parse-error ask. Sub-command deny can't run the AST loop
 857      // without a parse, so the fallback scan above is best-effort.
 858      if (preParseAskDecision !== null) {
 859        return preParseAskDecision
 860      }
 861      const decisionReason = {
 862        type: 'other' as const,
 863        reason: `Command contains malformed syntax that cannot be parsed: ${parsed.errors[0]?.message ?? 'unknown error'}`,
 864      }
 865      return {
 866        behavior: 'ask',
 867        decisionReason,
 868        message: createPermissionRequestMessage(
 869          POWERSHELL_TOOL_NAME,
 870          decisionReason,
 871        ),
 872        // No suggestions - don't recommend persisting invalid syntax
 873      }
 874    }
 875  
 876    // ========================================================================
 877    // COLLECT-THEN-REDUCE: post-parse decisions (deny > ask > allow > passthrough)
 878    // ========================================================================
 879    // Ported from bashPermissions.ts:1446-1472. Every post-parse check pushes
 880    // its decision into a single array; a single reduce applies precedence.
 881    // This structurally closes the ask-before-deny bug class: an 'ask' from an
 882    // earlier check (security flags, provider paths, cd+git) can no longer mask
 883    // a 'deny' from a later check (sub-command deny, checkPathConstraints).
 884    //
 885    // Supersedes the firstSubCommandAskRule stash from commit 8f5ae6c56b — that
 886    // fix only patched step 4; steps 3, 3.5, 4.42 had the same flaw. The stash
 887    // pattern is also fragile: the next author who writes `return ask` is back
 888    // where we started. Collect-then-reduce makes the bypass impossible to write.
 889    //
 890    // First-of-each-behavior wins (array order = step order), so single-check
 891    // ask messages are unchanged vs. sequential-early-return.
 892    //
 893    // Pre-parse deny checks above (exact/prefix deny) stay sequential: they
 894    // fire even when pwsh is unavailable. Pre-parse asks (prefix ask, raw UNC)
 895    // are now deferred here so sub-command deny (step 4) beats them.
 896  
 897    // Gather sub-commands once (used by decisions 3, 4, and fallthrough step 5).
 898    const allSubCommands = await getSubCommandsForPermissionCheck(parsed, command)
 899  
 900    const decisions: PermissionResult[] = []
 901  
 902    // Decision: deferred pre-parse ask (2b prefix ask or UNC path).
 903    // Pushed first so its message wins over later asks (first-of-behavior wins),
 904    // but the reduce ensures any deny in decisions[] still beats it.
 905    if (preParseAskDecision !== null) {
 906      decisions.push(preParseAskDecision)
 907    }
 908  
 909    // Decision: security check — was step 3 (:630-650).
 910    // powershellCommandIsSafe returns 'ask' for subexpressions, script blocks,
 911    // encoded commands, download cradles, etc. Only 'ask' | 'passthrough'.
 912    const safetyResult = powershellCommandIsSafe(command, parsed)
 913    if (safetyResult.behavior !== 'passthrough') {
 914      const decisionReason: PermissionDecisionReason = {
 915        type: 'other' as const,
 916        reason:
 917          safetyResult.behavior === 'ask' && safetyResult.message
 918            ? safetyResult.message
 919            : 'This command contains patterns that could pose security risks and requires approval',
 920      }
 921      decisions.push({
 922        behavior: 'ask',
 923        message: createPermissionRequestMessage(
 924          POWERSHELL_TOOL_NAME,
 925          decisionReason,
 926        ),
 927        decisionReason,
 928        suggestions: suggestionForExactCommand(command),
 929      })
 930    }
 931  
 932    // Decision: using statements / script requirements — invisible to AST block walk.
 933    // `using module ./evil.psm1` loads and executes a module's top-level script body;
 934    // `using assembly ./evil.dll` loads a .NET assembly (module initializers run).
 935    // `#Requires -Modules <name>` triggers module loading from PSModulePath.
 936    // These are siblings of the named blocks on ScriptBlockAst, not children, so
 937    // Process-BlockStatements and all downstream command walkers never see them.
 938    // Without this check, a decoy cmdlet like Get-Process fills subCommands,
 939    // bypassing the empty-statement fallback, and isReadOnlyCommand auto-allows.
 940    if (parsed.hasUsingStatements) {
 941      const decisionReason: PermissionDecisionReason = {
 942        type: 'other' as const,
 943        reason:
 944          'Command contains a `using` statement that may load external code (module or assembly)',
 945      }
 946      decisions.push({
 947        behavior: 'ask',
 948        message: createPermissionRequestMessage(
 949          POWERSHELL_TOOL_NAME,
 950          decisionReason,
 951        ),
 952        decisionReason,
 953        suggestions: suggestionForExactCommand(command),
 954      })
 955    }
 956    if (parsed.hasScriptRequirements) {
 957      const decisionReason: PermissionDecisionReason = {
 958        type: 'other' as const,
 959        reason:
 960          'Command contains a `#Requires` directive that may trigger module loading',
 961      }
 962      decisions.push({
 963        behavior: 'ask',
 964        message: createPermissionRequestMessage(
 965          POWERSHELL_TOOL_NAME,
 966          decisionReason,
 967        ),
 968        decisionReason,
 969        suggestions: suggestionForExactCommand(command),
 970      })
 971    }
 972  
 973    // Decision: resolved-arg provider/UNC scan — was step 3.5 (:652-709).
 974    // Provider paths (env:, HKLM:, function:) access non-filesystem resources.
 975    // UNC paths can leak NTLM/Kerberos credentials on Windows. The raw-string
 976    // UNC check above (pre-parse) misses backtick-escaped forms; cmd.args has
 977    // backtick escapes resolved by the parser. Labeled loop breaks on FIRST
 978    // match (same as the previous early-return).
 979    // Provider prefix matches both the short form (`env:`, `HKLM:`) and the
 980    // fully-qualified form (`Microsoft.PowerShell.Core\Registry::HKLM\...`).
 981    // The optional `(?:[\w.]+\\)?` handles the module-qualified prefix; `::?`
 982    // matches either single-colon drive syntax or double-colon provider syntax.
 983    const NON_FS_PROVIDER_PATTERN =
 984      /^(?:[\w.]+\\)?(env|hklm|hkcu|function|alias|variable|cert|wsman|registry)::?/i
 985    function extractProviderPathFromArg(arg: string): string {
 986      // Handle colon parameter syntax: -Path:env:HOME → extract 'env:HOME'.
 987      // SECURITY: PowerShell's tokenizer accepts en-dash/em-dash/horizontal-bar
 988      // (U+2013/2014/2015) as parameter prefixes. `–Path:env:HOME` (en-dash)
 989      // must also strip the `–Path:` prefix or NON_FS_PROVIDER_PATTERN won't
 990      // match (pattern is `^(env|...):` which fails on `–Path:env:...`).
 991      let s = arg
 992      if (s.length > 0 && PS_TOKENIZER_DASH_CHARS.has(s[0]!)) {
 993        const colonIdx = s.indexOf(':', 1) // skip the leading dash
 994        if (colonIdx > 0) {
 995          s = s.substring(colonIdx + 1)
 996        }
 997      }
 998      // Strip backtick escapes before matching: `Registry`::HKLM\...` has a
 999      // backtick before `::` that the PS tokenizer removes at runtime but that
1000      // would otherwise prevent the ^-anchored pattern from matching.
1001      return s.replace(/`/g, '')
1002    }
1003    function providerOrUncDecisionForArg(arg: string): PermissionResult | null {
1004      const value = extractProviderPathFromArg(arg)
1005      if (NON_FS_PROVIDER_PATTERN.test(value)) {
1006        return {
1007          behavior: 'ask',
1008          message: `Command argument '${arg}' uses a non-filesystem provider path and requires approval`,
1009        }
1010      }
1011      if (containsVulnerableUncPath(value)) {
1012        return {
1013          behavior: 'ask',
1014          message: `Command argument '${arg}' contains a UNC path that could trigger network requests`,
1015        }
1016      }
1017      return null
1018    }
1019    providerScan: for (const statement of parsed.statements) {
1020      for (const cmd of statement.commands) {
1021        if (cmd.elementType !== 'CommandAst') continue
1022        for (const arg of cmd.args) {
1023          const decision = providerOrUncDecisionForArg(arg)
1024          if (decision !== null) {
1025            decisions.push(decision)
1026            break providerScan
1027          }
1028        }
1029      }
1030      if (statement.nestedCommands) {
1031        for (const cmd of statement.nestedCommands) {
1032          for (const arg of cmd.args) {
1033            const decision = providerOrUncDecisionForArg(arg)
1034            if (decision !== null) {
1035              decisions.push(decision)
1036              break providerScan
1037            }
1038          }
1039        }
1040      }
1041    }
1042  
1043    // Decision: per-sub-command deny/ask rules — was step 4 (:711-803).
1044    // Each sub-command produces at most one decision (deny or ask). Deny rules
1045    // on LATER sub-commands still beat ask rules on EARLIER ones via the reduce.
1046    // No stash needed — the reduce structurally enforces deny > ask.
1047    //
1048    // SECURITY: Always build a canonical command string from AST-derived data
1049    // (element.name + space-joined args) and check rules against it too. Deny
1050    // and allow must use the same normalized form to close asymmetries:
1051    //   - Invocation operators (`& 'Remove-Item' ./x`): raw text starts with `&`,
1052    //     splitting on whitespace yields the operator, not the cmdlet name.
1053    //   - Non-space whitespace (`rm\t./x`): raw prefix match uses `prefix + ' '`
1054    //     (literal space), but PowerShell accepts any whitespace separator.
1055    //     checkPermissionMode auto-allow (using AST cmd.name) WOULD match while
1056    //     deny-rule match on raw text would miss — a deny-rule bypass.
1057    //   - Module prefixes (`Microsoft.PowerShell.Management\Remove-Item`):
1058    //     element.name has the module prefix stripped.
1059    for (const { text: subCmd, element } of allSubCommands) {
1060      // element.name is quote-stripped at the parser (transformCommandAst) so
1061      // `& 'Invoke-Expression' 'x'` yields name='Invoke-Expression', not
1062      // "'Invoke-Expression'". canonicalSubCmd is built from the same stripped
1063      // name, so deny-rule prefix matching on `Invoke-Expression:*` hits.
1064      const canonicalSubCmd =
1065        element.name !== '' ? [element.name, ...element.args].join(' ') : null
1066  
1067      const subInput = { command: subCmd }
1068      const { matchingDenyRules: subDenyRules, matchingAskRules: subAskRules } =
1069        matchingRulesForInput(subInput, toolPermissionContext, 'prefix')
1070      let matchedDenyRule = subDenyRules[0]
1071      let matchedAskRule = subAskRules[0]
1072  
1073      if (matchedDenyRule === undefined && canonicalSubCmd !== null) {
1074        const {
1075          matchingDenyRules: canonicalDenyRules,
1076          matchingAskRules: canonicalAskRules,
1077        } = matchingRulesForInput(
1078          { command: canonicalSubCmd },
1079          toolPermissionContext,
1080          'prefix',
1081        )
1082        matchedDenyRule = canonicalDenyRules[0]
1083        if (matchedAskRule === undefined) {
1084          matchedAskRule = canonicalAskRules[0]
1085        }
1086      }
1087  
1088      if (matchedDenyRule !== undefined) {
1089        decisions.push({
1090          behavior: 'deny',
1091          message: `Permission to use ${POWERSHELL_TOOL_NAME} with command ${command} has been denied.`,
1092          decisionReason: {
1093            type: 'rule',
1094            rule: matchedDenyRule,
1095          },
1096        })
1097      } else if (matchedAskRule !== undefined) {
1098        decisions.push({
1099          behavior: 'ask',
1100          message: createPermissionRequestMessage(POWERSHELL_TOOL_NAME),
1101          decisionReason: {
1102            type: 'rule',
1103            rule: matchedAskRule,
1104          },
1105        })
1106      }
1107    }
1108  
1109    // Decision: cd+git compound guard — was step 4.42 (:805-833).
1110    // When cd/Set-Location is paired with git, don't allow without prompting —
1111    // cd to a malicious directory makes git dangerous (fake hooks, bare repo
1112    // attacks). Collect-then-reduce keeps the improvement over BashTool: in
1113    // bash, cd+git (B9, line 1416) runs BEFORE sub-command deny (B11), so cd+git
1114    // ask masks deny. Here, both are in the same decision array; deny wins.
1115    //
1116    // SECURITY: NO cd-to-CWD no-op exclusion. A previous iteration excluded
1117    // `Set-Location .` as a no-op, but the "first non-dash arg" heuristic used
1118    // to extract the target is fooled by colon-bound params:
1119    // `Set-Location -Path:/etc .` — real target is /etc, heuristic sees `.`,
1120    // exclusion fires, bypass. The UX case (model emitting `Set-Location .; foo`)
1121    // is rare; the attack surface isn't worth the special-case. Any cd-family
1122    // cmdlet in the compound sets this flag, period.
1123    // Only flag compound cd when there are multiple sub-commands. A standalone
1124    // `Set-Location ./subdir` is not a TOCTOU risk (no later statement resolves
1125    // relative paths against stale cwd). Without this, standalone cd forces the
1126    // compound guard, suppressing the per-subcommand auto-allow path. (bug #25)
1127    const hasCdSubCommand =
1128      allSubCommands.length > 1 &&
1129      allSubCommands.some(({ element }) => isCwdChangingCmdlet(element.name))
1130    // Symlink-create compound guard (finding #18 / bug 001+004): when the
1131    // compound creates a filesystem link, subsequent writes through that link
1132    // land outside the validator's view. Same TOCTOU shape as cwd desync.
1133    const hasSymlinkCreate =
1134      allSubCommands.length > 1 &&
1135      allSubCommands.some(({ element }) => isSymlinkCreatingCommand(element))
1136    const hasGitSubCommand = allSubCommands.some(
1137      ({ element }) => resolveToCanonical(element.name) === 'git',
1138    )
1139    if (hasCdSubCommand && hasGitSubCommand) {
1140      decisions.push({
1141        behavior: 'ask',
1142        message:
1143          'Compound commands with cd/Set-Location and git require approval to prevent bare repository attacks',
1144      })
1145    }
1146  
1147    // cd+write compound guard — SUBSUMED by checkPathConstraints(compoundCommandHasCd).
1148    // Previously this block pushed 'ask' when hasCdSubCommand && hasAcceptEditsWrite,
1149    // but checkPathConstraints now receives hasCdSubCommand and pushes 'ask' for ANY
1150    // path operation (read or write) in a cd-compound — broader coverage at the path
1151    // layer (BashTool parity). The step-5 !hasCdSubCommand gates and modeValidation's
1152    // compound-cd guard remain as defense-in-depth for paths that don't reach
1153    // checkPathConstraints (e.g., cmdlets not in CMDLET_PATH_CONFIG).
1154  
1155    // Decision: bare-git-repo guard — bash parity.
1156    // If cwd has HEAD/objects/refs/ without a valid .git/HEAD, Git treats
1157    // cwd as a bare repository and runs hooks from cwd. Attacker creates
1158    // hooks/pre-commit, deletes .git/HEAD, then any git subcommand runs it.
1159    // Port of BashTool readOnlyValidation.ts isCurrentDirectoryBareGitRepo.
1160    if (hasGitSubCommand && isCurrentDirectoryBareGitRepo()) {
1161      decisions.push({
1162        behavior: 'ask',
1163        message:
1164          'Git command in a directory with bare-repository indicators (HEAD, objects/, refs/ in cwd without .git/HEAD). Git may execute hooks from cwd.',
1165      })
1166    }
1167  
1168    // Decision: git-internal-paths write guard — bash parity.
1169    // Compound command creates HEAD/objects/refs/hooks/ then runs git → the
1170    // git subcommand executes freshly-created malicious hooks. Check all
1171    // extracted write paths + redirection targets against git-internal patterns.
1172    // Port of BashTool commandWritesToGitInternalPaths, adapted for AST.
1173    if (hasGitSubCommand) {
1174      const writesToGitInternal = allSubCommands.some(
1175        ({ element, statement }) => {
1176          // Redirection targets on this sub-command (raw Extent.Text — quotes
1177          // and ./ intact; normalizer handles both)
1178          for (const r of element.redirections ?? []) {
1179            if (isGitInternalPathPS(r.target)) return true
1180          }
1181          // Write cmdlet args (new-item HEAD; mkdir hooks; set-content hooks/pre-commit)
1182          const canonical = resolveToCanonical(element.name)
1183          if (!GIT_SAFETY_WRITE_CMDLETS.has(canonical)) return false
1184          // Raw arg text — normalizer strips colon-bound params, quotes, ./, case.
1185          // PS ArrayLiteralAst (`New-Item a,hooks/pre-commit`) surfaces as a single
1186          // comma-joined arg — split before checking.
1187          if (
1188            element.args
1189              .flatMap(a => a.split(','))
1190              .some(a => isGitInternalPathPS(a))
1191          ) {
1192            return true
1193          }
1194          // Pipeline input: `"hooks/pre-commit" | New-Item -ItemType File` binds the
1195          // string to -Path at runtime. The path is in a non-CommandAst pipeline
1196          // element, not in element.args. The hasExpressionSource guard at step 5
1197          // already forces approval here; this check just adds the git-internal
1198          // warning text.
1199          if (statement !== null) {
1200            for (const c of statement.commands) {
1201              if (c.elementType === 'CommandAst') continue
1202              if (isGitInternalPathPS(c.text)) return true
1203            }
1204          }
1205          return false
1206        },
1207      )
1208      // Also check top-level file redirections (> hooks/pre-commit)
1209      const redirWritesToGitInternal = getFileRedirections(parsed).some(r =>
1210        isGitInternalPathPS(r.target),
1211      )
1212      if (writesToGitInternal || redirWritesToGitInternal) {
1213        decisions.push({
1214          behavior: 'ask',
1215          message:
1216            'Command writes to a git-internal path (HEAD, objects/, refs/, hooks/, .git/) and runs git. This could plant a malicious hook that git then executes.',
1217        })
1218      }
1219      // SECURITY: Archive-extraction TOCTOU. isCurrentDirectoryBareGitRepo
1220      // checks at permission-eval time; `tar -xf x.tar; git status` extracts
1221      // bare-repo indicators AFTER the check, BEFORE git runs. Unlike write
1222      // cmdlets (where we inspect args for git-internal paths), archive
1223      // contents are opaque — any extraction in a compound with git must ask.
1224      const hasArchiveExtractor = allSubCommands.some(({ element }) =>
1225        GIT_SAFETY_ARCHIVE_EXTRACTORS.has(element.name.toLowerCase()),
1226      )
1227      if (hasArchiveExtractor) {
1228        decisions.push({
1229          behavior: 'ask',
1230          message:
1231            'Compound command extracts an archive and runs git. Archive contents may plant bare-repository indicators (HEAD, hooks/, refs/) that git then treats as the repository root.',
1232        })
1233      }
1234    }
1235  
1236    // .git/ writes are dangerous even WITHOUT a git subcommand — a planted
1237    // .git/hooks/pre-commit fires on the user's next commit. Unlike the
1238    // bare-repo check above (which gates on hasGitSubCommand because `hooks/`
1239    // is a common project dirname), `.git/` is unambiguous.
1240    {
1241      const found =
1242        allSubCommands.some(({ element }) => {
1243          for (const r of element.redirections ?? []) {
1244            if (isDotGitPathPS(r.target)) return true
1245          }
1246          const canonical = resolveToCanonical(element.name)
1247          if (!GIT_SAFETY_WRITE_CMDLETS.has(canonical)) return false
1248          return element.args.flatMap(a => a.split(',')).some(isDotGitPathPS)
1249        }) || getFileRedirections(parsed).some(r => isDotGitPathPS(r.target))
1250      if (found) {
1251        decisions.push({
1252          behavior: 'ask',
1253          message:
1254            'Command writes to .git/ — hooks or config planted there execute on the next git operation.',
1255        })
1256      }
1257    }
1258  
1259    // Decision: path constraints — was step 4.44 (:835-845).
1260    // The deny-capable check that was being masked by earlier asks. Returns
1261    // 'deny' when an Edit(...) deny rule matches an extracted path (pathValidation
1262    // lines ~994, 1088, 1160, 1210), 'ask' for paths outside working dirs, or
1263    // 'passthrough'.
1264    //
1265    // Thread hasCdSubCommand (BashTool compoundCommandHasCd parity): when the
1266    // compound contains a cwd-changing cmdlet, checkPathConstraints forces 'ask'
1267    // for any statement with path operations — relative paths resolve against the
1268    // stale validator cwd, not PowerShell's runtime cwd. This is the architectural
1269    // fix for the CWD-desync cluster (findings #3/#21/#27/#28), replacing the
1270    // per-auto-allow-site guards with a single gate at the path-resolution layer.
1271    const pathResult = checkPathConstraints(
1272      input,
1273      parsed,
1274      toolPermissionContext,
1275      hasCdSubCommand,
1276    )
1277    if (pathResult.behavior !== 'passthrough') {
1278      decisions.push(pathResult)
1279    }
1280  
1281    // Decision: exact allow (parse-succeeded case) — was step 4.45 (:861-867).
1282    // Matches BashTool ordering: sub-command deny → path constraints → exact
1283    // allow. Reduce enforces deny > ask > allow, so the exact allow only
1284    // surfaces when no deny or ask fired — same as sequential.
1285    //
1286    // SECURITY: nameType gate — mirrors the parse-failed guard at L696-700.
1287    // Input-side stripModulePrefix is unconditional: `scripts\Get-Content`
1288    // strips to `Get-Content`, canonicalCommand matches exact allow. Without
1289    // this gate, allow enters decisions[] and reduce returns it before step 5
1290    // can inspect nameType — PowerShell runs the local .ps1 file. The AST's
1291    // nameType for the first command element is authoritative when parse
1292    // succeeded; 'application' means a script/executable path, not a cmdlet.
1293    // SECURITY: Same argLeaksValue gate as the per-subcommand loop below
1294    // (finding #32). Without it, `PowerShell(Write-Output:*)` exact-matches
1295    // `Write-Output $env:ANTHROPIC_API_KEY`, pushes allow to decisions[], and
1296    // reduce returns it before the per-subcommand gate ever runs. The
1297    // allSubCommands.every check ensures NO command in the statement leaks
1298    // (a single-command exact-allow has one element; a pipeline has several).
1299    //
1300    // SECURITY: nameType gate must check ALL subcommands, not just [0]
1301    // (finding #10). canonicalCommand at L171 collapses `\n` → space, so
1302    // `code\n.\build.ps1` (two statements) matches exact rule
1303    // `PowerShell(code .\build.ps1)`. Checking only allSubCommands[0] lets the
1304    // second statement (nameType=application, a script path) through. Require
1305    // EVERY subcommand to have nameType !== 'application'.
1306    if (
1307      exactMatchResult.behavior === 'allow' &&
1308      allSubCommands[0] !== undefined &&
1309      allSubCommands.every(
1310        sc =>
1311          sc.element.nameType !== 'application' &&
1312          !argLeaksValue(sc.text, sc.element),
1313      )
1314    ) {
1315      decisions.push(exactMatchResult)
1316    }
1317  
1318    // Decision: read-only allowlist — was step 4.5 (:869-885).
1319    // Mirrors Bash auto-allow for ls, cat, git status, etc. PowerShell
1320    // equivalents: Get-Process, Get-ChildItem, Get-Content, git log, etc.
1321    // Reduce places this below sub-command ask rules (ask > allow).
1322    if (isReadOnlyCommand(command, parsed)) {
1323      decisions.push({
1324        behavior: 'allow',
1325        updatedInput: input,
1326        decisionReason: {
1327          type: 'other',
1328          reason: 'Command is read-only and safe to execute',
1329        },
1330      })
1331    }
1332  
1333    // Decision: file redirections — was :887-900.
1334    // Redirections (>, >>, 2>) write to arbitrary paths. isReadOnlyCommand
1335    // already rejects redirections internally so this can't conflict with the
1336    // read-only allow above. Reduce places it above checkPermissionMode allow.
1337    const fileRedirections = getFileRedirections(parsed)
1338    if (fileRedirections.length > 0) {
1339      decisions.push({
1340        behavior: 'ask',
1341        message:
1342          'Command contains file redirections that could write to arbitrary paths',
1343        suggestions: suggestionForExactCommand(command),
1344      })
1345    }
1346  
1347    // Decision: mode-specific handling (acceptEdits) — was step 4.7 (:902-906).
1348    // checkPermissionMode only returns 'allow' | 'passthrough'.
1349    const modeResult = checkPermissionMode(input, parsed, toolPermissionContext)
1350    if (modeResult.behavior !== 'passthrough') {
1351      decisions.push(modeResult)
1352    }
1353  
1354    // REDUCE: deny > ask > allow > passthrough. First of each behavior type
1355    // wins (preserves step-order messaging for single-check cases). If nothing
1356    // decided, fall through to step 5 per-sub-command approval collection.
1357    const deniedDecision = decisions.find(d => d.behavior === 'deny')
1358    if (deniedDecision !== undefined) {
1359      return deniedDecision
1360    }
1361    const askDecision = decisions.find(d => d.behavior === 'ask')
1362    if (askDecision !== undefined) {
1363      return askDecision
1364    }
1365    const allowDecision = decisions.find(d => d.behavior === 'allow')
1366    if (allowDecision !== undefined) {
1367      return allowDecision
1368    }
1369  
1370    // 5. Pipeline/statement splitting: check each sub-command independently.
1371    // This prevents a prefix rule like "Get-Process:*" from silently allowing
1372    // piped commands like "Get-Process | Stop-Process -Force".
1373    // Note: deny rules are already checked above (4.4), so this loop handles
1374    // ask rules, explicit allow rules, and read-only allowlist fallback.
1375  
1376    // Filter out safe output cmdlets (Format-Table, etc.) — they were checked
1377    // for deny rules in step 4.4 but shouldn't need independent approval here.
1378    // Also filter out cd/Set-Location to CWD (model habit, Bash parity).
1379    const subCommands = allSubCommands.filter(({ element, isSafeOutput }) => {
1380      if (isSafeOutput) {
1381        return false
1382      }
1383      // SECURITY: nameType gate — sixth location. Filtering out of the approval
1384      // list is a form of auto-allow. scripts\\Set-Location . would match below
1385      // (stripped name 'Set-Location', arg '.' → CWD) and be silently dropped,
1386      // then scripts\\Set-Location.ps1 executes with no prompt. Keep 'application'
1387      // commands in the list so they reach isAllowlistedCommand (which rejects them).
1388      if (element.nameType === 'application') {
1389        return true
1390      }
1391      const canonical = resolveToCanonical(element.name)
1392      if (canonical === 'set-location' && element.args.length > 0) {
1393        // SECURITY: use PS_TOKENIZER_DASH_CHARS, not ASCII-only startsWith('-').
1394        // `Set-Location –Path .` (en-dash) would otherwise treat `–Path` as the
1395        // target, resolve it against cwd (mismatch), and keep the command in the
1396        // approval list — correct. But `Set-Location –LiteralPath evil` with
1397        // en-dash would find `–LiteralPath` as "target", mismatch cwd, stay in
1398        // list — also correct. The risk is the inverse: a Unicode-dash parameter
1399        // being treated as the positional target. Use the tokenizer dash set.
1400        const target = element.args.find(
1401          a => a.length === 0 || !PS_TOKENIZER_DASH_CHARS.has(a[0]!),
1402        )
1403        if (target && resolve(getCwd(), target) === getCwd()) {
1404          return false
1405        }
1406      }
1407      return true
1408    })
1409  
1410    // Note: cd+git compound guard already ran at step 4.42. If we reach here,
1411    // either there's no cd or no git in the compound.
1412  
1413    const subCommandsNeedingApproval: string[] = []
1414    // Statements whose sub-commands were PUSHED to subCommandsNeedingApproval
1415    // in the step-5 loop below. The fail-closed gate (after the loop) only
1416    // pushes statements NOT tracked here — prevents duplicate suggestions where
1417    // both "Get-Process" (sub-command) AND "$x = Get-Process" (full statement)
1418    // appear.
1419    //
1420    // SECURITY: track on PUSH only, not on loop entry.
1421    // If a statement's only sub-commands `continue` via user allow rules
1422    // (L1113), marking it seen at loop-entry would make the fail-closed gate
1423    // skip it — auto-allowing invisible non-CommandAst content like bare
1424    // `$env:SECRET` inside control flow. Example attack: user approves
1425    // Get-Process, then `if ($true) { Get-Process; $env:SECRET }` — Get-Process
1426    // is allow-ruled (continue, no push), $env:SECRET is VariableExpressionAst
1427    // (not a sub-command), statement marked seen → gate skips → auto-allow →
1428    // secret leaks. Tracking on push only: statement stays unseen → gate fires
1429    // → ask.
1430    const statementsSeenInLoop = new Set<
1431      ParsedPowerShellCommand['statements'][number]
1432    >()
1433  
1434    for (const { text: subCmd, element, statement } of subCommands) {
1435      // Check deny rules FIRST - user explicit rules take precedence over allowlist
1436      const subInput = { command: subCmd }
1437      const subResult = powershellToolCheckPermission(
1438        subInput,
1439        toolPermissionContext,
1440      )
1441  
1442      if (subResult.behavior === 'deny') {
1443        return {
1444          behavior: 'deny',
1445          message: `Permission to use ${POWERSHELL_TOOL_NAME} with command ${command} has been denied.`,
1446          decisionReason: subResult.decisionReason,
1447        }
1448      }
1449  
1450      if (subResult.behavior === 'ask') {
1451        if (statement !== null) {
1452          statementsSeenInLoop.add(statement)
1453        }
1454        subCommandsNeedingApproval.push(subCmd)
1455        continue
1456      }
1457  
1458      // Explicitly allowed by a user rule — BUT NOT for applications/scripts.
1459      // SECURITY: INPUT-side stripModulePrefix is unconditional, so
1460      // `scripts\Get-Content /etc/shadow` strips to 'Get-Content' and matches
1461      // an allow rule `Get-Content:*`. Without the nameType guard, continue
1462      // skips all checks and the local script runs. nameType is classified from
1463      // the RAW name pre-strip — `scripts\Get-Content` → 'application' (has `\`).
1464      // Module-qualified cmdlets also classify 'application' — fail-safe over-fire.
1465      // An application should NEVER be auto-allowed by a cmdlet allow rule.
1466      if (
1467        subResult.behavior === 'allow' &&
1468        element.nameType !== 'application' &&
1469        !hasSymlinkCreate
1470      ) {
1471        // SECURITY: User allow rule asserts the cmdlet is safe, NOT that
1472        // arbitrary variable expansion through it is safe. A user who allows
1473        // PowerShell(Write-Output:*) did not intend to auto-allow
1474        // `Write-Output $env:ANTHROPIC_API_KEY`. Apply the same argLeaksValue
1475        // gate that protects the built-in allowlist path below — rejects
1476        // Variable/Other/ScriptBlock/SubExpression elementTypes and colon-bound
1477        // expression children. (security finding #32)
1478        //
1479        // SECURITY: Also skip when the compound contains a symlink-creating
1480        // command (finding — symlink+read gap). New-Item -ItemType SymbolicLink
1481        // can redirect subsequent reads to arbitrary paths. The built-in
1482        // allowlist path (below) and acceptEdits path both gate on
1483        // !hasSymlinkCreate; the user-rule path must too.
1484        if (argLeaksValue(subCmd, element)) {
1485          if (statement !== null) {
1486            statementsSeenInLoop.add(statement)
1487          }
1488          subCommandsNeedingApproval.push(subCmd)
1489          continue
1490        }
1491        continue
1492      }
1493      if (subResult.behavior === 'allow') {
1494        // nameType === 'application' with a matching allow rule: the rule was
1495        // written for a cmdlet, but this is a script/executable masquerading.
1496        // Don't continue; fall through to approval (NOT deny — the user may
1497        // actually want to run `scripts\Get-Content` and will see a prompt).
1498        if (statement !== null) {
1499          statementsSeenInLoop.add(statement)
1500        }
1501        subCommandsNeedingApproval.push(subCmd)
1502        continue
1503      }
1504  
1505      // SECURITY: fail-closed gate. Do NOT take the allowlist shortcut unless
1506      // the parent statement is a PipelineAst where every element is a
1507      // CommandAst. This subsumes the previous hasExpressionSource check
1508      // (expression sources are one way a statement fails the gate) and also
1509      // rejects assignments, chain operators, control flow, and any future
1510      // AST type by construction. Examples this blocks:
1511      //   'env:SECRET_API_KEY' | Get-Content  — CommandExpressionAst element
1512      //   $x = Get-Process                   — AssignmentStatementAst
1513      //   Get-Process && Get-Service         — PipelineChainAst
1514      // Explicit user allow rules (above) run before this gate but apply their
1515      // own argLeaksValue check; both paths now gate argument elementTypes.
1516      //
1517      // SECURITY: Also skip when the compound contains a cwd-changing cmdlet
1518      // (finding #27 — cd+read gap). isAllowlistedCommand validates Get-Content
1519      // in isolation, but `Set-Location ~; Get-Content ./.ssh/id_rsa` runs
1520      // Get-Content from ~, not from the validator's cwd. Path validation saw
1521      // /project/.ssh/id_rsa; runtime reads ~/.ssh/id_rsa. Same gate as the
1522      // checkPermissionMode call below and the checkPathConstraints threading.
1523      if (
1524        statement !== null &&
1525        !hasCdSubCommand &&
1526        !hasSymlinkCreate &&
1527        isProvablySafeStatement(statement) &&
1528        isAllowlistedCommand(element, subCmd)
1529      ) {
1530        continue
1531      }
1532  
1533      // Check per-sub-command acceptEdits mode (BashTool parity).
1534      // Delegate to checkPermissionMode on a single-statement AST so that ALL
1535      // of its guards apply: expression pipeline sources (non-CommandAst elements),
1536      // security flags (subexpressions, script blocks, assignments, splatting, etc.),
1537      // and the ACCEPT_EDITS_ALLOWED_CMDLETS allowlist. This keeps one source of
1538      // truth for what makes a statement safe in acceptEdits mode — any future
1539      // hardening of checkPermissionMode automatically applies here.
1540      //
1541      // Pass parsed.variables (not []) so splatting from any statement in the
1542      // compound command is visible. Conservative: if we can't tell which statement
1543      // a splatted variable affects, assume it affects all of them.
1544      //
1545      // SECURITY: Skip this auto-allow path when the compound contains a
1546      // cwd-changing command (Set-Location/Push-Location/Pop-Location). The
1547      // synthetic single-statement AST strips compound context, so
1548      // checkPermissionMode cannot see the cd in other statements. Without this
1549      // gate, `Set-Location ./.claude; Set-Content ./settings.json '...'` would
1550      // pass: Set-Content is checked in isolation, matches ACCEPT_EDITS_ALLOWED_CMDLETS,
1551      // and auto-allows — but PowerShell runs it from the changed cwd, writing to
1552      // .claude/settings.json (a Claude config file the path validator didn't check).
1553      // This matches BashTool's compoundCommandHasCd guard.
1554      if (statement !== null && !hasCdSubCommand && !hasSymlinkCreate) {
1555        const subModeResult = checkPermissionMode(
1556          { command: subCmd },
1557          {
1558            valid: true,
1559            errors: [],
1560            variables: parsed.variables,
1561            hasStopParsing: parsed.hasStopParsing,
1562            originalCommand: subCmd,
1563            statements: [statement],
1564          },
1565          toolPermissionContext,
1566        )
1567        if (subModeResult.behavior === 'allow') {
1568          continue
1569        }
1570      }
1571  
1572      // Not allowlisted, no mode auto-allow, and no explicit rule — needs approval
1573      if (statement !== null) {
1574        statementsSeenInLoop.add(statement)
1575      }
1576      subCommandsNeedingApproval.push(subCmd)
1577    }
1578  
1579    // SECURITY: fail-closed gate (second half). The step-5 loop above only
1580    // iterates sub-commands that getSubCommandsForPermissionCheck surfaced
1581    // AND survived the safe-output filter. Statements that produce zero
1582    // CommandAst sub-commands (bare $env:SECRET) or whose only sub-commands
1583    // were filtered as safe-output ($env:X | Out-String) never enter the loop.
1584    // Without this, they silently auto-allow on empty subCommandsNeedingApproval.
1585    //
1586    // Only push statements NOT tracked above: if the loop PUSHED any
1587    // sub-command from a statement, the user will see a prompt. Pushing the
1588    // statement text too creates a duplicate suggestion where accepting the
1589    // sub-command rule does not prevent re-prompting.
1590    // If all sub-commands `continue`d (allow-ruled / allowlisted / mode-allowed)
1591    // the statement is NOT tracked and the gate re-checks it below — this is
1592    // the fail-closed property.
1593    for (const stmt of parsed.statements) {
1594      if (!isProvablySafeStatement(stmt) && !statementsSeenInLoop.has(stmt)) {
1595        subCommandsNeedingApproval.push(stmt.text)
1596      }
1597    }
1598  
1599    if (subCommandsNeedingApproval.length === 0) {
1600      // SECURITY: empty-list auto-allow is only safe when there's nothing
1601      // unverifiable. If the pipeline has script blocks, every safe-output
1602      // cmdlet was filtered at :1032, but the block content wasn't verified —
1603      // non-command AST nodes (AssignmentStatementAst etc.) are invisible to
1604      // getAllCommands. `Where-Object {$true} | Sort-Object {$env:PATH='evil'}`
1605      // would auto-allow here. hasAssignments is top-level-only (parser.ts:1385)
1606      // so it doesn't catch nested assignments either. Prompt instead.
1607      if (deriveSecurityFlags(parsed).hasScriptBlocks) {
1608        return {
1609          behavior: 'ask',
1610          message: createPermissionRequestMessage(POWERSHELL_TOOL_NAME),
1611          decisionReason: {
1612            type: 'other',
1613            reason:
1614              'Pipeline consists of output-formatting cmdlets with script blocks — block content cannot be verified',
1615          },
1616        }
1617      }
1618      return {
1619        behavior: 'allow',
1620        updatedInput: input,
1621        decisionReason: {
1622          type: 'other',
1623          reason: 'All pipeline commands are individually allowed',
1624        },
1625      }
1626    }
1627  
1628    // 6. Some sub-commands need approval — build suggestions
1629    const decisionReason = {
1630      type: 'other' as const,
1631      reason: 'This command requires approval',
1632    }
1633  
1634    const pendingSuggestions: PermissionUpdate[] = []
1635    for (const subCmd of subCommandsNeedingApproval) {
1636      pendingSuggestions.push(...suggestionForExactCommand(subCmd))
1637    }
1638  
1639    return {
1640      behavior: 'passthrough',
1641      message: createPermissionRequestMessage(
1642        POWERSHELL_TOOL_NAME,
1643        decisionReason,
1644      ),
1645      decisionReason,
1646      suggestions: pendingSuggestions,
1647    }
1648  }