@goodandready/dsh-approval-gate 0.1.13 → 0.1.15

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,175 @@
1
+ // lib/rules-loader.js
2
+ // Cascading declarative YAML rules loader and evaluator for dsh-approval-gate (#38, #45, #44, #93).
3
+ // Evaluates rules cascading from workspace (.dsh/rules.yaml), user (~/.dsh/rules.yaml) and builtin threats.
4
+
5
+ import fs from "node:fs";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ import { parseYaml } from "./yaml-parser.js";
9
+ import { evaluateWhen } from "./when-matcher.js";
10
+ import { HotwordsMatcher } from "./hotwords.js";
11
+
12
+ export const BUILTIN_RULES_FILE = path.join("rules", "builtin-high-risk.yaml");
13
+ export const WORKSPACE_RULES_REL = path.join(".dsh", "rules.yaml");
14
+ export const USER_RULES_REL = path.join(".dsh", "rules.yaml");
15
+
16
+ /**
17
+ * @typedef {Object} CompiledRule
18
+ * @property {string} id
19
+ * @property {string} [tool]
20
+ * @property {RegExp} regex
21
+ * @property {"allow" | "deny" | "ask"} verdict
22
+ * @property {string} reason
23
+ * @property {string} [description]
24
+ * @property {Record<string, any>} [when]
25
+ * @property {string} source - "workspace" | "user" | "builtin"
26
+ */
27
+
28
+ export class DeclarativeRulesEngine {
29
+ /**
30
+ * @param {{
31
+ * workspaceDir?: string,
32
+ * userHomeDir?: string,
33
+ * builtinCatalogPath?: string,
34
+ * customRules?: any[],
35
+ * hotwords?: HotwordsMatcher
36
+ * }} [options]
37
+ */
38
+ constructor(options = {}) {
39
+ this.workspaceDir = options.workspaceDir || "";
40
+ this.userHomeDir = options.userHomeDir || (os.homedir ? os.homedir() : (process.env.HOME || process.env.USERPROFILE || ""));
41
+ this.builtinCatalogPath = options.builtinCatalogPath || "";
42
+ this.hotwords = options.hotwords || new HotwordsMatcher();
43
+
44
+ /** @type {CompiledRule[]} */
45
+ this.rules = [];
46
+ this.load(options);
47
+ }
48
+
49
+ /**
50
+ * Loads and merges all rule sources (workspace -> user -> builtin).
51
+ *
52
+ * @param {{ customRules?: any[] }} [options]
53
+ */
54
+ load(options = {}) {
55
+ this.rules = [];
56
+ const rawList = [];
57
+
58
+ // 1. Workspace local rules (.dsh/rules.yaml)
59
+ if (this.workspaceDir) {
60
+ const wsPath = path.isAbsolute(this.workspaceDir)
61
+ ? path.join(this.workspaceDir, WORKSPACE_RULES_REL)
62
+ : path.resolve(this.workspaceDir, WORKSPACE_RULES_REL);
63
+ const wsRules = this._loadFile(wsPath, "workspace");
64
+ rawList.push(...wsRules);
65
+ }
66
+
67
+ // 2. User global rules (~/.dsh/rules.yaml)
68
+ if (this.userHomeDir) {
69
+ const userPath = path.join(this.userHomeDir, USER_RULES_REL);
70
+ const userRules = this._loadFile(userPath, "user");
71
+ rawList.push(...userRules);
72
+ }
73
+
74
+ // 3. Builtin threat catalog
75
+ const builtinPath = this.builtinCatalogPath || path.resolve(process.cwd(), BUILTIN_RULES_FILE);
76
+ const builtinRules = this._loadFile(builtinPath, "builtin");
77
+ rawList.push(...builtinRules);
78
+
79
+ // 4. In-memory / custom rules passed via options
80
+ if (Array.isArray(options.customRules)) {
81
+ rawList.unshift(...options.customRules.map((r) => ({ ...r, source: "custom" })));
82
+ }
83
+
84
+ // Compile and deduplicate by rule ID (first defined wins!)
85
+ const seenIds = new Set();
86
+ for (const r of rawList) {
87
+ if (!r || !r.id || !r.verdict) continue;
88
+ if (seenIds.has(r.id)) continue;
89
+ seenIds.add(r.id);
90
+
91
+ const patternStr = r.pattern || r.regex || r.command;
92
+ if (!patternStr) continue;
93
+
94
+ try {
95
+ const regex = patternStr instanceof RegExp ? patternStr : new RegExp(patternStr, r.flags || "");
96
+ this.rules.push({
97
+ id: String(r.id),
98
+ tool: r.tool ? String(r.tool).toLowerCase() : undefined,
99
+ regex,
100
+ verdict: r.verdict,
101
+ reason: r.reason || r.id,
102
+ description: r.description,
103
+ when: r.when,
104
+ source: r.source || "custom",
105
+ });
106
+ } catch {
107
+ // Invalid regex in user file must not crash the loader
108
+ }
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Safely reads and parses a rules file.
114
+ * @private
115
+ */
116
+ _loadFile(filePath, source) {
117
+ if (!filePath || !fs.existsSync(filePath)) return [];
118
+ try {
119
+ const text = fs.readFileSync(filePath, "utf8");
120
+ const doc = parseYaml(text);
121
+ const rules = Array.isArray(doc) ? doc : (doc && Array.isArray(doc.rules) ? doc.rules : []);
122
+ return rules.map((r) => ({ ...r, source }));
123
+ } catch {
124
+ return [];
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Evaluates a command against the declarative rule chain.
130
+ *
131
+ * @param {string} toolName
132
+ * @param {string} command
133
+ * @param {{ os?: string, branch?: string, env?: Record<string, string>, cwd?: string }} [context]
134
+ * @returns {{ match: boolean, ruleId?: string, verdict?: "allow"|"deny"|"ask", reason?: string, source?: string }}
135
+ */
136
+ evaluate(toolName, command, context = {}) {
137
+ if (!command || typeof command !== "string") {
138
+ return { match: false };
139
+ }
140
+
141
+ const normTool = String(toolName || "").toLowerCase();
142
+ const envContext = { ...context, tool: normTool };
143
+
144
+ for (let i = 0; i < this.rules.length; i += 1) {
145
+ const rule = this.rules[i];
146
+
147
+ // Tool filter
148
+ if (rule.tool && rule.tool !== normTool) {
149
+ continue;
150
+ }
151
+
152
+ // Context predicate (when) filter
153
+ if (rule.when && !evaluateWhen(rule.when, envContext)) {
154
+ continue;
155
+ }
156
+
157
+ // Pattern match
158
+ if (rule.regex.test(command)) {
159
+ return {
160
+ match: true,
161
+ ruleId: rule.id,
162
+ verdict: rule.verdict,
163
+ reason: rule.reason,
164
+ source: rule.source,
165
+ };
166
+ }
167
+ }
168
+
169
+ return { match: false };
170
+ }
171
+
172
+ get ruleCount() {
173
+ return this.rules.length;
174
+ }
175
+ }
@@ -0,0 +1,89 @@
1
+ // lib/when-matcher.js
2
+ // Conditional predicates (when) evaluator for dsh-approval-gate rules (#44).
3
+ // Evaluates OS, git branch, environment variables, and tool context.
4
+
5
+ /**
6
+ * Checks a string or regex value against a target with negation support.
7
+ *
8
+ * @param {string | RegExp} expected
9
+ * @param {string} actual
10
+ * @returns {boolean}
11
+ */
12
+ export function matchConditionValue(expected, actual) {
13
+ if (expected === undefined || expected === null) return true;
14
+ const actualStr = String(actual || "");
15
+
16
+ if (typeof expected === "string") {
17
+ const trimmed = expected.trim();
18
+ if (trimmed.startsWith("!")) {
19
+ const negated = trimmed.slice(1).trim();
20
+ return actualStr !== negated;
21
+ }
22
+ return actualStr === trimmed;
23
+ }
24
+
25
+ if (expected instanceof RegExp) {
26
+ return expected.test(actualStr);
27
+ }
28
+
29
+ return expected === actual;
30
+ }
31
+
32
+ /**
33
+ * Evaluates a rule's `when` section against runtime facts.
34
+ *
35
+ * @param {Record<string, any>} [when] - When clause from rule
36
+ * @param {{
37
+ * os?: string,
38
+ * branch?: string,
39
+ * tool?: string,
40
+ * env?: Record<string, string | undefined>
41
+ * }} [context] - Environmental context
42
+ * @returns {boolean}
43
+ */
44
+ export function evaluateWhen(when, context = {}) {
45
+ if (!when || typeof when !== "object" || Object.keys(when).length === 0) {
46
+ return true;
47
+ }
48
+
49
+ const currentOs = context.os || process.platform;
50
+ const currentBranch = context.branch || "";
51
+ const currentTool = (context.tool || "").toLowerCase();
52
+ const currentEnv = context.env || process.env || {};
53
+
54
+ // 1. OS check
55
+ if (when.os !== undefined) {
56
+ if (!matchConditionValue(when.os, currentOs)) {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ // 2. Branch check
62
+ if (when.branch !== undefined) {
63
+ if (!matchConditionValue(when.branch, currentBranch)) {
64
+ return false;
65
+ }
66
+ }
67
+
68
+ // 3. Tool check
69
+ if (when.tool !== undefined) {
70
+ if (!matchConditionValue(String(when.tool).toLowerCase(), currentTool)) {
71
+ return false;
72
+ }
73
+ }
74
+
75
+ // 4. Environment variables check
76
+ if (when.env && typeof when.env === "object") {
77
+ for (const [varName, expectedVal] of Object.entries(when.env)) {
78
+ const actualVal = currentEnv[varName];
79
+ if (typeof expectedVal === "boolean") {
80
+ const isPresent = actualVal !== undefined && actualVal !== "" && actualVal !== "0" && actualVal !== "false";
81
+ if (isPresent !== expectedVal) return false;
82
+ } else if (!matchConditionValue(expectedVal, actualVal)) {
83
+ return false;
84
+ }
85
+ }
86
+ }
87
+
88
+ return true;
89
+ }
@@ -0,0 +1,210 @@
1
+ // lib/yaml-parser.js
2
+ // Compact zero-dependency YAML subset parser for dsh-approval-gate (#38).
3
+ // Supports scalar values, lists, mappings, nested blocks, and comments.
4
+
5
+ /**
6
+ * Parses scalar string into JS primitive (string, number, boolean, null).
7
+ *
8
+ * @param {string} val
9
+ * @returns {any}
10
+ */
11
+ export function parseScalar(val) {
12
+ if (val === undefined || val === null) return null;
13
+ const trimmed = val.trim();
14
+ if (trimmed === "" || trimmed === "~" || trimmed === "null") return null;
15
+ if (trimmed === "true") return true;
16
+ if (trimmed === "false") return false;
17
+
18
+ // Single or double quoted strings
19
+ if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
20
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
21
+ const quote = trimmed[0];
22
+ const inner = trimmed.slice(1, -1);
23
+ if (quote === '"') {
24
+ return inner
25
+ .replace(/\\"/g, '"')
26
+ .replace(/\\\\/g, "\\")
27
+ .replace(/\\n/g, "\n")
28
+ .replace(/\\t/g, "\t");
29
+ }
30
+ return inner.replace(/''/g, "'");
31
+ }
32
+
33
+ // Numbers
34
+ if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
35
+ return Number(trimmed);
36
+ }
37
+
38
+ return trimmed;
39
+ }
40
+
41
+ /**
42
+ * Strips comments from a line, preserving comments inside quotes.
43
+ *
44
+ * @param {string} line
45
+ * @returns {string}
46
+ */
47
+ export function stripComment(line) {
48
+ let inSingle = false;
49
+ let inDouble = false;
50
+ for (let i = 0; i < line.length; i += 1) {
51
+ const ch = line[i];
52
+ if (ch === "'" && !inDouble) {
53
+ inSingle = !inSingle;
54
+ } else if (ch === '"' && !inSingle) {
55
+ inDouble = !inDouble;
56
+ } else if (ch === "#" && !inSingle && !inDouble) {
57
+ return line.slice(0, i);
58
+ }
59
+ }
60
+ return line;
61
+ }
62
+
63
+ /**
64
+ * Parses a simple YAML string into JavaScript objects and arrays.
65
+ *
66
+ * @param {string} yamlText
67
+ * @returns {any}
68
+ */
69
+ export function parseYaml(yamlText) {
70
+ if (typeof yamlText !== "string" || !yamlText.trim()) return {};
71
+
72
+ const trimmedText = yamlText.trim();
73
+ // Fast path for JSON-compatible YAML
74
+ if (trimmedText.startsWith("{") || trimmedText.startsWith("[")) {
75
+ try {
76
+ return JSON.parse(trimmedText);
77
+ } catch {
78
+ // Fallback to YAML line parser
79
+ }
80
+ }
81
+
82
+ const rawLines = yamlText.split(/\r?\n/);
83
+ const lines = [];
84
+
85
+ for (let i = 0; i < rawLines.length; i += 1) {
86
+ const clean = stripComment(rawLines[i]);
87
+ if (!clean.trim()) continue; // skip blank / comment-only lines
88
+ const indent = clean.match(/^(\s*)/)[0].length;
89
+ lines.push({ indent, text: clean.trim(), lineNum: i + 1 });
90
+ }
91
+
92
+ if (lines.length === 0) return {};
93
+
94
+ let index = 0;
95
+
96
+ function parseBlock(currentIndent) {
97
+ if (index >= lines.length) return null;
98
+
99
+ const firstLine = lines[index];
100
+ if (firstLine.text.startsWith("- ") || firstLine.text === "-") {
101
+ // It's a sequence (array)
102
+ const list = [];
103
+ while (index < lines.length) {
104
+ const line = lines[index];
105
+ if (line.indent < currentIndent) break;
106
+ if (line.indent > currentIndent) {
107
+ // Handled within child elements
108
+ break;
109
+ }
110
+
111
+ if (line.text.startsWith("- ") || line.text === "-") {
112
+ const itemPayload = line.text.slice(2).trim();
113
+ index += 1;
114
+
115
+ if (!itemPayload) {
116
+ // Nested block under list item
117
+ if (index < lines.length && lines[index].indent > line.indent) {
118
+ list.push(parseBlock(lines[index].indent));
119
+ } else {
120
+ list.push(null);
121
+ }
122
+ } else if (itemPayload.includes(":") && !itemPayload.startsWith("{")) {
123
+ // Key-value on the same line as list dash: e.g. "- id: my-rule"
124
+ const colonIdx = itemPayload.indexOf(":");
125
+ const k = itemPayload.slice(0, colonIdx).trim();
126
+ const v = itemPayload.slice(colonIdx + 1).trim();
127
+ const obj = {};
128
+ if (v) {
129
+ obj[k] = parseScalar(v);
130
+ } else {
131
+ // Nested under this key
132
+ if (index < lines.length && lines[index].indent > line.indent) {
133
+ obj[k] = parseBlock(lines[index].indent);
134
+ } else {
135
+ obj[k] = null;
136
+ }
137
+ }
138
+
139
+ // Read subsequent keys belonging to this list item
140
+ const itemIndent = line.indent + 2;
141
+ while (index < lines.length) {
142
+ const nextLine = lines[index];
143
+ if (nextLine.indent < itemIndent) break;
144
+ if (nextLine.text.startsWith("- ") && nextLine.indent <= line.indent) break;
145
+
146
+ if (nextLine.text.includes(":")) {
147
+ const cIdx = nextLine.text.indexOf(":");
148
+ const subK = nextLine.text.slice(0, cIdx).trim();
149
+ const subV = nextLine.text.slice(cIdx + 1).trim();
150
+ index += 1;
151
+ if (subV) {
152
+ obj[subK] = parseScalar(subV);
153
+ } else {
154
+ if (index < lines.length && lines[index].indent > nextLine.indent) {
155
+ obj[subK] = parseBlock(lines[index].indent);
156
+ } else {
157
+ obj[subK] = null;
158
+ }
159
+ }
160
+ } else {
161
+ index += 1;
162
+ }
163
+ }
164
+ list.push(obj);
165
+ } else {
166
+ // Scalar list item: e.g. "- item1"
167
+ list.push(parseScalar(itemPayload));
168
+ }
169
+ } else {
170
+ break;
171
+ }
172
+ }
173
+ return list;
174
+ } else {
175
+ // It's a mapping (object)
176
+ const obj = {};
177
+ while (index < lines.length) {
178
+ const line = lines[index];
179
+ if (line.indent < currentIndent) break;
180
+ if (line.indent > currentIndent) {
181
+ break;
182
+ }
183
+
184
+ if (line.text.includes(":")) {
185
+ const colonIdx = line.text.indexOf(":");
186
+ const key = line.text.slice(0, colonIdx).trim();
187
+ const rest = line.text.slice(colonIdx + 1).trim();
188
+ index += 1;
189
+
190
+ if (rest) {
191
+ obj[key] = parseScalar(rest);
192
+ } else {
193
+ // Value is a nested block
194
+ if (index < lines.length && lines[index].indent > line.indent) {
195
+ obj[key] = parseBlock(lines[index].indent);
196
+ } else {
197
+ obj[key] = null;
198
+ }
199
+ }
200
+ } else {
201
+ index += 1;
202
+ }
203
+ }
204
+ return obj;
205
+ }
206
+ }
207
+
208
+ const result = parseBlock(lines[0].indent);
209
+ return result || {};
210
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-approval-gate",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
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",
@@ -11,6 +11,7 @@
11
11
  },
12
12
  "files": [
13
13
  "lib",
14
+ "rules",
14
15
  "cordis.patch.yml",
15
16
  "README.md",
16
17
  "README.zh.md",
@@ -51,4 +52,4 @@
51
52
  "approval-gate",
52
53
  "safety"
53
54
  ]
54
- }
55
+ }
@@ -0,0 +1,81 @@
1
+ version: 1
2
+ catalog: builtin-high-risk
3
+ description: Core Band 0 critical security threat definitions for dsh-approval-gate
4
+
5
+ rules:
6
+ - id: builtin-recursive-rm
7
+ tool: bash
8
+ pattern: '(?:^|[;&|\s])rm\s+-[a-zA-Z]*[rR][a-zA-Z]*\s+(?:/(?:$|\s|\*)|/(?:var|etc|usr|bin|sbin|boot|lib|root|home)(?:/|$|\s|\*))'
9
+ verdict: deny
10
+ reason: recursive-rm
11
+ description: Recursive deletion of critical system directory
12
+
13
+ - id: builtin-process-signal
14
+ tool: bash
15
+ pattern: '(?:^|[;&|\s])kill\s+-[a-zA-Z0-9]*9\s+1(?:$|\s)'
16
+ verdict: deny
17
+ reason: process-signal
18
+ description: SIGKILL targeted at PID 1 / init
19
+
20
+ - id: builtin-service-stop
21
+ tool: bash
22
+ pattern: '(?:^|[;&|\s])systemctl\s+(?:stop|disable|mask|poweroff|reboot|halt)(?:$|\s)'
23
+ verdict: deny
24
+ reason: service-control
25
+ description: System service shutdown, masking, or machine halt
26
+
27
+ - id: builtin-mkfs-format
28
+ tool: bash
29
+ pattern: '(?:^|[;&|\s])(?:mkfs|fdisk|parted)\b'
30
+ verdict: deny
31
+ reason: mkfs-device
32
+ description: Block device partitioning or filesystem formatting
33
+
34
+ - id: builtin-curl-pipe-shell
35
+ tool: bash
36
+ pattern: '(?:curl|wget)\s+[^|;]+?\|\s*(?:sudo\s+)?(?:bash|sh|zsh|python|perl|ruby)'
37
+ verdict: deny
38
+ reason: curl-pipe
39
+ description: Piping downloaded remote web content directly into shell interpreter
40
+
41
+ - id: builtin-reverse-shell-nc
42
+ tool: bash
43
+ pattern: '(?:^|[;&|\s])(?:nc|netcat|ncat)\s+.*-[a-zA-Z0-9]*e\s+(?:/bin/(?:ba)?sh|sh)'
44
+ verdict: deny
45
+ reason: band-hard-deny
46
+ description: Netcat reverse shell execution
47
+
48
+ - id: builtin-reverse-shell-tcp
49
+ tool: bash
50
+ pattern: '/dev/tcp/\d+\.\d+\.\d+\.\d+/\d+'
51
+ verdict: deny
52
+ reason: band-hard-deny
53
+ description: Direct bash TCP socket reverse shell attempt
54
+
55
+ - id: builtin-git-reset-hard
56
+ tool: bash
57
+ pattern: '(?:^|[;&|\s])git\s+reset\s+--hard'
58
+ verdict: deny
59
+ reason: git-reset-hard
60
+ description: Destructive git reset --hard discarding working tree
61
+
62
+ - id: builtin-git-clean-force
63
+ tool: bash
64
+ pattern: '(?:^|[;&|\s])git\s+clean\s+-[a-zA-Z0-9]*f'
65
+ verdict: deny
66
+ reason: git-clean
67
+ description: Destructive git clean -f discarding untracked files
68
+
69
+ - id: builtin-git-force-push
70
+ tool: bash
71
+ pattern: '(?:^|[;&|\s])git\s+push\s+.*(?:--force|-f\b|\+refs/)'
72
+ verdict: deny
73
+ reason: git-force-push
74
+ description: Destructive forced push to git repository
75
+
76
+ - id: builtin-chmod-chown-root
77
+ tool: bash
78
+ pattern: '(?:^|[;&|\s])(?:chmod|chown)\s+-[a-zA-Z]*[rR].*\s+/(?:$|\s|\*|etc|var|usr|root)'
79
+ verdict: deny
80
+ reason: chmod-chown
81
+ description: Recursive permission mutation of system root directories