From 7fc39f7c0319c19ec52d21dc60248a27c128fe3f Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 13 Apr 2026 08:39:59 -0400 Subject: [PATCH] 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); + }); });