@goodandready/dsh-approval-gate 0.1.14 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.15
4
+
5
+ - Rules: Каскадный декларативный YAML-конфиг правил `rules.yaml` (`.dsh/rules.yaml` в проекте и `~/.dsh/rules.yaml` пользователя) с поддержкой allow/deny/ask политик (Refs: #38).
6
+ - Rules: Вынесение каталога встроенных критических угроз нулевого уровня (P0) в отчуждаемый версионируемый каталог `rules/builtin-high-risk.yaml` (Refs: #45).
7
+ - Rules: Условные предикаты правил `when` с сопоставлением контекста окружения (ОС `os`, git-ветка `branch`, переменные окружения `env` и инструмент `tool`) с поддержкой отрицания `!` (Refs: #44).
8
+ - Performance: Высокоскоростной пре-фильтр ключевых слов `HotwordsMatcher` (rm, kill, shadow, sudoers, curl, format и др.) для микросекундного пропуска безопасных команд (Refs: #93).
9
+ - Testing: Корпус тестовых векторов `test/vectors/corpus.json` из 110+ команд с автотестом контроля регрессий (FPR=0, FNR=0) (Refs: #46).
10
+
3
11
  ## 0.1.14
4
12
 
5
13
  - Feature: Режим теневого аудита (`dryRun: true` / `shadowMode: true`) в `Config` и `SecurityEngine` для безопасного тестирования правил на проде без блокировки выполнения процессов агента (Refs: #128).
package/lib/engine.js CHANGED
@@ -1,11 +1,11 @@
1
1
  // lib/engine.js
2
2
  // 5-tier Security Decision Engine for dsh-approval-gate.
3
3
  // Waterfall:
4
- // P0: Hard Deny (Bands Band 0 + inspectExecution hard denials + protected files + retry exhaustion)
5
- // P1: Session Grants (scoped temporary allowances)
4
+ // P0: Hard Deny (Bands Band 0 + Declarative Rules Deny + inspectExecution hard denials + protected files + retry exhaustion)
5
+ // P1: Session Grants & Declarative Allow (scoped temporary allowances and whitelists)
6
6
  // P2: Static Rules / Safe Allow (Bands Band 1 + safe read-only commands)
7
7
  // P3: Optional Semantic Classifier
8
- // P4: Human Ask Fallback (uncertain syntax / ambiguous commands / tripped breaker)
8
+ // P4: Human Ask Fallback (uncertain syntax / ambiguous commands / tripped breaker / declarative ask)
9
9
 
10
10
  import { BandsEngine } from "./bands.js";
11
11
  import { CircuitBreaker } from "./breaker.js";
@@ -15,6 +15,7 @@ import { VerdictCache } from "./cache.js";
15
15
  import { RetryExhaustionGuard } from "./retry-exhaustion.js";
16
16
  import { QualityLedger } from "./ledger.js";
17
17
  import { AuditLogger } from "./audit-log.js";
18
+ import { DeclarativeRulesEngine } from "./rules-loader.js";
18
19
 
19
20
  export class SecurityEngine {
20
21
  /**
@@ -25,6 +26,7 @@ export class SecurityEngine {
25
26
  * retryGuard?: RetryExhaustionGuard,
26
27
  * ledger?: QualityLedger,
27
28
  * auditLogger?: AuditLogger,
29
+ * declarativeRules?: DeclarativeRulesEngine,
28
30
  * inspectConfig?: { toolName?: string, fileWriteTools?: string[], workspaceDir?: string },
29
31
  * classifier?: ((execution: any, context?: any) => { verdict: "allow"|"deny", reason?: string }),
30
32
  * grantRegistry?: { decide: (tool: string, args: any, context?: any) => "allow"|"no-match" },
@@ -46,6 +48,9 @@ export class SecurityEngine {
46
48
  this.retryGuard = options.retryGuard !== undefined ? options.retryGuard : new RetryExhaustionGuard();
47
49
  this.ledger = options.ledger !== undefined ? options.ledger : new QualityLedger();
48
50
  this.auditLogger = options.auditLogger !== undefined ? options.auditLogger : new AuditLogger();
51
+ this.declarativeRules = options.declarativeRules || new DeclarativeRulesEngine({
52
+ workspaceDir: this.inspectConfig?.workspaceDir,
53
+ });
49
54
  }
50
55
 
51
56
  /**
@@ -157,6 +162,26 @@ export class SecurityEngine {
157
162
  return { verdict: "deny", stage: "P0", reason: "band-hard-deny", detail: command, hit };
158
163
  }
159
164
 
165
+ // Declarative YAML Rules evaluation (#38, #45, #44)
166
+ if (this.declarativeRules && command) {
167
+ const decl = this.declarativeRules.evaluate(toolName, command, context);
168
+ if (decl.match) {
169
+ if (decl.verdict === "deny") {
170
+ this.breaker.countDeny(sessionId);
171
+ const hit = { deny: true, reason: decl.reason || "declarative-deny", detail: command, snippet: command, ruleId: decl.ruleId };
172
+ return { verdict: "deny", stage: "P0", reason: decl.reason || "declarative-deny", detail: command, hit };
173
+ }
174
+ if (decl.verdict === "allow") {
175
+ this.breaker.resetConsecutive(sessionId);
176
+ return { verdict: "allow", stage: "P1", reason: decl.reason || "declarative-allow", hit: null };
177
+ }
178
+ if (decl.verdict === "ask") {
179
+ const hit = { ask: true, reason: decl.reason || "declarative-ask", detail: command, snippet: command, ruleId: decl.ruleId };
180
+ return { verdict: "ask", stage: "P4", reason: decl.reason || "declarative-ask", detail: command, hit };
181
+ }
182
+ }
183
+ }
184
+
160
185
  // Inspect execution for hard denials and syntax checks
161
186
  const hit = inspectExecution(execution, this.inspectConfig);
162
187
  if (hit.deny) {
@@ -0,0 +1,122 @@
1
+ // lib/hotwords.js
2
+ // High-speed keyword pre-filter (Hotwords / Lexicon Matcher) for dsh-approval-gate (#93).
3
+ // Provides sub-microsecond pre-check to bypass heavy AST/regex parsing for clean commands.
4
+
5
+ export const DEFAULT_HOTWORDS = Object.freeze([
6
+ "rm",
7
+ "mkfs",
8
+ "fdisk",
9
+ "parted",
10
+ "dd",
11
+ "shadow",
12
+ "sudoers",
13
+ "passwd",
14
+ "kill",
15
+ "pkill",
16
+ "killall",
17
+ "systemctl",
18
+ "service",
19
+ "curl",
20
+ "wget",
21
+ "bash",
22
+ "sh",
23
+ "zsh",
24
+ "nc",
25
+ "netcat",
26
+ "ncat",
27
+ "socat",
28
+ "chmod",
29
+ "chown",
30
+ "eval",
31
+ "printenv",
32
+ "drop",
33
+ "truncate",
34
+ "format",
35
+ "sudo",
36
+ "su",
37
+ "ssh",
38
+ ]);
39
+
40
+ /**
41
+ * Splits text into normalized lowercase word tokens.
42
+ *
43
+ * @param {string} text
44
+ * @returns {string[]}
45
+ */
46
+ export function extractWords(text) {
47
+ if (typeof text !== "string") return [];
48
+ // Split on whitespace and common shell operators/delimiters
49
+ const tokens = text.toLowerCase().split(/[^a-z0-9_-]+/);
50
+ return tokens.filter((t) => t.length > 0);
51
+ }
52
+
53
+ export class HotwordsMatcher {
54
+ /**
55
+ * @param {string[]} [customHotwords]
56
+ */
57
+ constructor(customHotwords) {
58
+ /** @type {Set<string>} */
59
+ this.lexicon = new Set();
60
+ const words = Array.isArray(customHotwords) && customHotwords.length > 0
61
+ ? customHotwords
62
+ : DEFAULT_HOTWORDS;
63
+
64
+ for (const w of words) {
65
+ if (typeof w === "string" && w.trim()) {
66
+ this.lexicon.add(w.trim().toLowerCase());
67
+ }
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Add new words to lexicon.
73
+ * @param {string | string[]} words
74
+ */
75
+ add(words) {
76
+ const list = Array.isArray(words) ? words : [words];
77
+ for (const w of list) {
78
+ if (typeof w === "string" && w.trim()) {
79
+ this.lexicon.add(w.trim().toLowerCase());
80
+ }
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Checks if text contains any word present in the hotwords lexicon.
86
+ *
87
+ * @param {string} text
88
+ * @returns {boolean} True if any high-risk keyword matched
89
+ */
90
+ hasMatch(text) {
91
+ if (!text || typeof text !== "string") return false;
92
+ const words = extractWords(text);
93
+ for (let i = 0; i < words.length; i += 1) {
94
+ if (this.lexicon.has(words[i])) {
95
+ return true;
96
+ }
97
+ }
98
+ return false;
99
+ }
100
+
101
+ /**
102
+ * Returns list of matching hotwords found in text.
103
+ *
104
+ * @param {string} text
105
+ * @returns {string[]}
106
+ */
107
+ findMatches(text) {
108
+ if (!text || typeof text !== "string") return [];
109
+ const words = extractWords(text);
110
+ const matches = [];
111
+ for (let i = 0; i < words.length; i += 1) {
112
+ if (this.lexicon.has(words[i]) && !matches.includes(words[i])) {
113
+ matches.push(words[i]);
114
+ }
115
+ }
116
+ return matches;
117
+ }
118
+
119
+ get size() {
120
+ return this.lexicon.size;
121
+ }
122
+ }
package/lib/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { SecurityEngine } from "./engine.js";
2
2
  import { BandsEngine } from "./bands.js";
3
3
  import { AuditLogger } from "./audit-log.js";
4
+ import { DeclarativeRulesEngine } from "./rules-loader.js";
4
5
  import Schema from "@deepseek-ai/schemastery";
5
6
 
6
7
  if (typeof Schema.prototype?.volatile !== "function") {
@@ -212,6 +213,8 @@ export const Config = Schema.object({
212
213
  shadowMode: Schema.boolean().default(false).volatile(),
213
214
  /** Path to append-only audit log file (#56). */
214
215
  auditLogPath: Schema.string().default("").volatile(),
216
+ /** Optional custom declarative rules YAML file path (#38). */
217
+ rulesFile: Schema.string().default("").volatile(),
215
218
  /** Security engine instance (internal/testing). */
216
219
  engine: Schema.any().hidden(),
217
220
  });
@@ -257,6 +260,10 @@ export function apply(ctx, config) {
257
260
  defaultVerdict: cfg.defaultVerdict || "allow",
258
261
  dryRun: Boolean(cfg.dryRun || cfg.shadowMode),
259
262
  auditLogger: new AuditLogger({ logPath: cfg.auditLogPath || undefined }),
263
+ declarativeRules: new DeclarativeRulesEngine({
264
+ workspaceDir: cfg.workspaceDir || "",
265
+ builtinCatalogPath: cfg.rulesFile || undefined,
266
+ }),
260
267
  });
261
268
 
262
269
  const gateService = {
@@ -266,6 +273,7 @@ export function apply(ctx, config) {
266
273
  cache: engine.cache,
267
274
  retryGuard: engine.retryGuard,
268
275
  ledger: engine.ledger,
276
+ declarativeRules: engine.declarativeRules,
269
277
  getLedgerSummary: (sessionId) => engine.getLedgerSummary(sessionId),
270
278
  };
271
279
  if (ctx && typeof ctx === "object") {
@@ -450,3 +458,23 @@ export {
450
458
  AuditLogger,
451
459
  DEFAULT_AUDIT_LOG_RELATIVE,
452
460
  } from "./audit-log.js";
461
+ export {
462
+ parseYaml,
463
+ parseScalar,
464
+ stripComment,
465
+ } from "./yaml-parser.js";
466
+ export {
467
+ matchConditionValue,
468
+ evaluateWhen,
469
+ } from "./when-matcher.js";
470
+ export {
471
+ HotwordsMatcher,
472
+ DEFAULT_HOTWORDS,
473
+ extractWords,
474
+ } from "./hotwords.js";
475
+ export {
476
+ DeclarativeRulesEngine,
477
+ BUILTIN_RULES_FILE,
478
+ WORKSPACE_RULES_REL,
479
+ USER_RULES_REL,
480
+ } from "./rules-loader.js";
@@ -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.14",
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",
@@ -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