dsh-perm-gate 5.4.1 → 5.4.3
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 +3 -1
- package/README.zh.md +1 -1
- package/dsh.plugin.json +1 -1
- package/lib/client.js +64 -0
- package/lib/client.js.map +1 -1
- package/lib/config.d.ts +22 -6
- package/lib/config.js +6 -3
- package/lib/events.js +8 -1
- package/lib/host-llm.d.ts +19 -2
- package/lib/host-llm.js +31 -8
- package/lib/index.d.ts +46 -0
- package/lib/index.js +126 -20
- package/lib/risk.js +5 -7
- package/lib/rule.d.ts +11 -0
- package/lib/rule.js +13 -0
- package/lib/runtime.d.ts +27 -1
- package/lib/runtime.js +40 -11
- 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;
|
|
@@ -483,7 +497,8 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
|
|
|
483
497
|
caseInsensitivePaths: z<boolean, boolean, "defined">;
|
|
484
498
|
classifierEnabled: z<boolean, boolean, "defined">;
|
|
485
499
|
classifierEndpoint: z<string, string, "plain">;
|
|
486
|
-
classifierModel: z<string, string, "
|
|
500
|
+
classifierModel: z<string, string, "plain">;
|
|
501
|
+
classifierEffort: z<"" | "off" | "low" | "medium" | "high", "" | "off" | "low" | "medium" | "high", "defined">;
|
|
487
502
|
classifierApiKey: z<string, string, "plain">;
|
|
488
503
|
riskTimeoutMs: z<number, number, "defined">;
|
|
489
504
|
riskLearning: z<boolean, boolean, "defined">;
|
|
@@ -557,7 +572,7 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
|
|
|
557
572
|
sandboxEnabled: z<boolean, boolean, "defined">;
|
|
558
573
|
sandboxMode: z<"read-only" | "workspace-write", "read-only" | "workspace-write", "defined">;
|
|
559
574
|
sandboxProxy: z<string, string, "defined">;
|
|
560
|
-
sandboxLoopback: z<"
|
|
575
|
+
sandboxLoopback: z<"off" | "exempt", "off" | "exempt", "defined">;
|
|
561
576
|
rules: z<RulesConfig>;
|
|
562
577
|
watch: z<boolean, boolean, "defined">;
|
|
563
578
|
watchDebounceMs: z<number, number, "defined">;
|
|
@@ -568,7 +583,8 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
|
|
|
568
583
|
caseInsensitivePaths: z<boolean, boolean, "defined">;
|
|
569
584
|
classifierEnabled: z<boolean, boolean, "defined">;
|
|
570
585
|
classifierEndpoint: z<string, string, "plain">;
|
|
571
|
-
classifierModel: z<string, string, "
|
|
586
|
+
classifierModel: z<string, string, "plain">;
|
|
587
|
+
classifierEffort: z<"" | "off" | "low" | "medium" | "high", "" | "off" | "low" | "medium" | "high", "defined">;
|
|
572
588
|
classifierApiKey: z<string, string, "plain">;
|
|
573
589
|
riskTimeoutMs: z<number, number, "defined">;
|
|
574
590
|
riskLearning: z<boolean, boolean, "defined">;
|
|
@@ -642,7 +658,7 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
|
|
|
642
658
|
sandboxEnabled: z<boolean, boolean, "defined">;
|
|
643
659
|
sandboxMode: z<"read-only" | "workspace-write", "read-only" | "workspace-write", "defined">;
|
|
644
660
|
sandboxProxy: z<string, string, "defined">;
|
|
645
|
-
sandboxLoopback: z<"
|
|
661
|
+
sandboxLoopback: z<"off" | "exempt", "off" | "exempt", "defined">;
|
|
646
662
|
rules: z<RulesConfig>;
|
|
647
663
|
watch: z<boolean, boolean, "defined">;
|
|
648
664
|
watchDebounceMs: z<number, number, "defined">;
|
|
@@ -650,12 +666,12 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
|
|
|
650
666
|
/** Live reference the 0.1.7 loader hands `apply` for `.volatile()` config fields. */
|
|
651
667
|
export type VolatileRef<T> = Volatile<T>;
|
|
652
668
|
/** 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"];
|
|
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"];
|
|
654
670
|
/** Resolve one possibly-volatile field: a live ref on 0.1.7+, a plain value otherwise. */
|
|
655
671
|
export declare function readVolatileValue<T>(value: T | Volatile<T> | undefined): VolatileSnapshot<T> | undefined;
|
|
656
672
|
/** Shallow-resolve every volatile field into a plain snapshot (one per read). */
|
|
657
673
|
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'> & {
|
|
674
|
+
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
675
|
readonly permissiveStrategies: PermissiveStrategies;
|
|
660
676
|
readonly gatePresets: readonly string[];
|
|
661
677
|
};
|
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)),
|
|
@@ -264,7 +265,7 @@ export const Config = z.object({
|
|
|
264
265
|
/** The config fields marked `.volatile()` — live refs inside `apply`'s config. */
|
|
265
266
|
export const VOLATILE_CONFIG_KEYS = [
|
|
266
267
|
'defaultAction', 'caseInsensitivePaths', 'classifierEnabled', 'classifierEndpoint',
|
|
267
|
-
'classifierModel', 'classifierApiKey', 'riskTimeoutMs', 'riskLearning', 'riskSediment',
|
|
268
|
+
'classifierModel', 'classifierEffort', 'classifierApiKey', 'riskTimeoutMs', 'riskLearning', 'riskSediment',
|
|
268
269
|
'classifierSource', 'classifierProvider', 'riskThreshold', 'grantTtlMs', 'grantMaxUses',
|
|
269
270
|
'permissive', 'yolo', 'rulesFallback', 'unattendedTiers', 'safenetPass', 'safenetAsk', 'allowProtectedBranchPush', 'credWriteExemptFamilies', 'presetRulesVersion', 'scriptReview', 'permissiveStrategies', 'allowlist', 'denyKeywords', 'denyKeywordsExtra', 'credentialPaths', 'extraContentArgKeys', 'extraOperationArgKeys', 'presetRulesSeeded', 'autoAllowTools',
|
|
270
271
|
'sessionSweep', 'searchUp', 'badFilePolicy', 'maxChainLength',
|
|
@@ -303,7 +304,9 @@ export function resolveConfig(config = {}) {
|
|
|
303
304
|
caseInsensitivePaths: parsed.caseInsensitivePaths ?? true,
|
|
304
305
|
classifierEnabled: parsed.classifierEnabled ?? false,
|
|
305
306
|
classifierEndpoint: parsed.classifierEndpoint,
|
|
306
|
-
|
|
307
|
+
// Not defaulted on purpose: unset means "inherit the host model" (issue #8).
|
|
308
|
+
classifierModel: parsed.classifierModel,
|
|
309
|
+
classifierEffort: parsed.classifierEffort ?? '',
|
|
307
310
|
classifierApiKey: parsed.classifierApiKey,
|
|
308
311
|
riskTimeoutMs: parsed.riskTimeoutMs ?? 20_000,
|
|
309
312
|
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.d.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import type { Context } from '@deepseek-ai/cordis';
|
|
9
9
|
import { Config } from './config.js';
|
|
10
|
+
import { type SessionSender } from './events.js';
|
|
10
11
|
import { PermGateRuntime, type PreToolDecisionLike, type ToolExecutionLike } from './runtime.js';
|
|
11
12
|
export declare const name = "dsh-perm-gate";
|
|
12
13
|
/**
|
|
@@ -20,6 +21,51 @@ export declare const inject: string[];
|
|
|
20
21
|
export { Config };
|
|
21
22
|
/** Runtime settings namespace: `permissive` + `permissiveStrategies` (editable in the UI). */
|
|
22
23
|
export declare const PERMISSIVE_NAMESPACE = "dsh-perm-gate";
|
|
24
|
+
/**
|
|
25
|
+
* The `source.kind` the gate stamps on its own block opinions.
|
|
26
|
+
*
|
|
27
|
+
* The host's vocabulary is explicit about this: `source.kind === 'user'` (with an
|
|
28
|
+
* `rpcId`) means *a human typed it*, and `ui-trajectory` / `ui-chat` render any
|
|
29
|
+
* other kind as injected **context**, not as a queued user input. The host's own
|
|
30
|
+
* notices use the same convention (`user-approval`, `policy`, `time-context`…).
|
|
31
|
+
* A gate note is the gate talking, so it must not borrow the human's identity —
|
|
32
|
+
* otherwise the waiting area lists it as a message the person can plan, reorder
|
|
33
|
+
* or resend.
|
|
34
|
+
*/
|
|
35
|
+
export declare const BLOCK_NOTE_SOURCE_KIND = "perm-gate";
|
|
36
|
+
/**
|
|
37
|
+
* Deliver the gate's block opinion into the running turn.
|
|
38
|
+
*
|
|
39
|
+
* The opinion is model-facing context, not user input, so this uses the host's
|
|
40
|
+
* injection lane (`agent.inject`: same `next-step` band as steering, but the
|
|
41
|
+
* note waits for the next pre-step instead of waking a driver) and stamps
|
|
42
|
+
* {@link BLOCK_NOTE_SOURCE_KIND} on it. The role stays `user` — that is the only
|
|
43
|
+
* role a model reads as an instruction — but the source says who really spoke.
|
|
44
|
+
*
|
|
45
|
+
* Falls back to {@link buildSessionSender}'s steering lane when no live agent is
|
|
46
|
+
* reachable (a hosted runtime that only exposes the typert gateway): the note
|
|
47
|
+
* then arrives as a steer, which the UI may show as a pending message — worse,
|
|
48
|
+
* but never a dropped block reason.
|
|
49
|
+
*/
|
|
50
|
+
export declare function buildBlockNoteSender(get: (name: string) => unknown): SessionSender;
|
|
51
|
+
/**
|
|
52
|
+
* Deliver a message into a conversation. A DSH user message content must be a
|
|
53
|
+
* block array — a bare string is rendered per character by the GUI — and two
|
|
54
|
+
* channels are tried in order: the typert gateway, then the live agent's own
|
|
55
|
+
* inbox method.
|
|
56
|
+
*
|
|
57
|
+
* `mode` picks the lane, and the lanes are physically different queues:
|
|
58
|
+
*
|
|
59
|
+
* - `queue` → `agent.followup` → **next-turn**: a new user turn. That is the
|
|
60
|
+
* lane for an instruction the person means to type (the review page's
|
|
61
|
+
* "撤销此改动"), and it shares the queue the user's own input waits in.
|
|
62
|
+
* - `steer` → `agent.steer` → **next-step**: lands beside the tool result that
|
|
63
|
+
* is about to be delivered, inside the *running* turn. That is the lane for
|
|
64
|
+
* the gate's own block opinion — it must not cost the user a turn, must not
|
|
65
|
+
* look like something they typed, and must reach the model at the very next
|
|
66
|
+
* step boundary rather than after the turn ends.
|
|
67
|
+
*/
|
|
68
|
+
export declare function buildSessionSender(get: (name: string) => unknown, mode?: 'queue' | 'steer'): SessionSender;
|
|
23
69
|
/**
|
|
24
70
|
* Build the `approval/request` listener (exported for tests).
|
|
25
71
|
*
|
package/lib/index.js
CHANGED
|
@@ -52,18 +52,79 @@ function asSurface(value) {
|
|
|
52
52
|
: undefined;
|
|
53
53
|
}
|
|
54
54
|
/**
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
55
|
+
* The `source.kind` the gate stamps on its own block opinions.
|
|
56
|
+
*
|
|
57
|
+
* The host's vocabulary is explicit about this: `source.kind === 'user'` (with an
|
|
58
|
+
* `rpcId`) means *a human typed it*, and `ui-trajectory` / `ui-chat` render any
|
|
59
|
+
* other kind as injected **context**, not as a queued user input. The host's own
|
|
60
|
+
* notices use the same convention (`user-approval`, `policy`, `time-context`…).
|
|
61
|
+
* A gate note is the gate talking, so it must not borrow the human's identity —
|
|
62
|
+
* otherwise the waiting area lists it as a message the person can plan, reorder
|
|
63
|
+
* or resend.
|
|
59
64
|
*/
|
|
60
|
-
|
|
65
|
+
export const BLOCK_NOTE_SOURCE_KIND = 'perm-gate';
|
|
66
|
+
/**
|
|
67
|
+
* Deliver the gate's block opinion into the running turn.
|
|
68
|
+
*
|
|
69
|
+
* The opinion is model-facing context, not user input, so this uses the host's
|
|
70
|
+
* injection lane (`agent.inject`: same `next-step` band as steering, but the
|
|
71
|
+
* note waits for the next pre-step instead of waking a driver) and stamps
|
|
72
|
+
* {@link BLOCK_NOTE_SOURCE_KIND} on it. The role stays `user` — that is the only
|
|
73
|
+
* role a model reads as an instruction — but the source says who really spoke.
|
|
74
|
+
*
|
|
75
|
+
* Falls back to {@link buildSessionSender}'s steering lane when no live agent is
|
|
76
|
+
* reachable (a hosted runtime that only exposes the typert gateway): the note
|
|
77
|
+
* then arrives as a steer, which the UI may show as a pending message — worse,
|
|
78
|
+
* but never a dropped block reason.
|
|
79
|
+
*/
|
|
80
|
+
export function buildBlockNoteSender(get) {
|
|
81
|
+
const steer = buildSessionSender(get, 'steer');
|
|
82
|
+
return async (sessionId, content) => {
|
|
83
|
+
const agents = get('agents');
|
|
84
|
+
if (agents !== undefined && typeof agents.get === 'function') {
|
|
85
|
+
const agent = agents.get(sessionId);
|
|
86
|
+
if (agent !== undefined && typeof agent.inject === 'function') {
|
|
87
|
+
try {
|
|
88
|
+
agent.inject({
|
|
89
|
+
id: 'pg-note-' + Date.now().toString(36) + '-' + Math.floor(Math.random() * 1e9).toString(36),
|
|
90
|
+
role: 'user',
|
|
91
|
+
content: [{ type: 'text', text: content }],
|
|
92
|
+
source: { kind: BLOCK_NOTE_SOURCE_KIND },
|
|
93
|
+
});
|
|
94
|
+
return { ok: true, via: 'inject' };
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// fall through to the steering lane
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return steer(sessionId, content);
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Deliver a message into a conversation. A DSH user message content must be a
|
|
106
|
+
* block array — a bare string is rendered per character by the GUI — and two
|
|
107
|
+
* channels are tried in order: the typert gateway, then the live agent's own
|
|
108
|
+
* inbox method.
|
|
109
|
+
*
|
|
110
|
+
* `mode` picks the lane, and the lanes are physically different queues:
|
|
111
|
+
*
|
|
112
|
+
* - `queue` → `agent.followup` → **next-turn**: a new user turn. That is the
|
|
113
|
+
* lane for an instruction the person means to type (the review page's
|
|
114
|
+
* "撤销此改动"), and it shares the queue the user's own input waits in.
|
|
115
|
+
* - `steer` → `agent.steer` → **next-step**: lands beside the tool result that
|
|
116
|
+
* is about to be delivered, inside the *running* turn. That is the lane for
|
|
117
|
+
* the gate's own block opinion — it must not cost the user a turn, must not
|
|
118
|
+
* look like something they typed, and must reach the model at the very next
|
|
119
|
+
* step boundary rather than after the turn ends.
|
|
120
|
+
*/
|
|
121
|
+
export function buildSessionSender(get, mode = 'queue') {
|
|
61
122
|
return async (sessionId, content) => {
|
|
62
123
|
const textBlock = [{ type: 'text', text: content }];
|
|
63
124
|
const gateway = get('typertGateway');
|
|
64
125
|
if (gateway !== undefined && typeof gateway.invoke === 'function') {
|
|
65
126
|
try {
|
|
66
|
-
await gateway.invoke({ namespace: 'session', method: 'prompt', args: { sessionId, mode
|
|
127
|
+
await gateway.invoke({ namespace: 'session', method: 'prompt', args: { sessionId, mode, content: textBlock } });
|
|
67
128
|
return { ok: true, via: 'gateway' };
|
|
68
129
|
}
|
|
69
130
|
catch {
|
|
@@ -73,14 +134,18 @@ function buildSessionSender(get) {
|
|
|
73
134
|
const agents = get('agents');
|
|
74
135
|
if (agents !== undefined && typeof agents.get === 'function') {
|
|
75
136
|
const agent = agents.get(sessionId);
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
137
|
+
// The fallback stays on the requested lane: silently degrading a steer to
|
|
138
|
+
// followup would put the block opinion back in the user's turn queue — the
|
|
139
|
+
// exact confusion this lane split exists to remove.
|
|
140
|
+
const push = mode === 'steer' ? agent?.steer : agent?.followup;
|
|
141
|
+
if (agent !== undefined && typeof push === 'function') {
|
|
142
|
+
push.call(agent, {
|
|
143
|
+
id: `pg-${mode}-` + Date.now().toString(36) + '-' + Math.floor(Math.random() * 1e9).toString(36),
|
|
79
144
|
role: 'user',
|
|
80
145
|
content: textBlock,
|
|
81
146
|
source: { kind: 'user' },
|
|
82
147
|
});
|
|
83
|
-
return { ok: true, via:
|
|
148
|
+
return { ok: true, via: mode };
|
|
84
149
|
}
|
|
85
150
|
}
|
|
86
151
|
return { ok: false, error: '没有可用的消息投递通道' };
|
|
@@ -445,7 +510,15 @@ export function apply(ctx, config = {}) {
|
|
|
445
510
|
// channel in buildSessionSender.
|
|
446
511
|
let approvalService;
|
|
447
512
|
const runtime = new PermGateRuntime({
|
|
448
|
-
|
|
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,
|
|
449
522
|
hostLlm,
|
|
450
523
|
// Raise a network approval through the DSH approval seam. The service
|
|
451
524
|
// applies the session policy, routes the prompt to the agent, and appends
|
|
@@ -517,13 +590,26 @@ export function apply(ctx, config = {}) {
|
|
|
517
590
|
// same namespace, so editing the settings card applies to the next tool call.
|
|
518
591
|
readClassifyConfig: () => {
|
|
519
592
|
const c = current();
|
|
593
|
+
const source = c.classifierSource === 'host' ? 'host' : 'custom';
|
|
520
594
|
return {
|
|
521
595
|
endpoint: c.classifierEndpoint,
|
|
522
|
-
|
|
596
|
+
// Host mode: an unset model means "inherit the host selection" — the old
|
|
597
|
+
// schema default (`deepseek-chat`) came back on every clear and was then
|
|
598
|
+
// treated as an explicit override, so provider and model diverged (issue
|
|
599
|
+
// #8). Custom-endpoint mode keeps the historical default.
|
|
600
|
+
model: typeof c.classifierModel === 'string' && c.classifierModel !== ''
|
|
601
|
+
? c.classifierModel
|
|
602
|
+
: source === 'custom' ? 'deepseek-chat' : undefined,
|
|
523
603
|
apiKey: c.classifierApiKey,
|
|
524
604
|
timeoutMs: c.riskTimeoutMs ?? 20_000,
|
|
525
605
|
};
|
|
526
606
|
},
|
|
607
|
+
// Host reviews: an empty effort sends no `reasoningEffort` at all, so the host
|
|
608
|
+
// model configuration decides instead of a value it may reject (issue #8).
|
|
609
|
+
readClassifyEffort: () => {
|
|
610
|
+
const effort = current().classifierEffort;
|
|
611
|
+
return typeof effort === 'string' && effort !== '' ? effort : undefined;
|
|
612
|
+
},
|
|
527
613
|
// Read the verdict-learning switch and threshold live.
|
|
528
614
|
readRiskLearning: () => {
|
|
529
615
|
const c = current();
|
|
@@ -686,6 +772,9 @@ export function apply(ctx, config = {}) {
|
|
|
686
772
|
// The host no longer seeds settings (there is no write path): an empty
|
|
687
773
|
// `allowlist` keeps the rules-file default via `runtime.allowlist()`.
|
|
688
774
|
let lastRulesDocKey;
|
|
775
|
+
// Root compile-time inputs (default action, path case-fold). Seeded on the first
|
|
776
|
+
// pass so a startup value is not mistaken for an edit.
|
|
777
|
+
let lastStaticKey;
|
|
689
778
|
function applyVolatileUpdate() {
|
|
690
779
|
const next = asSurface(current());
|
|
691
780
|
if (next !== undefined) {
|
|
@@ -714,6 +803,17 @@ export function apply(ctx, config = {}) {
|
|
|
714
803
|
if (changed)
|
|
715
804
|
runtime.reload();
|
|
716
805
|
}
|
|
806
|
+
// The root `defaultAction` (and the path case-fold) are compile-time inputs of
|
|
807
|
+
// the ruleset. Without this the settings switch persisted but never reached the
|
|
808
|
+
// live ruleset — "default value changes also miss invalidation" (issue #6).
|
|
809
|
+
const staticKey = `${String(current().defaultAction)}\u0000${String(current().caseInsensitivePaths)}`;
|
|
810
|
+
if (lastStaticKey === undefined) {
|
|
811
|
+
lastStaticKey = staticKey;
|
|
812
|
+
}
|
|
813
|
+
else if (lastStaticKey !== staticKey) {
|
|
814
|
+
lastStaticKey = staticKey;
|
|
815
|
+
runtime.reload();
|
|
816
|
+
}
|
|
717
817
|
}
|
|
718
818
|
;
|
|
719
819
|
ctx.on('loader/volatile-update', () => {
|
|
@@ -853,11 +953,17 @@ export function apply(ctx, config = {}) {
|
|
|
853
953
|
catch {
|
|
854
954
|
// webServer unavailable: the feed remains disk-only, sediment is view-only in files
|
|
855
955
|
}
|
|
856
|
-
//
|
|
857
|
-
// any mode),
|
|
858
|
-
//
|
|
859
|
-
//
|
|
860
|
-
|
|
956
|
+
// Block feedback: when a call is blocked (YOLO / mechanical / degraded-review block
|
|
957
|
+
// **or** a deterministic deny in any mode), the gate hands the main LLM a
|
|
958
|
+
// `工具审核意见` note telling it why, which rule fired and what to do instead.
|
|
959
|
+
//
|
|
960
|
+
// The note is context the gate injects, not something the person typed:
|
|
961
|
+
// `buildBlockNoteSender` uses `agent.inject` (next-step band, no driver wake) and
|
|
962
|
+
// stamps its own `source.kind`, so it lands beside the refused tool result at the
|
|
963
|
+
// next pre-step, never in the next-turn queue the user's own input waits in, and
|
|
964
|
+
// never claims to be a human message in the waiting area.
|
|
965
|
+
// Best-effort; a failed delivery never un-blocks.
|
|
966
|
+
const blockSender = buildBlockNoteSender(fallbackGet);
|
|
861
967
|
const blockedSubject = (exec) => {
|
|
862
968
|
const args = exec.arguments ?? {};
|
|
863
969
|
const cmd = (typeof exec.commandText === 'string' && exec.commandText !== ''
|
|
@@ -868,18 +974,18 @@ export function apply(ctx, config = {}) {
|
|
|
868
974
|
const p = ['file_path', 'path', 'cwd', 'workdir'].map((k) => args[k]).find((v) => typeof v === 'string');
|
|
869
975
|
return `${exec.name}${typeof p === 'string' ? ': ' + p : ''}`;
|
|
870
976
|
};
|
|
871
|
-
const
|
|
977
|
+
const blockFeedback = (exec, decision) => {
|
|
872
978
|
const sessionId = (typeof exec.sessionId === 'string' && exec.sessionId !== ''
|
|
873
979
|
? exec.sessionId
|
|
874
980
|
: (exec.agent?.sessionId ?? exec.agent?.session?.id) ?? '');
|
|
875
981
|
if (sessionId === '')
|
|
876
982
|
return;
|
|
877
983
|
const { text } = composeReviewOpinion({ command: blockedSubject(exec), reason: decision.reason, locale: 'zh' });
|
|
878
|
-
void
|
|
984
|
+
void blockSender(sessionId, `工具审核意见: "${text}"`).catch(() => {
|
|
879
985
|
// no delivery channel — the block still stands, the LLM just gets no note.
|
|
880
986
|
});
|
|
881
987
|
};
|
|
882
|
-
const listener = makePreExecuteListener(runtime,
|
|
988
|
+
const listener = makePreExecuteListener(runtime, blockFeedback);
|
|
883
989
|
// `tools/pre-execute` / `tools/result` / `approval/request` are service events,
|
|
884
990
|
// not part of cordis core's typed `Events`, so they are registered through the
|
|
885
991
|
// string overload.
|
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;
|
|
@@ -177,8 +184,13 @@ export interface PermGateRuntimeOptions extends PermGateConfig {
|
|
|
177
184
|
* Defaults to on (`config.scriptReview`).
|
|
178
185
|
*/
|
|
179
186
|
readonly readScriptReview?: () => boolean;
|
|
180
|
-
/** Optional live source of the llmAssist
|
|
187
|
+
/** Optional live source of the llmAssist receiver setup (endpoint/model/secret/timeout). */
|
|
181
188
|
readonly readClassifyConfig?: () => ClassifierConfig;
|
|
189
|
+
/**
|
|
190
|
+
* Optional live source of the host-review reasoning effort. Undefined/empty
|
|
191
|
+
* omits the field entirely — see {@link hostEffort} (issue #8).
|
|
192
|
+
*/
|
|
193
|
+
readonly readClassifyEffort?: () => string | undefined;
|
|
182
194
|
/**
|
|
183
195
|
* Optional live receiver source: `'custom'` (an OpenAI-compatible endpoint,
|
|
184
196
|
* default) or `'host'` (the DSH host `llm` service — the model-group setup
|
|
@@ -389,6 +401,14 @@ export declare class PermGateRuntime {
|
|
|
389
401
|
private readonly cleared;
|
|
390
402
|
constructor(options?: PermGateRuntimeOptions);
|
|
391
403
|
private compileInline;
|
|
404
|
+
/**
|
|
405
|
+
* The root default action, read live (the config field is volatile) and
|
|
406
|
+
* validated. An unrecognised value — most often a `.volatile()` ref that
|
|
407
|
+
* reached the options un-unwrapped — fails **closed** to `ask`: previously it
|
|
408
|
+
* became the ruleset default verbatim, both the deny and ask branches missed
|
|
409
|
+
* it, and every unmatched call was allowed without review (issue #6).
|
|
410
|
+
*/
|
|
411
|
+
private effectiveDefaultAction;
|
|
392
412
|
/** Reload rules from disk (HMR); on failure keeps the previous rules. */
|
|
393
413
|
reload(): boolean;
|
|
394
414
|
get auditEntries(): readonly AuditEntry[];
|
|
@@ -462,6 +482,12 @@ export declare class PermGateRuntime {
|
|
|
462
482
|
private liveClassifySource;
|
|
463
483
|
/** The host model-group selection, or undefined when unusable. */
|
|
464
484
|
private hostModel;
|
|
485
|
+
/**
|
|
486
|
+
* Reasoning effort for host reviews. `undefined` **omits** the field so the host
|
|
487
|
+
* model configuration decides; the old hardcoded `'off'` was rejected by host
|
|
488
|
+
* validation for models whose advertised efforts exclude it (issue #8).
|
|
489
|
+
*/
|
|
490
|
+
private hostEffort;
|
|
465
491
|
private livePermissive;
|
|
466
492
|
/** Whether YOLO mode is live (never human-confirm). */
|
|
467
493
|
isYolo(): boolean;
|