@netcatty/dsh-guard 0.0.0-stage → 0.2.0
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 +23 -0
- package/LICENSE +21 -0
- package/README.md +595 -2
- package/cordis.patch.yml +3 -0
- package/lib/audit-log.js +64 -0
- package/lib/client.js +679 -0
- package/lib/diff-view.js +155 -0
- package/lib/index.js +1954 -0
- package/lib/jsonc.js +140 -0
- package/lib/mask.js +22 -0
- package/lib/match.js +119 -0
- package/lib/params.js +105 -0
- package/lib/paths.js +140 -0
- package/lib/policy-edit.js +74 -0
- package/lib/presets.js +50 -0
- package/lib/preview.js +152 -0
- package/lib/shell.js +127 -0
- package/lib/snapshots.js +198 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +72 -4
- package/policy.json +289 -0
- package/policy.schema.json +317 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,679 @@
|
|
|
1
|
+
// Browser half of dsh-guard: the 权限守卫 tab inside 设置 → 内置插件.
|
|
2
|
+
//
|
|
3
|
+
// Only switches and scalar knobs are editable here. Rules stay in policy.json —
|
|
4
|
+
// they are nested maps with unlimited combinations, and a form that pretended to
|
|
5
|
+
// cover them would end up as a JSON textarea anyway. Everything this panel writes
|
|
6
|
+
// goes through POST /api/dsh-guard/config, which runs the same validate + backup
|
|
7
|
+
// path as `/guard-policy`, so the panel cannot produce a policy a hand edit could
|
|
8
|
+
// not.
|
|
9
|
+
//
|
|
10
|
+
// Hand-written CJS wrapper so the repository still needs no build step.
|
|
11
|
+
|
|
12
|
+
window.__ModuleLoader__.load({
|
|
13
|
+
id: '@netcatty/dsh-guard',
|
|
14
|
+
factory: (require) => {
|
|
15
|
+
const React = require('react');
|
|
16
|
+
const e = React.createElement;
|
|
17
|
+
|
|
18
|
+
const C = {
|
|
19
|
+
text: 'var(--dsh-text-primary, #111827)',
|
|
20
|
+
dim: 'var(--dsh-text-secondary, #6b7280)',
|
|
21
|
+
line: 'var(--dsh-border, #e5e7eb)',
|
|
22
|
+
surface: 'var(--dsh-surface, #ffffff)',
|
|
23
|
+
soft: 'var(--dsh-surface-muted, #f9fafb)',
|
|
24
|
+
accent: 'var(--dsh-accent, #2563eb)',
|
|
25
|
+
field: '#dfe3e8',
|
|
26
|
+
fieldBg: '#fbfcfd',
|
|
27
|
+
focusRing: 'rgba(37,99,235,0.14)',
|
|
28
|
+
ok: '#16794a',
|
|
29
|
+
okBg: '#e7f6ee',
|
|
30
|
+
warn: '#8a5a00',
|
|
31
|
+
warnBg: '#fff4d6',
|
|
32
|
+
bad: '#b3261e',
|
|
33
|
+
badBg: '#fdecea',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const mono = 'ui-monospace, SFMono-Regular, Menlo, monospace';
|
|
37
|
+
|
|
38
|
+
// One column geometry for every card, so the two parameter cards line up.
|
|
39
|
+
const GRID = '196px 168px minmax(0, 1fr)';
|
|
40
|
+
const GRID_SWITCH = '196px 38px minmax(0, 1fr)';
|
|
41
|
+
|
|
42
|
+
const styles = {
|
|
43
|
+
wrap: { display: 'flex', flexDirection: 'column', gap: 14, padding: '2px 0 8px' },
|
|
44
|
+
head: { display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' },
|
|
45
|
+
titleRow: { display: 'flex', alignItems: 'center', gap: 10 },
|
|
46
|
+
title: { fontSize: 17, fontWeight: 650, color: C.text, letterSpacing: '-0.01em' },
|
|
47
|
+
titleCode: { fontSize: 11, color: C.dim, fontFamily: mono },
|
|
48
|
+
subtitle: { fontSize: 11.5, color: C.dim, marginTop: 4, fontFamily: mono, wordBreak: 'break-all' },
|
|
49
|
+
pill: (tone) => ({
|
|
50
|
+
display: 'inline-flex', alignItems: 'center', gap: 6, height: 24, padding: '0 10px',
|
|
51
|
+
borderRadius: 999, fontSize: 11.5, fontWeight: 600, whiteSpace: 'nowrap',
|
|
52
|
+
color: tone === 'ok' ? C.ok : tone === 'warn' ? C.warn : C.dim,
|
|
53
|
+
background: tone === 'ok' ? C.okBg : tone === 'warn' ? C.warnBg : C.soft,
|
|
54
|
+
border: `1px solid ${tone === 'ok' ? '#bfe6cf' : tone === 'warn' ? '#f0dfae' : C.line}`,
|
|
55
|
+
}),
|
|
56
|
+
card: {
|
|
57
|
+
border: `1px solid ${C.line}`, borderRadius: 12, padding: '14px 16px 6px',
|
|
58
|
+
background: C.surface, boxShadow: '0 1px 2px rgba(15,23,42,0.03)',
|
|
59
|
+
},
|
|
60
|
+
cardHead: {
|
|
61
|
+
display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10,
|
|
62
|
+
paddingBottom: 10, borderBottom: `1px solid ${C.soft}`,
|
|
63
|
+
},
|
|
64
|
+
cardTitle: { fontSize: 12.5, fontWeight: 650, color: C.text },
|
|
65
|
+
cardHint: { fontSize: 11, color: C.dim, marginLeft: 'auto' },
|
|
66
|
+
row: {
|
|
67
|
+
display: 'grid', gridTemplateColumns: GRID,
|
|
68
|
+
alignItems: 'center', gap: 12, minHeight: 38, paddingBottom: 4,
|
|
69
|
+
},
|
|
70
|
+
rowSwitch: {
|
|
71
|
+
display: 'grid', gridTemplateColumns: GRID_SWITCH,
|
|
72
|
+
alignItems: 'center', gap: 12, minHeight: 38, paddingBottom: 4,
|
|
73
|
+
},
|
|
74
|
+
label: { display: 'flex', alignItems: 'baseline', gap: 6, minWidth: 0 },
|
|
75
|
+
labelZh: { fontSize: 12.5, color: C.text, whiteSpace: 'nowrap' },
|
|
76
|
+
labelCode: { fontSize: 10.5, color: C.dim, fontFamily: mono, whiteSpace: 'nowrap' },
|
|
77
|
+
note: {
|
|
78
|
+
fontSize: 11, color: C.dim, lineHeight: 1.5,
|
|
79
|
+
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
|
80
|
+
},
|
|
81
|
+
input: {
|
|
82
|
+
width: '100%', minWidth: 0, maxWidth: '100%', boxSizing: 'border-box',
|
|
83
|
+
padding: '6px 9px', fontSize: 12, color: C.text,
|
|
84
|
+
border: `1px solid ${C.field}`, borderRadius: 8, background: C.fieldBg,
|
|
85
|
+
fontVariantNumeric: 'tabular-nums', textAlign: 'right',
|
|
86
|
+
// The native spinner sits right against the digits and gives every field
|
|
87
|
+
// a different content width, so the numbers stop lining up. Hide it.
|
|
88
|
+
appearance: 'textfield',
|
|
89
|
+
WebkitAppearance: 'none',
|
|
90
|
+
MozAppearance: 'textfield',
|
|
91
|
+
transition: 'border-color 120ms ease, box-shadow 120ms ease, background 120ms ease',
|
|
92
|
+
},
|
|
93
|
+
// A native <select> popup is drawn by the OS and cannot be styled, which is
|
|
94
|
+
// why this panel draws its own list. The trigger mirrors the number inputs;
|
|
95
|
+
// the panel is absolutely positioned under it.
|
|
96
|
+
trigger: {
|
|
97
|
+
width: '100%', boxSizing: 'border-box', display: 'flex', alignItems: 'center', gap: 6,
|
|
98
|
+
padding: '6px 9px', fontSize: 12, color: C.text, cursor: 'pointer',
|
|
99
|
+
border: `1px solid ${C.field}`, borderRadius: 8, background: C.fieldBg,
|
|
100
|
+
transition: 'border-color 120ms ease, box-shadow 120ms ease, background 120ms ease',
|
|
101
|
+
},
|
|
102
|
+
caret: { fontSize: 8, color: C.dim, lineHeight: 1, flex: '0 0 auto' },
|
|
103
|
+
menu: {
|
|
104
|
+
position: 'absolute', top: 'calc(100% + 6px)', minWidth: '100%',
|
|
105
|
+
// Without border-box the padding + border make the panel wider than its
|
|
106
|
+
// trigger, so its leading edge sticks out.
|
|
107
|
+
boxSizing: 'border-box',
|
|
108
|
+
background: C.surface, border: `1px solid ${C.line}`, borderRadius: 10,
|
|
109
|
+
boxShadow: '0 12px 32px rgba(15,23,42,0.16), 0 2px 6px rgba(15,23,42,0.06)',
|
|
110
|
+
padding: 5, zIndex: 60,
|
|
111
|
+
display: 'flex', flexDirection: 'column', gap: 1,
|
|
112
|
+
},
|
|
113
|
+
menuItem: (active) => ({
|
|
114
|
+
display: 'flex', alignItems: 'center', gap: 10,
|
|
115
|
+
justifyContent: 'flex-start',
|
|
116
|
+
width: '100%', boxSizing: 'border-box',
|
|
117
|
+
padding: '7px 10px', fontSize: 12, border: 'none', borderRadius: 7,
|
|
118
|
+
cursor: 'pointer', whiteSpace: 'nowrap',
|
|
119
|
+
background: active ? '#eef2ff' : 'transparent',
|
|
120
|
+
color: active ? C.accent : C.text,
|
|
121
|
+
fontWeight: active ? 600 : 400,
|
|
122
|
+
transition: 'background 100ms ease',
|
|
123
|
+
}),
|
|
124
|
+
check: { fontSize: 11, color: C.accent, flex: '0 0 auto', width: 12, textAlign: 'center' },
|
|
125
|
+
inlineRow: { display: 'flex', alignItems: 'center', gap: 10, paddingBottom: 12, flexWrap: 'wrap' },
|
|
126
|
+
button: {
|
|
127
|
+
border: `1px solid ${C.line}`, borderRadius: 7, background: C.surface,
|
|
128
|
+
padding: '5px 12px', fontSize: 12, cursor: 'pointer', color: C.text, whiteSpace: 'nowrap',
|
|
129
|
+
},
|
|
130
|
+
buttonPrimary: {
|
|
131
|
+
border: `1px solid ${C.accent}`, borderRadius: 7, background: C.accent,
|
|
132
|
+
padding: '5px 12px', fontSize: 12, cursor: 'pointer', color: '#fff', fontWeight: 600, whiteSpace: 'nowrap',
|
|
133
|
+
},
|
|
134
|
+
listRow: {
|
|
135
|
+
display: 'flex', alignItems: 'center', gap: 8, minHeight: 26,
|
|
136
|
+
fontSize: 12, color: C.text, borderBottom: `1px solid ${C.soft}`, paddingBottom: 4, marginBottom: 4,
|
|
137
|
+
},
|
|
138
|
+
listRowLast: { display: 'flex', alignItems: 'center', gap: 8, minHeight: 26, fontSize: 12, color: C.text, paddingBottom: 2 },
|
|
139
|
+
listCard: { padding: '14px 16px 14px' },
|
|
140
|
+
mono: { fontFamily: mono },
|
|
141
|
+
dim: { color: C.dim },
|
|
142
|
+
empty: { fontSize: 12, color: C.dim, padding: '6px 0' },
|
|
143
|
+
error: {
|
|
144
|
+
color: C.bad, background: C.badBg, border: '1px solid #f5c2c0',
|
|
145
|
+
borderRadius: 8, padding: '8px 10px', fontSize: 12,
|
|
146
|
+
},
|
|
147
|
+
badge: (state) => {
|
|
148
|
+
const tone = state === 'deny' ? { c: C.bad, b: C.badBg } : state === 'ask' ? { c: C.warn, b: C.warnBg } : { c: C.ok, b: C.okBg };
|
|
149
|
+
return {
|
|
150
|
+
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
|
151
|
+
width: 52, height: 20, borderRadius: 999, padding: 0,
|
|
152
|
+
fontSize: 10.5, fontWeight: 700, letterSpacing: '0.02em', color: tone.c, background: tone.b,
|
|
153
|
+
flex: '0 0 auto',
|
|
154
|
+
};
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const PRESET_LABEL = { baseline: '基线', strict: '严格', off: '仅审计' };
|
|
159
|
+
|
|
160
|
+
function Label({ zh, code }) {
|
|
161
|
+
return e('span', { style: styles.label }, [
|
|
162
|
+
e('span', { key: 'z', style: styles.labelZh }, zh),
|
|
163
|
+
e('span', { key: 'c', style: styles.labelCode }, code),
|
|
164
|
+
]);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function Switch({ checked, disabled, onChange }) {
|
|
168
|
+
return e('button', {
|
|
169
|
+
type: 'button',
|
|
170
|
+
role: 'switch',
|
|
171
|
+
'aria-checked': checked === true,
|
|
172
|
+
disabled,
|
|
173
|
+
onClick: () => onChange(!checked),
|
|
174
|
+
style: {
|
|
175
|
+
width: 38, height: 22, borderRadius: 999, border: 'none', padding: 0,
|
|
176
|
+
position: 'relative', flex: '0 0 auto',
|
|
177
|
+
background: checked ? C.accent : '#cbd5e1',
|
|
178
|
+
cursor: disabled ? 'not-allowed' : 'pointer',
|
|
179
|
+
opacity: disabled ? 0.6 : 1,
|
|
180
|
+
transition: 'background 120ms ease',
|
|
181
|
+
},
|
|
182
|
+
}, e('span', {
|
|
183
|
+
style: {
|
|
184
|
+
position: 'absolute', top: 3, left: checked ? 19 : 3,
|
|
185
|
+
width: 16, height: 16, borderRadius: '50%', background: '#fff',
|
|
186
|
+
boxShadow: '0 1px 2px rgba(15,23,42,0.28)',
|
|
187
|
+
transition: 'left 120ms ease',
|
|
188
|
+
},
|
|
189
|
+
}));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function SwitchRow({ zh, code, hint, value, disabled, onChange }) {
|
|
193
|
+
return e('div', { style: styles.rowSwitch }, [
|
|
194
|
+
e(Label, { key: 'l', zh, code }),
|
|
195
|
+
e('span', { key: 's' }, e(Switch, { checked: value === true, disabled, onChange })),
|
|
196
|
+
e('span', { key: 'h', style: styles.note, title: hint }, hint),
|
|
197
|
+
]);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Native <select> popups are OS-drawn, so their item spacing, hover colour
|
|
201
|
+
// and radius can never match the panel. This trigger/panel pair can.
|
|
202
|
+
function Select({ value, options, disabled, onChange, align = 'right', boxStyle }) {
|
|
203
|
+
const [open, setOpen] = React.useState(false);
|
|
204
|
+
const boxRef = React.useRef(null);
|
|
205
|
+
|
|
206
|
+
React.useEffect(() => {
|
|
207
|
+
if (!open) return undefined;
|
|
208
|
+
const onDown = (event) => {
|
|
209
|
+
if (boxRef.current !== null && !boxRef.current.contains(event.target)) setOpen(false);
|
|
210
|
+
};
|
|
211
|
+
const onKey = (event) => { if (event.key === 'Escape') setOpen(false); };
|
|
212
|
+
document.addEventListener('mousedown', onDown);
|
|
213
|
+
document.addEventListener('keydown', onKey);
|
|
214
|
+
return () => {
|
|
215
|
+
document.removeEventListener('mousedown', onDown);
|
|
216
|
+
document.removeEventListener('keydown', onKey);
|
|
217
|
+
};
|
|
218
|
+
}, [open]);
|
|
219
|
+
|
|
220
|
+
const current = options.find((option) => option.value === value);
|
|
221
|
+
// Both dropdowns put the value on the left and the caret on the right, so
|
|
222
|
+
// the parameter picker and the preset picker look like the same control.
|
|
223
|
+
const justify = 'space-between';
|
|
224
|
+
|
|
225
|
+
return e('div', { ref: boxRef, style: { position: 'relative', ...(boxStyle ?? { width: '100%', minWidth: 0 }) } }, [
|
|
226
|
+
e('button', {
|
|
227
|
+
key: 't',
|
|
228
|
+
type: 'button',
|
|
229
|
+
disabled,
|
|
230
|
+
onClick: () => setOpen((was) => !was),
|
|
231
|
+
style: {
|
|
232
|
+
...styles.trigger,
|
|
233
|
+
justifyContent: justify,
|
|
234
|
+
opacity: disabled ? 0.6 : 1,
|
|
235
|
+
cursor: disabled ? 'not-allowed' : 'pointer',
|
|
236
|
+
borderColor: open ? C.accent : C.field,
|
|
237
|
+
background: open ? C.surface : C.fieldBg,
|
|
238
|
+
boxShadow: open ? `0 0 0 3px ${C.focusRing}` : 'none',
|
|
239
|
+
},
|
|
240
|
+
}, [
|
|
241
|
+
e('span', { key: 'v', style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, current?.label ?? value),
|
|
242
|
+
e('span', { key: 'c', style: styles.caret }, '▼'),
|
|
243
|
+
]),
|
|
244
|
+
open
|
|
245
|
+
? e('div', { key: 'm', style: { ...styles.menu, ...(align === 'right' ? { right: 0 } : { left: 0 }) }, role: 'listbox' }, options.map((option) => e('button', {
|
|
246
|
+
key: option.value,
|
|
247
|
+
type: 'button',
|
|
248
|
+
role: 'option',
|
|
249
|
+
'aria-selected': option.value === value,
|
|
250
|
+
onClick: () => {
|
|
251
|
+
setOpen(false);
|
|
252
|
+
if (option.value !== value) onChange(option.value);
|
|
253
|
+
},
|
|
254
|
+
style: styles.menuItem(option.value === value),
|
|
255
|
+
onMouseEnter: (event) => { event.currentTarget.style.background = option.value === value ? '#e0e7ff' : C.soft; },
|
|
256
|
+
onMouseLeave: (event) => { event.currentTarget.style.background = option.value === value ? '#eef2ff' : 'transparent'; },
|
|
257
|
+
}, [
|
|
258
|
+
// Every row reserves the check column, so selected and unselected
|
|
259
|
+
// labels start at the same x.
|
|
260
|
+
e('span', { key: 'k', style: styles.check }, option.value === value ? '✓' : ''),
|
|
261
|
+
e('span', { key: 'l' }, option.label),
|
|
262
|
+
])))
|
|
263
|
+
: null,
|
|
264
|
+
]);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Non-controlled input: the committed value comes back through `load()`, so
|
|
268
|
+
// the field is keyed by that value to force a remount after a save.
|
|
269
|
+
function NumberField({ zh, code, hint, value, min, disabled, onCommit }) {
|
|
270
|
+
return e('div', { style: styles.row }, [
|
|
271
|
+
e(Label, { key: 'l', zh, code }),
|
|
272
|
+
e('input', {
|
|
273
|
+
key: `i:${String(value)}`,
|
|
274
|
+
type: 'number',
|
|
275
|
+
min,
|
|
276
|
+
style: styles.input,
|
|
277
|
+
disabled,
|
|
278
|
+
defaultValue: value ?? 0,
|
|
279
|
+
onFocus: (event) => {
|
|
280
|
+
event.target.style.borderColor = C.accent;
|
|
281
|
+
event.target.style.background = C.surface;
|
|
282
|
+
event.target.style.boxShadow = `0 0 0 3px ${C.focusRing}`;
|
|
283
|
+
},
|
|
284
|
+
onBlur: (event) => {
|
|
285
|
+
event.target.style.borderColor = C.field;
|
|
286
|
+
event.target.style.background = C.fieldBg;
|
|
287
|
+
event.target.style.boxShadow = 'none';
|
|
288
|
+
const next = Number(event.target.value);
|
|
289
|
+
if (Number.isInteger(next) && next >= min && next !== value) onCommit(next);
|
|
290
|
+
else event.target.value = String(value ?? 0);
|
|
291
|
+
},
|
|
292
|
+
}),
|
|
293
|
+
e('span', { key: 'h', style: styles.note, title: hint }, hint),
|
|
294
|
+
]);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function Card({ title, hint, children, list }) {
|
|
298
|
+
return e('div', { style: list === true ? { ...styles.card, ...styles.listCard } : styles.card }, [
|
|
299
|
+
e('div', { key: 'h', style: styles.cardHead }, [
|
|
300
|
+
e('span', { key: 't', style: styles.cardTitle }, title),
|
|
301
|
+
hint === undefined ? null : e('span', { key: 'n', style: styles.cardHint }, hint),
|
|
302
|
+
]),
|
|
303
|
+
e('div', { key: 'b' }, children),
|
|
304
|
+
]);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function useGuardState() {
|
|
308
|
+
const [state, setState] = React.useState(null);
|
|
309
|
+
const [error, setError] = React.useState('');
|
|
310
|
+
const [busy, setBusy] = React.useState(false);
|
|
311
|
+
|
|
312
|
+
const load = React.useCallback(async () => {
|
|
313
|
+
try {
|
|
314
|
+
const response = await fetch('/api/dsh-guard/state', { headers: { accept: 'application/json' } });
|
|
315
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
316
|
+
setState(await response.json());
|
|
317
|
+
setError('');
|
|
318
|
+
} catch (problem) {
|
|
319
|
+
setError(problem instanceof Error ? problem.message : String(problem));
|
|
320
|
+
}
|
|
321
|
+
}, []);
|
|
322
|
+
|
|
323
|
+
const post = React.useCallback(async (path, payload) => {
|
|
324
|
+
setBusy(true);
|
|
325
|
+
try {
|
|
326
|
+
const response = await fetch(path, {
|
|
327
|
+
method: 'POST',
|
|
328
|
+
headers: { 'content-type': 'application/json' },
|
|
329
|
+
body: JSON.stringify(payload),
|
|
330
|
+
});
|
|
331
|
+
const body = await response.json();
|
|
332
|
+
if (!response.ok) throw new Error(body.error ?? `HTTP ${response.status}`);
|
|
333
|
+
await load();
|
|
334
|
+
} catch (problem) {
|
|
335
|
+
setError(problem instanceof Error ? problem.message : String(problem));
|
|
336
|
+
} finally {
|
|
337
|
+
setBusy(false);
|
|
338
|
+
}
|
|
339
|
+
}, [load]);
|
|
340
|
+
|
|
341
|
+
const save = React.useCallback((patch) => post('/api/dsh-guard/config', { patch }), [post]);
|
|
342
|
+
const applyPreset = React.useCallback((name) => post('/api/dsh-guard/preset', { name }), [post]);
|
|
343
|
+
|
|
344
|
+
React.useEffect(() => { load(); }, [load]);
|
|
345
|
+
return { state, error, busy, load, save, applyPreset };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function GuardTab() {
|
|
349
|
+
const { state, error, busy, save, applyPreset, load } = useGuardState();
|
|
350
|
+
const [preset, setPreset] = React.useState('baseline');
|
|
351
|
+
|
|
352
|
+
if (state === null) {
|
|
353
|
+
return e('div', { style: styles.wrap }, [
|
|
354
|
+
e('div', { key: 't', style: styles.title }, '权限守卫'),
|
|
355
|
+
e('div', { key: 'h', style: styles.note }, error === '' ? '读取中…' : `读取失败:${error}`),
|
|
356
|
+
]);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const config = state.config ?? {};
|
|
360
|
+
const presets = Array.isArray(state.presets) ? state.presets : [];
|
|
361
|
+
const presetLines = presets.map((line) => {
|
|
362
|
+
const parts = String(line).trim().split(/\s+/u);
|
|
363
|
+
return { name: parts[0] ?? '', desc: parts.slice(1).join(' ') };
|
|
364
|
+
}).filter((entry) => entry.name !== '');
|
|
365
|
+
const currentPreset = presetLines.find((entry) => entry.name === preset);
|
|
366
|
+
const recent = Array.isArray(state.recentDecisions) ? state.recentDecisions : [];
|
|
367
|
+
const snapshots = Array.isArray(state.snapshots) ? state.snapshots : [];
|
|
368
|
+
const enforcing = config.enforce !== false;
|
|
369
|
+
|
|
370
|
+
return e('div', { style: styles.wrap }, [
|
|
371
|
+
e('div', { key: 'head', style: styles.head }, [
|
|
372
|
+
e('div', { key: 'l' }, [
|
|
373
|
+
e('div', { key: 't', style: styles.titleRow }, [
|
|
374
|
+
e('span', { key: 'n', style: styles.title }, '权限守卫'),
|
|
375
|
+
e('span', { key: 'c', style: styles.titleCode }, 'dsh-guard'),
|
|
376
|
+
e('span', { key: 'p', style: styles.pill(enforcing ? 'ok' : 'warn') }, enforcing ? '拦截生效' : '仅审计'),
|
|
377
|
+
e('span', { key: 'r', style: styles.pill(config.repeatLimit > 0 ? 'ok' : 'muted') },
|
|
378
|
+
config.repeatLimit > 0 ? `循环检测 ${config.repeatLimit}` : '循环检测关闭'),
|
|
379
|
+
]),
|
|
380
|
+
e('div', { key: 's', style: styles.subtitle }, `策略:${state.policyPath ?? ''}`),
|
|
381
|
+
]),
|
|
382
|
+
e('button', { key: 'r', style: styles.button, disabled: busy, onClick: () => load() }, busy ? '刷新中…' : '刷新'),
|
|
383
|
+
]),
|
|
384
|
+
|
|
385
|
+
e(Card, { key: 'switches', title: '总开关', hint: '改动立即生效', list: true },
|
|
386
|
+
e('div', {}, [
|
|
387
|
+
e(SwitchRow, {
|
|
388
|
+
key: 'enforce', zh: '拦截开关', code: 'enforce', value: config.enforce, disabled: busy,
|
|
389
|
+
hint: '关闭后只审计不拦截', onChange: (value) => save({ enforce: value }),
|
|
390
|
+
}),
|
|
391
|
+
e(SwitchRow, {
|
|
392
|
+
key: 'yolo', zh: '自动放行', code: 'yolo', value: config.yolo, disabled: busy,
|
|
393
|
+
hint: '把询问当放行,拒绝仍然生效', onChange: (value) => save({ yolo: value }),
|
|
394
|
+
}),
|
|
395
|
+
e(SwitchRow, {
|
|
396
|
+
key: 'snapshots', zh: '写前快照', code: 'snapshots', value: config.snapshots, disabled: busy,
|
|
397
|
+
hint: '改文件前备份,可回滚', onChange: (value) => save({ snapshots: value }),
|
|
398
|
+
}),
|
|
399
|
+
e(SwitchRow, {
|
|
400
|
+
key: 'audit', zh: '审计日志', code: 'audit', value: config.audit, disabled: busy,
|
|
401
|
+
hint: '每次判定写入 audit.jsonl', onChange: (value) => save({ audit: value }),
|
|
402
|
+
}),
|
|
403
|
+
])),
|
|
404
|
+
|
|
405
|
+
e(Card, { key: 'repeat', title: '循环检测', hint: '同一调用反复执行时提醒' },
|
|
406
|
+
e('div', {}, [
|
|
407
|
+
e(NumberField, {
|
|
408
|
+
key: 'limit', zh: '触发次数', code: 'repeatLimit', min: 0, value: config.repeatLimit,
|
|
409
|
+
hint: '0 = 关闭', disabled: busy,
|
|
410
|
+
onCommit: (value) => save({ repeatLimit: value }),
|
|
411
|
+
}),
|
|
412
|
+
e(NumberField, {
|
|
413
|
+
key: 'window', zh: '检测窗口', code: 'repeatWindowMs', min: 1, value: config.repeatWindowMs,
|
|
414
|
+
hint: '毫秒', disabled: busy,
|
|
415
|
+
onCommit: (value) => save({ repeatWindowMs: value }),
|
|
416
|
+
}),
|
|
417
|
+
e('div', { key: 'action', style: styles.row }, [
|
|
418
|
+
e(Label, { key: 'l', zh: '超限处置', code: 'repeatAction' }),
|
|
419
|
+
e(Select, {
|
|
420
|
+
key: 's', disabled: busy, value: config.repeatAction ?? 'ask', align: 'right',
|
|
421
|
+
options: ['allow', 'ask', 'deny'].map((option) => ({ value: option, label: option })),
|
|
422
|
+
onChange: (value) => save({ repeatAction: value }),
|
|
423
|
+
}),
|
|
424
|
+
e('span', { key: 'h', style: styles.note }, '超限后如何处理'),
|
|
425
|
+
]),
|
|
426
|
+
])),
|
|
427
|
+
|
|
428
|
+
e(Card, { key: 'storage', title: '快照与审计', hint: '容量与轮转' },
|
|
429
|
+
e('div', {}, [
|
|
430
|
+
e(NumberField, {
|
|
431
|
+
key: 'files', zh: '快照文件数', code: 'snapshotMaxFiles', min: 0, value: config.snapshotMaxFiles,
|
|
432
|
+
hint: '单次最多几个', disabled: busy,
|
|
433
|
+
onCommit: (value) => save({ snapshotMaxFiles: value }),
|
|
434
|
+
}),
|
|
435
|
+
e(NumberField, {
|
|
436
|
+
key: 'bytes', zh: '单文件上限', code: 'snapshotMaxBytes', min: 0, value: config.snapshotMaxBytes,
|
|
437
|
+
hint: '字节,超出跳过', disabled: busy,
|
|
438
|
+
onCommit: (value) => save({ snapshotMaxBytes: value }),
|
|
439
|
+
}),
|
|
440
|
+
e(NumberField, {
|
|
441
|
+
key: 'retain', zh: '保留份数', code: 'snapshotRetain', min: 0, value: config.snapshotRetain,
|
|
442
|
+
hint: '最旧的先删', disabled: busy,
|
|
443
|
+
onCommit: (value) => save({ snapshotRetain: value }),
|
|
444
|
+
}),
|
|
445
|
+
e(NumberField, {
|
|
446
|
+
key: 'audit', zh: '审计轮转', code: 'auditMaxBytes', min: 0, value: config.auditMaxBytes,
|
|
447
|
+
hint: '超出后滚动到 .1', disabled: busy,
|
|
448
|
+
onCommit: (value) => save({ auditMaxBytes: value }),
|
|
449
|
+
}),
|
|
450
|
+
])),
|
|
451
|
+
|
|
452
|
+
e(Card, { key: 'preset', title: '预设模板', hint: '合并进当前策略,不替换' },
|
|
453
|
+
e('div', { style: styles.inlineRow }, [
|
|
454
|
+
e(Select, {
|
|
455
|
+
key: 's', disabled: busy, value: preset, align: 'left',
|
|
456
|
+
boxStyle: { position: 'relative', width: 168, flex: '0 0 auto' },
|
|
457
|
+
options: presetLines.map((entry) => ({ value: entry.name, label: PRESET_LABEL[entry.name] ?? entry.name })),
|
|
458
|
+
onChange: setPreset,
|
|
459
|
+
}),
|
|
460
|
+
e('button', {
|
|
461
|
+
key: 'b', style: styles.buttonPrimary, disabled: busy,
|
|
462
|
+
onClick: () => applyPreset(preset),
|
|
463
|
+
}, '应用'),
|
|
464
|
+
e('span', { key: 'h', style: { ...styles.note, flex: '1 1 240px' } }, currentPreset?.desc ?? ''),
|
|
465
|
+
])),
|
|
466
|
+
|
|
467
|
+
e(Card, { key: 'recent', title: '最近判定', hint: `${recent.length} 条`, list: true },
|
|
468
|
+
recent.length === 0
|
|
469
|
+
? e('div', { style: styles.empty }, '暂无记录')
|
|
470
|
+
: e('div', {}, recent.map((row, index) => e('div', {
|
|
471
|
+
key: `${row.at}-${index}`,
|
|
472
|
+
style: index === recent.length - 1 ? styles.listRowLast : styles.listRow,
|
|
473
|
+
}, [
|
|
474
|
+
e('span', { key: 's', style: styles.badge(row.state) }, row.state),
|
|
475
|
+
e('span', { key: 't', style: styles.mono }, row.tool),
|
|
476
|
+
e('span', { key: 'p', style: { ...styles.mono, ...styles.dim } }, row.pattern ?? ''),
|
|
477
|
+
e('span', { key: 'd', style: { ...styles.dim, marginLeft: 'auto', fontSize: 11 } },
|
|
478
|
+
row.at === undefined ? '' : String(row.at).slice(11, 19)),
|
|
479
|
+
])))),
|
|
480
|
+
|
|
481
|
+
e(Card, {
|
|
482
|
+
key: 'snapshots',
|
|
483
|
+
title: '可回滚快照',
|
|
484
|
+
hint: snapshots.length === 0 ? '' : `${snapshots.length} 条 · 用 /guard-undo 回滚`,
|
|
485
|
+
list: true,
|
|
486
|
+
},
|
|
487
|
+
snapshots.length === 0
|
|
488
|
+
? e('div', { style: styles.empty }, '暂无快照')
|
|
489
|
+
: e('div', {}, snapshots.map((entry, index) => e('div', {
|
|
490
|
+
key: entry.id,
|
|
491
|
+
style: index === snapshots.length - 1 ? styles.listRowLast : styles.listRow,
|
|
492
|
+
}, [
|
|
493
|
+
e('span', { key: 't', style: styles.mono }, entry.tool ?? 'tool'),
|
|
494
|
+
e('span', { key: 'p', style: { ...styles.mono, ...styles.dim } },
|
|
495
|
+
(entry.paths ?? []).map((path) => String(path).split('/').pop()).join(', ')),
|
|
496
|
+
e('span', { key: 'd', style: { ...styles.dim, marginLeft: 'auto', fontSize: 11 } },
|
|
497
|
+
entry.at === undefined ? '' : String(entry.at).slice(11, 19)),
|
|
498
|
+
])))),
|
|
499
|
+
|
|
500
|
+
error === '' ? null : e('div', { key: 'err', style: styles.error }, error),
|
|
501
|
+
]);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Side-by-side rows for the dialog: common prefix/suffix stay aligned and the
|
|
505
|
+
// middle is paired positionally. Mirrors lib/diff-view.js without importing
|
|
506
|
+
// server code into the browser bundle.
|
|
507
|
+
function pairRows(before, after) {
|
|
508
|
+
const a = String(before ?? '').split('\n');
|
|
509
|
+
const b = String(after ?? '').split('\n');
|
|
510
|
+
let head = 0;
|
|
511
|
+
while (head < a.length && head < b.length && a[head] === b[head]) head += 1;
|
|
512
|
+
let tailA = a.length - 1;
|
|
513
|
+
let tailB = b.length - 1;
|
|
514
|
+
while (tailA >= head && tailB >= head && a[tailA] === b[tailB]) { tailA -= 1; tailB -= 1; }
|
|
515
|
+
const rows = [];
|
|
516
|
+
for (let index = 0; index < head; index += 1) rows.push({ left: a[index], right: b[index], kind: 'same' });
|
|
517
|
+
const middle = Math.max(tailA - head + 1, tailB - head + 1);
|
|
518
|
+
for (let index = 0; index < middle; index += 1) {
|
|
519
|
+
const left = head + index <= tailA ? a[head + index] : null;
|
|
520
|
+
const right = head + index <= tailB ? b[head + index] : null;
|
|
521
|
+
const kind = left === right ? 'same' : left === null ? 'add' : right === null ? 'del' : 'change';
|
|
522
|
+
rows.push({ left, right, kind });
|
|
523
|
+
}
|
|
524
|
+
for (let index = 0; tailA + 1 + index < a.length; index += 1) {
|
|
525
|
+
const left = a[tailA + 1 + index];
|
|
526
|
+
const right = b[tailB + 1 + index];
|
|
527
|
+
rows.push({ left, right, kind: left === right ? 'same' : 'change' });
|
|
528
|
+
}
|
|
529
|
+
return rows;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const overlayStyles = {
|
|
533
|
+
backdrop: { position: 'fixed', inset: 0, background: 'rgba(15,23,42,0.42)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '4vh 16px', zIndex: 900, overflowY: 'auto' },
|
|
534
|
+
card: { width: 'min(940px, 94vw)', maxWidth: '94vw', boxSizing: 'border-box', background: '#fff', borderRadius: 14, boxShadow: '0 24px 60px rgba(15,23,42,0.32)', padding: '18px 20px 16px', fontFamily: 'system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif' },
|
|
535
|
+
cardNarrow: { width: 'min(480px, 92vw)' },
|
|
536
|
+
header: { display: 'flex', alignItems: 'baseline', gap: 10, marginBottom: 4 },
|
|
537
|
+
eyebrow: { fontSize: 11, color: '#6b7280', letterSpacing: '0.04em' },
|
|
538
|
+
title: { fontSize: 16, fontWeight: 650, margin: 0, color: '#111827' },
|
|
539
|
+
rule: { fontSize: 12, color: '#6b7280', marginBottom: 12, fontFamily: 'ui-monospace, Menlo, monospace' },
|
|
540
|
+
table: { width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed', fontFamily: 'ui-monospace, Menlo, monospace', fontSize: 12, maxHeight: 340, overflow: 'auto', display: 'block', border: '1px solid #e5e7eb', borderRadius: 8 },
|
|
541
|
+
th: { position: 'sticky', top: 0, background: '#f9fafb', padding: '5px 8px', textAlign: 'left', fontSize: 11, color: '#6b7280', borderBottom: '1px solid #e5e7eb' },
|
|
542
|
+
td: { padding: '1px 8px', verticalAlign: 'top', whiteSpace: 'pre-wrap', wordBreak: 'break-word', borderBottom: '1px solid #f3f4f6' },
|
|
543
|
+
num: { width: 34, flex: '0 0 34px', textAlign: 'right', color: '#9ca3af', background: '#fafafa', userSelect: 'none', padding: '1px 6px' },
|
|
544
|
+
actions: { display: 'flex', gap: 8, marginTop: 14, flexWrap: 'wrap' },
|
|
545
|
+
primary: { border: '1px solid #2563eb', background: '#2563eb', color: '#fff', borderRadius: 8, padding: '6px 14px', fontSize: 12, fontWeight: 600, cursor: 'pointer' },
|
|
546
|
+
secondary: { border: '1px solid #e5e7eb', background: '#fff', color: '#111827', borderRadius: 8, padding: '6px 14px', fontSize: 12, cursor: 'pointer' },
|
|
547
|
+
danger: { border: '1px solid #f5c2c0', background: '#fdecea', color: '#b3261e', borderRadius: 8, padding: '6px 14px', fontSize: 12, cursor: 'pointer' },
|
|
548
|
+
error: { color: '#b3261e', fontSize: 12, marginTop: 8 },
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
function rowStyle(kind, side) {
|
|
552
|
+
if (kind === 'same') return { background: 'transparent', color: '#6b7280' };
|
|
553
|
+
if (side === 'left' && (kind === 'del' || kind === 'change')) return { background: '#fdecea', color: '#8a1f1a' };
|
|
554
|
+
if (side === 'right' && (kind === 'add' || kind === 'change')) return { background: '#e7f6ee', color: '#0f5c37' };
|
|
555
|
+
return { background: '#fafafa', color: '#9ca3af' };
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// One id per tab: presence is reported to the host so it knows an approval
|
|
559
|
+
// UI is listening before parking a tool call.
|
|
560
|
+
const CLIENT_ID = 'c' + Math.random().toString(36).slice(2, 10);
|
|
561
|
+
|
|
562
|
+
function ApprovalOverlay() {
|
|
563
|
+
const [items, setItems] = React.useState([]);
|
|
564
|
+
const [error, setError] = React.useState('');
|
|
565
|
+
|
|
566
|
+
React.useEffect(() => {
|
|
567
|
+
let alive = true;
|
|
568
|
+
let lastId = '';
|
|
569
|
+
const tick = async () => {
|
|
570
|
+
try {
|
|
571
|
+
const response = await fetch('/api/dsh-guard/pending?id=' + encodeURIComponent(CLIENT_ID), { headers: { accept: 'application/json' } });
|
|
572
|
+
if (!response.ok) return;
|
|
573
|
+
const body = await response.json();
|
|
574
|
+
if (!alive) return;
|
|
575
|
+
const next = Array.isArray(body.pending) ? body.pending : [];
|
|
576
|
+
const nextId = next.map((item) => item.id).join(',');
|
|
577
|
+
if (nextId !== lastId) { lastId = nextId; setItems(next); }
|
|
578
|
+
} catch {
|
|
579
|
+
// A transient poll failure must not clear a visible dialog.
|
|
580
|
+
}
|
|
581
|
+
};
|
|
582
|
+
tick();
|
|
583
|
+
const beat = () => { fetch('/api/dsh-guard/hello?id=' + encodeURIComponent(CLIENT_ID), { method: 'POST' }).catch(() => {}); };
|
|
584
|
+
beat();
|
|
585
|
+
const timer = setInterval(tick, 1500);
|
|
586
|
+
const beatTimer = setInterval(beat, 20000);
|
|
587
|
+
return () => { alive = false; clearInterval(timer); clearInterval(beatTimer); };
|
|
588
|
+
}, []);
|
|
589
|
+
|
|
590
|
+
const decide = React.useCallback(async (id, choice, reason) => {
|
|
591
|
+
try {
|
|
592
|
+
const response = await fetch('/api/dsh-guard/decide', {
|
|
593
|
+
method: 'POST',
|
|
594
|
+
headers: { 'content-type': 'application/json' },
|
|
595
|
+
body: JSON.stringify({ id, choice, reason }),
|
|
596
|
+
});
|
|
597
|
+
if (!response.ok) {
|
|
598
|
+
const body = await response.json().catch(() => ({}));
|
|
599
|
+
setError(body.error ?? '提交失败');
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
setItems((current) => current.filter((item) => item.id !== id));
|
|
603
|
+
} catch (problem) {
|
|
604
|
+
setError(problem instanceof Error ? problem.message : String(problem));
|
|
605
|
+
}
|
|
606
|
+
}, []);
|
|
607
|
+
|
|
608
|
+
if (items.length === 0) return null;
|
|
609
|
+
const item = items[0];
|
|
610
|
+
const rows = pairRows(item.before, item.after).slice(0, 400);
|
|
611
|
+
|
|
612
|
+
const hasDiff = item.before !== null || item.after !== null;
|
|
613
|
+
const cardStyle = hasDiff ? overlayStyles.card : { ...overlayStyles.card, ...overlayStyles.cardNarrow };
|
|
614
|
+
return e('div', { style: overlayStyles.backdrop }, e('div', { style: cardStyle }, [
|
|
615
|
+
e('div', { key: 'h', style: overlayStyles.header }, [
|
|
616
|
+
e('span', { key: 'e', style: overlayStyles.eyebrow }, '权限守卫'),
|
|
617
|
+
e('span', { key: 't', style: overlayStyles.title }, `允许 ${item.tool} 执行吗?`),
|
|
618
|
+
]),
|
|
619
|
+
e('div', { key: 'r', style: overlayStyles.rule }, `规则:${item.pattern}${item.path ? ' · ' + item.path : ''}`),
|
|
620
|
+
item.before === null && item.after === null ? null : e('div', { key: 'd', style: overlayStyles.table }, [
|
|
621
|
+
e('div', { key: 'head', style: { display: 'flex' } }, [
|
|
622
|
+
e('span', { key: 'on', style: { ...overlayStyles.th, ...overlayStyles.num } }),
|
|
623
|
+
e('span', { key: 'l', style: { ...overlayStyles.th, flex: '1 1 0', minWidth: 0 } }, '原'),
|
|
624
|
+
e('span', { key: 'g', style: { ...overlayStyles.th, width: 3, padding: 0, background: '#e5e7eb' } }),
|
|
625
|
+
e('span', { key: 'nn', style: { ...overlayStyles.th, ...overlayStyles.num } }),
|
|
626
|
+
e('span', { key: 'r2', style: { ...overlayStyles.th, flex: '1 1 0', minWidth: 0 } }, '新'),
|
|
627
|
+
]),
|
|
628
|
+
...rows.map((row, index) => e('div', { key: index, style: { display: 'flex' } }, [
|
|
629
|
+
e('span', { key: 'on', style: { ...overlayStyles.td, ...overlayStyles.num } }, row.left === null ? '' : String(index + 1)),
|
|
630
|
+
e('span', { key: 'l', style: { ...overlayStyles.td, flex: '1 1 0', minWidth: 0, ...rowStyle(row.kind, 'left') } }, row.left ?? ''),
|
|
631
|
+
e('span', { key: 'g', style: { width: 3, flex: '0 0 3px', background: row.kind === 'same' ? '#e5e7eb' : row.kind === 'add' ? '#16a34a' : '#dc2626' } }),
|
|
632
|
+
e('span', { key: 'nn', style: { ...overlayStyles.td, ...overlayStyles.num } }, row.right === null ? '' : String(index + 1)),
|
|
633
|
+
e('span', { key: 'r2', style: { ...overlayStyles.td, flex: '1 1 0', minWidth: 0, ...rowStyle(row.kind, 'right') } }, row.right ?? ''),
|
|
634
|
+
])),
|
|
635
|
+
]),
|
|
636
|
+
e('div', { key: 'a', style: overlayStyles.actions }, [
|
|
637
|
+
e('button', { key: '1', style: overlayStyles.primary, onClick: () => decide(item.id, 'allow') }, '允许一次'),
|
|
638
|
+
e('button', { key: '2', style: overlayStyles.secondary, onClick: () => decide(item.id, 'session') }, '本会话允许'),
|
|
639
|
+
item.canPersist ? e('button', { key: '3', style: overlayStyles.secondary, onClick: () => decide(item.id, 'persist') }, '写入项目规则') : null,
|
|
640
|
+
e('button', { key: '4', style: overlayStyles.danger, onClick: () => decide(item.id, 'deny', '用户拒绝') }, '拒绝'),
|
|
641
|
+
]),
|
|
642
|
+
error === '' ? null : e('div', { key: 'err', style: overlayStyles.error }, error),
|
|
643
|
+
]));
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
async function apply(ctx) {
|
|
647
|
+
ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
|
|
648
|
+
name: 'settings.plugins.tab',
|
|
649
|
+
id: 'guard',
|
|
650
|
+
order: 45,
|
|
651
|
+
label: () => '权限守卫',
|
|
652
|
+
inject: () => ({}),
|
|
653
|
+
}, GuardTab));
|
|
654
|
+
// The approval overlay is global: it must appear wherever the user is,
|
|
655
|
+
// not only inside the settings tab. Slots are page-relative, so this mounts
|
|
656
|
+
// on document.body instead. react-dom is optional; without it the dialog
|
|
657
|
+
// channel simply stays idle and pre-execute falls back after 10s.
|
|
658
|
+
try {
|
|
659
|
+
const ReactDOM = require('react-dom/client');
|
|
660
|
+
if (ReactDOM !== null && typeof ReactDOM.createRoot === 'function' && typeof document !== 'undefined') {
|
|
661
|
+
const host = document.createElement('div');
|
|
662
|
+
host.dataset.plugin = 'dsh-guard-overlay';
|
|
663
|
+
document.body.appendChild(host);
|
|
664
|
+
const root = ReactDOM.createRoot(host);
|
|
665
|
+
root.render(e(ApprovalOverlay));
|
|
666
|
+
ctx.effect(() => () => {
|
|
667
|
+
try { root.unmount(); } catch {}
|
|
668
|
+
try { host.remove(); } catch {}
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
} catch (error) {
|
|
672
|
+
console.warn('dsh-guard: approval overlay not mounted', error);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const module = { exports: { name: 'dsh-guard', inject: ['slots'], apply } };
|
|
677
|
+
return module.exports;
|
|
678
|
+
},
|
|
679
|
+
});
|