/** * Best-effort WebSearch trace helpers. * * Writes opt-in JSONL trace records to ~/.ccs/logs/websearch-trace.jsonl so * CCS can explain launch intent, MCP exposure, provider selection, and likely * bypass scenarios without polluting Claude/MCP stdout. */ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { getCcsDir } from '../config-manager'; 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.'; function parseToolValue(rawValue: string): string[] { return rawValue .split(',') .map((value) => value.trim()) .filter((value) => value.length > 0); } function getImmediateFlagValue(args: string[], index: number): string | null { const value = args[index + 1]; if (value === undefined || value === '--' || value.startsWith('--')) { return null; } return value; } function hasToolInFlag(args: string[], flag: string, toolName: string): boolean { for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === flag) { const value = getImmediateFlagValue(args, index); if (value && parseToolValue(value).includes(toolName)) { return true; } continue; } if (arg.startsWith(`${flag}=`)) { const rawValue = arg.slice(flag.length + 1); if (parseToolValue(rawValue).includes(toolName)) { return true; } } } return false; } function hasExactFlagValue(args: string[], flag: string, expectedValue: string): boolean { for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === flag) { if (getImmediateFlagValue(args, index) === expectedValue) { return true; } continue; } if (arg === `${flag}=${expectedValue}`) { return true; } } return false; } function normalizeSafePrefix(inputPath: string): string { return `${path.resolve(inputPath)}${path.sep}`; } function getSafeTracePrefixes(): string[] { return [ normalizeSafePrefix(path.join(getCcsDir(), 'logs')), normalizeSafePrefix(os.tmpdir()), normalizeSafePrefix('/var/log'), ]; } export function resolveAllowedWebSearchTraceFile( env: NodeJS.ProcessEnv = process.env ): string | null { const configured = env.CCS_WEBSEARCH_TRACE_FILE?.trim(); if (!configured) { return null; } const resolved = path.resolve(configured); if (getSafeTracePrefixes().some((prefix) => resolved.startsWith(prefix))) { return resolved; } return null; } function getTraceFilePath(env: NodeJS.ProcessEnv): string { return resolveAllowedWebSearchTraceFile(env) ?? path.join(getCcsDir(), 'logs', TRACE_FILE_NAME); } export function isWebSearchTraceEnabled(env: NodeJS.ProcessEnv = process.env): boolean { return env.CCS_WEBSEARCH_TRACE === '1' || env.CCS_DEBUG === '1'; } export function appendWebSearchTrace( event: string, payload: Record = {}, env: NodeJS.ProcessEnv = process.env ): void { if (!isWebSearchTraceEnabled(env)) { return; } try { const traceFilePath = getTraceFilePath(env); fs.mkdirSync(path.dirname(traceFilePath), { recursive: true }); fs.appendFileSync( traceFilePath, JSON.stringify({ at: new Date().toISOString(), event, launchId: env.CCS_WEBSEARCH_TRACE_LAUNCH_ID || null, launcher: env.CCS_WEBSEARCH_TRACE_LAUNCHER || null, profileType: env.CCS_PROFILE_TYPE || null, pid: process.pid, ...payload, }) + '\n', 'utf8' ); } catch { // Tracing must never affect launch behavior. } } export function readWebSearchTraceRecords( launchId: string, env: NodeJS.ProcessEnv = process.env ): Array> { if (!launchId) { return []; } try { const traceFilePath = getTraceFilePath(env); if (!fs.existsSync(traceFilePath)) { return []; } return fs .readFileSync(traceFilePath, 'utf8') .split('\n') .map((line) => line.trim()) .filter((line) => line.length > 0) .map((line) => JSON.parse(line) as Record) .filter((record) => record.launchId === launchId); } catch { return []; } } function buildLaunchId(): string { const random = Math.random().toString(36).slice(2, 10); return `websearch-${Date.now()}-${process.pid}-${random}`; } 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 ), }; } export function createWebSearchTraceContext(params: { launcher: string; args: string[]; cwd?: string; profile?: string; profileType?: string; settingsPath?: string; claudeConfigDir?: string; env?: NodeJS.ProcessEnv; }): Record { const env = params.env ?? process.env; if (!isWebSearchTraceEnabled(env)) { return {}; } const launchId = buildLaunchId(); const traceEnv: Record = { CCS_WEBSEARCH_TRACE: '1', CCS_WEBSEARCH_TRACE_LAUNCH_ID: launchId, CCS_WEBSEARCH_TRACE_LAUNCHER: params.launcher, }; const traceFileOverride = resolveAllowedWebSearchTraceFile(env); if (traceFileOverride) { traceEnv.CCS_WEBSEARCH_TRACE_FILE = traceFileOverride; } appendWebSearchTrace( 'ccs_websearch_launch', { launcher: params.launcher, profile: params.profile || null, profileType: params.profileType || null, cwd: params.cwd || null, settingsPath: params.settingsPath || null, claudeConfigDir: params.claudeConfigDir || null, ...summarizeLaunchArgs(params.args), }, { ...env, ...traceEnv, CCS_PROFILE_TYPE: params.profileType || env.CCS_PROFILE_TYPE || '', } ); return traceEnv; }