init claude-code

This commit is contained in:
2026-04-01 17:32:37 +02:00
commit 73b208c009
1902 changed files with 513237 additions and 0 deletions
+185
View File
@@ -0,0 +1,185 @@
/**
* Shared constants for PowerShell cmdlets that execute arbitrary code.
*
* These lists are consumed by both the permission-engine validators
* (powershellSecurity.ts) and the UI suggestion gate (staticPrefix.ts).
* Keeping them here avoids duplicating the lists and prevents sync drift
* — add a cmdlet once, both consumers pick it up.
*/
import { CROSS_PLATFORM_CODE_EXEC } from '../permissions/dangerousPatterns.js'
import { COMMON_ALIASES } from './parser.js'
/**
* Cmdlets that accept a -FilePath (or positional path) and execute the
* file's contents as a script.
*/
export const FILEPATH_EXECUTION_CMDLETS = new Set([
'invoke-command',
'start-job',
'start-threadjob',
'register-scheduledjob',
])
/**
* Cmdlets where a scriptblock argument executes arbitrary code (not just
* filtering/transforming pipeline input like Where-Object).
*/
export const DANGEROUS_SCRIPT_BLOCK_CMDLETS = new Set([
'invoke-command',
'invoke-expression',
'start-job',
'start-threadjob',
'register-scheduledjob',
'register-engineevent',
'register-objectevent',
'register-wmievent',
'new-pssession',
'enter-pssession',
])
/**
* Cmdlets that load and execute module/script code. `.psm1` files run
* their top-level body on import — same code-execution risk as iex.
*/
export const MODULE_LOADING_CMDLETS = new Set([
'import-module',
'ipmo',
'install-module',
'save-module',
'update-module',
'install-script',
'save-script',
])
/**
* Shells and process spawners. Small, stable — add here only for cmdlets
* not covered by the validator lists above.
*/
const SHELLS_AND_SPAWNERS = [
'pwsh',
'powershell',
'cmd',
'bash',
'wsl',
'sh',
'start-process',
'start',
'add-type',
'new-object',
] as const
function aliasesOf(targets: ReadonlySet<string>): string[] {
return Object.entries(COMMON_ALIASES)
.filter(([, target]) => targets.has(target.toLowerCase()))
.map(([alias]) => alias)
}
/**
* Network cmdlets — wildcard rules for these enable exfil/download without
* prompt. No legitimate narrow prefix exists.
*/
export const NETWORK_CMDLETS = new Set([
'invoke-webrequest',
'invoke-restmethod',
])
/**
* Alias/variable mutation cmdlets — Set-Alias rebinds command resolution,
* Set-Variable can poison $PSDefaultParameterValues. checkRuntimeStateManipulation
* validator in powershellSecurity.ts independently gates on the permission path.
*/
export const ALIAS_HIJACK_CMDLETS = new Set([
'set-alias',
'sal', // alias not in COMMON_ALIASES — list explicitly
'new-alias',
'nal', // alias not in COMMON_ALIASES — list explicitly
'set-variable',
'sv', // alias not in COMMON_ALIASES — list explicitly
'new-variable',
'nv', // alias not in COMMON_ALIASES — list explicitly
])
/**
* WMI/CIM process spawn — Invoke-WmiMethod -Class Win32_Process -Name Create
* is a Start-Process equivalent that bypasses checkStartProcess. No legitimate
* narrow prefix exists; any invocation can spawn arbitrary processes.
* checkWmiProcessSpawn validator gates on the permission path.
* (security finding #34)
*/
export const WMI_CIM_CMDLETS = new Set([
'invoke-wmimethod',
'iwmi', // alias not in COMMON_ALIASES — list explicitly
'invoke-cimmethod',
])
/**
* Cmdlets in CMDLET_ALLOWLIST with additionalCommandIsDangerousCallback.
*
* The allowlist auto-allows these for safe args (StringConstant identifiers).
* The permission dialog only fires when the callback rejected — i.e. the args
* contain a scriptblock, variable, subexpression, etc. Accepting a
* `Cmdlet:*` wildcard at that point would match ALL future invocations via
* prefix-startsWith, bypassing the callback forever.
* `ForEach-Object:*` → `ForEach-Object { Remove-Item -Recurse / }` auto-allows.
*
* Sync with readOnlyValidation.ts — test/utils/powershell/dangerousCmdlets.test.ts
* asserts this set covers every additionalCommandIsDangerousCallback entry.
*/
export const ARG_GATED_CMDLETS = new Set([
'select-object',
'sort-object',
'group-object',
'where-object',
'measure-object',
'write-output',
'write-host',
'start-sleep',
'format-table',
'format-list',
'format-wide',
'format-custom',
'out-string',
'out-host',
// Native executables with callback-gated args (e.g. ipconfig /flushdns
// is rejected, ipconfig /all is allowed). Same bypass risk.
'ipconfig',
'hostname',
'route',
])
/**
* Commands to never suggest as a wildcard prefix in the permission dialog.
*
* Derived from the validator lists above plus the small static shells list.
* Add a cmdlet to the appropriate validator list and it automatically
* appears here — no separate maintenance.
*/
export const NEVER_SUGGEST: ReadonlySet<string> = (() => {
const core = new Set<string>([
...SHELLS_AND_SPAWNERS,
...FILEPATH_EXECUTION_CMDLETS,
...DANGEROUS_SCRIPT_BLOCK_CMDLETS,
...MODULE_LOADING_CMDLETS,
...NETWORK_CMDLETS,
...ALIAS_HIJACK_CMDLETS,
...WMI_CIM_CMDLETS,
...ARG_GATED_CMDLETS,
// ForEach-Object's -MemberName (positional: `% Delete`) resolves against
// the runtime pipeline object — `Get-ChildItem | % Delete` invokes
// FileInfo.Delete(). StaticParameterBinder identifies the
// PropertyAndMethodSet parameter set, but the set handles both; the arg
// is a plain StringConstantExpressionAst with no property/method signal.
// Pipeline type inference (upstream OutputType → GetMember) misses ETS
// AliasProperty members and has no answer for `$var | %` or external
// upstream. Not in ARG_GATED (no allowlist entry to sync with).
'foreach-object',
// Interpreters/runners — `node script.js` stops at the file arg and
// suggests bare `node:*`, auto-allowing arbitrary code via -e/-p. The
// auto-mode classifier strips these rules (isDangerousPowerShellPermission)
// but the suggestion gate didn't. Multi-word entries ('npm run') are
// filtered out — NEVER_SUGGEST is a single-name lookup on cmd.name.
...CROSS_PLATFORM_CODE_EXEC.filter(p => !p.includes(' ')),
])
return new Set([...core, ...aliasesOf(core)])
})()
File diff suppressed because it is too large Load Diff
+316
View File
@@ -0,0 +1,316 @@
/**
* PowerShell static command prefix extraction.
*
* Mirrors bash's getCommandPrefixStatic / getCompoundCommandPrefixesStatic
* (src/utils/bash/prefix.ts) but uses the PowerShell AST parser instead of
* tree-sitter. The AST gives us cmd.name and cmd.args already split; for
* external commands we feed those into the same fig-spec walker bash uses
* (src/utils/shell/specPrefix.ts) — git/npm/kubectl CLIs are shell-agnostic.
*
* Feeds the "Yes, and don't ask again for: ___" editable input in the
* permission dialog — static extractor provides a best-guess prefix, user
* edits it down if needed.
*/
import { getCommandSpec } from '../bash/registry.js'
import { buildPrefix, DEPTH_RULES } from '../shell/specPrefix.js'
import { countCharInString } from '../stringUtils.js'
import { NEVER_SUGGEST } from './dangerousCmdlets.js'
import {
getAllCommands,
type ParsedCommandElement,
parsePowerShellCommand,
} from './parser.js'
/**
* Extract a static prefix from a single parsed command element.
* Returns null for commands we won't suggest (shells, eval cmdlets, path-like
* invocations) or can't extract a meaningful prefix from.
*/
async function extractPrefixFromElement(
cmd: ParsedCommandElement,
): Promise<string | null> {
// nameType === 'application' means the raw name had path chars (./x, x\y,
// x.exe) — PowerShell will run a file, not a named cmdlet. Don't suggest.
// Same reasoning as the permission engine's nameType gate (PR #20096).
if (cmd.nameType === 'application') {
return null
}
const name = cmd.name
if (!name) {
return null
}
if (NEVER_SUGGEST.has(name.toLowerCase())) {
return null
}
// Cmdlets (Verb-Noun): the name alone is the right prefix granularity.
// Get-Process -Name pwsh → Get-Process. There's no subcommand concept.
if (cmd.nameType === 'cmdlet') {
return name
}
// External command. Guard the argv before feeding it to buildPrefix.
//
// elementTypes[0] (command name) must be a literal. `& $cmd status` has
// elementTypes[0]='Variable', name='$cmd' — classifies as 'unknown' (no path
// chars), passes NEVER_SUGGEST, getCommandSpec('$cmd')=null → returns bare
// '$cmd' → dead rule. Cheap to gate here.
//
// elementTypes[1..] (args) must all be StringConstant or Parameter. Anything
// dynamic (Variable/SubExpression/ScriptBlock/ExpandableString) would embed
// `$foo`/`$(...)` in the prefix → dead rule.
if (cmd.elementTypes?.[0] !== 'StringConstant') {
return null
}
for (let i = 0; i < cmd.args.length; i++) {
const t = cmd.elementTypes[i + 1]
if (t !== 'StringConstant' && t !== 'Parameter') {
return null
}
}
// Consult the fig spec — same oracle bash uses. If git's spec says -C takes
// a value, buildPrefix skips -C /repo and finds `status` as a subcommand.
// Lowercase for lookup: fig specs are filesystem paths (git.js), case-
// sensitive on Linux. PowerShell is case-insensitive (Git === git) so `Git`
// must resolve to the git spec. macOS hides this bug (case-insensitive fs).
// Call buildPrefix unconditionally — calculateDepth consults DEPTH_RULES
// before its own `if (!spec) return 2` fallback, so gcloud/aws/kubectl/az
// get depth-aware prefixes even without a loaded spec. The old
// `if (!spec) return name` short-circuit produced bare `gcloud:*` which
// auto-allows every gcloud subcommand.
const nameLower = name.toLowerCase()
const spec = await getCommandSpec(nameLower)
const prefix = await buildPrefix(name, cmd.args, spec)
// Post-buildPrefix word integrity: buildPrefix space-joins consumed args
// into the prefix string. parser.ts:685 stores .value (quote-stripped) for
// single-quoted literals: git 'push origin' → args=['push origin']. If
// that arg is consumed, buildPrefix emits 'git push origin' — silently
// promoting 1 argv element to 3 prefix words. Rule PowerShell(git push
// origin:*) then matches `git push origin --force` (3-element argv) — not
// what the user approved.
//
// The old set-membership check (`!cmd.args.includes(word)`) was defeated
// by decoy args: `git 'push origin' push origin` → args=['push origin',
// 'push', 'origin'], prefix='git push origin'. Each word ∈ args (decoys at
// indices 1,2 satisfy .includes()) → passed. Now POSITIONAL: walk args in
// order; each prefix word must exactly match the next non-flag arg. A
// positional that doesn't match means buildPrefix split it. Flags and
// their values are skipped (buildPrefix skips them too) so
// `git -C '/my repo' status` and `git commit -m 'fix typo'` still pass.
// Backslash (C:\repo) rejected: dead over-specific rule.
let argIdx = 0
for (const word of prefix.split(' ').slice(1)) {
if (word.includes('\\')) return null
while (argIdx < cmd.args.length) {
const a = cmd.args[argIdx]!
if (a === word) break
if (a.startsWith('-')) {
argIdx++
// Only skip the flag's value if the spec says this flag takes a
// value argument. Without spec info, treat as a switch (no value)
// — fail-safe avoids over-skipping positional args. (bug #16)
if (
spec?.options &&
argIdx < cmd.args.length &&
cmd.args[argIdx] !== word &&
!cmd.args[argIdx]!.startsWith('-')
) {
const flagLower = a.toLowerCase()
const opt = spec.options.find(o =>
Array.isArray(o.name)
? o.name.includes(flagLower)
: o.name === flagLower,
)
if (opt?.args) {
argIdx++
}
}
continue
}
// Positional arg that isn't the expected word → arg was split.
return null
}
if (argIdx >= cmd.args.length) return null
argIdx++
}
// Bare-root guard: buildPrefix returns 'git' for `git` with no subcommand
// found (empty args, or only global flags). That's too broad — would
// auto-allow `git push --force` forever. Bash's extractor doesn't gate this
// (bash/prefix.ts:363, separate fix). Reject single-word results for
// commands whose spec declares subcommands OR that have DEPTH_RULES entries
// (gcloud, aws, kubectl, etc.) which implies subcommand structure even
// without a loaded spec. (bug #17)
if (
!prefix.includes(' ') &&
(spec?.subcommands?.length || DEPTH_RULES[nameLower])
) {
return null
}
return prefix
}
/**
* Extract a prefix suggestion for a PowerShell command.
*
* Parses the command, takes the first CommandAst, returns a prefix suitable
* for the permission dialog's "don't ask again for: ___" editable input.
* Returns null when no safe prefix can be extracted (parse failure, shell
* invocation, path-like name, bare subcommand-aware command).
*/
export async function getCommandPrefixStatic(
command: string,
): Promise<{ commandPrefix: string | null } | null> {
const parsed = await parsePowerShellCommand(command)
if (!parsed.valid) {
return null
}
// Find the first actual command (CommandAst). getAllCommands iterates
// both statement.commands and statement.nestedCommands (for &&/||/if/for).
// Skip synthetic CommandExpressionAst entries (expression pipeline sources,
// non-PipelineAst statement placeholders).
const firstCommand = getAllCommands(parsed).find(
cmd => cmd.elementType === 'CommandAst',
)
if (!firstCommand) {
return { commandPrefix: null }
}
return { commandPrefix: await extractPrefixFromElement(firstCommand) }
}
/**
* Extract prefixes for all subcommands in a compound PowerShell command.
*
* For `Get-Process; git status && npm test`, returns per-subcommand prefixes.
* Subcommands for which `excludeSubcommand` returns true (e.g. already
* read-only/auto-allowed) are skipped — no point suggesting a rule for them.
* Prefixes sharing a root are collapsed via word-aligned LCP:
* `npm run test && npm run lint` → `npm run`.
*
* The filter receives the ParsedCommandElement (not cmd.text) because
* PowerShell's read-only check (isAllowlistedCommand) needs the element's
* structured fields (nameType, args). Passing text would require reparsing,
* which spawns pwsh.exe per subcommand — expensive and wasteful since we
* already have the parsed elements here. Bash's equivalent passes text
* because BashTool.isReadOnly works from regex/patterns, not parsed AST.
*/
export async function getCompoundCommandPrefixesStatic(
command: string,
excludeSubcommand?: (element: ParsedCommandElement) => boolean,
): Promise<string[]> {
const parsed = await parsePowerShellCommand(command)
if (!parsed.valid) {
return []
}
const commands = getAllCommands(parsed).filter(
cmd => cmd.elementType === 'CommandAst',
)
// Single command — no compound collapse needed.
if (commands.length <= 1) {
const prefix = commands[0]
? await extractPrefixFromElement(commands[0])
: null
return prefix ? [prefix] : []
}
const prefixes: string[] = []
for (const cmd of commands) {
if (excludeSubcommand?.(cmd)) {
continue
}
const prefix = await extractPrefixFromElement(cmd)
if (prefix) {
prefixes.push(prefix)
}
}
if (prefixes.length === 0) {
return []
}
// Group by root command (first word) and collapse each group via
// word-aligned longest common prefix. `npm run test` + `npm run lint`
// → `npm run`. But NEVER collapse down to a bare subcommand-aware root:
// `git add` + `git commit` would LCP to `git`, which extractPrefixFromElement
// explicitly refuses as too broad (line ~119). Collapsing through that gate
// would suggest PowerShell(git:*) → auto-allows git push --force forever.
// When LCP yields a bare subcommand-aware root, drop the group entirely
// rather than suggest either the too-broad root or N un-collapsed rules.
//
// Bash's getCompoundCommandPrefixesStatic has this same collapse without
// the guard (src/utils/bash/prefix.ts:360-365) — that's a separate fix.
//
// Grouping and word-comparison are case-insensitive (PowerShell is
// case-insensitive: Git === git, Get-Process === get-process). The Map key
// is lowercased; the emitted prefix keeps the first-seen casing.
const groups = new Map<string, string[]>()
for (const prefix of prefixes) {
const root = prefix.split(' ')[0]!
const key = root.toLowerCase()
const group = groups.get(key)
if (group) {
group.push(prefix)
} else {
groups.set(key, [prefix])
}
}
const collapsed: string[] = []
for (const [rootLower, group] of groups) {
const lcp = wordAlignedLCP(group)
const lcpWordCount = lcp === '' ? 0 : countCharInString(lcp, ' ') + 1
if (lcpWordCount <= 1) {
// LCP collapsed to a single word. If that root's fig spec declares
// subcommands, this is the same too-broad case extractPrefixFromElement
// rejects (bare `git` → allows `git push --force`). Drop the group.
// getCommandSpec is LRU-memoized; one lookup per distinct root.
const rootSpec = await getCommandSpec(rootLower)
if (rootSpec?.subcommands?.length || DEPTH_RULES[rootLower]) {
continue
}
}
collapsed.push(lcp)
}
return collapsed
}
/**
* Word-aligned longest common prefix. Doesn't chop mid-word.
* Case-insensitive comparison (PowerShell: Git === git), emits first
* string's casing.
* ["npm run test", "npm run lint"] → "npm run"
* ["Git status", "git log"] → "Git" (first-seen casing)
* ["Get-Process"] → "Get-Process"
*/
function wordAlignedLCP(strings: string[]): string {
if (strings.length === 0) return ''
if (strings.length === 1) return strings[0]!
const firstWords = strings[0]!.split(' ')
let commonWordCount = firstWords.length
for (let i = 1; i < strings.length; i++) {
const words = strings[i]!.split(' ')
let matchCount = 0
while (
matchCount < commonWordCount &&
matchCount < words.length &&
words[matchCount]!.toLowerCase() === firstWords[matchCount]!.toLowerCase()
) {
matchCount++
}
commonWordCount = matchCount
if (commonWordCount === 0) break
}
return firstWords.slice(0, commonWordCount).join(' ')
}