@goodandready/dsh-approval-gate 0.1.19 → 0.1.21
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/bands.js +243 -239
- package/lib/compat.js +124 -0
- package/lib/engine.js +78 -60
- package/lib/index.js +58 -97
- package/lib/measure-kinds.js +135 -0
- package/lib/messages.js +28 -1
- package/lib/snapshots.js +268 -0
- package/lib/trash.js +119 -0
- package/lib/version-guard.js +177 -0
- package/package.json +55 -55
package/lib/messages.js
CHANGED
|
@@ -22,6 +22,7 @@ export const MESSAGES = Object.freeze({
|
|
|
22
22
|
detailUnsupported: "unsupported shell syntax",
|
|
23
23
|
approvalRequired: "Approval required",
|
|
24
24
|
askUnknown: "The command could not be fully inspected; review it before allowing",
|
|
25
|
+
askUnmatched: "The operation was not recognized by safety rules; human confirmation is required by default",
|
|
25
26
|
askUnparseable: "Shell syntax could not be fully inspected; review it before allowing",
|
|
26
27
|
askUnknownFormat: "The tool call format could not be inspected; review it before allowing",
|
|
27
28
|
askUnknownWriteTarget: "The file write target could not be identified; review it before allowing",
|
|
@@ -77,6 +78,10 @@ export const MESSAGES = Object.freeze({
|
|
|
77
78
|
ruleUnisolatedTempPath: "Target path uses unisolated global temp directory",
|
|
78
79
|
askOverengineeringLimit: "Session file creation limit exceeded; potential overengineering requires review before allowing",
|
|
79
80
|
ruleOverengineeringLimit: "Session exceeded maximum allowed created files limit",
|
|
81
|
+
ruleAgentsMdProhibitedCommand: "Command execution prohibited by rules in AGENTS.md",
|
|
82
|
+
ruleAgentsMdProhibitedPath: "File modification prohibited by rules in AGENTS.md",
|
|
83
|
+
askMajorVersionBump: "Package.json modification contains major dependency bump; review before allowing",
|
|
84
|
+
ruleMajorVersionBump: "Unauthorized major dependency version bump",
|
|
80
85
|
hintRecursiveRm: "Specify exact target files instead of recursive deletion",
|
|
81
86
|
hintPathTraversal: "Keep operations strictly within the project directory",
|
|
82
87
|
hintSecretWrite: "Modify configuration via settings or environment variables, not raw file overwrite",
|
|
@@ -100,6 +105,10 @@ export const MESSAGES = Object.freeze({
|
|
|
100
105
|
hintContractViolation: "Confine operations to permitted task tools and project scope",
|
|
101
106
|
hintUnisolatedTempPath: "Use workspace-isolated temporary directory (<workspace>/.dsh/tmp)",
|
|
102
107
|
hintOverengineeringLimit: "Reuse existing files and avoid creating redundant abstractions or boilerplate",
|
|
108
|
+
hintTrashAlternative: "Move files to reversible .dsh/trash/<session>/ directory instead of rm",
|
|
109
|
+
hintAgentsMdProhibited: "Review project-specific guidelines and constraints defined in AGENTS.md",
|
|
110
|
+
hintMajorVersionBump: "Verify compatibility before bumping major dependency versions in package.json",
|
|
111
|
+
hintUnmatchedAsk: "Unrecognized operations default to fail-closed approval under defaultVerdict='ask'",
|
|
103
112
|
},
|
|
104
113
|
zh: {
|
|
105
114
|
blockedPrefix: "dsh-approval-gate 规则已阻止",
|
|
@@ -121,6 +130,7 @@ export const MESSAGES = Object.freeze({
|
|
|
121
130
|
detailUnsupported: "不支持的 shell 语法",
|
|
122
131
|
approvalRequired: "需要批准",
|
|
123
132
|
askUnknown: "命令无法完整检查;请在允许前进行审查",
|
|
133
|
+
askUnmatched: "该操作未被安全规则识别;默认需要人工确认",
|
|
124
134
|
askUnparseable: "Shell 语法无法完整检查;请在允许前进行审查",
|
|
125
135
|
askUnknownFormat: "工具调用格式无法检查;请在允许前进行审查",
|
|
126
136
|
askUnknownWriteTarget: "无法识别文件写入目标;请在允许前进行审查",
|
|
@@ -176,6 +186,10 @@ export const MESSAGES = Object.freeze({
|
|
|
176
186
|
ruleUnisolatedTempPath: "目标路径使用未隔离的全局临时目录",
|
|
177
187
|
askOverengineeringLimit: "会话创建文件数超过限制;潜在的过度工程需要人工审查",
|
|
178
188
|
ruleOverengineeringLimit: "会话超出最大允许创建文件数限制",
|
|
189
|
+
ruleAgentsMdProhibitedCommand: "命令执行被 AGENTS.md 中的规则明确禁止",
|
|
190
|
+
ruleAgentsMdProhibitedPath: "文件修改被 AGENTS.md 中的规则明确禁止",
|
|
191
|
+
askMajorVersionBump: "package.json 包含破坏性依赖主版本升级;请在允许前进行审查",
|
|
192
|
+
ruleMajorVersionBump: "未授权的依赖包主版本升级",
|
|
179
193
|
hintRecursiveRm: "请指定具体目标文件,避免递归删除",
|
|
180
194
|
hintPathTraversal: "请将操作限制在项目根目录内",
|
|
181
195
|
hintSecretWrite: "请通过设置或环境变量修改配置,不要直接覆盖敏感配置文件",
|
|
@@ -199,6 +213,10 @@ export const MESSAGES = Object.freeze({
|
|
|
199
213
|
hintContractViolation: "将操作限制在允许的任务工具和项目范围内",
|
|
200
214
|
hintUnisolatedTempPath: "使用工作区隔离的临时目录 (<workspace>/.dsh/tmp)",
|
|
201
215
|
hintOverengineeringLimit: "复用现有文件,避免创建多余的抽象或样板代码",
|
|
216
|
+
hintTrashAlternative: "建议将文件移动到可逆的 .dsh/trash/<session>/ 目录,避免直接 rm",
|
|
217
|
+
hintAgentsMdProhibited: "请核对项目根目录 AGENTS.md 中定义的安全约束和禁止指令",
|
|
218
|
+
hintMajorVersionBump: "在升级 package.json 依赖主版本前请确认破坏性变更与兼容性",
|
|
219
|
+
hintUnmatchedAsk: "在 defaultVerdict='ask' 模式下,未识别的操作默认需要人工审核确认",
|
|
202
220
|
},
|
|
203
221
|
});
|
|
204
222
|
|
|
@@ -270,10 +288,13 @@ const RULE_KEYS = Object.freeze({
|
|
|
270
288
|
"contract-violation": "ruleContractViolation",
|
|
271
289
|
"unisolated-temp-path": "ruleUnisolatedTempPath",
|
|
272
290
|
"overengineering-file-limit": "ruleOverengineeringLimit",
|
|
291
|
+
"agents-md-prohibited-command": "ruleAgentsMdProhibitedCommand",
|
|
292
|
+
"agents-md-prohibited-path": "ruleAgentsMdProhibitedPath",
|
|
293
|
+
"major-version-bump": "askMajorVersionBump",
|
|
273
294
|
});
|
|
274
295
|
|
|
275
296
|
const HINT_KEYS = Object.freeze({
|
|
276
|
-
"recursive-rm": "
|
|
297
|
+
"recursive-rm": "hintTrashAlternative",
|
|
277
298
|
"path-traversal": "hintPathTraversal",
|
|
278
299
|
"secret-write": "hintSecretWrite",
|
|
279
300
|
"env-dump-leak": "hintEnvDumpLeak",
|
|
@@ -297,12 +318,17 @@ const HINT_KEYS = Object.freeze({
|
|
|
297
318
|
"contract-violation": "hintContractViolation",
|
|
298
319
|
"unisolated-temp-path": "hintUnisolatedTempPath",
|
|
299
320
|
"overengineering-file-limit": "hintOverengineeringLimit",
|
|
321
|
+
"agents-md-prohibited-command": "hintAgentsMdProhibited",
|
|
322
|
+
"agents-md-prohibited-path": "hintAgentsMdProhibited",
|
|
323
|
+
"major-version-bump": "hintMajorVersionBump",
|
|
324
|
+
"unmatched-ask": "hintUnmatchedAsk",
|
|
300
325
|
});
|
|
301
326
|
|
|
302
327
|
const ASK_KEYS = Object.freeze({
|
|
303
328
|
unparseable: "askUnparseable",
|
|
304
329
|
"unknown-format": "askUnknownFormat",
|
|
305
330
|
"unknown-write-target": "askUnknownWriteTarget",
|
|
331
|
+
"unmatched-ask": "askUnmatched",
|
|
306
332
|
"dynamic-command": "askDynamicCommand",
|
|
307
333
|
"dynamic-arguments": "askDynamicArguments",
|
|
308
334
|
"dynamic-redirect": "askDynamicRedirect",
|
|
@@ -321,6 +347,7 @@ const ASK_KEYS = Object.freeze({
|
|
|
321
347
|
"contract-violation": "askContractViolation",
|
|
322
348
|
"unisolated-temp-path": "askUnisolatedTempPath",
|
|
323
349
|
"overengineering-file-limit": "askOverengineeringLimit",
|
|
350
|
+
"major-version-bump": "askMajorVersionBump",
|
|
324
351
|
});
|
|
325
352
|
|
|
326
353
|
const DETAIL_KEYS = Object.freeze({
|
package/lib/snapshots.js
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// lib/snapshots.js
|
|
2
|
+
// Pre-execution Snapshots (#77), Undo/Rollback (#76), and Dual-level Cleanup (#78).
|
|
3
|
+
// Zero external dependencies; safe filesystem operations with in-memory metadata.
|
|
4
|
+
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import crypto from "node:crypto";
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_MAX_SNAPSHOTS_PER_SESSION = 50;
|
|
10
|
+
export const DEFAULT_MAX_SNAPSHOT_BYTES = 1024 * 1024; // 1 MB
|
|
11
|
+
|
|
12
|
+
export class SnapshotManager {
|
|
13
|
+
/**
|
|
14
|
+
* @param {Record<string, any>} [options]
|
|
15
|
+
*/
|
|
16
|
+
constructor(options = {}) {
|
|
17
|
+
this.maxSnapshotsPerSession = Number(options.maxSnapshotsPerSession) || DEFAULT_MAX_SNAPSHOTS_PER_SESSION;
|
|
18
|
+
this.maxSnapshotBytes = Number(options.maxSnapshotBytes) || DEFAULT_MAX_SNAPSHOT_BYTES;
|
|
19
|
+
this.workspaceDir = options.workspaceDir ? path.resolve(options.workspaceDir) : "";
|
|
20
|
+
|
|
21
|
+
/** @type {Map<string, Record<string, any>>} snapshotId -> Snapshot */
|
|
22
|
+
this.snapshots = new Map();
|
|
23
|
+
/** @type {Map<string, string[]>} sessionId -> [snapshotId, ...] */
|
|
24
|
+
this.sessionIndex = new Map();
|
|
25
|
+
/** @type {Map<string, string>} callId -> snapshotId */
|
|
26
|
+
this.callIndex = new Map();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolves absolute file path within workspace or host.
|
|
31
|
+
* @param {string} targetPath
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
34
|
+
resolvePath(targetPath) {
|
|
35
|
+
if (!targetPath || typeof targetPath !== "string") return "";
|
|
36
|
+
if (path.isAbsolute(targetPath)) return path.normalize(targetPath);
|
|
37
|
+
if (this.workspaceDir) return path.resolve(this.workspaceDir, targetPath);
|
|
38
|
+
return path.resolve(targetPath);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Captures a pre-execution snapshot before file mutation (#77).
|
|
43
|
+
* @param {string} targetPath - Relative or absolute path to target file
|
|
44
|
+
* @param {Record<string, any>} [meta] - Metadata (sessionId, callId, tool)
|
|
45
|
+
* @returns {Record<string, any>} Created snapshot record
|
|
46
|
+
*/
|
|
47
|
+
takeSnapshot(targetPath, meta = {}) {
|
|
48
|
+
const resolved = this.resolvePath(targetPath);
|
|
49
|
+
if (!resolved) {
|
|
50
|
+
return { success: false, error: "invalid-target-path" };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const sessionId = String(meta.sessionId || "default");
|
|
54
|
+
const callId = meta.callId ? String(meta.callId) : null;
|
|
55
|
+
const tool = meta.tool ? String(meta.tool) : "unknown";
|
|
56
|
+
|
|
57
|
+
let existed = false;
|
|
58
|
+
let content = null;
|
|
59
|
+
let tooLarge = false;
|
|
60
|
+
let size = 0;
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
if (fs.existsSync(resolved)) {
|
|
64
|
+
existed = true;
|
|
65
|
+
const stat = fs.statSync(resolved);
|
|
66
|
+
size = stat.size;
|
|
67
|
+
if (size > this.maxSnapshotBytes) {
|
|
68
|
+
tooLarge = true;
|
|
69
|
+
} else {
|
|
70
|
+
content = fs.readFileSync(resolved, "utf8");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
// If file unreadable or permission error, proceed safely
|
|
75
|
+
existed = false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const id = "snap_" + Date.now() + "_" + crypto.randomBytes(4).toString("hex");
|
|
79
|
+
const snapshot = {
|
|
80
|
+
id,
|
|
81
|
+
sessionId,
|
|
82
|
+
callId,
|
|
83
|
+
tool,
|
|
84
|
+
targetPath: resolved,
|
|
85
|
+
originalPath: targetPath,
|
|
86
|
+
existed,
|
|
87
|
+
content,
|
|
88
|
+
size,
|
|
89
|
+
tooLarge,
|
|
90
|
+
createdAt: Date.now(),
|
|
91
|
+
rolledBack: false,
|
|
92
|
+
rolledBackAt: null,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
this.snapshots.set(id, snapshot);
|
|
96
|
+
|
|
97
|
+
if (callId) {
|
|
98
|
+
this.callIndex.set(callId, id);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!this.sessionIndex.has(sessionId)) {
|
|
102
|
+
this.sessionIndex.set(sessionId, []);
|
|
103
|
+
}
|
|
104
|
+
const sessionList = this.sessionIndex.get(sessionId);
|
|
105
|
+
sessionList.push(id);
|
|
106
|
+
|
|
107
|
+
// Evict oldest if exceeding maxSnapshotsPerSession
|
|
108
|
+
if (sessionList.length > this.maxSnapshotsPerSession) {
|
|
109
|
+
const evictedId = sessionList.shift();
|
|
110
|
+
if (evictedId) {
|
|
111
|
+
const evicted = this.snapshots.get(evictedId);
|
|
112
|
+
if (evicted && evicted.callId) {
|
|
113
|
+
this.callIndex.delete(evicted.callId);
|
|
114
|
+
}
|
|
115
|
+
this.snapshots.delete(evictedId);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return snapshot;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Retrieves a snapshot by ID.
|
|
124
|
+
* @param {string} id
|
|
125
|
+
* @returns {Record<string, any> | null}
|
|
126
|
+
*/
|
|
127
|
+
getSnapshot(id) {
|
|
128
|
+
return this.snapshots.get(id) || null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Retrieves a snapshot by tool callId.
|
|
133
|
+
* @param {string} callId
|
|
134
|
+
* @returns {Record<string, any> | null}
|
|
135
|
+
*/
|
|
136
|
+
getSnapshotByCallId(callId) {
|
|
137
|
+
const id = this.callIndex.get(String(callId));
|
|
138
|
+
return id ? this.getSnapshot(id) : null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Retrieves all snapshots for a session.
|
|
143
|
+
* @param {string} sessionId
|
|
144
|
+
* @returns {Record<string, any>[]}
|
|
145
|
+
*/
|
|
146
|
+
getSnapshots(sessionId) {
|
|
147
|
+
const ids = this.sessionIndex.get(String(sessionId)) || [];
|
|
148
|
+
const res = [];
|
|
149
|
+
for (const id of ids) {
|
|
150
|
+
const snap = this.snapshots.get(id);
|
|
151
|
+
if (snap) res.push(snap);
|
|
152
|
+
}
|
|
153
|
+
return res;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Undoes a file change by rolling back to its pre-execution snapshot (#76).
|
|
158
|
+
* @param {string} snapshotIdOrCallback - Snapshot ID or Call ID
|
|
159
|
+
* @returns {{ success: boolean, error?: string, snapshotId?: string, targetPath?: string, restoredExisted?: boolean }}
|
|
160
|
+
*/
|
|
161
|
+
rollbackSnapshot(snapshotIdOrCallback) {
|
|
162
|
+
if (!snapshotIdOrCallback) {
|
|
163
|
+
return { success: false, error: "snapshot-id-required" };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let snap = this.snapshots.get(snapshotIdOrCallback);
|
|
167
|
+
if (!snap) {
|
|
168
|
+
const id = this.callIndex.get(String(snapshotIdOrCallback));
|
|
169
|
+
if (id) snap = this.snapshots.get(id);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (!snap) {
|
|
173
|
+
return { success: false, error: "snapshot-not-found" };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (snap.rolledBack) {
|
|
177
|
+
return { success: false, error: "snapshot-already-rolled-back", snapshotId: snap.id };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (snap.tooLarge) {
|
|
181
|
+
return { success: false, error: "snapshot-size-limit-exceeded", snapshotId: snap.id };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
if (snap.existed && snap.content !== null) {
|
|
186
|
+
// Restore previous content
|
|
187
|
+
const dir = path.dirname(snap.targetPath);
|
|
188
|
+
if (!fs.existsSync(dir)) {
|
|
189
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
190
|
+
}
|
|
191
|
+
fs.writeFileSync(snap.targetPath, snap.content, "utf8");
|
|
192
|
+
} else if (!snap.existed) {
|
|
193
|
+
// File was newly created by this call: delete it to undo
|
|
194
|
+
if (fs.existsSync(snap.targetPath)) {
|
|
195
|
+
fs.unlinkSync(snap.targetPath);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
snap.rolledBack = true;
|
|
200
|
+
snap.rolledBackAt = Date.now();
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
success: true,
|
|
204
|
+
snapshotId: snap.id,
|
|
205
|
+
targetPath: snap.targetPath,
|
|
206
|
+
restoredExisted: snap.existed,
|
|
207
|
+
rolledBackAt: snap.rolledBackAt,
|
|
208
|
+
};
|
|
209
|
+
} catch (err) {
|
|
210
|
+
return {
|
|
211
|
+
success: false,
|
|
212
|
+
error: "rollback-io-error",
|
|
213
|
+
detail: String(err && err.message || err),
|
|
214
|
+
snapshotId: snap.id,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Cleans up snapshots for a single session safely (#78).
|
|
221
|
+
* @param {string} sessionId
|
|
222
|
+
* @returns {{ cleared: number, sessionId: string }}
|
|
223
|
+
*/
|
|
224
|
+
clearSnapshots(sessionId) {
|
|
225
|
+
const sId = String(sessionId || "default");
|
|
226
|
+
const ids = this.sessionIndex.get(sId) || [];
|
|
227
|
+
let cleared = 0;
|
|
228
|
+
for (const id of ids) {
|
|
229
|
+
const snap = this.snapshots.get(id);
|
|
230
|
+
if (snap && snap.callId) {
|
|
231
|
+
this.callIndex.delete(snap.callId);
|
|
232
|
+
}
|
|
233
|
+
if (this.snapshots.delete(id)) {
|
|
234
|
+
cleared++;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
this.sessionIndex.delete(sId);
|
|
238
|
+
return { cleared, sessionId: sId };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Cleans up snapshots globally across all sessions (#78).
|
|
243
|
+
* @returns {{ cleared: number }}
|
|
244
|
+
*/
|
|
245
|
+
clearAllSnapshots() {
|
|
246
|
+
const total = this.snapshots.size;
|
|
247
|
+
this.snapshots.clear();
|
|
248
|
+
this.sessionIndex.clear();
|
|
249
|
+
this.callIndex.clear();
|
|
250
|
+
return { cleared: total };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Returns snapshot usage metrics and statistics.
|
|
255
|
+
* @returns {{ totalSnapshots: number, sessionCount: number, totalBytes: number }}
|
|
256
|
+
*/
|
|
257
|
+
getStats() {
|
|
258
|
+
let totalBytes = 0;
|
|
259
|
+
for (const snap of this.snapshots.values()) {
|
|
260
|
+
totalBytes += snap.size || 0;
|
|
261
|
+
}
|
|
262
|
+
return {
|
|
263
|
+
totalSnapshots: this.snapshots.size,
|
|
264
|
+
sessionCount: this.sessionIndex.size,
|
|
265
|
+
totalBytes,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
}
|
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
|
+
}
|