diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectThreadChatSubscription.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectThreadChatSubscription.ts index 0f6d6dcee..1385de7d4 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectThreadChatSubscription.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectThreadChatSubscription.ts @@ -20,6 +20,10 @@ import { resolveDirectThreadBootstrap, selectDirectChatEntries, } from '../conversation/directThreadChat'; +import { + sanitizeDirectThreadEvents, + sanitizeDirectThreadItems, +} from '../conversation/directThreadSanitize'; import type { ConsumeResult } from '../generated/ConsumeResult'; import type { SubscriptionBootstrap } from '../generated/SubscriptionBootstrap'; import type { ThreadItem } from '../generated/ThreadItem'; @@ -117,7 +121,10 @@ export function useDirectThreadChatSubscription({ ); if (disposed) return; setState((current) => - applyDirectThreadConsumeResult(current, result), + applyDirectThreadConsumeResult(current, { + ...result, + events: sanitizeDirectThreadEvents(projectPath, result.events), + }), ); } while (consumeAgain && !disposed); } catch (error) { @@ -151,7 +158,12 @@ export function useDirectThreadChatSubscription({ } subscriptionId = result.subscriptionId; anchorGate.settle(result.lastCompletedItemId ?? null); - setState((current) => resolveDirectThreadBootstrap(current, result)); + setState((current) => + resolveDirectThreadBootstrap(current, { + ...result, + events: sanitizeDirectThreadEvents(projectPath, result.events), + }), + ); if (notifyBeforeSubscription) { notifyBeforeSubscription = false; void consume(); @@ -190,9 +202,14 @@ export function useDirectThreadChatSubscription({ const mergeHistoryItems = useMemo( () => (items: readonly ThreadItem[]) => { - setState((current) => mergeDirectHistoryItems(current, items)); + setState((current) => + mergeDirectHistoryItems( + current, + sanitizeDirectThreadItems(projectPath, items), + ), + ); }, - [], + [projectPath], ); const entries = useMemo(() => selectDirectChatEntries(state), [state]); diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadSanitize.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadSanitize.ts new file mode 100644 index 000000000..3f1495115 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadSanitize.ts @@ -0,0 +1,613 @@ +/** + * DirectProject 线程条目的前端脱敏。 + * + * Thread Manager 现在把宿主文本**原样**下发(不脱敏、不限长),密钥、绝对路径、凭据配置等敏感 + * 片段在这里统一遮挡:运行态事件、流式增量与历史切片在进入聊天状态之前先过一次本模块。 + * + * 为什么放在前端:脱敏是"给谁看"的问题,不是"搬运"的问题——Thread Manager 只归一身形与身份, + * 由离展示最近、手里又有 `projectPath` 的这一层决定什么能上屏。失败载荷(`turn.completed.failure` + * 的错误文案)的脱敏仍由 Rust 在投影时完成,不在这里重复。 + * + * 只遮挡、不截断:正文与明细都保持原文长度。 + * + * 占位符沿用宿主原来的词表,`features/agent-runtime/model.ts` 的中文显示映射不用改: + * `` / `[redacted-secret]` / `[redacted-sensitive-field]` / + * `[redacted-config]` / `[redacted sensitive context]`。 + */ + +import type { ThreadEvent } from '../generated/ThreadEvent'; +import type { ThreadItem } from '../generated/ThreadItem'; + +const ABSOLUTE_PATH_MARKER = ''; +const REDACTED_SECRET = '[redacted-secret]'; +const REDACTED_SENSITIVE_FIELD = '[redacted-sensitive-field]'; +const REDACTED_CONFIG = '[redacted-config]'; +const REDACTED_SENSITIVE_CONTEXT = '[redacted sensitive context]'; + +/** 有辨识前缀的凭据:前缀 + 最小 body 长度。 */ +const SECRET_PREFIXES: ReadonlyArray = [ + ['tnr_sk_', 8], + ['sk-', 8], + ['ghp_', 20], + ['gho_', 20], + ['ghu_', 20], + ['ghs_', 20], + ['ghr_', 20], + ['npm_', 20], + ['AKIA', 16], + ['ASIA', 16], + ['AIza', 20], + ['sk_live_', 16], + ['rk_live_', 16], + ['xoxb-', 16], + ['xoxp-', 16], + ['xoxa-', 16], + ['xoxr-', 16], +]; + +/** 出现这些开关的行整行换成敏感上下文占位符(值形态不可靠,只能整行遮挡)。 */ +const SENSITIVE_CLI_FLAGS = [ + '--password', + '--token', + '--secret', + '--api-key', + '--apikey', + '--authorization', + '--cookie', +]; + +/** 赋值形式的敏感键:只遮挡值,保留 HTTP 状态等同一行上的其它信息。 */ +const SENSITIVE_ASSIGNMENT_KEYS = [ + 'proxy-authorization', + 'authorization', + 'set-cookie', + 'cookie', + 'access-token', + 'access_token', + 'access token', + 'accesstoken', + 'refresh-token', + 'refresh_token', + 'refresh token', + 'refreshtoken', + 'oauth-token', + 'oauth_token', + 'oauth token', + 'id-token', + 'id_token', + 'id token', + 'auth-token', + 'auth_token', + 'auth token', + 'authtoken', + 'client-secret', + 'client_secret', + 'client secret', + 'clientsecret', + 'private-key', + 'private_key', + 'private key', + 'privatekey', + 'secret-key', + 'secret_key', + 'secret key', + 'secretkey', + 'x-api-key', + 'x_api_key', + 'api-key', + 'api_key', + 'api key', + 'apikey', + 'credentials', + 'credential', + 'password', + 'token', + 'secret', + 'bearer', +]; + +const REDACTION_MARKERS = [ + ABSOLUTE_PATH_MARKER, + REDACTED_SECRET, + REDACTED_SENSITIVE_FIELD, + REDACTED_CONFIG, + REDACTED_SENSITIVE_CONTEXT, + '', + '', +]; + +const PATH_BOUNDARY = new Set([ + ' ', + '\t', + '\n', + '\r', + '"', + "'", + '`', + '=', + '(', + '[', + '{', + '<', + ',', + ';', +]); + +const PATH_TOKEN_STOP = new Set(['"', "'", '`', ')', ']', '}', '>', ',', ';']); + +const RELATIVE_SEGMENT_STOP = new Set([ + "'", + '"', + '`', + ',', + ';', + '|', + '&', + '(', + ')', + '[', + ']', + '{', + '}', + '<', + '>', + ':', +]); + +/** 只小写 ASCII,和宿主口径一致:非 ASCII 路径名不因 locale 变化。 */ +function asciiLower(value: string): string { + return value.replace(/[A-Z]/g, (character) => character.toLowerCase()); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function matchIndices(value: string, needle: string): number[] { + const indices: number[] = []; + let from = 0; + for (;;) { + const at = value.indexOf(needle, from); + if (at < 0) break; + indices.push(at); + from = at + needle.length; + } + return indices; +} + +function isRedactionMarker(value: string): boolean { + return REDACTION_MARKERS.some((marker) => value.startsWith(marker)); +} + +/** 项目根之后的路径段:分隔符统一成 `/`,遇空白 / 标点停下。 */ +function projectRelativePathSegment( + value: string, + start: number, +): { end: number; relative: string } { + let index = start; + let relative = ''; + while (index < value.length) { + const character = value[index]!; + if (character === '/' || character === '\\') { + if (relative) relative += '/'; + index += 1; + continue; + } + if (/\s/.test(character) || RELATIVE_SEGMENT_STOP.has(character)) break; + relative += character; + index += 1; + } + while (relative.endsWith('/')) relative = relative.slice(0, -1); + return { end: index, relative }; +} + +/** `/game/src/x.ts` → `game/src/x.ts`;只剩项目根本身时按 `` 处理。 */ +function relativizeProjectRootPaths( + projectRoot: string, + value: string, +): string { + const trimmed = projectRoot.replace(/[\\/]+$/, ''); + if (!trimmed) return value; + const needles = [ + ...new Set( + [trimmed, trimmed.replace(/\\/g, '/'), trimmed.replace(/\//g, '\\')] + .map(asciiLower) + .filter((needle) => needle.length > 0), + ), + ].sort(); + if (needles.length === 0) return value; + const lower = asciiLower(value); + + let output = ''; + let cursor = 0; + while (cursor < value.length) { + let hit: { start: number; end: number } | null = null; + for (const needle of needles) { + let search = cursor; + for (;;) { + const found = lower.indexOf(needle, search); + if (found < 0) break; + const start = found; + const end = start + needle.length; + const leftBoundary = + start === 0 || !/[\p{L}\p{N}_-]/u.test(lower[start - 1]!); + const followsSeparator = value[end] === '/' || value[end] === '\\'; + if (leftBoundary && followsSeparator) { + if (hit === null || start < hit.start) hit = { start, end }; + break; + } + search = end; + } + } + if (hit === null) break; + output += value.slice(cursor, hit.start); + const { end, relative } = projectRelativePathSegment(value, hit.end); + output += relative || ABSOLUTE_PATH_MARKER; + cursor = end; + } + return output + value.slice(cursor); +} + +function isPathBoundary(character: string): boolean { + return /\s/.test(character) || PATH_BOUNDARY.has(character); +} + +function consumePathToken(value: string, start: number): number { + let index = start; + while (index < value.length) { + const character = value[index]!; + if (/\s/.test(character) || PATH_TOKEN_STOP.has(character)) break; + index += 1; + } + return index; +} + +function startsForwardSlashUncPath(value: string, index: number): boolean { + const serverStart = index + 2; + const tokenEnd = consumePathToken(value, serverStart); + const slashOffset = value.slice(serverStart, tokenEnd).indexOf('/'); + if (slashOffset < 0) return false; + const serverEnd = serverStart + slashOffset; + const shareStart = serverEnd + 1; + return ( + serverEnd > serverStart && + shareStart < tokenEnd && + value[shareStart] !== '/' + ); +} + +function startsAbsolutePath(value: string, index: number): boolean { + if (index !== 0 && !isPathBoundary(value[index - 1]!)) return false; + const character = value[index]!; + if (character === '/') { + return value[index + 1] !== '/' || startsForwardSlashUncPath(value, index); + } + if (character === '\\' && value[index + 1] === '\\') return true; + return ( + /[A-Za-z]/.test(character) && + value[index + 1] === ':' && + (value[index + 2] === '/' || value[index + 2] === '\\') + ); +} + +function startsFileUri(value: string, index: number): boolean { + if (index !== 0 && !isPathBoundary(value[index - 1]!)) return false; + const prefix = 'file:'; + if (asciiLower(value.slice(index, index + prefix.length)) !== prefix) { + return false; + } + const prefixEnd = index + prefix.length; + const tokenEnd = consumePathToken(value, prefixEnd); + return ( + value[prefixEnd] === '/' && + tokenEnd > prefixEnd && + value.slice(prefixEnd, tokenEnd) !== '//' + ); +} + +function redactAbsolutePaths(value: string): string { + let output = ''; + let index = 0; + while (index < value.length) { + if (startsAbsolutePath(value, index) || startsFileUri(value, index)) { + output += ABSOLUTE_PATH_MARKER; + index = consumePathToken(value, index); + continue; + } + const character = value[index]!; + const code = character.codePointAt(0) ?? 0; + const isControl = code < 0x20 || code === 0x7f; + if ( + isControl && + character !== '\n' && + character !== '\r' && + character !== '\t' + ) { + output += ' '; + } else { + output += character; + } + index += character.length; + } + return output; +} + +function secretTokenEndWithMinimum( + value: string, + index: number, + prefix: string, + minimumBodyLength: number, +): number | null { + if (!prefix) return null; + const startsAtBoundary = + index === 0 || !/[0-9A-Za-z_]/.test(value[index - 1]!); + if (!startsAtBoundary) return null; + const bodyStart = index + prefix.length; + let bodyLength = 0; + let cursor = bodyStart; + let tokenEnd = bodyStart; + while (cursor < value.length) { + const character = value[cursor]!; + if (!/[0-9A-Za-z\-_]/.test(character)) break; + bodyLength += 1; + cursor += 1; + tokenEnd = cursor; + } + return bodyLength >= minimumBodyLength ? tokenEnd : null; +} + +function jwtTokenEnd(value: string, index: number): number | null { + const startsAtBoundary = + index === 0 || !/[0-9A-Za-z_]/.test(value[index - 1]!); + if (!startsAtBoundary) return null; + let cursor = index; + for (let segmentIndex = 0; segmentIndex < 3; segmentIndex += 1) { + const segmentStart = cursor; + while (cursor < value.length && /[0-9A-Za-z\-_]/.test(value[cursor]!)) { + cursor += 1; + } + const minimumSegmentLength = segmentIndex === 2 ? 8 : 3; + if (cursor - segmentStart < minimumSegmentLength) return null; + if (segmentIndex < 2) { + if (value[cursor] !== '.') return null; + cursor += 1; + } + } + const endsAtBoundary = + cursor >= value.length || !/[0-9A-Za-z\-_.]/.test(value[cursor]!); + return endsAtBoundary && cursor - index >= 32 ? cursor : null; +} + +function redactSecretTokens(value: string): string { + const spans: Array<[number, number]> = []; + for (const [prefix, minimumBodyLength] of SECRET_PREFIXES) { + for (const at of matchIndices(value, prefix)) { + const end = secretTokenEndWithMinimum( + value, + at, + prefix, + minimumBodyLength, + ); + if (end !== null) spans.push([at, end]); + } + } + for (const at of matchIndices(value, 'eyJ')) { + const end = jwtTokenEnd(value, at); + if (end !== null) spans.push([at, end]); + } + if (spans.length === 0) return value; + spans.sort((left, right) => left[0] - right[0] || left[1] - right[1]); + let output = ''; + let cursor = 0; + for (const [start, end] of spans) { + if (start < cursor) continue; + output += value.slice(cursor, start); + output += REDACTED_SECRET; + cursor = end; + } + return output + value.slice(cursor); +} + +function containsSensitiveCliFlag(lower: string): boolean { + return lower.split(/\s+/).some((rawToken) => { + const token = rawToken + .replace(/^[,;,;()[\]{}]+/, '') + .replace(/[,;,;()[\]{}]+$/, ''); + return SENSITIVE_CLI_FLAGS.some( + (flag) => token === flag || token.startsWith(`${flag}=`), + ); + }); +} + +const ASSIGNMENT_KEY_PATTERN = [...SENSITIVE_ASSIGNMENT_KEYS] + .sort((left, right) => right.length - left.length) + .map(escapeRegExp) + .join('|'); + +// 键名可被引号 / 转义引号包裹;值可以是引号字符串(含 JSON 转义)、占位符或裸 token。 +const SENSITIVE_ASSIGNMENT = new RegExp( + `(^|[^0-9A-Za-z])((?:\\\\?["'])?)((?:${ASSIGNMENT_KEY_PATTERN}))((?:\\\\?["'])?)(\\s*[:=]>?\\s*)((?:\\\\?["'][^"']*\\\\?["'])|(?:<[^>]*>)|(?:\\[[^\\]]*\\])|(?:[Bb]earer\\s+[^\\s,;,;&\\]})<>]+)|(?:[^\\s,;,;&\\]})<>]+))`, + 'gi', +); + +const BEARER_VALUE = + /(\bbearer\b)(\s+)((?:\\.?["'][^"']*\\.?["'])|(?:<[^>]*>)|(?:\[[^\]]*\])|(?:[^\s,;,;&\]})<>]+))/gi; + +const CONFIG_NAMES = /(?:\.env|game-creator\.config)[^\s'"`,;::&\]})<>]*/gi; + +function redactErrorAssignments(line: string): string { + return line.replace( + SENSITIVE_ASSIGNMENT, + ( + _match: string, + left: string, + openQuote: string, + _key: string, + closeQuote: string, + separator: string, + value: string, + ) => + `${left}${openQuote}${REDACTED_SENSITIVE_FIELD}${closeQuote}${separator}${ + isRedactionMarker(value) ? value : REDACTED_SECRET + }`, + ); +} + +function redactBearerValues(line: string): string { + return line.replace( + BEARER_VALUE, + (_match: string, bearer: string, whitespace: string, value: string) => + `${bearer}${whitespace}${isRedactionMarker(value) ? value : REDACTED_SECRET}`, + ); +} + +function redactConfigNames(line: string): string { + return line.replace(CONFIG_NAMES, REDACTED_CONFIG); +} + +/** 逐行遮挡:私钥块整块吞掉,敏感开关整行遮挡,其余只遮挡赋值 / Bearer / 配置名。 */ +function sanitizeSensitiveLines(value: string): string { + let output = ''; + let insidePrivateKey = false; + let start = 0; + while (start < value.length) { + const newlineAt = value.indexOf('\n', start); + const terminated = newlineAt >= 0; + let line = terminated ? value.slice(start, newlineAt) : value.slice(start); + start = terminated ? newlineAt + 1 : value.length; + if (line.endsWith('\r')) line = line.slice(0, -1); + const lower = asciiLower(line); + if (containsSensitiveCliFlag(lower)) { + output += REDACTED_SENSITIVE_CONTEXT; + } else if (insidePrivateKey) { + if (lower.includes('-----end') && lower.includes('private key')) { + insidePrivateKey = false; + } + continue; + } else if (lower.includes('-----begin') && lower.includes('private key')) { + output += REDACTED_SENSITIVE_CONTEXT; + insidePrivateKey = !( + lower.includes('-----end') && lower.includes('private key') + ); + } else { + const redacted = redactConfigNames( + redactBearerValues(redactErrorAssignments(redactSecretTokens(line))), + ); + output += redactSecretTokens(redacted); + } + if (terminated) output += '\n'; + } + return output; +} + +/** 一段宿主文本的前端脱敏入口:项目内路径归一 → 绝对路径 → 密钥 → 逐行敏感上下文。 */ +export function sanitizeDirectThreadText( + projectRoot: string | null | undefined, + value: string, +): string { + if (!value) return value; + const withoutProjectRoot = relativizeProjectRootPaths( + projectRoot ?? '', + value, + ); + const withoutAbsolutePaths = redactAbsolutePaths(withoutProjectRoot); + const withoutSecrets = redactSecretTokens(withoutAbsolutePaths); + return sanitizeSensitiveLines(withoutSecrets); +} + +/** 条目投影的脱敏:只改自由文本字段,身份与时间原样保留。 */ +export function sanitizeDirectThreadItem( + projectRoot: string | null | undefined, + item: ThreadItem, +): ThreadItem { + const text = (value: string) => sanitizeDirectThreadText(projectRoot, value); + const optionalText = (value: string | null) => + value === null ? null : text(value); + switch (item.itemType) { + case 'message': + return { ...item, text: text(item.text) }; + case 'reasoning': + return { ...item, text: text(item.text) }; + case 'function_call': + return { + ...item, + name: text(item.name), + arguments: text(item.arguments), + }; + case 'function_call_output': + return { ...item, output: text(item.output) }; + case 'commandExecution': + return { + ...item, + command: text(item.command), + output: optionalText(item.output), + status: optionalText(item.status), + }; + case 'fileChange': + return { + ...item, + changes: item.changes.map((change) => ({ + path: text(change.path), + kind: text(change.kind), + })), + }; + case 'mcpToolCall': + return { + ...item, + tool: text(item.tool), + arguments: text(item.arguments), + output: optionalText(item.output), + status: optionalText(item.status), + }; + case 'webSearch': + return { + ...item, + query: optionalText(item.query), + output: optionalText(item.output), + }; + case 'contextCompaction': + return item; + case 'other': + return { ...item, rawType: text(item.rawType) }; + } +} + +/** 事件投影的脱敏:条目事件与流式增量是唯一的宿主文本载体。 */ +export function sanitizeDirectThreadEvent( + projectRoot: string | null | undefined, + event: ThreadEvent, +): ThreadEvent { + switch (event.type) { + case 'item.started': + return { + ...event, + item: sanitizeDirectThreadItem(projectRoot, event.item), + }; + case 'item.completed': + return { + ...event, + item: sanitizeDirectThreadItem(projectRoot, event.item), + }; + case 'item.delta': + return { + ...event, + delta: sanitizeDirectThreadText(projectRoot, event.delta), + }; + default: + return event; + } +} + +export function sanitizeDirectThreadEvents( + projectRoot: string | null | undefined, + events: readonly ThreadEvent[], +): ThreadEvent[] { + return events.map((event) => sanitizeDirectThreadEvent(projectRoot, event)); +} + +export function sanitizeDirectThreadItems( + projectRoot: string | null | undefined, + items: readonly ThreadItem[], +): ThreadItem[] { + return items.map((item) => sanitizeDirectThreadItem(projectRoot, item)); +} diff --git a/apps/ai-game-creator-shell/tests/directThreadSanitize.test.ts b/apps/ai-game-creator-shell/tests/directThreadSanitize.test.ts new file mode 100644 index 000000000..2a264aa9f --- /dev/null +++ b/apps/ai-game-creator-shell/tests/directThreadSanitize.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest'; + +import { + sanitizeDirectThreadEvent, + sanitizeDirectThreadText, +} from '../src/view/project-development/chat/conversation/directThreadSanitize'; +import type { ThreadEvent } from '../src/view/project-development/chat/generated/ThreadEvent'; + +const ROOT = '/workspace/direct-project'; + +describe('directThreadSanitize', () => { + it('项目内绝对路径归一成项目相对路径', () => { + expect( + sanitizeDirectThreadText( + ROOT, + 'open /workspace/direct-project/game/src/x.ts', + ), + ).toBe('open game/src/x.ts'); + }); + + it('项目外绝对路径与 file:// 换成占位符', () => { + expect(sanitizeDirectThreadText(ROOT, 'at /root/secret/x')).toBe( + 'at ', + ); + expect(sanitizeDirectThreadText(ROOT, 'file:///etc/passwd')).toBe( + '', + ); + expect(sanitizeDirectThreadText(ROOT, 'win C:\\Users\\me\\x.txt')).toBe( + 'win ', + ); + }); + + it('有前缀的密钥与 JWT 换成占位符', () => { + const jwt = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcdefgh'; + expect(sanitizeDirectThreadText(ROOT, 'key=sk-abcdefghijklmnop')).toBe( + 'key=[redacted-secret]', + ); + expect(sanitizeDirectThreadText(ROOT, `token ${jwt}`)).toBe( + 'token [redacted-secret]', + ); + }); + + it('敏感赋值只遮挡值,保留同行其它信息', () => { + expect(sanitizeDirectThreadText(ROOT, 'password=hunter2 status=401')).toBe( + '[redacted-sensitive-field]=[redacted-secret] status=401', + ); + expect(sanitizeDirectThreadText(ROOT, '"token": "raw-value"')).toBe( + '"[redacted-sensitive-field]": [redacted-secret]', + ); + }); + + it('Bearer 值换成占位符', () => { + expect(sanitizeDirectThreadText(ROOT, 'Authorization: Bearer abc123')).toBe( + '[redacted-sensitive-field]: [redacted-secret]', + ); + expect(sanitizeDirectThreadText(ROOT, 'h x Bearer abc123 y')).toBe( + 'h x Bearer [redacted-secret] y', + ); + }); + + it('.env / game-creator.config 换成配置占位符', () => { + expect(sanitizeDirectThreadText(ROOT, 'cat .env.local')).toBe( + 'cat [redacted-config]', + ); + }); + + it('私钥块整块吞掉', () => { + const pem = [ + '-----BEGIN PRIVATE KEY-----', + 'MIIEvQIBADANBg', + '-----END PRIVATE KEY-----', + '', + ].join('\n'); + expect(sanitizeDirectThreadText(ROOT, pem)).toBe( + '[redacted sensitive context]\n', + ); + }); + + it('敏感开关整行遮挡', () => { + expect( + sanitizeDirectThreadText(ROOT, 'curl --token abc123 https://x'), + ).toBe('[redacted sensitive context]'); + }); + + it('已有占位符不再二次变形', () => { + expect(sanitizeDirectThreadText(ROOT, 'at ')).toBe( + 'at ', + ); + }); + + it('多行文本保留换行', () => { + const value = '第一段\nkey=sk-abcdefghijklmnop\n第三段\n'; + expect(sanitizeDirectThreadText(ROOT, value)).toBe( + '第一段\nkey=[redacted-secret]\n第三段\n', + ); + }); + + it('条目事件只改自由文本字段,身份与时间原样', () => { + const event: ThreadEvent = { + type: 'item.completed', + item: { + itemType: 'message', + itemId: 'msg-1', + role: 'assistant', + text: 'key=sk-abcdefghijklmnop at /root/secret/x', + at: 1_700_000_000_000, + }, + at: 1_700_000_000_100, + }; + expect(sanitizeDirectThreadEvent(ROOT, event)).toEqual({ + type: 'item.completed', + item: { + itemType: 'message', + itemId: 'msg-1', + role: 'assistant', + text: 'key=[redacted-secret] at ', + at: 1_700_000_000_000, + }, + at: 1_700_000_000_100, + }); + }); + + it('流式增量事件遮挡正文', () => { + const event: ThreadEvent = { + type: 'item.delta', + itemId: 'msg-1', + kind: 'message', + delta: 'see /root/secret/x', + }; + expect(sanitizeDirectThreadEvent(ROOT, event)).toEqual({ + type: 'item.delta', + itemId: 'msg-1', + kind: 'message', + delta: 'see ', + }); + }); +});