@goodandready/dsh-approval-gate 0.1.13 → 0.1.15
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 +20 -7
- package/lib/audit-log.js +76 -0
- package/lib/breaker.js +26 -2
- package/lib/cache.js +146 -0
- package/lib/engine.js +156 -19
- package/lib/fingerprint.js +100 -0
- package/lib/hotwords.js +122 -0
- package/lib/index.js +78 -1
- package/lib/ledger.js +164 -0
- package/lib/messages.js +8 -2
- package/lib/retry-exhaustion.js +171 -0
- package/lib/rules-loader.js +175 -0
- package/lib/when-matcher.js +89 -0
- package/lib/yaml-parser.js +210 -0
- package/package.json +3 -2
- package/rules/builtin-high-risk.yaml +81 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.15
|
|
4
|
+
|
|
5
|
+
- Rules: Каскадный декларативный YAML-конфиг правил `rules.yaml` (`.dsh/rules.yaml` в проекте и `~/.dsh/rules.yaml` пользователя) с поддержкой allow/deny/ask политик (Refs: #38).
|
|
6
|
+
- Rules: Вынесение каталога встроенных критических угроз нулевого уровня (P0) в отчуждаемый версионируемый каталог `rules/builtin-high-risk.yaml` (Refs: #45).
|
|
7
|
+
- Rules: Условные предикаты правил `when` с сопоставлением контекста окружения (ОС `os`, git-ветка `branch`, переменные окружения `env` и инструмент `tool`) с поддержкой отрицания `!` (Refs: #44).
|
|
8
|
+
- Performance: Высокоскоростной пре-фильтр ключевых слов `HotwordsMatcher` (rm, kill, shadow, sudoers, curl, format и др.) для микросекундного пропуска безопасных команд (Refs: #93).
|
|
9
|
+
- Testing: Корпус тестовых векторов `test/vectors/corpus.json` из 110+ команд с автотестом контроля регрессий (FPR=0, FNR=0) (Refs: #46).
|
|
10
|
+
|
|
11
|
+
## 0.1.14
|
|
12
|
+
|
|
13
|
+
- Feature: Режим теневого аудита (`dryRun: true` / `shadowMode: true`) в `Config` и `SecurityEngine` для безопасного тестирования правил на проде без блокировки выполнения процессов агента (Refs: #128).
|
|
14
|
+
- Security: Генерация канонического отпечатка операции (`lib/fingerprint.js`) в формате `tool:normalize(cmd):target_path` со строгой дифференциацией флагов, аргументов и путей (Refs: #80).
|
|
15
|
+
- Performance: Семантический LRU/TTL кэш вердиктов безопасности (`lib/cache.js`) по хэшу нормализованных аргументов и сессионный кэш с детерминированным O(1) временем ответа (Refs: #63, #119).
|
|
16
|
+
- Security: Предохранитель повторных попыток обхода (`lib/retry-exhaustion.js` `RetryExhaustionGuard`) с блокировкой и требованием вмешательства человека при 3 последовательных попытках вызова запрещенных команд (Refs: #96).
|
|
17
|
+
- Resilience: Исключение сетевых сбоев, таймаутов LLM и ошибок парсинга классификатора из срабатывания предохранителя `CircuitBreaker` (`countDeny` с опцией исключения сбоев) (Refs: #111).
|
|
18
|
+
- Quality: Журнал аномалий и качества сессии (`lib/ledger.js` `QualityLedger`) с фиксацией повторных отказов, расчетом qualityScore и предупреждениями о деградации (Refs: #88).
|
|
19
|
+
- Security: Изоляция журнала аудита от контекста сессии (`lib/audit-log.js` `AuditLogger`) с записью в append-only файл `~/.dsh/approval-gate-audit.jsonl` для предотвращения indirect prompt injection (Refs: #56).
|
|
20
|
+
|
|
3
21
|
## 0.1.13
|
|
4
22
|
|
|
5
23
|
- Architecture: выделение `CommandDispatcher` (`lib/dispatcher.js`) для полной изоляции синтаксического разбора аргументов и классификации типов инструментов от движка принятия решений `SecurityEngine` (Refs: #133).
|
|
@@ -33,19 +51,14 @@
|
|
|
33
51
|
- Policy: оптимизация сканирования истории событий сессии в `readKnobs` — однопроходный поиск, ограничение глубины до 100 событий и кэширование политики сессии на `_gateCachedPolicy` (Refs: #148).
|
|
34
52
|
- Schema: поддержка `.volatile()` на полях конфигурации, явные поля `workspaceDir` и `engine`, полифилл для DSH UI формы настроек и функция разворачивания реактивных боксов `plainConfig` (Refs: #142, #150, #151).
|
|
35
53
|
- Engine: методы `addGrant`, `getGrants` и `resume` на `SecurityEngine` и экспорт `ctx.approvalGate` для управления грантами и сбросом предохранителя (Refs: #152).
|
|
36
|
-
- Tokenizer: исправление передачи маркера heredoc (`marker: heredoc.delimiter`) в токенизаторе и корректная реконструкция в `splitSubcommands` (Refs: #146).
|
|
37
|
-
- Tests: матрица проверок на обходные векторы через операторы конвейеров и allow-префиксы (Refs: #164).
|
|
38
54
|
|
|
39
55
|
## 0.1.10
|
|
40
56
|
|
|
41
|
-
-
|
|
42
|
-
-
|
|
43
|
-
- Observability: форматирование `sessionTag` с получением строкового идентификатора сессии `session.id` / `session.name` вместо `[object Object]` (Refs: #140).
|
|
57
|
+
- Performance: lazy compilation of regular expressions in `inspectArgv` (Refs: #140).
|
|
58
|
+
- Test: fix path traversal tests on Windows by dynamically reading root drive (Refs: #141).
|
|
44
59
|
|
|
45
60
|
## 0.1.9
|
|
46
61
|
|
|
47
|
-
- Inspect: декомпозиция `lib/inspect.js` с выносом `checkEnvExfiltration` в `lib/inspect-env.js`, строгое соблюдение порога 600 строк (Refs: #35).
|
|
48
|
-
- Security: автоматическое маскирование и redaction чувствительных данных, токенов и паролей в сетевых URL и аудиторских логах `redactUrl` и `redactCredentials` (Refs: #42).
|
|
49
62
|
- Paths: интеграция `resolveSafePath` как основного примитива безопасности файловых путей и защита от symlink и path traversal обходов (Refs: #94, #137).
|
|
50
63
|
- Engine: интеграция 5-уровневого каскада принятия решений `SecurityEngine` (P0 Hard-Deny -> P1 Grants -> P2 Static -> P3 LLM -> P4 Ask) в хук `tools/pre-execute` и рантайм-гард `tools.guard` (Refs: #122).
|
|
51
64
|
- 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
|
+
}
|
package/lib/engine.js
CHANGED
|
@@ -1,25 +1,38 @@
|
|
|
1
1
|
// lib/engine.js
|
|
2
2
|
// 5-tier Security Decision Engine for dsh-approval-gate.
|
|
3
3
|
// Waterfall:
|
|
4
|
-
// P0: Hard Deny (Bands Band 0 + inspectExecution hard denials + protected files)
|
|
5
|
-
// P1: Session Grants (scoped temporary allowances)
|
|
4
|
+
// P0: Hard Deny (Bands Band 0 + Declarative Rules Deny + inspectExecution hard denials + protected files + retry exhaustion)
|
|
5
|
+
// P1: Session Grants & Declarative Allow (scoped temporary allowances and whitelists)
|
|
6
6
|
// P2: Static Rules / Safe Allow (Bands Band 1 + safe read-only commands)
|
|
7
7
|
// P3: Optional Semantic Classifier
|
|
8
|
-
// P4: Human Ask Fallback (uncertain syntax / ambiguous commands / tripped breaker)
|
|
8
|
+
// P4: Human Ask Fallback (uncertain syntax / ambiguous commands / tripped breaker / declarative ask)
|
|
9
9
|
|
|
10
10
|
import { BandsEngine } from "./bands.js";
|
|
11
11
|
import { CircuitBreaker } from "./breaker.js";
|
|
12
12
|
import { SessionGrantStore } from "./grants.js";
|
|
13
13
|
import { inspectExecution } from "./inspect.js";
|
|
14
|
+
import { VerdictCache } from "./cache.js";
|
|
15
|
+
import { RetryExhaustionGuard } from "./retry-exhaustion.js";
|
|
16
|
+
import { QualityLedger } from "./ledger.js";
|
|
17
|
+
import { AuditLogger } from "./audit-log.js";
|
|
18
|
+
import { DeclarativeRulesEngine } from "./rules-loader.js";
|
|
14
19
|
|
|
15
20
|
export class SecurityEngine {
|
|
16
21
|
/**
|
|
17
22
|
* @param {{
|
|
18
23
|
* bands?: BandsEngine,
|
|
19
24
|
* breaker?: CircuitBreaker,
|
|
20
|
-
*
|
|
25
|
+
* cache?: VerdictCache,
|
|
26
|
+
* retryGuard?: RetryExhaustionGuard,
|
|
27
|
+
* ledger?: QualityLedger,
|
|
28
|
+
* auditLogger?: AuditLogger,
|
|
29
|
+
* declarativeRules?: DeclarativeRulesEngine,
|
|
30
|
+
* inspectConfig?: { toolName?: string, fileWriteTools?: string[], workspaceDir?: string },
|
|
21
31
|
* classifier?: ((execution: any, context?: any) => { verdict: "allow"|"deny", reason?: string }),
|
|
22
|
-
* grantRegistry?: { decide: (tool: string, args: any, context?: any) => "allow"|"no-match" }
|
|
32
|
+
* grantRegistry?: { decide: (tool: string, args: any, context?: any) => "allow"|"no-match" },
|
|
33
|
+
* dryRun?: boolean,
|
|
34
|
+
* shadowMode?: boolean,
|
|
35
|
+
* defaultVerdict?: "allow" | "ask"
|
|
23
36
|
* }} [options]
|
|
24
37
|
*/
|
|
25
38
|
constructor(options = {}) {
|
|
@@ -29,21 +42,118 @@ export class SecurityEngine {
|
|
|
29
42
|
this.classifier = options.classifier || null;
|
|
30
43
|
this.grantRegistry = options.grantRegistry || new SessionGrantStore(options);
|
|
31
44
|
this.defaultVerdict = options.defaultVerdict || "allow";
|
|
45
|
+
|
|
46
|
+
this.dryRun = Boolean(options.dryRun || options.shadowMode);
|
|
47
|
+
this.cache = options.cache !== undefined ? options.cache : new VerdictCache();
|
|
48
|
+
this.retryGuard = options.retryGuard !== undefined ? options.retryGuard : new RetryExhaustionGuard();
|
|
49
|
+
this.ledger = options.ledger !== undefined ? options.ledger : new QualityLedger();
|
|
50
|
+
this.auditLogger = options.auditLogger !== undefined ? options.auditLogger : new AuditLogger();
|
|
51
|
+
this.declarativeRules = options.declarativeRules || new DeclarativeRulesEngine({
|
|
52
|
+
workspaceDir: this.inspectConfig?.workspaceDir,
|
|
53
|
+
});
|
|
32
54
|
}
|
|
33
55
|
|
|
34
56
|
/**
|
|
35
57
|
* Evaluates an execution request through the P0-P4 waterfall.
|
|
36
58
|
*
|
|
37
59
|
* @param {{ name: string, arguments?: any }} execution - Tool call
|
|
38
|
-
* @param {{ sessionId?: string, knobs?: any }} [context] - Context options
|
|
39
|
-
* @returns {{ verdict: "allow" | "deny" | "ask", stage: "P0" | "P1" | "P2" | "P3" | "P4", reason: string, detail?: string, hit?: any }}
|
|
60
|
+
* @param {{ sessionId?: string, knobs?: any, cwd?: string }} [context] - Context options
|
|
61
|
+
* @returns {{ verdict: "allow" | "deny" | "ask", stage: "P0" | "P1" | "P2" | "P3" | "P4", reason: string, detail?: string, hit?: any, dryRun?: boolean, cached?: boolean }}
|
|
40
62
|
*/
|
|
41
63
|
decide(execution, context = {}) {
|
|
42
64
|
try {
|
|
43
65
|
const sessionId = context.sessionId || "default";
|
|
44
|
-
|
|
45
|
-
|
|
66
|
+
const toolName = execution && execution.name;
|
|
67
|
+
const command = execution && execution.arguments && execution.arguments.command;
|
|
68
|
+
|
|
69
|
+
// Check Retry Exhaustion guard (#96)
|
|
70
|
+
if (this.retryGuard && this.retryGuard.isExhausted(sessionId)) {
|
|
71
|
+
const exhHit = {
|
|
72
|
+
deny: true,
|
|
73
|
+
reason: "retry-exhaustion",
|
|
74
|
+
detail: "Retry exhaustion: consecutive blocked attempts exceeded threshold",
|
|
75
|
+
snippet: command || toolName,
|
|
76
|
+
};
|
|
77
|
+
const exhDecision = { verdict: "deny", stage: "P0", reason: "retry-exhaustion", detail: exhHit.detail, hit: exhHit };
|
|
78
|
+
if (this.ledger) this.ledger.record(sessionId, "exhaustion", { reason: "retry-exhaustion", tool: toolName });
|
|
79
|
+
if (this.auditLogger) this.auditLogger.log({ sessionId, execution, decision: exhDecision, dryRun: this.dryRun });
|
|
80
|
+
|
|
81
|
+
if (this.dryRun) {
|
|
82
|
+
return { verdict: "allow", stage: "P0", dryRun: true, originalVerdict: "deny", reason: "retry-exhaustion", detail: exhHit.detail, hit: exhHit };
|
|
83
|
+
}
|
|
84
|
+
return exhDecision;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Check Verdict Cache (#63, #119)
|
|
88
|
+
const cacheKey = this.cache ? this.cache.makeKey(execution, context) : null;
|
|
89
|
+
if (this.cache && cacheKey) {
|
|
90
|
+
const cached = this.cache.get(cacheKey);
|
|
91
|
+
if (cached) {
|
|
92
|
+
if (this.dryRun && (cached.verdict === "deny" || cached.verdict === "ask")) {
|
|
93
|
+
return {
|
|
94
|
+
verdict: "allow",
|
|
95
|
+
stage: cached.stage,
|
|
96
|
+
dryRun: true,
|
|
97
|
+
originalVerdict: cached.verdict,
|
|
98
|
+
reason: cached.reason,
|
|
99
|
+
detail: cached.detail,
|
|
100
|
+
hit: cached.hit,
|
|
101
|
+
cached: true,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return cached;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const decision = this._evaluateWaterfall(execution, context, sessionId, toolName, command);
|
|
109
|
+
|
|
110
|
+
// Post-decision hooks: ledger, retryGuard, cache, audit
|
|
111
|
+
if (decision.verdict === "deny") {
|
|
112
|
+
if (this.retryGuard) this.retryGuard.recordVerdict(sessionId, execution, "deny");
|
|
113
|
+
if (this.ledger) this.ledger.record(sessionId, "deny", { reason: decision.reason, detail: decision.detail, tool: toolName });
|
|
114
|
+
if (this.cache && cacheKey) this.cache.set(cacheKey, decision, { sessionId });
|
|
115
|
+
} else if (decision.verdict === "allow") {
|
|
116
|
+
if (this.retryGuard) this.retryGuard.recordVerdict(sessionId, execution, "allow");
|
|
117
|
+
if (this.ledger) this.ledger.record(sessionId, "allow", { reason: decision.reason, detail: decision.detail, tool: toolName });
|
|
118
|
+
if (this.cache && cacheKey) this.cache.set(cacheKey, decision, { sessionId });
|
|
119
|
+
} else if (decision.verdict === "ask") {
|
|
120
|
+
if (this.ledger) this.ledger.record(sessionId, "ask", { reason: decision.reason, detail: decision.detail, tool: toolName });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (this.auditLogger) {
|
|
124
|
+
this.auditLogger.log({ sessionId, execution, decision, dryRun: this.dryRun });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Shadow Mode (#128)
|
|
128
|
+
if (this.dryRun && (decision.verdict === "deny" || decision.verdict === "ask")) {
|
|
129
|
+
return {
|
|
130
|
+
verdict: "allow",
|
|
131
|
+
stage: decision.stage,
|
|
132
|
+
dryRun: true,
|
|
133
|
+
originalVerdict: decision.verdict,
|
|
134
|
+
reason: decision.reason,
|
|
135
|
+
detail: decision.detail,
|
|
136
|
+
hit: decision.hit,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return decision;
|
|
141
|
+
} catch (err) {
|
|
142
|
+
return {
|
|
143
|
+
verdict: "ask",
|
|
144
|
+
stage: "P4",
|
|
145
|
+
reason: "inspect-error",
|
|
146
|
+
detail: String(err && err.message || err),
|
|
147
|
+
hit: { ask: true, reason: "inspect-error", detail: String(err && err.message || err) },
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
}
|
|
46
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Internal evaluation of P0-P4 waterfall.
|
|
154
|
+
* @private
|
|
155
|
+
*/
|
|
156
|
+
_evaluateWaterfall(execution, context, sessionId, toolName, command) {
|
|
47
157
|
// Fast-path Band 0 Hard Deny
|
|
48
158
|
const bandVerdict = this.bands.evaluate(toolName, command);
|
|
49
159
|
if (bandVerdict === "deny") {
|
|
@@ -52,6 +162,26 @@ export class SecurityEngine {
|
|
|
52
162
|
return { verdict: "deny", stage: "P0", reason: "band-hard-deny", detail: command, hit };
|
|
53
163
|
}
|
|
54
164
|
|
|
165
|
+
// Declarative YAML Rules evaluation (#38, #45, #44)
|
|
166
|
+
if (this.declarativeRules && command) {
|
|
167
|
+
const decl = this.declarativeRules.evaluate(toolName, command, context);
|
|
168
|
+
if (decl.match) {
|
|
169
|
+
if (decl.verdict === "deny") {
|
|
170
|
+
this.breaker.countDeny(sessionId);
|
|
171
|
+
const hit = { deny: true, reason: decl.reason || "declarative-deny", detail: command, snippet: command, ruleId: decl.ruleId };
|
|
172
|
+
return { verdict: "deny", stage: "P0", reason: decl.reason || "declarative-deny", detail: command, hit };
|
|
173
|
+
}
|
|
174
|
+
if (decl.verdict === "allow") {
|
|
175
|
+
this.breaker.resetConsecutive(sessionId);
|
|
176
|
+
return { verdict: "allow", stage: "P1", reason: decl.reason || "declarative-allow", hit: null };
|
|
177
|
+
}
|
|
178
|
+
if (decl.verdict === "ask") {
|
|
179
|
+
const hit = { ask: true, reason: decl.reason || "declarative-ask", detail: command, snippet: command, ruleId: decl.ruleId };
|
|
180
|
+
return { verdict: "ask", stage: "P4", reason: decl.reason || "declarative-ask", detail: command, hit };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
55
185
|
// Inspect execution for hard denials and syntax checks
|
|
56
186
|
const hit = inspectExecution(execution, this.inspectConfig);
|
|
57
187
|
if (hit.deny) {
|
|
@@ -70,6 +200,7 @@ export class SecurityEngine {
|
|
|
70
200
|
|
|
71
201
|
// If Circuit Breaker is tripped, suspend auto-mode and force P4 Human Ask
|
|
72
202
|
if (this.breaker.isTripped(sessionId)) {
|
|
203
|
+
if (this.ledger) this.ledger.record(sessionId, "breaker", { reason: "circuit-breaker-tripped", tool: toolName });
|
|
73
204
|
const breakerHit = {
|
|
74
205
|
ask: true,
|
|
75
206
|
reason: "circuit-breaker-tripped",
|
|
@@ -110,7 +241,8 @@ export class SecurityEngine {
|
|
|
110
241
|
return { verdict: "deny", stage: "P3", reason: cls.reason || "classifier-deny", detail: cls.reason, hit: clsHit };
|
|
111
242
|
}
|
|
112
243
|
} catch (err) {
|
|
113
|
-
// Classifier error fails closed to P4 Ask
|
|
244
|
+
// Classifier error fails closed to P4 Ask (#111: does not trip breaker)
|
|
245
|
+
if (this.ledger) this.ledger.record(sessionId, "error", { reason: "classifier-error", detail: String(err && err.message || err), tool: toolName });
|
|
114
246
|
return { verdict: "ask", stage: "P4", reason: "classifier-error", detail: String(err && err.message || err), hit: null };
|
|
115
247
|
}
|
|
116
248
|
}
|
|
@@ -121,15 +253,6 @@ export class SecurityEngine {
|
|
|
121
253
|
return { verdict: "ask", stage: "P4", reason: "unmatched-ask", hit: askHit };
|
|
122
254
|
}
|
|
123
255
|
return { verdict: "allow", stage: "P2", reason: "unmatched-allow", hit };
|
|
124
|
-
} catch (err) {
|
|
125
|
-
return {
|
|
126
|
-
verdict: "ask",
|
|
127
|
-
stage: "P4",
|
|
128
|
-
reason: "inspect-error",
|
|
129
|
-
detail: String(err && err.message || err),
|
|
130
|
-
hit: { ask: true, reason: "inspect-error", detail: String(err && err.message || err) },
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
256
|
}
|
|
134
257
|
|
|
135
258
|
addGrant(options) {
|
|
@@ -146,7 +269,21 @@ export class SecurityEngine {
|
|
|
146
269
|
return [];
|
|
147
270
|
}
|
|
148
271
|
|
|
272
|
+
getLedgerSummary(sessionId) {
|
|
273
|
+
return this.ledger ? this.ledger.getSummary(sessionId) : null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
isRetryExhausted(sessionId) {
|
|
277
|
+
return this.retryGuard ? this.retryGuard.isExhausted(sessionId) : false;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
clearCache() {
|
|
281
|
+
if (this.cache) this.cache.clear();
|
|
282
|
+
}
|
|
283
|
+
|
|
149
284
|
resume(sessionId, reason) {
|
|
285
|
+
if (this.retryGuard) this.retryGuard.reset(sessionId);
|
|
286
|
+
if (this.cache) this.cache.clearSession(sessionId);
|
|
150
287
|
return this.breaker.resume(sessionId, reason);
|
|
151
288
|
}
|
|
152
289
|
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// lib/fingerprint.js
|
|
2
|
+
// Canonical operation fingerprint generator for dsh-approval-gate (#80).
|
|
3
|
+
// Format: tool:normalize(cmd):target_path
|
|
4
|
+
// Strict: flag or argument variations produce distinct fingerprints.
|
|
5
|
+
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { extractFilePath } from "./dispatcher.js";
|
|
8
|
+
import { canonicalizeFsPath, normalizeSeparators } from "./paths.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Normalizes command whitespace while preserving quotes.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} cmd
|
|
14
|
+
* @returns {string}
|
|
15
|
+
*/
|
|
16
|
+
export function normalizeCommandString(cmd) {
|
|
17
|
+
if (typeof cmd !== "string") return "";
|
|
18
|
+
const trimmed = cmd.trim();
|
|
19
|
+
if (!trimmed) return "";
|
|
20
|
+
|
|
21
|
+
let result = "";
|
|
22
|
+
let inSingle = false;
|
|
23
|
+
let inDouble = false;
|
|
24
|
+
let lastWasSpace = false;
|
|
25
|
+
|
|
26
|
+
for (let i = 0; i < trimmed.length; i += 1) {
|
|
27
|
+
const ch = trimmed[i];
|
|
28
|
+
if (ch === "'" && !inDouble) {
|
|
29
|
+
inSingle = !inSingle;
|
|
30
|
+
result += ch;
|
|
31
|
+
lastWasSpace = false;
|
|
32
|
+
} else if (ch === '"' && !inSingle) {
|
|
33
|
+
inDouble = !inDouble;
|
|
34
|
+
result += ch;
|
|
35
|
+
lastWasSpace = false;
|
|
36
|
+
} else if (/\s/.test(ch) && !inSingle && !inDouble) {
|
|
37
|
+
if (!lastWasSpace) {
|
|
38
|
+
result += " ";
|
|
39
|
+
lastWasSpace = true;
|
|
40
|
+
}
|
|
41
|
+
} else {
|
|
42
|
+
result += ch;
|
|
43
|
+
lastWasSpace = false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return result.trim();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Generates a canonical fingerprint for an operation.
|
|
52
|
+
* Format: tool:normalized_cmd:target_path
|
|
53
|
+
*
|
|
54
|
+
* @param {{ name?: string, tool?: string, arguments?: any } | string} execution
|
|
55
|
+
* @param {{ cwd?: string }} [options]
|
|
56
|
+
* @returns {string}
|
|
57
|
+
*/
|
|
58
|
+
export function computeFingerprint(execution, options = {}) {
|
|
59
|
+
let tool = "";
|
|
60
|
+
let args = {};
|
|
61
|
+
|
|
62
|
+
if (typeof execution === "string") {
|
|
63
|
+
tool = execution.trim().toLowerCase();
|
|
64
|
+
} else if (execution && typeof execution === "object") {
|
|
65
|
+
tool = String(execution.name || execution.tool || "").trim().toLowerCase();
|
|
66
|
+
args = execution.arguments || {};
|
|
67
|
+
if (typeof args === "string") {
|
|
68
|
+
try {
|
|
69
|
+
args = JSON.parse(args);
|
|
70
|
+
} catch {
|
|
71
|
+
args = { raw: args };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const rawCmd = args && typeof args.command === "string" ? args.command : "";
|
|
77
|
+
const normalizedCmd = normalizeCommandString(rawCmd);
|
|
78
|
+
|
|
79
|
+
let targetPath = (args && (args.cwd || extractFilePath(args))) || options.cwd || "";
|
|
80
|
+
if (typeof targetPath === "string" && targetPath.trim()) {
|
|
81
|
+
targetPath = canonicalizeFsPath(normalizeSeparators(targetPath.trim()));
|
|
82
|
+
} else {
|
|
83
|
+
targetPath = "";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return `${tool}:${normalizedCmd}:${targetPath}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Computes sha256 hash of the canonical operation fingerprint.
|
|
91
|
+
*
|
|
92
|
+
* @param {{ name?: string, tool?: string, arguments?: any } | string} execution
|
|
93
|
+
* @param {{ cwd?: string, algorithm?: string }} [options]
|
|
94
|
+
* @returns {string}
|
|
95
|
+
*/
|
|
96
|
+
export function computeFingerprintHash(execution, options = {}) {
|
|
97
|
+
const fp = computeFingerprint(execution, options);
|
|
98
|
+
const algo = options.algorithm || "sha256";
|
|
99
|
+
return createHash(algo).update(fp, "utf8").digest("hex");
|
|
100
|
+
}
|