@goodandready/dsh-approval-gate 0.1.17 → 0.1.19
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/lib/classifier.js +150 -20
- package/lib/contract-guard.js +147 -0
- package/lib/dispatcher.js +209 -207
- package/lib/engine.js +110 -17
- package/lib/index.js +42 -22
- package/lib/inspect.js +573 -520
- package/lib/latency.js +137 -0
- package/lib/messages.js +53 -0
- package/lib/overengineering.js +97 -0
- package/lib/paths.js +378 -320
- package/lib/risk.js +224 -0
- package/package.json +1 -1
package/lib/risk.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// lib/risk.js
|
|
2
|
+
// Structured risk bucket classification and operational risk tagging.
|
|
3
|
+
// Resolves: #58 (Risk Buckets) and #70 (Risk Tags)
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_BASH_TOOL,
|
|
7
|
+
DEFAULT_FILE_WRITE_TOOLS,
|
|
8
|
+
DEFAULT_READ_ONLY_TOOLS,
|
|
9
|
+
DEFAULT_NETWORK_TOOLS,
|
|
10
|
+
extractFilePath,
|
|
11
|
+
} from "./dispatcher.js";
|
|
12
|
+
import { isProtectedConfigPath } from "./paths.js";
|
|
13
|
+
import { isLoopbackHost, isLoopbackUrl } from "./loopback.js";
|
|
14
|
+
|
|
15
|
+
export const RISK_BUCKETS = Object.freeze({
|
|
16
|
+
READ_ONLY: "READ_ONLY",
|
|
17
|
+
WORKSPACE: "WORKSPACE",
|
|
18
|
+
EGRESS: "EGRESS",
|
|
19
|
+
SYSTEM: "SYSTEM",
|
|
20
|
+
SECRETS: "SECRETS",
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const RISK_TAGS = Object.freeze({
|
|
24
|
+
READ_ONLY: "read-only",
|
|
25
|
+
FILE_MUTATION: "file-mutation",
|
|
26
|
+
NETWORK: "network",
|
|
27
|
+
GIT: "git",
|
|
28
|
+
CREDENTIALS: "credentials",
|
|
29
|
+
PACKAGE_MANAGER: "package-manager",
|
|
30
|
+
SYSTEM_MUTATION: "system-mutation",
|
|
31
|
+
PRIVILEGE_ESCALATION: "privilege-escalation",
|
|
32
|
+
DESTRUCTIVE: "destructive",
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const READ_ONLY_CMDS = new Set([
|
|
36
|
+
"ls", "cat", "head", "tail", "grep", "find", "pwd", "which", "whereis",
|
|
37
|
+
"file", "stat", "echo", "printf", "diff", "wc", "sort", "uniq", "date",
|
|
38
|
+
"uname", "uptime", "hostname", "whoami", "id", "node -v", "npm -v", "git status",
|
|
39
|
+
"git log", "git diff", "git show", "git rev-parse", "git branch",
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
const SYSTEM_PKG_MANAGERS = new Set([
|
|
43
|
+
"apt", "apt-get", "dpkg", "yum", "dnf", "pacman", "zypper", "brew", "snap", "flatpak", "apk",
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
const DEV_PKG_MANAGERS = new Set(["npm", "pnpm", "yarn", "pip", "pip3", "cargo", "gem", "go", "composer"]);
|
|
47
|
+
|
|
48
|
+
const SYSTEM_SERVICES = new Set(["systemctl", "service", "init", "reboot", "shutdown", "poweroff", "journalctl"]);
|
|
49
|
+
|
|
50
|
+
const NETWORK_CMDS = new Set(["curl", "wget", "fetch", "nc", "netcat", "ncat", "ssh", "scp", "rsync", "ping", "telnet"]);
|
|
51
|
+
|
|
52
|
+
const SECRET_PATTERNS = [
|
|
53
|
+
/(\.env|\.env\.[a-z0-9_.-]+)$/i,
|
|
54
|
+
/id_rsa|id_ed25519|authorized_keys|known_hosts/i,
|
|
55
|
+
/\/etc\/(shadow|sudoers|master\.passwd)/i,
|
|
56
|
+
/\.git-credentials|\.netrc|\.dockercfg|\.npmrc/i,
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Categorizes an execution into one of the 5 standardized risk buckets (#58).
|
|
61
|
+
* Priority waterfall: SECRETS -> SYSTEM -> EGRESS -> WORKSPACE -> READ_ONLY
|
|
62
|
+
*
|
|
63
|
+
* @param {any} execution
|
|
64
|
+
* @param {any} [context]
|
|
65
|
+
* @returns {string} Bucket name from RISK_BUCKETS
|
|
66
|
+
*/
|
|
67
|
+
export function categorizeRiskBucket(execution, context = {}) {
|
|
68
|
+
const toolName = execution && execution.name;
|
|
69
|
+
const args = execution && execution.arguments;
|
|
70
|
+
const command = (args && typeof args === "object" && typeof args.command === "string")
|
|
71
|
+
? args.command.trim()
|
|
72
|
+
: (typeof args === "string" ? args.trim() : "");
|
|
73
|
+
const targetPath = extractFilePath(args);
|
|
74
|
+
|
|
75
|
+
// 1. SECRETS check
|
|
76
|
+
if (targetPath) {
|
|
77
|
+
if (isProtectedConfigPath(targetPath) || SECRET_PATTERNS.some((p) => p.test(targetPath))) {
|
|
78
|
+
return RISK_BUCKETS.SECRETS;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (command) {
|
|
82
|
+
if (/\b(?:printenv|export|env)\b(?!\s*=[a-zA-Z0-9_.-]+)/i.test(command) && !/\b(?:npm|node|git|test|cargo)\b/i.test(command)) {
|
|
83
|
+
return RISK_BUCKETS.SECRETS;
|
|
84
|
+
}
|
|
85
|
+
if (SECRET_PATTERNS.some((p) => p.test(command))) {
|
|
86
|
+
return RISK_BUCKETS.SECRETS;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 2. SYSTEM check
|
|
91
|
+
if (command) {
|
|
92
|
+
const firstWord = command.split(/\s+/)[0].replace(/^sudo\s+/, "");
|
|
93
|
+
if (/^(sudo|su|doas)\b/i.test(command)) return RISK_BUCKETS.SYSTEM;
|
|
94
|
+
if (SYSTEM_PKG_MANAGERS.has(firstWord) || SYSTEM_SERVICES.has(firstWord)) return RISK_BUCKETS.SYSTEM;
|
|
95
|
+
if (/\b(chmod|chown)\s+(-[a-zA-Z]*\s+)?[0-7]{3,4}\b/i.test(command)) return RISK_BUCKETS.SYSTEM;
|
|
96
|
+
if (/\b(mkfs|fdisk|parted|mount|umount)\b/i.test(command)) return RISK_BUCKETS.SYSTEM;
|
|
97
|
+
if (/\/(etc|usr|var|boot|sys|proc)\//i.test(command) && /[><]/.test(command)) return RISK_BUCKETS.SYSTEM;
|
|
98
|
+
}
|
|
99
|
+
if (targetPath && /(\/etc\/|\/boot\/|\/sys\/|\/proc\/)/i.test(targetPath)) {
|
|
100
|
+
return RISK_BUCKETS.SYSTEM;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 3. EGRESS check
|
|
104
|
+
const netTools = new Set(DEFAULT_NETWORK_TOOLS);
|
|
105
|
+
if (netTools.has(toolName)) {
|
|
106
|
+
const url = (args && (args.url || args.Url || args.uri)) || "";
|
|
107
|
+
if (url && (isLoopbackHost(url) || isLoopbackUrl(url))) {
|
|
108
|
+
return RISK_BUCKETS.WORKSPACE;
|
|
109
|
+
}
|
|
110
|
+
return RISK_BUCKETS.EGRESS;
|
|
111
|
+
}
|
|
112
|
+
if (command) {
|
|
113
|
+
if (/git\s+push\b/i.test(command)) return RISK_BUCKETS.EGRESS;
|
|
114
|
+
const firstWord = command.split(/\s+/)[0];
|
|
115
|
+
if (NETWORK_CMDS.has(firstWord)) {
|
|
116
|
+
if (/\b(localhost|127\.0\.0\.1|::1|0\.0\.0\.0)\b/i.test(command)) {
|
|
117
|
+
return RISK_BUCKETS.WORKSPACE;
|
|
118
|
+
}
|
|
119
|
+
return RISK_BUCKETS.EGRESS;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 4. READ_ONLY check
|
|
124
|
+
const readTools = new Set(DEFAULT_READ_ONLY_TOOLS);
|
|
125
|
+
if (readTools.has(toolName)) {
|
|
126
|
+
return RISK_BUCKETS.READ_ONLY;
|
|
127
|
+
}
|
|
128
|
+
if (command) {
|
|
129
|
+
const normalizedCmd = command.split(/\s+/).slice(0, 2).join(" ");
|
|
130
|
+
const singleCmd = command.split(/\s+/)[0];
|
|
131
|
+
if (READ_ONLY_CMDS.has(normalizedCmd) || READ_ONLY_CMDS.has(singleCmd)) {
|
|
132
|
+
if (!/[><|&;]/.test(command)) {
|
|
133
|
+
return RISK_BUCKETS.READ_ONLY;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 5. WORKSPACE (default for dev tasks)
|
|
139
|
+
return RISK_BUCKETS.WORKSPACE;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Extracts multidimensional risk tags for an execution (#70).
|
|
144
|
+
*
|
|
145
|
+
* @param {any} execution
|
|
146
|
+
* @param {any} [context]
|
|
147
|
+
* @returns {string[]} Sorted array of unique tag strings
|
|
148
|
+
*/
|
|
149
|
+
export function extractRiskTags(execution, context = {}) {
|
|
150
|
+
const tags = new Set();
|
|
151
|
+
const toolName = String((execution && execution.name) || "").trim();
|
|
152
|
+
const args = execution && execution.arguments;
|
|
153
|
+
const command = (args && typeof args === "object" && typeof args.command === "string")
|
|
154
|
+
? args.command.trim()
|
|
155
|
+
: (typeof args === "string" ? args.trim() : "");
|
|
156
|
+
const targetPath = extractFilePath(args);
|
|
157
|
+
|
|
158
|
+
// File mutation
|
|
159
|
+
const writeTools = new Set(DEFAULT_FILE_WRITE_TOOLS);
|
|
160
|
+
if (
|
|
161
|
+
writeTools.has(toolName) ||
|
|
162
|
+
toolName === "write_file" ||
|
|
163
|
+
toolName === "replace_file_content" ||
|
|
164
|
+
/\b(write|edit)\b/i.test(toolName) ||
|
|
165
|
+
/[><]/.test(command) ||
|
|
166
|
+
/\b(rm|mv|cp|touch|mkdir|rmdir)\b/i.test(command)
|
|
167
|
+
) {
|
|
168
|
+
tags.add(RISK_TAGS.FILE_MUTATION);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Network
|
|
172
|
+
const netTools = new Set(DEFAULT_NETWORK_TOOLS);
|
|
173
|
+
if (netTools.has(toolName) || /git\s+push\b/i.test(command) || /\b(curl|wget|fetch|nc|ssh|ping)\b/i.test(command)) {
|
|
174
|
+
tags.add(RISK_TAGS.NETWORK);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Git
|
|
178
|
+
if (/^git\b/i.test(command) || toolName.includes("git")) {
|
|
179
|
+
tags.add(RISK_TAGS.GIT);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Credentials / Secrets
|
|
183
|
+
if (
|
|
184
|
+
(targetPath && (isProtectedConfigPath(targetPath) || SECRET_PATTERNS.some((p) => p.test(targetPath)))) ||
|
|
185
|
+
/\b(printenv|export|env)\b(?!\s*=[a-zA-Z0-9_.-]+)/i.test(command) ||
|
|
186
|
+
SECRET_PATTERNS.some((p) => p.test(command)) ||
|
|
187
|
+
/\b(Bearer|token|secret|password|api[_-]?key)\b/i.test(command)
|
|
188
|
+
) {
|
|
189
|
+
tags.add(RISK_TAGS.CREDENTIALS);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Package manager
|
|
193
|
+
if (command) {
|
|
194
|
+
const firstWord = command.split(/\s+/)[0].replace(/^sudo\s+/, "");
|
|
195
|
+
if (SYSTEM_PKG_MANAGERS.has(firstWord) || DEV_PKG_MANAGERS.has(firstWord)) {
|
|
196
|
+
tags.add(RISK_TAGS.PACKAGE_MANAGER);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Privilege escalation / System mutation
|
|
201
|
+
if (command) {
|
|
202
|
+
if (/^(sudo|su|doas)\b/i.test(command) || /\bchmod\s+(-[a-zA-Z]*\s+)?777\b/i.test(command)) {
|
|
203
|
+
tags.add(RISK_TAGS.PRIVILEGE_ESCALATION);
|
|
204
|
+
}
|
|
205
|
+
const firstWord = command.split(/\s+/)[0].replace(/^sudo\s+/, "");
|
|
206
|
+
if (SYSTEM_PKG_MANAGERS.has(firstWord) || SYSTEM_SERVICES.has(firstWord) || /\b(systemctl|service|reboot|shutdown)\b/i.test(command)) {
|
|
207
|
+
tags.add(RISK_TAGS.SYSTEM_MUTATION);
|
|
208
|
+
}
|
|
209
|
+
if (/\b(rm\s+-[a-zA-Z]*[rf]|mkfs|shred|wipefs|git\s+clean\s+-[a-zA-Z]*f|git\s+reset\s+--hard)\b/i.test(command)) {
|
|
210
|
+
tags.add(RISK_TAGS.DESTRUCTIVE);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Read only
|
|
215
|
+
const readTools = new Set(DEFAULT_READ_ONLY_TOOLS);
|
|
216
|
+
if (readTools.has(toolName) || (command && !tags.has(RISK_TAGS.FILE_MUTATION) && !tags.has(RISK_TAGS.DESTRUCTIVE) && !/[><|&;]/.test(command))) {
|
|
217
|
+
const singleCmd = command ? command.split(/\s+/)[0] : "";
|
|
218
|
+
if (READ_ONLY_CMDS.has(singleCmd) || readTools.has(toolName)) {
|
|
219
|
+
tags.add(RISK_TAGS.READ_ONLY);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return Array.from(tags).sort();
|
|
224
|
+
}
|
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.19",
|
|
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",
|