@goodandready/dsh-approval-gate 0.1.19 → 0.1.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/lib/agents-parser.js +183 -0
- package/lib/artifacts.js +162 -0
- package/lib/bands.js +243 -239
- package/lib/compat.js +124 -0
- package/lib/engine.js +78 -60
- package/lib/index.js +58 -97
- package/lib/measure-kinds.js +135 -0
- package/lib/messages.js +28 -1
- package/lib/snapshots.js +268 -0
- 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,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.21
|
|
4
|
+
|
|
5
|
+
- Feature: Автоматические легковесные пре-снимки файлов (`Pre-execution Snapshots` в `lib/snapshots.js`) перед выполнением мутирующих инструментов с отслеживанием размера и FIFO-вытеснением по сессиям (Refs: #77).
|
|
6
|
+
- Feature: Однократный откат изменений (`Undo this change` / `rollbackSnapshot`) из сохраненного пре-снимка с восстановлением исходного контента или удалением созданного файла (Refs: #76).
|
|
7
|
+
- Feature: Двухуровневая очистка файловых снимков (`clearSnapshots` для безопасной очистки текущей сессии без влияния на соседние диалоги vs `clearAllSnapshots` для глобального сброса) (Refs: #78).
|
|
8
|
+
- Feature: Многоуровневая категоризация масштаба влияния операций (`Measure Kinds` в `lib/measure-kinds.js`: `local-single`, `workspace-wide`, `system-level`) с обогащением вердикта и фиксацией в аудите (Refs: #97).
|
|
9
|
+
- Security: Завершение перехода #163 — перевод конфигурации по умолчанию в строгий fail-closed режим (`defaultVerdict: 'ask'`) для неизвестных команд оболочки (Refs: #172).
|
|
10
|
+
|
|
11
|
+
## 0.1.20
|
|
12
|
+
|
|
13
|
+
- Feature: Безопасная альтернатива необратимому удалению (`Reversible Trash Move` в `lib/trash.js`) с переносом удаляемых файлов в изолированную сессионную корзину `<workspace>/.dsh/trash/<session_id>/` вместо деструктивного `rm` (Refs: #104).
|
|
14
|
+
- Feature: Реестр сессионных артефактов (`ArtifactRegistry` в `lib/artifacts.js`) для автоматического отслеживания и безопасного разрешения очистки временных файлов текущей сессии на стадии P1 без спама подтверждениями (Refs: #100).
|
|
15
|
+
- Security: Парсер проектных правил безопасности из `AGENTS.md` (`lib/agents-parser.js`) с жестким перехватом запрещенных команд и путей на стадии P0 (`agents-md-prohibited-command`, `agents-md-prohibited-path` -> `deny`) (Refs: #91).
|
|
16
|
+
- Resilience: Проверка совместимости версий хоста DSH (`assertHarnessCompatibility` в `lib/compat.js`) и безопасные полифилы `provideServicePolyfill` / `registerHookPolyfill` для бесшовной работы на различных версиях Cordis (Refs: #106).
|
|
17
|
+
- Security: Страж зависимостей проекта (`VersionGuard` в `lib/version-guard.js`) с автоматическим обнаружением мажорных обновлений в `package.json` и переводом их на ручное подтверждение человеком (`major-version-bump` -> `ask`) (Refs: #95).
|
|
18
|
+
|
|
3
19
|
## 0.1.19
|
|
4
20
|
|
|
5
21
|
- 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
|
+
}
|