@goodandready/dsh-approval-gate 0.1.23 → 0.1.25

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.
@@ -0,0 +1,132 @@
1
+ // lib/rules-hmr.js
2
+ // Hot Module Reloading for Security Rules (#43).
3
+ // Watches .dsh/rules.yaml and declarative rule definitions using Node.js stdlib fs watchers.
4
+ // Recompiles rules in-memory atomically without dropping active sessions or restarting dsh.
5
+
6
+ import fs from "node:fs";
7
+
8
+ export const DEFAULT_HMR_DEBOUNCE_MS = 150;
9
+
10
+ /**
11
+ * Manages atomic hot reload watchers for rule definitions (#43).
12
+ */
13
+ export class RulesHmrWatcher {
14
+ /**
15
+ * @param {{ debounceMs?: number, logger?: any }} [options]
16
+ */
17
+ constructor(options = {}) {
18
+ this.debounceMs = options.debounceMs || DEFAULT_HMR_DEBOUNCE_MS;
19
+ this.logger = options.logger || null;
20
+ /** @type {Map<string, fs.FSWatcher>} */
21
+ this.watchers = new Map();
22
+ /** @type {NodeJS.Timeout | null} */
23
+ this.debounceTimer = null;
24
+ this.reloading = false;
25
+ }
26
+
27
+ /**
28
+ * Attaches file watchers for declarative rules files.
29
+ *
30
+ * @param {any} rulesEngine - Instance of DeclarativeRulesEngine
31
+ * @param {string[]} filePaths - Files or directories to watch
32
+ * @param {(info: { path: string, timestamp: number }) => void} [onReload]
33
+ * @returns {number} Count of successfully watched paths
34
+ */
35
+ watch(rulesEngine, filePaths = [], onReload = null) {
36
+ if (!rulesEngine || !Array.isArray(filePaths)) return 0;
37
+ let watchedCount = 0;
38
+
39
+ for (const targetPath of filePaths) {
40
+ if (!targetPath || typeof targetPath !== "string") continue;
41
+ if (this.watchers.has(targetPath)) continue;
42
+
43
+ try {
44
+ if (!fs.existsSync(targetPath)) continue;
45
+
46
+ const watcher = fs.watch(targetPath, { persistent: false }, (eventType, filename) => {
47
+ this._handleFileChange(rulesEngine, targetPath, onReload);
48
+ });
49
+
50
+ watcher.on("error", (err) => {
51
+ if (this.logger) this.logger.warn?.(`RulesHmrWatcher error on ${targetPath}:`, err);
52
+ this.unwatch(targetPath);
53
+ });
54
+
55
+ this.watchers.set(targetPath, watcher);
56
+ watchedCount += 1;
57
+ } catch (err) {
58
+ if (this.logger) this.logger.warn?.(`Failed to watch ${targetPath}:`, err);
59
+ }
60
+ }
61
+
62
+ return watchedCount;
63
+ }
64
+
65
+ /**
66
+ * Unwatches a specific file path.
67
+ * @param {string} targetPath
68
+ */
69
+ unwatch(targetPath) {
70
+ const watcher = this.watchers.get(targetPath);
71
+ if (watcher) {
72
+ try {
73
+ watcher.close();
74
+ } catch (_e) {}
75
+ this.watchers.delete(targetPath);
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Closes all active file watchers.
81
+ */
82
+ close() {
83
+ if (this.debounceTimer) {
84
+ clearTimeout(this.debounceTimer);
85
+ this.debounceTimer = null;
86
+ }
87
+ for (const [p, watcher] of this.watchers.entries()) {
88
+ try {
89
+ watcher.close();
90
+ } catch (_e) {}
91
+ }
92
+ this.watchers.clear();
93
+ }
94
+
95
+ /**
96
+ * @private
97
+ */
98
+ _handleFileChange(rulesEngine, targetPath, onReload) {
99
+ if (this.debounceTimer) {
100
+ clearTimeout(this.debounceTimer);
101
+ }
102
+
103
+ this.debounceTimer = setTimeout(() => {
104
+ this.debounceTimer = null;
105
+ this._triggerReload(rulesEngine, targetPath, onReload);
106
+ }, this.debounceMs);
107
+ }
108
+
109
+ /**
110
+ * @private
111
+ */
112
+ _triggerReload(rulesEngine, targetPath, onReload) {
113
+ if (this.reloading) return;
114
+ this.reloading = true;
115
+
116
+ try {
117
+ if (typeof rulesEngine.reload === "function") {
118
+ rulesEngine.reload();
119
+ } else if (typeof rulesEngine.loadRules === "function") {
120
+ rulesEngine.loadRules();
121
+ }
122
+
123
+ if (typeof onReload === "function") {
124
+ onReload({ path: targetPath, timestamp: Date.now() });
125
+ }
126
+ } catch (err) {
127
+ if (this.logger) this.logger.error?.(`Failed to reload rules for ${targetPath}:`, err);
128
+ } finally {
129
+ this.reloading = false;
130
+ }
131
+ }
132
+ }
@@ -0,0 +1,134 @@
1
+ // lib/similarity.js
2
+ // Semantic Command Similarity & Fast Validation (#85).
3
+ // Normalizes and validates syntactic variations of commands (e.g. pnpm test vs npm run test vs npm test).
4
+ // Provides canonical command normalization and optional fast Flash-model validator with deterministic fallback.
5
+
6
+ const PKG_MGR_REGEX = /^(?:pnpm|npm|yarn|bun)\b/;
7
+
8
+ /**
9
+ * Normalizes common command variants into canonical representation (#85).
10
+ *
11
+ * @param {string} command
12
+ * @returns {string}
13
+ */
14
+ export function canonicalizeCommand(command) {
15
+ if (!command || typeof command !== "string") return "";
16
+ let cmd = command.trim().replace(/\s+/g, " ");
17
+
18
+ // 1. Package manager scripts normalization
19
+ // pnpm test / npm run test / yarn test / bun test -> npm test
20
+ if (/^(?:pnpm|npm\s+run|yarn|bun)\s+test\b/.test(cmd)) {
21
+ return cmd.replace(/^(?:pnpm|npm\s+run|yarn|bun)\s+test/, "npm test");
22
+ }
23
+ // pnpm run <script> / yarn <script> / bun run <script> -> npm run <script>
24
+ if (/^(?:pnpm\s+run|yarn\s+run|bun\s+run)\s+([a-zA-Z0-9_:.-]+)/.test(cmd)) {
25
+ return cmd.replace(/^(?:pnpm\s+run|yarn\s+run|bun\s+run)/, "npm run");
26
+ }
27
+ if (/^(?:pnpm|yarn|bun)\s+(build|lint|check|start|dev)\b/.test(cmd)) {
28
+ return cmd.replace(/^(?:pnpm|yarn|bun)\s+/, "npm run ");
29
+ }
30
+
31
+ // 2. Python testing tools
32
+ // python -m pytest / python3 -m pytest -> pytest
33
+ if (/^python[23]?\s+-m\s+pytest\b/.test(cmd)) {
34
+ return cmd.replace(/^python[23]?\s+-m\s+pytest/, "pytest");
35
+ }
36
+
37
+ // 3. Git status / diff flags
38
+ if (/^git\s+status\s+(?:-s|--short)\b/.test(cmd)) {
39
+ return "git status";
40
+ }
41
+ if (/^git\s+diff\s+HEAD\b/.test(cmd)) {
42
+ return "git diff";
43
+ }
44
+
45
+ return cmd;
46
+ }
47
+
48
+ /**
49
+ * Checks whether two commands are semantically equivalent (#85).
50
+ *
51
+ * @param {string} cmdA
52
+ * @param {string} cmdB
53
+ * @returns {{ equivalent: boolean, confidence: number, method: string }}
54
+ */
55
+ export function isSemanticallyEquivalent(cmdA, cmdB) {
56
+ if (!cmdA || !cmdB) return { equivalent: false, confidence: 0, method: "none" };
57
+ const a = String(cmdA).trim();
58
+ const b = String(cmdB).trim();
59
+ if (a === b) {
60
+ return { equivalent: true, confidence: 1.0, method: "exact" };
61
+ }
62
+
63
+ const normA = canonicalizeCommand(a);
64
+ const normB = canonicalizeCommand(b);
65
+
66
+ if (normA && normA === normB) {
67
+ return { equivalent: true, confidence: 0.98, method: "canonical" };
68
+ }
69
+
70
+ return { equivalent: false, confidence: 0, method: "mismatch" };
71
+ }
72
+
73
+ /**
74
+ * Searches a list of learned rules for a semantically equivalent match (#85).
75
+ *
76
+ * @param {string} command
77
+ * @param {Array<{ command?: string, key?: string }>} learnedRules
78
+ * @returns {{ matched: boolean, matchedRule?: any, method?: string }}
79
+ */
80
+ export function findSemanticallyEquivalentRule(command, learnedRules = []) {
81
+ if (!command || !Array.isArray(learnedRules) || learnedRules.length === 0) {
82
+ return { matched: false };
83
+ }
84
+
85
+ for (const rule of learnedRules) {
86
+ const ruleCmd = rule.command || (rule.key && rule.key.includes(":") ? rule.key.slice(rule.key.indexOf(":") + 1) : "");
87
+ if (!ruleCmd) continue;
88
+
89
+ const check = isSemanticallyEquivalent(command, ruleCmd);
90
+ if (check.equivalent) {
91
+ return { matched: true, matchedRule: rule, method: check.method };
92
+ }
93
+ }
94
+
95
+ return { matched: false };
96
+ }
97
+
98
+ /**
99
+ * Validates semantic similarity with optional Flash-model LLM client (#85).
100
+ * Falls back safely to deterministic canonical matching on error or absence.
101
+ *
102
+ * @param {string} cmdA
103
+ * @param {string} cmdB
104
+ * @param {{ classify?: Function, model?: string }} [llmClient]
105
+ * @returns {Promise<{ equivalent: boolean, confidence: number, method: string }>}
106
+ */
107
+ export async function validateSemanticSimilarity(cmdA, cmdB, llmClient = null) {
108
+ // 1. Fast deterministic check
109
+ const det = isSemanticallyEquivalent(cmdA, cmdB);
110
+ if (det.equivalent) {
111
+ return det;
112
+ }
113
+
114
+ // 2. If no LLM client, return deterministic result
115
+ if (!llmClient || typeof llmClient.classify !== "function") {
116
+ return det;
117
+ }
118
+
119
+ try {
120
+ const prompt = `Are these two terminal commands semantically equivalent in purpose and safety?\nCommand 1: ${cmdA}\nCommand 2: ${cmdB}\nRespond with JSON: {"equivalent": true|false, "confidence": number}`;
121
+ const res = await llmClient.classify({ name: "semantic_check", prompt });
122
+ if (res && typeof res.equivalent === "boolean") {
123
+ return {
124
+ equivalent: res.equivalent,
125
+ confidence: typeof res.confidence === "number" ? res.confidence : 0.9,
126
+ method: "flash-model",
127
+ };
128
+ }
129
+ } catch (_err) {
130
+ // Fail closed to deterministic fallback
131
+ }
132
+
133
+ return det;
134
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-approval-gate",
3
- "version": "0.1.23",
3
+ "version": "0.1.25",
4
4
  "description": "Host-only command safety gate for DeepSeek Harness: blocks recognized dangerous operations and requests DSH approval when a command cannot be inspected.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -17,7 +17,8 @@
17
17
  "README.zh.md",
18
18
  "README.ru.md",
19
19
  "CHANGELOG.md",
20
- "LICENSE"
20
+ "LICENSE",
21
+ "bin"
21
22
  ],
22
23
  "dsh": {
23
24
  "bundle": {
@@ -38,7 +39,8 @@
38
39
  "url": "https://github.com/GooDAnDReaDY/dsh-approval-gate/issues"
39
40
  },
40
41
  "scripts": {
41
- "test": "node --test test/*.test.mjs"
42
+ "test": "node --test test/*.test.mjs",
43
+ "eval": "node bin/eval.mjs"
42
44
  },
43
45
  "publishConfig": {
44
46
  "registry": "https://registry.npmjs.org",
@@ -51,5 +53,8 @@
51
53
  "security",
52
54
  "approval-gate",
53
55
  "safety"
54
- ]
56
+ ],
57
+ "bin": {
58
+ "dsh-approval-eval": "./bin/eval.mjs"
59
+ }
55
60
  }