init claude-code
This commit is contained in:
@@ -0,0 +1,404 @@
|
||||
/**
|
||||
* 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',
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user