@goodandready/dsh-approval-gate 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/lib/bands.js +125 -0
- package/lib/breaker.js +223 -0
- package/lib/canonical.js +88 -0
- package/lib/engine.js +110 -0
- package/lib/grants.js +383 -0
- package/lib/index.js +24 -0
- package/lib/inspect.js +7 -4
- package/lib/paths.js +25 -0
- package/lib/tokenizer.js +61 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.7
|
|
4
|
+
|
|
5
|
+
- Canonical: каноническая сериализация вызовов инструментов `canonicalizeCall` со стабильной рекурсивной сортировкой ключей и хэшированием `hashCall` (Refs: #124).
|
|
6
|
+
- Grants: хранилище временных сессионных грантов `SessionGrantStore` с ограничением по времени жизни (TTL) и максимальному числу вызовов (`maxUses`) (Refs: #123).
|
|
7
|
+
- Grants: поддержка точечных грантов на уровне инструмента, префикса аргументов (`commandPrefix`, `pathPrefix`, `prefix`) и регулярных выражений `argsPattern` (Refs: #132).
|
|
8
|
+
- Grants: автоматическая сборка мусора и очистка просроченных/исчерпанных сессионных грантов `prune()` с таймером и счетчиком операций (Refs: #130).
|
|
9
|
+
- Breaker: детальное логирование переходов состояний предохранителя сессии `CircuitBreaker` (`active -> tripped -> resumed`), история событий и поддержка подписок `onStateChange` (Refs: #121).
|
|
10
|
+
|
|
11
|
+
## 0.1.6
|
|
12
|
+
|
|
13
|
+
- Security: автоматическое раскрытие домашних путей `expandHome` (`~`, `$HOME`, `${HOME}`, `%USERPROFILE%`) в путях файлов, аргументах инструментов записи и перенаправлениях shell (Refs: #114).
|
|
14
|
+
- Security: синтаксический разбор составных цепочек shell-команд `splitSubcommands` по операторам `&&`, `||`, `;`, `|`, `&` с сохранением кавычек и подстановок (Refs: #113).
|
|
15
|
+
- Security: автоматический предохранитель сессии `CircuitBreaker` (блокировка зацикливания агента при 3 подряд или 20 общих отказах с принудительным запросом человека) (Refs: #110).
|
|
16
|
+
- Engine: двухполосная предварительная фильтрация `BandsEngine`: Band 0 (Hard Deny regex) для мгновенной блокировки деструктивных команд и Band 1 (Safe Allow glob) для нулевой задержки на рутинных командах инспекции (Refs: #112).
|
|
17
|
+
- Engine: 5-уровневый каскад принятия решений `SecurityEngine` (P0 Hard-Deny -> P1 Grants -> P2 Static Rules -> P3 LLM -> P4 Human Ask) с координацией предохранителя и правил (Refs: #122).
|
|
18
|
+
|
|
3
19
|
## 0.1.5
|
|
4
20
|
|
|
5
21
|
- Security: block recursive `rm` with uppercase `-R` and `-Rf` flags in compliance with POSIX standard (Refs: #28).
|
package/lib/bands.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// lib/bands.js
|
|
2
|
+
// Two-band deterministic filtering engine for dsh-approval-gate.
|
|
3
|
+
// Band 0: Hard Deny (fast regexes for exfiltration, fork-bombs, disk wipes)
|
|
4
|
+
// Band 1: Safe Allow (prefix globs & curated safe inspection commands)
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_DENY_PATTERNS = Object.freeze([
|
|
7
|
+
/\b(?:nc|ncat|netcat)\s+.*-e\s+/i,
|
|
8
|
+
/\/dev\/tcp\/\d+\.\d+\.\d+\.\d+\/\d+/i,
|
|
9
|
+
/\/dev\/udp\/\d+\.\d+\.\d+\.\d+\/\d+/i,
|
|
10
|
+
/\bmkfs(?:\.[a-z0-9]+)?\s+/i,
|
|
11
|
+
/\bdd\s+.*of=\/dev\/(?:sd|hd|vd|xvd|nvme|mmcblk|loop|mapper)/i,
|
|
12
|
+
/:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/,
|
|
13
|
+
/\bchmod\s+(?:-R\s+)?(?:0?777|a\+rwx)\s+\/(?!tmp)/i,
|
|
14
|
+
/\bchown\s+(?:-R\s+)?root\b/i,
|
|
15
|
+
/\b(?:shutdown|reboot|poweroff|halt)\s*(?:-|$)/i,
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_ALLOW_GLOBS = Object.freeze([
|
|
19
|
+
"git status*",
|
|
20
|
+
"git diff*",
|
|
21
|
+
"git log*",
|
|
22
|
+
"git branch*",
|
|
23
|
+
"git show*",
|
|
24
|
+
"ls*",
|
|
25
|
+
"pwd*",
|
|
26
|
+
"cat *",
|
|
27
|
+
"head *",
|
|
28
|
+
"tail *",
|
|
29
|
+
"wc *",
|
|
30
|
+
"which *",
|
|
31
|
+
"whereis *",
|
|
32
|
+
"whoami*",
|
|
33
|
+
"node --version*",
|
|
34
|
+
"node -v*",
|
|
35
|
+
"npm test*",
|
|
36
|
+
"npm --version*",
|
|
37
|
+
"npm -v*",
|
|
38
|
+
"pnpm test*",
|
|
39
|
+
"pnpm --version*",
|
|
40
|
+
"pnpm -v*",
|
|
41
|
+
"yarn test*",
|
|
42
|
+
"cargo test*",
|
|
43
|
+
"cargo check*",
|
|
44
|
+
"cargo --version*",
|
|
45
|
+
"pytest*",
|
|
46
|
+
"python --version*",
|
|
47
|
+
"python3 --version*",
|
|
48
|
+
"echo *",
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
export const DEFAULT_READ_ONLY_TOOLS = Object.freeze([
|
|
52
|
+
"read",
|
|
53
|
+
"read_file",
|
|
54
|
+
"readFile",
|
|
55
|
+
"view_file",
|
|
56
|
+
"glob",
|
|
57
|
+
"grep",
|
|
58
|
+
"find_files",
|
|
59
|
+
"search_code",
|
|
60
|
+
"file_search",
|
|
61
|
+
"list_dir",
|
|
62
|
+
"list_directory",
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Compile a prefix-glob into an anchored RegExp.
|
|
67
|
+
* @param {string} pattern
|
|
68
|
+
* @returns {RegExp}
|
|
69
|
+
*/
|
|
70
|
+
export function compileGlob(pattern) {
|
|
71
|
+
let source = "";
|
|
72
|
+
for (const ch of pattern) {
|
|
73
|
+
if (ch === "*") source += ".*";
|
|
74
|
+
else if (ch === "?") source += ".";
|
|
75
|
+
else source += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
76
|
+
}
|
|
77
|
+
return new RegExp(`^${source}$`, "i");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export class BandsEngine {
|
|
81
|
+
/**
|
|
82
|
+
* @param {{ denyPatterns?: Array<RegExp|string>, allowGlobs?: Array<RegExp|string>, readOnlyTools?: Array<string> }} [options]
|
|
83
|
+
*/
|
|
84
|
+
constructor(options = {}) {
|
|
85
|
+
this.denyPatterns = (options.denyPatterns || DEFAULT_DENY_PATTERNS).map((p) =>
|
|
86
|
+
typeof p === "string" ? new RegExp(p, "i") : p
|
|
87
|
+
);
|
|
88
|
+
this.allowGlobs = (options.allowGlobs || DEFAULT_ALLOW_GLOBS).map((g) =>
|
|
89
|
+
typeof g === "string" ? compileGlob(g) : g
|
|
90
|
+
);
|
|
91
|
+
this.readOnlyTools = new Set(options.readOnlyTools || DEFAULT_READ_ONLY_TOOLS);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Evaluates an execution request through Band 0 and Band 1.
|
|
96
|
+
* @param {string} tool - Tool name
|
|
97
|
+
* @param {string} [command] - Normalized command text (for bash)
|
|
98
|
+
* @returns {"deny" | "allow" | "unmatched"}
|
|
99
|
+
*/
|
|
100
|
+
evaluate(tool, command) {
|
|
101
|
+
if (this.readOnlyTools.has(tool)) {
|
|
102
|
+
return "allow";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (typeof command === "string" && command.trim().length > 0) {
|
|
106
|
+
const text = command.trim();
|
|
107
|
+
|
|
108
|
+
// Band 0: Hard Deny
|
|
109
|
+
for (const pattern of this.denyPatterns) {
|
|
110
|
+
if (pattern.test(text)) {
|
|
111
|
+
return "deny";
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Band 1: Safe Allow
|
|
116
|
+
for (const glob of this.allowGlobs) {
|
|
117
|
+
if (glob.test(text)) {
|
|
118
|
+
return "allow";
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return "unmatched";
|
|
124
|
+
}
|
|
125
|
+
}
|
package/lib/breaker.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// lib/breaker.js
|
|
2
|
+
// Session-scoped Circuit Breaker for dsh-approval-gate with state transition events and history.
|
|
3
|
+
// Protects against agent thrashing / loops when blocked by security gates.
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_MAX_CONSECUTIVE_DENIES = 3;
|
|
6
|
+
export const DEFAULT_MAX_TOTAL_DENIES = 20;
|
|
7
|
+
|
|
8
|
+
export const CIRCUIT_STATE_ACTIVE = "active";
|
|
9
|
+
export const CIRCUIT_STATE_TRIPPED = "tripped";
|
|
10
|
+
export const CIRCUIT_STATE_RESUMED = "resumed";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {Object} CircuitTransitionEvent
|
|
14
|
+
* @property {string} sessionId
|
|
15
|
+
* @property {string} from
|
|
16
|
+
* @property {string} to
|
|
17
|
+
* @property {string} reason
|
|
18
|
+
* @property {number} consecutive
|
|
19
|
+
* @property {number} total
|
|
20
|
+
* @property {number} timestamp
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export class CircuitBreaker {
|
|
24
|
+
/**
|
|
25
|
+
* @param {{
|
|
26
|
+
* maxConsecutive?: number,
|
|
27
|
+
* maxTotal?: number,
|
|
28
|
+
* logger?: { info?: (msg: string) => void, warn?: (msg: string) => void }
|
|
29
|
+
* }} [options]
|
|
30
|
+
*/
|
|
31
|
+
constructor(options = {}) {
|
|
32
|
+
this.maxConsecutive = options.maxConsecutive || DEFAULT_MAX_CONSECUTIVE_DENIES;
|
|
33
|
+
this.maxTotal = options.maxTotal || DEFAULT_MAX_TOTAL_DENIES;
|
|
34
|
+
this.logger = options.logger || null;
|
|
35
|
+
/** @type {Array<(event: CircuitTransitionEvent) => void>} */
|
|
36
|
+
this.listeners = [];
|
|
37
|
+
/** @type {Map<string, { consecutive: number, total: number, tripped: boolean, state: string, history: CircuitTransitionEvent[] }>} */
|
|
38
|
+
this.sessions = new Map();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Register a state change listener callback.
|
|
43
|
+
* @param {(event: CircuitTransitionEvent) => void} fn
|
|
44
|
+
* @returns {() => void} Unsubscribe function
|
|
45
|
+
*/
|
|
46
|
+
onStateChange(fn) {
|
|
47
|
+
if (typeof fn === "function") {
|
|
48
|
+
this.listeners.push(fn);
|
|
49
|
+
}
|
|
50
|
+
return () => {
|
|
51
|
+
const idx = this.listeners.indexOf(fn);
|
|
52
|
+
if (idx !== -1) this.listeners.splice(idx, 1);
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Emit a transition event to registered listeners and optional logger.
|
|
58
|
+
* @private
|
|
59
|
+
*/
|
|
60
|
+
_emitTransition(sessionState, sessionId, from, to, reason) {
|
|
61
|
+
const event = {
|
|
62
|
+
sessionId,
|
|
63
|
+
from,
|
|
64
|
+
to,
|
|
65
|
+
reason,
|
|
66
|
+
consecutive: sessionState.consecutive,
|
|
67
|
+
total: sessionState.total,
|
|
68
|
+
timestamp: Date.now(),
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
sessionState.state = to;
|
|
72
|
+
sessionState.history.push(event);
|
|
73
|
+
|
|
74
|
+
if (this.logger) {
|
|
75
|
+
const logLine = `[dsh-approval-gate:breaker] session=${sessionId} state=${to} (was ${from}) reason=${reason} consecutive=${sessionState.consecutive} total=${sessionState.total}`;
|
|
76
|
+
if (to === CIRCUIT_STATE_TRIPPED && typeof this.logger.warn === "function") {
|
|
77
|
+
this.logger.warn(logLine);
|
|
78
|
+
} else if (typeof this.logger.info === "function") {
|
|
79
|
+
this.logger.info(logLine);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (const listener of this.listeners) {
|
|
84
|
+
try {
|
|
85
|
+
listener(event);
|
|
86
|
+
} catch {
|
|
87
|
+
// Listener error must not crash the breaker
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Internal session state fetch or initialize.
|
|
94
|
+
* @private
|
|
95
|
+
*/
|
|
96
|
+
_getOrCreate(sessionId) {
|
|
97
|
+
const key = sessionId || "default";
|
|
98
|
+
let state = this.sessions.get(key);
|
|
99
|
+
if (!state) {
|
|
100
|
+
state = {
|
|
101
|
+
consecutive: 0,
|
|
102
|
+
total: 0,
|
|
103
|
+
tripped: false,
|
|
104
|
+
state: CIRCUIT_STATE_ACTIVE,
|
|
105
|
+
history: [],
|
|
106
|
+
};
|
|
107
|
+
this.sessions.set(key, state);
|
|
108
|
+
}
|
|
109
|
+
return state;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Get current state snapshot for a session (backward compatible).
|
|
114
|
+
* @param {string} [sessionId]
|
|
115
|
+
* @returns {{ consecutive: number, total: number, tripped: boolean }}
|
|
116
|
+
*/
|
|
117
|
+
get(sessionId) {
|
|
118
|
+
const state = this._getOrCreate(sessionId);
|
|
119
|
+
return {
|
|
120
|
+
consecutive: state.consecutive,
|
|
121
|
+
total: state.total,
|
|
122
|
+
tripped: state.tripped,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Get current lifecycle state string ("active" | "tripped" | "resumed").
|
|
128
|
+
* @param {string} [sessionId]
|
|
129
|
+
* @returns {string}
|
|
130
|
+
*/
|
|
131
|
+
getState(sessionId) {
|
|
132
|
+
const state = this._getOrCreate(sessionId);
|
|
133
|
+
return state.state;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Retrieve state transition history for a session.
|
|
138
|
+
* @param {string} [sessionId]
|
|
139
|
+
* @returns {CircuitTransitionEvent[]}
|
|
140
|
+
*/
|
|
141
|
+
getHistory(sessionId) {
|
|
142
|
+
const state = this._getOrCreate(sessionId);
|
|
143
|
+
return state.history.map((ev) => ({ ...ev }));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Whether the breaker is currently tripped for this session.
|
|
148
|
+
* @param {string} [sessionId]
|
|
149
|
+
* @returns {boolean}
|
|
150
|
+
*/
|
|
151
|
+
isTripped(sessionId) {
|
|
152
|
+
const state = this._getOrCreate(sessionId);
|
|
153
|
+
return Boolean(state && state.tripped);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Record a security denial. Returns true if this call tripped the breaker.
|
|
158
|
+
* @param {string} [sessionId]
|
|
159
|
+
* @returns {boolean}
|
|
160
|
+
*/
|
|
161
|
+
countDeny(sessionId) {
|
|
162
|
+
const key = sessionId || "default";
|
|
163
|
+
const state = this._getOrCreate(key);
|
|
164
|
+
state.consecutive += 1;
|
|
165
|
+
state.total += 1;
|
|
166
|
+
|
|
167
|
+
const wasTripped = state.tripped;
|
|
168
|
+
const isConsecutiveTrip = state.consecutive >= this.maxConsecutive;
|
|
169
|
+
const isTotalTrip = state.total >= this.maxTotal;
|
|
170
|
+
|
|
171
|
+
if ((isConsecutiveTrip || isTotalTrip) && !wasTripped) {
|
|
172
|
+
state.tripped = true;
|
|
173
|
+
const reason = isConsecutiveTrip
|
|
174
|
+
? `consecutive-threshold-exceeded (${state.consecutive}/${this.maxConsecutive})`
|
|
175
|
+
: `total-threshold-exceeded (${state.total}/${this.maxTotal})`;
|
|
176
|
+
this._emitTransition(state, key, CIRCUIT_STATE_ACTIVE, CIRCUIT_STATE_TRIPPED, reason);
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Reset the consecutive counter on an allowed operation.
|
|
185
|
+
* @param {string} [sessionId]
|
|
186
|
+
*/
|
|
187
|
+
resetConsecutive(sessionId) {
|
|
188
|
+
const key = sessionId || "default";
|
|
189
|
+
const state = this._getOrCreate(key);
|
|
190
|
+
if (state.consecutive > 0) {
|
|
191
|
+
state.consecutive = 0;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Fully resume auto-mode after human approval or admin action.
|
|
197
|
+
* @param {string} [sessionId]
|
|
198
|
+
* @param {string} [reason="manual-human-resume"]
|
|
199
|
+
*/
|
|
200
|
+
resume(sessionId, reason = "manual-human-resume") {
|
|
201
|
+
const key = sessionId || "default";
|
|
202
|
+
const state = this._getOrCreate(key);
|
|
203
|
+
const wasTripped = state.tripped;
|
|
204
|
+
const oldState = state.state;
|
|
205
|
+
|
|
206
|
+
state.consecutive = 0;
|
|
207
|
+
state.total = 0;
|
|
208
|
+
state.tripped = false;
|
|
209
|
+
|
|
210
|
+
if (wasTripped || oldState !== CIRCUIT_STATE_ACTIVE) {
|
|
211
|
+
this._emitTransition(state, key, oldState, CIRCUIT_STATE_RESUMED, reason);
|
|
212
|
+
this._emitTransition(state, key, CIRCUIT_STATE_RESUMED, CIRCUIT_STATE_ACTIVE, "session-reset-complete");
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Clear all session tracking states.
|
|
218
|
+
*/
|
|
219
|
+
clear() {
|
|
220
|
+
this.sessions.clear();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
package/lib/canonical.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// lib/canonical.js
|
|
2
|
+
// Deterministic canonicalization and hashing for tool execution requests.
|
|
3
|
+
// Ensures identical tool calls with different key ordering produce identical hashes.
|
|
4
|
+
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Recursively sort object keys alphabetically and normalize values.
|
|
9
|
+
*
|
|
10
|
+
* @param {any} value
|
|
11
|
+
* @returns {any}
|
|
12
|
+
*/
|
|
13
|
+
export function sortKeys(value) {
|
|
14
|
+
if (value === null || typeof value !== "object") {
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
if (Array.isArray(value)) {
|
|
18
|
+
return value.map(sortKeys);
|
|
19
|
+
}
|
|
20
|
+
const sorted = {};
|
|
21
|
+
const keys = Object.keys(value).sort();
|
|
22
|
+
for (const k of keys) {
|
|
23
|
+
sorted[k] = sortKeys(value[k]);
|
|
24
|
+
}
|
|
25
|
+
return sorted;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Canonicalizes a tool call into a stable structure { tool: string, arguments: any }.
|
|
30
|
+
*
|
|
31
|
+
* @param {{ name?: string, tool?: string, arguments?: any } | string} executionOrTool
|
|
32
|
+
* @param {any} [args]
|
|
33
|
+
* @returns {{ tool: string, arguments: any }}
|
|
34
|
+
*/
|
|
35
|
+
export function canonicalizeCall(executionOrTool, args) {
|
|
36
|
+
let tool = "";
|
|
37
|
+
let rawArgs = {};
|
|
38
|
+
|
|
39
|
+
if (typeof executionOrTool === "string") {
|
|
40
|
+
tool = executionOrTool.trim();
|
|
41
|
+
rawArgs = args || {};
|
|
42
|
+
} else if (executionOrTool && typeof executionOrTool === "object") {
|
|
43
|
+
tool = String(executionOrTool.name || executionOrTool.tool || "").trim();
|
|
44
|
+
rawArgs = executionOrTool.arguments !== undefined ? executionOrTool.arguments : (args || {});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// If rawArgs is a JSON string, try to parse it
|
|
48
|
+
if (typeof rawArgs === "string") {
|
|
49
|
+
try {
|
|
50
|
+
rawArgs = JSON.parse(rawArgs);
|
|
51
|
+
} catch {
|
|
52
|
+
// Keep as string if not valid JSON
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const normalizedArgs = rawArgs && typeof rawArgs === "object"
|
|
57
|
+
? sortKeys(rawArgs)
|
|
58
|
+
: (rawArgs ?? {});
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
tool,
|
|
62
|
+
arguments: normalizedArgs,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Converts a tool call to a deterministic canonical JSON string.
|
|
68
|
+
*
|
|
69
|
+
* @param {{ name?: string, tool?: string, arguments?: any } | string} executionOrTool
|
|
70
|
+
* @param {any} [args]
|
|
71
|
+
* @returns {string}
|
|
72
|
+
*/
|
|
73
|
+
export function canonicalString(executionOrTool, args) {
|
|
74
|
+
return JSON.stringify(canonicalizeCall(executionOrTool, args));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Computes a cryptographic hash of the canonicalized tool call.
|
|
79
|
+
*
|
|
80
|
+
* @param {{ name?: string, tool?: string, arguments?: any } | string} executionOrTool
|
|
81
|
+
* @param {any} [args]
|
|
82
|
+
* @param {string} [algorithm="sha256"]
|
|
83
|
+
* @returns {string}
|
|
84
|
+
*/
|
|
85
|
+
export function hashCall(executionOrTool, args, algorithm = "sha256") {
|
|
86
|
+
const str = canonicalString(executionOrTool, args);
|
|
87
|
+
return createHash(algorithm).update(str, "utf8").digest("hex");
|
|
88
|
+
}
|
package/lib/engine.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// lib/engine.js
|
|
2
|
+
// 5-tier Security Decision Engine for dsh-approval-gate.
|
|
3
|
+
// Waterfall:
|
|
4
|
+
// P0: Hard Deny (Bands Band 0 + inspectExecution hard denials + protected files)
|
|
5
|
+
// P1: Session Grants (scoped temporary allowances)
|
|
6
|
+
// P2: Static Rules / Safe Allow (Bands Band 1 + safe read-only commands)
|
|
7
|
+
// P3: Optional Semantic Classifier
|
|
8
|
+
// P4: Human Ask Fallback (uncertain syntax / ambiguous commands / tripped breaker)
|
|
9
|
+
|
|
10
|
+
import { BandsEngine } from "./bands.js";
|
|
11
|
+
import { CircuitBreaker } from "./breaker.js";
|
|
12
|
+
import { SessionGrantStore } from "./grants.js";
|
|
13
|
+
import { inspectExecution } from "./inspect.js";
|
|
14
|
+
|
|
15
|
+
export class SecurityEngine {
|
|
16
|
+
/**
|
|
17
|
+
* @param {{
|
|
18
|
+
* bands?: BandsEngine,
|
|
19
|
+
* breaker?: CircuitBreaker,
|
|
20
|
+
* inspectConfig?: { toolName?: string, fileWriteTools?: string[] },
|
|
21
|
+
* classifier?: ((execution: any, context?: any) => { verdict: "allow"|"deny", reason?: string }),
|
|
22
|
+
* grantRegistry?: { decide: (tool: string, args: any, context?: any) => "allow"|"no-match" }
|
|
23
|
+
* }} [options]
|
|
24
|
+
*/
|
|
25
|
+
constructor(options = {}) {
|
|
26
|
+
this.bands = options.bands || new BandsEngine(options);
|
|
27
|
+
this.breaker = options.breaker || new CircuitBreaker(options);
|
|
28
|
+
this.inspectConfig = options.inspectConfig || {};
|
|
29
|
+
this.classifier = options.classifier || null;
|
|
30
|
+
this.grantRegistry = options.grantRegistry || new SessionGrantStore(options);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Evaluates an execution request through the P0-P4 waterfall.
|
|
35
|
+
*
|
|
36
|
+
* @param {{ name: string, arguments?: any }} execution - Tool call
|
|
37
|
+
* @param {{ sessionId?: string, knobs?: any }} [context] - Context options
|
|
38
|
+
* @returns {{ verdict: "allow" | "deny" | "ask", stage: "P0" | "P1" | "P2" | "P3" | "P4", reason: string, detail?: string }}
|
|
39
|
+
*/
|
|
40
|
+
decide(execution, context = {}) {
|
|
41
|
+
const sessionId = context.sessionId || "default";
|
|
42
|
+
const toolName = execution && execution.name;
|
|
43
|
+
const command = execution && execution.arguments && execution.arguments.command;
|
|
44
|
+
|
|
45
|
+
// Fast-path Band 0 Hard Deny
|
|
46
|
+
const bandVerdict = this.bands.evaluate(toolName, command);
|
|
47
|
+
if (bandVerdict === "deny") {
|
|
48
|
+
this.breaker.countDeny(sessionId);
|
|
49
|
+
return { verdict: "deny", stage: "P0", reason: "band-hard-deny", detail: command };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Inspect execution for hard denials and syntax checks
|
|
53
|
+
const hit = inspectExecution(execution, this.inspectConfig);
|
|
54
|
+
if (hit.deny) {
|
|
55
|
+
this.breaker.countDeny(sessionId);
|
|
56
|
+
return { verdict: "deny", stage: "P0", reason: hit.reason || "hard-deny", detail: hit.detail || hit.snippet };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// P1: Session Grants
|
|
60
|
+
if (this.grantRegistry && typeof this.grantRegistry.decide === "function") {
|
|
61
|
+
const grantMatch = this.grantRegistry.decide(toolName, execution.arguments, context);
|
|
62
|
+
if (grantMatch === "allow") {
|
|
63
|
+
this.breaker.resetConsecutive(sessionId);
|
|
64
|
+
return { verdict: "allow", stage: "P1", reason: "session-grant" };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// If Circuit Breaker is tripped, suspend auto-mode and force P4 Human Ask
|
|
69
|
+
if (this.breaker.isTripped(sessionId)) {
|
|
70
|
+
return {
|
|
71
|
+
verdict: "ask",
|
|
72
|
+
stage: "P4",
|
|
73
|
+
reason: "circuit-breaker-tripped",
|
|
74
|
+
detail: "Circuit breaker tripped due to repeated security denials. Manual confirmation required."
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// P2: Static Rules / Safe Allow
|
|
79
|
+
if (bandVerdict === "allow") {
|
|
80
|
+
this.breaker.resetConsecutive(sessionId);
|
|
81
|
+
return { verdict: "allow", stage: "P2", reason: "safe-allow" };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// P3: Optional Semantic Classifier
|
|
85
|
+
if (this.classifier && typeof this.classifier === "function") {
|
|
86
|
+
try {
|
|
87
|
+
const cls = this.classifier(execution, context);
|
|
88
|
+
if (cls && cls.verdict === "allow") {
|
|
89
|
+
this.breaker.resetConsecutive(sessionId);
|
|
90
|
+
return { verdict: "allow", stage: "P3", reason: cls.reason || "classifier-allow" };
|
|
91
|
+
}
|
|
92
|
+
if (cls && cls.verdict === "deny") {
|
|
93
|
+
this.breaker.countDeny(sessionId);
|
|
94
|
+
return { verdict: "deny", stage: "P3", reason: cls.reason || "classifier-deny" };
|
|
95
|
+
}
|
|
96
|
+
} catch (err) {
|
|
97
|
+
// Classifier error fails closed to P4 Ask
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// P4: Human Ask (uncertain syntax or unlisted command)
|
|
102
|
+
if (hit.ask) {
|
|
103
|
+
return { verdict: "ask", stage: "P4", reason: hit.reason || "uncertain-syntax", detail: hit.detail || hit.snippet };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Safe default for unclassified commands
|
|
107
|
+
return { verdict: "allow", stage: "P2", reason: "unmatched-allow" };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
package/lib/grants.js
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
// lib/grants.js
|
|
2
|
+
// Session-scoped temporary grants with TTL, usage limits, and automatic garbage collection.
|
|
3
|
+
// Fulfills level P1 (Session Grants) in SecurityEngine.
|
|
4
|
+
|
|
5
|
+
import { canonicalizeCall, hashCall } from "./canonical.js";
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_GRANT_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
8
|
+
export const DEFAULT_GRANT_MAX_USES = 1;
|
|
9
|
+
export const DEFAULT_AUTO_PRUNE_OPS = 50;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Checks if candidate string starts with prefix.
|
|
13
|
+
* @param {any} val
|
|
14
|
+
* @param {string} prefix
|
|
15
|
+
* @returns {boolean}
|
|
16
|
+
*/
|
|
17
|
+
function matchesPrefix(val, prefix) {
|
|
18
|
+
if (typeof val !== "string" || typeof prefix !== "string") return false;
|
|
19
|
+
return val.startsWith(prefix);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Evaluates whether arguments satisfy a grant's prefix / pattern requirements.
|
|
24
|
+
*
|
|
25
|
+
* @param {any} args
|
|
26
|
+
* @param {Grant} grant
|
|
27
|
+
* @returns {boolean}
|
|
28
|
+
*/
|
|
29
|
+
export function matchGrantCriteria(args, grant) {
|
|
30
|
+
// 1. Specific command prefix
|
|
31
|
+
if (grant.commandPrefix) {
|
|
32
|
+
const cmd = args && typeof args.command === "string" ? args.command : "";
|
|
33
|
+
if (!matchesPrefix(cmd, grant.commandPrefix)) return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 2. Specific path prefix
|
|
37
|
+
if (grant.pathPrefix) {
|
|
38
|
+
const targetPath = args && (args.path || args.target || args.file || "");
|
|
39
|
+
if (!matchesPrefix(targetPath, grant.pathPrefix)) return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 3. Structured prefix match
|
|
43
|
+
if (grant.prefix) {
|
|
44
|
+
if (typeof grant.prefix === "string") {
|
|
45
|
+
const cmd = args && typeof args.command === "string" ? args.command : "";
|
|
46
|
+
if (!matchesPrefix(cmd, grant.prefix)) return false;
|
|
47
|
+
} else if (typeof grant.prefix === "object" && grant.prefix !== null) {
|
|
48
|
+
for (const [key, expectedPrefix] of Object.entries(grant.prefix)) {
|
|
49
|
+
const val = args && args[key];
|
|
50
|
+
if (!matchesPrefix(val, expectedPrefix)) return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 4. Pattern matching (RegExp or predicate)
|
|
56
|
+
if (grant.argsPattern) {
|
|
57
|
+
if (typeof grant.argsPattern === "function") {
|
|
58
|
+
if (!grant.argsPattern(args)) return false;
|
|
59
|
+
} else if (typeof grant.argsPattern === "object" && grant.argsPattern !== null) {
|
|
60
|
+
for (const [key, pattern] of Object.entries(grant.argsPattern)) {
|
|
61
|
+
const val = args && args[key];
|
|
62
|
+
if (pattern instanceof RegExp) {
|
|
63
|
+
if (typeof val !== "string" || !pattern.test(val)) return false;
|
|
64
|
+
} else if (typeof pattern === "string") {
|
|
65
|
+
if (val !== pattern) return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @typedef {Object} Grant
|
|
76
|
+
* @property {string} id
|
|
77
|
+
* @property {string} sessionId
|
|
78
|
+
* @property {string} tool
|
|
79
|
+
* @property {number} createdAt
|
|
80
|
+
* @property {number} expiresAt
|
|
81
|
+
* @property {number} ttlMs
|
|
82
|
+
* @property {number} maxUses
|
|
83
|
+
* @property {number} uses
|
|
84
|
+
* @property {string} [commandPrefix]
|
|
85
|
+
* @property {string} [pathPrefix]
|
|
86
|
+
* @property {string|Record<string, string>} [prefix]
|
|
87
|
+
* @property {Record<string, RegExp|string>|Function} [argsPattern]
|
|
88
|
+
* @property {any} [match]
|
|
89
|
+
* @property {string} [callHash]
|
|
90
|
+
*/
|
|
91
|
+
|
|
92
|
+
export class SessionGrantStore {
|
|
93
|
+
/**
|
|
94
|
+
* @param {{
|
|
95
|
+
* defaultTtlMs?: number,
|
|
96
|
+
* defaultMaxUses?: number,
|
|
97
|
+
* autoPruneOps?: number
|
|
98
|
+
* }} [options]
|
|
99
|
+
*/
|
|
100
|
+
constructor(options = {}) {
|
|
101
|
+
this.defaultTtlMs = options.defaultTtlMs || DEFAULT_GRANT_TTL_MS;
|
|
102
|
+
this.defaultMaxUses = options.defaultMaxUses || DEFAULT_GRANT_MAX_USES;
|
|
103
|
+
this.autoPruneOps = options.autoPruneOps || DEFAULT_AUTO_PRUNE_OPS;
|
|
104
|
+
this._opCount = 0;
|
|
105
|
+
this._pruneTimer = null;
|
|
106
|
+
/** @type {Map<string, Grant[]>} */
|
|
107
|
+
this.sessions = new Map();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Check if a grant is expired at a given timestamp.
|
|
112
|
+
* @param {Grant} grant
|
|
113
|
+
* @param {number} [now]
|
|
114
|
+
* @returns {boolean}
|
|
115
|
+
*/
|
|
116
|
+
isExpired(grant, now = Date.now()) {
|
|
117
|
+
return Number.isFinite(grant.expiresAt) && now > grant.expiresAt;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Check if a grant has exhausted its allowed uses.
|
|
122
|
+
* @param {Grant} grant
|
|
123
|
+
* @returns {boolean}
|
|
124
|
+
*/
|
|
125
|
+
isExhausted(grant) {
|
|
126
|
+
return Number.isFinite(grant.maxUses) && grant.uses >= grant.maxUses;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Trigger automatic counter-based pruning if threshold is reached.
|
|
131
|
+
* @private
|
|
132
|
+
*/
|
|
133
|
+
_maybeAutoPrune() {
|
|
134
|
+
this._opCount += 1;
|
|
135
|
+
if (this.autoPruneOps > 0 && this._opCount >= this.autoPruneOps) {
|
|
136
|
+
this._opCount = 0;
|
|
137
|
+
this.prune();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Issue a new temporary session grant.
|
|
143
|
+
*
|
|
144
|
+
* @param {{
|
|
145
|
+
* sessionId?: string,
|
|
146
|
+
* tool: string,
|
|
147
|
+
* ttlMs?: number,
|
|
148
|
+
* maxUses?: number,
|
|
149
|
+
* commandPrefix?: string,
|
|
150
|
+
* pathPrefix?: string,
|
|
151
|
+
* prefix?: string | Record<string, string>,
|
|
152
|
+
* argsPattern?: Record<string, RegExp|string> | Function,
|
|
153
|
+
* match?: any,
|
|
154
|
+
* callHash?: string,
|
|
155
|
+
* id?: string
|
|
156
|
+
* }} options
|
|
157
|
+
* @returns {Grant}
|
|
158
|
+
*/
|
|
159
|
+
addGrant(options) {
|
|
160
|
+
if (!options || typeof options.tool !== "string") {
|
|
161
|
+
throw new TypeError("addGrant requires options.tool as string");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
this._maybeAutoPrune();
|
|
165
|
+
|
|
166
|
+
const sessionId = options.sessionId || "default";
|
|
167
|
+
const now = Date.now();
|
|
168
|
+
const ttlMs = options.ttlMs !== undefined ? options.ttlMs : this.defaultTtlMs;
|
|
169
|
+
const maxUses = options.maxUses !== undefined ? options.maxUses : this.defaultMaxUses;
|
|
170
|
+
const expiresAt = Number.isFinite(ttlMs) ? now + ttlMs : Infinity;
|
|
171
|
+
const id = options.id || ("grant_" + now + "_" + Math.random().toString(36).slice(2, 9));
|
|
172
|
+
|
|
173
|
+
const grant = {
|
|
174
|
+
id,
|
|
175
|
+
sessionId,
|
|
176
|
+
tool: options.tool.trim(),
|
|
177
|
+
createdAt: now,
|
|
178
|
+
expiresAt,
|
|
179
|
+
ttlMs,
|
|
180
|
+
maxUses,
|
|
181
|
+
uses: 0,
|
|
182
|
+
commandPrefix: options.commandPrefix,
|
|
183
|
+
pathPrefix: options.pathPrefix,
|
|
184
|
+
prefix: options.prefix,
|
|
185
|
+
argsPattern: options.argsPattern,
|
|
186
|
+
match: options.match,
|
|
187
|
+
callHash: options.callHash,
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
let list = this.sessions.get(sessionId);
|
|
191
|
+
if (!list) {
|
|
192
|
+
list = [];
|
|
193
|
+
this.sessions.set(sessionId, list);
|
|
194
|
+
}
|
|
195
|
+
list.push(grant);
|
|
196
|
+
|
|
197
|
+
return grant;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Evaluates if a tool execution is authorized by an active session grant.
|
|
202
|
+
* If a match is found, increments the grant usage counter and returns "allow".
|
|
203
|
+
*
|
|
204
|
+
* @param {string} toolName
|
|
205
|
+
* @param {any} args
|
|
206
|
+
* @param {{ sessionId?: string, now?: number }} [context]
|
|
207
|
+
* @returns {"allow" | "no-match"}
|
|
208
|
+
*/
|
|
209
|
+
decide(toolName, args, context = {}) {
|
|
210
|
+
this._maybeAutoPrune();
|
|
211
|
+
|
|
212
|
+
const sessionId = (context && context.sessionId) || "default";
|
|
213
|
+
const now = (context && context.now) || Date.now();
|
|
214
|
+
const list = this.sessions.get(sessionId);
|
|
215
|
+
|
|
216
|
+
if (!list || list.length === 0) {
|
|
217
|
+
return "no-match";
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const incomingHash = hashCall(toolName, args);
|
|
221
|
+
|
|
222
|
+
for (let i = 0; i < list.length; i++) {
|
|
223
|
+
const grant = list[i];
|
|
224
|
+
|
|
225
|
+
// Expiry & Exhaustion check
|
|
226
|
+
if (this.isExpired(grant, now) || this.isExhausted(grant)) {
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Tool match
|
|
231
|
+
if (grant.tool !== "*" && grant.tool !== toolName) {
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Hash match (if specific call was granted)
|
|
236
|
+
if (grant.callHash && grant.callHash !== incomingHash) {
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Prefix and pattern matching criteria
|
|
241
|
+
if (!matchGrantCriteria(args, grant)) {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Generic match predicate if provided
|
|
246
|
+
if (typeof grant.match === "function") {
|
|
247
|
+
if (!grant.match(toolName, args)) {
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Valid grant found: increment usage and allow
|
|
253
|
+
grant.uses += 1;
|
|
254
|
+
return "allow";
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return "no-match";
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Retrieves active, non-expired grants for a session.
|
|
262
|
+
* @param {string} [sessionId]
|
|
263
|
+
* @param {number} [now]
|
|
264
|
+
* @returns {Grant[]}
|
|
265
|
+
*/
|
|
266
|
+
getGrants(sessionId, now = Date.now()) {
|
|
267
|
+
const key = sessionId || "default";
|
|
268
|
+
const list = this.sessions.get(key) || [];
|
|
269
|
+
return list
|
|
270
|
+
.filter((g) => !this.isExpired(g, now) && !this.isExhausted(g))
|
|
271
|
+
.map((g) => ({ ...g }));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Revoke a specific grant by ID.
|
|
276
|
+
* @param {string} grantId
|
|
277
|
+
* @param {string} [sessionId]
|
|
278
|
+
* @returns {boolean} True if grant was found and revoked
|
|
279
|
+
*/
|
|
280
|
+
revokeGrant(grantId, sessionId) {
|
|
281
|
+
if (sessionId) {
|
|
282
|
+
const list = this.sessions.get(sessionId);
|
|
283
|
+
if (!list) return false;
|
|
284
|
+
const idx = list.findIndex((g) => g.id === grantId);
|
|
285
|
+
if (idx !== -1) {
|
|
286
|
+
list.splice(idx, 1);
|
|
287
|
+
if (list.length === 0) this.sessions.delete(sessionId);
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Search across all sessions if sessionId not specified
|
|
294
|
+
for (const [sId, list] of this.sessions.entries()) {
|
|
295
|
+
const idx = list.findIndex((g) => g.id === grantId);
|
|
296
|
+
if (idx !== -1) {
|
|
297
|
+
list.splice(idx, 1);
|
|
298
|
+
if (list.length === 0) this.sessions.delete(sId);
|
|
299
|
+
return true;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Garbage-collect expired and exhausted grants across sessions.
|
|
307
|
+
* Cleans up empty session entries to prevent memory leaks.
|
|
308
|
+
*
|
|
309
|
+
* @param {{ now?: number, sessionId?: string }} [options]
|
|
310
|
+
* @returns {{ removedCount: number, remainingCount: number, emptySessionsPruned: number }}
|
|
311
|
+
*/
|
|
312
|
+
prune(options = {}) {
|
|
313
|
+
const now = options.now || Date.now();
|
|
314
|
+
let removedCount = 0;
|
|
315
|
+
let remainingCount = 0;
|
|
316
|
+
let emptySessionsPruned = 0;
|
|
317
|
+
|
|
318
|
+
const targetSessions = options.sessionId
|
|
319
|
+
? [options.sessionId]
|
|
320
|
+
: Array.from(this.sessions.keys());
|
|
321
|
+
|
|
322
|
+
for (const sId of targetSessions) {
|
|
323
|
+
const list = this.sessions.get(sId);
|
|
324
|
+
if (!list) continue;
|
|
325
|
+
|
|
326
|
+
const active = [];
|
|
327
|
+
for (const grant of list) {
|
|
328
|
+
if (this.isExpired(grant, now) || this.isExhausted(grant)) {
|
|
329
|
+
removedCount += 1;
|
|
330
|
+
} else {
|
|
331
|
+
active.push(grant);
|
|
332
|
+
remainingCount += 1;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (active.length === 0) {
|
|
337
|
+
this.sessions.delete(sId);
|
|
338
|
+
emptySessionsPruned += 1;
|
|
339
|
+
} else {
|
|
340
|
+
this.sessions.set(sId, active);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return { removedCount, remainingCount, emptySessionsPruned };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Start periodic timer-based background pruning.
|
|
349
|
+
* @param {number} [intervalMs=60000]
|
|
350
|
+
*/
|
|
351
|
+
startPeriodicPrune(intervalMs = 60000) {
|
|
352
|
+
this.stopPeriodicPrune();
|
|
353
|
+
this._pruneTimer = setInterval(() => {
|
|
354
|
+
this.prune();
|
|
355
|
+
}, intervalMs);
|
|
356
|
+
if (this._pruneTimer && typeof this._pruneTimer.unref === "function") {
|
|
357
|
+
this._pruneTimer.unref();
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Stop periodic timer-based background pruning.
|
|
363
|
+
*/
|
|
364
|
+
stopPeriodicPrune() {
|
|
365
|
+
if (this._pruneTimer) {
|
|
366
|
+
clearInterval(this._pruneTimer);
|
|
367
|
+
this._pruneTimer = null;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Clear grants for a session or entirely.
|
|
373
|
+
* @param {string} [sessionId]
|
|
374
|
+
*/
|
|
375
|
+
clear(sessionId) {
|
|
376
|
+
if (sessionId) {
|
|
377
|
+
this.sessions.delete(sessionId);
|
|
378
|
+
} else {
|
|
379
|
+
this.sessions.clear();
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
package/lib/index.js
CHANGED
|
@@ -130,3 +130,27 @@ export function apply(ctx, config) {
|
|
|
130
130
|
}
|
|
131
131
|
return mount();
|
|
132
132
|
}
|
|
133
|
+
|
|
134
|
+
export { SecurityEngine } from "./engine.js";
|
|
135
|
+
export { BandsEngine } from "./bands.js";
|
|
136
|
+
export {
|
|
137
|
+
CircuitBreaker,
|
|
138
|
+
CIRCUIT_STATE_ACTIVE,
|
|
139
|
+
CIRCUIT_STATE_TRIPPED,
|
|
140
|
+
CIRCUIT_STATE_RESUMED,
|
|
141
|
+
} from "./breaker.js";
|
|
142
|
+
export {
|
|
143
|
+
SessionGrantStore,
|
|
144
|
+
DEFAULT_GRANT_TTL_MS,
|
|
145
|
+
DEFAULT_GRANT_MAX_USES,
|
|
146
|
+
matchGrantCriteria,
|
|
147
|
+
} from "./grants.js";
|
|
148
|
+
export {
|
|
149
|
+
canonicalizeCall,
|
|
150
|
+
canonicalString,
|
|
151
|
+
hashCall,
|
|
152
|
+
sortKeys,
|
|
153
|
+
} from "./canonical.js";
|
|
154
|
+
export { expandHome } from "./paths.js";
|
|
155
|
+
export { splitSubcommands } from "./tokenizer.js";
|
|
156
|
+
|
package/lib/inspect.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { DYNAMIC_MARKER, HEREDOC_OPS, REDIR_OPS, SPLIT_OPS, tokenize, readCommandSubstitution, readBacktick, readParameterExpansion } from "./tokenizer.js";
|
|
2
|
+
import { expandHome } from "./paths.js";
|
|
2
3
|
import { askMessage, denyMessage, sessionTag } from "./messages.js";
|
|
3
4
|
|
|
4
|
-
export { DYNAMIC_MARKER, tokenize } from "./tokenizer.js";
|
|
5
|
+
export { DYNAMIC_MARKER, tokenize, splitSubcommands } from "./tokenizer.js";
|
|
5
6
|
export { askMessage, denyMessage, sessionTag } from "./messages.js";
|
|
7
|
+
export { expandHome } from "./paths.js";
|
|
6
8
|
|
|
7
9
|
// Pure command/path inspector for dsh-approval-gate.
|
|
8
10
|
// No Cordis imports: unit tests run this without the harness.
|
|
@@ -36,11 +38,12 @@ export function basename(path) {
|
|
|
36
38
|
return parts[parts.length - 1] || "";
|
|
37
39
|
}
|
|
38
40
|
|
|
39
|
-
export function isProtectedPath(path) {
|
|
41
|
+
export function isProtectedPath(path, env = process.env) {
|
|
40
42
|
if (typeof path !== "string" || path.trim() === "") return false;
|
|
41
|
-
const
|
|
43
|
+
const expanded = expandHome(path, env);
|
|
44
|
+
const normalized = expanded.replace(/\\/g, "/");
|
|
42
45
|
if (normalized.includes("/etc/sudoers.d/")) return true;
|
|
43
|
-
const base = basename(
|
|
46
|
+
const base = basename(normalized);
|
|
44
47
|
if (base === ".env" || base.startsWith(".env.")) return true;
|
|
45
48
|
if (/^credentials\.ya?ml$/i.test(base)) return true;
|
|
46
49
|
if (/^settings\.ya?ml$/i.test(base)) return true;
|
package/lib/paths.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// lib/paths.js
|
|
2
|
+
// Home path expansion and path boundary helpers for dsh-approval-gate.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Expand `~`, `${HOME}`, `$HOME`, and `%USERPROFILE%` at the start of or within a path.
|
|
6
|
+
*
|
|
7
|
+
* @param {string} p - Path or string containing path references
|
|
8
|
+
* @param {Record<string, string|undefined>} [env] - Environment dictionary (defaults to process.env)
|
|
9
|
+
* @returns {string} Normalized path with home expanded
|
|
10
|
+
*/
|
|
11
|
+
export function expandHome(p, env = process.env) {
|
|
12
|
+
if (typeof p !== "string" || p.trim() === "") return p;
|
|
13
|
+
const home = (env && (env.HOME || env.USERPROFILE)) || "";
|
|
14
|
+
if (!home) return p;
|
|
15
|
+
const cleanHome = home.replace(/[/\\]+$/, "");
|
|
16
|
+
|
|
17
|
+
let res = p;
|
|
18
|
+
// Expand leading ~ or ~/ or ~\
|
|
19
|
+
res = res.replace(/^~(?=$|[/\\])/, cleanHome);
|
|
20
|
+
// Expand ${HOME} and $HOME
|
|
21
|
+
res = res.replace(/\$\{HOME\}|\$HOME/g, cleanHome);
|
|
22
|
+
// Expand %USERPROFILE% (Windows)
|
|
23
|
+
res = res.replace(/%USERPROFILE%/gi, cleanHome);
|
|
24
|
+
return res;
|
|
25
|
+
}
|
package/lib/tokenizer.js
CHANGED
|
@@ -317,3 +317,64 @@ export function tokenize(src) {
|
|
|
317
317
|
return { ok: true, tokens: tokens };
|
|
318
318
|
}
|
|
319
319
|
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Splits a complex shell command line into individual subcommands across pipeline
|
|
323
|
+
* and sequence operators (&&, ||, ;, |, &).
|
|
324
|
+
* Respects single quotes, double quotes, subshells $(...), backticks, and heredocs.
|
|
325
|
+
*
|
|
326
|
+
* @param {string} command - Raw shell command
|
|
327
|
+
* @returns {Array<{ command: string, argv: string[], operator: string|null, tokens: Array<any>, ok: boolean }>}
|
|
328
|
+
*/
|
|
329
|
+
export function splitSubcommands(command) {
|
|
330
|
+
if (typeof command !== "string" || command.trim() === "") return [];
|
|
331
|
+
const tok = tokenize(command);
|
|
332
|
+
const result = [];
|
|
333
|
+
let currentTokens = [];
|
|
334
|
+
let prevOp = null;
|
|
335
|
+
|
|
336
|
+
function pushSub(tokens, op) {
|
|
337
|
+
if (!tokens.length) return;
|
|
338
|
+
const words = tokens
|
|
339
|
+
.filter((t) => t.kind === "word")
|
|
340
|
+
.map((t) => t.value);
|
|
341
|
+
const subStr = tokens
|
|
342
|
+
.map((t) => {
|
|
343
|
+
if (t.kind === "heredoc") return `<<${t.marker}`;
|
|
344
|
+
return t.value;
|
|
345
|
+
})
|
|
346
|
+
.join(" ")
|
|
347
|
+
.trim();
|
|
348
|
+
result.push({
|
|
349
|
+
command: subStr,
|
|
350
|
+
argv: words,
|
|
351
|
+
operator: op,
|
|
352
|
+
tokens: tokens,
|
|
353
|
+
ok: tok.ok,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
for (const t of tok.tokens) {
|
|
358
|
+
if (t.kind === "op" && SPLIT_OPS.has(t.value)) {
|
|
359
|
+
pushSub(currentTokens, prevOp);
|
|
360
|
+
currentTokens = [];
|
|
361
|
+
prevOp = t.value;
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
currentTokens.push(t);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
pushSub(currentTokens, prevOp);
|
|
368
|
+
|
|
369
|
+
if (result.length === 0 && command.trim().length > 0) {
|
|
370
|
+
result.push({
|
|
371
|
+
command: command.trim(),
|
|
372
|
+
argv: command.trim().split(/\s+/),
|
|
373
|
+
operator: null,
|
|
374
|
+
tokens: [],
|
|
375
|
+
ok: false,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return result;
|
|
380
|
+
}
|
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.7",
|
|
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",
|