dsh-perm-gate 2.1.0 → 3.0.0
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 +82 -0
- package/CHANGELOG.ko.md +80 -0
- package/CHANGELOG.md +321 -0
- package/INSTALL.ja.md +70 -8
- package/INSTALL.ko.md +68 -9
- package/INSTALL.md +68 -8
- package/INSTALL.zh.md +59 -7
- package/README.ja.md +103 -16
- package/README.ko.md +101 -17
- package/README.md +152 -25
- package/README.zh.md +99 -12
- package/cordis.patch.yml +34 -3
- package/docs/baseline-guard.md +56 -0
- package/docs/plan-rules-to-settings.md +217 -0
- package/docs/rules-format.en.md +294 -0
- package/docs/rules-format.md +279 -0
- package/docs/ui-gap-analysis-and-branch-permissions.md +338 -0
- package/dsh.plugin.json +22 -0
- package/examples/permissions.example.yaml +16 -1
- package/lib/agent-identity.d.ts +43 -0
- package/lib/agent-identity.js +95 -0
- package/lib/allowlist.d.ts +27 -0
- package/lib/allowlist.js +103 -1
- package/lib/cli.js +11 -14
- package/lib/client.js +598 -6
- package/lib/client.js.map +1 -1
- package/lib/command-dispatcher.d.ts +22 -0
- package/lib/command-dispatcher.js +97 -0
- package/lib/command-semantics.d.ts +62 -0
- package/lib/command-semantics.js +6 -0
- package/lib/compiler.d.ts +50 -0
- package/lib/compiler.js +122 -0
- package/lib/config.d.ts +161 -7
- package/lib/config.js +78 -11
- package/lib/dry-run.d.ts +87 -0
- package/lib/dry-run.js +110 -0
- package/lib/engine.js +61 -4
- package/lib/evaluate.d.ts +9 -0
- package/lib/evaluate.js +264 -1
- package/lib/events.d.ts +69 -2
- package/lib/events.js +123 -0
- package/lib/index.d.ts +1 -1
- package/lib/index.js +320 -11
- package/lib/network-lifecycle.d.ts +107 -0
- package/lib/network-lifecycle.js +180 -0
- package/lib/network.d.ts +109 -0
- package/lib/network.js +196 -0
- package/lib/parsers/git.d.ts +43 -0
- package/lib/parsers/git.js +404 -0
- package/lib/parsers/shell-cmds.d.ts +32 -0
- package/lib/parsers/shell-cmds.js +293 -0
- package/lib/preset.d.ts +13 -0
- package/lib/preset.js +17 -0
- package/lib/proxy.d.ts +160 -0
- package/lib/proxy.js +529 -0
- package/lib/risk.d.ts +17 -3
- package/lib/risk.js +19 -6
- package/lib/rule-chain.d.ts +39 -0
- package/lib/rule-chain.js +213 -0
- package/lib/rule-dims.d.ts +174 -0
- package/lib/rule-dims.js +230 -0
- package/lib/rule.d.ts +60 -0
- package/lib/rule.js +69 -7
- package/lib/rules-view.d.ts +52 -0
- package/lib/rules-view.js +126 -0
- package/lib/runtime.d.ts +208 -7
- package/lib/runtime.js +433 -27
- package/lib/session-sweep.d.ts +45 -0
- package/lib/session-sweep.js +153 -0
- package/lib/shadow.d.ts +34 -0
- package/lib/shadow.js +143 -0
- package/lib/shell.d.ts +14 -0
- package/lib/shell.js +17 -0
- package/lib/watch.d.ts +42 -0
- package/lib/watch.js +170 -0
- package/package.json +15 -2
- package/scripts/patch-permission-glyph.mjs +209 -0
package/lib/runtime.js
CHANGED
|
@@ -11,21 +11,44 @@
|
|
|
11
11
|
import { readFileSync } from 'node:fs';
|
|
12
12
|
import { makeEntry, MemoryAuditMirror } from './audit.js';
|
|
13
13
|
import { resolvePermissiveStrategies } from './config.js';
|
|
14
|
-
import { appendAllowCommand, listAllowCommands, replaceAllowCommands } from './allowlist.js';
|
|
14
|
+
import { appendAllowCommand, listAllowCommands, listAllowFromRulesDoc, replaceAllowCommands } from './allowlist.js';
|
|
15
15
|
import { decide, CONTENT_ARG_KEYS } from './engine.js';
|
|
16
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 { decomposeShellCommand } from './shell.js';
|
|
22
21
|
import { classifyRisk, classifyRiskWith } from './risk.js';
|
|
23
22
|
import { completeViaHost, DEFAULT_HOST_MODEL } from './host-llm.js';
|
|
24
23
|
import { chatCompletion } from './classifier.js';
|
|
25
|
-
import { compileDocument, documentHash, extractPathCandidates, parsePermissionsDocument } from './rule.js';
|
|
24
|
+
import { compileDocument, compileRulesObject, documentHash, extractPathCandidates, parsePermissionsDocument, } from './rule.js';
|
|
25
|
+
import { decomposeShellCommand } from './shell.js';
|
|
26
|
+
import { extractAgentCandidates } from './agent-identity.js';
|
|
27
|
+
import { resolveRuleChain } from './rule-chain.js';
|
|
26
28
|
import { DEFAULT_DENY_KEYWORDS } from './deny-defaults.js';
|
|
27
29
|
import { permissionPresetOf, presetInScope } from './preset.js';
|
|
28
30
|
import { resolveGatePresets } from './config.js';
|
|
31
|
+
/** Locate a rule by its chain-wide index (deny entries first, then allow, then ask). */
|
|
32
|
+
function findRuleByIndex(ruleset, index) {
|
|
33
|
+
return (ruleset.deny.find((entry) => entry.index === index) ??
|
|
34
|
+
ruleset.allow.find((entry) => entry.index === index) ??
|
|
35
|
+
ruleset.ask.find((entry) => entry.index === index));
|
|
36
|
+
}
|
|
37
|
+
/** Which layer of the chain produced a decision, from its stage. */
|
|
38
|
+
function sourceOfStage(stage, reason) {
|
|
39
|
+
switch (stage) {
|
|
40
|
+
case 'hard-deny':
|
|
41
|
+
return 'hard-deny';
|
|
42
|
+
case 'grant':
|
|
43
|
+
return 'grant';
|
|
44
|
+
case 'rule':
|
|
45
|
+
return 'rule';
|
|
46
|
+
case 'classifier':
|
|
47
|
+
return 'classifier';
|
|
48
|
+
default:
|
|
49
|
+
return reason.startsWith('permissive') ? 'permissive' : 'default';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
29
52
|
/**
|
|
30
53
|
* One execution's session event log, or `undefined` when the host exposes none.
|
|
31
54
|
*
|
|
@@ -164,6 +187,18 @@ function isCleanupSafe(exec) {
|
|
|
164
187
|
}
|
|
165
188
|
return undefined;
|
|
166
189
|
}
|
|
190
|
+
/** A shell whose result never arrived is dropped after this long (ms). */
|
|
191
|
+
const IN_FLIGHT_SHELL_TTL_MS = 30 * 60_000;
|
|
192
|
+
/** Tool names that execute a command string (mirrors evaluate's shell roster). */
|
|
193
|
+
const SHELL_TOOL_NAMES = new Set(['shell', 'terminal', 'bash', 'pwsh', 'sh', 'cmd', 'powershell']);
|
|
194
|
+
/** Whether a tool name runs a shell command. */
|
|
195
|
+
function isShellToolName(name) {
|
|
196
|
+
return SHELL_TOOL_NAMES.has(name);
|
|
197
|
+
}
|
|
198
|
+
/** The session-grant key for one network target (scheme/host/port). */
|
|
199
|
+
function networkGrantKey(sessionId, target) {
|
|
200
|
+
return `${sessionId}|${target.scheme ?? ''}|${target.host}|${target.port ?? ''}`;
|
|
201
|
+
}
|
|
167
202
|
export class PermGateRuntime {
|
|
168
203
|
options;
|
|
169
204
|
ruleset;
|
|
@@ -177,6 +212,15 @@ export class PermGateRuntime {
|
|
|
177
212
|
gatePresets;
|
|
178
213
|
/** Extra tool names the user classified as safe (config `autoAllowTools`). */
|
|
179
214
|
autoAllowExtra;
|
|
215
|
+
// ─── Network seam (T2.11) ───────────────────────────────────────────
|
|
216
|
+
/** In-flight shell executions, for proxy-layer network attribution. */
|
|
217
|
+
inFlightShells = new Map();
|
|
218
|
+
/** Stable key per execution object (the host call id is not always present). */
|
|
219
|
+
shellKeys = new WeakMap();
|
|
220
|
+
shellSeq = 0;
|
|
221
|
+
/** Session-scoped network grants: grantKey -> expiry epoch ms. */
|
|
222
|
+
networkGrants = new Map();
|
|
223
|
+
networkGrantTtlMs;
|
|
180
224
|
/**
|
|
181
225
|
* Cache of the permission-preset fold. The key is the log's length plus the
|
|
182
226
|
* identity of its LAST event: a session's log only appends, so "same length
|
|
@@ -186,6 +230,12 @@ export class PermGateRuntime {
|
|
|
186
230
|
* frozen events, which would otherwise re-fold on every tool call.
|
|
187
231
|
*/
|
|
188
232
|
presetCache;
|
|
233
|
+
/**
|
|
234
|
+
* Last stand-down announced per session (sessionId → the out-of-scope preset
|
|
235
|
+
* name, `''` when the session records none). Bounded by the session count and
|
|
236
|
+
* only read on the stand-down path — the in-scope path never touches it.
|
|
237
|
+
*/
|
|
238
|
+
standDownAnnounced = new Map();
|
|
189
239
|
learning;
|
|
190
240
|
events;
|
|
191
241
|
/** Learning candidates awaiting human approval + execution (call fingerprint → candidate). */
|
|
@@ -213,6 +263,7 @@ export class PermGateRuntime {
|
|
|
213
263
|
this.gatePresets = options.gatePresets === undefined ? ['*'] : resolveGatePresets(options.gatePresets);
|
|
214
264
|
this.autoAllowExtra = new Set((options.autoAllowTools ?? []).filter((name) => typeof name === 'string' && name !== ''));
|
|
215
265
|
this.strategies = resolvePermissiveStrategies(options.permissiveStrategies);
|
|
266
|
+
this.networkGrantTtlMs = options.networkGrantTtlMs ?? 30 * 60_000;
|
|
216
267
|
this.learning = new RiskLearning(options.learningFile, {
|
|
217
268
|
threshold: () => this.liveRiskLearning().threshold,
|
|
218
269
|
now: options.now,
|
|
@@ -223,12 +274,51 @@ export class PermGateRuntime {
|
|
|
223
274
|
this.ruleset = this.compileInline(options);
|
|
224
275
|
}
|
|
225
276
|
compileInline(options) {
|
|
226
|
-
const text = options.rulesFile !== undefined ? readFileSafe(options.rulesFile) : '';
|
|
227
277
|
const empty = {
|
|
228
278
|
defaultAction: options.defaultAction ?? 'ask',
|
|
229
279
|
deny: [], allow: [], ask: [],
|
|
230
280
|
caseInsensitivePaths: options.caseInsensitivePaths ?? true,
|
|
231
281
|
};
|
|
282
|
+
// Settings-first (dual-source): a configured `dsh-perm-gate-rules`
|
|
283
|
+
// namespace overrides every file path — the gate then loads with zero
|
|
284
|
+
// filesystem dependency. Bare defaults (nothing configured) fall through
|
|
285
|
+
// to the file sources below. Compile errors propagate: the constructor
|
|
286
|
+
// fails loud, and `reload()` keeps the previous rules on a hot change.
|
|
287
|
+
const settingsDoc = options.readRulesDocument?.();
|
|
288
|
+
if (settingsDoc !== undefined) {
|
|
289
|
+
const hash = documentHash(JSON.stringify(settingsDoc));
|
|
290
|
+
const hit = this.cache.get(hash);
|
|
291
|
+
if (hit !== undefined)
|
|
292
|
+
return hit;
|
|
293
|
+
const compiled = compileRulesObject(settingsDoc, {
|
|
294
|
+
maxGlobStars: 2,
|
|
295
|
+
caseInsensitivePaths: options.caseInsensitivePaths ?? true,
|
|
296
|
+
});
|
|
297
|
+
this.cache.set(hash, compiled);
|
|
298
|
+
return compiled;
|
|
299
|
+
}
|
|
300
|
+
// Chain mode: when searchUp is enabled, use the multi-file chain resolver.
|
|
301
|
+
if (options.searchUp === true) {
|
|
302
|
+
const cwd = options.cwd ?? process.cwd();
|
|
303
|
+
const rulesFile = options.rulesFile !== undefined ? options.rulesFile : 'rules.yml';
|
|
304
|
+
try {
|
|
305
|
+
return resolveRuleChain(cwd, {
|
|
306
|
+
rulesFile,
|
|
307
|
+
searchUp: true,
|
|
308
|
+
fallbackPath: options.fallbackPath,
|
|
309
|
+
badFilePolicy: options.badFilePolicy ?? 'fail',
|
|
310
|
+
maxChainLength: options.maxChainLength ?? 10,
|
|
311
|
+
}, {
|
|
312
|
+
maxGlobStars: 2,
|
|
313
|
+
caseInsensitivePaths: options.caseInsensitivePaths ?? true,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
return empty;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
// Single-file mode (legacy): read and compile one rules file.
|
|
321
|
+
const text = options.rulesFile !== undefined ? readFileSafe(options.rulesFile) : '';
|
|
232
322
|
if (text === '')
|
|
233
323
|
return empty;
|
|
234
324
|
const hash = documentHash(text);
|
|
@@ -307,8 +397,190 @@ export class PermGateRuntime {
|
|
|
307
397
|
home: cwdOf(exec),
|
|
308
398
|
caseInsensitive: this.options.caseInsensitivePaths ?? true,
|
|
309
399
|
dshHome: this.options.dshHome,
|
|
400
|
+
agentCandidates: extractAgentCandidates(exec),
|
|
310
401
|
};
|
|
311
402
|
}
|
|
403
|
+
// ─── Network seam (T2.11) ───────────────────────────────────────────
|
|
404
|
+
/**
|
|
405
|
+
* Register a shell execution as in-flight so a proxy-layer block made by its
|
|
406
|
+
* child process can be attributed back to the session — and therefore ride
|
|
407
|
+
* the interactive approval seam. Called when the gate lets a shell call run;
|
|
408
|
+
* {@link endShellExecution} removes it.
|
|
409
|
+
*/
|
|
410
|
+
beginShellExecution(exec) {
|
|
411
|
+
if (!isShellToolName(exec.name))
|
|
412
|
+
return;
|
|
413
|
+
const key = this.shellKeyOf(exec);
|
|
414
|
+
this.inFlightShells.set(key, {
|
|
415
|
+
key,
|
|
416
|
+
tool: exec.name,
|
|
417
|
+
callId: exec.callId,
|
|
418
|
+
agent: exec.agent,
|
|
419
|
+
sessionId: sessionIdOf(exec),
|
|
420
|
+
signal: exec.signal,
|
|
421
|
+
at: Date.now(),
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
/** Remove a shell execution from the in-flight table (its tool call settled). */
|
|
425
|
+
endShellExecution(exec) {
|
|
426
|
+
if (!isShellToolName(exec.name))
|
|
427
|
+
return;
|
|
428
|
+
this.inFlightShells.delete(this.shellKeyOf(exec));
|
|
429
|
+
}
|
|
430
|
+
/** Drop every in-flight entry (dispose / session teardown). */
|
|
431
|
+
clearShellExecutions() {
|
|
432
|
+
this.inFlightShells.clear();
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* The newest live in-flight shell execution, for proxy-layer attribution.
|
|
436
|
+
* Stale entries (a shell that never reported a result) are expired here so a
|
|
437
|
+
* long-dead call can never authorize a later connection.
|
|
438
|
+
*/
|
|
439
|
+
currentAttribution() {
|
|
440
|
+
const now = Date.now();
|
|
441
|
+
let newest;
|
|
442
|
+
for (const entry of this.inFlightShells.values()) {
|
|
443
|
+
if (now - entry.at > IN_FLIGHT_SHELL_TTL_MS) {
|
|
444
|
+
this.inFlightShells.delete(entry.key);
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
// An aborted call was cancelled or rejected: it will never run, so it
|
|
448
|
+
// must not authorize a connection made by some later command.
|
|
449
|
+
if (entry.signal?.aborted === true) {
|
|
450
|
+
this.inFlightShells.delete(entry.key);
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
if (newest === undefined || entry.at >= newest.at)
|
|
454
|
+
newest = entry;
|
|
455
|
+
}
|
|
456
|
+
if (newest === undefined)
|
|
457
|
+
return undefined;
|
|
458
|
+
return {
|
|
459
|
+
tool: newest.tool,
|
|
460
|
+
...(newest.callId !== undefined ? { callId: newest.callId } : {}),
|
|
461
|
+
...(newest.agent !== undefined ? { agent: newest.agent } : {}),
|
|
462
|
+
sessionId: newest.sessionId,
|
|
463
|
+
...(newest.signal !== undefined ? { signal: newest.signal } : {}),
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
/** A stable per-execution key: the host call id, else an object-keyed fallback. */
|
|
467
|
+
shellKeyOf(exec) {
|
|
468
|
+
const cached = this.shellKeys.get(exec);
|
|
469
|
+
if (cached !== undefined)
|
|
470
|
+
return cached;
|
|
471
|
+
const key = typeof exec.callId === 'string' && exec.callId !== ''
|
|
472
|
+
? `call:${exec.callId}`
|
|
473
|
+
: `exec:${sessionIdOf(exec)}:${exec.name}:${++this.shellSeq}`;
|
|
474
|
+
this.shellKeys.set(exec, key);
|
|
475
|
+
return key;
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Ask the human to allow one network target, on behalf of the attributed
|
|
479
|
+
* shell execution. Returns `'allow'` only for an explicit `allowed-once`;
|
|
480
|
+
* every other outcome (rejection, cancellation, timeout, no answerer)
|
|
481
|
+
* fails closed to `'deny'`.
|
|
482
|
+
*
|
|
483
|
+
* The approval seam requires an open turn — the durable audit pair must be
|
|
484
|
+
* enclosed by the log's commit boundary — which holds here because the ask
|
|
485
|
+
* happens *while* the shell tool call is executing.
|
|
486
|
+
*/
|
|
487
|
+
async askNetwork(target, reason, timeoutMs) {
|
|
488
|
+
const attribution = this.currentAttribution();
|
|
489
|
+
if (attribution === undefined || attribution.agent === undefined) {
|
|
490
|
+
// Unattributable traffic cannot ride the approval seam — fail closed.
|
|
491
|
+
this.auditNetworkAsk(target, 'deny', 'no in-flight shell to attribute the connection to', undefined);
|
|
492
|
+
return 'deny';
|
|
493
|
+
}
|
|
494
|
+
// A session already allowed this exact target: honour it without asking.
|
|
495
|
+
const grantKey = networkGrantKey(attribution.sessionId, target);
|
|
496
|
+
if (this.networkGrants.has(grantKey)) {
|
|
497
|
+
return 'allow';
|
|
498
|
+
}
|
|
499
|
+
const requester = this.options.requestApproval;
|
|
500
|
+
if (requester === undefined) {
|
|
501
|
+
this.auditNetworkAsk(target, 'deny', 'no approval service available', attribution);
|
|
502
|
+
return 'deny';
|
|
503
|
+
}
|
|
504
|
+
const controller = new AbortController();
|
|
505
|
+
let timer;
|
|
506
|
+
// The timeout is raced, not merely signalled. `abort()` only *asks* the
|
|
507
|
+
// callee to withdraw the question; a callee that ignores the signal would
|
|
508
|
+
// otherwise leave this await pending forever and hang the CONNECT. The
|
|
509
|
+
// race guarantees the wait settles no matter what the seam does.
|
|
510
|
+
const timedOut = new Promise((resolve) => {
|
|
511
|
+
timer = setTimeout(() => {
|
|
512
|
+
controller.abort();
|
|
513
|
+
resolve('unavailable');
|
|
514
|
+
}, Math.max(1, timeoutMs));
|
|
515
|
+
});
|
|
516
|
+
let outcome = 'unavailable';
|
|
517
|
+
try {
|
|
518
|
+
outcome = await Promise.race([
|
|
519
|
+
requester({
|
|
520
|
+
agent: attribution.agent,
|
|
521
|
+
toolName: attribution.tool,
|
|
522
|
+
...(attribution.callId !== undefined ? { callId: attribution.callId } : {}),
|
|
523
|
+
reason,
|
|
524
|
+
signal: controller.signal,
|
|
525
|
+
}),
|
|
526
|
+
timedOut,
|
|
527
|
+
]);
|
|
528
|
+
}
|
|
529
|
+
catch {
|
|
530
|
+
// A seam failure (e.g. no open turn) is never an allow.
|
|
531
|
+
outcome = 'unavailable';
|
|
532
|
+
}
|
|
533
|
+
finally {
|
|
534
|
+
if (timer !== undefined)
|
|
535
|
+
clearTimeout(timer);
|
|
536
|
+
}
|
|
537
|
+
if (outcome === 'allowed-once') {
|
|
538
|
+
// Remember it for this session so one command making many connections
|
|
539
|
+
// does not prompt once per connection.
|
|
540
|
+
this.networkGrants.set(grantKey, Date.now() + this.networkGrantTtlMs);
|
|
541
|
+
this.auditNetworkAsk(target, 'allow', 'approved by the human (session grant)', attribution);
|
|
542
|
+
return 'allow';
|
|
543
|
+
}
|
|
544
|
+
this.auditNetworkAsk(target, 'deny', `approval seam returned ${outcome}`, attribution);
|
|
545
|
+
return 'deny';
|
|
546
|
+
}
|
|
547
|
+
/** Record one network escalation outcome in the audit mirror + event feed. */
|
|
548
|
+
auditNetworkAsk(target, verdict, reason, attribution) {
|
|
549
|
+
const now = Date.now();
|
|
550
|
+
const tool = attribution?.tool ?? 'subprocess';
|
|
551
|
+
const detail = `network ${verdict === 'allow' ? 'allowed' : 'denied'} ${target.scheme ?? '?'}://${target.host}${target.port !== undefined ? `:${target.port}` : ''} — ${reason}`;
|
|
552
|
+
try {
|
|
553
|
+
this.audit.append(makeEntry({
|
|
554
|
+
callId: randomId(),
|
|
555
|
+
tool,
|
|
556
|
+
outcome: verdict === 'allow' ? 'allow' : 'deny',
|
|
557
|
+
source: 'rule',
|
|
558
|
+
reason: detail,
|
|
559
|
+
at: now,
|
|
560
|
+
}));
|
|
561
|
+
}
|
|
562
|
+
catch {
|
|
563
|
+
// Auditing must never influence the verdict.
|
|
564
|
+
}
|
|
565
|
+
if (this.events !== undefined && attribution?.sessionId !== undefined) {
|
|
566
|
+
try {
|
|
567
|
+
this.events.append({
|
|
568
|
+
sessionId: attribution.sessionId,
|
|
569
|
+
tool,
|
|
570
|
+
kind: verdict === 'allow' ? 'auto' : 'deny',
|
|
571
|
+
reason: detail,
|
|
572
|
+
verdict: 'network-ask',
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
catch {
|
|
576
|
+
// Same: the feed is best-effort.
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
/** The compiled ruleset (read-only view for network module). */
|
|
581
|
+
get compiledRuleset() {
|
|
582
|
+
return this.ruleset;
|
|
583
|
+
}
|
|
312
584
|
liveRiskLearning() {
|
|
313
585
|
const read = this.options.readRiskLearning;
|
|
314
586
|
if (read !== undefined)
|
|
@@ -362,9 +634,38 @@ export class PermGateRuntime {
|
|
|
362
634
|
* `danger-full-access` means "full access without approval prompts", where a
|
|
363
635
|
* forwarded ask can only fail (the approval seam rejects before any answerer
|
|
364
636
|
* runs) and a hard-deny would silently overrule the tier the user chose.
|
|
637
|
+
*
|
|
638
|
+
* P0 is therefore monotonic only WITHIN the gate's scope, not across every
|
|
639
|
+
* preset. {@link announceStandDown} makes that visible instead of silent.
|
|
365
640
|
*/
|
|
366
|
-
gateActive(
|
|
367
|
-
return presetInScope(
|
|
641
|
+
gateActive(preset) {
|
|
642
|
+
return presetInScope(preset, this.gatePresets);
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Record one `stand-down` event the first time a session is observed with a
|
|
646
|
+
* preset outside {@link gatePresets}, and again whenever that preset changes.
|
|
647
|
+
*
|
|
648
|
+
* Per-call silence is what makes a stand-down dangerous: the tool call looks
|
|
649
|
+
* exactly like a call the gate inspected and allowed. One event per
|
|
650
|
+
* transition is enough to say otherwise, and keeps the feed (and the
|
|
651
|
+
* `events.jsonl` append) proportional to preset changes rather than to call
|
|
652
|
+
* volume.
|
|
653
|
+
*
|
|
654
|
+
* The event is a NOTICE, not a decision: it carries no allow/deny meaning,
|
|
655
|
+
* and the call it was observed on is still settled entirely by the selected
|
|
656
|
+
* tier. `verdict: 'stand-down'` labels it for the history view.
|
|
657
|
+
*/
|
|
658
|
+
announceStandDown(exec, preset) {
|
|
659
|
+
const sessionId = sessionIdOf(exec);
|
|
660
|
+
const announced = preset ?? '';
|
|
661
|
+
if (this.standDownAnnounced.get(sessionId) === announced)
|
|
662
|
+
return;
|
|
663
|
+
this.standDownAnnounced.set(sessionId, announced);
|
|
664
|
+
const scope = this.gatePresets.includes('*') ? 'every preset' : this.gatePresets.join(', ');
|
|
665
|
+
const named = preset === undefined ? 'no permission/preset recorded' : `"${preset}"`;
|
|
666
|
+
this.recordEvent(exec, 'stand-down', `perm-gate is INACTIVE: the session permission preset is ${named} and the gate scope is ${scope}. `
|
|
667
|
+
+ 'No P0 hard-deny, no rule, no ask and no allow is evaluated for any call in this session — '
|
|
668
|
+
+ 'the selected tier\'s own policy governs. Select a preset in the gate scope to re-arm it.', { verdict: 'stand-down' });
|
|
368
669
|
}
|
|
369
670
|
/**
|
|
370
671
|
* Whether an `ask` produced while the gate is active can actually reach a
|
|
@@ -581,10 +882,16 @@ export class PermGateRuntime {
|
|
|
581
882
|
*/
|
|
582
883
|
decideExecution(exec) {
|
|
583
884
|
// The gate is scoped to its own tier(s): anywhere else it stands down
|
|
584
|
-
// entirely and
|
|
585
|
-
|
|
885
|
+
// entirely and decides nothing (the session's selected tier owns the call).
|
|
886
|
+
// The stand-down is announced ONCE per (session, preset) transition — the
|
|
887
|
+
// call's outcome is unchanged, but the user must be able to see that the
|
|
888
|
+
// gate — including P0 hard-deny — is inactive; a silent stand-down reads as
|
|
889
|
+
// "the gate looked at this and allowed it".
|
|
890
|
+
const preset = this.presetOf(exec);
|
|
891
|
+
if (!this.gateActive(preset)) {
|
|
892
|
+
this.announceStandDown(exec, preset);
|
|
586
893
|
return undefined;
|
|
587
|
-
|
|
894
|
+
}
|
|
588
895
|
const callId = randomId();
|
|
589
896
|
// Preset deny-keyword layer (deny wins over allow): a dangerous-keyword hit
|
|
590
897
|
// vetoes before grants / rules / LLM. Additive deny only — P0 semantics are
|
|
@@ -596,13 +903,9 @@ export class PermGateRuntime {
|
|
|
596
903
|
this.recordEvent(exec, 'deny', reason, { verdict: 'deny-keyword' });
|
|
597
904
|
return { kind: 'deny', reason };
|
|
598
905
|
}
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
return this.grants.decide(tool, args);
|
|
603
|
-
};
|
|
604
|
-
const raw = decide(ctx, { decide: (c) => decideRules(this.ruleset, c) }, grantResolver, this.autoAllowExtra);
|
|
605
|
-
const decision = this.applyPermissive(raw);
|
|
906
|
+
// The same chain `explainCall` reports, evaluated once here so the two views
|
|
907
|
+
// cannot drift; this caller adds the side effects and the session layers.
|
|
908
|
+
const decision = this.applyPermissive(this.rawPolicy(exec));
|
|
606
909
|
// Deterministic cleanup pre-screen: if a deletion command targets a
|
|
607
910
|
// regenerable/temporary artifact inside the workspace, allow it directly.
|
|
608
911
|
// This runs BEFORE the ask/passthrough logic so it works even under
|
|
@@ -671,6 +974,83 @@ export class PermGateRuntime {
|
|
|
671
974
|
this.clearCall(exec, decision.reason, source, source);
|
|
672
975
|
return undefined;
|
|
673
976
|
}
|
|
977
|
+
/**
|
|
978
|
+
* The **pure policy** outcome for one call: the same chain
|
|
979
|
+
* {@link decideExecution} runs, with every side effect and every session-state
|
|
980
|
+
* layer removed.
|
|
981
|
+
*
|
|
982
|
+
* Side effects removed — no audit entry, no event, no ask tracking, no call
|
|
983
|
+
* clearance, no learning. A rule-test panel must be able to run on every
|
|
984
|
+
* keystroke without writing to the live decision feed.
|
|
985
|
+
*
|
|
986
|
+
* Session-state layers removed — the preset stand-down and the
|
|
987
|
+
* `approval: never` ask degradation both describe a session a session-less
|
|
988
|
+
* caller does not have. Including them is not harmless: the degradation turns
|
|
989
|
+
* every "the rules would ask a human" into a reported **allow**, which is the
|
|
990
|
+
* opposite of the truth for exactly the rules someone opens the panel to
|
|
991
|
+
* review (measured on the live host: `shell ls -la` reported `allow` while the
|
|
992
|
+
* rule layer said `ask`).
|
|
993
|
+
*
|
|
994
|
+
* `reason` is never collapsed: "allow" has causes worth naming (a rule, a
|
|
995
|
+
* grant, cleanup-safety, the permissive default).
|
|
996
|
+
*/
|
|
997
|
+
explainCall(exec) {
|
|
998
|
+
const keyword = this.denyKeywordHit(exec);
|
|
999
|
+
if (keyword !== undefined) {
|
|
1000
|
+
return {
|
|
1001
|
+
action: 'deny',
|
|
1002
|
+
reason: `deny-keyword: matches preset blacklist entry "${keyword}"`,
|
|
1003
|
+
source: 'deny-keyword',
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
const decision = this.applyPermissive(this.rawPolicy(exec));
|
|
1007
|
+
if (decision.action === 'ask') {
|
|
1008
|
+
const cleanupSafe = isCleanupSafe(exec);
|
|
1009
|
+
if (cleanupSafe !== undefined) {
|
|
1010
|
+
return { action: 'allow', reason: `cleanup-safe: ${cleanupSafe}`, source: 'cleanup-safe' };
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
return {
|
|
1014
|
+
action: decision.action,
|
|
1015
|
+
reason: decision.reason,
|
|
1016
|
+
source: sourceOfStage(decision.stage, decision.reason),
|
|
1017
|
+
...(decision.ruleIndex !== undefined ? { ruleIndex: decision.ruleIndex } : {}),
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
/**
|
|
1021
|
+
* P0 hard-deny → P1 session grant → P2 rules → P4 ask, as a value. Shared by
|
|
1022
|
+
* the host-facing decision and {@link explainCall} so the two cannot drift.
|
|
1023
|
+
*/
|
|
1024
|
+
rawPolicy(exec) {
|
|
1025
|
+
const grantResolver = (tool, args) => {
|
|
1026
|
+
if (exec.parentAuthorized === false)
|
|
1027
|
+
return 'no-match';
|
|
1028
|
+
return this.grants.decide(tool, args);
|
|
1029
|
+
};
|
|
1030
|
+
return decide(this.ctxFor(exec), { decide: (c) => decideRules(this.ruleset, c) }, grantResolver, this.autoAllowExtra);
|
|
1031
|
+
}
|
|
1032
|
+
/**
|
|
1033
|
+
* Rule-layer explanation for one would-be call: what the `permissions` chain
|
|
1034
|
+
* decides on its own, before P0 hard-deny, P1 session grants, the P3
|
|
1035
|
+
* classifier and the P4 ask ever get a say.
|
|
1036
|
+
*
|
|
1037
|
+
* This is the read-only half of a dry-run. It appends nothing to the audit
|
|
1038
|
+
* mirror, records no event, mints no grant and touches no learning store, so a
|
|
1039
|
+
* "rule test" panel may call it on every keystroke without leaving a trace in
|
|
1040
|
+
* the decision feed. It answers a narrower question than
|
|
1041
|
+
* {@link decideExecution} and says so: `ruleIndex` identifies a rule in the
|
|
1042
|
+
* chain, which only this layer can attribute.
|
|
1043
|
+
*/
|
|
1044
|
+
explainRules(exec) {
|
|
1045
|
+
const decision = decideRules(this.ruleset, this.ctxFor(exec));
|
|
1046
|
+
return {
|
|
1047
|
+
action: decision.action,
|
|
1048
|
+
reason: decision.reason,
|
|
1049
|
+
ruleIndex: decision.ruleIndex,
|
|
1050
|
+
rule: decision.ruleIndex === undefined ? undefined : findRuleByIndex(this.ruleset, decision.ruleIndex),
|
|
1051
|
+
defaultAction: this.ruleset.defaultAction,
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
674
1054
|
/**
|
|
675
1055
|
* Apply the Permissive tier to a raw decision. Hard-deny and minted-grant
|
|
676
1056
|
* outcomes are never touched (fail-closed). `alwaysConfirm` escalates a
|
|
@@ -777,13 +1157,22 @@ export class PermGateRuntime {
|
|
|
777
1157
|
if (risk.kind === 'risky') {
|
|
778
1158
|
if (risk.category !== 'neutral') {
|
|
779
1159
|
// Hard category (deletion / credential / remote / system / bulk) or an
|
|
780
|
-
// off-protocol output graded hard:
|
|
781
|
-
//
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
1160
|
+
// off-protocol output graded hard: KEEP THE ASK.
|
|
1161
|
+
//
|
|
1162
|
+
// Denying here would make a probabilistic verdict the source of an
|
|
1163
|
+
// unappealable block: the gate would be "sure enough" to refuse on the
|
|
1164
|
+
// model's word alone, with no popup to appeal to and no grant to retry
|
|
1165
|
+
// with — measured live on a benign `git commit -F …` the grader called
|
|
1166
|
+
// `remote`. Deny stays with the deterministic layers (P0 hard-deny, the
|
|
1167
|
+
// deny-keyword blacklist, explicit `deny:` rules); everything the
|
|
1168
|
+
// classifier merely *suspects* is negotiated with the human instead.
|
|
1169
|
+
//
|
|
1170
|
+
// Hard categories stay distinct from `neutral` in one way that matters:
|
|
1171
|
+
// they are NEVER learnable, so repeated confirmations cannot sediment
|
|
1172
|
+
// them into an auto-allow.
|
|
1173
|
+
const hardReason = `${decision.reason} [llm-assist risky:${risk.category} → ask]`;
|
|
1174
|
+
this.trackAsk(exec, hardReason, undefined);
|
|
1175
|
+
return { kind: 'ask', reason: hardReason };
|
|
787
1176
|
}
|
|
788
1177
|
const learning = this.liveRiskLearning();
|
|
789
1178
|
const fp = operationFingerprint(exec.name, exec.arguments ?? {}, exec.commandText);
|
|
@@ -1022,27 +1411,44 @@ export class PermGateRuntime {
|
|
|
1022
1411
|
}
|
|
1023
1412
|
/**
|
|
1024
1413
|
* Grant "allow every occurrence of this command" (the always-confirm panel's
|
|
1025
|
-
* second extended allow button): persist the command into the rules
|
|
1026
|
-
*
|
|
1414
|
+
* second extended allow button): persist the command into the rules' `allow`
|
|
1415
|
+
* whitelist and reload. Prefers the settings-backed writer (the namespace's
|
|
1416
|
+
* watch triggers the reload once the write lands); falls back to the rules
|
|
1417
|
+
* file when no settings scope is wired. Returns the write verdict.
|
|
1027
1418
|
*/
|
|
1028
1419
|
approveAllowEverywhere(commandWord, reason = 'permissive allow-everywhere') {
|
|
1420
|
+
const writer = this.options.allowlistWriter;
|
|
1421
|
+
if (writer !== undefined && writer.append(commandWord, reason))
|
|
1422
|
+
return true;
|
|
1029
1423
|
if (!this.options.rulesFile)
|
|
1030
1424
|
return false;
|
|
1031
1425
|
if (!appendAllowCommand(this.options.rulesFile, commandWord, reason))
|
|
1032
1426
|
return false;
|
|
1033
1427
|
return this.reload();
|
|
1034
1428
|
}
|
|
1035
|
-
/**
|
|
1429
|
+
/**
|
|
1430
|
+
* Read-only view of the current allow-list command patterns (whitelist).
|
|
1431
|
+
* Reads the settings document when the namespace is configured, else the
|
|
1432
|
+
* rules file.
|
|
1433
|
+
*/
|
|
1036
1434
|
allowlist() {
|
|
1435
|
+
const doc = this.options.readRulesDocument?.();
|
|
1436
|
+
if (doc !== undefined)
|
|
1437
|
+
return listAllowFromRulesDoc(doc);
|
|
1037
1438
|
if (!this.options.rulesFile)
|
|
1038
1439
|
return [];
|
|
1039
1440
|
return listAllowCommands(this.options.rulesFile);
|
|
1040
1441
|
}
|
|
1041
1442
|
/**
|
|
1042
1443
|
* Replace the whitelist with exactly the given command patterns and reload.
|
|
1043
|
-
*
|
|
1444
|
+
* Prefers the settings-backed writer; falls back to the rules file when no
|
|
1445
|
+
* settings scope is wired. Returns the write verdict (false when neither
|
|
1446
|
+
* sink is available or the write failed).
|
|
1044
1447
|
*/
|
|
1045
1448
|
setAllowlist(patterns) {
|
|
1449
|
+
const writer = this.options.allowlistWriter;
|
|
1450
|
+
if (writer !== undefined && writer.replace(patterns, 'permissive allowlist'))
|
|
1451
|
+
return true;
|
|
1046
1452
|
if (!this.options.rulesFile)
|
|
1047
1453
|
return false;
|
|
1048
1454
|
if (!replaceAllowCommands(this.options.rulesFile, patterns))
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Session ids grouped by their lifecycle class, all prefix-stripped (see {@link normalizeSessionId}). */
|
|
2
|
+
export interface SessionClassification {
|
|
3
|
+
/** Sessions referenced by some workspace's `sessionIds`. */
|
|
4
|
+
readonly live: ReadonlySet<string>;
|
|
5
|
+
/** Sessions listed in the store's `global.archivedSessionIds`. */
|
|
6
|
+
readonly archived: ReadonlySet<string>;
|
|
7
|
+
}
|
|
8
|
+
/** A session id whose gate data is stale and can be swept. */
|
|
9
|
+
export interface SweepReport {
|
|
10
|
+
/** Decision-event lines removed from events.jsonl. */
|
|
11
|
+
readonly eventsRemoved: number;
|
|
12
|
+
/** Snapshot files deleted from the snapshots directory. */
|
|
13
|
+
readonly snapshotsRemoved: number;
|
|
14
|
+
}
|
|
15
|
+
/** Strip the `session-` prefix so ids from different sources compare equal. */
|
|
16
|
+
export declare function normalizeSessionId(id: unknown): string;
|
|
17
|
+
/**
|
|
18
|
+
* Classify sessions from the raw text of the workspace store. Returns `null`
|
|
19
|
+
* on unparsable or structurally unexpected content (torn write, format bump) —
|
|
20
|
+
* the caller skips the round. Missing `global`/`tables` sections degrade to
|
|
21
|
+
* empty sets rather than failing: a fresh install has neither.
|
|
22
|
+
*/
|
|
23
|
+
export declare function classifySessions(raw: string): SessionClassification | null;
|
|
24
|
+
/**
|
|
25
|
+
* Whether a gate-side session id belongs to an archived or deleted session.
|
|
26
|
+
* An empty id (unattributable legacy row) never matches — the same semantics
|
|
27
|
+
* `snapshotMatchesSession` uses for snapshots, so unattributable data is kept.
|
|
28
|
+
*/
|
|
29
|
+
export declare function isStaleSession(id: string, cls: SessionClassification): boolean;
|
|
30
|
+
/** Options for {@link sweepSessionData}; all paths are plugin-owned except the store. */
|
|
31
|
+
export interface SweepOptions {
|
|
32
|
+
readonly classification: SessionClassification;
|
|
33
|
+
/** The decision-event JSONL feed (plugin data dir). */
|
|
34
|
+
readonly eventsFile: string;
|
|
35
|
+
/** The per-event pre-change snapshot directory (plugin data dir). */
|
|
36
|
+
readonly snapshotsDir: string;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Drop archived/dead sessions' rows from `events.jsonl` (atomic tmp+rename
|
|
40
|
+
* rewrite, only when something is actually removed) and delete their snapshot
|
|
41
|
+
* files. Both halves are independent: a failure in one still lets the other
|
|
42
|
+
* run. I/O errors are swallowed with a warn — best-effort cleanup, never a
|
|
43
|
+
* gating concern.
|
|
44
|
+
*/
|
|
45
|
+
export declare function sweepSessionData(opts: SweepOptions): SweepReport;
|