@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 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
+ }
@@ -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.24",
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",