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
+255
View File
@@ -0,0 +1,255 @@
import { feature } from 'bun:bundle'
import { access } from 'fs/promises'
import { tmpdir as osTmpdir } from 'os'
import { join as nativeJoin } from 'path'
import { join as posixJoin } from 'path/posix'
import { rearrangePipeCommand } from '../bash/bashPipeCommand.js'
import { createAndSaveSnapshot } from '../bash/ShellSnapshot.js'
import { formatShellPrefixCommand } from '../bash/shellPrefix.js'
import { quote } from '../bash/shellQuote.js'
import {
quoteShellCommand,
rewriteWindowsNullRedirect,
shouldAddStdinRedirect,
} from '../bash/shellQuoting.js'
import { logForDebugging } from '../debug.js'
import { getPlatform } from '../platform.js'
import { getSessionEnvironmentScript } from '../sessionEnvironment.js'
import { getSessionEnvVars } from '../sessionEnvVars.js'
import {
ensureSocketInitialized,
getClaudeTmuxEnv,
hasTmuxToolBeenUsed,
} from '../tmuxSocket.js'
import { windowsPathToPosixPath } from '../windowsPaths.js'
import type { ShellProvider } from './shellProvider.js'
/**
* Returns a shell command to disable extended glob patterns for security.
* Extended globs (bash extglob, zsh EXTENDED_GLOB) can be exploited via
* malicious filenames that expand after our security validation.
*
* When CLAUDE_CODE_SHELL_PREFIX is set, the actual executing shell may differ
* from shellPath (e.g., shellPath is zsh but the wrapper runs bash). In this
* case, we include commands for BOTH shells. We redirect both stdout and stderr
* to /dev/null because zsh's command_not_found_handler writes to STDOUT.
*
* When no shell prefix is set, we use the appropriate command for the detected shell.
*/
function getDisableExtglobCommand(shellPath: string): string | null {
// When CLAUDE_CODE_SHELL_PREFIX is set, the wrapper may use a different shell
// than shellPath, so we include both bash and zsh commands
if (process.env.CLAUDE_CODE_SHELL_PREFIX) {
// Redirect both stdout and stderr because zsh's command_not_found_handler
// writes to stdout instead of stderr
return '{ shopt -u extglob || setopt NO_EXTENDED_GLOB; } >/dev/null 2>&1 || true'
}
// No shell prefix - use shell-specific command
if (shellPath.includes('bash')) {
return 'shopt -u extglob 2>/dev/null || true'
} else if (shellPath.includes('zsh')) {
return 'setopt NO_EXTENDED_GLOB 2>/dev/null || true'
}
// Unknown shell - do nothing, we don't know the right command
return null
}
export async function createBashShellProvider(
shellPath: string,
options?: { skipSnapshot?: boolean },
): Promise<ShellProvider> {
let currentSandboxTmpDir: string | undefined
const snapshotPromise: Promise<string | undefined> = options?.skipSnapshot
? Promise.resolve(undefined)
: createAndSaveSnapshot(shellPath).catch(error => {
logForDebugging(`Failed to create shell snapshot: ${error}`)
return undefined
})
// Track the last resolved snapshot path for use in getSpawnArgs
let lastSnapshotFilePath: string | undefined
return {
type: 'bash',
shellPath,
detached: true,
async buildExecCommand(
command: string,
opts: {
id: number | string
sandboxTmpDir?: string
useSandbox: boolean
},
): Promise<{ commandString: string; cwdFilePath: string }> {
let snapshotFilePath = await snapshotPromise
// This access() check is NOT pure TOCTOU — it's the fallback decision
// point for getSpawnArgs. When the snapshot disappears mid-session
// (tmpdir cleanup), we must clear lastSnapshotFilePath so getSpawnArgs
// adds -l and the command gets login-shell init. Without this check,
// `source ... || true` silently fails and commands run with NO shell
// init (neither snapshot env nor login profile). The `|| true` on source
// still guards the race between this check and the spawned shell.
if (snapshotFilePath) {
try {
await access(snapshotFilePath)
} catch {
logForDebugging(
`Snapshot file missing, falling back to login shell: ${snapshotFilePath}`,
)
snapshotFilePath = undefined
}
}
lastSnapshotFilePath = snapshotFilePath
// Stash sandboxTmpDir for use in getEnvironmentOverrides
currentSandboxTmpDir = opts.sandboxTmpDir
const tmpdir = osTmpdir()
const isWindows = getPlatform() === 'windows'
const shellTmpdir = isWindows ? windowsPathToPosixPath(tmpdir) : tmpdir
// shellCwdFilePath: POSIX path used inside the bash command (pwd -P >| ...)
// cwdFilePath: native OS path used by Node.js for readFileSync/unlinkSync
// On non-Windows these are identical; on Windows, Git Bash needs POSIX paths
// but Node.js needs native Windows paths for file operations.
const shellCwdFilePath = opts.useSandbox
? posixJoin(opts.sandboxTmpDir!, `cwd-${opts.id}`)
: posixJoin(shellTmpdir, `claude-${opts.id}-cwd`)
const cwdFilePath = opts.useSandbox
? posixJoin(opts.sandboxTmpDir!, `cwd-${opts.id}`)
: nativeJoin(tmpdir, `claude-${opts.id}-cwd`)
// Defensive rewrite: the model sometimes emits Windows CMD-style `2>nul`
// redirects. In POSIX bash (including Git Bash on Windows), this creates a
// literal file named `nul` — a reserved device name that breaks git.
// See anthropics/claude-code#4928.
const normalizedCommand = rewriteWindowsNullRedirect(command)
const addStdinRedirect = shouldAddStdinRedirect(normalizedCommand)
let quotedCommand = quoteShellCommand(normalizedCommand, addStdinRedirect)
// Debug logging for heredoc/multiline commands to trace trailer handling
// Only log when commit attribution is enabled to avoid noise
if (
feature('COMMIT_ATTRIBUTION') &&
(command.includes('<<') || command.includes('\n'))
) {
logForDebugging(
`Shell: Command before quoting (first 500 chars):\n${command.slice(0, 500)}`,
)
logForDebugging(
`Shell: Quoted command (first 500 chars):\n${quotedCommand.slice(0, 500)}`,
)
}
// Special handling for pipes: move stdin redirect after first command
// This ensures the redirect applies to the first command, not to eval itself.
// Without this, `eval 'rg foo | wc -l' \< /dev/null` becomes
// `rg foo | wc -l < /dev/null` — wc reads /dev/null and outputs 0, and
// rg (with no path arg) waits on the open spawn stdin pipe forever.
// Applies to sandbox mode too: sandbox wraps the assembled commandString,
// not the raw command (since PR #9189).
if (normalizedCommand.includes('|') && addStdinRedirect) {
quotedCommand = rearrangePipeCommand(normalizedCommand)
}
const commandParts: string[] = []
// Source the snapshot file. The `|| true` guards the race between the
// access() check above and the spawned shell's `source` — if the file
// vanishes in that window, the `&&` chain still continues.
if (snapshotFilePath) {
const finalPath =
getPlatform() === 'windows'
? windowsPathToPosixPath(snapshotFilePath)
: snapshotFilePath
commandParts.push(`source ${quote([finalPath])} 2>/dev/null || true`)
}
// Source session environment variables captured from session start hooks
const sessionEnvScript = await getSessionEnvironmentScript()
if (sessionEnvScript) {
commandParts.push(sessionEnvScript)
}
// Disable extended glob patterns for security (after sourcing user config to override)
const disableExtglobCmd = getDisableExtglobCommand(shellPath)
if (disableExtglobCmd) {
commandParts.push(disableExtglobCmd)
}
// When sourcing a file with aliases, they won't be expanded in the same command line
// because the shell parses the entire line before execution. Using eval after
// sourcing causes a second parsing pass where aliases are now available for expansion.
commandParts.push(`eval ${quotedCommand}`)
// Use `pwd -P` to get the physical path of the current working directory for consistency with `process.cwd()`
commandParts.push(`pwd -P >| ${quote([shellCwdFilePath])}`)
let commandString = commandParts.join(' && ')
// Apply CLAUDE_CODE_SHELL_PREFIX if set
if (process.env.CLAUDE_CODE_SHELL_PREFIX) {
commandString = formatShellPrefixCommand(
process.env.CLAUDE_CODE_SHELL_PREFIX,
commandString,
)
}
return { commandString, cwdFilePath }
},
getSpawnArgs(commandString: string): string[] {
const skipLoginShell = lastSnapshotFilePath !== undefined
if (skipLoginShell) {
logForDebugging('Spawning shell without login (-l flag skipped)')
}
return ['-c', ...(skipLoginShell ? [] : ['-l']), commandString]
},
async getEnvironmentOverrides(
command: string,
): Promise<Record<string, string>> {
// TMUX SOCKET ISOLATION (DEFERRED):
// We initialize Claude's tmux socket ONLY AFTER the Tmux tool has been used
// at least once, OR if the current command appears to use tmux.
// This defers the startup cost until tmux is actually needed.
//
// Once the Tmux tool is used (or a tmux command runs), all subsequent Bash
// commands will use Claude's isolated socket via the TMUX env var override.
//
// See tmuxSocket.ts for the full isolation architecture documentation.
const commandUsesTmux = command.includes('tmux')
if (
process.env.USER_TYPE === 'ant' &&
(hasTmuxToolBeenUsed() || commandUsesTmux)
) {
await ensureSocketInitialized()
}
const claudeTmuxEnv = getClaudeTmuxEnv()
const env: Record<string, string> = {}
// CRITICAL: Override TMUX to isolate ALL tmux commands to Claude's socket.
// This is NOT the user's TMUX value - it points to Claude's isolated socket.
// When null (before socket initializes), user's TMUX is preserved.
if (claudeTmuxEnv) {
env.TMUX = claudeTmuxEnv
}
if (currentSandboxTmpDir) {
let posixTmpDir = currentSandboxTmpDir
if (getPlatform() === 'windows') {
posixTmpDir = windowsPathToPosixPath(posixTmpDir)
}
env.TMPDIR = posixTmpDir
env.CLAUDE_CODE_TMPDIR = posixTmpDir
// Zsh uses TMPPREFIX (default /tmp/zsh) for heredoc temp files,
// not TMPDIR. Set it to a path inside the sandbox tmp dir so
// heredocs work in sandboxed zsh commands.
// Safe to set unconditionally — non-zsh shells ignore TMPPREFIX.
env.TMPPREFIX = posixJoin(posixTmpDir, 'zsh')
}
// Apply session env vars set via /env (child processes only, not the REPL)
for (const [key, value] of getSessionEnvVars()) {
env[key] = value
}
return env
},
}
}
+14
View File
@@ -0,0 +1,14 @@
import { validateBoundedIntEnvVar } from '../envValidation.js'
export const BASH_MAX_OUTPUT_UPPER_LIMIT = 150_000
export const BASH_MAX_OUTPUT_DEFAULT = 30_000
export function getMaxOutputLength(): number {
const result = validateBoundedIntEnvVar(
'BASH_MAX_OUTPUT_LENGTH',
process.env.BASH_MAX_OUTPUT_LENGTH,
BASH_MAX_OUTPUT_DEFAULT,
BASH_MAX_OUTPUT_UPPER_LIMIT,
)
return result.effective
}
+107
View File
@@ -0,0 +1,107 @@
import { realpath, stat } from 'fs/promises'
import { getPlatform } from '../platform.js'
import { which } from '../which.js'
async function probePath(p: string): Promise<string | null> {
try {
return (await stat(p)).isFile() ? p : null
} catch {
return null
}
}
/**
* Attempts to find PowerShell on the system via PATH.
* Prefers pwsh (PowerShell Core 7+), falls back to powershell (5.1).
*
* On Linux, if PATH resolves to a snap launcher (/snap/…) — directly or
* via a symlink chain like /usr/bin/pwsh → /snap/bin/pwsh — probe known
* apt/rpm install locations instead: the snap launcher can hang in
* subprocesses while snapd initializes confinement, but the underlying
* binary at /opt/microsoft/powershell/7/pwsh is reliable. On
* Windows/macOS, PATH is sufficient.
*/
export async function findPowerShell(): Promise<string | null> {
const pwshPath = await which('pwsh')
if (pwshPath) {
// Snap launcher hangs in subprocesses. Prefer the direct binary.
// Check both the resolved PATH entry and its symlink target: on
// some distros /usr/bin/pwsh is a symlink to /snap/bin/pwsh, which
// would bypass a naive startsWith('/snap/') on the which() result.
if (getPlatform() === 'linux') {
const resolved = await realpath(pwshPath).catch(() => pwshPath)
if (pwshPath.startsWith('/snap/') || resolved.startsWith('/snap/')) {
const direct =
(await probePath('/opt/microsoft/powershell/7/pwsh')) ??
(await probePath('/usr/bin/pwsh'))
if (direct) {
const directResolved = await realpath(direct).catch(() => direct)
if (
!direct.startsWith('/snap/') &&
!directResolved.startsWith('/snap/')
) {
return direct
}
}
}
}
return pwshPath
}
const powershellPath = await which('powershell')
if (powershellPath) {
return powershellPath
}
return null
}
let cachedPowerShellPath: Promise<string | null> | null = null
/**
* Gets the cached PowerShell path. Returns a memoized promise that
* resolves to the PowerShell executable path or null.
*/
export function getCachedPowerShellPath(): Promise<string | null> {
if (!cachedPowerShellPath) {
cachedPowerShellPath = findPowerShell()
}
return cachedPowerShellPath
}
export type PowerShellEdition = 'core' | 'desktop'
/**
* Infers the PowerShell edition from the binary name without spawning.
* - `pwsh` / `pwsh.exe` → 'core' (PowerShell 7+: supports `&&`, `||`, `?:`, `??`)
* - `powershell` / `powershell.exe` → 'desktop' (Windows PowerShell 5.1:
* no pipeline chain operators, stderr-sets-$? bug, UTF-16 default encoding)
*
* PowerShell 6 (also `pwsh`, no `&&`) has been EOL since 2020 and is not
* a realistic install target, so 'core' safely implies 7+ semantics.
*
* Used by the tool prompt to give version-appropriate syntax guidance so
* the model doesn't emit `cmd1 && cmd2` on 5.1 (parser error) or avoid
* `&&` on 7+ where it's the correct short-circuiting operator.
*/
export async function getPowerShellEdition(): Promise<PowerShellEdition | null> {
const p = await getCachedPowerShellPath()
if (!p) return null
// basename without extension, case-insensitive. Covers:
// C:\Program Files\PowerShell\7\pwsh.exe
// /opt/microsoft/powershell/7/pwsh
// C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe
const base = p
.split(/[/\\]/)
.pop()!
.toLowerCase()
.replace(/\.exe$/, '')
return base === 'pwsh' ? 'core' : 'desktop'
}
/**
* Resets the cached PowerShell path. Only for testing.
*/
export function resetPowerShellCache(): void {
cachedPowerShellPath = null
}
+123
View File
@@ -0,0 +1,123 @@
import { tmpdir } from 'os'
import { join } from 'path'
import { join as posixJoin } from 'path/posix'
import { getSessionEnvVars } from '../sessionEnvVars.js'
import type { ShellProvider } from './shellProvider.js'
/**
* PowerShell invocation flags + command. Shared by the provider's getSpawnArgs
* and the hook spawn path in hooks.ts so the flag set stays in one place.
*/
export function buildPowerShellArgs(cmd: string): string[] {
return ['-NoProfile', '-NonInteractive', '-Command', cmd]
}
/**
* Base64-encode a string as UTF-16LE for PowerShell's -EncodedCommand.
* Same encoding the parser uses (parser.ts toUtf16LeBase64). The output
* is [A-Za-z0-9+/=] only — survives ANY shell-quoting layer, including
* @anthropic-ai/sandbox-runtime's shellquote.quote() which would otherwise
* corrupt !$? to \!$? when re-wrapping a single-quoted string in double
* quotes. Review 2964609818.
*/
function encodePowerShellCommand(psCommand: string): string {
return Buffer.from(psCommand, 'utf16le').toString('base64')
}
export function createPowerShellProvider(shellPath: string): ShellProvider {
let currentSandboxTmpDir: string | undefined
return {
type: 'powershell' as ShellProvider['type'],
shellPath,
detached: false,
async buildExecCommand(
command: string,
opts: {
id: number | string
sandboxTmpDir?: string
useSandbox: boolean
},
): Promise<{ commandString: string; cwdFilePath: string }> {
// Stash sandboxTmpDir for getEnvironmentOverrides (mirrors bashProvider)
currentSandboxTmpDir = opts.useSandbox ? opts.sandboxTmpDir : undefined
// When sandboxed, tmpdir() is not writable — the sandbox only allows
// writes to sandboxTmpDir. Put the cwd tracking file there so the
// inner pwsh can actually write it. Only applies on Linux/macOS/WSL2;
// on Windows native, sandbox is never enabled so this branch is dead.
const cwdFilePath =
opts.useSandbox && opts.sandboxTmpDir
? posixJoin(opts.sandboxTmpDir, `claude-pwd-ps-${opts.id}`)
: join(tmpdir(), `claude-pwd-ps-${opts.id}`)
const escapedCwdFilePath = cwdFilePath.replace(/'/g, "''")
// Exit-code capture: prefer $LASTEXITCODE when a native exe ran.
// On PS 5.1, a native command that writes to stderr while the stream
// is PS-redirected (e.g. `git push 2>&1`) sets $? = $false even when
// the exe returned exit 0 — so `if (!$?)` reports a false positive.
// $LASTEXITCODE is $null only when no native exe has run in the
// session; in that case fall back to $? for cmdlet-only pipelines.
// Tradeoff: `native-ok; cmdlet-fail` now returns 0 (was 1). Reverse
// is also true: `native-fail; cmdlet-ok` now returns the native
// exit code (was 0 — old logic only looked at $? which the trailing
// cmdlet set true). Both rarer than the git/npm/curl stderr case.
const cwdTracking = `\n; $_ec = if ($null -ne $LASTEXITCODE) { $LASTEXITCODE } elseif ($?) { 0 } else { 1 }\n; (Get-Location).Path | Out-File -FilePath '${escapedCwdFilePath}' -Encoding utf8 -NoNewline\n; exit $_ec`
const psCommand = command + cwdTracking
// Sandbox wraps the returned commandString as `<binShell> -c '<cmd>'` —
// hardcoded `-c`, no way to inject -NoProfile -NonInteractive. So for
// the sandbox path, build a command that itself invokes pwsh with the
// full flag set. Shell.ts passes /bin/sh as the sandbox binShell,
// producing: bwrap ... sh -c 'pwsh -NoProfile ... -EncodedCommand ...'.
// The non-sandbox path returns the bare PS command; getSpawnArgs() adds
// the flags via buildPowerShellArgs().
//
// -EncodedCommand (base64 UTF-16LE), not -Command: the sandbox runtime
// applies its OWN shellquote.quote() on top of whatever we build. Any
// string containing ' triggers double-quote mode which escapes ! as \! —
// POSIX sh preserves that literally, pwsh parse error. Base64 is
// [A-Za-z0-9+/=] — no chars that any quoting layer can corrupt.
// Review 2964609818.
//
// shellPath is POSIX-single-quoted so a space-containing install path
// (e.g. /opt/my tools/pwsh) survives the inner `/bin/sh -c` word-split.
// Flags and base64 are [A-Za-z0-9+/=-] only — no quoting needed.
const commandString = opts.useSandbox
? [
`'${shellPath.replace(/'/g, `'\\''`)}'`,
'-NoProfile',
'-NonInteractive',
'-EncodedCommand',
encodePowerShellCommand(psCommand),
].join(' ')
: psCommand
return { commandString, cwdFilePath }
},
getSpawnArgs(commandString: string): string[] {
return buildPowerShellArgs(commandString)
},
async getEnvironmentOverrides(): Promise<Record<string, string>> {
const env: Record<string, string> = {}
// Apply session env vars set via /env (child processes only, not
// the REPL). Without this, `/env PATH=...` affects Bash tool
// commands but not PowerShell — so PyCharm users with a stripped
// PATH can't self-rescue.
// Ordering: session vars FIRST so the sandbox TMPDIR below can't be
// overridden by `/env TMPDIR=...`. bashProvider.ts has these in the
// opposite order (pre-existing), but sandbox isolation should win.
for (const [key, value] of getSessionEnvVars()) {
env[key] = value
}
if (currentSandboxTmpDir) {
// PowerShell on Linux/macOS honors TMPDIR for [System.IO.Path]::GetTempPath()
env.TMPDIR = currentSandboxTmpDir
env.CLAUDE_CODE_TMPDIR = currentSandboxTmpDir
}
return env
},
}
}
+367
View File
@@ -0,0 +1,367 @@
/**
* Shared command prefix extraction using Haiku LLM
*
* This module provides a factory for creating command prefix extractors
* that can be used by different shell tools. The core logic
* (Haiku query, response validation) is shared, while tool-specific
* aspects (examples, pre-checks) are configurable.
*/
import chalk from 'chalk'
import type { QuerySource } from '../../constants/querySource.js'
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from '../../services/analytics/index.js'
import { queryHaiku } from '../../services/api/claude.js'
import { startsWithApiErrorPrefix } from '../../services/api/errors.js'
import { memoizeWithLRU } from '../memoize.js'
import { jsonStringify } from '../slowOperations.js'
import { asSystemPrompt } from '../systemPromptType.js'
/**
* Shell executables that must never be accepted as bare prefixes.
* Allowing e.g. "bash:*" would let any command through, defeating
* the permission system. Includes Unix shells and Windows equivalents.
*/
const DANGEROUS_SHELL_PREFIXES = new Set([
'sh',
'bash',
'zsh',
'fish',
'csh',
'tcsh',
'ksh',
'dash',
'cmd',
'cmd.exe',
'powershell',
'powershell.exe',
'pwsh',
'pwsh.exe',
'bash.exe',
])
/**
* Result of command prefix extraction
*/
export type CommandPrefixResult = {
/** The detected command prefix, or null if no prefix could be determined */
commandPrefix: string | null
}
/**
* Result including subcommand prefixes for compound commands
*/
export type CommandSubcommandPrefixResult = CommandPrefixResult & {
subcommandPrefixes: Map<string, CommandPrefixResult>
}
/**
* Configuration for creating a command prefix extractor
*/
export type PrefixExtractorConfig = {
/** Tool name for logging and warning messages */
toolName: string
/** The policy spec containing examples for Haiku */
policySpec: string
/** Analytics event name for logging */
eventName: string
/** Query source identifier for the API call */
querySource: QuerySource
/** Optional pre-check function that can short-circuit the Haiku call */
preCheck?: (command: string) => CommandPrefixResult | null
}
/**
* Creates a memoized command prefix extractor function.
*
* Uses two-layer memoization: the outer memoized function creates the promise
* and attaches a .catch handler that evicts the cache entry on rejection.
* This prevents aborted or failed Haiku calls from poisoning future lookups.
*
* Bounded to 200 entries via LRU to prevent unbounded growth in heavy sessions.
*
* @param config - Configuration for the extractor
* @returns A memoized async function that extracts command prefixes
*/
export function createCommandPrefixExtractor(config: PrefixExtractorConfig) {
const { toolName, policySpec, eventName, querySource, preCheck } = config
const memoized = memoizeWithLRU(
(
command: string,
abortSignal: AbortSignal,
isNonInteractiveSession: boolean,
): Promise<CommandPrefixResult | null> => {
const promise = getCommandPrefixImpl(
command,
abortSignal,
isNonInteractiveSession,
toolName,
policySpec,
eventName,
querySource,
preCheck,
)
// Evict on rejection so aborted calls don't poison future turns.
// Identity guard: after LRU eviction, a newer promise may occupy
// this key; a stale rejection must not delete it.
promise.catch(() => {
if (memoized.cache.get(command) === promise) {
memoized.cache.delete(command)
}
})
return promise
},
command => command, // memoize by command only
200,
)
return memoized
}
/**
* Creates a memoized function to get prefixes for compound commands with subcommands.
*
* Uses the same two-layer memoization pattern as createCommandPrefixExtractor:
* a .catch handler evicts the cache entry on rejection to prevent poisoning.
*
* @param getPrefix - The single-command prefix extractor (from createCommandPrefixExtractor)
* @param splitCommand - Function to split a compound command into subcommands
* @returns A memoized async function that extracts prefixes for the main command and all subcommands
*/
export function createSubcommandPrefixExtractor(
getPrefix: ReturnType<typeof createCommandPrefixExtractor>,
splitCommand: (command: string) => string[] | Promise<string[]>,
) {
const memoized = memoizeWithLRU(
(
command: string,
abortSignal: AbortSignal,
isNonInteractiveSession: boolean,
): Promise<CommandSubcommandPrefixResult | null> => {
const promise = getCommandSubcommandPrefixImpl(
command,
abortSignal,
isNonInteractiveSession,
getPrefix,
splitCommand,
)
// Evict on rejection so aborted calls don't poison future turns.
// Identity guard: after LRU eviction, a newer promise may occupy
// this key; a stale rejection must not delete it.
promise.catch(() => {
if (memoized.cache.get(command) === promise) {
memoized.cache.delete(command)
}
})
return promise
},
command => command, // memoize by command only
200,
)
return memoized
}
async function getCommandPrefixImpl(
command: string,
abortSignal: AbortSignal,
isNonInteractiveSession: boolean,
toolName: string,
policySpec: string,
eventName: string,
querySource: QuerySource,
preCheck?: (command: string) => CommandPrefixResult | null,
): Promise<CommandPrefixResult | null> {
if (process.env.NODE_ENV === 'test') {
return null
}
// Run pre-check if provided (e.g., isHelpCommand for Bash)
if (preCheck) {
const preCheckResult = preCheck(command)
if (preCheckResult !== null) {
return preCheckResult
}
}
let preflightCheckTimeoutId: NodeJS.Timeout | undefined
const startTime = Date.now()
let result: CommandPrefixResult | null = null
try {
// Log a warning if the pre-flight check takes too long
preflightCheckTimeoutId = setTimeout(
(tn, nonInteractive) => {
const message = `[${tn}Tool] Pre-flight check is taking longer than expected. Run with ANTHROPIC_LOG=debug to check for failed or slow API requests.`
if (nonInteractive) {
process.stderr.write(jsonStringify({ level: 'warn', message }) + '\n')
} else {
// biome-ignore lint/suspicious/noConsole: intentional warning
console.warn(chalk.yellow(`⚠️ ${message}`))
}
},
10000, // 10 seconds
toolName,
isNonInteractiveSession,
)
const useSystemPromptPolicySpec = getFeatureValue_CACHED_MAY_BE_STALE(
'tengu_cork_m4q',
false,
)
const response = await queryHaiku({
systemPrompt: asSystemPrompt(
useSystemPromptPolicySpec
? [
`Your task is to process ${toolName} commands that an AI coding agent wants to run.\n\n${policySpec}`,
]
: [
`Your task is to process ${toolName} commands that an AI coding agent wants to run.\n\nThis policy spec defines how to determine the prefix of a ${toolName} command:`,
],
),
userPrompt: useSystemPromptPolicySpec
? `Command: ${command}`
: `${policySpec}\n\nCommand: ${command}`,
signal: abortSignal,
options: {
enablePromptCaching: useSystemPromptPolicySpec,
querySource,
agents: [],
isNonInteractiveSession,
hasAppendSystemPrompt: false,
mcpTools: [],
},
})
// Clear the timeout since the query completed
clearTimeout(preflightCheckTimeoutId)
const durationMs = Date.now() - startTime
const prefix =
typeof response.message.content === 'string'
? response.message.content
: Array.isArray(response.message.content)
? (response.message.content.find(_ => _.type === 'text')?.text ??
'none')
: 'none'
if (startsWithApiErrorPrefix(prefix)) {
logEvent(eventName, {
success: false,
error:
'API error' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
durationMs,
})
result = null
} else if (prefix === 'command_injection_detected') {
// Haiku detected something suspicious - treat as no prefix available
logEvent(eventName, {
success: false,
error:
'command_injection_detected' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
durationMs,
})
result = {
commandPrefix: null,
}
} else if (
prefix === 'git' ||
DANGEROUS_SHELL_PREFIXES.has(prefix.toLowerCase())
) {
// Never accept bare `git` or shell executables as a prefix
logEvent(eventName, {
success: false,
error:
'dangerous_shell_prefix' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
durationMs,
})
result = {
commandPrefix: null,
}
} else if (prefix === 'none') {
// No prefix detected
logEvent(eventName, {
success: false,
error:
'prefix "none"' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
durationMs,
})
result = {
commandPrefix: null,
}
} else {
// Validate that the prefix is actually a prefix of the command
if (!command.startsWith(prefix)) {
// Prefix isn't actually a prefix of the command
logEvent(eventName, {
success: false,
error:
'command did not start with prefix' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
durationMs,
})
result = {
commandPrefix: null,
}
} else {
logEvent(eventName, {
success: true,
durationMs,
})
result = {
commandPrefix: prefix,
}
}
}
return result
} catch (error) {
clearTimeout(preflightCheckTimeoutId)
throw error
}
}
async function getCommandSubcommandPrefixImpl(
command: string,
abortSignal: AbortSignal,
isNonInteractiveSession: boolean,
getPrefix: ReturnType<typeof createCommandPrefixExtractor>,
splitCommandFn: (command: string) => string[] | Promise<string[]>,
): Promise<CommandSubcommandPrefixResult | null> {
const subcommands = await splitCommandFn(command)
const [fullCommandPrefix, ...subcommandPrefixesResults] = await Promise.all([
getPrefix(command, abortSignal, isNonInteractiveSession),
...subcommands.map(async subcommand => ({
subcommand,
prefix: await getPrefix(subcommand, abortSignal, isNonInteractiveSession),
})),
])
if (!fullCommandPrefix) {
return null
}
const subcommandPrefixes = subcommandPrefixesResults.reduce(
(acc, { subcommand, prefix }) => {
if (prefix) {
acc.set(subcommand, prefix)
}
return acc
},
new Map<string, CommandPrefixResult>(),
)
return {
...fullCommandPrefix,
subcommandPrefixes,
}
}
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
import { getInitialSettings } from '../settings/settings.js'
/**
* Resolve the default shell for input-box `!` commands.
*
* Resolution order (docs/design/ps-shell-selection.md §4.2):
* settings.defaultShell → 'bash'
*
* Platform default is 'bash' everywhere — we do NOT auto-flip Windows to
* PowerShell (would break existing Windows users with bash hooks).
*/
export function resolveDefaultShell(): 'bash' | 'powershell' {
return getInitialSettings().defaultShell ?? 'bash'
}
+33
View File
@@ -0,0 +1,33 @@
export const SHELL_TYPES = ['bash', 'powershell'] as const
export type ShellType = (typeof SHELL_TYPES)[number]
export const DEFAULT_HOOK_SHELL: ShellType = 'bash'
export type ShellProvider = {
type: ShellType
shellPath: string
detached: boolean
/**
* Build the full command string including all shell-specific setup.
* For bash: source snapshot, session env, disable extglob, eval-wrap, pwd tracking.
*/
buildExecCommand(
command: string,
opts: {
id: number | string
sandboxTmpDir?: string
useSandbox: boolean
},
): Promise<{ commandString: string; cwdFilePath: string }>
/**
* Shell args for spawn (e.g., ['-c', '-l', cmd] for bash).
*/
getSpawnArgs(commandString: string): string[]
/**
* Extra env vars for this shell type.
* May perform async initialization (e.g., tmux socket setup for bash).
*/
getEnvironmentOverrides(command: string): Promise<Record<string, string>>
}
+22
View File
@@ -0,0 +1,22 @@
import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js'
import { POWERSHELL_TOOL_NAME } from '../../tools/PowerShellTool/toolName.js'
import { isEnvDefinedFalsy, isEnvTruthy } from '../envUtils.js'
import { getPlatform } from '../platform.js'
export const SHELL_TOOL_NAMES: string[] = [BASH_TOOL_NAME, POWERSHELL_TOOL_NAME]
/**
* Runtime gate for PowerShellTool. Windows-only (the permission engine uses
* Win32-specific path normalizations). Ant defaults on (opt-out via env=0);
* external defaults off (opt-in via env=1).
*
* Used by tools.ts (tool-list visibility), processBashCommand (! routing),
* and promptShellExecution (skill frontmatter routing) so the gate is
* consistent across all paths that invoke PowerShellTool.call().
*/
export function isPowerShellToolEnabled(): boolean {
if (getPlatform() !== 'windows') return false
return process.env.USER_TYPE === 'ant'
? !isEnvDefinedFalsy(process.env.CLAUDE_CODE_USE_POWERSHELL_TOOL)
: isEnvTruthy(process.env.CLAUDE_CODE_USE_POWERSHELL_TOOL)
}
+241
View File
@@ -0,0 +1,241 @@
/**
* Fig-spec-driven command prefix extraction.
*
* Given a command name + args array + its @withfig/autocomplete spec, walks
* the spec to find how deep into the args a meaningful prefix extends.
* `git -C /repo status --short` → `git status` (spec says -C takes a value,
* skip it, find `status` as a known subcommand).
*
* Pure over (string, string[], CommandSpec) — no parser dependency. Extracted
* from src/utils/bash/prefix.ts so PowerShell's extractor can reuse it;
* external CLIs (git, npm, kubectl) are shell-agnostic.
*/
import type { CommandSpec } from '../bash/registry.js'
const URL_PROTOCOLS = ['http://', 'https://', 'ftp://']
// Overrides for commands whose fig specs aren't available at runtime
// (dynamic imports don't work in native/node builds). Without these,
// calculateDepth falls back to 2, producing overly broad prefixes.
export const DEPTH_RULES: Record<string, number> = {
rg: 2, // pattern argument is required despite variadic paths
'pre-commit': 2,
// CLI tools with deep subcommand trees (e.g. gcloud scheduler jobs list)
gcloud: 4,
'gcloud compute': 6,
'gcloud beta': 6,
aws: 4,
az: 4,
kubectl: 3,
docker: 3,
dotnet: 3,
'git push': 2,
}
const toArray = <T>(val: T | T[]): T[] => (Array.isArray(val) ? val : [val])
// Check if an argument matches a known subcommand (case-insensitive: PS
// callers pass original-cased args; fig spec names are lowercase)
function isKnownSubcommand(arg: string, spec: CommandSpec | null): boolean {
if (!spec?.subcommands?.length) return false
const argLower = arg.toLowerCase()
return spec.subcommands.some(sub =>
Array.isArray(sub.name)
? sub.name.some(n => n.toLowerCase() === argLower)
: sub.name.toLowerCase() === argLower,
)
}
// Check if a flag takes an argument based on spec, or use heuristic
function flagTakesArg(
flag: string,
nextArg: string | undefined,
spec: CommandSpec | null,
): boolean {
// Check if flag is in spec.options
if (spec?.options) {
const option = spec.options.find(opt =>
Array.isArray(opt.name) ? opt.name.includes(flag) : opt.name === flag,
)
if (option) return !!option.args
}
// Heuristic: if next arg isn't a flag and isn't a known subcommand, assume it's a flag value
if (spec?.subcommands?.length && nextArg && !nextArg.startsWith('-')) {
return !isKnownSubcommand(nextArg, spec)
}
return false
}
// Find the first subcommand by skipping flags and their values
function findFirstSubcommand(
args: string[],
spec: CommandSpec | null,
): string | undefined {
for (let i = 0; i < args.length; i++) {
const arg = args[i]
if (!arg) continue
if (arg.startsWith('-')) {
if (flagTakesArg(arg, args[i + 1], spec)) i++
continue
}
if (!spec?.subcommands?.length) return arg
if (isKnownSubcommand(arg, spec)) return arg
}
return undefined
}
export async function buildPrefix(
command: string,
args: string[],
spec: CommandSpec | null,
): Promise<string> {
const maxDepth = await calculateDepth(command, args, spec)
const parts = [command]
const hasSubcommands = !!spec?.subcommands?.length
let foundSubcommand = false
for (let i = 0; i < args.length; i++) {
const arg = args[i]
if (!arg || parts.length >= maxDepth) break
if (arg.startsWith('-')) {
// Special case: python -c should stop after -c
if (arg === '-c' && ['python', 'python3'].includes(command.toLowerCase()))
break
// Check for isCommand/isModule flags that should be included in prefix
if (spec?.options) {
const option = spec.options.find(opt =>
Array.isArray(opt.name) ? opt.name.includes(arg) : opt.name === arg,
)
if (
option?.args &&
toArray(option.args).some(a => a?.isCommand || a?.isModule)
) {
parts.push(arg)
continue
}
}
// For commands with subcommands, skip global flags to find the subcommand
if (hasSubcommands && !foundSubcommand) {
if (flagTakesArg(arg, args[i + 1], spec)) i++
continue
}
break // Stop at flags (original behavior)
}
if (await shouldStopAtArg(arg, args.slice(0, i), spec)) break
if (hasSubcommands && !foundSubcommand) {
foundSubcommand = isKnownSubcommand(arg, spec)
}
parts.push(arg)
}
return parts.join(' ')
}
async function calculateDepth(
command: string,
args: string[],
spec: CommandSpec | null,
): Promise<number> {
// Find first subcommand by skipping flags and their values
const firstSubcommand = findFirstSubcommand(args, spec)
const commandLower = command.toLowerCase()
const key = firstSubcommand
? `${commandLower} ${firstSubcommand.toLowerCase()}`
: commandLower
if (DEPTH_RULES[key]) return DEPTH_RULES[key]
if (DEPTH_RULES[commandLower]) return DEPTH_RULES[commandLower]
if (!spec) return 2
if (spec.options && args.some(arg => arg?.startsWith('-'))) {
for (const arg of args) {
if (!arg?.startsWith('-')) continue
const option = spec.options.find(opt =>
Array.isArray(opt.name) ? opt.name.includes(arg) : opt.name === arg,
)
if (
option?.args &&
toArray(option.args).some(arg => arg?.isCommand || arg?.isModule)
)
return 3
}
}
// Find subcommand spec using the already-found firstSubcommand
if (firstSubcommand && spec.subcommands?.length) {
const firstSubLower = firstSubcommand.toLowerCase()
const subcommand = spec.subcommands.find(sub =>
Array.isArray(sub.name)
? sub.name.some(n => n.toLowerCase() === firstSubLower)
: sub.name.toLowerCase() === firstSubLower,
)
if (subcommand) {
if (subcommand.args) {
const subArgs = toArray(subcommand.args)
if (subArgs.some(arg => arg?.isCommand)) return 3
if (subArgs.some(arg => arg?.isVariadic)) return 2
}
if (subcommand.subcommands?.length) return 4
// Leaf subcommand with NO args declared (git show, git log, git tag):
// the 3rd word is transient (SHA, ref, tag name) → dead over-specific
// rule like PowerShell(git show 81210f8:*). NOT the isOptional case —
// `git fetch` declares optional remote/branch and `git fetch origin`
// is tested (bash/prefix.test.ts:912) as intentional remote scoping.
if (!subcommand.args) return 2
return 3
}
}
if (spec.args) {
const argsArray = toArray(spec.args)
if (argsArray.some(arg => arg?.isCommand)) {
return !Array.isArray(spec.args) && spec.args.isCommand
? 2
: Math.min(2 + argsArray.findIndex(arg => arg?.isCommand), 3)
}
if (!spec.subcommands?.length) {
if (argsArray.some(arg => arg?.isVariadic)) return 1
if (argsArray[0] && !argsArray[0].isOptional) return 2
}
}
return spec.args && toArray(spec.args).some(arg => arg?.isDangerous) ? 3 : 2
}
async function shouldStopAtArg(
arg: string,
args: string[],
spec: CommandSpec | null,
): Promise<boolean> {
if (arg.startsWith('-')) return true
const dotIndex = arg.lastIndexOf('.')
const hasExtension =
dotIndex > 0 &&
dotIndex < arg.length - 1 &&
!arg.substring(dotIndex + 1).includes(':')
const hasFile = arg.includes('/') || hasExtension
const hasUrl = URL_PROTOCOLS.some(proto => arg.startsWith(proto))
if (!hasFile && !hasUrl) return false
// Check if we're after a -m flag for python modules
if (spec?.options && args.length > 0 && args[args.length - 1] === '-m') {
const option = spec.options.find(opt =>
Array.isArray(opt.name) ? opt.name.includes('-m') : opt.name === '-m',
)
if (option?.args && toArray(option.args).some(arg => arg?.isModule)) {
return false // Don't stop at module names
}
}
// For actual files/URLs, always stop regardless of context
return true
}