From 45c231a71732c40046a67f1cb46f1a5bcd8e96ee Mon Sep 17 00:00:00 2001 From: Yehor Baklanov Date: Sun, 12 Apr 2026 20:40:50 +0000 Subject: [PATCH] feat(cli): add --append-system-prompt-file support --- src/ccs.ts | 3 + src/utils/image-analysis/claude-tool-args.ts | 66 ++++++++++--- src/utils/prompt-injection-strategy.ts | 92 +++++++++++++++++++ src/utils/websearch/claude-tool-args.ts | 62 ++++++++++--- src/utils/websearch/trace.ts | 54 +++++++++-- .../image-analysis/claude-tool-args.test.ts | 30 ++++++ .../utils/prompt-injection-strategy.test.ts | 79 ++++++++++++++++ .../utils/websearch/claude-tool-args.test.ts | 30 +++++- 8 files changed, 382 insertions(+), 34 deletions(-) create mode 100644 src/utils/prompt-injection-strategy.ts create mode 100644 tests/unit/utils/prompt-injection-strategy.test.ts diff --git a/src/ccs.ts b/src/ccs.ts index a6edc978..d19f71a7 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -1014,6 +1014,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) { @@ -1254,6 +1255,7 @@ async function main(): Promise { const imageAnalysisArgs = imageAnalysisMcpReady ? appendThirdPartyImageAnalysisToolArgs(remainingArgs) : remainingArgs; + const launchArgs = [ '--settings', expandedSettingsPath, @@ -1266,6 +1268,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 f1d0cf0b..81131de4 100644 --- a/src/utils/image-analysis/claude-tool-args.ts +++ b/src/utils/image-analysis/claude-tool-args.ts @@ -1,10 +1,21 @@ /** * 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. */ -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.'; +import { + buildSteeringArg, + PROMPT_FLAG_INLINE, + PROMPT_FLAG_FILE, +} from '../prompt-injection-strategy'; + +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 splitArgsAtTerminator(args: string[]): { optionArgs: string[]; trailingArgs: string[] } { const terminatorIndex = args.indexOf('--'); @@ -26,15 +37,28 @@ function getImmediateFlagValue(args: string[], index: number): string | null { return value; } -function hasExactFlagValue(args: string[], flag: string, expectedValue: string): boolean { +function hasExactFlagValue(params: { + args: string[]; + flag: string; + expectedValue: string; + allowPartiallyMatch?: boolean; +}): boolean { + const { args, flag, expectedValue, allowPartiallyMatch } = params; + for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === flag) { const value = getImmediateFlagValue(args, index); + if (value === expectedValue) { return true; } + + if (allowPartiallyMatch && value?.includes(expectedValue)) { + return true; + } + continue; } @@ -53,16 +77,34 @@ function hasExactFlagValue(args: string[], flag: string, expectedValue: string): function ensureImageAnalysisSteeringPrompt(args: string[]): string[] { const { optionArgs, trailingArgs } = splitArgsAtTerminator(args); - if (hasExactFlagValue(optionArgs, APPEND_SYSTEM_PROMPT_FLAG, IMAGE_ANALYSIS_STEERING_PROMPT)) { + if ( + hasExactFlagValue({ + args: optionArgs, + flag: PROMPT_FLAG_INLINE, + expectedValue: IMAGE_ANALYSIS_STEERING_PROMPT.content, + }) + ) { return args; } - return [ - ...optionArgs, - APPEND_SYSTEM_PROMPT_FLAG, - IMAGE_ANALYSIS_STEERING_PROMPT, - ...trailingArgs, - ]; + if ( + hasExactFlagValue({ + args: optionArgs, + flag: PROMPT_FLAG_FILE, + expectedValue: IMAGE_ANALYSIS_STEERING_PROMPT.name, + allowPartiallyMatch: true, + }) + ) { + 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[] { @@ -70,5 +112,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..53e15e4a --- /dev/null +++ b/src/utils/prompt-injection-strategy.ts @@ -0,0 +1,92 @@ +/** + * 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'; + +/** + * 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 ccsDir = getCcsDir(); + + const promptsFolder = path.join(ccsDir, '/prompts'); + + 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: `${params.promptName}.txt`, + 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 15028178..52a821a2 100644 --- a/src/utils/websearch/claude-tool-args.ts +++ b/src/utils/websearch/claude-tool-args.ts @@ -1,12 +1,23 @@ /** * 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 { + buildSteeringArg, + PROMPT_FLAG_INLINE, + PROMPT_FLAG_FILE, +} 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 @@ -68,15 +79,28 @@ 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; + allowPartiallyMatch?: boolean; +}): boolean { + const { args, flag, expectedValue, allowPartiallyMatch } = params; + for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === flag) { const value = getImmediateFlagValue(args, index); + if (value === expectedValue) { return true; } + + if (allowPartiallyMatch && value?.includes(expectedValue)) { + return true; + } + continue; } @@ -135,17 +159,33 @@ function ensureWebSearchSteeringPrompt(args: string[]): string[] { const { optionArgs, trailingArgs } = splitArgsAtTerminator(args); if ( - hasExactFlagValue(optionArgs, APPEND_SYSTEM_PROMPT_FLAG, THIRD_PARTY_WEBSEARCH_STEERING_PROMPT) + hasExactFlagValue({ + args: optionArgs, + flag: PROMPT_FLAG_INLINE, + expectedValue: THIRD_PARTY_WEBSEARCH_STEERING_PROMPT.content, + }) ) { return args; } - return [ - ...optionArgs, - APPEND_SYSTEM_PROMPT_FLAG, - THIRD_PARTY_WEBSEARCH_STEERING_PROMPT, - ...trailingArgs, - ]; + if ( + hasExactFlagValue({ + args: optionArgs, + flag: PROMPT_FLAG_FILE, + expectedValue: THIRD_PARTY_WEBSEARCH_STEERING_PROMPT.name, + allowPartiallyMatch: true, + }) + ) { + 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..64a9e596 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 { PROMPT_FLAG_INLINE, PROMPT_FLAG_FILE } 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,28 @@ 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; + allowIncludesValue?: boolean; +}): boolean { + const { args, flag, expectedValue, allowIncludesValue } = 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; } + + if (allowIncludesValue && immediateFlagValue?.includes(expectedValue)) { + return true; + } + continue; } @@ -179,16 +192,37 @@ 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 ( + hasExactFlagValue({ + args, + flag: PROMPT_FLAG_FILE, + expectedValue: THIRD_PARTY_WEBSEARCH_STEERING_PROMPT.name, + allowIncludesValue: true, + }) + ) { + 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..a68bc5e9 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,34 @@ 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); + }); }); 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..b69693fb --- /dev/null +++ b/tests/unit/utils/prompt-injection-strategy.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'bun:test'; +import { + detectPromptInjectionMode, + buildInlineSteeringArg, + buildFileSteeringArg, + buildSteeringArg, + PROMPT_FLAG_INLINE, + PROMPT_FLAG_FILE, +} from '../../../src/utils/prompt-injection-strategy'; + +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 temp file', () => { + const result = buildFileSteeringArg({promptFileName: 'ccs-test-prompt.txt', promptContent: 'hello world' }); + expect(result[0]).toBe('--append-system-prompt-file'); + expect(result[1]).toContain('ccs-test-prompt.txt'); + }); +}); + +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]).toContain('ccs-test.txt'); + }); +}); diff --git a/tests/unit/utils/websearch/claude-tool-args.test.ts b/tests/unit/utils/websearch/claude-tool-args.test.ts index 9f902b80..acad3acf 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,32 @@ 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'); + }); });