@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 +8 -0
- package/lib/agents-parser.js +183 -0
- package/lib/artifacts.js +162 -0
- package/lib/compat.js +124 -0
- package/lib/engine.js +68 -32
- package/lib/index.js +17 -13
- package/lib/messages.js +22 -1
- package/lib/trash.js +119 -0
- package/lib/version-guard.js +177 -0
- package/package.json +55 -55
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
|
+
}
|
package/lib/artifacts.js
ADDED
|
@@ -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
|
-
|
|
542
|
-
|
|
543
|
-
if (
|
|
544
|
-
decision.
|
|
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
|
-
|
|
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": "
|
|
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.
|
|
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
|
+
}
|