@goodandready/dsh-approval-gate 0.1.25 → 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 +8 -0
- package/lib/client.js +363 -0
- package/lib/diff-viewer.js +371 -0
- package/lib/index.js +12 -0
- package/lib/timeline-service.js +252 -0
- package/lib/turn-card.js +286 -0
- package/package.json +12 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
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
|
+
|
|
3
11
|
## 0.1.25
|
|
4
12
|
|
|
5
13
|
- Security: Асинхронная гонка решения (`raceHumanDecision` в `lib/race-decision.js`) между подтверждением человека и настраиваемым таймером хоста с гарантированной защитой от зависания сессий и fail-closed обработкой `lockedAsk` (Refs: #50).
|
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
|
+
});
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
// lib/diff-viewer.js
|
|
2
|
+
// Unified Diff Generator and Hunk Folding Engine for DSH Approval Gate (#74, #75).
|
|
3
|
+
// Zero-dependency pure JavaScript Myers-style line diffing with folding support.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {Object} DiffLine
|
|
7
|
+
* @property {'eq' | 'add' | 'del'} type
|
|
8
|
+
* @property {string} text
|
|
9
|
+
* @property {number | null} oldLine
|
|
10
|
+
* @property {number | null} newLine
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {Object} FoldItem
|
|
15
|
+
* @property {'fold'} type
|
|
16
|
+
* @property {number} count
|
|
17
|
+
* @property {number} startOldLine
|
|
18
|
+
* @property {number} startNewLine
|
|
19
|
+
* @property {DiffLine[]} foldedLines
|
|
20
|
+
* @property {boolean} expanded
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {Object} Hunk
|
|
25
|
+
* @property {number} oldStart
|
|
26
|
+
* @property {number} oldCount
|
|
27
|
+
* @property {number} newStart
|
|
28
|
+
* @property {number} newCount
|
|
29
|
+
* @property {string} header
|
|
30
|
+
* @property {(DiffLine | FoldItem)[]} items
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Splits text into lines preserving empty lines.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} text
|
|
37
|
+
* @returns {string[]}
|
|
38
|
+
*/
|
|
39
|
+
export function splitLines(text) {
|
|
40
|
+
if (typeof text !== "string") return [];
|
|
41
|
+
const lines = text.split(/\r?\n/);
|
|
42
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") {
|
|
43
|
+
lines.pop();
|
|
44
|
+
}
|
|
45
|
+
return lines;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Computes Longest Common Subsequence matrix for two arrays of lines.
|
|
50
|
+
*
|
|
51
|
+
* @param {string[]} a
|
|
52
|
+
* @param {string[]} b
|
|
53
|
+
* @returns {number[][]}
|
|
54
|
+
*/
|
|
55
|
+
function lcsMatrix(a, b) {
|
|
56
|
+
const m = a.length;
|
|
57
|
+
const n = b.length;
|
|
58
|
+
const dp = Array.from({ length: m + 1 }, () => new Uint32Array(n + 1));
|
|
59
|
+
for (let i = 0; i < m; i++) {
|
|
60
|
+
for (let j = 0; j < n; j++) {
|
|
61
|
+
if (a[i] === b[j]) {
|
|
62
|
+
dp[i + 1][j + 1] = dp[i][j] + 1;
|
|
63
|
+
} else {
|
|
64
|
+
dp[i + 1][j + 1] = Math.max(dp[i + 1][j], dp[i][j + 1]);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return dp;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Generates raw diff lines between old and new text.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} oldText
|
|
75
|
+
* @param {string} newText
|
|
76
|
+
* @returns {DiffLine[]}
|
|
77
|
+
*/
|
|
78
|
+
export function generateDiffLines(oldText, newText) {
|
|
79
|
+
const a = splitLines(oldText);
|
|
80
|
+
const b = splitLines(newText);
|
|
81
|
+
const dp = lcsMatrix(a, b);
|
|
82
|
+
|
|
83
|
+
let i = a.length;
|
|
84
|
+
let j = b.length;
|
|
85
|
+
/** @type {DiffLine[]} */
|
|
86
|
+
const reversed = [];
|
|
87
|
+
|
|
88
|
+
while (i > 0 || j > 0) {
|
|
89
|
+
if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {
|
|
90
|
+
reversed.push({ type: "eq", text: a[i - 1], oldLine: i, newLine: j });
|
|
91
|
+
i--;
|
|
92
|
+
j--;
|
|
93
|
+
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
|
|
94
|
+
reversed.push({ type: "add", text: b[j - 1], oldLine: null, newLine: j });
|
|
95
|
+
j--;
|
|
96
|
+
} else if (i > 0 && (j === 0 || dp[i][j - 1] < dp[i - 1][j])) {
|
|
97
|
+
reversed.push({ type: "del", text: a[i - 1], oldLine: i, newLine: null });
|
|
98
|
+
i--;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return reversed.reverse();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Groups diff lines into hunks with context and hunk folding (#75).
|
|
107
|
+
*
|
|
108
|
+
* @param {DiffLine[]} lines
|
|
109
|
+
* @param {Object} [options]
|
|
110
|
+
* @param {number} [options.contextLines=3]
|
|
111
|
+
* @param {number} [options.minFoldLines=4]
|
|
112
|
+
* @returns {Hunk[]}
|
|
113
|
+
*/
|
|
114
|
+
export function groupHunks(lines, options = {}) {
|
|
115
|
+
const ctx = typeof options.contextLines === "number" ? options.contextLines : 3;
|
|
116
|
+
const minFold = typeof options.minFoldLines === "number" ? options.minFoldLines : 4;
|
|
117
|
+
if (!lines || lines.length === 0) return [];
|
|
118
|
+
|
|
119
|
+
// Find all modified indices
|
|
120
|
+
const isChanged = lines.map((l) => l.type !== "eq");
|
|
121
|
+
const anyChange = isChanged.some(Boolean);
|
|
122
|
+
if (!anyChange) {
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Group changed clusters with context
|
|
127
|
+
const ranges = [];
|
|
128
|
+
let curStart = null;
|
|
129
|
+
let curEnd = null;
|
|
130
|
+
|
|
131
|
+
for (let k = 0; k < lines.length; k++) {
|
|
132
|
+
if (isChanged[k]) {
|
|
133
|
+
const start = Math.max(0, k - ctx);
|
|
134
|
+
const end = Math.min(lines.length - 1, k + ctx);
|
|
135
|
+
if (curStart === null) {
|
|
136
|
+
curStart = start;
|
|
137
|
+
curEnd = end;
|
|
138
|
+
} else if (start <= curEnd + 1) {
|
|
139
|
+
curEnd = Math.max(curEnd, end);
|
|
140
|
+
} else {
|
|
141
|
+
ranges.push({ start: curStart, end: curEnd });
|
|
142
|
+
curStart = start;
|
|
143
|
+
curEnd = end;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (curStart !== null) {
|
|
148
|
+
ranges.push({ start: curStart, end: curEnd });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** @type {Hunk[]} */
|
|
152
|
+
const hunks = [];
|
|
153
|
+
let lastRangeEnd = -1;
|
|
154
|
+
|
|
155
|
+
for (let idx = 0; idx < ranges.length; idx++) {
|
|
156
|
+
const r = ranges[idx];
|
|
157
|
+
const prevEnd = lastRangeEnd;
|
|
158
|
+
const hunkLines = lines.slice(r.start, r.end + 1);
|
|
159
|
+
|
|
160
|
+
let oldStart = 0;
|
|
161
|
+
let oldCount = 0;
|
|
162
|
+
let newStart = 0;
|
|
163
|
+
let newCount = 0;
|
|
164
|
+
|
|
165
|
+
for (const l of hunkLines) {
|
|
166
|
+
if (l.type === "eq" || l.type === "del") {
|
|
167
|
+
oldCount++;
|
|
168
|
+
if (!oldStart && l.oldLine) oldStart = l.oldLine;
|
|
169
|
+
}
|
|
170
|
+
if (l.type === "eq" || l.type === "add") {
|
|
171
|
+
newCount++;
|
|
172
|
+
if (!newStart && l.newLine) newStart = l.newLine;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** @type {(DiffLine | FoldItem)[]} */
|
|
177
|
+
const items = [];
|
|
178
|
+
|
|
179
|
+
// Leading fold if there are unchanged lines before this range (#75)
|
|
180
|
+
if (r.start > prevEnd + 1) {
|
|
181
|
+
const leadingLines = lines.slice(prevEnd + 1, r.start);
|
|
182
|
+
if (leadingLines.length >= minFold) {
|
|
183
|
+
items.push({
|
|
184
|
+
type: "fold",
|
|
185
|
+
count: leadingLines.length,
|
|
186
|
+
startOldLine: leadingLines[0].oldLine,
|
|
187
|
+
startNewLine: leadingLines[0].newLine,
|
|
188
|
+
foldedLines: leadingLines,
|
|
189
|
+
expanded: false,
|
|
190
|
+
});
|
|
191
|
+
} else {
|
|
192
|
+
items.push(...leadingLines);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Inside range: check for fold streaks
|
|
197
|
+
let eqStreak = [];
|
|
198
|
+
const flushStreak = () => {
|
|
199
|
+
if (eqStreak.length >= minFold) {
|
|
200
|
+
items.push({
|
|
201
|
+
type: "fold",
|
|
202
|
+
count: eqStreak.length,
|
|
203
|
+
startOldLine: eqStreak[0].oldLine,
|
|
204
|
+
startNewLine: eqStreak[0].newLine,
|
|
205
|
+
foldedLines: eqStreak.slice(),
|
|
206
|
+
expanded: false,
|
|
207
|
+
});
|
|
208
|
+
} else {
|
|
209
|
+
items.push(...eqStreak);
|
|
210
|
+
}
|
|
211
|
+
eqStreak = [];
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
for (const l of hunkLines) {
|
|
215
|
+
if (l.type === "eq") {
|
|
216
|
+
eqStreak.push(l);
|
|
217
|
+
} else {
|
|
218
|
+
if (eqStreak.length > 0) flushStreak();
|
|
219
|
+
items.push(l);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (eqStreak.length > 0) flushStreak();
|
|
223
|
+
|
|
224
|
+
// Trailing fold if this is the last range and there are remaining lines
|
|
225
|
+
if (idx === ranges.length - 1 && r.end < lines.length - 1) {
|
|
226
|
+
const trailingLines = lines.slice(r.end + 1);
|
|
227
|
+
if (trailingLines.length >= minFold) {
|
|
228
|
+
items.push({
|
|
229
|
+
type: "fold",
|
|
230
|
+
count: trailingLines.length,
|
|
231
|
+
startOldLine: trailingLines[0].oldLine,
|
|
232
|
+
startNewLine: trailingLines[0].newLine,
|
|
233
|
+
foldedLines: trailingLines,
|
|
234
|
+
expanded: false,
|
|
235
|
+
});
|
|
236
|
+
} else {
|
|
237
|
+
items.push(...trailingLines);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const header = `@@ -${oldStart || 1},${oldCount} +${newStart || 1},${newCount} @@`;
|
|
242
|
+
hunks.push({
|
|
243
|
+
oldStart: oldStart || 1,
|
|
244
|
+
oldCount,
|
|
245
|
+
newStart: newStart || 1,
|
|
246
|
+
newCount,
|
|
247
|
+
header,
|
|
248
|
+
items,
|
|
249
|
+
});
|
|
250
|
+
lastRangeEnd = r.end;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return hunks;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Formats hunks as standard Unified Diff string (#74).
|
|
258
|
+
*
|
|
259
|
+
* @param {Hunk[]} hunks
|
|
260
|
+
* @param {Object} [options]
|
|
261
|
+
* @param {string} [options.oldFile='a/file']
|
|
262
|
+
* @param {string} [options.newFile='b/file']
|
|
263
|
+
* @returns {string}
|
|
264
|
+
*/
|
|
265
|
+
export function formatUnifiedDiff(hunks, options = {}) {
|
|
266
|
+
const oldFile = options.oldFile || "a/file";
|
|
267
|
+
const newFile = options.newFile || "b/file";
|
|
268
|
+
if (!hunks || hunks.length === 0) return "";
|
|
269
|
+
|
|
270
|
+
const out = [`--- ${oldFile}`, `+++ ${newFile}`];
|
|
271
|
+
|
|
272
|
+
for (const h of hunks) {
|
|
273
|
+
out.push(h.header);
|
|
274
|
+
for (const item of h.items) {
|
|
275
|
+
if (item.type === "fold") {
|
|
276
|
+
out.push(`@@ ... [${item.count} unchanged lines folded] ... @@`);
|
|
277
|
+
if (item.expanded) {
|
|
278
|
+
for (const fl of item.foldedLines) {
|
|
279
|
+
out.push(` ${fl.text}`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
} else if (item.type === "eq") {
|
|
283
|
+
out.push(` ${item.text}`);
|
|
284
|
+
} else if (item.type === "add") {
|
|
285
|
+
out.push(`+${item.text}`);
|
|
286
|
+
} else if (item.type === "del") {
|
|
287
|
+
out.push(`-${item.text}`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return out.join("\n");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Parses a unified diff string into hunks and lines.
|
|
297
|
+
*
|
|
298
|
+
* @param {string} diffText
|
|
299
|
+
* @returns {Hunk[]}
|
|
300
|
+
*/
|
|
301
|
+
export function parseUnifiedDiff(diffText) {
|
|
302
|
+
if (typeof diffText !== "string" || !diffText.trim()) return [];
|
|
303
|
+
const lines = splitLines(diffText);
|
|
304
|
+
/** @type {Hunk[]} */
|
|
305
|
+
const hunks = [];
|
|
306
|
+
let curHunk = null;
|
|
307
|
+
let curOldLine = 1;
|
|
308
|
+
let curNewLine = 1;
|
|
309
|
+
|
|
310
|
+
for (const l of lines) {
|
|
311
|
+
if (l.startsWith("---") || l.startsWith("+++")) {
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
const hMatch = l.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
|
|
315
|
+
if (hMatch) {
|
|
316
|
+
if (curHunk) hunks.push(curHunk);
|
|
317
|
+
curOldLine = parseInt(hMatch[1], 10);
|
|
318
|
+
curNewLine = parseInt(hMatch[3], 10);
|
|
319
|
+
curHunk = {
|
|
320
|
+
oldStart: curOldLine,
|
|
321
|
+
oldCount: hMatch[2] !== undefined ? parseInt(hMatch[2], 10) : 1,
|
|
322
|
+
newStart: curNewLine,
|
|
323
|
+
newCount: hMatch[4] !== undefined ? parseInt(hMatch[4], 10) : 1,
|
|
324
|
+
header: l,
|
|
325
|
+
items: [],
|
|
326
|
+
};
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
if (!curHunk) continue;
|
|
330
|
+
|
|
331
|
+
if (l.startsWith("+")) {
|
|
332
|
+
curHunk.items.push({ type: "add", text: l.slice(1), oldLine: null, newLine: curNewLine++ });
|
|
333
|
+
} else if (l.startsWith("-")) {
|
|
334
|
+
curHunk.items.push({ type: "del", text: l.slice(1), oldLine: curOldLine++, newLine: null });
|
|
335
|
+
} else if (l.startsWith(" ")) {
|
|
336
|
+
curHunk.items.push({ type: "eq", text: l.slice(1), oldLine: curOldLine++, newLine: curNewLine++ });
|
|
337
|
+
} else if (l.startsWith("@@ ...")) {
|
|
338
|
+
const fMatch = l.match(/\[(\d+) unchanged lines folded\]/);
|
|
339
|
+
const count = fMatch ? parseInt(fMatch[1], 10) : 0;
|
|
340
|
+
curHunk.items.push({
|
|
341
|
+
type: "fold",
|
|
342
|
+
count,
|
|
343
|
+
startOldLine: curOldLine,
|
|
344
|
+
startNewLine: curNewLine,
|
|
345
|
+
foldedLines: [],
|
|
346
|
+
expanded: false,
|
|
347
|
+
});
|
|
348
|
+
curOldLine += count;
|
|
349
|
+
curNewLine += count;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
if (curHunk) hunks.push(curHunk);
|
|
353
|
+
return hunks;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Toggles fold expansion in a hunk item (#75).
|
|
358
|
+
*
|
|
359
|
+
* @param {Hunk} hunk
|
|
360
|
+
* @param {number} itemIndex
|
|
361
|
+
* @returns {boolean}
|
|
362
|
+
*/
|
|
363
|
+
export function toggleFold(hunk, itemIndex) {
|
|
364
|
+
if (!hunk || !Array.isArray(hunk.items)) return false;
|
|
365
|
+
const it = hunk.items[itemIndex];
|
|
366
|
+
if (it && it.type === "fold") {
|
|
367
|
+
it.expanded = !it.expanded;
|
|
368
|
+
return true;
|
|
369
|
+
}
|
|
370
|
+
return false;
|
|
371
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -11,6 +11,8 @@ import { MEASURE_KINDS, classifyMeasureKind } from "./measure-kinds.js";
|
|
|
11
11
|
import { LineageManager } from "./lineage.js";
|
|
12
12
|
import { generateAgentGuidance, injectAgentGuidance, extractGuidance, GUIDANCE_MARKER } from "./guidance.js";
|
|
13
13
|
import { isRedundantSandboxRequest, buildRedundantSandboxRemediation, checkEscalationJustification } from "./escalation.js";
|
|
14
|
+
import { TimelineService } from "./timeline-service.js";
|
|
15
|
+
import { TurnCardTracker } from "./turn-card.js";
|
|
14
16
|
import Schema from "@deepseek-ai/schemastery";
|
|
15
17
|
|
|
16
18
|
if (typeof Schema.prototype?.volatile !== "function") {
|
|
@@ -405,6 +407,11 @@ export function apply(ctx, config) {
|
|
|
405
407
|
}),
|
|
406
408
|
};
|
|
407
409
|
provideServicePolyfill(ctx, "approvalGate", gateService);
|
|
410
|
+
const turnCard = new TurnCardTracker();
|
|
411
|
+
const timeline = new TimelineService({ turnCardTracker: turnCard });
|
|
412
|
+
timeline.registerRoutes(ctx);
|
|
413
|
+
gateService.timeline = timeline;
|
|
414
|
+
gateService.turnCard = turnCard;
|
|
408
415
|
|
|
409
416
|
const hasPreExecute = ctx && typeof ctx.on === "function";
|
|
410
417
|
if (hasPreExecute) {
|
|
@@ -428,6 +435,7 @@ export function apply(ctx, config) {
|
|
|
428
435
|
return { kind: "ask", reason: askMessage(hit, who, translate, ctx && ctx.logger) };
|
|
429
436
|
}
|
|
430
437
|
|
|
438
|
+
timeline.recordEvent({ sessionId, turnId: execution?.turnId, tool: execution?.name, command: execution?.arguments?.command, snippet: decision.snippet, verdict: decision.verdict, stage: decision.stage, reason: decision.reason, timeoutMs: decision.timeoutMs });
|
|
431
439
|
if (decision.dryRun) {
|
|
432
440
|
if (ctx && ctx.logger && typeof ctx.logger.info === "function") {
|
|
433
441
|
ctx.logger.info(`[dsh-approval-gate:DRY-RUN] would block rule=${decision.reason} (${decision.originalVerdict})${who}`);
|
|
@@ -497,6 +505,7 @@ export function apply(ctx, config) {
|
|
|
497
505
|
return denyMessage(hit, who, translate, ctx && ctx.logger);
|
|
498
506
|
}
|
|
499
507
|
|
|
508
|
+
timeline.recordEvent({ sessionId, turnId: execution?.turnId, tool: execution?.name, command: execution?.arguments?.command, snippet: decision.snippet, verdict: decision.verdict, stage: decision.stage, reason: decision.reason });
|
|
500
509
|
if (decision.dryRun) {
|
|
501
510
|
if (ctx && ctx.logger && typeof ctx.logger.info === "function") {
|
|
502
511
|
ctx.logger.info(`[dsh-approval-gate:DRY-RUN] would block rule=${decision.reason} (${decision.originalVerdict})${who}`);
|
|
@@ -582,3 +591,6 @@ export { DEFAULT_HUMAN_TIMEOUT_MS, RACE_WINNERS, RaceHumanHandle, raceHumanDecis
|
|
|
582
591
|
export { DEFAULT_HMR_DEBOUNCE_MS, RulesHmrWatcher } from "./rules-hmr.js";
|
|
583
592
|
export { NETWORK_POLICIES, SANDBOX_NETWORK_MAP, resolveNetworkPolicy, parseDestination, checkNetworkAccess } from "./network-sandbox.js";
|
|
584
593
|
export { LocalEgressProxy } from "./forward-proxy.js";
|
|
594
|
+
export { splitLines, generateDiffLines, groupHunks, formatUnifiedDiff, parseUnifiedDiff, toggleFold } from "./diff-viewer.js";
|
|
595
|
+
export { TurnCardTracker, formatTurnCardSummary } from "./turn-card.js";
|
|
596
|
+
export { TimelineService } from "./timeline-service.js";
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// lib/timeline-service.js
|
|
2
|
+
// Session Audit Timeline and WebServer REST Service for DSH Approval Gate (#82, #60, #74, #86).
|
|
3
|
+
|
|
4
|
+
import { generateDiffLines, groupHunks, formatUnifiedDiff } from "./diff-viewer.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {Object} TimelineEvent
|
|
8
|
+
* @property {string} id
|
|
9
|
+
* @property {string} sessionId
|
|
10
|
+
* @property {string | number} [turnId]
|
|
11
|
+
* @property {number} timestamp
|
|
12
|
+
* @property {string} tool
|
|
13
|
+
* @property {string} [command]
|
|
14
|
+
* @property {string} [snippet]
|
|
15
|
+
* @property {'allow' | 'deny' | 'ask' | 'preempted'} verdict
|
|
16
|
+
* @property {string} [stage]
|
|
17
|
+
* @property {string} [reason]
|
|
18
|
+
* @property {number} [durationMs]
|
|
19
|
+
* @property {number | null} [expiresAt]
|
|
20
|
+
* @property {number | null} [countdownSec]
|
|
21
|
+
* @property {Object} [diffMeta]
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* TimelineService: records call history per session and exposes query & webServer routes (#82).
|
|
26
|
+
*/
|
|
27
|
+
export class TimelineService {
|
|
28
|
+
/**
|
|
29
|
+
* @param {{ maxEventsPerSession?: number, turnCardTracker?: any }} [options]
|
|
30
|
+
*/
|
|
31
|
+
constructor(options = {}) {
|
|
32
|
+
this.maxEventsPerSession = options.maxEventsPerSession || 500;
|
|
33
|
+
this.turnCardTracker = options.turnCardTracker || null;
|
|
34
|
+
/** @type {Map<string, TimelineEvent[]>} */
|
|
35
|
+
this._sessions = new Map();
|
|
36
|
+
this._nextId = 1;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Records a security gate event into session timeline (#82).
|
|
41
|
+
*
|
|
42
|
+
* @param {Object} raw
|
|
43
|
+
* @returns {TimelineEvent}
|
|
44
|
+
*/
|
|
45
|
+
recordEvent(raw) {
|
|
46
|
+
const sid = String(raw.sessionId || "default");
|
|
47
|
+
const tid = raw.turnId !== undefined && raw.turnId !== null ? raw.turnId : "active";
|
|
48
|
+
|
|
49
|
+
let list = this._sessions.get(sid);
|
|
50
|
+
if (!list) {
|
|
51
|
+
list = [];
|
|
52
|
+
this._sessions.set(sid, list);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const id = `evt_${Date.now()}_${this._nextId++}`;
|
|
56
|
+
const timestamp = raw.timestamp || Date.now();
|
|
57
|
+
let countdownSec = null;
|
|
58
|
+
let expiresAt = null;
|
|
59
|
+
|
|
60
|
+
if (raw.verdict === "ask" && typeof raw.timeoutMs === "number" && raw.timeoutMs > 0) {
|
|
61
|
+
expiresAt = timestamp + raw.timeoutMs;
|
|
62
|
+
countdownSec = Math.max(0, Math.round(raw.timeoutMs / 1000));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** @type {TimelineEvent} */
|
|
66
|
+
const evt = {
|
|
67
|
+
id,
|
|
68
|
+
sessionId: sid,
|
|
69
|
+
turnId: tid,
|
|
70
|
+
timestamp,
|
|
71
|
+
tool: raw.tool || "bash",
|
|
72
|
+
command: raw.command || "",
|
|
73
|
+
snippet: raw.snippet || "",
|
|
74
|
+
verdict: raw.verdict || "allow",
|
|
75
|
+
stage: raw.stage || "",
|
|
76
|
+
reason: raw.reason || "",
|
|
77
|
+
durationMs: raw.durationMs || 0,
|
|
78
|
+
expiresAt,
|
|
79
|
+
countdownSec,
|
|
80
|
+
diffMeta: raw.diffMeta || null,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
list.push(evt);
|
|
84
|
+
if (list.length > this.maxEventsPerSession) {
|
|
85
|
+
list.splice(0, list.length - this.maxEventsPerSession);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Also forward to TurnCardTracker if attached (#86)
|
|
89
|
+
if (this.turnCardTracker && typeof this.turnCardTracker.recordCall === "function") {
|
|
90
|
+
this.turnCardTracker.recordCall(sid, tid, {
|
|
91
|
+
tool: evt.tool,
|
|
92
|
+
command: evt.command,
|
|
93
|
+
snippet: evt.snippet,
|
|
94
|
+
verdict: evt.verdict,
|
|
95
|
+
reason: evt.reason,
|
|
96
|
+
durationMs: evt.durationMs,
|
|
97
|
+
timestamp: evt.timestamp,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return evt;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Retrieves timeline events for a given session with optional filtering (#82).
|
|
106
|
+
*
|
|
107
|
+
* @param {string} sessionId
|
|
108
|
+
* @param {Object} [options]
|
|
109
|
+
* @param {'all' | 'blocked' | 'allowed' | 'ask'} [options.filter='all']
|
|
110
|
+
* @param {number} [options.limit=100]
|
|
111
|
+
* @param {number} [options.offset=0]
|
|
112
|
+
* @returns {{ total: number, events: TimelineEvent[] }}
|
|
113
|
+
*/
|
|
114
|
+
getEvents(sessionId, options = {}) {
|
|
115
|
+
const sid = String(sessionId || "default");
|
|
116
|
+
const list = this._sessions.get(sid) || [];
|
|
117
|
+
const filter = options.filter || "all";
|
|
118
|
+
const limit = typeof options.limit === "number" ? Math.max(1, options.limit) : 100;
|
|
119
|
+
const offset = typeof options.offset === "number" ? Math.max(0, options.offset) : 0;
|
|
120
|
+
|
|
121
|
+
let filtered = list;
|
|
122
|
+
if (filter === "blocked") {
|
|
123
|
+
filtered = list.filter((e) => e.verdict === "deny");
|
|
124
|
+
} else if (filter === "allowed") {
|
|
125
|
+
filtered = list.filter((e) => e.verdict === "allow");
|
|
126
|
+
} else if (filter === "ask") {
|
|
127
|
+
filtered = list.filter((e) => e.verdict === "ask");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const total = filtered.length;
|
|
131
|
+
// Return newest first
|
|
132
|
+
const reversed = filtered.slice().reverse();
|
|
133
|
+
const paginated = reversed.slice(offset, offset + limit);
|
|
134
|
+
|
|
135
|
+
return { total, events: paginated };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Clears timeline events for a specific session.
|
|
140
|
+
*
|
|
141
|
+
* @param {string} sessionId
|
|
142
|
+
*/
|
|
143
|
+
clearSession(sessionId) {
|
|
144
|
+
const sid = String(sessionId || "default");
|
|
145
|
+
this._sessions.delete(sid);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Registers REST HTTP routes on ctx.webServer for browser UI interaction (#82, #74, #86).
|
|
150
|
+
*
|
|
151
|
+
* @param {any} ctx Cordis context
|
|
152
|
+
* @param {Object} [options]
|
|
153
|
+
* @param {string} [options.prefix='/dsh-approval-gate']
|
|
154
|
+
*/
|
|
155
|
+
registerRoutes(ctx, options = {}) {
|
|
156
|
+
const prefix = options.prefix || "/dsh-approval-gate";
|
|
157
|
+
if (!ctx || !ctx.webServer || typeof ctx.webServer.register !== "function") {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const jsonResponse = (res, status, data) => {
|
|
162
|
+
res.writeHead(status, {
|
|
163
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
164
|
+
"Cache-Control": "no-cache, no-store, must-revalidate",
|
|
165
|
+
});
|
|
166
|
+
res.end(JSON.stringify(data));
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// Route 1: GET /dsh-approval-gate/timeline
|
|
170
|
+
const timelineRoute = () =>
|
|
171
|
+
ctx.webServer.register({
|
|
172
|
+
path: `${prefix}/timeline`,
|
|
173
|
+
method: "GET",
|
|
174
|
+
handler: async (req, res) => {
|
|
175
|
+
try {
|
|
176
|
+
const url = new URL(req.url, "http://127.0.0.1");
|
|
177
|
+
const sid = url.searchParams.get("sessionId") || "default";
|
|
178
|
+
const filter = url.searchParams.get("filter") || "all";
|
|
179
|
+
const limit = parseInt(url.searchParams.get("limit") || "100", 10);
|
|
180
|
+
const data = this.getEvents(sid, { filter, limit });
|
|
181
|
+
jsonResponse(res, 200, { ok: true, sessionId: sid, ...data });
|
|
182
|
+
} catch (err) {
|
|
183
|
+
jsonResponse(res, 500, { ok: false, error: String(err && err.message || err) });
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Route 2: GET /dsh-approval-gate/turn-card
|
|
189
|
+
const turnCardRoute = () =>
|
|
190
|
+
ctx.webServer.register({
|
|
191
|
+
path: `${prefix}/turn-card`,
|
|
192
|
+
method: "GET",
|
|
193
|
+
handler: async (req, res) => {
|
|
194
|
+
try {
|
|
195
|
+
const url = new URL(req.url, "http://127.0.0.1");
|
|
196
|
+
const sid = url.searchParams.get("sessionId") || "default";
|
|
197
|
+
const tid = url.searchParams.get("turnId") || "active";
|
|
198
|
+
const lang = url.searchParams.get("lang") || "en";
|
|
199
|
+
let card = null;
|
|
200
|
+
if (this.turnCardTracker && typeof this.turnCardTracker.buildTurnCard === "function") {
|
|
201
|
+
card = this.turnCardTracker.buildTurnCard(sid, tid, { lang });
|
|
202
|
+
}
|
|
203
|
+
jsonResponse(res, 200, { ok: true, sessionId: sid, turnId: tid, card });
|
|
204
|
+
} catch (err) {
|
|
205
|
+
jsonResponse(res, 500, { ok: false, error: String(err && err.message || err) });
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// Route 3: POST /dsh-approval-gate/diff
|
|
211
|
+
const diffRoute = () =>
|
|
212
|
+
ctx.webServer.register({
|
|
213
|
+
path: `${prefix}/diff`,
|
|
214
|
+
method: "POST",
|
|
215
|
+
handler: async (req, res) => {
|
|
216
|
+
try {
|
|
217
|
+
let bodyStr = "";
|
|
218
|
+
req.on("data", (chunk) => { bodyStr += chunk; });
|
|
219
|
+
req.on("end", () => {
|
|
220
|
+
try {
|
|
221
|
+
const parsed = JSON.parse(bodyStr || "{}");
|
|
222
|
+
const oldText = parsed.oldText || "";
|
|
223
|
+
const newText = parsed.newText || "";
|
|
224
|
+
const contextLines = typeof parsed.contextLines === "number" ? parsed.contextLines : 3;
|
|
225
|
+
const diffLines = generateDiffLines(oldText, newText);
|
|
226
|
+
const hunks = groupHunks(diffLines, { contextLines });
|
|
227
|
+
const diffText = formatUnifiedDiff(hunks, {
|
|
228
|
+
oldFile: parsed.filename ? `a/${parsed.filename}` : "a/original",
|
|
229
|
+
newFile: parsed.filename ? `b/${parsed.filename}` : "b/modified",
|
|
230
|
+
});
|
|
231
|
+
jsonResponse(res, 200, { ok: true, hunks, diffText, linesCount: diffLines.length });
|
|
232
|
+
} catch (parseErr) {
|
|
233
|
+
jsonResponse(res, 400, { ok: false, error: "invalid-json" });
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
} catch (err) {
|
|
237
|
+
jsonResponse(res, 500, { ok: false, error: String(err && err.message || err) });
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
if (typeof ctx.effect === "function") {
|
|
243
|
+
ctx.effect(timelineRoute, "dsh-approval-gate: timeline route");
|
|
244
|
+
ctx.effect(turnCardRoute, "dsh-approval-gate: turn-card route");
|
|
245
|
+
ctx.effect(diffRoute, "dsh-approval-gate: diff route");
|
|
246
|
+
} else {
|
|
247
|
+
timelineRoute();
|
|
248
|
+
turnCardRoute();
|
|
249
|
+
diffRoute();
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
package/lib/turn-card.js
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// lib/turn-card.js
|
|
2
|
+
// TurnCard Round Summary Engine for DSH Approval Gate (#86).
|
|
3
|
+
// Aggregates tool hits, blocked rules, asked confirmations, and builds a comprehensive summary per agent turn.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {Object} TurnCallRecord
|
|
7
|
+
* @property {string} [tool]
|
|
8
|
+
* @property {string} [command]
|
|
9
|
+
* @property {string} [snippet]
|
|
10
|
+
* @property {'allow' | 'deny' | 'ask' | 'preempted'} verdict
|
|
11
|
+
* @property {string} [reason]
|
|
12
|
+
* @property {string} [rule]
|
|
13
|
+
* @property {number} [durationMs]
|
|
14
|
+
* @property {number} [timestamp]
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {Object} RuleHitSummary
|
|
19
|
+
* @property {string} rule
|
|
20
|
+
* @property {number} count
|
|
21
|
+
* @property {string[]} reasons
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {Object} TurnCard
|
|
26
|
+
* @property {string} sessionId
|
|
27
|
+
* @property {string | number} turnId
|
|
28
|
+
* @property {number} timestamp
|
|
29
|
+
* @property {'clean' | 'warnings' | 'blocked'} status
|
|
30
|
+
* @property {number} totalCalls
|
|
31
|
+
* @property {number} allowedCalls
|
|
32
|
+
* @property {number} blockedCalls
|
|
33
|
+
* @property {number} askedCalls
|
|
34
|
+
* @property {number} preemptedCalls
|
|
35
|
+
* @property {RuleHitSummary[]} ruleHits
|
|
36
|
+
* @property {TurnCallRecord[]} blockedDetails
|
|
37
|
+
* @property {string} summaryText
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Formats a localized or human-readable summary string for a TurnCard (#86).
|
|
42
|
+
*
|
|
43
|
+
* @param {TurnCard} card
|
|
44
|
+
* @param {'en' | 'ru' | 'zh'} [lang='en']
|
|
45
|
+
* @returns {string}
|
|
46
|
+
*/
|
|
47
|
+
export function formatTurnCardSummary(card, lang = "en") {
|
|
48
|
+
if (!card) return "";
|
|
49
|
+
const tid = card.turnId !== undefined && card.turnId !== null ? card.turnId : "current";
|
|
50
|
+
|
|
51
|
+
if (lang === "ru") {
|
|
52
|
+
if (card.status === "clean") {
|
|
53
|
+
return `Раунд #${tid}: ${card.totalCalls} вызовов, все одобрены без замечаний.`;
|
|
54
|
+
}
|
|
55
|
+
const parts = [`Раунд #${tid}: ${card.totalCalls} вызовов`];
|
|
56
|
+
if (card.blockedCalls > 0) parts.push(`заблокировано: ${card.blockedCalls}`);
|
|
57
|
+
if (card.askedCalls > 0) parts.push(`запросов подтверждения: ${card.askedCalls}`);
|
|
58
|
+
if (card.ruleHits && card.ruleHits.length > 0) {
|
|
59
|
+
const rules = card.ruleHits.map((r) => `${r.rule} (${r.count})`).join(", ");
|
|
60
|
+
parts.push(`сработавшие правила: [${rules}]`);
|
|
61
|
+
}
|
|
62
|
+
return parts.join("; ") + ".";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (lang === "zh") {
|
|
66
|
+
if (card.status === "clean") {
|
|
67
|
+
return `轮次 #${tid}: 共 ${card.totalCalls} 次调用,全部安全通过。`;
|
|
68
|
+
}
|
|
69
|
+
const parts = [`轮次 #${tid}: 共 ${card.totalCalls} 次调用`];
|
|
70
|
+
if (card.blockedCalls > 0) parts.push(`拦截: ${card.blockedCalls}`);
|
|
71
|
+
if (card.askedCalls > 0) parts.push(`需人工确认: ${card.askedCalls}`);
|
|
72
|
+
if (card.ruleHits && card.ruleHits.length > 0) {
|
|
73
|
+
const rules = card.ruleHits.map((r) => `${r.rule} (${r.count})`).join(", ");
|
|
74
|
+
parts.push(`命中规则: [${rules}]`);
|
|
75
|
+
}
|
|
76
|
+
return parts.join(";") + "。";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Default English
|
|
80
|
+
if (card.status === "clean") {
|
|
81
|
+
return `Turn #${tid}: ${card.totalCalls} calls, all allowed cleanly.`;
|
|
82
|
+
}
|
|
83
|
+
const parts = [`Turn #${tid}: ${card.totalCalls} calls`];
|
|
84
|
+
if (card.blockedCalls > 0) parts.push(`${card.blockedCalls} blocked`);
|
|
85
|
+
if (card.askedCalls > 0) parts.push(`${card.askedCalls} confirmation prompts`);
|
|
86
|
+
if (card.ruleHits && card.ruleHits.length > 0) {
|
|
87
|
+
const rules = card.ruleHits.map((r) => `${r.rule} (${r.count})`).join(", ");
|
|
88
|
+
parts.push(`rule hits: [${rules}]`);
|
|
89
|
+
}
|
|
90
|
+
return parts.join(", ") + ".";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* TurnCardTracker: tracks tool calls per agent turn and builds structured summaries (#86).
|
|
95
|
+
*/
|
|
96
|
+
export class TurnCardTracker {
|
|
97
|
+
/**
|
|
98
|
+
* @param {{ maxRetainedTurns?: number }} [options]
|
|
99
|
+
*/
|
|
100
|
+
constructor(options = {}) {
|
|
101
|
+
this.maxRetainedTurns = options.maxRetainedTurns || 50;
|
|
102
|
+
/** @type {Map<string, Map<string | number, TurnCallRecord[]>>} */
|
|
103
|
+
this._sessions = new Map();
|
|
104
|
+
/** @type {Map<string, Map<string | number, TurnCard>>} */
|
|
105
|
+
this._cards = new Map();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Records a tool call within a specific session and turn.
|
|
110
|
+
*
|
|
111
|
+
* @param {string} sessionId
|
|
112
|
+
* @param {string | number} turnId
|
|
113
|
+
* @param {TurnCallRecord} record
|
|
114
|
+
*/
|
|
115
|
+
recordCall(sessionId, turnId, record) {
|
|
116
|
+
const sid = String(sessionId || "default");
|
|
117
|
+
const tid = turnId !== undefined && turnId !== null ? turnId : "active";
|
|
118
|
+
|
|
119
|
+
let sessionTurns = this._sessions.get(sid);
|
|
120
|
+
if (!sessionTurns) {
|
|
121
|
+
sessionTurns = new Map();
|
|
122
|
+
this._sessions.set(sid, sessionTurns);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let calls = sessionTurns.get(tid);
|
|
126
|
+
if (!calls) {
|
|
127
|
+
calls = [];
|
|
128
|
+
sessionTurns.set(tid, calls);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
calls.push({
|
|
132
|
+
tool: record.tool || "unknown",
|
|
133
|
+
command: record.command || "",
|
|
134
|
+
snippet: record.snippet || "",
|
|
135
|
+
verdict: record.verdict || "allow",
|
|
136
|
+
reason: record.reason || "",
|
|
137
|
+
rule: record.rule || record.reason || "",
|
|
138
|
+
durationMs: record.durationMs || 0,
|
|
139
|
+
timestamp: record.timestamp || Date.now(),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Invalidate cached card for this turn
|
|
143
|
+
const sessionCards = this._cards.get(sid);
|
|
144
|
+
if (sessionCards) {
|
|
145
|
+
sessionCards.delete(tid);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Builds the TurnCard for a specific turn in a session (#86).
|
|
151
|
+
*
|
|
152
|
+
* @param {string} sessionId
|
|
153
|
+
* @param {string | number} turnId
|
|
154
|
+
* @param {Object} [options]
|
|
155
|
+
* @param {'en' | 'ru' | 'zh'} [options.lang='en']
|
|
156
|
+
* @returns {TurnCard}
|
|
157
|
+
*/
|
|
158
|
+
buildTurnCard(sessionId, turnId, options = {}) {
|
|
159
|
+
const sid = String(sessionId || "default");
|
|
160
|
+
const tid = turnId !== undefined && turnId !== null ? turnId : "active";
|
|
161
|
+
const lang = options.lang || "en";
|
|
162
|
+
|
|
163
|
+
let sessionCards = this._cards.get(sid);
|
|
164
|
+
if (!sessionCards) {
|
|
165
|
+
sessionCards = new Map();
|
|
166
|
+
this._cards.set(sid, sessionCards);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const cached = sessionCards.get(tid);
|
|
170
|
+
if (cached) return cached;
|
|
171
|
+
|
|
172
|
+
const sessionTurns = this._sessions.get(sid);
|
|
173
|
+
const calls = (sessionTurns && sessionTurns.get(tid)) || [];
|
|
174
|
+
|
|
175
|
+
let totalCalls = calls.length;
|
|
176
|
+
let allowedCalls = 0;
|
|
177
|
+
let blockedCalls = 0;
|
|
178
|
+
let askedCalls = 0;
|
|
179
|
+
let preemptedCalls = 0;
|
|
180
|
+
/** @type {Map<string, { count: number, reasons: Set<string> }>} */
|
|
181
|
+
const ruleHitMap = new Map();
|
|
182
|
+
/** @type {TurnCallRecord[]} */
|
|
183
|
+
const blockedDetails = [];
|
|
184
|
+
|
|
185
|
+
for (const c of calls) {
|
|
186
|
+
if (c.verdict === "deny") {
|
|
187
|
+
blockedCalls++;
|
|
188
|
+
blockedDetails.push(c);
|
|
189
|
+
} else if (c.verdict === "ask") {
|
|
190
|
+
askedCalls++;
|
|
191
|
+
} else if (c.verdict === "preempted") {
|
|
192
|
+
preemptedCalls++;
|
|
193
|
+
} else {
|
|
194
|
+
allowedCalls++;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (c.rule && c.verdict !== "allow") {
|
|
198
|
+
let entry = ruleHitMap.get(c.rule);
|
|
199
|
+
if (!entry) {
|
|
200
|
+
entry = { count: 0, reasons: new Set() };
|
|
201
|
+
ruleHitMap.set(c.rule, entry);
|
|
202
|
+
}
|
|
203
|
+
entry.count++;
|
|
204
|
+
if (c.reason) entry.reasons.add(c.reason);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** @type {RuleHitSummary[]} */
|
|
209
|
+
const ruleHits = Array.from(ruleHitMap.entries()).map(([rule, data]) => ({
|
|
210
|
+
rule,
|
|
211
|
+
count: data.count,
|
|
212
|
+
reasons: Array.from(data.reasons),
|
|
213
|
+
}));
|
|
214
|
+
|
|
215
|
+
/** @type {'clean' | 'warnings' | 'blocked'} */
|
|
216
|
+
let status = "clean";
|
|
217
|
+
if (blockedCalls > 0) {
|
|
218
|
+
status = "blocked";
|
|
219
|
+
} else if (askedCalls > 0) {
|
|
220
|
+
status = "warnings";
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** @type {TurnCard} */
|
|
224
|
+
const card = {
|
|
225
|
+
sessionId: sid,
|
|
226
|
+
turnId: tid,
|
|
227
|
+
timestamp: Date.now(),
|
|
228
|
+
status,
|
|
229
|
+
totalCalls,
|
|
230
|
+
allowedCalls,
|
|
231
|
+
blockedCalls,
|
|
232
|
+
askedCalls,
|
|
233
|
+
preemptedCalls,
|
|
234
|
+
ruleHits,
|
|
235
|
+
blockedDetails,
|
|
236
|
+
summaryText: "",
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
card.summaryText = formatTurnCardSummary(card, lang);
|
|
240
|
+
sessionCards.set(tid, card);
|
|
241
|
+
return card;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Returns all TurnCards for a session.
|
|
246
|
+
*
|
|
247
|
+
* @param {string} sessionId
|
|
248
|
+
* @returns {TurnCard[]}
|
|
249
|
+
*/
|
|
250
|
+
getTurnCards(sessionId) {
|
|
251
|
+
const sid = String(sessionId || "default");
|
|
252
|
+
const sessionTurns = this._sessions.get(sid);
|
|
253
|
+
if (!sessionTurns) return [];
|
|
254
|
+
const list = [];
|
|
255
|
+
for (const tid of sessionTurns.keys()) {
|
|
256
|
+
list.push(this.buildTurnCard(sid, tid));
|
|
257
|
+
}
|
|
258
|
+
return list;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Clears tracking for a given turn.
|
|
263
|
+
*
|
|
264
|
+
* @param {string} sessionId
|
|
265
|
+
* @param {string | number} turnId
|
|
266
|
+
*/
|
|
267
|
+
clearTurn(sessionId, turnId) {
|
|
268
|
+
const sid = String(sessionId || "default");
|
|
269
|
+
const tid = turnId !== undefined && turnId !== null ? turnId : "active";
|
|
270
|
+
const st = this._sessions.get(sid);
|
|
271
|
+
if (st) st.delete(tid);
|
|
272
|
+
const sc = this._cards.get(sid);
|
|
273
|
+
if (sc) sc.delete(tid);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Clears tracking for an entire session.
|
|
278
|
+
*
|
|
279
|
+
* @param {string} sessionId
|
|
280
|
+
*/
|
|
281
|
+
clearSession(sessionId) {
|
|
282
|
+
const sid = String(sessionId || "default");
|
|
283
|
+
this._sessions.delete(sid);
|
|
284
|
+
this._cards.delete(sid);
|
|
285
|
+
}
|
|
286
|
+
}
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-approval-gate",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.26",
|
|
4
4
|
"description": "Host-only command safety gate for DeepSeek Harness: blocks recognized dangerous operations and requests DSH approval when a command cannot be inspected.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./lib/index.js",
|
|
9
9
|
"./package.json": "./package.json",
|
|
10
|
-
"./cordis.patch.yml": "./cordis.patch.yml"
|
|
10
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
11
|
+
"./client": "./lib/client.js"
|
|
11
12
|
},
|
|
12
13
|
"files": [
|
|
13
14
|
"lib",
|
|
@@ -23,6 +24,14 @@
|
|
|
23
24
|
"dsh": {
|
|
24
25
|
"bundle": {
|
|
25
26
|
"patch": "./cordis.patch.yml"
|
|
27
|
+
},
|
|
28
|
+
"client": {
|
|
29
|
+
"platform": "web",
|
|
30
|
+
"inject": [
|
|
31
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
32
|
+
"@deepseek-ai/dsh-client-ui-slots",
|
|
33
|
+
"@deepseek-ai/dsh-client-locale"
|
|
34
|
+
]
|
|
26
35
|
}
|
|
27
36
|
},
|
|
28
37
|
"license": "MIT",
|
|
@@ -57,4 +66,4 @@
|
|
|
57
66
|
"bin": {
|
|
58
67
|
"dsh-approval-eval": "./bin/eval.mjs"
|
|
59
68
|
}
|
|
60
|
-
}
|
|
69
|
+
}
|