dsh-perm-gate 0.2.0 → 0.2.1-beta.2
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.ja.md +97 -0
- package/CHANGELOG.ko.md +96 -0
- package/CHANGELOG.md +146 -0
- package/INSTALL.ja.md +2 -2
- package/INSTALL.ko.md +2 -2
- package/INSTALL.md +3 -2
- package/INSTALL.zh.md +2 -2
- package/README.ja.md +29 -6
- package/README.ko.md +37 -8
- package/README.md +73 -11
- package/README.zh.md +44 -9
- package/cordis.patch.yml +19 -12
- package/lib/audit.d.ts +1 -1
- package/lib/classifier.d.ts +2 -0
- package/lib/classifier.js +7 -4
- package/lib/client.js +1786 -53
- package/lib/client.js.map +1 -1
- package/lib/config.d.ts +63 -4
- package/lib/config.js +50 -2
- package/lib/deny-defaults.d.ts +12 -0
- package/lib/deny-defaults.js +19 -0
- package/lib/engine.d.ts +13 -1
- package/lib/engine.js +85 -8
- package/lib/evaluate.d.ts +10 -0
- package/lib/evaluate.js +101 -11
- package/lib/events.d.ts +140 -4
- package/lib/events.js +578 -13
- package/lib/host-llm.d.ts +61 -0
- package/lib/host-llm.js +52 -0
- package/lib/index.d.ts +15 -1
- package/lib/index.js +266 -23
- package/lib/learning.d.ts +19 -2
- package/lib/learning.js +47 -5
- package/lib/llm-presets.d.ts +16 -0
- package/lib/llm-presets.js +11 -0
- package/lib/preset.d.ts +30 -0
- package/lib/preset.js +41 -0
- package/lib/receiver-info.d.ts +76 -0
- package/lib/receiver-info.js +70 -0
- package/lib/risk.d.ts +16 -1
- package/lib/risk.js +15 -3
- package/lib/runtime.d.ts +210 -4
- package/lib/runtime.js +589 -33
- package/package.json +6 -5
package/lib/runtime.js
CHANGED
|
@@ -12,14 +12,33 @@ import { readFileSync } from 'node:fs';
|
|
|
12
12
|
import { makeEntry, MemoryAuditMirror } from './audit.js';
|
|
13
13
|
import { resolvePermissiveStrategies } from './config.js';
|
|
14
14
|
import { appendAllowCommand, listAllowCommands, replaceAllowCommands } from './allowlist.js';
|
|
15
|
-
import { decide } from './engine.js';
|
|
16
|
-
import { EventLog } from './events.js';
|
|
15
|
+
import { decide, CONTENT_ARG_KEYS } from './engine.js';
|
|
16
|
+
import { EventLog, saveEventSnapshots } from './events.js';
|
|
17
17
|
import { decideRules } from './evaluate.js';
|
|
18
18
|
import { canonicalizeCall, GrantRegistry } from './grant.js';
|
|
19
19
|
import { learnKey, operationFingerprint, RiskLearning } from './learning.js';
|
|
20
20
|
import { ArtifactRegistry } from './path.js';
|
|
21
|
-
import { classifyRisk } from './risk.js';
|
|
22
|
-
import {
|
|
21
|
+
import { classifyRisk, classifyRiskWith } from './risk.js';
|
|
22
|
+
import { completeViaHost, DEFAULT_HOST_MODEL } from './host-llm.js';
|
|
23
|
+
import { chatCompletion } from './classifier.js';
|
|
24
|
+
import { compileDocument, documentHash, extractPathCandidates, parsePermissionsDocument } from './rule.js';
|
|
25
|
+
import { DEFAULT_DENY_KEYWORDS } from './deny-defaults.js';
|
|
26
|
+
import { permissionPresetOf, presetInScope } from './preset.js';
|
|
27
|
+
import { resolveGatePresets } from './config.js';
|
|
28
|
+
/** The session id of one execution (direct field, else the agent's session). */
|
|
29
|
+
function sessionIdOf(exec) {
|
|
30
|
+
if (typeof exec.sessionId === 'string' && exec.sessionId !== '')
|
|
31
|
+
return exec.sessionId;
|
|
32
|
+
const id = exec.agent?.session?.id;
|
|
33
|
+
return typeof id === 'string' ? id : '';
|
|
34
|
+
}
|
|
35
|
+
/** The workspace cwd of one execution (direct field, else the session header). */
|
|
36
|
+
function cwdOf(exec) {
|
|
37
|
+
if (typeof exec.cwd === 'string' && exec.cwd !== '')
|
|
38
|
+
return exec.cwd;
|
|
39
|
+
const cwd = exec.agent?.session?.header?.cwd;
|
|
40
|
+
return typeof cwd === 'string' && cwd !== '' ? cwd : undefined;
|
|
41
|
+
}
|
|
23
42
|
/** Upper bound on in-flight learning candidates awaiting `tools/result`. */
|
|
24
43
|
const PENDING_CAP = 100;
|
|
25
44
|
export class PermGateRuntime {
|
|
@@ -31,20 +50,40 @@ export class PermGateRuntime {
|
|
|
31
50
|
cache = new Map(); // hash -> compiled
|
|
32
51
|
permissiveEnabled;
|
|
33
52
|
strategies;
|
|
53
|
+
/** Presets in which this gate is active at all; outside them it stands down. */
|
|
54
|
+
gatePresets;
|
|
55
|
+
/** Extra tool names the user classified as safe (config `autoAllowTools`). */
|
|
56
|
+
autoAllowExtra;
|
|
57
|
+
/** Cache of the permission-preset fold, keyed by log identity + length. */
|
|
58
|
+
presetCache;
|
|
34
59
|
learning;
|
|
35
60
|
events;
|
|
36
61
|
/** Learning candidates awaiting human approval + execution (call fingerprint → candidate). */
|
|
37
62
|
pending = new Map();
|
|
63
|
+
/**
|
|
64
|
+
* Asks awaiting the human's terminal answer, keyed by {@link askKey}. Both the
|
|
65
|
+
* approval observer (primary) and `tools/result` (fallback) settle an entry by
|
|
66
|
+
* deleting it — "delete on settle" is the whole de-duplication rule, so a
|
|
67
|
+
* missing `callId` or an untriggered observer can never lose a record.
|
|
68
|
+
*/
|
|
69
|
+
pendingAsks = new Map();
|
|
38
70
|
constructor(options = {}) {
|
|
39
71
|
this.options = options;
|
|
40
72
|
this.grants = new GrantRegistry('__session__', options.now);
|
|
41
73
|
this.permissiveEnabled = options.permissive ?? false;
|
|
74
|
+
// An embedding that never states a scope keeps the historical behaviour
|
|
75
|
+
// (the gate acts in every preset); the plugin always passes the resolved
|
|
76
|
+
// scope from `resolveConfig`, whose product default is `['permissive']`.
|
|
77
|
+
this.gatePresets = options.gatePresets === undefined ? ['*'] : resolveGatePresets(options.gatePresets);
|
|
78
|
+
this.autoAllowExtra = new Set((options.autoAllowTools ?? []).filter((name) => typeof name === 'string' && name !== ''));
|
|
42
79
|
this.strategies = resolvePermissiveStrategies(options.permissiveStrategies);
|
|
43
80
|
this.learning = new RiskLearning(options.learningFile, {
|
|
44
81
|
threshold: () => this.liveRiskLearning().threshold,
|
|
45
82
|
now: options.now,
|
|
46
83
|
});
|
|
47
|
-
this.events = options.eventsFile !== undefined
|
|
84
|
+
this.events = options.eventsFile !== undefined
|
|
85
|
+
? new EventLog(options.eventsFile, options.now, options.snapshotsDir)
|
|
86
|
+
: undefined;
|
|
48
87
|
this.ruleset = this.compileInline(options);
|
|
49
88
|
}
|
|
50
89
|
compileInline(options) {
|
|
@@ -107,6 +146,10 @@ export class PermGateRuntime {
|
|
|
107
146
|
get eventLog() {
|
|
108
147
|
return this.events;
|
|
109
148
|
}
|
|
149
|
+
/** The snapshot directory backing diff/revert (undefined when disabled). */
|
|
150
|
+
get snapshotDir() {
|
|
151
|
+
return this.options.snapshotsDir;
|
|
152
|
+
}
|
|
110
153
|
/** Learning candidates currently awaiting settlement (diagnostics/tests). */
|
|
111
154
|
pendingCount() {
|
|
112
155
|
return this.pending.size;
|
|
@@ -116,11 +159,16 @@ export class PermGateRuntime {
|
|
|
116
159
|
return this.learning.snapshot();
|
|
117
160
|
}
|
|
118
161
|
ctxFor(exec) {
|
|
162
|
+
const cwd = cwdOf(exec) ?? process.cwd();
|
|
119
163
|
return {
|
|
120
164
|
tool: exec.name,
|
|
121
165
|
args: exec.arguments ?? {},
|
|
122
166
|
commandText: exec.commandText ?? (typeof exec.arguments.command === 'string' ? exec.arguments.command : undefined),
|
|
123
|
-
cwd
|
|
167
|
+
cwd,
|
|
168
|
+
// The workspace root (the session's cwd). The write-path override in
|
|
169
|
+
// `decideRules` uses this to determine whether a write/edit target is
|
|
170
|
+
// inside the workspace or outside it.
|
|
171
|
+
home: cwdOf(exec),
|
|
124
172
|
caseInsensitive: this.options.caseInsensitivePaths ?? true,
|
|
125
173
|
dshHome: this.options.dshHome,
|
|
126
174
|
};
|
|
@@ -131,13 +179,169 @@ export class PermGateRuntime {
|
|
|
131
179
|
return read();
|
|
132
180
|
return { enabled: this.options.riskLearning ?? false, threshold: this.options.riskThreshold ?? 3 };
|
|
133
181
|
}
|
|
182
|
+
liveRiskSediment() {
|
|
183
|
+
const read = this.options.readRiskSediment;
|
|
184
|
+
return read !== undefined ? read() : (this.options.riskSediment ?? true);
|
|
185
|
+
}
|
|
186
|
+
liveClassifySource() {
|
|
187
|
+
const read = this.options.readClassifySource;
|
|
188
|
+
return read !== undefined ? read() : 'custom';
|
|
189
|
+
}
|
|
190
|
+
/** The host model-group selection, or undefined when unusable. */
|
|
191
|
+
hostModel() {
|
|
192
|
+
const sel = this.options.readHostModel?.();
|
|
193
|
+
return sel !== undefined && sel.provider !== '' && sel.model !== '' ? sel : undefined;
|
|
194
|
+
}
|
|
134
195
|
livePermissive() {
|
|
135
196
|
return this.options.readPermissive !== undefined
|
|
136
197
|
? this.options.readPermissive()
|
|
137
198
|
: { enabled: this.permissiveEnabled, strategies: this.strategies };
|
|
138
199
|
}
|
|
139
|
-
|
|
140
|
-
|
|
200
|
+
/**
|
|
201
|
+
* The session's selected permission preset, folded from its event log. The
|
|
202
|
+
* fold is cached by the log array identity plus its length: a session's log
|
|
203
|
+
* is a stable, append-only array, so a changed reference or a grown log
|
|
204
|
+
* invalidates the cache while repeated calls on the same log stay cheap.
|
|
205
|
+
*/
|
|
206
|
+
presetOf(exec) {
|
|
207
|
+
const events = exec.agent?.session?.events;
|
|
208
|
+
const length = events?.length ?? -1;
|
|
209
|
+
const cached = this.presetCache;
|
|
210
|
+
if (cached !== undefined && cached.events === events && cached.length === length)
|
|
211
|
+
return cached.preset;
|
|
212
|
+
const preset = permissionPresetOf(events);
|
|
213
|
+
this.presetCache = { events, length, preset };
|
|
214
|
+
return preset;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Whether this gate is active for one call at all.
|
|
218
|
+
*
|
|
219
|
+
* The gate owns an independent permission tier, so it only acts while that
|
|
220
|
+
* tier (or another preset the user listed in `gatePresets`) is selected.
|
|
221
|
+
* Outside it the gate stands down completely — no allow, no ask, no deny, no
|
|
222
|
+
* P0 hard-deny — because the selected tier's own policy governs the session:
|
|
223
|
+
* `danger-full-access` means "full access without approval prompts", where a
|
|
224
|
+
* forwarded ask can only fail (the approval seam rejects before any answerer
|
|
225
|
+
* runs) and a hard-deny would silently overrule the tier the user chose.
|
|
226
|
+
*/
|
|
227
|
+
gateActive(exec) {
|
|
228
|
+
return presetInScope(this.presetOf(exec), this.gatePresets);
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Whether an `ask` produced while the gate is active can actually reach a
|
|
232
|
+
* human. Under `approval: never` the DSH approval seam returns `rejected`
|
|
233
|
+
* before any answerer runs, so such an ask degrades to passthrough.
|
|
234
|
+
*
|
|
235
|
+
* The reader is an optional duck-typed seam (the host half probes a PRIVATE
|
|
236
|
+
* approval-service method, identical in DSH 0.1.1-rc.2 and 0.1.2-rc.1). A
|
|
237
|
+
* throwing reader must not kill the decision path: "unknown" is treated as
|
|
238
|
+
* answerable, so the ask stands and the approval seam decides.
|
|
239
|
+
*/
|
|
240
|
+
askAnswerable(exec) {
|
|
241
|
+
try {
|
|
242
|
+
return this.options.readApprovalPolicy?.(exec) !== 'never';
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
/** Effective deny-keyword blacklist: the live namespace override or the preset.
|
|
249
|
+
* An empty override is treated as "no meaningful override" (the blacklist is
|
|
250
|
+
* protective and stays on); schemastery materializes unset arrays as `[]`. */
|
|
251
|
+
liveDenyKeywords() {
|
|
252
|
+
const read = this.options.readDenyKeywords;
|
|
253
|
+
const override = read !== undefined ? read() : undefined;
|
|
254
|
+
return Array.isArray(override) && override.length > 0 ? override : DEFAULT_DENY_KEYWORDS;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* The preset deny-keyword layer (inherited from dsh-approval-gate): a keyword
|
|
258
|
+
* hit over the call's command/argument text vetoes it with `deny` before any
|
|
259
|
+
* allow path (deny wins over allow). Purely additive to P0 — it can only ever
|
|
260
|
+
* deny, never widen. Matching is boundary-aware and skips document bodies, so
|
|
261
|
+
* a keyword can no longer veto an unrelated identifier or a file's text.
|
|
262
|
+
*/
|
|
263
|
+
denyKeywordHit(exec) {
|
|
264
|
+
const keywords = this.liveDenyKeywords();
|
|
265
|
+
if (keywords.length === 0)
|
|
266
|
+
return undefined;
|
|
267
|
+
const text = denyScanText(exec);
|
|
268
|
+
if (text === '')
|
|
269
|
+
return undefined;
|
|
270
|
+
for (const keyword of keywords) {
|
|
271
|
+
if (String(keyword).trim() === '')
|
|
272
|
+
continue;
|
|
273
|
+
if (keywordMatcher(String(keyword)).test(text))
|
|
274
|
+
return keyword;
|
|
275
|
+
}
|
|
276
|
+
return undefined;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Record one decision event on the feed. `verdict` labels the decision path
|
|
280
|
+
* for the history view; `files` (defaulted from the call itself) drives the
|
|
281
|
+
* snapshot/diff/revert chain, and `baseDir` resolves relative paths.
|
|
282
|
+
*/
|
|
283
|
+
recordEvent(exec, kind, reason, extra = {}) {
|
|
284
|
+
this.events?.append({
|
|
285
|
+
sessionId: sessionIdOf(exec),
|
|
286
|
+
tool: exec.name,
|
|
287
|
+
kind,
|
|
288
|
+
risk: extra.risk,
|
|
289
|
+
reason,
|
|
290
|
+
verdict: extra.verdict,
|
|
291
|
+
justification: extra.justification ?? reason,
|
|
292
|
+
category: extra.category,
|
|
293
|
+
files: extra.files ?? eventFiles(exec),
|
|
294
|
+
learningCount: extra.learningCount,
|
|
295
|
+
threshold: extra.threshold,
|
|
296
|
+
baseDir: cwdOf(exec),
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
/** The correlation key of one ask: its call id, or a canonical call fingerprint. */
|
|
300
|
+
askKey(exec) {
|
|
301
|
+
return typeof exec.callId === 'string' && exec.callId !== ''
|
|
302
|
+
? `id:${exec.callId}`
|
|
303
|
+
: `fp:${canonicalizeCall(exec.name, exec.arguments ?? {})}`;
|
|
304
|
+
}
|
|
305
|
+
/** Remember one ask so either settlement channel can record its terminal answer. */
|
|
306
|
+
trackAsk(exec, reason, learningKey) {
|
|
307
|
+
if (this.pendingAsks.size >= PENDING_CAP) {
|
|
308
|
+
const oldest = this.pendingAsks.keys().next();
|
|
309
|
+
if (!oldest.done)
|
|
310
|
+
this.pendingAsks.delete(oldest.value);
|
|
311
|
+
}
|
|
312
|
+
// Generate a synthetic snapshot ID since we no longer record 'ask' events
|
|
313
|
+
// (they'd pollute the approval record). Snapshots are keyed by this ID.
|
|
314
|
+
const now = typeof this.options.now === 'function' ? this.options.now : Date.now;
|
|
315
|
+
const snapshotId = now();
|
|
316
|
+
const pending = {
|
|
317
|
+
reason,
|
|
318
|
+
tool: exec.name,
|
|
319
|
+
sessionId: sessionIdOf(exec),
|
|
320
|
+
files: eventFiles(exec),
|
|
321
|
+
baseDir: cwdOf(exec),
|
|
322
|
+
snapshotId,
|
|
323
|
+
learningKey,
|
|
324
|
+
};
|
|
325
|
+
this.pendingAsks.set(this.askKey(exec), pending);
|
|
326
|
+
// Save pre-change snapshots so the review page can diff/revert later.
|
|
327
|
+
if (pending.files.length > 0 && this.snapshotDir) {
|
|
328
|
+
try {
|
|
329
|
+
saveEventSnapshots(this.snapshotDir, snapshotId, pending.files, pending.baseDir, pending.sessionId, now);
|
|
330
|
+
}
|
|
331
|
+
catch { /* best-effort */ }
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
/** Drop one tracked ask without recording it (the call never needed a human). */
|
|
335
|
+
untrackAsk(exec) {
|
|
336
|
+
this.pendingAsks.delete(this.askKey(exec));
|
|
337
|
+
}
|
|
338
|
+
/** Asks currently awaiting a terminal answer (diagnostics/tests). */
|
|
339
|
+
pendingAskCount() {
|
|
340
|
+
return this.pendingAsks.size;
|
|
341
|
+
}
|
|
342
|
+
/** Get one pending ask by its key (diagnostics/tests only). */
|
|
343
|
+
getPendingAsk(key) {
|
|
344
|
+
return this.pendingAsks.get(key);
|
|
141
345
|
}
|
|
142
346
|
/**
|
|
143
347
|
* Decide one tool call. Returns a decision to veto (`deny`/`ask`) or
|
|
@@ -149,14 +353,28 @@ export class PermGateRuntime {
|
|
|
149
353
|
* the human seam.
|
|
150
354
|
*/
|
|
151
355
|
decideExecution(exec) {
|
|
356
|
+
// The gate is scoped to its own tier(s): anywhere else it stands down
|
|
357
|
+
// entirely and records nothing (the session's selected tier owns the call).
|
|
358
|
+
if (!this.gateActive(exec))
|
|
359
|
+
return undefined;
|
|
152
360
|
const ctx = this.ctxFor(exec);
|
|
153
361
|
const callId = randomId();
|
|
362
|
+
// Preset deny-keyword layer (deny wins over allow): a dangerous-keyword hit
|
|
363
|
+
// vetoes before grants / rules / LLM. Additive deny only — P0 semantics are
|
|
364
|
+
// untouched and every later stage stays behind this veto.
|
|
365
|
+
const keyword = this.denyKeywordHit(exec);
|
|
366
|
+
if (keyword !== undefined) {
|
|
367
|
+
const reason = `deny-keyword: matches preset blacklist entry "${keyword}"`;
|
|
368
|
+
this.audit.append(makeEntry({ callId, tool: exec.name, outcome: 'deny', source: 'deny-keyword', reason, at: Date.now() }));
|
|
369
|
+
this.recordEvent(exec, 'deny', reason, { verdict: 'deny-keyword' });
|
|
370
|
+
return { kind: 'deny', reason };
|
|
371
|
+
}
|
|
154
372
|
const grantResolver = (tool, args) => {
|
|
155
373
|
if (exec.parentAuthorized === false)
|
|
156
374
|
return 'no-match';
|
|
157
375
|
return this.grants.decide(tool, args);
|
|
158
376
|
};
|
|
159
|
-
const raw = decide(ctx, { decide: (c) => decideRules(this.ruleset, c) }, grantResolver);
|
|
377
|
+
const raw = decide(ctx, { decide: (c) => decideRules(this.ruleset, c) }, grantResolver, this.autoAllowExtra);
|
|
160
378
|
const decision = this.applyPermissive(raw);
|
|
161
379
|
let outcome;
|
|
162
380
|
let source;
|
|
@@ -183,12 +401,30 @@ export class PermGateRuntime {
|
|
|
183
401
|
source = decision.reason.startsWith('permissive') ? 'permissive' : 'default';
|
|
184
402
|
break;
|
|
185
403
|
}
|
|
186
|
-
|
|
187
|
-
|
|
404
|
+
// An ask the gate cannot deliver: under `approval: never` the DSH approval
|
|
405
|
+
// seam rejects before any answerer runs, so forwarding it would only ever
|
|
406
|
+
// produce `the user rejected tool "..."`. Degrade to passthrough instead.
|
|
407
|
+
if (decision.action === 'ask' && !this.askAnswerable(exec)) {
|
|
408
|
+
const reason = `passthrough: approval policy "never" cannot answer an ask — ${decision.reason}`;
|
|
409
|
+
this.audit.append(makeEntry({ callId, tool: exec.name, outcome: 'allow', source, reason, at: Date.now() }));
|
|
410
|
+
this.recordEvent(exec, 'auto', reason, { verdict: 'preset-passthrough' });
|
|
411
|
+
return undefined;
|
|
412
|
+
}
|
|
413
|
+
// Only log terminal outcomes; 'ask' is intermediate and must not pollute
|
|
414
|
+
// the audit or the event feed — recording it would be recursive since the
|
|
415
|
+
// user's terminal answer comes back through tools/result anyway.
|
|
416
|
+
if (outcome !== 'ask') {
|
|
417
|
+
this.audit.append(makeEntry({ callId, tool: exec.name, outcome, source, reason: decision.reason, at: Date.now() }));
|
|
418
|
+
this.recordEvent(exec, outcome === 'allow' ? 'auto' : outcome === 'deny' ? 'deny' : 'ask', decision.reason, { verdict: source });
|
|
419
|
+
}
|
|
188
420
|
if (decision.action === 'deny')
|
|
189
421
|
return { kind: 'deny', reason: decision.reason };
|
|
190
|
-
if (decision.action === 'ask')
|
|
422
|
+
if (decision.action === 'ask') {
|
|
423
|
+
// Track before returning: the approval observer can settle this ask
|
|
424
|
+
// before the call ever reaches `tools/result`.
|
|
425
|
+
this.trackAsk(exec, decision.reason);
|
|
191
426
|
return { kind: 'ask', reason: decision.reason };
|
|
427
|
+
}
|
|
192
428
|
return undefined;
|
|
193
429
|
}
|
|
194
430
|
/**
|
|
@@ -220,42 +456,77 @@ export class PermGateRuntime {
|
|
|
220
456
|
*/
|
|
221
457
|
async refineAsk(exec, decision) {
|
|
222
458
|
const permissive = this.livePermissive();
|
|
223
|
-
if (!permissive.enabled
|
|
459
|
+
if (!permissive.enabled)
|
|
224
460
|
return decision;
|
|
225
461
|
if (decision.kind !== 'ask')
|
|
226
462
|
return decision;
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
//
|
|
463
|
+
// Sedimented learning rules (checked before any LLM work): a confirmed
|
|
464
|
+
// sample of a threshold-reached key auto-allows deterministically. This is
|
|
465
|
+
// the "沉淀" of verdict learning — it keeps working even with llmAssist
|
|
466
|
+
// off, because the human confirmations already happened. Deny-first and
|
|
467
|
+
// P0 are untouched: this only ever converts an `ask` into an allow.
|
|
468
|
+
const learning = this.liveRiskLearning();
|
|
469
|
+
if (learning.enabled && this.liveRiskSediment()) {
|
|
470
|
+
const fp = operationFingerprint(exec.name, exec.arguments ?? {}, exec.commandText);
|
|
471
|
+
const key = learnKey(exec.name, 'neutral', fp);
|
|
472
|
+
if (this.learning.shouldAutoAllow(key, fp)) {
|
|
473
|
+
const reason = `learned sediment allow (${key}, ${fp})`;
|
|
474
|
+
this.audit.append(makeEntry({ callId: randomId(), tool: exec.name, outcome: 'allow', source: 'classifier', reason, at: Date.now() }));
|
|
475
|
+
this.recordEvent(exec, 'learned', reason, { risk: 'neutral', verdict: 'learned-sediment', category: 'neutral' });
|
|
476
|
+
this.untrackAsk(exec); // auto-allowed: no human answer is coming
|
|
477
|
+
return undefined;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
if (!permissive.strategies.llmAssist)
|
|
481
|
+
return decision;
|
|
482
|
+
// One live read of the classifier setup. Without a usable receiver (a
|
|
483
|
+
// configured custom endpoint, or the host llm service in host mode) the
|
|
484
|
+
// ask goes straight to the human seam (fail-closed) — unless a risk hook
|
|
485
|
+
// is injected (tests / direct embedding replace the grader entirely).
|
|
230
486
|
const cfg = this.options.readClassifyConfig?.();
|
|
231
|
-
|
|
487
|
+
const useHost = this.liveClassifySource() === 'host' && this.options.hostLlm !== undefined;
|
|
488
|
+
if (this.options.riskHook === undefined && !useHost
|
|
232
489
|
&& (cfg?.endpoint === undefined || cfg?.endpoint === '' || cfg?.model === undefined || cfg?.model === '')) {
|
|
233
490
|
return decision;
|
|
234
491
|
}
|
|
235
492
|
const req = { tool: exec.name, args: exec.arguments ?? {}, reason: decision.reason };
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
493
|
+
let risk;
|
|
494
|
+
if (this.options.riskHook !== undefined) {
|
|
495
|
+
risk = await this.options.riskHook(req);
|
|
496
|
+
}
|
|
497
|
+
else if (useHost) {
|
|
498
|
+
const selection = this.hostModel() ?? DEFAULT_HOST_MODEL;
|
|
499
|
+
risk = await classifyRiskWith((system, user) => completeViaHost(this.options.hostLlm, selection, system, user, cfg?.timeoutMs ?? 20_000), req);
|
|
500
|
+
}
|
|
501
|
+
else {
|
|
502
|
+
risk = await classifyRisk(cfg ?? {}, req);
|
|
503
|
+
}
|
|
239
504
|
if (risk.kind === 'safe') {
|
|
240
505
|
const reason = `llm-assist risk: safe${risk.reason === undefined ? '' : ` (${risk.reason})`}`;
|
|
241
506
|
this.audit.append(makeEntry({ callId: randomId(), tool: exec.name, outcome: 'allow', source: 'classifier', reason, at: Date.now() }));
|
|
242
|
-
this.recordEvent(exec, 'auto', reason, 'safe');
|
|
507
|
+
this.recordEvent(exec, 'auto', reason, { risk: 'safe', verdict: 'llm-safe' });
|
|
508
|
+
this.untrackAsk(exec); // auto-allowed: no human answer is coming
|
|
243
509
|
return undefined;
|
|
244
510
|
}
|
|
245
511
|
if (risk.kind === 'risky') {
|
|
246
512
|
if (risk.category !== 'neutral') {
|
|
247
|
-
// Hard category (
|
|
248
|
-
//
|
|
249
|
-
|
|
250
|
-
|
|
513
|
+
// Hard category (deletion / credential / remote / system / bulk) or an
|
|
514
|
+
// off-protocol output graded hard: auto-deny. The operation is clearly
|
|
515
|
+
// dangerous or sensitive — no popup, no learning, just block.
|
|
516
|
+
const reason = `${decision.reason} [llm-assist risky:${risk.category} → auto-deny]`;
|
|
517
|
+
this.audit.append(makeEntry({ callId: randomId(), tool: exec.name, outcome: 'deny', source: 'classifier', reason, at: Date.now() }));
|
|
518
|
+
this.recordEvent(exec, 'deny', reason, { risk: risk.category, verdict: 'llm-deny', category: risk.category });
|
|
519
|
+
this.untrackAsk(exec);
|
|
520
|
+
return { kind: 'deny', reason };
|
|
251
521
|
}
|
|
252
522
|
const learning = this.liveRiskLearning();
|
|
253
|
-
const key = learnKey(exec.name, risk.category);
|
|
254
523
|
const fp = operationFingerprint(exec.name, exec.arguments ?? {}, exec.commandText);
|
|
524
|
+
const key = learnKey(exec.name, risk.category, fp);
|
|
255
525
|
if (learning.enabled && this.learning.shouldAutoAllow(key, fp)) {
|
|
256
526
|
const reason = `llm-assist learned allow (${key}, ${fp})`;
|
|
257
527
|
this.audit.append(makeEntry({ callId: randomId(), tool: exec.name, outcome: 'allow', source: 'classifier', reason, at: Date.now() }));
|
|
258
|
-
this.recordEvent(exec, 'auto', reason, risk.category);
|
|
528
|
+
this.recordEvent(exec, 'auto', reason, { risk: risk.category, verdict: 'llm-learned', category: risk.category });
|
|
529
|
+
this.untrackAsk(exec); // auto-allowed: no human answer is coming
|
|
259
530
|
return undefined;
|
|
260
531
|
}
|
|
261
532
|
if (learning.enabled) {
|
|
@@ -266,17 +537,45 @@ export class PermGateRuntime {
|
|
|
266
537
|
}
|
|
267
538
|
this.pending.set(canonicalizeCall(exec.name, exec.arguments ?? {}), { key, fp, ctx: decision.reason });
|
|
268
539
|
}
|
|
269
|
-
|
|
540
|
+
const neutralReason = `${decision.reason} [llm-assist risky:neutral]`;
|
|
541
|
+
// The refined reason supersedes the raw ask, and the candidate key lets the
|
|
542
|
+
// terminal event report the post-approval learning progress.
|
|
543
|
+
this.trackAsk(exec, neutralReason, learning.enabled ? key : undefined);
|
|
544
|
+
return { kind: 'ask', reason: neutralReason };
|
|
270
545
|
}
|
|
271
546
|
// unresolved: keep the ask, learn nothing.
|
|
272
547
|
return decision;
|
|
273
548
|
}
|
|
274
549
|
/**
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
550
|
+
* Record the human's terminal answer for one tracked ask. This is the primary
|
|
551
|
+
* channel, driven by the `approval/request` observer, which sees the closed
|
|
552
|
+
* outcome (`allowed-once` / `rejected` / `cancelled` / `unavailable`) without
|
|
553
|
+
* having to parse any reason text.
|
|
554
|
+
*
|
|
555
|
+
* Consuming the entry is the entire de-duplication rule: a second call for the
|
|
556
|
+
* same ask — or a later `tools/result` — finds nothing and records nothing.
|
|
557
|
+
* @returns whether an ask was settled.
|
|
278
558
|
*/
|
|
279
|
-
|
|
559
|
+
settleAskOutcome(callId, outcome) {
|
|
560
|
+
if (callId === '')
|
|
561
|
+
return false;
|
|
562
|
+
const key = `id:${callId}`;
|
|
563
|
+
const ask = this.pendingAsks.get(key);
|
|
564
|
+
if (ask === undefined)
|
|
565
|
+
return false;
|
|
566
|
+
this.pendingAsks.delete(key);
|
|
567
|
+
this.recordAskOutcome(ask, outcome);
|
|
568
|
+
return true;
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Settle after the call finished (`tools/result`): the fallback channel for an
|
|
572
|
+
* ask whose approval never surfaced to the observer (no approval service, a
|
|
573
|
+
* missing `callId`, or an earlier listener short-circuiting the waterfall).
|
|
574
|
+
* Also settles a pending learning candidate: a result for one means the human
|
|
575
|
+
* approved it and it actually executed — one confirmation.
|
|
576
|
+
*/
|
|
577
|
+
settleExecution(exec, result) {
|
|
578
|
+
this.settleAskFromResult(exec, result);
|
|
280
579
|
const callKey = canonicalizeCall(exec.name, exec.arguments ?? {});
|
|
281
580
|
const candidate = this.pending.get(callKey);
|
|
282
581
|
if (candidate === undefined)
|
|
@@ -285,7 +584,151 @@ export class PermGateRuntime {
|
|
|
285
584
|
if (!this.liveRiskLearning().enabled)
|
|
286
585
|
return;
|
|
287
586
|
this.learning.confirm(candidate.key, candidate.fp, candidate.ctx);
|
|
288
|
-
this.recordEvent(exec, 'learned', `confirmed ${candidate.key} (${candidate.fp})`, 'neutral');
|
|
587
|
+
this.recordEvent(exec, 'learned', `confirmed ${candidate.key} (${candidate.fp})`, { risk: 'neutral', verdict: 'learned-confirm', category: 'neutral' });
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* Promote a pending learning candidate to a session-level grant: the same
|
|
591
|
+
* operation auto-passes for the rest of the session (bounded by TTL and
|
|
592
|
+
* max-uses) without waiting for human confirmations. This is the "one-click
|
|
593
|
+
* allow in session" shortcut the settings card can expose.
|
|
594
|
+
*
|
|
595
|
+
* @returns `true` when a pending candidate was promoted.
|
|
596
|
+
*/
|
|
597
|
+
approveSessionGrant(exec) {
|
|
598
|
+
const callKey = canonicalizeCall(exec.name, exec.arguments ?? {});
|
|
599
|
+
const candidate = this.pending.get(callKey);
|
|
600
|
+
if (candidate === undefined)
|
|
601
|
+
return false;
|
|
602
|
+
this.pending.delete(callKey);
|
|
603
|
+
// Record the learning confirmation so sediment also benefits.
|
|
604
|
+
if (this.liveRiskLearning().enabled) {
|
|
605
|
+
this.learning.confirm(candidate.key, candidate.fp, candidate.ctx);
|
|
606
|
+
}
|
|
607
|
+
this.grants.mint({
|
|
608
|
+
tool: exec.name,
|
|
609
|
+
fingerprint: callKey,
|
|
610
|
+
decidedBy: 'human',
|
|
611
|
+
parentAuthorized: exec.parentAuthorized !== false,
|
|
612
|
+
ttlMs: this.options.grantTtlMs ?? 5 * 60_000,
|
|
613
|
+
maxUses: this.options.grantMaxUses ?? 1,
|
|
614
|
+
});
|
|
615
|
+
this.untrackAsk(exec);
|
|
616
|
+
this.recordEvent(exec, 'learned', `approved session grant (${candidate.key}, ${candidate.fp})`, { risk: 'neutral', verdict: 'learned-granted', category: 'neutral' });
|
|
617
|
+
return true;
|
|
618
|
+
}
|
|
619
|
+
/** Classify one `tools/result` payload into the ask's terminal outcome. */
|
|
620
|
+
settleAskFromResult(exec, result) {
|
|
621
|
+
const key = this.askKey(exec);
|
|
622
|
+
const ask = this.pendingAsks.get(key);
|
|
623
|
+
if (ask === undefined)
|
|
624
|
+
return;
|
|
625
|
+
const message = typeof result?.error?.message === 'string' ? result.error.message : '';
|
|
626
|
+
const failed = result?.isError === true;
|
|
627
|
+
let outcome;
|
|
628
|
+
if (!failed)
|
|
629
|
+
outcome = 'allowed-once';
|
|
630
|
+
else if (/rejected tool/i.test(message))
|
|
631
|
+
outcome = 'rejected';
|
|
632
|
+
else if (/was cancelled/i.test(message))
|
|
633
|
+
outcome = 'cancelled';
|
|
634
|
+
else if (/approval/i.test(message))
|
|
635
|
+
outcome = 'unavailable';
|
|
636
|
+
// The human allowed it and the tool failed on its own afterwards.
|
|
637
|
+
else
|
|
638
|
+
outcome = 'allowed-once';
|
|
639
|
+
this.pendingAsks.delete(key);
|
|
640
|
+
this.recordAskOutcome(ask, outcome);
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Write one terminal event from the ask's snapshotted context. The observer
|
|
644
|
+
* has no execution object of its own, so everything here comes from what
|
|
645
|
+
* {@link trackAsk} captured when the ask was recorded.
|
|
646
|
+
*/
|
|
647
|
+
recordAskOutcome(ask, outcome) {
|
|
648
|
+
const exec = {
|
|
649
|
+
name: ask.tool,
|
|
650
|
+
arguments: {},
|
|
651
|
+
sessionId: ask.sessionId,
|
|
652
|
+
cwd: ask.baseDir,
|
|
653
|
+
};
|
|
654
|
+
if (outcome === 'allowed-once') {
|
|
655
|
+
const learning = this.liveRiskLearning();
|
|
656
|
+
// Report the post-approval progress: a registered candidate is confirmed
|
|
657
|
+
// by `tools/result` right after this, so it counts as this approval.
|
|
658
|
+
const key = ask.learningKey ?? learnKey(ask.tool, 'neutral');
|
|
659
|
+
const learningCount = learning.enabled
|
|
660
|
+
? this.learning.count(key) + (ask.learningKey === undefined ? 0 : 1)
|
|
661
|
+
: undefined;
|
|
662
|
+
this.recordEvent(exec, 'manual-approved', ask.reason, {
|
|
663
|
+
verdict: 'human-approved',
|
|
664
|
+
files: ask.files,
|
|
665
|
+
learningCount,
|
|
666
|
+
threshold: learning.enabled ? learning.threshold : undefined,
|
|
667
|
+
});
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
if (outcome === 'rejected') {
|
|
671
|
+
this.recordEvent(exec, 'manual-rejected', ask.reason, { verdict: 'human-rejected', files: ask.files });
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
if (outcome === 'cancelled') {
|
|
675
|
+
this.recordEvent(exec, 'manual-cancelled', ask.reason, { verdict: 'human-cancelled', files: ask.files });
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
// `unavailable`: no human was involved — the ask degraded to a denial.
|
|
679
|
+
this.recordEvent(exec, 'deny', ask.reason, { verdict: 'no-approval-channel', files: ask.files });
|
|
680
|
+
}
|
|
681
|
+
// ---- Learning-store management (the settings UI's sediment view) ----
|
|
682
|
+
/** The live confirmation threshold the sediment view compares counts against. */
|
|
683
|
+
learningThreshold() {
|
|
684
|
+
return this.liveRiskLearning().threshold;
|
|
685
|
+
}
|
|
686
|
+
/** Terminate one key's learning, or drop a single sedimented sample. */
|
|
687
|
+
learningReset(key, fp) {
|
|
688
|
+
if (fp !== undefined)
|
|
689
|
+
this.learning.dropSample(key, fp);
|
|
690
|
+
else
|
|
691
|
+
this.learning.resetKey(key);
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* One minimal completion through the currently configured receiver (host
|
|
695
|
+
* model group or custom endpoint) with latency — the settings card's
|
|
696
|
+
* "health test". Never throws; failures come back as `ok: false` + detail.
|
|
697
|
+
*/
|
|
698
|
+
async healthCheck() {
|
|
699
|
+
const now = this.options.now ?? Date.now;
|
|
700
|
+
const start = now();
|
|
701
|
+
const done = (ok, detail) => ({ ok, ms: now() - start, detail });
|
|
702
|
+
try {
|
|
703
|
+
const cfg = this.options.readClassifyConfig?.();
|
|
704
|
+
if (this.liveClassifySource() === 'host') {
|
|
705
|
+
if (this.options.hostLlm === undefined)
|
|
706
|
+
return done(false, 'host llm service unavailable');
|
|
707
|
+
const selection = this.hostModel() ?? DEFAULT_HOST_MODEL;
|
|
708
|
+
const r = await completeViaHost(this.options.hostLlm, selection, 'Reply with exactly: OK', 'ping', cfg?.timeoutMs ?? 20_000);
|
|
709
|
+
return r.ok ? done(true, `${selection.provider}/${selection.model} → ${r.content.trim().slice(0, 60) || 'ok'}`) : done(false, r.error ?? 'host llm call failed');
|
|
710
|
+
}
|
|
711
|
+
if (cfg?.endpoint === undefined || cfg.endpoint === '' || cfg?.model === undefined || cfg.model === '') {
|
|
712
|
+
return done(false, 'custom endpoint/model not configured');
|
|
713
|
+
}
|
|
714
|
+
const r = await chatCompletion(cfg, 'Reply with exactly: OK', 'ping');
|
|
715
|
+
if (r.ok)
|
|
716
|
+
return done(true, `${cfg.model} → ${r.content.trim().slice(0, 60) || 'ok'}`);
|
|
717
|
+
// Name the failing leg of the endpoint/model/key triple when possible.
|
|
718
|
+
const why = r.error === 'timeout'
|
|
719
|
+
? `timeout after ${cfg.timeoutMs ?? 20_000} ms`
|
|
720
|
+
: r.status === 401 || r.status === 403
|
|
721
|
+
? `HTTP ${r.status} — API key rejected`
|
|
722
|
+
: r.status === 404
|
|
723
|
+
? 'HTTP 404 — endpoint path or model id not found'
|
|
724
|
+
: r.status !== undefined
|
|
725
|
+
? `HTTP ${r.status} — request rejected (check model id / endpoint shape)`
|
|
726
|
+
: 'network error (endpoint unreachable)';
|
|
727
|
+
return done(false, why);
|
|
728
|
+
}
|
|
729
|
+
catch (e) {
|
|
730
|
+
return done(false, String(e?.message ?? e));
|
|
731
|
+
}
|
|
289
732
|
}
|
|
290
733
|
/** Mint a precise session grant bound to a canonical call fingerprint. */
|
|
291
734
|
grant(exec, maxUses, ttlMs) {
|
|
@@ -345,6 +788,119 @@ function readFileSafe(p) {
|
|
|
345
788
|
return '';
|
|
346
789
|
}
|
|
347
790
|
}
|
|
791
|
+
/** Path-ish tokens and bare filenames lifted out of a command string. */
|
|
792
|
+
const COMMAND_PATH_RE = /(?:~\/|\/|\.\/)?[\w@.-]+[\\/][\w@.\\/-]+/g;
|
|
793
|
+
const COMMAND_FILE_RE = /[\w@.-]+\.(?:md|js|json|ya?ml|env|txt|py|ts|tsx|css|html|log|mjs|cjs|sh|ps1|toml|ini|cfg|conf)/gi;
|
|
794
|
+
/** A bare filename (no separator) still names a real target. */
|
|
795
|
+
const BARE_FILE_RE = /^[\w@.-]+\.(?:md|js|json|ya?ml|env|txt|py|ts|tsx|css|html|log|mjs|cjs|sh|ps1|toml|ini|cfg|conf)$/i;
|
|
796
|
+
/**
|
|
797
|
+
* Paths one decision concerns, for the review page's file chips and snapshots.
|
|
798
|
+
* Explicit argument candidates win (edit/write/read style tools); otherwise the
|
|
799
|
+
* paths are lifted from a write-shaped shell command only — a read command
|
|
800
|
+
* (cat/tail/grep) changes nothing, so its paths would be false positives.
|
|
801
|
+
*/
|
|
802
|
+
function eventFiles(exec) {
|
|
803
|
+
const out = [];
|
|
804
|
+
const seen = new Set();
|
|
805
|
+
const add = (value) => {
|
|
806
|
+
if (typeof value !== 'string')
|
|
807
|
+
return;
|
|
808
|
+
const seg = value.trim();
|
|
809
|
+
if (seg.length < 3 || seg.length > 1024)
|
|
810
|
+
return;
|
|
811
|
+
if (/^(https?:|data:|blob:)/i.test(seg))
|
|
812
|
+
return;
|
|
813
|
+
if (!seg.includes('/') && !seg.includes('\\') && !BARE_FILE_RE.test(seg))
|
|
814
|
+
return;
|
|
815
|
+
if (seen.has(seg))
|
|
816
|
+
return;
|
|
817
|
+
seen.add(seg);
|
|
818
|
+
out.push(seg);
|
|
819
|
+
};
|
|
820
|
+
for (const candidate of extractPathCandidates(exec.arguments ?? {}))
|
|
821
|
+
add(candidate);
|
|
822
|
+
if (out.length === 0) {
|
|
823
|
+
const raw = typeof exec.arguments?.command === 'string'
|
|
824
|
+
? exec.arguments.command
|
|
825
|
+
: (exec.commandText ?? '');
|
|
826
|
+
if (raw !== '') {
|
|
827
|
+
// Strip stderr suppression (2>/dev/null, 2>&1) — a read command's idiom,
|
|
828
|
+
// never a write target.
|
|
829
|
+
const clean = raw.replace(/2>>?\/dev\/null/g, ' ').replace(/2>&1/g, ' ');
|
|
830
|
+
const hasWrite = /(^|[;&|]\s*)(touch|cp|mv|rm|tee|mkdir|rmdir|install|dd|truncate|shred|chmod|chown|chgrp)\b/i.test(clean)
|
|
831
|
+
|| /(^|[;&|]\s*)(sed|perl|python|node|ruby)\b[^;|]*\s-i\b/i.test(clean)
|
|
832
|
+
|| /(^|[;&|]\s*)(curl|wget)\b[^;|]*\s(-o|--output|-O)\b/i.test(clean)
|
|
833
|
+
|| /(^|[;&|]\s*)(npm|pnpm|yarn|pip|pip3|gem|go|brew)\b[^;|]*\s(install|add|update|remove|uninstall)\b/i.test(clean)
|
|
834
|
+
|| />>?|&>/.test(clean.replace(/[^<>=]/g, '').replace(/<<+/g, ''));
|
|
835
|
+
if (hasWrite) {
|
|
836
|
+
for (const m of clean.matchAll(COMMAND_PATH_RE))
|
|
837
|
+
add(m[0]);
|
|
838
|
+
for (const m of clean.matchAll(COMMAND_FILE_RE))
|
|
839
|
+
add(m[0]);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
return out.slice(0, 8);
|
|
844
|
+
}
|
|
845
|
+
/** Keyword → matcher cache (the blacklist is editable at runtime). */
|
|
846
|
+
const keywordMatchers = new Map();
|
|
847
|
+
/**
|
|
848
|
+
* Build the matcher for one blacklist keyword. An ASCII-edged keyword is matched
|
|
849
|
+
* against identifier characters — letters, digits, `_` and `-` — so it can no
|
|
850
|
+
* longer veto a longer identifier that merely contains it: `format` no longer
|
|
851
|
+
* matches the PowerShell cmdlet `Format-Table`, while `mkfs` still matches
|
|
852
|
+
* `mkfs.ext4` (`.` ends the identifier). A keyword ending in punctuation keeps
|
|
853
|
+
* its literal edge (a device target keyword still matches its argument), and CJK
|
|
854
|
+
* keywords (no `\w` edges) stay plain substrings.
|
|
855
|
+
*/
|
|
856
|
+
function keywordMatcher(keyword) {
|
|
857
|
+
const cached = keywordMatchers.get(keyword);
|
|
858
|
+
if (cached !== undefined)
|
|
859
|
+
return cached;
|
|
860
|
+
const needle = keyword.trim().replace(/\s+/g, ' ');
|
|
861
|
+
const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
862
|
+
const lead = /^\w/.test(needle) ? '(?<![A-Za-z0-9_-])' : '';
|
|
863
|
+
const trail = /\w$/.test(needle) ? '(?![A-Za-z0-9_-])' : '';
|
|
864
|
+
const re = new RegExp(`${lead}${escaped}${trail}`, 'i');
|
|
865
|
+
keywordMatchers.set(keyword, re);
|
|
866
|
+
return re;
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* The text a blacklist keyword may match: the shell command plus the call's
|
|
870
|
+
* scalar arguments, excluding document-body fields. Whitespace is collapsed so
|
|
871
|
+
* a command written with extra spaces still reads as the command it is.
|
|
872
|
+
*/
|
|
873
|
+
function denyScanText(exec) {
|
|
874
|
+
const parts = [];
|
|
875
|
+
const walk = (node, depth) => {
|
|
876
|
+
if (depth > 6)
|
|
877
|
+
return;
|
|
878
|
+
if (Array.isArray(node)) {
|
|
879
|
+
for (const item of node) {
|
|
880
|
+
if (typeof item === 'string')
|
|
881
|
+
parts.push(item);
|
|
882
|
+
else
|
|
883
|
+
walk(item, depth + 1);
|
|
884
|
+
}
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
if (typeof node !== 'object' || node === null)
|
|
888
|
+
return;
|
|
889
|
+
for (const [key, value] of Object.entries(node)) {
|
|
890
|
+
if (typeof value === 'string') {
|
|
891
|
+
if (!CONTENT_ARG_KEYS.has(key))
|
|
892
|
+
parts.push(value);
|
|
893
|
+
}
|
|
894
|
+
else {
|
|
895
|
+
walk(value, depth + 1);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
};
|
|
899
|
+
walk(exec.arguments ?? {}, 0);
|
|
900
|
+
if (typeof exec.commandText === 'string' && exec.commandText !== '')
|
|
901
|
+
parts.unshift(exec.commandText);
|
|
902
|
+
return parts.join(' ').replace(/\s+/g, ' ');
|
|
903
|
+
}
|
|
348
904
|
function randomId() {
|
|
349
905
|
return Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
|
|
350
906
|
}
|