@goodandready/dsh-approval-gate 0.1.20 → 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 CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.21
4
+
5
+ - Feature: Автоматические легковесные пре-снимки файлов (`Pre-execution Snapshots` в `lib/snapshots.js`) перед выполнением мутирующих инструментов с отслеживанием размера и FIFO-вытеснением по сессиям (Refs: #77).
6
+ - Feature: Однократный откат изменений (`Undo this change` / `rollbackSnapshot`) из сохраненного пре-снимка с восстановлением исходного контента или удалением созданного файла (Refs: #76).
7
+ - Feature: Двухуровневая очистка файловых снимков (`clearSnapshots` для безопасной очистки текущей сессии без влияния на соседние диалоги vs `clearAllSnapshots` для глобального сброса) (Refs: #78).
8
+ - Feature: Многоуровневая категоризация масштаба влияния операций (`Measure Kinds` в `lib/measure-kinds.js`: `local-single`, `workspace-wide`, `system-level`) с обогащением вердикта и фиксацией в аудите (Refs: #97).
9
+ - Security: Завершение перехода #163 — перевод конфигурации по умолчанию в строгий fail-closed режим (`defaultVerdict: 'ask'`) для неизвестных команд оболочки (Refs: #172).
10
+
3
11
  ## 0.1.20
4
12
 
5
13
  - Feature: Безопасная альтернатива необратимому удалению (`Reversible Trash Move` в `lib/trash.js`) с переносом удаляемых файлов в изолированную сессионную корзину `<workspace>/.dsh/trash/<session_id>/` вместо деструктивного `rm` (Refs: #104).
package/lib/bands.js CHANGED
@@ -1,239 +1,243 @@
1
- // lib/bands.js
2
- // Two-band deterministic filtering engine for dsh-approval-gate.
3
- // Band 0: Hard Deny (fast regexes for exfiltration, fork-bombs, disk wipes)
4
- // Band 1: Safe Allow (prefix globs & curated safe read-only inspection commands and tools)
5
-
6
- export const DEFAULT_DENY_PATTERNS = Object.freeze([
7
- /\b(?:nc|ncat|netcat)\s+.*-e\s+/i,
8
- /\/dev\/tcp\/\d+\.\d+\.\d+\.\d+\/\d+/i,
9
- /\/dev\/udp\/\d+\.\d+\.\d+\.\d+\/\d+/i,
10
- /\bmkfs(?:\.[a-z0-9]+)?\s+/i,
11
- /\bdd\s+.*of=\/dev\/(?:sd|hd|vd|xvd|nvme|mmcblk|loop|mapper)/i,
12
- /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/,
13
- /\bchmod\s+(?:-R\s+)?(?:0?777|a\+rwx)\s+\/(?!tmp)/i,
14
- /\bchown\s+(?:-R\s+)?root\b/i,
15
- /\b(?:shutdown|reboot|poweroff|halt)\s*(?:-|$)/i,
16
- ]);
17
-
18
- export const DEFAULT_ALLOW_GLOBS = Object.freeze([
19
- // Git inspection commands
20
- "git status*",
21
- "git diff*",
22
- "git log*",
23
- "git branch*",
24
- "git show*",
25
- "git remote*",
26
- "git tag*",
27
- "git rev-parse*",
28
-
29
- // File viewing & analysis
30
- "cat *",
31
- "head *",
32
- "tail *",
33
- "more *",
34
- "less *",
35
- "wc *",
36
- "file *",
37
- "stat *",
38
- "diff *",
39
-
40
- // Search & finding
41
- "grep *",
42
- "fgrep *",
43
- "egrep *",
44
- "rg *",
45
- "ag *",
46
- "find *",
47
-
48
- // Directory listing & environment info
49
- "ls*",
50
- "dir*",
51
- "pwd*",
52
- "which *",
53
- "whereis *",
54
- "whoami*",
55
- "hostname*",
56
- "uname*",
57
- "date*",
58
- "uptime*",
59
- "echo *",
60
- "printf *",
61
-
62
- // Language / runtime versions & test runners
63
- "node --version*",
64
- "node -v*",
65
- "npm test*",
66
- "npm --version*",
67
- "npm -v*",
68
- "pnpm test*",
69
- "pnpm --version*",
70
- "pnpm -v*",
71
- "yarn test*",
72
- "cargo test*",
73
- "cargo check*",
74
- "cargo --version*",
75
- "pytest*",
76
- "python --version*",
77
- "python3 --version*",
78
- ]);
79
-
80
- export const DEFAULT_READ_ONLY_TOOLS = Object.freeze([
81
- "read",
82
- "read_file",
83
- "readFile",
84
- "read_file_content",
85
- "view_file",
86
- "view",
87
- "show",
88
- "glob",
89
- "grep",
90
- "find_files",
91
- "find_by_name",
92
- "search_code",
93
- "file_search",
94
- "list_dir",
95
- "list_directory",
96
- "directory_list",
97
- "tree",
98
- "read_url_content",
99
- "fetch_web_page",
100
- "web_search",
101
- "search_web",
102
- "list_resources",
103
- "read_resource",
104
- ]);
105
-
106
- /**
107
- * Compile a prefix-glob into an anchored RegExp.
108
- * @param {string} pattern
109
- * @returns {RegExp}
110
- */
111
- export function compileGlob(pattern) {
112
- let source = "";
113
- for (const ch of pattern) {
114
- if (ch === "*") source += ".*";
115
- else if (ch === "?") source += ".";
116
- else source += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
117
- }
118
- return new RegExp(`^${source}$`, "i");
119
- }
120
-
121
- export class BandsEngine {
122
- /**
123
- * @param {{
124
- * denyPatterns?: Array<RegExp|string>,
125
- * allowGlobs?: Array<RegExp|string>,
126
- * readOnlyTools?: Array<string>
127
- * }} [options]
128
- */
129
- constructor(options = {}) {
130
- this.denyPatterns = (options.denyPatterns || DEFAULT_DENY_PATTERNS).map((p) =>
131
- typeof p === "string" ? new RegExp(p, "i") : p
132
- );
133
- this.allowGlobs = (options.allowGlobs || DEFAULT_ALLOW_GLOBS).map((g) =>
134
- typeof g === "string" ? compileGlob(g) : g
135
- );
136
- this.readOnlyTools = new Set(options.readOnlyTools || DEFAULT_READ_ONLY_TOOLS);
137
-
138
- // Derive read-only tools dynamically from host tool registry if provided (#156)
139
- if (options.toolRegistry) {
140
- try {
141
- const tools = typeof options.toolRegistry.getTools === "function"
142
- ? options.toolRegistry.getTools()
143
- : (Array.isArray(options.toolRegistry) ? options.toolRegistry : []);
144
- for (const t of tools) {
145
- if (t && (t.readOnly || t.readonly || t.isReadOnly) && typeof t.name === "string") {
146
- this.readOnlyTools.add(t.name);
147
- }
148
- }
149
- } catch {
150
- // ignore
151
- }
152
- }
153
- }
154
-
155
- /**
156
- * Check if a tool is configured as an unconditional read-only tool.
157
- * @param {string} tool
158
- * @returns {boolean}
159
- */
160
- isReadOnlyTool(tool) {
161
- return typeof tool === "string" && this.readOnlyTools.has(tool.trim());
162
- }
163
-
164
- /**
165
- * Check if a command matches safe inspection patterns.
166
- * @param {string} command
167
- * @returns {boolean}
168
- */
169
- isSafeInspectionCommand(command) {
170
- if (typeof command !== "string" || command.trim() === "") return false;
171
- const text = command.trim();
172
- // Chained commands, subshells, substitutions, and redirections cannot match whole-string safe-allow
173
- if (/[\n;&|`<>]|\$\([\s\S]*\)|\$\{[\s\S]*\}/.test(text)) {
174
- return false;
175
- }
176
- for (const pattern of this.denyPatterns) {
177
- if (pattern.test(text)) return false;
178
- }
179
- for (const glob of this.allowGlobs) {
180
- if (glob.test(text)) return true;
181
- }
182
- return false;
183
- }
184
-
185
- /**
186
- * Register an additional read-only tool name.
187
- * @param {string} tool
188
- */
189
- addReadOnlyTool(tool) {
190
- if (typeof tool === "string" && tool.trim()) {
191
- this.readOnlyTools.add(tool.trim());
192
- }
193
- }
194
-
195
- /**
196
- * Register an additional allow-glob pattern.
197
- * @param {string} globPattern
198
- */
199
- addAllowGlob(globPattern) {
200
- if (typeof globPattern === "string" && globPattern.trim()) {
201
- this.allowGlobs.push(compileGlob(globPattern.trim()));
202
- }
203
- }
204
-
205
- /**
206
- * Evaluates an execution request through Band 0 and Band 1.
207
- * @param {string} tool - Tool name
208
- * @param {string} [command] - Normalized command text (for bash)
209
- * @returns {"deny" | "allow" | "unmatched"}
210
- */
211
- evaluate(tool, command) {
212
- if (this.isReadOnlyTool(tool)) {
213
- return "allow";
214
- }
215
-
216
- if (typeof command === "string" && command.trim().length > 0) {
217
- const text = command.trim();
218
-
219
- // Band 0: Hard Deny
220
- for (const pattern of this.denyPatterns) {
221
- if (pattern.test(text)) {
222
- return "deny";
223
- }
224
- }
225
-
226
- // Band 1: Safe Allow (applies strictly to single commands without chaining, subshells or redirects)
227
- if (!/[\n;&|`<>]|\$\([\s\S]*\)|\$\{[\s\S]*\}/.test(text)) {
228
- for (const glob of this.allowGlobs) {
229
- if (glob.test(text)) {
230
- return "allow";
231
- }
232
- }
233
- }
234
- }
235
-
236
- return "unmatched";
237
- }
238
- }
239
-
1
+ // lib/bands.js
2
+ // Two-band deterministic filtering engine for dsh-approval-gate.
3
+ // Band 0: Hard Deny (fast regexes for exfiltration, fork-bombs, disk wipes)
4
+ // Band 1: Safe Allow (prefix globs & curated safe read-only inspection commands and tools)
5
+
6
+ export const DEFAULT_DENY_PATTERNS = Object.freeze([
7
+ /\b(?:nc|ncat|netcat)\s+.*-e\s+/i,
8
+ /\/dev\/tcp\/\d+\.\d+\.\d+\.\d+\/\d+/i,
9
+ /\/dev\/udp\/\d+\.\d+\.\d+\.\d+\/\d+/i,
10
+ /\bmkfs(?:\.[a-z0-9]+)?\s+/i,
11
+ /\bdd\s+.*of=\/dev\/(?:sd|hd|vd|xvd|nvme|mmcblk|loop|mapper)/i,
12
+ /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/,
13
+ /\bchmod\s+(?:-R\s+)?(?:0?777|a\+rwx)\s+\/(?!tmp)/i,
14
+ /\bchown\s+(?:-R\s+)?root\b/i,
15
+ /\b(?:shutdown|reboot|poweroff|halt)\s*(?:-|$)/i,
16
+ ]);
17
+
18
+ export const DEFAULT_ALLOW_GLOBS = Object.freeze([
19
+ // Git inspection commands
20
+ "git status*",
21
+ "git diff*",
22
+ "git log*",
23
+ "git branch*",
24
+ "git show*",
25
+ "git remote*",
26
+ "git tag*",
27
+ "git rev-parse*",
28
+
29
+ // File viewing & analysis
30
+ "cat *",
31
+ "head *",
32
+ "tail *",
33
+ "more *",
34
+ "less *",
35
+ "wc *",
36
+ "file *",
37
+ "stat *",
38
+ "diff *",
39
+
40
+ // Search & finding
41
+ "grep *",
42
+ "fgrep *",
43
+ "egrep *",
44
+ "rg *",
45
+ "ag *",
46
+ "find *",
47
+
48
+ // Directory listing & environment info
49
+ "ls*",
50
+ "dir*",
51
+ "pwd*",
52
+ "which *",
53
+ "whereis *",
54
+ "whoami*",
55
+ "hostname*",
56
+ "uname*",
57
+ "date*",
58
+ "uptime*",
59
+ "echo *",
60
+ "printf *",
61
+
62
+ // Language / runtime versions & test runners
63
+ "node --version*",
64
+ "node -v*",
65
+ "npm test*",
66
+ "npm --version*",
67
+ "npm -v*",
68
+ "pnpm test*",
69
+ "pnpm --version*",
70
+ "pnpm -v*",
71
+ "yarn test*",
72
+ "cargo test*",
73
+ "cargo check*",
74
+ "cargo --version*",
75
+ "pytest*",
76
+ "python --version*",
77
+ "python3 --version*",
78
+
79
+ // Service status inspection
80
+ "systemctl is-active*",
81
+ "systemctl status*",
82
+ ]);
83
+
84
+ export const DEFAULT_READ_ONLY_TOOLS = Object.freeze([
85
+ "read",
86
+ "read_file",
87
+ "readFile",
88
+ "read_file_content",
89
+ "view_file",
90
+ "view",
91
+ "show",
92
+ "glob",
93
+ "grep",
94
+ "find_files",
95
+ "find_by_name",
96
+ "search_code",
97
+ "file_search",
98
+ "list_dir",
99
+ "list_directory",
100
+ "directory_list",
101
+ "tree",
102
+ "read_url_content",
103
+ "fetch_web_page",
104
+ "web_search",
105
+ "search_web",
106
+ "list_resources",
107
+ "read_resource",
108
+ ]);
109
+
110
+ /**
111
+ * Compile a prefix-glob into an anchored RegExp.
112
+ * @param {string} pattern
113
+ * @returns {RegExp}
114
+ */
115
+ export function compileGlob(pattern) {
116
+ let source = "";
117
+ for (const ch of pattern) {
118
+ if (ch === "*") source += ".*";
119
+ else if (ch === "?") source += ".";
120
+ else source += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
121
+ }
122
+ return new RegExp(`^${source}$`, "i");
123
+ }
124
+
125
+ export class BandsEngine {
126
+ /**
127
+ * @param {{
128
+ * denyPatterns?: Array<RegExp|string>,
129
+ * allowGlobs?: Array<RegExp|string>,
130
+ * readOnlyTools?: Array<string>
131
+ * }} [options]
132
+ */
133
+ constructor(options = {}) {
134
+ this.denyPatterns = (options.denyPatterns || DEFAULT_DENY_PATTERNS).map((p) =>
135
+ typeof p === "string" ? new RegExp(p, "i") : p
136
+ );
137
+ this.allowGlobs = (options.allowGlobs || DEFAULT_ALLOW_GLOBS).map((g) =>
138
+ typeof g === "string" ? compileGlob(g) : g
139
+ );
140
+ this.readOnlyTools = new Set(options.readOnlyTools || DEFAULT_READ_ONLY_TOOLS);
141
+
142
+ // Derive read-only tools dynamically from host tool registry if provided (#156)
143
+ if (options.toolRegistry) {
144
+ try {
145
+ const tools = typeof options.toolRegistry.getTools === "function"
146
+ ? options.toolRegistry.getTools()
147
+ : (Array.isArray(options.toolRegistry) ? options.toolRegistry : []);
148
+ for (const t of tools) {
149
+ if (t && (t.readOnly || t.readonly || t.isReadOnly) && typeof t.name === "string") {
150
+ this.readOnlyTools.add(t.name);
151
+ }
152
+ }
153
+ } catch {
154
+ // ignore
155
+ }
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Check if a tool is configured as an unconditional read-only tool.
161
+ * @param {string} tool
162
+ * @returns {boolean}
163
+ */
164
+ isReadOnlyTool(tool) {
165
+ return typeof tool === "string" && this.readOnlyTools.has(tool.trim());
166
+ }
167
+
168
+ /**
169
+ * Check if a command matches safe inspection patterns.
170
+ * @param {string} command
171
+ * @returns {boolean}
172
+ */
173
+ isSafeInspectionCommand(command) {
174
+ if (typeof command !== "string" || command.trim() === "") return false;
175
+ const text = command.trim();
176
+ // Chained commands, subshells, substitutions, and redirections cannot match whole-string safe-allow
177
+ if (/[\n;&|`<>]|\$\([\s\S]*\)|\$\{[\s\S]*\}/.test(text)) {
178
+ return false;
179
+ }
180
+ for (const pattern of this.denyPatterns) {
181
+ if (pattern.test(text)) return false;
182
+ }
183
+ for (const glob of this.allowGlobs) {
184
+ if (glob.test(text)) return true;
185
+ }
186
+ return false;
187
+ }
188
+
189
+ /**
190
+ * Register an additional read-only tool name.
191
+ * @param {string} tool
192
+ */
193
+ addReadOnlyTool(tool) {
194
+ if (typeof tool === "string" && tool.trim()) {
195
+ this.readOnlyTools.add(tool.trim());
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Register an additional allow-glob pattern.
201
+ * @param {string} globPattern
202
+ */
203
+ addAllowGlob(globPattern) {
204
+ if (typeof globPattern === "string" && globPattern.trim()) {
205
+ this.allowGlobs.push(compileGlob(globPattern.trim()));
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Evaluates an execution request through Band 0 and Band 1.
211
+ * @param {string} tool - Tool name
212
+ * @param {string} [command] - Normalized command text (for bash)
213
+ * @returns {"deny" | "allow" | "unmatched"}
214
+ */
215
+ evaluate(tool, command) {
216
+ if (this.isReadOnlyTool(tool)) {
217
+ return "allow";
218
+ }
219
+
220
+ if (typeof command === "string" && command.trim().length > 0) {
221
+ const text = command.trim();
222
+
223
+ // Band 0: Hard Deny
224
+ for (const pattern of this.denyPatterns) {
225
+ if (pattern.test(text)) {
226
+ return "deny";
227
+ }
228
+ }
229
+
230
+ // Band 1: Safe Allow (applies strictly to single commands without chaining, subshells or redirects)
231
+ if (!/[\n;&|`<>]|\$\([\s\S]*\)|\$\{[\s\S]*\}/.test(text)) {
232
+ for (const glob of this.allowGlobs) {
233
+ if (glob.test(text)) {
234
+ return "allow";
235
+ }
236
+ }
237
+ }
238
+ }
239
+
240
+ return "unmatched";
241
+ }
242
+ }
243
+
package/lib/engine.js CHANGED
@@ -13,13 +13,15 @@ import { DeclarativeRulesEngine } from "./rules-loader.js";
13
13
  import { BlockCounters } from "./counters.js";
14
14
  import { ContractGuard } from "./contract-guard.js";
15
15
  import { OverengineeringGuard } from "./overengineering.js";
16
- import { extractFilePath, DEFAULT_FILE_WRITE_TOOLS } from "./dispatcher.js";
16
+ import { extractFilePath, DEFAULT_BASH_TOOL, DEFAULT_FILE_WRITE_TOOLS } from "./dispatcher.js";
17
17
  import { categorizeRiskBucket, extractRiskTags } from "./risk.js";
18
18
  import { ClassifierLatencyTracker } from "./latency.js";
19
19
  import { ArtifactRegistry } from "./artifacts.js";
20
20
  import { isRmCommand, extractRmTargets, suggestTrashAlternative } from "./trash.js";
21
21
  import { VersionGuard } from "./version-guard.js";
22
22
  import { loadAgentsRules, evaluateAgentsRules } from "./agents-parser.js";
23
+ import { classifyMeasureKind } from "./measure-kinds.js";
24
+ import { SnapshotManager } from "./snapshots.js";
23
25
 
24
26
  /**
25
27
  * Determine whether an operation belongs to a critical risk category requiring lockedAsk (#52).
@@ -79,7 +81,7 @@ export class SecurityEngine {
79
81
  this.classifier = options.classifier || null;
80
82
  this.timeoutPolicy = options.timeoutPolicy === "permissive" ? "permissive" : "strict";
81
83
  this.grantRegistry = options.grantRegistry || new SessionGrantStore(options);
82
- this.defaultVerdict = options.defaultVerdict || "allow";
84
+ this.defaultVerdict = options.defaultVerdict || "ask";
83
85
 
84
86
  this.dryRun = Boolean(options.dryRun || options.shadowMode);
85
87
  this.auditFailClosed = options.auditFailClosed === true;
@@ -87,33 +89,16 @@ export class SecurityEngine {
87
89
  this.retryGuard = options.retryGuard !== undefined ? options.retryGuard : new RetryExhaustionGuard();
88
90
  this.ledger = options.ledger !== undefined ? options.ledger : new QualityLedger();
89
91
  this.counters = options.counters || new BlockCounters();
90
- this.auditLogger = options.auditLogger !== undefined ? options.auditLogger : new AuditLogger({
91
- failClosed: this.auditFailClosed,
92
- });
93
- if (this.auditFailClosed && this.auditLogger) {
94
- this.auditLogger.failClosed = true;
95
- }
96
- this.declarativeRules = options.declarativeRules || new DeclarativeRulesEngine({
97
- workspaceDir: this.inspectConfig?.workspaceDir,
98
- });
99
- this.contractGuard = options.contractGuard !== undefined
100
- ? options.contractGuard
101
- : (options.taskContract ? new ContractGuard(options.taskContract) : null);
102
- this.overengineeringGuard = options.overengineeringGuard !== undefined
103
- ? options.overengineeringGuard
104
- : (options.maxFilesPerSession !== undefined || options.maxCreatedFilesPerSession !== undefined
105
- ? new OverengineeringGuard(options)
106
- : null);
92
+ this.auditLogger = options.auditLogger !== undefined ? options.auditLogger : new AuditLogger({ failClosed: this.auditFailClosed });
93
+ if (this.auditFailClosed && this.auditLogger) this.auditLogger.failClosed = true;
94
+ this.declarativeRules = options.declarativeRules || new DeclarativeRulesEngine({ workspaceDir: this.inspectConfig?.workspaceDir });
95
+ this.contractGuard = options.contractGuard !== undefined ? options.contractGuard : (options.taskContract ? new ContractGuard(options.taskContract) : null);
96
+ this.overengineeringGuard = options.overengineeringGuard !== undefined ? options.overengineeringGuard : (options.maxFilesPerSession !== undefined || options.maxCreatedFilesPerSession !== undefined ? new OverengineeringGuard(options) : null);
107
97
  this.latencyTracker = options.latencyTracker || new ClassifierLatencyTracker(options);
108
- this.artifactRegistry = options.artifactRegistry !== undefined
109
- ? options.artifactRegistry
110
- : new ArtifactRegistry({ workspaceDir: this.inspectConfig?.workspaceDir });
111
- this.versionGuard = options.versionGuard !== undefined
112
- ? options.versionGuard
113
- : new VersionGuard({ workspaceDir: this.inspectConfig?.workspaceDir });
114
- this.agentsRules = options.agentsRules !== undefined
115
- ? options.agentsRules
116
- : (loadAgentsRules(this.inspectConfig?.workspaceDir)?.rules || null);
98
+ this.artifactRegistry = options.artifactRegistry !== undefined ? options.artifactRegistry : new ArtifactRegistry({ workspaceDir: this.inspectConfig?.workspaceDir });
99
+ this.versionGuard = options.versionGuard !== undefined ? options.versionGuard : new VersionGuard({ workspaceDir: this.inspectConfig?.workspaceDir });
100
+ this.agentsRules = options.agentsRules !== undefined ? options.agentsRules : (loadAgentsRules(this.inspectConfig?.workspaceDir)?.rules || null);
101
+ this.snapshotManager = options.snapshotManager !== undefined ? options.snapshotManager : new SnapshotManager({ workspaceDir: this.inspectConfig?.workspaceDir });
117
102
  }
118
103
 
119
104
  /**
@@ -493,8 +478,10 @@ export class SecurityEngine {
493
478
  }
494
479
  }
495
480
 
496
- // Default verdict for unclassified commands (#163)
497
- if (this.defaultVerdict === "ask") {
481
+ // Default verdict for unclassified commands (#163, #172)
482
+ const bashTool = this.inspectConfig?.toolName || DEFAULT_BASH_TOOL;
483
+ const isBashTool = toolName === bashTool || !toolName;
484
+ if (isBashTool && this.defaultVerdict === "ask") {
498
485
  const askHit = { ask: true, reason: "unmatched-ask", snippet: command || toolName };
499
486
  const res = { verdict: "ask", stage: "P4", reason: "unmatched-ask", hit: askHit };
500
487
  this._applyLockedAskIfCritical(res, toolName, command, execution);
@@ -566,6 +553,7 @@ export class SecurityEngine {
566
553
  if (!decision) return decision;
567
554
  if (!decision.riskBucket) decision.riskBucket = categorizeRiskBucket(execution, context);
568
555
  if (!decision.riskTags || !Array.isArray(decision.riskTags)) decision.riskTags = extractRiskTags(execution, context);
556
+ if (!decision.measureKind) decision.measureKind = classifyMeasureKind(execution, context);
569
557
  const command = execution && execution.arguments && execution.arguments.command;
570
558
  if (command && isRmCommand(command)) {
571
559
  decision.trashAlternative = suggestTrashAlternative(command, this.inspectConfig?.workspaceDir, context?.sessionId);
@@ -573,21 +561,15 @@ export class SecurityEngine {
573
561
  return decision;
574
562
  }
575
563
 
576
- getArtifacts(sessionId) {
577
- return this.artifactRegistry ? this.artifactRegistry.getArtifacts(sessionId) : [];
578
- }
579
-
580
- clearArtifacts(sessionId) {
581
- if (this.artifactRegistry) this.artifactRegistry.clearSession(sessionId);
582
- }
583
-
584
- getLatencyMetrics() {
585
- return this.latencyTracker ? this.latencyTracker.getMetrics() : null;
586
- }
587
-
588
- resetLatencyTracker() {
589
- if (this.latencyTracker) this.latencyTracker.reset();
590
- }
564
+ getArtifacts(sessionId) { return this.artifactRegistry ? this.artifactRegistry.getArtifacts(sessionId) : []; }
565
+ clearArtifacts(sessionId) { if (this.artifactRegistry) this.artifactRegistry.clearSession(sessionId); }
566
+ getLatencyMetrics() { return this.latencyTracker ? this.latencyTracker.getMetrics() : null; }
567
+ resetLatencyTracker() { if (this.latencyTracker) this.latencyTracker.reset(); }
568
+ takeSnapshot(targetPath, meta) { return this.snapshotManager ? this.snapshotManager.takeSnapshot(targetPath, meta) : null; }
569
+ rollbackSnapshot(id) { return this.snapshotManager ? this.snapshotManager.rollbackSnapshot(id) : { success: false, error: "no-snapshot-manager" }; }
570
+ getSnapshots(sessionId) { return this.snapshotManager ? this.snapshotManager.getSnapshots(sessionId) : []; }
571
+ clearSnapshots(sessionId) { return this.snapshotManager ? this.snapshotManager.clearSnapshots(sessionId) : { cleared: 0 }; }
572
+ clearAllSnapshots() { return this.snapshotManager ? this.snapshotManager.clearAllSnapshots() : { cleared: 0 }; }
591
573
  }
592
574
 
593
575
 
package/lib/index.js CHANGED
@@ -5,6 +5,9 @@ import { DeclarativeRulesEngine } from "./rules-loader.js";
5
5
  import { LlmClassifier } from "./classifier.js";
6
6
  import { ClassifierLatencyTracker } from "./latency.js";
7
7
  import { assertHarnessCompatibility, provideServicePolyfill } from "./compat.js";
8
+ import { extractFilePath } from "./dispatcher.js";
9
+ import { SnapshotManager } from "./snapshots.js";
10
+ import { MEASURE_KINDS, classifyMeasureKind } from "./measure-kinds.js";
8
11
  import Schema from "@deepseek-ai/schemastery";
9
12
 
10
13
  if (typeof Schema.prototype?.volatile !== "function") {
@@ -208,8 +211,12 @@ export const Config = Schema.object({
208
211
  unattended: Schema.boolean().default(false).volatile(),
209
212
  /** Workspace directory for path traversal boundary checks. */
210
213
  workspaceDir: Schema.string().default("").volatile(),
211
- /** Default verdict for unclassified commands: 'allow' (default) or 'ask' (fail-closed) (#163). */
212
- defaultVerdict: Schema.union(["allow", "ask"]).default("allow").volatile(),
214
+ /** Default verdict for unclassified commands: 'ask' (default, fail-closed) or 'allow' (#163, #172). */
215
+ defaultVerdict: Schema.union(["allow", "ask"]).default("ask").volatile(),
216
+ /** Maximum retained file snapshots per session (#78). */
217
+ maxSnapshotsPerSession: Schema.number().default(50).volatile(),
218
+ /** Maximum file size in bytes for pre-execution snapshots (#77). */
219
+ maxSnapshotBytes: Schema.number().default(1048576).volatile(),
213
220
  /** Dry-run / shadow mode for safe testing of rules on production (#128). */
214
221
  dryRun: Schema.boolean().default(false).volatile(),
215
222
  /** Shadow mode alias for dryRun (#128). */
@@ -313,7 +320,8 @@ export function apply(ctx, config) {
313
320
  latencyTracker: new ClassifierLatencyTracker({
314
321
  slaMs: cfg.classifierSlaMs !== undefined ? cfg.classifierSlaMs : 2000,
315
322
  }),
316
- defaultVerdict: cfg.defaultVerdict || "allow",
323
+ defaultVerdict: cfg.defaultVerdict || "ask",
324
+ snapshotManager: new SnapshotManager({ workspaceDir: cfg.workspaceDir || "", maxSnapshotsPerSession: cfg.maxSnapshotsPerSession, maxSnapshotBytes: cfg.maxSnapshotBytes }),
317
325
  dryRun: Boolean(cfg.dryRun || cfg.shadowMode),
318
326
  auditFailClosed: Boolean(cfg.auditFailClosed),
319
327
  auditLogger: new AuditLogger({
@@ -342,6 +350,12 @@ export function apply(ctx, config) {
342
350
  resetLatencyTracker: () => (engine.resetLatencyTracker ? engine.resetLatencyTracker() : undefined),
343
351
  getArtifacts: (sessionId) => (engine.getArtifacts ? engine.getArtifacts(sessionId) : []),
344
352
  clearArtifacts: (sessionId) => (engine.clearArtifacts ? engine.clearArtifacts(sessionId) : undefined),
353
+ takeSnapshot: (targetPath, meta) => (engine.takeSnapshot ? engine.takeSnapshot(targetPath, meta) : null),
354
+ rollbackSnapshot: (id) => (engine.rollbackSnapshot ? engine.rollbackSnapshot(id) : { success: false, error: "no-engine" }),
355
+ getSnapshots: (sessionId) => (engine.getSnapshots ? engine.getSnapshots(sessionId) : []),
356
+ clearSnapshots: (sessionId) => (engine.clearSnapshots ? engine.clearSnapshots(sessionId) : { cleared: 0 }),
357
+ clearAllSnapshots: () => (engine.clearAllSnapshots ? engine.clearAllSnapshots() : { cleared: 0 }),
358
+ getSnapshotStats: () => (engine.snapshotManager ? engine.snapshotManager.getStats() : null),
345
359
  getSnapshot: () => ({
346
360
  active: true,
347
361
  dryRun: Boolean(cfg.dryRun || cfg.shadowMode),
@@ -353,6 +367,7 @@ export function apply(ctx, config) {
353
367
  cacheStats: engine.cache ? engine.cache.stats() : null,
354
368
  breakerState: engine.breaker ? engine.breaker.getState() : null,
355
369
  latencyMetrics: engine.getLatencyMetrics ? engine.getLatencyMetrics() : null,
370
+ snapshots: engine.snapshotManager ? engine.snapshotManager.getStats() : null,
356
371
  harnessCompat: compat.capabilities,
357
372
  }),
358
373
  };
@@ -418,6 +433,10 @@ export function apply(ctx, config) {
418
433
  };
419
434
  return { kind: "ask", reason: askMessage(hit, who, translate, ctx && ctx.logger) };
420
435
  }
436
+ if (engine.snapshotManager) {
437
+ const targetPath = extractFilePath(execution && execution.arguments);
438
+ if (targetPath) engine.takeSnapshot(targetPath, { sessionId, callId: execution && (execution.callId || execution.id), tool: execution && execution.name });
439
+ }
421
440
  return next();
422
441
  });
423
442
  }
@@ -455,6 +474,10 @@ export function apply(ctx, config) {
455
474
  logBlocked(ctx, hit, who);
456
475
  return denyMessage(hit, who, translate, ctx && ctx.logger);
457
476
  }
477
+ if (engine.snapshotManager) {
478
+ const targetPath = extractFilePath(execution && execution.arguments);
479
+ if (targetPath) engine.takeSnapshot(targetPath, { sessionId, callId: execution && (execution.callId || execution.id), tool: execution && execution.name });
480
+ }
458
481
  return undefined;
459
482
  };
460
483
 
@@ -476,96 +499,27 @@ export {
476
499
  CIRCUIT_STATE_TRIPPED,
477
500
  CIRCUIT_STATE_RESUMED,
478
501
  } from "./breaker.js";
479
- export {
480
- SessionGrantStore,
481
- DEFAULT_GRANT_TTL_MS,
482
- DEFAULT_GRANT_MAX_USES,
483
- matchGrantCriteria,
484
- } from "./grants.js";
485
- export {
486
- canonicalizeCall,
487
- canonicalString,
488
- hashCall,
489
- sortKeys,
490
- } from "./canonical.js";
502
+ export { SessionGrantStore, DEFAULT_GRANT_TTL_MS, DEFAULT_GRANT_MAX_USES, matchGrantCriteria } from "./grants.js";
503
+ export { canonicalizeCall, canonicalString, hashCall, sortKeys } from "./canonical.js";
491
504
  export { expandHome, resolveSafePath, isPathTraversal, canonicalizeFsPath } from "./paths.js";
492
505
  export { splitSubcommands } from "./tokenizer.js";
493
506
  export { redactCredentials, redactUrl } from "./redact.js";
494
- export {
495
- CommandDispatcher,
496
- isReviewerAgent,
497
- extractFilePath,
498
- TOOL_KIND_SHELL,
499
- TOOL_KIND_FILE_WRITE,
500
- TOOL_KIND_FILE_READ,
501
- TOOL_KIND_NETWORK,
502
- TOOL_KIND_GENERIC,
503
- } from "./dispatcher.js";
504
- export {
505
- parseGitArgv,
506
- classifyGitOperation,
507
- inspectGitCommand,
508
- GIT_CATEGORY_READ,
509
- GIT_CATEGORY_MUTATE_LOCAL,
510
- GIT_CATEGORY_REMOTE,
511
- GIT_CATEGORY_DESTRUCTIVE,
512
- } from "./parsers/git.js";
513
- export {
514
- isDevicePath,
515
- extractRedirects,
516
- inspectRedirectSecurity,
517
- inspectSilentErrors,
518
- inspectPipelines,
519
- } from "./parsers/shell-cmds.js";
520
- export {
521
- isLoopbackHost,
522
- isLoopbackUrl,
523
- isLoopbackCommand,
524
- } from "./loopback.js";
525
- export {
526
- computeFingerprint,
527
- computeFingerprintHash,
528
- normalizeCommandString,
529
- } from "./fingerprint.js";
530
- export {
531
- VerdictCache,
532
- DEFAULT_CACHE_MAX_SIZE,
533
- DEFAULT_CACHE_TTL_MS,
534
- } from "./cache.js";
535
- export {
536
- RetryExhaustionGuard,
537
- DEFAULT_MAX_RETRY_ATTEMPTS,
538
- } from "./retry-exhaustion.js";
539
- export {
540
- QualityLedger,
541
- MAX_LEDGER_EVENTS_PER_SESSION,
542
- } from "./ledger.js";
543
- export {
544
- AuditLogger,
545
- DEFAULT_AUDIT_LOG_RELATIVE,
546
- DEFAULT_MAX_AUDIT_BYTES,
547
- DEFAULT_MAX_AUDIT_LINES,
548
- DEFAULT_RETAIN_LINES,
549
- auditRotateContent,
550
- } from "./audit-log.js";
507
+ export { CommandDispatcher, isReviewerAgent, extractFilePath, TOOL_KIND_SHELL, TOOL_KIND_FILE_WRITE, TOOL_KIND_FILE_READ, TOOL_KIND_NETWORK, TOOL_KIND_GENERIC } from "./dispatcher.js";
508
+ export { parseGitArgv, classifyGitOperation, inspectGitCommand, GIT_CATEGORY_READ, GIT_CATEGORY_MUTATE_LOCAL, GIT_CATEGORY_REMOTE, GIT_CATEGORY_DESTRUCTIVE } from "./parsers/git.js";
509
+ export { isDevicePath, extractRedirects, inspectRedirectSecurity, inspectSilentErrors, inspectPipelines } from "./parsers/shell-cmds.js";
510
+ export { isLoopbackHost, isLoopbackUrl, isLoopbackCommand } from "./loopback.js";
511
+ export { computeFingerprint, computeFingerprintHash, normalizeCommandString } from "./fingerprint.js";
512
+ export { VerdictCache, DEFAULT_CACHE_MAX_SIZE, DEFAULT_CACHE_TTL_MS } from "./cache.js";
513
+ export { RetryExhaustionGuard, DEFAULT_MAX_RETRY_ATTEMPTS } from "./retry-exhaustion.js";
514
+ export { QualityLedger, MAX_LEDGER_EVENTS_PER_SESSION } from "./ledger.js";
515
+ export { AuditLogger, DEFAULT_AUDIT_LOG_RELATIVE, DEFAULT_MAX_AUDIT_BYTES, DEFAULT_MAX_AUDIT_LINES, DEFAULT_RETAIN_LINES, auditRotateContent } from "./audit-log.js";
551
516
  export { BlockCounters, categorizeReason } from "./counters.js";
552
517
  export { isCriticalOperation } from "./engine.js";
553
518
  export { parseYaml, parseScalar, stripComment } from "./yaml-parser.js";
554
519
  export { matchConditionValue, evaluateWhen } from "./when-matcher.js";
555
520
  export { HotwordsMatcher, DEFAULT_HOTWORDS, extractWords } from "./hotwords.js";
556
521
  export { DeclarativeRulesEngine, BUILTIN_RULES_FILE, WORKSPACE_RULES_REL, USER_RULES_REL } from "./rules-loader.js";
557
- export {
558
- normalizeChatCompletionsUrl,
559
- withLlmRetry,
560
- sanitizeClassifierInput,
561
- truncateClassifierContext,
562
- isLowRiskExecution,
563
- isShapeRejectError,
564
- extractJsonPayload,
565
- ClassifierVerdictSchema,
566
- validateClassifierResponse,
567
- LlmClassifier,
568
- } from "./classifier.js";
522
+ export { normalizeChatCompletionsUrl, withLlmRetry, sanitizeClassifierInput, truncateClassifierContext, isLowRiskExecution, isShapeRejectError, extractJsonPayload, ClassifierVerdictSchema, validateClassifierResponse, LlmClassifier } from "./classifier.js";
569
523
  export { ContractGuard } from "./contract-guard.js";
570
524
  export { OverengineeringGuard, DEFAULT_MAX_FILES_PER_SESSION } from "./overengineering.js";
571
525
  export { resolveTempRoot, isGlobalTempPath, checkTempIsolation } from "./paths.js";
@@ -577,5 +531,8 @@ export { ArtifactRegistry, normalizeArtifactPath } from "./artifacts.js";
577
531
  export { parseAgentsSecurityRules, loadAgentsRules, evaluateAgentsRules } from "./agents-parser.js";
578
532
  export { detectHarnessCapabilities, assertHarnessCompatibility, provideServicePolyfill, registerHookPolyfill } from "./compat.js";
579
533
  export { parseSemver, isMajorBump, inspectPackageJsonDiff, VersionGuard } from "./version-guard.js";
534
+ export { SnapshotManager, DEFAULT_MAX_SNAPSHOTS_PER_SESSION, DEFAULT_MAX_SNAPSHOT_BYTES } from "./snapshots.js";
535
+ export { MEASURE_KINDS, classifyMeasureKind, isSystemLevelExecution, isWorkspaceWideExecution } from "./measure-kinds.js";
536
+
580
537
 
581
538
 
@@ -0,0 +1,135 @@
1
+ // lib/measure-kinds.js
2
+ // Multi-level operational impact scale categorization (#97).
3
+ // Classifies execution into:
4
+ // - 'local-single': operation affects exactly one file or inspects local state
5
+ // - 'workspace-wide': operation affects the entire workspace/repository or multiple files
6
+ // - 'system-level': operation accesses or mutates OS-level, network, or environment state
7
+
8
+ export const MEASURE_KINDS = Object.freeze({
9
+ LOCAL_SINGLE: "local-single",
10
+ WORKSPACE_WIDE: "workspace-wide",
11
+ SYSTEM_LEVEL: "system-level",
12
+ });
13
+
14
+ const SYSTEM_BINARIES = new Set([
15
+ "sudo", "su", "doas",
16
+ "systemctl", "service", "journalctl", "init",
17
+ "apt", "apt-get", "dpkg", "apk", "dnf", "yum", "pacman", "zypper",
18
+ "curl", "wget", "nc", "netcat", "ncat", "telnet", "ssh", "scp", "sftp", "rsync",
19
+ "iptables", "nftables", "ufw", "firewall-cmd",
20
+ "crontab", "at",
21
+ "reboot", "shutdown", "poweroff", "halt",
22
+ "mount", "umount", "fdisk", "parted", "mkfs", "dd",
23
+ "insmod", "rmmod", "modprobe",
24
+ "docker", "podman", "containerd", "runc", "crictl", "kubectl",
25
+ "passwd", "useradd", "userdel", "usermod", "groupadd",
26
+ "kill", "killall", "pkill", "xkill",
27
+ ]);
28
+
29
+ const SYSTEM_PATH_REGEX = /((\/|\b)(etc|boot|sys|proc|dev|usr|var\/log|root)(\/|\b)|~?\/\.(ssh|gnupg|bashrc|zshrc|profile)(\/|\b))/i;
30
+
31
+ const WORKSPACE_COMMAND_PATTERNS = [
32
+ /^(npm|pnpm|yarn|bun)\s+(install|add|remove|uninstall|update|upgrade|ci|build|pack|publish|run)/i,
33
+ /^(cargo|go|dotnet|mvn|gradle)\s+(build|test|clean|install|run)/i,
34
+ /^git\s+(checkout|switch|merge|rebase|pull|push|reset|clean|branch|tag|stash|restore|clone)/i,
35
+ /^(find|grep|ripgrep|rg|ack|ag)\s+/i,
36
+ /\b(make|ninja|cmake|meson)\b/i,
37
+ ];
38
+
39
+ const SINGLE_FILE_TOOLS = new Set([
40
+ "view_file", "read_file", "view",
41
+ "write_to_file", "create_file", "write",
42
+ "replace_file_content", "edit_file", "patch_file",
43
+ "inspect_file",
44
+ ]);
45
+
46
+ const SYSTEM_LEVEL_TOOLS = new Set([
47
+ "read_url_content", "search_web", "web_search",
48
+ "browse", "http_request", "network_fetch",
49
+ ]);
50
+
51
+ /**
52
+ * Classifies the operational impact scale for a tool call execution.
53
+ * @param {Record<string, any>} execution
54
+ * @param {Record<string, any>} [context]
55
+ * @returns {'local-single' | 'workspace-wide' | 'system-level'}
56
+ */
57
+ export function classifyMeasureKind(execution, context = {}) {
58
+ if (!execution) return MEASURE_KINDS.LOCAL_SINGLE;
59
+
60
+ const toolName = execution.name || execution.tool || "";
61
+ const args = execution.arguments || {};
62
+
63
+ // 1. Tool-level system categorization
64
+ if (SYSTEM_LEVEL_TOOLS.has(toolName)) {
65
+ return MEASURE_KINDS.SYSTEM_LEVEL;
66
+ }
67
+
68
+ // 2. Tool-level single-file mutations / reads
69
+ if (SINGLE_FILE_TOOLS.has(toolName)) {
70
+ const target = args.TargetFile || args.targetPath || args.path || args.file || "";
71
+ if (typeof target === "string" && SYSTEM_PATH_REGEX.test(target)) {
72
+ return MEASURE_KINDS.SYSTEM_LEVEL;
73
+ }
74
+ return MEASURE_KINDS.LOCAL_SINGLE;
75
+ }
76
+
77
+ // 3. Shell / command inspection
78
+ const command = typeof args.command === "string" ? args.command.trim() : "";
79
+ if (!command) {
80
+ return MEASURE_KINDS.LOCAL_SINGLE;
81
+ }
82
+
83
+ // Fast check: first word
84
+ const firstWord = command.split(/\s+/)[0]?.toLowerCase();
85
+ if (SYSTEM_BINARIES.has(firstWord)) {
86
+ return MEASURE_KINDS.SYSTEM_LEVEL;
87
+ }
88
+ if ((command.includes("/") || command.includes(".")) && SYSTEM_PATH_REGEX.test(command)) {
89
+ return MEASURE_KINDS.SYSTEM_LEVEL;
90
+ }
91
+
92
+ // Check subcommands or pipes for system binaries only if operators present
93
+ if (/[|;&]/.test(command)) {
94
+ const tokens = command.split(/[\s|;&]+/);
95
+ for (const token of tokens) {
96
+ if (SYSTEM_BINARIES.has(token.toLowerCase())) {
97
+ return MEASURE_KINDS.SYSTEM_LEVEL;
98
+ }
99
+ }
100
+ }
101
+
102
+ // Check workspace-wide patterns
103
+ for (const pattern of WORKSPACE_COMMAND_PATTERNS) {
104
+ if (pattern.test(command)) {
105
+ return MEASURE_KINDS.WORKSPACE_WIDE;
106
+ }
107
+ }
108
+
109
+ // Multi-target rm or recursive rm
110
+ if (/^rm\s+/i.test(command)) {
111
+ if (/\s-[a-zA-Z]*r/i.test(command) || command.includes("*") || command.split(/\s+/).length > 3) {
112
+ return MEASURE_KINDS.WORKSPACE_WIDE;
113
+ }
114
+ return MEASURE_KINDS.LOCAL_SINGLE;
115
+ }
116
+
117
+ // Simple single-target commands
118
+ if (/^(cat|head|tail|sed|awk|echo|touch|ls|stat|file)\b/i.test(command)) {
119
+ if (command.includes("*") || command.includes(";")) {
120
+ return MEASURE_KINDS.WORKSPACE_WIDE;
121
+ }
122
+ return MEASURE_KINDS.LOCAL_SINGLE;
123
+ }
124
+
125
+ // Default fallback for unrecognized operations
126
+ return MEASURE_KINDS.LOCAL_SINGLE;
127
+ }
128
+
129
+ export function isSystemLevelExecution(execution, context) {
130
+ return classifyMeasureKind(execution, context) === MEASURE_KINDS.SYSTEM_LEVEL;
131
+ }
132
+
133
+ export function isWorkspaceWideExecution(execution, context) {
134
+ return classifyMeasureKind(execution, context) === MEASURE_KINDS.WORKSPACE_WIDE;
135
+ }
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",
@@ -107,6 +108,7 @@ export const MESSAGES = Object.freeze({
107
108
  hintTrashAlternative: "Move files to reversible .dsh/trash/<session>/ directory instead of rm",
108
109
  hintAgentsMdProhibited: "Review project-specific guidelines and constraints defined in AGENTS.md",
109
110
  hintMajorVersionBump: "Verify compatibility before bumping major dependency versions in package.json",
111
+ hintUnmatchedAsk: "Unrecognized operations default to fail-closed approval under defaultVerdict='ask'",
110
112
  },
111
113
  zh: {
112
114
  blockedPrefix: "dsh-approval-gate 规则已阻止",
@@ -128,6 +130,7 @@ export const MESSAGES = Object.freeze({
128
130
  detailUnsupported: "不支持的 shell 语法",
129
131
  approvalRequired: "需要批准",
130
132
  askUnknown: "命令无法完整检查;请在允许前进行审查",
133
+ askUnmatched: "该操作未被安全规则识别;默认需要人工确认",
131
134
  askUnparseable: "Shell 语法无法完整检查;请在允许前进行审查",
132
135
  askUnknownFormat: "工具调用格式无法检查;请在允许前进行审查",
133
136
  askUnknownWriteTarget: "无法识别文件写入目标;请在允许前进行审查",
@@ -213,6 +216,7 @@ export const MESSAGES = Object.freeze({
213
216
  hintTrashAlternative: "建议将文件移动到可逆的 .dsh/trash/<session>/ 目录,避免直接 rm",
214
217
  hintAgentsMdProhibited: "请核对项目根目录 AGENTS.md 中定义的安全约束和禁止指令",
215
218
  hintMajorVersionBump: "在升级 package.json 依赖主版本前请确认破坏性变更与兼容性",
219
+ hintUnmatchedAsk: "在 defaultVerdict='ask' 模式下,未识别的操作默认需要人工审核确认",
216
220
  },
217
221
  });
218
222
 
@@ -317,12 +321,14 @@ const HINT_KEYS = Object.freeze({
317
321
  "agents-md-prohibited-command": "hintAgentsMdProhibited",
318
322
  "agents-md-prohibited-path": "hintAgentsMdProhibited",
319
323
  "major-version-bump": "hintMajorVersionBump",
324
+ "unmatched-ask": "hintUnmatchedAsk",
320
325
  });
321
326
 
322
327
  const ASK_KEYS = Object.freeze({
323
328
  unparseable: "askUnparseable",
324
329
  "unknown-format": "askUnknownFormat",
325
330
  "unknown-write-target": "askUnknownWriteTarget",
331
+ "unmatched-ask": "askUnmatched",
326
332
  "dynamic-command": "askDynamicCommand",
327
333
  "dynamic-arguments": "askDynamicArguments",
328
334
  "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,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-approval-gate",
3
- "version": "0.1.20",
3
+ "version": "0.1.21",
4
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
5
  "type": "module",
6
6
  "main": "lib/index.js",