/ tools / PowerShellTool / modeValidation.ts
modeValidation.ts
  1  /**
  2   * PowerShell permission mode validation.
  3   *
  4   * Checks if commands should be auto-allowed based on the current permission mode.
  5   * In acceptEdits mode, filesystem-modifying PowerShell cmdlets are auto-allowed.
  6   * Follows the same patterns as BashTool/modeValidation.ts.
  7   */
  8  
  9  import type { ToolPermissionContext } from '../../Tool.js'
 10  import type { PermissionResult } from '../../utils/permissions/PermissionResult.js'
 11  import type { ParsedPowerShellCommand } from '../../utils/powershell/parser.js'
 12  import {
 13    deriveSecurityFlags,
 14    getPipelineSegments,
 15    PS_TOKENIZER_DASH_CHARS,
 16  } from '../../utils/powershell/parser.js'
 17  import {
 18    argLeaksValue,
 19    isAllowlistedPipelineTail,
 20    isCwdChangingCmdlet,
 21    isSafeOutputCommand,
 22    resolveToCanonical,
 23  } from './readOnlyValidation.js'
 24  
 25  /**
 26   * Filesystem-modifying cmdlets that are auto-allowed in acceptEdits mode.
 27   * Stored as canonical (lowercase) cmdlet names.
 28   *
 29   * Tier 3 cmdlets with complex parameter binding removed — they fall through to
 30   * 'ask'. Only simple write cmdlets (first positional = -Path) are auto-allowed
 31   * here, and they get path validation via CMDLET_PATH_CONFIG in pathValidation.ts.
 32   */
 33  const ACCEPT_EDITS_ALLOWED_CMDLETS = new Set([
 34    'set-content',
 35    'add-content',
 36    'remove-item',
 37    'clear-content',
 38  ])
 39  
 40  function isAcceptEditsAllowedCmdlet(name: string): boolean {
 41    // resolveToCanonical handles aliases via COMMON_ALIASES, so e.g. 'rm' → 'remove-item',
 42    // 'ac' → 'add-content'. Any alias that resolves to an allowed cmdlet is automatically
 43    // allowed. Tier 3 cmdlets (new-item, copy-item, move-item, etc.) and their aliases
 44    // (mkdir, ni, cp, mv, etc.) resolve to cmdlets NOT in the set and fall through to 'ask'.
 45    const canonical = resolveToCanonical(name)
 46    return ACCEPT_EDITS_ALLOWED_CMDLETS.has(canonical)
 47  }
 48  
 49  /**
 50   * New-Item -ItemType values that create filesystem links (reparse points or
 51   * hard links). All three redirect path resolution at runtime — symbolic links
 52   * and junctions are directory/file reparse points; hard links alias a file's
 53   * inode. Any of these let a later relative-path write land outside the
 54   * validator's view.
 55   */
 56  const LINK_ITEM_TYPES = new Set(['symboliclink', 'junction', 'hardlink'])
 57  
 58  /**
 59   * Check if a lowered, dash-normalized arg (colon-value stripped) is an
 60   * unambiguous PowerShell abbreviation of New-Item's -ItemType or -Type param.
 61   * Min prefixes: `-it` (avoids ambiguity with other New-Item params), `-ty`
 62   * (avoids `-t` colliding with `-Target`).
 63   */
 64  function isItemTypeParamAbbrev(p: string): boolean {
 65    return (
 66      (p.length >= 3 && '-itemtype'.startsWith(p)) ||
 67      (p.length >= 3 && '-type'.startsWith(p))
 68    )
 69  }
 70  
 71  /**
 72   * Detects New-Item creating a filesystem link (-ItemType SymbolicLink /
 73   * Junction / HardLink, or the -Type alias). Links poison subsequent path
 74   * resolution the same way Set-Location/New-PSDrive do: a relative path
 75   * through the link resolves to the link target, not the validator's view.
 76   * Finding #18.
 77   *
 78   * Handles PS parameter abbreviation (`-it`, `-ite`, ... `-itemtype`; `-ty`,
 79   * `-typ`, `-type`), unicode dash prefixes (en-dash/em-dash/horizontal-bar),
 80   * and colon-bound values (`-it:Junction`).
 81   */
 82  export function isSymlinkCreatingCommand(cmd: {
 83    name: string
 84    args: string[]
 85  }): boolean {
 86    const canonical = resolveToCanonical(cmd.name)
 87    if (canonical !== 'new-item') return false
 88    for (let i = 0; i < cmd.args.length; i++) {
 89      const raw = cmd.args[i] ?? ''
 90      if (raw.length === 0) continue
 91      // Normalize unicode dash prefixes (–, —, ―) and forward-slash (PS 5.1
 92      // parameter prefix) → ASCII `-` so prefix comparison works. PS tokenizer
 93      // treats all four dash chars plus `/` as parameter markers. (bug #26)
 94      const normalized =
 95        PS_TOKENIZER_DASH_CHARS.has(raw[0]!) || raw[0] === '/'
 96          ? '-' + raw.slice(1)
 97          : raw
 98      const lower = normalized.toLowerCase()
 99      // Split colon-bound value: -it:SymbolicLink → param='-it', val='symboliclink'
100      const colonIdx = lower.indexOf(':', 1)
101      const paramRaw = colonIdx > 0 ? lower.slice(0, colonIdx) : lower
102      // Strip backtick escapes: -Item`Type → -ItemType (bug #22)
103      const param = paramRaw.replace(/`/g, '')
104      if (!isItemTypeParamAbbrev(param)) continue
105      const rawVal =
106        colonIdx > 0
107          ? lower.slice(colonIdx + 1)
108          : (cmd.args[i + 1]?.toLowerCase() ?? '')
109      // Strip backtick escapes from colon-bound value: -it:Sym`bolicLink → symboliclink
110      // Mirrors the param-name strip at L103. Space-separated args use .value
111      // (backtick-resolved by .NET parser), but colon-bound uses .text (raw source).
112      // Strip surrounding quotes: -it:'SymbolicLink' or -it:"Junction" (bug #6)
113      const val = rawVal.replace(/`/g, '').replace(/^['"]|['"]$/g, '')
114      if (LINK_ITEM_TYPES.has(val)) return true
115    }
116    return false
117  }
118  
119  /**
120   * Checks if commands should be handled differently based on the current permission mode.
121   *
122   * In acceptEdits mode, auto-allows filesystem-modifying PowerShell cmdlets.
123   * Uses the AST to resolve aliases before checking the allowlist.
124   *
125   * @param input - The PowerShell command input
126   * @param parsed - The parsed AST of the command
127   * @param toolPermissionContext - Context containing mode and permissions
128   * @returns
129   * - 'allow' if the current mode permits auto-approval
130   * - 'passthrough' if no mode-specific handling applies
131   */
132  export function checkPermissionMode(
133    input: { command: string },
134    parsed: ParsedPowerShellCommand,
135    toolPermissionContext: ToolPermissionContext,
136  ): PermissionResult {
137    // Skip bypass and dontAsk modes (handled elsewhere)
138    if (
139      toolPermissionContext.mode === 'bypassPermissions' ||
140      toolPermissionContext.mode === 'dontAsk'
141    ) {
142      return {
143        behavior: 'passthrough',
144        message: 'Mode is handled in main permission flow',
145      }
146    }
147  
148    if (toolPermissionContext.mode !== 'acceptEdits') {
149      return {
150        behavior: 'passthrough',
151        message: 'No mode-specific validation required',
152      }
153    }
154  
155    // acceptEdits mode: check if all commands are filesystem-modifying cmdlets
156    if (!parsed.valid) {
157      return {
158        behavior: 'passthrough',
159        message: 'Cannot validate mode for unparsed command',
160      }
161    }
162  
163    // SECURITY: Check for subexpressions, script blocks, or member invocations
164    // that could be used to smuggle arbitrary code through acceptEdits mode.
165    const securityFlags = deriveSecurityFlags(parsed)
166    if (
167      securityFlags.hasSubExpressions ||
168      securityFlags.hasScriptBlocks ||
169      securityFlags.hasMemberInvocations ||
170      securityFlags.hasSplatting ||
171      securityFlags.hasAssignments ||
172      securityFlags.hasStopParsing ||
173      securityFlags.hasExpandableStrings
174    ) {
175      return {
176        behavior: 'passthrough',
177        message:
178          'Command contains subexpressions, script blocks, or member invocations that require approval',
179      }
180    }
181  
182    const segments = getPipelineSegments(parsed)
183  
184    // SECURITY: Empty segments with valid parse = no commands to check, don't auto-allow
185    if (segments.length === 0) {
186      return {
187        behavior: 'passthrough',
188        message: 'No commands found to validate for acceptEdits mode',
189      }
190    }
191  
192    // SECURITY: Compound cwd desync guard — BashTool parity.
193    // When any statement in a compound contains Set-Location/Push-Location/Pop-Location
194    // (or aliases like cd, sl, chdir, pushd, popd), the cwd changes between statements.
195    // Path validation resolves relative paths against the stale process cwd, so a write
196    // cmdlet in a later statement targets a different directory than the validator checked.
197    // Example: `Set-Location ./.claude; Set-Content ./settings.json '...'` — the validator
198    // sees ./settings.json as /project/settings.json, but PowerShell writes to
199    // /project/.claude/settings.json. Refuse to auto-allow any write operation in a
200    // compound that contains a cwd-changing command. This matches BashTool's
201    // compoundCommandHasCd guard (BashTool/pathValidation.ts:630-655).
202    const totalCommands = segments.reduce(
203      (sum, seg) => sum + seg.commands.length,
204      0,
205    )
206    if (totalCommands > 1) {
207      let hasCdCommand = false
208      let hasSymlinkCreate = false
209      let hasWriteCommand = false
210      for (const seg of segments) {
211        for (const cmd of seg.commands) {
212          if (cmd.elementType !== 'CommandAst') continue
213          if (isCwdChangingCmdlet(cmd.name)) hasCdCommand = true
214          if (isSymlinkCreatingCommand(cmd)) hasSymlinkCreate = true
215          if (isAcceptEditsAllowedCmdlet(cmd.name)) hasWriteCommand = true
216        }
217      }
218      if (hasCdCommand && hasWriteCommand) {
219        return {
220          behavior: 'passthrough',
221          message:
222            'Compound command contains a directory-changing command (Set-Location/Push-Location/Pop-Location) with a write operation — cannot auto-allow because path validation uses stale cwd',
223        }
224      }
225      // SECURITY: Link-create compound guard (finding #18). Mirrors the cd
226      // guard above. `New-Item -ItemType SymbolicLink -Path ./link -Value /etc;
227      // Get-Content ./link/passwd` — path validation resolves ./link/passwd
228      // against cwd (no link there at validation time), but runtime follows
229      // the just-created link to /etc/passwd. Same TOCTOU shape as cwd desync.
230      // Applies to SymbolicLink, Junction, and HardLink — all three redirect
231      // path resolution at runtime.
232      // No `hasWriteCommand` requirement: read-through-symlink is equally
233      // dangerous (exfil via Get-Content ./link/etc/shadow), and any other
234      // command using paths after a just-created link is unvalidatable.
235      if (hasSymlinkCreate) {
236        return {
237          behavior: 'passthrough',
238          message:
239            'Compound command creates a filesystem link (New-Item -ItemType SymbolicLink/Junction/HardLink) — cannot auto-allow because path validation cannot follow just-created links',
240        }
241      }
242    }
243  
244    for (const segment of segments) {
245      for (const cmd of segment.commands) {
246        if (cmd.elementType !== 'CommandAst') {
247          // SECURITY: This guard is load-bearing for THREE cases. Do not narrow it.
248          //
249          // 1. Expression pipeline sources (designed): '/etc/passwd' | Remove-Item
250          //    — the string literal is CommandExpressionAst, piped value binds to
251          //    -Path. We cannot statically know what path it represents.
252          //
253          // 2. Control-flow statements (accidental but relied upon):
254          //    foreach ($x in ...) { Remove-Item $x }. Non-PipelineAst statements
255          //    produce a synthetic CommandExpressionAst entry in segment.commands
256          //    (parser.ts transformStatement). Without this guard, Remove-Item $x
257          //    in nestedCommands would be checked below and auto-allowed — but $x
258          //    is a loop-bound variable we cannot validate.
259          //
260          // 3. Non-PipelineAst redirection coverage (accidental): cmd && cmd2 > /tmp
261          //    also produces a synthetic element here. isReadOnlyCommand relies on
262          //    the same accident (its allowlist rejects the synthetic element's
263          //    full-text name), so both paths fail safe together.
264          return {
265            behavior: 'passthrough',
266            message: `Pipeline contains expression source (${cmd.elementType}) that cannot be statically validated`,
267          }
268        }
269        // SECURITY: nameType is computed from the raw name before stripModulePrefix.
270        // 'application' = raw name had path chars (. \\ /). scripts\\Remove-Item
271        // strips to Remove-Item and would match ACCEPT_EDITS_ALLOWED_CMDLETS below,
272        // but PowerShell runs scripts\\Remove-Item.ps1. Same gate as isAllowlistedCommand.
273        if (cmd.nameType === 'application') {
274          return {
275            behavior: 'passthrough',
276            message: `Command '${cmd.name}' resolved from a path-like name and requires approval`,
277          }
278        }
279        // SECURITY: elementTypes whitelist — same as isAllowlistedCommand.
280        // deriveSecurityFlags above checks hasSubExpressions/etc. but does NOT
281        // flag bare Variable/Other elementTypes. `Remove-Item $env:PATH`:
282        //   elementTypes = ['StringConstant', 'Variable']
283        //   deriveSecurityFlags: no subexpression → passes
284        //   checkPathConstraints: resolves literal text '$env:PATH' as relative
285        //     path → cwd/$env:PATH → inside cwd → allow
286        //   RUNTIME: PowerShell expands $env:PATH → deletes actual env value path
287        // isAllowlistedCommand rejects non-StringConstant/Parameter; this is the
288        // acceptEdits parity gate.
289        //
290        // Also check colon-bound expression metachars (same as isAllowlistedCommand's
291        // colon-bound check). `Remove-Item -Path:(1 > /tmp/x)`:
292        //   elementTypes = ['StringConstant', 'Parameter'] — passes whitelist above
293        //   deriveSecurityFlags: ParenExpressionAst in .Argument not detected by
294        //     Get-SecurityPatterns (ParenExpressionAst not in FindAll filter)
295        //   checkPathConstraints: literal text '-Path:(1 > /tmp/x)' not a path
296        //   RUNTIME: paren evaluates, redirection writes /tmp/x → arbitrary write
297        if (cmd.elementTypes) {
298          for (let i = 1; i < cmd.elementTypes.length; i++) {
299            const t = cmd.elementTypes[i]
300            if (t !== 'StringConstant' && t !== 'Parameter') {
301              return {
302                behavior: 'passthrough',
303                message: `Command argument has unvalidatable type (${t}) — variable paths cannot be statically resolved`,
304              }
305            }
306            if (t === 'Parameter') {
307              // elementTypes[i] ↔ args[i-1] (elementTypes[0] is the command name).
308              const arg = cmd.args[i - 1] ?? ''
309              const colonIdx = arg.indexOf(':')
310              if (colonIdx > 0 && /[$(@{[]/.test(arg.slice(colonIdx + 1))) {
311                return {
312                  behavior: 'passthrough',
313                  message:
314                    'Colon-bound parameter contains an expression that cannot be statically validated',
315                }
316              }
317            }
318          }
319        }
320        // Safe output cmdlets (Out-Null, etc.) and allowlisted pipeline-tail
321        // transformers (Format-*, Measure-Object, Select-Object, etc.) don't
322        // affect the semantics of the preceding command. Skip them so
323        // `Remove-Item ./foo | Out-Null` or `Set-Content ./foo hi | Format-Table`
324        // auto-allows the same as the bare write cmdlet. isAllowlistedPipelineTail
325        // is the narrow fallback for cmdlets moved from SAFE_OUTPUT_CMDLETS to
326        // CMDLET_ALLOWLIST (argLeaksValue validates their args).
327        if (
328          isSafeOutputCommand(cmd.name) ||
329          isAllowlistedPipelineTail(cmd, input.command)
330        ) {
331          continue
332        }
333        if (!isAcceptEditsAllowedCmdlet(cmd.name)) {
334          return {
335            behavior: 'passthrough',
336            message: `No mode-specific handling for '${cmd.name}' in acceptEdits mode`,
337          }
338        }
339        // SECURITY: Reject commands with unclassifiable argument types. 'Other'
340        // covers HashtableAst, ConvertExpressionAst, BinaryExpressionAst — all
341        // can contain nested redirections or code that the parser cannot fully
342        // decompose. isAllowlistedCommand (readOnlyValidation.ts) already
343        // enforces this whitelist via argLeaksValue; this closes the same gap
344        // in acceptEdits mode. Without this, @{k='payload' > ~/.bashrc} as a
345        // -Value argument passes because HashtableAst maps to 'Other'.
346        // argLeaksValue also catches colon-bound variables (-Flag:$env:SECRET).
347        if (argLeaksValue(cmd.name, cmd)) {
348          return {
349            behavior: 'passthrough',
350            message: `Arguments in '${cmd.name}' cannot be statically validated in acceptEdits mode`,
351          }
352        }
353      }
354  
355      // Also check nested commands from control flow statements
356      if (segment.nestedCommands) {
357        for (const cmd of segment.nestedCommands) {
358          if (cmd.elementType !== 'CommandAst') {
359            // SECURITY: Same as above — non-CommandAst element in nested commands
360            // (control flow bodies) cannot be statically validated as a path source.
361            return {
362              behavior: 'passthrough',
363              message: `Nested expression element (${cmd.elementType}) cannot be statically validated`,
364            }
365          }
366          if (cmd.nameType === 'application') {
367            return {
368              behavior: 'passthrough',
369              message: `Nested command '${cmd.name}' resolved from a path-like name and requires approval`,
370            }
371          }
372          if (
373            isSafeOutputCommand(cmd.name) ||
374            isAllowlistedPipelineTail(cmd, input.command)
375          ) {
376            continue
377          }
378          if (!isAcceptEditsAllowedCmdlet(cmd.name)) {
379            return {
380              behavior: 'passthrough',
381              message: `No mode-specific handling for '${cmd.name}' in acceptEdits mode`,
382            }
383          }
384          // SECURITY: Same argLeaksValue check as the main command loop above.
385          if (argLeaksValue(cmd.name, cmd)) {
386            return {
387              behavior: 'passthrough',
388              message: `Arguments in nested '${cmd.name}' cannot be statically validated in acceptEdits mode`,
389            }
390          }
391        }
392      }
393    }
394  
395    // All commands are filesystem-modifying cmdlets -- auto-allow
396    return {
397      behavior: 'allow',
398      updatedInput: input,
399      decisionReason: {
400        type: 'mode',
401        mode: 'acceptEdits',
402      },
403    }
404  }