@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.
- package/CHANGELOG.md +20 -7
- package/lib/audit-log.js +76 -0
- package/lib/breaker.js +26 -2
- package/lib/cache.js +146 -0
- package/lib/engine.js +156 -19
- package/lib/fingerprint.js +100 -0
- package/lib/hotwords.js +122 -0
- package/lib/index.js +78 -1
- package/lib/ledger.js +164 -0
- package/lib/messages.js +8 -2
- package/lib/retry-exhaustion.js +171 -0
- package/lib/rules-loader.js +175 -0
- package/lib/when-matcher.js +89 -0
- package/lib/yaml-parser.js +210 -0
- package/package.json +3 -2
- package/rules/builtin-high-risk.yaml +81 -0
|
@@ -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.
|
|
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
|