@goodandready/dsh-approval-gate 0.1.25 → 0.1.27
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 +233 -218
- package/lib/client.js +548 -0
- package/lib/diff-viewer.js +371 -0
- package/lib/index.js +14 -4
- package/lib/learning.js +5 -0
- package/lib/timeline-service.js +333 -0
- package/lib/turn-card.js +286 -0
- package/package.json +12 -3
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// lib/timeline-service.js
|
|
2
|
+
// Session Audit Timeline, Learned Rules & WebServer REST Service for DSH Approval Gate (#82, #60, #74, #86, #84, #49, #116).
|
|
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, learner?: any, engine?: any }} [options]
|
|
30
|
+
*/
|
|
31
|
+
constructor(options = {}) {
|
|
32
|
+
this.maxEventsPerSession = options.maxEventsPerSession || 500;
|
|
33
|
+
this.turnCardTracker = options.turnCardTracker || null;
|
|
34
|
+
this.learner = options.learner || null;
|
|
35
|
+
this.engine = options.engine || null;
|
|
36
|
+
/** @type {Map<string, TimelineEvent[]>} */
|
|
37
|
+
this._sessions = new Map();
|
|
38
|
+
this._nextId = 1;
|
|
39
|
+
this.customRules = [];
|
|
40
|
+
this.whitelistPaths = [];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Records a security gate event into session timeline (#82).
|
|
45
|
+
*
|
|
46
|
+
* @param {Object} raw
|
|
47
|
+
* @returns {TimelineEvent}
|
|
48
|
+
*/
|
|
49
|
+
recordEvent(raw) {
|
|
50
|
+
const sid = String(raw.sessionId || "default");
|
|
51
|
+
const tid = raw.turnId !== undefined && raw.turnId !== null ? raw.turnId : "active";
|
|
52
|
+
|
|
53
|
+
let list = this._sessions.get(sid);
|
|
54
|
+
if (!list) {
|
|
55
|
+
list = [];
|
|
56
|
+
this._sessions.set(sid, list);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const id = `evt_${Date.now()}_${this._nextId++}`;
|
|
60
|
+
const timestamp = raw.timestamp || Date.now();
|
|
61
|
+
let countdownSec = null;
|
|
62
|
+
let expiresAt = null;
|
|
63
|
+
|
|
64
|
+
if (raw.verdict === "ask" && typeof raw.timeoutMs === "number" && raw.timeoutMs > 0) {
|
|
65
|
+
expiresAt = timestamp + raw.timeoutMs;
|
|
66
|
+
countdownSec = Math.max(0, Math.round(raw.timeoutMs / 1000));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** @type {TimelineEvent} */
|
|
70
|
+
const evt = {
|
|
71
|
+
id,
|
|
72
|
+
sessionId: sid,
|
|
73
|
+
turnId: tid,
|
|
74
|
+
timestamp,
|
|
75
|
+
tool: raw.tool || "bash",
|
|
76
|
+
command: raw.command || "",
|
|
77
|
+
snippet: raw.snippet || "",
|
|
78
|
+
verdict: raw.verdict || "allow",
|
|
79
|
+
stage: raw.stage || "",
|
|
80
|
+
reason: raw.reason || "",
|
|
81
|
+
durationMs: raw.durationMs || 0,
|
|
82
|
+
expiresAt,
|
|
83
|
+
countdownSec,
|
|
84
|
+
diffMeta: raw.diffMeta || null,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
list.push(evt);
|
|
88
|
+
if (list.length > this.maxEventsPerSession) {
|
|
89
|
+
list.splice(0, list.length - this.maxEventsPerSession);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Also forward to TurnCardTracker if attached (#86)
|
|
93
|
+
if (this.turnCardTracker && typeof this.turnCardTracker.recordCall === "function") {
|
|
94
|
+
this.turnCardTracker.recordCall(sid, tid, {
|
|
95
|
+
tool: evt.tool,
|
|
96
|
+
command: evt.command,
|
|
97
|
+
snippet: evt.snippet,
|
|
98
|
+
verdict: evt.verdict,
|
|
99
|
+
reason: evt.reason,
|
|
100
|
+
durationMs: evt.durationMs,
|
|
101
|
+
timestamp: evt.timestamp,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return evt;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Retrieves timeline events for a given session with optional filtering (#82).
|
|
110
|
+
*
|
|
111
|
+
* @param {string} sessionId
|
|
112
|
+
* @param {Object} [options]
|
|
113
|
+
* @param {'all' | 'blocked' | 'allowed' | 'ask'} [options.filter='all']
|
|
114
|
+
* @param {number} [options.limit=100]
|
|
115
|
+
* @param {number} [options.offset=0]
|
|
116
|
+
* @returns {{ total: number, events: TimelineEvent[] }}
|
|
117
|
+
*/
|
|
118
|
+
getEvents(sessionId, options = {}) {
|
|
119
|
+
const sid = String(sessionId || "default");
|
|
120
|
+
const list = this._sessions.get(sid) || [];
|
|
121
|
+
const filter = options.filter || "all";
|
|
122
|
+
const limit = typeof options.limit === "number" ? Math.max(1, options.limit) : 100;
|
|
123
|
+
const offset = typeof options.offset === "number" ? Math.max(0, options.offset) : 0;
|
|
124
|
+
|
|
125
|
+
let filtered = list;
|
|
126
|
+
if (filter === "blocked") {
|
|
127
|
+
filtered = list.filter((e) => e.verdict === "deny");
|
|
128
|
+
} else if (filter === "allowed") {
|
|
129
|
+
filtered = list.filter((e) => e.verdict === "allow");
|
|
130
|
+
} else if (filter === "ask") {
|
|
131
|
+
filtered = list.filter((e) => e.verdict === "ask");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const total = filtered.length;
|
|
135
|
+
const reversed = filtered.slice().reverse();
|
|
136
|
+
const paginated = reversed.slice(offset, offset + limit);
|
|
137
|
+
|
|
138
|
+
return { total, events: paginated };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Clears timeline events for a specific session.
|
|
143
|
+
*
|
|
144
|
+
* @param {string} sessionId
|
|
145
|
+
*/
|
|
146
|
+
clearSession(sessionId) {
|
|
147
|
+
const sid = String(sessionId || "default");
|
|
148
|
+
this._sessions.delete(sid);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Registers REST HTTP routes on ctx.webServer for browser UI interaction (#82, #74, #86, #84, #49, #116).
|
|
153
|
+
*
|
|
154
|
+
* @param {any} ctx Cordis context
|
|
155
|
+
* @param {Object} [options]
|
|
156
|
+
* @param {string} [options.prefix='/dsh-approval-gate']
|
|
157
|
+
*/
|
|
158
|
+
registerRoutes(ctx, options = {}) {
|
|
159
|
+
const prefix = options.prefix || "/dsh-approval-gate";
|
|
160
|
+
if (!ctx || !ctx.webServer || typeof ctx.webServer.register !== "function") {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const jsonResponse = (res, status, data) => {
|
|
165
|
+
res.writeHead(status, {
|
|
166
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
167
|
+
"Cache-Control": "no-cache, no-store, must-revalidate",
|
|
168
|
+
});
|
|
169
|
+
res.end(JSON.stringify(data));
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const parseBody = (req) =>
|
|
173
|
+
new Promise((resolve) => {
|
|
174
|
+
let body = "";
|
|
175
|
+
req.on("data", (c) => { body += c; });
|
|
176
|
+
req.on("end", () => {
|
|
177
|
+
try { resolve(JSON.parse(body || "{}")); }
|
|
178
|
+
catch { resolve({}); }
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// 1. GET /timeline
|
|
183
|
+
const timelineRoute = () =>
|
|
184
|
+
ctx.webServer.register({
|
|
185
|
+
path: `${prefix}/timeline`,
|
|
186
|
+
method: "GET",
|
|
187
|
+
handler: async (req, res) => {
|
|
188
|
+
try {
|
|
189
|
+
const url = new URL(req.url, "http://127.0.0.1");
|
|
190
|
+
const sid = url.searchParams.get("sessionId") || "default";
|
|
191
|
+
const filter = url.searchParams.get("filter") || "all";
|
|
192
|
+
const limit = parseInt(url.searchParams.get("limit") || "100", 10);
|
|
193
|
+
const data = this.getEvents(sid, { filter, limit });
|
|
194
|
+
jsonResponse(res, 200, { ok: true, sessionId: sid, ...data });
|
|
195
|
+
} catch (err) {
|
|
196
|
+
jsonResponse(res, 500, { ok: false, error: String(err && err.message || err) });
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// 2. GET /turn-card
|
|
202
|
+
const turnCardRoute = () =>
|
|
203
|
+
ctx.webServer.register({
|
|
204
|
+
path: `${prefix}/turn-card`,
|
|
205
|
+
method: "GET",
|
|
206
|
+
handler: async (req, res) => {
|
|
207
|
+
try {
|
|
208
|
+
const url = new URL(req.url, "http://127.0.0.1");
|
|
209
|
+
const sid = url.searchParams.get("sessionId") || "default";
|
|
210
|
+
const tid = url.searchParams.get("turnId") || "active";
|
|
211
|
+
const lang = url.searchParams.get("lang") || "en";
|
|
212
|
+
let card = null;
|
|
213
|
+
if (this.turnCardTracker && typeof this.turnCardTracker.buildTurnCard === "function") {
|
|
214
|
+
card = this.turnCardTracker.buildTurnCard(sid, tid, { lang });
|
|
215
|
+
}
|
|
216
|
+
jsonResponse(res, 200, { ok: true, sessionId: sid, turnId: tid, card });
|
|
217
|
+
} catch (err) {
|
|
218
|
+
jsonResponse(res, 500, { ok: false, error: String(err && err.message || err) });
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// 3. POST /diff
|
|
224
|
+
const diffRoute = () =>
|
|
225
|
+
ctx.webServer.register({
|
|
226
|
+
path: `${prefix}/diff`,
|
|
227
|
+
method: "POST",
|
|
228
|
+
handler: async (req, res) => {
|
|
229
|
+
try {
|
|
230
|
+
const parsed = await parseBody(req);
|
|
231
|
+
const oldText = parsed.oldText || "";
|
|
232
|
+
const newText = parsed.newText || "";
|
|
233
|
+
const contextLines = typeof parsed.contextLines === "number" ? parsed.contextLines : 3;
|
|
234
|
+
const diffLines = generateDiffLines(oldText, newText);
|
|
235
|
+
const hunks = groupHunks(diffLines, { contextLines });
|
|
236
|
+
const diffText = formatUnifiedDiff(hunks, {
|
|
237
|
+
oldFile: parsed.filename ? `a/${parsed.filename}` : "a/original",
|
|
238
|
+
newFile: parsed.filename ? `b/${parsed.filename}` : "b/modified",
|
|
239
|
+
});
|
|
240
|
+
jsonResponse(res, 200, { ok: true, hunks, diffText, linesCount: diffLines.length });
|
|
241
|
+
} catch (err) {
|
|
242
|
+
jsonResponse(res, 500, { ok: false, error: String(err && err.message || err) });
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// 4. GET /learned-rules (#84)
|
|
248
|
+
const learnedRulesRoute = () =>
|
|
249
|
+
ctx.webServer.register({
|
|
250
|
+
path: `${prefix}/learned-rules`,
|
|
251
|
+
method: "GET",
|
|
252
|
+
handler: async (req, res) => {
|
|
253
|
+
try {
|
|
254
|
+
const url = new URL(req.url, "http://127.0.0.1");
|
|
255
|
+
const sid = url.searchParams.get("sessionId") || "default";
|
|
256
|
+
const rules = (this.learner && typeof this.learner.getLearnedRules === "function")
|
|
257
|
+
? this.learner.getLearnedRules(sid)
|
|
258
|
+
: [];
|
|
259
|
+
jsonResponse(res, 200, { ok: true, sessionId: sid, rules });
|
|
260
|
+
} catch (err) {
|
|
261
|
+
jsonResponse(res, 500, { ok: false, error: String(err && err.message || err) });
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// 5. POST /learned-rules/revoke (#84)
|
|
267
|
+
const revokeLearnedRoute = () =>
|
|
268
|
+
ctx.webServer.register({
|
|
269
|
+
path: `${prefix}/learned-rules/revoke`,
|
|
270
|
+
method: "POST",
|
|
271
|
+
handler: async (req, res) => {
|
|
272
|
+
try {
|
|
273
|
+
const body = await parseBody(req);
|
|
274
|
+
const sid = body.sessionId || "default";
|
|
275
|
+
const key = body.key || "";
|
|
276
|
+
let revoked = false;
|
|
277
|
+
if (this.learner && typeof this.learner.revoke === "function" && key) {
|
|
278
|
+
revoked = this.learner.revoke(sid, key);
|
|
279
|
+
}
|
|
280
|
+
jsonResponse(res, 200, { ok: true, revoked, key });
|
|
281
|
+
} catch (err) {
|
|
282
|
+
jsonResponse(res, 500, { ok: false, error: String(err && err.message || err) });
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// 6. GET /mode (#116)
|
|
288
|
+
const modeRoute = () =>
|
|
289
|
+
ctx.webServer.register({
|
|
290
|
+
path: `${prefix}/mode`,
|
|
291
|
+
method: "GET",
|
|
292
|
+
handler: async (req, res) => {
|
|
293
|
+
try {
|
|
294
|
+
const mode = this.engine?.mode || "hybrid";
|
|
295
|
+
jsonResponse(res, 200, { ok: true, mode });
|
|
296
|
+
} catch (err) {
|
|
297
|
+
jsonResponse(res, 500, { ok: false, error: String(err && err.message || err) });
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// 7. GET & POST /rules (#49)
|
|
303
|
+
const rulesRoute = () =>
|
|
304
|
+
ctx.webServer.register({
|
|
305
|
+
path: `${prefix}/rules`,
|
|
306
|
+
method: "GET",
|
|
307
|
+
handler: async (req, res) => {
|
|
308
|
+
jsonResponse(res, 200, { ok: true, rules: this.customRules, whitelistPaths: this.whitelistPaths });
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
const updateRulesRoute = () =>
|
|
312
|
+
ctx.webServer.register({
|
|
313
|
+
path: `${prefix}/rules`,
|
|
314
|
+
method: "POST",
|
|
315
|
+
handler: async (req, res) => {
|
|
316
|
+
try {
|
|
317
|
+
const body = await parseBody(req);
|
|
318
|
+
if (Array.isArray(body.rules)) this.customRules = body.rules;
|
|
319
|
+
if (Array.isArray(body.whitelistPaths)) this.whitelistPaths = body.whitelistPaths;
|
|
320
|
+
jsonResponse(res, 200, { ok: true, rules: this.customRules, whitelistPaths: this.whitelistPaths });
|
|
321
|
+
} catch (err) {
|
|
322
|
+
jsonResponse(res, 500, { ok: false, error: String(err && err.message || err) });
|
|
323
|
+
}
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
const routes = [timelineRoute, turnCardRoute, diffRoute, learnedRulesRoute, revokeLearnedRoute, modeRoute, rulesRoute, updateRulesRoute];
|
|
328
|
+
for (const r of routes) {
|
|
329
|
+
if (typeof ctx.effect === "function") ctx.effect(r, `dsh-approval-gate: ${r.name}`);
|
|
330
|
+
else r();
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
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.27",
|
|
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
|
+
}
|