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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+265
View File
@@ -0,0 +1,265 @@
import type { z } from 'zod/v4'
import {
isUnsafeCompoundCommand_DEPRECATED,
splitCommand_DEPRECATED,
} from '../../utils/bash/commands.js'
import {
buildParsedCommandFromRoot,
type IParsedCommand,
ParsedCommand,
} from '../../utils/bash/ParsedCommand.js'
import { type Node, PARSE_ABORTED } from '../../utils/bash/parser.js'
import type { PermissionResult } from '../../utils/permissions/PermissionResult.js'
import type { PermissionUpdate } from '../../utils/permissions/PermissionUpdateSchema.js'
import { createPermissionRequestMessage } from '../../utils/permissions/permissions.js'
import { BashTool } from './BashTool.js'
import { bashCommandIsSafeAsync_DEPRECATED } from './bashSecurity.js'
export type CommandIdentityCheckers = {
isNormalizedCdCommand: (command: string) => boolean
isNormalizedGitCommand: (command: string) => boolean
}
async function segmentedCommandPermissionResult(
input: z.infer<typeof BashTool.inputSchema>,
segments: string[],
bashToolHasPermissionFn: (
input: z.infer<typeof BashTool.inputSchema>,
) => Promise<PermissionResult>,
checkers: CommandIdentityCheckers,
): Promise<PermissionResult> {
// Check for multiple cd commands across all segments
const cdCommands = segments.filter(segment => {
const trimmed = segment.trim()
return checkers.isNormalizedCdCommand(trimmed)
})
if (cdCommands.length > 1) {
const decisionReason = {
type: 'other' as const,
reason:
'Multiple directory changes in one command require approval for clarity',
}
return {
behavior: 'ask',
decisionReason,
message: createPermissionRequestMessage(BashTool.name, decisionReason),
}
}
// SECURITY: Check for cd+git across pipe segments to prevent bare repo fsmonitor bypass.
// When cd and git are in different pipe segments (e.g., "cd sub && echo | git status"),
// each segment is checked independently and neither triggers the cd+git check in
// bashPermissions.ts. We must detect this cross-segment pattern here.
// Each pipe segment can itself be a compound command (e.g., "cd sub && echo"),
// so we split each segment into subcommands before checking.
{
let hasCd = false
let hasGit = false
for (const segment of segments) {
const subcommands = splitCommand_DEPRECATED(segment)
for (const sub of subcommands) {
const trimmed = sub.trim()
if (checkers.isNormalizedCdCommand(trimmed)) {
hasCd = true
}
if (checkers.isNormalizedGitCommand(trimmed)) {
hasGit = true
}
}
}
if (hasCd && hasGit) {
const decisionReason = {
type: 'other' as const,
reason:
'Compound commands with cd and git require approval to prevent bare repository attacks',
}
return {
behavior: 'ask',
decisionReason,
message: createPermissionRequestMessage(BashTool.name, decisionReason),
}
}
}
const segmentResults = new Map<string, PermissionResult>()
// Check each segment through the full permission system
for (const segment of segments) {
const trimmedSegment = segment.trim()
if (!trimmedSegment) continue // Skip empty segments
const segmentResult = await bashToolHasPermissionFn({
...input,
command: trimmedSegment,
})
segmentResults.set(trimmedSegment, segmentResult)
}
// Check if any segment is denied (after evaluating all)
const deniedSegment = Array.from(segmentResults.entries()).find(
([, result]) => result.behavior === 'deny',
)
if (deniedSegment) {
const [segmentCommand, segmentResult] = deniedSegment
return {
behavior: 'deny',
message:
segmentResult.behavior === 'deny'
? segmentResult.message
: `Permission denied for: ${segmentCommand}`,
decisionReason: {
type: 'subcommandResults',
reasons: segmentResults,
},
}
}
const allAllowed = Array.from(segmentResults.values()).every(
result => result.behavior === 'allow',
)
if (allAllowed) {
return {
behavior: 'allow',
updatedInput: input,
decisionReason: {
type: 'subcommandResults',
reasons: segmentResults,
},
}
}
// Collect suggestions from segments that need approval
const suggestions: PermissionUpdate[] = []
for (const [, result] of segmentResults) {
if (
result.behavior !== 'allow' &&
'suggestions' in result &&
result.suggestions
) {
suggestions.push(...result.suggestions)
}
}
const decisionReason = {
type: 'subcommandResults' as const,
reasons: segmentResults,
}
return {
behavior: 'ask',
message: createPermissionRequestMessage(BashTool.name, decisionReason),
decisionReason,
suggestions: suggestions.length > 0 ? suggestions : undefined,
}
}
/**
* Builds a command segment, stripping output redirections to avoid
* treating filenames as commands in permission checking.
* Uses ParsedCommand to preserve original quoting.
*/
async function buildSegmentWithoutRedirections(
segmentCommand: string,
): Promise<string> {
// Fast path: skip parsing if no redirection operators present
if (!segmentCommand.includes('>')) {
return segmentCommand
}
// Use ParsedCommand to strip redirections while preserving quotes
const parsed = await ParsedCommand.parse(segmentCommand)
return parsed?.withoutOutputRedirections() ?? segmentCommand
}
/**
* Wrapper that resolves an IParsedCommand (from a pre-parsed AST root if
* available, else via ParsedCommand.parse) and delegates to
* bashToolCheckCommandOperatorPermissions.
*/
export async function checkCommandOperatorPermissions(
input: z.infer<typeof BashTool.inputSchema>,
bashToolHasPermissionFn: (
input: z.infer<typeof BashTool.inputSchema>,
) => Promise<PermissionResult>,
checkers: CommandIdentityCheckers,
astRoot: Node | null | typeof PARSE_ABORTED,
): Promise<PermissionResult> {
const parsed =
astRoot && astRoot !== PARSE_ABORTED
? buildParsedCommandFromRoot(input.command, astRoot)
: await ParsedCommand.parse(input.command)
if (!parsed) {
return { behavior: 'passthrough', message: 'Failed to parse command' }
}
return bashToolCheckCommandOperatorPermissions(
input,
bashToolHasPermissionFn,
checkers,
parsed,
)
}
/**
* Checks if the command has special operators that require behavior beyond
* simple subcommand checking.
*/
async function bashToolCheckCommandOperatorPermissions(
input: z.infer<typeof BashTool.inputSchema>,
bashToolHasPermissionFn: (
input: z.infer<typeof BashTool.inputSchema>,
) => Promise<PermissionResult>,
checkers: CommandIdentityCheckers,
parsed: IParsedCommand,
): Promise<PermissionResult> {
// 1. Check for unsafe compound commands (subshells, command groups).
const tsAnalysis = parsed.getTreeSitterAnalysis()
const isUnsafeCompound = tsAnalysis
? tsAnalysis.compoundStructure.hasSubshell ||
tsAnalysis.compoundStructure.hasCommandGroup
: isUnsafeCompoundCommand_DEPRECATED(input.command)
if (isUnsafeCompound) {
// This command contains an operator like `>` that we don't support as a subcommand separator
// Check if bashCommandIsSafe_DEPRECATED has a more specific message
const safetyResult = await bashCommandIsSafeAsync_DEPRECATED(input.command)
const decisionReason = {
type: 'other' as const,
reason:
safetyResult.behavior === 'ask' && safetyResult.message
? safetyResult.message
: 'This command uses shell operators that require approval for safety',
}
return {
behavior: 'ask',
message: createPermissionRequestMessage(BashTool.name, decisionReason),
decisionReason,
// This is an unsafe compound command, so we don't want to suggest rules since we wont be able to allow it
}
}
// 2. Check for piped commands using ParsedCommand (preserves quotes)
const pipeSegments = parsed.getPipeSegments()
// If no pipes (single segment), let normal flow handle it
if (pipeSegments.length <= 1) {
return {
behavior: 'passthrough',
message: 'No pipes found in command',
}
}
// Strip output redirections from each segment while preserving quotes
const segments = await Promise.all(
pipeSegments.map(segment => buildSegmentWithoutRedirections(segment)),
)
// Handle as segmented command
return segmentedCommandPermissionResult(
input,
segments,
bashToolHasPermissionFn,
checkers,
)
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+140
View File
@@ -0,0 +1,140 @@
/**
* Command semantics configuration for interpreting exit codes in different contexts.
*
* Many commands use exit codes to convey information other than just success/failure.
* For example, grep returns 1 when no matches are found, which is not an error condition.
*/
import { splitCommand_DEPRECATED } from '../../utils/bash/commands.js'
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,
})
/**
* Command-specific semantics
*/
const COMMAND_SEMANTICS: Map<string, CommandSemantic> = new Map([
// grep: 0=matches found, 1=no matches, 2+=error
[
'grep',
(exitCode, _stdout, _stderr) => ({
isError: exitCode >= 2,
message: exitCode === 1 ? 'No matches found' : undefined,
}),
],
// ripgrep has same semantics as grep
[
'rg',
(exitCode, _stdout, _stderr) => ({
isError: exitCode >= 2,
message: exitCode === 1 ? 'No matches found' : undefined,
}),
],
// find: 0=success, 1=partial success (some dirs inaccessible), 2+=error
[
'find',
(exitCode, _stdout, _stderr) => ({
isError: exitCode >= 2,
message:
exitCode === 1 ? 'Some directories were inaccessible' : undefined,
}),
],
// diff: 0=no differences, 1=differences found, 2+=error
[
'diff',
(exitCode, _stdout, _stderr) => ({
isError: exitCode >= 2,
message: exitCode === 1 ? 'Files differ' : undefined,
}),
],
// test/[: 0=condition true, 1=condition false, 2+=error
[
'test',
(exitCode, _stdout, _stderr) => ({
isError: exitCode >= 2,
message: exitCode === 1 ? 'Condition is false' : undefined,
}),
],
// [ is an alias for test
[
'[',
(exitCode, _stdout, _stderr) => ({
isError: exitCode >= 2,
message: exitCode === 1 ? 'Condition is false' : undefined,
}),
],
// wc, head, tail, cat, etc.: these typically only fail on real errors
// so we use default semantics
])
/**
* Get the semantic interpretation for a command
*/
function getCommandSemantic(command: string): CommandSemantic {
// Extract the base command (first word, handling pipes)
const baseCommand = heuristicallyExtractBaseCommand(command)
const semantic = COMMAND_SEMANTICS.get(baseCommand)
return semantic !== undefined ? semantic : DEFAULT_SEMANTIC
}
/**
* Extract just the command name (first word) from a single command string.
*/
function extractBaseCommand(command: string): string {
return command.trim().split(/\s+/)[0] || ''
}
/**
* Extract the primary command from a complex command line;
* May get it super wrong - don't depend on this for security
*/
function heuristicallyExtractBaseCommand(command: string): string {
const segments = splitCommand_DEPRECATED(command)
// Take the last command as that's what determines the exit code
const lastCommand = segments[segments.length - 1] || command
return extractBaseCommand(lastCommand)
}
/**
* Interpret command result based on semantic rules
*/
export function interpretCommandResult(
command: string,
exitCode: number,
stdout: string,
stderr: string,
): {
isError: boolean
message?: string
} {
const semantic = getCommandSemantic(command)
const result = semantic(exitCode, stdout, stderr)
return {
isError: result.isError,
message: result.message,
}
}
+13
View File
@@ -0,0 +1,13 @@
/**
* If the first line of a bash command is a `# comment` (not a `#!` shebang),
* return the comment text stripped of the `#` prefix. Otherwise undefined.
*
* Under fullscreen mode this is the non-verbose tool-use label AND the
* collapse-group ⎿ hint — it's what Claude wrote for the human to read.
*/
export function extractBashCommentLabel(command: string): string | undefined {
const nl = command.indexOf('\n')
const firstLine = (nl === -1 ? command : command.slice(0, nl)).trim()
if (!firstLine.startsWith('#') || firstLine.startsWith('#!')) return undefined
return firstLine.replace(/^#+\s*/, '') || undefined
}
+102
View File
@@ -0,0 +1,102 @@
/**
* Detects potentially destructive bash 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[] = [
// Git — data loss / hard to reverse
{
pattern: /\bgit\s+reset\s+--hard\b/,
warning: 'Note: may discard uncommitted changes',
},
{
pattern: /\bgit\s+push\b[^;&|\n]*[ \t](--force|--force-with-lease|-f)\b/,
warning: 'Note: may overwrite remote history',
},
{
pattern:
/\bgit\s+clean\b(?![^;&|\n]*(?:-[a-zA-Z]*n|--dry-run))[^;&|\n]*-[a-zA-Z]*f/,
warning: 'Note: may permanently delete untracked files',
},
{
pattern: /\bgit\s+checkout\s+(--\s+)?\.[ \t]*($|[;&|\n])/,
warning: 'Note: may discard all working tree changes',
},
{
pattern: /\bgit\s+restore\s+(--\s+)?\.[ \t]*($|[;&|\n])/,
warning: 'Note: may discard all working tree changes',
},
{
pattern: /\bgit\s+stash[ \t]+(drop|clear)\b/,
warning: 'Note: may permanently remove stashed changes',
},
{
pattern:
/\bgit\s+branch\s+(-D[ \t]|--delete\s+--force|--force\s+--delete)\b/,
warning: 'Note: may force-delete a branch',
},
// Git — safety bypass
{
pattern: /\bgit\s+(commit|push|merge)\b[^;&|\n]*--no-verify\b/,
warning: 'Note: may skip safety hooks',
},
{
pattern: /\bgit\s+commit\b[^;&|\n]*--amend\b/,
warning: 'Note: may rewrite the last commit',
},
// File deletion (dangerous paths already handled by checkDangerousRemovalPaths)
{
pattern:
/(^|[;&|\n]\s*)rm\s+-[a-zA-Z]*[rR][a-zA-Z]*f|(^|[;&|\n]\s*)rm\s+-[a-zA-Z]*f[a-zA-Z]*[rR]/,
warning: 'Note: may recursively force-remove files',
},
{
pattern: /(^|[;&|\n]\s*)rm\s+-[a-zA-Z]*[rR]/,
warning: 'Note: may recursively remove files',
},
{
pattern: /(^|[;&|\n]\s*)rm\s+-[a-zA-Z]*f/,
warning: 'Note: may force-remove files',
},
// Database
{
pattern: /\b(DROP|TRUNCATE)\s+(TABLE|DATABASE|SCHEMA)\b/i,
warning: 'Note: may drop or truncate database objects',
},
{
pattern: /\bDELETE\s+FROM\s+\w+[ \t]*(;|"|'|\n|$)/i,
warning: 'Note: may delete all rows from a database table',
},
// Infrastructure
{
pattern: /\bkubectl\s+delete\b/,
warning: 'Note: may delete Kubernetes resources',
},
{
pattern: /\bterraform\s+destroy\b/,
warning: 'Note: may destroy Terraform infrastructure',
},
]
/**
* Checks if a bash 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
}
+115
View File
@@ -0,0 +1,115 @@
import type { z } from 'zod/v4'
import type { ToolPermissionContext } from '../../Tool.js'
import { splitCommand_DEPRECATED } from '../../utils/bash/commands.js'
import type { PermissionResult } from '../../utils/permissions/PermissionResult.js'
import type { BashTool } from './BashTool.js'
const ACCEPT_EDITS_ALLOWED_COMMANDS = [
'mkdir',
'touch',
'rm',
'rmdir',
'mv',
'cp',
'sed',
] as const
type FilesystemCommand = (typeof ACCEPT_EDITS_ALLOWED_COMMANDS)[number]
function isFilesystemCommand(command: string): command is FilesystemCommand {
return ACCEPT_EDITS_ALLOWED_COMMANDS.includes(command as FilesystemCommand)
}
function validateCommandForMode(
cmd: string,
toolPermissionContext: ToolPermissionContext,
): PermissionResult {
const trimmedCmd = cmd.trim()
const [baseCmd] = trimmedCmd.split(/\s+/)
if (!baseCmd) {
return {
behavior: 'passthrough',
message: 'Base command not found',
}
}
// In Accept Edits mode, auto-allow filesystem operations
if (
toolPermissionContext.mode === 'acceptEdits' &&
isFilesystemCommand(baseCmd)
) {
return {
behavior: 'allow',
updatedInput: { command: cmd },
decisionReason: {
type: 'mode',
mode: 'acceptEdits',
},
}
}
return {
behavior: 'passthrough',
message: `No mode-specific handling for '${baseCmd}' in ${toolPermissionContext.mode} mode`,
}
}
/**
* Checks if commands should be handled differently based on the current permission mode
*
* This is the main entry point for mode-based permission logic.
* Currently handles Accept Edits mode for filesystem commands,
* but designed to be extended for other modes.
*
* @param input - The bash command input
* @param toolPermissionContext - Context containing mode and permissions
* @returns
* - 'allow' if the current mode permits auto-approval
* - 'ask' if the command needs approval in current mode
* - 'passthrough' if no mode-specific handling applies
*/
export function checkPermissionMode(
input: z.infer<typeof BashTool.inputSchema>,
toolPermissionContext: ToolPermissionContext,
): PermissionResult {
// Skip if in bypass mode (handled elsewhere)
if (toolPermissionContext.mode === 'bypassPermissions') {
return {
behavior: 'passthrough',
message: 'Bypass mode is handled in main permission flow',
}
}
// Skip if in dontAsk mode (handled in main permission flow)
if (toolPermissionContext.mode === 'dontAsk') {
return {
behavior: 'passthrough',
message: 'DontAsk mode is handled in main permission flow',
}
}
const commands = splitCommand_DEPRECATED(input.command)
// Check each subcommand
for (const cmd of commands) {
const result = validateCommandForMode(cmd, toolPermissionContext)
// If any command triggers mode-specific behavior, return that result
if (result.behavior !== 'passthrough') {
return result
}
}
// No mode-specific handling needed
return {
behavior: 'passthrough',
message: 'No mode-specific validation required',
}
}
export function getAutoAllowedCommands(
mode: ToolPermissionContext['mode'],
): readonly string[] {
return mode === 'acceptEdits' ? ACCEPT_EDITS_ALLOWED_COMMANDS : []
}
File diff suppressed because it is too large Load Diff
+369
View File
@@ -0,0 +1,369 @@
import { feature } from 'bun:bundle'
import { prependBullets } from '../../constants/prompts.js'
import { getAttributionTexts } from '../../utils/attribution.js'
import { hasEmbeddedSearchTools } from '../../utils/embeddedTools.js'
import { isEnvTruthy } from '../../utils/envUtils.js'
import { shouldIncludeGitInstructions } from '../../utils/gitSettings.js'
import { getClaudeTempDir } from '../../utils/permissions/filesystem.js'
import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js'
import { jsonStringify } from '../../utils/slowOperations.js'
import {
getDefaultBashTimeoutMs,
getMaxBashTimeoutMs,
} from '../../utils/timeouts.js'
import {
getUndercoverInstructions,
isUndercover,
} from '../../utils/undercover.js'
import { AGENT_TOOL_NAME } from '../AgentTool/constants.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 { TodoWriteTool } from '../TodoWriteTool/TodoWriteTool.js'
import { BASH_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. You do not need to use '&' at the end of the command when using this parameter."
}
function getCommitAndPRInstructions(): string {
// Defense-in-depth: undercover instructions must survive even if the user
// has disabled git instructions entirely. Attribution stripping and model-ID
// hiding are mechanical and work regardless, but the explicit "don't blow
// your cover" instructions are the last line of defense against the model
// volunteering an internal codename in a commit message.
const undercoverSection =
process.env.USER_TYPE === 'ant' && isUndercover()
? getUndercoverInstructions() + '\n'
: ''
if (!shouldIncludeGitInstructions()) return undercoverSection
// For ant users, use the short version pointing to skills
if (process.env.USER_TYPE === 'ant') {
const skillsSection = !isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)
? `For git commits and pull requests, use the \`/commit\` and \`/commit-push-pr\` skills:
- \`/commit\` - Create a git commit with staged changes
- \`/commit-push-pr\` - Commit, push, and create a pull request
These skills handle git safety protocols, proper commit message formatting, and PR creation.
Before creating a pull request, run \`/simplify\` to review your changes, then test end-to-end (e.g. via \`/tmux\` for interactive features).
`
: ''
return `${undercoverSection}# Git operations
${skillsSection}IMPORTANT: NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the user explicitly requests it.
Use the gh command via the Bash tool for other GitHub-related tasks including working with issues, checks, and releases. If given a Github URL use the gh command to get the information needed.
# Other common operations
- View comments on a Github PR: gh api repos/foo/bar/pulls/123/comments`
}
// For external users, include full inline instructions
const { commit: commitAttribution, pr: prAttribution } = getAttributionTexts()
return `# Committing changes with git
Only create commits when requested by the user. If unclear, ask first. When the user asks you to create a new git commit, follow these steps carefully:
You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. The numbered steps below indicate which commands should be batched in parallel.
Git Safety Protocol:
- NEVER update the git config
- NEVER run destructive git commands (push --force, reset --hard, checkout ., restore ., clean -f, branch -D) unless the user explicitly requests these actions. Taking unauthorized destructive actions is unhelpful and can result in lost work, so it's best to ONLY run these commands when given direct instructions
- NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the user explicitly requests it
- NEVER run force push to main/master, warn the user if they request it
- CRITICAL: Always create NEW commits rather than amending, unless the user explicitly requests a git amend. When a pre-commit hook fails, the commit did NOT happen — so --amend would modify the PREVIOUS commit, which may result in destroying work or losing previous changes. Instead, after hook failure, fix the issue, re-stage, and create a NEW commit
- When staging files, prefer adding specific files by name rather than using "git add -A" or "git add .", which can accidentally include sensitive files (.env, credentials) or large binaries
- NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive
1. Run the following bash commands in parallel, each using the ${BASH_TOOL_NAME} tool:
- Run a git status command to see all untracked files. IMPORTANT: Never use the -uall flag as it can cause memory issues on large repos.
- Run a git diff command to see both staged and unstaged changes that will be committed.
- Run a git log command to see recent commit messages, so that you can follow this repository's commit message style.
2. Analyze all staged changes (both previously staged and newly added) and draft a commit message:
- Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.). Ensure the message accurately reflects the changes and their purpose (i.e. "add" means a wholly new feature, "update" means an enhancement to an existing feature, "fix" means a bug fix, etc.).
- Do not commit files that likely contain secrets (.env, credentials.json, etc). Warn the user if they specifically request to commit those files
- Draft a concise (1-2 sentences) commit message that focuses on the "why" rather than the "what"
- Ensure it accurately reflects the changes and their purpose
3. Run the following commands in parallel:
- Add relevant untracked files to the staging area.
- Create the commit with a message${commitAttribution ? ` ending with:\n ${commitAttribution}` : '.'}
- Run git status after the commit completes to verify success.
Note: git status depends on the commit completing, so run it sequentially after the commit.
4. If the commit fails due to pre-commit hook: fix the issue and create a NEW commit
Important notes:
- NEVER run additional commands to read or explore code, besides git bash commands
- NEVER use the ${TodoWriteTool.name} or ${AGENT_TOOL_NAME} tools
- DO NOT push to the remote repository unless the user explicitly asks you to do so
- IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported.
- IMPORTANT: Do not use --no-edit with git rebase commands, as the --no-edit flag is not a valid option for git rebase.
- If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit
- In order to ensure good formatting, ALWAYS pass the commit message via a HEREDOC, a la this example:
<example>
git commit -m "$(cat <<'EOF'
Commit message here.${commitAttribution ? `\n\n ${commitAttribution}` : ''}
EOF
)"
</example>
# Creating pull requests
Use the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a Github URL use the gh command to get the information needed.
IMPORTANT: When the user asks you to create a pull request, follow these steps carefully:
1. Run the following bash commands in parallel using the ${BASH_TOOL_NAME} tool, in order to understand the current state of the branch since it diverged from the main branch:
- Run a git status command to see all untracked files (never use -uall flag)
- Run a git diff command to see both staged and unstaged changes that will be committed
- Check if the current branch tracks a remote branch and is up to date with the remote, so you know if you need to push to the remote
- Run a git log command and \`git diff [base-branch]...HEAD\` to understand the full commit history for the current branch (from the time it diverged from the base branch)
2. Analyze all changes that will be included in the pull request, making sure to look at all relevant commits (NOT just the latest commit, but ALL commits that will be included in the pull request!!!), and draft a pull request title and summary:
- Keep the PR title short (under 70 characters)
- Use the description/body for details, not the title
3. Run the following commands in parallel:
- Create new branch if needed
- Push to remote with -u flag if needed
- Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.
<example>
gh pr create --title "the pr title" --body "$(cat <<'EOF'
## Summary
<1-3 bullet points>
## Test plan
[Bulleted markdown checklist of TODOs for testing the pull request...]${prAttribution ? `\n\n${prAttribution}` : ''}
EOF
)"
</example>
Important:
- DO NOT use the ${TodoWriteTool.name} or ${AGENT_TOOL_NAME} tools
- Return the PR URL when you're done, so the user can see it
# Other common operations
- View comments on a Github PR: gh api repos/foo/bar/pulls/123/comments`
}
// SandboxManager merges config from multiple sources (settings layers, defaults,
// CLI flags) without deduping, so paths like ~/.cache appear 3× in allowOnly.
// Dedup here before inlining into the prompt — affects only what the model sees,
// not sandbox enforcement. Saves ~150-200 tokens/request when sandbox is enabled.
function dedup<T>(arr: T[] | undefined): T[] | undefined {
if (!arr || arr.length === 0) return arr
return [...new Set(arr)]
}
function getSimpleSandboxSection(): string {
if (!SandboxManager.isSandboxingEnabled()) {
return ''
}
const fsReadConfig = SandboxManager.getFsReadConfig()
const fsWriteConfig = SandboxManager.getFsWriteConfig()
const networkRestrictionConfig = SandboxManager.getNetworkRestrictionConfig()
const allowUnixSockets = SandboxManager.getAllowUnixSockets()
const ignoreViolations = SandboxManager.getIgnoreViolations()
const allowUnsandboxedCommands =
SandboxManager.areUnsandboxedCommandsAllowed()
// Replace the per-UID temp dir literal (e.g. /private/tmp/claude-1001/) with
// "$TMPDIR" so the prompt is identical across users — avoids busting the
// cross-user global prompt cache. The sandbox already sets $TMPDIR at runtime.
const claudeTempDir = getClaudeTempDir()
const normalizeAllowOnly = (paths: string[]): string[] =>
[...new Set(paths)].map(p => (p === claudeTempDir ? '$TMPDIR' : p))
const filesystemConfig = {
read: {
denyOnly: dedup(fsReadConfig.denyOnly),
...(fsReadConfig.allowWithinDeny && {
allowWithinDeny: dedup(fsReadConfig.allowWithinDeny),
}),
},
write: {
allowOnly: normalizeAllowOnly(fsWriteConfig.allowOnly),
denyWithinAllow: dedup(fsWriteConfig.denyWithinAllow),
},
}
const networkConfig = {
...(networkRestrictionConfig?.allowedHosts && {
allowedHosts: dedup(networkRestrictionConfig.allowedHosts),
}),
...(networkRestrictionConfig?.deniedHosts && {
deniedHosts: dedup(networkRestrictionConfig.deniedHosts),
}),
...(allowUnixSockets && { allowUnixSockets: dedup(allowUnixSockets) }),
}
const restrictionsLines = []
if (Object.keys(filesystemConfig).length > 0) {
restrictionsLines.push(`Filesystem: ${jsonStringify(filesystemConfig)}`)
}
if (Object.keys(networkConfig).length > 0) {
restrictionsLines.push(`Network: ${jsonStringify(networkConfig)}`)
}
if (ignoreViolations) {
restrictionsLines.push(
`Ignored violations: ${jsonStringify(ignoreViolations)}`,
)
}
const sandboxOverrideItems: Array<string | string[]> =
allowUnsandboxedCommands
? [
'You should always default to running commands within the sandbox. Do NOT attempt to set `dangerouslyDisableSandbox: true` unless:',
[
'The user *explicitly* asks you to bypass sandbox',
'A specific command just failed and you see evidence of sandbox restrictions causing the failure. Note that commands can fail for many reasons unrelated to the sandbox (missing files, wrong arguments, network issues, etc.).',
],
'Evidence of sandbox-caused failures includes:',
[
'"Operation not permitted" errors for file/network operations',
'Access denied to specific paths outside allowed directories',
'Network connection failures to non-whitelisted hosts',
'Unix socket connection errors',
],
'When you see evidence of sandbox-caused failure:',
[
"Immediately retry with `dangerouslyDisableSandbox: true` (don't ask, just do it)",
'Briefly explain what sandbox restriction likely caused the failure. Be sure to mention that the user can use the `/sandbox` command to manage restrictions.',
'This will prompt the user for permission',
],
'Treat each command you execute with `dangerouslyDisableSandbox: true` individually. Even if you have recently run a command with this setting, you should default to running future commands within the sandbox.',
'Do not suggest adding sensitive paths like ~/.bashrc, ~/.zshrc, ~/.ssh/*, or credential files to the sandbox allowlist.',
]
: [
'All commands MUST run in sandbox mode - the `dangerouslyDisableSandbox` parameter is disabled by policy.',
'Commands cannot run outside the sandbox under any circumstances.',
'If a command fails due to sandbox restrictions, work with the user to adjust sandbox settings instead.',
]
const items: Array<string | string[]> = [
...sandboxOverrideItems,
'For temporary files, always use the `$TMPDIR` environment variable. TMPDIR is automatically set to the correct sandbox-writable directory in sandbox mode. Do NOT use `/tmp` directly - use `$TMPDIR` instead.',
]
return [
'',
'## Command sandbox',
'By default, your command will be run in a sandbox. This sandbox controls which directories and network hosts commands may access or modify without an explicit override.',
'',
'The sandbox has the following restrictions:',
restrictionsLines.join('\n'),
'',
...prependBullets(items),
].join('\n')
}
export function getSimplePrompt(): string {
// Ant-native builds alias find/grep to embedded bfs/ugrep in Claude's shell,
// so we don't steer away from them (and Glob/Grep tools are removed).
const embedded = hasEmbeddedSearchTools()
const toolPreferenceItems = [
...(embedded
? []
: [
`File search: Use ${GLOB_TOOL_NAME} (NOT find or ls)`,
`Content search: Use ${GREP_TOOL_NAME} (NOT grep or rg)`,
]),
`Read files: Use ${FILE_READ_TOOL_NAME} (NOT cat/head/tail)`,
`Edit files: Use ${FILE_EDIT_TOOL_NAME} (NOT sed/awk)`,
`Write files: Use ${FILE_WRITE_TOOL_NAME} (NOT echo >/cat <<EOF)`,
'Communication: Output text directly (NOT echo/printf)',
]
const avoidCommands = embedded
? '`cat`, `head`, `tail`, `sed`, `awk`, or `echo`'
: '`find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or `echo`'
const multipleCommandsSubitems = [
`If the commands are independent and can run in parallel, make multiple ${BASH_TOOL_NAME} tool calls in a single message. Example: if you need to run "git status" and "git diff", send a single message with two ${BASH_TOOL_NAME} tool calls in parallel.`,
`If the commands depend on each other and must run sequentially, use a single ${BASH_TOOL_NAME} call with '&&' to chain them together.`,
"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).',
]
const gitSubitems = [
'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.',
]
const sleepSubitems = [
'Do not sleep between commands that can run immediately — just run them.',
...(feature('MONITOR_TOOL')
? [
'Use the Monitor tool to stream events from a background process (each stdout line is a notification). For one-shot "wait until done," use Bash with run_in_background instead.',
]
: []),
'If your command is long running and you would like to be notified when it finishes — use `run_in_background`. No sleep needed.',
'Do not retry failing commands in a sleep loop — diagnose the root cause.',
'If waiting for a background task you started with `run_in_background`, you will be notified when it completes — do not poll.',
...(feature('MONITOR_TOOL')
? [
'`sleep N` as the first command with N ≥ 2 is blocked. If you need a delay (rate limiting, deliberate pacing), keep it under 2 seconds.',
]
: [
'If you must poll an external process, use a check command (e.g. `gh run view`) rather than sleeping first.',
'If you must sleep, keep the duration short (1-5 seconds) to avoid blocking the user.',
]),
]
const backgroundNote = getBackgroundUsageNote()
const instructionItems: Array<string | string[]> = [
'If your command will create new directories or files, first use this tool to run `ls` to verify the parent directory exists and is the correct location.',
'Always quote file paths that contain spaces with double quotes in your command (e.g., cd "path with spaces/file.txt")',
'Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of `cd`. You may use `cd` if the User explicitly requests it.',
`You may specify an optional timeout in milliseconds (up to ${getMaxTimeoutMs()}ms / ${getMaxTimeoutMs() / 60000} minutes). By default, your command will timeout after ${getDefaultTimeoutMs()}ms (${getDefaultTimeoutMs() / 60000} minutes).`,
...(backgroundNote !== null ? [backgroundNote] : []),
'When issuing multiple commands:',
multipleCommandsSubitems,
'For git commands:',
gitSubitems,
'Avoid unnecessary `sleep` commands:',
sleepSubitems,
...(embedded
? [
// bfs (which backs `find`) uses Oniguruma for -regex, which picks the
// FIRST matching alternative (leftmost-first), unlike GNU find's
// POSIX leftmost-longest. This silently drops matches when a shorter
// alternative is a prefix of a longer one.
"When using `find -regex` with alternation, put the longest alternative first. Example: use `'.*\\.\\(tsx\\|ts\\)'` not `'.*\\.\\(ts\\|tsx\\)'` — the second form silently skips `.tsx` files.",
]
: []),
]
return [
'Executes a given bash command and returns its output.',
'',
"The working directory persists between commands, but shell state does not. The shell environment is initialized from the user's profile (bash or zsh).",
'',
`IMPORTANT: Avoid using this tool to run ${avoidCommands} commands, unless explicitly instructed or after you have verified that a dedicated tool cannot accomplish your task. Instead, use the appropriate dedicated tool as this will provide a much better experience for the user:`,
'',
...prependBullets(toolPreferenceItems),
`While the ${BASH_TOOL_NAME} tool can do similar things, its better to use the built-in tools as they provide a better user experience and make it easier to review tool calls and give permission.`,
'',
'# Instructions',
...prependBullets(instructionItems),
getSimpleSandboxSection(),
...(getCommitAndPRInstructions() ? ['', getCommitAndPRInstructions()] : []),
].join('\n')
}
File diff suppressed because it is too large Load Diff
+322
View File
@@ -0,0 +1,322 @@
/**
* Parser for sed edit commands (-i flag substitutions)
* Extracts file paths and substitution patterns to enable file-edit-style rendering
*/
import { randomBytes } from 'crypto'
import { tryParseShellCommand } from '../../utils/bash/shellQuote.js'
// BRE→ERE conversion placeholders (null-byte sentinels, never appear in user input)
const BACKSLASH_PLACEHOLDER = '\x00BACKSLASH\x00'
const PLUS_PLACEHOLDER = '\x00PLUS\x00'
const QUESTION_PLACEHOLDER = '\x00QUESTION\x00'
const PIPE_PLACEHOLDER = '\x00PIPE\x00'
const LPAREN_PLACEHOLDER = '\x00LPAREN\x00'
const RPAREN_PLACEHOLDER = '\x00RPAREN\x00'
const BACKSLASH_PLACEHOLDER_RE = new RegExp(BACKSLASH_PLACEHOLDER, 'g')
const PLUS_PLACEHOLDER_RE = new RegExp(PLUS_PLACEHOLDER, 'g')
const QUESTION_PLACEHOLDER_RE = new RegExp(QUESTION_PLACEHOLDER, 'g')
const PIPE_PLACEHOLDER_RE = new RegExp(PIPE_PLACEHOLDER, 'g')
const LPAREN_PLACEHOLDER_RE = new RegExp(LPAREN_PLACEHOLDER, 'g')
const RPAREN_PLACEHOLDER_RE = new RegExp(RPAREN_PLACEHOLDER, 'g')
export type SedEditInfo = {
/** The file path being edited */
filePath: string
/** The search pattern (regex) */
pattern: string
/** The replacement string */
replacement: string
/** Substitution flags (g, i, etc.) */
flags: string
/** Whether to use extended regex (-E or -r flag) */
extendedRegex: boolean
}
/**
* Check if a command is a sed in-place edit command
* Returns true only for simple sed -i 's/pattern/replacement/flags' file commands
*/
export function isSedInPlaceEdit(command: string): boolean {
const info = parseSedEditCommand(command)
return info !== null
}
/**
* Parse a sed edit command and extract the edit information
* Returns null if the command is not a valid sed in-place edit
*/
export function parseSedEditCommand(command: string): SedEditInfo | null {
const trimmed = command.trim()
// Must start with sed
const sedMatch = trimmed.match(/^\s*sed\s+/)
if (!sedMatch) return null
const withoutSed = trimmed.slice(sedMatch[0].length)
const parseResult = tryParseShellCommand(withoutSed)
if (!parseResult.success) return null
const tokens = parseResult.tokens
// Extract string tokens only
const args: string[] = []
for (const token of tokens) {
if (typeof token === 'string') {
args.push(token)
} else if (
typeof token === 'object' &&
token !== null &&
'op' in token &&
token.op === 'glob'
) {
// Glob patterns are too complex for this simple parser
return null
}
}
// Parse flags and arguments
let hasInPlaceFlag = false
let extendedRegex = false
let expression: string | null = null
let filePath: string | null = null
let i = 0
while (i < args.length) {
const arg = args[i]!
// Handle -i flag (with or without backup suffix)
if (arg === '-i' || arg === '--in-place') {
hasInPlaceFlag = true
i++
// On macOS, -i requires a suffix argument (even if empty string)
// Check if next arg looks like a backup suffix (empty, or starts with dot)
// Don't consume flags (-E, -r) or sed expressions (starting with s, y, d)
if (i < args.length) {
const nextArg = args[i]
// If next arg is empty string or starts with dot, it's a backup suffix
if (
typeof nextArg === 'string' &&
!nextArg.startsWith('-') &&
(nextArg === '' || nextArg.startsWith('.'))
) {
i++ // Skip the backup suffix
}
}
continue
}
if (arg.startsWith('-i')) {
// -i.bak or similar (inline suffix)
hasInPlaceFlag = true
i++
continue
}
// Handle extended regex flags
if (arg === '-E' || arg === '-r' || arg === '--regexp-extended') {
extendedRegex = true
i++
continue
}
// Handle -e flag with expression
if (arg === '-e' || arg === '--expression') {
if (i + 1 < args.length && typeof args[i + 1] === 'string') {
// Only support single expression
if (expression !== null) return null
expression = args[i + 1]!
i += 2
continue
}
return null
}
if (arg.startsWith('--expression=')) {
if (expression !== null) return null
expression = arg.slice('--expression='.length)
i++
continue
}
// Skip other flags we don't understand
if (arg.startsWith('-')) {
// Unknown flag - not safe to parse
return null
}
// Non-flag argument
if (expression === null) {
// First non-flag arg is the expression
expression = arg
} else if (filePath === null) {
// Second non-flag arg is the file path
filePath = arg
} else {
// More than one file - not supported for simple rendering
return null
}
i++
}
// Must have -i flag, expression, and file path
if (!hasInPlaceFlag || !expression || !filePath) {
return null
}
// Parse the substitution expression: s/pattern/replacement/flags
// Only support / as delimiter for simplicity
const substMatch = expression.match(/^s\//)
if (!substMatch) {
return null
}
const rest = expression.slice(2) // Skip 's/'
// Find pattern and replacement by tracking escaped characters
let pattern = ''
let replacement = ''
let flags = ''
let state: 'pattern' | 'replacement' | 'flags' = 'pattern'
let j = 0
while (j < rest.length) {
const char = rest[j]!
if (char === '\\' && j + 1 < rest.length) {
// Escaped character
if (state === 'pattern') {
pattern += char + rest[j + 1]
} else if (state === 'replacement') {
replacement += char + rest[j + 1]
} else {
flags += char + rest[j + 1]
}
j += 2
continue
}
if (char === '/') {
if (state === 'pattern') {
state = 'replacement'
} else if (state === 'replacement') {
state = 'flags'
} else {
// Extra delimiter in flags - unexpected
return null
}
j++
continue
}
if (state === 'pattern') {
pattern += char
} else if (state === 'replacement') {
replacement += char
} else {
flags += char
}
j++
}
// Must have found all three parts (pattern, replacement delimiter, and optional flags)
if (state !== 'flags') {
return null
}
// Validate flags - only allow safe substitution flags
const validFlags = /^[gpimIM1-9]*$/
if (!validFlags.test(flags)) {
return null
}
return {
filePath,
pattern,
replacement,
flags,
extendedRegex,
}
}
/**
* Apply a sed substitution to file content
* Returns the new content after applying the substitution
*/
export function applySedSubstitution(
content: string,
sedInfo: SedEditInfo,
): string {
// Convert sed pattern to JavaScript regex
let regexFlags = ''
// Handle global flag
if (sedInfo.flags.includes('g')) {
regexFlags += 'g'
}
// Handle case-insensitive flag (i or I in sed)
if (sedInfo.flags.includes('i') || sedInfo.flags.includes('I')) {
regexFlags += 'i'
}
// Handle multiline flag (m or M in sed)
if (sedInfo.flags.includes('m') || sedInfo.flags.includes('M')) {
regexFlags += 'm'
}
// Convert sed pattern to JavaScript regex pattern
let jsPattern = sedInfo.pattern
// Unescape \/ to /
.replace(/\\\//g, '/')
// In BRE mode (no -E flag), metacharacters have opposite escaping:
// BRE: \+ means "one or more", + is literal
// ERE/JS: + means "one or more", \+ is literal
// We need to convert BRE escaping to ERE for JavaScript regex
if (!sedInfo.extendedRegex) {
jsPattern = jsPattern
// Step 1: Protect literal backslashes (\\) first - in both BRE and ERE, \\ is literal backslash
.replace(/\\\\/g, BACKSLASH_PLACEHOLDER)
// Step 2: Replace escaped metacharacters with placeholders (these should become unescaped in JS)
.replace(/\\\+/g, PLUS_PLACEHOLDER)
.replace(/\\\?/g, QUESTION_PLACEHOLDER)
.replace(/\\\|/g, PIPE_PLACEHOLDER)
.replace(/\\\(/g, LPAREN_PLACEHOLDER)
.replace(/\\\)/g, RPAREN_PLACEHOLDER)
// Step 3: Escape unescaped metacharacters (these are literal in BRE)
.replace(/\+/g, '\\+')
.replace(/\?/g, '\\?')
.replace(/\|/g, '\\|')
.replace(/\(/g, '\\(')
.replace(/\)/g, '\\)')
// Step 4: Replace placeholders with their JS equivalents
.replace(BACKSLASH_PLACEHOLDER_RE, '\\\\')
.replace(PLUS_PLACEHOLDER_RE, '+')
.replace(QUESTION_PLACEHOLDER_RE, '?')
.replace(PIPE_PLACEHOLDER_RE, '|')
.replace(LPAREN_PLACEHOLDER_RE, '(')
.replace(RPAREN_PLACEHOLDER_RE, ')')
}
// Unescape sed-specific escapes in replacement
// Convert \n to newline, & to $& (match), etc.
// Use a unique placeholder with random salt to prevent injection attacks
const salt = randomBytes(8).toString('hex')
const ESCAPED_AMP_PLACEHOLDER = `___ESCAPED_AMPERSAND_${salt}___`
const jsReplacement = sedInfo.replacement
// Unescape \/ to /
.replace(/\\\//g, '/')
// First escape \& to a placeholder
.replace(/\\&/g, ESCAPED_AMP_PLACEHOLDER)
// Convert & to $& (full match) - use $$& to get literal $& in output
.replace(/&/g, '$$&')
// Convert placeholder back to literal &
.replace(new RegExp(ESCAPED_AMP_PLACEHOLDER, 'g'), '&')
try {
const regex = new RegExp(jsPattern, regexFlags)
return content.replace(regex, jsReplacement)
} catch {
// If regex is invalid, return original content
return content
}
}
+684
View File
@@ -0,0 +1,684 @@
import type { ToolPermissionContext } from '../../Tool.js'
import { splitCommand_DEPRECATED } from '../../utils/bash/commands.js'
import { tryParseShellCommand } from '../../utils/bash/shellQuote.js'
import type { PermissionResult } from '../../utils/permissions/PermissionResult.js'
/**
* Helper: Validate flags against an allowlist
* Handles both single flags and combined flags (e.g., -nE)
* @param flags Array of flags to validate
* @param allowedFlags Array of allowed single-character and long flags
* @returns true if all flags are valid, false otherwise
*/
function validateFlagsAgainstAllowlist(
flags: string[],
allowedFlags: string[],
): boolean {
for (const flag of flags) {
// Handle combined flags like -nE or -Er
if (flag.startsWith('-') && !flag.startsWith('--') && flag.length > 2) {
// Check each character in combined flag
for (let i = 1; i < flag.length; i++) {
const singleFlag = '-' + flag[i]
if (!allowedFlags.includes(singleFlag)) {
return false
}
}
} else {
// Single flag or long flag
if (!allowedFlags.includes(flag)) {
return false
}
}
}
return true
}
/**
* Pattern 1: Check if this is a line printing command with -n flag
* Allows: sed -n 'N' | sed -n 'N,M' with optional -E, -r, -z flags
* Allows semicolon-separated print commands like: sed -n '1p;2p;3p'
* File arguments are ALLOWED for this pattern
* @internal Exported for testing
*/
export function isLinePrintingCommand(
command: string,
expressions: string[],
): boolean {
const sedMatch = command.match(/^\s*sed\s+/)
if (!sedMatch) return false
const withoutSed = command.slice(sedMatch[0].length)
const parseResult = tryParseShellCommand(withoutSed)
if (!parseResult.success) return false
const parsed = parseResult.tokens
// Extract all flags
const flags: string[] = []
for (const arg of parsed) {
if (typeof arg === 'string' && arg.startsWith('-') && arg !== '--') {
flags.push(arg)
}
}
// Validate flags - only allow -n, -E, -r, -z and their long forms
const allowedFlags = [
'-n',
'--quiet',
'--silent',
'-E',
'--regexp-extended',
'-r',
'-z',
'--zero-terminated',
'--posix',
]
if (!validateFlagsAgainstAllowlist(flags, allowedFlags)) {
return false
}
// Check if -n flag is present (required for Pattern 1)
let hasNFlag = false
for (const flag of flags) {
if (flag === '-n' || flag === '--quiet' || flag === '--silent') {
hasNFlag = true
break
}
// Check in combined flags
if (flag.startsWith('-') && !flag.startsWith('--') && flag.includes('n')) {
hasNFlag = true
break
}
}
// Must have -n flag for Pattern 1
if (!hasNFlag) {
return false
}
// Must have at least one expression
if (expressions.length === 0) {
return false
}
// All expressions must be print commands (strict allowlist)
// Allow semicolon-separated commands
for (const expr of expressions) {
const commands = expr.split(';')
for (const cmd of commands) {
if (!isPrintCommand(cmd.trim())) {
return false
}
}
}
return true
}
/**
* Helper: Check if a single command is a valid print command
* STRICT ALLOWLIST - only these exact forms are allowed:
* - p (print all)
* - Np (print line N, where N is digits)
* - N,Mp (print lines N through M)
* Anything else (including w, W, e, E commands) is rejected.
* @internal Exported for testing
*/
export function isPrintCommand(cmd: string): boolean {
if (!cmd) return false
// Single strict regex that only matches allowed print commands
// ^(?:\d+|\d+,\d+)?p$ matches: p, 1p, 123p, 1,5p, 10,200p
return /^(?:\d+|\d+,\d+)?p$/.test(cmd)
}
/**
* Pattern 2: Check if this is a substitution command
* Allows: sed 's/pattern/replacement/flags' where flags are only: g, p, i, I, m, M, 1-9
* When allowFileWrites is true, allows -i flag and file arguments for in-place editing
* When allowFileWrites is false (default), requires stdout-only (no file arguments, no -i flag)
* @internal Exported for testing
*/
function isSubstitutionCommand(
command: string,
expressions: string[],
hasFileArguments: boolean,
options?: { allowFileWrites?: boolean },
): boolean {
const allowFileWrites = options?.allowFileWrites ?? false
// When not allowing file writes, must NOT have file arguments
if (!allowFileWrites && hasFileArguments) {
return false
}
const sedMatch = command.match(/^\s*sed\s+/)
if (!sedMatch) return false
const withoutSed = command.slice(sedMatch[0].length)
const parseResult = tryParseShellCommand(withoutSed)
if (!parseResult.success) return false
const parsed = parseResult.tokens
// Extract all flags
const flags: string[] = []
for (const arg of parsed) {
if (typeof arg === 'string' && arg.startsWith('-') && arg !== '--') {
flags.push(arg)
}
}
// Validate flags based on mode
// Base allowed flags for both modes
const allowedFlags = ['-E', '--regexp-extended', '-r', '--posix']
// When allowing file writes, also permit -i and --in-place
if (allowFileWrites) {
allowedFlags.push('-i', '--in-place')
}
if (!validateFlagsAgainstAllowlist(flags, allowedFlags)) {
return false
}
// Must have exactly one expression
if (expressions.length !== 1) {
return false
}
const expr = expressions[0]!.trim()
// STRICT ALLOWLIST: Must be exactly a substitution command starting with 's'
// This rejects standalone commands like 'e', 'w file', etc.
if (!expr.startsWith('s')) {
return false
}
// Parse substitution: s/pattern/replacement/flags
// Only allow / as delimiter (strict)
const substitutionMatch = expr.match(/^s\/(.*?)$/)
if (!substitutionMatch) {
return false
}
const rest = substitutionMatch[1]!
// Find the positions of / delimiters
let delimiterCount = 0
let lastDelimiterPos = -1
let i = 0
while (i < rest.length) {
if (rest[i] === '\\') {
// Skip escaped character
i += 2
continue
}
if (rest[i] === '/') {
delimiterCount++
lastDelimiterPos = i
}
i++
}
// Must have found exactly 2 delimiters (pattern and replacement)
if (delimiterCount !== 2) {
return false
}
// Extract flags (everything after the last delimiter)
const exprFlags = rest.slice(lastDelimiterPos + 1)
// Validate flags: only allow g, p, i, I, m, M, and optionally ONE digit 1-9
const allowedFlagChars = /^[gpimIM]*[1-9]?[gpimIM]*$/
if (!allowedFlagChars.test(exprFlags)) {
return false
}
return true
}
/**
* Checks if a sed command is allowed by the allowlist.
* The allowlist patterns themselves are strict enough to reject dangerous operations.
* @param command The sed command to check
* @param options.allowFileWrites When true, allows -i flag and file arguments for substitution commands
* @returns true if the command is allowed (matches allowlist and passes denylist check), false otherwise
*/
export function sedCommandIsAllowedByAllowlist(
command: string,
options?: { allowFileWrites?: boolean },
): boolean {
const allowFileWrites = options?.allowFileWrites ?? false
// Extract sed expressions (content inside quotes where actual sed commands live)
let expressions: string[]
try {
expressions = extractSedExpressions(command)
} catch (_error) {
// If parsing failed, treat as not allowed
return false
}
// Check if sed command has file arguments
const hasFileArguments = hasFileArgs(command)
// Check if command matches allowlist patterns
let isPattern1 = false
let isPattern2 = false
if (allowFileWrites) {
// When allowing file writes, only check substitution commands (Pattern 2 variant)
// Pattern 1 (line printing) doesn't need file writes
isPattern2 = isSubstitutionCommand(command, expressions, hasFileArguments, {
allowFileWrites: true,
})
} else {
// Standard read-only mode: check both patterns
isPattern1 = isLinePrintingCommand(command, expressions)
isPattern2 = isSubstitutionCommand(command, expressions, hasFileArguments)
}
if (!isPattern1 && !isPattern2) {
return false
}
// Pattern 2 does not allow semicolons (command separators)
// Pattern 1 allows semicolons for separating print commands
for (const expr of expressions) {
if (isPattern2 && expr.includes(';')) {
return false
}
}
// Defense-in-depth: Even if allowlist matches, check denylist
for (const expr of expressions) {
if (containsDangerousOperations(expr)) {
return false
}
}
return true
}
/**
* Check if a sed command has file arguments (not just stdin)
* @internal Exported for testing
*/
export function hasFileArgs(command: string): boolean {
const sedMatch = command.match(/^\s*sed\s+/)
if (!sedMatch) return false
const withoutSed = command.slice(sedMatch[0].length)
const parseResult = tryParseShellCommand(withoutSed)
if (!parseResult.success) return true
const parsed = parseResult.tokens
try {
let argCount = 0
let hasEFlag = false
for (let i = 0; i < parsed.length; i++) {
const arg = parsed[i]
// Handle both string arguments and glob patterns (like *.log)
if (typeof arg !== 'string' && typeof arg !== 'object') continue
// If it's a glob pattern, it counts as a file argument
if (
typeof arg === 'object' &&
arg !== null &&
'op' in arg &&
arg.op === 'glob'
) {
return true
}
// Skip non-string arguments that aren't glob patterns
if (typeof arg !== 'string') continue
// Handle -e flag followed by expression
if ((arg === '-e' || arg === '--expression') && i + 1 < parsed.length) {
hasEFlag = true
i++ // Skip the next argument since it's the expression
continue
}
// Handle --expression=value format
if (arg.startsWith('--expression=')) {
hasEFlag = true
continue
}
// Handle -e=value format (non-standard but defense in depth)
if (arg.startsWith('-e=')) {
hasEFlag = true
continue
}
// Skip other flags
if (arg.startsWith('-')) continue
argCount++
// If we used -e flags, ALL non-flag arguments are file arguments
if (hasEFlag) {
return true
}
// If we didn't use -e flags, the first non-flag argument is the sed expression,
// so we need more than 1 non-flag argument to have file arguments
if (argCount > 1) {
return true
}
}
return false
} catch (_error) {
return true // Assume dangerous if parsing fails
}
}
/**
* Extract sed expressions from command, ignoring flags and filenames
* @param command Full sed command
* @returns Array of sed expressions to check for dangerous operations
* @throws Error if parsing fails
* @internal Exported for testing
*/
export function extractSedExpressions(command: string): string[] {
const expressions: string[] = []
// Calculate withoutSed by trimming off the first N characters (removing 'sed ')
const sedMatch = command.match(/^\s*sed\s+/)
if (!sedMatch) return expressions
const withoutSed = command.slice(sedMatch[0].length)
// Reject dangerous flag combinations like -ew, -eW, -ee, -we (combined -e/-w with dangerous commands)
if (/-e[wWe]/.test(withoutSed) || /-w[eE]/.test(withoutSed)) {
throw new Error('Dangerous flag combination detected')
}
// Use shell-quote to parse the arguments properly
const parseResult = tryParseShellCommand(withoutSed)
if (!parseResult.success) {
// Malformed shell syntax - throw error to be caught by caller
throw new Error(`Malformed shell syntax: ${parseResult.error}`)
}
const parsed = parseResult.tokens
try {
let foundEFlag = false
let foundExpression = false
for (let i = 0; i < parsed.length; i++) {
const arg = parsed[i]
// Skip non-string arguments (like control operators)
if (typeof arg !== 'string') continue
// Handle -e flag followed by expression
if ((arg === '-e' || arg === '--expression') && i + 1 < parsed.length) {
foundEFlag = true
const nextArg = parsed[i + 1]
if (typeof nextArg === 'string') {
expressions.push(nextArg)
i++ // Skip the next argument since we consumed it
}
continue
}
// Handle --expression=value format
if (arg.startsWith('--expression=')) {
foundEFlag = true
expressions.push(arg.slice('--expression='.length))
continue
}
// Handle -e=value format (non-standard but defense in depth)
if (arg.startsWith('-e=')) {
foundEFlag = true
expressions.push(arg.slice('-e='.length))
continue
}
// Skip other flags
if (arg.startsWith('-')) continue
// If we haven't found any -e flags, the first non-flag argument is the sed expression
if (!foundEFlag && !foundExpression) {
expressions.push(arg)
foundExpression = true
continue
}
// If we've already found -e flags or a standalone expression,
// remaining non-flag arguments are filenames
break
}
} catch (error) {
// If shell-quote parsing fails, treat the sed command as unsafe
throw new Error(
`Failed to parse sed command: ${error instanceof Error ? error.message : 'Unknown error'}`,
)
}
return expressions
}
/**
* Check if a sed expression contains dangerous operations (denylist)
* @param expression Single sed expression (without quotes)
* @returns true if dangerous, false if safe
*/
function containsDangerousOperations(expression: string): boolean {
const cmd = expression.trim()
if (!cmd) return false
// CONSERVATIVE REJECTIONS: Broadly reject patterns that could be dangerous
// When in doubt, treat as unsafe
// Reject non-ASCII characters (Unicode homoglyphs, combining chars, etc.)
// Examples: (fullwidth), (small capital), w̃ (combining tilde)
// Check for characters outside ASCII range (0x01-0x7F, excluding null byte)
// eslint-disable-next-line no-control-regex
if (/[^\x01-\x7F]/.test(cmd)) {
return true
}
// Reject curly braces (blocks) - too complex to parse
if (cmd.includes('{') || cmd.includes('}')) {
return true
}
// Reject newlines - multi-line commands are too complex
if (cmd.includes('\n')) {
return true
}
// Reject comments (# not immediately after s command)
// Comments look like: #comment or start with #
// Delimiter looks like: s#pattern#replacement#
const hashIndex = cmd.indexOf('#')
if (hashIndex !== -1 && !(hashIndex > 0 && cmd[hashIndex - 1] === 's')) {
return true
}
// Reject negation operator
// Negation can appear: at start (!/pattern/), after address (/pattern/!, 1,10!, $!)
// Delimiter looks like: s!pattern!replacement! (has 's' before it)
if (/^!/.test(cmd) || /[/\d$]!/.test(cmd)) {
return true
}
// Reject tilde in GNU step address format (digit~digit, ,~digit, or $~digit)
// Allow whitespace around tilde
if (/\d\s*~\s*\d|,\s*~\s*\d|\$\s*~\s*\d/.test(cmd)) {
return true
}
// Reject comma at start (bare comma is shorthand for 1,$ address range)
if (/^,/.test(cmd)) {
return true
}
// Reject comma followed by +/- (GNU offset addresses)
if (/,\s*[+-]/.test(cmd)) {
return true
}
// Reject backslash tricks:
// 1. s\ (substitution with backslash delimiter)
// 2. \X where X could be an alternate delimiter (|, #, %, etc.) - not regex escapes
if (/s\\/.test(cmd) || /\\[|#%@]/.test(cmd)) {
return true
}
// Reject escaped slashes followed by w/W (patterns like /\/path\/to\/file/w)
if (/\\\/.*[wW]/.test(cmd)) {
return true
}
// Reject malformed/suspicious patterns we don't understand
// If there's a slash followed by non-slash chars, then whitespace, then dangerous commands
// Examples: /pattern w file, /pattern e cmd, /foo X;w file
if (/\/[^/]*\s+[wWeE]/.test(cmd)) {
return true
}
// Reject malformed substitution commands that don't follow normal pattern
// Examples: s/foobareoutput.txt (missing delimiters), s/foo/bar//w (extra delimiter)
if (/^s\//.test(cmd) && !/^s\/[^/]*\/[^/]*\/[^/]*$/.test(cmd)) {
return true
}
// PARANOID: Reject any command starting with 's' that ends with dangerous chars (w, W, e, E)
// and doesn't match our known safe substitution pattern. This catches malformed s commands
// with non-slash delimiters that might be trying to use dangerous flags.
if (/^s./.test(cmd) && /[wWeE]$/.test(cmd)) {
// Check if it's a properly formed substitution (any delimiter, not just /)
const properSubst = /^s([^\\\n]).*?\1.*?\1[^wWeE]*$/.test(cmd)
if (!properSubst) {
return true
}
}
// Check for dangerous write commands
// Patterns: [address]w filename, [address]W filename, /pattern/w filename, /pattern/W filename
// Simplified to avoid exponential backtracking (CodeQL issue)
// Check for w/W in contexts where it would be a command (with optional whitespace)
if (
/^[wW]\s*\S+/.test(cmd) || // At start: w file
/^\d+\s*[wW]\s*\S+/.test(cmd) || // After line number: 1w file or 1 w file
/^\$\s*[wW]\s*\S+/.test(cmd) || // After $: $w file or $ w file
/^\/[^/]*\/[IMim]*\s*[wW]\s*\S+/.test(cmd) || // After pattern: /pattern/w file
/^\d+,\d+\s*[wW]\s*\S+/.test(cmd) || // After range: 1,10w file
/^\d+,\$\s*[wW]\s*\S+/.test(cmd) || // After range: 1,$w file
/^\/[^/]*\/[IMim]*,\/[^/]*\/[IMim]*\s*[wW]\s*\S+/.test(cmd) // After pattern range: /s/,/e/w file
) {
return true
}
// Check for dangerous execute commands
// Patterns: [address]e [command], /pattern/e [command], or commands starting with e
// Simplified to avoid exponential backtracking (CodeQL issue)
// Check for e in contexts where it would be a command (with optional whitespace)
if (
/^e/.test(cmd) || // At start: e cmd
/^\d+\s*e/.test(cmd) || // After line number: 1e or 1 e
/^\$\s*e/.test(cmd) || // After $: $e or $ e
/^\/[^/]*\/[IMim]*\s*e/.test(cmd) || // After pattern: /pattern/e
/^\d+,\d+\s*e/.test(cmd) || // After range: 1,10e
/^\d+,\$\s*e/.test(cmd) || // After range: 1,$e
/^\/[^/]*\/[IMim]*,\/[^/]*\/[IMim]*\s*e/.test(cmd) // After pattern range: /s/,/e/e
) {
return true
}
// Check for substitution commands with dangerous flags
// Pattern: s<delim>pattern<delim>replacement<delim>flags where flags contain w or e
// Per POSIX, sed allows any character except backslash and newline as delimiter
const substitutionMatch = cmd.match(/s([^\\\n]).*?\1.*?\1(.*?)$/)
if (substitutionMatch) {
const flags = substitutionMatch[2] || ''
// Check for write flag: s/old/new/w filename or s/old/new/gw filename
if (flags.includes('w') || flags.includes('W')) {
return true
}
// Check for execute flag: s/old/new/e or s/old/new/ge
if (flags.includes('e') || flags.includes('E')) {
return true
}
}
// Check for y (transliterate) command followed by dangerous operations
// Pattern: y<delim>source<delim>dest<delim> followed by anything
// The y command uses same delimiter syntax as s command
// PARANOID: Reject any y command that has w/W/e/E anywhere after the delimiters
const yCommandMatch = cmd.match(/y([^\\\n])/)
if (yCommandMatch) {
// If we see a y command, check if there's any w, W, e, or E in the entire command
// This is paranoid but safe - y commands are rare and w/e after y is suspicious
if (/[wWeE]/.test(cmd)) {
return true
}
}
return false
}
/**
* Cross-cutting validation step for sed commands.
*
* This is a constraint check that blocks dangerous sed operations regardless of mode.
* It returns 'passthrough' for non-sed commands or safe sed commands,
* and 'ask' for dangerous sed operations (w/W/e/E commands).
*
* @param input - Object containing the command string
* @param toolPermissionContext - Context containing mode and permissions
* @returns
* - 'ask' if any sed command contains dangerous operations
* - 'passthrough' if no sed commands or all are safe
*/
export function checkSedConstraints(
input: { command: string },
toolPermissionContext: ToolPermissionContext,
): PermissionResult {
const commands = splitCommand_DEPRECATED(input.command)
for (const cmd of commands) {
// Skip non-sed commands
const trimmed = cmd.trim()
const baseCmd = trimmed.split(/\s+/)[0]
if (baseCmd !== 'sed') {
continue
}
// In acceptEdits mode, allow file writes (-i flag) but still block dangerous operations
const allowFileWrites = toolPermissionContext.mode === 'acceptEdits'
const isAllowed = sedCommandIsAllowedByAllowlist(trimmed, {
allowFileWrites,
})
if (!isAllowed) {
return {
behavior: 'ask',
message:
'sed command requires approval (contains potentially dangerous operations)',
decisionReason: {
type: 'other',
reason:
'sed command contains operations that require explicit approval (e.g., write commands, execute commands)',
},
}
}
}
// No dangerous sed commands found (or no sed commands at all)
return {
behavior: 'passthrough',
message: 'No dangerous sed operations detected',
}
}
+153
View File
@@ -0,0 +1,153 @@
import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'
import { splitCommand_DEPRECATED } from '../../utils/bash/commands.js'
import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js'
import { getSettings_DEPRECATED } from '../../utils/settings/settings.js'
import {
BINARY_HIJACK_VARS,
bashPermissionRule,
matchWildcardPattern,
stripAllLeadingEnvVars,
stripSafeWrappers,
} from './bashPermissions.js'
type SandboxInput = {
command?: string
dangerouslyDisableSandbox?: boolean
}
// NOTE: excludedCommands is a user-facing convenience feature, not a security boundary.
// It is not a security bug to be able to bypass excludedCommands — the sandbox permission
// system (which prompts users) is the actual security control.
function containsExcludedCommand(command: string): boolean {
// Check dynamic config for disabled commands and substrings (only for ants)
if (process.env.USER_TYPE === 'ant') {
const disabledCommands = getFeatureValue_CACHED_MAY_BE_STALE<{
commands: string[]
substrings: string[]
}>('tengu_sandbox_disabled_commands', { commands: [], substrings: [] })
// Check if command contains any disabled substrings
for (const substring of disabledCommands.substrings) {
if (command.includes(substring)) {
return true
}
}
// Check if command starts with any disabled commands
try {
const commandParts = splitCommand_DEPRECATED(command)
for (const part of commandParts) {
const baseCommand = part.trim().split(' ')[0]
if (baseCommand && disabledCommands.commands.includes(baseCommand)) {
return true
}
}
} catch {
// If we can't parse the command (e.g., malformed bash syntax),
// treat it as not excluded to allow other validation checks to handle it
// This prevents crashes when rendering tool use messages
}
}
// Check user-configured excluded commands from settings
const settings = getSettings_DEPRECATED()
const userExcludedCommands = settings.sandbox?.excludedCommands ?? []
if (userExcludedCommands.length === 0) {
return false
}
// Split compound commands (e.g. "docker ps && curl evil.com") into individual
// subcommands and check each one against excluded patterns. This prevents a
// compound command from escaping the sandbox just because its first subcommand
// matches an excluded pattern.
let subcommands: string[]
try {
subcommands = splitCommand_DEPRECATED(command)
} catch {
subcommands = [command]
}
for (const subcommand of subcommands) {
const trimmed = subcommand.trim()
// Also try matching with env var prefixes and wrapper commands stripped, so
// that `FOO=bar bazel ...` and `timeout 30 bazel ...` match `bazel:*`. Not a
// security boundary (see NOTE at top); the &&-split above already lets
// `export FOO=bar && bazel ...` match. BINARY_HIJACK_VARS kept as a heuristic.
//
// We iteratively apply both stripping operations until no new candidates are
// produced (fixed-point), matching the approach in filterRulesByContentsMatchingInput.
// This handles interleaved patterns like `timeout 300 FOO=bar bazel run`
// where single-pass composition would fail.
const candidates = [trimmed]
const seen = new Set(candidates)
let startIdx = 0
while (startIdx < candidates.length) {
const endIdx = candidates.length
for (let i = startIdx; i < endIdx; i++) {
const cmd = candidates[i]!
const envStripped = stripAllLeadingEnvVars(cmd, BINARY_HIJACK_VARS)
if (!seen.has(envStripped)) {
candidates.push(envStripped)
seen.add(envStripped)
}
const wrapperStripped = stripSafeWrappers(cmd)
if (!seen.has(wrapperStripped)) {
candidates.push(wrapperStripped)
seen.add(wrapperStripped)
}
}
startIdx = endIdx
}
for (const pattern of userExcludedCommands) {
const rule = bashPermissionRule(pattern)
for (const cand of candidates) {
switch (rule.type) {
case 'prefix':
if (cand === rule.prefix || cand.startsWith(rule.prefix + ' ')) {
return true
}
break
case 'exact':
if (cand === rule.command) {
return true
}
break
case 'wildcard':
if (matchWildcardPattern(rule.pattern, cand)) {
return true
}
break
}
}
}
}
return false
}
export function shouldUseSandbox(input: Partial<SandboxInput>): boolean {
if (!SandboxManager.isSandboxingEnabled()) {
return false
}
// Don't sandbox if explicitly overridden AND unsandboxed commands are allowed by policy
if (
input.dangerouslyDisableSandbox &&
SandboxManager.areUnsandboxedCommandsAllowed()
) {
return false
}
if (!input.command) {
return false
}
// Don't sandbox if the command contains user-configured excluded commands
if (containsExcludedCommand(input.command)) {
return false
}
return true
}
+2
View File
@@ -0,0 +1,2 @@
// Here to break circular dependency from prompt.ts
export const BASH_TOOL_NAME = 'Bash'
+223
View File
@@ -0,0 +1,223 @@
import type {
Base64ImageSource,
ContentBlockParam,
ToolResultBlockParam,
} from '@anthropic-ai/sdk/resources/index.mjs'
import { readFile, stat } from 'fs/promises'
import { getOriginalCwd } from 'src/bootstrap/state.js'
import { logEvent } from 'src/services/analytics/index.js'
import type { ToolPermissionContext } from 'src/Tool.js'
import { getCwd } from 'src/utils/cwd.js'
import { pathInAllowedWorkingPath } from 'src/utils/permissions/filesystem.js'
import { setCwd } from 'src/utils/Shell.js'
import { shouldMaintainProjectWorkingDir } from '../../utils/envUtils.js'
import { maybeResizeAndDownsampleImageBuffer } from '../../utils/imageResizer.js'
import { getMaxOutputLength } from '../../utils/shell/outputLimits.js'
import { countCharInString, plural } from '../../utils/stringUtils.js'
/**
* Strips leading and trailing lines that contain only whitespace/newlines.
* Unlike trim(), this preserves whitespace within content lines and only removes
* completely empty lines from the beginning and end.
*/
export function stripEmptyLines(content: string): string {
const lines = content.split('\n')
// Find the first non-empty line
let startIndex = 0
while (startIndex < lines.length && lines[startIndex]?.trim() === '') {
startIndex++
}
// Find the last non-empty line
let endIndex = lines.length - 1
while (endIndex >= 0 && lines[endIndex]?.trim() === '') {
endIndex--
}
// If all lines are empty, return empty string
if (startIndex > endIndex) {
return ''
}
// Return the slice with non-empty lines
return lines.slice(startIndex, endIndex + 1).join('\n')
}
/**
* Check if content is a base64 encoded image data URL
*/
export function isImageOutput(content: string): boolean {
return /^data:image\/[a-z0-9.+_-]+;base64,/i.test(content)
}
const DATA_URI_RE = /^data:([^;]+);base64,(.+)$/
/**
* Parse a data-URI string into its media type and base64 payload.
* Input is trimmed before matching.
*/
export function parseDataUri(
s: string,
): { mediaType: string; data: string } | null {
const match = s.trim().match(DATA_URI_RE)
if (!match || !match[1] || !match[2]) return null
return { mediaType: match[1], data: match[2] }
}
/**
* Build an image tool_result block from shell stdout containing a data URI.
* Returns null if parse fails so callers can fall through to text handling.
*/
export function buildImageToolResult(
stdout: string,
toolUseID: string,
): ToolResultBlockParam | null {
const parsed = parseDataUri(stdout)
if (!parsed) return null
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: [
{
type: 'image',
source: {
type: 'base64',
media_type: parsed.mediaType as Base64ImageSource['media_type'],
data: parsed.data,
},
},
],
}
}
// Cap file reads to 20 MB — any image data URI larger than this is
// well beyond what the API accepts (5 MB base64) and would OOM if read
// into memory.
const MAX_IMAGE_FILE_SIZE = 20 * 1024 * 1024
/**
* Resize image output from a shell tool. stdout is capped at
* getMaxOutputLength() when read back from the shell output file — if the
* full output spilled to disk, re-read it from there, since truncated base64
* would decode to a corrupt image that either throws here or gets rejected by
* the API. Caps dimensions too: compressImageBuffer only checks byte size, so
* a small-but-high-DPI PNG (e.g. matplotlib at dpi=300) sails through at full
* resolution and poisons many-image requests (CC-304).
*
* Returns the re-encoded data URI on success, or null if the source didn't
* parse as a data URI (caller decides whether to flip isImage).
*/
export async function resizeShellImageOutput(
stdout: string,
outputFilePath: string | undefined,
outputFileSize: number | undefined,
): Promise<string | null> {
let source = stdout
if (outputFilePath) {
const size = outputFileSize ?? (await stat(outputFilePath)).size
if (size > MAX_IMAGE_FILE_SIZE) return null
source = await readFile(outputFilePath, 'utf8')
}
const parsed = parseDataUri(source)
if (!parsed) return null
const buf = Buffer.from(parsed.data, 'base64')
const ext = parsed.mediaType.split('/')[1] || 'png'
const resized = await maybeResizeAndDownsampleImageBuffer(
buf,
buf.length,
ext,
)
return `data:image/${resized.mediaType};base64,${resized.buffer.toString('base64')}`
}
export function formatOutput(content: string): {
totalLines: number
truncatedContent: string
isImage?: boolean
} {
const isImage = isImageOutput(content)
if (isImage) {
return {
totalLines: 1,
truncatedContent: content,
isImage,
}
}
const maxOutputLength = getMaxOutputLength()
if (content.length <= maxOutputLength) {
return {
totalLines: countCharInString(content, '\n') + 1,
truncatedContent: content,
isImage,
}
}
const truncatedPart = content.slice(0, maxOutputLength)
const remainingLines = countCharInString(content, '\n', maxOutputLength) + 1
const truncated = `${truncatedPart}\n\n... [${remainingLines} lines truncated] ...`
return {
totalLines: countCharInString(content, '\n') + 1,
truncatedContent: truncated,
isImage,
}
}
export const stdErrAppendShellResetMessage = (stderr: string): string =>
`${stderr.trim()}\nShell cwd was reset to ${getOriginalCwd()}`
export function resetCwdIfOutsideProject(
toolPermissionContext: ToolPermissionContext,
): boolean {
const cwd = getCwd()
const originalCwd = getOriginalCwd()
const shouldMaintain = shouldMaintainProjectWorkingDir()
if (
shouldMaintain ||
// Fast path: originalCwd is unconditionally in allWorkingDirectories
// (filesystem.ts), so when cwd hasn't moved, pathInAllowedWorkingPath is
// trivially true — skip its syscalls for the no-cd common case.
(cwd !== originalCwd &&
!pathInAllowedWorkingPath(cwd, toolPermissionContext))
) {
// Reset to original directory if maintaining project dir OR outside allowed working directory
setCwd(originalCwd)
if (!shouldMaintain) {
logEvent('tengu_bash_tool_reset_to_original_dir', {})
return true
}
}
return false
}
/**
* Creates a human-readable summary of structured content blocks.
* Used to display MCP results with images and text in the UI.
*/
export function createContentSummary(content: ContentBlockParam[]): string {
const parts: string[] = []
let textCount = 0
let imageCount = 0
for (const block of content) {
if (block.type === 'image') {
imageCount++
} else if (block.type === 'text' && 'text' in block) {
textCount++
// Include first 200 chars of text blocks for context
const preview = block.text.slice(0, 200)
parts.push(preview + (block.text.length > 200 ? '...' : ''))
}
}
const summary: string[] = []
if (imageCount > 0) {
summary.push(`[${imageCount} ${plural(imageCount, 'image')}]`)
}
if (textCount > 0) {
summary.push(`[${textCount} text ${plural(textCount, 'block')}]`)
}
return `MCP Result: ${summary.join(', ')}${parts.length > 0 ? '\n\n' + parts.join('\n\n') : ''}`
}