dsh-perm-gate 4.1.3 → 4.1.5

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/dsh.plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "dsh-perm-gate",
3
3
  "name": "dsh-perm-gate",
4
- "version": "4.1.3",
4
+ "version": "4.1.5",
5
5
  "description": {
6
6
  "en": "Deterministic-first, fail-closed permission gate for DeepSeek Harness 0.1.2+: a P0 hard-deny -> P1 precise session grant -> P2 static allow/deny rule chain -> P3 optional LLM classifier -> P4 ask pipeline over the tools/pre-execute waterfall, with a standalone CLI dry-run evaluator and an auditable decision log. It only vetoes with deny/ask and always delegates otherwise, and unknown or malformed rules fail loud instead of silently standing the gate down.",
7
7
  "zh": "面向 DeepSeek Harness 0.1.2+ 的确定性优先、失败即拒的权限闸门:在 tools/pre-execute 瀑布上串起 P0 硬拒绝 → P1 精确会话授权 → P2 静态 allow/deny 规则链 → P3 可选 LLM 语义分类 → P4 ask,并附带独立 CLI 干跑评估器与可审计的决策日志。只以 deny/ask 否决、其余一律放行,规则未知或非法时直接报错,绝不静默失效。"
@@ -44,3 +44,11 @@ export declare function listAllowCommands(rulesFile: string): string[];
44
44
  * @param reason - reason recorded on each generated allow entry.
45
45
  */
46
46
  export declare function replaceAllowCommands(rulesFile: string, patterns: readonly string[], reason?: string): boolean;
47
+ /**
48
+ * Parse the rules file into a plain document — the seed for the first
49
+ * settings-backed allowlist write, so migrating a file-defined document into
50
+ * the rules namespace cannot drop its deny/ask sections. The file's optional
51
+ * `permissions:` wrapper is unwrapped (the settings document stores the bare
52
+ * form). Returns `{}` when the file is absent or unreadable.
53
+ */
54
+ export declare function readRulesFileDoc(rulesFile: string): unknown;
package/lib/allowlist.js CHANGED
@@ -197,3 +197,26 @@ function readFileSafe(p) {
197
197
  return '';
198
198
  }
199
199
  }
200
+ /**
201
+ * Parse the rules file into a plain document — the seed for the first
202
+ * settings-backed allowlist write, so migrating a file-defined document into
203
+ * the rules namespace cannot drop its deny/ask sections. The file's optional
204
+ * `permissions:` wrapper is unwrapped (the settings document stores the bare
205
+ * form). Returns `{}` when the file is absent or unreadable.
206
+ */
207
+ export function readRulesFileDoc(rulesFile) {
208
+ const text = readFileSafe(rulesFile);
209
+ if (text === '')
210
+ return {};
211
+ try {
212
+ const root = parse(text);
213
+ if (root !== null && typeof root === 'object' && !Array.isArray(root)
214
+ && root['permissions'] !== null && typeof root['permissions'] === 'object') {
215
+ return root['permissions'];
216
+ }
217
+ return root ?? {};
218
+ }
219
+ catch {
220
+ return {};
221
+ }
222
+ }
package/lib/config.d.ts CHANGED
@@ -276,14 +276,17 @@ export interface RulesConfig {
276
276
  readonly deny?: unknown[];
277
277
  readonly allow?: unknown[];
278
278
  readonly ask?: unknown[];
279
+ /** Activation-time seed marker (see RulesSchema). */
280
+ readonly initialized?: boolean;
279
281
  }
280
282
  /** Settings namespace for rules (separate from the main perm-gate namespace). */
281
283
  export declare const RULES_NAMESPACE = "dsh-perm-gate-rules";
282
284
  /**
283
285
  * Whether a settings-sourced rules document carries a REAL configuration —
284
- * entries or a non-default `defaultAction`. A namespace still holding bare
285
- * schema defaults is "not configured" and must not shadow the rules file
286
- * (the dual-source contract: settings first, file fallback).
286
+ * entries, a non-default `defaultAction`, or the `initialized` marker the
287
+ * activation-time seed writes after populating an empty document. A namespace
288
+ * still holding bare schema defaults is "not configured" and must not shadow
289
+ * the rules file (the dual-source contract: settings first, file fallback).
287
290
  */
288
291
  export declare function isRulesConfigured(value: unknown): boolean;
289
292
  /**
package/lib/config.js CHANGED
@@ -113,19 +113,26 @@ export const RulesSchema = z.object({
113
113
  deny: z.array(RuleEntrySchema).default([]),
114
114
  allow: z.array(RuleEntrySchema).default([]),
115
115
  ask: z.array(RuleEntrySchema).default([]),
116
+ // Activation-time seed marker: the gate writes `true` the first time it
117
+ // populates an empty document (file migration or bare starter), so the
118
+ // seeded namespace becomes the rules source instead of shadowing nothing.
119
+ initialized: z.boolean().default(false),
116
120
  });
117
121
  /** Settings namespace for rules (separate from the main perm-gate namespace). */
118
122
  export const RULES_NAMESPACE = 'dsh-perm-gate-rules';
119
123
  /**
120
124
  * Whether a settings-sourced rules document carries a REAL configuration —
121
- * entries or a non-default `defaultAction`. A namespace still holding bare
122
- * schema defaults is "not configured" and must not shadow the rules file
123
- * (the dual-source contract: settings first, file fallback).
125
+ * entries, a non-default `defaultAction`, or the `initialized` marker the
126
+ * activation-time seed writes after populating an empty document. A namespace
127
+ * still holding bare schema defaults is "not configured" and must not shadow
128
+ * the rules file (the dual-source contract: settings first, file fallback).
124
129
  */
125
130
  export function isRulesConfigured(value) {
126
131
  if (typeof value !== 'object' || value === null || Array.isArray(value))
127
132
  return false;
128
133
  const doc = value;
134
+ if (doc.initialized === true)
135
+ return true;
129
136
  if (doc.defaultAction !== undefined && doc.defaultAction !== 'ask')
130
137
  return true;
131
138
  for (const key of ['deny', 'allow', 'ask']) {
package/lib/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- import { Config, RULES_NAMESPACE, RulesSchema, readRulesFromSettings, resolveDshHome, resolveDataDir, ensureDataDir, resolveGatePresets, resolvePermissiveStrategies, resolveRulesFile } from './config.js';
3
+ import { Config, RULES_NAMESPACE, RulesSchema, readRulesFromSettings, resolveDshHome, resolveDataDir, ensureDataDir, resolveGatePresets, resolvePermissiveStrategies, resolveRulesFile, isRulesConfigured } from './config.js';
4
+ import { appendAllowToSettings, readRulesFileDoc, replaceAllowInSettings } from './allowlist.js';
4
5
  import { runDryRun } from './dry-run.js';
5
6
  import { registerDryRunRoute, registerEventsRoute, registerHealthRoute, registerLearningRoute, registerNetworkRoute, registerReceiverRoute, registerReviewRoutes, registerRulesRoute } from './events.js';
6
7
  import { buildReceiverInfo } from './receiver-info.js';
@@ -194,13 +195,29 @@ export function apply(ctx, config = {}) {
194
195
  let current = () => config;
195
196
  // ─── Rules settings scope (dsh-perm-gate-rules namespace) ────────────────
196
197
  // Holds the live settings-sourced rules document. When non-undefined, the
197
- // runtime prefers it over the rules file on disk.
198
+ // runtime prefers it over the rules file on disk. The write-capable view is
199
+ // captured separately so allowlist writes can go through the namespace.
198
200
  let rulesScope;
201
+ let rulesWriteScope;
199
202
  const readRulesDocument = () => readRulesFromSettings(rulesScope);
200
203
  // Plugin-owned data files live under $DSH_HOME (node_modules may be
201
204
  // read-only). The default resolves to `$DSH_HOME` / `~/.dsh`, so a profile
202
205
  // entry that omits `config` still records events, snapshots and learning.
203
206
  const dataDir = resolveDataDir(typeof config.dshHome === 'string' ? config.dshHome : undefined);
207
+ const rulesFilePath = resolveRulesFile(typeof config.rulesFile === 'string' ? config.rulesFile : undefined, dataDir);
208
+ // Allowlist write-back through the rules namespace: "allow always" and the
209
+ // panel's allowlist editor land in the live settings document (seeded from
210
+ // the rules file on first write) instead of the file the document shadows.
211
+ // A falsy verdict (namespace not wired yet) falls back to the rules file.
212
+ const settingsWrite = (write) => {
213
+ const scope = rulesWriteScope;
214
+ if (scope === undefined)
215
+ return false;
216
+ void write(scope, readRulesFileDoc(rulesFilePath)).catch((e) => {
217
+ console.warn('[dsh-perm-gate] settings rules write failed:', e);
218
+ });
219
+ return true;
220
+ };
204
221
  // Host model-group services (typed minimally; supplied by the dsh runtime
205
222
  // through the loader `inject` — dsh-approval-gate demonstrates the same
206
223
  // contract). The llm service backs the `host` receiver; agentDefaultModel
@@ -342,9 +359,15 @@ export function apply(ctx, config = {}) {
342
359
  // The rules document lives in the plugin's data dir by default, so a
343
360
  // `$DSH_HOME/perm-gate/rules.yml` the user writes is actually loaded without
344
361
  // also having to declare `rulesFile` in the composition entry.
345
- rulesFile: resolveRulesFile(typeof config.rulesFile === 'string' ? config.rulesFile : undefined, dataDir),
362
+ rulesFile: rulesFilePath,
346
363
  // Settings-sourced rules take precedence over the file when present.
347
364
  readRulesDocument,
365
+ // Allowlist write-back (see settingsWrite above); falsy verdict falls back
366
+ // to the rules file paths.
367
+ allowlistWriter: {
368
+ append: (pattern, reason) => settingsWrite((scope, seed) => appendAllowToSettings(scope, pattern, reason, seed)),
369
+ replace: (patterns, reason) => settingsWrite((scope, seed) => replaceAllowInSettings(scope, patterns, reason, seed)),
370
+ },
348
371
  // The whole gate is scoped to the presets that opt into it (default: the
349
372
  // `permissive` tier this plugin adds); elsewhere it stands down entirely.
350
373
  gatePresets: resolveGatePresets(Array.isArray(config.gatePresets) ? config.gatePresets : undefined),
@@ -473,9 +496,21 @@ export function apply(ctx, config = {}) {
473
496
  onChange: () => { },
474
497
  onScope: (scope) => {
475
498
  rulesScope = scope;
499
+ rulesWriteScope = scope;
476
500
  // When rules change in settings, reload the runtime so the new ruleset
477
501
  // takes effect on the next tool call.
478
502
  scope.watch(() => { runtime.reload(); });
503
+ // Activation-time seed: an empty (never-configured) namespace is
504
+ // populated once — from the rules file when one exists (implicit
505
+ // migration), otherwise a bare starter document — and marked
506
+ // `initialized`, so the panel shows a live rules document from the
507
+ // first boot instead of a missing-file fallback.
508
+ if (!isRulesConfigured(scope.get())) {
509
+ const seed = { ...readRulesFileDoc(rulesFilePath), initialized: true };
510
+ void Promise.resolve(scope.update(seed)).catch((e) => {
511
+ console.warn('[dsh-perm-gate] rules seed write failed:', e);
512
+ });
513
+ }
479
514
  },
480
515
  });
481
516
  // Event feed HTTP API (best effort): the dsh webServer service exposes the
package/lib/rule.js CHANGED
@@ -71,7 +71,8 @@ export function parsePermissionsObject(raw) {
71
71
  if (!isRecord(raw))
72
72
  throw new RuleError('permissions document must be a mapping');
73
73
  const root = isRecord(raw.permissions) ? raw.permissions : raw;
74
- const unknown = Object.keys(root).filter((k) => !VALID_ACTIONS.includes(k) && k !== 'defaultAction');
74
+ // `initialized` is the activation-time seed marker (RulesSchema), not a rule.
75
+ const unknown = Object.keys(root).filter((k) => !VALID_ACTIONS.includes(k) && k !== 'defaultAction' && k !== 'initialized');
75
76
  if (unknown.length > 0) {
76
77
  throw new RuleError(`unknown permissions field${unknown.length > 1 ? 's' : ''} ${unknown.map((k) => JSON.stringify(k)).join(', ')}`);
77
78
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-perm-gate",
3
- "version": "4.1.3",
3
+ "version": "4.1.5",
4
4
  "publishConfig": {
5
5
  "registry": "https://registry.npmjs.org",
6
6
  "access": "public",