dsh-perm-gate 5.4.2 → 5.4.4
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 +13 -0
- package/README.md +26 -0
- package/dsh.plugin.json +1 -1
- package/lib/ask-context.d.ts +19 -0
- package/lib/ask-context.js +58 -9
- package/lib/client.js +99 -0
- package/lib/client.js.map +1 -1
- package/lib/config.d.ts +41 -6
- package/lib/config.js +14 -4
- package/lib/events.js +8 -1
- package/lib/host-llm.d.ts +19 -2
- package/lib/host-llm.js +31 -8
- package/lib/index.js +40 -2
- package/lib/risk.js +5 -7
- package/lib/rule.d.ts +11 -0
- package/lib/rule.js +13 -0
- package/lib/runtime.d.ts +45 -1
- package/lib/runtime.js +81 -15
- package/lib/workspace-bound.d.ts +7 -0
- package/lib/workspace-bound.js +48 -0
- package/package.json +1 -1
package/lib/runtime.js
CHANGED
|
@@ -24,11 +24,12 @@ import { ArtifactRegistry } from './path.js';
|
|
|
24
24
|
import { classifyRisk, classifyRiskWith, graderNote, RISK_SYSTEM_PROMPT_VERSION } from './risk.js';
|
|
25
25
|
import { askDoubtKinds, buildAskContext, humanBrief, humanCardLine, humanCommand, toEventFields, toGraderPayload } from './ask-context.js';
|
|
26
26
|
import { readScriptContents, scriptRefs, scriptsFullyCovered } from './script-review.js';
|
|
27
|
+
import { pathsRealInsideWorkspace } from './workspace-bound.js';
|
|
27
28
|
import { redactCommandText } from './redact.js';
|
|
28
29
|
import { executableVariants, normalizeCommandText } from './command-normalize.js';
|
|
29
30
|
import { completeViaHost, DEFAULT_HOST_MODEL } from './host-llm.js';
|
|
30
31
|
import { chatCompletion } from './classifier.js';
|
|
31
|
-
import { compileDocument, compileRulesObject, documentHash, extractPathCandidates, parsePermissionsDocument, } from './rule.js';
|
|
32
|
+
import { coerceRuleAction, compileDocument, compileRulesObject, documentHash, extractPathCandidates, parsePermissionsDocument, } from './rule.js';
|
|
32
33
|
import { decomposeShellCommand } from './shell.js';
|
|
33
34
|
import { extractAgentCandidates } from './agent-identity.js';
|
|
34
35
|
import { resolveRuleChain } from './rule-chain.js';
|
|
@@ -289,10 +290,17 @@ export class PermGateRuntime {
|
|
|
289
290
|
}
|
|
290
291
|
compileInline(options) {
|
|
291
292
|
const empty = {
|
|
292
|
-
defaultAction: options
|
|
293
|
+
defaultAction: this.effectiveDefaultAction(options),
|
|
293
294
|
deny: [], allow: [], ask: [],
|
|
294
295
|
caseInsensitivePaths: options.caseInsensitivePaths ?? true,
|
|
295
296
|
};
|
|
297
|
+
// Every other path yields a ruleset whose default came from a document; a
|
|
298
|
+
// document is validated, but the action is still coerced so one bad source
|
|
299
|
+
// can never hand the runtime an action it has no branch for.
|
|
300
|
+
const settle = (rs) => {
|
|
301
|
+
const action = coerceRuleAction(rs.defaultAction);
|
|
302
|
+
return action === rs.defaultAction ? rs : { ...rs, defaultAction: action };
|
|
303
|
+
};
|
|
296
304
|
// Settings-first (dual-source): a configured `dsh-perm-gate-rules`
|
|
297
305
|
// namespace overrides every file path — the gate then loads with zero
|
|
298
306
|
// filesystem dependency. Bare defaults (nothing configured) fall through
|
|
@@ -303,20 +311,20 @@ export class PermGateRuntime {
|
|
|
303
311
|
const hash = documentHash(JSON.stringify(settingsDoc));
|
|
304
312
|
const hit = this.cache.get(hash);
|
|
305
313
|
if (hit !== undefined)
|
|
306
|
-
return hit;
|
|
314
|
+
return settle(hit);
|
|
307
315
|
const compiled = compileRulesObject(settingsDoc, {
|
|
308
316
|
maxGlobStars: 2,
|
|
309
317
|
caseInsensitivePaths: options.caseInsensitivePaths ?? true,
|
|
310
318
|
});
|
|
311
319
|
this.cache.set(hash, compiled);
|
|
312
|
-
return compiled;
|
|
320
|
+
return settle(compiled);
|
|
313
321
|
}
|
|
314
322
|
// Chain mode: when searchUp is enabled, use the multi-file chain resolver.
|
|
315
323
|
if (options.searchUp === true) {
|
|
316
324
|
const cwd = options.cwd ?? process.cwd();
|
|
317
325
|
const rulesFile = options.rulesFile !== undefined ? options.rulesFile : 'rules.yml';
|
|
318
326
|
try {
|
|
319
|
-
return resolveRuleChain(cwd, {
|
|
327
|
+
return settle(resolveRuleChain(cwd, {
|
|
320
328
|
rulesFile,
|
|
321
329
|
searchUp: true,
|
|
322
330
|
fallbackPath: options.fallbackPath,
|
|
@@ -325,7 +333,7 @@ export class PermGateRuntime {
|
|
|
325
333
|
}, {
|
|
326
334
|
maxGlobStars: 2,
|
|
327
335
|
caseInsensitivePaths: options.caseInsensitivePaths ?? true,
|
|
328
|
-
});
|
|
336
|
+
}));
|
|
329
337
|
}
|
|
330
338
|
catch {
|
|
331
339
|
return empty;
|
|
@@ -338,14 +346,27 @@ export class PermGateRuntime {
|
|
|
338
346
|
const hash = documentHash(text);
|
|
339
347
|
const hit = this.cache.get(hash);
|
|
340
348
|
if (hit !== undefined)
|
|
341
|
-
return hit;
|
|
349
|
+
return settle(hit);
|
|
342
350
|
const doc = parsePermissionsDocument(text);
|
|
343
351
|
const compiled = compileDocument(doc, {
|
|
344
352
|
maxGlobStars: 2,
|
|
345
353
|
caseInsensitivePaths: options.caseInsensitivePaths ?? true,
|
|
346
354
|
});
|
|
347
355
|
this.cache.set(hash, compiled);
|
|
348
|
-
return compiled;
|
|
356
|
+
return settle(compiled);
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* The root default action, read live (the config field is volatile) and
|
|
360
|
+
* validated. An unrecognised value — most often a `.volatile()` ref that
|
|
361
|
+
* reached the options un-unwrapped — fails **closed** to `ask`: previously it
|
|
362
|
+
* became the ruleset default verbatim, both the deny and ask branches missed
|
|
363
|
+
* it, and every unmatched call was allowed without review (issue #6).
|
|
364
|
+
*/
|
|
365
|
+
effectiveDefaultAction(options) {
|
|
366
|
+
const raw = options.readDefaultAction !== undefined
|
|
367
|
+
? options.readDefaultAction()
|
|
368
|
+
: options.defaultAction;
|
|
369
|
+
return coerceRuleAction(raw);
|
|
349
370
|
}
|
|
350
371
|
/** Reload rules from disk (HMR); on failure keeps the previous rules. */
|
|
351
372
|
reload() {
|
|
@@ -659,6 +680,15 @@ export class PermGateRuntime {
|
|
|
659
680
|
const sel = this.options.readHostModel?.();
|
|
660
681
|
return sel !== undefined && sel.provider !== '' && sel.model !== '' ? sel : undefined;
|
|
661
682
|
}
|
|
683
|
+
/**
|
|
684
|
+
* Reasoning effort for host reviews. `undefined` **omits** the field so the host
|
|
685
|
+
* model configuration decides; the old hardcoded `'off'` was rejected by host
|
|
686
|
+
* validation for models whose advertised efforts exclude it (issue #8).
|
|
687
|
+
*/
|
|
688
|
+
hostEffort() {
|
|
689
|
+
const effort = this.options.readClassifyEffort?.();
|
|
690
|
+
return typeof effort === 'string' && effort !== '' ? effort : undefined;
|
|
691
|
+
}
|
|
662
692
|
livePermissive() {
|
|
663
693
|
return this.options.readPermissive !== undefined
|
|
664
694
|
? this.options.readPermissive()
|
|
@@ -668,6 +698,34 @@ export class PermGateRuntime {
|
|
|
668
698
|
isYolo() {
|
|
669
699
|
return this.options.readYolo !== undefined ? this.options.readYolo() : (this.options.yolo ?? false);
|
|
670
700
|
}
|
|
701
|
+
/**
|
|
702
|
+
* Whether the YOLO tier should stop blocking on "target unpinnable" alone.
|
|
703
|
+
* Default false: 5.4.3 behaviour, one switch away.
|
|
704
|
+
*/
|
|
705
|
+
liveYoloWorkspaceExempt() {
|
|
706
|
+
return this.options.readYoloWorkspaceExempt !== undefined
|
|
707
|
+
? this.options.readYoloWorkspaceExempt()
|
|
708
|
+
: (this.options.yoloWorkspaceExempt ?? false);
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* The one narrowing the exemption is allowed to make: the call is ordinary and visible,
|
|
712
|
+
* only its *location* was unpinnable, and everything it names is inside the workspace.
|
|
713
|
+
* Delete, remote write, invisible content, P0/irreversible and unlearnable acts are all
|
|
714
|
+
* refused here — this method can only *skip* the opaque signal, never clear a danger.
|
|
715
|
+
*/
|
|
716
|
+
yoloExemptsLocation(ctx) {
|
|
717
|
+
return this.liveYoloWorkspaceExempt()
|
|
718
|
+
&& ctx.opaqueKind === 'location'
|
|
719
|
+
&& ctx.targetKind !== 'delete'
|
|
720
|
+
&& ctx.targetKind !== 'remote-write'
|
|
721
|
+
&& !ctx.irreversible
|
|
722
|
+
&& !ctx.unlearnable
|
|
723
|
+
&& ctx.workspaceBound === true
|
|
724
|
+
// `workspaceBound` is lexical (the shared module cannot touch the filesystem); the
|
|
725
|
+
// relaxation also has to survive link resolution, so a link inside the workspace
|
|
726
|
+
// that points outside is still refused here.
|
|
727
|
+
&& pathsRealInsideWorkspace(ctx.workspaceRoot, ctx.files ?? []);
|
|
728
|
+
}
|
|
671
729
|
/** Whether degraded review is live (no reviewer ⇒ rule-only gate). */
|
|
672
730
|
isRulesFallback() {
|
|
673
731
|
return this.options.readRulesFallback !== undefined
|
|
@@ -1422,7 +1480,7 @@ export class PermGateRuntime {
|
|
|
1422
1480
|
}
|
|
1423
1481
|
else if (useHost) {
|
|
1424
1482
|
const selection = this.hostModel() ?? DEFAULT_HOST_MODEL;
|
|
1425
|
-
risk = await classifyRiskWith((system, user) => completeViaHost(this.options.hostLlm, selection, system, user, cfg?.timeoutMs ?? 20_000), req);
|
|
1483
|
+
risk = await classifyRiskWith((system, user) => completeViaHost(this.options.hostLlm, selection, system, user, cfg?.timeoutMs ?? 20_000, { effort: this.hostEffort() }), req);
|
|
1426
1484
|
}
|
|
1427
1485
|
else {
|
|
1428
1486
|
risk = await classifyRisk(cfg ?? {}, req);
|
|
@@ -1540,7 +1598,11 @@ export class PermGateRuntime {
|
|
|
1540
1598
|
async resolveYolo(exec, decision) {
|
|
1541
1599
|
const callId = randomId();
|
|
1542
1600
|
const askCtx = this.unattendedContextFor(exec, decision);
|
|
1543
|
-
|
|
1601
|
+
// The workspace exemption narrows exactly one signal: "the target could not be pinned
|
|
1602
|
+
// down". It is decided before the screen so an exempted call reaches the reviewer (or
|
|
1603
|
+
// the mechanical conclusion) as an ordinary one; every other danger signal still fires.
|
|
1604
|
+
const exempt = this.yoloExemptsLocation(askCtx);
|
|
1605
|
+
const judged = exempt ? null : this.mechanicalJudge(askCtx);
|
|
1544
1606
|
let riskCategory;
|
|
1545
1607
|
// Whether the reviewer actually spoke. YOLO advertises "the model answers in the
|
|
1546
1608
|
// person's place"; when there is no grader, or it gave up, the tier still refuses to ask
|
|
@@ -1565,11 +1627,15 @@ export class PermGateRuntime {
|
|
|
1565
1627
|
}
|
|
1566
1628
|
const danger = judged !== null || riskCategory !== undefined;
|
|
1567
1629
|
if (!danger) {
|
|
1568
|
-
const
|
|
1630
|
+
const exemptTag = exempt ? ' [exempt:opaque-location]' : '';
|
|
1631
|
+
const reason = (reviewerAnswered
|
|
1569
1632
|
? `yolo: allow (no danger signal) — ${decision.reason}`
|
|
1570
|
-
: `yolo: allow (no reviewer answered; mechanical screen found nothing) — ${decision.reason}
|
|
1633
|
+
: `yolo: allow (no reviewer answered; mechanical screen found nothing) — ${decision.reason}`) + exemptTag;
|
|
1634
|
+
const verdict = exempt
|
|
1635
|
+
? (reviewerAnswered ? 'yolo-allow-opaque-location-exempt' : 'yolo-allow-opaque-location-exempt-no-reviewer')
|
|
1636
|
+
: (reviewerAnswered ? 'yolo-allow' : 'yolo-allow-no-reviewer');
|
|
1571
1637
|
this.audit.append(makeEntry({ callId, tool: exec.name, outcome: 'allow', source: 'classifier', reason, at: Date.now() }));
|
|
1572
|
-
this.recordEvent(exec, 'auto', reason, { verdict
|
|
1638
|
+
this.recordEvent(exec, 'auto', reason, { verdict, risk: 'safe' });
|
|
1573
1639
|
this.untrackAsk(exec);
|
|
1574
1640
|
return undefined;
|
|
1575
1641
|
}
|
|
@@ -1624,7 +1690,7 @@ export class PermGateRuntime {
|
|
|
1624
1690
|
}
|
|
1625
1691
|
else if (useHost) {
|
|
1626
1692
|
const selection = this.hostModel() ?? DEFAULT_HOST_MODEL;
|
|
1627
|
-
risk = await classifyRiskWith((system, user) => completeViaHost(this.options.hostLlm, selection, system, user, cfg?.timeoutMs ?? 20_000), req);
|
|
1693
|
+
risk = await classifyRiskWith((system, user) => completeViaHost(this.options.hostLlm, selection, system, user, cfg?.timeoutMs ?? 20_000, { effort: this.hostEffort() }), req);
|
|
1628
1694
|
}
|
|
1629
1695
|
else {
|
|
1630
1696
|
risk = await classifyRisk(cfg ?? {}, req);
|
|
@@ -2010,7 +2076,7 @@ export class PermGateRuntime {
|
|
|
2010
2076
|
if (this.options.hostLlm === undefined)
|
|
2011
2077
|
return done(false, 'host llm service unavailable');
|
|
2012
2078
|
const selection = this.hostModel() ?? DEFAULT_HOST_MODEL;
|
|
2013
|
-
const r = await completeViaHost(this.options.hostLlm, selection, 'Reply with exactly: OK', 'ping', cfg?.timeoutMs ?? 20_000);
|
|
2079
|
+
const r = await completeViaHost(this.options.hostLlm, selection, 'Reply with exactly: OK', 'ping', cfg?.timeoutMs ?? 20_000, { effort: this.hostEffort() });
|
|
2014
2080
|
return r.ok ? done(true, `${selection.provider}/${selection.model} → ${r.content.trim().slice(0, 60) || 'ok'}`) : done(false, r.error ?? 'host llm call failed');
|
|
2015
2081
|
}
|
|
2016
2082
|
if (cfg?.endpoint === undefined || cfg.endpoint === '' || cfg?.model === undefined || cfg.model === '') {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether every named path resolves inside `root`, after following links.
|
|
3
|
+
*
|
|
4
|
+
* An empty list is `true` (the call named nothing), a missing root is `false`, and any
|
|
5
|
+
* path that cannot be resolved is `false`.
|
|
6
|
+
*/
|
|
7
|
+
export declare function pathsRealInsideWorkspace(root: string | undefined, paths: readonly string[]): boolean;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node-side containment check for the YOLO workspace exemption.
|
|
3
|
+
*
|
|
4
|
+
* `ask-context.ts` stays dependency-free (the browser half imports its types), so its
|
|
5
|
+
* `workspaceBound` is a **lexical** prefix test. That is the right answer for the fact
|
|
6
|
+
* layer but not sufficient to *relax* a safety screen: a link inside the workspace can
|
|
7
|
+
* point anywhere. This module resolves the paths the call actually names and answers the
|
|
8
|
+
* same question through the filesystem, and it is deliberately pessimistic — a path that
|
|
9
|
+
* cannot be resolved (missing, unreadable, permission denied) answers `false`, because
|
|
10
|
+
* "I could not prove it stays inside" must never read as "it stays inside".
|
|
11
|
+
*
|
|
12
|
+
* Node half only: never imported by the client bundle.
|
|
13
|
+
*/
|
|
14
|
+
import { realpathSync } from 'node:fs';
|
|
15
|
+
import { isAbsolute, resolve, sep } from 'node:path';
|
|
16
|
+
/** Windows paths are case-insensitive; the other supported lines are not. */
|
|
17
|
+
function fold(path) {
|
|
18
|
+
return process.platform === 'win32' ? path.toLowerCase() : path;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Whether every named path resolves inside `root`, after following links.
|
|
22
|
+
*
|
|
23
|
+
* An empty list is `true` (the call named nothing), a missing root is `false`, and any
|
|
24
|
+
* path that cannot be resolved is `false`.
|
|
25
|
+
*/
|
|
26
|
+
export function pathsRealInsideWorkspace(root, paths) {
|
|
27
|
+
if (root === undefined || root === '')
|
|
28
|
+
return false;
|
|
29
|
+
if (paths.length === 0)
|
|
30
|
+
return true;
|
|
31
|
+
let realRoot;
|
|
32
|
+
try {
|
|
33
|
+
realRoot = fold(realpathSync.native(root));
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
return paths.every((candidate) => {
|
|
39
|
+
try {
|
|
40
|
+
const absolute = isAbsolute(candidate) ? candidate : resolve(root, candidate);
|
|
41
|
+
const real = fold(realpathSync.native(absolute));
|
|
42
|
+
return real === realRoot || real.startsWith(`${realRoot}${sep}`);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|