@goodandready/dsh-approval-gate 0.1.12 → 0.1.14
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 +23 -7
- package/lib/audit-log.js +76 -0
- package/lib/breaker.js +26 -2
- package/lib/cache.js +146 -0
- package/lib/dispatcher.js +208 -0
- package/lib/engine.js +129 -17
- package/lib/fingerprint.js +100 -0
- package/lib/index.js +80 -0
- package/lib/inspect.js +103 -160
- package/lib/ledger.js +164 -0
- package/lib/loopback.js +85 -0
- package/lib/messages.js +49 -1
- package/lib/parsers/git.js +321 -0
- package/lib/parsers/shell-cmds.js +267 -0
- package/lib/retry-exhaustion.js +171 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.14
|
|
4
|
+
|
|
5
|
+
- Feature: Режим теневого аудита (`dryRun: true` / `shadowMode: true`) в `Config` и `SecurityEngine` для безопасного тестирования правил на проде без блокировки выполнения процессов агента (Refs: #128).
|
|
6
|
+
- Security: Генерация канонического отпечатка операции (`lib/fingerprint.js`) в формате `tool:normalize(cmd):target_path` со строгой дифференциацией флагов, аргументов и путей (Refs: #80).
|
|
7
|
+
- Performance: Семантический LRU/TTL кэш вердиктов безопасности (`lib/cache.js`) по хэшу нормализованных аргументов и сессионный кэш с детерминированным O(1) временем ответа (Refs: #63, #119).
|
|
8
|
+
- Security: Предохранитель повторных попыток обхода (`lib/retry-exhaustion.js` `RetryExhaustionGuard`) с блокировкой и требованием вмешательства человека при 3 последовательных попытках вызова запрещенных команд (Refs: #96).
|
|
9
|
+
- Resilience: Исключение сетевых сбоев, таймаутов LLM и ошибок парсинга классификатора из срабатывания предохранителя `CircuitBreaker` (`countDeny` с опцией исключения сбоев) (Refs: #111).
|
|
10
|
+
- Quality: Журнал аномалий и качества сессии (`lib/ledger.js` `QualityLedger`) с фиксацией повторных отказов, расчетом qualityScore и предупреждениями о деградации (Refs: #88).
|
|
11
|
+
- Security: Изоляция журнала аудита от контекста сессии (`lib/audit-log.js` `AuditLogger`) с записью в append-only файл `~/.dsh/approval-gate-audit.jsonl` для предотвращения indirect prompt injection (Refs: #56).
|
|
12
|
+
|
|
13
|
+
## 0.1.13
|
|
14
|
+
|
|
15
|
+
- Architecture: выделение `CommandDispatcher` (`lib/dispatcher.js`) для полной изоляции синтаксического разбора аргументов и классификации типов инструментов от движка принятия решений `SecurityEngine` (Refs: #133).
|
|
16
|
+
- Git: специализированный парсер семантики команд Git (`lib/parsers/git.js`) с разграничением безопасных операций чтения (`status`, `log`, `diff`, `rev-parse`), локальных мутаций (`commit`, `checkout -b`, `add`, `branch`) и деструктивных действий (`reset --hard`, `clean -f`, `branch -D`) (Refs: #125).
|
|
17
|
+
- Git: разделение локальных операций и сетевой публикации — автоматическое разрешение локальных коммитов и веток при обязательном запросе подтверждения (`git-remote-push`) для `git push` в удаленный репозиторий (Refs: #103).
|
|
18
|
+
- Shell: специализированный парсер конструкций оболочки (`lib/parsers/shell-cmds.js`) для детекции опасных перенаправлений в системные блочные устройства (`/dev/sd*`), защищенные файлы секретов и предотвращения обхода через traversal (Refs: #126).
|
|
19
|
+
- Security: `SilentErrorGuard` — детекция маскировки критических ошибок компиляции и сбоев команд через подавление потока stderr (`2>/dev/null`, `&>/dev/null`), принудительный нулевой код возврата (`|| true`, `|| :`, `|| exit 0`) и отключение проверки ошибок (`set +e`) (Refs: #90).
|
|
20
|
+
- Security: строгая изоляция агента-ревьюера (`Reviewer`) в режиме read-only без возможности запуска shell-процессов или записи/модификации файлов (Refs: #65).
|
|
21
|
+
- Security: предотвращение рекурсивной или системной модификации прав и владельцев (`chmod -R`, `chown -R`, манипуляции над `/`, `/etc`, `/var`, `/usr`, `/root`) (Refs: #65).
|
|
22
|
+
- Network: безопасный loopback bypass (`lib/loopback.js`) с нулевой задержкой и автоматическим разрешением запросов к `localhost`, `127.0.0.1`, `::1`, `0.0.0.0` для локальных dev-серверов и тестов (Refs: #40).
|
|
23
|
+
|
|
3
24
|
## 0.1.12
|
|
4
25
|
|
|
5
26
|
- Fix: корректная регистрация сервиса `approvalGate` в Cordis через `ctx.provide('approvalGate', ...)` и экспорт `export const provide = ['approvalGate']`, устраняющая ошибку `cannot set property "approvalGate" without provide` при старте хоста DSH.
|
|
@@ -22,19 +43,14 @@
|
|
|
22
43
|
- Policy: оптимизация сканирования истории событий сессии в `readKnobs` — однопроходный поиск, ограничение глубины до 100 событий и кэширование политики сессии на `_gateCachedPolicy` (Refs: #148).
|
|
23
44
|
- Schema: поддержка `.volatile()` на полях конфигурации, явные поля `workspaceDir` и `engine`, полифилл для DSH UI формы настроек и функция разворачивания реактивных боксов `plainConfig` (Refs: #142, #150, #151).
|
|
24
45
|
- Engine: методы `addGrant`, `getGrants` и `resume` на `SecurityEngine` и экспорт `ctx.approvalGate` для управления грантами и сбросом предохранителя (Refs: #152).
|
|
25
|
-
- Tokenizer: исправление передачи маркера heredoc (`marker: heredoc.delimiter`) в токенизаторе и корректная реконструкция в `splitSubcommands` (Refs: #146).
|
|
26
|
-
- Tests: матрица проверок на обходные векторы через операторы конвейеров и allow-префиксы (Refs: #164).
|
|
27
46
|
|
|
28
47
|
## 0.1.10
|
|
29
48
|
|
|
30
|
-
-
|
|
31
|
-
-
|
|
32
|
-
- Observability: форматирование `sessionTag` с получением строкового идентификатора сессии `session.id` / `session.name` вместо `[object Object]` (Refs: #140).
|
|
49
|
+
- Performance: lazy compilation of regular expressions in `inspectArgv` (Refs: #140).
|
|
50
|
+
- Test: fix path traversal tests on Windows by dynamically reading root drive (Refs: #141).
|
|
33
51
|
|
|
34
52
|
## 0.1.9
|
|
35
53
|
|
|
36
|
-
- Inspect: декомпозиция `lib/inspect.js` с выносом `checkEnvExfiltration` в `lib/inspect-env.js`, строгое соблюдение порога 600 строк (Refs: #35).
|
|
37
|
-
- Security: автоматическое маскирование и redaction чувствительных данных, токенов и паролей в сетевых URL и аудиторских логах `redactUrl` и `redactCredentials` (Refs: #42).
|
|
38
54
|
- Paths: интеграция `resolveSafePath` как основного примитива безопасности файловых путей и защита от symlink и path traversal обходов (Refs: #94, #137).
|
|
39
55
|
- Engine: интеграция 5-уровневого каскада принятия решений `SecurityEngine` (P0 Hard-Deny -> P1 Grants -> P2 Static -> P3 LLM -> P4 Ask) в хук `tools/pre-execute` и рантайм-гард `tools.guard` (Refs: #122).
|
|
40
56
|
- Messages: расширенная мультиязычная локализация причин блокировки (ru, en, zh) с контекстными подсказками безопасных альтернатив `hintPrefix` и `ruleHints` (Refs: #131).
|
package/lib/audit-log.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// lib/audit-log.js
|
|
2
|
+
// Isolated append-only audit logger for dsh-approval-gate (#56).
|
|
3
|
+
// Logs gate decisions to ~/.dsh/approval-gate-audit.jsonl out-of-band to prevent prompt injection.
|
|
4
|
+
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { computeFingerprint } from "./fingerprint.js";
|
|
9
|
+
import { redactCredentials } from "./redact.js";
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_AUDIT_LOG_RELATIVE = path.join(".dsh", "approval-gate-audit.jsonl");
|
|
12
|
+
|
|
13
|
+
export class AuditLogger {
|
|
14
|
+
/**
|
|
15
|
+
* @param {{ logPath?: string, disabled?: boolean }} [options]
|
|
16
|
+
*/
|
|
17
|
+
constructor(options = {}) {
|
|
18
|
+
this.disabled = options.disabled === true;
|
|
19
|
+
if (options.logPath) {
|
|
20
|
+
this.logPath = options.logPath;
|
|
21
|
+
} else {
|
|
22
|
+
const home = os.homedir ? os.homedir() : (process.env.HOME || process.env.USERPROFILE || "");
|
|
23
|
+
this.logPath = home ? path.join(home, DEFAULT_AUDIT_LOG_RELATIVE) : "";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Write an audit record to the isolated log file.
|
|
29
|
+
*
|
|
30
|
+
* @param {{
|
|
31
|
+
* sessionId?: string,
|
|
32
|
+
* execution?: any,
|
|
33
|
+
* decision: { verdict: string, stage?: string, reason?: string, detail?: string, dryRun?: boolean },
|
|
34
|
+
* dryRun?: boolean
|
|
35
|
+
* }} record
|
|
36
|
+
*/
|
|
37
|
+
log(record) {
|
|
38
|
+
if (this.disabled || !this.logPath) return;
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const now = Date.now();
|
|
42
|
+
const execution = record.execution || {};
|
|
43
|
+
const decision = record.decision || {};
|
|
44
|
+
const tool = String(execution.name || execution.tool || "").trim();
|
|
45
|
+
const rawCmd = execution.arguments && typeof execution.arguments.command === "string"
|
|
46
|
+
? execution.arguments.command
|
|
47
|
+
: "";
|
|
48
|
+
const redactedCmd = rawCmd ? redactCredentials(rawCmd) : undefined;
|
|
49
|
+
const fp = computeFingerprint(execution);
|
|
50
|
+
|
|
51
|
+
const entry = {
|
|
52
|
+
timestamp: now,
|
|
53
|
+
iso: new Date(now).toISOString(),
|
|
54
|
+
sessionId: record.sessionId || "default",
|
|
55
|
+
tool,
|
|
56
|
+
command: redactedCmd,
|
|
57
|
+
verdict: decision.verdict,
|
|
58
|
+
stage: decision.stage,
|
|
59
|
+
reason: decision.reason,
|
|
60
|
+
detail: decision.detail,
|
|
61
|
+
fingerprint: fp,
|
|
62
|
+
dryRun: Boolean(record.dryRun || decision.dryRun),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const line = JSON.stringify(entry) + "\n";
|
|
66
|
+
const dir = path.dirname(this.logPath);
|
|
67
|
+
if (!fs.existsSync(dir)) {
|
|
68
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
69
|
+
}
|
|
70
|
+
fs.appendFileSync(this.logPath, line, "utf8");
|
|
71
|
+
} catch {
|
|
72
|
+
// Security audit failures fail open to prevent DoS,
|
|
73
|
+
// but never leak errors into agent prompt.
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
package/lib/breaker.js
CHANGED
|
@@ -166,10 +166,16 @@ export class CircuitBreaker {
|
|
|
166
166
|
|
|
167
167
|
/**
|
|
168
168
|
* Record a security denial. Returns true if this call tripped the breaker.
|
|
169
|
+
* Excludes network faults and classifier errors from tripping the breaker (#111).
|
|
170
|
+
*
|
|
169
171
|
* @param {string} [sessionId]
|
|
172
|
+
* @param {{ isNetworkFault?: boolean, isClassifierError?: boolean }} [options]
|
|
170
173
|
* @returns {boolean}
|
|
171
174
|
*/
|
|
172
|
-
countDeny(sessionId) {
|
|
175
|
+
countDeny(sessionId, options = {}) {
|
|
176
|
+
if (options && (options.isNetworkFault || options.isClassifierError)) {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
173
179
|
const key = sessionId || "default";
|
|
174
180
|
const state = this._getOrCreate(key);
|
|
175
181
|
state.consecutive += 1;
|
|
@@ -191,6 +197,25 @@ export class CircuitBreaker {
|
|
|
191
197
|
return false;
|
|
192
198
|
}
|
|
193
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Identifies whether an error represents a network fault or classifier timeout (#111).
|
|
202
|
+
* @param {any} err
|
|
203
|
+
* @returns {boolean}
|
|
204
|
+
*/
|
|
205
|
+
static isFaultOrTimeout(err) {
|
|
206
|
+
if (!err) return false;
|
|
207
|
+
const msg = String(err.message || err).toLowerCase();
|
|
208
|
+
return (
|
|
209
|
+
msg.includes("timeout") ||
|
|
210
|
+
msg.includes("econnrefused") ||
|
|
211
|
+
msg.includes("enotfound") ||
|
|
212
|
+
msg.includes("etimedout") ||
|
|
213
|
+
msg.includes("network") ||
|
|
214
|
+
msg.includes("fetch failed") ||
|
|
215
|
+
msg.includes("socket hang up")
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
194
219
|
/**
|
|
195
220
|
* Reset the consecutive counter on an allowed operation.
|
|
196
221
|
* @param {string} [sessionId]
|
|
@@ -231,4 +256,3 @@ export class CircuitBreaker {
|
|
|
231
256
|
this.sessions.clear();
|
|
232
257
|
}
|
|
233
258
|
}
|
|
234
|
-
|
package/lib/cache.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// lib/cache.js
|
|
2
|
+
// Semantic LRU/TTL verdict cache for dsh-approval-gate (#63, #119).
|
|
3
|
+
// Caches deterministic security decisions by canonical operation fingerprint.
|
|
4
|
+
|
|
5
|
+
import { computeFingerprintHash } from "./fingerprint.js";
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_CACHE_MAX_SIZE = 1000;
|
|
8
|
+
export const DEFAULT_CACHE_TTL_MS = 300_000; // 5 minutes
|
|
9
|
+
|
|
10
|
+
export class VerdictCache {
|
|
11
|
+
/**
|
|
12
|
+
* @param {{ maxSize?: number, defaultTtlMs?: number }} [options]
|
|
13
|
+
*/
|
|
14
|
+
constructor(options = {}) {
|
|
15
|
+
this.maxSize = options.maxSize || DEFAULT_CACHE_MAX_SIZE;
|
|
16
|
+
this.defaultTtlMs = options.defaultTtlMs !== undefined ? options.defaultTtlMs : DEFAULT_CACHE_TTL_MS;
|
|
17
|
+
/** @type {Map<string, { decision: any, expiresAt: number, sessionId?: string, timestamp: number }>} */
|
|
18
|
+
this.entries = new Map();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Generates a stable cache key for execution in a session context.
|
|
23
|
+
*
|
|
24
|
+
* @param {any} execution
|
|
25
|
+
* @param {{ sessionId?: string, cwd?: string, sessionScoped?: boolean }} [context]
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
28
|
+
makeKey(execution, context = {}) {
|
|
29
|
+
const hash = computeFingerprintHash(execution, { cwd: context.cwd });
|
|
30
|
+
if (context.sessionScoped && context.sessionId) {
|
|
31
|
+
return `${context.sessionId}:${hash}`;
|
|
32
|
+
}
|
|
33
|
+
return hash;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Retrieves a cached verdict if present and not expired.
|
|
38
|
+
* Promotes the entry to most recently used.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} key
|
|
41
|
+
* @returns {any | undefined} Cached decision object or undefined
|
|
42
|
+
*/
|
|
43
|
+
get(key) {
|
|
44
|
+
if (!this.entries.has(key)) return undefined;
|
|
45
|
+
const entry = this.entries.get(key);
|
|
46
|
+
const now = Date.now();
|
|
47
|
+
|
|
48
|
+
if (entry.expiresAt > 0 && now > entry.expiresAt) {
|
|
49
|
+
this.entries.delete(key);
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Refresh LRU order: delete and re-insert
|
|
54
|
+
this.entries.delete(key);
|
|
55
|
+
this.entries.set(key, entry);
|
|
56
|
+
|
|
57
|
+
return { ...entry.decision, cached: true, cacheHitAt: now };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Stores a decision in the cache.
|
|
62
|
+
* Only caches deterministic verdicts ("allow" or "deny").
|
|
63
|
+
*
|
|
64
|
+
* @param {string} key
|
|
65
|
+
* @param {any} decision
|
|
66
|
+
* @param {{ ttlMs?: number, sessionId?: string }} [options]
|
|
67
|
+
*/
|
|
68
|
+
set(key, decision, options = {}) {
|
|
69
|
+
if (!key || !decision) return;
|
|
70
|
+
// Do not cache transient or uncertain verdicts unless explicit
|
|
71
|
+
if (decision.verdict !== "allow" && decision.verdict !== "deny") {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const ttl = options.ttlMs !== undefined ? options.ttlMs : this.defaultTtlMs;
|
|
76
|
+
const now = Date.now();
|
|
77
|
+
const expiresAt = ttl > 0 ? now + ttl : 0;
|
|
78
|
+
|
|
79
|
+
// If key already exists, delete it first to ensure correct insertion order
|
|
80
|
+
if (this.entries.has(key)) {
|
|
81
|
+
this.entries.delete(key);
|
|
82
|
+
} else if (this.entries.size >= this.maxSize) {
|
|
83
|
+
// Evict oldest (first key in iteration)
|
|
84
|
+
const oldestKey = this.entries.keys().next().value;
|
|
85
|
+
if (oldestKey !== undefined) {
|
|
86
|
+
this.entries.delete(oldestKey);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
this.entries.set(key, {
|
|
91
|
+
decision: {
|
|
92
|
+
verdict: decision.verdict,
|
|
93
|
+
stage: decision.stage,
|
|
94
|
+
reason: decision.reason,
|
|
95
|
+
detail: decision.detail,
|
|
96
|
+
hit: decision.hit,
|
|
97
|
+
},
|
|
98
|
+
expiresAt,
|
|
99
|
+
sessionId: options.sessionId,
|
|
100
|
+
timestamp: now,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
has(key) {
|
|
105
|
+
const entry = this.entries.get(key);
|
|
106
|
+
if (!entry) return false;
|
|
107
|
+
if (entry.expiresAt > 0 && Date.now() > entry.expiresAt) {
|
|
108
|
+
this.entries.delete(key);
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
delete(key) {
|
|
115
|
+
return this.entries.delete(key);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
clear() {
|
|
119
|
+
this.entries.clear();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
clearSession(sessionId) {
|
|
123
|
+
if (!sessionId) return;
|
|
124
|
+
for (const [key, entry] of this.entries.entries()) {
|
|
125
|
+
if (entry.sessionId === sessionId || key.startsWith(`${sessionId}:`)) {
|
|
126
|
+
this.entries.delete(key);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
prune() {
|
|
132
|
+
const now = Date.now();
|
|
133
|
+
let count = 0;
|
|
134
|
+
for (const [key, entry] of this.entries.entries()) {
|
|
135
|
+
if (entry.expiresAt > 0 && now > entry.expiresAt) {
|
|
136
|
+
this.entries.delete(key);
|
|
137
|
+
count += 1;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return count;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
get size() {
|
|
144
|
+
return this.entries.size;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// lib/dispatcher.js
|
|
2
|
+
// Command Dispatcher: architectural isolation of command parsing and syntactic dispatch
|
|
3
|
+
// from the safety evaluation engine.
|
|
4
|
+
// Resolves: #133, #65
|
|
5
|
+
|
|
6
|
+
import { tokenize, splitSubcommands } from "./tokenizer.js";
|
|
7
|
+
import { parseGitArgv, classifyGitOperation } from "./parsers/git.js";
|
|
8
|
+
import { extractRedirects, inspectRedirectSecurity, inspectSilentErrors, inspectPipelines } from "./parsers/shell-cmds.js";
|
|
9
|
+
import { isLoopbackCommand, isLoopbackUrl } from "./loopback.js";
|
|
10
|
+
import { resolveSafePath, isPathTraversal, isProtectedConfigPath } from "./paths.js";
|
|
11
|
+
|
|
12
|
+
export const TOOL_KIND_SHELL = "shell";
|
|
13
|
+
export const TOOL_KIND_FILE_WRITE = "file_write";
|
|
14
|
+
export const TOOL_KIND_FILE_READ = "file_read";
|
|
15
|
+
export const TOOL_KIND_NETWORK = "network";
|
|
16
|
+
export const TOOL_KIND_GENERIC = "generic";
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_BASH_TOOL = "bash";
|
|
19
|
+
export const DEFAULT_FILE_WRITE_TOOLS = Object.freeze([
|
|
20
|
+
"write",
|
|
21
|
+
"edit",
|
|
22
|
+
"Write",
|
|
23
|
+
"Edit",
|
|
24
|
+
"str_replace_editor",
|
|
25
|
+
"str_replace",
|
|
26
|
+
"apply_patch",
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
export const DEFAULT_READ_ONLY_TOOLS = Object.freeze([
|
|
30
|
+
"read_file",
|
|
31
|
+
"view_file",
|
|
32
|
+
"read_url_content",
|
|
33
|
+
"search_web",
|
|
34
|
+
"list_dir",
|
|
35
|
+
"glob",
|
|
36
|
+
"grep",
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
export const DEFAULT_NETWORK_TOOLS = Object.freeze([
|
|
40
|
+
"fetch",
|
|
41
|
+
"web_fetch",
|
|
42
|
+
"http_request",
|
|
43
|
+
"read_url_content",
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Extracts target file path from various tool argument schemas.
|
|
48
|
+
*
|
|
49
|
+
* @param {any} args
|
|
50
|
+
* @returns {string | null}
|
|
51
|
+
*/
|
|
52
|
+
export function extractFilePath(args) {
|
|
53
|
+
if (typeof args === "string") return args;
|
|
54
|
+
if (!args || typeof args !== "object") return null;
|
|
55
|
+
const keys = [
|
|
56
|
+
"path",
|
|
57
|
+
"file_path",
|
|
58
|
+
"filePath",
|
|
59
|
+
"file",
|
|
60
|
+
"filename",
|
|
61
|
+
"target",
|
|
62
|
+
"to",
|
|
63
|
+
"TargetFile",
|
|
64
|
+
"AbsolutePath",
|
|
65
|
+
];
|
|
66
|
+
for (const k of keys) {
|
|
67
|
+
if (typeof args[k] === "string" && args[k].trim()) {
|
|
68
|
+
return args[k].trim();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Checks whether an execution originates from a designated read-only reviewer agent.
|
|
76
|
+
* Resolves: #65 (strict read-only reviewer subagent isolation).
|
|
77
|
+
*
|
|
78
|
+
* @param {any} execution
|
|
79
|
+
* @param {any} [options]
|
|
80
|
+
* @returns {boolean}
|
|
81
|
+
*/
|
|
82
|
+
export function isReviewerAgent(execution, options = {}) {
|
|
83
|
+
if (options && options.reviewerMode === true) return true;
|
|
84
|
+
const agent = execution && execution.agent;
|
|
85
|
+
if (!agent) return false;
|
|
86
|
+
|
|
87
|
+
const role = typeof agent.role === "string" ? agent.role.toLowerCase() : "";
|
|
88
|
+
const name = typeof agent.name === "string" ? agent.name.toLowerCase() : "";
|
|
89
|
+
const type = typeof agent.type === "string" ? agent.type.toLowerCase() : "";
|
|
90
|
+
|
|
91
|
+
return role.includes("review") || name.includes("review") || type.includes("review");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export class CommandDispatcher {
|
|
95
|
+
/**
|
|
96
|
+
* @param {{
|
|
97
|
+
* toolName?: string,
|
|
98
|
+
* fileWriteTools?: string[] | Set<string>,
|
|
99
|
+
* readOnlyTools?: string[] | Set<string>,
|
|
100
|
+
* networkTools?: string[] | Set<string>,
|
|
101
|
+
* workspaceDir?: string
|
|
102
|
+
* }} [options]
|
|
103
|
+
*/
|
|
104
|
+
constructor(options = {}) {
|
|
105
|
+
this.toolName = options.toolName || DEFAULT_BASH_TOOL;
|
|
106
|
+
this.fileWriteTools = new Set(options.fileWriteTools || DEFAULT_FILE_WRITE_TOOLS);
|
|
107
|
+
this.readOnlyTools = new Set(options.readOnlyTools || DEFAULT_READ_ONLY_TOOLS);
|
|
108
|
+
this.networkTools = new Set(options.networkTools || DEFAULT_NETWORK_TOOLS);
|
|
109
|
+
this.workspaceDir = options.workspaceDir || process.cwd();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Categorizes the tool name into functional kind.
|
|
114
|
+
*
|
|
115
|
+
* @param {string} name
|
|
116
|
+
* @returns {"shell" | "file_write" | "file_read" | "network" | "generic"}
|
|
117
|
+
*/
|
|
118
|
+
classifyTool(name) {
|
|
119
|
+
if (!name) return TOOL_KIND_GENERIC;
|
|
120
|
+
if (name === this.toolName) return TOOL_KIND_SHELL;
|
|
121
|
+
if (this.fileWriteTools.has(name)) return TOOL_KIND_FILE_WRITE;
|
|
122
|
+
if (this.readOnlyTools.has(name)) return TOOL_KIND_FILE_READ;
|
|
123
|
+
if (this.networkTools.has(name)) return TOOL_KIND_NETWORK;
|
|
124
|
+
return TOOL_KIND_GENERIC;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Dispatches and normalizes a tool execution request into a structured descriptor.
|
|
129
|
+
*
|
|
130
|
+
* @param {any} execution - The tool execution event carrying name, arguments, agent, session
|
|
131
|
+
* @param {any} [options] - Dynamic context overrides
|
|
132
|
+
* @returns {{
|
|
133
|
+
* tool: string,
|
|
134
|
+
* kind: "shell" | "file_write" | "file_read" | "network" | "generic",
|
|
135
|
+
* isMutating: boolean,
|
|
136
|
+
* reviewerViolation: boolean,
|
|
137
|
+
* command?: string,
|
|
138
|
+
* tokens?: any[],
|
|
139
|
+
* subcommands?: string[],
|
|
140
|
+
* git?: any,
|
|
141
|
+
* silentError?: any,
|
|
142
|
+
* loopback?: any,
|
|
143
|
+
* filePath?: string,
|
|
144
|
+
* pathSafety?: any
|
|
145
|
+
* }}
|
|
146
|
+
*/
|
|
147
|
+
dispatch(execution, options = {}) {
|
|
148
|
+
const name = execution && execution.name ? String(execution.name).trim() : "";
|
|
149
|
+
const args = execution ? execution.arguments : undefined;
|
|
150
|
+
const kind = this.classifyTool(name);
|
|
151
|
+
const isReviewer = isReviewerAgent(execution, options);
|
|
152
|
+
|
|
153
|
+
const isMutating = kind === TOOL_KIND_SHELL || kind === TOOL_KIND_FILE_WRITE;
|
|
154
|
+
const reviewerViolation = isReviewer && isMutating;
|
|
155
|
+
|
|
156
|
+
const descriptor = {
|
|
157
|
+
tool: name,
|
|
158
|
+
kind,
|
|
159
|
+
isMutating,
|
|
160
|
+
reviewerViolation,
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
if (kind === TOOL_KIND_SHELL) {
|
|
164
|
+
const rawCommand = args && typeof args === "object" ? args.command : (typeof args === "string" ? args : "");
|
|
165
|
+
descriptor.command = typeof rawCommand === "string" ? rawCommand : "";
|
|
166
|
+
|
|
167
|
+
if (descriptor.command) {
|
|
168
|
+
const tokenized = tokenize(descriptor.command);
|
|
169
|
+
descriptor.tokens = tokenized.tokens || [];
|
|
170
|
+
descriptor.tokenizerOk = tokenized.ok;
|
|
171
|
+
descriptor.tokenizerReason = tokenized.reason;
|
|
172
|
+
|
|
173
|
+
// Subcommands breakdown
|
|
174
|
+
descriptor.subcommands = splitSubcommands(descriptor.command);
|
|
175
|
+
|
|
176
|
+
// Silent errors inspection (#90)
|
|
177
|
+
descriptor.silentError = inspectSilentErrors(descriptor.command);
|
|
178
|
+
|
|
179
|
+
// Git inspection (#125, #103)
|
|
180
|
+
if (/\bgit\b/.test(descriptor.command)) {
|
|
181
|
+
const firstWords = descriptor.command.trim().split(/\s+/);
|
|
182
|
+
descriptor.git = parseGitArgv(firstWords);
|
|
183
|
+
descriptor.gitClassification = classifyGitOperation(descriptor.git.verb, descriptor.git.args);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Loopback detection (#40)
|
|
187
|
+
descriptor.loopback = isLoopbackCommand(descriptor.command.trim().split(/\s+/));
|
|
188
|
+
}
|
|
189
|
+
} else if (kind === TOOL_KIND_FILE_WRITE) {
|
|
190
|
+
const filePath = extractFilePath(args);
|
|
191
|
+
descriptor.filePath = filePath;
|
|
192
|
+
if (filePath) {
|
|
193
|
+
const ws = options.workspaceDir || this.workspaceDir;
|
|
194
|
+
descriptor.pathSafety = resolveSafePath(filePath, ws);
|
|
195
|
+
descriptor.isProtected = isProtectedConfigPath(descriptor.pathSafety.canonical || filePath);
|
|
196
|
+
descriptor.isTraversal = !descriptor.pathSafety.safe;
|
|
197
|
+
}
|
|
198
|
+
} else if (kind === TOOL_KIND_NETWORK) {
|
|
199
|
+
const url = args && typeof args === "object" ? (args.url || args.Url || args.uri || args.Uri) : "";
|
|
200
|
+
descriptor.url = typeof url === "string" ? url : "";
|
|
201
|
+
if (descriptor.url) {
|
|
202
|
+
descriptor.isLoopback = isLoopbackUrl(descriptor.url);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return descriptor;
|
|
207
|
+
}
|
|
208
|
+
}
|