@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/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": "hintRecursiveRm",
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
+ }