@goodandready/dsh-approval-gate 0.1.13 → 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 +12 -7
- package/lib/audit-log.js +76 -0
- package/lib/breaker.js +26 -2
- package/lib/cache.js +146 -0
- package/lib/engine.js +129 -17
- package/lib/fingerprint.js +100 -0
- package/lib/index.js +50 -1
- package/lib/ledger.js +164 -0
- package/lib/messages.js +8 -2
- package/lib/retry-exhaustion.js +171 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
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
|
+
|
|
3
13
|
## 0.1.13
|
|
4
14
|
|
|
5
15
|
- Architecture: выделение `CommandDispatcher` (`lib/dispatcher.js`) для полной изоляции синтаксического разбора аргументов и классификации типов инструментов от движка принятия решений `SecurityEngine` (Refs: #133).
|
|
@@ -33,19 +43,14 @@
|
|
|
33
43
|
- Policy: оптимизация сканирования истории событий сессии в `readKnobs` — однопроходный поиск, ограничение глубины до 100 событий и кэширование политики сессии на `_gateCachedPolicy` (Refs: #148).
|
|
34
44
|
- Schema: поддержка `.volatile()` на полях конфигурации, явные поля `workspaceDir` и `engine`, полифилл для DSH UI формы настроек и функция разворачивания реактивных боксов `plainConfig` (Refs: #142, #150, #151).
|
|
35
45
|
- Engine: методы `addGrant`, `getGrants` и `resume` на `SecurityEngine` и экспорт `ctx.approvalGate` для управления грантами и сбросом предохранителя (Refs: #152).
|
|
36
|
-
- Tokenizer: исправление передачи маркера heredoc (`marker: heredoc.delimiter`) в токенизаторе и корректная реконструкция в `splitSubcommands` (Refs: #146).
|
|
37
|
-
- Tests: матрица проверок на обходные векторы через операторы конвейеров и allow-префиксы (Refs: #164).
|
|
38
46
|
|
|
39
47
|
## 0.1.10
|
|
40
48
|
|
|
41
|
-
-
|
|
42
|
-
-
|
|
43
|
-
- 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).
|
|
44
51
|
|
|
45
52
|
## 0.1.9
|
|
46
53
|
|
|
47
|
-
- Inspect: декомпозиция `lib/inspect.js` с выносом `checkEnvExfiltration` в `lib/inspect-env.js`, строгое соблюдение порога 600 строк (Refs: #35).
|
|
48
|
-
- Security: автоматическое маскирование и redaction чувствительных данных, токенов и паролей в сетевых URL и аудиторских логах `redactUrl` и `redactCredentials` (Refs: #42).
|
|
49
54
|
- Paths: интеграция `resolveSafePath` как основного примитива безопасности файловых путей и защита от symlink и path traversal обходов (Refs: #94, #137).
|
|
50
55
|
- Engine: интеграция 5-уровневого каскада принятия решений `SecurityEngine` (P0 Hard-Deny -> P1 Grants -> P2 Static -> P3 LLM -> P4 Ask) в хук `tools/pre-execute` и рантайм-гард `tools.guard` (Refs: #122).
|
|
51
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
|
+
}
|
package/lib/engine.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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)
|
|
4
|
+
// P0: Hard Deny (Bands Band 0 + inspectExecution hard denials + protected files + retry exhaustion)
|
|
5
5
|
// P1: Session Grants (scoped temporary allowances)
|
|
6
6
|
// P2: Static Rules / Safe Allow (Bands Band 1 + safe read-only commands)
|
|
7
7
|
// P3: Optional Semantic Classifier
|
|
@@ -11,15 +11,26 @@ 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";
|
|
14
18
|
|
|
15
19
|
export class SecurityEngine {
|
|
16
20
|
/**
|
|
17
21
|
* @param {{
|
|
18
22
|
* bands?: BandsEngine,
|
|
19
23
|
* breaker?: CircuitBreaker,
|
|
20
|
-
*
|
|
24
|
+
* cache?: VerdictCache,
|
|
25
|
+
* retryGuard?: RetryExhaustionGuard,
|
|
26
|
+
* ledger?: QualityLedger,
|
|
27
|
+
* auditLogger?: AuditLogger,
|
|
28
|
+
* inspectConfig?: { toolName?: string, fileWriteTools?: string[], workspaceDir?: string },
|
|
21
29
|
* classifier?: ((execution: any, context?: any) => { verdict: "allow"|"deny", reason?: string }),
|
|
22
|
-
* grantRegistry?: { decide: (tool: string, args: any, context?: any) => "allow"|"no-match" }
|
|
30
|
+
* grantRegistry?: { decide: (tool: string, args: any, context?: any) => "allow"|"no-match" },
|
|
31
|
+
* dryRun?: boolean,
|
|
32
|
+
* shadowMode?: boolean,
|
|
33
|
+
* defaultVerdict?: "allow" | "ask"
|
|
23
34
|
* }} [options]
|
|
24
35
|
*/
|
|
25
36
|
constructor(options = {}) {
|
|
@@ -29,21 +40,115 @@ export class SecurityEngine {
|
|
|
29
40
|
this.classifier = options.classifier || null;
|
|
30
41
|
this.grantRegistry = options.grantRegistry || new SessionGrantStore(options);
|
|
31
42
|
this.defaultVerdict = options.defaultVerdict || "allow";
|
|
43
|
+
|
|
44
|
+
this.dryRun = Boolean(options.dryRun || options.shadowMode);
|
|
45
|
+
this.cache = options.cache !== undefined ? options.cache : new VerdictCache();
|
|
46
|
+
this.retryGuard = options.retryGuard !== undefined ? options.retryGuard : new RetryExhaustionGuard();
|
|
47
|
+
this.ledger = options.ledger !== undefined ? options.ledger : new QualityLedger();
|
|
48
|
+
this.auditLogger = options.auditLogger !== undefined ? options.auditLogger : new AuditLogger();
|
|
32
49
|
}
|
|
33
50
|
|
|
34
51
|
/**
|
|
35
52
|
* Evaluates an execution request through the P0-P4 waterfall.
|
|
36
53
|
*
|
|
37
54
|
* @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 }}
|
|
55
|
+
* @param {{ sessionId?: string, knobs?: any, cwd?: string }} [context] - Context options
|
|
56
|
+
* @returns {{ verdict: "allow" | "deny" | "ask", stage: "P0" | "P1" | "P2" | "P3" | "P4", reason: string, detail?: string, hit?: any, dryRun?: boolean, cached?: boolean }}
|
|
40
57
|
*/
|
|
41
58
|
decide(execution, context = {}) {
|
|
42
59
|
try {
|
|
43
60
|
const sessionId = context.sessionId || "default";
|
|
44
|
-
|
|
45
|
-
|
|
61
|
+
const toolName = execution && execution.name;
|
|
62
|
+
const command = execution && execution.arguments && execution.arguments.command;
|
|
63
|
+
|
|
64
|
+
// Check Retry Exhaustion guard (#96)
|
|
65
|
+
if (this.retryGuard && this.retryGuard.isExhausted(sessionId)) {
|
|
66
|
+
const exhHit = {
|
|
67
|
+
deny: true,
|
|
68
|
+
reason: "retry-exhaustion",
|
|
69
|
+
detail: "Retry exhaustion: consecutive blocked attempts exceeded threshold",
|
|
70
|
+
snippet: command || toolName,
|
|
71
|
+
};
|
|
72
|
+
const exhDecision = { verdict: "deny", stage: "P0", reason: "retry-exhaustion", detail: exhHit.detail, hit: exhHit };
|
|
73
|
+
if (this.ledger) this.ledger.record(sessionId, "exhaustion", { reason: "retry-exhaustion", tool: toolName });
|
|
74
|
+
if (this.auditLogger) this.auditLogger.log({ sessionId, execution, decision: exhDecision, dryRun: this.dryRun });
|
|
75
|
+
|
|
76
|
+
if (this.dryRun) {
|
|
77
|
+
return { verdict: "allow", stage: "P0", dryRun: true, originalVerdict: "deny", reason: "retry-exhaustion", detail: exhHit.detail, hit: exhHit };
|
|
78
|
+
}
|
|
79
|
+
return exhDecision;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Check Verdict Cache (#63, #119)
|
|
83
|
+
const cacheKey = this.cache ? this.cache.makeKey(execution, context) : null;
|
|
84
|
+
if (this.cache && cacheKey) {
|
|
85
|
+
const cached = this.cache.get(cacheKey);
|
|
86
|
+
if (cached) {
|
|
87
|
+
if (this.dryRun && (cached.verdict === "deny" || cached.verdict === "ask")) {
|
|
88
|
+
return {
|
|
89
|
+
verdict: "allow",
|
|
90
|
+
stage: cached.stage,
|
|
91
|
+
dryRun: true,
|
|
92
|
+
originalVerdict: cached.verdict,
|
|
93
|
+
reason: cached.reason,
|
|
94
|
+
detail: cached.detail,
|
|
95
|
+
hit: cached.hit,
|
|
96
|
+
cached: true,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
return cached;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const decision = this._evaluateWaterfall(execution, context, sessionId, toolName, command);
|
|
104
|
+
|
|
105
|
+
// Post-decision hooks: ledger, retryGuard, cache, audit
|
|
106
|
+
if (decision.verdict === "deny") {
|
|
107
|
+
if (this.retryGuard) this.retryGuard.recordVerdict(sessionId, execution, "deny");
|
|
108
|
+
if (this.ledger) this.ledger.record(sessionId, "deny", { reason: decision.reason, detail: decision.detail, tool: toolName });
|
|
109
|
+
if (this.cache && cacheKey) this.cache.set(cacheKey, decision, { sessionId });
|
|
110
|
+
} else if (decision.verdict === "allow") {
|
|
111
|
+
if (this.retryGuard) this.retryGuard.recordVerdict(sessionId, execution, "allow");
|
|
112
|
+
if (this.ledger) this.ledger.record(sessionId, "allow", { reason: decision.reason, detail: decision.detail, tool: toolName });
|
|
113
|
+
if (this.cache && cacheKey) this.cache.set(cacheKey, decision, { sessionId });
|
|
114
|
+
} else if (decision.verdict === "ask") {
|
|
115
|
+
if (this.ledger) this.ledger.record(sessionId, "ask", { reason: decision.reason, detail: decision.detail, tool: toolName });
|
|
116
|
+
}
|
|
46
117
|
|
|
118
|
+
if (this.auditLogger) {
|
|
119
|
+
this.auditLogger.log({ sessionId, execution, decision, dryRun: this.dryRun });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Shadow Mode (#128)
|
|
123
|
+
if (this.dryRun && (decision.verdict === "deny" || decision.verdict === "ask")) {
|
|
124
|
+
return {
|
|
125
|
+
verdict: "allow",
|
|
126
|
+
stage: decision.stage,
|
|
127
|
+
dryRun: true,
|
|
128
|
+
originalVerdict: decision.verdict,
|
|
129
|
+
reason: decision.reason,
|
|
130
|
+
detail: decision.detail,
|
|
131
|
+
hit: decision.hit,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return decision;
|
|
136
|
+
} catch (err) {
|
|
137
|
+
return {
|
|
138
|
+
verdict: "ask",
|
|
139
|
+
stage: "P4",
|
|
140
|
+
reason: "inspect-error",
|
|
141
|
+
detail: String(err && err.message || err),
|
|
142
|
+
hit: { ask: true, reason: "inspect-error", detail: String(err && err.message || err) },
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Internal evaluation of P0-P4 waterfall.
|
|
149
|
+
* @private
|
|
150
|
+
*/
|
|
151
|
+
_evaluateWaterfall(execution, context, sessionId, toolName, command) {
|
|
47
152
|
// Fast-path Band 0 Hard Deny
|
|
48
153
|
const bandVerdict = this.bands.evaluate(toolName, command);
|
|
49
154
|
if (bandVerdict === "deny") {
|
|
@@ -70,6 +175,7 @@ export class SecurityEngine {
|
|
|
70
175
|
|
|
71
176
|
// If Circuit Breaker is tripped, suspend auto-mode and force P4 Human Ask
|
|
72
177
|
if (this.breaker.isTripped(sessionId)) {
|
|
178
|
+
if (this.ledger) this.ledger.record(sessionId, "breaker", { reason: "circuit-breaker-tripped", tool: toolName });
|
|
73
179
|
const breakerHit = {
|
|
74
180
|
ask: true,
|
|
75
181
|
reason: "circuit-breaker-tripped",
|
|
@@ -110,7 +216,8 @@ export class SecurityEngine {
|
|
|
110
216
|
return { verdict: "deny", stage: "P3", reason: cls.reason || "classifier-deny", detail: cls.reason, hit: clsHit };
|
|
111
217
|
}
|
|
112
218
|
} catch (err) {
|
|
113
|
-
// Classifier error fails closed to P4 Ask
|
|
219
|
+
// Classifier error fails closed to P4 Ask (#111: does not trip breaker)
|
|
220
|
+
if (this.ledger) this.ledger.record(sessionId, "error", { reason: "classifier-error", detail: String(err && err.message || err), tool: toolName });
|
|
114
221
|
return { verdict: "ask", stage: "P4", reason: "classifier-error", detail: String(err && err.message || err), hit: null };
|
|
115
222
|
}
|
|
116
223
|
}
|
|
@@ -121,15 +228,6 @@ export class SecurityEngine {
|
|
|
121
228
|
return { verdict: "ask", stage: "P4", reason: "unmatched-ask", hit: askHit };
|
|
122
229
|
}
|
|
123
230
|
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
231
|
}
|
|
134
232
|
|
|
135
233
|
addGrant(options) {
|
|
@@ -146,7 +244,21 @@ export class SecurityEngine {
|
|
|
146
244
|
return [];
|
|
147
245
|
}
|
|
148
246
|
|
|
247
|
+
getLedgerSummary(sessionId) {
|
|
248
|
+
return this.ledger ? this.ledger.getSummary(sessionId) : null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
isRetryExhausted(sessionId) {
|
|
252
|
+
return this.retryGuard ? this.retryGuard.isExhausted(sessionId) : false;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
clearCache() {
|
|
256
|
+
if (this.cache) this.cache.clear();
|
|
257
|
+
}
|
|
258
|
+
|
|
149
259
|
resume(sessionId, reason) {
|
|
260
|
+
if (this.retryGuard) this.retryGuard.reset(sessionId);
|
|
261
|
+
if (this.cache) this.cache.clearSession(sessionId);
|
|
150
262
|
return this.breaker.resume(sessionId, reason);
|
|
151
263
|
}
|
|
152
264
|
}
|
|
@@ -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
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { SecurityEngine } from "./engine.js";
|
|
2
2
|
import { BandsEngine } from "./bands.js";
|
|
3
|
+
import { AuditLogger } from "./audit-log.js";
|
|
3
4
|
import Schema from "@deepseek-ai/schemastery";
|
|
4
5
|
|
|
5
6
|
if (typeof Schema.prototype?.volatile !== "function") {
|
|
@@ -205,6 +206,12 @@ export const Config = Schema.object({
|
|
|
205
206
|
workspaceDir: Schema.string().default("").volatile(),
|
|
206
207
|
/** Default verdict for unclassified commands: 'allow' (default) or 'ask' (fail-closed) (#163). */
|
|
207
208
|
defaultVerdict: Schema.union(["allow", "ask"]).default("allow").volatile(),
|
|
209
|
+
/** Dry-run / shadow mode for safe testing of rules on production (#128). */
|
|
210
|
+
dryRun: Schema.boolean().default(false).volatile(),
|
|
211
|
+
/** Shadow mode alias for dryRun (#128). */
|
|
212
|
+
shadowMode: Schema.boolean().default(false).volatile(),
|
|
213
|
+
/** Path to append-only audit log file (#56). */
|
|
214
|
+
auditLogPath: Schema.string().default("").volatile(),
|
|
208
215
|
/** Security engine instance (internal/testing). */
|
|
209
216
|
engine: Schema.any().hidden(),
|
|
210
217
|
});
|
|
@@ -248,12 +255,18 @@ export function apply(ctx, config) {
|
|
|
248
255
|
inspectConfig: { toolName: toolName, fileWriteTools: fileWriteTools, workspaceDir: cfg.workspaceDir || "" },
|
|
249
256
|
bands: new BandsEngine({ toolRegistry: ctx && ctx.tools }),
|
|
250
257
|
defaultVerdict: cfg.defaultVerdict || "allow",
|
|
258
|
+
dryRun: Boolean(cfg.dryRun || cfg.shadowMode),
|
|
259
|
+
auditLogger: new AuditLogger({ logPath: cfg.auditLogPath || undefined }),
|
|
251
260
|
});
|
|
252
261
|
|
|
253
262
|
const gateService = {
|
|
254
263
|
engine,
|
|
255
264
|
addGrant: (options) => engine.addGrant(options),
|
|
256
265
|
resume: (sessionId, reason) => engine.resume(sessionId, reason),
|
|
266
|
+
cache: engine.cache,
|
|
267
|
+
retryGuard: engine.retryGuard,
|
|
268
|
+
ledger: engine.ledger,
|
|
269
|
+
getLedgerSummary: (sessionId) => engine.getLedgerSummary(sessionId),
|
|
257
270
|
};
|
|
258
271
|
if (ctx && typeof ctx === "object") {
|
|
259
272
|
if (typeof ctx.provide === "function") {
|
|
@@ -282,6 +295,13 @@ export function apply(ctx, config) {
|
|
|
282
295
|
return { kind: "ask", reason: askMessage(hit, who, translate, ctx && ctx.logger) };
|
|
283
296
|
}
|
|
284
297
|
|
|
298
|
+
if (decision.dryRun) {
|
|
299
|
+
if (ctx && ctx.logger && typeof ctx.logger.info === "function") {
|
|
300
|
+
ctx.logger.info(`[dsh-approval-gate:DRY-RUN] would block rule=${decision.reason} (${decision.originalVerdict})${who}`);
|
|
301
|
+
}
|
|
302
|
+
return next();
|
|
303
|
+
}
|
|
304
|
+
|
|
285
305
|
if (decision.verdict === "ask") {
|
|
286
306
|
if (decision.reason === "circuit-breaker-tripped") {
|
|
287
307
|
const hit = decision.hit || {
|
|
@@ -325,6 +345,13 @@ export function apply(ctx, config) {
|
|
|
325
345
|
return denyMessage(hit, who, translate, ctx && ctx.logger);
|
|
326
346
|
}
|
|
327
347
|
|
|
348
|
+
if (decision.dryRun) {
|
|
349
|
+
if (ctx && ctx.logger && typeof ctx.logger.info === "function") {
|
|
350
|
+
ctx.logger.info(`[dsh-approval-gate:DRY-RUN] would block rule=${decision.reason} (${decision.originalVerdict})${who}`);
|
|
351
|
+
}
|
|
352
|
+
return undefined;
|
|
353
|
+
}
|
|
354
|
+
|
|
328
355
|
if (decision.verdict === "deny" || (decision.verdict === "ask" && !hasPreExecute)) {
|
|
329
356
|
const hit = decision.hit || {
|
|
330
357
|
deny: true,
|
|
@@ -400,4 +427,26 @@ export {
|
|
|
400
427
|
isLoopbackHost,
|
|
401
428
|
isLoopbackUrl,
|
|
402
429
|
isLoopbackCommand,
|
|
403
|
-
} from "./loopback.js";
|
|
430
|
+
} from "./loopback.js";
|
|
431
|
+
export {
|
|
432
|
+
computeFingerprint,
|
|
433
|
+
computeFingerprintHash,
|
|
434
|
+
normalizeCommandString,
|
|
435
|
+
} from "./fingerprint.js";
|
|
436
|
+
export {
|
|
437
|
+
VerdictCache,
|
|
438
|
+
DEFAULT_CACHE_MAX_SIZE,
|
|
439
|
+
DEFAULT_CACHE_TTL_MS,
|
|
440
|
+
} from "./cache.js";
|
|
441
|
+
export {
|
|
442
|
+
RetryExhaustionGuard,
|
|
443
|
+
DEFAULT_MAX_RETRY_ATTEMPTS,
|
|
444
|
+
} from "./retry-exhaustion.js";
|
|
445
|
+
export {
|
|
446
|
+
QualityLedger,
|
|
447
|
+
MAX_LEDGER_EVENTS_PER_SESSION,
|
|
448
|
+
} from "./ledger.js";
|
|
449
|
+
export {
|
|
450
|
+
AuditLogger,
|
|
451
|
+
DEFAULT_AUDIT_LOG_RELATIVE,
|
|
452
|
+
} from "./audit-log.js";
|
package/lib/ledger.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// lib/ledger.js
|
|
2
|
+
// Quality and Anomaly Ledger for dsh-approval-gate (#88).
|
|
3
|
+
// Tracks session health, consecutive denials, and execution anomalies.
|
|
4
|
+
|
|
5
|
+
export const MAX_LEDGER_EVENTS_PER_SESSION = 50;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {Object} LedgerEvent
|
|
9
|
+
* @property {number} timestamp
|
|
10
|
+
* @property {string} type - "allow" | "deny" | "ask" | "exhaustion" | "breaker" | "error"
|
|
11
|
+
* @property {string} reason
|
|
12
|
+
* @property {string} [detail]
|
|
13
|
+
* @property {string} [tool]
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {Object} SessionLedger
|
|
18
|
+
* @property {string} sessionId
|
|
19
|
+
* @property {number} totalDecisions
|
|
20
|
+
* @property {number} allowedCount
|
|
21
|
+
* @property {number} deniedCount
|
|
22
|
+
* @property {number} askCount
|
|
23
|
+
* @property {number} anomalyCount
|
|
24
|
+
* @property {number} consecutiveDenies
|
|
25
|
+
* @property {boolean} breakerTripped
|
|
26
|
+
* @property {boolean} retryExhausted
|
|
27
|
+
* @property {LedgerEvent[]} recentEvents
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export class QualityLedger {
|
|
31
|
+
constructor() {
|
|
32
|
+
/** @type {Map<string, SessionLedger>} */
|
|
33
|
+
this.sessions = new Map();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @private
|
|
38
|
+
*/
|
|
39
|
+
_getOrCreate(sessionId) {
|
|
40
|
+
const key = sessionId || "default";
|
|
41
|
+
let ledger = this.sessions.get(key);
|
|
42
|
+
if (!ledger) {
|
|
43
|
+
ledger = {
|
|
44
|
+
sessionId: key,
|
|
45
|
+
totalDecisions: 0,
|
|
46
|
+
allowedCount: 0,
|
|
47
|
+
deniedCount: 0,
|
|
48
|
+
askCount: 0,
|
|
49
|
+
anomalyCount: 0,
|
|
50
|
+
consecutiveDenies: 0,
|
|
51
|
+
breakerTripped: false,
|
|
52
|
+
retryExhausted: false,
|
|
53
|
+
recentEvents: [],
|
|
54
|
+
};
|
|
55
|
+
this.sessions.set(key, ledger);
|
|
56
|
+
}
|
|
57
|
+
return ledger;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Record a decision or system event in the ledger.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} sessionId
|
|
64
|
+
* @param {string} type - "allow" | "deny" | "ask" | "exhaustion" | "breaker" | "error"
|
|
65
|
+
* @param {{ reason?: string, detail?: string, tool?: string }} [details]
|
|
66
|
+
*/
|
|
67
|
+
record(sessionId, type, details = {}) {
|
|
68
|
+
const ledger = this._getOrCreate(sessionId);
|
|
69
|
+
const now = Date.now();
|
|
70
|
+
|
|
71
|
+
const event = {
|
|
72
|
+
timestamp: now,
|
|
73
|
+
type,
|
|
74
|
+
reason: details.reason || "",
|
|
75
|
+
detail: details.detail ? String(details.detail).slice(0, 200) : undefined,
|
|
76
|
+
tool: details.tool || "",
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
ledger.recentEvents.push(event);
|
|
80
|
+
if (ledger.recentEvents.length > MAX_LEDGER_EVENTS_PER_SESSION) {
|
|
81
|
+
ledger.recentEvents.shift();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (type === "allow") {
|
|
85
|
+
ledger.totalDecisions += 1;
|
|
86
|
+
ledger.allowedCount += 1;
|
|
87
|
+
ledger.consecutiveDenies = 0;
|
|
88
|
+
} else if (type === "deny") {
|
|
89
|
+
ledger.totalDecisions += 1;
|
|
90
|
+
ledger.deniedCount += 1;
|
|
91
|
+
ledger.consecutiveDenies += 1;
|
|
92
|
+
if (ledger.consecutiveDenies > 1) {
|
|
93
|
+
ledger.anomalyCount += 1;
|
|
94
|
+
}
|
|
95
|
+
} else if (type === "ask") {
|
|
96
|
+
ledger.totalDecisions += 1;
|
|
97
|
+
ledger.askCount += 1;
|
|
98
|
+
} else if (type === "exhaustion") {
|
|
99
|
+
ledger.retryExhausted = true;
|
|
100
|
+
ledger.anomalyCount += 1;
|
|
101
|
+
} else if (type === "breaker") {
|
|
102
|
+
ledger.breakerTripped = true;
|
|
103
|
+
ledger.anomalyCount += 1;
|
|
104
|
+
} else if (type === "error") {
|
|
105
|
+
ledger.anomalyCount += 1;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Returns a quality summary for the session.
|
|
111
|
+
*
|
|
112
|
+
* @param {string} sessionId
|
|
113
|
+
* @returns {{
|
|
114
|
+
* sessionId: string,
|
|
115
|
+
* totalDecisions: number,
|
|
116
|
+
* allowedCount: number,
|
|
117
|
+
* deniedCount: number,
|
|
118
|
+
* askCount: number,
|
|
119
|
+
* anomalyCount: number,
|
|
120
|
+
* consecutiveDenies: number,
|
|
121
|
+
* qualityScore: number,
|
|
122
|
+
* degradationWarning: boolean,
|
|
123
|
+
* recentEvents: LedgerEvent[]
|
|
124
|
+
* }}
|
|
125
|
+
*/
|
|
126
|
+
getSummary(sessionId) {
|
|
127
|
+
const ledger = this._getOrCreate(sessionId);
|
|
128
|
+
const total = ledger.totalDecisions;
|
|
129
|
+
|
|
130
|
+
// Quality score: 1.0 down towards 0.0 based on denial & anomaly ratio
|
|
131
|
+
let score = 1.0;
|
|
132
|
+
if (total > 0) {
|
|
133
|
+
const penalty = (ledger.deniedCount * 0.2 + ledger.anomalyCount * 0.3) / total;
|
|
134
|
+
score = Math.max(0, Math.min(1.0, 1.0 - penalty));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const degradationWarning = ledger.consecutiveDenies >= 2 ||
|
|
138
|
+
ledger.retryExhausted ||
|
|
139
|
+
ledger.breakerTripped ||
|
|
140
|
+
(total >= 3 && ledger.deniedCount / total >= 0.5);
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
sessionId: ledger.sessionId,
|
|
144
|
+
totalDecisions: ledger.totalDecisions,
|
|
145
|
+
allowedCount: ledger.allowedCount,
|
|
146
|
+
deniedCount: ledger.deniedCount,
|
|
147
|
+
askCount: ledger.askCount,
|
|
148
|
+
anomalyCount: ledger.anomalyCount,
|
|
149
|
+
consecutiveDenies: ledger.consecutiveDenies,
|
|
150
|
+
qualityScore: Number(score.toFixed(2)),
|
|
151
|
+
degradationWarning,
|
|
152
|
+
recentEvents: ledger.recentEvents.map((e) => ({ ...e })),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
reset(sessionId) {
|
|
157
|
+
const key = sessionId || "default";
|
|
158
|
+
this.sessions.delete(key);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
clear() {
|
|
162
|
+
this.sessions.clear();
|
|
163
|
+
}
|
|
164
|
+
}
|
package/lib/messages.js
CHANGED
|
@@ -60,6 +60,7 @@ export const MESSAGES = Object.freeze({
|
|
|
60
60
|
ruleSilentErrorMasked: "Suppression of command failure or error stream",
|
|
61
61
|
ruleChmodChown: "Permission or ownership mutation",
|
|
62
62
|
ruleReviewerReadOnly: "Reviewer agent is strictly isolated in read-only mode",
|
|
63
|
+
ruleRetryExhaustion: "Exhausted repeated attempts to bypass safety gate",
|
|
63
64
|
hintRecursiveRm: "Specify exact target files instead of recursive deletion",
|
|
64
65
|
hintPathTraversal: "Keep operations strictly within the project directory",
|
|
65
66
|
hintSecretWrite: "Modify configuration via settings or environment variables, not raw file overwrite",
|
|
@@ -72,6 +73,7 @@ export const MESSAGES = Object.freeze({
|
|
|
72
73
|
hintSilentErrorMasked: "Allow standard error output to be captured and handle non-zero exit codes explicitly",
|
|
73
74
|
hintChmodChown: "Avoid modifying file permissions or system directory ownership directly",
|
|
74
75
|
hintReviewerReadOnly: "Reviewer agents may only read repository files, not run commands or modify files",
|
|
76
|
+
hintRetryExhaustion: "Repeated denials detected; manual operator intervention required",
|
|
75
77
|
},
|
|
76
78
|
zh: {
|
|
77
79
|
blockedPrefix: "dsh-approval-gate 规则已阻止",
|
|
@@ -99,7 +101,7 @@ export const MESSAGES = Object.freeze({
|
|
|
99
101
|
askDynamicCommand: "无法验证展开后的命令名;请在允许前进行审查",
|
|
100
102
|
askDynamicArguments: "敏感命令的展开参数无法验证;请在允许前进行审查",
|
|
101
103
|
askDynamicRedirect: "展开后的重定向目标无法验证;请在允许前进行审查",
|
|
102
|
-
askScriptFile: "
|
|
104
|
+
askScriptFile: "脚本文件内容不可见;请在允许前进行审查",
|
|
103
105
|
askShellStdin: "卫兵无法完整获取 Shell 输入;请在允许前进行审查",
|
|
104
106
|
askInterpreterPayload: "解释器副作用未完全分析;请在允许前进行审查",
|
|
105
107
|
askCredentialInArgv: "展开后的授权凭据作为进程参数传递;请使用凭据安全的 API 辅助工具,或在允许前进行审查",
|
|
@@ -131,6 +133,7 @@ export const MESSAGES = Object.freeze({
|
|
|
131
133
|
ruleSilentErrorMasked: "屏蔽错误输出或强制忽略退出码",
|
|
132
134
|
ruleChmodChown: "修改权限或所有权",
|
|
133
135
|
ruleReviewerReadOnly: "审查者代理处于只读隔离模式",
|
|
136
|
+
ruleRetryExhaustion: "多次尝试绕过安全网关已达上限",
|
|
134
137
|
hintRecursiveRm: "请指定具体目标文件,避免递归删除",
|
|
135
138
|
hintPathTraversal: "请将操作限制在项目根目录内",
|
|
136
139
|
hintSecretWrite: "请通过设置或环境变量修改配置,不要直接覆盖敏感配置文件",
|
|
@@ -143,6 +146,7 @@ export const MESSAGES = Object.freeze({
|
|
|
143
146
|
hintSilentErrorMasked: "请保留标准错误输出,并明确处理非零退出码",
|
|
144
147
|
hintChmodChown: "避免直接修改系统目录所有权或文件权限",
|
|
145
148
|
hintReviewerReadOnly: "审查者代理仅可读取项目文件,禁止执行命令或修改文件",
|
|
149
|
+
hintRetryExhaustion: "检测到重复拒绝;需要操作员手动介入",
|
|
146
150
|
},
|
|
147
151
|
});
|
|
148
152
|
|
|
@@ -202,6 +206,7 @@ const RULE_KEYS = Object.freeze({
|
|
|
202
206
|
"silent-error-masked": "ruleSilentErrorMasked",
|
|
203
207
|
"chmod-chown": "ruleChmodChown",
|
|
204
208
|
"reviewer-read-only": "ruleReviewerReadOnly",
|
|
209
|
+
"retry-exhaustion": "ruleRetryExhaustion",
|
|
205
210
|
});
|
|
206
211
|
|
|
207
212
|
const HINT_KEYS = Object.freeze({
|
|
@@ -217,6 +222,7 @@ const HINT_KEYS = Object.freeze({
|
|
|
217
222
|
"silent-error-masked": "hintSilentErrorMasked",
|
|
218
223
|
"chmod-chown": "hintChmodChown",
|
|
219
224
|
"reviewer-read-only": "hintReviewerReadOnly",
|
|
225
|
+
"retry-exhaustion": "hintRetryExhaustion",
|
|
220
226
|
});
|
|
221
227
|
|
|
222
228
|
const ASK_KEYS = Object.freeze({
|
|
@@ -273,4 +279,4 @@ export function askMessage(hit, who, translate, logger) {
|
|
|
273
279
|
if (snippet) parts.push(translated(translate, "commandLabel", logger) + ": " + snippet);
|
|
274
280
|
if (who) parts.push(who.trim());
|
|
275
281
|
return parts.join(" — ") + ".";
|
|
276
|
-
}
|
|
282
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// lib/retry-exhaustion.js
|
|
2
|
+
// Anti-replay and Retry Exhaustion guard for dsh-approval-gate (#96).
|
|
3
|
+
// Blocks looping/thrashing agents attempting repeated bypasses after consecutive denials.
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_MAX_RETRY_ATTEMPTS = 3;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Extracts base command root from execution for grouping repeated bypass attempts.
|
|
9
|
+
*
|
|
10
|
+
* @param {any} execution
|
|
11
|
+
* @returns {string}
|
|
12
|
+
*/
|
|
13
|
+
export function extractCommandRoot(execution) {
|
|
14
|
+
if (!execution) return "";
|
|
15
|
+
const tool = String(execution.name || execution.tool || "").trim().toLowerCase();
|
|
16
|
+
const args = execution.arguments || {};
|
|
17
|
+
if (tool === "bash" || tool === "sh" || tool === "shell") {
|
|
18
|
+
const cmd = typeof args.command === "string" ? args.command.trim() : "";
|
|
19
|
+
if (!cmd) return "bash";
|
|
20
|
+
const parts = cmd.split(/[\s;&|]+/);
|
|
21
|
+
for (const p of parts) {
|
|
22
|
+
const clean = p.replace(/^['"]+|['"]+$/g, "").trim();
|
|
23
|
+
if (!clean) continue;
|
|
24
|
+
if (clean === "sudo" || clean === "env" || clean === "nohup") continue;
|
|
25
|
+
return clean;
|
|
26
|
+
}
|
|
27
|
+
return "bash";
|
|
28
|
+
}
|
|
29
|
+
const path = args.path || args.file || args.target;
|
|
30
|
+
if (typeof path === "string") return `${tool}:${path}`;
|
|
31
|
+
return tool;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @typedef {Object} RetrySessionState
|
|
36
|
+
* @property {string} lastRoot
|
|
37
|
+
* @property {number} rootRetries
|
|
38
|
+
* @property {number} totalDenies
|
|
39
|
+
* @property {boolean} exhausted
|
|
40
|
+
* @property {number} trippedAt
|
|
41
|
+
* @property {string[]} recentRoots
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
export class RetryExhaustionGuard {
|
|
45
|
+
/**
|
|
46
|
+
* @param {{ maxAttempts?: number }} [options]
|
|
47
|
+
*/
|
|
48
|
+
constructor(options = {}) {
|
|
49
|
+
this.maxAttempts = options.maxAttempts || DEFAULT_MAX_RETRY_ATTEMPTS;
|
|
50
|
+
/** @type {Map<string, RetrySessionState>} */
|
|
51
|
+
this.sessions = new Map();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @private
|
|
56
|
+
*/
|
|
57
|
+
_getOrCreate(sessionId) {
|
|
58
|
+
const key = sessionId || "default";
|
|
59
|
+
let state = this.sessions.get(key);
|
|
60
|
+
if (!state) {
|
|
61
|
+
state = {
|
|
62
|
+
lastRoot: "",
|
|
63
|
+
rootRetries: 0,
|
|
64
|
+
totalDenies: 0,
|
|
65
|
+
exhausted: false,
|
|
66
|
+
trippedAt: 0,
|
|
67
|
+
recentRoots: [],
|
|
68
|
+
};
|
|
69
|
+
this.sessions.set(key, state);
|
|
70
|
+
}
|
|
71
|
+
return state;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Check if session has exhausted retries.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} [sessionId]
|
|
78
|
+
* @returns {boolean}
|
|
79
|
+
*/
|
|
80
|
+
isExhausted(sessionId) {
|
|
81
|
+
const key = sessionId || "default";
|
|
82
|
+
const state = this.sessions.get(key);
|
|
83
|
+
return Boolean(state && state.exhausted);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Get consecutive denials count for session.
|
|
88
|
+
*
|
|
89
|
+
* @param {string} [sessionId]
|
|
90
|
+
* @returns {number}
|
|
91
|
+
*/
|
|
92
|
+
getConsecutiveDenials(sessionId) {
|
|
93
|
+
const key = sessionId || "default";
|
|
94
|
+
const state = this.sessions.get(key);
|
|
95
|
+
return state ? state.rootRetries : 0;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Record a verdict for session and evaluate exhaustion threshold.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} sessionId
|
|
102
|
+
* @param {any} execution
|
|
103
|
+
* @param {"allow" | "deny" | "ask"} verdict
|
|
104
|
+
* @returns {boolean} True if this verdict caused exhaustion to trip
|
|
105
|
+
*/
|
|
106
|
+
recordVerdict(sessionId, execution, verdict) {
|
|
107
|
+
const state = this._getOrCreate(sessionId);
|
|
108
|
+
const root = extractCommandRoot(execution);
|
|
109
|
+
|
|
110
|
+
if (verdict === "deny") {
|
|
111
|
+
state.totalDenies += 1;
|
|
112
|
+
if (state.lastRoot === root) {
|
|
113
|
+
state.rootRetries += 1;
|
|
114
|
+
} else {
|
|
115
|
+
state.lastRoot = root;
|
|
116
|
+
state.rootRetries = 1;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
state.recentRoots.push(root);
|
|
120
|
+
if (state.recentRoots.length > 10) {
|
|
121
|
+
state.recentRoots.shift();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (state.rootRetries >= this.maxAttempts && !state.exhausted) {
|
|
125
|
+
state.exhausted = true;
|
|
126
|
+
state.trippedAt = Date.now();
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
} else if (verdict === "allow") {
|
|
130
|
+
state.rootRetries = 0;
|
|
131
|
+
state.lastRoot = "";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Reset consecutive denies without clearing total denies or exhaustion.
|
|
139
|
+
*
|
|
140
|
+
* @param {string} [sessionId]
|
|
141
|
+
*/
|
|
142
|
+
resetConsecutive(sessionId) {
|
|
143
|
+
const key = sessionId || "default";
|
|
144
|
+
const state = this.sessions.get(key);
|
|
145
|
+
if (state && state.rootRetries > 0) {
|
|
146
|
+
state.rootRetries = 0;
|
|
147
|
+
state.lastRoot = "";
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Resume / reset a session to active state.
|
|
153
|
+
*
|
|
154
|
+
* @param {string} [sessionId]
|
|
155
|
+
*/
|
|
156
|
+
reset(sessionId) {
|
|
157
|
+
const key = sessionId || "default";
|
|
158
|
+
const state = this.sessions.get(key);
|
|
159
|
+
if (state) {
|
|
160
|
+
state.lastRoot = "";
|
|
161
|
+
state.rootRetries = 0;
|
|
162
|
+
state.exhausted = false;
|
|
163
|
+
state.trippedAt = 0;
|
|
164
|
+
state.recentRoots = [];
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
clear() {
|
|
169
|
+
this.sessions.clear();
|
|
170
|
+
}
|
|
171
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-approval-gate",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.14",
|
|
4
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
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -51,4 +51,4 @@
|
|
|
51
51
|
"approval-gate",
|
|
52
52
|
"safety"
|
|
53
53
|
]
|
|
54
|
-
}
|
|
54
|
+
}
|