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/config.d.ts
CHANGED
|
@@ -22,6 +22,8 @@ export declare function dataDirReady(dataDir: string): boolean;
|
|
|
22
22
|
* applies `defaultAction`), so the fallback never fails the plugin load.
|
|
23
23
|
*/
|
|
24
24
|
export declare function resolveRulesFile(configured: string | undefined, dataDir: string): string;
|
|
25
|
+
/** Reasoning effort the host reviewer may request; empty = let the host decide. */
|
|
26
|
+
export type ClassifierEffort = '' | 'off' | 'low' | 'medium' | 'high';
|
|
25
27
|
export interface PermGateConfig {
|
|
26
28
|
/** Absolute or `./`-relative path to the YAML permissions document. */
|
|
27
29
|
readonly rulesFile?: string;
|
|
@@ -34,7 +36,19 @@ export interface PermGateConfig {
|
|
|
34
36
|
/** Enable the optional LLM semantic classifier (P3). Default false. */
|
|
35
37
|
readonly classifierEnabled?: boolean;
|
|
36
38
|
readonly classifierEndpoint?: string;
|
|
39
|
+
/**
|
|
40
|
+
* Model id for the llmAssist receiver. Unset in host mode means **inherit the
|
|
41
|
+
* host model-group selection** — an inherited default here (the old
|
|
42
|
+
* `deepseek-chat`) pinned the reviewer to an unrelated model while the provider
|
|
43
|
+
* followed the host (issue #8). Custom-endpoint mode still falls back to
|
|
44
|
+
* `deepseek-chat` at request time.
|
|
45
|
+
*/
|
|
37
46
|
readonly classifierModel?: string;
|
|
47
|
+
/**
|
|
48
|
+
* Reasoning effort for host reviews (`off` | `low` | `medium` | `high`).
|
|
49
|
+
* Empty omits the field so the host model configuration decides (issue #8).
|
|
50
|
+
*/
|
|
51
|
+
readonly classifierEffort?: ClassifierEffort;
|
|
38
52
|
readonly classifierApiKey?: string;
|
|
39
53
|
/** Abort the llmAssist risk call after this many milliseconds. Default 20000. */
|
|
40
54
|
readonly riskTimeoutMs?: number;
|
|
@@ -80,6 +94,11 @@ export interface PermGateConfig {
|
|
|
80
94
|
* the model never answered instead of letting the tier quietly become a different one.
|
|
81
95
|
*/
|
|
82
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;
|
|
83
102
|
/**
|
|
84
103
|
* `safenet-pass` — the switch behind the `permissive-mech` tier: the deterministic
|
|
85
104
|
* mechanical screen plus the preset opinions take over the ask. Named danger is blocked
|
|
@@ -483,7 +502,8 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
|
|
|
483
502
|
caseInsensitivePaths: z<boolean, boolean, "defined">;
|
|
484
503
|
classifierEnabled: z<boolean, boolean, "defined">;
|
|
485
504
|
classifierEndpoint: z<string, string, "plain">;
|
|
486
|
-
classifierModel: z<string, string, "
|
|
505
|
+
classifierModel: z<string, string, "plain">;
|
|
506
|
+
classifierEffort: z<"" | "off" | "low" | "medium" | "high", "" | "off" | "low" | "medium" | "high", "defined">;
|
|
487
507
|
classifierApiKey: z<string, string, "plain">;
|
|
488
508
|
riskTimeoutMs: z<number, number, "defined">;
|
|
489
509
|
riskLearning: z<boolean, boolean, "defined">;
|
|
@@ -497,6 +517,13 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
|
|
|
497
517
|
grantMaxUses: z<number, number, "defined">;
|
|
498
518
|
permissive: z<boolean, boolean, "defined">;
|
|
499
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">;
|
|
500
527
|
rulesFallback: z<boolean, boolean, "defined">;
|
|
501
528
|
/**
|
|
502
529
|
* Retired (2026-10-17): the per-preset mode table that briefly mapped
|
|
@@ -557,7 +584,7 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
|
|
|
557
584
|
sandboxEnabled: z<boolean, boolean, "defined">;
|
|
558
585
|
sandboxMode: z<"read-only" | "workspace-write", "read-only" | "workspace-write", "defined">;
|
|
559
586
|
sandboxProxy: z<string, string, "defined">;
|
|
560
|
-
sandboxLoopback: z<"
|
|
587
|
+
sandboxLoopback: z<"off" | "exempt", "off" | "exempt", "defined">;
|
|
561
588
|
rules: z<RulesConfig>;
|
|
562
589
|
watch: z<boolean, boolean, "defined">;
|
|
563
590
|
watchDebounceMs: z<number, number, "defined">;
|
|
@@ -568,7 +595,8 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
|
|
|
568
595
|
caseInsensitivePaths: z<boolean, boolean, "defined">;
|
|
569
596
|
classifierEnabled: z<boolean, boolean, "defined">;
|
|
570
597
|
classifierEndpoint: z<string, string, "plain">;
|
|
571
|
-
classifierModel: z<string, string, "
|
|
598
|
+
classifierModel: z<string, string, "plain">;
|
|
599
|
+
classifierEffort: z<"" | "off" | "low" | "medium" | "high", "" | "off" | "low" | "medium" | "high", "defined">;
|
|
572
600
|
classifierApiKey: z<string, string, "plain">;
|
|
573
601
|
riskTimeoutMs: z<number, number, "defined">;
|
|
574
602
|
riskLearning: z<boolean, boolean, "defined">;
|
|
@@ -582,6 +610,13 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
|
|
|
582
610
|
grantMaxUses: z<number, number, "defined">;
|
|
583
611
|
permissive: z<boolean, boolean, "defined">;
|
|
584
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">;
|
|
585
620
|
rulesFallback: z<boolean, boolean, "defined">;
|
|
586
621
|
/**
|
|
587
622
|
* Retired (2026-10-17): the per-preset mode table that briefly mapped
|
|
@@ -642,7 +677,7 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
|
|
|
642
677
|
sandboxEnabled: z<boolean, boolean, "defined">;
|
|
643
678
|
sandboxMode: z<"read-only" | "workspace-write", "read-only" | "workspace-write", "defined">;
|
|
644
679
|
sandboxProxy: z<string, string, "defined">;
|
|
645
|
-
sandboxLoopback: z<"
|
|
680
|
+
sandboxLoopback: z<"off" | "exempt", "off" | "exempt", "defined">;
|
|
646
681
|
rules: z<RulesConfig>;
|
|
647
682
|
watch: z<boolean, boolean, "defined">;
|
|
648
683
|
watchDebounceMs: z<number, number, "defined">;
|
|
@@ -650,12 +685,12 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
|
|
|
650
685
|
/** Live reference the 0.1.7 loader hands `apply` for `.volatile()` config fields. */
|
|
651
686
|
export type VolatileRef<T> = Volatile<T>;
|
|
652
687
|
/** The config fields marked `.volatile()` — live refs inside `apply`'s config. */
|
|
653
|
-
export declare const VOLATILE_CONFIG_KEYS: readonly ["defaultAction", "caseInsensitivePaths", "classifierEnabled", "classifierEndpoint", "classifierModel", "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"];
|
|
654
689
|
/** Resolve one possibly-volatile field: a live ref on 0.1.7+, a plain value otherwise. */
|
|
655
690
|
export declare function readVolatileValue<T>(value: T | Volatile<T> | undefined): VolatileSnapshot<T> | undefined;
|
|
656
691
|
/** Shallow-resolve every volatile field into a plain snapshot (one per read). */
|
|
657
692
|
export declare function resolveVolatileConfig(config: object): Record<string, unknown>;
|
|
658
|
-
export type ResolvedPermGateConfig = Required<Pick<PermGateConfig, 'caseInsensitivePaths' | 'grantTtlMs' | 'grantMaxUses' | 'permissive' | 'riskTimeoutMs' | 'riskLearning' | 'riskThreshold'>> & Pick<PermGateConfig, 'rulesFile' | 'dshHome' | 'defaultAction' | 'classifierEnabled' | 'classifierEndpoint' | 'classifierModel' | 'classifierApiKey' | 'learningFile' | 'eventsFile'> & {
|
|
693
|
+
export type ResolvedPermGateConfig = Required<Pick<PermGateConfig, 'caseInsensitivePaths' | 'grantTtlMs' | 'grantMaxUses' | 'permissive' | 'riskTimeoutMs' | 'riskLearning' | 'riskThreshold'>> & Pick<PermGateConfig, 'rulesFile' | 'dshHome' | 'defaultAction' | 'classifierEnabled' | 'classifierEndpoint' | 'classifierModel' | 'classifierEffort' | 'classifierApiKey' | 'learningFile' | 'eventsFile'> & {
|
|
659
694
|
readonly permissiveStrategies: PermissiveStrategies;
|
|
660
695
|
readonly gatePresets: readonly string[];
|
|
661
696
|
};
|
package/lib/config.js
CHANGED
|
@@ -180,7 +180,8 @@ export const Config = z.object({
|
|
|
180
180
|
caseInsensitivePaths: vol(z.boolean().default(true)),
|
|
181
181
|
classifierEnabled: vol(z.boolean().default(false)),
|
|
182
182
|
classifierEndpoint: vol(z.string()),
|
|
183
|
-
classifierModel: vol(z.string()
|
|
183
|
+
classifierModel: vol(z.string()),
|
|
184
|
+
classifierEffort: vol(z.union(['', 'off', 'low', 'medium', 'high']).default('')),
|
|
184
185
|
classifierApiKey: vol(z.string()),
|
|
185
186
|
riskTimeoutMs: vol(z.number().min(1000).default(20_000)),
|
|
186
187
|
riskLearning: vol(z.boolean().default(true)),
|
|
@@ -194,6 +195,13 @@ export const Config = z.object({
|
|
|
194
195
|
grantMaxUses: vol(z.number().min(1).default(1)),
|
|
195
196
|
permissive: vol(z.boolean().default(false)),
|
|
196
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)),
|
|
197
205
|
rulesFallback: vol(z.boolean().default(false)),
|
|
198
206
|
/**
|
|
199
207
|
* Retired (2026-10-17): the per-preset mode table that briefly mapped
|
|
@@ -264,9 +272,9 @@ export const Config = z.object({
|
|
|
264
272
|
/** The config fields marked `.volatile()` — live refs inside `apply`'s config. */
|
|
265
273
|
export const VOLATILE_CONFIG_KEYS = [
|
|
266
274
|
'defaultAction', 'caseInsensitivePaths', 'classifierEnabled', 'classifierEndpoint',
|
|
267
|
-
'classifierModel', 'classifierApiKey', 'riskTimeoutMs', 'riskLearning', 'riskSediment',
|
|
275
|
+
'classifierModel', 'classifierEffort', 'classifierApiKey', 'riskTimeoutMs', 'riskLearning', 'riskSediment',
|
|
268
276
|
'classifierSource', 'classifierProvider', 'riskThreshold', 'grantTtlMs', 'grantMaxUses',
|
|
269
|
-
'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',
|
|
270
278
|
'sessionSweep', 'searchUp', 'badFilePolicy', 'maxChainLength',
|
|
271
279
|
'networkEnabled', 'networkMode', 'networkUnlisted', 'networkUnattributed', 'networkLoopback',
|
|
272
280
|
'networkBind', 'networkPort', 'networkNoProxy', 'networkInjectEnv', 'networkAskTimeoutMs',
|
|
@@ -303,7 +311,9 @@ export function resolveConfig(config = {}) {
|
|
|
303
311
|
caseInsensitivePaths: parsed.caseInsensitivePaths ?? true,
|
|
304
312
|
classifierEnabled: parsed.classifierEnabled ?? false,
|
|
305
313
|
classifierEndpoint: parsed.classifierEndpoint,
|
|
306
|
-
|
|
314
|
+
// Not defaulted on purpose: unset means "inherit the host model" (issue #8).
|
|
315
|
+
classifierModel: parsed.classifierModel,
|
|
316
|
+
classifierEffort: parsed.classifierEffort ?? '',
|
|
307
317
|
classifierApiKey: parsed.classifierApiKey,
|
|
308
318
|
riskTimeoutMs: parsed.riskTimeoutMs ?? 20_000,
|
|
309
319
|
riskLearning: parsed.riskLearning ?? false,
|
package/lib/events.js
CHANGED
|
@@ -822,7 +822,14 @@ export function registerHealthRoute(server, provider) {
|
|
|
822
822
|
json(res, 405, { ok: false, error: 'method not allowed' });
|
|
823
823
|
return;
|
|
824
824
|
}
|
|
825
|
-
provider.check().then(
|
|
825
|
+
provider.check().then(
|
|
826
|
+
// The check's own `ok` decides the answer. Spelling `ok: true` after the
|
|
827
|
+
// spread overwrote a resolved `{ ok: false }` and reported a dead review
|
|
828
|
+
// connection as healthy (issue #8).
|
|
829
|
+
(result) => json(res, 200, {
|
|
830
|
+
...result,
|
|
831
|
+
ok: result?.ok === true,
|
|
832
|
+
}), (e) => json(res, 500, { ok: false, error: String(e?.message ?? e) }));
|
|
826
833
|
},
|
|
827
834
|
});
|
|
828
835
|
}
|
package/lib/host-llm.d.ts
CHANGED
|
@@ -47,12 +47,29 @@ export interface HostModelSelection {
|
|
|
47
47
|
}
|
|
48
48
|
/** Fallback matching dsh-approval-gate's resolveModel default. */
|
|
49
49
|
export declare const DEFAULT_HOST_MODEL: HostModelSelection;
|
|
50
|
+
/** Optional knobs for one host completion. */
|
|
51
|
+
export interface HostCallOptions {
|
|
52
|
+
/**
|
|
53
|
+
* Reasoning effort to request. Empty/undefined **omits** the field entirely so
|
|
54
|
+
* the host's own model configuration decides — a hardcoded value is rejected by
|
|
55
|
+
* host validation when the selected model does not advertise it (issue #8).
|
|
56
|
+
*/
|
|
57
|
+
readonly effort?: string;
|
|
58
|
+
readonly maxTokens?: number;
|
|
59
|
+
}
|
|
50
60
|
/**
|
|
51
61
|
* One completion through the host `llm` service with an abort timeout.
|
|
62
|
+
*
|
|
63
|
+
* Only **final text** is the answer: `reasoning-delta` chunks are the model's
|
|
64
|
+
* private working, never a verdict, and a stream that ends on `max-tokens` (or
|
|
65
|
+
* any other truncating finish) is a failure rather than a short answer. Both
|
|
66
|
+
* used to be accepted, which let a reasoning-only or cut-off stream reach the
|
|
67
|
+
* verdict parser and be read as `safe` (issue #7).
|
|
68
|
+
*
|
|
52
69
|
* Resolves `{ ok: true, content }` with the accumulated assistant text, or
|
|
53
|
-
* `{ ok: false, error }` on stream failure, abort, or an empty answer.
|
|
70
|
+
* `{ ok: false, error }` on stream failure, abort, truncation, or an empty answer.
|
|
54
71
|
*/
|
|
55
|
-
export declare function completeViaHost(hostLlm: HostLlmLike, selection: HostModelSelection, system: string, user: string, timeoutMs: number): Promise<{
|
|
72
|
+
export declare function completeViaHost(hostLlm: HostLlmLike, selection: HostModelSelection, system: string, user: string, timeoutMs: number, opts?: HostCallOptions): Promise<{
|
|
56
73
|
ok: true;
|
|
57
74
|
content: string;
|
|
58
75
|
} | {
|
package/lib/host-llm.js
CHANGED
|
@@ -10,35 +10,58 @@
|
|
|
10
10
|
*/
|
|
11
11
|
/** Fallback matching dsh-approval-gate's resolveModel default. */
|
|
12
12
|
export const DEFAULT_HOST_MODEL = { provider: 'deepseek-official', model: 'deepseek-v4-flash' };
|
|
13
|
+
/**
|
|
14
|
+
* Finish kinds that mean "what you got is not the whole answer". Graded as a
|
|
15
|
+
* failure: a truncated or filtered stream is not a verdict (issue #7).
|
|
16
|
+
*/
|
|
17
|
+
const TRUNCATED_FINISH_KINDS = new Set([
|
|
18
|
+
'max-tokens', 'max_tokens', 'length', 'truncated', 'content-filter', 'content_filter',
|
|
19
|
+
]);
|
|
13
20
|
/**
|
|
14
21
|
* One completion through the host `llm` service with an abort timeout.
|
|
22
|
+
*
|
|
23
|
+
* Only **final text** is the answer: `reasoning-delta` chunks are the model's
|
|
24
|
+
* private working, never a verdict, and a stream that ends on `max-tokens` (or
|
|
25
|
+
* any other truncating finish) is a failure rather than a short answer. Both
|
|
26
|
+
* used to be accepted, which let a reasoning-only or cut-off stream reach the
|
|
27
|
+
* verdict parser and be read as `safe` (issue #7).
|
|
28
|
+
*
|
|
15
29
|
* Resolves `{ ok: true, content }` with the accumulated assistant text, or
|
|
16
|
-
* `{ ok: false, error }` on stream failure, abort, or an empty answer.
|
|
30
|
+
* `{ ok: false, error }` on stream failure, abort, truncation, or an empty answer.
|
|
17
31
|
*/
|
|
18
|
-
export async function completeViaHost(hostLlm, selection, system, user, timeoutMs) {
|
|
32
|
+
export async function completeViaHost(hostLlm, selection, system, user, timeoutMs, opts = {}) {
|
|
19
33
|
const controller = new AbortController();
|
|
20
34
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
21
35
|
try {
|
|
22
36
|
let text = '';
|
|
37
|
+
let finishKind;
|
|
38
|
+
const effort = typeof opts.effort === 'string' && opts.effort !== '' ? opts.effort : undefined;
|
|
23
39
|
const stream = hostLlm.stream({
|
|
24
40
|
provider: selection.provider,
|
|
25
41
|
model: selection.model,
|
|
26
42
|
messages: [{ role: 'user', content: [{ type: 'text', text: user }] }],
|
|
27
43
|
system,
|
|
28
44
|
temperature: 0,
|
|
29
|
-
reasoningEffort:
|
|
30
|
-
maxTokens: 256,
|
|
45
|
+
...(effort !== undefined ? { reasoningEffort: effort } : {}),
|
|
46
|
+
maxTokens: opts.maxTokens ?? 256,
|
|
31
47
|
signal: controller.signal,
|
|
32
48
|
});
|
|
33
49
|
for await (const chunk of stream) {
|
|
34
|
-
if (
|
|
50
|
+
if (chunk.type === 'text-delta' && typeof chunk.text === 'string') {
|
|
35
51
|
text += chunk.text;
|
|
36
52
|
}
|
|
37
|
-
else if (chunk.type === 'finish'
|
|
38
|
-
const
|
|
39
|
-
|
|
53
|
+
else if (chunk.type === 'finish') {
|
|
54
|
+
const kind = chunk.reason?.kind;
|
|
55
|
+
if (kind === 'error' || kind === 'aborted') {
|
|
56
|
+
const failure = chunk.reason?.failure?.message;
|
|
57
|
+
return { ok: false, error: failure ?? kind };
|
|
58
|
+
}
|
|
59
|
+
finishKind = kind;
|
|
40
60
|
}
|
|
41
61
|
}
|
|
62
|
+
if (finishKind !== undefined && TRUNCATED_FINISH_KINDS.has(finishKind)) {
|
|
63
|
+
return { ok: false, error: `truncated answer (finish: ${finishKind})` };
|
|
64
|
+
}
|
|
42
65
|
if (text.trim() === '')
|
|
43
66
|
return { ok: false, error: 'empty answer' };
|
|
44
67
|
return { ok: true, content: text };
|
package/lib/index.js
CHANGED
|
@@ -510,7 +510,15 @@ export function apply(ctx, config = {}) {
|
|
|
510
510
|
// channel in buildSessionSender.
|
|
511
511
|
let approvalService;
|
|
512
512
|
const runtime = new PermGateRuntime({
|
|
513
|
-
|
|
513
|
+
// The host hands `.volatile()` fields to apply() as **live refs**. Resolve
|
|
514
|
+
// them once before spreading, or a ref object becomes a static option — the
|
|
515
|
+
// ruleset default among them, where no deny/ask branch matches it and every
|
|
516
|
+
// unmatched call was allowed unreviewed (issue #6). Anything that must stay
|
|
517
|
+
// live is read through its own `read*` hook below.
|
|
518
|
+
...resolveVolatileConfig(config),
|
|
519
|
+
// Read live *and* validated: `coerceRuleAction` refuses a non-literal value
|
|
520
|
+
// instead of letting it fall through the runtime's action branches.
|
|
521
|
+
readDefaultAction: () => current().defaultAction,
|
|
514
522
|
hostLlm,
|
|
515
523
|
// Raise a network approval through the DSH approval seam. The service
|
|
516
524
|
// applies the session policy, routes the prompt to the agent, and appends
|
|
@@ -556,6 +564,9 @@ export function apply(ctx, config = {}) {
|
|
|
556
564
|
// call. It is the switch behind the `permissive-yolo` tier only — never a flow added to
|
|
557
565
|
// sessions that did not select it.
|
|
558
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,
|
|
559
570
|
// The two `safenet` switches, read live: each controls whether one implementation
|
|
560
571
|
// exists, and each implementation owns one preset, so presets never cross.
|
|
561
572
|
readSafenetPass: () => current().safenetPass ?? true,
|
|
@@ -582,13 +593,26 @@ export function apply(ctx, config = {}) {
|
|
|
582
593
|
// same namespace, so editing the settings card applies to the next tool call.
|
|
583
594
|
readClassifyConfig: () => {
|
|
584
595
|
const c = current();
|
|
596
|
+
const source = c.classifierSource === 'host' ? 'host' : 'custom';
|
|
585
597
|
return {
|
|
586
598
|
endpoint: c.classifierEndpoint,
|
|
587
|
-
|
|
599
|
+
// Host mode: an unset model means "inherit the host selection" — the old
|
|
600
|
+
// schema default (`deepseek-chat`) came back on every clear and was then
|
|
601
|
+
// treated as an explicit override, so provider and model diverged (issue
|
|
602
|
+
// #8). Custom-endpoint mode keeps the historical default.
|
|
603
|
+
model: typeof c.classifierModel === 'string' && c.classifierModel !== ''
|
|
604
|
+
? c.classifierModel
|
|
605
|
+
: source === 'custom' ? 'deepseek-chat' : undefined,
|
|
588
606
|
apiKey: c.classifierApiKey,
|
|
589
607
|
timeoutMs: c.riskTimeoutMs ?? 20_000,
|
|
590
608
|
};
|
|
591
609
|
},
|
|
610
|
+
// Host reviews: an empty effort sends no `reasoningEffort` at all, so the host
|
|
611
|
+
// model configuration decides instead of a value it may reject (issue #8).
|
|
612
|
+
readClassifyEffort: () => {
|
|
613
|
+
const effort = current().classifierEffort;
|
|
614
|
+
return typeof effort === 'string' && effort !== '' ? effort : undefined;
|
|
615
|
+
},
|
|
592
616
|
// Read the verdict-learning switch and threshold live.
|
|
593
617
|
readRiskLearning: () => {
|
|
594
618
|
const c = current();
|
|
@@ -751,6 +775,9 @@ export function apply(ctx, config = {}) {
|
|
|
751
775
|
// The host no longer seeds settings (there is no write path): an empty
|
|
752
776
|
// `allowlist` keeps the rules-file default via `runtime.allowlist()`.
|
|
753
777
|
let lastRulesDocKey;
|
|
778
|
+
// Root compile-time inputs (default action, path case-fold). Seeded on the first
|
|
779
|
+
// pass so a startup value is not mistaken for an edit.
|
|
780
|
+
let lastStaticKey;
|
|
754
781
|
function applyVolatileUpdate() {
|
|
755
782
|
const next = asSurface(current());
|
|
756
783
|
if (next !== undefined) {
|
|
@@ -779,6 +806,17 @@ export function apply(ctx, config = {}) {
|
|
|
779
806
|
if (changed)
|
|
780
807
|
runtime.reload();
|
|
781
808
|
}
|
|
809
|
+
// The root `defaultAction` (and the path case-fold) are compile-time inputs of
|
|
810
|
+
// the ruleset. Without this the settings switch persisted but never reached the
|
|
811
|
+
// live ruleset — "default value changes also miss invalidation" (issue #6).
|
|
812
|
+
const staticKey = `${String(current().defaultAction)}\u0000${String(current().caseInsensitivePaths)}`;
|
|
813
|
+
if (lastStaticKey === undefined) {
|
|
814
|
+
lastStaticKey = staticKey;
|
|
815
|
+
}
|
|
816
|
+
else if (lastStaticKey !== staticKey) {
|
|
817
|
+
lastStaticKey = staticKey;
|
|
818
|
+
runtime.reload();
|
|
819
|
+
}
|
|
782
820
|
}
|
|
783
821
|
;
|
|
784
822
|
ctx.on('loader/volatile-update', () => {
|
package/lib/risk.js
CHANGED
|
@@ -135,13 +135,11 @@ export function parseRiskVerdict(content) {
|
|
|
135
135
|
return undefined;
|
|
136
136
|
}
|
|
137
137
|
catch {
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
if (/\bsafe\b/i.test(trimmed))
|
|
144
|
-
return { kind: 'safe' };
|
|
138
|
+
// No lenient keyword scan. A bare `safe` used to be accepted from prose such
|
|
139
|
+
// as "not safe" or from a half-written JSON object, which is exactly how a
|
|
140
|
+
// truncated stream could be read as an all-clear (issue #7). Anything that is
|
|
141
|
+
// not the protocol object is `undefined` — the caller keeps the human ask
|
|
142
|
+
// and learns nothing.
|
|
145
143
|
return undefined;
|
|
146
144
|
}
|
|
147
145
|
}
|
package/lib/rule.d.ts
CHANGED
|
@@ -86,6 +86,17 @@ export interface CompileOptions {
|
|
|
86
86
|
readonly maxGlobStars?: number;
|
|
87
87
|
readonly caseInsensitivePaths?: boolean;
|
|
88
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Coerce a configuration-supplied value into a decision action, fail-closed.
|
|
91
|
+
*
|
|
92
|
+
* The host hands `.volatile()` fields to `apply` as **live refs**, and the runtime
|
|
93
|
+
* options are built by spreading that config. A ref that reaches the ruleset
|
|
94
|
+
* un-unwrapped is not one of the three literals, so every deny/ask branch in the
|
|
95
|
+
* runtime misses it and the call falls through to allow — an ask the user
|
|
96
|
+
* configured, silently skipped. Anything unrecognised therefore becomes `ask`:
|
|
97
|
+
* the conservative action, never a bypass (issue #6).
|
|
98
|
+
*/
|
|
99
|
+
export declare function coerceRuleAction(value: unknown, fallback?: RuleAction): RuleAction;
|
|
89
100
|
export declare class RuleError extends Error {
|
|
90
101
|
constructor(message: string);
|
|
91
102
|
}
|
package/lib/rule.js
CHANGED
|
@@ -12,6 +12,19 @@ import { parse } from 'yaml';
|
|
|
12
12
|
import { compileGlob, compileBranchPattern, hashText } from './compiler.js';
|
|
13
13
|
import { parseParamsDimension, parseAbsentDimension, parseAgentsDimension, parseWhenDimension, parseArgvDimension, parseNetworkDimension, parseBranchDimension, } from './rule-dims.js';
|
|
14
14
|
const VALID_ACTIONS = ['allow', 'ask', 'deny'];
|
|
15
|
+
/**
|
|
16
|
+
* Coerce a configuration-supplied value into a decision action, fail-closed.
|
|
17
|
+
*
|
|
18
|
+
* The host hands `.volatile()` fields to `apply` as **live refs**, and the runtime
|
|
19
|
+
* options are built by spreading that config. A ref that reaches the ruleset
|
|
20
|
+
* un-unwrapped is not one of the three literals, so every deny/ask branch in the
|
|
21
|
+
* runtime misses it and the call falls through to allow — an ask the user
|
|
22
|
+
* configured, silently skipped. Anything unrecognised therefore becomes `ask`:
|
|
23
|
+
* the conservative action, never a bypass (issue #6).
|
|
24
|
+
*/
|
|
25
|
+
export function coerceRuleAction(value, fallback = 'ask') {
|
|
26
|
+
return VALID_ACTIONS.includes(value) ? value : fallback;
|
|
27
|
+
}
|
|
15
28
|
const PATH_CANDIDATE_KEYS = [
|
|
16
29
|
'path', 'paths', 'file', 'files', 'file_path', 'dir', 'directory', 'directories',
|
|
17
30
|
'cwd', 'workspace', 'root', 'target', 'targets', 'output',
|
package/lib/runtime.d.ts
CHANGED
|
@@ -131,6 +131,13 @@ interface PendingAsk {
|
|
|
131
131
|
readonly doubtKinds?: readonly string[];
|
|
132
132
|
}
|
|
133
133
|
export interface PermGateRuntimeOptions extends PermGateConfig {
|
|
134
|
+
/**
|
|
135
|
+
* Live read of the root `defaultAction`. The host hands `.volatile()` fields to
|
|
136
|
+
* `apply` as refs, so the value spread into these options may be a ref object —
|
|
137
|
+
* read through this hook at (re)compile time instead, and let
|
|
138
|
+
* `coerceRuleAction` refuse anything that is not a literal action (issue #6).
|
|
139
|
+
*/
|
|
140
|
+
readonly readDefaultAction?: () => unknown;
|
|
134
141
|
/** Workspace root for chain resolution. Defaults to process.cwd(). */
|
|
135
142
|
readonly cwd?: string;
|
|
136
143
|
readonly onRulesChanged?: (ruleset: CompiledRuleset) => void;
|
|
@@ -144,6 +151,12 @@ export interface PermGateRuntimeOptions extends PermGateConfig {
|
|
|
144
151
|
readonly readPermissive?: () => PermissiveState;
|
|
145
152
|
/** Optional live source of the YOLO tier switch (never human-confirm; block-on-danger). */
|
|
146
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;
|
|
147
160
|
/**
|
|
148
161
|
* Optional live source of the degraded-review switch: with no reviewer able to grade
|
|
149
162
|
* an ask, decide on the deterministic nets instead of parking it on a human.
|
|
@@ -177,8 +190,13 @@ export interface PermGateRuntimeOptions extends PermGateConfig {
|
|
|
177
190
|
* Defaults to on (`config.scriptReview`).
|
|
178
191
|
*/
|
|
179
192
|
readonly readScriptReview?: () => boolean;
|
|
180
|
-
/** Optional live source of the llmAssist
|
|
193
|
+
/** Optional live source of the llmAssist receiver setup (endpoint/model/secret/timeout). */
|
|
181
194
|
readonly readClassifyConfig?: () => ClassifierConfig;
|
|
195
|
+
/**
|
|
196
|
+
* Optional live source of the host-review reasoning effort. Undefined/empty
|
|
197
|
+
* omits the field entirely — see {@link hostEffort} (issue #8).
|
|
198
|
+
*/
|
|
199
|
+
readonly readClassifyEffort?: () => string | undefined;
|
|
182
200
|
/**
|
|
183
201
|
* Optional live receiver source: `'custom'` (an OpenAI-compatible endpoint,
|
|
184
202
|
* default) or `'host'` (the DSH host `llm` service — the model-group setup
|
|
@@ -389,6 +407,14 @@ export declare class PermGateRuntime {
|
|
|
389
407
|
private readonly cleared;
|
|
390
408
|
constructor(options?: PermGateRuntimeOptions);
|
|
391
409
|
private compileInline;
|
|
410
|
+
/**
|
|
411
|
+
* The root default action, read live (the config field is volatile) and
|
|
412
|
+
* validated. An unrecognised value — most often a `.volatile()` ref that
|
|
413
|
+
* reached the options un-unwrapped — fails **closed** to `ask`: previously it
|
|
414
|
+
* became the ruleset default verbatim, both the deny and ask branches missed
|
|
415
|
+
* it, and every unmatched call was allowed without review (issue #6).
|
|
416
|
+
*/
|
|
417
|
+
private effectiveDefaultAction;
|
|
392
418
|
/** Reload rules from disk (HMR); on failure keeps the previous rules. */
|
|
393
419
|
reload(): boolean;
|
|
394
420
|
get auditEntries(): readonly AuditEntry[];
|
|
@@ -462,9 +488,27 @@ export declare class PermGateRuntime {
|
|
|
462
488
|
private liveClassifySource;
|
|
463
489
|
/** The host model-group selection, or undefined when unusable. */
|
|
464
490
|
private hostModel;
|
|
491
|
+
/**
|
|
492
|
+
* Reasoning effort for host reviews. `undefined` **omits** the field so the host
|
|
493
|
+
* model configuration decides; the old hardcoded `'off'` was rejected by host
|
|
494
|
+
* validation for models whose advertised efforts exclude it (issue #8).
|
|
495
|
+
*/
|
|
496
|
+
private hostEffort;
|
|
465
497
|
private livePermissive;
|
|
466
498
|
/** Whether YOLO mode is live (never human-confirm). */
|
|
467
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;
|
|
468
512
|
/** Whether degraded review is live (no reviewer ⇒ rule-only gate). */
|
|
469
513
|
isRulesFallback(): boolean;
|
|
470
514
|
/** Whether script-body review is live (default on). */
|