@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,147 @@
|
|
|
1
|
+
// lib/network-sandbox.js
|
|
2
|
+
// Mapping Network Egress Policies to DSH Sandbox Modes (#41).
|
|
3
|
+
// Controls outbound socket and HTTP connections based on shell.sandboxMode.
|
|
4
|
+
|
|
5
|
+
import { isLoopbackHost } from "./loopback.js";
|
|
6
|
+
|
|
7
|
+
export const NETWORK_POLICIES = Object.freeze({
|
|
8
|
+
DENY_ALL: "deny-all",
|
|
9
|
+
WHITELIST_ONLY: "whitelist-only",
|
|
10
|
+
ALLOW_ALL: "allow-all",
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export const SANDBOX_NETWORK_MAP = Object.freeze({
|
|
14
|
+
"read-only": NETWORK_POLICIES.DENY_ALL,
|
|
15
|
+
readonly: NETWORK_POLICIES.DENY_ALL,
|
|
16
|
+
isolated: NETWORK_POLICIES.DENY_ALL,
|
|
17
|
+
"workspace-write": NETWORK_POLICIES.WHITELIST_ONLY,
|
|
18
|
+
standard: NETWORK_POLICIES.WHITELIST_ONLY,
|
|
19
|
+
hybrid: NETWORK_POLICIES.WHITELIST_ONLY,
|
|
20
|
+
"danger-full-access": NETWORK_POLICIES.ALLOW_ALL,
|
|
21
|
+
full: NETWORK_POLICIES.ALLOW_ALL,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resolves network egress policy from DSH sandbox mode (#41).
|
|
26
|
+
*
|
|
27
|
+
* @param {string} [sandboxMode]
|
|
28
|
+
* @param {Record<string, any>} [options]
|
|
29
|
+
* @returns {"deny-all" | "whitelist-only" | "allow-all"}
|
|
30
|
+
*/
|
|
31
|
+
export function resolveNetworkPolicy(sandboxMode, options = {}) {
|
|
32
|
+
if (options.policy && Object.values(NETWORK_POLICIES).includes(options.policy)) {
|
|
33
|
+
return options.policy;
|
|
34
|
+
}
|
|
35
|
+
const mode = String(sandboxMode || "").toLowerCase().trim();
|
|
36
|
+
return SANDBOX_NETWORK_MAP[mode] || options.defaultPolicy || NETWORK_POLICIES.WHITELIST_ONLY;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Normalizes a destination host / URL to hostname and port.
|
|
41
|
+
* @param {string} destination
|
|
42
|
+
* @returns {{ hostname: string, port: number }}
|
|
43
|
+
*/
|
|
44
|
+
export function parseDestination(destination) {
|
|
45
|
+
if (!destination || typeof destination !== "string") {
|
|
46
|
+
return { hostname: "", port: 80 };
|
|
47
|
+
}
|
|
48
|
+
const clean = destination.trim();
|
|
49
|
+
try {
|
|
50
|
+
if (clean.includes("://")) {
|
|
51
|
+
const u = new URL(clean);
|
|
52
|
+
return { hostname: u.hostname.toLowerCase(), port: Number(u.port) || (u.protocol === "https:" ? 443 : 80) };
|
|
53
|
+
}
|
|
54
|
+
} catch (_e) {}
|
|
55
|
+
|
|
56
|
+
// host:port format
|
|
57
|
+
if (clean.includes(":")) {
|
|
58
|
+
const [h, p] = clean.split(":");
|
|
59
|
+
return { hostname: h.toLowerCase(), port: Number(p) || 80 };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { hostname: clean.toLowerCase(), port: 80 };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Checks whether outbound network access to destination is allowed (#41).
|
|
67
|
+
*
|
|
68
|
+
* @param {string} destination - Host, host:port, or full URL
|
|
69
|
+
* @param {string} [sandboxMode] - Active DSH sandbox mode
|
|
70
|
+
* @param {{
|
|
71
|
+
* whitelist?: string[],
|
|
72
|
+
* allowLoopback?: boolean,
|
|
73
|
+
* policy?: string,
|
|
74
|
+
* }} [options]
|
|
75
|
+
* @returns {{
|
|
76
|
+
* allowed: boolean,
|
|
77
|
+
* policy: string,
|
|
78
|
+
* destination: string,
|
|
79
|
+
* hostname: string,
|
|
80
|
+
* reason?: string
|
|
81
|
+
* }}
|
|
82
|
+
*/
|
|
83
|
+
export function checkNetworkAccess(destination, sandboxMode, options = {}) {
|
|
84
|
+
const policy = resolveNetworkPolicy(sandboxMode, options);
|
|
85
|
+
const { hostname } = parseDestination(destination);
|
|
86
|
+
|
|
87
|
+
// 1. Loopback check (allowed by default for internal services unless explicitly blocked)
|
|
88
|
+
const isLoopback = isLoopbackHost(hostname);
|
|
89
|
+
if (isLoopback && options.allowLoopback !== false) {
|
|
90
|
+
return {
|
|
91
|
+
allowed: true,
|
|
92
|
+
policy,
|
|
93
|
+
destination,
|
|
94
|
+
hostname,
|
|
95
|
+
reason: "loopback-allowed",
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 2. Deny-all policy (read-only sandbox mode)
|
|
100
|
+
if (policy === NETWORK_POLICIES.DENY_ALL) {
|
|
101
|
+
return {
|
|
102
|
+
allowed: false,
|
|
103
|
+
policy,
|
|
104
|
+
destination,
|
|
105
|
+
hostname,
|
|
106
|
+
reason: "sandbox-read-only-network-denied",
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 3. Allow-all policy (danger-full-access sandbox mode)
|
|
111
|
+
if (policy === NETWORK_POLICIES.ALLOW_ALL) {
|
|
112
|
+
return {
|
|
113
|
+
allowed: true,
|
|
114
|
+
policy,
|
|
115
|
+
destination,
|
|
116
|
+
hostname,
|
|
117
|
+
reason: "danger-full-access-unrestricted",
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 4. Whitelist-only policy (workspace-write / standard sandbox mode)
|
|
122
|
+
const whitelist = Array.isArray(options.whitelist) ? options.whitelist : [];
|
|
123
|
+
const isWhitelisted = whitelist.some((entry) => {
|
|
124
|
+
const entryHost = String(entry).toLowerCase().trim();
|
|
125
|
+
if (entryHost === hostname) return true;
|
|
126
|
+
if (entryHost.startsWith("*.") && hostname.endsWith(entryHost.slice(1))) return true;
|
|
127
|
+
return false;
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
if (isWhitelisted) {
|
|
131
|
+
return {
|
|
132
|
+
allowed: true,
|
|
133
|
+
policy,
|
|
134
|
+
destination,
|
|
135
|
+
hostname,
|
|
136
|
+
reason: "whitelist-allowed",
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
allowed: false,
|
|
142
|
+
policy,
|
|
143
|
+
destination,
|
|
144
|
+
hostname,
|
|
145
|
+
reason: "not-in-network-whitelist",
|
|
146
|
+
};
|
|
147
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// lib/race-decision.js
|
|
2
|
+
// Asynchronous Race Decision & Pre-emption Claim (#50, #51).
|
|
3
|
+
// Races human decision against host timeout and enables fast LLM pre-emption claim.
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_HUMAN_TIMEOUT_MS = 60000;
|
|
6
|
+
|
|
7
|
+
export const RACE_WINNERS = Object.freeze({
|
|
8
|
+
HUMAN: "human",
|
|
9
|
+
TIMEOUT: "timeout",
|
|
10
|
+
CLAIM: "claim",
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Handle coordinating human decision, host timeout, and pre-emption claims (#50, #51).
|
|
15
|
+
*/
|
|
16
|
+
export class RaceHumanHandle {
|
|
17
|
+
/**
|
|
18
|
+
* @param {{
|
|
19
|
+
* timeoutMs?: number,
|
|
20
|
+
* timeoutAction?: "allow" | "deny" | "ask",
|
|
21
|
+
* lockedAsk?: boolean,
|
|
22
|
+
* onClaim?: (verdict: string, reason: string) => void,
|
|
23
|
+
* onTimeout?: () => void,
|
|
24
|
+
* }} [options]
|
|
25
|
+
*/
|
|
26
|
+
constructor(options = {}) {
|
|
27
|
+
this.timeoutMs = typeof options.timeoutMs === "number" ? options.timeoutMs : DEFAULT_HUMAN_TIMEOUT_MS;
|
|
28
|
+
this.timeoutAction = options.timeoutAction || "deny";
|
|
29
|
+
this.lockedAsk = Boolean(options.lockedAsk);
|
|
30
|
+
this.onClaim = options.onClaim || null;
|
|
31
|
+
this.onTimeout = options.onTimeout || null;
|
|
32
|
+
|
|
33
|
+
this.settled = false;
|
|
34
|
+
this.winner = null;
|
|
35
|
+
this.startTime = Date.now();
|
|
36
|
+
this.timer = null;
|
|
37
|
+
|
|
38
|
+
this._resolve = null;
|
|
39
|
+
this._reject = null;
|
|
40
|
+
|
|
41
|
+
this.promise = new Promise((resolve, reject) => {
|
|
42
|
+
this._resolve = resolve;
|
|
43
|
+
this._reject = reject;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
if (this.timeoutMs > 0 && this.timeoutMs < Infinity) {
|
|
47
|
+
this.timer = setTimeout(() => {
|
|
48
|
+
this._handleTimeout();
|
|
49
|
+
}, this.timeoutMs);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
isSettled() {
|
|
54
|
+
return this.settled;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Pre-empts open human confirmation dialog when fast LLM or reviewer arrives at confident verdict (#51).
|
|
59
|
+
*
|
|
60
|
+
* @param {"allow" | "deny" | "ask"} outcome
|
|
61
|
+
* @param {string} [reason="pre-emption-claim"]
|
|
62
|
+
* @returns {{ claimed: boolean, reason?: string, winner?: string }}
|
|
63
|
+
*/
|
|
64
|
+
claim(outcome, reason = "pre-emption-claim") {
|
|
65
|
+
if (this.settled) {
|
|
66
|
+
return { claimed: false, reason: "already-settled", winner: this.winner };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Critical lockedAsk operations cannot be auto-claimed as allow
|
|
70
|
+
if (this.lockedAsk && outcome === "allow") {
|
|
71
|
+
return { claimed: false, reason: "locked-ask-cannot-be-claimed-as-allow" };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
this._settle(RACE_WINNERS.CLAIM, {
|
|
75
|
+
winner: RACE_WINNERS.CLAIM,
|
|
76
|
+
verdict: outcome,
|
|
77
|
+
reason,
|
|
78
|
+
elapsedMs: Date.now() - this.startTime,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
if (typeof this.onClaim === "function") {
|
|
82
|
+
this.onClaim(outcome, reason);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { claimed: true, verdict: outcome, reason };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Resolves decision from human approval input.
|
|
90
|
+
* @param {Record<string, any> | string} decision
|
|
91
|
+
*/
|
|
92
|
+
resolveHuman(decision) {
|
|
93
|
+
if (this.settled) return false;
|
|
94
|
+
const verdict = typeof decision === "string" ? decision : (decision?.verdict || "allow");
|
|
95
|
+
this._settle(RACE_WINNERS.HUMAN, {
|
|
96
|
+
winner: RACE_WINNERS.HUMAN,
|
|
97
|
+
verdict,
|
|
98
|
+
decision: typeof decision === "object" ? decision : { verdict },
|
|
99
|
+
elapsedMs: Date.now() - this.startTime,
|
|
100
|
+
});
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Aborts the race decision handle.
|
|
106
|
+
* @param {string} [reason="aborted"]
|
|
107
|
+
*/
|
|
108
|
+
abort(reason = "aborted") {
|
|
109
|
+
if (this.settled) return false;
|
|
110
|
+
this._settle(RACE_WINNERS.TIMEOUT, {
|
|
111
|
+
winner: RACE_WINNERS.TIMEOUT,
|
|
112
|
+
verdict: "deny",
|
|
113
|
+
reason,
|
|
114
|
+
elapsedMs: Date.now() - this.startTime,
|
|
115
|
+
});
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* @private
|
|
121
|
+
*/
|
|
122
|
+
_handleTimeout() {
|
|
123
|
+
if (this.settled) return;
|
|
124
|
+
|
|
125
|
+
// Locked ask operations must fail closed on timeout
|
|
126
|
+
const resolvedVerdict = this.lockedAsk ? "deny" : this.timeoutAction;
|
|
127
|
+
|
|
128
|
+
if (typeof this.onTimeout === "function") {
|
|
129
|
+
this.onTimeout();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
this._settle(RACE_WINNERS.TIMEOUT, {
|
|
133
|
+
winner: RACE_WINNERS.TIMEOUT,
|
|
134
|
+
verdict: resolvedVerdict,
|
|
135
|
+
reason: "host-timeout-expired",
|
|
136
|
+
elapsedMs: Date.now() - this.startTime,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* @private
|
|
142
|
+
*/
|
|
143
|
+
_settle(winner, result) {
|
|
144
|
+
if (this.settled) return;
|
|
145
|
+
this.settled = true;
|
|
146
|
+
this.winner = winner;
|
|
147
|
+
if (this.timer) {
|
|
148
|
+
clearTimeout(this.timer);
|
|
149
|
+
this.timer = null;
|
|
150
|
+
}
|
|
151
|
+
if (this._resolve) {
|
|
152
|
+
this._resolve(result);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Races human decision against host timeout and optional fast claims (#50, #51).
|
|
159
|
+
*
|
|
160
|
+
* @param {Promise<any>} humanPromise
|
|
161
|
+
* @param {ConstructorParameters<typeof RaceHumanHandle>[0]} [options]
|
|
162
|
+
* @returns {Promise<{
|
|
163
|
+
* winner: "human" | "timeout" | "claim",
|
|
164
|
+
* verdict: string,
|
|
165
|
+
* elapsedMs: number,
|
|
166
|
+
* reason?: string,
|
|
167
|
+
* decision?: any
|
|
168
|
+
* }>}
|
|
169
|
+
*/
|
|
170
|
+
export async function raceHumanDecision(humanPromise, options = {}) {
|
|
171
|
+
const handle = new RaceHumanHandle(options);
|
|
172
|
+
|
|
173
|
+
if (humanPromise && typeof humanPromise.then === "function") {
|
|
174
|
+
humanPromise
|
|
175
|
+
.then((res) => handle.resolveHuman(res))
|
|
176
|
+
.catch((err) => handle.abort(err?.message || "human-error"));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return handle.promise;
|
|
180
|
+
}
|
package/lib/rules-hmr.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// lib/rules-hmr.js
|
|
2
|
+
// Hot Module Reloading for Security Rules (#43).
|
|
3
|
+
// Watches .dsh/rules.yaml and declarative rule definitions using Node.js stdlib fs watchers.
|
|
4
|
+
// Recompiles rules in-memory atomically without dropping active sessions or restarting dsh.
|
|
5
|
+
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_HMR_DEBOUNCE_MS = 150;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Manages atomic hot reload watchers for rule definitions (#43).
|
|
12
|
+
*/
|
|
13
|
+
export class RulesHmrWatcher {
|
|
14
|
+
/**
|
|
15
|
+
* @param {{ debounceMs?: number, logger?: any }} [options]
|
|
16
|
+
*/
|
|
17
|
+
constructor(options = {}) {
|
|
18
|
+
this.debounceMs = options.debounceMs || DEFAULT_HMR_DEBOUNCE_MS;
|
|
19
|
+
this.logger = options.logger || null;
|
|
20
|
+
/** @type {Map<string, fs.FSWatcher>} */
|
|
21
|
+
this.watchers = new Map();
|
|
22
|
+
/** @type {NodeJS.Timeout | null} */
|
|
23
|
+
this.debounceTimer = null;
|
|
24
|
+
this.reloading = false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Attaches file watchers for declarative rules files.
|
|
29
|
+
*
|
|
30
|
+
* @param {any} rulesEngine - Instance of DeclarativeRulesEngine
|
|
31
|
+
* @param {string[]} filePaths - Files or directories to watch
|
|
32
|
+
* @param {(info: { path: string, timestamp: number }) => void} [onReload]
|
|
33
|
+
* @returns {number} Count of successfully watched paths
|
|
34
|
+
*/
|
|
35
|
+
watch(rulesEngine, filePaths = [], onReload = null) {
|
|
36
|
+
if (!rulesEngine || !Array.isArray(filePaths)) return 0;
|
|
37
|
+
let watchedCount = 0;
|
|
38
|
+
|
|
39
|
+
for (const targetPath of filePaths) {
|
|
40
|
+
if (!targetPath || typeof targetPath !== "string") continue;
|
|
41
|
+
if (this.watchers.has(targetPath)) continue;
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
if (!fs.existsSync(targetPath)) continue;
|
|
45
|
+
|
|
46
|
+
const watcher = fs.watch(targetPath, { persistent: false }, (eventType, filename) => {
|
|
47
|
+
this._handleFileChange(rulesEngine, targetPath, onReload);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
watcher.on("error", (err) => {
|
|
51
|
+
if (this.logger) this.logger.warn?.(`RulesHmrWatcher error on ${targetPath}:`, err);
|
|
52
|
+
this.unwatch(targetPath);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
this.watchers.set(targetPath, watcher);
|
|
56
|
+
watchedCount += 1;
|
|
57
|
+
} catch (err) {
|
|
58
|
+
if (this.logger) this.logger.warn?.(`Failed to watch ${targetPath}:`, err);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return watchedCount;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Unwatches a specific file path.
|
|
67
|
+
* @param {string} targetPath
|
|
68
|
+
*/
|
|
69
|
+
unwatch(targetPath) {
|
|
70
|
+
const watcher = this.watchers.get(targetPath);
|
|
71
|
+
if (watcher) {
|
|
72
|
+
try {
|
|
73
|
+
watcher.close();
|
|
74
|
+
} catch (_e) {}
|
|
75
|
+
this.watchers.delete(targetPath);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Closes all active file watchers.
|
|
81
|
+
*/
|
|
82
|
+
close() {
|
|
83
|
+
if (this.debounceTimer) {
|
|
84
|
+
clearTimeout(this.debounceTimer);
|
|
85
|
+
this.debounceTimer = null;
|
|
86
|
+
}
|
|
87
|
+
for (const [p, watcher] of this.watchers.entries()) {
|
|
88
|
+
try {
|
|
89
|
+
watcher.close();
|
|
90
|
+
} catch (_e) {}
|
|
91
|
+
}
|
|
92
|
+
this.watchers.clear();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* @private
|
|
97
|
+
*/
|
|
98
|
+
_handleFileChange(rulesEngine, targetPath, onReload) {
|
|
99
|
+
if (this.debounceTimer) {
|
|
100
|
+
clearTimeout(this.debounceTimer);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
this.debounceTimer = setTimeout(() => {
|
|
104
|
+
this.debounceTimer = null;
|
|
105
|
+
this._triggerReload(rulesEngine, targetPath, onReload);
|
|
106
|
+
}, this.debounceMs);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @private
|
|
111
|
+
*/
|
|
112
|
+
_triggerReload(rulesEngine, targetPath, onReload) {
|
|
113
|
+
if (this.reloading) return;
|
|
114
|
+
this.reloading = true;
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
if (typeof rulesEngine.reload === "function") {
|
|
118
|
+
rulesEngine.reload();
|
|
119
|
+
} else if (typeof rulesEngine.loadRules === "function") {
|
|
120
|
+
rulesEngine.loadRules();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (typeof onReload === "function") {
|
|
124
|
+
onReload({ path: targetPath, timestamp: Date.now() });
|
|
125
|
+
}
|
|
126
|
+
} catch (err) {
|
|
127
|
+
if (this.logger) this.logger.error?.(`Failed to reload rules for ${targetPath}:`, err);
|
|
128
|
+
} finally {
|
|
129
|
+
this.reloading = false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -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
|
+
}
|