@goodandready/dsh-approval-gate 0.1.19 → 0.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.20
4
+
5
+ - Feature: Безопасная альтернатива необратимому удалению (`Reversible Trash Move` в `lib/trash.js`) с переносом удаляемых файлов в изолированную сессионную корзину `<workspace>/.dsh/trash/<session_id>/` вместо деструктивного `rm` (Refs: #104).
6
+ - Feature: Реестр сессионных артефактов (`ArtifactRegistry` в `lib/artifacts.js`) для автоматического отслеживания и безопасного разрешения очистки временных файлов текущей сессии на стадии P1 без спама подтверждениями (Refs: #100).
7
+ - Security: Парсер проектных правил безопасности из `AGENTS.md` (`lib/agents-parser.js`) с жестким перехватом запрещенных команд и путей на стадии P0 (`agents-md-prohibited-command`, `agents-md-prohibited-path` -> `deny`) (Refs: #91).
8
+ - Resilience: Проверка совместимости версий хоста DSH (`assertHarnessCompatibility` в `lib/compat.js`) и безопасные полифилы `provideServicePolyfill` / `registerHookPolyfill` для бесшовной работы на различных версиях Cordis (Refs: #106).
9
+ - Security: Страж зависимостей проекта (`VersionGuard` в `lib/version-guard.js`) с автоматическим обнаружением мажорных обновлений в `package.json` и переводом их на ручное подтверждение человеком (`major-version-bump` -> `ask`) (Refs: #95).
10
+
3
11
  ## 0.1.19
4
12
 
5
13
  - Security: Структурированная категоризация рисков операций по 5 стандартизированным бакетам (`READ_ONLY`, `WORKSPACE`, `EGRESS`, `SYSTEM`, `SECRETS`) в `lib/risk.js` с приоритетной оценкой чувствительности путей, команд и сетевых направлений (Refs: #58).
@@ -0,0 +1,183 @@
1
+ // lib/agents-parser.js
2
+ // Parser and enforcement for local project safety rules from AGENTS.md / CLAUDE.md (#91).
3
+
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+
7
+ const AGENTS_FILENAMES = ["AGENTS.md", ".dsh/AGENTS.md", "CLAUDE.md"];
8
+
9
+ const SECURITY_HEADING_REGEX = /^(?:#{1,3})\s*(?:security|safety|rules|constraints|prohibited|forbidden|restrictions|do\s+not|never)\b/i;
10
+
11
+ const FORBIDDEN_DIRECTIVE_REGEX = /(?:(?:never|do\s+not|don't|prohibit(?:ed)?|forbid(?:den)?|cannot|must\s+not)\s+(?:run|execute|use|modify|delete|touch|access|call|write)|forbidden:\s*|prohibited:\s*)/i;
12
+
13
+ /**
14
+ * Parses markdown text of AGENTS.md and extracts active security constraints (#91).
15
+ *
16
+ * @param {string} content - Markdown document text
17
+ * @returns {{
18
+ * rules: Array<{ type: "command" | "path" | "keyword" | "directive", pattern: string, text: string }>,
19
+ * rawDirectives: string[]
20
+ * }}
21
+ */
22
+ export function parseAgentsSecurityRules(content) {
23
+ if (typeof content !== "string" || !content.trim()) {
24
+ return { rules: [], rawDirectives: [] };
25
+ }
26
+
27
+ const lines = content.split(/\r?\n/);
28
+ const rules = [];
29
+ const rawDirectives = [];
30
+
31
+ let inSecuritySection = false;
32
+
33
+ for (let i = 0; i < lines.length; i++) {
34
+ const rawLine = lines[i];
35
+ const trimmed = rawLine.trim();
36
+
37
+ // Check headings
38
+ if (trimmed.startsWith("#")) {
39
+ inSecuritySection = SECURITY_HEADING_REGEX.test(trimmed);
40
+ continue;
41
+ }
42
+
43
+ if (!trimmed) continue;
44
+
45
+ // Check bullet points or bold directives in security section or explicit global directives
46
+ const isBullet = /^[-*+]\s+/.test(trimmed) || /^\d+\.\s+/.test(trimmed);
47
+ const cleanText = trimmed.replace(/^[-*+]\s+/, "").replace(/^\d+\.\s+/, "").trim();
48
+
49
+ if ((inSecuritySection && isBullet) || FORBIDDEN_DIRECTIVE_REGEX.test(cleanText)) {
50
+ rawDirectives.push(cleanText);
51
+
52
+ // 1. Check for command / tool restriction: e.g. `Never run rm -rf`, `Do not execute curl`
53
+ const cmdMatch = /(?:run|execute|use|call)\s+(?:`([^`]+)`|"([^"]+)"|([a-zA-Z0-9_\-\.\/]+(?:\s+[a-zA-Z0-9_\-\.\/]+)?))/i.exec(cleanText);
54
+ if (cmdMatch) {
55
+ const cmdPattern = (cmdMatch[1] || cmdMatch[2] || cmdMatch[3]).trim();
56
+ if (cmdPattern && !["to", "the", "a", "an", "any", "this"].includes(cmdPattern.toLowerCase())) {
57
+ rules.push({
58
+ type: "command",
59
+ pattern: cmdPattern,
60
+ text: cleanText,
61
+ });
62
+ continue;
63
+ }
64
+ }
65
+
66
+ // 2. Check for path restriction: e.g. `Do not modify package.json`, `Do not touch .env`
67
+ const pathMatch = /(?:modify|edit|delete|write|overwrite|touch)\s+(?:`([^`]+)`|"([^"]+)"|([a-zA-Z0-9_\-\.\/\*]+))/i.exec(cleanText);
68
+ if (pathMatch) {
69
+ const pathPattern = (pathMatch[1] || pathMatch[2] || pathMatch[3]).trim();
70
+ if (pathPattern && !["to", "the", "a", "an", "any", "this"].includes(pathPattern.toLowerCase())) {
71
+ rules.push({
72
+ type: "path",
73
+ pattern: pathPattern,
74
+ text: cleanText,
75
+ });
76
+ continue;
77
+ }
78
+ }
79
+
80
+ // 3. Fallback general directive
81
+ rules.push({
82
+ type: "directive",
83
+ pattern: cleanText,
84
+ text: cleanText,
85
+ });
86
+ }
87
+ }
88
+
89
+ return { rules, rawDirectives };
90
+ }
91
+
92
+ /**
93
+ * Loads and parses AGENTS.md rules from a workspace directory (#91).
94
+ *
95
+ * @param {string} [workspaceDir]
96
+ * @returns {{ rules: Array<any>, rawDirectives: string[], filePath?: string } | null}
97
+ */
98
+ export function loadAgentsRules(workspaceDir) {
99
+ const ws = workspaceDir && typeof workspaceDir === "string" ? workspaceDir : process.cwd();
100
+
101
+ for (const rel of AGENTS_FILENAMES) {
102
+ const fullPath = path.resolve(ws, rel);
103
+ try {
104
+ if (fs.existsSync(fullPath)) {
105
+ const content = fs.readFileSync(fullPath, "utf-8");
106
+ const parsed = parseAgentsSecurityRules(content);
107
+ return {
108
+ rules: parsed.rules,
109
+ rawDirectives: parsed.rawDirectives,
110
+ filePath: fullPath,
111
+ };
112
+ }
113
+ } catch {
114
+ // ignore read faults
115
+ }
116
+ }
117
+
118
+ return null;
119
+ }
120
+
121
+ /**
122
+ * Evaluates an execution against active AGENTS.md rules (#91).
123
+ *
124
+ * @param {Array<any>} rules - Parsed rules from parseAgentsSecurityRules
125
+ * @param {{ name?: string, arguments?: any }} execution
126
+ * @param {string} [targetPath]
127
+ * @returns {{ match: boolean, reason?: string, rule?: any }}
128
+ */
129
+ export function evaluateAgentsRules(rules, execution, targetPath) {
130
+ if (!Array.isArray(rules) || rules.length === 0) {
131
+ return { match: false };
132
+ }
133
+
134
+ const args = execution && execution.arguments;
135
+ const command = (args && typeof args === "object" && typeof args.command === "string")
136
+ ? args.command.trim()
137
+ : (typeof args === "string" ? args.trim() : "");
138
+ const toolName = String((execution && execution.name) || "").trim();
139
+ const pathToCheck = String(targetPath || (args && (args.path || args.file || args.TargetFile)) || "").trim();
140
+
141
+ for (const rule of rules) {
142
+ if (rule.type === "command") {
143
+ const pat = rule.pattern.toLowerCase();
144
+ if (command && command.toLowerCase().includes(pat)) {
145
+ return {
146
+ match: true,
147
+ reason: "agents-md-prohibited-command",
148
+ rule,
149
+ };
150
+ }
151
+ if (toolName && toolName.toLowerCase() === pat) {
152
+ return {
153
+ match: true,
154
+ reason: "agents-md-prohibited-command",
155
+ rule,
156
+ };
157
+ }
158
+ }
159
+
160
+ if (rule.type === "path" && pathToCheck) {
161
+ const pat = rule.pattern.replace(/\\/g, "/");
162
+ const normPath = pathToCheck.replace(/\\/g, "/");
163
+ if (pat.endsWith("*")) {
164
+ const prefix = pat.slice(0, -1);
165
+ if (normPath.startsWith(prefix) || normPath.includes(prefix)) {
166
+ return {
167
+ match: true,
168
+ reason: "agents-md-prohibited-path",
169
+ rule,
170
+ };
171
+ }
172
+ } else if (normPath.endsWith(pat) || normPath === pat) {
173
+ return {
174
+ match: true,
175
+ reason: "agents-md-prohibited-path",
176
+ rule,
177
+ };
178
+ }
179
+ }
180
+ }
181
+
182
+ return { match: false };
183
+ }
@@ -0,0 +1,162 @@
1
+ // lib/artifacts.js
2
+ // Session Artifact Registry: tracks files created during current session
3
+ // to permit safe auto-cleanup of temporary artifacts without approval prompt spam (#100).
4
+
5
+ import path from "node:path";
6
+
7
+ /**
8
+ * Normalizes file paths for consistent artifact matching across relative and absolute forms.
9
+ *
10
+ * @param {string} filePath
11
+ * @param {string} [workspaceDir]
12
+ * @returns {string}
13
+ */
14
+ export function normalizeArtifactPath(filePath, workspaceDir) {
15
+ if (typeof filePath !== "string" || !filePath.trim()) return "";
16
+ let p = filePath.trim().replace(/^["']|["']$/g, "");
17
+ p = p.replace(/\\/g, "/");
18
+
19
+ if (workspaceDir && typeof workspaceDir === "string") {
20
+ const ws = workspaceDir.replace(/\\/g, "/").replace(/\/+$/, "");
21
+ if (p.startsWith(ws + "/")) {
22
+ p = p.slice(ws.length + 1);
23
+ }
24
+ }
25
+
26
+ p = p.replace(/^\.\//, "");
27
+ return p;
28
+ }
29
+
30
+ /**
31
+ * Registry tracking temporary artifacts created within agent sessions (#100).
32
+ */
33
+ export class ArtifactRegistry {
34
+ /**
35
+ * @param {{ workspaceDir?: string }} [options]
36
+ */
37
+ constructor(options = {}) {
38
+ this.workspaceDir = options.workspaceDir || "";
39
+ /** @type {Map<string, Set<string>>} */
40
+ this._sessions = new Map();
41
+ }
42
+
43
+ /**
44
+ * Records a created or written artifact for a session.
45
+ *
46
+ * @param {string} sessionId
47
+ * @param {string} filePath
48
+ * @returns {boolean} True if new artifact added, false if already tracked or invalid
49
+ */
50
+ recordArtifact(sessionId, filePath) {
51
+ const sid = String(sessionId || "default");
52
+ const norm = normalizeArtifactPath(filePath, this.workspaceDir);
53
+ if (!norm) return false;
54
+
55
+ let set = this._sessions.get(sid);
56
+ if (!set) {
57
+ set = new Set();
58
+ this._sessions.set(sid, set);
59
+ }
60
+ const had = set.has(norm);
61
+ set.add(norm);
62
+ return !had;
63
+ }
64
+
65
+ /**
66
+ * Checks whether a specific file was created as an artifact in the given session.
67
+ *
68
+ * @param {string} sessionId
69
+ * @param {string} filePath
70
+ * @returns {boolean}
71
+ */
72
+ isSessionArtifact(sessionId, filePath) {
73
+ const sid = String(sessionId || "default");
74
+ const norm = normalizeArtifactPath(filePath, this.workspaceDir);
75
+ if (!norm) return false;
76
+
77
+ const set = this._sessions.get(sid);
78
+ if (!set) return false;
79
+ return set.has(norm);
80
+ }
81
+
82
+ /**
83
+ * Checks whether ALL provided files are artifacts created in the given session.
84
+ *
85
+ * @param {string} sessionId
86
+ * @param {string[]} filePaths
87
+ * @returns {boolean}
88
+ */
89
+ areAllSessionArtifacts(sessionId, filePaths) {
90
+ if (!Array.isArray(filePaths) || filePaths.length === 0) return false;
91
+ const sid = String(sessionId || "default");
92
+ const set = this._sessions.get(sid);
93
+ if (!set || set.size === 0) return false;
94
+
95
+ for (const fp of filePaths) {
96
+ const norm = normalizeArtifactPath(fp, this.workspaceDir);
97
+ if (!norm || !set.has(norm)) {
98
+ return false;
99
+ }
100
+ }
101
+ return true;
102
+ }
103
+
104
+ /**
105
+ * Removes an artifact from session tracking (e.g. after deletion).
106
+ *
107
+ * @param {string} sessionId
108
+ * @param {string} filePath
109
+ * @returns {boolean}
110
+ */
111
+ removeArtifact(sessionId, filePath) {
112
+ const sid = String(sessionId || "default");
113
+ const norm = normalizeArtifactPath(filePath, this.workspaceDir);
114
+ if (!norm) return false;
115
+
116
+ const set = this._sessions.get(sid);
117
+ if (!set) return false;
118
+ return set.delete(norm);
119
+ }
120
+
121
+ /**
122
+ * Returns list of recorded artifacts for a session.
123
+ *
124
+ * @param {string} sessionId
125
+ * @returns {string[]}
126
+ */
127
+ getArtifacts(sessionId) {
128
+ const sid = String(sessionId || "default");
129
+ const set = this._sessions.get(sid);
130
+ if (!set) return [];
131
+ return Array.from(set);
132
+ }
133
+
134
+ /**
135
+ * Returns total count of artifacts recorded for a session.
136
+ *
137
+ * @param {string} sessionId
138
+ * @returns {number}
139
+ */
140
+ count(sessionId) {
141
+ const sid = String(sessionId || "default");
142
+ const set = this._sessions.get(sid);
143
+ return set ? set.size : 0;
144
+ }
145
+
146
+ /**
147
+ * Clears tracked artifacts for a specific session.
148
+ *
149
+ * @param {string} sessionId
150
+ */
151
+ clearSession(sessionId) {
152
+ const sid = String(sessionId || "default");
153
+ this._sessions.delete(sid);
154
+ }
155
+
156
+ /**
157
+ * Clears all session artifacts across the entire registry.
158
+ */
159
+ clearAll() {
160
+ this._sessions.clear();
161
+ }
162
+ }
package/lib/compat.js ADDED
@@ -0,0 +1,124 @@
1
+ // lib/compat.js
2
+ // Host compatibility checks and polyfills across DeepSeek Harness minor versions (#106).
3
+
4
+ /**
5
+ * Detects available runtime capabilities and services in the Cordis host context (#106).
6
+ *
7
+ * @param {any} ctx - Cordis / DSH host context
8
+ * @returns {{
9
+ * hasContext: boolean,
10
+ * hasToolsService: boolean,
11
+ * hasPreExecuteHook: boolean,
12
+ * hasProvideApi: boolean,
13
+ * hasLocaleService: boolean,
14
+ * harnessVersion: string | null
15
+ * }}
16
+ */
17
+ export function detectHarnessCapabilities(ctx) {
18
+ if (!ctx || typeof ctx !== "object") {
19
+ return {
20
+ hasContext: false,
21
+ hasToolsService: false,
22
+ hasPreExecuteHook: false,
23
+ hasProvideApi: false,
24
+ hasLocaleService: false,
25
+ harnessVersion: null,
26
+ };
27
+ }
28
+
29
+ const getService = (name) => {
30
+ try {
31
+ if (typeof ctx.get === "function") return ctx.get(name);
32
+ } catch {
33
+ // ignore
34
+ }
35
+ return ctx[name];
36
+ };
37
+
38
+ const tools = getService("tools");
39
+ const locale = getService("locale");
40
+
41
+ const harnessVersion =
42
+ (typeof ctx.version === "string" && ctx.version) ||
43
+ (typeof process.env.DSH_VERSION === "string" && process.env.DSH_VERSION) ||
44
+ null;
45
+
46
+ return {
47
+ hasContext: true,
48
+ hasToolsService: Boolean(tools),
49
+ hasPreExecuteHook: typeof ctx.on === "function",
50
+ hasProvideApi: typeof ctx.provide === "function",
51
+ hasLocaleService: Boolean(locale && typeof locale.register === "function"),
52
+ harnessVersion,
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Asserts host compatibility and identifies missing optional or required features (#106).
58
+ *
59
+ * @param {any} ctx
60
+ * @param {{ requireTools?: boolean }} [options]
61
+ * @returns {{ compatible: boolean, issues: string[], capabilities: ReturnType<typeof detectHarnessCapabilities> }}
62
+ */
63
+ export function assertHarnessCompatibility(ctx, options = {}) {
64
+ const caps = detectHarnessCapabilities(ctx);
65
+ const issues = [];
66
+
67
+ if (!caps.hasContext) {
68
+ issues.push("Invalid or missing Cordis context object");
69
+ }
70
+
71
+ if (options.requireTools && !caps.hasToolsService) {
72
+ issues.push("Missing tools service in host context");
73
+ }
74
+
75
+ return {
76
+ compatible: issues.length === 0,
77
+ issues,
78
+ capabilities: caps,
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Safely provides a service to the host context with backward-compatible polyfill (#106).
84
+ *
85
+ * @param {any} ctx
86
+ * @param {string} serviceName
87
+ * @param {any} serviceInstance
88
+ * @returns {boolean} True if successfully registered
89
+ */
90
+ export function provideServicePolyfill(ctx, serviceName, serviceInstance) {
91
+ if (!ctx || typeof ctx !== "object" || !serviceName) return false;
92
+
93
+ if (typeof ctx.provide === "function") {
94
+ try {
95
+ ctx.provide(serviceName, serviceInstance);
96
+ return true;
97
+ } catch {
98
+ // fallback to property assignment below
99
+ }
100
+ }
101
+
102
+ ctx[serviceName] = serviceInstance;
103
+ return true;
104
+ }
105
+
106
+ /**
107
+ * Safely attaches an event hook to the host context with error boundary (#106).
108
+ *
109
+ * @param {any} ctx
110
+ * @param {string} event
111
+ * @param {(...args: any[]) => any} handler
112
+ * @returns {(() => void) | null} Unsubscribe dispose function or null
113
+ */
114
+ export function registerHookPolyfill(ctx, event, handler) {
115
+ if (!ctx || typeof ctx.on !== "function" || typeof handler !== "function") {
116
+ return null;
117
+ }
118
+
119
+ try {
120
+ return ctx.on(event, handler);
121
+ } catch {
122
+ return null;
123
+ }
124
+ }
package/lib/engine.js CHANGED
@@ -1,11 +1,5 @@
1
1
  // lib/engine.js
2
- // 5-tier Security Decision Engine for dsh-approval-gate.
3
- // Waterfall:
4
- // P0: Hard Deny (Bands Band 0 + Declarative Rules Deny + inspectExecution hard denials + protected files + retry exhaustion + fail-closed audit)
5
- // P1: Session Grants & Declarative Allow (scoped temporary allowances and whitelists)
6
- // P2: Static Rules / Safe Allow (Bands Band 1 + safe read-only commands)
7
- // P3: Optional Semantic Classifier
8
- // P4: Human Ask Fallback (uncertain syntax / ambiguous commands / tripped breaker / declarative ask / lockedAsk)
2
+ // 5-tier Security Decision Engine for dsh-approval-gate (P0-P4 Waterfall).
9
3
 
10
4
  import { BandsEngine } from "./bands.js";
11
5
  import { CircuitBreaker } from "./breaker.js";
@@ -22,6 +16,10 @@ import { OverengineeringGuard } from "./overengineering.js";
22
16
  import { extractFilePath, DEFAULT_FILE_WRITE_TOOLS } from "./dispatcher.js";
23
17
  import { categorizeRiskBucket, extractRiskTags } from "./risk.js";
24
18
  import { ClassifierLatencyTracker } from "./latency.js";
19
+ import { ArtifactRegistry } from "./artifacts.js";
20
+ import { isRmCommand, extractRmTargets, suggestTrashAlternative } from "./trash.js";
21
+ import { VersionGuard } from "./version-guard.js";
22
+ import { loadAgentsRules, evaluateAgentsRules } from "./agents-parser.js";
25
23
 
26
24
  /**
27
25
  * Determine whether an operation belongs to a critical risk category requiring lockedAsk (#52).
@@ -73,26 +71,7 @@ export function isCriticalOperation(toolName, command, execution) {
73
71
  }
74
72
 
75
73
  export class SecurityEngine {
76
- /**
77
- * @param {{
78
- * bands?: BandsEngine,
79
- * breaker?: CircuitBreaker,
80
- * cache?: VerdictCache,
81
- * retryGuard?: RetryExhaustionGuard,
82
- * ledger?: QualityLedger,
83
- * auditLogger?: AuditLogger,
84
- * declarativeRules?: DeclarativeRulesEngine,
85
- * counters?: BlockCounters,
86
- * auditFailClosed?: boolean,
87
- * timeoutPolicy?: "strict" | "permissive",
88
- * inspectConfig?: { toolName?: string, fileWriteTools?: string[], workspaceDir?: string },
89
- * classifier?: ((execution: any, context?: any) => { verdict: "allow"|"deny", reason?: string }),
90
- * grantRegistry?: { decide: (tool: string, args: any, context?: any) => "allow"|"no-match" },
91
- * dryRun?: boolean,
92
- * shadowMode?: boolean,
93
- * defaultVerdict?: "allow" | "ask"
94
- * }} [options]
95
- */
74
+ /** @param {Record<string, any>} [options] */
96
75
  constructor(options = {}) {
97
76
  this.bands = options.bands || new BandsEngine(options);
98
77
  this.breaker = options.breaker || new CircuitBreaker(options);
@@ -126,6 +105,15 @@ export class SecurityEngine {
126
105
  ? new OverengineeringGuard(options)
127
106
  : null);
128
107
  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);
129
117
  }
130
118
 
131
119
  /**
@@ -194,6 +182,10 @@ export class SecurityEngine {
194
182
  if (this.retryGuard) this.retryGuard.recordVerdict(sessionId, execution, "allow");
195
183
  if (this.ledger) this.ledger.record(sessionId, "allow", { reason: decision.reason, detail: decision.detail, tool: toolName });
196
184
  if (this.cache && cacheKey) this.cache.set(cacheKey, decision, { sessionId });
185
+ if (this.artifactRegistry) {
186
+ const targetPath = extractFilePath(execution && execution.arguments);
187
+ if (targetPath) this.artifactRegistry.recordArtifact(sessionId, targetPath);
188
+ }
197
189
  } else if (decision.verdict === "ask") {
198
190
  if (this.ledger) this.ledger.record(sessionId, "ask", { reason: decision.reason, detail: decision.detail, tool: toolName });
199
191
  }
@@ -341,6 +333,16 @@ export class SecurityEngine {
341
333
  }
342
334
  }
343
335
 
336
+ // AGENTS.md rules (#91)
337
+ if (this.agentsRules) {
338
+ const ag = evaluateAgentsRules(this.agentsRules, execution, extractFilePath(execution && execution.arguments));
339
+ if (ag.match) {
340
+ this.breaker.countDeny(sessionId);
341
+ const agHit = { deny: true, reason: ag.reason, detail: ag.rule?.text, snippet: command || toolName };
342
+ return { verdict: "deny", stage: "P0", reason: ag.reason, detail: ag.rule?.text, hit: agHit };
343
+ }
344
+ }
345
+
344
346
  // Inspect execution for hard denials and syntax checks
345
347
  const hit = inspectExecution(execution, this.inspectConfig);
346
348
  if (hit.deny) {
@@ -384,6 +386,31 @@ export class SecurityEngine {
384
386
  }
385
387
  }
386
388
 
389
+ // VersionGuard: major version bump control (#95)
390
+ if (this.versionGuard && typeof toolName === "string" && fileTools.has(toolName)) {
391
+ const targetPath = extractFilePath(execution ? execution.arguments : undefined);
392
+ const content = execution?.arguments?.content || execution?.arguments?.code || execution?.arguments?.CodeContent || execution?.arguments?.text;
393
+ if (targetPath && content) {
394
+ const vg = this.versionGuard.inspectWrite(targetPath, content);
395
+ if (vg.majorBump) {
396
+ const vgHit = { ask: true, reason: "major-version-bump", detail: vg.detail, snippet: targetPath };
397
+ const res = { verdict: "ask", stage: "P4", reason: "major-version-bump", detail: vg.detail, hit: vgHit };
398
+ this._applyLockedAskIfCritical(res, toolName, command, execution);
399
+ return res;
400
+ }
401
+ }
402
+ }
403
+
404
+ // P1: Session Artifact Cleanup Whitelist (#100)
405
+ if (command && isRmCommand(command)) {
406
+ const targets = extractRmTargets(command);
407
+ if (targets.length > 0 && this.artifactRegistry && this.artifactRegistry.areAllSessionArtifacts(sessionId, targets)) {
408
+ this.breaker.resetConsecutive(sessionId);
409
+ const artHit = { allow: true, reason: "session-artifact-cleanup", snippet: command };
410
+ return { verdict: "allow", stage: "P1", reason: "session-artifact-cleanup", hit: artHit };
411
+ }
412
+ }
413
+
387
414
  // P1: Session Grants
388
415
  if (this.grantRegistry && typeof this.grantRegistry.decide === "function") {
389
416
  const grantMatch = this.grantRegistry.decide(toolName, execution.arguments, context);
@@ -537,15 +564,23 @@ export class SecurityEngine {
537
564
  */
538
565
  _enrichDecision(decision, execution, context) {
539
566
  if (!decision) return decision;
540
- if (!decision.riskBucket) {
541
- decision.riskBucket = categorizeRiskBucket(execution, context);
542
- }
543
- if (!decision.riskTags || !Array.isArray(decision.riskTags)) {
544
- decision.riskTags = extractRiskTags(execution, context);
567
+ if (!decision.riskBucket) decision.riskBucket = categorizeRiskBucket(execution, context);
568
+ if (!decision.riskTags || !Array.isArray(decision.riskTags)) decision.riskTags = extractRiskTags(execution, context);
569
+ const command = execution && execution.arguments && execution.arguments.command;
570
+ if (command && isRmCommand(command)) {
571
+ decision.trashAlternative = suggestTrashAlternative(command, this.inspectConfig?.workspaceDir, context?.sessionId);
545
572
  }
546
573
  return decision;
547
574
  }
548
575
 
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
+
549
584
  getLatencyMetrics() {
550
585
  return this.latencyTracker ? this.latencyTracker.getMetrics() : null;
551
586
  }
@@ -555,3 +590,4 @@ export class SecurityEngine {
555
590
  }
556
591
  }
557
592
 
593
+
package/lib/index.js CHANGED
@@ -4,6 +4,7 @@ import { AuditLogger } from "./audit-log.js";
4
4
  import { DeclarativeRulesEngine } from "./rules-loader.js";
5
5
  import { LlmClassifier } from "./classifier.js";
6
6
  import { ClassifierLatencyTracker } from "./latency.js";
7
+ import { assertHarnessCompatibility, provideServicePolyfill } from "./compat.js";
7
8
  import Schema from "@deepseek-ai/schemastery";
8
9
 
9
10
  if (typeof Schema.prototype?.volatile !== "function") {
@@ -241,6 +242,10 @@ export const Config = Schema.object({
241
242
  classifierSlaMs: Schema.number().default(2000).volatile(),
242
243
  /** Task contract boundary rules (#87). */
243
244
  taskContract: Schema.any().volatile(),
245
+ /** Enforce project rules and constraints parsed from AGENTS.md (#91). */
246
+ agentsRules: Schema.boolean().default(true).volatile(),
247
+ /** Enforce human confirmation on breaking major dependency bumps in package.json (#95). */
248
+ versionGuard: Schema.boolean().default(true).volatile(),
244
249
  /** Security engine instance (internal/testing). */
245
250
  engine: Schema.any().hidden(),
246
251
  });
@@ -251,6 +256,7 @@ function logBlocked(ctx, hit, who) {
251
256
  }
252
257
 
253
258
  export function apply(ctx, config) {
259
+ const compat = assertHarnessCompatibility(ctx);
254
260
  const cfg = plainConfig(Config(plainConfig(config) || {}) || {});
255
261
  const toolName = cfg.toolName || DEFAULT_BASH_TOOL;
256
262
  const fileWriteTools = cfg.fileWriteTools || DEFAULT_FILE_WRITE_TOOLS.slice();
@@ -334,6 +340,8 @@ export function apply(ctx, config) {
334
340
  getLedgerSummary: (sessionId) => engine.getLedgerSummary(sessionId),
335
341
  getLatencyMetrics: () => (engine.getLatencyMetrics ? engine.getLatencyMetrics() : null),
336
342
  resetLatencyTracker: () => (engine.resetLatencyTracker ? engine.resetLatencyTracker() : undefined),
343
+ getArtifacts: (sessionId) => (engine.getArtifacts ? engine.getArtifacts(sessionId) : []),
344
+ clearArtifacts: (sessionId) => (engine.clearArtifacts ? engine.clearArtifacts(sessionId) : undefined),
337
345
  getSnapshot: () => ({
338
346
  active: true,
339
347
  dryRun: Boolean(cfg.dryRun || cfg.shadowMode),
@@ -345,15 +353,10 @@ export function apply(ctx, config) {
345
353
  cacheStats: engine.cache ? engine.cache.stats() : null,
346
354
  breakerState: engine.breaker ? engine.breaker.getState() : null,
347
355
  latencyMetrics: engine.getLatencyMetrics ? engine.getLatencyMetrics() : null,
356
+ harnessCompat: compat.capabilities,
348
357
  }),
349
358
  };
350
- if (ctx && typeof ctx === "object") {
351
- if (typeof ctx.provide === "function") {
352
- ctx.provide("approvalGate", gateService);
353
- } else {
354
- ctx.approvalGate = gateService;
355
- }
356
- }
359
+ provideServicePolyfill(ctx, "approvalGate", gateService);
357
360
 
358
361
  const hasPreExecute = ctx && typeof ctx.on === "function";
359
362
  if (hasPreExecute) {
@@ -550,12 +553,7 @@ export { isCriticalOperation } from "./engine.js";
550
553
  export { parseYaml, parseScalar, stripComment } from "./yaml-parser.js";
551
554
  export { matchConditionValue, evaluateWhen } from "./when-matcher.js";
552
555
  export { HotwordsMatcher, DEFAULT_HOTWORDS, extractWords } from "./hotwords.js";
553
- export {
554
- DeclarativeRulesEngine,
555
- BUILTIN_RULES_FILE,
556
- WORKSPACE_RULES_REL,
557
- USER_RULES_REL,
558
- } from "./rules-loader.js";
556
+ export { DeclarativeRulesEngine, BUILTIN_RULES_FILE, WORKSPACE_RULES_REL, USER_RULES_REL } from "./rules-loader.js";
559
557
  export {
560
558
  normalizeChatCompletionsUrl,
561
559
  withLlmRetry,
@@ -574,4 +572,10 @@ export { resolveTempRoot, isGlobalTempPath, checkTempIsolation } from "./paths.j
574
572
  export { computePayloadSize, scanPromptInjection } from "./inspect.js";
575
573
  export { RISK_BUCKETS, RISK_TAGS, categorizeRiskBucket, extractRiskTags } from "./risk.js";
576
574
  export { ClassifierLatencyTracker, DEFAULT_CLASSIFIER_SLA_MS } from "./latency.js";
575
+ export { resolveTrashDir, isRmCommand, extractRmTargets, buildTrashCommand, suggestTrashAlternative } from "./trash.js";
576
+ export { ArtifactRegistry, normalizeArtifactPath } from "./artifacts.js";
577
+ export { parseAgentsSecurityRules, loadAgentsRules, evaluateAgentsRules } from "./agents-parser.js";
578
+ export { detectHarnessCapabilities, assertHarnessCompatibility, provideServicePolyfill, registerHookPolyfill } from "./compat.js";
579
+ export { parseSemver, isMajorBump, inspectPackageJsonDiff, VersionGuard } from "./version-guard.js";
580
+
577
581
 
package/lib/messages.js CHANGED
@@ -77,6 +77,10 @@ export const MESSAGES = Object.freeze({
77
77
  ruleUnisolatedTempPath: "Target path uses unisolated global temp directory",
78
78
  askOverengineeringLimit: "Session file creation limit exceeded; potential overengineering requires review before allowing",
79
79
  ruleOverengineeringLimit: "Session exceeded maximum allowed created files limit",
80
+ ruleAgentsMdProhibitedCommand: "Command execution prohibited by rules in AGENTS.md",
81
+ ruleAgentsMdProhibitedPath: "File modification prohibited by rules in AGENTS.md",
82
+ askMajorVersionBump: "Package.json modification contains major dependency bump; review before allowing",
83
+ ruleMajorVersionBump: "Unauthorized major dependency version bump",
80
84
  hintRecursiveRm: "Specify exact target files instead of recursive deletion",
81
85
  hintPathTraversal: "Keep operations strictly within the project directory",
82
86
  hintSecretWrite: "Modify configuration via settings or environment variables, not raw file overwrite",
@@ -100,6 +104,9 @@ export const MESSAGES = Object.freeze({
100
104
  hintContractViolation: "Confine operations to permitted task tools and project scope",
101
105
  hintUnisolatedTempPath: "Use workspace-isolated temporary directory (<workspace>/.dsh/tmp)",
102
106
  hintOverengineeringLimit: "Reuse existing files and avoid creating redundant abstractions or boilerplate",
107
+ hintTrashAlternative: "Move files to reversible .dsh/trash/<session>/ directory instead of rm",
108
+ hintAgentsMdProhibited: "Review project-specific guidelines and constraints defined in AGENTS.md",
109
+ hintMajorVersionBump: "Verify compatibility before bumping major dependency versions in package.json",
103
110
  },
104
111
  zh: {
105
112
  blockedPrefix: "dsh-approval-gate 规则已阻止",
@@ -176,6 +183,10 @@ export const MESSAGES = Object.freeze({
176
183
  ruleUnisolatedTempPath: "目标路径使用未隔离的全局临时目录",
177
184
  askOverengineeringLimit: "会话创建文件数超过限制;潜在的过度工程需要人工审查",
178
185
  ruleOverengineeringLimit: "会话超出最大允许创建文件数限制",
186
+ ruleAgentsMdProhibitedCommand: "命令执行被 AGENTS.md 中的规则明确禁止",
187
+ ruleAgentsMdProhibitedPath: "文件修改被 AGENTS.md 中的规则明确禁止",
188
+ askMajorVersionBump: "package.json 包含破坏性依赖主版本升级;请在允许前进行审查",
189
+ ruleMajorVersionBump: "未授权的依赖包主版本升级",
179
190
  hintRecursiveRm: "请指定具体目标文件,避免递归删除",
180
191
  hintPathTraversal: "请将操作限制在项目根目录内",
181
192
  hintSecretWrite: "请通过设置或环境变量修改配置,不要直接覆盖敏感配置文件",
@@ -199,6 +210,9 @@ export const MESSAGES = Object.freeze({
199
210
  hintContractViolation: "将操作限制在允许的任务工具和项目范围内",
200
211
  hintUnisolatedTempPath: "使用工作区隔离的临时目录 (<workspace>/.dsh/tmp)",
201
212
  hintOverengineeringLimit: "复用现有文件,避免创建多余的抽象或样板代码",
213
+ hintTrashAlternative: "建议将文件移动到可逆的 .dsh/trash/<session>/ 目录,避免直接 rm",
214
+ hintAgentsMdProhibited: "请核对项目根目录 AGENTS.md 中定义的安全约束和禁止指令",
215
+ hintMajorVersionBump: "在升级 package.json 依赖主版本前请确认破坏性变更与兼容性",
202
216
  },
203
217
  });
204
218
 
@@ -270,10 +284,13 @@ const RULE_KEYS = Object.freeze({
270
284
  "contract-violation": "ruleContractViolation",
271
285
  "unisolated-temp-path": "ruleUnisolatedTempPath",
272
286
  "overengineering-file-limit": "ruleOverengineeringLimit",
287
+ "agents-md-prohibited-command": "ruleAgentsMdProhibitedCommand",
288
+ "agents-md-prohibited-path": "ruleAgentsMdProhibitedPath",
289
+ "major-version-bump": "askMajorVersionBump",
273
290
  });
274
291
 
275
292
  const HINT_KEYS = Object.freeze({
276
- "recursive-rm": "hintRecursiveRm",
293
+ "recursive-rm": "hintTrashAlternative",
277
294
  "path-traversal": "hintPathTraversal",
278
295
  "secret-write": "hintSecretWrite",
279
296
  "env-dump-leak": "hintEnvDumpLeak",
@@ -297,6 +314,9 @@ const HINT_KEYS = Object.freeze({
297
314
  "contract-violation": "hintContractViolation",
298
315
  "unisolated-temp-path": "hintUnisolatedTempPath",
299
316
  "overengineering-file-limit": "hintOverengineeringLimit",
317
+ "agents-md-prohibited-command": "hintAgentsMdProhibited",
318
+ "agents-md-prohibited-path": "hintAgentsMdProhibited",
319
+ "major-version-bump": "hintMajorVersionBump",
300
320
  });
301
321
 
302
322
  const ASK_KEYS = Object.freeze({
@@ -321,6 +341,7 @@ const ASK_KEYS = Object.freeze({
321
341
  "contract-violation": "askContractViolation",
322
342
  "unisolated-temp-path": "askUnisolatedTempPath",
323
343
  "overengineering-file-limit": "askOverengineeringLimit",
344
+ "major-version-bump": "askMajorVersionBump",
324
345
  });
325
346
 
326
347
  const DETAIL_KEYS = Object.freeze({
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
+ }
package/package.json CHANGED
@@ -1,55 +1,55 @@
1
- {
2
- "name": "@goodandready/dsh-approval-gate",
3
- "version": "0.1.19",
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.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
+ }