重构:线程条目脱敏改由前端统一做
- 新增 directThreadSanitize:项目内路径归一成相对路径,绝对路径 / 密钥 / 敏感赋值 / Bearer / 配置文件 / 私钥块 / 敏感开关统一换成占位符,只遮挡不截断 - 订阅 hook 在 bootstrap / consume / 历史切片进入聊天状态之前统一过脱敏,复用已有的 projectPath - 新增 12 条单测:项目内外路径、密钥前缀与 JWT、Bearer、.env、私钥块、多行换行保留、条目与增量事件
This commit is contained in:
+21
-4
@@ -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]);
|
||||
|
||||
+613
File diff suppressed because it is too large
Load Diff
@@ -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>',
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user