diff --git a/src/ccs.ts b/src/ccs.ts index e3fdfb9d..5c907085 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -1050,6 +1050,7 @@ async function main(): Promise { : getSettingsPath(profileInfo.name)); const settings = resolvedSettings ?? loadSettings(expandedSettingsPath); const cliproxyBridge = resolvedCliproxyBridge ?? resolveCliproxyBridgeMetadata(settings); + let imageAnalysisFallbackHookReady: boolean | undefined; if (resolvedTarget === 'claude') { if (imageAnalysisMcpReady) { @@ -1315,6 +1316,7 @@ async function main(): Promise { profileType: profileInfo.type, settingsPath: expandedSettingsPath, }); + execClaude(claudeCli, launchArgs, { ...envVars, ...traceEnv }); } else if (profileInfo.type === 'account') { // NEW FLOW: Account-based profile (work, personal) diff --git a/src/utils/image-analysis/claude-tool-args.ts b/src/utils/image-analysis/claude-tool-args.ts index 267e731a..c4be4df1 100644 --- a/src/utils/image-analysis/claude-tool-args.ts +++ b/src/utils/image-analysis/claude-tool-args.ts @@ -1,31 +1,48 @@ /** * Claude launch argument helpers for first-class Image Analysis. + * + * Uses the same prompt injection mode as the user to avoid mixing + * `--append-system-prompt` and `--append-system-prompt-file` in one request. */ import { hasExactFlagValue as hasExactClaudeFlagValue, splitArgsAtTerminator as splitClaudeArgsAtTerminator, } from '../claude-tool-args'; +import { + buildSteeringArg, + hasManagedPromptFileArg, + PROMPT_FLAG_INLINE, +} from '../prompt-injection-strategy'; -const APPEND_SYSTEM_PROMPT_FLAG = '--append-system-prompt'; -const IMAGE_ANALYSIS_STEERING_PROMPT = - 'For local image or PDF files, prefer the CCS MCP tool ImageAnalysis instead of Read. Use Read for text, code, and other plain files. If the user asks a specific question about the visual, pass that question as the focus field when useful. If ImageAnalysis is unavailable or fails, you may fall back to Read.'; +const IMAGE_ANALYSIS_STEERING_PROMPT = { + name: 'ccs-prompt-image-analysis-tool', + content: + 'For local image or PDF files, prefer the CCS MCP tool ImageAnalysis instead of Read. Use Read for text, code, and other plain files. If the user asks a specific question about the visual, pass that question as the focus field when useful. If ImageAnalysis is unavailable or fails, you may fall back to Read.', +}; function ensureImageAnalysisSteeringPrompt(args: string[]): string[] { const { optionArgs, trailingArgs } = splitClaudeArgsAtTerminator(args); if ( - hasExactClaudeFlagValue(optionArgs, APPEND_SYSTEM_PROMPT_FLAG, IMAGE_ANALYSIS_STEERING_PROMPT) + hasExactClaudeFlagValue(optionArgs, PROMPT_FLAG_INLINE, IMAGE_ANALYSIS_STEERING_PROMPT.content) ) { return args; } - return [ - ...optionArgs, - APPEND_SYSTEM_PROMPT_FLAG, - IMAGE_ANALYSIS_STEERING_PROMPT, - ...trailingArgs, - ]; + if ( + hasManagedPromptFileArg({ args: optionArgs, promptName: IMAGE_ANALYSIS_STEERING_PROMPT.name }) + ) { + return args; + } + + const steeringArg = buildSteeringArg({ + args: optionArgs, + promptName: IMAGE_ANALYSIS_STEERING_PROMPT.name, + promptContent: IMAGE_ANALYSIS_STEERING_PROMPT.content, + }); + + return [...optionArgs, ...steeringArg, ...trailingArgs]; } export function appendThirdPartyImageAnalysisToolArgs(args: string[]): string[] { @@ -33,5 +50,5 @@ export function appendThirdPartyImageAnalysisToolArgs(args: string[]): string[] } export function getImageAnalysisSteeringPrompt(): string { - return IMAGE_ANALYSIS_STEERING_PROMPT; + return IMAGE_ANALYSIS_STEERING_PROMPT.content; } diff --git a/src/utils/prompt-injection-strategy.ts b/src/utils/prompt-injection-strategy.ts new file mode 100644 index 00000000..7624556d --- /dev/null +++ b/src/utils/prompt-injection-strategy.ts @@ -0,0 +1,127 @@ +/** + * Shared prompt injection strategy. + * + * Detects which prompt injection mode the user is using and ensures CCS + * always uses the SAME mode so Claude CLI never receives mixed + * `--append-system-prompt` and `--append-system-prompt-file` flags. + * + * Rules: + * - User passes `--append-system-prompt` → all CCS prompts use inline + * - User passes `--append-system-prompt-file` → all CCS prompts use file + * - Neither present → default to inline (`--append-system-prompt`) + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { getCcsDir } from './config-manager'; + +export type PromptInjectionMode = 'inline' | 'file'; + +/** `--append-system-prompt` — inline prompt text */ +export const PROMPT_FLAG_INLINE = '--append-system-prompt'; +/** `--append-system-prompt-file` — prompt read from file */ +export const PROMPT_FLAG_FILE = '--append-system-prompt-file'; + +function getManagedPromptsDir(): string { + return path.join(getCcsDir(), 'prompts'); +} + +export function getManagedPromptFileName(promptName: string): string { + return `${promptName}.txt`; +} + +export function getManagedPromptFilePath(promptName: string): string { + return path.join(getManagedPromptsDir(), getManagedPromptFileName(promptName)); +} + +export function hasManagedPromptFileArg(params: { args: string[]; promptName: string }): boolean { + const expectedPath = path.resolve(getManagedPromptFilePath(params.promptName)); + + for (let index = 0; index < params.args.length; index += 1) { + const arg = params.args[index]; + + if (arg === PROMPT_FLAG_FILE) { + const filePath = params.args[index + 1]; + if (filePath && path.resolve(filePath) === expectedPath) { + return true; + } + continue; + } + + if ( + arg.startsWith(`${PROMPT_FLAG_FILE}=`) && + path.resolve(arg.slice(PROMPT_FLAG_FILE.length + 1)) === expectedPath + ) { + return true; + } + } + + return false; +} + +/** + * Detect which prompt injection mode to use based on user-provided args. + * + * - `--append-system-prompt-file` found (space or `=` form) → 'file' + * - `--append-system-prompt` found (space or `=` form) → 'inline' + * - Neither → 'inline' (default) + */ +export function detectPromptInjectionMode(args: string[]): PromptInjectionMode { + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + + if (arg === PROMPT_FLAG_FILE || arg.startsWith(`${PROMPT_FLAG_FILE}=`)) { + return 'file'; + } + } + + return 'inline'; +} + +/** + * Build a `--append-system-prompt ` arg pair. + */ +export function buildInlineSteeringArg(params: { promptContent: string }): string[] { + return [PROMPT_FLAG_INLINE, params.promptContent]; +} + +/** + * Build a `--append-system-prompt-file ` arg pair. + * Writes the prompt to a temp file first. + */ +export function buildFileSteeringArg(params: { + promptFileName: string; + promptContent: string; +}): string[] { + const promptsFolder = getManagedPromptsDir(); + + if (!fs.existsSync(promptsFolder)) { + fs.mkdirSync(promptsFolder, { recursive: true }); + } + + const promptFile = path.join(promptsFolder, params.promptFileName); + + fs.writeFileSync(promptFile, params.promptContent); + + return [PROMPT_FLAG_FILE, promptFile]; +} + +/** + * Build steering prompt args in the given mode. + */ +export function buildSteeringArg(params: { + args: string[]; + promptName: string; + promptContent: string; +}): string[] { + const mode = detectPromptInjectionMode(params.args); + + if (mode === 'file') { + return buildFileSteeringArg({ + promptFileName: getManagedPromptFileName(params.promptName), + promptContent: params.promptContent, + }); + } + + return buildInlineSteeringArg({ promptContent: params.promptContent }); +} diff --git a/src/utils/websearch/claude-tool-args.ts b/src/utils/websearch/claude-tool-args.ts index 254353a2..a680fedd 100644 --- a/src/utils/websearch/claude-tool-args.ts +++ b/src/utils/websearch/claude-tool-args.ts @@ -1,5 +1,8 @@ /** * Claude launch argument helpers for third-party WebSearch. + * + * Uses the same prompt injection mode as the user to avoid mixing + * `--append-system-prompt` and `--append-system-prompt-file` in one request. */ import { @@ -7,12 +10,19 @@ import { hasExactFlagValue as hasExactClaudeFlagValue, splitArgsAtTerminator as splitClaudeArgsAtTerminator, } from '../claude-tool-args'; +import { + buildSteeringArg, + hasManagedPromptFileArg, + PROMPT_FLAG_INLINE, +} from '../prompt-injection-strategy'; const NATIVE_WEBSEARCH_TOOL = 'WebSearch'; const DISALLOWED_TOOLS_FLAG = '--disallowedTools'; -const APPEND_SYSTEM_PROMPT_FLAG = '--append-system-prompt'; -const THIRD_PARTY_WEBSEARCH_STEERING_PROMPT = - 'For web lookup or current-information requests, prefer the CCS MCP tool WebSearch instead of Bash/curl/http fetches. If the user explicitly wants shell commands, or WebSearch is unavailable or fails, you may fall back to Bash/network tools.'; +export const THIRD_PARTY_WEBSEARCH_STEERING_PROMPT = { + name: 'ccs-prompt-websearch-tool', + content: + 'For web lookup or current-information requests, prefer the CCS MCP tool WebSearch instead of Bash/curl/http fetches. If the user explicitly wants shell commands, or WebSearch is unavailable or fails, you may fall back to Bash/network tools.', +}; function parseToolValue(rawValue: string): string[] { return rawValue @@ -99,19 +109,29 @@ function ensureWebSearchSteeringPrompt(args: string[]): string[] { if ( hasExactClaudeFlagValue( optionArgs, - APPEND_SYSTEM_PROMPT_FLAG, - THIRD_PARTY_WEBSEARCH_STEERING_PROMPT + PROMPT_FLAG_INLINE, + THIRD_PARTY_WEBSEARCH_STEERING_PROMPT.content ) ) { return args; } - return [ - ...optionArgs, - APPEND_SYSTEM_PROMPT_FLAG, - THIRD_PARTY_WEBSEARCH_STEERING_PROMPT, - ...trailingArgs, - ]; + if ( + hasManagedPromptFileArg({ + args: optionArgs, + promptName: THIRD_PARTY_WEBSEARCH_STEERING_PROMPT.name, + }) + ) { + return args; + } + + const steeringArgs = buildSteeringArg({ + args: optionArgs, + promptName: THIRD_PARTY_WEBSEARCH_STEERING_PROMPT.name, + promptContent: THIRD_PARTY_WEBSEARCH_STEERING_PROMPT.content, + }); + + return [...optionArgs, ...steeringArgs, ...trailingArgs]; } export function appendThirdPartyWebSearchToolArgs(args: string[]): string[] { diff --git a/src/utils/websearch/trace.ts b/src/utils/websearch/trace.ts index 5629be7a..114cc48c 100644 --- a/src/utils/websearch/trace.ts +++ b/src/utils/websearch/trace.ts @@ -11,13 +11,12 @@ import * as os from 'os'; import * as path from 'path'; import { getCcsDir } from '../config-manager'; import { createLogger } from '../../services/logging'; +import { hasManagedPromptFileArg, PROMPT_FLAG_INLINE } from '../prompt-injection-strategy'; +import { THIRD_PARTY_WEBSEARCH_STEERING_PROMPT } from './claude-tool-args'; const TRACE_FILE_NAME = 'websearch-trace.jsonl'; const NATIVE_WEBSEARCH_TOOL = 'WebSearch'; const DISALLOWED_TOOLS_FLAG = '--disallowedTools'; -const APPEND_SYSTEM_PROMPT_FLAG = '--append-system-prompt'; -const THIRD_PARTY_WEBSEARCH_STEERING_PROMPT = - 'For web lookup or current-information requests, prefer the CCS MCP tool WebSearch instead of Bash/curl/http fetches. If the user explicitly wants shell commands, or WebSearch is unavailable or fails, you may fall back to Bash/network tools.'; const logger = createLogger('websearch'); function parseToolValue(rawValue: string): string[] { @@ -58,14 +57,23 @@ function hasToolInFlag(args: string[], flag: string, toolName: string): boolean return false; } -function hasExactFlagValue(args: string[], flag: string, expectedValue: string): boolean { +function hasExactFlagValue(params: { + args: string[]; + flag: string; + expectedValue: string; +}): boolean { + const { args, flag, expectedValue } = params; + for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === flag) { - if (getImmediateFlagValue(args, index) === expectedValue) { + const immediateFlagValue = getImmediateFlagValue(args, index); + + if (immediateFlagValue === expectedValue) { return true; } + continue; } @@ -179,16 +187,30 @@ function buildLaunchId(): string { return `websearch-${Date.now()}-${process.pid}-${random}`; } +function hasSteeringPromptInArgs(args: string[]): boolean { + if ( + hasExactFlagValue({ + args, + flag: PROMPT_FLAG_INLINE, + expectedValue: THIRD_PARTY_WEBSEARCH_STEERING_PROMPT.content, + }) + ) { + return true; + } + + if (hasManagedPromptFileArg({ args, promptName: THIRD_PARTY_WEBSEARCH_STEERING_PROMPT.name })) { + return true; + } + + return false; +} + function summarizeLaunchArgs(args: string[]): Record { return { argCount: args.length, hasSettingsFlag: args.includes('--settings'), nativeWebSearchDisallowed: hasToolInFlag(args, DISALLOWED_TOOLS_FLAG, NATIVE_WEBSEARCH_TOOL), - steeringPromptApplied: hasExactFlagValue( - args, - APPEND_SYSTEM_PROMPT_FLAG, - THIRD_PARTY_WEBSEARCH_STEERING_PROMPT - ), + steeringPromptApplied: hasSteeringPromptInArgs(args), }; } diff --git a/tests/unit/utils/image-analysis/claude-tool-args.test.ts b/tests/unit/utils/image-analysis/claude-tool-args.test.ts index 8f62322b..a95c19c9 100644 --- a/tests/unit/utils/image-analysis/claude-tool-args.test.ts +++ b/tests/unit/utils/image-analysis/claude-tool-args.test.ts @@ -36,4 +36,47 @@ describe('appendThirdPartyImageAnalysisToolArgs', () => { 'extra', ]); }); + + // File mode: --append-system-prompt-file when user passes --append-system-prompt-file + + it('uses --append-system-prompt-file when user passes --append-system-prompt-file', () => { + const result = appendThirdPartyImageAnalysisToolArgs([ + '-p', + 'describe', + '--append-system-prompt-file', + '/tmp/user-prompt.txt', + ]); + + expect(result).toContain('--append-system-prompt-file'); + expect(result).not.toContain('--append-system-prompt'); + const fileFlags = result.filter((arg) => arg === '--append-system-prompt-file'); + expect(fileFlags.length).toBeGreaterThanOrEqual(2); + }); + + it('uses --append-system-prompt-file when user passes equals form', () => { + const result = appendThirdPartyImageAnalysisToolArgs([ + '-p', + 'describe', + '--append-system-prompt-file=/tmp/user-prompt.txt', + ]); + + expect(result).not.toContain('--append-system-prompt'); + const fileFlags = result.filter( + (arg) => arg === '--append-system-prompt-file' || arg.startsWith('--append-system-prompt-file=') + ); + expect(fileFlags.length).toBeGreaterThanOrEqual(2); + }); + + it('does not treat unrelated user prompt files as the managed CCS steering prompt', () => { + const result = appendThirdPartyImageAnalysisToolArgs([ + '-p', + 'describe', + '--append-system-prompt-file', + '/tmp/user-ccs-prompt-image-analysis-tool-notes.txt', + ]); + + const filePaths = result.filter((arg, index) => result[index - 1] === '--append-system-prompt-file'); + expect(filePaths).toContain('/tmp/user-ccs-prompt-image-analysis-tool-notes.txt'); + expect(filePaths.some((filePath) => filePath.endsWith('/ccs-prompt-image-analysis-tool.txt'))).toBe(true); + }); }); diff --git a/tests/unit/utils/prompt-injection-strategy.test.ts b/tests/unit/utils/prompt-injection-strategy.test.ts new file mode 100644 index 00000000..e2ccdb34 --- /dev/null +++ b/tests/unit/utils/prompt-injection-strategy.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + buildInlineSteeringArg, + buildFileSteeringArg, + buildSteeringArg, + detectPromptInjectionMode, + getManagedPromptFilePath, + hasManagedPromptFileArg, + PROMPT_FLAG_INLINE, + PROMPT_FLAG_FILE, +} from '../../../src/utils/prompt-injection-strategy'; + +let originalCcsHome: string | undefined; +let tempHome: string; + +beforeEach(() => { + originalCcsHome = process.env.CCS_HOME; + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-prompt-strategy-')); + process.env.CCS_HOME = tempHome; +}); + +afterEach(() => { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + fs.rmSync(tempHome, { recursive: true, force: true }); +}); + +describe('detectPromptInjectionMode', () => { + it('returns inline when no prompt flags present', () => { + expect(detectPromptInjectionMode(['-p', 'hello'])).toBe('inline'); + }); + + it('returns inline when only --append-system-prompt is present', () => { + expect(detectPromptInjectionMode(['--append-system-prompt', 'test'])).toBe('inline'); + }); + + it('returns inline when --append-system-prompt equals form is present', () => { + expect(detectPromptInjectionMode(['--append-system-prompt=test'])).toBe('inline'); + }); + + it('returns file when --append-system-prompt-file is present', () => { + expect(detectPromptInjectionMode(['--append-system-prompt-file', '/tmp/p.txt'])).toBe('file'); + }); + + it('returns file when --append-system-prompt-file equals form is present', () => { + expect(detectPromptInjectionMode(['--append-system-prompt-file=/tmp/p.txt'])).toBe('file'); + }); + + it('returns file even when --append-system-prompt is also present', () => { + expect( + detectPromptInjectionMode([ + '--append-system-prompt', + 'inline-text', + '--append-system-prompt-file', + '/tmp/p.txt', + ]) + ).toBe('file'); + }); +}); + +describe('buildInlineSteeringArg', () => { + it('returns inline flag and prompt text', () => { + expect(buildInlineSteeringArg({promptContent: 'hello world'})).toEqual(['--append-system-prompt', 'hello world']); + }); +}); + +describe('buildFileSteeringArg', () => { + it('returns file flag and writes the prompt into the isolated CCS home', () => { + const result = buildFileSteeringArg({ + promptFileName: 'ccs-test-prompt.txt', + promptContent: 'hello world', + }); + + expect(result[0]).toBe('--append-system-prompt-file'); + expect(result[1]).toBe(path.join(tempHome, '.ccs', 'prompts', 'ccs-test-prompt.txt')); + expect(fs.readFileSync(result[1], 'utf8')).toBe('hello world'); + }); +}); + +describe('hasManagedPromptFileArg', () => { + it('returns true for the exact CCS-managed prompt path', () => { + expect( + hasManagedPromptFileArg({ + args: [PROMPT_FLAG_FILE, getManagedPromptFilePath('ccs-test')], + promptName: 'ccs-test', + }) + ).toBe(true); + }); + + it('returns false for unrelated user files that only contain the prompt name', () => { + expect( + hasManagedPromptFileArg({ + args: [PROMPT_FLAG_FILE, '/tmp/user-ccs-test-notes.txt'], + promptName: 'ccs-test', + }) + ).toBe(false); + }); +}); + +describe('buildSteeringArg', () => { + it('delegates to inline in inline mode', () => { + expect( + buildSteeringArg({ + args: [PROMPT_FLAG_INLINE], + promptName: 'ignored.txt', + promptContent: 'hello', + }) + ).toEqual(['--append-system-prompt', 'hello']); + }); + + it('delegates to file in file mode', () => { + const result = buildSteeringArg({ + args: [PROMPT_FLAG_FILE], + promptName: 'ccs-test', + promptContent: 'hello', + }); + expect(result[0]).toBe('--append-system-prompt-file'); + expect(result[1]).toBe(getManagedPromptFilePath('ccs-test')); + }); +}); diff --git a/tests/unit/utils/websearch/claude-tool-args.test.ts b/tests/unit/utils/websearch/claude-tool-args.test.ts index 9f902b80..59d484c0 100644 --- a/tests/unit/utils/websearch/claude-tool-args.test.ts +++ b/tests/unit/utils/websearch/claude-tool-args.test.ts @@ -5,7 +5,7 @@ const STEERING_PROMPT = 'For web lookup or current-information requests, prefer the CCS MCP tool WebSearch instead of Bash/curl/http fetches. If the user explicitly wants shell commands, or WebSearch is unavailable or fails, you may fall back to Bash/network tools.'; describe('appendThirdPartyWebSearchToolArgs', () => { - it('appends native WebSearch suppression and steering prompt when no tool flags are present', () => { + it('appends native WebSearch suppression and inline steering prompt when no prompt flags are present', () => { expect(appendThirdPartyWebSearchToolArgs(['smoke'])).toEqual([ 'smoke', '--disallowedTools', @@ -129,4 +129,44 @@ describe('appendThirdPartyWebSearchToolArgs', () => { STEERING_PROMPT, ]); }); + + // File mode: --append-system-prompt-file when user passes --append-system-prompt-file + + it('uses --append-system-prompt-file when user passes --append-system-prompt-file', () => { + const result = appendThirdPartyWebSearchToolArgs([ + 'smoke', + '--append-system-prompt-file', + '/tmp/user-prompt.txt', + ]); + expect(result).toContain('--disallowedTools'); + expect(result).toContain('WebSearch'); + const fileFlags = result.filter((arg) => arg === '--append-system-prompt-file'); + expect(fileFlags.length).toBeGreaterThanOrEqual(2); + // No inline flag should be present + expect(result).not.toContain('--append-system-prompt'); + }); + + it('uses --append-system-prompt-file when user passes --append-system-prompt-file= form', () => { + const result = appendThirdPartyWebSearchToolArgs([ + 'smoke', + '--append-system-prompt-file=/tmp/user-prompt.txt', + ]); + const fileFlags = result.filter( + (arg) => arg === '--append-system-prompt-file' || arg.startsWith('--append-system-prompt-file=') + ); + expect(fileFlags.length).toBeGreaterThanOrEqual(2); + expect(result).not.toContain('--append-system-prompt'); + }); + + it('does not treat unrelated user prompt files as the managed CCS steering prompt', () => { + const result = appendThirdPartyWebSearchToolArgs([ + 'smoke', + '--append-system-prompt-file', + '/tmp/user-ccs-prompt-websearch-tool-notes.txt', + ]); + + const filePaths = result.filter((arg, index) => result[index - 1] === '--append-system-prompt-file'); + expect(filePaths).toContain('/tmp/user-ccs-prompt-websearch-tool-notes.txt'); + expect(filePaths.some((filePath) => filePath.endsWith('/ccs-prompt-websearch-tool.txt'))).toBe(true); + }); });