@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
|
@@ -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";
|