405 lines
17 KiB
TypeScript
405 lines
17 KiB
TypeScript
/**
|
||
* PowerShell permission mode validation.
|
||
*
|
||
* Checks if commands should be auto-allowed based on the current permission mode.
|
||
* In acceptEdits mode, filesystem-modifying PowerShell cmdlets are auto-allowed.
|
||
* Follows the same patterns as BashTool/modeValidation.ts.
|
||
*/
|
||
|
||
import type { ToolPermissionContext } from '../../Tool.js'
|
||
import type { PermissionResult } from '../../utils/permissions/PermissionResult.js'
|
||
import type { ParsedPowerShellCommand } from '../../utils/powershell/parser.js'
|
||
import {
|
||
deriveSecurityFlags,
|
||
getPipelineSegments,
|
||
PS_TOKENIZER_DASH_CHARS,
|
||
} from '../../utils/powershell/parser.js'
|
||
import {
|
||
argLeaksValue,
|
||
isAllowlistedPipelineTail,
|
||
isCwdChangingCmdlet,
|
||
isSafeOutputCommand,
|
||
resolveToCanonical,
|
||
} from './readOnlyValidation.js'
|
||
|
||
/**
|
||
* Filesystem-modifying cmdlets that are auto-allowed in acceptEdits mode.
|
||
* Stored as canonical (lowercase) cmdlet names.
|
||
*
|
||
* Tier 3 cmdlets with complex parameter binding removed — they fall through to
|
||
* 'ask'. Only simple write cmdlets (first positional = -Path) are auto-allowed
|
||
* here, and they get path validation via CMDLET_PATH_CONFIG in pathValidation.ts.
|
||
*/
|
||
const ACCEPT_EDITS_ALLOWED_CMDLETS = new Set([
|
||
'set-content',
|
||
'add-content',
|
||
'remove-item',
|
||
'clear-content',
|
||
])
|
||
|
||
function isAcceptEditsAllowedCmdlet(name: string): boolean {
|
||
// resolveToCanonical handles aliases via COMMON_ALIASES, so e.g. 'rm' → 'remove-item',
|
||
// 'ac' → 'add-content'. Any alias that resolves to an allowed cmdlet is automatically
|
||
// allowed. Tier 3 cmdlets (new-item, copy-item, move-item, etc.) and their aliases
|
||
// (mkdir, ni, cp, mv, etc.) resolve to cmdlets NOT in the set and fall through to 'ask'.
|
||
const canonical = resolveToCanonical(name)
|
||
return ACCEPT_EDITS_ALLOWED_CMDLETS.has(canonical)
|
||
}
|
||
|
||
/**
|
||
* New-Item -ItemType values that create filesystem links (reparse points or
|
||
* hard links). All three redirect path resolution at runtime — symbolic links
|
||
* and junctions are directory/file reparse points; hard links alias a file's
|
||
* inode. Any of these let a later relative-path write land outside the
|
||
* validator's view.
|
||
*/
|
||
const LINK_ITEM_TYPES = new Set(['symboliclink', 'junction', 'hardlink'])
|
||
|
||
/**
|
||
* Check if a lowered, dash-normalized arg (colon-value stripped) is an
|
||
* unambiguous PowerShell abbreviation of New-Item's -ItemType or -Type param.
|
||
* Min prefixes: `-it` (avoids ambiguity with other New-Item params), `-ty`
|
||
* (avoids `-t` colliding with `-Target`).
|
||
*/
|
||
function isItemTypeParamAbbrev(p: string): boolean {
|
||
return (
|
||
(p.length >= 3 && '-itemtype'.startsWith(p)) ||
|
||
(p.length >= 3 && '-type'.startsWith(p))
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Detects New-Item creating a filesystem link (-ItemType SymbolicLink /
|
||
* Junction / HardLink, or the -Type alias). Links poison subsequent path
|
||
* resolution the same way Set-Location/New-PSDrive do: a relative path
|
||
* through the link resolves to the link target, not the validator's view.
|
||
* Finding #18.
|
||
*
|
||
* Handles PS parameter abbreviation (`-it`, `-ite`, ... `-itemtype`; `-ty`,
|
||
* `-typ`, `-type`), unicode dash prefixes (en-dash/em-dash/horizontal-bar),
|
||
* and colon-bound values (`-it:Junction`).
|
||
*/
|
||
export function isSymlinkCreatingCommand(cmd: {
|
||
name: string
|
||
args: string[]
|
||
}): boolean {
|
||
const canonical = resolveToCanonical(cmd.name)
|
||
if (canonical !== 'new-item') return false
|
||
for (let i = 0; i < cmd.args.length; i++) {
|
||
const raw = cmd.args[i] ?? ''
|
||
if (raw.length === 0) continue
|
||
// Normalize unicode dash prefixes (–, —, ―) and forward-slash (PS 5.1
|
||
// parameter prefix) → ASCII `-` so prefix comparison works. PS tokenizer
|
||
// treats all four dash chars plus `/` as parameter markers. (bug #26)
|
||
const normalized =
|
||
PS_TOKENIZER_DASH_CHARS.has(raw[0]!) || raw[0] === '/'
|
||
? '-' + raw.slice(1)
|
||
: raw
|
||
const lower = normalized.toLowerCase()
|
||
// Split colon-bound value: -it:SymbolicLink → param='-it', val='symboliclink'
|
||
const colonIdx = lower.indexOf(':', 1)
|
||
const paramRaw = colonIdx > 0 ? lower.slice(0, colonIdx) : lower
|
||
// Strip backtick escapes: -Item`Type → -ItemType (bug #22)
|
||
const param = paramRaw.replace(/`/g, '')
|
||
if (!isItemTypeParamAbbrev(param)) continue
|
||
const rawVal =
|
||
colonIdx > 0
|
||
? lower.slice(colonIdx + 1)
|
||
: (cmd.args[i + 1]?.toLowerCase() ?? '')
|
||
// Strip backtick escapes from colon-bound value: -it:Sym`bolicLink → symboliclink
|
||
// Mirrors the param-name strip at L103. Space-separated args use .value
|
||
// (backtick-resolved by .NET parser), but colon-bound uses .text (raw source).
|
||
// Strip surrounding quotes: -it:'SymbolicLink' or -it:"Junction" (bug #6)
|
||
const val = rawVal.replace(/`/g, '').replace(/^['"]|['"]$/g, '')
|
||
if (LINK_ITEM_TYPES.has(val)) return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
/**
|
||
* Checks if commands should be handled differently based on the current permission mode.
|
||
*
|
||
* In acceptEdits mode, auto-allows filesystem-modifying PowerShell cmdlets.
|
||
* Uses the AST to resolve aliases before checking the allowlist.
|
||
*
|
||
* @param input - The PowerShell command input
|
||
* @param parsed - The parsed AST of the command
|
||
* @param toolPermissionContext - Context containing mode and permissions
|
||
* @returns
|
||
* - 'allow' if the current mode permits auto-approval
|
||
* - 'passthrough' if no mode-specific handling applies
|
||
*/
|
||
export function checkPermissionMode(
|
||
input: { command: string },
|
||
parsed: ParsedPowerShellCommand,
|
||
toolPermissionContext: ToolPermissionContext,
|
||
): PermissionResult {
|
||
// Skip bypass and dontAsk modes (handled elsewhere)
|
||
if (
|
||
toolPermissionContext.mode === 'bypassPermissions' ||
|
||
toolPermissionContext.mode === 'dontAsk'
|
||
) {
|
||
return {
|
||
behavior: 'passthrough',
|
||
message: 'Mode is handled in main permission flow',
|
||
}
|
||
}
|
||
|
||
if (toolPermissionContext.mode !== 'acceptEdits') {
|
||
return {
|
||
behavior: 'passthrough',
|
||
message: 'No mode-specific validation required',
|
||
}
|
||
}
|
||
|
||
// acceptEdits mode: check if all commands are filesystem-modifying cmdlets
|
||
if (!parsed.valid) {
|
||
return {
|
||
behavior: 'passthrough',
|
||
message: 'Cannot validate mode for unparsed command',
|
||
}
|
||
}
|
||
|
||
// SECURITY: Check for subexpressions, script blocks, or member invocations
|
||
// that could be used to smuggle arbitrary code through acceptEdits mode.
|
||
const securityFlags = deriveSecurityFlags(parsed)
|
||
if (
|
||
securityFlags.hasSubExpressions ||
|
||
securityFlags.hasScriptBlocks ||
|
||
securityFlags.hasMemberInvocations ||
|
||
securityFlags.hasSplatting ||
|
||
securityFlags.hasAssignments ||
|
||
securityFlags.hasStopParsing ||
|
||
securityFlags.hasExpandableStrings
|
||
) {
|
||
return {
|
||
behavior: 'passthrough',
|
||
message:
|
||
'Command contains subexpressions, script blocks, or member invocations that require approval',
|
||
}
|
||
}
|
||
|
||
const segments = getPipelineSegments(parsed)
|
||
|
||
// SECURITY: Empty segments with valid parse = no commands to check, don't auto-allow
|
||
if (segments.length === 0) {
|
||
return {
|
||
behavior: 'passthrough',
|
||
message: 'No commands found to validate for acceptEdits mode',
|
||
}
|
||
}
|
||
|
||
// SECURITY: Compound cwd desync guard — BashTool parity.
|
||
// When any statement in a compound contains Set-Location/Push-Location/Pop-Location
|
||
// (or aliases like cd, sl, chdir, pushd, popd), the cwd changes between statements.
|
||
// Path validation resolves relative paths against the stale process cwd, so a write
|
||
// cmdlet in a later statement targets a different directory than the validator checked.
|
||
// Example: `Set-Location ./.claude; Set-Content ./settings.json '...'` — the validator
|
||
// sees ./settings.json as /project/settings.json, but PowerShell writes to
|
||
// /project/.claude/settings.json. Refuse to auto-allow any write operation in a
|
||
// compound that contains a cwd-changing command. This matches BashTool's
|
||
// compoundCommandHasCd guard (BashTool/pathValidation.ts:630-655).
|
||
const totalCommands = segments.reduce(
|
||
(sum, seg) => sum + seg.commands.length,
|
||
0,
|
||
)
|
||
if (totalCommands > 1) {
|
||
let hasCdCommand = false
|
||
let hasSymlinkCreate = false
|
||
let hasWriteCommand = false
|
||
for (const seg of segments) {
|
||
for (const cmd of seg.commands) {
|
||
if (cmd.elementType !== 'CommandAst') continue
|
||
if (isCwdChangingCmdlet(cmd.name)) hasCdCommand = true
|
||
if (isSymlinkCreatingCommand(cmd)) hasSymlinkCreate = true
|
||
if (isAcceptEditsAllowedCmdlet(cmd.name)) hasWriteCommand = true
|
||
}
|
||
}
|
||
if (hasCdCommand && hasWriteCommand) {
|
||
return {
|
||
behavior: 'passthrough',
|
||
message:
|
||
'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',
|
||
}
|
||
}
|
||
// SECURITY: Link-create compound guard (finding #18). Mirrors the cd
|
||
// guard above. `New-Item -ItemType SymbolicLink -Path ./link -Value /etc;
|
||
// Get-Content ./link/passwd` — path validation resolves ./link/passwd
|
||
// against cwd (no link there at validation time), but runtime follows
|
||
// the just-created link to /etc/passwd. Same TOCTOU shape as cwd desync.
|
||
// Applies to SymbolicLink, Junction, and HardLink — all three redirect
|
||
// path resolution at runtime.
|
||
// No `hasWriteCommand` requirement: read-through-symlink is equally
|
||
// dangerous (exfil via Get-Content ./link/etc/shadow), and any other
|
||
// command using paths after a just-created link is unvalidatable.
|
||
if (hasSymlinkCreate) {
|
||
return {
|
||
behavior: 'passthrough',
|
||
message:
|
||
'Compound command creates a filesystem link (New-Item -ItemType SymbolicLink/Junction/HardLink) — cannot auto-allow because path validation cannot follow just-created links',
|
||
}
|
||
}
|
||
}
|
||
|
||
for (const segment of segments) {
|
||
for (const cmd of segment.commands) {
|
||
if (cmd.elementType !== 'CommandAst') {
|
||
// SECURITY: This guard is load-bearing for THREE cases. Do not narrow it.
|
||
//
|
||
// 1. Expression pipeline sources (designed): '/etc/passwd' | Remove-Item
|
||
// — the string literal is CommandExpressionAst, piped value binds to
|
||
// -Path. We cannot statically know what path it represents.
|
||
//
|
||
// 2. Control-flow statements (accidental but relied upon):
|
||
// foreach ($x in ...) { Remove-Item $x }. Non-PipelineAst statements
|
||
// produce a synthetic CommandExpressionAst entry in segment.commands
|
||
// (parser.ts transformStatement). Without this guard, Remove-Item $x
|
||
// in nestedCommands would be checked below and auto-allowed — but $x
|
||
// is a loop-bound variable we cannot validate.
|
||
//
|
||
// 3. Non-PipelineAst redirection coverage (accidental): cmd && cmd2 > /tmp
|
||
// also produces a synthetic element here. isReadOnlyCommand relies on
|
||
// the same accident (its allowlist rejects the synthetic element's
|
||
// full-text name), so both paths fail safe together.
|
||
return {
|
||
behavior: 'passthrough',
|
||
message: `Pipeline contains expression source (${cmd.elementType}) that cannot be statically validated`,
|
||
}
|
||
}
|
||
// SECURITY: nameType is computed from the raw name before stripModulePrefix.
|
||
// 'application' = raw name had path chars (. \\ /). scripts\\Remove-Item
|
||
// strips to Remove-Item and would match ACCEPT_EDITS_ALLOWED_CMDLETS below,
|
||
// but PowerShell runs scripts\\Remove-Item.ps1. Same gate as isAllowlistedCommand.
|
||
if (cmd.nameType === 'application') {
|
||
return {
|
||
behavior: 'passthrough',
|
||
message: `Command '${cmd.name}' resolved from a path-like name and requires approval`,
|
||
}
|
||
}
|
||
// SECURITY: elementTypes whitelist — same as isAllowlistedCommand.
|
||
// deriveSecurityFlags above checks hasSubExpressions/etc. but does NOT
|
||
// flag bare Variable/Other elementTypes. `Remove-Item $env:PATH`:
|
||
// elementTypes = ['StringConstant', 'Variable']
|
||
// deriveSecurityFlags: no subexpression → passes
|
||
// checkPathConstraints: resolves literal text '$env:PATH' as relative
|
||
// path → cwd/$env:PATH → inside cwd → allow
|
||
// RUNTIME: PowerShell expands $env:PATH → deletes actual env value path
|
||
// isAllowlistedCommand rejects non-StringConstant/Parameter; this is the
|
||
// acceptEdits parity gate.
|
||
//
|
||
// Also check colon-bound expression metachars (same as isAllowlistedCommand's
|
||
// colon-bound check). `Remove-Item -Path:(1 > /tmp/x)`:
|
||
// elementTypes = ['StringConstant', 'Parameter'] — passes whitelist above
|
||
// deriveSecurityFlags: ParenExpressionAst in .Argument not detected by
|
||
// Get-SecurityPatterns (ParenExpressionAst not in FindAll filter)
|
||
// checkPathConstraints: literal text '-Path:(1 > /tmp/x)' not a path
|
||
// RUNTIME: paren evaluates, redirection writes /tmp/x → arbitrary write
|
||
if (cmd.elementTypes) {
|
||
for (let i = 1; i < cmd.elementTypes.length; i++) {
|
||
const t = cmd.elementTypes[i]
|
||
if (t !== 'StringConstant' && t !== 'Parameter') {
|
||
return {
|
||
behavior: 'passthrough',
|
||
message: `Command argument has unvalidatable type (${t}) — variable paths cannot be statically resolved`,
|
||
}
|
||
}
|
||
if (t === 'Parameter') {
|
||
// elementTypes[i] ↔ args[i-1] (elementTypes[0] is the command name).
|
||
const arg = cmd.args[i - 1] ?? ''
|
||
const colonIdx = arg.indexOf(':')
|
||
if (colonIdx > 0 && /[$(@{[]/.test(arg.slice(colonIdx + 1))) {
|
||
return {
|
||
behavior: 'passthrough',
|
||
message:
|
||
'Colon-bound parameter contains an expression that cannot be statically validated',
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Safe output cmdlets (Out-Null, etc.) and allowlisted pipeline-tail
|
||
// transformers (Format-*, Measure-Object, Select-Object, etc.) don't
|
||
// affect the semantics of the preceding command. Skip them so
|
||
// `Remove-Item ./foo | Out-Null` or `Set-Content ./foo hi | Format-Table`
|
||
// auto-allows the same as the bare write cmdlet. isAllowlistedPipelineTail
|
||
// is the narrow fallback for cmdlets moved from SAFE_OUTPUT_CMDLETS to
|
||
// CMDLET_ALLOWLIST (argLeaksValue validates their args).
|
||
if (
|
||
isSafeOutputCommand(cmd.name) ||
|
||
isAllowlistedPipelineTail(cmd, input.command)
|
||
) {
|
||
continue
|
||
}
|
||
if (!isAcceptEditsAllowedCmdlet(cmd.name)) {
|
||
return {
|
||
behavior: 'passthrough',
|
||
message: `No mode-specific handling for '${cmd.name}' in acceptEdits mode`,
|
||
}
|
||
}
|
||
// SECURITY: Reject commands with unclassifiable argument types. 'Other'
|
||
// covers HashtableAst, ConvertExpressionAst, BinaryExpressionAst — all
|
||
// can contain nested redirections or code that the parser cannot fully
|
||
// decompose. isAllowlistedCommand (readOnlyValidation.ts) already
|
||
// enforces this whitelist via argLeaksValue; this closes the same gap
|
||
// in acceptEdits mode. Without this, @{k='payload' > ~/.bashrc} as a
|
||
// -Value argument passes because HashtableAst maps to 'Other'.
|
||
// argLeaksValue also catches colon-bound variables (-Flag:$env:SECRET).
|
||
if (argLeaksValue(cmd.name, cmd)) {
|
||
return {
|
||
behavior: 'passthrough',
|
||
message: `Arguments in '${cmd.name}' cannot be statically validated in acceptEdits mode`,
|
||
}
|
||
}
|
||
}
|
||
|
||
// Also check nested commands from control flow statements
|
||
if (segment.nestedCommands) {
|
||
for (const cmd of segment.nestedCommands) {
|
||
if (cmd.elementType !== 'CommandAst') {
|
||
// SECURITY: Same as above — non-CommandAst element in nested commands
|
||
// (control flow bodies) cannot be statically validated as a path source.
|
||
return {
|
||
behavior: 'passthrough',
|
||
message: `Nested expression element (${cmd.elementType}) cannot be statically validated`,
|
||
}
|
||
}
|
||
if (cmd.nameType === 'application') {
|
||
return {
|
||
behavior: 'passthrough',
|
||
message: `Nested command '${cmd.name}' resolved from a path-like name and requires approval`,
|
||
}
|
||
}
|
||
if (
|
||
isSafeOutputCommand(cmd.name) ||
|
||
isAllowlistedPipelineTail(cmd, input.command)
|
||
) {
|
||
continue
|
||
}
|
||
if (!isAcceptEditsAllowedCmdlet(cmd.name)) {
|
||
return {
|
||
behavior: 'passthrough',
|
||
message: `No mode-specific handling for '${cmd.name}' in acceptEdits mode`,
|
||
}
|
||
}
|
||
// SECURITY: Same argLeaksValue check as the main command loop above.
|
||
if (argLeaksValue(cmd.name, cmd)) {
|
||
return {
|
||
behavior: 'passthrough',
|
||
message: `Arguments in nested '${cmd.name}' cannot be statically validated in acceptEdits mode`,
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// All commands are filesystem-modifying cmdlets -- auto-allow
|
||
return {
|
||
behavior: 'allow',
|
||
updatedInput: input,
|
||
decisionReason: {
|
||
type: 'mode',
|
||
mode: 'acceptEdits',
|
||
},
|
||
}
|
||
}
|