Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-session-title-filename-redaction.md
Original file line number Diff line number Diff line change
@@ -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.
55 changes: 54 additions & 1 deletion packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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] === '~';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Fail closed for rooted slash-delimited tokens

Fresh evidence in this revision is that the narrowed root allow-list still preserves lowercase/hex token chunks when they sit under an allowed filesystem root: secret /tmp/${'a'.repeat(20)}/${'b'.repeat(20)} reaches this return with tmp allow-listed and both opaque segments under the 24-char cap, so the catch-all leaves the full value in lastPrompt/session titles instead of redacting it. Require stronger local filesystem context than root + short word-shaped segments, or fail closed for repeated opaque segments here and in the copied v1 helper.

Useful? React with 👍 / 👎.

}

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);
Comment on lines +100 to +101

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve camelCase file paths

When the prompt mentions a long path whose basename is camelCase or PascalCase, such as packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts, isPathLike() rejects it here because the segment regex only accepts lowercase, one initial capital, or all-caps words. The catch-all token regex then still collapses the whole path to [redacted].ts, so titles remain unreadable for common TypeScript files in this repo; allow normal code filename casing or decide path basenames from their safe extension before this word-shape filter. The legacy copy in packages/agent-core/src/session/prompt-metadata.ts has the same behavior.

Useful? React with 👍 / 👎.

}

function promptPartText(part: ContentPart): string | undefined {
switch (part.type) {
case 'text': {
Expand Down
81 changes: 81 additions & 0 deletions packages/agent-core-v2/test/agent/rpc/prompt-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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]');
});
});
87 changes: 86 additions & 1 deletion packages/agent-core/src/session/prompt-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,96 @@ 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();

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);
}
81 changes: 81 additions & 0 deletions packages/agent-core/test/session/prompt-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
*/
Expand Down Expand Up @@ -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();
Expand Down