@goodandready/dsh-approval-gate 0.1.6 → 0.1.8
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 +98 -6
- package/lib/breaker.js +153 -27
- package/lib/canonical.js +88 -0
- package/lib/engine.js +5 -3
- package/lib/grants.js +383 -0
- package/lib/index.js +19 -1
- package/lib/inspect.js +64 -15
- package/lib/messages.js +1 -0
- package/lib/paths.js +246 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.8
|
|
4
|
+
|
|
5
|
+
- Paths: нормализация разделителей путей `normalizeSeparators` для кроссплатформенной обработки Windows (`\`) и POSIX (`/`) слешей (Refs: #109).
|
|
6
|
+
- Paths: канонизация путей и превентивная защита от path traversal обходов (`..`, `.` сегменты) `canonicalizePath` и `isPathTraversal` (Refs: #94).
|
|
7
|
+
- Security: блокировка модификации скрытых системных файлов и каталогов конфигурации (`.git`, `.env`, `.ssh`) в путях инструментов и перенаправлениях shell (Refs: #108).
|
|
8
|
+
- Security: защита от утечки переменных окружения и секретов через команды `env`, `printenv`, `export` (Refs: #105).
|
|
9
|
+
- Bands: безусловное разрешение безопасных read-only инструментов и инспекций файловой системы через Band 1 (`isReadOnlyTool`, `isSafeInspectionCommand`) (Refs: #120).
|
|
10
|
+
|
|
11
|
+
## 0.1.7
|
|
12
|
+
|
|
13
|
+
- Canonical: каноническая сериализация вызовов инструментов `canonicalizeCall` со стабильной рекурсивной сортировкой ключей и хэшированием `hashCall` (Refs: #124).
|
|
14
|
+
- Grants: хранилище временных сессионных грантов `SessionGrantStore` с ограничением по времени жизни (TTL) и максимальному числу вызовов (`maxUses`) (Refs: #123).
|
|
15
|
+
- Grants: поддержка точечных грантов на уровне инструмента, префикса аргументов (`commandPrefix`, `pathPrefix`, `prefix`) и регулярных выражений `argsPattern` (Refs: #132).
|
|
16
|
+
- Grants: автоматическая сборка мусора и очистка просроченных/исчерпанных сессионных грантов `prune()` с таймером и счетчиком операций (Refs: #130).
|
|
17
|
+
- Breaker: детальное логирование переходов состояний предохранителя сессии `CircuitBreaker` (`active -> tripped -> resumed`), история событий и поддержка подписок `onStateChange` (Refs: #121).
|
|
18
|
+
|
|
3
19
|
## 0.1.6
|
|
4
20
|
|
|
5
21
|
- Security: автоматическое раскрытие домашних путей `expandHome` (`~`, `$HOME`, `${HOME}`, `%USERPROFILE%`) в путях файлов, аргументах инструментов записи и перенаправлениях shell (Refs: #114).
|
package/lib/bands.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// lib/bands.js
|
|
2
2
|
// Two-band deterministic filtering engine for dsh-approval-gate.
|
|
3
3
|
// Band 0: Hard Deny (fast regexes for exfiltration, fork-bombs, disk wipes)
|
|
4
|
-
// Band 1: Safe Allow (prefix globs & curated safe inspection commands)
|
|
4
|
+
// Band 1: Safe Allow (prefix globs & curated safe read-only inspection commands and tools)
|
|
5
5
|
|
|
6
6
|
export const DEFAULT_DENY_PATTERNS = Object.freeze([
|
|
7
7
|
/\b(?:nc|ncat|netcat)\s+.*-e\s+/i,
|
|
@@ -16,20 +16,50 @@ export const DEFAULT_DENY_PATTERNS = Object.freeze([
|
|
|
16
16
|
]);
|
|
17
17
|
|
|
18
18
|
export const DEFAULT_ALLOW_GLOBS = Object.freeze([
|
|
19
|
+
// Git inspection commands
|
|
19
20
|
"git status*",
|
|
20
21
|
"git diff*",
|
|
21
22
|
"git log*",
|
|
22
23
|
"git branch*",
|
|
23
24
|
"git show*",
|
|
24
|
-
"
|
|
25
|
-
"
|
|
25
|
+
"git remote*",
|
|
26
|
+
"git tag*",
|
|
27
|
+
"git rev-parse*",
|
|
28
|
+
|
|
29
|
+
// File viewing & analysis
|
|
26
30
|
"cat *",
|
|
27
31
|
"head *",
|
|
28
32
|
"tail *",
|
|
33
|
+
"more *",
|
|
34
|
+
"less *",
|
|
29
35
|
"wc *",
|
|
36
|
+
"file *",
|
|
37
|
+
"stat *",
|
|
38
|
+
"diff *",
|
|
39
|
+
|
|
40
|
+
// Search & finding
|
|
41
|
+
"grep *",
|
|
42
|
+
"fgrep *",
|
|
43
|
+
"egrep *",
|
|
44
|
+
"rg *",
|
|
45
|
+
"ag *",
|
|
46
|
+
"find *",
|
|
47
|
+
|
|
48
|
+
// Directory listing & environment info
|
|
49
|
+
"ls*",
|
|
50
|
+
"dir*",
|
|
51
|
+
"pwd*",
|
|
30
52
|
"which *",
|
|
31
53
|
"whereis *",
|
|
32
54
|
"whoami*",
|
|
55
|
+
"hostname*",
|
|
56
|
+
"uname*",
|
|
57
|
+
"date*",
|
|
58
|
+
"uptime*",
|
|
59
|
+
"echo *",
|
|
60
|
+
"printf *",
|
|
61
|
+
|
|
62
|
+
// Language / runtime versions & test runners
|
|
33
63
|
"node --version*",
|
|
34
64
|
"node -v*",
|
|
35
65
|
"npm test*",
|
|
@@ -45,21 +75,32 @@ export const DEFAULT_ALLOW_GLOBS = Object.freeze([
|
|
|
45
75
|
"pytest*",
|
|
46
76
|
"python --version*",
|
|
47
77
|
"python3 --version*",
|
|
48
|
-
"echo *",
|
|
49
78
|
]);
|
|
50
79
|
|
|
51
80
|
export const DEFAULT_READ_ONLY_TOOLS = Object.freeze([
|
|
52
81
|
"read",
|
|
53
82
|
"read_file",
|
|
54
83
|
"readFile",
|
|
84
|
+
"read_file_content",
|
|
55
85
|
"view_file",
|
|
86
|
+
"view",
|
|
87
|
+
"show",
|
|
56
88
|
"glob",
|
|
57
89
|
"grep",
|
|
58
90
|
"find_files",
|
|
91
|
+
"find_by_name",
|
|
59
92
|
"search_code",
|
|
60
93
|
"file_search",
|
|
61
94
|
"list_dir",
|
|
62
95
|
"list_directory",
|
|
96
|
+
"directory_list",
|
|
97
|
+
"tree",
|
|
98
|
+
"read_url_content",
|
|
99
|
+
"fetch_web_page",
|
|
100
|
+
"web_search",
|
|
101
|
+
"search_web",
|
|
102
|
+
"list_resources",
|
|
103
|
+
"read_resource",
|
|
63
104
|
]);
|
|
64
105
|
|
|
65
106
|
/**
|
|
@@ -79,7 +120,11 @@ export function compileGlob(pattern) {
|
|
|
79
120
|
|
|
80
121
|
export class BandsEngine {
|
|
81
122
|
/**
|
|
82
|
-
* @param {{
|
|
123
|
+
* @param {{
|
|
124
|
+
* denyPatterns?: Array<RegExp|string>,
|
|
125
|
+
* allowGlobs?: Array<RegExp|string>,
|
|
126
|
+
* readOnlyTools?: Array<string>
|
|
127
|
+
* }} [options]
|
|
83
128
|
*/
|
|
84
129
|
constructor(options = {}) {
|
|
85
130
|
this.denyPatterns = (options.denyPatterns || DEFAULT_DENY_PATTERNS).map((p) =>
|
|
@@ -91,6 +136,52 @@ export class BandsEngine {
|
|
|
91
136
|
this.readOnlyTools = new Set(options.readOnlyTools || DEFAULT_READ_ONLY_TOOLS);
|
|
92
137
|
}
|
|
93
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Check if a tool is configured as an unconditional read-only tool.
|
|
141
|
+
* @param {string} tool
|
|
142
|
+
* @returns {boolean}
|
|
143
|
+
*/
|
|
144
|
+
isReadOnlyTool(tool) {
|
|
145
|
+
return typeof tool === "string" && this.readOnlyTools.has(tool.trim());
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Check if a command matches safe inspection patterns.
|
|
150
|
+
* @param {string} command
|
|
151
|
+
* @returns {boolean}
|
|
152
|
+
*/
|
|
153
|
+
isSafeInspectionCommand(command) {
|
|
154
|
+
if (typeof command !== "string" || command.trim() === "") return false;
|
|
155
|
+
const text = command.trim();
|
|
156
|
+
for (const pattern of this.denyPatterns) {
|
|
157
|
+
if (pattern.test(text)) return false;
|
|
158
|
+
}
|
|
159
|
+
for (const glob of this.allowGlobs) {
|
|
160
|
+
if (glob.test(text)) return true;
|
|
161
|
+
}
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Register an additional read-only tool name.
|
|
167
|
+
* @param {string} tool
|
|
168
|
+
*/
|
|
169
|
+
addReadOnlyTool(tool) {
|
|
170
|
+
if (typeof tool === "string" && tool.trim()) {
|
|
171
|
+
this.readOnlyTools.add(tool.trim());
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Register an additional allow-glob pattern.
|
|
177
|
+
* @param {string} globPattern
|
|
178
|
+
*/
|
|
179
|
+
addAllowGlob(globPattern) {
|
|
180
|
+
if (typeof globPattern === "string" && globPattern.trim()) {
|
|
181
|
+
this.allowGlobs.push(compileGlob(globPattern.trim()));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
94
185
|
/**
|
|
95
186
|
* Evaluates an execution request through Band 0 and Band 1.
|
|
96
187
|
* @param {string} tool - Tool name
|
|
@@ -98,7 +189,7 @@ export class BandsEngine {
|
|
|
98
189
|
* @returns {"deny" | "allow" | "unmatched"}
|
|
99
190
|
*/
|
|
100
191
|
evaluate(tool, command) {
|
|
101
|
-
if (this.
|
|
192
|
+
if (this.isReadOnlyTool(tool)) {
|
|
102
193
|
return "allow";
|
|
103
194
|
}
|
|
104
195
|
|
|
@@ -123,3 +214,4 @@ export class BandsEngine {
|
|
|
123
214
|
return "unmatched";
|
|
124
215
|
}
|
|
125
216
|
}
|
|
217
|
+
|
package/lib/breaker.js
CHANGED
|
@@ -1,34 +1,146 @@
|
|
|
1
1
|
// lib/breaker.js
|
|
2
|
-
// Session-scoped Circuit Breaker for dsh-approval-gate.
|
|
2
|
+
// Session-scoped Circuit Breaker for dsh-approval-gate with state transition events and history.
|
|
3
3
|
// Protects against agent thrashing / loops when blocked by security gates.
|
|
4
4
|
|
|
5
5
|
export const DEFAULT_MAX_CONSECUTIVE_DENIES = 3;
|
|
6
6
|
export const DEFAULT_MAX_TOTAL_DENIES = 20;
|
|
7
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
|
+
|
|
8
23
|
export class CircuitBreaker {
|
|
9
24
|
/**
|
|
10
|
-
* @param {{
|
|
25
|
+
* @param {{
|
|
26
|
+
* maxConsecutive?: number,
|
|
27
|
+
* maxTotal?: number,
|
|
28
|
+
* logger?: { info?: (msg: string) => void, warn?: (msg: string) => void }
|
|
29
|
+
* }} [options]
|
|
11
30
|
*/
|
|
12
31
|
constructor(options = {}) {
|
|
13
32
|
this.maxConsecutive = options.maxConsecutive || DEFAULT_MAX_CONSECUTIVE_DENIES;
|
|
14
33
|
this.maxTotal = options.maxTotal || DEFAULT_MAX_TOTAL_DENIES;
|
|
15
|
-
|
|
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[] }>} */
|
|
16
38
|
this.sessions = new Map();
|
|
17
39
|
}
|
|
18
40
|
|
|
19
41
|
/**
|
|
20
|
-
*
|
|
21
|
-
* @param {
|
|
22
|
-
* @returns {
|
|
42
|
+
* Register a state change listener callback.
|
|
43
|
+
* @param {(event: CircuitTransitionEvent) => void} fn
|
|
44
|
+
* @returns {() => void} Unsubscribe function
|
|
23
45
|
*/
|
|
24
|
-
|
|
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) {
|
|
25
97
|
const key = sessionId || "default";
|
|
26
98
|
let state = this.sessions.get(key);
|
|
27
99
|
if (!state) {
|
|
28
|
-
state = {
|
|
100
|
+
state = {
|
|
101
|
+
consecutive: 0,
|
|
102
|
+
total: 0,
|
|
103
|
+
tripped: false,
|
|
104
|
+
state: CIRCUIT_STATE_ACTIVE,
|
|
105
|
+
history: [],
|
|
106
|
+
};
|
|
29
107
|
this.sessions.set(key, state);
|
|
30
108
|
}
|
|
31
|
-
return
|
|
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 }));
|
|
32
144
|
}
|
|
33
145
|
|
|
34
146
|
/**
|
|
@@ -37,8 +149,7 @@ export class CircuitBreaker {
|
|
|
37
149
|
* @returns {boolean}
|
|
38
150
|
*/
|
|
39
151
|
isTripped(sessionId) {
|
|
40
|
-
const
|
|
41
|
-
const state = this.sessions.get(key);
|
|
152
|
+
const state = this._getOrCreate(sessionId);
|
|
42
153
|
return Boolean(state && state.tripped);
|
|
43
154
|
}
|
|
44
155
|
|
|
@@ -49,18 +160,23 @@ export class CircuitBreaker {
|
|
|
49
160
|
*/
|
|
50
161
|
countDeny(sessionId) {
|
|
51
162
|
const key = sessionId || "default";
|
|
52
|
-
|
|
53
|
-
if (!state) {
|
|
54
|
-
state = { consecutive: 0, total: 0, tripped: false };
|
|
55
|
-
this.sessions.set(key, state);
|
|
56
|
-
}
|
|
163
|
+
const state = this._getOrCreate(key);
|
|
57
164
|
state.consecutive += 1;
|
|
58
165
|
state.total += 1;
|
|
166
|
+
|
|
59
167
|
const wasTripped = state.tripped;
|
|
60
|
-
|
|
168
|
+
const isConsecutiveTrip = state.consecutive >= this.maxConsecutive;
|
|
169
|
+
const isTotalTrip = state.total >= this.maxTotal;
|
|
170
|
+
|
|
171
|
+
if ((isConsecutiveTrip || isTotalTrip) && !wasTripped) {
|
|
61
172
|
state.tripped = true;
|
|
62
|
-
|
|
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;
|
|
63
178
|
}
|
|
179
|
+
|
|
64
180
|
return false;
|
|
65
181
|
}
|
|
66
182
|
|
|
@@ -70,21 +186,30 @@ export class CircuitBreaker {
|
|
|
70
186
|
*/
|
|
71
187
|
resetConsecutive(sessionId) {
|
|
72
188
|
const key = sessionId || "default";
|
|
73
|
-
const state = this.
|
|
74
|
-
if (state
|
|
189
|
+
const state = this._getOrCreate(key);
|
|
190
|
+
if (state.consecutive > 0) {
|
|
191
|
+
state.consecutive = 0;
|
|
192
|
+
}
|
|
75
193
|
}
|
|
76
194
|
|
|
77
195
|
/**
|
|
78
|
-
* Fully resume auto-mode after human approval.
|
|
196
|
+
* Fully resume auto-mode after human approval or admin action.
|
|
79
197
|
* @param {string} [sessionId]
|
|
198
|
+
* @param {string} [reason="manual-human-resume"]
|
|
80
199
|
*/
|
|
81
|
-
resume(sessionId) {
|
|
200
|
+
resume(sessionId, reason = "manual-human-resume") {
|
|
82
201
|
const key = sessionId || "default";
|
|
83
|
-
const state = this.
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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");
|
|
88
213
|
}
|
|
89
214
|
}
|
|
90
215
|
|
|
@@ -95,3 +220,4 @@ export class CircuitBreaker {
|
|
|
95
220
|
this.sessions.clear();
|
|
96
221
|
}
|
|
97
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
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { BandsEngine } from "./bands.js";
|
|
11
11
|
import { CircuitBreaker } from "./breaker.js";
|
|
12
|
+
import { SessionGrantStore } from "./grants.js";
|
|
12
13
|
import { inspectExecution } from "./inspect.js";
|
|
13
14
|
|
|
14
15
|
export class SecurityEngine {
|
|
@@ -18,7 +19,7 @@ export class SecurityEngine {
|
|
|
18
19
|
* breaker?: CircuitBreaker,
|
|
19
20
|
* inspectConfig?: { toolName?: string, fileWriteTools?: string[] },
|
|
20
21
|
* classifier?: ((execution: any, context?: any) => { verdict: "allow"|"deny", reason?: string }),
|
|
21
|
-
* grantRegistry?: { decide: (tool: string, args: any) => "allow"|"no-match" }
|
|
22
|
+
* grantRegistry?: { decide: (tool: string, args: any, context?: any) => "allow"|"no-match" }
|
|
22
23
|
* }} [options]
|
|
23
24
|
*/
|
|
24
25
|
constructor(options = {}) {
|
|
@@ -26,7 +27,7 @@ export class SecurityEngine {
|
|
|
26
27
|
this.breaker = options.breaker || new CircuitBreaker(options);
|
|
27
28
|
this.inspectConfig = options.inspectConfig || {};
|
|
28
29
|
this.classifier = options.classifier || null;
|
|
29
|
-
this.grantRegistry = options.grantRegistry ||
|
|
30
|
+
this.grantRegistry = options.grantRegistry || new SessionGrantStore(options);
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
/**
|
|
@@ -57,7 +58,7 @@ export class SecurityEngine {
|
|
|
57
58
|
|
|
58
59
|
// P1: Session Grants
|
|
59
60
|
if (this.grantRegistry && typeof this.grantRegistry.decide === "function") {
|
|
60
|
-
const grantMatch = this.grantRegistry.decide(toolName, execution.arguments);
|
|
61
|
+
const grantMatch = this.grantRegistry.decide(toolName, execution.arguments, context);
|
|
61
62
|
if (grantMatch === "allow") {
|
|
62
63
|
this.breaker.resetConsecutive(sessionId);
|
|
63
64
|
return { verdict: "allow", stage: "P1", reason: "session-grant" };
|
|
@@ -106,3 +107,4 @@ export class SecurityEngine {
|
|
|
106
107
|
return { verdict: "allow", stage: "P2", reason: "unmatched-allow" };
|
|
107
108
|
}
|
|
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
|
@@ -133,6 +133,24 @@ export function apply(ctx, config) {
|
|
|
133
133
|
|
|
134
134
|
export { SecurityEngine } from "./engine.js";
|
|
135
135
|
export { BandsEngine } from "./bands.js";
|
|
136
|
-
export {
|
|
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";
|
|
137
154
|
export { expandHome } from "./paths.js";
|
|
138
155
|
export { splitSubcommands } from "./tokenizer.js";
|
|
156
|
+
|
package/lib/inspect.js
CHANGED
|
@@ -1,10 +1,54 @@
|
|
|
1
|
+
|
|
2
|
+
export function checkEnvExfiltration(words, fullCommand = "") {
|
|
3
|
+
if (!Array.isArray(words) || words.length === 0) return { deny: false };
|
|
4
|
+
const cmd = basename(words[0]);
|
|
5
|
+
|
|
6
|
+
// 1. Reading /proc/*/environ
|
|
7
|
+
if (fullCommand && /\/proc\/(?:self|\$\$|\d+)\/environ\b/i.test(fullCommand)) {
|
|
8
|
+
return { deny: true, reason: "env-dump-leak", detail: "Attempted read of process environ file" };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// 2. Bare env
|
|
12
|
+
if (cmd === "env") {
|
|
13
|
+
if (words.length === 1) {
|
|
14
|
+
return { deny: true, reason: "env-dump-leak", detail: "Bare env command dumps all process secrets" };
|
|
15
|
+
}
|
|
16
|
+
const nonEnvWords = words.slice(1).filter((w) => !w.startsWith("-"));
|
|
17
|
+
const onlyAssignments = nonEnvWords.length > 0 && nonEnvWords.every((w) => w.includes("="));
|
|
18
|
+
if (nonEnvWords.length === 0 || onlyAssignments) {
|
|
19
|
+
return { deny: true, reason: "env-dump-leak", detail: "env invocation without command dumps environment" };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// 3. Bare printenv or sensitive key read
|
|
24
|
+
if (cmd === "printenv") {
|
|
25
|
+
if (words.length === 1) {
|
|
26
|
+
return { deny: true, reason: "env-dump-leak", detail: "Bare printenv command dumps all process secrets" };
|
|
27
|
+
}
|
|
28
|
+
for (const w of words.slice(1)) {
|
|
29
|
+
if (/(?:secret|token|key|password|credential|auth|dsh_)/i.test(w)) {
|
|
30
|
+
return { deny: true, reason: "env-dump-leak", detail: "printenv querying sensitive variable" };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 4. export without assignment
|
|
36
|
+
if (cmd === "export") {
|
|
37
|
+
if (words.length === 1 || (words.length === 2 && words[1] === "-p")) {
|
|
38
|
+
return { deny: true, reason: "env-dump-leak", detail: "export without assignment dumps environment" };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return { deny: false };
|
|
43
|
+
}
|
|
44
|
+
|
|
1
45
|
import { DYNAMIC_MARKER, HEREDOC_OPS, REDIR_OPS, SPLIT_OPS, tokenize, readCommandSubstitution, readBacktick, readParameterExpansion } from "./tokenizer.js";
|
|
2
|
-
import { expandHome } from "./paths.js";
|
|
46
|
+
import { expandHome, isProtectedConfigPath } from "./paths.js";
|
|
3
47
|
import { askMessage, denyMessage, sessionTag } from "./messages.js";
|
|
4
48
|
|
|
5
49
|
export { DYNAMIC_MARKER, tokenize, splitSubcommands } from "./tokenizer.js";
|
|
6
50
|
export { askMessage, denyMessage, sessionTag } from "./messages.js";
|
|
7
|
-
export { expandHome } from "./paths.js";
|
|
51
|
+
export { expandHome, isProtectedConfigPath } from "./paths.js";
|
|
8
52
|
|
|
9
53
|
// Pure command/path inspector for dsh-approval-gate.
|
|
10
54
|
// No Cordis imports: unit tests run this without the harness.
|
|
@@ -40,19 +84,7 @@ export function basename(path) {
|
|
|
40
84
|
|
|
41
85
|
export function isProtectedPath(path, env = process.env) {
|
|
42
86
|
if (typeof path !== "string" || path.trim() === "") return false;
|
|
43
|
-
|
|
44
|
-
const normalized = expanded.replace(/\\/g, "/");
|
|
45
|
-
if (normalized.includes("/etc/sudoers.d/")) return true;
|
|
46
|
-
const base = basename(normalized);
|
|
47
|
-
if (base === ".env" || base.startsWith(".env.")) return true;
|
|
48
|
-
if (/^credentials\.ya?ml$/i.test(base)) return true;
|
|
49
|
-
if (/^settings\.ya?ml$/i.test(base)) return true;
|
|
50
|
-
if (base === "cordis.patch.yml") return true;
|
|
51
|
-
if (/\.(pem|key|crt|p12|pfx)$/i.test(base)) return true;
|
|
52
|
-
if (/^(id_rsa|id_ed25519|id_ecdsa)$/.test(base)) return true;
|
|
53
|
-
if (/^(shadow|gshadow|shadow-|gshadow-|sudoers|authorized_keys2?)$/i.test(base)) return true;
|
|
54
|
-
if (/(?:^|[._-])(?:secret|token|credential|passwd|api[_-]?key)(?:$|[._-])/i.test(base)) return true;
|
|
55
|
-
return false;
|
|
87
|
+
return isProtectedConfigPath(path, env);
|
|
56
88
|
}
|
|
57
89
|
|
|
58
90
|
function wordsOf(seg) {
|
|
@@ -190,6 +222,18 @@ function inspectArgv(argv, depth, hasHeredoc) {
|
|
|
190
222
|
const cmd = basename(argv[0]);
|
|
191
223
|
const rest = argv.slice(1);
|
|
192
224
|
|
|
225
|
+
const envHit = checkEnvExfiltration(argv, argv.join(" "));
|
|
226
|
+
if (envHit.deny) {
|
|
227
|
+
return deny("env-dump-leak", envHit.detail || argv.join(" "));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (cmd === "env") {
|
|
231
|
+
const sub = rest.filter(a => !a.startsWith("-") && !a.includes("="));
|
|
232
|
+
if (sub.length > 0) {
|
|
233
|
+
return inspectArgv(sub, depth, hasHeredoc);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
193
237
|
if (cmd === "sudo" || cmd === "command" || cmd === "builtin" || cmd === "nohup" || cmd === "time" || cmd === "nice" || cmd === "ionice" || cmd === "stdbuf" || cmd === "timeout") {
|
|
194
238
|
const arity = new Set(["-u", "--user", "-g", "--group", "-p", "-s", "--signal", "-k", "--kill-after", "-o", "-e", "-i"]);
|
|
195
239
|
let next = skipWrapper(rest, arity);
|
|
@@ -456,6 +500,11 @@ export function inspectBashCommand(command, depth) {
|
|
|
456
500
|
|
|
457
501
|
const segs = splitSegments(tokens);
|
|
458
502
|
for (const seg of segs) {
|
|
503
|
+
const rawWords = wordsOf(seg);
|
|
504
|
+
const envHit = checkEnvExfiltration(rawWords, command);
|
|
505
|
+
if (envHit.deny) {
|
|
506
|
+
return deny("env-dump-leak", envHit.detail || command);
|
|
507
|
+
}
|
|
459
508
|
const wordTokens = wordTokensOf(seg);
|
|
460
509
|
const heredocs = seg.filter(function (t) { return t.kind === "heredoc"; });
|
|
461
510
|
const argv = stripPrefixes(wordsOf(seg));
|
package/lib/messages.js
CHANGED
package/lib/paths.js
CHANGED
|
@@ -1,5 +1,55 @@
|
|
|
1
1
|
// lib/paths.js
|
|
2
|
-
//
|
|
2
|
+
// Cross-platform path normalization, home expansion, canonicalization, and path boundary traversal protection for dsh-approval-gate.
|
|
3
|
+
|
|
4
|
+
const PROTECTED_BASENAMES = new Set([
|
|
5
|
+
".bashrc",
|
|
6
|
+
".bash_profile",
|
|
7
|
+
".bash_login",
|
|
8
|
+
".profile",
|
|
9
|
+
".zshrc",
|
|
10
|
+
".zprofile",
|
|
11
|
+
".zshenv",
|
|
12
|
+
".cshrc",
|
|
13
|
+
"cordis.patch.yml",
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Normalizes Windows and POSIX path separators to uniform forward slashes.
|
|
18
|
+
* Collapses redundant slashes and standardizes Windows drive letters.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} p
|
|
21
|
+
* @returns {string}
|
|
22
|
+
*/
|
|
23
|
+
export function normalizeSeparators(p) {
|
|
24
|
+
if (typeof p !== "string") return "";
|
|
25
|
+
let s = p.trim();
|
|
26
|
+
if (s === "") return "";
|
|
27
|
+
|
|
28
|
+
// Check UNC network share (e.g. \\server\share or //server/share)
|
|
29
|
+
const isUnc = /^([\\/]{2})([^\\/]+)/.test(s);
|
|
30
|
+
|
|
31
|
+
// Convert all backslashes to forward slashes
|
|
32
|
+
s = s.replace(/\\/g, "/");
|
|
33
|
+
|
|
34
|
+
// Collapse multiple consecutive slashes
|
|
35
|
+
if (isUnc) {
|
|
36
|
+
s = "//" + s.slice(2).replace(/\/+/g, "/");
|
|
37
|
+
} else {
|
|
38
|
+
s = s.replace(/\/+/g, "/");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Normalize Windows drive letter: c:/ -> C:/
|
|
42
|
+
s = s.replace(/^([a-zA-Z]):(\/|$)/, (_, drive, rest) => `${drive.toUpperCase()}:${rest}`);
|
|
43
|
+
|
|
44
|
+
// Strip trailing slash unless root / or C:/
|
|
45
|
+
if (s.length > 1 && s.endsWith("/")) {
|
|
46
|
+
if (!/^(?:\/|[A-Za-z]:\/)$/.test(s)) {
|
|
47
|
+
s = s.slice(0, -1);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return s;
|
|
52
|
+
}
|
|
3
53
|
|
|
4
54
|
/**
|
|
5
55
|
* Expand `~`, `${HOME}`, `$HOME`, and `%USERPROFILE%` at the start of or within a path.
|
|
@@ -23,3 +73,198 @@ export function expandHome(p, env = process.env) {
|
|
|
23
73
|
res = res.replace(/%USERPROFILE%/gi, cleanHome);
|
|
24
74
|
return res;
|
|
25
75
|
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Canonicalizes a path resolving dot and dot-dot segments without disk I/O.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} p
|
|
81
|
+
* @param {{ env?: Record<string, string|undefined> }} [options]
|
|
82
|
+
* @returns {string}
|
|
83
|
+
*/
|
|
84
|
+
export function canonicalizeFsPath(p, options = {}) {
|
|
85
|
+
if (typeof p !== "string") return "";
|
|
86
|
+
let s = expandHome(p, options.env);
|
|
87
|
+
s = normalizeSeparators(s);
|
|
88
|
+
if (!s) return "";
|
|
89
|
+
|
|
90
|
+
// Check prefix: / (root), C:/ (Windows root), //server/share (UNC), or relative
|
|
91
|
+
let prefix = "";
|
|
92
|
+
if (s.startsWith("//")) {
|
|
93
|
+
const m = s.match(/^(\/\/[^/]+\/[^/]+)(?:\/|$)/);
|
|
94
|
+
if (m) {
|
|
95
|
+
prefix = m[1];
|
|
96
|
+
s = s.slice(prefix.length);
|
|
97
|
+
}
|
|
98
|
+
} else if (/^[A-Za-z]:\//.test(s)) {
|
|
99
|
+
prefix = s.slice(0, 3);
|
|
100
|
+
s = s.slice(3);
|
|
101
|
+
} else if (s.startsWith("/")) {
|
|
102
|
+
prefix = "/";
|
|
103
|
+
s = s.slice(1);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const rawSegments = s.split("/");
|
|
107
|
+
const stack = [];
|
|
108
|
+
|
|
109
|
+
for (const seg of rawSegments) {
|
|
110
|
+
if (seg === "" || seg === ".") continue;
|
|
111
|
+
if (seg === "..") {
|
|
112
|
+
if (stack.length > 0 && stack[stack.length - 1] !== "..") {
|
|
113
|
+
stack.pop();
|
|
114
|
+
} else if (!prefix) {
|
|
115
|
+
stack.push("..");
|
|
116
|
+
}
|
|
117
|
+
// If there is an absolute prefix (e.g. / or C:/), .. at root cannot escape above root
|
|
118
|
+
} else {
|
|
119
|
+
stack.push(seg);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const resolvedBody = stack.join("/");
|
|
124
|
+
if (!prefix) {
|
|
125
|
+
return resolvedBody || ".";
|
|
126
|
+
}
|
|
127
|
+
if (!resolvedBody) {
|
|
128
|
+
return prefix;
|
|
129
|
+
}
|
|
130
|
+
return prefix.endsWith("/") ? `${prefix}${resolvedBody}` : `${prefix}/${resolvedBody}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Checks whether a path contains directory traversal sequences attempting to escape baseDir.
|
|
135
|
+
*
|
|
136
|
+
* @param {string} p
|
|
137
|
+
* @param {string} [baseDir]
|
|
138
|
+
* @returns {boolean}
|
|
139
|
+
*/
|
|
140
|
+
export function isPathTraversal(p, baseDir = "/workspace") {
|
|
141
|
+
if (typeof p !== "string" || p.trim() === "") return false;
|
|
142
|
+
|
|
143
|
+
// Detect null byte injection
|
|
144
|
+
if (p.includes("\0") || /%00/i.test(p)) return true;
|
|
145
|
+
|
|
146
|
+
// Detect encoded traversal sequences
|
|
147
|
+
if (/(?:%2e|%2f|%5c|\.\.)(?:%2e|%2f|%5c|\.\.)/i.test(p)) return true;
|
|
148
|
+
|
|
149
|
+
const normBase = canonicalizeFsPath(baseDir);
|
|
150
|
+
const isAbsolute = /^(?:\/|[A-Za-z]:\/|\/\/)/.test(normalizeSeparators(p));
|
|
151
|
+
|
|
152
|
+
let candidate = "";
|
|
153
|
+
if (isAbsolute) {
|
|
154
|
+
candidate = canonicalizeFsPath(p);
|
|
155
|
+
} else {
|
|
156
|
+
candidate = canonicalizeFsPath(`${normBase}/${p}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (candidate === normBase) return false;
|
|
160
|
+
return !(candidate.startsWith(normBase.endsWith("/") ? normBase : `${normBase}/`));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Safely resolves a path relative to a base directory, verifying boundaries.
|
|
165
|
+
*
|
|
166
|
+
* @param {string} p
|
|
167
|
+
* @param {string} [baseDir]
|
|
168
|
+
* @returns {{ safe: boolean, path: string, canonical: string, reason?: string }}
|
|
169
|
+
*/
|
|
170
|
+
export function resolveSafePath(p, baseDir = "/workspace") {
|
|
171
|
+
if (isPathTraversal(p, baseDir)) {
|
|
172
|
+
return {
|
|
173
|
+
safe: false,
|
|
174
|
+
reason: "path-traversal-escape",
|
|
175
|
+
path: p,
|
|
176
|
+
canonical: canonicalizeFsPath(p),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
const normBase = canonicalizeFsPath(baseDir);
|
|
180
|
+
const isAbsolute = /^(?:\/|[A-Za-z]:\/|\/\/)/.test(normalizeSeparators(p));
|
|
181
|
+
const resolved = isAbsolute ? canonicalizeFsPath(p) : canonicalizeFsPath(`${normBase}/${p}`);
|
|
182
|
+
return {
|
|
183
|
+
safe: true,
|
|
184
|
+
path: p,
|
|
185
|
+
canonical: resolved,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Checks whether a path targets protected system, git, ssh, or secret configuration.
|
|
191
|
+
*
|
|
192
|
+
* @param {string} p
|
|
193
|
+
* @param {Record<string, string|undefined>} [env]
|
|
194
|
+
* @returns {boolean}
|
|
195
|
+
*/
|
|
196
|
+
export function isProtectedConfigPath(p, env = process.env) {
|
|
197
|
+
if (typeof p !== "string" || p.trim() === "") return false;
|
|
198
|
+
|
|
199
|
+
const expanded = expandHome(p, env);
|
|
200
|
+
const normalized = canonicalizeFsPath(expanded);
|
|
201
|
+
const lower = normalized.toLowerCase();
|
|
202
|
+
|
|
203
|
+
// 1. .git internal tree
|
|
204
|
+
if (
|
|
205
|
+
lower === ".git" ||
|
|
206
|
+
lower.startsWith(".git/") ||
|
|
207
|
+
lower.endsWith("/.git") ||
|
|
208
|
+
lower.includes("/.git/")
|
|
209
|
+
) {
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// 2. .ssh directory and key files
|
|
214
|
+
if (
|
|
215
|
+
lower === ".ssh" ||
|
|
216
|
+
lower.startsWith(".ssh/") ||
|
|
217
|
+
lower.endsWith("/.ssh") ||
|
|
218
|
+
lower.includes("/.ssh/")
|
|
219
|
+
) {
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// 3. System auth and security files
|
|
224
|
+
if (
|
|
225
|
+
lower === "/etc/sudoers" ||
|
|
226
|
+
lower.includes("/etc/sudoers.d/") ||
|
|
227
|
+
lower === "/etc/shadow" ||
|
|
228
|
+
lower === "/etc/gshadow" ||
|
|
229
|
+
lower.includes("/etc/pam.d/")
|
|
230
|
+
) {
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// 4. Shell startup files
|
|
235
|
+
const parts = normalized.split("/");
|
|
236
|
+
const base = parts[parts.length - 1];
|
|
237
|
+
const baseLower = base.toLowerCase();
|
|
238
|
+
|
|
239
|
+
if (PROTECTED_BASENAMES.has(baseLower)) {
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// 5. Env files (.env, .env.local, .env.prod)
|
|
244
|
+
if (baseLower === ".env" || baseLower.startsWith(".env.")) {
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// 6. Credentials and settings
|
|
249
|
+
if (/^credentials\.ya?ml$/i.test(base) || /^settings\.ya?ml$/i.test(base)) {
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// 7. Certificates and keys
|
|
254
|
+
if (/\.(pem|key|crt|p12|pfx)$/i.test(base)) {
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// 8. SSH key filenames anywhere
|
|
259
|
+
if (/^(id_rsa|id_ed25519|id_ecdsa|authorized_keys2?)$/i.test(base)) {
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// 9. Generic secrets / tokens / passwords
|
|
264
|
+
if (/(?:^|[._-])(?:secret|token|credential|passwd|api[_-]?key)(?:$|[._-])/i.test(baseLower)) {
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
|
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.8",
|
|
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",
|