From 45c231a71732c40046a67f1cb46f1a5bcd8e96ee Mon Sep 17 00:00:00 2001 From: Yehor Baklanov Date: Sun, 12 Apr 2026 20:40:50 +0000 Subject: [PATCH 1/2] 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'); + }); }); From 7fc39f7c0319c19ec52d21dc60248a27c128fe3f Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 13 Apr 2026 08:39:59 -0400 Subject: [PATCH 2/2] fix(cli): match managed prompt files by exact path --- src/utils/image-analysis/claude-tool-args.ts | 16 +--- src/utils/prompt-injection-strategy.ts | 43 ++++++++++- src/utils/websearch/claude-tool-args.ts | 15 +--- src/utils/websearch/trace.ts | 18 +---- .../image-analysis/claude-tool-args.test.ts | 13 ++++ .../utils/prompt-injection-strategy.test.ts | 75 +++++++++++++++---- .../utils/websearch/claude-tool-args.test.ts | 12 +++ 7 files changed, 135 insertions(+), 57 deletions(-) diff --git a/src/utils/image-analysis/claude-tool-args.ts b/src/utils/image-analysis/claude-tool-args.ts index 81131de4..6c54e1ef 100644 --- a/src/utils/image-analysis/claude-tool-args.ts +++ b/src/utils/image-analysis/claude-tool-args.ts @@ -7,8 +7,8 @@ import { buildSteeringArg, + hasManagedPromptFileArg, PROMPT_FLAG_INLINE, - PROMPT_FLAG_FILE, } from '../prompt-injection-strategy'; const IMAGE_ANALYSIS_STEERING_PROMPT = { @@ -41,9 +41,8 @@ function hasExactFlagValue(params: { args: string[]; flag: string; expectedValue: string; - allowPartiallyMatch?: boolean; }): boolean { - const { args, flag, expectedValue, allowPartiallyMatch } = params; + const { args, flag, expectedValue } = params; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; @@ -55,10 +54,6 @@ function hasExactFlagValue(params: { return true; } - if (allowPartiallyMatch && value?.includes(expectedValue)) { - return true; - } - continue; } @@ -88,12 +83,7 @@ function ensureImageAnalysisSteeringPrompt(args: string[]): string[] { } if ( - hasExactFlagValue({ - args: optionArgs, - flag: PROMPT_FLAG_FILE, - expectedValue: IMAGE_ANALYSIS_STEERING_PROMPT.name, - allowPartiallyMatch: true, - }) + hasManagedPromptFileArg({ args: optionArgs, promptName: IMAGE_ANALYSIS_STEERING_PROMPT.name }) ) { return args; } diff --git a/src/utils/prompt-injection-strategy.ts b/src/utils/prompt-injection-strategy.ts index 53e15e4a..7624556d 100644 --- a/src/utils/prompt-injection-strategy.ts +++ b/src/utils/prompt-injection-strategy.ts @@ -22,6 +22,43 @@ 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. * @@ -56,9 +93,7 @@ export function buildFileSteeringArg(params: { promptFileName: string; promptContent: string; }): string[] { - const ccsDir = getCcsDir(); - - const promptsFolder = path.join(ccsDir, '/prompts'); + const promptsFolder = getManagedPromptsDir(); if (!fs.existsSync(promptsFolder)) { fs.mkdirSync(promptsFolder, { recursive: true }); @@ -83,7 +118,7 @@ export function buildSteeringArg(params: { if (mode === 'file') { return buildFileSteeringArg({ - promptFileName: `${params.promptName}.txt`, + promptFileName: getManagedPromptFileName(params.promptName), promptContent: params.promptContent, }); } diff --git a/src/utils/websearch/claude-tool-args.ts b/src/utils/websearch/claude-tool-args.ts index 52a821a2..de73e829 100644 --- a/src/utils/websearch/claude-tool-args.ts +++ b/src/utils/websearch/claude-tool-args.ts @@ -7,8 +7,8 @@ import { buildSteeringArg, + hasManagedPromptFileArg, PROMPT_FLAG_INLINE, - PROMPT_FLAG_FILE, } from '../prompt-injection-strategy'; const NATIVE_WEBSEARCH_TOOL = 'WebSearch'; @@ -83,9 +83,8 @@ function hasExactFlagValue(params: { args: string[]; flag: string; expectedValue: string; - allowPartiallyMatch?: boolean; }): boolean { - const { args, flag, expectedValue, allowPartiallyMatch } = params; + const { args, flag, expectedValue } = params; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; @@ -97,10 +96,6 @@ function hasExactFlagValue(params: { return true; } - if (allowPartiallyMatch && value?.includes(expectedValue)) { - return true; - } - continue; } @@ -169,11 +164,9 @@ function ensureWebSearchSteeringPrompt(args: string[]): string[] { } if ( - hasExactFlagValue({ + hasManagedPromptFileArg({ args: optionArgs, - flag: PROMPT_FLAG_FILE, - expectedValue: THIRD_PARTY_WEBSEARCH_STEERING_PROMPT.name, - allowPartiallyMatch: true, + promptName: THIRD_PARTY_WEBSEARCH_STEERING_PROMPT.name, }) ) { return args; diff --git a/src/utils/websearch/trace.ts b/src/utils/websearch/trace.ts index 64a9e596..114cc48c 100644 --- a/src/utils/websearch/trace.ts +++ b/src/utils/websearch/trace.ts @@ -11,7 +11,7 @@ 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 { 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'; @@ -61,9 +61,8 @@ function hasExactFlagValue(params: { args: string[]; flag: string; expectedValue: string; - allowIncludesValue?: boolean; }): boolean { - const { args, flag, expectedValue, allowIncludesValue } = params; + const { args, flag, expectedValue } = params; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; @@ -75,10 +74,6 @@ function hasExactFlagValue(params: { return true; } - if (allowIncludesValue && immediateFlagValue?.includes(expectedValue)) { - return true; - } - continue; } @@ -203,14 +198,7 @@ function hasSteeringPromptInArgs(args: string[]): boolean { return true; } - if ( - hasExactFlagValue({ - args, - flag: PROMPT_FLAG_FILE, - expectedValue: THIRD_PARTY_WEBSEARCH_STEERING_PROMPT.name, - allowIncludesValue: true, - }) - ) { + if (hasManagedPromptFileArg({ args, promptName: THIRD_PARTY_WEBSEARCH_STEERING_PROMPT.name })) { return true; } 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 a68bc5e9..a95c19c9 100644 --- a/tests/unit/utils/image-analysis/claude-tool-args.test.ts +++ b/tests/unit/utils/image-analysis/claude-tool-args.test.ts @@ -66,4 +66,17 @@ describe('appendThirdPartyImageAnalysisToolArgs', () => { ); 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 index b69693fb..e2ccdb34 100644 --- a/tests/unit/utils/prompt-injection-strategy.test.ts +++ b/tests/unit/utils/prompt-injection-strategy.test.ts @@ -1,13 +1,36 @@ -import { describe, expect, it } from 'bun:test'; +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 { - detectPromptInjectionMode, 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'); @@ -48,23 +71,47 @@ describe('buildInlineSteeringArg', () => { }); describe('buildFileSteeringArg', () => { - it('returns file flag and writes temp file', () => { - const result = buildFileSteeringArg({promptFileName: 'ccs-test-prompt.txt', promptContent: 'hello world' }); + 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]).toContain('ccs-test-prompt.txt'); + 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', - ]); + expect( + buildSteeringArg({ + args: [PROMPT_FLAG_INLINE], + promptName: 'ignored.txt', + promptContent: 'hello', + }) + ).toEqual(['--append-system-prompt', 'hello']); }); it('delegates to file in file mode', () => { @@ -74,6 +121,6 @@ describe('buildSteeringArg', () => { promptContent: 'hello', }); expect(result[0]).toBe('--append-system-prompt-file'); - expect(result[1]).toContain('ccs-test.txt'); + 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 acad3acf..59d484c0 100644 --- a/tests/unit/utils/websearch/claude-tool-args.test.ts +++ b/tests/unit/utils/websearch/claude-tool-args.test.ts @@ -157,4 +157,16 @@ describe('appendThirdPartyWebSearchToolArgs', () => { 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); + }); });