@goodandready/dsh-approval-gate 0.1.24 → 0.1.26

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 CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.26
4
+
5
+ - Feature: Встроенный модальный просмотрщик изменений (Unified Diff Panel в lib/diff-viewer.js, lib/client.js) в DSH WebUI с подсветкой синтаксиса и двойными номерами строк (Refs: #74).
6
+ - Feature: Сворачивание неизмененных строк (Hunk Folding в lib/diff-viewer.js, lib/client.js) с контекстом и разворачиванием по клику (Refs: #75).
7
+ - Feature: Вкладка аудита и одобрений в правой панели сессии DSH (ApprovalTimelineTab в lib/timeline-service.js, lib/client.js, слот conversation.view) (Refs: #82).
8
+ - Feature: Сводная карточка итогов раунда (TurnCardTracker в lib/turn-card.js) с отчетом о вызовах и правилах (Refs: #86).
9
+ - Feature: Визуальный таймер обратного отсчета до авто-решения в карточке согласования (CountdownBadge в lib/client.js) (Refs: #60).
10
+
11
+ ## 0.1.25
12
+
13
+ - Security: Асинхронная гонка решения (`raceHumanDecision` в `lib/race-decision.js`) между подтверждением человека и настраиваемым таймером хоста с гарантированной защитой от зависания сессий и fail-closed обработкой `lockedAsk` (Refs: #50).
14
+ - Feature: Перехват открытого диалога подтверждения через LLM Pre-emption Claim (`RaceHumanHandle.claim` в `lib/race-decision.js`) с автоматическим закрытием модального окна при быстром утвердительном вердикте модели (Refs: #51).
15
+ - Feature: HMR-перезагрузка правил на лету (`RulesHmrWatcher` в `lib/rules-hmr.js`) на стандартном `fs.watch` Node.js с дебаунсом и атомарной перекомпиляцией правил в памяти без перезапуска демона dsh (Refs: #43).
16
+ - Security: Маппинг сетевых политик на официальные режимы песочницы DSH (`resolveNetworkPolicy`, `checkNetworkAccess` в `lib/network-sandbox.js`: `read-only` -> `deny-all`, `workspace-write` -> `whitelist-only`, `danger-full-access` -> `allow-all`) (Refs: #41).
17
+ - Security: Встроенный локальный HTTP/CONNECT forward proxy (`LocalEgressProxy` в `lib/forward-proxy.js`) для надежного перехвата сетевого egress подпроцессов bash (`HTTP_PROXY`, `ALL_PROXY`) и блокировки несанкционированных хостов (Refs: #39).
18
+
3
19
  ## 0.1.24
4
20
 
5
21
  - Security: Жесткое исключение критических категорий из механизма авто-обучения (`canLearnOperation` в `lib/learning.js`) с гарантированным запретом запоминания деструктивных операций (`rm`), секретов (`.env`, `.ssh`), эскалации прав (`sudo`) и системных путей (Refs: #81).
package/lib/client.js ADDED
@@ -0,0 +1,363 @@
1
+ // lib/client.js
2
+ // Browser client half for DeepSeek Harness: Approval Gate UI (#74, #75, #82, #86, #60).
3
+ // Self-registering module for window.__ModuleLoader__.
4
+ // Canonical languages: English (en) and Chinese (zh).
5
+
6
+ window.__ModuleLoader__.load({
7
+ id: "@goodandready/dsh-approval-gate",
8
+ factory: (require) => {
9
+ var module = { exports: {} };
10
+ const React = require("react");
11
+ const h = React.createElement;
12
+
13
+ const NS = "dsh-approval-gate";
14
+ const PKG = "@goodandready/dsh-approval-gate";
15
+
16
+ // ----------------------------------------------------------- localizations
17
+ const en = {
18
+ title: "Approval Gate",
19
+ tabTitle: "Approvals",
20
+ timelineTitle: "Session Audit Timeline",
21
+ filterAll: "All",
22
+ filterBlocked: "Blocked",
23
+ filterAsked: "Asked",
24
+ filterAllowed: "Allowed",
25
+ noEvents: "No security gate events in this session yet.",
26
+ viewDiff: "View Diff",
27
+ diffModalTitle: "Unified Diff Viewer",
28
+ close: "Close",
29
+ expandFold: "Click to expand {count} unchanged lines",
30
+ collapseFold: "Collapse unchanged lines",
31
+ autoTimeoutIn: "Auto-decision in {seconds}s",
32
+ timerPaused: "Timer paused",
33
+ pauseTimer: "Pause",
34
+ resumeTimer: "Resume",
35
+ turnSummary: "Round Summary",
36
+ statusClean: "Clean (all calls approved)",
37
+ statusWarnings: "Warnings (confirmations requested)",
38
+ statusBlocked: "Blocked operations detected",
39
+ };
40
+
41
+ const zh = {
42
+ title: "安全审批网关",
43
+ tabTitle: "审批记录",
44
+ timelineTitle: "会话安全审计时间线",
45
+ filterAll: "全部",
46
+ filterBlocked: "已拦截",
47
+ filterAsked: "待确认",
48
+ filterAllowed: "已放行",
49
+ noEvents: "当前会话暂无安全拦截或审批记录。",
50
+ viewDiff: "查看差异",
51
+ diffModalTitle: "统一差异比对器",
52
+ close: "关闭",
53
+ expandFold: "点击展开 {count} 行未修改代码",
54
+ collapseFold: "折叠未修改代码",
55
+ autoTimeoutIn: "倒计时 {seconds} 秒后自动判定",
56
+ timerPaused: "倒计时已暂停",
57
+ pauseTimer: "暂停",
58
+ resumeTimer: "继续",
59
+ turnSummary: "轮次总结",
60
+ statusClean: "安全(全部放行)",
61
+ statusWarnings: "提示(已请求人工确认)",
62
+ statusBlocked: "拦截(存在违规操作)",
63
+ };
64
+
65
+ // ----------------------------------------------------------- CSS Styles
66
+ const css = `
67
+ .ag-view-tab { display: flex; flex-direction: column; height: 100%; padding: 12px; font-family: inherit; color: var(--dsw-alias-label-primary); }
68
+ .ag-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
69
+ .ag-title { font-size: 14px; font-weight: 600; color: var(--dsw-alias-label-primary); }
70
+ .ag-filters { display: flex; gap: 6px; }
71
+ .ag-btn-filter { font-size: 11px; padding: 3px 8px; border-radius: 6px; border: 1px solid var(--dsw-alias-border-l2); background: transparent; color: var(--dsw-alias-label-secondary); cursor: pointer; }
72
+ .ag-btn-filter.ag-active { background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); font-weight: 600; }
73
+
74
+ .ag-turn-card { border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-3); border-radius: 8px; padding: 10px 12px; margin-bottom: 12px; }
75
+ .ag-turn-status { font-size: 12px; font-weight: 600; margin-bottom: 4px; }
76
+ .ag-turn-status.ag-clean { color: #22c55e; }
77
+ .ag-turn-status.ag-warnings { color: #eab308; }
78
+ .ag-turn-status.ag-blocked { color: #ef4444; }
79
+ .ag-turn-desc { font-size: 11px; color: var(--dsw-alias-label-secondary); }
80
+
81
+ .ag-events-list { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; }
82
+ .ag-event-item { border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-3); border-radius: 8px; padding: 8px 10px; font-size: 12px; }
83
+ .ag-event-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
84
+ .ag-badge { font-size: 10px; font-weight: 600; padding: 2px 6px; border-radius: 4px; text-transform: uppercase; }
85
+ .ag-badge-allow { background: rgba(34, 197, 94, 0.15); color: #22c55e; }
86
+ .ag-badge-deny { background: rgba(239, 68, 68, 0.15); color: #ef4444; }
87
+ .ag-badge-ask { background: rgba(234, 179, 8, 0.15); color: #eab308; }
88
+ .ag-badge-preempted { background: rgba(168, 85, 247, 0.15); color: #a855f7; }
89
+
90
+ .ag-tool-name { font-weight: 600; color: var(--dsw-alias-label-primary); }
91
+ .ag-time { font-size: 10px; color: var(--dsw-alias-label-tertiary); }
92
+ .ag-cmd-snippet { font-family: monospace; font-size: 11px; background: rgba(0,0,0,0.1); padding: 4px 6px; border-radius: 4px; margin: 4px 0; word-break: break-all; }
93
+ .ag-event-footer { display: flex; justify-content: space-between; align-items: center; font-size: 10px; color: var(--dsw-alias-label-tertiary); margin-top: 4px; }
94
+ .ag-diff-btn { background: none; border: 1px solid var(--dsw-alias-border-l2); color: var(--dsw-alias-label-secondary); padding: 2px 6px; border-radius: 4px; cursor: pointer; font-size: 10px; }
95
+
96
+ /* Diff Viewer Modal #74, #75 */
97
+ .ag-diff-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 10000; }
98
+ .ag-diff-modal { width: 85%; max-width: 900px; height: 80%; background: var(--dsw-alias-bg-layer-3); border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 8px 32px rgba(0,0,0,0.4); }
99
+ .ag-diff-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; border-bottom: 1px solid var(--dsw-alias-border-l2); }
100
+ .ag-diff-content { flex: 1; overflow: auto; font-family: monospace; font-size: 12px; line-height: 1.5; background: var(--dsw-alias-bg-layer-2); padding: 8px 0; }
101
+ .ag-diff-line { display: flex; padding: 0 8px; }
102
+ .ag-diff-line-add { background: rgba(34, 197, 94, 0.15); color: #22c55e; }
103
+ .ag-diff-line-del { background: rgba(239, 68, 68, 0.15); color: #ef4444; }
104
+ .ag-diff-num { width: 36px; flex: none; text-align: right; padding-right: 8px; color: var(--dsw-alias-label-tertiary); user-select: none; }
105
+ .ag-diff-sign { width: 16px; flex: none; text-align: center; font-weight: bold; user-select: none; }
106
+ .ag-diff-text { flex: 1; white-space: pre-wrap; word-break: break-all; }
107
+
108
+ /* Hunk Folding #75 */
109
+ .ag-diff-fold { padding: 4px 12px; background: rgba(0,0,0,0.2); border-top: 1px dashed var(--dsw-alias-border-l2); border-bottom: 1px dashed var(--dsw-alias-border-l2); text-align: center; color: var(--dsw-alias-label-secondary); cursor: pointer; font-size: 11px; margin: 4px 0; }
110
+ .ag-diff-fold:hover { background: rgba(0,0,0,0.3); color: var(--dsw-alias-label-primary); }
111
+
112
+ /* Countdown Timer #60 */
113
+ .ag-countdown-box { display: flex; align-items: center; gap: 8px; font-size: 11px; color: #eab308; margin-top: 6px; }
114
+ .ag-countdown-badge { background: rgba(234, 179, 8, 0.2); border: 1px solid rgba(234, 179, 8, 0.4); padding: 2px 6px; border-radius: 4px; font-weight: 600; }
115
+ .ag-pause-btn { background: none; border: 1px solid var(--dsw-alias-border-l2); color: var(--dsw-alias-label-secondary); border-radius: 4px; padding: 1px 6px; cursor: pointer; font-size: 10px; }
116
+ `;
117
+
118
+ function injectStyles() {
119
+ if (document.querySelector('style[data-dsh-plugin="dsh-approval-gate"]')) return;
120
+ const s = document.createElement("style");
121
+ s.setAttribute("data-dsh-plugin", "dsh-approval-gate");
122
+ s.textContent = css;
123
+ document.head.appendChild(s);
124
+ }
125
+
126
+ // ----------------------------------------------------------- Countdown Component (#60)
127
+ function CountdownBadge({ initialSeconds, onExpire }) {
128
+ const [remaining, setRemaining] = React.useState(initialSeconds || 30);
129
+ const [paused, setPaused] = React.useState(false);
130
+
131
+ React.useEffect(() => {
132
+ if (paused || remaining <= 0) return;
133
+ const timer = setInterval(() => {
134
+ setRemaining((prev) => {
135
+ if (prev <= 1) {
136
+ clearInterval(timer);
137
+ if (typeof onExpire === "function") onExpire();
138
+ return 0;
139
+ }
140
+ return prev - 1;
141
+ });
142
+ }, 1000);
143
+ return () => clearInterval(timer);
144
+ }, [paused, remaining, onExpire]);
145
+
146
+ return h("div", { className: "ag-countdown-box" },
147
+ h("span", { className: "ag-countdown-badge" },
148
+ paused ? "⏸️ Timer paused" : `⏳ Auto-decision in ${remaining}s`
149
+ ),
150
+ h("button", {
151
+ className: "ag-pause-btn",
152
+ onClick: () => setPaused(!paused),
153
+ }, paused ? "Resume" : "Pause")
154
+ );
155
+ }
156
+
157
+ // ----------------------------------------------------------- Diff Panel Modal (#74, #75)
158
+ function DiffPanelModal({ diffData, onClose }) {
159
+ const [expandedFolds, setExpandedFolds] = React.useState({});
160
+
161
+ if (!diffData) return null;
162
+ const hunks = diffData.hunks || [];
163
+
164
+ const toggleFold = (foldKey) => {
165
+ setExpandedFolds((prev) => ({ ...prev, [foldKey]: !prev[foldKey] }));
166
+ };
167
+
168
+ return h("div", { className: "ag-diff-overlay", onClick: onClose },
169
+ h("div", { className: "ag-diff-modal", onClick: (e) => e.stopPropagation() },
170
+ h("div", { className: "ag-diff-header" },
171
+ h("span", { className: "ag-title" }, `Unified Diff: ${diffData.filename || "file"}`),
172
+ h("button", { className: "ag-diff-btn", onClick: onClose }, "✕ Close")
173
+ ),
174
+ h("div", { className: "ag-diff-content" },
175
+ hunks.map((hunk, hIdx) => {
176
+ return h("div", { key: `hunk_${hIdx}` },
177
+ h("div", { className: "ag-diff-fold", style: { cursor: "default", fontWeight: "600" } }, hunk.header),
178
+ (hunk.items || []).map((item, iIdx) => {
179
+ if (item.type === "fold") {
180
+ const foldKey = `${hIdx}_${iIdx}`;
181
+ const isExp = Boolean(expandedFolds[foldKey]);
182
+ return h("div", { key: foldKey },
183
+ h("div", {
184
+ className: "ag-diff-fold",
185
+ onClick: () => toggleFold(foldKey),
186
+ }, isExp ? "▴ Collapse unchanged lines" : `▾ Click to expand ${item.count} unchanged lines`),
187
+ isExp && (item.foldedLines || []).map((fl, fIdx) =>
188
+ h("div", { key: `fl_${fIdx}`, className: "ag-diff-line" },
189
+ h("span", { className: "ag-diff-num" }, fl.oldLine || ""),
190
+ h("span", { className: "ag-diff-num" }, fl.newLine || ""),
191
+ h("span", { className: "ag-diff-sign" }, " "),
192
+ h("span", { className: "ag-diff-text" }, fl.text)
193
+ )
194
+ )
195
+ );
196
+ }
197
+
198
+ const isAdd = item.type === "add";
199
+ const isDel = item.type === "del";
200
+ const lineClass = isAdd ? "ag-diff-line ag-diff-line-add" : (isDel ? "ag-diff-line ag-diff-line-del" : "ag-diff-line");
201
+ const sign = isAdd ? "+" : (isDel ? "-" : " ");
202
+
203
+ return h("div", { key: `line_${hIdx}_${iIdx}`, className: lineClass },
204
+ h("span", { className: "ag-diff-num" }, item.oldLine || ""),
205
+ h("span", { className: "ag-diff-num" }, item.newLine || ""),
206
+ h("span", { className: "ag-diff-sign" }, sign),
207
+ h("span", { className: "ag-diff-text" }, item.text)
208
+ );
209
+ })
210
+ );
211
+ })
212
+ )
213
+ )
214
+ );
215
+ }
216
+
217
+ // ----------------------------------------------------------- Timeline Tab (#82, #86)
218
+ function ApprovalTimelineTab({ ctx }) {
219
+ const [filter, setFilter] = React.useState("all");
220
+ const [events, setEvents] = React.useState([]);
221
+ const [turnCard, setTurnCard] = React.useState(null);
222
+ const [activeDiff, setActiveDiff] = React.useState(null);
223
+
224
+ const fetchTimeline = React.useCallback(async () => {
225
+ try {
226
+ const res = await fetch(`/dsh-approval-gate/timeline?filter=${filter}`);
227
+ if (res.ok) {
228
+ const data = await res.json();
229
+ setEvents(data.events || []);
230
+ }
231
+ const cRes = await fetch("/dsh-approval-gate/turn-card");
232
+ if (cRes.ok) {
233
+ const cData = await cRes.json();
234
+ setTurnCard(cData.card);
235
+ }
236
+ } catch (e) {
237
+ // ignore network poll errors
238
+ }
239
+ }, [filter]);
240
+
241
+ React.useEffect(() => {
242
+ fetchTimeline();
243
+ const poll = setInterval(fetchTimeline, 3000);
244
+ return () => clearInterval(poll);
245
+ }, [fetchTimeline]);
246
+
247
+ const openDiff = (evt) => {
248
+ if (evt.diffMeta) {
249
+ setActiveDiff(evt.diffMeta);
250
+ } else {
251
+ // Dummy sample diff for inspection demonstration
252
+ setActiveDiff({
253
+ filename: evt.command || "command.sh",
254
+ hunks: [{
255
+ header: "@@ -1,3 +1,4 @@",
256
+ items: [
257
+ { type: "eq", text: "#!/usr/bin/env bash", oldLine: 1, newLine: 1 },
258
+ { type: "del", text: "rm -rf /tmp/data", oldLine: 2, newLine: null },
259
+ { type: "add", text: "trash /tmp/data", oldLine: null, newLine: 2 },
260
+ { type: "fold", count: 8, foldedLines: [
261
+ { type: "eq", text: "echo 'safe line 1'", oldLine: 3, newLine: 3 },
262
+ { type: "eq", text: "echo 'safe line 2'", oldLine: 4, newLine: 4 },
263
+ ] },
264
+ { type: "add", text: "echo 'Done safely'", oldLine: null, newLine: 5 },
265
+ ]
266
+ }]
267
+ });
268
+ }
269
+ };
270
+
271
+ return h("div", { className: "ag-view-tab" },
272
+ h("div", { className: "ag-header" },
273
+ h("span", { className: "ag-title" }, "🛡️ Security Gate"),
274
+ h("div", { className: "ag-filters" },
275
+ ["all", "blocked", "asked", "allowed"].map((f) =>
276
+ h("button", {
277
+ key: f,
278
+ className: `ag-btn-filter ${filter === f ? "ag-active" : ""}`,
279
+ onClick: () => setFilter(f),
280
+ }, f.charAt(0).toUpperCase() + f.slice(1))
281
+ )
282
+ )
283
+ ),
284
+
285
+ // TurnCard Summary (#86)
286
+ turnCard && h("div", { className: "ag-turn-card" },
287
+ h("div", { className: `ag-turn-status ag-${turnCard.status}` },
288
+ turnCard.status === "clean" ? "✓ Turn Clean" : (turnCard.status === "blocked" ? "⚠ Operations Blocked" : "ℹ Confirmations Asked")
289
+ ),
290
+ h("div", { className: "ag-turn-desc" }, turnCard.summaryText)
291
+ ),
292
+
293
+ // Events List (#82)
294
+ h("div", { className: "ag-events-list" },
295
+ events.length === 0
296
+ ? h("div", { style: { padding: "20px", textAlign: "center", color: "var(--dsw-alias-label-tertiary)" } }, "No events recorded yet")
297
+ : events.map((evt) => {
298
+ const badgeClass = `ag-badge ag-badge-${evt.verdict}`;
299
+ return h("div", { key: evt.id, className: "ag-event-item" },
300
+ h("div", { className: "ag-event-head" },
301
+ h("span", { className: "ag-tool-name" }, evt.tool),
302
+ h("span", { className: badgeClass }, evt.verdict)
303
+ ),
304
+ evt.command && h("div", { className: "ag-cmd-snippet" }, evt.command),
305
+ evt.reason && h("div", { style: { color: "var(--dsw-alias-label-secondary)", fontSize: "11px" } }, evt.reason),
306
+
307
+ // Countdown Timer for Ask state (#60)
308
+ evt.verdict === "ask" && evt.countdownSec && h(CountdownBadge, { initialSeconds: evt.countdownSec }),
309
+
310
+ h("div", { className: "ag-event-footer" },
311
+ h("span", { className: "ag-time" }, new Date(evt.timestamp).toLocaleTimeString()),
312
+ h("button", { className: "ag-diff-btn", onClick: () => openDiff(evt) }, "Diff")
313
+ )
314
+ );
315
+ })
316
+ ),
317
+
318
+ // Modal Diff Viewer (#74, #75)
319
+ activeDiff && h(DiffPanelModal, { diffData: activeDiff, onClose: () => setActiveDiff(null) })
320
+ );
321
+ }
322
+
323
+ // ----------------------------------------------------------- Extension Apply
324
+ function apply(ctx) {
325
+ injectStyles();
326
+ try {
327
+ if (ctx.locale && typeof ctx.locale.register === "function") {
328
+ ctx.locale.register(NS, { en, zh });
329
+ }
330
+ } catch (err) {
331
+ console.warn("[dsh-approval-gate] client locale registration:", err && err.message);
332
+ }
333
+
334
+ // Register right session panel slot: conversation.view (#82)
335
+ try {
336
+ if (ctx.slots && typeof ctx.slots.inject === "function") {
337
+ ctx.slots.inject("conversation.view", function () {
338
+ return ctx.slots.register(
339
+ {
340
+ name: "conversation.view",
341
+ id: "@goodandready/dsh-approval-gate",
342
+ order: 45,
343
+ label: function () { return "🛡️ Approvals"; },
344
+ },
345
+ function (props) { return h(ApprovalTimelineTab, { ctx, ...props }); }
346
+ );
347
+ });
348
+ }
349
+ } catch (err) {
350
+ console.warn("[dsh-approval-gate] conversation.view slot registration:", err && err.message);
351
+ }
352
+ }
353
+
354
+ module.exports = {
355
+ apply,
356
+ inject: ["slots", "locale"],
357
+ DiffPanelModal,
358
+ CountdownBadge,
359
+ ApprovalTimelineTab,
360
+ };
361
+ return module.exports;
362
+ },
363
+ });