@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/lib/hotwords.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// lib/hotwords.js
|
|
2
|
+
// High-speed keyword pre-filter (Hotwords / Lexicon Matcher) for dsh-approval-gate (#93).
|
|
3
|
+
// Provides sub-microsecond pre-check to bypass heavy AST/regex parsing for clean commands.
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_HOTWORDS = Object.freeze([
|
|
6
|
+
"rm",
|
|
7
|
+
"mkfs",
|
|
8
|
+
"fdisk",
|
|
9
|
+
"parted",
|
|
10
|
+
"dd",
|
|
11
|
+
"shadow",
|
|
12
|
+
"sudoers",
|
|
13
|
+
"passwd",
|
|
14
|
+
"kill",
|
|
15
|
+
"pkill",
|
|
16
|
+
"killall",
|
|
17
|
+
"systemctl",
|
|
18
|
+
"service",
|
|
19
|
+
"curl",
|
|
20
|
+
"wget",
|
|
21
|
+
"bash",
|
|
22
|
+
"sh",
|
|
23
|
+
"zsh",
|
|
24
|
+
"nc",
|
|
25
|
+
"netcat",
|
|
26
|
+
"ncat",
|
|
27
|
+
"socat",
|
|
28
|
+
"chmod",
|
|
29
|
+
"chown",
|
|
30
|
+
"eval",
|
|
31
|
+
"printenv",
|
|
32
|
+
"drop",
|
|
33
|
+
"truncate",
|
|
34
|
+
"format",
|
|
35
|
+
"sudo",
|
|
36
|
+
"su",
|
|
37
|
+
"ssh",
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Splits text into normalized lowercase word tokens.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} text
|
|
44
|
+
* @returns {string[]}
|
|
45
|
+
*/
|
|
46
|
+
export function extractWords(text) {
|
|
47
|
+
if (typeof text !== "string") return [];
|
|
48
|
+
// Split on whitespace and common shell operators/delimiters
|
|
49
|
+
const tokens = text.toLowerCase().split(/[^a-z0-9_-]+/);
|
|
50
|
+
return tokens.filter((t) => t.length > 0);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class HotwordsMatcher {
|
|
54
|
+
/**
|
|
55
|
+
* @param {string[]} [customHotwords]
|
|
56
|
+
*/
|
|
57
|
+
constructor(customHotwords) {
|
|
58
|
+
/** @type {Set<string>} */
|
|
59
|
+
this.lexicon = new Set();
|
|
60
|
+
const words = Array.isArray(customHotwords) && customHotwords.length > 0
|
|
61
|
+
? customHotwords
|
|
62
|
+
: DEFAULT_HOTWORDS;
|
|
63
|
+
|
|
64
|
+
for (const w of words) {
|
|
65
|
+
if (typeof w === "string" && w.trim()) {
|
|
66
|
+
this.lexicon.add(w.trim().toLowerCase());
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Add new words to lexicon.
|
|
73
|
+
* @param {string | string[]} words
|
|
74
|
+
*/
|
|
75
|
+
add(words) {
|
|
76
|
+
const list = Array.isArray(words) ? words : [words];
|
|
77
|
+
for (const w of list) {
|
|
78
|
+
if (typeof w === "string" && w.trim()) {
|
|
79
|
+
this.lexicon.add(w.trim().toLowerCase());
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Checks if text contains any word present in the hotwords lexicon.
|
|
86
|
+
*
|
|
87
|
+
* @param {string} text
|
|
88
|
+
* @returns {boolean} True if any high-risk keyword matched
|
|
89
|
+
*/
|
|
90
|
+
hasMatch(text) {
|
|
91
|
+
if (!text || typeof text !== "string") return false;
|
|
92
|
+
const words = extractWords(text);
|
|
93
|
+
for (let i = 0; i < words.length; i += 1) {
|
|
94
|
+
if (this.lexicon.has(words[i])) {
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Returns list of matching hotwords found in text.
|
|
103
|
+
*
|
|
104
|
+
* @param {string} text
|
|
105
|
+
* @returns {string[]}
|
|
106
|
+
*/
|
|
107
|
+
findMatches(text) {
|
|
108
|
+
if (!text || typeof text !== "string") return [];
|
|
109
|
+
const words = extractWords(text);
|
|
110
|
+
const matches = [];
|
|
111
|
+
for (let i = 0; i < words.length; i += 1) {
|
|
112
|
+
if (this.lexicon.has(words[i]) && !matches.includes(words[i])) {
|
|
113
|
+
matches.push(words[i]);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return matches;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
get size() {
|
|
120
|
+
return this.lexicon.size;
|
|
121
|
+
}
|
|
122
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { SecurityEngine } from "./engine.js";
|
|
2
2
|
import { BandsEngine } from "./bands.js";
|
|
3
|
+
import { AuditLogger } from "./audit-log.js";
|
|
4
|
+
import { DeclarativeRulesEngine } from "./rules-loader.js";
|
|
3
5
|
import Schema from "@deepseek-ai/schemastery";
|
|
4
6
|
|
|
5
7
|
if (typeof Schema.prototype?.volatile !== "function") {
|
|
@@ -205,6 +207,14 @@ export const Config = Schema.object({
|
|
|
205
207
|
workspaceDir: Schema.string().default("").volatile(),
|
|
206
208
|
/** Default verdict for unclassified commands: 'allow' (default) or 'ask' (fail-closed) (#163). */
|
|
207
209
|
defaultVerdict: Schema.union(["allow", "ask"]).default("allow").volatile(),
|
|
210
|
+
/** Dry-run / shadow mode for safe testing of rules on production (#128). */
|
|
211
|
+
dryRun: Schema.boolean().default(false).volatile(),
|
|
212
|
+
/** Shadow mode alias for dryRun (#128). */
|
|
213
|
+
shadowMode: Schema.boolean().default(false).volatile(),
|
|
214
|
+
/** Path to append-only audit log file (#56). */
|
|
215
|
+
auditLogPath: Schema.string().default("").volatile(),
|
|
216
|
+
/** Optional custom declarative rules YAML file path (#38). */
|
|
217
|
+
rulesFile: Schema.string().default("").volatile(),
|
|
208
218
|
/** Security engine instance (internal/testing). */
|
|
209
219
|
engine: Schema.any().hidden(),
|
|
210
220
|
});
|
|
@@ -248,12 +258,23 @@ export function apply(ctx, config) {
|
|
|
248
258
|
inspectConfig: { toolName: toolName, fileWriteTools: fileWriteTools, workspaceDir: cfg.workspaceDir || "" },
|
|
249
259
|
bands: new BandsEngine({ toolRegistry: ctx && ctx.tools }),
|
|
250
260
|
defaultVerdict: cfg.defaultVerdict || "allow",
|
|
261
|
+
dryRun: Boolean(cfg.dryRun || cfg.shadowMode),
|
|
262
|
+
auditLogger: new AuditLogger({ logPath: cfg.auditLogPath || undefined }),
|
|
263
|
+
declarativeRules: new DeclarativeRulesEngine({
|
|
264
|
+
workspaceDir: cfg.workspaceDir || "",
|
|
265
|
+
builtinCatalogPath: cfg.rulesFile || undefined,
|
|
266
|
+
}),
|
|
251
267
|
});
|
|
252
268
|
|
|
253
269
|
const gateService = {
|
|
254
270
|
engine,
|
|
255
271
|
addGrant: (options) => engine.addGrant(options),
|
|
256
272
|
resume: (sessionId, reason) => engine.resume(sessionId, reason),
|
|
273
|
+
cache: engine.cache,
|
|
274
|
+
retryGuard: engine.retryGuard,
|
|
275
|
+
ledger: engine.ledger,
|
|
276
|
+
declarativeRules: engine.declarativeRules,
|
|
277
|
+
getLedgerSummary: (sessionId) => engine.getLedgerSummary(sessionId),
|
|
257
278
|
};
|
|
258
279
|
if (ctx && typeof ctx === "object") {
|
|
259
280
|
if (typeof ctx.provide === "function") {
|
|
@@ -282,6 +303,13 @@ export function apply(ctx, config) {
|
|
|
282
303
|
return { kind: "ask", reason: askMessage(hit, who, translate, ctx && ctx.logger) };
|
|
283
304
|
}
|
|
284
305
|
|
|
306
|
+
if (decision.dryRun) {
|
|
307
|
+
if (ctx && ctx.logger && typeof ctx.logger.info === "function") {
|
|
308
|
+
ctx.logger.info(`[dsh-approval-gate:DRY-RUN] would block rule=${decision.reason} (${decision.originalVerdict})${who}`);
|
|
309
|
+
}
|
|
310
|
+
return next();
|
|
311
|
+
}
|
|
312
|
+
|
|
285
313
|
if (decision.verdict === "ask") {
|
|
286
314
|
if (decision.reason === "circuit-breaker-tripped") {
|
|
287
315
|
const hit = decision.hit || {
|
|
@@ -325,6 +353,13 @@ export function apply(ctx, config) {
|
|
|
325
353
|
return denyMessage(hit, who, translate, ctx && ctx.logger);
|
|
326
354
|
}
|
|
327
355
|
|
|
356
|
+
if (decision.dryRun) {
|
|
357
|
+
if (ctx && ctx.logger && typeof ctx.logger.info === "function") {
|
|
358
|
+
ctx.logger.info(`[dsh-approval-gate:DRY-RUN] would block rule=${decision.reason} (${decision.originalVerdict})${who}`);
|
|
359
|
+
}
|
|
360
|
+
return undefined;
|
|
361
|
+
}
|
|
362
|
+
|
|
328
363
|
if (decision.verdict === "deny" || (decision.verdict === "ask" && !hasPreExecute)) {
|
|
329
364
|
const hit = decision.hit || {
|
|
330
365
|
deny: true,
|
|
@@ -400,4 +435,46 @@ export {
|
|
|
400
435
|
isLoopbackHost,
|
|
401
436
|
isLoopbackUrl,
|
|
402
437
|
isLoopbackCommand,
|
|
403
|
-
} from "./loopback.js";
|
|
438
|
+
} from "./loopback.js";
|
|
439
|
+
export {
|
|
440
|
+
computeFingerprint,
|
|
441
|
+
computeFingerprintHash,
|
|
442
|
+
normalizeCommandString,
|
|
443
|
+
} from "./fingerprint.js";
|
|
444
|
+
export {
|
|
445
|
+
VerdictCache,
|
|
446
|
+
DEFAULT_CACHE_MAX_SIZE,
|
|
447
|
+
DEFAULT_CACHE_TTL_MS,
|
|
448
|
+
} from "./cache.js";
|
|
449
|
+
export {
|
|
450
|
+
RetryExhaustionGuard,
|
|
451
|
+
DEFAULT_MAX_RETRY_ATTEMPTS,
|
|
452
|
+
} from "./retry-exhaustion.js";
|
|
453
|
+
export {
|
|
454
|
+
QualityLedger,
|
|
455
|
+
MAX_LEDGER_EVENTS_PER_SESSION,
|
|
456
|
+
} from "./ledger.js";
|
|
457
|
+
export {
|
|
458
|
+
AuditLogger,
|
|
459
|
+
DEFAULT_AUDIT_LOG_RELATIVE,
|
|
460
|
+
} from "./audit-log.js";
|
|
461
|
+
export {
|
|
462
|
+
parseYaml,
|
|
463
|
+
parseScalar,
|
|
464
|
+
stripComment,
|
|
465
|
+
} from "./yaml-parser.js";
|
|
466
|
+
export {
|
|
467
|
+
matchConditionValue,
|
|
468
|
+
evaluateWhen,
|
|
469
|
+
} from "./when-matcher.js";
|
|
470
|
+
export {
|
|
471
|
+
HotwordsMatcher,
|
|
472
|
+
DEFAULT_HOTWORDS,
|
|
473
|
+
extractWords,
|
|
474
|
+
} from "./hotwords.js";
|
|
475
|
+
export {
|
|
476
|
+
DeclarativeRulesEngine,
|
|
477
|
+
BUILTIN_RULES_FILE,
|
|
478
|
+
WORKSPACE_RULES_REL,
|
|
479
|
+
USER_RULES_REL,
|
|
480
|
+
} from "./rules-loader.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
|
+
}
|