diff --git a/.changeset/fix-session-title-filename-redaction.md b/.changeset/fix-session-title-filename-redaction.md new file mode 100644 index 0000000000..a00428ec18 --- /dev/null +++ b/.changeset/fix-session-title-filename-redaction.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix session titles redacting long hyphenated or underscored file names as if they were secret tokens. diff --git a/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts b/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts index 631c7c5d34..69945000ea 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts @@ -39,7 +39,15 @@ export function promptMetadataTextFromText(text: string): string | undefined { '$1=[redacted]', ) .replaceAll(/\bsk-[A-Za-z0-9_-]{12,}\b/g, '[redacted]') - .replaceAll(/\b[A-Za-z0-9][A-Za-z0-9+/=_-]{39,}\b/g, '[redacted]') + .replaceAll( + /\b[A-Za-z0-9][A-Za-z0-9+/=_-]{39,}\b/g, + (match: string, offset: number, source: string) => { + const following = source.slice(offset + match.length); + return isFileNameStem(match, following) || isPathLike(match, offset, source) + ? match + : '[redacted]'; + }, + ) .replaceAll(/\p{Cc}+/gu, ' ') .replaceAll(/\s+/g, ' ') .trim(); @@ -48,6 +56,51 @@ export function promptMetadataTextFromText(text: string): string | undefined { return sanitized.slice(0, MAX_LAST_PROMPT_LENGTH); } +const SAFE_FILENAME_EXTENSIONS = new Set([ + 'md', 'markdown', 'txt', 'ts', 'tsx', 'mts', 'cts', 'js', 'jsx', 'mjs', 'cjs', + 'py', 'rb', 'go', 'rs', 'java', 'kt', 'swift', 'c', 'h', 'cc', 'cpp', 'hpp', + 'cs', 'css', 'scss', 'less', 'html', 'vue', 'svelte', 'php', 'sh', 'sql', + 'graphql', 'proto', 'lua', 'dart', +]); + +function isFileNameStem(stem: string, following: string): boolean { + if (!/^(?=.*[-_])[a-z0-9_-]+$/.test(stem)) return false; + return safeSuffixFollows(following); +} + +function safeSuffixFollows(following: string): boolean { + const suffix = /^((?:\.[A-Za-z0-9]{1,8})+)(?![.A-Za-z0-9+/=_-])/.exec(following)?.[1]; + if (suffix === undefined) return false; + const extension = suffix.slice(suffix.lastIndexOf('.') + 1); + return SAFE_FILENAME_EXTENSIONS.has(extension.toLowerCase()); +} + +function isPathLike(match: string, offset: number, source: string): boolean { + if (!match.includes('/')) return false; + const segments = match.split('/'); + const directories = segments.slice(0, -1); + const base = segments[segments.length - 1]; + if (!directories.every(isWordShapedSegment)) return false; + const following = source.slice(offset + match.length); + if (isFileNameStem(base, following)) return true; + if (base.length < 40 && /^[A-Za-z][A-Za-z0-9_-]*$/.test(base) && safeSuffixFollows(following)) { + return true; + } + if (!isWordShapedSegment(base)) return false; + if (segments.length < 3 || !segments.every((segment) => segment.length <= 24)) return false; + if (offset === 0 || source[offset - 1] !== '/') return false; + return PATH_ROOT_SEGMENTS.has(segments[0]) || source[offset - 2] === '~'; +} + +const PATH_ROOT_SEGMENTS = new Set([ + 'Users', 'Volumes', 'home', 'tmp', 'var', 'opt', 'usr', 'etc', 'root', 'mnt', + 'media', 'srv', +]); + +function isWordShapedSegment(segment: string): boolean { + return segment.length < 40 && /^([A-Z]?[a-z0-9_-]*|[A-Z0-9_-]+)$/.test(segment); +} + function promptPartText(part: ContentPart): string | undefined { switch (part.type) { case 'text': { diff --git a/packages/agent-core-v2/test/agent/rpc/prompt-metadata.test.ts b/packages/agent-core-v2/test/agent/rpc/prompt-metadata.test.ts index 7e1527c443..ef3cc9d2c5 100644 --- a/packages/agent-core-v2/test/agent/rpc/prompt-metadata.test.ts +++ b/packages/agent-core-v2/test/agent/rpc/prompt-metadata.test.ts @@ -7,6 +7,9 @@ * - an inline image-compression caption (harness metadata placed next to * the image by prompt ingestion) never leaks into titles/lastPrompt, * whether it is a standalone text part or merged into the user's text + * - sanitization keeps slug-shaped text/code file names readable while + * bare tokens, `sk-` keys (even with a file extension), git SHAs, and + * JWT segments stay redacted */ import { describe, expect, it } from 'vitest'; @@ -53,3 +56,81 @@ describe('promptMetadataTextFromPayload', () => { expect(text).not.toContain('Image compressed'); }); }); + +describe('prompt metadata sanitization', () => { + const sanitize = (text: string) => + promptMetadataTextFromPayload({ input: [{ type: 'text', text }] }); + + it('keeps slug-shaped stems of text/code files readable', () => { + expect(sanitize('帮我看看 refact-000-08-12-external-hooks-feature-scopes.ts 这个文件')).toBe( + '帮我看看 refact-000-08-12-external-hooks-feature-scopes.ts 这个文件', + ); + expect(sanitize('打开 src/refact-000-08-12-external-hooks-feature-scopes.md')).toBe( + '打开 src/refact-000-08-12-external-hooks-feature-scopes.md', + ); + expect(sanitize('跑下 refact-000-08-12-external-hooks-feature-scopes.test.ts')).toBe( + '跑下 refact-000-08-12-external-hooks-feature-scopes.test.ts', + ); + }); + + it('still redacts bare long tokens, sk- keys, and git SHAs', () => { + expect(sanitize(`token ${'A1b2'.repeat(13)}`)).toBe('token [redacted]'); + expect(sanitize('key sk-abcdefghijklmnop1234')).toBe('key [redacted]'); + expect(sanitize(`看下 commit ${'9f8e7d6c5b'.repeat(4)}`)).toBe('看下 commit [redacted]'); + }); + + it('redacts token-shaped strings even when a file extension follows', () => { + expect(sanitize('cat sk-abcdefghijklmnop1234.env')).toBe('cat [redacted].env'); + expect(sanitize(`cat ${'A1b2'.repeat(13)}.json`)).toBe('cat [redacted].json'); + expect(sanitize('检查 sk-project-notes-2024.md')).toBe('检查 [redacted].md'); + expect(sanitize('refact-000-08-12-external-hooks-feature-scopes.json')).toBe('[redacted].json'); + expect(sanitize('refact-000-08-12-external-hooks-feature-scopes.ts.json')).toBe( + '[redacted].ts.json', + ); + expect(sanitize(`${'A1b2'.repeat(10)}_.ts-${'Z9y8'.repeat(12)}`)).toBe('[redacted].[redacted]'); + expect(sanitize(`${'Ab1c'.repeat(10)}-.ts`)).toBe('[redacted]-.ts'); + expect(sanitize(`open ${'a'.repeat(44)}/refact-000-08-12-external-hooks-feature-scopes.ts`)).toBe( + 'open [redacted].ts', + ); + expect(sanitize(`https://example.com/${'Ab1c'.repeat(10)}_.ts?download=1`)).toBe( + 'https://example.[redacted].ts?download=1', + ); + }); + + it('keeps absolute paths readable but redacts token-looking segments', () => { + expect(sanitize('cd /Users/alice/Projects/kimi-code-workspace/')).toBe( + 'cd /Users/alice/Projects/kimi-code-workspace/', + ); + expect( + sanitize('看下 /Users/alice/Projects/kimi-code-workspace/kimi-code/packages/README.md'), + ).toBe('看下 /Users/alice/Projects/kimi-code-workspace/kimi-code/packages/README.md'); + expect( + sanitize( + '看下 /Users/alice/Projects/kimi-code-workspace/refact-000-08-12-external-hooks-feature-scopes.ts', + ), + ).toBe( + '看下 /Users/alice/Projects/kimi-code-workspace/refact-000-08-12-external-hooks-feature-scopes.ts', + ); + expect(sanitize('看下 packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts')).toBe( + '看下 packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts', + ); + expect(sanitize(`cat /tmp/${'Ab1c'.repeat(12)}`)).toBe('cat /[redacted]'); + expect(sanitize(`token ${'Ab1c'.repeat(10)}/${'Z9x8'.repeat(10)}`)).toBe('token [redacted]'); + expect(sanitize(`token ${'a'.repeat(32)}/${'b'.repeat(32)}`)).toBe('token [redacted]'); + expect(sanitize(`token ${'a'.repeat(20)}/${'b'.repeat(20)}/${'c'.repeat(20)}`)).toBe( + 'token [redacted]', + ); + expect(sanitize(`token /users/${'a'.repeat(20)}/${'b'.repeat(20)}/${'c'.repeat(20)}`)).toBe( + 'token /[redacted]', + ); + expect(sanitize('open ~/Projects/kimi-code-workspace/external-hooks-feature')).toBe( + 'open ~/Projects/kimi-code-workspace/external-hooks-feature', + ); + expect(sanitize(`secret /${'Ab1c'.repeat(8)}/${'Z9x8'.repeat(8)}`)).toBe('secret /[redacted]'); + }); + + it('still redacts JWT segments joined by dots', () => { + const jwt = `eyJhbGciOiJIUzI1NiJ9.${'a'.repeat(45)}.${'b'.repeat(43)}`; + expect(sanitize(`jwt ${jwt}`)).toBe('jwt eyJhbGciOiJIUzI1NiJ9.[redacted].[redacted]'); + }); +}); diff --git a/packages/agent-core/src/session/prompt-metadata.ts b/packages/agent-core/src/session/prompt-metadata.ts index c43c056bb5..c5f0a18407 100644 --- a/packages/agent-core/src/session/prompt-metadata.ts +++ b/packages/agent-core/src/session/prompt-metadata.ts @@ -69,7 +69,15 @@ function sanitizeAndTruncatePromptText(text: string, maxLength: number): string '$1=[redacted]', ) .replaceAll(/\bsk-[A-Za-z0-9_-]{12,}\b/g, '[redacted]') - .replaceAll(/\b[A-Za-z0-9][A-Za-z0-9+/=_-]{39,}\b/g, '[redacted]') + .replaceAll( + /\b[A-Za-z0-9][A-Za-z0-9+/=_-]{39,}\b/g, + (match: string, offset: number, source: string) => { + const following = source.slice(offset + match.length); + return isFileNameStem(match, following) || isPathLike(match, offset, source) + ? match + : '[redacted]'; + }, + ) .replaceAll(/\p{Cc}+/gu, ' ') .replaceAll(/\s+/g, ' ') .trim(); @@ -77,3 +85,80 @@ function sanitizeAndTruncatePromptText(text: string, maxLength: number): string if (sanitized.length === 0) return undefined; return sanitized.slice(0, maxLength); } + +// Extensions that mark a long token-shaped word as a human-named file stem. +// Secret-carrier formats (env/json/yaml/pem/key/...) are excluded on purpose: +// this sanitizer is a privacy boundary and fails closed. +const SAFE_FILENAME_EXTENSIONS = new Set([ + 'md', 'markdown', 'txt', 'ts', 'tsx', 'mts', 'cts', 'js', 'jsx', 'mjs', 'cjs', + 'py', 'rb', 'go', 'rs', 'java', 'kt', 'swift', 'c', 'h', 'cc', 'cpp', 'hpp', + 'cs', 'css', 'scss', 'less', 'html', 'vue', 'svelte', 'php', 'sh', 'sql', + 'graphql', 'proto', 'lua', 'dart', +]); + +// A long token-shaped word stays readable only as the stem of a human-named +// slug of a text/code file, e.g. +// `refact-000-08-12-external-hooks-feature-scopes.test.ts`. The stem must be +// all-lowercase slug characters (`a-z0-9_-`) with at least one `-`/`_` +// separator — machine-generated tokens are mixed-case with overwhelming +// probability and never qualify — and a safe extension must follow. +function isFileNameStem(stem: string, following: string): boolean { + if (!/^(?=.*[-_])[a-z0-9_-]+$/.test(stem)) return false; + return safeSuffixFollows(following); +} + +// A dotted suffix counts as a file extension only when its final component +// is a safe text/code extension; a dotted segment longer than 8 +// alphanumerics (e.g. a JWT segment) is not an extension, and anything that +// continues with another dot or token character after the suffix is not a +// file name either. +function safeSuffixFollows(following: string): boolean { + const suffix = /^((?:\.[A-Za-z0-9]{1,8})+)(?![.A-Za-z0-9+/=_-])/.exec(following)?.[1]; + if (suffix === undefined) return false; + const extension = suffix.slice(suffix.lastIndexOf('.') + 1); + return SAFE_FILENAME_EXTENSIONS.has(extension.toLowerCase()); +} + +// A long token-shaped word also stays readable as a path, absolute or +// relative, e.g. `/Users/.../refact-...-scopes.ts` or `src/.../README.md`. +// Every directory segment must stay below the 40-char token threshold and +// look like a human-named word (lowercase, Capitalized, or ALL-CAPS like +// `README`). The basename is more flexible: a long slug must pass the strict +// file-name rule above, but below the token threshold it cannot be a +// catch-all secret on its own, so normal code-file casing (camelCase / +// PascalCase / kebab / snake) with a safe suffix stays readable. An +// extensionless match needs stronger local-path context — rooted at a +// well-known filesystem root (or `~/`) with at least three segments, each no +// longer than a natural directory name — so slash-joined token material like +// `<20 lowercase chars>/<20 lowercase chars>/<20 lowercase chars>` fails +// closed, as do mixed-case random segments and token-length basenames (e.g. +// `/tmp/<48-char token>`). +function isPathLike(match: string, offset: number, source: string): boolean { + if (!match.includes('/')) return false; + const segments = match.split('/'); + const directories = segments.slice(0, -1); + const base = segments[segments.length - 1]; + if (!directories.every(isWordShapedSegment)) return false; + const following = source.slice(offset + match.length); + if (isFileNameStem(base, following)) return true; + if (base.length < 40 && /^[A-Za-z][A-Za-z0-9_-]*$/.test(base) && safeSuffixFollows(following)) { + return true; + } + if (!isWordShapedSegment(base)) return false; + if (segments.length < 3 || !segments.every((segment) => segment.length <= 24)) return false; + if (offset === 0 || source[offset - 1] !== '/') return false; + return PATH_ROOT_SEGMENTS.has(segments[0]) || source[offset - 2] === '~'; +} + +// Case-sensitive filesystem roots that anchor an extensionless absolute path. +// macOS roots keep their capital (`Users`, `Volumes`); lowercase lookalikes +// that double as API route segments (`/users/...`, `/data/...`) do not +// qualify, so API-shaped slash-delimited IDs fail closed. +const PATH_ROOT_SEGMENTS = new Set([ + 'Users', 'Volumes', 'home', 'tmp', 'var', 'opt', 'usr', 'etc', 'root', 'mnt', + 'media', 'srv', +]); + +function isWordShapedSegment(segment: string): boolean { + return segment.length < 40 && /^([A-Z]?[a-z0-9_-]*|[A-Z0-9_-]+)$/.test(segment); +} diff --git a/packages/agent-core/test/session/prompt-metadata.test.ts b/packages/agent-core/test/session/prompt-metadata.test.ts index 4af1ad01a6..539cc0a183 100644 --- a/packages/agent-core/test/session/prompt-metadata.test.ts +++ b/packages/agent-core/test/session/prompt-metadata.test.ts @@ -7,6 +7,9 @@ * - an inline image-compression caption (harness metadata placed next to * the image by prompt ingestion) never leaks into titles/lastPrompt, * whether it is a standalone text part or merged into the user's text + * - sanitization keeps slug-shaped text/code file names readable while + * bare tokens, `sk-` keys (even with a file extension), git SHAs, and + * JWT segments stay redacted * - SessionAPIImpl.steer updates title/lastPrompt exactly like prompt — * a steer can launch the session's first turn (e.g. goal mode) */ @@ -68,6 +71,84 @@ describe('promptMetadataTextFromPayload', () => { }); }); +describe('prompt metadata sanitization', () => { + const sanitize = (text: string) => + promptMetadataTextFromPayload({ input: [{ type: 'text', text }] }); + + it('keeps slug-shaped stems of text/code files readable', () => { + expect(sanitize('帮我看看 refact-000-08-12-external-hooks-feature-scopes.ts 这个文件')).toBe( + '帮我看看 refact-000-08-12-external-hooks-feature-scopes.ts 这个文件', + ); + expect(sanitize('打开 src/refact-000-08-12-external-hooks-feature-scopes.md')).toBe( + '打开 src/refact-000-08-12-external-hooks-feature-scopes.md', + ); + expect(sanitize('跑下 refact-000-08-12-external-hooks-feature-scopes.test.ts')).toBe( + '跑下 refact-000-08-12-external-hooks-feature-scopes.test.ts', + ); + }); + + it('still redacts bare long tokens, sk- keys, and git SHAs', () => { + expect(sanitize(`token ${'A1b2'.repeat(13)}`)).toBe('token [redacted]'); + expect(sanitize('key sk-abcdefghijklmnop1234')).toBe('key [redacted]'); + expect(sanitize(`看下 commit ${'9f8e7d6c5b'.repeat(4)}`)).toBe('看下 commit [redacted]'); + }); + + it('redacts token-shaped strings even when a file extension follows', () => { + expect(sanitize('cat sk-abcdefghijklmnop1234.env')).toBe('cat [redacted].env'); + expect(sanitize(`cat ${'A1b2'.repeat(13)}.json`)).toBe('cat [redacted].json'); + expect(sanitize('检查 sk-project-notes-2024.md')).toBe('检查 [redacted].md'); + expect(sanitize('refact-000-08-12-external-hooks-feature-scopes.json')).toBe('[redacted].json'); + expect(sanitize('refact-000-08-12-external-hooks-feature-scopes.ts.json')).toBe( + '[redacted].ts.json', + ); + expect(sanitize(`${'A1b2'.repeat(10)}_.ts-${'Z9y8'.repeat(12)}`)).toBe('[redacted].[redacted]'); + expect(sanitize(`${'Ab1c'.repeat(10)}-.ts`)).toBe('[redacted]-.ts'); + expect(sanitize(`open ${'a'.repeat(44)}/refact-000-08-12-external-hooks-feature-scopes.ts`)).toBe( + 'open [redacted].ts', + ); + expect(sanitize(`https://example.com/${'Ab1c'.repeat(10)}_.ts?download=1`)).toBe( + 'https://example.[redacted].ts?download=1', + ); + }); + + it('keeps absolute paths readable but redacts token-looking segments', () => { + expect(sanitize('cd /Users/alice/Projects/kimi-code-workspace/')).toBe( + 'cd /Users/alice/Projects/kimi-code-workspace/', + ); + expect( + sanitize('看下 /Users/alice/Projects/kimi-code-workspace/kimi-code/packages/README.md'), + ).toBe('看下 /Users/alice/Projects/kimi-code-workspace/kimi-code/packages/README.md'); + expect( + sanitize( + '看下 /Users/alice/Projects/kimi-code-workspace/refact-000-08-12-external-hooks-feature-scopes.ts', + ), + ).toBe( + '看下 /Users/alice/Projects/kimi-code-workspace/refact-000-08-12-external-hooks-feature-scopes.ts', + ); + expect(sanitize('看下 packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts')).toBe( + '看下 packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts', + ); + expect(sanitize(`cat /tmp/${'Ab1c'.repeat(12)}`)).toBe('cat /[redacted]'); + expect(sanitize(`token ${'Ab1c'.repeat(10)}/${'Z9x8'.repeat(10)}`)).toBe('token [redacted]'); + expect(sanitize(`token ${'a'.repeat(32)}/${'b'.repeat(32)}`)).toBe('token [redacted]'); + expect(sanitize(`token ${'a'.repeat(20)}/${'b'.repeat(20)}/${'c'.repeat(20)}`)).toBe( + 'token [redacted]', + ); + expect(sanitize(`token /users/${'a'.repeat(20)}/${'b'.repeat(20)}/${'c'.repeat(20)}`)).toBe( + 'token /[redacted]', + ); + expect(sanitize('open ~/Projects/kimi-code-workspace/external-hooks-feature')).toBe( + 'open ~/Projects/kimi-code-workspace/external-hooks-feature', + ); + expect(sanitize(`secret /${'Ab1c'.repeat(8)}/${'Z9x8'.repeat(8)}`)).toBe('secret /[redacted]'); + }); + + it('still redacts JWT segments joined by dots', () => { + const jwt = `eyJhbGciOiJIUzI1NiJ9.${'a'.repeat(45)}.${'b'.repeat(43)}`; + expect(sanitize(`jwt ${jwt}`)).toBe('jwt eyJhbGciOiJIUzI1NiJ9.[redacted].[redacted]'); + }); +}); + describe('SessionAPIImpl prompt metadata', () => { it('derives title and lastPrompt from a steer the same way as a prompt', async () => { const sessionDir = await makeTempDir();