重构:线程条目脱敏改由前端统一做

- 新增 directThreadSanitize:项目内路径归一成相对路径,绝对路径 / 密钥 / 敏感赋值 / Bearer / 配置文件 / 私钥块 / 敏感开关统一换成占位符,只遮挡不截断
- 订阅 hook 在 bootstrap / consume / 历史切片进入聊天状态之前统一过脱敏,复用已有的 projectPath
- 新增 12 条单测:项目内外路径、密钥前缀与 JWT、Bearer、.env、私钥块、多行换行保留、条目与增量事件
This commit is contained in:
2026-10-03 12:06:40 +08:00
parent 500479b4aa
commit 549915a2be
3 changed files with 771 additions and 4 deletions
@@ -20,6 +20,10 @@ import {
resolveDirectThreadBootstrap,
selectDirectChatEntries,
} from '../conversation/directThreadChat';
import {
sanitizeDirectThreadEvents,
sanitizeDirectThreadItems,
} from '../conversation/directThreadSanitize';
import type { ConsumeResult } from '../generated/ConsumeResult';
import type { SubscriptionBootstrap } from '../generated/SubscriptionBootstrap';
import type { ThreadItem } from '../generated/ThreadItem';
@@ -117,7 +121,10 @@ export function useDirectThreadChatSubscription({
);
if (disposed) return;
setState((current) =>
applyDirectThreadConsumeResult(current, result),
applyDirectThreadConsumeResult(current, {
...result,
events: sanitizeDirectThreadEvents(projectPath, result.events),
}),
);
} while (consumeAgain && !disposed);
} catch (error) {
@@ -151,7 +158,12 @@ export function useDirectThreadChatSubscription({
}
subscriptionId = result.subscriptionId;
anchorGate.settle(result.lastCompletedItemId ?? null);
setState((current) => resolveDirectThreadBootstrap(current, result));
setState((current) =>
resolveDirectThreadBootstrap(current, {
...result,
events: sanitizeDirectThreadEvents(projectPath, result.events),
}),
);
if (notifyBeforeSubscription) {
notifyBeforeSubscription = false;
void consume();
@@ -190,9 +202,14 @@ export function useDirectThreadChatSubscription({
const mergeHistoryItems = useMemo(
() => (items: readonly ThreadItem[]) => {
setState((current) => mergeDirectHistoryItems(current, items));
setState((current) =>
mergeDirectHistoryItems(
current,
sanitizeDirectThreadItems(projectPath, items),
),
);
},
[],
[projectPath],
);
const entries = useMemo(() => selectDirectChatEntries(state), [state]);
@@ -0,0 +1,137 @@
import { describe, expect, it } from 'vitest';
import {
sanitizeDirectThreadEvent,
sanitizeDirectThreadText,
} from '../src/view/project-development/chat/conversation/directThreadSanitize';
import type { ThreadEvent } from '../src/view/project-development/chat/generated/ThreadEvent';
const ROOT = '/workspace/direct-project';
describe('directThreadSanitize', () => {
it('项目内绝对路径归一成项目相对路径', () => {
expect(
sanitizeDirectThreadText(
ROOT,
'open /workspace/direct-project/game/src/x.ts',
),
).toBe('open game/src/x.ts');
});
it('项目外绝对路径与 file:// 换成占位符', () => {
expect(sanitizeDirectThreadText(ROOT, 'at /root/secret/x')).toBe(
'at <absolute-path>',
);
expect(sanitizeDirectThreadText(ROOT, 'file:///etc/passwd')).toBe(
'<absolute-path>',
);
expect(sanitizeDirectThreadText(ROOT, 'win C:\\Users\\me\\x.txt')).toBe(
'win <absolute-path>',
);
});
it('有前缀的密钥与 JWT 换成占位符', () => {
const jwt = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcdefgh';
expect(sanitizeDirectThreadText(ROOT, 'key=sk-abcdefghijklmnop')).toBe(
'key=[redacted-secret]',
);
expect(sanitizeDirectThreadText(ROOT, `token ${jwt}`)).toBe(
'token [redacted-secret]',
);
});
it('敏感赋值只遮挡值,保留同行其它信息', () => {
expect(sanitizeDirectThreadText(ROOT, 'password=hunter2 status=401')).toBe(
'[redacted-sensitive-field]=[redacted-secret] status=401',
);
expect(sanitizeDirectThreadText(ROOT, '"token": "raw-value"')).toBe(
'"[redacted-sensitive-field]": [redacted-secret]',
);
});
it('Bearer 值换成占位符', () => {
expect(sanitizeDirectThreadText(ROOT, 'Authorization: Bearer abc123')).toBe(
'[redacted-sensitive-field]: [redacted-secret]',
);
expect(sanitizeDirectThreadText(ROOT, 'h x Bearer abc123 y')).toBe(
'h x Bearer [redacted-secret] y',
);
});
it('.env / game-creator.config 换成配置占位符', () => {
expect(sanitizeDirectThreadText(ROOT, 'cat .env.local')).toBe(
'cat [redacted-config]',
);
});
it('私钥块整块吞掉', () => {
const pem = [
'-----BEGIN PRIVATE KEY-----',
'MIIEvQIBADANBg',
'-----END PRIVATE KEY-----',
'',
].join('\n');
expect(sanitizeDirectThreadText(ROOT, pem)).toBe(
'[redacted sensitive context]\n',
);
});
it('敏感开关整行遮挡', () => {
expect(
sanitizeDirectThreadText(ROOT, 'curl --token abc123 https://x'),
).toBe('[redacted sensitive context]');
});
it('已有占位符不再二次变形', () => {
expect(sanitizeDirectThreadText(ROOT, 'at <absolute-path>')).toBe(
'at <absolute-path>',
);
});
it('多行文本保留换行', () => {
const value = '第一段\nkey=sk-abcdefghijklmnop\n第三段\n';
expect(sanitizeDirectThreadText(ROOT, value)).toBe(
'第一段\nkey=[redacted-secret]\n第三段\n',
);
});
it('条目事件只改自由文本字段,身份与时间原样', () => {
const event: ThreadEvent = {
type: 'item.completed',
item: {
itemType: 'message',
itemId: 'msg-1',
role: 'assistant',
text: 'key=sk-abcdefghijklmnop at /root/secret/x',
at: 1_700_000_000_000,
},
at: 1_700_000_000_100,
};
expect(sanitizeDirectThreadEvent(ROOT, event)).toEqual({
type: 'item.completed',
item: {
itemType: 'message',
itemId: 'msg-1',
role: 'assistant',
text: 'key=[redacted-secret] at <absolute-path>',
at: 1_700_000_000_000,
},
at: 1_700_000_000_100,
});
});
it('流式增量事件遮挡正文', () => {
const event: ThreadEvent = {
type: 'item.delta',
itemId: 'msg-1',
kind: 'message',
delta: 'see /root/secret/x',
};
expect(sanitizeDirectThreadEvent(ROOT, event)).toEqual({
type: 'item.delta',
itemId: 'msg-1',
kind: 'message',
delta: 'see <absolute-path>',
});
});
});