@goodandready/dsh-approval-gate 0.1.14 → 0.1.16
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/audit-log.js +277 -8
- package/lib/counters.js +144 -0
- package/lib/engine.js +158 -15
- package/lib/hotwords.js +122 -0
- package/lib/index.js +521 -452
- package/lib/messages.js +294 -282
- package/lib/rules-loader.js +175 -0
- package/lib/when-matcher.js +89 -0
- package/lib/yaml-parser.js +210 -0
- package/package.json +2 -1
- package/rules/builtin-high-risk.yaml +81 -0
package/lib/engine.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// lib/engine.js
|
|
2
2
|
// 5-tier Security Decision Engine for dsh-approval-gate.
|
|
3
3
|
// Waterfall:
|
|
4
|
-
// P0: Hard Deny (Bands Band 0 + inspectExecution hard denials + protected files + retry exhaustion)
|
|
5
|
-
// P1: Session Grants (scoped temporary allowances)
|
|
4
|
+
// P0: Hard Deny (Bands Band 0 + Declarative Rules Deny + inspectExecution hard denials + protected files + retry exhaustion + fail-closed audit)
|
|
5
|
+
// P1: Session Grants & Declarative Allow (scoped temporary allowances and whitelists)
|
|
6
6
|
// P2: Static Rules / Safe Allow (Bands Band 1 + safe read-only commands)
|
|
7
7
|
// P3: Optional Semantic Classifier
|
|
8
|
-
// P4: Human Ask Fallback (uncertain syntax / ambiguous commands / tripped breaker)
|
|
8
|
+
// P4: Human Ask Fallback (uncertain syntax / ambiguous commands / tripped breaker / declarative ask / lockedAsk)
|
|
9
9
|
|
|
10
10
|
import { BandsEngine } from "./bands.js";
|
|
11
11
|
import { CircuitBreaker } from "./breaker.js";
|
|
@@ -15,6 +15,57 @@ import { VerdictCache } from "./cache.js";
|
|
|
15
15
|
import { RetryExhaustionGuard } from "./retry-exhaustion.js";
|
|
16
16
|
import { QualityLedger } from "./ledger.js";
|
|
17
17
|
import { AuditLogger } from "./audit-log.js";
|
|
18
|
+
import { DeclarativeRulesEngine } from "./rules-loader.js";
|
|
19
|
+
import { BlockCounters } from "./counters.js";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Determine whether an operation belongs to a critical risk category requiring lockedAsk (#52).
|
|
23
|
+
* Locked ask operations can never be auto-approved by unattended timeouts or fallback heuristics.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} [toolName]
|
|
26
|
+
* @param {string} [command]
|
|
27
|
+
* @param {any} [execution]
|
|
28
|
+
* @returns {boolean}
|
|
29
|
+
*/
|
|
30
|
+
export function isCriticalOperation(toolName, command, execution) {
|
|
31
|
+
const cmd = String(command || "").trim();
|
|
32
|
+
const tool = String(toolName || "").trim();
|
|
33
|
+
if (!cmd && !tool) return false;
|
|
34
|
+
|
|
35
|
+
// 1. Root and privilege escalation
|
|
36
|
+
if (/^(sudo|su|doas)\b/i.test(cmd)) return true;
|
|
37
|
+
if (/\b(chmod\s+(-[a-zA-Z]*\s+)?777|chown\s+(-[a-zA-Z]*\s+)?root)\b/i.test(cmd)) return true;
|
|
38
|
+
|
|
39
|
+
// 2. Recursive or bulk destructive deletion
|
|
40
|
+
if (/\brm\s+(-[a-zA-Z]*[rf][a-zA-Z]*\s+|--recursive\s+)/i.test(cmd)) return true;
|
|
41
|
+
if (/\b(shred|wipefs)\b/i.test(cmd)) return true;
|
|
42
|
+
|
|
43
|
+
// 3. Sensitive credentials and SSH access
|
|
44
|
+
if (/(\.ssh\/(id_|authorized_keys|known_hosts)|\.gnupg\/|\/etc\/shadow)/i.test(cmd)) return true;
|
|
45
|
+
|
|
46
|
+
// 4. Raw disk and filesystem manipulation
|
|
47
|
+
if (/\b(mkfs|fdisk|parted)\b/i.test(cmd)) return true;
|
|
48
|
+
if (/\bdd\s+.*of=\/dev\/(sd[a-z]|nvme|vd[a-z]|hd[a-z]|loop)/i.test(cmd)) return true;
|
|
49
|
+
|
|
50
|
+
// 5. Remote shell piping and reverse shells
|
|
51
|
+
if (/curl\s+.*\|\s*(bash|sh|python|perl)/i.test(cmd)) return true;
|
|
52
|
+
if (/\b(nc|ncat|netcat)\s+.*-e\s+/i.test(cmd)) return true;
|
|
53
|
+
if (/>&\s*\/dev\/tcp\//i.test(cmd)) return true;
|
|
54
|
+
|
|
55
|
+
// 6. System halts and service shutdowns
|
|
56
|
+
if (/\b(shutdown|reboot|poweroff|halt|init\s+0)\b/i.test(cmd)) return true;
|
|
57
|
+
if (/systemctl\s+(stop|disable|mask)\s+(sshd|systemd|ufw|iptables)/i.test(cmd)) return true;
|
|
58
|
+
|
|
59
|
+
// 7. Protected file writes via tools
|
|
60
|
+
if (tool === "write_to_file" || tool === "edit" || tool === "replace_file_content" || tool === "str_replace_editor") {
|
|
61
|
+
const target = execution && execution.arguments && (execution.arguments.TargetFile || execution.arguments.target_file || execution.arguments.path || "");
|
|
62
|
+
if (typeof target === "string" && /(\/etc\/|\.ssh\/|\/boot\/|\/sys\/|\/proc\/)/i.test(target)) {
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
18
69
|
|
|
19
70
|
export class SecurityEngine {
|
|
20
71
|
/**
|
|
@@ -25,6 +76,9 @@ export class SecurityEngine {
|
|
|
25
76
|
* retryGuard?: RetryExhaustionGuard,
|
|
26
77
|
* ledger?: QualityLedger,
|
|
27
78
|
* auditLogger?: AuditLogger,
|
|
79
|
+
* declarativeRules?: DeclarativeRulesEngine,
|
|
80
|
+
* counters?: BlockCounters,
|
|
81
|
+
* auditFailClosed?: boolean,
|
|
28
82
|
* inspectConfig?: { toolName?: string, fileWriteTools?: string[], workspaceDir?: string },
|
|
29
83
|
* classifier?: ((execution: any, context?: any) => { verdict: "allow"|"deny", reason?: string }),
|
|
30
84
|
* grantRegistry?: { decide: (tool: string, args: any, context?: any) => "allow"|"no-match" },
|
|
@@ -42,10 +96,20 @@ export class SecurityEngine {
|
|
|
42
96
|
this.defaultVerdict = options.defaultVerdict || "allow";
|
|
43
97
|
|
|
44
98
|
this.dryRun = Boolean(options.dryRun || options.shadowMode);
|
|
99
|
+
this.auditFailClosed = options.auditFailClosed === true;
|
|
45
100
|
this.cache = options.cache !== undefined ? options.cache : new VerdictCache();
|
|
46
101
|
this.retryGuard = options.retryGuard !== undefined ? options.retryGuard : new RetryExhaustionGuard();
|
|
47
102
|
this.ledger = options.ledger !== undefined ? options.ledger : new QualityLedger();
|
|
48
|
-
this.
|
|
103
|
+
this.counters = options.counters || new BlockCounters();
|
|
104
|
+
this.auditLogger = options.auditLogger !== undefined ? options.auditLogger : new AuditLogger({
|
|
105
|
+
failClosed: this.auditFailClosed,
|
|
106
|
+
});
|
|
107
|
+
if (this.auditFailClosed && this.auditLogger) {
|
|
108
|
+
this.auditLogger.failClosed = true;
|
|
109
|
+
}
|
|
110
|
+
this.declarativeRules = options.declarativeRules || new DeclarativeRulesEngine({
|
|
111
|
+
workspaceDir: this.inspectConfig?.workspaceDir,
|
|
112
|
+
});
|
|
49
113
|
}
|
|
50
114
|
|
|
51
115
|
/**
|
|
@@ -53,7 +117,7 @@ export class SecurityEngine {
|
|
|
53
117
|
*
|
|
54
118
|
* @param {{ name: string, arguments?: any }} execution - Tool call
|
|
55
119
|
* @param {{ sessionId?: string, knobs?: any, cwd?: string }} [context] - Context options
|
|
56
|
-
* @returns {{ verdict: "allow" | "deny" | "ask", stage: "P0" | "P1" | "P2" | "P3" | "P4", reason: string, detail?: string, hit?: any, dryRun?: boolean, cached?: boolean }}
|
|
120
|
+
* @returns {{ verdict: "allow" | "deny" | "ask", stage: "P0" | "P1" | "P2" | "P3" | "P4", reason: string, detail?: string, hit?: any, dryRun?: boolean, cached?: boolean, lockedAsk?: boolean, lockedReason?: string, auditFailed?: boolean }}
|
|
57
121
|
*/
|
|
58
122
|
decide(execution, context = {}) {
|
|
59
123
|
try {
|
|
@@ -71,7 +135,8 @@ export class SecurityEngine {
|
|
|
71
135
|
};
|
|
72
136
|
const exhDecision = { verdict: "deny", stage: "P0", reason: "retry-exhaustion", detail: exhHit.detail, hit: exhHit };
|
|
73
137
|
if (this.ledger) this.ledger.record(sessionId, "exhaustion", { reason: "retry-exhaustion", tool: toolName });
|
|
74
|
-
if (this.
|
|
138
|
+
if (this.counters) this.counters.record(exhDecision, execution);
|
|
139
|
+
if (this.auditLogger) this.auditLogger.appendAuditLine({ sessionId, execution, decision: exhDecision, dryRun: this.dryRun });
|
|
75
140
|
|
|
76
141
|
if (this.dryRun) {
|
|
77
142
|
return { verdict: "allow", stage: "P0", dryRun: true, originalVerdict: "deny", reason: "retry-exhaustion", detail: exhHit.detail, hit: exhHit };
|
|
@@ -96,13 +161,14 @@ export class SecurityEngine {
|
|
|
96
161
|
cached: true,
|
|
97
162
|
};
|
|
98
163
|
}
|
|
164
|
+
if (this.counters) this.counters.record(cached, execution);
|
|
99
165
|
return cached;
|
|
100
166
|
}
|
|
101
167
|
}
|
|
102
168
|
|
|
103
|
-
|
|
169
|
+
let decision = this._evaluateWaterfall(execution, context, sessionId, toolName, command);
|
|
104
170
|
|
|
105
|
-
// Post-decision hooks: ledger, retryGuard, cache
|
|
171
|
+
// Post-decision hooks: ledger, retryGuard, cache
|
|
106
172
|
if (decision.verdict === "deny") {
|
|
107
173
|
if (this.retryGuard) this.retryGuard.recordVerdict(sessionId, execution, "deny");
|
|
108
174
|
if (this.ledger) this.ledger.record(sessionId, "deny", { reason: decision.reason, detail: decision.detail, tool: toolName });
|
|
@@ -115,8 +181,30 @@ export class SecurityEngine {
|
|
|
115
181
|
if (this.ledger) this.ledger.record(sessionId, "ask", { reason: decision.reason, detail: decision.detail, tool: toolName });
|
|
116
182
|
}
|
|
117
183
|
|
|
184
|
+
// Audit Logger (#56) with fail-closed audit verification (#53)
|
|
118
185
|
if (this.auditLogger) {
|
|
119
|
-
this.auditLogger.
|
|
186
|
+
const auditOk = this.auditLogger.appendAuditLine({ sessionId, execution, decision, dryRun: this.dryRun });
|
|
187
|
+
if (!auditOk && this.auditFailClosed && !this.dryRun) {
|
|
188
|
+
const auditDenyHit = {
|
|
189
|
+
deny: true,
|
|
190
|
+
reason: "fail-closed-audit",
|
|
191
|
+
detail: "Audit log write failed. Execution blocked by fail-closed audit policy (#53).",
|
|
192
|
+
snippet: command || toolName,
|
|
193
|
+
};
|
|
194
|
+
decision = {
|
|
195
|
+
verdict: "deny",
|
|
196
|
+
stage: "P0",
|
|
197
|
+
reason: "fail-closed-audit",
|
|
198
|
+
detail: auditDenyHit.detail,
|
|
199
|
+
hit: auditDenyHit,
|
|
200
|
+
auditFailed: true,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Record block counters (#48)
|
|
206
|
+
if (this.counters) {
|
|
207
|
+
this.counters.record(decision, execution);
|
|
120
208
|
}
|
|
121
209
|
|
|
122
210
|
// Shadow Mode (#128)
|
|
@@ -129,18 +217,21 @@ export class SecurityEngine {
|
|
|
129
217
|
reason: decision.reason,
|
|
130
218
|
detail: decision.detail,
|
|
131
219
|
hit: decision.hit,
|
|
220
|
+
lockedAsk: decision.lockedAsk,
|
|
132
221
|
};
|
|
133
222
|
}
|
|
134
223
|
|
|
135
224
|
return decision;
|
|
136
225
|
} catch (err) {
|
|
137
|
-
|
|
226
|
+
const errDecision = {
|
|
138
227
|
verdict: "ask",
|
|
139
228
|
stage: "P4",
|
|
140
229
|
reason: "inspect-error",
|
|
141
230
|
detail: String(err && err.message || err),
|
|
142
231
|
hit: { ask: true, reason: "inspect-error", detail: String(err && err.message || err) },
|
|
143
232
|
};
|
|
233
|
+
if (this.counters) this.counters.record(errDecision, execution);
|
|
234
|
+
return errDecision;
|
|
144
235
|
}
|
|
145
236
|
}
|
|
146
237
|
|
|
@@ -157,6 +248,28 @@ export class SecurityEngine {
|
|
|
157
248
|
return { verdict: "deny", stage: "P0", reason: "band-hard-deny", detail: command, hit };
|
|
158
249
|
}
|
|
159
250
|
|
|
251
|
+
// Declarative YAML Rules evaluation (#38, #45, #44)
|
|
252
|
+
if (this.declarativeRules && command) {
|
|
253
|
+
const decl = this.declarativeRules.evaluate(toolName, command, context);
|
|
254
|
+
if (decl.match) {
|
|
255
|
+
if (decl.verdict === "deny") {
|
|
256
|
+
this.breaker.countDeny(sessionId);
|
|
257
|
+
const hit = { deny: true, reason: decl.reason || "declarative-deny", detail: command, snippet: command, ruleId: decl.ruleId };
|
|
258
|
+
return { verdict: "deny", stage: "P0", reason: decl.reason || "declarative-deny", detail: command, hit };
|
|
259
|
+
}
|
|
260
|
+
if (decl.verdict === "allow") {
|
|
261
|
+
this.breaker.resetConsecutive(sessionId);
|
|
262
|
+
return { verdict: "allow", stage: "P1", reason: decl.reason || "declarative-allow", hit: null, ruleId: decl.ruleId };
|
|
263
|
+
}
|
|
264
|
+
if (decl.verdict === "ask") {
|
|
265
|
+
const hit = { ask: true, reason: decl.reason || "declarative-ask", detail: command, snippet: command, ruleId: decl.ruleId };
|
|
266
|
+
const res = { verdict: "ask", stage: "P4", reason: decl.reason || "declarative-ask", detail: command, hit, ruleId: decl.ruleId };
|
|
267
|
+
this._applyLockedAskIfCritical(res, toolName, command, execution);
|
|
268
|
+
return res;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
160
273
|
// Inspect execution for hard denials and syntax checks
|
|
161
274
|
const hit = inspectExecution(execution, this.inspectConfig);
|
|
162
275
|
if (hit.deny) {
|
|
@@ -173,7 +286,7 @@ export class SecurityEngine {
|
|
|
173
286
|
}
|
|
174
287
|
}
|
|
175
288
|
|
|
176
|
-
// If Circuit Breaker is tripped,
|
|
289
|
+
// If Circuit Breaker is tripped, force P4 Human Ask
|
|
177
290
|
if (this.breaker.isTripped(sessionId)) {
|
|
178
291
|
if (this.ledger) this.ledger.record(sessionId, "breaker", { reason: "circuit-breaker-tripped", tool: toolName });
|
|
179
292
|
const breakerHit = {
|
|
@@ -182,18 +295,22 @@ export class SecurityEngine {
|
|
|
182
295
|
detail: "Circuit breaker tripped due to repeated security denials. Manual confirmation required.",
|
|
183
296
|
snippet: command || toolName,
|
|
184
297
|
};
|
|
185
|
-
|
|
298
|
+
const res = {
|
|
186
299
|
verdict: "ask",
|
|
187
300
|
stage: "P4",
|
|
188
301
|
reason: "circuit-breaker-tripped",
|
|
189
302
|
detail: breakerHit.detail,
|
|
190
303
|
hit: breakerHit,
|
|
191
304
|
};
|
|
305
|
+
this._applyLockedAskIfCritical(res, toolName, command, execution);
|
|
306
|
+
return res;
|
|
192
307
|
}
|
|
193
308
|
|
|
194
309
|
// P4: Human Ask if syntax is unparseable or uncertain
|
|
195
310
|
if (hit.ask) {
|
|
196
|
-
|
|
311
|
+
const res = { verdict: "ask", stage: "P4", reason: hit.reason || "uncertain-syntax", detail: hit.detail || hit.snippet, hit };
|
|
312
|
+
this._applyLockedAskIfCritical(res, toolName, command, execution);
|
|
313
|
+
return res;
|
|
197
314
|
}
|
|
198
315
|
|
|
199
316
|
// P2: Static Rules / Safe Allow
|
|
@@ -218,18 +335,36 @@ export class SecurityEngine {
|
|
|
218
335
|
} catch (err) {
|
|
219
336
|
// Classifier error fails closed to P4 Ask (#111: does not trip breaker)
|
|
220
337
|
if (this.ledger) this.ledger.record(sessionId, "error", { reason: "classifier-error", detail: String(err && err.message || err), tool: toolName });
|
|
221
|
-
|
|
338
|
+
const res = { verdict: "ask", stage: "P4", reason: "classifier-error", detail: String(err && err.message || err), hit: null };
|
|
339
|
+
this._applyLockedAskIfCritical(res, toolName, command, execution);
|
|
340
|
+
return res;
|
|
222
341
|
}
|
|
223
342
|
}
|
|
224
343
|
|
|
225
344
|
// Default verdict for unclassified commands (#163)
|
|
226
345
|
if (this.defaultVerdict === "ask") {
|
|
227
346
|
const askHit = { ask: true, reason: "unmatched-ask", snippet: command || toolName };
|
|
228
|
-
|
|
347
|
+
const res = { verdict: "ask", stage: "P4", reason: "unmatched-ask", hit: askHit };
|
|
348
|
+
this._applyLockedAskIfCritical(res, toolName, command, execution);
|
|
349
|
+
return res;
|
|
229
350
|
}
|
|
230
351
|
return { verdict: "allow", stage: "P2", reason: "unmatched-allow", hit };
|
|
231
352
|
}
|
|
232
353
|
|
|
354
|
+
/**
|
|
355
|
+
* Helper to check and mark lockedAsk for critical operations (#52).
|
|
356
|
+
* @private
|
|
357
|
+
*/
|
|
358
|
+
_applyLockedAskIfCritical(decision, toolName, command, execution) {
|
|
359
|
+
if (decision && decision.verdict === "ask" && isCriticalOperation(toolName, command, execution)) {
|
|
360
|
+
decision.lockedAsk = true;
|
|
361
|
+
decision.lockedReason = "critical-operation";
|
|
362
|
+
if (decision.hit) {
|
|
363
|
+
decision.hit.lockedAsk = true;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
233
368
|
addGrant(options) {
|
|
234
369
|
if (this.grantRegistry && typeof this.grantRegistry.addGrant === "function") {
|
|
235
370
|
return this.grantRegistry.addGrant(options);
|
|
@@ -256,6 +391,14 @@ export class SecurityEngine {
|
|
|
256
391
|
if (this.cache) this.cache.clear();
|
|
257
392
|
}
|
|
258
393
|
|
|
394
|
+
getCounters() {
|
|
395
|
+
return this.counters ? this.counters.getSnapshot() : null;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
resetCounters() {
|
|
399
|
+
if (this.counters) this.counters.reset();
|
|
400
|
+
}
|
|
401
|
+
|
|
259
402
|
resume(sessionId, reason) {
|
|
260
403
|
if (this.retryGuard) this.retryGuard.reset(sessionId);
|
|
261
404
|
if (this.cache) this.cache.clearSession(sessionId);
|
package/lib/hotwords.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// lib/hotwords.js
|
|
2
|
+
// High-speed keyword pre-filter (Hotwords / Lexicon Matcher) for dsh-approval-gate (#93).
|
|
3
|
+
// Provides sub-microsecond pre-check to bypass heavy AST/regex parsing for clean commands.
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_HOTWORDS = Object.freeze([
|
|
6
|
+
"rm",
|
|
7
|
+
"mkfs",
|
|
8
|
+
"fdisk",
|
|
9
|
+
"parted",
|
|
10
|
+
"dd",
|
|
11
|
+
"shadow",
|
|
12
|
+
"sudoers",
|
|
13
|
+
"passwd",
|
|
14
|
+
"kill",
|
|
15
|
+
"pkill",
|
|
16
|
+
"killall",
|
|
17
|
+
"systemctl",
|
|
18
|
+
"service",
|
|
19
|
+
"curl",
|
|
20
|
+
"wget",
|
|
21
|
+
"bash",
|
|
22
|
+
"sh",
|
|
23
|
+
"zsh",
|
|
24
|
+
"nc",
|
|
25
|
+
"netcat",
|
|
26
|
+
"ncat",
|
|
27
|
+
"socat",
|
|
28
|
+
"chmod",
|
|
29
|
+
"chown",
|
|
30
|
+
"eval",
|
|
31
|
+
"printenv",
|
|
32
|
+
"drop",
|
|
33
|
+
"truncate",
|
|
34
|
+
"format",
|
|
35
|
+
"sudo",
|
|
36
|
+
"su",
|
|
37
|
+
"ssh",
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Splits text into normalized lowercase word tokens.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} text
|
|
44
|
+
* @returns {string[]}
|
|
45
|
+
*/
|
|
46
|
+
export function extractWords(text) {
|
|
47
|
+
if (typeof text !== "string") return [];
|
|
48
|
+
// Split on whitespace and common shell operators/delimiters
|
|
49
|
+
const tokens = text.toLowerCase().split(/[^a-z0-9_-]+/);
|
|
50
|
+
return tokens.filter((t) => t.length > 0);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class HotwordsMatcher {
|
|
54
|
+
/**
|
|
55
|
+
* @param {string[]} [customHotwords]
|
|
56
|
+
*/
|
|
57
|
+
constructor(customHotwords) {
|
|
58
|
+
/** @type {Set<string>} */
|
|
59
|
+
this.lexicon = new Set();
|
|
60
|
+
const words = Array.isArray(customHotwords) && customHotwords.length > 0
|
|
61
|
+
? customHotwords
|
|
62
|
+
: DEFAULT_HOTWORDS;
|
|
63
|
+
|
|
64
|
+
for (const w of words) {
|
|
65
|
+
if (typeof w === "string" && w.trim()) {
|
|
66
|
+
this.lexicon.add(w.trim().toLowerCase());
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Add new words to lexicon.
|
|
73
|
+
* @param {string | string[]} words
|
|
74
|
+
*/
|
|
75
|
+
add(words) {
|
|
76
|
+
const list = Array.isArray(words) ? words : [words];
|
|
77
|
+
for (const w of list) {
|
|
78
|
+
if (typeof w === "string" && w.trim()) {
|
|
79
|
+
this.lexicon.add(w.trim().toLowerCase());
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Checks if text contains any word present in the hotwords lexicon.
|
|
86
|
+
*
|
|
87
|
+
* @param {string} text
|
|
88
|
+
* @returns {boolean} True if any high-risk keyword matched
|
|
89
|
+
*/
|
|
90
|
+
hasMatch(text) {
|
|
91
|
+
if (!text || typeof text !== "string") return false;
|
|
92
|
+
const words = extractWords(text);
|
|
93
|
+
for (let i = 0; i < words.length; i += 1) {
|
|
94
|
+
if (this.lexicon.has(words[i])) {
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Returns list of matching hotwords found in text.
|
|
103
|
+
*
|
|
104
|
+
* @param {string} text
|
|
105
|
+
* @returns {string[]}
|
|
106
|
+
*/
|
|
107
|
+
findMatches(text) {
|
|
108
|
+
if (!text || typeof text !== "string") return [];
|
|
109
|
+
const words = extractWords(text);
|
|
110
|
+
const matches = [];
|
|
111
|
+
for (let i = 0; i < words.length; i += 1) {
|
|
112
|
+
if (this.lexicon.has(words[i]) && !matches.includes(words[i])) {
|
|
113
|
+
matches.push(words[i]);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return matches;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
get size() {
|
|
120
|
+
return this.lexicon.size;
|
|
121
|
+
}
|
|
122
|
+
}
|