@goodandready/dsh-approval-gate 0.1.18 → 0.1.20
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/agents-parser.js +183 -0
- package/lib/artifacts.js +162 -0
- package/lib/classifier.js +150 -20
- package/lib/compat.js +124 -0
- package/lib/engine.js +125 -44
- package/lib/index.js +38 -34
- package/lib/latency.js +137 -0
- package/lib/messages.js +36 -1
- package/lib/risk.js +224 -0
- package/lib/trash.js +119 -0
- package/lib/version-guard.js +177 -0
- package/package.json +55 -55
package/lib/messages.js
CHANGED
|
@@ -67,6 +67,8 @@ export const MESSAGES = Object.freeze({
|
|
|
67
67
|
ruleClassifierTimeoutPermissive: "Semantic classifier timed out; allowed by low-risk fallback policy",
|
|
68
68
|
askClassifierTimeout: "Semantic classifier request timed out; human confirmation required",
|
|
69
69
|
askClassifierError: "Semantic classifier error; human confirmation required",
|
|
70
|
+
askClassifierSlaFallback: "Semantic classifier exceeded SLA latency threshold; manual review required",
|
|
71
|
+
askMalformedClassifierResponse: "Semantic classifier returned malformed or unverified decision; manual review required",
|
|
70
72
|
askPayloadTooLarge: "Command arguments exceed maximum payload size limit; review before allowing",
|
|
71
73
|
ruleReviewerInjectionAttempt: "Prompt instruction injection detected in tool arguments",
|
|
72
74
|
askContractViolation: "Operation falls outside task contract boundaries; review before allowing",
|
|
@@ -75,6 +77,10 @@ export const MESSAGES = Object.freeze({
|
|
|
75
77
|
ruleUnisolatedTempPath: "Target path uses unisolated global temp directory",
|
|
76
78
|
askOverengineeringLimit: "Session file creation limit exceeded; potential overengineering requires review before allowing",
|
|
77
79
|
ruleOverengineeringLimit: "Session exceeded maximum allowed created files limit",
|
|
80
|
+
ruleAgentsMdProhibitedCommand: "Command execution prohibited by rules in AGENTS.md",
|
|
81
|
+
ruleAgentsMdProhibitedPath: "File modification prohibited by rules in AGENTS.md",
|
|
82
|
+
askMajorVersionBump: "Package.json modification contains major dependency bump; review before allowing",
|
|
83
|
+
ruleMajorVersionBump: "Unauthorized major dependency version bump",
|
|
78
84
|
hintRecursiveRm: "Specify exact target files instead of recursive deletion",
|
|
79
85
|
hintPathTraversal: "Keep operations strictly within the project directory",
|
|
80
86
|
hintSecretWrite: "Modify configuration via settings or environment variables, not raw file overwrite",
|
|
@@ -91,11 +97,16 @@ export const MESSAGES = Object.freeze({
|
|
|
91
97
|
hintFailClosedAudit: "Ensure the audit log directory is writable and disk space is available",
|
|
92
98
|
hintLockedAskUnattended: "Switch to an interactive session or request explicit human confirmation",
|
|
93
99
|
hintClassifierTimeout: "Check external LLM connectivity or review timeout policy settings",
|
|
100
|
+
hintClassifierSlaFallback: "Check external LLM classifier load or increase classifierSlaMs",
|
|
101
|
+
hintMalformedClassifierResponse: "Verify model JSON output format or check response schema compatibility",
|
|
94
102
|
hintPayloadTooLarge: "Reduce input payload size or chunk data into smaller batches",
|
|
95
103
|
hintReviewerInjectionAttempt: "Remove adversarial prompt override directives from input",
|
|
96
104
|
hintContractViolation: "Confine operations to permitted task tools and project scope",
|
|
97
105
|
hintUnisolatedTempPath: "Use workspace-isolated temporary directory (<workspace>/.dsh/tmp)",
|
|
98
106
|
hintOverengineeringLimit: "Reuse existing files and avoid creating redundant abstractions or boilerplate",
|
|
107
|
+
hintTrashAlternative: "Move files to reversible .dsh/trash/<session>/ directory instead of rm",
|
|
108
|
+
hintAgentsMdProhibited: "Review project-specific guidelines and constraints defined in AGENTS.md",
|
|
109
|
+
hintMajorVersionBump: "Verify compatibility before bumping major dependency versions in package.json",
|
|
99
110
|
},
|
|
100
111
|
zh: {
|
|
101
112
|
blockedPrefix: "dsh-approval-gate 规则已阻止",
|
|
@@ -162,6 +173,8 @@ export const MESSAGES = Object.freeze({
|
|
|
162
173
|
ruleClassifierTimeoutPermissive: "语义分类器超时;根据低风险容错策略放行",
|
|
163
174
|
askClassifierTimeout: "语义分类器请求超时;需要人工审查",
|
|
164
175
|
askClassifierError: "语义分类器发生异常;需要人工审查",
|
|
176
|
+
askClassifierSlaFallback: "语义分类器延迟超出 SLA 阈值;需要人工审查",
|
|
177
|
+
askMalformedClassifierResponse: "语义分类器返回非预期或未通过校验的判定;需要人工审查",
|
|
165
178
|
askPayloadTooLarge: "命令参数超过最大负载大小限制;请在允许前进行审查",
|
|
166
179
|
ruleReviewerInjectionAttempt: "在工具参数中检测到提示词指令注入尝试",
|
|
167
180
|
askContractViolation: "操作超出任务契约范围;请在允许前进行审查",
|
|
@@ -170,6 +183,10 @@ export const MESSAGES = Object.freeze({
|
|
|
170
183
|
ruleUnisolatedTempPath: "目标路径使用未隔离的全局临时目录",
|
|
171
184
|
askOverengineeringLimit: "会话创建文件数超过限制;潜在的过度工程需要人工审查",
|
|
172
185
|
ruleOverengineeringLimit: "会话超出最大允许创建文件数限制",
|
|
186
|
+
ruleAgentsMdProhibitedCommand: "命令执行被 AGENTS.md 中的规则明确禁止",
|
|
187
|
+
ruleAgentsMdProhibitedPath: "文件修改被 AGENTS.md 中的规则明确禁止",
|
|
188
|
+
askMajorVersionBump: "package.json 包含破坏性依赖主版本升级;请在允许前进行审查",
|
|
189
|
+
ruleMajorVersionBump: "未授权的依赖包主版本升级",
|
|
173
190
|
hintRecursiveRm: "请指定具体目标文件,避免递归删除",
|
|
174
191
|
hintPathTraversal: "请将操作限制在项目根目录内",
|
|
175
192
|
hintSecretWrite: "请通过设置或环境变量修改配置,不要直接覆盖敏感配置文件",
|
|
@@ -186,11 +203,16 @@ export const MESSAGES = Object.freeze({
|
|
|
186
203
|
hintFailClosedAudit: "请确保审计日志目录可写且磁盘空间充足",
|
|
187
204
|
hintLockedAskUnattended: "请切换到交互式会话或请求人工显式确认",
|
|
188
205
|
hintClassifierTimeout: "请检查外部 LLM 服务连通性或调整分类器超时容错策略",
|
|
206
|
+
hintClassifierSlaFallback: "请检查外部 LLM 分类器负载或调高 classifierSlaMs 阈值",
|
|
207
|
+
hintMalformedClassifierResponse: "请检查模型 JSON 输出格式或验证返回模式兼容性",
|
|
189
208
|
hintPayloadTooLarge: "减小输入参数大小或分批传输数据",
|
|
190
209
|
hintReviewerInjectionAttempt: "从输入中移除对抗性指令覆盖指令",
|
|
191
210
|
hintContractViolation: "将操作限制在允许的任务工具和项目范围内",
|
|
192
211
|
hintUnisolatedTempPath: "使用工作区隔离的临时目录 (<workspace>/.dsh/tmp)",
|
|
193
212
|
hintOverengineeringLimit: "复用现有文件,避免创建多余的抽象或样板代码",
|
|
213
|
+
hintTrashAlternative: "建议将文件移动到可逆的 .dsh/trash/<session>/ 目录,避免直接 rm",
|
|
214
|
+
hintAgentsMdProhibited: "请核对项目根目录 AGENTS.md 中定义的安全约束和禁止指令",
|
|
215
|
+
hintMajorVersionBump: "在升级 package.json 依赖主版本前请确认破坏性变更与兼容性",
|
|
194
216
|
},
|
|
195
217
|
});
|
|
196
218
|
|
|
@@ -256,14 +278,19 @@ const RULE_KEYS = Object.freeze({
|
|
|
256
278
|
"classifier-timeout-strict": "ruleClassifierTimeoutStrict",
|
|
257
279
|
"classifier-timeout-fallback-strict": "ruleClassifierTimeoutStrict",
|
|
258
280
|
"classifier-timeout-fallback-permissive": "ruleClassifierTimeoutPermissive",
|
|
281
|
+
"classifier-sla-fallback": "askClassifierSlaFallback",
|
|
282
|
+
"malformed-classifier-response": "askMalformedClassifierResponse",
|
|
259
283
|
"reviewer-injection-attempt": "ruleReviewerInjectionAttempt",
|
|
260
284
|
"contract-violation": "ruleContractViolation",
|
|
261
285
|
"unisolated-temp-path": "ruleUnisolatedTempPath",
|
|
262
286
|
"overengineering-file-limit": "ruleOverengineeringLimit",
|
|
287
|
+
"agents-md-prohibited-command": "ruleAgentsMdProhibitedCommand",
|
|
288
|
+
"agents-md-prohibited-path": "ruleAgentsMdProhibitedPath",
|
|
289
|
+
"major-version-bump": "askMajorVersionBump",
|
|
263
290
|
});
|
|
264
291
|
|
|
265
292
|
const HINT_KEYS = Object.freeze({
|
|
266
|
-
"recursive-rm": "
|
|
293
|
+
"recursive-rm": "hintTrashAlternative",
|
|
267
294
|
"path-traversal": "hintPathTraversal",
|
|
268
295
|
"secret-write": "hintSecretWrite",
|
|
269
296
|
"env-dump-leak": "hintEnvDumpLeak",
|
|
@@ -280,11 +307,16 @@ const HINT_KEYS = Object.freeze({
|
|
|
280
307
|
"locked-ask-unattended-denied": "hintLockedAskUnattended",
|
|
281
308
|
"classifier-timeout-strict": "hintClassifierTimeout",
|
|
282
309
|
"classifier-timeout-fallback-strict": "hintClassifierTimeout",
|
|
310
|
+
"classifier-sla-fallback": "hintClassifierSlaFallback",
|
|
311
|
+
"malformed-classifier-response": "hintMalformedClassifierResponse",
|
|
283
312
|
"payload-too-large": "hintPayloadTooLarge",
|
|
284
313
|
"reviewer-injection-attempt": "hintReviewerInjectionAttempt",
|
|
285
314
|
"contract-violation": "hintContractViolation",
|
|
286
315
|
"unisolated-temp-path": "hintUnisolatedTempPath",
|
|
287
316
|
"overengineering-file-limit": "hintOverengineeringLimit",
|
|
317
|
+
"agents-md-prohibited-command": "hintAgentsMdProhibited",
|
|
318
|
+
"agents-md-prohibited-path": "hintAgentsMdProhibited",
|
|
319
|
+
"major-version-bump": "hintMajorVersionBump",
|
|
288
320
|
});
|
|
289
321
|
|
|
290
322
|
const ASK_KEYS = Object.freeze({
|
|
@@ -303,10 +335,13 @@ const ASK_KEYS = Object.freeze({
|
|
|
303
335
|
"classifier-timeout-strict": "askClassifierTimeout",
|
|
304
336
|
"classifier-timeout-fallback-strict": "askClassifierTimeout",
|
|
305
337
|
"classifier-error": "askClassifierError",
|
|
338
|
+
"classifier-sla-fallback": "askClassifierSlaFallback",
|
|
339
|
+
"malformed-classifier-response": "askMalformedClassifierResponse",
|
|
306
340
|
"payload-too-large": "askPayloadTooLarge",
|
|
307
341
|
"contract-violation": "askContractViolation",
|
|
308
342
|
"unisolated-temp-path": "askUnisolatedTempPath",
|
|
309
343
|
"overengineering-file-limit": "askOverengineeringLimit",
|
|
344
|
+
"major-version-bump": "askMajorVersionBump",
|
|
310
345
|
});
|
|
311
346
|
|
|
312
347
|
const DETAIL_KEYS = Object.freeze({
|
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/lib/trash.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// lib/trash.js
|
|
2
|
+
// Reversible Trash Move: safe alternative to destructive rm commands (#104).
|
|
3
|
+
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
const RM_COMMAND_PATTERN = /^\s*rm\b/i;
|
|
7
|
+
const RM_FLAGS_PATTERN = /^-[a-zA-Z0-9_\-]+$/;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Resolves the isolated trash directory for a workspace and session (#104).
|
|
11
|
+
* Default path: <workspaceDir>/.dsh/trash/<sessionId>
|
|
12
|
+
*
|
|
13
|
+
* @param {string} [workspaceDir]
|
|
14
|
+
* @param {string} [sessionId]
|
|
15
|
+
* @returns {string}
|
|
16
|
+
*/
|
|
17
|
+
export function resolveTrashDir(workspaceDir, sessionId = "default") {
|
|
18
|
+
const base = workspaceDir && typeof workspaceDir === "string" ? workspaceDir : ".";
|
|
19
|
+
const session = String(sessionId || "default").replace(/[^a-zA-Z0-9_\-]/g, "_");
|
|
20
|
+
return path.join(base, ".dsh", "trash", session);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Checks if a shell command is an rm command (#104).
|
|
25
|
+
*
|
|
26
|
+
* @param {string} command
|
|
27
|
+
* @returns {boolean}
|
|
28
|
+
*/
|
|
29
|
+
export function isRmCommand(command) {
|
|
30
|
+
if (typeof command !== "string") return false;
|
|
31
|
+
return RM_COMMAND_PATTERN.test(command.trim());
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Extracts target file and directory paths from an rm command string (#104).
|
|
36
|
+
*
|
|
37
|
+
* @param {string} command
|
|
38
|
+
* @returns {string[]}
|
|
39
|
+
*/
|
|
40
|
+
export function extractRmTargets(command) {
|
|
41
|
+
if (typeof command !== "string") return [];
|
|
42
|
+
const trimmed = command.trim();
|
|
43
|
+
if (!RM_COMMAND_PATTERN.test(trimmed)) return [];
|
|
44
|
+
|
|
45
|
+
const parts = trimmed.split(/\s+/);
|
|
46
|
+
const targets = [];
|
|
47
|
+
|
|
48
|
+
// Skip leading "rm" and any option flags (-r, -f, -rf, --recursive, etc.)
|
|
49
|
+
for (let i = 1; i < parts.length; i++) {
|
|
50
|
+
const part = parts[i];
|
|
51
|
+
if (part.startsWith("-")) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
// Stop if shell control operators occur
|
|
55
|
+
if (/[;&|]/.test(part)) {
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
targets.push(part);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return targets;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Builds a reversible trash move command replacing destructive rm (#104).
|
|
66
|
+
*
|
|
67
|
+
* @param {string[]} targetPaths
|
|
68
|
+
* @param {string} trashDir
|
|
69
|
+
* @returns {string}
|
|
70
|
+
*/
|
|
71
|
+
export function buildTrashCommand(targetPaths, trashDir) {
|
|
72
|
+
if (!Array.isArray(targetPaths) || targetPaths.length === 0) {
|
|
73
|
+
return "";
|
|
74
|
+
}
|
|
75
|
+
const cleanTargets = targetPaths
|
|
76
|
+
.map((t) => String(t || "").trim())
|
|
77
|
+
.filter((t) => t.length > 0 && !t.startsWith("-"));
|
|
78
|
+
|
|
79
|
+
if (cleanTargets.length === 0) {
|
|
80
|
+
return "";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const quotedTargets = cleanTargets.map((t) => (t.includes(" ") ? `"${t}"` : t)).join(" ");
|
|
84
|
+
const normalizedTrash = String(trashDir || ".dsh/trash").trim();
|
|
85
|
+
const quotedTrash = normalizedTrash.includes(" ") ? `"${normalizedTrash}"` : normalizedTrash;
|
|
86
|
+
|
|
87
|
+
return `mkdir -p ${quotedTrash} && mv ${quotedTargets} ${quotedTrash}/`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Suggests a safe trash alternative for an rm execution (#104).
|
|
92
|
+
*
|
|
93
|
+
* @param {string} command
|
|
94
|
+
* @param {string} [workspaceDir]
|
|
95
|
+
* @param {string} [sessionId]
|
|
96
|
+
* @returns {{ isRm: boolean, targets: string[], trashDir: string, suggestedCommand: string | null }}
|
|
97
|
+
*/
|
|
98
|
+
export function suggestTrashAlternative(command, workspaceDir, sessionId) {
|
|
99
|
+
const isRm = isRmCommand(command);
|
|
100
|
+
if (!isRm) {
|
|
101
|
+
return {
|
|
102
|
+
isRm: false,
|
|
103
|
+
targets: [],
|
|
104
|
+
trashDir: "",
|
|
105
|
+
suggestedCommand: null,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const targets = extractRmTargets(command);
|
|
110
|
+
const trashDir = resolveTrashDir(workspaceDir, sessionId);
|
|
111
|
+
const suggestedCommand = targets.length > 0 ? buildTrashCommand(targets, trashDir) : null;
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
isRm: true,
|
|
115
|
+
targets,
|
|
116
|
+
trashDir,
|
|
117
|
+
suggestedCommand,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// lib/version-guard.js
|
|
2
|
+
// Dependency version guard: detects unauthorized major dependency bumps in package.json (#95).
|
|
3
|
+
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
const DEPENDENCY_SECTIONS = [
|
|
8
|
+
"dependencies",
|
|
9
|
+
"devDependencies",
|
|
10
|
+
"peerDependencies",
|
|
11
|
+
"optionalDependencies",
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const SEMVER_REGEX = /^(?:[\^~>=<v\s]*?)(\d+)(?:\.(\d+))?(?:\.(\d+))?/;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Parses semver string into numeric major, minor, and patch components (#95).
|
|
18
|
+
*
|
|
19
|
+
* @param {string} ver
|
|
20
|
+
* @returns {{ major: number, minor: number, patch: number } | null}
|
|
21
|
+
*/
|
|
22
|
+
export function parseSemver(ver) {
|
|
23
|
+
if (typeof ver !== "string" || !ver.trim()) return null;
|
|
24
|
+
const match = SEMVER_REGEX.exec(ver.trim());
|
|
25
|
+
if (!match) return null;
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
major: parseInt(match[1], 10),
|
|
29
|
+
minor: match[2] !== undefined ? parseInt(match[2], 10) : 0,
|
|
30
|
+
patch: match[3] !== undefined ? parseInt(match[3], 10) : 0,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Determines whether a version transition represents a major version bump (#95).
|
|
36
|
+
*
|
|
37
|
+
* @param {string} oldVer
|
|
38
|
+
* @param {string} newVer
|
|
39
|
+
* @returns {boolean}
|
|
40
|
+
*/
|
|
41
|
+
export function isMajorBump(oldVer, newVer) {
|
|
42
|
+
const oldParsed = parseSemver(oldVer);
|
|
43
|
+
const newParsed = parseSemver(newVer);
|
|
44
|
+
if (!oldParsed || !newParsed) return false;
|
|
45
|
+
|
|
46
|
+
return newParsed.major > oldParsed.major;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Compares two dependency maps and returns detected major bumps (#95).
|
|
51
|
+
*
|
|
52
|
+
* @param {Record<string, string>} [oldDeps]
|
|
53
|
+
* @param {Record<string, string>} [newDeps]
|
|
54
|
+
* @param {string} [section="dependencies"]
|
|
55
|
+
* @returns {Array<{ pkg: string, from: string, to: string, section: string }>}
|
|
56
|
+
*/
|
|
57
|
+
export function compareDependencySection(oldDeps = {}, newDeps = {}, section = "dependencies") {
|
|
58
|
+
if (!oldDeps || !newDeps) return [];
|
|
59
|
+
const bumps = [];
|
|
60
|
+
|
|
61
|
+
for (const [pkg, newVer] of Object.entries(newDeps)) {
|
|
62
|
+
const oldVer = oldDeps[pkg];
|
|
63
|
+
if (typeof oldVer === "string" && typeof newVer === "string") {
|
|
64
|
+
if (isMajorBump(oldVer, newVer)) {
|
|
65
|
+
bumps.push({
|
|
66
|
+
pkg,
|
|
67
|
+
from: oldVer,
|
|
68
|
+
to: newVer,
|
|
69
|
+
section,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return bumps;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Inspects a mutation between two package.json objects for major dependency bumps (#95).
|
|
80
|
+
*
|
|
81
|
+
* @param {any} oldPkg
|
|
82
|
+
* @param {any} newPkg
|
|
83
|
+
* @returns {{ hasMajorBump: boolean, bumps: Array<{ pkg: string, from: string, to: string, section: string }> }}
|
|
84
|
+
*/
|
|
85
|
+
export function inspectPackageJsonDiff(oldPkg, newPkg) {
|
|
86
|
+
if (!oldPkg || typeof oldPkg !== "object" || !newPkg || typeof newPkg !== "object") {
|
|
87
|
+
return { hasMajorBump: false, bumps: [] };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const allBumps = [];
|
|
91
|
+
|
|
92
|
+
for (const section of DEPENDENCY_SECTIONS) {
|
|
93
|
+
const oldSec = oldPkg[section] || {};
|
|
94
|
+
const newSec = newPkg[section] || {};
|
|
95
|
+
const sectionBumps = compareDependencySection(oldSec, newSec, section);
|
|
96
|
+
allBumps.push(...sectionBumps);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
hasMajorBump: allBumps.length > 0,
|
|
101
|
+
bumps: allBumps,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Guard enforcing human confirmation on major dependency version updates (#95).
|
|
107
|
+
*/
|
|
108
|
+
export class VersionGuard {
|
|
109
|
+
/**
|
|
110
|
+
* @param {{ workspaceDir?: string }} [options]
|
|
111
|
+
*/
|
|
112
|
+
constructor(options = {}) {
|
|
113
|
+
this.workspaceDir = options.workspaceDir || "";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Inspects a file write operation to detect major dependency bumps in package.json (#95).
|
|
118
|
+
*
|
|
119
|
+
* @param {string} targetPath - Path of file being written
|
|
120
|
+
* @param {string | object} newContent - Incoming content or parsed JSON
|
|
121
|
+
* @returns {{ majorBump: boolean, bumps: Array<any>, detail?: string }}
|
|
122
|
+
*/
|
|
123
|
+
inspectWrite(targetPath, newContent) {
|
|
124
|
+
if (typeof targetPath !== "string") {
|
|
125
|
+
return { majorBump: false, bumps: [] };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const basename = path.basename(targetPath);
|
|
129
|
+
if (basename !== "package.json") {
|
|
130
|
+
return { majorBump: false, bumps: [] };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let parsedNew;
|
|
134
|
+
if (typeof newContent === "string") {
|
|
135
|
+
try {
|
|
136
|
+
parsedNew = JSON.parse(newContent);
|
|
137
|
+
} catch {
|
|
138
|
+
return { majorBump: false, bumps: [] };
|
|
139
|
+
}
|
|
140
|
+
} else if (newContent && typeof newContent === "object") {
|
|
141
|
+
parsedNew = newContent;
|
|
142
|
+
} else {
|
|
143
|
+
return { majorBump: false, bumps: [] };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Attempt reading existing package.json
|
|
147
|
+
let existingPkg = null;
|
|
148
|
+
const resolvedPath = path.isAbsolute(targetPath)
|
|
149
|
+
? targetPath
|
|
150
|
+
: path.resolve(this.workspaceDir || process.cwd(), targetPath);
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
if (fs.existsSync(resolvedPath)) {
|
|
154
|
+
const raw = fs.readFileSync(resolvedPath, "utf-8");
|
|
155
|
+
existingPkg = JSON.parse(raw);
|
|
156
|
+
}
|
|
157
|
+
} catch {
|
|
158
|
+
// ignore read/parse error on existing file
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!existingPkg) {
|
|
162
|
+
return { majorBump: false, bumps: [] };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const diff = inspectPackageJsonDiff(existingPkg, parsedNew);
|
|
166
|
+
if (diff.hasMajorBump) {
|
|
167
|
+
const descriptions = diff.bumps.map((b) => `${b.pkg} (${b.from} -> ${b.to})`).join(", ");
|
|
168
|
+
return {
|
|
169
|
+
majorBump: true,
|
|
170
|
+
bumps: diff.bumps,
|
|
171
|
+
detail: `Major dependency version bump detected: ${descriptions}`,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return { majorBump: false, bumps: [] };
|
|
176
|
+
}
|
|
177
|
+
}
|