@goodandready/dsh-approval-gate 0.1.20 → 0.1.22

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",
@@ -81,6 +82,12 @@ export const MESSAGES = Object.freeze({
81
82
  ruleAgentsMdProhibitedPath: "File modification prohibited by rules in AGENTS.md",
82
83
  askMajorVersionBump: "Package.json modification contains major dependency bump; review before allowing",
83
84
  ruleMajorVersionBump: "Unauthorized major dependency version bump",
85
+ askRedundantSandbox: "Redundant sandbox elevation requested for safe workspace operation; review or execute in sandboxed mode",
86
+ ruleRedundantSandbox: "Unnecessary sandbox elevation requested for workspace-internal task",
87
+ askMissingJustification: "Privilege escalation requires an explicit justification; review before allowing",
88
+ ruleMissingJustification: "System privilege escalation requested without justification",
89
+ askInsufficientJustification: "Privilege escalation justification is too brief or generic; review before allowing",
90
+ ruleInsufficientJustification: "Insufficient privilege escalation justification",
84
91
  hintRecursiveRm: "Specify exact target files instead of recursive deletion",
85
92
  hintPathTraversal: "Keep operations strictly within the project directory",
86
93
  hintSecretWrite: "Modify configuration via settings or environment variables, not raw file overwrite",
@@ -107,6 +114,10 @@ export const MESSAGES = Object.freeze({
107
114
  hintTrashAlternative: "Move files to reversible .dsh/trash/<session>/ directory instead of rm",
108
115
  hintAgentsMdProhibited: "Review project-specific guidelines and constraints defined in AGENTS.md",
109
116
  hintMajorVersionBump: "Verify compatibility before bumping major dependency versions in package.json",
117
+ hintUnmatchedAsk: "Unrecognized operations default to fail-closed approval under defaultVerdict='ask'",
118
+ hintRedundantSandbox: "Execute the command in standard sandbox mode without danger-full-access",
119
+ hintMissingJustification: "Provide a justification field with at least 10 non-placeholder characters",
120
+ hintInsufficientJustification: "Describe the specific operational reason for privilege escalation",
110
121
  },
111
122
  zh: {
112
123
  blockedPrefix: "dsh-approval-gate 规则已阻止",
@@ -128,6 +139,7 @@ export const MESSAGES = Object.freeze({
128
139
  detailUnsupported: "不支持的 shell 语法",
129
140
  approvalRequired: "需要批准",
130
141
  askUnknown: "命令无法完整检查;请在允许前进行审查",
142
+ askUnmatched: "该操作未被安全规则识别;默认需要人工确认",
131
143
  askUnparseable: "Shell 语法无法完整检查;请在允许前进行审查",
132
144
  askUnknownFormat: "工具调用格式无法检查;请在允许前进行审查",
133
145
  askUnknownWriteTarget: "无法识别文件写入目标;请在允许前进行审查",
@@ -187,6 +199,12 @@ export const MESSAGES = Object.freeze({
187
199
  ruleAgentsMdProhibitedPath: "文件修改被 AGENTS.md 中的规则明确禁止",
188
200
  askMajorVersionBump: "package.json 包含破坏性依赖主版本升级;请在允许前进行审查",
189
201
  ruleMajorVersionBump: "未授权的依赖包主版本升级",
202
+ askRedundantSandbox: "对安全的内部操作请求了冗余的沙箱提权;请在允许前审查或以标准沙箱模式执行",
203
+ ruleRedundantSandbox: "工作区内部任务请求了不必要的沙箱提权",
204
+ askMissingJustification: "特权提升需要明确的理由说明;请在允许前进行审查",
205
+ ruleMissingJustification: "未提供理由即请求系统提权",
206
+ askInsufficientJustification: "特权提升理由过短或为占位符;请在允许前进行审查",
207
+ ruleInsufficientJustification: "特权提升理由不充分",
190
208
  hintRecursiveRm: "请指定具体目标文件,避免递归删除",
191
209
  hintPathTraversal: "请将操作限制在项目根目录内",
192
210
  hintSecretWrite: "请通过设置或环境变量修改配置,不要直接覆盖敏感配置文件",
@@ -213,6 +231,10 @@ export const MESSAGES = Object.freeze({
213
231
  hintTrashAlternative: "建议将文件移动到可逆的 .dsh/trash/<session>/ 目录,避免直接 rm",
214
232
  hintAgentsMdProhibited: "请核对项目根目录 AGENTS.md 中定义的安全约束和禁止指令",
215
233
  hintMajorVersionBump: "在升级 package.json 依赖主版本前请确认破坏性变更与兼容性",
234
+ hintUnmatchedAsk: "在 defaultVerdict='ask' 模式下,未识别的操作默认需要人工审核确认",
235
+ hintRedundantSandbox: "在标准沙箱模式下重新执行该命令,无需申请 danger-full-access",
236
+ hintMissingJustification: "请提供至少10个字符的有效理由说明字段",
237
+ hintInsufficientJustification: "请详细说明提权操作的具体业务或技术原因",
216
238
  },
217
239
  });
218
240
 
@@ -287,6 +309,9 @@ const RULE_KEYS = Object.freeze({
287
309
  "agents-md-prohibited-command": "ruleAgentsMdProhibitedCommand",
288
310
  "agents-md-prohibited-path": "ruleAgentsMdProhibitedPath",
289
311
  "major-version-bump": "askMajorVersionBump",
312
+ "redundant-sandbox-privilege": "ruleRedundantSandbox",
313
+ "missing-escalation-justification": "ruleMissingJustification",
314
+ "insufficient-escalation-justification": "ruleInsufficientJustification",
290
315
  });
291
316
 
292
317
  const HINT_KEYS = Object.freeze({
@@ -317,12 +342,20 @@ const HINT_KEYS = Object.freeze({
317
342
  "agents-md-prohibited-command": "hintAgentsMdProhibited",
318
343
  "agents-md-prohibited-path": "hintAgentsMdProhibited",
319
344
  "major-version-bump": "hintMajorVersionBump",
345
+ "unmatched-ask": "hintUnmatchedAsk",
346
+ "redundant-sandbox-privilege": "hintRedundantSandbox",
347
+ "missing-escalation-justification": "hintMissingJustification",
348
+ "insufficient-escalation-justification": "hintInsufficientJustification",
320
349
  });
321
350
 
322
351
  const ASK_KEYS = Object.freeze({
323
352
  unparseable: "askUnparseable",
324
353
  "unknown-format": "askUnknownFormat",
325
354
  "unknown-write-target": "askUnknownWriteTarget",
355
+ "unmatched-ask": "askUnmatched",
356
+ "redundant-sandbox-privilege": "askRedundantSandbox",
357
+ "missing-escalation-justification": "askMissingJustification",
358
+ "insufficient-escalation-justification": "askInsufficientJustification",
326
359
  "dynamic-command": "askDynamicCommand",
327
360
  "dynamic-arguments": "askDynamicArguments",
328
361
  "dynamic-redirect": "askDynamicRedirect",
@@ -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/package.json CHANGED
@@ -1,55 +1,55 @@
1
- {
2
- "name": "@goodandready/dsh-approval-gate",
3
- "version": "0.1.20",
4
- "description": "Host-only command safety gate for DeepSeek Harness: blocks recognized dangerous operations and requests DSH approval when a command cannot be inspected.",
5
- "type": "module",
6
- "main": "lib/index.js",
7
- "exports": {
8
- ".": "./lib/index.js",
9
- "./package.json": "./package.json",
10
- "./cordis.patch.yml": "./cordis.patch.yml"
11
- },
12
- "files": [
13
- "lib",
14
- "rules",
15
- "cordis.patch.yml",
16
- "README.md",
17
- "README.zh.md",
18
- "README.ru.md",
19
- "CHANGELOG.md",
20
- "LICENSE"
21
- ],
22
- "dsh": {
23
- "bundle": {
24
- "patch": "./cordis.patch.yml"
25
- }
26
- },
27
- "license": "MIT",
28
- "peerDependencies": {
29
- "@deepseek-ai/cordis": "^4.0.1",
30
- "@deepseek-ai/schemastery": "^3.18.1"
31
- },
32
- "repository": {
33
- "type": "git",
34
- "url": "git+https://github.com/GooDAnDReaDY/dsh-approval-gate.git"
35
- },
36
- "homepage": "https://github.com/GooDAnDReaDY/dsh-approval-gate#readme",
37
- "bugs": {
38
- "url": "https://github.com/GooDAnDReaDY/dsh-approval-gate/issues"
39
- },
40
- "scripts": {
41
- "test": "node --test test/*.test.mjs"
42
- },
43
- "publishConfig": {
44
- "registry": "https://registry.npmjs.org",
45
- "access": "public"
46
- },
47
- "keywords": [
48
- "dsh",
49
- "dsh-plugin",
50
- "deepseek-harness",
51
- "security",
52
- "approval-gate",
53
- "safety"
54
- ]
55
- }
1
+ {
2
+ "name": "@goodandready/dsh-approval-gate",
3
+ "version": "0.1.22",
4
+ "description": "Host-only command safety gate for DeepSeek Harness: blocks recognized dangerous operations and requests DSH approval when a command cannot be inspected.",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./package.json": "./package.json",
10
+ "./cordis.patch.yml": "./cordis.patch.yml"
11
+ },
12
+ "files": [
13
+ "lib",
14
+ "rules",
15
+ "cordis.patch.yml",
16
+ "README.md",
17
+ "README.zh.md",
18
+ "README.ru.md",
19
+ "CHANGELOG.md",
20
+ "LICENSE"
21
+ ],
22
+ "dsh": {
23
+ "bundle": {
24
+ "patch": "./cordis.patch.yml"
25
+ }
26
+ },
27
+ "license": "MIT",
28
+ "peerDependencies": {
29
+ "@deepseek-ai/cordis": "^4.0.1",
30
+ "@deepseek-ai/schemastery": "^3.18.1"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/GooDAnDReaDY/dsh-approval-gate.git"
35
+ },
36
+ "homepage": "https://github.com/GooDAnDReaDY/dsh-approval-gate#readme",
37
+ "bugs": {
38
+ "url": "https://github.com/GooDAnDReaDY/dsh-approval-gate/issues"
39
+ },
40
+ "scripts": {
41
+ "test": "node --test test/*.test.mjs"
42
+ },
43
+ "publishConfig": {
44
+ "registry": "https://registry.npmjs.org",
45
+ "access": "public"
46
+ },
47
+ "keywords": [
48
+ "dsh",
49
+ "dsh-plugin",
50
+ "deepseek-harness",
51
+ "security",
52
+ "approval-gate",
53
+ "safety"
54
+ ]
55
+ }