@goodandready/dsh-approval-gate 0.1.22 → 0.1.24
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/dossier.js +122 -0
- package/lib/drift.js +214 -0
- package/lib/dualtrack.js +101 -0
- package/lib/engine.js +67 -69
- package/lib/eval-runner.js +152 -0
- package/lib/index.js +10 -10
- package/lib/learning.js +229 -0
- package/lib/ledger.js +264 -164
- package/lib/reviewer.js +134 -0
- package/lib/session-modes.js +146 -0
- package/lib/similarity.js +134 -0
- package/package.json +60 -55
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// lib/session-modes.js
|
|
2
|
+
// Session-level Gate Mode Switching (#61).
|
|
3
|
+
// Allows per-session mode switching between auto-approval, hybrid, manual, and strict modes.
|
|
4
|
+
|
|
5
|
+
export const GATE_MODES = Object.freeze({
|
|
6
|
+
AUTO: "auto-approval",
|
|
7
|
+
MANUAL: "manual",
|
|
8
|
+
HYBRID: "hybrid",
|
|
9
|
+
STRICT: "strict",
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export const PRESET_MODE_MAP = Object.freeze({
|
|
13
|
+
sandbox: GATE_MODES.AUTO,
|
|
14
|
+
test: GATE_MODES.AUTO,
|
|
15
|
+
auto: GATE_MODES.AUTO,
|
|
16
|
+
"auto-approval": GATE_MODES.AUTO,
|
|
17
|
+
prod: GATE_MODES.STRICT,
|
|
18
|
+
production: GATE_MODES.STRICT,
|
|
19
|
+
strict: GATE_MODES.STRICT,
|
|
20
|
+
manual: GATE_MODES.MANUAL,
|
|
21
|
+
dev: GATE_MODES.HYBRID,
|
|
22
|
+
development: GATE_MODES.HYBRID,
|
|
23
|
+
hybrid: GATE_MODES.HYBRID,
|
|
24
|
+
standard: GATE_MODES.HYBRID,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolves active gate mode for a session based on presets, context, or defaults.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} [sessionId]
|
|
31
|
+
* @param {string} [sessionPreset]
|
|
32
|
+
* @param {Record<string, any>} [options]
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
export function resolveSessionGateMode(sessionId, sessionPreset, options = {}) {
|
|
36
|
+
// 1. Explicit mode in options or context
|
|
37
|
+
if (options.mode && Object.values(GATE_MODES).includes(options.mode)) {
|
|
38
|
+
return options.mode;
|
|
39
|
+
}
|
|
40
|
+
if (options.sessionMode && Object.values(GATE_MODES).includes(options.sessionMode)) {
|
|
41
|
+
return options.sessionMode;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 2. Preset mapping
|
|
45
|
+
const presetKey = String(sessionPreset || options.preset || "").toLowerCase().trim();
|
|
46
|
+
if (presetKey && PRESET_MODE_MAP[presetKey]) {
|
|
47
|
+
return PRESET_MODE_MAP[presetKey];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 3. Fallback default
|
|
51
|
+
return options.defaultMode || GATE_MODES.HYBRID;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Applies session gate mode policy to a verdict decision (#61).
|
|
56
|
+
*
|
|
57
|
+
* @param {string} mode - Gate mode
|
|
58
|
+
* @param {Record<string, any>} decision - Original verdict decision
|
|
59
|
+
* @param {{ track?: string, riskBucket?: string, lockedAsk?: boolean, isRead?: boolean }} [meta]
|
|
60
|
+
* @returns {Record<string, any>}
|
|
61
|
+
*/
|
|
62
|
+
export function applySessionModePolicy(mode, decision, meta = {}) {
|
|
63
|
+
if (!decision) return decision;
|
|
64
|
+
|
|
65
|
+
const currentMode = mode || GATE_MODES.HYBRID;
|
|
66
|
+
const isLocked = Boolean(decision.lockedAsk || meta.lockedAsk);
|
|
67
|
+
const track = meta.track || decision.track || "external";
|
|
68
|
+
const riskBucket = meta.riskBucket || decision.riskBucket || "medium";
|
|
69
|
+
|
|
70
|
+
// Auto-approval mode: relaxes low-risk or internal chores that would otherwise ask
|
|
71
|
+
if (currentMode === GATE_MODES.AUTO && decision.verdict === "ask" && !isLocked) {
|
|
72
|
+
const isInternal = track === "internal";
|
|
73
|
+
const isLowRisk = riskBucket === "low" || riskBucket === "minimal";
|
|
74
|
+
if (isInternal || isLowRisk) {
|
|
75
|
+
return {
|
|
76
|
+
...decision,
|
|
77
|
+
verdict: "allow",
|
|
78
|
+
autoApproved: true,
|
|
79
|
+
originalVerdict: "ask",
|
|
80
|
+
sessionMode: currentMode,
|
|
81
|
+
stage: "session-mode-auto",
|
|
82
|
+
autoReason: isInternal ? "auto-approve-internal-track" : "auto-approve-low-risk",
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Manual or strict mode: requires manual confirmation for external mutations or higher risk
|
|
88
|
+
if ((currentMode === GATE_MODES.MANUAL || currentMode === GATE_MODES.STRICT) && decision.verdict === "allow") {
|
|
89
|
+
const isSafeRead = Boolean(meta.isRead);
|
|
90
|
+
const isInternalSafe = track === "internal" && (riskBucket === "low" || riskBucket === "minimal");
|
|
91
|
+
if (!isSafeRead && !isInternalSafe) {
|
|
92
|
+
return {
|
|
93
|
+
...decision,
|
|
94
|
+
verdict: "ask",
|
|
95
|
+
manualRequired: true,
|
|
96
|
+
originalVerdict: "allow",
|
|
97
|
+
sessionMode: currentMode,
|
|
98
|
+
stage: "session-mode-manual",
|
|
99
|
+
manualReason: currentMode === GATE_MODES.STRICT ? "strict-mode-enforcement" : "manual-mode-enforcement",
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
...decision,
|
|
106
|
+
sessionMode: currentMode,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Registry for maintaining per-session gate modes (#61).
|
|
112
|
+
*/
|
|
113
|
+
export class SessionModeRegistry {
|
|
114
|
+
constructor(defaultMode = GATE_MODES.HYBRID) {
|
|
115
|
+
this.defaultMode = defaultMode;
|
|
116
|
+
/** @type {Map<string, string>} */
|
|
117
|
+
this.sessionModes = new Map();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
setMode(sessionId, mode) {
|
|
121
|
+
const key = sessionId || "default";
|
|
122
|
+
const resolved = PRESET_MODE_MAP[String(mode).toLowerCase().trim()] || mode;
|
|
123
|
+
if (Object.values(GATE_MODES).includes(resolved)) {
|
|
124
|
+
this.sessionModes.set(key, resolved);
|
|
125
|
+
return resolved;
|
|
126
|
+
}
|
|
127
|
+
return this.defaultMode;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
getMode(sessionId, preset) {
|
|
131
|
+
const key = sessionId || "default";
|
|
132
|
+
if (this.sessionModes.has(key)) {
|
|
133
|
+
return this.sessionModes.get(key);
|
|
134
|
+
}
|
|
135
|
+
return resolveSessionGateMode(sessionId, preset, { defaultMode: this.defaultMode });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
clearSession(sessionId) {
|
|
139
|
+
const key = sessionId || "default";
|
|
140
|
+
this.sessionModes.delete(key);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
reset() {
|
|
144
|
+
this.sessionModes.clear();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -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,55 +1,60 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@goodandready/dsh-approval-gate",
|
|
3
|
-
"version": "0.1.
|
|
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
|
-
"type": "module",
|
|
6
|
-
"main": "lib/index.js",
|
|
7
|
-
"exports": {
|
|
8
|
-
".": "./lib/index.js",
|
|
9
|
-
"./package.json": "./package.json",
|
|
10
|
-
"./cordis.patch.yml": "./cordis.patch.yml"
|
|
11
|
-
},
|
|
12
|
-
"files": [
|
|
13
|
-
"lib",
|
|
14
|
-
"rules",
|
|
15
|
-
"cordis.patch.yml",
|
|
16
|
-
"README.md",
|
|
17
|
-
"README.zh.md",
|
|
18
|
-
"README.ru.md",
|
|
19
|
-
"CHANGELOG.md",
|
|
20
|
-
"LICENSE"
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
"
|
|
29
|
-
|
|
30
|
-
"@deepseek-ai/
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
"
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
"
|
|
54
|
-
|
|
55
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@goodandready/dsh-approval-gate",
|
|
3
|
+
"version": "0.1.24",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/index.js",
|
|
9
|
+
"./package.json": "./package.json",
|
|
10
|
+
"./cordis.patch.yml": "./cordis.patch.yml"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"lib",
|
|
14
|
+
"rules",
|
|
15
|
+
"cordis.patch.yml",
|
|
16
|
+
"README.md",
|
|
17
|
+
"README.zh.md",
|
|
18
|
+
"README.ru.md",
|
|
19
|
+
"CHANGELOG.md",
|
|
20
|
+
"LICENSE",
|
|
21
|
+
"bin"
|
|
22
|
+
],
|
|
23
|
+
"dsh": {
|
|
24
|
+
"bundle": {
|
|
25
|
+
"patch": "./cordis.patch.yml"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
31
|
+
"@deepseek-ai/schemastery": "^3.18.1"
|
|
32
|
+
},
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/GooDAnDReaDY/dsh-approval-gate.git"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/GooDAnDReaDY/dsh-approval-gate#readme",
|
|
38
|
+
"bugs": {
|
|
39
|
+
"url": "https://github.com/GooDAnDReaDY/dsh-approval-gate/issues"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"test": "node --test test/*.test.mjs",
|
|
43
|
+
"eval": "node bin/eval.mjs"
|
|
44
|
+
},
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"registry": "https://registry.npmjs.org",
|
|
47
|
+
"access": "public"
|
|
48
|
+
},
|
|
49
|
+
"keywords": [
|
|
50
|
+
"dsh",
|
|
51
|
+
"dsh-plugin",
|
|
52
|
+
"deepseek-harness",
|
|
53
|
+
"security",
|
|
54
|
+
"approval-gate",
|
|
55
|
+
"safety"
|
|
56
|
+
],
|
|
57
|
+
"bin": {
|
|
58
|
+
"dsh-approval-eval": "./bin/eval.mjs"
|
|
59
|
+
}
|
|
60
|
+
}
|