@goodandready/dsh-approval-gate 0.1.6 → 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 CHANGED
@@ -1,5 +1,13 @@
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
+
3
11
  ## 0.1.6
4
12
 
5
13
  - Security: автоматическое раскрытие домашних путей `expandHome` (`~`, `$HOME`, `${HOME}`, `%USERPROFILE%`) в путях файлов, аргументах инструментов записи и перенаправлениях shell (Refs: #114).
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 {{ maxConsecutive?: number, maxTotal?: number }} [options]
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
- /** @type {Map<string, { consecutive: number, total: number, tripped: boolean }>} */
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
- * Get current state snapshot for a session.
21
- * @param {string} [sessionId]
22
- * @returns {{ consecutive: number, total: number, tripped: boolean }}
42
+ * Register a state change listener callback.
43
+ * @param {(event: CircuitTransitionEvent) => void} fn
44
+ * @returns {() => void} Unsubscribe function
23
45
  */
24
- get(sessionId) {
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 = { consecutive: 0, total: 0, tripped: false };
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 { ...state };
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 key = sessionId || "default";
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
- let state = this.sessions.get(key);
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
- if (state.consecutive >= this.maxConsecutive || state.total >= this.maxTotal) {
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
- return !wasTripped;
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.sessions.get(key);
74
- if (state) state.consecutive = 0;
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.sessions.get(key);
84
- if (state) {
85
- state.consecutive = 0;
86
- state.total = 0;
87
- state.tripped = false;
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
+
@@ -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 || null;
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 { CircuitBreaker } from "./breaker.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";
137
154
  export { expandHome } from "./paths.js";
138
155
  export { splitSubcommands } from "./tokenizer.js";
156
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-approval-gate",
3
- "version": "0.1.6",
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",