dsh-perm-gate 5.4.2 → 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/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, "defined">;
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<"exempt" | "off", "exempt" | "off", "defined">;
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, "defined">;
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<"exempt" | "off", "exempt" | "off", "defined">;
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().default('deepseek-chat')),
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
- classifierModel: parsed.classifierModel ?? 'deepseek-chat',
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((result) => json(res, 200, { ...result, ok: true }), (e) => json(res, 500, { ok: false, error: String(e?.message ?? e) }));
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: 'off',
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 ((chunk.type === 'text-delta' || chunk.type === 'reasoning-delta') && typeof chunk.text === 'string') {
50
+ if (chunk.type === 'text-delta' && typeof chunk.text === 'string') {
35
51
  text += chunk.text;
36
52
  }
37
- else if (chunk.type === 'finish' && chunk.reason !== undefined && (chunk.reason.kind === 'error' || chunk.reason.kind === 'aborted')) {
38
- const failure = chunk.reason.failure?.message;
39
- return { ok: false, error: failure ?? chunk.reason.kind };
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
- ...config,
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
@@ -582,13 +590,26 @@ export function apply(ctx, config = {}) {
582
590
  // same namespace, so editing the settings card applies to the next tool call.
583
591
  readClassifyConfig: () => {
584
592
  const c = current();
593
+ const source = c.classifierSource === 'host' ? 'host' : 'custom';
585
594
  return {
586
595
  endpoint: c.classifierEndpoint,
587
- model: c.classifierModel,
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,
588
603
  apiKey: c.classifierApiKey,
589
604
  timeoutMs: c.riskTimeoutMs ?? 20_000,
590
605
  };
591
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
+ },
592
613
  // Read the verdict-learning switch and threshold live.
593
614
  readRiskLearning: () => {
594
615
  const c = current();
@@ -751,6 +772,9 @@ export function apply(ctx, config = {}) {
751
772
  // The host no longer seeds settings (there is no write path): an empty
752
773
  // `allowlist` keeps the rules-file default via `runtime.allowlist()`.
753
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;
754
778
  function applyVolatileUpdate() {
755
779
  const next = asSurface(current());
756
780
  if (next !== undefined) {
@@ -779,6 +803,17 @@ export function apply(ctx, config = {}) {
779
803
  if (changed)
780
804
  runtime.reload();
781
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
+ }
782
817
  }
783
818
  ;
784
819
  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
- // Lenient fallback for models that ignore response_format: bare keyword scan.
139
- // A bare "risky" carries no trustworthy category, so it stays unresolved —
140
- // the caller keeps the human ask rather than guessing a category.
141
- if (/\brisky\b/i.test(trimmed))
142
- return undefined;
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 classifier setup. */
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;
package/lib/runtime.js CHANGED
@@ -28,7 +28,7 @@ import { redactCommandText } from './redact.js';
28
28
  import { executableVariants, normalizeCommandText } from './command-normalize.js';
29
29
  import { completeViaHost, DEFAULT_HOST_MODEL } from './host-llm.js';
30
30
  import { chatCompletion } from './classifier.js';
31
- import { compileDocument, compileRulesObject, documentHash, extractPathCandidates, parsePermissionsDocument, } from './rule.js';
31
+ import { coerceRuleAction, compileDocument, compileRulesObject, documentHash, extractPathCandidates, parsePermissionsDocument, } from './rule.js';
32
32
  import { decomposeShellCommand } from './shell.js';
33
33
  import { extractAgentCandidates } from './agent-identity.js';
34
34
  import { resolveRuleChain } from './rule-chain.js';
@@ -289,10 +289,17 @@ export class PermGateRuntime {
289
289
  }
290
290
  compileInline(options) {
291
291
  const empty = {
292
- defaultAction: options.defaultAction ?? 'ask',
292
+ defaultAction: this.effectiveDefaultAction(options),
293
293
  deny: [], allow: [], ask: [],
294
294
  caseInsensitivePaths: options.caseInsensitivePaths ?? true,
295
295
  };
296
+ // Every other path yields a ruleset whose default came from a document; a
297
+ // document is validated, but the action is still coerced so one bad source
298
+ // can never hand the runtime an action it has no branch for.
299
+ const settle = (rs) => {
300
+ const action = coerceRuleAction(rs.defaultAction);
301
+ return action === rs.defaultAction ? rs : { ...rs, defaultAction: action };
302
+ };
296
303
  // Settings-first (dual-source): a configured `dsh-perm-gate-rules`
297
304
  // namespace overrides every file path — the gate then loads with zero
298
305
  // filesystem dependency. Bare defaults (nothing configured) fall through
@@ -303,20 +310,20 @@ export class PermGateRuntime {
303
310
  const hash = documentHash(JSON.stringify(settingsDoc));
304
311
  const hit = this.cache.get(hash);
305
312
  if (hit !== undefined)
306
- return hit;
313
+ return settle(hit);
307
314
  const compiled = compileRulesObject(settingsDoc, {
308
315
  maxGlobStars: 2,
309
316
  caseInsensitivePaths: options.caseInsensitivePaths ?? true,
310
317
  });
311
318
  this.cache.set(hash, compiled);
312
- return compiled;
319
+ return settle(compiled);
313
320
  }
314
321
  // Chain mode: when searchUp is enabled, use the multi-file chain resolver.
315
322
  if (options.searchUp === true) {
316
323
  const cwd = options.cwd ?? process.cwd();
317
324
  const rulesFile = options.rulesFile !== undefined ? options.rulesFile : 'rules.yml';
318
325
  try {
319
- return resolveRuleChain(cwd, {
326
+ return settle(resolveRuleChain(cwd, {
320
327
  rulesFile,
321
328
  searchUp: true,
322
329
  fallbackPath: options.fallbackPath,
@@ -325,7 +332,7 @@ export class PermGateRuntime {
325
332
  }, {
326
333
  maxGlobStars: 2,
327
334
  caseInsensitivePaths: options.caseInsensitivePaths ?? true,
328
- });
335
+ }));
329
336
  }
330
337
  catch {
331
338
  return empty;
@@ -338,14 +345,27 @@ export class PermGateRuntime {
338
345
  const hash = documentHash(text);
339
346
  const hit = this.cache.get(hash);
340
347
  if (hit !== undefined)
341
- return hit;
348
+ return settle(hit);
342
349
  const doc = parsePermissionsDocument(text);
343
350
  const compiled = compileDocument(doc, {
344
351
  maxGlobStars: 2,
345
352
  caseInsensitivePaths: options.caseInsensitivePaths ?? true,
346
353
  });
347
354
  this.cache.set(hash, compiled);
348
- return compiled;
355
+ return settle(compiled);
356
+ }
357
+ /**
358
+ * The root default action, read live (the config field is volatile) and
359
+ * validated. An unrecognised value — most often a `.volatile()` ref that
360
+ * reached the options un-unwrapped — fails **closed** to `ask`: previously it
361
+ * became the ruleset default verbatim, both the deny and ask branches missed
362
+ * it, and every unmatched call was allowed without review (issue #6).
363
+ */
364
+ effectiveDefaultAction(options) {
365
+ const raw = options.readDefaultAction !== undefined
366
+ ? options.readDefaultAction()
367
+ : options.defaultAction;
368
+ return coerceRuleAction(raw);
349
369
  }
350
370
  /** Reload rules from disk (HMR); on failure keeps the previous rules. */
351
371
  reload() {
@@ -659,6 +679,15 @@ export class PermGateRuntime {
659
679
  const sel = this.options.readHostModel?.();
660
680
  return sel !== undefined && sel.provider !== '' && sel.model !== '' ? sel : undefined;
661
681
  }
682
+ /**
683
+ * Reasoning effort for host reviews. `undefined` **omits** the field so the host
684
+ * model configuration decides; the old hardcoded `'off'` was rejected by host
685
+ * validation for models whose advertised efforts exclude it (issue #8).
686
+ */
687
+ hostEffort() {
688
+ const effort = this.options.readClassifyEffort?.();
689
+ return typeof effort === 'string' && effort !== '' ? effort : undefined;
690
+ }
662
691
  livePermissive() {
663
692
  return this.options.readPermissive !== undefined
664
693
  ? this.options.readPermissive()
@@ -1422,7 +1451,7 @@ export class PermGateRuntime {
1422
1451
  }
1423
1452
  else if (useHost) {
1424
1453
  const selection = this.hostModel() ?? DEFAULT_HOST_MODEL;
1425
- risk = await classifyRiskWith((system, user) => completeViaHost(this.options.hostLlm, selection, system, user, cfg?.timeoutMs ?? 20_000), req);
1454
+ risk = await classifyRiskWith((system, user) => completeViaHost(this.options.hostLlm, selection, system, user, cfg?.timeoutMs ?? 20_000, { effort: this.hostEffort() }), req);
1426
1455
  }
1427
1456
  else {
1428
1457
  risk = await classifyRisk(cfg ?? {}, req);
@@ -1624,7 +1653,7 @@ export class PermGateRuntime {
1624
1653
  }
1625
1654
  else if (useHost) {
1626
1655
  const selection = this.hostModel() ?? DEFAULT_HOST_MODEL;
1627
- risk = await classifyRiskWith((system, user) => completeViaHost(this.options.hostLlm, selection, system, user, cfg?.timeoutMs ?? 20_000), req);
1656
+ risk = await classifyRiskWith((system, user) => completeViaHost(this.options.hostLlm, selection, system, user, cfg?.timeoutMs ?? 20_000, { effort: this.hostEffort() }), req);
1628
1657
  }
1629
1658
  else {
1630
1659
  risk = await classifyRisk(cfg ?? {}, req);
@@ -2010,7 +2039,7 @@ export class PermGateRuntime {
2010
2039
  if (this.options.hostLlm === undefined)
2011
2040
  return done(false, 'host llm service unavailable');
2012
2041
  const selection = this.hostModel() ?? DEFAULT_HOST_MODEL;
2013
- const r = await completeViaHost(this.options.hostLlm, selection, 'Reply with exactly: OK', 'ping', cfg?.timeoutMs ?? 20_000);
2042
+ const r = await completeViaHost(this.options.hostLlm, selection, 'Reply with exactly: OK', 'ping', cfg?.timeoutMs ?? 20_000, { effort: this.hostEffort() });
2014
2043
  return r.ok ? done(true, `${selection.provider}/${selection.model} → ${r.content.trim().slice(0, 60) || 'ok'}`) : done(false, r.error ?? 'host llm call failed');
2015
2044
  }
2016
2045
  if (cfg?.endpoint === undefined || cfg.endpoint === '' || cfg?.model === undefined || cfg.model === '') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-perm-gate",
3
- "version": "5.4.2",
3
+ "version": "5.4.3",
4
4
  "publishConfig": {
5
5
  "registry": "https://registry.npmjs.org",
6
6
  "tag": "latest"