From a04e94c4b336bc2b055f1f1680975f7ffb72d223 Mon Sep 17 00:00:00 2001 From: LHMQ878 <72402929@cityu-dg.edu.cn> Date: Thu, 30 Jul 2026 20:59:06 +0800 Subject: [PATCH 1/3] fix(agent-core-v2): redact non-ASCII, UNC, and forward-slash paths in telemetry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cleanTelemetryString` built both path patterns from `\w`, which is ASCII-only, so a match ended at the first non-ASCII byte and the rest of the path — user name included — went out in the POSTed event. A home directory named `李明`, `иван`, or `josé` leaked, as did `O'Brien`, since `'` is not in `\w` either. Two more spellings were missed outright: UNC paths have no drive letter so nothing matched them at all, and `C:/a/b` only matched the POSIX pattern, which left the drive letter behind as `C:`. Replace both patterns with one alternation whose segment is defined by exclusion — anything that is not a separator, whitespace, or a character no filesystem permits — covering every script without enumerating any. Merging the branches into a single pass also stops a later pattern from re-scanning an earlier one's replacement. Two smaller fixes in the same function, both visible in the new tests: - Normalize separators before looking for the `node_modules/` marker, so the diagnostic tail the module docstring promises to keep survives on Windows too. - Require a separator after a space-containing segment, so `C:\Program Files\a.txt could not be read` no longer swallows the message. Adds `test/app/telemetry/privacy.test.ts`; the module had no coverage. Closes #2418 --- .../src/app/telemetry/privacy.ts | 62 +++++- .../test/app/telemetry/privacy.test.ts | 199 ++++++++++++++++++ 2 files changed, 253 insertions(+), 8 deletions(-) create mode 100644 packages/agent-core-v2/test/app/telemetry/privacy.test.ts diff --git a/packages/agent-core-v2/src/app/telemetry/privacy.ts b/packages/agent-core-v2/src/app/telemetry/privacy.ts index e7349cdf92..31023aa8ee 100644 --- a/packages/agent-core-v2/src/app/telemetry/privacy.ts +++ b/packages/agent-core-v2/src/app/telemetry/privacy.ts @@ -21,20 +21,66 @@ const LABELED_PATTERNS: ReadonlyArray = [ [/\b(?:sk|pk|ak)-[A-Za-z0-9_-]{16,}\b/g, ''], ]; -const POSIX_PATH = /(?:\/[\w.~+-]+){2,}\/?/g; -const WINDOWS_PATH = /\b[A-Za-z]:\\(?:[\w.~ -]+\\?){2,}/g; +/** + * One path segment, defined by exclusion: anything that is not a separator, + * whitespace, or a character no filesystem allows in a name. + * + * Deliberately not `\w`, which is ASCII-only — a home directory named `李明`, + * `иван`, or `josé` ends the match at the first non-ASCII byte and the rest of + * the path survives into the payload. Excluding rather than enumerating also + * covers names containing an apostrophe, e.g. `O'Brien`. + * + * Each fragment below is a self-contained group so that appending a quantifier + * to it applies to the whole fragment — bare `[^…]+` followed by `?` would read + * as a lazy `+?` instead of an optional segment. + */ +const SEGMENT = String.raw`(?:[^\\/\s"<>|:*?]+)`; + +/** + * A segment that may contain interior spaces — `Program Files`, `alice chen`. + * + * Only used where the segment is followed by a separator, which is what keeps it + * from running into surrounding prose: in `C:\proj\file.txt failed to open` + * there is no separator after `open`, so the match ends at `file.txt`. A final + * segment therefore uses `SEGMENT`, which stops at the first space. + */ +const SEGMENT_WITH_SPACES = String.raw`(?:${SEGMENT}(?: +${SEGMENT})*)`; + +/** Zero or more interior segments, each consumed together with its separator. */ +const INTERIOR = String.raw`(?:${SEGMENT_WITH_SPACES}[\\/])`; + +/** + * Absolute file paths, as one alternation so a single pass consumes each whole + * path. Running the branches as separate passes let a later one re-scan an + * earlier one's replacement, which produced `node_modules`. + */ +const ABSOLUTE_PATH = new RegExp( + [ + // Drive-letter, either separator, optional `\\?\` / `\\.\` long-path prefix: + // C:\a\b, C:/a/b, \\?\C:\a\b + String.raw`(?:\\\\[?.]\\)?[A-Za-z]:[\\/]${INTERIOR}*${SEGMENT}?`, + // UNC: \\server\share\... + String.raw`\\\\${INTERIOR}+${SEGMENT}?`, + // POSIX; the leading group is required, so a lone `/tmp` and the `/4` in + // `3/4` are left alone + String.raw`(?:\/${SEGMENT_WITH_SPACES}(?=\/))+\/${SEGMENT}\/?`, + ].join('|'), + 'g', +); + +function redactPath(match: string): string { + // Normalize separators so the `node_modules/` marker is found on Windows too. + const normalized = match.replaceAll('\\', '/'); + const index = normalized.indexOf(NODE_MODULES_MARKER); + return index === -1 ? REDACTED_PATH : normalized.slice(index); +} export function cleanTelemetryString(value: string): string { let out = value; for (const [pattern, label] of LABELED_PATTERNS) { out = out.replace(pattern, label); } - out = out.replace(WINDOWS_PATH, REDACTED_PATH); - out = out.replace(POSIX_PATH, (match) => { - const index = match.indexOf(NODE_MODULES_MARKER); - return index === -1 ? REDACTED_PATH : match.slice(index); - }); - return out; + return out.replace(ABSOLUTE_PATH, redactPath); } export function cleanTelemetryProperties

>(properties: P): P { diff --git a/packages/agent-core-v2/test/app/telemetry/privacy.test.ts b/packages/agent-core-v2/test/app/telemetry/privacy.test.ts new file mode 100644 index 0000000000..506bffd0e4 --- /dev/null +++ b/packages/agent-core-v2/test/app/telemetry/privacy.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from 'vitest'; + +import { cleanTelemetryProperties, cleanTelemetryString } from '#/app/telemetry/privacy'; + +/** + * `cleanTelemetryString` is the last step before `CloudAppender.track` buffers an + * event and `CloudTransport` POSTs it (`cloudAppender.ts:115`), so anything that + * survives here leaves the machine. The path patterns therefore have to hold for + * home directories that are not ASCII, and for Windows spellings other than + * `C:\a\b`. + */ +describe('cleanTelemetryString', () => { + describe('non-ASCII home directories', () => { + // `\w` is ASCII-only, so a path pattern built from it stopped at the first + // non-ASCII byte and left the remainder — user name included — in the payload. + it('redacts a POSIX path under a CJK home directory', () => { + expect(cleanTelemetryString('ENOENT: /home/李明/proj/secret.txt')).toBe( + 'ENOENT: ', + ); + }); + + it('redacts a Windows path under a CJK home directory', () => { + expect(cleanTelemetryString(String.raw`ENOENT: C:\Users\李明\proj\secret.txt`)).toBe( + 'ENOENT: ', + ); + }); + + it('redacts a Cyrillic home directory', () => { + expect(cleanTelemetryString('/home/иван/proj/secret.txt')).toBe( + '', + ); + }); + + it('redacts an accented home directory on both platforms', () => { + expect(cleanTelemetryString('/home/josé/proj/secret.txt')).toBe( + '', + ); + expect(cleanTelemetryString(String.raw`C:\Users\josé\proj\secret.txt`)).toBe( + '', + ); + }); + + it('redacts a name containing an apostrophe', () => { + expect(cleanTelemetryString(String.raw`C:\Users\O'Brien\proj\secret.txt`)).toBe( + '', + ); + }); + + it('redacts a name containing a space', () => { + expect(cleanTelemetryString('/home/alice chen/proj/secret.txt')).toBe( + '', + ); + expect(cleanTelemetryString(String.raw`C:\Users\alice chen\proj\secret.txt`)).toBe( + '', + ); + expect(cleanTelemetryString(String.raw`C:\Program Files\app\config.json`)).toBe( + '', + ); + }); + + it('stops a space-tolerant segment at the end of the path, not mid-sentence', () => { + // An interior segment may contain spaces, but only because a separator has + // to follow it. The last segment stops at the first space, so trailing + // prose is not swallowed into the placeholder. + expect(cleanTelemetryString(String.raw`C:\Program Files\a.txt could not be read`)).toBe( + ' could not be read', + ); + expect(cleanTelemetryString('/home/alice chen/a.txt could not be read')).toBe( + ' could not be read', + ); + }); + }); + + describe('Windows path spellings', () => { + it('redacts a UNC share path', () => { + // Matched neither the drive-letter pattern nor the POSIX one, so it passed + // through with the share name and the whole path intact. + const cleaned = cleanTelemetryString( + String.raw`ENOENT: \\fileserver\home\alice.chen\proj\secret.txt`, + ); + expect(cleaned).toBe('ENOENT: '); + expect(cleaned).not.toContain('fileserver'); + expect(cleaned).not.toContain('alice.chen'); + }); + + it('redacts the \\\\?\\ long-path form', () => { + const cleaned = cleanTelemetryString( + String.raw`ENOENT: \\?\C:\Users\alice.chen\proj\secret.txt`, + ); + expect(cleaned).not.toContain('alice.chen'); + expect(cleaned).not.toContain('secret.txt'); + }); + + it('redacts a drive-letter path written with forward slashes', () => { + // Node and many libraries normalize to `C:/...`; the drive letter used to + // survive as a stray `C:` before the placeholder. + expect(cleanTelemetryString('ENOENT: C:/Users/alice.chen/proj/secret.txt')).toBe( + 'ENOENT: ', + ); + }); + + it('redacts a path at the drive root', () => { + expect(cleanTelemetryString(String.raw`ENOENT: C:\alice-secrets.txt`)).toBe( + 'ENOENT: ', + ); + }); + }); + + describe('behaviour that must not regress', () => { + it('still redacts plain ASCII paths on both platforms', () => { + expect(cleanTelemetryString(String.raw`ENOENT: C:\Users\alice.chen\proj\secret.txt`)).toBe( + 'ENOENT: ', + ); + expect(cleanTelemetryString('ENOENT: /home/alice.chen/proj/secret.txt')).toBe( + 'ENOENT: ', + ); + }); + + it('keeps the node_modules tail, which carries diagnostic value', () => { + expect(cleanTelemetryString('/home/alice.chen/repo/node_modules/pkg/index.js')).toBe( + 'node_modules/pkg/index.js', + ); + }); + + it('keeps the node_modules tail on Windows too', () => { + // The marker is spelled with a forward slash, so a backslash path never + // matched it and the diagnostic tail was thrown away. + expect( + cleanTelemetryString(String.raw`C:\Users\alice.chen\repo\node_modules\pkg\index.js`), + ).toBe('node_modules/pkg/index.js'); + }); + + it('leaves text that is not an absolute path alone', () => { + expect(cleanTelemetryString('read 3/4 of the file')).toBe('read 3/4 of the file'); + expect(cleanTelemetryString('the operation timed out after 30s')).toBe( + 'the operation timed out after 30s', + ); + expect(cleanTelemetryString('/tmp')).toBe('/tmp'); + expect(cleanTelemetryString('moonshotai/kimi-k2-instruct')).toBe( + 'moonshotai/kimi-k2-instruct', + ); + expect(cleanTelemetryString('')).toBe(''); + }); + + it('still applies the labeled patterns', () => { + expect(cleanTelemetryString('mail alice@example.com')).toBe('mail '); + expect(cleanTelemetryString('see https://example.com/a/b')).toBe('see '); + expect(cleanTelemetryString('key sk-abcdefghijklmnopqrstuv')).toBe( + 'key ', + ); + }); + + it('does not let the path pattern consume an earlier placeholder', () => { + // The URL is replaced first; the path pass must not then treat the + // placeholder or its surroundings as a path. + expect( + cleanTelemetryString('GET https://api.example.com/v1/x failed for /home/alice/p/s.txt'), + ).toBe('GET failed for '); + expect(cleanTelemetryString('alice@example.com opened /home/alice/p/s.txt')).toBe( + ' opened ', + ); + }); + + it('redacts every path in a value, not just the first', () => { + expect(cleanTelemetryString('copy /home/alice/a.txt to /home/alice/b.txt')).toBe( + 'copy to ', + ); + }); + + it('does not leak across repeated calls', () => { + // The patterns are module-level `/g` literals; `replace` resets `lastIndex` + // but `exec`/`test` would not, so pin the behaviour callers rely on. + const input = '/home/李明/proj/secret.txt'; + expect(cleanTelemetryString(input)).toBe(''); + expect(cleanTelemetryString(input)).toBe(''); + expect(cleanTelemetryString(input)).toBe(''); + }); + }); +}); + +describe('cleanTelemetryProperties', () => { + it('cleans string values and passes other primitives through', () => { + expect( + cleanTelemetryProperties({ + cwd: '/home/李明/proj', + count: 3, + ok: true, + missing: undefined, + empty: null, + }), + ).toEqual({ + cwd: '', + count: 3, + ok: true, + missing: undefined, + empty: null, + }); + }); +}); From 73862fe4f11038c409d319e1a0eeb239b0e41787 Mon Sep 17 00:00:00 2001 From: LHMQ878 <72402929@cityu-dg.edu.cn> Date: Thu, 30 Jul 2026 21:32:03 +0800 Subject: [PATCH 2/3] fix(agent-core-v2): redact filenames containing spaces, keep fractions intact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The path alternation ended its final segment at the first space, so a filename with a space in it kept everything after that space on the wire: `C:\Users\alice\secret file.txt` cleaned to ` file.txt`. The filename is exactly the part worth redacting, so this is the leak the pattern exists to prevent. The final segment now accepts interior spaces, but only when what follows them still ends in a file extension. Trailing prose does not, so `C:\proj\a.txt could not be read` backtracks to the filename rather than swallowing the sentence — the boundary the previous design got by stopping at the first space is now expressed as a condition instead. Fixes a second over-redaction found while validating that boundary: with a space-tolerant final segment, `read 1/2 then 3/4 then 5/6` matched as one path, because `2 then 3` reads as a single spaced segment. The POSIX branch now refuses to start immediately after an alphanumeric. Requiring two interior segments would also have blocked the fraction, but it would have stopped redacting `/tmp/foo.txt` and `/etc/passwd`, which the pattern this branch replaced did redact. Comments move into the top-of-file block per the agent-core-v2 comment convention; the regex rationale is unchanged, only relocated. --- .../src/app/telemetry/privacy.ts | 54 ++++++------------- .../test/app/telemetry/privacy.test.ts | 44 +++++++++++++-- 2 files changed, 55 insertions(+), 43 deletions(-) diff --git a/packages/agent-core-v2/src/app/telemetry/privacy.ts b/packages/agent-core-v2/src/app/telemetry/privacy.ts index 31023aa8ee..aaa8492120 100644 --- a/packages/agent-core-v2/src/app/telemetry/privacy.ts +++ b/packages/agent-core-v2/src/app/telemetry/privacy.ts @@ -6,6 +6,18 @@ * paths become labeled `` placeholders, while `node_modules/` * path tails are kept because they carry diagnostic value without user data. * App-scoped, no collaborators. + * + * Path segments are matched by exclusion rather than with `\w`, which is + * ASCII-only and so left the tail of any path under a `李明`, `иван` or `josé` + * home directory in the payload. A segment may contain interior spaces + * (`Program Files`, `alice chen`); the final segment may too, but only when it + * ends in a file extension, so a path followed by prose is redacted without + * swallowing the sentence. A POSIX path must not start right after an + * alphanumeric, or chained fractions such as `read 1/2 then 3/4` would read as + * one. Absolute paths match as a single alternation in one pass, because + * running the branches separately let a later branch re-scan an earlier one's + * replacement. The behaviour these rules produce is pinned case by case in + * `test/app/telemetry/privacy.test.ts`. */ const REDACTED_PATH = ''; @@ -21,55 +33,21 @@ const LABELED_PATTERNS: ReadonlyArray = [ [/\b(?:sk|pk|ak)-[A-Za-z0-9_-]{16,}\b/g, ''], ]; -/** - * One path segment, defined by exclusion: anything that is not a separator, - * whitespace, or a character no filesystem allows in a name. - * - * Deliberately not `\w`, which is ASCII-only — a home directory named `李明`, - * `иван`, or `josé` ends the match at the first non-ASCII byte and the rest of - * the path survives into the payload. Excluding rather than enumerating also - * covers names containing an apostrophe, e.g. `O'Brien`. - * - * Each fragment below is a self-contained group so that appending a quantifier - * to it applies to the whole fragment — bare `[^…]+` followed by `?` would read - * as a lazy `+?` instead of an optional segment. - */ const SEGMENT = String.raw`(?:[^\\/\s"<>|:*?]+)`; - -/** - * A segment that may contain interior spaces — `Program Files`, `alice chen`. - * - * Only used where the segment is followed by a separator, which is what keeps it - * from running into surrounding prose: in `C:\proj\file.txt failed to open` - * there is no separator after `open`, so the match ends at `file.txt`. A final - * segment therefore uses `SEGMENT`, which stops at the first space. - */ const SEGMENT_WITH_SPACES = String.raw`(?:${SEGMENT}(?: +${SEGMENT})*)`; - -/** Zero or more interior segments, each consumed together with its separator. */ const INTERIOR = String.raw`(?:${SEGMENT_WITH_SPACES}[\\/])`; +const FINAL_SEGMENT = String.raw`(?:${SEGMENT_WITH_SPACES}\.[A-Za-z][A-Za-z0-9]{0,9}(?![^\s"'<>])|${SEGMENT})`; -/** - * Absolute file paths, as one alternation so a single pass consumes each whole - * path. Running the branches as separate passes let a later one re-scan an - * earlier one's replacement, which produced `node_modules`. - */ const ABSOLUTE_PATH = new RegExp( [ - // Drive-letter, either separator, optional `\\?\` / `\\.\` long-path prefix: - // C:\a\b, C:/a/b, \\?\C:\a\b - String.raw`(?:\\\\[?.]\\)?[A-Za-z]:[\\/]${INTERIOR}*${SEGMENT}?`, - // UNC: \\server\share\... - String.raw`\\\\${INTERIOR}+${SEGMENT}?`, - // POSIX; the leading group is required, so a lone `/tmp` and the `/4` in - // `3/4` are left alone - String.raw`(?:\/${SEGMENT_WITH_SPACES}(?=\/))+\/${SEGMENT}\/?`, + String.raw`(?:\\\\[?.]\\)?[A-Za-z]:[\\/]${INTERIOR}*${FINAL_SEGMENT}?`, + String.raw`\\\\${INTERIOR}+${FINAL_SEGMENT}?`, + String.raw`(? { ); }); - it('stops a space-tolerant segment at the end of the path, not mid-sentence', () => { - // An interior segment may contain spaces, but only because a separator has - // to follow it. The last segment stops at the first space, so trailing - // prose is not swallowed into the placeholder. + it('redacts a filename containing a space', () => { + // The last segment is where the filename lives, so a space in it must not + // end the match — that would leave the rest of the name on the wire. + expect(cleanTelemetryString(String.raw`C:\Users\alice\secret file.txt`)).toBe( + '', + ); + expect(cleanTelemetryString('/home/alice/proj/secret file.txt')).toBe( + '', + ); + expect(cleanTelemetryString(String.raw`\\fileserver\home\secret file.txt`)).toBe( + '', + ); + expect(cleanTelemetryString(String.raw`C:\Users\alice\my report v2.final.docx`)).toBe( + '', + ); + }); + + it('stops a space-tolerant final segment at the path, not mid-sentence', () => { + // A space may continue the final segment only when what follows it still + // ends in an extension. Trailing prose does not, so the match backtracks + // to the filename instead of swallowing the sentence. expect(cleanTelemetryString(String.raw`C:\Program Files\a.txt could not be read`)).toBe( ' could not be read', ); - expect(cleanTelemetryString('/home/alice chen/a.txt could not be read')).toBe( + expect(cleanTelemetryString('/home/alice chen/proj/a.txt could not be read')).toBe( ' could not be read', ); + expect(cleanTelemetryString(String.raw`C:\proj\a.txt failed at step 2.5`)).toBe( + ' failed at step 2.5', + ); }); }); @@ -130,6 +150,20 @@ describe('cleanTelemetryString', () => { ).toBe('node_modules/pkg/index.js'); }); + it('leaves consecutive fractions alone', () => { + // A space-tolerant segment lets `2 then 3` read as one segment, so without + // the alphanumeric lookbehind the run from `/2` to `/6` matches as a path. + expect(cleanTelemetryString('read 1/2 then 3/4 then 5/6')).toBe( + 'read 1/2 then 3/4 then 5/6', + ); + expect(cleanTelemetryString('ratio 1/2 and 3/4')).toBe('ratio 1/2 and 3/4'); + }); + + it('still redacts a two-segment absolute path', () => { + expect(cleanTelemetryString('/tmp/kimi-scratch.json')).toBe(''); + expect(cleanTelemetryString('/etc/passwd')).toBe(''); + }); + it('leaves text that is not an absolute path alone', () => { expect(cleanTelemetryString('read 3/4 of the file')).toBe('read 3/4 of the file'); expect(cleanTelemetryString('the operation timed out after 30s')).toBe( From 2c6936deba4216c991c13649cc454463c9688181 Mon Sep 17 00:00:00 2001 From: LHMQ878 <72402929@cityu-dg.edu.cn> Date: Thu, 30 Jul 2026 21:34:28 +0800 Subject: [PATCH 3/3] chore: add changeset for the telemetry redaction fix `agent-core-v2` is private, but it is a workspace dependency of the published `@moonshot-ai/kimi-code`, so the fix reaches users and needs a version bump. --- .changeset/telemetry-path-redaction-non-ascii.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/telemetry-path-redaction-non-ascii.md diff --git a/.changeset/telemetry-path-redaction-non-ascii.md b/.changeset/telemetry-path-redaction-non-ascii.md new file mode 100644 index 0000000000..03b8939e21 --- /dev/null +++ b/.changeset/telemetry-path-redaction-non-ascii.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix telemetry path redaction leaking home directory names that are not ASCII. Paths under a directory named e.g. `李明`, `иван`, or `josé` were only partially redacted, as were UNC paths and `C:/`-style spellings. The `node_modules/` tail that carries diagnostic value is now also preserved on Windows.