@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 +16 -0
- package/lib/client.js +363 -0
- package/lib/diff-viewer.js +371 -0
- package/lib/engine.js +10 -3
- package/lib/forward-proxy.js +218 -0
- package/lib/index.js +16 -0
- package/lib/network-sandbox.js +147 -0
- package/lib/race-decision.js +180 -0
- package/lib/rules-hmr.js +132 -0
- package/lib/timeline-service.js +252 -0
- package/lib/turn-card.js +286 -0
- package/package.json +12 -3
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
|
+
}
|