chore: initialize recovered claude workspace
This commit is contained in:
1001
src/tools/PowerShellTool/PowerShellTool.tsx
Normal file
1001
src/tools/PowerShellTool/PowerShellTool.tsx
Normal file
File diff suppressed because one or more lines are too long
131
src/tools/PowerShellTool/UI.tsx
Normal file
131
src/tools/PowerShellTool/UI.tsx
Normal file
File diff suppressed because one or more lines are too long
211
src/tools/PowerShellTool/clmTypes.ts
Normal file
211
src/tools/PowerShellTool/clmTypes.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* PowerShell Constrained Language Mode allowed types.
|
||||
*
|
||||
* Microsoft's CLM restricts .NET type usage to this allowlist when PS runs
|
||||
* under AppLocker/WDAC system lockdown. Any type NOT in this set is considered
|
||||
* unsafe for untrusted code execution.
|
||||
*
|
||||
* We invert this: type literals not in this set → ask. One canonical check
|
||||
* replaces enumerating individual dangerous types (named pipes, reflection,
|
||||
* process spawning, P/Invoke marshaling, etc.). Microsoft maintains the list.
|
||||
*
|
||||
* Source: https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_language_modes
|
||||
*
|
||||
* Normalization: entries stored lowercase, short AND full names where both
|
||||
* exist (PS resolves type accelerators like [int] → System.Int32 at runtime;
|
||||
* we match against what the AST emits, which is the literal text).
|
||||
*/
|
||||
export const CLM_ALLOWED_TYPES: ReadonlySet<string> = new Set(
|
||||
[
|
||||
// Type accelerators (short names as they appear in AST TypeName.Name)
|
||||
// SECURITY: 'adsi' and 'adsisearcher' REMOVED. Both are Active Directory
|
||||
// Service Interface types that perform NETWORK BINDS when cast:
|
||||
// [adsi]'LDAP://evil.com/...' → connects to LDAP server
|
||||
// [adsisearcher]'(objectClass=user)' → binds to AD and queries
|
||||
// Microsoft's CLM allows these because it's for Windows admins in trusted
|
||||
// domains; we block them since the target isn't validated.
|
||||
'alias',
|
||||
'allowemptycollection',
|
||||
'allowemptystring',
|
||||
'allownull',
|
||||
'argumentcompleter',
|
||||
'argumentcompletions',
|
||||
'array',
|
||||
'bigint',
|
||||
'bool',
|
||||
'byte',
|
||||
'char',
|
||||
'cimclass',
|
||||
'cimconverter',
|
||||
'ciminstance',
|
||||
// 'cimsession' REMOVED — see wmi/adsi comment below
|
||||
'cimtype',
|
||||
'cmdletbinding',
|
||||
'cultureinfo',
|
||||
'datetime',
|
||||
'decimal',
|
||||
'double',
|
||||
'dsclocalconfigurationmanager',
|
||||
'dscproperty',
|
||||
'dscresource',
|
||||
'experimentaction',
|
||||
'experimental',
|
||||
'experimentalfeature',
|
||||
'float',
|
||||
'guid',
|
||||
'hashtable',
|
||||
'int',
|
||||
'int16',
|
||||
'int32',
|
||||
'int64',
|
||||
'ipaddress',
|
||||
'ipendpoint',
|
||||
'long',
|
||||
'mailaddress',
|
||||
'norunspaceaffinity',
|
||||
'nullstring',
|
||||
'objectsecurity',
|
||||
'ordered',
|
||||
'outputtype',
|
||||
'parameter',
|
||||
'physicaladdress',
|
||||
'pscredential',
|
||||
'pscustomobject',
|
||||
'psdefaultvalue',
|
||||
'pslistmodifier',
|
||||
'psobject',
|
||||
'psprimitivedictionary',
|
||||
'pstypenameattribute',
|
||||
'ref',
|
||||
'regex',
|
||||
'sbyte',
|
||||
'securestring',
|
||||
'semver',
|
||||
'short',
|
||||
'single',
|
||||
'string',
|
||||
'supportswildcards',
|
||||
'switch',
|
||||
'timespan',
|
||||
'uint',
|
||||
'uint16',
|
||||
'uint32',
|
||||
'uint64',
|
||||
'ulong',
|
||||
'uri',
|
||||
'ushort',
|
||||
'validatecount',
|
||||
'validatedrive',
|
||||
'validatelength',
|
||||
'validatenotnull',
|
||||
'validatenotnullorempty',
|
||||
'validatenotnullorwhitespace',
|
||||
'validatepattern',
|
||||
'validaterange',
|
||||
'validatescript',
|
||||
'validateset',
|
||||
'validatetrusteddata',
|
||||
'validateuserdrive',
|
||||
'version',
|
||||
'void',
|
||||
'wildcardpattern',
|
||||
// SECURITY: 'wmi', 'wmiclass', 'wmisearcher', 'cimsession' REMOVED.
|
||||
// WMI type casts perform WMI queries which can target remote computers
|
||||
// (network request) and access dangerous classes like Win32_Process.
|
||||
// cimsession creates a CIM session (network connection to remote host).
|
||||
// [wmi]'\\evil-host\root\cimv2:Win32_Process.Handle="1"' → remote WMI
|
||||
// [wmisearcher]'SELECT * FROM Win32_Process' → runs WQL query
|
||||
// Same rationale as adsi/adsisearcher removal above.
|
||||
'x500distinguishedname',
|
||||
'x509certificate',
|
||||
'xml',
|
||||
// Full names for accelerators that resolve to System.* (AST may emit either)
|
||||
'system.array',
|
||||
'system.boolean',
|
||||
'system.byte',
|
||||
'system.char',
|
||||
'system.datetime',
|
||||
'system.decimal',
|
||||
'system.double',
|
||||
'system.guid',
|
||||
'system.int16',
|
||||
'system.int32',
|
||||
'system.int64',
|
||||
'system.numerics.biginteger',
|
||||
'system.sbyte',
|
||||
'system.single',
|
||||
'system.string',
|
||||
'system.timespan',
|
||||
'system.uint16',
|
||||
'system.uint32',
|
||||
'system.uint64',
|
||||
'system.uri',
|
||||
'system.version',
|
||||
'system.void',
|
||||
'system.collections.hashtable',
|
||||
'system.text.regularexpressions.regex',
|
||||
'system.globalization.cultureinfo',
|
||||
'system.net.ipaddress',
|
||||
'system.net.ipendpoint',
|
||||
'system.net.mail.mailaddress',
|
||||
'system.net.networkinformation.physicaladdress',
|
||||
'system.security.securestring',
|
||||
'system.security.cryptography.x509certificates.x509certificate',
|
||||
'system.security.cryptography.x509certificates.x500distinguishedname',
|
||||
'system.xml.xmldocument',
|
||||
// System.Management.Automation.* — FQ equivalents of PS-specific accelerators
|
||||
'system.management.automation.pscredential',
|
||||
'system.management.automation.pscustomobject',
|
||||
'system.management.automation.pslistmodifier',
|
||||
'system.management.automation.psobject',
|
||||
'system.management.automation.psprimitivedictionary',
|
||||
'system.management.automation.psreference',
|
||||
'system.management.automation.semanticversion',
|
||||
'system.management.automation.switchparameter',
|
||||
'system.management.automation.wildcardpattern',
|
||||
'system.management.automation.language.nullstring',
|
||||
// Microsoft.Management.Infrastructure.* — FQ equivalents of CIM accelerators
|
||||
// SECURITY: cimsession FQ REMOVED — same network-bind hazard as short name
|
||||
// (creates a CIM session to a remote host).
|
||||
'microsoft.management.infrastructure.cimclass',
|
||||
'microsoft.management.infrastructure.cimconverter',
|
||||
'microsoft.management.infrastructure.ciminstance',
|
||||
'microsoft.management.infrastructure.cimtype',
|
||||
// FQ equivalents of remaining short-name accelerators
|
||||
// SECURITY: DirectoryEntry/DirectorySearcher/ManagementObject/
|
||||
// ManagementClass/ManagementObjectSearcher FQ REMOVED — same network-bind
|
||||
// hazard as short names adsi/adsisearcher/wmi/wmiclass/wmisearcher
|
||||
// (LDAP bind, remote WMI). See short-name removal comments above.
|
||||
'system.collections.specialized.ordereddictionary',
|
||||
'system.security.accesscontrol.objectsecurity',
|
||||
// Arrays of allowed types are allowed (e.g. [string[]])
|
||||
// normalizeTypeName strips [] before lookup, so store the base name
|
||||
'object',
|
||||
'system.object',
|
||||
// ModuleSpecification — full qualified name
|
||||
'microsoft.powershell.commands.modulespecification',
|
||||
].map(t => t.toLowerCase()),
|
||||
)
|
||||
|
||||
/**
|
||||
* Normalize a type name from AST TypeName.FullName or TypeName.Name.
|
||||
* Handles array suffix ([]) and generic brackets.
|
||||
*/
|
||||
export function normalizeTypeName(name: string): string {
|
||||
// Strip array suffix: "String[]" → "string" (arrays of allowed types are allowed)
|
||||
// Strip generic args: "List[int]" → "list" (conservative — the generic wrapper
|
||||
// might be unsafe even if the type arg is safe, so we check the outer type)
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/\[\]$/, '')
|
||||
.replace(/\[.*\]$/, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* True if typeName (from AST) is in Microsoft's CLM allowlist.
|
||||
* Types NOT in this set trigger ask — they access system APIs CLM blocks.
|
||||
*/
|
||||
export function isClmAllowedType(typeName: string): boolean {
|
||||
return CLM_ALLOWED_TYPES.has(normalizeTypeName(typeName))
|
||||
}
|
||||
142
src/tools/PowerShellTool/commandSemantics.ts
Normal file
142
src/tools/PowerShellTool/commandSemantics.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Command semantics configuration for interpreting exit codes in PowerShell.
|
||||
*
|
||||
* PowerShell-native cmdlets do NOT need exit-code semantics:
|
||||
* - Select-String (grep equivalent) exits 0 on no-match (returns $null)
|
||||
* - Compare-Object (diff equivalent) exits 0 regardless
|
||||
* - Test-Path exits 0 regardless (returns bool via pipeline)
|
||||
* Native cmdlets signal failure via terminating errors ($?), not exit codes.
|
||||
*
|
||||
* However, EXTERNAL executables invoked from PowerShell DO set $LASTEXITCODE,
|
||||
* and many use non-zero codes to convey information rather than failure:
|
||||
* - grep.exe / rg.exe (Git for Windows, scoop, etc.): 1 = no match
|
||||
* - findstr.exe (Windows native): 1 = no match
|
||||
* - robocopy.exe (Windows native): 0-7 = success, 8+ = error (notorious!)
|
||||
*
|
||||
* Without this module, PowerShellTool throws ShellError on any non-zero exit,
|
||||
* so `robocopy` reporting "files copied successfully" (exit 1) shows as an error.
|
||||
*/
|
||||
|
||||
export type CommandSemantic = (
|
||||
exitCode: number,
|
||||
stdout: string,
|
||||
stderr: string,
|
||||
) => {
|
||||
isError: boolean
|
||||
message?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Default semantic: treat only 0 as success, everything else as error
|
||||
*/
|
||||
const DEFAULT_SEMANTIC: CommandSemantic = (exitCode, _stdout, _stderr) => ({
|
||||
isError: exitCode !== 0,
|
||||
message:
|
||||
exitCode !== 0 ? `Command failed with exit code ${exitCode}` : undefined,
|
||||
})
|
||||
|
||||
/**
|
||||
* grep / ripgrep: 0 = matches found, 1 = no matches, 2+ = error
|
||||
*/
|
||||
const GREP_SEMANTIC: CommandSemantic = (exitCode, _stdout, _stderr) => ({
|
||||
isError: exitCode >= 2,
|
||||
message: exitCode === 1 ? 'No matches found' : undefined,
|
||||
})
|
||||
|
||||
/**
|
||||
* Command-specific semantics for external executables.
|
||||
* Keys are lowercase command names WITHOUT .exe suffix.
|
||||
*
|
||||
* Deliberately omitted:
|
||||
* - 'diff': Ambiguous. Windows PowerShell 5.1 aliases `diff` → Compare-Object
|
||||
* (exit 0 on differ), but PS Core / Git for Windows may resolve to diff.exe
|
||||
* (exit 1 on differ). Cannot reliably interpret.
|
||||
* - 'fc': Ambiguous. PowerShell aliases `fc` → Format-Custom (a native cmdlet),
|
||||
* but `fc.exe` is the Windows file compare utility (exit 1 = files differ).
|
||||
* Same aliasing problem as `diff`.
|
||||
* - 'find': Ambiguous. Windows find.exe (text search) vs Unix find.exe
|
||||
* (file search via Git for Windows) have different semantics.
|
||||
* - 'test', '[': Not PowerShell constructs.
|
||||
* - 'select-string', 'compare-object', 'test-path': Native cmdlets exit 0.
|
||||
*/
|
||||
const COMMAND_SEMANTICS: Map<string, CommandSemantic> = new Map([
|
||||
// External grep/ripgrep (Git for Windows, scoop, choco)
|
||||
['grep', GREP_SEMANTIC],
|
||||
['rg', GREP_SEMANTIC],
|
||||
|
||||
// findstr.exe: Windows native text search
|
||||
// 0 = match found, 1 = no match, 2 = error
|
||||
['findstr', GREP_SEMANTIC],
|
||||
|
||||
// robocopy.exe: Windows native robust file copy
|
||||
// Exit codes are a BITFIELD — 0-7 are success, 8+ indicates at least one failure:
|
||||
// 0 = no files copied, no mismatch, no failures (already in sync)
|
||||
// 1 = files copied successfully
|
||||
// 2 = extra files/dirs detected (no copy)
|
||||
// 4 = mismatched files/dirs detected
|
||||
// 8 = some files/dirs could not be copied (copy errors)
|
||||
// 16 = serious error (robocopy did not copy any files)
|
||||
// This is the single most common "CI failed but nothing's wrong" Windows gotcha.
|
||||
[
|
||||
'robocopy',
|
||||
(exitCode, _stdout, _stderr) => ({
|
||||
isError: exitCode >= 8,
|
||||
message:
|
||||
exitCode === 0
|
||||
? 'No files copied (already in sync)'
|
||||
: exitCode >= 1 && exitCode < 8
|
||||
? exitCode & 1
|
||||
? 'Files copied successfully'
|
||||
: 'Robocopy completed (no errors)'
|
||||
: undefined,
|
||||
}),
|
||||
],
|
||||
])
|
||||
|
||||
/**
|
||||
* Extract the command name from a single pipeline segment.
|
||||
* Strips leading `&` / `.` call operators and `.exe` suffix, lowercases.
|
||||
*/
|
||||
function extractBaseCommand(segment: string): string {
|
||||
// Strip PowerShell call operators: & "cmd", . "cmd"
|
||||
// (& and . at segment start followed by whitespace invoke the next token)
|
||||
const stripped = segment.trim().replace(/^[&.]\s+/, '')
|
||||
const firstToken = stripped.split(/\s+/)[0] || ''
|
||||
// Strip surrounding quotes if command was invoked as & "grep.exe"
|
||||
const unquoted = firstToken.replace(/^["']|["']$/g, '')
|
||||
// Strip path: C:\bin\grep.exe → grep.exe, .\rg.exe → rg.exe
|
||||
const basename = unquoted.split(/[\\/]/).pop() || unquoted
|
||||
// Strip .exe suffix (Windows is case-insensitive)
|
||||
return basename.toLowerCase().replace(/\.exe$/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the primary command from a PowerShell command line.
|
||||
* Takes the LAST pipeline segment since that determines the exit code.
|
||||
*
|
||||
* Heuristic split on `;` and `|` — may get it wrong for quoted strings or
|
||||
* complex constructs. Do NOT depend on this for security; it's only used
|
||||
* for exit-code interpretation (false negatives just fall back to default).
|
||||
*/
|
||||
function heuristicallyExtractBaseCommand(command: string): string {
|
||||
const segments = command.split(/[;|]/).filter(s => s.trim())
|
||||
const last = segments[segments.length - 1] || command
|
||||
return extractBaseCommand(last)
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpret command result based on semantic rules
|
||||
*/
|
||||
export function interpretCommandResult(
|
||||
command: string,
|
||||
exitCode: number,
|
||||
stdout: string,
|
||||
stderr: string,
|
||||
): {
|
||||
isError: boolean
|
||||
message?: string
|
||||
} {
|
||||
const baseCommand = heuristicallyExtractBaseCommand(command)
|
||||
const semantic = COMMAND_SEMANTICS.get(baseCommand) ?? DEFAULT_SEMANTIC
|
||||
return semantic(exitCode, stdout, stderr)
|
||||
}
|
||||
30
src/tools/PowerShellTool/commonParameters.ts
Normal file
30
src/tools/PowerShellTool/commonParameters.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* PowerShell Common Parameters (available on all cmdlets via [CmdletBinding()]).
|
||||
* Source: about_CommonParameters (PowerShell docs) + Get-Command output.
|
||||
*
|
||||
* Shared between pathValidation.ts (merges into per-cmdlet known-param sets)
|
||||
* and readOnlyValidation.ts (merges into safeFlags check). Split out to break
|
||||
* what would otherwise be an import cycle between those two files.
|
||||
*
|
||||
* Stored lowercase with leading dash — callers `.toLowerCase()` their input.
|
||||
*/
|
||||
|
||||
export const COMMON_SWITCHES = ['-verbose', '-debug']
|
||||
|
||||
export const COMMON_VALUE_PARAMS = [
|
||||
'-erroraction',
|
||||
'-warningaction',
|
||||
'-informationaction',
|
||||
'-progressaction',
|
||||
'-errorvariable',
|
||||
'-warningvariable',
|
||||
'-informationvariable',
|
||||
'-outvariable',
|
||||
'-outbuffer',
|
||||
'-pipelinevariable',
|
||||
]
|
||||
|
||||
export const COMMON_PARAMETERS: ReadonlySet<string> = new Set([
|
||||
...COMMON_SWITCHES,
|
||||
...COMMON_VALUE_PARAMS,
|
||||
])
|
||||
109
src/tools/PowerShellTool/destructiveCommandWarning.ts
Normal file
109
src/tools/PowerShellTool/destructiveCommandWarning.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Detects potentially destructive PowerShell commands and returns a warning
|
||||
* string for display in the permission dialog. This is purely informational
|
||||
* -- it doesn't affect permission logic or auto-approval.
|
||||
*/
|
||||
|
||||
type DestructivePattern = {
|
||||
pattern: RegExp
|
||||
warning: string
|
||||
}
|
||||
|
||||
const DESTRUCTIVE_PATTERNS: DestructivePattern[] = [
|
||||
// Remove-Item with -Recurse and/or -Force (and common aliases)
|
||||
// Anchored to statement start (^, |, ;, &, newline, {, () so `git rm --force`
|
||||
// doesn't match — \b would match `rm` after any word boundary. The `{(`
|
||||
// chars catch scriptblock/group bodies: `{ rm -Force ./x }`. The stopper
|
||||
// adds only `}` (NOT `)`) — `}` ends a block so flags after it belong to a
|
||||
// different statement (`if {rm} else {... -Force}`), but `)` closes a path
|
||||
// grouping and flags after it are still this command's flags:
|
||||
// `Remove-Item (Join-Path $r "tmp") -Recurse -Force` must still warn.
|
||||
{
|
||||
pattern:
|
||||
/(?:^|[|;&\n({])\s*(Remove-Item|rm|del|rd|rmdir|ri)\b[^|;&\n}]*-Recurse\b[^|;&\n}]*-Force\b/i,
|
||||
warning: 'Note: may recursively force-remove files',
|
||||
},
|
||||
{
|
||||
pattern:
|
||||
/(?:^|[|;&\n({])\s*(Remove-Item|rm|del|rd|rmdir|ri)\b[^|;&\n}]*-Force\b[^|;&\n}]*-Recurse\b/i,
|
||||
warning: 'Note: may recursively force-remove files',
|
||||
},
|
||||
{
|
||||
pattern:
|
||||
/(?:^|[|;&\n({])\s*(Remove-Item|rm|del|rd|rmdir|ri)\b[^|;&\n}]*-Recurse\b/i,
|
||||
warning: 'Note: may recursively remove files',
|
||||
},
|
||||
{
|
||||
pattern:
|
||||
/(?:^|[|;&\n({])\s*(Remove-Item|rm|del|rd|rmdir|ri)\b[^|;&\n}]*-Force\b/i,
|
||||
warning: 'Note: may force-remove files',
|
||||
},
|
||||
|
||||
// Clear-Content on broad paths
|
||||
{
|
||||
pattern: /\bClear-Content\b[^|;&\n]*\*/i,
|
||||
warning: 'Note: may clear content of multiple files',
|
||||
},
|
||||
|
||||
// Format-Volume and Clear-Disk
|
||||
{
|
||||
pattern: /\bFormat-Volume\b/i,
|
||||
warning: 'Note: may format a disk volume',
|
||||
},
|
||||
{
|
||||
pattern: /\bClear-Disk\b/i,
|
||||
warning: 'Note: may clear a disk',
|
||||
},
|
||||
|
||||
// Git destructive operations (same as BashTool)
|
||||
{
|
||||
pattern: /\bgit\s+reset\s+--hard\b/i,
|
||||
warning: 'Note: may discard uncommitted changes',
|
||||
},
|
||||
{
|
||||
pattern: /\bgit\s+push\b[^|;&\n]*\s+(--force|--force-with-lease|-f)\b/i,
|
||||
warning: 'Note: may overwrite remote history',
|
||||
},
|
||||
{
|
||||
pattern:
|
||||
/\bgit\s+clean\b(?![^|;&\n]*(?:-[a-zA-Z]*n|--dry-run))[^|;&\n]*-[a-zA-Z]*f/i,
|
||||
warning: 'Note: may permanently delete untracked files',
|
||||
},
|
||||
{
|
||||
pattern: /\bgit\s+stash\s+(drop|clear)\b/i,
|
||||
warning: 'Note: may permanently remove stashed changes',
|
||||
},
|
||||
|
||||
// Database operations
|
||||
{
|
||||
pattern: /\b(DROP|TRUNCATE)\s+(TABLE|DATABASE|SCHEMA)\b/i,
|
||||
warning: 'Note: may drop or truncate database objects',
|
||||
},
|
||||
|
||||
// System operations
|
||||
{
|
||||
pattern: /\bStop-Computer\b/i,
|
||||
warning: 'Note: will shut down the computer',
|
||||
},
|
||||
{
|
||||
pattern: /\bRestart-Computer\b/i,
|
||||
warning: 'Note: will restart the computer',
|
||||
},
|
||||
{
|
||||
pattern: /\bClear-RecycleBin\b/i,
|
||||
warning: 'Note: permanently deletes recycled files',
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Checks if a PowerShell command matches known destructive patterns.
|
||||
* Returns a human-readable warning string, or null if no destructive pattern is detected.
|
||||
*/
|
||||
export function getDestructiveCommandWarning(command: string): string | null {
|
||||
for (const { pattern, warning } of DESTRUCTIVE_PATTERNS) {
|
||||
if (pattern.test(command)) {
|
||||
return warning
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
176
src/tools/PowerShellTool/gitSafety.ts
Normal file
176
src/tools/PowerShellTool/gitSafety.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Git can be weaponized for sandbox escape via two vectors:
|
||||
* 1. Bare-repo attack: if cwd contains HEAD + objects/ + refs/ but no valid
|
||||
* .git/HEAD, Git treats cwd as a bare repository and runs hooks from cwd.
|
||||
* 2. Git-internal write + git: a compound command creates HEAD/objects/refs/
|
||||
* hooks/ then runs git — the git subcommand executes the freshly-created
|
||||
* malicious hooks.
|
||||
*/
|
||||
|
||||
import { basename, posix, resolve, sep } from 'path'
|
||||
import { getCwd } from '../../utils/cwd.js'
|
||||
import { PS_TOKENIZER_DASH_CHARS } from '../../utils/powershell/parser.js'
|
||||
|
||||
/**
|
||||
* If a normalized path starts with `../<cwd-basename>/`, it re-enters cwd
|
||||
* via the parent — resolve it to the cwd-relative form. posix.normalize
|
||||
* preserves leading `..` (no cwd context), so `../project/hooks` with
|
||||
* cwd=/x/project stays `../project/hooks` and misses the `hooks/` prefix
|
||||
* match even though it resolves to the same directory at runtime.
|
||||
* Check/use divergence: validator sees `../project/hooks`, PowerShell
|
||||
* resolves against cwd to `hooks`.
|
||||
*/
|
||||
function resolveCwdReentry(normalized: string): string {
|
||||
if (!normalized.startsWith('../')) return normalized
|
||||
const cwdBase = basename(getCwd()).toLowerCase()
|
||||
if (!cwdBase) return normalized
|
||||
// Iteratively strip `../<cwd-basename>/` pairs (handles `../../p/p/hooks`
|
||||
// when cwd has repeated basename segments is unlikely, but one-level is
|
||||
// the common attack).
|
||||
const prefix = '../' + cwdBase + '/'
|
||||
let s = normalized
|
||||
while (s.startsWith(prefix)) {
|
||||
s = s.slice(prefix.length)
|
||||
}
|
||||
// Also handle exact `../<cwd-basename>` (no trailing slash)
|
||||
if (s === '../' + cwdBase) return '.'
|
||||
return s
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize PS arg text → canonical path for git-internal matching.
|
||||
* Order matters: structural strips first (colon-bound param, quotes,
|
||||
* backtick escapes, provider prefix, drive-relative prefix), then NTFS
|
||||
* per-component trailing-strip (spaces always; dots only if not `./..`
|
||||
* after space-strip), then posix.normalize (resolves `..`, `.`, `//`),
|
||||
* then case-fold.
|
||||
*/
|
||||
function normalizeGitPathArg(arg: string): string {
|
||||
let s = arg
|
||||
// Normalize parameter prefixes: dash chars (–, —, ―) and forward-slash
|
||||
// (PS 5.1). /Path:hooks/pre-commit → extract colon-bound value. (bug #28)
|
||||
if (s.length > 0 && (PS_TOKENIZER_DASH_CHARS.has(s[0]!) || s[0] === '/')) {
|
||||
const c = s.indexOf(':', 1)
|
||||
if (c > 0) s = s.slice(c + 1)
|
||||
}
|
||||
s = s.replace(/^['"]|['"]$/g, '')
|
||||
s = s.replace(/`/g, '')
|
||||
// PS provider-qualified path: FileSystem::hooks/pre-commit → hooks/pre-commit
|
||||
// Also handles fully-qualified form: Microsoft.PowerShell.Core\FileSystem::path
|
||||
s = s.replace(/^(?:[A-Za-z0-9_.]+\\){0,3}FileSystem::/i, '')
|
||||
// Drive-relative C:foo (no separator after colon) is cwd-relative on that
|
||||
// drive. C:\foo (WITH separator) is absolute and must NOT match — the
|
||||
// negative lookahead preserves it.
|
||||
s = s.replace(/^[A-Za-z]:(?![/\\])/, '')
|
||||
s = s.replace(/\\/g, '/')
|
||||
// Win32 CreateFileW per-component: iteratively strip trailing spaces,
|
||||
// then trailing dots, stopping if the result is `.` or `..` (special).
|
||||
// `.. ` → `..`, `.. .` → `..`, `...` → '' → `.`, `hooks .` → `hooks`.
|
||||
// Originally-'' (leading slash split) stays '' (absolute-path marker).
|
||||
s = s
|
||||
.split('/')
|
||||
.map(c => {
|
||||
if (c === '') return c
|
||||
let prev
|
||||
do {
|
||||
prev = c
|
||||
c = c.replace(/ +$/, '')
|
||||
if (c === '.' || c === '..') return c
|
||||
c = c.replace(/\.+$/, '')
|
||||
} while (c !== prev)
|
||||
return c || '.'
|
||||
})
|
||||
.join('/')
|
||||
s = posix.normalize(s)
|
||||
if (s.startsWith('./')) s = s.slice(2)
|
||||
return s.toLowerCase()
|
||||
}
|
||||
|
||||
const GIT_INTERNAL_PREFIXES = ['head', 'objects', 'refs', 'hooks'] as const
|
||||
|
||||
/**
|
||||
* SECURITY: Resolve a normalized path that escapes cwd (leading `../` or
|
||||
* absolute) against the actual cwd, then check if it lands back INSIDE cwd.
|
||||
* If so, strip cwd and return the cwd-relative remainder for prefix matching.
|
||||
* If it lands outside cwd, return null (genuinely external — path-validation's
|
||||
* concern). Covers `..\<cwd-basename>\HEAD` and `C:\<full-cwd>\HEAD` which
|
||||
* posix.normalize alone cannot resolve (it leaves leading `..` as-is).
|
||||
*
|
||||
* This is the SOLE guard for the bare-repo HEAD attack. path-validation's
|
||||
* DANGEROUS_FILES deliberately excludes bare `HEAD` (false-positive risk
|
||||
* on legitimate non-git files named HEAD) and DANGEROUS_DIRECTORIES
|
||||
* matches per-segment `.git` only — so `<cwd>/HEAD` passes that layer.
|
||||
* The cwd-resolution here is load-bearing; do not remove without adding
|
||||
* an alternative guard.
|
||||
*/
|
||||
function resolveEscapingPathToCwdRelative(n: string): string | null {
|
||||
const cwd = getCwd()
|
||||
// Reconstruct a platform-resolvable path from the posix-normalized form.
|
||||
// `n` has forward slashes (normalizeGitPathArg converted \\ → /); resolve()
|
||||
// handles forward slashes on Windows.
|
||||
const abs = resolve(cwd, n)
|
||||
const cwdWithSep = cwd.endsWith(sep) ? cwd : cwd + sep
|
||||
// Case-insensitive comparison: normalizeGitPathArg lowercased `n`, so
|
||||
// resolve() output has lowercase components from `n` but cwd may be
|
||||
// mixed-case (e.g. C:\Users\...). Windows paths are case-insensitive.
|
||||
const absLower = abs.toLowerCase()
|
||||
const cwdLower = cwd.toLowerCase()
|
||||
const cwdWithSepLower = cwdWithSep.toLowerCase()
|
||||
if (absLower === cwdLower) return '.'
|
||||
if (!absLower.startsWith(cwdWithSepLower)) return null
|
||||
return abs.slice(cwdWithSep.length).replace(/\\/g, '/').toLowerCase()
|
||||
}
|
||||
|
||||
function matchesGitInternalPrefix(n: string): boolean {
|
||||
if (n === 'head' || n === '.git') return true
|
||||
if (n.startsWith('.git/') || /^git~\d+($|\/)/.test(n)) return true
|
||||
for (const p of GIT_INTERNAL_PREFIXES) {
|
||||
if (p === 'head') continue
|
||||
if (n === p || n.startsWith(p + '/')) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* True if arg (raw PS arg text) resolves to a git-internal path in cwd.
|
||||
* Covers both bare-repo paths (hooks/, refs/) and standard-repo paths
|
||||
* (.git/hooks/, .git/config).
|
||||
*/
|
||||
export function isGitInternalPathPS(arg: string): boolean {
|
||||
const n = resolveCwdReentry(normalizeGitPathArg(arg))
|
||||
if (matchesGitInternalPrefix(n)) return true
|
||||
// SECURITY: leading `../` or absolute paths that resolveCwdReentry and
|
||||
// posix.normalize couldn't fully resolve. Resolve against actual cwd — if
|
||||
// the result lands back in cwd at a git-internal location, the guard must
|
||||
// still fire.
|
||||
if (n.startsWith('../') || n.startsWith('/') || /^[a-z]:/.test(n)) {
|
||||
const rel = resolveEscapingPathToCwdRelative(n)
|
||||
if (rel !== null && matchesGitInternalPrefix(rel)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* True if arg resolves to a path inside .git/ (standard-repo metadata dir).
|
||||
* Unlike isGitInternalPathPS, does NOT match bare-repo-style root-level
|
||||
* `hooks/`, `refs/` etc. — those are common project directory names.
|
||||
*/
|
||||
export function isDotGitPathPS(arg: string): boolean {
|
||||
const n = resolveCwdReentry(normalizeGitPathArg(arg))
|
||||
if (matchesDotGitPrefix(n)) return true
|
||||
// SECURITY: same cwd-resolution as isGitInternalPathPS — catch
|
||||
// `..\<cwd-basename>\.git\hooks\pre-commit` that lands back in cwd.
|
||||
if (n.startsWith('../') || n.startsWith('/') || /^[a-z]:/.test(n)) {
|
||||
const rel = resolveEscapingPathToCwdRelative(n)
|
||||
if (rel !== null && matchesDotGitPrefix(rel)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function matchesDotGitPrefix(n: string): boolean {
|
||||
if (n === '.git' || n.startsWith('.git/')) return true
|
||||
// NTFS 8.3 short names: .git becomes GIT~1 (or GIT~2, etc. if multiple
|
||||
// dotfiles start with "git"). normalizeGitPathArg lowercases, so check
|
||||
// for git~N as the first component.
|
||||
return /^git~\d+($|\/)/.test(n)
|
||||
}
|
||||
404
src/tools/PowerShellTool/modeValidation.ts
Normal file
404
src/tools/PowerShellTool/modeValidation.ts
Normal file
@@ -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',
|
||||
},
|
||||
}
|
||||
}
|
||||
2049
src/tools/PowerShellTool/pathValidation.ts
Normal file
2049
src/tools/PowerShellTool/pathValidation.ts
Normal file
File diff suppressed because it is too large
Load Diff
1648
src/tools/PowerShellTool/powershellPermissions.ts
Normal file
1648
src/tools/PowerShellTool/powershellPermissions.ts
Normal file
File diff suppressed because it is too large
Load Diff
1090
src/tools/PowerShellTool/powershellSecurity.ts
Normal file
1090
src/tools/PowerShellTool/powershellSecurity.ts
Normal file
File diff suppressed because it is too large
Load Diff
145
src/tools/PowerShellTool/prompt.ts
Normal file
145
src/tools/PowerShellTool/prompt.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { isEnvTruthy } from '../../utils/envUtils.js'
|
||||
import { getMaxOutputLength } from '../../utils/shell/outputLimits.js'
|
||||
import {
|
||||
getPowerShellEdition,
|
||||
type PowerShellEdition,
|
||||
} from '../../utils/shell/powershellDetection.js'
|
||||
import {
|
||||
getDefaultBashTimeoutMs,
|
||||
getMaxBashTimeoutMs,
|
||||
} from '../../utils/timeouts.js'
|
||||
import { FILE_EDIT_TOOL_NAME } from '../FileEditTool/constants.js'
|
||||
import { FILE_READ_TOOL_NAME } from '../FileReadTool/prompt.js'
|
||||
import { FILE_WRITE_TOOL_NAME } from '../FileWriteTool/prompt.js'
|
||||
import { GLOB_TOOL_NAME } from '../GlobTool/prompt.js'
|
||||
import { GREP_TOOL_NAME } from '../GrepTool/prompt.js'
|
||||
import { POWERSHELL_TOOL_NAME } from './toolName.js'
|
||||
|
||||
export function getDefaultTimeoutMs(): number {
|
||||
return getDefaultBashTimeoutMs()
|
||||
}
|
||||
|
||||
export function getMaxTimeoutMs(): number {
|
||||
return getMaxBashTimeoutMs()
|
||||
}
|
||||
|
||||
function getBackgroundUsageNote(): string | null {
|
||||
if (isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS)) {
|
||||
return null
|
||||
}
|
||||
return ` - You can use the \`run_in_background\` parameter to run the command in the background. Only use this if you don't need the result immediately and are OK being notified when the command completes later. You do not need to check the output right away - you'll be notified when it finishes.`
|
||||
}
|
||||
|
||||
function getSleepGuidance(): string | null {
|
||||
if (isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS)) {
|
||||
return null
|
||||
}
|
||||
return ` - Avoid unnecessary \`Start-Sleep\` commands:
|
||||
- Do not sleep between commands that can run immediately — just run them.
|
||||
- If your command is long running and you would like to be notified when it finishes — simply run your command using \`run_in_background\`. There is no need to sleep in this case.
|
||||
- Do not retry failing commands in a sleep loop — diagnose the root cause or consider an alternative approach.
|
||||
- If waiting for a background task you started with \`run_in_background\`, you will be notified when it completes — do not poll.
|
||||
- If you must poll an external process, use a check command rather than sleeping first.
|
||||
- If you must sleep, keep the duration short (1-5 seconds) to avoid blocking the user.`
|
||||
}
|
||||
|
||||
/**
|
||||
* Version-specific syntax guidance. The model's training data covers both
|
||||
* editions but it can't tell which one it's targeting, so it either emits
|
||||
* pwsh-7 syntax on 5.1 (parser error → exit 1) or needlessly avoids && on 7.
|
||||
*/
|
||||
function getEditionSection(edition: PowerShellEdition | null): string {
|
||||
if (edition === 'desktop') {
|
||||
return `PowerShell edition: Windows PowerShell 5.1 (powershell.exe)
|
||||
- Pipeline chain operators \`&&\` and \`||\` are NOT available — they cause a parser error. To run B only if A succeeds: \`A; if ($?) { B }\`. To chain unconditionally: \`A; B\`.
|
||||
- Ternary (\`?:\`), null-coalescing (\`??\`), and null-conditional (\`?.\`) operators are NOT available. Use \`if/else\` and explicit \`$null -eq\` checks instead.
|
||||
- Avoid \`2>&1\` on native executables. In 5.1, redirecting a native command's stderr inside PowerShell wraps each line in an ErrorRecord (NativeCommandError) and sets \`$?\` to \`$false\` even when the exe returned exit code 0. stderr is already captured for you — don't redirect it.
|
||||
- Default file encoding is UTF-16 LE (with BOM). When writing files other tools will read, pass \`-Encoding utf8\` to \`Out-File\`/\`Set-Content\`.
|
||||
- \`ConvertFrom-Json\` returns a PSCustomObject, not a hashtable. \`-AsHashtable\` is not available.`
|
||||
}
|
||||
if (edition === 'core') {
|
||||
return `PowerShell edition: PowerShell 7+ (pwsh)
|
||||
- Pipeline chain operators \`&&\` and \`||\` ARE available and work like bash. Prefer \`cmd1 && cmd2\` over \`cmd1; cmd2\` when cmd2 should only run if cmd1 succeeds.
|
||||
- Ternary (\`$cond ? $a : $b\`), null-coalescing (\`??\`), and null-conditional (\`?.\`) operators are available.
|
||||
- Default file encoding is UTF-8 without BOM.`
|
||||
}
|
||||
// Detection not yet resolved (first prompt build before any tool call) or
|
||||
// PS not installed. Give the conservative 5.1-safe guidance.
|
||||
return `PowerShell edition: unknown — assume Windows PowerShell 5.1 for compatibility
|
||||
- Do NOT use \`&&\`, \`||\`, ternary \`?:\`, null-coalescing \`??\`, or null-conditional \`?.\`. These are PowerShell 7+ only and parser-error on 5.1.
|
||||
- To chain commands conditionally: \`A; if ($?) { B }\`. Unconditionally: \`A; B\`.`
|
||||
}
|
||||
|
||||
export async function getPrompt(): Promise<string> {
|
||||
const backgroundNote = getBackgroundUsageNote()
|
||||
const sleepGuidance = getSleepGuidance()
|
||||
const edition = await getPowerShellEdition()
|
||||
|
||||
return `Executes a given PowerShell command with optional timeout. Working directory persists between commands; shell state (variables, functions) does not.
|
||||
|
||||
IMPORTANT: This tool is for terminal operations via PowerShell: git, npm, docker, and PS cmdlets. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead.
|
||||
|
||||
${getEditionSection(edition)}
|
||||
|
||||
Before executing the command, please follow these steps:
|
||||
|
||||
1. Directory Verification:
|
||||
- If the command will create new directories or files, first use \`Get-ChildItem\` (or \`ls\`) to verify the parent directory exists and is the correct location
|
||||
|
||||
2. Command Execution:
|
||||
- Always quote file paths that contain spaces with double quotes
|
||||
- Capture the output of the command.
|
||||
|
||||
PowerShell Syntax Notes:
|
||||
- Variables use $ prefix: $myVar = "value"
|
||||
- Escape character is backtick (\`), not backslash
|
||||
- Use Verb-Noun cmdlet naming: Get-ChildItem, Set-Location, New-Item, Remove-Item
|
||||
- Common aliases: ls (Get-ChildItem), cd (Set-Location), cat (Get-Content), rm (Remove-Item)
|
||||
- Pipe operator | works similarly to bash but passes objects, not text
|
||||
- Use Select-Object, Where-Object, ForEach-Object for filtering and transformation
|
||||
- String interpolation: "Hello $name" or "Hello $($obj.Property)"
|
||||
- Registry access uses PSDrive prefixes: \`HKLM:\\SOFTWARE\\...\`, \`HKCU:\\...\` — NOT raw \`HKEY_LOCAL_MACHINE\\...\`
|
||||
- Environment variables: read with \`$env:NAME\`, set with \`$env:NAME = "value"\` (NOT \`Set-Variable\` or bash \`export\`)
|
||||
- Call native exe with spaces in path via call operator: \`& "C:\\Program Files\\App\\app.exe" arg1 arg2\`
|
||||
|
||||
Interactive and blocking commands (will hang — this tool runs with -NonInteractive):
|
||||
- NEVER use \`Read-Host\`, \`Get-Credential\`, \`Out-GridView\`, \`$Host.UI.PromptForChoice\`, or \`pause\`
|
||||
- Destructive cmdlets (\`Remove-Item\`, \`Stop-Process\`, \`Clear-Content\`, etc.) may prompt for confirmation. Add \`-Confirm:$false\` when you intend the action to proceed. Use \`-Force\` for read-only/hidden items.
|
||||
- Never use \`git rebase -i\`, \`git add -i\`, or other commands that open an interactive editor
|
||||
|
||||
Passing multiline strings (commit messages, file content) to native executables:
|
||||
- Use a single-quoted here-string so PowerShell does not expand \`$\` or backticks inside. The closing \`'@\` MUST be at column 0 (no leading whitespace) on its own line — indenting it is a parse error:
|
||||
<example>
|
||||
git commit -m @'
|
||||
Commit message here.
|
||||
Second line with $literal dollar signs.
|
||||
'@
|
||||
</example>
|
||||
- Use \`@'...'@\` (single-quoted, literal) not \`@"..."@\` (double-quoted, interpolated) unless you need variable expansion
|
||||
- For arguments containing \`-\`, \`@\`, or other characters PowerShell parses as operators, use the stop-parsing token: \`git log --% --format=%H\`
|
||||
|
||||
Usage notes:
|
||||
- The command argument is required.
|
||||
- You can specify an optional timeout in milliseconds (up to ${getMaxTimeoutMs()}ms / ${getMaxTimeoutMs() / 60000} minutes). If not specified, commands will timeout after ${getDefaultTimeoutMs()}ms (${getDefaultTimeoutMs() / 60000} minutes).
|
||||
- It is very helpful if you write a clear, concise description of what this command does.
|
||||
- If the output exceeds ${getMaxOutputLength()} characters, output will be truncated before being returned to you.
|
||||
${backgroundNote ? backgroundNote + '\n' : ''}\
|
||||
- Avoid using PowerShell to run commands that have dedicated tools, unless explicitly instructed:
|
||||
- File search: Use ${GLOB_TOOL_NAME} (NOT Get-ChildItem -Recurse)
|
||||
- Content search: Use ${GREP_TOOL_NAME} (NOT Select-String)
|
||||
- Read files: Use ${FILE_READ_TOOL_NAME} (NOT Get-Content)
|
||||
- Edit files: Use ${FILE_EDIT_TOOL_NAME}
|
||||
- Write files: Use ${FILE_WRITE_TOOL_NAME} (NOT Set-Content/Out-File)
|
||||
- Communication: Output text directly (NOT Write-Output/Write-Host)
|
||||
- When issuing multiple commands:
|
||||
- If the commands are independent and can run in parallel, make multiple ${POWERSHELL_TOOL_NAME} tool calls in a single message.
|
||||
- If the commands depend on each other and must run sequentially, chain them in a single ${POWERSHELL_TOOL_NAME} call (see edition-specific chaining syntax above).
|
||||
- Use \`;\` only when you need to run commands sequentially but don't care if earlier commands fail.
|
||||
- DO NOT use newlines to separate commands (newlines are ok in quoted strings and here-strings)
|
||||
- Do NOT prefix commands with \`cd\` or \`Set-Location\` -- the working directory is already set to the correct project directory automatically.
|
||||
${sleepGuidance ? sleepGuidance + '\n' : ''}\
|
||||
- For git commands:
|
||||
- Prefer to create a new commit rather than amending an existing commit.
|
||||
- Before running destructive operations (e.g., git reset --hard, git push --force, git checkout --), consider whether there is a safer alternative that achieves the same goal. Only use destructive operations when they are truly the best approach.
|
||||
- Never skip hooks (--no-verify) or bypass signing (--no-gpg-sign, -c commit.gpgsign=false) unless the user has explicitly asked for it. If a hook fails, investigate and fix the underlying issue.`
|
||||
}
|
||||
1823
src/tools/PowerShellTool/readOnlyValidation.ts
Normal file
1823
src/tools/PowerShellTool/readOnlyValidation.ts
Normal file
File diff suppressed because it is too large
Load Diff
2
src/tools/PowerShellTool/toolName.ts
Normal file
2
src/tools/PowerShellTool/toolName.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Here to break circular dependency from prompt.ts
|
||||
export const POWERSHELL_TOOL_NAME = 'PowerShell' as const
|
||||
Reference in New Issue
Block a user