@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.
- package/CHANGELOG.md +16 -0
- package/bin/eval.mjs +16 -0
- package/lib/engine.js +37 -23
- package/lib/eval-runner.js +152 -0
- package/lib/forward-proxy.js +218 -0
- package/lib/index.js +8 -0
- package/lib/learning.js +229 -0
- package/lib/network-sandbox.js +147 -0
- package/lib/race-decision.js +180 -0
- package/lib/reviewer.js +134 -0
- package/lib/rules-hmr.js +132 -0
- package/lib/similarity.js +134 -0
- package/package.json +9 -4
package/lib/rules-hmr.js
ADDED
|
@@ -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.
|
|
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
|
}
|