dsh-sensitive-guard 0.0.0-stage → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +91 -0
- package/LICENSE +21 -0
- package/README.md +224 -2
- package/client.js +718 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +6 -0
- package/index.js +1183 -0
- package/locale/en.json +25 -0
- package/locale/zh.json +25 -0
- package/package.json +38 -4
- package/test.mjs +376 -0
package/client.js
ADDED
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser half of dsh-sensitive-guard: the Settings → 敏感保护 page
|
|
3
|
+
* (WorkBuddy 敏感数据管理 equivalent). Lists vault metadata and reveals one
|
|
4
|
+
* plaintext on demand through POST /guard/reveal (same-origin, audited,
|
|
5
|
+
* auto re-masks after 20s). No DOM outside this component.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
window.__ModuleLoader__.load({
|
|
9
|
+
id: 'dsh-sensitive-guard',
|
|
10
|
+
factory: (require) => {
|
|
11
|
+
const React = require('react');
|
|
12
|
+
const h = React.createElement;
|
|
13
|
+
const { useState, useEffect, useRef, useCallback } = React;
|
|
14
|
+
|
|
15
|
+
/** Dictionary namespace owned by this plugin. */
|
|
16
|
+
const NS = 'sensitiveGuard';
|
|
17
|
+
const zh = {
|
|
18
|
+
nav: '敏感保护',
|
|
19
|
+
heading: '敏感数据管理',
|
|
20
|
+
intro: '以下明文只保存在本机保管库。会话与模型上下文里只有 <SG_> 占位符;点右侧小锁可临时查看真实值。',
|
|
21
|
+
loading: '载入中…',
|
|
22
|
+
empty: '暂无被保护的敏感数据。输入或工具结果里出现密钥、令牌、密码时会自动入库。',
|
|
23
|
+
error: '载入失败',
|
|
24
|
+
reveal: '显示明文',
|
|
25
|
+
hide: '隐藏明文',
|
|
26
|
+
refresh: '刷新',
|
|
27
|
+
autoHide: '20 秒后自动重新遮挡',
|
|
28
|
+
colValue: '值',
|
|
29
|
+
colKind: '规则',
|
|
30
|
+
colTime: '入库时间',
|
|
31
|
+
colHits: '命中',
|
|
32
|
+
stats: '共 {count} 条 · 揭示只记录在本机',
|
|
33
|
+
placeholder: '上下文占位符格式:<SG_十六进制>',
|
|
34
|
+
usage: '模型侧取用:sensitive_guard_export 工具把明文写入 0600 文件,命令里用 "$(cat <路径>)" 引用,明文不进上下文。',
|
|
35
|
+
statusOn: '保护中',
|
|
36
|
+
statusOff: '已暂停',
|
|
37
|
+
pause30: '暂停 30 分钟',
|
|
38
|
+
pause120: '暂停 2 小时',
|
|
39
|
+
pauseRestart: '暂停到重启',
|
|
40
|
+
pauseRestartLeft: '重启前不再脱敏',
|
|
41
|
+
resume: '恢复保护',
|
|
42
|
+
pausedHint: '暂停期间不做任何脱敏,明文会直接进入上下文与日志。到期自动恢复。',
|
|
43
|
+
lanExposed: '⚠ 本页经局域网访问(允许网段:{cidrs})。这条链路上的明文与预览对该网段可见,用完建议把 allowCidrs 收回。',
|
|
44
|
+
restrictedTitle: '只读:这台机器不是服务所在的本机',
|
|
45
|
+
restrictedBody: '服务端默认只接受本机回环,所以这里能看到“有哪些凭据被保护”,看不到值。当前访问地址:{host}',
|
|
46
|
+
restrictedFile: '要在这里查看或揭示明文,二选一。① 在服务所在机器上编辑下面这个文件,加一行:',
|
|
47
|
+
restrictedAlt: '② 或者直接在服务所在机器的浏览器打开 http://127.0.0.1:<端口>。',
|
|
48
|
+
restrictedUnlock: '需在服务所在的本机打开才能揭示',
|
|
49
|
+
};
|
|
50
|
+
const en = {
|
|
51
|
+
nav: 'Sensitive guard',
|
|
52
|
+
heading: 'Sensitive data',
|
|
53
|
+
intro: 'These plaintexts live only in the local vault. Sessions and model context carry <SG_> placeholders; click the lock on the right to reveal a value temporarily.',
|
|
54
|
+
loading: 'Loading…',
|
|
55
|
+
empty: 'Nothing protected yet. Keys, tokens and passwords found in input or tool results are captured automatically.',
|
|
56
|
+
error: 'Failed to load',
|
|
57
|
+
reveal: 'Show secret',
|
|
58
|
+
hide: 'Hide secret',
|
|
59
|
+
refresh: 'Refresh',
|
|
60
|
+
autoHide: 'Re-masks in 20 seconds',
|
|
61
|
+
colValue: 'Value',
|
|
62
|
+
colKind: 'Rule',
|
|
63
|
+
colTime: 'Captured',
|
|
64
|
+
colHits: 'Hits',
|
|
65
|
+
stats: '{count} entries · reveals stay on this machine',
|
|
66
|
+
placeholder: 'Context placeholder format: <SG_hex>',
|
|
67
|
+
usage: 'For the agent: sensitive_guard_export writes the plaintext to a 0600 file; reference it as "$(cat <path>)" so plaintext never enters context.',
|
|
68
|
+
statusOn: 'Protecting',
|
|
69
|
+
statusOff: 'Paused',
|
|
70
|
+
pause30: 'Pause 30 min',
|
|
71
|
+
pause120: 'Pause 2 h',
|
|
72
|
+
pauseRestart: 'Pause until restart',
|
|
73
|
+
pauseRestartLeft: 'No masking until restart',
|
|
74
|
+
resume: 'Resume',
|
|
75
|
+
pausedHint: 'Nothing is masked while paused — plaintext reaches context and logs. Re-arms automatically.',
|
|
76
|
+
lanExposed: '⚠ This page is served over LAN (allowed: {cidrs}). Plaintext and previews on this route are visible to that network; narrow allowCidrs when you are done.',
|
|
77
|
+
restrictedTitle: 'Read-only: this machine is not the one running the server',
|
|
78
|
+
restrictedBody: 'The server only accepts loopback by default, so you can see which credentials are protected, but not their values. Current address: {host}',
|
|
79
|
+
restrictedFile: 'To view or reveal values here, pick one. ① On the server machine, edit this file and add one line:',
|
|
80
|
+
restrictedAlt: '② Or open http://127.0.0.1:<port> in a browser on that machine.',
|
|
81
|
+
restrictedUnlock: 'Reveal requires the browser on the server machine',
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const REVEAL_MS = 20_000;
|
|
85
|
+
const POLL_MS = 30_000;
|
|
86
|
+
|
|
87
|
+
const boxStyle = {
|
|
88
|
+
display: 'flex',
|
|
89
|
+
alignItems: 'center',
|
|
90
|
+
gap: 10,
|
|
91
|
+
padding: '8px 12px',
|
|
92
|
+
borderRadius: 12,
|
|
93
|
+
background: 'var(--dsw-alias-bg-layer-1)',
|
|
94
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
95
|
+
overflow: 'hidden',
|
|
96
|
+
};
|
|
97
|
+
const monoStyle = {
|
|
98
|
+
fontFamily: 'var(--dsw-alias-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
|
|
99
|
+
fontSize: 13,
|
|
100
|
+
fontVariantNumeric: 'tabular-nums',
|
|
101
|
+
};
|
|
102
|
+
const btnStyle = {
|
|
103
|
+
display: 'inline-flex',
|
|
104
|
+
alignItems: 'center',
|
|
105
|
+
justifyContent: 'center',
|
|
106
|
+
width: 30,
|
|
107
|
+
height: 30,
|
|
108
|
+
flex: 'none',
|
|
109
|
+
padding: 0,
|
|
110
|
+
borderRadius: 8,
|
|
111
|
+
background: 'transparent',
|
|
112
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
113
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
114
|
+
cursor: 'pointer',
|
|
115
|
+
};
|
|
116
|
+
const focusCss = '.dsguard-btn:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}';
|
|
117
|
+
|
|
118
|
+
function LockGlyph({ open }) {
|
|
119
|
+
return h('svg', { width: 15, height: 15, viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor', strokeWidth: 1.3, 'aria-hidden': 'true' },
|
|
120
|
+
h('rect', { x: 3, y: 7.5, width: 10, height: 6.5, rx: 1.6 }),
|
|
121
|
+
h('path', { d: open ? 'M5.5 7.5V5.5a2.5 2.5 0 0 1 4.9-.7' : 'M5.5 7.5V5.5a2.5 2.5 0 0 1 5 0v2' }));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function formatLeft(ms) {
|
|
125
|
+
const total = Math.max(0, Math.ceil((Number(ms) || 0) / 1000));
|
|
126
|
+
const m = Math.floor(total / 60);
|
|
127
|
+
const s = total % 60;
|
|
128
|
+
return `${m}:${String(s).padStart(2, '0')}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Shown when the page is opened from another machine and that peer is not
|
|
133
|
+
* in `allowCidrs`. Read-only: structure yes, values no. The whole point is
|
|
134
|
+
* that the fix (one JSON line, or open 127.0.0.1) is right here on screen.
|
|
135
|
+
*/
|
|
136
|
+
function RestrictedNotice({ t, host, suggested, dataDir }) {
|
|
137
|
+
const cidr = suggested || '192.168.0.0/16';
|
|
138
|
+
const snippet = `{\n "allowCidrs": ["${cidr}"]\n}`;
|
|
139
|
+
const file = dataDir ? `${dataDir}${dataDir.endsWith('/') || dataDir.endsWith('\\') ? '' : '/'}rules.json` : 'rules.json';
|
|
140
|
+
return h('div', {
|
|
141
|
+
style: {
|
|
142
|
+
display: 'grid',
|
|
143
|
+
gap: 8,
|
|
144
|
+
padding: '10px 12px',
|
|
145
|
+
borderRadius: 10,
|
|
146
|
+
border: '1px solid rgba(239,159,39,0.55)',
|
|
147
|
+
background: 'rgba(239,159,39,0.10)',
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
h('span', { style: { fontSize: 12, fontWeight: 500, color: 'var(--dsw-alias-state-warn-label)' } },
|
|
151
|
+
`⚠ ${t('restrictedTitle')}`),
|
|
152
|
+
h('p', { style: { fontSize: 11.5, lineHeight: 1.65, margin: 0, color: 'var(--dsw-alias-label-secondary)' } },
|
|
153
|
+
t('restrictedBody', { host: host || '—' })),
|
|
154
|
+
h('p', { style: { fontSize: 11.5, lineHeight: 1.65, margin: 0, color: 'var(--dsw-alias-label-secondary)' } },
|
|
155
|
+
t('restrictedFile')),
|
|
156
|
+
h('code', {
|
|
157
|
+
style: {
|
|
158
|
+
...monoStyle,
|
|
159
|
+
display: 'block',
|
|
160
|
+
padding: '8px 10px',
|
|
161
|
+
borderRadius: 8,
|
|
162
|
+
background: 'var(--dsw-alias-bg-layer-1)',
|
|
163
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
164
|
+
fontSize: 11.5,
|
|
165
|
+
lineHeight: 1.6,
|
|
166
|
+
whiteSpace: 'pre',
|
|
167
|
+
userSelect: 'text',
|
|
168
|
+
},
|
|
169
|
+
}, `${file}\n${snippet}`),
|
|
170
|
+
h('p', { style: { fontSize: 11.5, lineHeight: 1.65, margin: 0, color: 'var(--dsw-alias-label-secondary)' } },
|
|
171
|
+
t('restrictedAlt')));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function GuardView({ t }) {
|
|
175
|
+
const [data, setData] = useState(null);
|
|
176
|
+
const [status, setStatus] = useState('loading');
|
|
177
|
+
const [shown, setShown] = useState({});
|
|
178
|
+
const [guard, setGuard] = useState(null);
|
|
179
|
+
// Set when the server explains a rejection; keeps the page actionable
|
|
180
|
+
// instead of showing a bare "failed to load".
|
|
181
|
+
const [denied, setDenied] = useState(null);
|
|
182
|
+
const timers = useRef({});
|
|
183
|
+
const alive = useRef(true);
|
|
184
|
+
|
|
185
|
+
const load = useCallback(async (signal) => {
|
|
186
|
+
try {
|
|
187
|
+
const res = await fetch('/guard/vault', { cache: 'no-store', signal });
|
|
188
|
+
const json = await res.json().catch(() => null);
|
|
189
|
+
if (!alive.current) return;
|
|
190
|
+
if (!res.ok) {
|
|
191
|
+
setDenied(json && (json.hint || json.suggested)
|
|
192
|
+
? { host: json.host || '', suggested: json.suggested || '', hint: json.hint || '' }
|
|
193
|
+
: { host: '', suggested: '', hint: '' });
|
|
194
|
+
setStatus('error');
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
setDenied(null);
|
|
198
|
+
setData(json);
|
|
199
|
+
setStatus('ready');
|
|
200
|
+
} catch (error) {
|
|
201
|
+
if (!alive.current || (error && error.name === 'AbortError')) return;
|
|
202
|
+
setStatus('error');
|
|
203
|
+
}
|
|
204
|
+
}, []);
|
|
205
|
+
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
alive.current = true;
|
|
208
|
+
const controller = new AbortController();
|
|
209
|
+
load(controller.signal);
|
|
210
|
+
const poll = window.setInterval(() => load(controller.signal), POLL_MS);
|
|
211
|
+
const timersRef = timers.current;
|
|
212
|
+
return () => {
|
|
213
|
+
alive.current = false;
|
|
214
|
+
controller.abort();
|
|
215
|
+
window.clearInterval(poll);
|
|
216
|
+
Object.values(timersRef).forEach((id) => window.clearTimeout(id));
|
|
217
|
+
};
|
|
218
|
+
}, [load]);
|
|
219
|
+
|
|
220
|
+
const reveal = useCallback(async (ph) => {
|
|
221
|
+
try {
|
|
222
|
+
const res = await fetch('/guard/reveal', {
|
|
223
|
+
method: 'POST',
|
|
224
|
+
headers: { 'content-type': 'application/json' },
|
|
225
|
+
body: JSON.stringify({ placeholder: ph }),
|
|
226
|
+
});
|
|
227
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
228
|
+
const json = await res.json();
|
|
229
|
+
if (!alive.current) return;
|
|
230
|
+
setShown((prev) => ({ ...prev, [ph]: json.plaintext }));
|
|
231
|
+
window.clearTimeout(timers.current[ph]);
|
|
232
|
+
timers.current[ph] = window.setTimeout(() => {
|
|
233
|
+
setShown((prev) => {
|
|
234
|
+
const next = { ...prev };
|
|
235
|
+
delete next[ph];
|
|
236
|
+
return next;
|
|
237
|
+
});
|
|
238
|
+
}, REVEAL_MS);
|
|
239
|
+
} catch {
|
|
240
|
+
/* keep masked; the row simply stays closed */
|
|
241
|
+
}
|
|
242
|
+
}, []);
|
|
243
|
+
|
|
244
|
+
const toggle = useCallback((ph) => {
|
|
245
|
+
if (shown[ph]) {
|
|
246
|
+
window.clearTimeout(timers.current[ph]);
|
|
247
|
+
setShown((prev) => {
|
|
248
|
+
const next = { ...prev };
|
|
249
|
+
delete next[ph];
|
|
250
|
+
return next;
|
|
251
|
+
});
|
|
252
|
+
} else {
|
|
253
|
+
reveal(ph);
|
|
254
|
+
}
|
|
255
|
+
}, [shown, reveal]);
|
|
256
|
+
|
|
257
|
+
const loadGuard = useCallback(async () => {
|
|
258
|
+
try {
|
|
259
|
+
const res = await fetch('/guard/settings', { cache: 'no-store' });
|
|
260
|
+
if (res.ok) setGuard(await res.json());
|
|
261
|
+
} catch { /* leave the last known state */ }
|
|
262
|
+
}, []);
|
|
263
|
+
|
|
264
|
+
const applyPause = useCallback(async (payload) => {
|
|
265
|
+
try {
|
|
266
|
+
const res = await fetch('/guard/settings', {
|
|
267
|
+
method: 'POST',
|
|
268
|
+
headers: { 'content-type': 'application/json' },
|
|
269
|
+
body: JSON.stringify(payload),
|
|
270
|
+
});
|
|
271
|
+
if (res.ok) setGuard(await res.json());
|
|
272
|
+
} catch { /* ignore */ }
|
|
273
|
+
}, []);
|
|
274
|
+
|
|
275
|
+
useEffect(() => { loadGuard(); }, [loadGuard]);
|
|
276
|
+
|
|
277
|
+
const paused = Boolean(guard && guard.paused);
|
|
278
|
+
const untilRestart = Boolean(guard && guard.untilRestart);
|
|
279
|
+
|
|
280
|
+
useEffect(() => {
|
|
281
|
+
if (!paused || untilRestart) return undefined;
|
|
282
|
+
const id = window.setInterval(() => {
|
|
283
|
+
setGuard((prev) => {
|
|
284
|
+
if (!prev || !prev.paused) return prev;
|
|
285
|
+
const left = (prev.remainingMs || 0) - 1000;
|
|
286
|
+
if (left <= 0) { loadGuard(); return prev; }
|
|
287
|
+
return { ...prev, remainingMs: left };
|
|
288
|
+
});
|
|
289
|
+
}, 1000);
|
|
290
|
+
return () => window.clearInterval(id);
|
|
291
|
+
}, [paused, untilRestart, loadGuard]);
|
|
292
|
+
|
|
293
|
+
const entries = (data && data.entries) || [];
|
|
294
|
+
const access = (data && data.access) || denied || {};
|
|
295
|
+
// Either the server degraded us to metadata-only, or it refused outright.
|
|
296
|
+
const restricted = Boolean(data && data.restricted) || Boolean(denied);
|
|
297
|
+
|
|
298
|
+
return h('div', { style: { display: 'grid', gap: 14 } },
|
|
299
|
+
h('style', {}, focusCss),
|
|
300
|
+
restricted
|
|
301
|
+
? h(RestrictedNotice, {
|
|
302
|
+
t,
|
|
303
|
+
host: access.host,
|
|
304
|
+
suggested: access.suggested,
|
|
305
|
+
dataDir: data && data.dataDir,
|
|
306
|
+
})
|
|
307
|
+
: h('div', {
|
|
308
|
+
style: {
|
|
309
|
+
display: 'flex',
|
|
310
|
+
alignItems: 'center',
|
|
311
|
+
gap: 8,
|
|
312
|
+
flexWrap: 'wrap',
|
|
313
|
+
padding: '8px 12px',
|
|
314
|
+
borderRadius: 10,
|
|
315
|
+
border: `1px solid ${paused ? 'rgba(239,159,39,0.55)' : 'var(--dsw-alias-border-l2)'}`,
|
|
316
|
+
background: paused ? 'rgba(239,159,39,0.12)' : 'var(--dsw-alias-bg-layer-1)',
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
h('span', {
|
|
320
|
+
style: {
|
|
321
|
+
fontSize: 12,
|
|
322
|
+
fontWeight: 500,
|
|
323
|
+
color: paused ? 'var(--dsw-alias-state-warn-label)' : 'var(--dsw-alias-label-primary)',
|
|
324
|
+
},
|
|
325
|
+
}, paused ? `● ${t('statusOff')}` : `● ${t('statusOn')}`),
|
|
326
|
+
paused
|
|
327
|
+
? h('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-secondary)' } },
|
|
328
|
+
untilRestart ? t('pauseRestartLeft') : formatLeft(guard && guard.remainingMs))
|
|
329
|
+
: null,
|
|
330
|
+
h('span', { style: { flex: 1 } }),
|
|
331
|
+
paused
|
|
332
|
+
? h('button', {
|
|
333
|
+
type: 'button',
|
|
334
|
+
className: 'dsguard-btn',
|
|
335
|
+
style: { ...btnStyle, width: 'auto', padding: '0 10px', fontSize: 12 },
|
|
336
|
+
onClick: () => applyPause({ action: 'resume' }),
|
|
337
|
+
}, t('resume'))
|
|
338
|
+
: [
|
|
339
|
+
h('button', {
|
|
340
|
+
key: 'p30', type: 'button', className: 'dsguard-btn',
|
|
341
|
+
style: { ...btnStyle, width: 'auto', padding: '0 10px', fontSize: 12 },
|
|
342
|
+
onClick: () => applyPause({ action: 'pause', minutes: 30 }),
|
|
343
|
+
}, t('pause30')),
|
|
344
|
+
h('button', {
|
|
345
|
+
key: 'p120', type: 'button', className: 'dsguard-btn',
|
|
346
|
+
style: { ...btnStyle, width: 'auto', padding: '0 10px', fontSize: 12 },
|
|
347
|
+
onClick: () => applyPause({ action: 'pause', minutes: 120 }),
|
|
348
|
+
}, t('pause120')),
|
|
349
|
+
h('button', {
|
|
350
|
+
key: 'pr', type: 'button', className: 'dsguard-btn',
|
|
351
|
+
style: { ...btnStyle, width: 'auto', padding: '0 10px', fontSize: 12 },
|
|
352
|
+
onClick: () => applyPause({ action: 'pause', untilRestart: true }),
|
|
353
|
+
}, t('pauseRestart')),
|
|
354
|
+
]),
|
|
355
|
+
paused
|
|
356
|
+
? h('p', { style: { fontSize: 11, color: 'var(--dsw-alias-state-warn-label)', margin: 0 } }, t('pausedHint'))
|
|
357
|
+
: null,
|
|
358
|
+
data && data.access && data.access.level === 'lan'
|
|
359
|
+
? h('p', { style: { fontSize: 11, lineHeight: 1.6, color: 'var(--dsw-alias-state-warn-label)', margin: 0 } },
|
|
360
|
+
t('lanExposed', { cidrs: (data.access.allowCidrs || []).join(', ') || '-' }))
|
|
361
|
+
: null,
|
|
362
|
+
h('div', { style: { display: 'flex', alignItems: 'baseline', gap: 12, flexWrap: 'wrap' } },
|
|
363
|
+
h('h3', { style: { fontSize: 13, fontWeight: 500, margin: 0, color: 'var(--dsw-alias-label-primary)' } }, t('heading')),
|
|
364
|
+
data ? h('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-dimmed)' } }, t('stats', { count: String(entries.length) })) : null,
|
|
365
|
+
h('button', {
|
|
366
|
+
type: 'button',
|
|
367
|
+
className: 'dsguard-btn',
|
|
368
|
+
style: { ...btnStyle, width: 'auto', padding: '0 10px', marginLeft: 'auto', fontSize: 12, gap: 6 },
|
|
369
|
+
onClick: () => load(),
|
|
370
|
+
}, t('refresh'))),
|
|
371
|
+
h('p', { style: { fontSize: 12, lineHeight: 1.6, color: 'var(--dsw-alias-label-secondary)', margin: 0 } }, t('intro')),
|
|
372
|
+
status === 'loading' && !data
|
|
373
|
+
? h('p', { style: { fontSize: 12, color: 'var(--dsw-alias-label-dimmed)' } }, t('loading'))
|
|
374
|
+
: status === 'error' && !data
|
|
375
|
+
? (denied ? null : h('p', { style: { fontSize: 12, color: 'var(--dsw-alias-state-error-primary)' } }, t('error')))
|
|
376
|
+
: entries.length === 0
|
|
377
|
+
? h('p', { style: { fontSize: 12, color: 'var(--dsw-alias-label-dimmed)' } }, t('empty'))
|
|
378
|
+
: h('div', { style: { display: 'grid', gap: 8 } },
|
|
379
|
+
h('div', {
|
|
380
|
+
style: { display: 'flex', gap: 10, padding: '0 12px', fontSize: 11, color: 'var(--dsw-alias-label-dimmed)' },
|
|
381
|
+
},
|
|
382
|
+
h('span', { style: { flex: '1 1 46%' } }, t('colValue')),
|
|
383
|
+
h('span', { style: { flex: '0 0 90px' } }, t('colKind')),
|
|
384
|
+
h('span', { style: { flex: '0 0 140px' } }, t('colTime')),
|
|
385
|
+
h('span', { style: { flex: '0 0 48px' } }, t('colHits')),
|
|
386
|
+
h('span', { style: { flex: '0 0 30px' } }, '')),
|
|
387
|
+
...entries.map((entry) => {
|
|
388
|
+
const revealed = Boolean(shown[entry.ph]);
|
|
389
|
+
const value = revealed ? shown[entry.ph] : (entry.preview || entry.ph);
|
|
390
|
+
return h('div', { key: entry.ph, style: boxStyle },
|
|
391
|
+
h('span', { style: { flex: '1 1 46%', minWidth: 0, display: 'grid', gap: 2 } },
|
|
392
|
+
h('span', {
|
|
393
|
+
style: {
|
|
394
|
+
...monoStyle,
|
|
395
|
+
color: revealed ? 'var(--dsw-alias-state-warn-label)' : 'var(--dsw-alias-label-primary)',
|
|
396
|
+
overflow: 'hidden',
|
|
397
|
+
textOverflow: 'ellipsis',
|
|
398
|
+
whiteSpace: 'nowrap',
|
|
399
|
+
},
|
|
400
|
+
title: revealed ? undefined : entry.ph,
|
|
401
|
+
}, value),
|
|
402
|
+
h('span', { style: { ...monoStyle, fontSize: 11, color: 'var(--dsw-alias-label-dimmed)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, entry.ph)),
|
|
403
|
+
h('span', { style: { flex: '0 0 90px', fontSize: 12, color: 'var(--dsw-alias-label-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, entry.rule),
|
|
404
|
+
h('span', { style: { flex: '0 0 140px', fontSize: 12, color: 'var(--dsw-alias-label-dimmed)' } }, formatTime(entry.createdAt)),
|
|
405
|
+
h('span', { style: { flex: '0 0 48px', fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } }, String(entry.hits)),
|
|
406
|
+
h('button', {
|
|
407
|
+
type: 'button',
|
|
408
|
+
className: 'dsguard-btn',
|
|
409
|
+
style: { ...btnStyle, opacity: restricted ? 0.45 : 1, cursor: restricted ? 'not-allowed' : 'pointer' },
|
|
410
|
+
'aria-pressed': revealed,
|
|
411
|
+
'aria-label': restricted ? t('restrictedUnlock') : t(revealed ? 'hide' : 'reveal'),
|
|
412
|
+
title: restricted ? t('restrictedUnlock') : t(revealed ? 'hide' : 'reveal'),
|
|
413
|
+
disabled: restricted,
|
|
414
|
+
onClick: () => { if (!restricted) toggle(entry.ph); },
|
|
415
|
+
}, h(LockGlyph, { open: revealed })));
|
|
416
|
+
}),
|
|
417
|
+
revealedAny(shown)
|
|
418
|
+
? h('p', { style: { fontSize: 11, color: 'var(--dsw-alias-state-warn-label)', margin: 0 } }, t('autoHide'))
|
|
419
|
+
: null),
|
|
420
|
+
h('p', { style: { fontSize: 11, lineHeight: 1.6, color: 'var(--dsw-alias-label-dimmed)', margin: 0 } }, `${t('placeholder')} · ${t('usage')}`),
|
|
421
|
+
h('p', { style: { fontSize: 11, color: 'var(--dsw-alias-label-dimmed)', margin: 0 } }, `inline reveal: ${inlineState}`),
|
|
422
|
+
data && data.lastError
|
|
423
|
+
? h('p', { style: { fontSize: 11, color: 'var(--dsh-alias-state-error-primary, var(--dsw-alias-state-error-primary))', margin: 0 } }, `lastError: ${data.lastError}`)
|
|
424
|
+
: null);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function revealedAny(shown) {
|
|
428
|
+
return Object.keys(shown).length > 0;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const PH_RE = /<SG_[0-9a-f]{12}>/g;
|
|
432
|
+
/** Visible in Settings so a broken registration is diagnosable without DevTools. */
|
|
433
|
+
let inlineState = 'unknown';
|
|
434
|
+
|
|
435
|
+
/** Colour by what leaked, not by which regex fired. */
|
|
436
|
+
const TONES = {
|
|
437
|
+
password: { bg: 'rgba(226,75,74,0.16)', border: 'rgba(226,75,74,0.55)' },
|
|
438
|
+
token: { bg: 'rgba(127,119,221,0.18)', border: 'rgba(127,119,221,0.55)' },
|
|
439
|
+
key: { bg: 'rgba(29,158,117,0.18)', border: 'rgba(29,158,117,0.55)' },
|
|
440
|
+
other: { bg: 'rgba(85,135,200,0.16)', border: 'rgba(85,135,200,0.5)' },
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
const TONE_RULES = [
|
|
444
|
+
[/password|passwd|pwd|密码|口令|凭证|私钥/i, 'password'],
|
|
445
|
+
[/token|jwt|bearer|令牌/i, 'token'],
|
|
446
|
+
[/key|密钥|secret/i, 'key'],
|
|
447
|
+
];
|
|
448
|
+
|
|
449
|
+
function toneOf(label) {
|
|
450
|
+
const name = String(label || '');
|
|
451
|
+
for (const [re, tone] of TONE_RULES) if (re.test(name)) return TONES[tone];
|
|
452
|
+
return TONES.other;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** placeholder → vault metadata, so a chip can colour itself on first paint. */
|
|
456
|
+
let metaByPh = new Map();
|
|
457
|
+
let metaInflight = null;
|
|
458
|
+
|
|
459
|
+
function loadMeta() {
|
|
460
|
+
if (metaInflight) return metaInflight;
|
|
461
|
+
metaInflight = fetch('/guard/vault', { cache: 'no-store' })
|
|
462
|
+
.then((res) => (res.ok ? res.json() : null))
|
|
463
|
+
.then((json) => {
|
|
464
|
+
if (json && Array.isArray(json.entries)) {
|
|
465
|
+
metaByPh = new Map(json.entries.map((e) => [e.ph, e]));
|
|
466
|
+
}
|
|
467
|
+
return metaByPh;
|
|
468
|
+
})
|
|
469
|
+
.catch(() => metaByPh)
|
|
470
|
+
.finally(() => { metaInflight = null; });
|
|
471
|
+
return metaInflight;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Inline chip for a `<SG_…>` placeholder inside a user message. Clicking it
|
|
476
|
+
* asks the host for the plaintext (`/guard/reveal`, loopback-only) and
|
|
477
|
+
* re-masks on its own. The plaintext never enters model context: this is a
|
|
478
|
+
* browser fetch, not a tool result.
|
|
479
|
+
*/
|
|
480
|
+
function RevealChip({ ph, interactive }) {
|
|
481
|
+
const [value, setValue] = useState(null);
|
|
482
|
+
const [busy, setBusy] = useState(false);
|
|
483
|
+
const [tone, setTone] = useState(() => {
|
|
484
|
+
const entry = metaByPh.get(ph);
|
|
485
|
+
return toneOf(entry && (entry.label || entry.rule));
|
|
486
|
+
});
|
|
487
|
+
const timer = useRef(0);
|
|
488
|
+
|
|
489
|
+
useEffect(() => () => window.clearTimeout(timer.current), []);
|
|
490
|
+
|
|
491
|
+
useEffect(() => {
|
|
492
|
+
if (metaByPh.has(ph)) return undefined;
|
|
493
|
+
let alive = true;
|
|
494
|
+
loadMeta().then((map) => {
|
|
495
|
+
if (!alive) return;
|
|
496
|
+
const entry = map.get(ph);
|
|
497
|
+
if (entry) setTone(toneOf(entry.label || entry.rule));
|
|
498
|
+
});
|
|
499
|
+
return () => { alive = false; };
|
|
500
|
+
}, [ph]);
|
|
501
|
+
|
|
502
|
+
const toggle = useCallback(async () => {
|
|
503
|
+
if (value !== null) {
|
|
504
|
+
window.clearTimeout(timer.current);
|
|
505
|
+
setValue(null);
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
if (busy) return;
|
|
509
|
+
setBusy(true);
|
|
510
|
+
try {
|
|
511
|
+
const res = await fetch('/guard/reveal', {
|
|
512
|
+
method: 'POST',
|
|
513
|
+
headers: { 'content-type': 'application/json' },
|
|
514
|
+
body: JSON.stringify({ placeholder: ph }),
|
|
515
|
+
});
|
|
516
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
517
|
+
const json = await res.json();
|
|
518
|
+
// The chip sits immediately after the vendor prefix (`sk-`), so it must
|
|
519
|
+
// show only the tail — otherwise revealing reads as `sk-sk-abc123`.
|
|
520
|
+
setValue(typeof json.body === 'string' && json.body.length > 0 ? json.body : json.plaintext);
|
|
521
|
+
window.clearTimeout(timer.current);
|
|
522
|
+
timer.current = window.setTimeout(() => setValue(null), REVEAL_MS);
|
|
523
|
+
} catch {
|
|
524
|
+
/* remain masked */
|
|
525
|
+
} finally {
|
|
526
|
+
setBusy(false);
|
|
527
|
+
}
|
|
528
|
+
}, [value, busy, ph]);
|
|
529
|
+
|
|
530
|
+
return h('span', {
|
|
531
|
+
style: {
|
|
532
|
+
display: 'inline-flex',
|
|
533
|
+
alignItems: 'center',
|
|
534
|
+
gap: 0,
|
|
535
|
+
padding: '0 1px 0 7px',
|
|
536
|
+
margin: '0 2px',
|
|
537
|
+
borderRadius: 6,
|
|
538
|
+
border: `1px solid ${tone.border}`,
|
|
539
|
+
background: value !== null ? 'rgba(212,90,48,0.20)' : tone.bg,
|
|
540
|
+
fontFamily: 'var(--dsw-alias-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
|
|
541
|
+
fontSize: '0.92em',
|
|
542
|
+
lineHeight: 1.5,
|
|
543
|
+
verticalAlign: 'baseline',
|
|
544
|
+
whiteSpace: 'nowrap',
|
|
545
|
+
},
|
|
546
|
+
},
|
|
547
|
+
h('span', {
|
|
548
|
+
style: {
|
|
549
|
+
color: value !== null ? 'var(--dsw-alias-state-warn-label)' : 'var(--dsw-alias-label-secondary)',
|
|
550
|
+
userSelect: value !== null ? 'text' : 'none',
|
|
551
|
+
},
|
|
552
|
+
title: value !== null ? undefined : ph,
|
|
553
|
+
}, value !== null ? value : ph),
|
|
554
|
+
interactive === false
|
|
555
|
+
? null
|
|
556
|
+
: h('button', {
|
|
557
|
+
type: 'button',
|
|
558
|
+
className: 'dsguard-btn',
|
|
559
|
+
style: {
|
|
560
|
+
display: 'inline-flex',
|
|
561
|
+
alignItems: 'center',
|
|
562
|
+
justifyContent: 'center',
|
|
563
|
+
width: 18,
|
|
564
|
+
height: 18,
|
|
565
|
+
flex: 'none',
|
|
566
|
+
padding: 0,
|
|
567
|
+
borderRadius: 5,
|
|
568
|
+
border: 'none',
|
|
569
|
+
background: 'transparent',
|
|
570
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
571
|
+
cursor: 'pointer',
|
|
572
|
+
},
|
|
573
|
+
'aria-pressed': value !== null,
|
|
574
|
+
'aria-label': value !== null ? '隐藏明文' : '显示明文',
|
|
575
|
+
title: value !== null ? '隐藏明文' : '显示明文',
|
|
576
|
+
disabled: busy,
|
|
577
|
+
onClick: toggle,
|
|
578
|
+
}, h(LockGlyph, { open: value !== null })));
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Hosts contributed by the conversation package. Everything here is
|
|
583
|
+
* best-effort: a missing service or a changed slot contract must degrade to
|
|
584
|
+
* "placeholder shown as plain text", never break the message list.
|
|
585
|
+
*/
|
|
586
|
+
function registerInlineReveal(ctx, slots) {
|
|
587
|
+
const install = (conv) => {
|
|
588
|
+
if (!conv || !conv.userText || typeof conv.userText.register !== 'function') {
|
|
589
|
+
inlineState = 'no-service';
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
try {
|
|
594
|
+
conv.userText.register({
|
|
595
|
+
id: 'sensitive-guard',
|
|
596
|
+
order: 20,
|
|
597
|
+
match(text, from) {
|
|
598
|
+
PH_RE.lastIndex = from;
|
|
599
|
+
const m = PH_RE.exec(text);
|
|
600
|
+
if (m === null) return undefined;
|
|
601
|
+
return {
|
|
602
|
+
id: 'sensitive-guard',
|
|
603
|
+
start: m.index,
|
|
604
|
+
end: m.index + m[0].length,
|
|
605
|
+
data: { ph: m[0] },
|
|
606
|
+
};
|
|
607
|
+
},
|
|
608
|
+
});
|
|
609
|
+
} catch (error) {
|
|
610
|
+
inlineState = 'matcher-failed';
|
|
611
|
+
console.warn('[sensitive-guard] userText rule registration failed:', error && error.message);
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
try {
|
|
616
|
+
// Keyed child slot: it matches on `key`, not `id`, and the injector is
|
|
617
|
+
// a generator whose yielded disposers the slot owns. Registering with
|
|
618
|
+
// `id` silently falls through to "missing renderer", which shows the
|
|
619
|
+
// original fragment — the placeholder stays plain text.
|
|
620
|
+
slots.inject('conversation.user.text.node', function* () {
|
|
621
|
+
try {
|
|
622
|
+
yield slots.register({
|
|
623
|
+
name: 'conversation.user.text.node',
|
|
624
|
+
key: 'sensitive-guard',
|
|
625
|
+
priority: 100,
|
|
626
|
+
}, (props) => h(RevealChip, {
|
|
627
|
+
ph: props && props.node && props.node.data ? props.node.data.ph : '',
|
|
628
|
+
interactive: props ? props.interactive : true,
|
|
629
|
+
}));
|
|
630
|
+
inlineState = 'ok';
|
|
631
|
+
} catch (error) {
|
|
632
|
+
inlineState = 'renderer-failed';
|
|
633
|
+
console.warn('[sensitive-guard] inline renderer registration failed:', error && error.message);
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
} catch (error) {
|
|
637
|
+
inlineState = 'slot-inject-failed';
|
|
638
|
+
console.warn('[sensitive-guard] conversation slot injection failed:', error && error.message);
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
|
|
642
|
+
// `ctx.get` is a one-shot probe. On a cold boot the conversation package
|
|
643
|
+
// may not have provided `uiConversation` yet: it returns undefined, and the
|
|
644
|
+
// chip then stays off for the whole session. That is exactly why the lock
|
|
645
|
+
// showed up on one launch and disappeared on the next. Declare the
|
|
646
|
+
// dependency instead — cordis calls back when the service arrives, and
|
|
647
|
+
// again if it is re-created.
|
|
648
|
+
let ready = null;
|
|
649
|
+
try {
|
|
650
|
+
ready = typeof ctx.get === 'function' ? ctx.get('uiConversation') : null;
|
|
651
|
+
} catch {
|
|
652
|
+
/* not provided yet; fall through to the declared wait */
|
|
653
|
+
}
|
|
654
|
+
if (ready) {
|
|
655
|
+
install(ready);
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
inlineState = 'waiting';
|
|
660
|
+
try {
|
|
661
|
+
ctx.inject(['uiConversation'], (sub) => install(sub.uiConversation));
|
|
662
|
+
} catch (error) {
|
|
663
|
+
inlineState = 'no-service';
|
|
664
|
+
console.warn('[sensitive-guard] cannot wait for uiConversation:', error && error.message);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function formatTime(iso) {
|
|
669
|
+
const d = new Date(iso);
|
|
670
|
+
if (Number.isNaN(d.getTime())) return '';
|
|
671
|
+
return d.toLocaleString(undefined, { hour12: false });
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function apply(ctx) {
|
|
675
|
+
// The host is fail-fast: a throwing client entry stops the entire app from
|
|
676
|
+
// booting ("web boot: 1 entry did not activate"). Never let one plugin do that.
|
|
677
|
+
try {
|
|
678
|
+
applyEntry(ctx);
|
|
679
|
+
} catch (error) {
|
|
680
|
+
console.warn('[sensitive-guard] plugin disabled after error:', error && error.message);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function applyEntry(ctx) {
|
|
685
|
+
const slots = ctx && ctx.slots;
|
|
686
|
+
if (!slots || typeof slots.inject !== 'function' || typeof slots.register !== 'function') return;
|
|
687
|
+
// Inline reveal only needs slots, so register it before the settings page —
|
|
688
|
+
// a missing locale service must not cost users the in-conversation lock.
|
|
689
|
+
registerInlineReveal(ctx, slots);
|
|
690
|
+
const locale = ctx && ctx.locale;
|
|
691
|
+
if (!locale) return;
|
|
692
|
+
if (typeof locale.register !== 'function' || typeof locale.bind !== 'function') return;
|
|
693
|
+
try {
|
|
694
|
+
ctx.effect(() => locale.register(NS, { zh, en }), 'sensitive-guard: dictionaries');
|
|
695
|
+
} catch (error) {
|
|
696
|
+
console.warn('[sensitive-guard] dictionary registration failed:', error && error.message);
|
|
697
|
+
}
|
|
698
|
+
const t = locale.bind(NS);
|
|
699
|
+
slots.inject('settings.section', () => {
|
|
700
|
+
try {
|
|
701
|
+
return slots.register({
|
|
702
|
+
name: 'settings.section',
|
|
703
|
+
id: 'sensitive-guard',
|
|
704
|
+
order: 60,
|
|
705
|
+
label: () => t('nav'),
|
|
706
|
+
locale: NS,
|
|
707
|
+
inject: () => ({ t }),
|
|
708
|
+
}, () => h(GuardView, { t }));
|
|
709
|
+
} catch (error) {
|
|
710
|
+
console.warn('[sensitive-guard] settings section registration failed:', error && error.message);
|
|
711
|
+
return undefined;
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
return { inject: ['slots', 'locale'], apply };
|
|
717
|
+
},
|
|
718
|
+
});
|