feat(bughunter): make /bughunter public + add /bughunter-security & /bughunter-perf with robust fallback prompts (#1621)

* feat(bughunter): split into /bughunter, /bughunter-security, /bughunter-perf

Replace the single /bughunter command with three siblings that share a
common prefix:

  /bughunter          — general bug hunt (existing prompt, untouched)
  /bughunter-security — OWASP-aligned, exploit-driven, confidence ≥ 8
  /bughunter-perf     — hot-path complexity, sync I/O, leaks, N+1

Both new subcommands are prompt commands built with
createMovedToPluginCommand so they migrate to the bughunter marketplace
plugin unchanged once it ships. While the marketplace is private they
inline the full audit prompt (frontmatter + !`git ...` blocks) just like
the existing /bughunter.

All three stay in the public COMMANDS list (not INTERNAL_ONLY_COMMANDS)
so non-ant users can invoke them. clearCommandMemoizationCaches() now
also flushes the zero-arg COMMANDS() and builtInCommandNames() memos so
tests can switch USER_TYPE mid-run without poisoning the cache.

Adds regression tests in src/commands.test.ts covering:
  - bughunter stays public for non-ant users
  - bughunter-security and bughunter-perf are in the public list

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore(bughunter): remove orphan index.js after .js → .ts rename

The bughunter command directory was renamed from a single .js file to
index.ts in the previous commit, but git tracked them as separate paths
so the old .js was left in the tree. Drop it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(bughunter): enhance fallback prompts for robustness in non-git environments

- Add graceful error handling to all git commands in fallback prompts (|| echo fallbacks)
- Add explicit non-git fallback guidance in Phase 1 for all three commands
- /bughunter: search for entry points, core business logic, recently modified files
- /bughunter-security: search for auth/middleware, validation, DB, config, upload code
- /bughunter-perf: search for handlers, loops, data access, serialization, build configs
- Improve context labels to clarify git context may be empty

* fix(bughunter): address CodeRabbit feedback

- Fix test isolation: restore USER_TYPE/IS_DEMO env vars in finally blocks
- Add non-git fallback test cases for all three bughunter commands
- Fix bash pipeline issue: replace if/then/else subshells with simple git commands + static fallback text in template
- Fix output format contradiction: remove LOW confidence from scoring (Phase 3 drops LOW, so scoring only includes Critical/Medium)

* fix(test): correct case and prefix in git fallback assertions for bughunter-security and bughunter-perf tests

* fix(test): add missing opening parenthesis in bughunter test assertions

* fix(bughunter): complete non-git fallback and propagate allowedTools

- Fix git commands in all three prompts to always succeed with fallback text (using || echo)
- Modify createMovedToPluginCommand to accept allowedTools parameter
- Add allowedTools to all three bughunter commands so slash-command turn grants declared tools
- Parse allowed-tools from frontmatter at command creation time

* fix(bughunter): complete non-git fallback and allowedTools propagation

- Fix git commands in prompts to always succeed with fallback text (using || echo)
- Modify createMovedToPluginCommand to accept allowedTools parameter
- Add allowedTools to all three bughunter commands so slash-command turn grants declared tools
- Fix RECENTLY COMMITTED FILES command to avoid command substitution (permission check rejects )
- Update tests to accept shell tool's '(Bash completed with no output)' for empty results
- Use runWithCwdOverride and additionalWorkingDirectories for proper test isolation

* fix(bughunter): prevent shell injection via user-provided args

The user-provided scope was interpolated into the prompt template BEFORE
executeShellCommandsInPrompt() ran, so any !command or ```! block
syntax in the args would be interpreted and executed as shell commands.

Fix: parse frontmatter from the raw template and run shell execution first
(with {{ARGS}} still in place — inert to shell patterns), then replace
{{ARGS}} with the user scope on the processed output. This ensures args
are never fed through the shell command parser.

* refactor(bughunter): use createGetAppStateWithAllowedTools helper

Replaces duplicate inline getAppState overrides across all three bughunter
commands (bughunter, bughunter-security, bughunter-perf) with the shared
helper from src/utils/forkedAgent.ts. This:
- Eliminates ~30 lines of duplicated permission context modification
- Merges allowedTools with existing alwaysAllowRules.command (vs overwrite)

* fix(bughunter): address jatmn review - String.replace special patterns + test isolation

- Replace '{{ARGS}}' with a replacer function () => scope instead of
  the plain string 'scope'. JavaScript's String.replace treats $&, $',
  $', , 32855 specially even in string replacements, so a scope like
  'src/auth $&' would render as 'src/auth {{ARGS}}' instead of literal
  text. The replacer function bypasses all special patterns.

- Restore USER_TYPE and IS_DEMO env vars in the injection regression
  test's finally block, matching the isolation pattern used by all other
  bughunter tests.

* fix(bughunter): make fallback prompt generation work on Windows

Wrap executeShellCommandsInPrompt() in a try/catch in all three bughunter
commands. On platforms where bash is unavailable (e.g. Windows without Git
Bash), the bash-specific shell syntax (2>/dev/null, | head -N) would cause
executeShellCommandsInPrompt to throw MalformedCommandError, preventing the
prompt from being generated at all.

The catch handler replaces the !`command` inline patterns with a static
placeholder, allowing the LLM to still receive the full audit instructions
and non-git search strategies in Phase 1.

* fix(bughunter-perf): remove Low severity contradiction

The summary line included Low: L but Phase 3 drops non-measurable findings
and exclusions remove micro-optimizations. Low findings (measurable but
not user-visible) would never survive the filter, so remove Low from the
severity categories and summary line.

fix(bughunter-security): align log-forging exclusion with A9 criteria

Exclusion #11 blocked all log spoofing/forging, but A9 says to flag
log injection when it enables audit-trail forgery. Narrowed the exclusion
to allow concrete audit-trail attacks through while still excluding
generic non-exploitable logging suggestions.

* fix(bughunter-security): tighten log-forging exclusion threshold

Reword exclusion #11 to require concrete evidence of a log-entry or
structured-field forgery path, not merely unsanitized user input.

* fix(bughunter): preserve fallback text on Windows/no-bash path

Replace generic '(Shell execution unavailable)' placeholder with a regex
that extracts the || echo "..." fallback text from each shell command.
This ensures the prompt shows meaningful messages like
'(If empty: not a git repository or git unavailable)' even when bash is
unavailable (e.g. Windows without Git Bash), matching what Linux users see
from working shell execution.

Also make injection test assertion platform-agnostic — accept either bash
output or the static echo fallback text.

* refactor(test): extract duplicate mockContext into createMockToolContext helper

The three non-git fallback tests each had an identical ~42-line mockContext
object. Moved it to a shared createMockToolContext(cwd, commands) helper
and a FULL_GIT_COMMANDS constant. Also updated the injection test to use
the same helper. Net -89 lines.

* fix(createMovedToPluginCommand): only grant allowedTools when fallback prompt runs

The ant (USER_TYPE === 'ant') branch returns a plugin-install notice that
doesn't need Read/Glob/Grep/Bash tools, but allowedTools was statically
attached to the command object. This caused processSlashCommand to grant
turn-scoped permissions for tools that were never used.

Changed to a getter that returns undefined in the ant branch, so the
plugin-install notice runs without unnecessary tool permissions.

* fix(bughunter): simplify shell commands to single git commands, narrow catch to surface interruptions

* fix(bughunter): surface permission-denied/aborted shell preprocessing, fix Windows cleanup

* fix(dragDropPaths.test): resolve package.json relative to test file, not process.cwd()

* fix(commands.test): restore original cwd in rmRetry, guarantee env/cache cleanup on rm failure

* fix(bughunter): bound diff to 400 lines, swap HEAD~10 for git log -10

Address both P2 reviewer findings on feat/bughunter-command-v3-new.

(1) Fresh-repo HEAD~10 lookup stripped every snippet. In a one-commit
    repo, `git diff --name-only HEAD~10..HEAD --diff-filter=AM` exits
    128 (HEAD~10 doesn't resolve). The shell-execution catch then ran
    the outer "strip all snippets" fallback, leaving git status /
    diff --cached / diff HEAD empty even though those commands would
    have produced useful context. Switched to `git log -10 --name-only
    --diff-filter=AM`, which works at any history depth and yields the
    same file list. Applied to bughunter, bughunter-security, and
    bughunter-perf.

(2) Diff cap removed in 73d0bcb. The prompt label still advertised
    "first 400 lines" but the snippet was just `git diff HEAD -- .`,
    and a 900-line diff was injected verbatim. Added a new `lineLimits`
    option to `executeShellCommandsInPrompt` that bounds output by
    command prefix. The cap is applied to stdout *before*
    processToolResultBlock, so the persistence + empty-content guard
    flows run once on the bounded payload, and large diffs no longer
    hit the 30k Bash result cap and spill into the prompt. Each
    bughunter command passes
    `{ lineLimits: { 'git diff HEAD -- .': 400 } }`. Allowed-tools
    frontmatter is unchanged — no compound `| head -400` that the
    permission parser might reject.

Tests:
- `executeShellCommandsInPrompt applies per-prefix line limits` +
  `does not truncate below the cap` (new unit tests in
  promptShellExecution.test.ts).
- `bughunter keeps git context populated in a fresh single-commit
  repo` (regression for finding 1, uses real one-commit git repo).
- `bughunter diff block is bounded to 400 lines` (regression for
  finding 2, builds 1000-line diff and asserts ≤400 lines).
- `FULL_GIT_COMMANDS` and the injection test now include
  `git log -10 --name-only --diff-filter=AM` in place of the
  removed HEAD~10 form.

* fix(bughunter): keep recent-files path-only, cover all three siblings

Two review follow-ups on the previous P2 commit.

(P3) `git log -10 --name-only` defaulted to --pretty=fuller, so the
"RECENTLY COMMITTED FILES" block injected commit hash, author, date,
and message lines into the prompt under a files-only heading — that
extra metadata crowded out the scoped file list the command was
trying to provide. Added `--pretty=format:` to suppress the commit
header on all three commands (bughunter, bughunter-security,
bughunter-perf). Verified locally: the previous form emitted ~7
header lines per commit; the new form emits just the file paths.

(P2) The fresh-repo and 400-line cap regression tests only exercised
/bughunter, so a sibling could regress back to the old shallow-history
failure or lose the diff cap without this suite failing. Parameterized
both tests over {bughunter, bughunter-security, bughunter-perf} via a
BUGHUNTER_SIBLINGS const; each command now runs both regressions in
its own tmp dir (six new test cases total). Typecheck clean, 27
tests pass.

* fix(promptShellExecution): granular snippet fallback, restore rich error for other callers

- Add granularFallback option to executeShellCommandsInPrompt. When
  enabled, a failing shell snippet is blanked in place and the rest of
  the snippets keep their output. Permission denials and interrupted
  ShellError still rethrow as MalformedCommandError, never swallowed.
- Restore the formatted MalformedCommandError wrapping in the default
  path. Previously a no-op that rethrew the raw ShellError, which made
  processSlashCommand render only 'ShellError: Shell command failed'
  for /commit, /security-review, /commit-push-pr, loaded skills, and
  plugin commands. Now includes the failing pattern and formatted
  stdout/stderr.
- /bughunter, /bughunter-security, /bughunter-perf opt into
  granularFallback and drop the catch-and-strip-all pattern. A failing
  'git log -10' on a zero-commit repo no longer discards git status
  output.
- Tests cover per-snippet blanking, default-path rich error wrapping,
  and that permission denials still surface under granularFallback.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(promptShellExecution): preserve trailing newline in applyLineLimit truncation

---------

Co-authored-by: Gravirei <gravirei@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Gravirei
2026-06-17 11:04:01 +08:00
committed by GitHub
co-authored by Gravirei Claude Opus 4.6
parent d5588ea80d
commit 1aabe261db
10 changed files with 1622 additions and 23 deletions
+494 -3
View File
@@ -1,7 +1,17 @@
import { afterEach, describe, expect, test } from 'bun:test'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { chdir } from 'node:process'
import { afterEach, describe, expect, test } from 'bun:test'
import type { CommandBase, PromptCommand } from './types/command.js'
import { runWithCwdOverride } from './utils/cwd.js'
import {
builtInCommandNames,
clearCommandMemoizationCaches,
formatDescriptionWithSource,
getCommands,
INTERNAL_ONLY_COMMANDS,
} from './commands.js'
import { registerBatchSkill } from './skills/bundled/batch.js'
import { registerDebugSkill } from './skills/bundled/debug.js'
@@ -31,9 +41,490 @@ afterEach(() => {
clearBundledSkills()
})
// Narrows the Command union to the prompt variant so getPromptForCommand is
// callable; bughunter commands are always registered as prompt commands.
function findPromptCommand(cmds: ReturnType<typeof getCommands> extends Promise<infer T> ? T : never, name: string): CommandBase & PromptCommand {
const cmd = cmds.find(c => c.name === name)
if (!cmd || cmd.type !== 'prompt') {
throw new Error(`expected /${name} to be registered as a prompt command`)
}
return cmd
}
describe('builtInCommandNames', () => {
test('includes the LSP command', () => {
expect(builtInCommandNames()).toContain('lsp')
test('includes the LSP command', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'oc-test-lsp-'))
try {
const cmds = await getCommands(cwd)
expect(cmds.map(c => c.name)).toContain('lsp')
} finally {
await rm(cwd, { recursive: true, force: true })
}
})
test('getCommands() includes bughunter for normal users (USER_TYPE unset)', async () => {
// Regression: bughunter previously lived in INTERNAL_ONLY_COMMANDS and was
// never available to non-ant users. Ensure it stays in the public COMMANDS list.
const originalUserType = process.env['USER_TYPE']
const originalIsDemo = process.env['IS_DEMO']
delete process.env['USER_TYPE']
delete process.env['IS_DEMO']
// Clear ALL command caches — including the zero-arg COMMANDS() memoize that
// captures USER_TYPE at first call and never re-evaluates it. Without this,
// a prior test that ran with USER_TYPE=ant would pollute the COMMANDS cache
// and make bughunter appear gated even in a "normal user" run.
clearCommandMemoizationCaches()
// Use a unique tmp dir to avoid the loadAllCommands memoize cache
const cwd = await mkdtemp(join(tmpdir(), 'oc-test-bughunter-'))
try {
const cmds = await getCommands(cwd)
expect(cmds.map(c => c.name)).toContain('bughunter')
expect(INTERNAL_ONLY_COMMANDS.map(c => c.name)).not.toContain('bughunter')
} finally {
await rm(cwd, { recursive: true, force: true })
// Restore env vars to avoid test isolation issues
if (originalUserType !== undefined) {
process.env['USER_TYPE'] = originalUserType
} else {
delete process.env['USER_TYPE']
}
if (originalIsDemo !== undefined) {
process.env['IS_DEMO'] = originalIsDemo
} else {
delete process.env['IS_DEMO']
}
clearCommandMemoizationCaches()
}
})
test('getCommands() includes bughunter-security and bughunter-perf for normal users', async () => {
// Sibling subcommands of /bughunter — must stay in the public COMMANDS list,
// not in INTERNAL_ONLY_COMMANDS, so normal users can invoke them.
const originalUserType = process.env['USER_TYPE']
const originalIsDemo = process.env['IS_DEMO']
delete process.env['USER_TYPE']
delete process.env['IS_DEMO']
clearCommandMemoizationCaches()
const cwd = await mkdtemp(join(tmpdir(), 'oc-test-bughunter-sibs-'))
try {
const cmds = await getCommands(cwd)
const names = cmds.map(c => c.name)
expect(names).toContain('bughunter-security')
expect(names).toContain('bughunter-perf')
const internalNames = INTERNAL_ONLY_COMMANDS.map(c => c.name)
expect(internalNames).not.toContain('bughunter-security')
expect(internalNames).not.toContain('bughunter-perf')
} finally {
await rm(cwd, { recursive: true, force: true })
// Restore env vars to avoid test isolation issues
if (originalUserType !== undefined) {
process.env['USER_TYPE'] = originalUserType
} else {
delete process.env['USER_TYPE']
}
if (originalIsDemo !== undefined) {
process.env['IS_DEMO'] = originalIsDemo
} else {
delete process.env['IS_DEMO']
}
clearCommandMemoizationCaches()
}
})
const FULL_GIT_COMMANDS = [
'git status',
'git diff --name-only --diff-filter=AM',
'git diff --cached --name-only --diff-filter=AM',
'git log -10 --pretty=format: --name-only --diff-filter=AM',
'git ls-files',
'git diff HEAD -- .',
'git rev-parse --git-dir',
'git rev-parse --git-dir 2>&1',
'git diff HEAD -- . 2> /dev/null',
'git status 2>/dev/null',
'git diff --name-only --diff-filter=AM 2>/dev/null',
'git diff --cached --name-only --diff-filter=AM 2>/dev/null',
'git log -10 --pretty=format: --name-only --diff-filter=AM 2>/dev/null',
'git diff HEAD -- . 2>/dev/null',
]
const createMockToolContext = (cwd: string, commands: string[]) =>
({
getAppState: () => ({
toolPermissionContext: {
alwaysAllowRules: { command: commands },
alwaysDenyRules: {},
alwaysAskRules: {},
mode: 'default' as const,
additionalWorkingDirectories: new Map([[cwd, true]]),
isBypassPermissionsModeAvailable: false,
},
}),
abortController: new AbortController(),
options: {
debug: false,
mainLoopModel: '',
tools: {} as any,
verbose: false,
thinkingConfig: {} as any,
mcpClients: [] as any,
mcpResources: {} as any,
isNonInteractiveSession: false,
agentDefinitions: {} as any,
},
}) as any
/** Retry rm on EBUSY (Windows: dir held by spawned shell handle). */
async function rmRetry(dir: string, retries = 5, delay = 200): Promise<void> {
const originalCwd = process.cwd()
try {
// Move to a stable dir so the temp dir is no longer anyone's cwd.
chdir(tmpdir())
for (let i = 0; i < retries; i++) {
try {
await rm(dir, { recursive: true, force: true })
return
} catch (e: any) {
if ((e as NodeJS.ErrnoException).code === 'EBUSY' && i < retries - 1) {
await new Promise(r => setTimeout(r, delay))
continue
}
throw e
}
}
} finally {
chdir(originalCwd)
}
}
test('bughunter prompt generation works in non-git directory', async () => {
const originalUserType = process.env['USER_TYPE']
const originalIsDemo = process.env['IS_DEMO']
delete process.env['USER_TYPE']
delete process.env['IS_DEMO']
clearCommandMemoizationCaches()
// Create a temp dir WITHOUT .git
const cwd = await mkdtemp(join(tmpdir(), 'oc-test-bughunter-nogit-'))
try {
const cmds = await getCommands(cwd)
const bughunterCmd = findPromptCommand(cmds, 'bughunter')
// Generate the prompt - should not throw and should contain fallback text
const mockContext = createMockToolContext(cwd, FULL_GIT_COMMANDS)
// Run with cwd override so git commands execute in the temp dir (non-git)
const promptBlocks = await runWithCwdOverride(cwd, async () => {
return bughunterCmd.getPromptForCommand('', mockContext)
})
expect(promptBlocks).toBeDefined()
expect(promptBlocks.length).toBeGreaterThan(0)
const promptText = promptBlocks[0].type === 'text' ? promptBlocks[0].text : ''
// Verify git fallback text appears (not blank) - now in template as static text
// Shell tool outputs "(Bash completed with no output)" for empty results, which is valid
const checkEmptyIndicator = (text: string, expected: string) => {
expect(text).toMatch(new RegExp(`(${expected.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}|Bash completed with no output)`))
}
checkEmptyIndicator(promptText, '(If empty: not a git repository or git unavailable)')
checkEmptyIndicator(promptText, '(If empty: no unstaged changes or not a git repo)')
checkEmptyIndicator(promptText, '(If empty: no staged changes or not a git repo)')
checkEmptyIndicator(promptText, '(If empty: no git history or not a git repo)')
checkEmptyIndicator(promptText, '(If empty: no diff available or not a git repo)')
} finally {
try {
await rmRetry(cwd)
} finally {
if (originalUserType !== undefined) {
process.env['USER_TYPE'] = originalUserType
} else {
delete process.env['USER_TYPE']
}
if (originalIsDemo !== undefined) {
process.env['IS_DEMO'] = originalIsDemo
} else {
delete process.env['IS_DEMO']
}
clearCommandMemoizationCaches()
}
}
})
// All three bughunter commands share the same git-context block template
// and the same lineLimits call, so we parameterize the regression tests
// over the sibling names. A regression in any one of them (e.g. losing
// the HEAD~10 → git log swap, or dropping the 400-line cap) would fail
// here even if a sibling's prompt was edited in isolation.
const BUGHUNTER_SIBLINGS = [
'bughunter',
'bughunter-security',
'bughunter-perf',
] as const
for (const cmdName of BUGHUNTER_SIBLINGS) {
test(`${cmdName} keeps git context populated in a fresh single-commit repo`, async () => {
// Regression: in a one-commit repo, the prior `git diff --name-only
// HEAD~10..HEAD` snippet exited 128, which the outer catch converted to
// stripping every snippet. Switched to `git log -10` so all the other
// git-context blocks (status, staged, diff) still populate even when
// the repo is too shallow for HEAD~10 to resolve.
const originalUserType = process.env['USER_TYPE']
const originalIsDemo = process.env['IS_DEMO']
delete process.env['USER_TYPE']
delete process.env['IS_DEMO']
clearCommandMemoizationCaches()
const cwd = await mkdtemp(
join(tmpdir(), `oc-test-bughunter-fresh-${cmdName}-`),
)
try {
// Build a real one-commit repo with an unstaged change
const { spawnSync } = await import('node:child_process')
const run = (args: string[]) =>
spawnSync('git', args, { cwd, encoding: 'utf8' })
run(['init', '-q'])
run(['config', 'user.email', 't@t'])
run(['config', 'user.name', 't'])
run(['config', 'commit.gpgsign', 'false'])
await Bun.write(join(cwd, 'a.txt'), 'a\n')
run(['add', 'a.txt'])
run(['commit', '-q', '-m', 'init'])
await Bun.write(join(cwd, 'b.txt'), 'b\n') // untracked / unstaged
const cmds = await getCommands(cwd)
const cmd = findPromptCommand(cmds, cmdName)
const mockContext = createMockToolContext(cwd, FULL_GIT_COMMANDS)
const promptBlocks = await runWithCwdOverride(cwd, async () => {
return cmd.getPromptForCommand('', mockContext)
})
const promptText =
promptBlocks[0].type === 'text' ? promptBlocks[0].text : ''
// git status should mention the new untracked b.txt (not "(Bash completed
// with no output)" — that would mean the catch-all stripped it).
expect(promptText).toContain('b.txt')
// The diff block should at least mention the file or the diff marker —
// and crucially must not be the "head -10 revision" error.
expect(promptText).not.toMatch(/unknown revision|HEAD~10/)
} finally {
try {
await rmRetry(cwd)
} finally {
if (originalUserType !== undefined) {
process.env['USER_TYPE'] = originalUserType
} else {
delete process.env['USER_TYPE']
}
if (originalIsDemo !== undefined) {
process.env['IS_DEMO'] = originalIsDemo
} else {
delete process.env['IS_DEMO']
}
clearCommandMemoizationCaches()
}
}
})
}
for (const cmdName of BUGHUNTER_SIBLINGS) {
test(`${cmdName} diff block is bounded to 400 lines`, async () => {
// Regression: the prompt label advertises "first 400 lines" but commit
// 73d0bcb dropped the `| head -400` cap. Reproduce with a 1000-line diff
// and assert the diff code block in the rendered prompt has ≤ 400 lines.
const originalUserType = process.env['USER_TYPE']
const originalIsDemo = process.env['IS_DEMO']
delete process.env['USER_TYPE']
delete process.env['IS_DEMO']
clearCommandMemoizationCaches()
const cwd = await mkdtemp(
join(tmpdir(), `oc-test-bughunter-cap-${cmdName}-`),
)
try {
const { spawnSync } = await import('node:child_process')
const run = (args: string[]) =>
spawnSync('git', args, { cwd, encoding: 'utf8' })
run(['init', '-q'])
run(['config', 'user.email', 't@t'])
run(['config', 'user.name', 't'])
run(['config', 'commit.gpgsign', 'false'])
// Commit a baseline file with 1000 lines
const baseline =
Array.from({ length: 1000 }, (_, i) => `line${i + 1}`).join('\n') +
'\n'
await Bun.write(join(cwd, 'big.txt'), baseline)
run(['add', 'big.txt'])
run(['commit', '-q', '-m', 'baseline'])
// Modify every line to force a 1000+-line diff
const modified =
Array.from({ length: 1000 }, (_, i) => `+line${i + 1}`).join('\n') +
'\n'
await Bun.write(join(cwd, 'big.txt'), modified)
const cmds = await getCommands(cwd)
const cmd = findPromptCommand(cmds, cmdName)
const mockContext = createMockToolContext(cwd, FULL_GIT_COMMANDS)
const promptBlocks = await runWithCwdOverride(cwd, async () => {
return cmd.getPromptForCommand('', mockContext)
})
const promptText =
promptBlocks[0].type === 'text' ? promptBlocks[0].text : ''
// Locate the DIFF code block and count its lines
const diffMatch = promptText.match(
/DIFF OF UNSTAGED \+ STAGED CHANGES[\s\S]*?```\n([\s\S]*?)\n```/,
)
expect(diffMatch).not.toBeNull()
const diffBody = diffMatch![1]
const diffLineCount = diffBody.split('\n').length
expect(diffLineCount).toBeLessThanOrEqual(400)
// And the line cap is actually being applied, not just truncating by
// chance: the diff body should have hundreds of lines truncated.
expect(diffLineCount).toBeGreaterThan(100)
} finally {
try {
await rmRetry(cwd)
} finally {
if (originalUserType !== undefined) {
process.env['USER_TYPE'] = originalUserType
} else {
delete process.env['USER_TYPE']
}
if (originalIsDemo !== undefined) {
process.env['IS_DEMO'] = originalIsDemo
} else {
delete process.env['IS_DEMO']
}
clearCommandMemoizationCaches()
}
}
})
}
test('bughunter does not execute shell snippets in user-provided args', async () => {
const originalUserType = process.env['USER_TYPE']
const originalIsDemo = process.env['IS_DEMO']
delete process.env['USER_TYPE']
delete process.env['IS_DEMO']
clearCommandMemoizationCaches()
const cwd = await mkdtemp(join(tmpdir(), 'oc-test-bughunter-inject-'))
try {
const cmds = await getCommands(cwd)
const bughunterCmd = findPromptCommand(cmds, 'bughunter')
const mockContext = createMockToolContext(cwd, [
'git status', 'git diff --name-only --diff-filter=AM',
'git diff --cached --name-only --diff-filter=AM',
'git log -10 --pretty=format: --name-only --diff-filter=AM',
'git ls-files', 'git diff HEAD -- .', 'head -400', 'head -50',
])
// Pass args containing shell-like syntax - it must appear verbatim, not executed
const maliciousScope = 'src/auth !`echo pwned`'
const promptBlocks = await runWithCwdOverride(cwd, async () => {
return bughunterCmd.getPromptForCommand(maliciousScope, mockContext)
})
const promptText = promptBlocks[0].type === 'text' ? promptBlocks[0].text : ''
// The shell snippet should appear VERBATIM (injection prevented)
expect(promptText).toContain(maliciousScope)
// The git context shell blocks should have been executed (showing fallback text)
// Accept either bash output or the static echo fallback text (for Windows/no-bash)
expect(promptText).toMatch(/(If empty:|bash completed with no output)/i)
} finally {
try {
await rmRetry(cwd)
} finally {
if (originalUserType !== undefined) {
process.env['USER_TYPE'] = originalUserType
} else {
delete process.env['USER_TYPE']
}
if (originalIsDemo !== undefined) {
process.env['IS_DEMO'] = originalIsDemo
} else {
delete process.env['IS_DEMO']
}
clearCommandMemoizationCaches()
}
}
})
test('bughunter-security prompt generation works in non-git directory', async () => {
const originalUserType = process.env['USER_TYPE']
const originalIsDemo = process.env['IS_DEMO']
delete process.env['USER_TYPE']
delete process.env['IS_DEMO']
clearCommandMemoizationCaches()
const cwd = await mkdtemp(join(tmpdir(), 'oc-test-bughunter-sec-nogit-'))
try {
const cmds = await getCommands(cwd)
const cmd = findPromptCommand(cmds, 'bughunter-security')
const mockContext = createMockToolContext(cwd, FULL_GIT_COMMANDS)
// Run with cwd override so git commands execute in the temp dir (non-git)
const promptBlocks = await runWithCwdOverride(cwd, async () => {
return cmd.getPromptForCommand('', mockContext)
})
const promptText = promptBlocks[0].type === 'text' ? promptBlocks[0].text : ''
// Shell tool outputs "(Bash completed with no output)" for empty results, which is valid
const checkEmptyIndicator = (text: string, expected: string) => {
expect(text).toMatch(new RegExp(`(${expected.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}|Bash completed with no output)`))
}
checkEmptyIndicator(promptText, '(If empty: not a git repository or git unavailable)')
checkEmptyIndicator(promptText, '(If empty: no unstaged changes or not a git repo)')
checkEmptyIndicator(promptText, '(If empty: no staged changes or not a git repo)')
checkEmptyIndicator(promptText, '(If empty: no git history or not a git repo)')
checkEmptyIndicator(promptText, '(If empty: no diff available or not a git repo)')
} finally {
try {
await rmRetry(cwd)
} finally {
if (originalUserType !== undefined) {
process.env['USER_TYPE'] = originalUserType
} else {
delete process.env['USER_TYPE']
}
if (originalIsDemo !== undefined) {
process.env['IS_DEMO'] = originalIsDemo
} else {
delete process.env['IS_DEMO']
}
clearCommandMemoizationCaches()
}
}
})
test('bughunter-perf prompt generation works in non-git directory', async () => {
const originalUserType = process.env['USER_TYPE']
const originalIsDemo = process.env['IS_DEMO']
delete process.env['USER_TYPE']
delete process.env['IS_DEMO']
clearCommandMemoizationCaches()
const cwd = await mkdtemp(join(tmpdir(), 'oc-test-bughunter-perf-nogit-'))
try {
const cmds = await getCommands(cwd)
const cmd = findPromptCommand(cmds, 'bughunter-perf')
const mockContext = createMockToolContext(cwd, FULL_GIT_COMMANDS)
// Run with cwd override so git commands execute in the temp dir (non-git)
const promptBlocks = await runWithCwdOverride(cwd, async () => {
return cmd.getPromptForCommand('', mockContext)
})
const promptText = promptBlocks[0].type === 'text' ? promptBlocks[0].text : ''
// Shell tool outputs "(Bash completed with no output)" for empty results, which is valid
const checkEmptyIndicator = (text: string, expected: string) => {
expect(text).toMatch(new RegExp(`(${expected.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}|Bash completed with no output)`))
}
checkEmptyIndicator(promptText, '(If empty: not a git repository or git unavailable)')
checkEmptyIndicator(promptText, '(If empty: no unstaged changes or not a git repo)')
checkEmptyIndicator(promptText, '(If empty: no staged changes or not a git repo)')
checkEmptyIndicator(promptText, '(If empty: no git history or not a git repo)')
checkEmptyIndicator(promptText, '(If empty: no diff available or not a git repo)')
} finally {
try {
await rmRetry(cwd)
} finally {
if (originalUserType !== undefined) {
process.env['USER_TYPE'] = originalUserType
} else {
delete process.env['USER_TYPE']
}
if (originalIsDemo !== undefined) {
process.env['IS_DEMO'] = originalIsDemo
} else {
delete process.env['IS_DEMO']
}
clearCommandMemoizationCaches()
}
}
})
test('includes the request-size diagnostic command', () => {
+7 -1
View File
@@ -64,6 +64,8 @@ const agentsPlatform =
/* eslint-enable @typescript-eslint/no-require-imports */
import securityReview from './commands/security-review.js'
import bughunter from './commands/bughunter/index.js'
import bughunterSecurity from './commands/bughunter-security/index.js'
import bughunterPerf from './commands/bughunter-perf/index.js'
import terminalSetup from './commands/terminalSetup/index.js'
import usage from './commands/usage/index.js'
import theme from './commands/theme/index.js'
@@ -239,7 +241,6 @@ export { getCommandName, isCommandEnabled } from './types/command.js'
export const INTERNAL_ONLY_COMMANDS = [
backfillSessions,
breakCache,
bughunter,
commit,
commitPushPr,
goodClaude,
@@ -273,6 +274,9 @@ const COMMANDS = memoize((): Command[] => [
agents,
autoFix,
branch,
bughunter,
bughunterSecurity,
bughunterPerf,
btw,
cacheProbe,
cacheStats,
@@ -553,6 +557,8 @@ export async function getCommands(cwd: string): Promise<Command[]> {
* Use this when dynamic skills are added to invalidate cached command lists.
*/
export function clearCommandMemoizationCaches(): void {
COMMANDS.cache?.clear?.()
builtInCommandNames.cache?.clear?.()
loadAllCommands.cache?.clear?.()
getSkillToolCommands.cache?.clear?.()
getSlashCommandToolSkills.cache?.clear?.()
+264
View File
@@ -0,0 +1,264 @@
import { parseFrontmatter } from '../../utils/frontmatterParser.js'
import { createGetAppStateWithAllowedTools } from '../../utils/forkedAgent.js'
import { parseSlashCommandToolsFromFrontmatter } from '../../utils/markdownConfigLoader.js'
import { executeShellCommandsInPrompt } from '../../utils/promptShellExecution.js'
import { MalformedCommandError, ShellError } from '../../utils/errors.js'
import { createMovedToPluginCommand } from '../createMovedToPluginCommand.js'
const BUGHUNTER_PERF_PROMPT = `---
allowed-tools: Read, Glob, Grep, LS, Bash(git diff:*), Bash(git log:*), Bash(git show:*), Bash(git status:*)
description: Performance-focused bug hunt — hot-path complexity, sync I/O, leaks, N+1
---
You are a performance engineer running a focused performance audit on a real
codebase. This is a sibling of \`/bughunter\`, narrowed to performance and
resource issues. It is **not** a general code review.
SCOPE: {{ARGS}}
GIT CONTEXT (auto-collected, may be empty if not a git repo):
\`\`\`
!\`git status 2>/dev/null\`
\`\`\`
(If empty: not a git repository or git unavailable)
UNSTAGED CHANGES (working tree):
\`\`\`
!\`git diff --name-only --diff-filter=AM 2>/dev/null\`
\`\`\`
(If empty: no unstaged changes or not a git repo)
STAGED CHANGES (index):
\`\`\`
!\`git diff --cached --name-only --diff-filter=AM 2>/dev/null\`
\`\`\`
(If empty: no staged changes or not a git repo)
RECENTLY COMMITTED FILES (last 10 commits):
\`\`\`
!\`git log -10 --pretty=format: --name-only --diff-filter=AM 2>/dev/null\`
\`\`\`
(If empty: no git history or not a git repo)
DIFF OF UNSTAGED + STAGED CHANGES (first 400 lines):
\`\`\`
!\`git diff HEAD -- . 2>/dev/null\`
\`\`\`
(If empty: no diff available or not a git repo)
---
## Phase 1 — Map the Hot Paths
Use Glob and Grep to identify the 57 most critical files for the scope above.
If no scope was given, focus on staged + unstaged + recent-commit buckets.
**If git context is empty, use Glob/Grep to find performance-critical files:**
- Request handlers / RPC entry points / API routes (per-request cost matters)
- Tight loops, hot-path utility functions, render pipelines
- Data access layers (DB / cache / filesystem / network) on the request path
- Event listeners, subscriptions, timers, intervals
- Resource acquisition (connections, file handles, child processes)
- Serialization / deserialization hot paths (JSON, encoding, parsing)
- Build/bundle entry points (webpack config, vite config, etc.)
Read the surrounding code, not just the diff. Bugs hide in context.
## Phase 2 — Hunt (Performance Categories)
**A — Algorithmic complexity**
- O(n²) or worse in a tight loop, sort, or comparison chain that runs per request
- Nested loops over the same collection when a single pass + map would do
- Repeated linear scans where an index / hash would be O(1)
- Inefficient sort where a stable linear-time alternative exists for the data shape
- Recomputing the same value per iteration when it could be hoisted
**B — Concurrency / async**
- Sequential \`await\`s that could be \`Promise.all\` (independent I/O in series)
- Missing \`await\` on a promise that must complete before the next step
- Fire-and-forget promise that swallows errors
- \`forEach\` with async callbacks (does not await — almost always a bug)
- Blocking call (\`readFileSync\`, \`JSON.parse\` of a huge blob) on a request path
- Synchronous CPU work in an async function that stalls the event loop
**C — Data access**
- **N+1 query**: loop performing one query / fetch per item
- Missing pagination (loads full collection into memory)
- Missing index (query on unindexed column in a hot table)
- Cache miss where the same value is recomputed per request
- Cache stampede / unbounded cache growth
**D — Memory & resources**
- Unbounded array / map / set growth (no eviction, no cap)
- Listener / timer / interval leak (\`addEventListener\` / \`setInterval\` without cleanup)
- Stream or connection leak (opened but never closed / released)
- String concatenation in a loop building a huge final string
- Accidental retention via closure holding large objects
- Large object held in module scope for the process lifetime
**E — Hot-path I/O**
- Filesystem read / write on every request where a cached or in-memory value works
- Network call per iteration that could be batched
- Repeated DNS resolution in a loop
- Logging that builds a serialized payload on every request (even at info level)
**F — String & regex**
- Regex compiled inside a hot loop (move out of the loop body)
- Catastrophic backtracking risk (nested quantifiers like \`(a+)+\` on untrusted input)
- Large string \`slice\` / \`substring\` per iteration that allocates repeatedly
- JSON.parse / JSON.stringify of a deep object on every request
**G — Rendering / UI (if applicable)**
- Re-rendering the entire tree on a state change that affects one node
- Synchronous layout thrash (read DOM size, mutate, read again, mutate)
- Heavy work on the main thread where a worker would isolate it
- Inefficient list rendering (missing keys, no virtualization for long lists)
**H — Build / bundle**
- Large dependency imported into a hot path when a small alternative exists
- Side-effectful top-level import that runs expensive code on module load
## Phase 3 — Skeptic Pass
For each candidate, answer ALL three:
1. **Hot path?** Is this on a per-request / per-frame / per-message code path,
or is it a one-shot startup / build / migration cost? One-shot costs are
out of scope unless they prevent the app from starting.
2. **Realistic input size?** Will this trigger with realistic data, or does it
require a synthetic worst case? Note the input size at which it bites.
3. **Measurable impact?** Estimate order-of-magnitude (constant, linear, super-linear)
and the dominant cost (CPU / I/O / memory / GC).
**Drop all candidates where the dominant cost is not measurable at realistic load.**
## Phase 4 — Fix Proposals
For every surviving finding, write a **concrete patch sketch** with the actual
function signature, variable names, and import style. Show the before/after
in 38 lines. If the fix is structural (architectural change, requires new
infrastructure), say so and list what the user needs to decide.
---
## Hard Exclusions — Automatically Skip
Do not report findings in any of these categories:
1. **DoS / resource exhaustion as a security issue** — handled by \`/bughunter-security\`
2. **Micro-optimizations** with no measurable impact on real workloads
3. **One-time startup / migration costs** (cold start, first-run setup, schema seed)
4. **Theoretical complexity** without a concrete path that runs at realistic input size
5. **Build / test / CI performance** — out of scope for production code paths
6. **Memory safety issues** in memory-safe languages (Rust, etc.) — not perf bugs
7. **Logging performance** unless it is the dominant cost on a hot path
8. **Documentation files** (*.md) — not code
9. **Test files** — not production
10. **Style, naming, formatting** — never report
11. **Add TypeScript types to JS files** (or vice versa)
12. **Missing JSDoc / inline comments**
13. **"Could be faster in 5 years" speculative maintenance concerns**
14. **Outdated third-party libraries** — managed separately
15. **Concurrency primitives that are "technically" suboptimal but yield no
measurable difference at the actual input sizes the code sees**
16. **Performance of an unmaintained code path** (deprecated, scheduled for removal)
17. **Cache hits that "could be better"** without evidence of cache miss being a bottleneck
18. **Network latency** that the application cannot influence (3rd party API)
## Output Format
**Step 1 — Summary line:**
\`Total confirmed perf issues: N | Critical: C | Medium: M\`
Where:
- Critical = function will not return in reasonable time / OOM at realistic input
- Medium = noticeable latency (>100ms) or excessive allocation at realistic input
**Step 2 — Findings table:**
| # | File:Line | Severity | Hot path | Category | Bottleneck | Fix sketch |
|---|-----------|----------|----------|----------|------------|------------|
| 1 | src/foo.ts:42 | Critical | request handler | N+1 | DB call per loop iter | \`const items = await db.query('SELECT * FROM t WHERE id = ANY($1)', [ids])\` |
| 2 | src/bar.ts:17 | Medium | render loop | regex | /foo/g compiled per iter | \`const RE = /foo/g; while ((m = RE.exec(s)) !== null) ...\` |
The Hot path column must name the specific code path. The Bottleneck column
must state the dominant cost. The Fix sketch column must be **code**, not prose.
**Step 3 — Required follow-up:**
> "Found N performance issues (C critical, M medium). Want to open a fix spec for the critical ones?"
If no issues, say so and list the categories checked.
---
## Reminder
This audit is intentionally narrow. If a finding is not on a hot path, not
measurable at realistic load, or not user-visible, drop it. The goal is to
produce a punch list a senior engineer would actually action.
`
const bughunterPerf = createMovedToPluginCommand({
name: 'bughunter-perf',
description:
'Performance-focused bug hunt: hot-path complexity, sync I/O, leaks, N+1 queries',
progressMessage: 'hunting for performance bugs…',
pluginName: 'bughunter',
pluginCommand: 'bughunter-perf',
allowedTools: parseSlashCommandToolsFromFrontmatter(
parseFrontmatter(BUGHUNTER_PERF_PROMPT).frontmatter['allowed-tools'],
),
async getPromptWhileMarketplaceIsPrivate(args, context) {
const scope =
args?.trim() ||
'the current project — focus on staged, unstaged, and recently committed files'
const parsed = parseFrontmatter(BUGHUNTER_PERF_PROMPT)
const allowedTools = parseSlashCommandToolsFromFrontmatter(
parsed.frontmatter['allowed-tools'],
)
// Execute shell commands first ({{ARGS}} is inert to shell patterns),
// then inject user-provided scope so shell snippets in args cannot execute.
// On platforms without bash (e.g. Windows without Git Bash) or in a repo
// where one git command fails (e.g. zero-commit `git log`), use the
// granular fallback so a single failing snippet is blanked in place
// rather than discarding the rest of the successful git context.
// lineLimits bounds the diff snippet to 400 lines as the prompt advertises.
let processedContent: string
try {
processedContent = await executeShellCommandsInPrompt(
parsed.content,
{
...context,
getAppState: createGetAppStateWithAllowedTools(
context.getAppState,
allowedTools,
),
},
'bughunter-perf',
undefined,
{
lineLimits: { 'git diff HEAD -- .': 400 },
granularFallback: true,
},
)
} catch (e) {
// Permission denial and interruption — surface instead of falling back.
if (e instanceof MalformedCommandError || (e instanceof ShellError && e.interrupted)) {
throw e
}
// Granular fallback already blanked any failing snippets in place.
throw e
}
const finalContent = processedContent.replace('{{ARGS}}', () => scope)
return [{ type: 'text', text: finalContent }]
},
})
export default bughunterPerf
+284
View File
@@ -0,0 +1,284 @@
import { parseFrontmatter } from '../../utils/frontmatterParser.js'
import { createGetAppStateWithAllowedTools } from '../../utils/forkedAgent.js'
import { parseSlashCommandToolsFromFrontmatter } from '../../utils/markdownConfigLoader.js'
import { executeShellCommandsInPrompt } from '../../utils/promptShellExecution.js'
import { MalformedCommandError, ShellError } from '../../utils/errors.js'
import { createMovedToPluginCommand } from '../createMovedToPluginCommand.js'
const BUGHUNTER_SECURITY_PROMPT = `---
allowed-tools: Read, Glob, Grep, LS, Bash(git diff:*), Bash(git log:*), Bash(git show:*), Bash(git status:*)
description: Security-focused bug hunt — exploit-driven, OWASP-aligned, confidence-gated
---
You are a senior application security engineer running a focused security audit on
a real codebase. This is a sibling of \`/bughunter\`, narrowed to security findings
only. It is **not** a general code review.
SCOPE: {{ARGS}}
GIT CONTEXT (auto-collected, may be empty if not a git repo):
\`\`\`
!\`git status 2>/dev/null\`
\`\`\`
(If empty: not a git repository or git unavailable)
UNSTAGED CHANGES (working tree):
\`\`\`
!\`git diff --name-only --diff-filter=AM 2>/dev/null\`
\`\`\`
(If empty: no unstaged changes or not a git repo)
STAGED CHANGES (index):
\`\`\`
!\`git diff --cached --name-only --diff-filter=AM 2>/dev/null\`
\`\`\`
(If empty: no staged changes or not a git repo)
RECENTLY COMMITTED FILES (last 10 commits):
\`\`\`
!\`git log -10 --pretty=format: --name-only --diff-filter=AM 2>/dev/null\`
\`\`\`
(If empty: no git history or not a git repo)
DIFF OF UNSTAGED + STAGED CHANGES (first 400 lines):
\`\`\`
!\`git diff HEAD -- . 2>/dev/null\`
\`\`\`
(If empty: no diff available or not a git repo)
---
## Phase 1 — Map the Attack Surface
Use Glob and Grep to identify the 57 most critical files for the scope above.
If no scope was given, focus on staged + unstaged + recent-commit buckets.
**If git context is empty, use Glob/Grep to find security-relevant files:**
- Entry points (API routes, handlers, controllers, CLI commands)
- Auth/middleware code
- Data validation/sanitization layers
- Database/query builders
- Config/secret handling
- File upload/processing code
**Specifically map:**
- Trust boundaries (network ingress, IPC, deserialization sinks, file uploads)
- Data flow from user-controllable input to sensitive operations
- Authentication / authorization decision points
- Secret material at rest or in transit
- Process invocation and shell-out sites
- Template / query / DOM rendering sinks
Read the surrounding code, not just the diff. Bugs hide in context.
## Phase 2 — Hunt (OWASP-Aligned Categories)
**A1 — Injection**
- SQL injection (string concatenation, unparameterized queries)
- NoSQL injection (object operators in Mongo queries, etc.)
- Command injection in \`exec\` / \`spawn\` / shell calls
- Template injection in user-rendered templates
- LDAP / XPath / log injection where input reaches the sink
**A2 — Authentication & Session**
- Missing or weak authentication on sensitive endpoints
- Authentication bypass logic (header checks, IP-based trust)
- Session fixation, IDOR, JWT algorithm confusion (\`alg: none\`, key confusion)
- Privilege escalation paths (role checks, ownership checks)
- Missing authorization on internal-only endpoints
**A3 — Sensitive Data Exposure**
- Hardcoded API keys, passwords, tokens, private keys
- PII or secrets written to logs / error responses / telemetry
- Sensitive data in URL paths or query strings (server logs)
- Missing encryption at rest or in transit
- Debug information exposure in production responses
**A4 — XML / Deserialization**
- XXE in XML parsers (entity expansion, external entity)
- Unsafe deserialization (\`pickle\`, \`yaml.load\`, \`eval\` on input, \`unserialize\`)
- Prototype pollution via deep-merge of untrusted input
**A5 — Access Control / SSRF**
- SSRF where attacker controls host or protocol (NOT just path)
- Path traversal in file operations (\`../\` escape, symlink following)
- Open redirects with concrete exploitation path
- CORS misconfiguration allowing credentialed cross-origin
- WebSocket hijacking via missing origin checks
**A7 — Cross-Site Scripting**
- Reflected / stored / DOM-based XSS in raw HTML contexts
- \`dangerouslySetInnerHTML\`, \`bypassSecurityTrustHtml\`, \`v-html\` with user input
- Server-side template injection rendering user input
- CSP bypasses via inline scripts or JSONP
**A8 — Software & Data Integrity**
- Unsigned or weakly-signed updates / plugins
- CI/CD pipeline injection (GitHub Actions with untrusted input)
- Insecure deserialization in supply chain context
**A9 — Logging & Monitoring Failures**
- Security events not logged (auth failures, privilege escalation, money movement)
- Logs that can be injected (CRLF / log forging allowing forgery of audit trail)
- Only flag if it **enables an attack**, not as a generic "add more logging" finding
**A10 — SSRF (consolidated)**
- Already covered under A5; same threshold applies
## Phase 3 — Exploit-Driven Skeptic Pass
For each candidate, answer ALL three before reporting:
1. **Concrete path**: Trace data from an attacker-controllable source to the
vulnerable sink. A candidate without a clear path is dropped.
2. **Specific trigger**: What request, payload, or condition causes the bug?
No "could theoretically" or "depends on usage" hand-waving.
3. **Confidence score (110)**:
- **810** (HIGH): Clear path, well-defined trigger, known exploitation pattern
- **57** (MEDIUM): Plausible path, conditions documented, exploitation requires effort
- **14** (LOW): Speculative, missing a step, or theoretical only
**Drop all findings with confidence < 8.** This audit is intentionally strict;
better to miss a possible issue than flood the report with low-confidence noise.
## Phase 4 — Fix Proposals
For every surviving finding, write a **concrete patch sketch** using actual
function signatures, variable names, and import style from the codebase.
If the fix is structural (requires schema migration, framework change, or >10
lines of refactor), say so explicitly and state what the user would need to
decide before sketching.
---
## Hard Exclusions — Automatically Skip
Do not report findings in any of these categories:
1. DoS, rate limiting, or resource exhaustion (any severity)
2. Secrets stored on disk in a secured location (handled by other processes)
3. Theoretical race conditions or timing attacks
4. Memory consumption or CPU exhaustion issues
5. Lack of input validation on non-security-critical fields without proven exploit
6. Input sanitization in GitHub Action workflows unless clearly triggerable via
untrusted input
7. Lack of hardening measures (code is not expected to implement all best practices)
8. Vulnerabilities in outdated third-party libraries (managed separately)
9. Memory safety issues in Rust or any memory-safe language
10. Test files (*.test.ts, *.spec.ts, __tests__/) — not production code
11. Log spoofing or log forging concerns unless there is a concrete, demonstrable path to forge or manipulate log entries or structured log fields in a way that enables forgery of the audit trail — merely outputting unsanitized user input to logs is not sufficient
12. SSRF that only controls the path (only flag if it controls host or protocol)
13. User-controlled content in AI system prompts
14. Regex injection
15. Regex DoS
16. Documentation files (*.md) — bugs in docs are not security bugs
17. Lack of audit logging is not a security bug
18. Client-side JS/TS missing permission checks (server is responsible for auth)
19. UUID guessability — assume UUIDs are unguessable
20. Environment variable and CLI flag trust (env is trusted in secure environments)
21. React / Angular XSS without \`dangerouslySetInnerHTML\` / \`bypassSecurityTrustHtml\`
22. Subtle low-impact web vulns (tabnabbing, XS-Leaks, prototype pollution, open
redirects) unless extremely high confidence
23. Resource management issues (memory / FD leaks)
24. Code style, naming, formatting
## Output Format
**Step 1 — Summary line:**
\`Total confirmed vulnerabilities: N | High: H | Medium: M | Confidence threshold: 8/10\`
Where counts are after the skeptic pass. **All findings must have confidence ≥ 8.**
**Step 2 — Findings table:**
| # | File:Line | Severity | Confidence | CWE | Exploit | Fix sketch |
|---|-----------|----------|------------|-----|---------|------------|
| 1 | src/foo.ts:42 | High | 9 | CWE-89 | q=\`OR 1=1--\` → dump | Use parameterized query: \`db.query('SELECT … WHERE id = $1', [id])\` |
| 2 | src/bar.ts:17 | Medium | 8 | CWE-200 | Verbose error leaks stack trace | Catch + return generic \`{ error: 'internal' }\` in prod |
The Exploit column must show the **specific payload or request** that triggers
the bug. The Fix sketch column must be **code**, not prose.
**Step 3 — Required follow-up:**
> "Found N security findings (H high, M medium). Want to open a fix spec for each?"
If no findings, say so and list the categories checked.
---
## Signal Quality Reminder
For each surviving finding, ask:
- Is there a concrete, exploitable vulnerability with a clear attack path?
- Does this represent real risk vs theoretical best practice?
- Are there specific code locations and reproduction steps?
- Would a security team action this finding in a PR review?
If any answer is "no", drop the finding.
`
const bughunterSecurity = createMovedToPluginCommand({
name: 'bughunter-security',
description:
'Security-focused bug hunt: exploit-driven, OWASP-aligned, confidence ≥ 8',
progressMessage: 'hunting for security bugs…',
pluginName: 'bughunter',
pluginCommand: 'bughunter-security',
allowedTools: parseSlashCommandToolsFromFrontmatter(
parseFrontmatter(BUGHUNTER_SECURITY_PROMPT).frontmatter['allowed-tools'],
),
async getPromptWhileMarketplaceIsPrivate(args, context) {
const scope =
args?.trim() ||
'the current project — focus on staged, unstaged, and recently committed files'
const parsed = parseFrontmatter(BUGHUNTER_SECURITY_PROMPT)
const allowedTools = parseSlashCommandToolsFromFrontmatter(
parsed.frontmatter['allowed-tools'],
)
// Execute shell commands first ({{ARGS}} is inert to shell patterns),
// then inject user-provided scope so shell snippets in args cannot execute.
// On platforms without bash (e.g. Windows without Git Bash) or in a repo
// where one git command fails (e.g. zero-commit `git log`), use the
// granular fallback so a single failing snippet is blanked in place
// rather than discarding the rest of the successful git context.
// lineLimits bounds the diff snippet to 400 lines as the prompt advertises.
let processedContent: string
try {
processedContent = await executeShellCommandsInPrompt(
parsed.content,
{
...context,
getAppState: createGetAppStateWithAllowedTools(
context.getAppState,
allowedTools,
),
},
'bughunter-security',
undefined,
{
lineLimits: { 'git diff HEAD -- .': 400 },
granularFallback: true,
},
)
} catch (e) {
// Permission denial and interruption — surface instead of falling back.
if (e instanceof MalformedCommandError || (e instanceof ShellError && e.interrupted)) {
throw e
}
// Granular fallback already blanked any failing snippets in place.
throw e
}
const finalContent = processedContent.replace('{{ARGS}}', () => scope)
return [{ type: 'text', text: finalContent }]
},
})
export default bughunterSecurity
-1
View File
@@ -1 +0,0 @@
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
+235
View File
@@ -0,0 +1,235 @@
import { parseFrontmatter } from '../../utils/frontmatterParser.js'
import { createGetAppStateWithAllowedTools } from '../../utils/forkedAgent.js'
import { parseSlashCommandToolsFromFrontmatter } from '../../utils/markdownConfigLoader.js'
import { executeShellCommandsInPrompt } from '../../utils/promptShellExecution.js'
import { MalformedCommandError, ShellError } from '../../utils/errors.js'
import { createMovedToPluginCommand } from '../createMovedToPluginCommand.js'
const BUGHUNTER_PROMPT = `---
allowed-tools: Read, Glob, Grep, LS, Bash(git diff:*), Bash(git log:*), Bash(git show:*), Bash(git status:*)
description: Systematic four-phase bug hunt — map, hunt, skeptic pass, fix proposal
---
You are a rigorous code auditor running a systematic bug hunt on a real codebase.
SCOPE: {{ARGS}}
GIT CONTEXT (auto-collected, may be empty if not a git repo):
\`\`\`
!\`git status 2>/dev/null\`
\`\`\`
(If empty: not a git repository or git unavailable)
UNSTAGED CHANGES (working tree):
\`\`\`
!\`git diff --name-only --diff-filter=AM 2>/dev/null\`
\`\`\`
(If empty: no unstaged changes or not a git repo)
STAGED CHANGES (index):
\`\`\`
!\`git diff --cached --name-only --diff-filter=AM 2>/dev/null\`
\`\`\`
(If empty: no staged changes or not a git repo)
RECENTLY COMMITTED FILES (last 10 commits):
\`\`\`
!\`git log -10 --pretty=format: --name-only --diff-filter=AM 2>/dev/null\`
\`\`\`
(If empty: no git history or not a git repo)
DIFF OF UNSTAGED + STAGED CHANGES (first 400 lines):
\`\`\`
!\`git diff HEAD -- . 2>/dev/null\`
\`\`\`
(If empty: no diff available or not a git repo)
---
## Phase 1 — Map the Scope
Use Glob and Grep to identify the 57 most critical files related to the scope above.
If no scope was given, focus on the files in the staged + unstaged + recent-commit
buckets above. **If git context is empty, use Glob/Grep to find the main source files
in the project (e.g., src/**/*.ts, lib/**/*.js, etc.).**
Read those files. Do not skip this step — bugs hide in context, and
the diff blocks above are a starting point, not the whole story.
**If no files found via git, search for:**
- Entry points (main.ts, index.ts, app.ts, server.ts, cli.ts)
- Core business logic directories
- Recently modified files via filesystem timestamps
## Phase 2 — Hunt
Examine the code systematically. Look for:
**Logic errors**
- Off-by-one (loop bounds, slice/splice indices, pagination)
- Inverted conditions (=== vs !==, < vs <=, && vs ||)
- Incorrect default values or missing guards
**Async / concurrency**
- Missing await on promises
- Race conditions in state mutation
- Unhandled rejected promises or uncaught exceptions in async flows
**Error handling**
- I/O, network, and database calls with no error handling
- Silent swallows (\`catch (_) {}\`)
- Error paths that return undefined where a value is expected
**Security**
- Command injection, SQL injection, path traversal
- Hardcoded secrets or tokens
- Unvalidated user input reaching sensitive operations
- Exposed internal state in API responses
**Type / null safety**
- Null/undefined dereferences without guards
- Incorrect type casts or \`as any\` hiding real type errors
- Optional chaining gaps (\`obj.a.b\` where \`obj.a\` may be undefined)
**Data consistency**
- Missing transactions around multi-step writes
- Stale reads after write (cache coherence)
- Off-by-one in pagination or cursor logic
Score each finding against the rubric:
- **+5** Medium: functional failure under specific conditions
- **+10** Critical: security, data loss, crash, or always-failing path
## Phase 3 — Skeptic Pass
For each finding from Phase 2, answer all three:
1. Is there a **concrete code path** that reaches this bug? (not "could theoretically")
2. Under what **specific inputs or conditions** does it trigger?
3. Assign confidence:
- **HIGH** (>80%): clear path, well-defined trigger
- **MEDIUM** (5080%): plausible path, conditions documented
- **LOW** (<50%): speculative, missing a step, or theoretical
**Drop all LOW confidence findings.** Keep only HIGH and MEDIUM. (LOW findings are not scored or reported.)
## Phase 4 — Fix Proposals
For every surviving finding, write a **concrete code-level fix** as a 38 line patch
sketch. Use the actual function signature, the actual variable names from the
codebase, and the actual import style. Do not write a paragraph — write code.
If you cannot produce a concrete patch (because the fix is structural, requires
schema migration, or touches more than ~10 lines), say so explicitly and explain
what the user would need to decide before you can sketch it.
---
## Hard Exclusions — Automatically Skip
Do not report findings in any of these categories:
1. Lack of input validation on non-critical fields without proven exploit path
2. Theoretical race conditions or timing attacks (only flag if concretely problematic)
3. Memory consumption or CPU exhaustion issues
4. Logging concerns (log level, structured vs unstructured, PII) — only flag if it
leaks secrets, passwords, or PII to a remote sink
5. Regex injection or regex DoS
6. Documentation files (*.md) — bugs in docs are not code bugs
7. Test files — bugs in tests are not production bugs (note them in passing only)
8. Outdated third-party libraries — managed separately
9. Memory safety issues in Rust or other memory-safe languages
10. A lack of audit logging is not a bug
11. Code style, naming, formatting — never report
12. "This could be a problem in 5 years" speculative maintenance concerns
13. Suggestions to add TypeScript types to JavaScript files (or vice versa)
14. Missing JSDoc / inline comments
## Output Format
**Step 1 — Summary line:**
\`Total confirmed bugs: N | Critical: C | Medium: M | Total weighted score: X\`
Where weighted score is the sum of (10 × C) + (5 × M). Critical = score 10,
Medium = score 5. LOW confidence findings are dropped and not scored.
**Step 2 — Findings table:**
| # | File:Line | Severity | Confidence | Category | Description | Fix sketch |
|---|-----------|----------|------------|----------|-------------|------------|
| 1 | src/foo.ts:42 | Critical | HIGH | async | Missing await on saveUser(...) | \`await saveUser(user)\` then return |
| 2 | src/bar.ts:17 | Medium | MEDIUM | error-handling | Empty catch swallows DB error | \`catch (e) { log.error(e); throw }\` |
The Fix sketch column MUST be code, not prose. If you cannot sketch code, write
\`FIX: requires design decision — see note below\` and add a note.
**Step 3 — Critical follow-up:**
If any Critical (score 10) findings exist, ask the user:
> "Found N critical bugs. Want to open fix specs for the top issues?"
If no bugs are found, say so briefly and list what was checked.
`
const bughunter = createMovedToPluginCommand({
name: 'bughunter',
description:
'Systematic four-phase bug hunt: map → hunt → skeptic pass → fix proposals',
progressMessage: 'hunting for bugs…',
pluginName: 'bughunter',
pluginCommand: 'bughunter',
allowedTools: parseSlashCommandToolsFromFrontmatter(
parseFrontmatter(BUGHUNTER_PROMPT).frontmatter['allowed-tools'],
),
async getPromptWhileMarketplaceIsPrivate(args, context) {
const scope =
args?.trim() ||
'the current project — focus on staged, unstaged, and recently committed files'
const parsed = parseFrontmatter(BUGHUNTER_PROMPT)
const allowedTools = parseSlashCommandToolsFromFrontmatter(
parsed.frontmatter['allowed-tools'],
)
// Execute shell commands first ({{ARGS}} is inert to shell patterns),
// then inject user-provided scope so shell snippets in args cannot execute.
// On platforms without bash (e.g. Windows without Git Bash) or in a repo
// where one git command fails (e.g. zero-commit `git log`), use the
// granular fallback so a single failing snippet is blanked in place
// rather than discarding the rest of the successful git context.
// lineLimits bounds the diff snippet to 400 lines as the prompt advertises.
let processedContent: string
try {
processedContent = await executeShellCommandsInPrompt(
parsed.content,
{
...context,
getAppState: createGetAppStateWithAllowedTools(
context.getAppState,
allowedTools,
),
},
'bughunter',
undefined,
{
lineLimits: { 'git diff HEAD -- .': 400 },
granularFallback: true,
},
)
} catch (e) {
// Permission denial and interruption — surface instead of falling back.
if (e instanceof MalformedCommandError || (e instanceof ShellError && e.interrupted)) {
throw e
}
// Granular fallback already blanked any failing snippets in place.
throw e
}
const finalContent = processedContent.replace('{{ARGS}}', () => scope)
return [{ type: 'text', text: finalContent }]
},
})
export default bughunter
@@ -8,6 +8,7 @@ type Options = {
progressMessage: string
pluginName: string
pluginCommand: string
allowedTools?: string[]
/**
* The prompt to use while the marketplace is private.
* External users will get this prompt. Once the marketplace is public,
@@ -25,6 +26,7 @@ export function createMovedToPluginCommand({
progressMessage,
pluginName,
pluginCommand,
allowedTools,
getPromptWhileMarketplaceIsPrivate,
}: Options): Command {
return {
@@ -37,6 +39,12 @@ export function createMovedToPluginCommand({
return name
},
source: 'builtin',
get allowedTools() {
// The ant branch only returns a plugin-install notice that doesn't
// need any tools — avoid granting turn-scoped permissions for it.
if (process.env.USER_TYPE === 'ant') return undefined
return allowedTools
},
async getPromptForCommand(
args: string,
context: ToolUseContext,
+5 -2
View File
@@ -1,7 +1,8 @@
import { afterAll, describe, expect, test } from 'bun:test'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { dirname, join } from 'path'
import { fileURLToPath } from 'url'
import { extractDraggedFilePaths } from './dragDropPaths.js'
function escapeFinderDraggedPath(filePath: string): string {
@@ -11,7 +12,9 @@ function escapeFinderDraggedPath(filePath: string): string {
describe('extractDraggedFilePaths', () => {
// Paths that exist on any system.
const thisFile = import.meta.path
const packageJson = `${process.cwd()}/package.json`
// Resolve package.json relative to this test file so it works
// regardless of process.cwd() changes from other tests (e.g. xaiAuth).
const packageJson = join(dirname(fileURLToPath(import.meta.url)), '../../package.json')
// Fixtures created synchronously at describe-load time (not in
// `beforeAll`) so their paths are available to `test.each` tables,
+231
View File
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, expect, test } from 'bun:test'
import { getEmptyToolPermissionContext } from '../Tool.js'
import { BashTool } from '../tools/BashTool/BashTool.js'
import { MalformedCommandError, ShellError } from './errors.js'
import { executeShellCommandsInPrompt } from './promptShellExecution.js'
import {
acquireSharedMutationLock,
@@ -87,3 +88,233 @@ test('executeShellCommandsInPrompt normalizes null shell output', async () => {
interrupted: false,
})
})
test('executeShellCommandsInPrompt applies per-prefix line limits', async () => {
BashTool.call = (async () => ({
data: {
stdout: 'line1\nline2\nline3\nline4\nline5\n',
stderr: '',
interrupted: false,
},
})) as unknown as typeof BashTool.call
BashTool.mapToolResultToToolResultBlockParam = (result, toolUseID) =>
originalMapToolResultToToolResultBlockParam(
result as never,
toolUseID,
)
const result = await executeShellCommandsInPrompt(
'```!\ngit diff HEAD -- .\n```',
{
abortController: new AbortController(),
options: {
commands: [],
debug: false,
mainLoopModel: 'sonnet',
tools: new Map(),
verbose: false,
thinkingConfig: { type: 'disabled' },
mcpClients: [],
mcpResources: {},
isNonInteractiveSession: false,
agentDefinitions: {
systemDefinitions: [],
projectDefinitions: [],
userDefinitions: [],
},
},
readFileState: new Map(),
getAppState() {
return {
toolPermissionContext: {
...getEmptyToolPermissionContext(),
alwaysAllowRules: { command: ['Bash(*)'] },
},
}
},
setAppState() {},
} as never,
'bughunter',
undefined,
{ lineLimits: { 'git diff HEAD -- .': 3 } },
)
expect(result).toBe('line1\nline2\nline3')
})
test('executeShellCommandsInPrompt does not truncate below the cap', async () => {
BashTool.call = (async () => ({
data: {
stdout: 'line1\nline2\n',
stderr: '',
interrupted: false,
},
})) as unknown as typeof BashTool.call
BashTool.mapToolResultToToolResultBlockParam = (result, toolUseID) =>
originalMapToolResultToToolResultBlockParam(
result as never,
toolUseID,
)
const result = await executeShellCommandsInPrompt(
'```!\ngit diff HEAD -- .\n```',
{
abortController: new AbortController(),
options: {
commands: [],
debug: false,
mainLoopModel: 'sonnet',
tools: new Map(),
verbose: false,
thinkingConfig: { type: 'disabled' },
mcpClients: [],
mcpResources: {},
isNonInteractiveSession: false,
agentDefinitions: {
systemDefinitions: [],
projectDefinitions: [],
userDefinitions: [],
},
},
readFileState: new Map(),
getAppState() {
return {
toolPermissionContext: {
...getEmptyToolPermissionContext(),
alwaysAllowRules: { command: ['Bash(*)'] },
},
}
},
setAppState() {},
} as never,
'bughunter',
undefined,
{ lineLimits: { 'git diff HEAD -- .': 400 } },
)
expect(result).toBe('line1\nline2')
})
const buildContext = () =>
({
abortController: new AbortController(),
options: {
commands: [],
debug: false,
mainLoopModel: 'sonnet',
tools: new Map(),
verbose: false,
thinkingConfig: { type: 'disabled' },
mcpClients: [],
mcpResources: {},
isNonInteractiveSession: false,
agentDefinitions: {
systemDefinitions: [],
projectDefinitions: [],
userDefinitions: [],
},
},
readFileState: new Map(),
getAppState() {
return {
toolPermissionContext: {
...getEmptyToolPermissionContext(),
alwaysAllowRules: { command: ['Bash(*)'] },
},
}
},
setAppState() {},
}) as never
test('granularFallback blanks only failing snippets, keeps successful ones', async () => {
let invocation = 0
BashTool.call = (async () => {
invocation++
if (invocation === 1) {
return { data: { stdout: 'clean tree\n', stderr: '', interrupted: false } }
}
// Simulate git log on a zero-commit repo
throw new ShellError('', 'fatal: ambiguous argument HEAD', 128, false)
}) as unknown as typeof BashTool.call
BashTool.mapToolResultToToolResultBlockParam = (result, toolUseID) =>
originalMapToolResultToToolResultBlockParam(result as never, toolUseID)
const result = await executeShellCommandsInPrompt(
[
'```!',
'git status',
'```',
'',
'```!',
'git log -10 --pretty=format: --name-only',
'```',
].join('\n'),
buildContext(),
'bughunter',
undefined,
{ granularFallback: true },
)
expect(result).toBe('clean tree\n\n')
})
test('default path wraps non-permission shell failure in MalformedCommandError with stderr', async () => {
BashTool.call = (async () => {
throw new ShellError('', 'fatal: not a git repository', 128, false)
}) as unknown as typeof BashTool.call
BashTool.mapToolResultToToolResultBlockParam = (result, toolUseID) =>
originalMapToolResultToToolResultBlockParam(result as never, toolUseID)
await expect(
executeShellCommandsInPrompt('```!\ngit status\n```', buildContext(), 'security-review'),
).rejects.toThrowError(MalformedCommandError)
try {
await executeShellCommandsInPrompt(
'```!\ngit status\n```',
buildContext(),
'security-review',
)
} catch (e) {
expect(e).toBeInstanceOf(MalformedCommandError)
expect((e as Error).message).toContain('fatal: not a git repository')
expect((e as Error).message).toContain('git status')
}
})
test('granularFallback still surfaces permission denials as MalformedCommandError', async () => {
// hasPermissionsToUseTool returns deny via an `alwaysDenyRules` entry for
// Bash. With granularFallback enabled the catch block should still rethrow
// a MalformedCommandError (per the `if (e instanceof MalformedCommandError)
// throw e` guard), not silently blank the snippet.
BashTool.call = (async () => ({ data: { stdout: 'ok', stderr: '', interrupted: false } })) as unknown as typeof BashTool.call
BashTool.mapToolResultToToolResultBlockParam = (result, toolUseID) =>
originalMapToolResultToToolResultBlockParam(result as never, toolUseID)
const ctx = {
...(buildContext() as object),
getAppState() {
return {
toolPermissionContext: {
...getEmptyToolPermissionContext(),
alwaysAllowRules: { command: [] },
alwaysDenyRules: { command: ['Bash(git:*)'] },
},
}
},
}
await expect(
executeShellCommandsInPrompt(
'```!\ngit status\n```',
ctx as never,
'bughunter',
undefined,
{ granularFallback: true },
),
).rejects.toBeInstanceOf(MalformedCommandError)
})
+94 -16
View File
@@ -69,14 +69,30 @@ const INLINE_PATTERN = /(?<=^|\s)!`([^`]+)`/gm
* This is *never* read from settings.defaultShell — it comes from .md
* frontmatter (author's choice) or is undefined for built-in commands.
* See docs/design/ps-shell-selection.md §5.3.
* @param options.lineLimits - Map of command-prefix → max output lines.
* When a snippet's executed command (trimmed) starts with one of the
* prefixes, the output is sliced to that many lines before being
* substituted. Use to bound diffs or other potentially-large outputs
* without widening the Bash allowlist (avoids `| head -N` in commands,
* which the permission parser treats as a compound and may reject).
*/
export async function executeShellCommandsInPrompt(
text: string,
context: ToolUseContext,
slashCommandName: string,
shell?: FrontmatterShell,
options?: { lineLimits?: Record<string, number>; granularFallback?: boolean },
): Promise<string> {
let result = text
const lineLimits = options?.lineLimits ?? {}
// Default path: any non-permission, non-interrupted shell failure is wrapped
// in a MalformedCommandError with the failing pattern + formatted stderr so
// /commit, /security-review, loaded skills, and plugin commands show a
// useful message instead of the raw "ShellError: Shell command failed".
// Opt-in `granularFallback: true` rethrows the raw error and lets the caller
// blank just the failed snippet in place (used by the bughunter siblings
// where one bad git command should not discard the rest of the context).
const granularFallback = options?.granularFallback === true
// Resolve the tool once. `shell === undefined` and `shell === 'bash'` both
// hit BashTool. PowerShell only when the runtime gate allows — a skill
@@ -117,9 +133,21 @@ export async function executeShellCommandsInPrompt(
}
const { data } = await shellTool.call({ command }, context)
// Apply per-prefix line limit to the raw stdout BEFORE persistence
// so the trimmed output flows through processToolResultBlock and
// its empty-content guard fires correctly when truncation empties
// the block entirely. Also avoids the 30k-char Bash result cap
// short-circuit for huge diffs.
const trimmedStdout =
typeof data.stdout === 'string' ? data.stdout : ''
const boundedStdout = applyLineLimit(
command,
trimmedStdout,
lineLimits,
)
const normalizedData = {
...data,
stdout: typeof data.stdout === 'string' ? data.stdout : '',
stdout: boundedStdout,
stderr: typeof data.stderr === 'string' ? data.stderr : '',
}
// Reuse the same persistence flow as regular Bash tool calls
@@ -145,7 +173,15 @@ export async function executeShellCommandsInPrompt(
if (e instanceof MalformedCommandError) {
throw e
}
formatBashError(e, match[0])
if (granularFallback) {
// Blank the failed snippet in place so the other successful
// snippets (e.g. git status, git diff) are preserved. Callers
// can render their own fallback text outside the code blocks
// if they need to explain the gap.
result = result.replace(match[0], () => '')
return
}
throw formatBashError(e, match[0])
}
}
}),
@@ -178,20 +214,62 @@ function formatBashOutput(
return parts.join(inline ? ' ' : '\n')
}
function formatBashError(e: unknown, pattern: string, inline = false): never {
if (e instanceof ShellError) {
if (e.interrupted) {
throw new MalformedCommandError(
`Shell command interrupted for pattern "${pattern}": [Command interrupted]`,
)
}
const output = formatBashOutput(e.stdout, e.stderr, inline)
throw new MalformedCommandError(
`Shell command failed for pattern "${pattern}": ${output}`,
)
function formatBashError(
e: unknown,
pattern: string,
_inline = false,
): MalformedCommandError {
// Restore the original rich diagnostic: include the failing pattern and the
// formatted stdout/stderr so processSlashCommand can render something a user
// can act on. Permission denials and aborts are surfaced as
// MalformedCommandError by the caller; this path is for everything else.
if (e instanceof MalformedCommandError) {
return e
}
const stderr =
e instanceof Error && 'stderr' in e && typeof (e as { stderr?: unknown }).stderr === 'string'
? (e as { stderr: string }).stderr
: ''
const stdout =
e instanceof Error && 'stdout' in e && typeof (e as { stdout?: unknown }).stdout === 'string'
? (e as { stdout: string }).stdout
: ''
const formatted = formatBashOutput(stdout, stderr, false)
const message = `Shell command failed for pattern "${pattern}": ${errorMessage(e)}${formatted ? `\n${formatted}` : ''}`
return new MalformedCommandError(message)
}
const message = errorMessage(e)
const formatted = inline ? `[Error: ${message}]` : `[Error]\n${message}`
throw new MalformedCommandError(formatted)
/**
* If `command` (trimmed) starts with a key from `limits`, slice `output` to
* at most that many lines. Longest prefix wins so callers can register
* `git diff HEAD -- .` and `git diff` and get the more specific cap.
* Returns `output` unchanged when no key matches or the output is already
* under the cap. A trailing-newline-preserving split keeps the file as
* the diff tool would have rendered it.
*/
function applyLineLimit(
command: string,
output: string,
limits: Record<string, number>,
): string {
const trimmed = command.trim()
let bestPrefix = ''
let bestLimit = Infinity
for (const [prefix, limit] of Object.entries(limits)) {
if (trimmed === prefix || trimmed.startsWith(prefix + ' ')) {
if (prefix.length > bestPrefix.length) {
bestPrefix = prefix
bestLimit = limit
}
}
}
if (bestPrefix === '') {
return output
}
const lines = output.split('\n')
if (lines.length <= bestLimit) {
return output
}
const truncated = lines.slice(0, bestLimit).join('\n')
return output.endsWith('\n') ? truncated + '\n' : truncated
}