@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.
@@ -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/engine.js CHANGED
@@ -31,6 +31,9 @@ import { TrajectoryTracker } from "./drift.js";
31
31
  import { ConfirmationLearner } from "./learning.js";
32
32
  import { findSemanticallyEquivalentRule } from "./similarity.js";
33
33
  import { IndependentReviewer } from "./reviewer.js";
34
+ import { raceHumanDecision, RaceHumanHandle } from "./race-decision.js";
35
+ import { RulesHmrWatcher } from "./rules-hmr.js";
36
+ import { checkNetworkAccess, resolveNetworkPolicy } from "./network-sandbox.js";
34
37
 
35
38
  /**
36
39
  * Determine whether an operation belongs to a critical risk category requiring lockedAsk (#52).
@@ -98,6 +101,8 @@ export class SecurityEngine {
98
101
  this.trajectoryTracker = options.trajectoryTracker !== undefined ? options.trajectoryTracker : new TrajectoryTracker();
99
102
  this.learner = options.learner !== undefined ? options.learner : new ConfirmationLearner(options);
100
103
  this.reviewer = options.reviewer || new IndependentReviewer(options);
104
+ this.hmrWatcher = options.hmrWatcher || new RulesHmrWatcher(options);
105
+ this.egressProxy = options.egressProxy || null;
101
106
  }
102
107
 
103
108
  /**
@@ -522,9 +527,7 @@ export class SecurityEngine {
522
527
  if (decision && decision.verdict === "ask" && isCriticalOperation(toolName, command, execution)) {
523
528
  decision.lockedAsk = true;
524
529
  decision.lockedReason = "critical-operation";
525
- if (decision.hit) {
526
- decision.hit.lockedAsk = true;
527
- }
530
+ if (decision.hit) decision.hit.lockedAsk = true;
528
531
  }
529
532
  }
530
533
 
@@ -582,5 +585,9 @@ export class SecurityEngine {
582
585
  recordConfirmation(sessionId, execution) { return this.learner ? this.learner.recordConfirmation(sessionId, execution) : null; }
583
586
  getLearnedRules(sessionId) { return this.learner ? this.learner.getLearnedRules(sessionId) : []; }
584
587
  reviewWithIndependentModel(execution, context) { return this.reviewer ? this.reviewer.review(execution, context) : null; }
588
+ raceDecision(humanPromise, opts) { return raceHumanDecision(humanPromise, opts); }
589
+ createRaceHandle(opts) { return new RaceHumanHandle(opts); }
590
+ checkNetwork(dest, sandboxMode, opts) { return checkNetworkAccess(dest, sandboxMode, opts); }
591
+ resolveNetworkPolicy(mode, opts) { return resolveNetworkPolicy(mode, opts); }
585
592
  }
586
593
 
@@ -0,0 +1,218 @@
1
+ // lib/forward-proxy.js
2
+ // Local HTTP/CONNECT Forward Proxy for Bash Network Egress Interception (#39).
3
+ // Binds to 127.0.0.1 and intercepts egress traffic from child processes (curl, python, node).
4
+ // Enforces sandbox network policies and blocks unauthorized hosts via HTTP and CONNECT tunnels.
5
+
6
+ import http from "node:http";
7
+ import net from "node:net";
8
+ import { checkNetworkAccess, parseDestination } from "./network-sandbox.js";
9
+
10
+ /**
11
+ * Embedded forward proxy for intercepting subprocess egress (#39).
12
+ */
13
+ export class LocalEgressProxy {
14
+ /**
15
+ * @param {{
16
+ * port?: number,
17
+ * sandboxMode?: string,
18
+ * whitelist?: string[],
19
+ * auditLogger?: any,
20
+ * logger?: any,
21
+ * }} [options]
22
+ */
23
+ constructor(options = {}) {
24
+ this.port = typeof options.port === "number" ? options.port : 0;
25
+ this.sandboxMode = options.sandboxMode || "workspace-write";
26
+ this.whitelist = Array.isArray(options.whitelist) ? [...options.whitelist] : [];
27
+ this.auditLogger = options.auditLogger || null;
28
+ this.logger = options.logger || null;
29
+
30
+ this.server = null;
31
+ this.listeningPort = 0;
32
+ this.activeSockets = new Set();
33
+ }
34
+
35
+ /**
36
+ * Starts the forward proxy server.
37
+ * @returns {Promise<{ host: string, port: number }>}
38
+ */
39
+ async start() {
40
+ if (this.server) {
41
+ return { host: "127.0.0.1", port: this.listeningPort };
42
+ }
43
+
44
+ return new Promise((resolve, reject) => {
45
+ this.server = http.createServer((req, res) => {
46
+ this._handleHttpRequest(req, res);
47
+ });
48
+
49
+ this.server.on("connect", (req, clientSocket, head) => {
50
+ this._handleConnectTunnel(req, clientSocket, head);
51
+ });
52
+
53
+ this.server.on("connection", (socket) => {
54
+ this.activeSockets.add(socket);
55
+ socket.once("close", () => this.activeSockets.delete(socket));
56
+ });
57
+
58
+ this.server.on("error", (err) => {
59
+ if (this.logger) this.logger.error?.("LocalEgressProxy server error:", err);
60
+ reject(err);
61
+ });
62
+
63
+ this.server.listen(this.port, "127.0.0.1", () => {
64
+ const addr = this.server.address();
65
+ this.listeningPort = typeof addr === "object" && addr ? addr.port : this.port;
66
+ resolve({ host: "127.0.0.1", port: this.listeningPort });
67
+ });
68
+ });
69
+ }
70
+
71
+ /**
72
+ * Stops the forward proxy and closes all active sockets.
73
+ * @returns {Promise<void>}
74
+ */
75
+ async stop() {
76
+ if (!this.server) return;
77
+
78
+ return new Promise((resolve) => {
79
+ for (const socket of this.activeSockets) {
80
+ try {
81
+ socket.destroy();
82
+ } catch (_e) {}
83
+ }
84
+ this.activeSockets.clear();
85
+
86
+ this.server.close(() => {
87
+ this.server = null;
88
+ this.listeningPort = 0;
89
+ resolve();
90
+ });
91
+ });
92
+ }
93
+
94
+ /**
95
+ * Returns proxy environment variables to inject into child processes.
96
+ * @returns {{ HTTP_PROXY: string, HTTPS_PROXY: string, ALL_PROXY: string, NO_PROXY: string }}
97
+ */
98
+ getEnv() {
99
+ if (!this.listeningPort) return {};
100
+ const proxyUrl = `http://127.0.0.1:${this.listeningPort}`;
101
+ return {
102
+ HTTP_PROXY: proxyUrl,
103
+ HTTPS_PROXY: proxyUrl,
104
+ ALL_PROXY: proxyUrl,
105
+ NO_PROXY: "127.0.0.1,localhost",
106
+ };
107
+ }
108
+
109
+ /**
110
+ * Checks whether access to destination is allowed under proxy rules.
111
+ * @param {string} destination
112
+ * @returns {ReturnType<typeof checkNetworkAccess>}
113
+ */
114
+ checkAccess(destination) {
115
+ return checkNetworkAccess(destination, this.sandboxMode, {
116
+ whitelist: this.whitelist,
117
+ });
118
+ }
119
+
120
+ /**
121
+ * @private
122
+ */
123
+ _handleHttpRequest(req, res) {
124
+ const dest = req.url || req.headers.host || "";
125
+ const access = this.checkAccess(dest);
126
+
127
+ if (!access.allowed) {
128
+ this._recordBlockedEgress(access.hostname, "HTTP", access.reason);
129
+ res.writeHead(403, { "Content-Type": "text/plain", "X-Proxy-Blocked": "unauthorized-egress" });
130
+ res.end(`403 Forbidden: Egress to ${access.hostname} blocked by security sandbox (${access.reason})\n`);
131
+ return;
132
+ }
133
+
134
+ // Forward HTTP request
135
+ try {
136
+ const u = new URL(req.url);
137
+ const options = {
138
+ hostname: u.hostname,
139
+ port: Number(u.port) || 80,
140
+ path: `${u.pathname}${u.search}`,
141
+ method: req.method,
142
+ headers: req.headers,
143
+ };
144
+
145
+ const proxyReq = http.request(options, (proxyRes) => {
146
+ res.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
147
+ proxyRes.pipe(res);
148
+ });
149
+
150
+ proxyReq.on("error", (err) => {
151
+ res.writeHead(502, { "Content-Type": "text/plain" });
152
+ res.end(`502 Bad Gateway: ${err.message}\n`);
153
+ });
154
+
155
+ req.pipe(proxyReq);
156
+ } catch (err) {
157
+ res.writeHead(400, { "Content-Type": "text/plain" });
158
+ res.end(`400 Bad Request: ${err.message}\n`);
159
+ }
160
+ }
161
+
162
+ /**
163
+ * @private
164
+ */
165
+ _handleConnectTunnel(req, clientSocket, head) {
166
+ const dest = req.url || "";
167
+ const access = this.checkAccess(dest);
168
+
169
+ if (!access.allowed) {
170
+ this._recordBlockedEgress(access.hostname, "CONNECT", access.reason);
171
+ clientSocket.write("HTTP/1.1 403 Forbidden\r\nContent-Type: text/plain\r\n\r\nBlocked by security sandbox\n");
172
+ clientSocket.destroy();
173
+ return;
174
+ }
175
+
176
+ const { hostname, port } = parseDestination(dest);
177
+ const targetPort = port || 443;
178
+
179
+ const serverSocket = net.connect(targetPort, hostname, () => {
180
+ clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
181
+ if (head && head.length > 0) {
182
+ serverSocket.write(head);
183
+ }
184
+ serverSocket.pipe(clientSocket);
185
+ clientSocket.pipe(serverSocket);
186
+ });
187
+
188
+ serverSocket.on("error", (_err) => {
189
+ try {
190
+ clientSocket.write("HTTP/1.1 502 Bad Gateway\r\n\r\n");
191
+ clientSocket.destroy();
192
+ } catch (_e) {}
193
+ });
194
+
195
+ clientSocket.on("error", (_err) => {
196
+ try {
197
+ serverSocket.destroy();
198
+ } catch (_e) {}
199
+ });
200
+ }
201
+
202
+ /**
203
+ * @private
204
+ */
205
+ _recordBlockedEgress(host, protocol, reason) {
206
+ if (this.auditLogger && typeof this.auditLogger.appendAuditLine === "function") {
207
+ this.auditLogger.appendAuditLine({
208
+ sessionId: "egress-proxy",
209
+ decision: {
210
+ verdict: "deny",
211
+ stage: "P0",
212
+ reason: `egress-proxy-blocked: ${reason}`,
213
+ detail: `${protocol} to ${host}`,
214
+ },
215
+ });
216
+ }
217
+ }
218
+ }
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}`);
@@ -578,3 +587,10 @@ export { DEFAULT_MIN_CONFIRMATIONS, DEFAULT_MAX_LEARNED_RULES, EXCLUSION_REASONS
578
587
  export { canonicalizeCommand, isSemanticallyEquivalent, findSemanticallyEquivalentRule, validateSemanticSimilarity } from "./similarity.js";
579
588
  export { SAFETY_REVIEWER_SYSTEM_PROMPT, DEFAULT_REVIEWER_AGENT_ID, assertSeparationOfDuties, IndependentReviewer } from "./reviewer.js";
580
589
  export { DEFAULT_EVAL_CORPUS, runEvaluation, formatEvaluationReport } from "./eval-runner.js";
590
+ export { DEFAULT_HUMAN_TIMEOUT_MS, RACE_WINNERS, RaceHumanHandle, raceHumanDecision } from "./race-decision.js";
591
+ export { DEFAULT_HMR_DEBOUNCE_MS, RulesHmrWatcher } from "./rules-hmr.js";
592
+ export { NETWORK_POLICIES, SANDBOX_NETWORK_MAP, resolveNetworkPolicy, parseDestination, checkNetworkAccess } from "./network-sandbox.js";
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";