From 6ef1f84adc569614738b02e32d7f5b17ce777740 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Tue, 11 Aug 2026 15:47:34 +0800 Subject: [PATCH 01/13] fix(agent-core): keep dotted file names readable in session titles --- .../fix-session-title-filename-redaction.md | 5 ++++ .../src/agent/prompt/promptMetadataText.ts | 10 +++++-- .../test/agent/rpc/prompt-metadata.test.ts | 26 +++++++++++++++++++ .../agent-core/src/session/prompt-metadata.ts | 14 ++++++++-- .../test/session/prompt-metadata.test.ts | 26 +++++++++++++++++++ 5 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 .changeset/fix-session-title-filename-redaction.md 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..f7453d677e 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts @@ -38,8 +38,14 @@ export function promptMetadataTextFromText(text: string): string | undefined { /\b(api[_-]?key|token|secret|password|passwd|pwd)\b\s*[:=]\s*(?:"[^"]*"|'[^']*'|\S+)/gi, '$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( + /\bsk-[A-Za-z0-9_-]{12,}\b(?![A-Za-z0-9_-]*\.[A-Za-z0-9]{1,8}(?![A-Za-z0-9_]))/g, + '[redacted]', + ) + .replaceAll( + /\b[A-Za-z0-9][A-Za-z0-9+/=_-]{39,}\b(?![A-Za-z0-9+/=_-]*\.[A-Za-z0-9]{1,8}(?![A-Za-z0-9_]))/g, + '[redacted]', + ) .replaceAll(/\p{Cc}+/gu, ' ') .replaceAll(/\s+/g, ' ') .trim(); 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..7520753e20 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 dotted filenames (even long kebab-case or `sk-` + * prefixed ones) readable while bare tokens, `sk-` keys, git SHAs, and + * JWT segments stay redacted */ import { describe, expect, it } from 'vitest'; @@ -53,3 +56,26 @@ describe('promptMetadataTextFromPayload', () => { expect(text).not.toContain('Image compressed'); }); }); + +describe('prompt metadata sanitization', () => { + const sanitize = (text: string) => + promptMetadataTextFromPayload({ input: [{ type: 'text', text }] }); + + it('keeps dotted filenames, even long kebab-case or sk- prefixed ones', () => { + expect(sanitize('帮我看看 refact-000-08-12-external-hooks-feature-scopes.ts 这个文件')).toBe( + '帮我看看 refact-000-08-12-external-hooks-feature-scopes.ts 这个文件', + ); + expect(sanitize('检查 sk-project-notes-2024.md')).toBe('检查 sk-project-notes-2024.md'); + }); + + 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('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..231c195cdc 100644 --- a/packages/agent-core/src/session/prompt-metadata.ts +++ b/packages/agent-core/src/session/prompt-metadata.ts @@ -68,8 +68,18 @@ function sanitizeAndTruncatePromptText(text: string, maxLength: number): string /\b(api[_-]?key|token|secret|password|passwd|pwd)\b\s*[:=]\s*(?:"[^"]*"|'[^']*'|\S+)/gi, '$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]') + // The negative lookaheads keep stems of dotted filenames (e.g. + // `refact-000-08-12-external-hooks-feature-scopes.ts`) out of the token + // patterns; a dot followed by >8 alphanumerics (e.g. a JWT segment + // boundary) does not count as an extension and stays redacted. + .replaceAll( + /\bsk-[A-Za-z0-9_-]{12,}\b(?![A-Za-z0-9_-]*\.[A-Za-z0-9]{1,8}(?![A-Za-z0-9_]))/g, + '[redacted]', + ) + .replaceAll( + /\b[A-Za-z0-9][A-Za-z0-9+/=_-]{39,}\b(?![A-Za-z0-9+/=_-]*\.[A-Za-z0-9]{1,8}(?![A-Za-z0-9_]))/g, + '[redacted]', + ) .replaceAll(/\p{Cc}+/gu, ' ') .replaceAll(/\s+/g, ' ') .trim(); diff --git a/packages/agent-core/test/session/prompt-metadata.test.ts b/packages/agent-core/test/session/prompt-metadata.test.ts index 4af1ad01a6..907db6ba57 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 dotted filenames (even long kebab-case or `sk-` + * prefixed ones) readable while bare tokens, `sk-` keys, 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,29 @@ describe('promptMetadataTextFromPayload', () => { }); }); +describe('prompt metadata sanitization', () => { + const sanitize = (text: string) => + promptMetadataTextFromPayload({ input: [{ type: 'text', text }] }); + + it('keeps dotted filenames, even long kebab-case or sk- prefixed ones', () => { + expect(sanitize('帮我看看 refact-000-08-12-external-hooks-feature-scopes.ts 这个文件')).toBe( + '帮我看看 refact-000-08-12-external-hooks-feature-scopes.ts 这个文件', + ); + expect(sanitize('检查 sk-project-notes-2024.md')).toBe('检查 sk-project-notes-2024.md'); + }); + + 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('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(); From 9454699d0ee7358d5747215bdf90dc3e561b9d32 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Thu, 13 Aug 2026 10:08:48 +0800 Subject: [PATCH 02/13] fix(agent-core): fail closed on token-shaped strings with file extensions --- .../src/agent/prompt/promptMetadataText.ts | 23 +++++++++---- .../test/agent/rpc/prompt-metadata.test.ts | 17 +++++++--- .../agent-core/src/session/prompt-metadata.ts | 34 +++++++++++++------ .../test/session/prompt-metadata.test.ts | 17 +++++++--- 4 files changed, 67 insertions(+), 24 deletions(-) diff --git a/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts b/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts index f7453d677e..e9843d6743 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts @@ -38,13 +38,11 @@ export function promptMetadataTextFromText(text: string): string | undefined { /\b(api[_-]?key|token|secret|password|passwd|pwd)\b\s*[:=]\s*(?:"[^"]*"|'[^']*'|\S+)/gi, '$1=[redacted]', ) + .replaceAll(/\bsk-[A-Za-z0-9_-]{12,}\b/g, '[redacted]') .replaceAll( - /\bsk-[A-Za-z0-9_-]{12,}\b(?![A-Za-z0-9_-]*\.[A-Za-z0-9]{1,8}(?![A-Za-z0-9_]))/g, - '[redacted]', - ) - .replaceAll( - /\b[A-Za-z0-9][A-Za-z0-9+/=_-]{39,}\b(?![A-Za-z0-9+/=_-]*\.[A-Za-z0-9]{1,8}(?![A-Za-z0-9_]))/g, - '[redacted]', + /\b[A-Za-z0-9][A-Za-z0-9+/=_-]{39,}\b/g, + (match: string, offset: number, source: string) => + isFileNameStem(match, source.slice(offset + match.length)) ? match : '[redacted]', ) .replaceAll(/\p{Cc}+/gu, ' ') .replaceAll(/\s+/g, ' ') @@ -54,6 +52,19 @@ 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 (!/[-_]/.test(stem)) return false; + const extension = /^\.([A-Za-z0-9]{1,8})(?![A-Za-z0-9_])/.exec(following)?.[1]; + return extension !== undefined && SAFE_FILENAME_EXTENSIONS.has(extension.toLowerCase()); +} + 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 7520753e20..e36abc3ceb 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,8 +7,8 @@ * - 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 dotted filenames (even long kebab-case or `sk-` - * prefixed ones) readable while bare tokens, `sk-` keys, git SHAs, and + * - 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 */ @@ -61,11 +61,13 @@ describe('prompt metadata sanitization', () => { const sanitize = (text: string) => promptMetadataTextFromPayload({ input: [{ type: 'text', text }] }); - it('keeps dotted filenames, even long kebab-case or sk- prefixed ones', () => { + 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('检查 sk-project-notes-2024.md')).toBe('检查 sk-project-notes-2024.md'); + expect(sanitize('打开 src/refact-000-08-12-external-hooks-feature-scopes.md')).toBe( + '打开 src/refact-000-08-12-external-hooks-feature-scopes.md', + ); }); it('still redacts bare long tokens, sk- keys, and git SHAs', () => { @@ -74,6 +76,13 @@ describe('prompt metadata sanitization', () => { 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'); + }); + 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 231c195cdc..ce86f8372a 100644 --- a/packages/agent-core/src/session/prompt-metadata.ts +++ b/packages/agent-core/src/session/prompt-metadata.ts @@ -68,17 +68,11 @@ function sanitizeAndTruncatePromptText(text: string, maxLength: number): string /\b(api[_-]?key|token|secret|password|passwd|pwd)\b\s*[:=]\s*(?:"[^"]*"|'[^']*'|\S+)/gi, '$1=[redacted]', ) - // The negative lookaheads keep stems of dotted filenames (e.g. - // `refact-000-08-12-external-hooks-feature-scopes.ts`) out of the token - // patterns; a dot followed by >8 alphanumerics (e.g. a JWT segment - // boundary) does not count as an extension and stays redacted. + .replaceAll(/\bsk-[A-Za-z0-9_-]{12,}\b/g, '[redacted]') .replaceAll( - /\bsk-[A-Za-z0-9_-]{12,}\b(?![A-Za-z0-9_-]*\.[A-Za-z0-9]{1,8}(?![A-Za-z0-9_]))/g, - '[redacted]', - ) - .replaceAll( - /\b[A-Za-z0-9][A-Za-z0-9+/=_-]{39,}\b(?![A-Za-z0-9+/=_-]*\.[A-Za-z0-9]{1,8}(?![A-Za-z0-9_]))/g, - '[redacted]', + /\b[A-Za-z0-9][A-Za-z0-9+/=_-]{39,}\b/g, + (match: string, offset: number, source: string) => + isFileNameStem(match, source.slice(offset + match.length)) ? match : '[redacted]', ) .replaceAll(/\p{Cc}+/gu, ' ') .replaceAll(/\s+/g, ' ') @@ -87,3 +81,23 @@ 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 (contains `-`/`_`) of a text/code file, e.g. +// `refact-000-08-12-external-hooks-feature-scopes.ts`. A dot followed by more +// than 8 alphanumerics (e.g. a JWT segment boundary) is not an extension. +function isFileNameStem(stem: string, following: string): boolean { + if (!/[-_]/.test(stem)) return false; + const extension = /^\.([A-Za-z0-9]{1,8})(?![A-Za-z0-9_])/.exec(following)?.[1]; + return extension !== undefined && SAFE_FILENAME_EXTENSIONS.has(extension.toLowerCase()); +} diff --git a/packages/agent-core/test/session/prompt-metadata.test.ts b/packages/agent-core/test/session/prompt-metadata.test.ts index 907db6ba57..ead1c70bb8 100644 --- a/packages/agent-core/test/session/prompt-metadata.test.ts +++ b/packages/agent-core/test/session/prompt-metadata.test.ts @@ -7,8 +7,8 @@ * - 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 dotted filenames (even long kebab-case or `sk-` - * prefixed ones) readable while bare tokens, `sk-` keys, git SHAs, and + * - 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) @@ -75,11 +75,13 @@ describe('prompt metadata sanitization', () => { const sanitize = (text: string) => promptMetadataTextFromPayload({ input: [{ type: 'text', text }] }); - it('keeps dotted filenames, even long kebab-case or sk- prefixed ones', () => { + 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('检查 sk-project-notes-2024.md')).toBe('检查 sk-project-notes-2024.md'); + expect(sanitize('打开 src/refact-000-08-12-external-hooks-feature-scopes.md')).toBe( + '打开 src/refact-000-08-12-external-hooks-feature-scopes.md', + ); }); it('still redacts bare long tokens, sk- keys, and git SHAs', () => { @@ -88,6 +90,13 @@ describe('prompt metadata sanitization', () => { 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'); + }); + 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]'); From 40d2097b61f920c282476fbaab9931bced4bc6ec Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Thu, 13 Aug 2026 10:20:50 +0800 Subject: [PATCH 03/13] fix(agent-core): require a real filename boundary after the extension --- packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts | 2 +- packages/agent-core-v2/test/agent/rpc/prompt-metadata.test.ts | 1 + packages/agent-core/src/session/prompt-metadata.ts | 2 +- packages/agent-core/test/session/prompt-metadata.test.ts | 1 + 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts b/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts index e9843d6743..e7428ca957 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts @@ -61,7 +61,7 @@ const SAFE_FILENAME_EXTENSIONS = new Set([ function isFileNameStem(stem: string, following: string): boolean { if (!/[-_]/.test(stem)) return false; - const extension = /^\.([A-Za-z0-9]{1,8})(?![A-Za-z0-9_])/.exec(following)?.[1]; + const extension = /^\.([A-Za-z0-9]{1,8})(?![A-Za-z0-9+/=_-])/.exec(following)?.[1]; return extension !== undefined && SAFE_FILENAME_EXTENSIONS.has(extension.toLowerCase()); } 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 e36abc3ceb..7e51cb37c6 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 @@ -81,6 +81,7 @@ describe('prompt metadata sanitization', () => { 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(`${'A1b2'.repeat(10)}_.ts-${'Z9y8'.repeat(12)}`)).toBe('[redacted].[redacted]'); }); it('still redacts JWT segments joined by dots', () => { diff --git a/packages/agent-core/src/session/prompt-metadata.ts b/packages/agent-core/src/session/prompt-metadata.ts index ce86f8372a..403d724935 100644 --- a/packages/agent-core/src/session/prompt-metadata.ts +++ b/packages/agent-core/src/session/prompt-metadata.ts @@ -98,6 +98,6 @@ const SAFE_FILENAME_EXTENSIONS = new Set([ // than 8 alphanumerics (e.g. a JWT segment boundary) is not an extension. function isFileNameStem(stem: string, following: string): boolean { if (!/[-_]/.test(stem)) return false; - const extension = /^\.([A-Za-z0-9]{1,8})(?![A-Za-z0-9_])/.exec(following)?.[1]; + const extension = /^\.([A-Za-z0-9]{1,8})(?![A-Za-z0-9+/=_-])/.exec(following)?.[1]; return extension !== undefined && SAFE_FILENAME_EXTENSIONS.has(extension.toLowerCase()); } diff --git a/packages/agent-core/test/session/prompt-metadata.test.ts b/packages/agent-core/test/session/prompt-metadata.test.ts index ead1c70bb8..99a9aa1a5d 100644 --- a/packages/agent-core/test/session/prompt-metadata.test.ts +++ b/packages/agent-core/test/session/prompt-metadata.test.ts @@ -95,6 +95,7 @@ describe('prompt metadata sanitization', () => { 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(`${'A1b2'.repeat(10)}_.ts-${'Z9y8'.repeat(12)}`)).toBe('[redacted].[redacted]'); }); it('still redacts JWT segments joined by dots', () => { From 7205bd2e21cc87b59717bd6506189d1a951d646d Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Thu, 13 Aug 2026 10:29:59 +0800 Subject: [PATCH 04/13] fix(agent-core): recognize safe compound suffixes like .test.ts in file stems --- .../src/agent/prompt/promptMetadataText.ts | 6 ++++-- .../test/agent/rpc/prompt-metadata.test.ts | 6 ++++++ packages/agent-core/src/session/prompt-metadata.ts | 13 +++++++++---- .../agent-core/test/session/prompt-metadata.test.ts | 6 ++++++ 4 files changed, 25 insertions(+), 6 deletions(-) diff --git a/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts b/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts index e7428ca957..152ff253bb 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts @@ -61,8 +61,10 @@ const SAFE_FILENAME_EXTENSIONS = new Set([ function isFileNameStem(stem: string, following: string): boolean { if (!/[-_]/.test(stem)) return false; - const extension = /^\.([A-Za-z0-9]{1,8})(?![A-Za-z0-9+/=_-])/.exec(following)?.[1]; - return extension !== undefined && SAFE_FILENAME_EXTENSIONS.has(extension.toLowerCase()); + 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 promptPartText(part: ContentPart): string | undefined { 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 7e51cb37c6..887b2af52c 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 @@ -68,6 +68,9 @@ describe('prompt metadata sanitization', () => { 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', () => { @@ -81,6 +84,9 @@ describe('prompt metadata sanitization', () => { 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]'); }); diff --git a/packages/agent-core/src/session/prompt-metadata.ts b/packages/agent-core/src/session/prompt-metadata.ts index 403d724935..d17478693e 100644 --- a/packages/agent-core/src/session/prompt-metadata.ts +++ b/packages/agent-core/src/session/prompt-metadata.ts @@ -94,10 +94,15 @@ const SAFE_FILENAME_EXTENSIONS = new Set([ // A long token-shaped word stays readable only as the stem of a human-named // slug (contains `-`/`_`) of a text/code file, e.g. -// `refact-000-08-12-external-hooks-feature-scopes.ts`. A dot followed by more -// than 8 alphanumerics (e.g. a JWT segment boundary) is not an extension. +// `refact-000-08-12-external-hooks-feature-scopes.test.ts`. Compound suffixes +// are judged by their final component; 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 isFileNameStem(stem: string, following: string): boolean { if (!/[-_]/.test(stem)) return false; - const extension = /^\.([A-Za-z0-9]{1,8})(?![A-Za-z0-9+/=_-])/.exec(following)?.[1]; - return extension !== undefined && SAFE_FILENAME_EXTENSIONS.has(extension.toLowerCase()); + 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()); } diff --git a/packages/agent-core/test/session/prompt-metadata.test.ts b/packages/agent-core/test/session/prompt-metadata.test.ts index 99a9aa1a5d..e74841e99b 100644 --- a/packages/agent-core/test/session/prompt-metadata.test.ts +++ b/packages/agent-core/test/session/prompt-metadata.test.ts @@ -82,6 +82,9 @@ describe('prompt metadata sanitization', () => { 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', () => { @@ -95,6 +98,9 @@ describe('prompt metadata sanitization', () => { 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]'); }); From 3210caa8c64dc4dfaccc2ab52f578fad32f869ac Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Thu, 13 Aug 2026 10:36:20 +0800 Subject: [PATCH 05/13] fix(agent-core): require lowercase slug stems for the filename exemption --- .../src/agent/prompt/promptMetadataText.ts | 2 +- .../test/agent/rpc/prompt-metadata.test.ts | 4 ++++ packages/agent-core/src/session/prompt-metadata.ts | 13 ++++++++----- .../agent-core/test/session/prompt-metadata.test.ts | 4 ++++ 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts b/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts index 152ff253bb..7fced88bec 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts @@ -60,7 +60,7 @@ const SAFE_FILENAME_EXTENSIONS = new Set([ ]); function isFileNameStem(stem: string, following: string): boolean { - if (!/[-_]/.test(stem)) return false; + if (!/^(?=.*[-_])[a-z0-9_/-]+$/.test(stem)) return false; 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); 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 887b2af52c..bd02930f01 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 @@ -88,6 +88,10 @@ describe('prompt metadata sanitization', () => { '[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(`https://example.com/${'Ab1c'.repeat(10)}_.ts?download=1`)).toBe( + 'https://example.[redacted].ts?download=1', + ); }); it('still redacts JWT segments joined by dots', () => { diff --git a/packages/agent-core/src/session/prompt-metadata.ts b/packages/agent-core/src/session/prompt-metadata.ts index d17478693e..6200d8fd72 100644 --- a/packages/agent-core/src/session/prompt-metadata.ts +++ b/packages/agent-core/src/session/prompt-metadata.ts @@ -93,14 +93,17 @@ const SAFE_FILENAME_EXTENSIONS = new Set([ ]); // A long token-shaped word stays readable only as the stem of a human-named -// slug (contains `-`/`_`) of a text/code file, e.g. -// `refact-000-08-12-external-hooks-feature-scopes.test.ts`. Compound suffixes -// are judged by their final component; a dotted segment longer than 8 -// alphanumerics (e.g. a JWT segment) is not an extension, and anything that +// 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_-/`, so a `src/`-style path prefix +// is allowed) with at least one `-`/`_` separator — machine-generated tokens +// are mixed-case with overwhelming probability and never qualify. Compound +// suffixes are judged by their final component; 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 isFileNameStem(stem: string, following: string): boolean { - if (!/[-_]/.test(stem)) return false; + if (!/^(?=.*[-_])[a-z0-9_/-]+$/.test(stem)) return false; 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); diff --git a/packages/agent-core/test/session/prompt-metadata.test.ts b/packages/agent-core/test/session/prompt-metadata.test.ts index e74841e99b..c113791d43 100644 --- a/packages/agent-core/test/session/prompt-metadata.test.ts +++ b/packages/agent-core/test/session/prompt-metadata.test.ts @@ -102,6 +102,10 @@ describe('prompt metadata sanitization', () => { '[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(`https://example.com/${'Ab1c'.repeat(10)}_.ts?download=1`)).toBe( + 'https://example.[redacted].ts?download=1', + ); }); it('still redacts JWT segments joined by dots', () => { From b3ba50f7e1e58414fe1e61322788e1c19b720df3 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Thu, 13 Aug 2026 10:45:22 +0800 Subject: [PATCH 06/13] fix(agent-core): keep absolute paths readable in session titles --- .../src/agent/prompt/promptMetadataText.ts | 11 ++++++++++- .../test/agent/rpc/prompt-metadata.test.ts | 11 +++++++++++ .../agent-core/src/session/prompt-metadata.ts | 16 +++++++++++++++- .../test/session/prompt-metadata.test.ts | 11 +++++++++++ 4 files changed, 47 insertions(+), 2 deletions(-) diff --git a/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts b/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts index 7fced88bec..57dea5ac76 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts @@ -42,7 +42,10 @@ export function promptMetadataTextFromText(text: string): string | undefined { .replaceAll( /\b[A-Za-z0-9][A-Za-z0-9+/=_-]{39,}\b/g, (match: string, offset: number, source: string) => - isFileNameStem(match, source.slice(offset + match.length)) ? match : '[redacted]', + isFileNameStem(match, source.slice(offset + match.length)) || + isAbsolutePath(match, offset, source) + ? match + : '[redacted]', ) .replaceAll(/\p{Cc}+/gu, ' ') .replaceAll(/\s+/g, ' ') @@ -67,6 +70,12 @@ function isFileNameStem(stem: string, following: string): boolean { return SAFE_FILENAME_EXTENSIONS.has(extension.toLowerCase()); } +function isAbsolutePath(match: string, offset: number, source: string): boolean { + if (offset === 0 || source[offset - 1] !== '/') return false; + if (!match.includes('/')) return false; + return match.split('/').every((segment) => segment.length < 40); +} + 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 bd02930f01..9fd02fb9c6 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 @@ -94,6 +94,17 @@ describe('prompt metadata sanitization', () => { ); }); + it('keeps absolute paths readable but redacts token-looking segments', () => { + expect(sanitize('cd /Users/moonshot/Projects/kimi-code-workspace/')).toBe( + 'cd /Users/moonshot/Projects/kimi-code-workspace/', + ); + expect( + sanitize('看下 /Users/moonshot/Projects/kimi-code-workspace/kimi-code/packages/README.md'), + ).toBe('看下 /Users/moonshot/Projects/kimi-code-workspace/kimi-code/packages/README.md'); + expect(sanitize(`cat /tmp/${'Ab1c'.repeat(12)}`)).toBe('cat /[redacted]'); + expect(sanitize(`token ${'Ab1c'.repeat(10)}/${'Z9x8'.repeat(10)}`)).toBe('token [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 6200d8fd72..119f550cb8 100644 --- a/packages/agent-core/src/session/prompt-metadata.ts +++ b/packages/agent-core/src/session/prompt-metadata.ts @@ -72,7 +72,10 @@ function sanitizeAndTruncatePromptText(text: string, maxLength: number): string .replaceAll( /\b[A-Za-z0-9][A-Za-z0-9+/=_-]{39,}\b/g, (match: string, offset: number, source: string) => - isFileNameStem(match, source.slice(offset + match.length)) ? match : '[redacted]', + isFileNameStem(match, source.slice(offset + match.length)) || + isAbsolutePath(match, offset, source) + ? match + : '[redacted]', ) .replaceAll(/\p{Cc}+/gu, ' ') .replaceAll(/\s+/g, ' ') @@ -109,3 +112,14 @@ function isFileNameStem(stem: string, following: string): boolean { const extension = suffix.slice(suffix.lastIndexOf('.') + 1); return SAFE_FILENAME_EXTENSIONS.has(extension.toLowerCase()); } + +// A long token-shaped word also stays readable as an absolute path, e.g. +// `/Users/.../kimi-code-workspace/`. The match must be rooted (preceded by +// `/`) and every `/`-separated segment must stay below the 40-char token +// threshold — a path containing a token-length segment (e.g. +// `/tmp/<48-char token>`) is redacted as a whole instead. +function isAbsolutePath(match: string, offset: number, source: string): boolean { + if (offset === 0 || source[offset - 1] !== '/') return false; + if (!match.includes('/')) return false; + return match.split('/').every((segment) => segment.length < 40); +} diff --git a/packages/agent-core/test/session/prompt-metadata.test.ts b/packages/agent-core/test/session/prompt-metadata.test.ts index c113791d43..b4749eaf58 100644 --- a/packages/agent-core/test/session/prompt-metadata.test.ts +++ b/packages/agent-core/test/session/prompt-metadata.test.ts @@ -108,6 +108,17 @@ describe('prompt metadata sanitization', () => { ); }); + it('keeps absolute paths readable but redacts token-looking segments', () => { + expect(sanitize('cd /Users/moonshot/Projects/kimi-code-workspace/')).toBe( + 'cd /Users/moonshot/Projects/kimi-code-workspace/', + ); + expect( + sanitize('看下 /Users/moonshot/Projects/kimi-code-workspace/kimi-code/packages/README.md'), + ).toBe('看下 /Users/moonshot/Projects/kimi-code-workspace/kimi-code/packages/README.md'); + expect(sanitize(`cat /tmp/${'Ab1c'.repeat(12)}`)).toBe('cat /[redacted]'); + expect(sanitize(`token ${'Ab1c'.repeat(10)}/${'Z9x8'.repeat(10)}`)).toBe('token [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]'); From 317e0a838ba0e8583a4595fc424d8c4161f98818 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Thu, 13 Aug 2026 11:07:23 +0800 Subject: [PATCH 07/13] fix(agent-core): require word-shaped segments in the absolute-path exemption --- .../src/agent/prompt/promptMetadataText.ts | 4 +++- .../test/agent/rpc/prompt-metadata.test.ts | 9 +++++---- packages/agent-core/src/session/prompt-metadata.ts | 10 +++++++--- .../agent-core/test/session/prompt-metadata.test.ts | 9 +++++---- 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts b/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts index 57dea5ac76..d0c3b7a63c 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts @@ -73,7 +73,9 @@ function isFileNameStem(stem: string, following: string): boolean { function isAbsolutePath(match: string, offset: number, source: string): boolean { if (offset === 0 || source[offset - 1] !== '/') return false; if (!match.includes('/')) return false; - return match.split('/').every((segment) => segment.length < 40); + return match + .split('/') + .every((segment) => segment.length < 40 && /^([A-Z]?[a-z0-9_-]*|[A-Z0-9_-]+)$/.test(segment)); } function promptPartText(part: ContentPart): string | undefined { 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 9fd02fb9c6..558605b675 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 @@ -95,14 +95,15 @@ describe('prompt metadata sanitization', () => { }); it('keeps absolute paths readable but redacts token-looking segments', () => { - expect(sanitize('cd /Users/moonshot/Projects/kimi-code-workspace/')).toBe( - 'cd /Users/moonshot/Projects/kimi-code-workspace/', + expect(sanitize('cd /Users/alice/Projects/kimi-code-workspace/')).toBe( + 'cd /Users/alice/Projects/kimi-code-workspace/', ); expect( - sanitize('看下 /Users/moonshot/Projects/kimi-code-workspace/kimi-code/packages/README.md'), - ).toBe('看下 /Users/moonshot/Projects/kimi-code-workspace/kimi-code/packages/README.md'); + 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(`cat /tmp/${'Ab1c'.repeat(12)}`)).toBe('cat /[redacted]'); expect(sanitize(`token ${'Ab1c'.repeat(10)}/${'Z9x8'.repeat(10)}`)).toBe('token [redacted]'); + expect(sanitize(`secret /${'Ab1c'.repeat(8)}/${'Z9x8'.repeat(8)}`)).toBe('secret /[redacted]'); }); it('still redacts JWT segments joined by dots', () => { diff --git a/packages/agent-core/src/session/prompt-metadata.ts b/packages/agent-core/src/session/prompt-metadata.ts index 119f550cb8..de798435ef 100644 --- a/packages/agent-core/src/session/prompt-metadata.ts +++ b/packages/agent-core/src/session/prompt-metadata.ts @@ -116,10 +116,14 @@ function isFileNameStem(stem: string, following: string): boolean { // A long token-shaped word also stays readable as an absolute path, e.g. // `/Users/.../kimi-code-workspace/`. The match must be rooted (preceded by // `/`) and every `/`-separated segment must stay below the 40-char token -// threshold — a path containing a token-length segment (e.g. -// `/tmp/<48-char token>`) is redacted as a whole instead. +// threshold and look like a human-named word (lowercase, Capitalized, or +// ALL-CAPS like `README`) — a base64/base64url token pasted with slashes has +// mixed-case random segments and fails closed, as does a path containing a +// token-length segment (e.g. `/tmp/<48-char token>`). function isAbsolutePath(match: string, offset: number, source: string): boolean { if (offset === 0 || source[offset - 1] !== '/') return false; if (!match.includes('/')) return false; - return match.split('/').every((segment) => segment.length < 40); + return match + .split('/') + .every((segment) => 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 b4749eaf58..a94502f9d2 100644 --- a/packages/agent-core/test/session/prompt-metadata.test.ts +++ b/packages/agent-core/test/session/prompt-metadata.test.ts @@ -109,14 +109,15 @@ describe('prompt metadata sanitization', () => { }); it('keeps absolute paths readable but redacts token-looking segments', () => { - expect(sanitize('cd /Users/moonshot/Projects/kimi-code-workspace/')).toBe( - 'cd /Users/moonshot/Projects/kimi-code-workspace/', + expect(sanitize('cd /Users/alice/Projects/kimi-code-workspace/')).toBe( + 'cd /Users/alice/Projects/kimi-code-workspace/', ); expect( - sanitize('看下 /Users/moonshot/Projects/kimi-code-workspace/kimi-code/packages/README.md'), - ).toBe('看下 /Users/moonshot/Projects/kimi-code-workspace/kimi-code/packages/README.md'); + 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(`cat /tmp/${'Ab1c'.repeat(12)}`)).toBe('cat /[redacted]'); expect(sanitize(`token ${'Ab1c'.repeat(10)}/${'Z9x8'.repeat(10)}`)).toBe('token [redacted]'); + expect(sanitize(`secret /${'Ab1c'.repeat(8)}/${'Z9x8'.repeat(8)}`)).toBe('secret /[redacted]'); }); it('still redacts JWT segments joined by dots', () => { From 87c3e84b80b997a6c7582bafd029291e95f7b69b Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Thu, 13 Aug 2026 11:13:05 +0800 Subject: [PATCH 08/13] fix(agent-core): evaluate the path basename separately in the exemption --- .../src/agent/prompt/promptMetadataText.ts | 11 ++++++--- .../test/agent/rpc/prompt-metadata.test.ts | 7 ++++++ .../agent-core/src/session/prompt-metadata.ts | 23 ++++++++++++------- .../test/session/prompt-metadata.test.ts | 7 ++++++ 4 files changed, 37 insertions(+), 11 deletions(-) diff --git a/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts b/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts index d0c3b7a63c..c7fb4065a4 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts @@ -73,9 +73,14 @@ function isFileNameStem(stem: string, following: string): boolean { function isAbsolutePath(match: string, offset: number, source: string): boolean { if (offset === 0 || source[offset - 1] !== '/') return false; if (!match.includes('/')) return false; - return match - .split('/') - .every((segment) => segment.length < 40 && /^([A-Z]?[a-z0-9_-]*|[A-Z0-9_-]+)$/.test(segment)); + const segments = match.split('/'); + const directories = segments.slice(0, -1); + const base = segments[segments.length - 1]; + const wordShaped = (segment: string) => + segment.length < 40 && /^([A-Z]?[a-z0-9_-]*|[A-Z0-9_-]+)$/.test(segment); + if (!directories.every(wordShaped)) return false; + if (wordShaped(base)) return true; + return isFileNameStem(base, source.slice(offset + match.length)); } function promptPartText(part: ContentPart): string | undefined { 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 558605b675..21f00c578c 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 @@ -101,6 +101,13 @@ describe('prompt metadata sanitization', () => { 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(`cat /tmp/${'Ab1c'.repeat(12)}`)).toBe('cat /[redacted]'); expect(sanitize(`token ${'Ab1c'.repeat(10)}/${'Z9x8'.repeat(10)}`)).toBe('token [redacted]'); expect(sanitize(`secret /${'Ab1c'.repeat(8)}/${'Z9x8'.repeat(8)}`)).toBe('secret /[redacted]'); diff --git a/packages/agent-core/src/session/prompt-metadata.ts b/packages/agent-core/src/session/prompt-metadata.ts index de798435ef..5fbbb8ae0e 100644 --- a/packages/agent-core/src/session/prompt-metadata.ts +++ b/packages/agent-core/src/session/prompt-metadata.ts @@ -115,15 +115,22 @@ function isFileNameStem(stem: string, following: string): boolean { // A long token-shaped word also stays readable as an absolute path, e.g. // `/Users/.../kimi-code-workspace/`. The match must be rooted (preceded by -// `/`) and every `/`-separated segment must stay below the 40-char token -// threshold and look like a human-named word (lowercase, Capitalized, or -// ALL-CAPS like `README`) — a base64/base64url token pasted with slashes has -// mixed-case random segments and fails closed, as does a path containing a -// token-length segment (e.g. `/tmp/<48-char token>`). +// `/`); 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`), while the basename may additionally be a long slug passing the +// file-name rule above — so `/Users/Alice/.../refact-...-scopes.ts` survives +// but a base64/base64url token pasted with slashes (mixed-case random +// segments) or a token-length basename (e.g. `/tmp/<48-char token>`) fails +// closed. function isAbsolutePath(match: string, offset: number, source: string): boolean { if (offset === 0 || source[offset - 1] !== '/') return false; if (!match.includes('/')) return false; - return match - .split('/') - .every((segment) => segment.length < 40 && /^([A-Z]?[a-z0-9_-]*|[A-Z0-9_-]+)$/.test(segment)); + const segments = match.split('/'); + const directories = segments.slice(0, -1); + const base = segments[segments.length - 1]; + const wordShaped = (segment: string) => + segment.length < 40 && /^([A-Z]?[a-z0-9_-]*|[A-Z0-9_-]+)$/.test(segment); + if (!directories.every(wordShaped)) return false; + if (wordShaped(base)) return true; + return isFileNameStem(base, source.slice(offset + match.length)); } diff --git a/packages/agent-core/test/session/prompt-metadata.test.ts b/packages/agent-core/test/session/prompt-metadata.test.ts index a94502f9d2..481cc24953 100644 --- a/packages/agent-core/test/session/prompt-metadata.test.ts +++ b/packages/agent-core/test/session/prompt-metadata.test.ts @@ -115,6 +115,13 @@ describe('prompt metadata sanitization', () => { 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(`cat /tmp/${'Ab1c'.repeat(12)}`)).toBe('cat /[redacted]'); expect(sanitize(`token ${'Ab1c'.repeat(10)}/${'Z9x8'.repeat(10)}`)).toBe('token [redacted]'); expect(sanitize(`secret /${'Ab1c'.repeat(8)}/${'Z9x8'.repeat(8)}`)).toBe('secret /[redacted]'); From 12f7bfd90607694655c7f601543895d63adbd5a5 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Thu, 13 Aug 2026 11:19:32 +0800 Subject: [PATCH 09/13] fix(agent-core): keep token-length segments out of the path exemption --- .../src/agent/prompt/promptMetadataText.ts | 26 +++++---- .../test/agent/rpc/prompt-metadata.test.ts | 3 + .../agent-core/src/session/prompt-metadata.ts | 57 ++++++++++--------- .../test/session/prompt-metadata.test.ts | 3 + 4 files changed, 49 insertions(+), 40 deletions(-) diff --git a/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts b/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts index c7fb4065a4..767ff65aa1 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts @@ -41,11 +41,12 @@ export function promptMetadataTextFromText(text: string): string | undefined { .replaceAll(/\bsk-[A-Za-z0-9_-]{12,}\b/g, '[redacted]') .replaceAll( /\b[A-Za-z0-9][A-Za-z0-9+/=_-]{39,}\b/g, - (match: string, offset: number, source: string) => - isFileNameStem(match, source.slice(offset + match.length)) || - isAbsolutePath(match, offset, source) + (match: string, offset: number, source: string) => { + const following = source.slice(offset + match.length); + return isFileNameStem(match, following) || isPathLike(match, following) ? match - : '[redacted]', + : '[redacted]'; + }, ) .replaceAll(/\p{Cc}+/gu, ' ') .replaceAll(/\s+/g, ' ') @@ -63,24 +64,25 @@ const SAFE_FILENAME_EXTENSIONS = new Set([ ]); function isFileNameStem(stem: string, following: string): boolean { - if (!/^(?=.*[-_])[a-z0-9_/-]+$/.test(stem)) return false; + if (!/^(?=.*[-_])[a-z0-9_-]+$/.test(stem)) return false; 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 isAbsolutePath(match: string, offset: number, source: string): boolean { - if (offset === 0 || source[offset - 1] !== '/') return false; +function isPathLike(match: string, following: string): boolean { if (!match.includes('/')) return false; const segments = match.split('/'); const directories = segments.slice(0, -1); const base = segments[segments.length - 1]; - const wordShaped = (segment: string) => - segment.length < 40 && /^([A-Z]?[a-z0-9_-]*|[A-Z0-9_-]+)$/.test(segment); - if (!directories.every(wordShaped)) return false; - if (wordShaped(base)) return true; - return isFileNameStem(base, source.slice(offset + match.length)); + if (!directories.every(isWordShapedSegment)) return false; + if (isWordShapedSegment(base)) return true; + return isFileNameStem(base, following); +} + +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 { 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 21f00c578c..ac4c6858f6 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 @@ -89,6 +89,9 @@ describe('prompt metadata sanitization', () => { ); 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', ); diff --git a/packages/agent-core/src/session/prompt-metadata.ts b/packages/agent-core/src/session/prompt-metadata.ts index 5fbbb8ae0e..5913e46b32 100644 --- a/packages/agent-core/src/session/prompt-metadata.ts +++ b/packages/agent-core/src/session/prompt-metadata.ts @@ -71,11 +71,12 @@ function sanitizeAndTruncatePromptText(text: string, maxLength: number): string .replaceAll(/\bsk-[A-Za-z0-9_-]{12,}\b/g, '[redacted]') .replaceAll( /\b[A-Za-z0-9][A-Za-z0-9+/=_-]{39,}\b/g, - (match: string, offset: number, source: string) => - isFileNameStem(match, source.slice(offset + match.length)) || - isAbsolutePath(match, offset, source) + (match: string, offset: number, source: string) => { + const following = source.slice(offset + match.length); + return isFileNameStem(match, following) || isPathLike(match, following) ? match - : '[redacted]', + : '[redacted]'; + }, ) .replaceAll(/\p{Cc}+/gu, ' ') .replaceAll(/\s+/g, ' ') @@ -98,39 +99,39 @@ const SAFE_FILENAME_EXTENSIONS = new Set([ // 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_-/`, so a `src/`-style path prefix -// is allowed) with at least one `-`/`_` separator — machine-generated tokens -// are mixed-case with overwhelming probability and never qualify. Compound -// suffixes are judged by their final component; 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. +// all-lowercase slug characters (`a-z0-9_-`) with at least one `-`/`_` +// separator — machine-generated tokens are mixed-case with overwhelming +// probability and never qualify. Compound suffixes are judged by their final +// component; 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 isFileNameStem(stem: string, following: string): boolean { - if (!/^(?=.*[-_])[a-z0-9_/-]+$/.test(stem)) return false; + if (!/^(?=.*[-_])[a-z0-9_-]+$/.test(stem)) return false; 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 an absolute path, e.g. -// `/Users/.../kimi-code-workspace/`. The match must be rooted (preceded by -// `/`); 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`), while the basename may additionally be a long slug passing the -// file-name rule above — so `/Users/Alice/.../refact-...-scopes.ts` survives -// but a base64/base64url token pasted with slashes (mixed-case random -// segments) or a token-length basename (e.g. `/tmp/<48-char token>`) fails -// closed. -function isAbsolutePath(match: string, offset: number, source: string): boolean { - if (offset === 0 || source[offset - 1] !== '/') return false; +// 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 may additionally be a long slug passing the +// file-name rule above. A base64/base64url token pasted with slashes has +// mixed-case random segments, and a token-length segment like +// `<44 lowercase chars>/refact-...-scopes.ts` exceeds the threshold — both +// fail closed. +function isPathLike(match: string, following: string): boolean { if (!match.includes('/')) return false; const segments = match.split('/'); const directories = segments.slice(0, -1); const base = segments[segments.length - 1]; - const wordShaped = (segment: string) => - segment.length < 40 && /^([A-Z]?[a-z0-9_-]*|[A-Z0-9_-]+)$/.test(segment); - if (!directories.every(wordShaped)) return false; - if (wordShaped(base)) return true; - return isFileNameStem(base, source.slice(offset + match.length)); + if (!directories.every(isWordShapedSegment)) return false; + if (isWordShapedSegment(base)) return true; + return isFileNameStem(base, following); +} + +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 481cc24953..adf515927c 100644 --- a/packages/agent-core/test/session/prompt-metadata.test.ts +++ b/packages/agent-core/test/session/prompt-metadata.test.ts @@ -103,6 +103,9 @@ describe('prompt metadata sanitization', () => { ); 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', ); From 735b85345273ff301af1a06bcaa2e3615787a261 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Thu, 13 Aug 2026 11:25:19 +0800 Subject: [PATCH 10/13] fix(agent-core): require stronger context for extensionless path matches --- .../src/agent/prompt/promptMetadataText.ts | 5 +++-- .../test/agent/rpc/prompt-metadata.test.ts | 1 + packages/agent-core/src/session/prompt-metadata.ts | 14 ++++++++------ .../test/session/prompt-metadata.test.ts | 1 + 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts b/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts index 767ff65aa1..4d8febd9dd 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts @@ -77,8 +77,9 @@ function isPathLike(match: string, following: string): boolean { const directories = segments.slice(0, -1); const base = segments[segments.length - 1]; if (!directories.every(isWordShapedSegment)) return false; - if (isWordShapedSegment(base)) return true; - return isFileNameStem(base, following); + if (isFileNameStem(base, following)) return true; + if (!isWordShapedSegment(base)) return false; + return segments.length >= 3 && segments.every((segment) => segment.length <= 24); } function isWordShapedSegment(segment: string): boolean { 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 ac4c6858f6..49e26aa4e1 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 @@ -113,6 +113,7 @@ describe('prompt metadata sanitization', () => { ); 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(`secret /${'Ab1c'.repeat(8)}/${'Z9x8'.repeat(8)}`)).toBe('secret /[redacted]'); }); diff --git a/packages/agent-core/src/session/prompt-metadata.ts b/packages/agent-core/src/session/prompt-metadata.ts index 5913e46b32..5a9f875742 100644 --- a/packages/agent-core/src/session/prompt-metadata.ts +++ b/packages/agent-core/src/session/prompt-metadata.ts @@ -118,18 +118,20 @@ function isFileNameStem(stem: string, following: string): boolean { // 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 may additionally be a long slug passing the -// file-name rule above. A base64/base64url token pasted with slashes has -// mixed-case random segments, and a token-length segment like -// `<44 lowercase chars>/refact-...-scopes.ts` exceeds the threshold — both -// fail closed. +// file-name rule above. An extensionless match needs stronger context — at +// least three segments, each no longer than a natural directory name — so +// slash-joined token material like `<32 lowercase chars>/<32 lowercase +// chars>` fails closed, as do mixed-case random segments and token-length +// basenames (e.g. `/tmp/<48-char token>`). function isPathLike(match: string, following: 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; - if (isWordShapedSegment(base)) return true; - return isFileNameStem(base, following); + if (isFileNameStem(base, following)) return true; + if (!isWordShapedSegment(base)) return false; + return segments.length >= 3 && segments.every((segment) => segment.length <= 24); } function isWordShapedSegment(segment: string): boolean { diff --git a/packages/agent-core/test/session/prompt-metadata.test.ts b/packages/agent-core/test/session/prompt-metadata.test.ts index adf515927c..1d669e564f 100644 --- a/packages/agent-core/test/session/prompt-metadata.test.ts +++ b/packages/agent-core/test/session/prompt-metadata.test.ts @@ -127,6 +127,7 @@ describe('prompt metadata sanitization', () => { ); 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(`secret /${'Ab1c'.repeat(8)}/${'Z9x8'.repeat(8)}`)).toBe('secret /[redacted]'); }); From ea4623ae9d46d15e237548a3a35a6e8ed56caf1a Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Thu, 13 Aug 2026 11:30:43 +0800 Subject: [PATCH 11/13] fix(agent-core): allow normal code-file casing for path basenames with safe suffixes --- .../src/agent/prompt/promptMetadataText.ts | 7 ++++ .../test/agent/rpc/prompt-metadata.test.ts | 3 ++ .../agent-core/src/session/prompt-metadata.ts | 32 +++++++++++++------ .../test/session/prompt-metadata.test.ts | 3 ++ 4 files changed, 35 insertions(+), 10 deletions(-) diff --git a/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts b/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts index 4d8febd9dd..4982317b66 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts @@ -65,6 +65,10 @@ const SAFE_FILENAME_EXTENSIONS = new Set([ 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); @@ -78,6 +82,9 @@ function isPathLike(match: string, following: string): boolean { const base = segments[segments.length - 1]; if (!directories.every(isWordShapedSegment)) return false; 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; return segments.length >= 3 && segments.every((segment) => segment.length <= 24); } 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 49e26aa4e1..5b440ec386 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 @@ -111,6 +111,9 @@ describe('prompt metadata sanitization', () => { ).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]'); diff --git a/packages/agent-core/src/session/prompt-metadata.ts b/packages/agent-core/src/session/prompt-metadata.ts index 5a9f875742..99f445e524 100644 --- a/packages/agent-core/src/session/prompt-metadata.ts +++ b/packages/agent-core/src/session/prompt-metadata.ts @@ -101,12 +101,18 @@ const SAFE_FILENAME_EXTENSIONS = new Set([ // `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. Compound suffixes are judged by their final -// component; 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. +// 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); @@ -117,12 +123,15 @@ function isFileNameStem(stem: string, following: string): boolean { // 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 may additionally be a long slug passing the -// file-name rule above. An extensionless match needs stronger context — at -// least three segments, each no longer than a natural directory name — so -// slash-joined token material like `<32 lowercase chars>/<32 lowercase -// chars>` fails closed, as do mixed-case random segments and token-length -// basenames (e.g. `/tmp/<48-char token>`). +// `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 context — at least three segments, each +// no longer than a natural directory name — so slash-joined token material +// like `<32 lowercase chars>/<32 lowercase chars>` fails closed, as do +// mixed-case random segments and token-length basenames (e.g. +// `/tmp/<48-char token>`). function isPathLike(match: string, following: string): boolean { if (!match.includes('/')) return false; const segments = match.split('/'); @@ -130,6 +139,9 @@ function isPathLike(match: string, following: string): boolean { const base = segments[segments.length - 1]; if (!directories.every(isWordShapedSegment)) return false; 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; return segments.length >= 3 && segments.every((segment) => segment.length <= 24); } diff --git a/packages/agent-core/test/session/prompt-metadata.test.ts b/packages/agent-core/test/session/prompt-metadata.test.ts index 1d669e564f..45953f1bf7 100644 --- a/packages/agent-core/test/session/prompt-metadata.test.ts +++ b/packages/agent-core/test/session/prompt-metadata.test.ts @@ -125,6 +125,9 @@ describe('prompt metadata sanitization', () => { ).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]'); From 9dcd7aacbe5b28ed4f8e06a5a2966fb40599fd38 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Thu, 13 Aug 2026 11:36:37 +0800 Subject: [PATCH 12/13] fix(agent-core): anchor extensionless path matches at well-known filesystem roots --- .../src/agent/prompt/promptMetadataText.ts | 14 ++++++++--- .../test/agent/rpc/prompt-metadata.test.ts | 6 +++++ .../agent-core/src/session/prompt-metadata.ts | 24 +++++++++++++------ .../test/session/prompt-metadata.test.ts | 6 +++++ 4 files changed, 40 insertions(+), 10 deletions(-) diff --git a/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts b/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts index 4982317b66..adee4ae2b3 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts @@ -43,7 +43,7 @@ export function promptMetadataTextFromText(text: string): string | undefined { /\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, following) + return isFileNameStem(match, following) || isPathLike(match, offset, source) ? match : '[redacted]'; }, @@ -75,20 +75,28 @@ function safeSuffixFollows(following: string): boolean { return SAFE_FILENAME_EXTENSIONS.has(extension.toLowerCase()); } -function isPathLike(match: string, following: string): boolean { +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; - return segments.length >= 3 && segments.every((segment) => segment.length <= 24); + 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].toLowerCase()) || source[offset - 2] === '~'; } +const PATH_ROOT_SEGMENTS = new Set([ + 'users', 'home', 'tmp', 'var', 'opt', 'usr', 'etc', 'root', 'mnt', 'media', + 'volumes', 'data', '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-v2/test/agent/rpc/prompt-metadata.test.ts b/packages/agent-core-v2/test/agent/rpc/prompt-metadata.test.ts index 5b440ec386..4075883aad 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 @@ -117,6 +117,12 @@ describe('prompt metadata sanitization', () => { 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('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]'); }); diff --git a/packages/agent-core/src/session/prompt-metadata.ts b/packages/agent-core/src/session/prompt-metadata.ts index 99f445e524..965553250f 100644 --- a/packages/agent-core/src/session/prompt-metadata.ts +++ b/packages/agent-core/src/session/prompt-metadata.ts @@ -73,7 +73,7 @@ function sanitizeAndTruncatePromptText(text: string, maxLength: number): string /\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, following) + return isFileNameStem(match, following) || isPathLike(match, offset, source) ? match : '[redacted]'; }, @@ -127,25 +127,35 @@ function safeSuffixFollows(following: string): boolean { // 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 context — at least three segments, each -// no longer than a natural directory name — so slash-joined token material -// like `<32 lowercase chars>/<32 lowercase chars>` fails closed, as do -// mixed-case random segments and token-length basenames (e.g. +// 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, following: string): boolean { +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; - return segments.length >= 3 && segments.every((segment) => segment.length <= 24); + 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].toLowerCase()) || source[offset - 2] === '~'; } +// Well-known filesystem roots that anchor an extensionless absolute path. +const PATH_ROOT_SEGMENTS = new Set([ + 'users', 'home', 'tmp', 'var', 'opt', 'usr', 'etc', 'root', 'mnt', 'media', + 'volumes', 'data', '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 45953f1bf7..0892490e5b 100644 --- a/packages/agent-core/test/session/prompt-metadata.test.ts +++ b/packages/agent-core/test/session/prompt-metadata.test.ts @@ -131,6 +131,12 @@ describe('prompt metadata sanitization', () => { 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('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]'); }); From e023c6a9cecd03a7b5c1d9f0005f5cba77157d9c Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Thu, 13 Aug 2026 11:43:16 +0800 Subject: [PATCH 13/13] fix(agent-core): use case-sensitive filesystem-only roots for extensionless paths --- .../src/agent/prompt/promptMetadataText.ts | 6 +++--- .../test/agent/rpc/prompt-metadata.test.ts | 3 +++ packages/agent-core/src/session/prompt-metadata.ts | 11 +++++++---- .../agent-core/test/session/prompt-metadata.test.ts | 3 +++ 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts b/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts index adee4ae2b3..69945000ea 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts @@ -89,12 +89,12 @@ function isPathLike(match: string, offset: number, source: string): boolean { 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].toLowerCase()) || source[offset - 2] === '~'; + return PATH_ROOT_SEGMENTS.has(segments[0]) || source[offset - 2] === '~'; } const PATH_ROOT_SEGMENTS = new Set([ - 'users', 'home', 'tmp', 'var', 'opt', 'usr', 'etc', 'root', 'mnt', 'media', - 'volumes', 'data', 'srv', + 'Users', 'Volumes', 'home', 'tmp', 'var', 'opt', 'usr', 'etc', 'root', 'mnt', + 'media', 'srv', ]); function isWordShapedSegment(segment: string): boolean { 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 4075883aad..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 @@ -120,6 +120,9 @@ describe('prompt metadata sanitization', () => { 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', ); diff --git a/packages/agent-core/src/session/prompt-metadata.ts b/packages/agent-core/src/session/prompt-metadata.ts index 965553250f..c5f0a18407 100644 --- a/packages/agent-core/src/session/prompt-metadata.ts +++ b/packages/agent-core/src/session/prompt-metadata.ts @@ -147,13 +147,16 @@ function isPathLike(match: string, offset: number, source: string): boolean { 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].toLowerCase()) || source[offset - 2] === '~'; + return PATH_ROOT_SEGMENTS.has(segments[0]) || source[offset - 2] === '~'; } -// Well-known filesystem roots that anchor an extensionless absolute path. +// 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', 'home', 'tmp', 'var', 'opt', 'usr', 'etc', 'root', 'mnt', 'media', - 'volumes', 'data', 'srv', + 'Users', 'Volumes', 'home', 'tmp', 'var', 'opt', 'usr', 'etc', 'root', 'mnt', + 'media', 'srv', ]); function isWordShapedSegment(segment: string): boolean { diff --git a/packages/agent-core/test/session/prompt-metadata.test.ts b/packages/agent-core/test/session/prompt-metadata.test.ts index 0892490e5b..539cc0a183 100644 --- a/packages/agent-core/test/session/prompt-metadata.test.ts +++ b/packages/agent-core/test/session/prompt-metadata.test.ts @@ -134,6 +134,9 @@ describe('prompt metadata sanitization', () => { 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', );