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