dsh-perm-gate 5.4.3 → 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 +5 -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 +35 -0
- package/lib/client.js.map +1 -1
- package/lib/config.d.ts +20 -1
- package/lib/config.js +8 -1
- package/lib/index.js +3 -0
- package/lib/runtime.d.ts +18 -0
- package/lib/runtime.js +41 -4
- package/lib/workspace-bound.d.ts +7 -0
- package/lib/workspace-bound.js +48 -0
- package/package.json +1 -1
package/lib/config.d.ts
CHANGED
|
@@ -94,6 +94,11 @@ export interface PermGateConfig {
|
|
|
94
94
|
* the model never answered instead of letting the tier quietly become a different one.
|
|
95
95
|
*/
|
|
96
96
|
readonly yolo?: boolean;
|
|
97
|
+
/**
|
|
98
|
+
* YOLO only: relax the "target unpinnable" opaque signal. Never relaxes invisible
|
|
99
|
+
* content, deletes, remote writes, P0, credentials or irreversible acts. Default false.
|
|
100
|
+
*/
|
|
101
|
+
readonly yoloWorkspaceExempt?: boolean;
|
|
97
102
|
/**
|
|
98
103
|
* `safenet-pass` — the switch behind the `permissive-mech` tier: the deterministic
|
|
99
104
|
* mechanical screen plus the preset opinions take over the ask. Named danger is blocked
|
|
@@ -512,6 +517,13 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
|
|
|
512
517
|
grantMaxUses: z<number, number, "defined">;
|
|
513
518
|
permissive: z<boolean, boolean, "defined">;
|
|
514
519
|
yolo: z<boolean, boolean, "defined">;
|
|
520
|
+
/**
|
|
521
|
+
* YOLO only: stop treating "the target could not be pinned down" as a danger signal
|
|
522
|
+
* (no-argument tool calls, a script outside the workspace whose body was not read).
|
|
523
|
+
* Invisible *content* — encoded payloads, unreadable bodies, prose — is still blocked,
|
|
524
|
+
* and delete/remote-write never qualify. Default false keeps the 5.4.3 behaviour.
|
|
525
|
+
*/
|
|
526
|
+
yoloWorkspaceExempt: z<boolean, boolean, "defined">;
|
|
515
527
|
rulesFallback: z<boolean, boolean, "defined">;
|
|
516
528
|
/**
|
|
517
529
|
* Retired (2026-10-17): the per-preset mode table that briefly mapped
|
|
@@ -598,6 +610,13 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
|
|
|
598
610
|
grantMaxUses: z<number, number, "defined">;
|
|
599
611
|
permissive: z<boolean, boolean, "defined">;
|
|
600
612
|
yolo: z<boolean, boolean, "defined">;
|
|
613
|
+
/**
|
|
614
|
+
* YOLO only: stop treating "the target could not be pinned down" as a danger signal
|
|
615
|
+
* (no-argument tool calls, a script outside the workspace whose body was not read).
|
|
616
|
+
* Invisible *content* — encoded payloads, unreadable bodies, prose — is still blocked,
|
|
617
|
+
* and delete/remote-write never qualify. Default false keeps the 5.4.3 behaviour.
|
|
618
|
+
*/
|
|
619
|
+
yoloWorkspaceExempt: z<boolean, boolean, "defined">;
|
|
601
620
|
rulesFallback: z<boolean, boolean, "defined">;
|
|
602
621
|
/**
|
|
603
622
|
* Retired (2026-10-17): the per-preset mode table that briefly mapped
|
|
@@ -666,7 +685,7 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
|
|
|
666
685
|
/** Live reference the 0.1.7 loader hands `apply` for `.volatile()` config fields. */
|
|
667
686
|
export type VolatileRef<T> = Volatile<T>;
|
|
668
687
|
/** The config fields marked `.volatile()` — live refs inside `apply`'s config. */
|
|
669
|
-
export declare const VOLATILE_CONFIG_KEYS: readonly ["defaultAction", "caseInsensitivePaths", "classifierEnabled", "classifierEndpoint", "classifierModel", "classifierEffort", "classifierApiKey", "riskTimeoutMs", "riskLearning", "riskSediment", "classifierSource", "classifierProvider", "riskThreshold", "grantTtlMs", "grantMaxUses", "permissive", "yolo", "rulesFallback", "unattendedTiers", "safenetPass", "safenetAsk", "allowProtectedBranchPush", "credWriteExemptFamilies", "presetRulesVersion", "scriptReview", "permissiveStrategies", "allowlist", "denyKeywords", "denyKeywordsExtra", "credentialPaths", "extraContentArgKeys", "extraOperationArgKeys", "presetRulesSeeded", "autoAllowTools", "sessionSweep", "searchUp", "badFilePolicy", "maxChainLength", "networkEnabled", "networkMode", "networkUnlisted", "networkUnattributed", "networkLoopback", "networkBind", "networkPort", "networkNoProxy", "networkInjectEnv", "networkAskTimeoutMs", "networkGrantTtlMs", "rules", "sandboxEnabled", "sandboxMode", "sandboxProxy", "sandboxLoopback"];
|
|
688
|
+
export declare const VOLATILE_CONFIG_KEYS: readonly ["defaultAction", "caseInsensitivePaths", "classifierEnabled", "classifierEndpoint", "classifierModel", "classifierEffort", "classifierApiKey", "riskTimeoutMs", "riskLearning", "riskSediment", "classifierSource", "classifierProvider", "riskThreshold", "grantTtlMs", "grantMaxUses", "permissive", "yolo", "yoloWorkspaceExempt", "rulesFallback", "unattendedTiers", "safenetPass", "safenetAsk", "allowProtectedBranchPush", "credWriteExemptFamilies", "presetRulesVersion", "scriptReview", "permissiveStrategies", "allowlist", "denyKeywords", "denyKeywordsExtra", "credentialPaths", "extraContentArgKeys", "extraOperationArgKeys", "presetRulesSeeded", "autoAllowTools", "sessionSweep", "searchUp", "badFilePolicy", "maxChainLength", "networkEnabled", "networkMode", "networkUnlisted", "networkUnattributed", "networkLoopback", "networkBind", "networkPort", "networkNoProxy", "networkInjectEnv", "networkAskTimeoutMs", "networkGrantTtlMs", "rules", "sandboxEnabled", "sandboxMode", "sandboxProxy", "sandboxLoopback"];
|
|
670
689
|
/** Resolve one possibly-volatile field: a live ref on 0.1.7+, a plain value otherwise. */
|
|
671
690
|
export declare function readVolatileValue<T>(value: T | Volatile<T> | undefined): VolatileSnapshot<T> | undefined;
|
|
672
691
|
/** Shallow-resolve every volatile field into a plain snapshot (one per read). */
|
package/lib/config.js
CHANGED
|
@@ -195,6 +195,13 @@ export const Config = z.object({
|
|
|
195
195
|
grantMaxUses: vol(z.number().min(1).default(1)),
|
|
196
196
|
permissive: vol(z.boolean().default(false)),
|
|
197
197
|
yolo: vol(z.boolean().default(false)),
|
|
198
|
+
/**
|
|
199
|
+
* YOLO only: stop treating "the target could not be pinned down" as a danger signal
|
|
200
|
+
* (no-argument tool calls, a script outside the workspace whose body was not read).
|
|
201
|
+
* Invisible *content* — encoded payloads, unreadable bodies, prose — is still blocked,
|
|
202
|
+
* and delete/remote-write never qualify. Default false keeps the 5.4.3 behaviour.
|
|
203
|
+
*/
|
|
204
|
+
yoloWorkspaceExempt: vol(z.boolean().default(false)),
|
|
198
205
|
rulesFallback: vol(z.boolean().default(false)),
|
|
199
206
|
/**
|
|
200
207
|
* Retired (2026-10-17): the per-preset mode table that briefly mapped
|
|
@@ -267,7 +274,7 @@ export const VOLATILE_CONFIG_KEYS = [
|
|
|
267
274
|
'defaultAction', 'caseInsensitivePaths', 'classifierEnabled', 'classifierEndpoint',
|
|
268
275
|
'classifierModel', 'classifierEffort', 'classifierApiKey', 'riskTimeoutMs', 'riskLearning', 'riskSediment',
|
|
269
276
|
'classifierSource', 'classifierProvider', 'riskThreshold', 'grantTtlMs', 'grantMaxUses',
|
|
270
|
-
'permissive', 'yolo', 'rulesFallback', 'unattendedTiers', 'safenetPass', 'safenetAsk', 'allowProtectedBranchPush', 'credWriteExemptFamilies', 'presetRulesVersion', 'scriptReview', 'permissiveStrategies', 'allowlist', 'denyKeywords', 'denyKeywordsExtra', 'credentialPaths', 'extraContentArgKeys', 'extraOperationArgKeys', 'presetRulesSeeded', 'autoAllowTools',
|
|
277
|
+
'permissive', 'yolo', 'yoloWorkspaceExempt', 'rulesFallback', 'unattendedTiers', 'safenetPass', 'safenetAsk', 'allowProtectedBranchPush', 'credWriteExemptFamilies', 'presetRulesVersion', 'scriptReview', 'permissiveStrategies', 'allowlist', 'denyKeywords', 'denyKeywordsExtra', 'credentialPaths', 'extraContentArgKeys', 'extraOperationArgKeys', 'presetRulesSeeded', 'autoAllowTools',
|
|
271
278
|
'sessionSweep', 'searchUp', 'badFilePolicy', 'maxChainLength',
|
|
272
279
|
'networkEnabled', 'networkMode', 'networkUnlisted', 'networkUnattributed', 'networkLoopback',
|
|
273
280
|
'networkBind', 'networkPort', 'networkNoProxy', 'networkInjectEnv', 'networkAskTimeoutMs',
|
package/lib/index.js
CHANGED
|
@@ -564,6 +564,9 @@ export function apply(ctx, config = {}) {
|
|
|
564
564
|
// call. It is the switch behind the `permissive-yolo` tier only — never a flow added to
|
|
565
565
|
// sessions that did not select it.
|
|
566
566
|
readYolo: () => current().yolo ?? false,
|
|
567
|
+
// YOLO-only narrowing: stop treating "the target could not be pinned down" as danger.
|
|
568
|
+
// Invisible content, deletes, remote writes and every deterministic floor stay armed.
|
|
569
|
+
readYoloWorkspaceExempt: () => current().yoloWorkspaceExempt === true,
|
|
567
570
|
// The two `safenet` switches, read live: each controls whether one implementation
|
|
568
571
|
// exists, and each implementation owns one preset, so presets never cross.
|
|
569
572
|
readSafenetPass: () => current().safenetPass ?? true,
|
package/lib/runtime.d.ts
CHANGED
|
@@ -151,6 +151,12 @@ export interface PermGateRuntimeOptions extends PermGateConfig {
|
|
|
151
151
|
readonly readPermissive?: () => PermissiveState;
|
|
152
152
|
/** Optional live source of the YOLO tier switch (never human-confirm; block-on-danger). */
|
|
153
153
|
readonly readYolo?: () => boolean;
|
|
154
|
+
/**
|
|
155
|
+
* Optional live source of the YOLO workspace exemption: stop treating "the target could
|
|
156
|
+
* not be pinned down" as a danger signal. Only `permissive-yolo` reads it, and only a
|
|
157
|
+
* *location*-kind opacity qualifies — invisible content, deletes and remote writes never do.
|
|
158
|
+
*/
|
|
159
|
+
readonly readYoloWorkspaceExempt?: () => boolean;
|
|
154
160
|
/**
|
|
155
161
|
* Optional live source of the degraded-review switch: with no reviewer able to grade
|
|
156
162
|
* an ask, decide on the deterministic nets instead of parking it on a human.
|
|
@@ -491,6 +497,18 @@ export declare class PermGateRuntime {
|
|
|
491
497
|
private livePermissive;
|
|
492
498
|
/** Whether YOLO mode is live (never human-confirm). */
|
|
493
499
|
isYolo(): boolean;
|
|
500
|
+
/**
|
|
501
|
+
* Whether the YOLO tier should stop blocking on "target unpinnable" alone.
|
|
502
|
+
* Default false: 5.4.3 behaviour, one switch away.
|
|
503
|
+
*/
|
|
504
|
+
private liveYoloWorkspaceExempt;
|
|
505
|
+
/**
|
|
506
|
+
* The one narrowing the exemption is allowed to make: the call is ordinary and visible,
|
|
507
|
+
* only its *location* was unpinnable, and everything it names is inside the workspace.
|
|
508
|
+
* Delete, remote write, invisible content, P0/irreversible and unlearnable acts are all
|
|
509
|
+
* refused here — this method can only *skip* the opaque signal, never clear a danger.
|
|
510
|
+
*/
|
|
511
|
+
private yoloExemptsLocation;
|
|
494
512
|
/** Whether degraded review is live (no reviewer ⇒ rule-only gate). */
|
|
495
513
|
isRulesFallback(): boolean;
|
|
496
514
|
/** Whether script-body review is live (default on). */
|
package/lib/runtime.js
CHANGED
|
@@ -24,6 +24,7 @@ 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';
|
|
@@ -697,6 +698,34 @@ export class PermGateRuntime {
|
|
|
697
698
|
isYolo() {
|
|
698
699
|
return this.options.readYolo !== undefined ? this.options.readYolo() : (this.options.yolo ?? false);
|
|
699
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
|
+
}
|
|
700
729
|
/** Whether degraded review is live (no reviewer ⇒ rule-only gate). */
|
|
701
730
|
isRulesFallback() {
|
|
702
731
|
return this.options.readRulesFallback !== undefined
|
|
@@ -1569,7 +1598,11 @@ export class PermGateRuntime {
|
|
|
1569
1598
|
async resolveYolo(exec, decision) {
|
|
1570
1599
|
const callId = randomId();
|
|
1571
1600
|
const askCtx = this.unattendedContextFor(exec, decision);
|
|
1572
|
-
|
|
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);
|
|
1573
1606
|
let riskCategory;
|
|
1574
1607
|
// Whether the reviewer actually spoke. YOLO advertises "the model answers in the
|
|
1575
1608
|
// person's place"; when there is no grader, or it gave up, the tier still refuses to ask
|
|
@@ -1594,11 +1627,15 @@ export class PermGateRuntime {
|
|
|
1594
1627
|
}
|
|
1595
1628
|
const danger = judged !== null || riskCategory !== undefined;
|
|
1596
1629
|
if (!danger) {
|
|
1597
|
-
const
|
|
1630
|
+
const exemptTag = exempt ? ' [exempt:opaque-location]' : '';
|
|
1631
|
+
const reason = (reviewerAnswered
|
|
1598
1632
|
? `yolo: allow (no danger signal) — ${decision.reason}`
|
|
1599
|
-
: `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');
|
|
1600
1637
|
this.audit.append(makeEntry({ callId, tool: exec.name, outcome: 'allow', source: 'classifier', reason, at: Date.now() }));
|
|
1601
|
-
this.recordEvent(exec, 'auto', reason, { verdict
|
|
1638
|
+
this.recordEvent(exec, 'auto', reason, { verdict, risk: 'safe' });
|
|
1602
1639
|
this.untrackAsk(exec);
|
|
1603
1640
|
return undefined;
|
|
1604
1641
|
}
|
|
@@ -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
|
+
}
|