@goodandready/dsh-approval-gate 0.1.24 → 0.1.25
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 +8 -0
- package/lib/engine.js +10 -3
- package/lib/forward-proxy.js +218 -0
- package/lib/index.js +4 -0
- package/lib/network-sandbox.js +147 -0
- package/lib/race-decision.js +180 -0
- package/lib/rules-hmr.js +132 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.25
|
|
4
|
+
|
|
5
|
+
- Security: Асинхронная гонка решения (`raceHumanDecision` в `lib/race-decision.js`) между подтверждением человека и настраиваемым таймером хоста с гарантированной защитой от зависания сессий и fail-closed обработкой `lockedAsk` (Refs: #50).
|
|
6
|
+
- Feature: Перехват открытого диалога подтверждения через LLM Pre-emption Claim (`RaceHumanHandle.claim` в `lib/race-decision.js`) с автоматическим закрытием модального окна при быстром утвердительном вердикте модели (Refs: #51).
|
|
7
|
+
- Feature: HMR-перезагрузка правил на лету (`RulesHmrWatcher` в `lib/rules-hmr.js`) на стандартном `fs.watch` Node.js с дебаунсом и атомарной перекомпиляцией правил в памяти без перезапуска демона dsh (Refs: #43).
|
|
8
|
+
- Security: Маппинг сетевых политик на официальные режимы песочницы DSH (`resolveNetworkPolicy`, `checkNetworkAccess` в `lib/network-sandbox.js`: `read-only` -> `deny-all`, `workspace-write` -> `whitelist-only`, `danger-full-access` -> `allow-all`) (Refs: #41).
|
|
9
|
+
- Security: Встроенный локальный HTTP/CONNECT forward proxy (`LocalEgressProxy` в `lib/forward-proxy.js`) для надежного перехвата сетевого egress подпроцессов bash (`HTTP_PROXY`, `ALL_PROXY`) и блокировки несанкционированных хостов (Refs: #39).
|
|
10
|
+
|
|
3
11
|
## 0.1.24
|
|
4
12
|
|
|
5
13
|
- Security: Жесткое исключение критических категорий из механизма авто-обучения (`canLearnOperation` в `lib/learning.js`) с гарантированным запретом запоминания деструктивных операций (`rm`), секретов (`.env`, `.ssh`), эскалации прав (`sudo`) и системных путей (Refs: #81).
|
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
|
@@ -578,3 +578,7 @@ export { DEFAULT_MIN_CONFIRMATIONS, DEFAULT_MAX_LEARNED_RULES, EXCLUSION_REASONS
|
|
|
578
578
|
export { canonicalizeCommand, isSemanticallyEquivalent, findSemanticallyEquivalentRule, validateSemanticSimilarity } from "./similarity.js";
|
|
579
579
|
export { SAFETY_REVIEWER_SYSTEM_PROMPT, DEFAULT_REVIEWER_AGENT_ID, assertSeparationOfDuties, IndependentReviewer } from "./reviewer.js";
|
|
580
580
|
export { DEFAULT_EVAL_CORPUS, runEvaluation, formatEvaluationReport } from "./eval-runner.js";
|
|
581
|
+
export { DEFAULT_HUMAN_TIMEOUT_MS, RACE_WINNERS, RaceHumanHandle, raceHumanDecision } from "./race-decision.js";
|
|
582
|
+
export { DEFAULT_HMR_DEBOUNCE_MS, RulesHmrWatcher } from "./rules-hmr.js";
|
|
583
|
+
export { NETWORK_POLICIES, SANDBOX_NETWORK_MAP, resolveNetworkPolicy, parseDestination, checkNetworkAccess } from "./network-sandbox.js";
|
|
584
|
+
export { LocalEgressProxy } from "./forward-proxy.js";
|
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-approval-gate",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.25",
|
|
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",
|