@goodandready/dsh-approval-gate 0.1.15 → 0.1.17

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/lib/index.js CHANGED
@@ -1,480 +1,557 @@
1
- import { SecurityEngine } from "./engine.js";
2
- import { BandsEngine } from "./bands.js";
3
- import { AuditLogger } from "./audit-log.js";
4
- import { DeclarativeRulesEngine } from "./rules-loader.js";
5
- import Schema from "@deepseek-ai/schemastery";
6
-
7
- if (typeof Schema.prototype?.volatile !== "function") {
8
- if (Schema.prototype) {
9
- Schema.prototype.volatile = function volatile() {
10
- if (this.meta && this.meta.volatile) return this;
11
- return typeof this.extra === "function" ? this.extra("volatile", true) : this;
12
- };
13
- }
14
- }
15
-
16
- function isVolatileRef(value) {
17
- return value !== null && typeof value === "object" && !Array.isArray(value) && typeof value.get === "function";
18
- }
19
-
20
- export function plainConfig(value) {
21
- if (isVolatileRef(value)) return plainConfig(value.get());
22
- if (value === null || typeof value !== "object") return value;
23
- if (Array.isArray(value)) return value.map(plainConfig);
24
- return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, plainConfig(v)]));
25
- }
26
- import {
27
- DEFAULT_BASH_TOOL,
28
- DEFAULT_FILE_WRITE_TOOLS,
29
- askMessage,
30
- denyMessage,
31
- inspectExecution,
32
- sessionTag,
33
- } from "./inspect.js";
34
- import { LOCALE_NS, MESSAGES } from "./messages.js";
35
-
36
- export const name = "dsh-approval-gate";
37
- export const inject = ["tools"];
38
- export const provide = ["approvalGate"];
39
-
40
- /** Policies under which a prompt cannot be answered, so raising one is a silent denial. */
41
- export const DEFAULT_UNATTENDED_POLICIES = ["never"];
42
-
43
- /**
44
- * Whether a raised ask would be answerable in this session.
45
- *
46
- * Checks explicit unattended policies, as well as preset and sandbox modes.
47
- * In 'danger-full-access' mode, approval prompts are disabled by host design.
48
- * @param {{policy?: string, preset?: string, sandbox?: string, unattendedPolicies?: string[]}} knobs
49
- * @returns {boolean}
50
- */
51
- export function isUnattended(knobs) {
52
- if (!knobs) return false;
53
- if (knobs.preset === "danger-full-access" || knobs.sandbox === "danger-full-access") return true;
54
- const policy = knobs.policy;
55
- const policies = (knobs && knobs.unattendedPolicies) || DEFAULT_UNATTENDED_POLICIES;
56
- if (typeof policy !== "string" || policy === "") return false;
57
- return policies.includes(policy);
58
- }
59
-
60
- /**
61
- * Extracts a clean string session identifier from an execution object.
62
- * @param {any} execution
63
- * @returns {string}
64
- */
65
- export function resolveSessionId(execution) {
66
- const agent = execution && execution.agent;
67
- const session = agent && agent.session;
68
- if (session) {
69
- if (typeof session === "string") return session;
70
- if (typeof session.id === "string") return session.id;
71
- if (typeof session.sessionId === "string") return session.sessionId;
72
- if (typeof session.sessionKey === "string") return session.sessionKey;
73
- }
74
- if (agent) {
75
- if (typeof agent === "string") return agent;
76
- if (typeof agent.id === "string") return agent.id;
77
- }
78
- return "default";
79
- }
80
-
81
- /**
82
- * Effective permission knobs, as the harness exposes them.
83
- * Resolves session-level approval policy, preset, and sandbox mode.
84
- *
85
- * @param {any} ctx - Cordis context
86
- * @param {any} [execution] - Optional tool execution carrying agent/session
87
- * @returns {{ policy: string, sandbox: string, preset: string }}
88
- */
89
- export function readKnobs(ctx, execution) {
90
- const read = (name) => {
91
- try {
92
- return ctx && typeof ctx.get === "function" ? ctx.get(name) : undefined;
93
- } catch {
94
- return undefined;
95
- }
96
- };
97
- const approval = read("approval");
98
- const shell = read("shell");
99
-
100
- const agent = execution && execution.agent;
101
- const session = agent && (agent.session || (typeof agent === "object" && typeof agent.eventAt === "function" ? agent : undefined));
102
-
103
- let policy = "";
104
- let preset = "";
105
- let sessionSandbox = "";
106
-
107
- // 1. Inspect session events backwards (bounded to last 100 events) with sequence caching
108
- if (session && typeof session.eventAt === "function" && typeof session.seq === "number") {
109
- if (session._gateCachedSeq === session.seq && session._gateCachedPolicy !== undefined) {
110
- policy = session._gateCachedPolicy;
111
- preset = session._gateCachedPreset || "";
112
- sessionSandbox = session._gateCachedSandbox || "";
113
- } else {
114
- try {
115
- const minSeq = Math.max(0, session.seq - 100);
116
- for (let seq = session.seq - 1; seq >= minSeq; seq -= 1) {
117
- const event = session.eventAt(seq);
118
- if (!policy && event && event.type === "approval/policy" && event.data && event.data.policy) {
119
- policy = event.data.policy;
120
- }
121
- if (!preset && event && event.type === "permission/preset" && event.data && event.data.preset) {
122
- preset = event.data.preset;
123
- }
124
- if (!sessionSandbox && event && event.type === "sandbox/mode" && event.data && event.data.mode) {
125
- sessionSandbox = event.data.mode;
126
- }
127
- if (policy && preset && sessionSandbox) break;
128
- }
129
- session._gateCachedSeq = session.seq;
130
- session._gateCachedPolicy = policy || "";
131
- session._gateCachedPreset = preset || "";
132
- session._gateCachedSandbox = sessionSandbox || "";
133
- } catch {
134
- // ignore
135
- }
136
- }
137
- }
138
-
139
- // 2. In-memory session.events array fallback (common in mocks / tests)
140
- if (session && Array.isArray(session.events)) {
141
- try {
142
- const minIdx = Math.max(0, session.events.length - 100);
143
- for (let i = session.events.length - 1; i >= minIdx; i -= 1) {
144
- const event = session.events[i];
145
- if (!policy && event && event.type === "approval/policy" && event.data && event.data.policy) {
146
- policy = event.data.policy;
147
- }
148
- if (!preset && event && event.type === "permission/preset" && event.data && event.data.preset) {
149
- preset = event.data.preset;
150
- }
151
- if (!sessionSandbox && event && event.type === "sandbox/mode" && event.data && event.data.mode) {
152
- sessionSandbox = event.data.mode;
153
- }
154
- if (policy && preset && sessionSandbox) break;
155
- }
156
- } catch {
157
- // ignore
158
- }
159
- }
160
-
161
- // 3. Preset / sandbox direct values: danger-full-access implies "never" approval
162
- if (preset === "danger-full-access" || sessionSandbox === "danger-full-access") {
163
- policy = "never";
164
- }
165
-
166
- // 4. Direct session property fallback
167
- if (!policy && session && typeof session.approvalPolicy === "string") {
168
- policy = session.approvalPolicy;
169
- }
170
-
171
- // 5. Live approval service effectivePolicy(session)
172
- if (!policy && approval && session && typeof approval.effectivePolicy === "function") {
173
- try {
174
- policy = approval.effectivePolicy(session) || "";
175
- } catch {
176
- // ignore
177
- }
178
- }
179
-
180
- // 6. Global static fallback from approval service config
181
- if (!policy && approval && approval.config && approval.config.policy) {
182
- policy = approval.config.policy;
183
- }
184
-
185
- const sandbox = sessionSandbox || (shell && shell.sandboxMode) || "";
186
- if (sandbox === "danger-full-access" && !policy) {
187
- policy = "never";
188
- }
189
-
190
- return {
191
- policy: policy || "",
192
- sandbox: sandbox || "",
193
- preset: preset || "",
194
- };
195
- }
196
-
197
- export const Config = Schema.object({
198
- /** Name of the tool whose `command` arg is inspected (the DSH bash tool). */
199
- toolName: Schema.string().default(DEFAULT_BASH_TOOL).volatile(),
200
- /** Extra tool names treated as file writes; paths are matched against secret files. */
201
- fileWriteTools: Schema.array(Schema.string()).default(DEFAULT_FILE_WRITE_TOOLS.slice()).volatile(),
202
- /** Approval policies under which an ask could not be answered, so it is not raised. */
203
- unattendedPolicies: Schema.array(Schema.string()).default(DEFAULT_UNATTENDED_POLICIES.slice()).volatile(),
204
- /** Force unattended behaviour where the approval policy is not visible. */
205
- unattended: Schema.boolean().default(false).volatile(),
206
- /** Workspace directory for path traversal boundary checks. */
207
- workspaceDir: Schema.string().default("").volatile(),
208
- /** Default verdict for unclassified commands: 'allow' (default) or 'ask' (fail-closed) (#163). */
209
- defaultVerdict: Schema.union(["allow", "ask"]).default("allow").volatile(),
210
- /** Dry-run / shadow mode for safe testing of rules on production (#128). */
211
- dryRun: Schema.boolean().default(false).volatile(),
212
- /** Shadow mode alias for dryRun (#128). */
213
- shadowMode: Schema.boolean().default(false).volatile(),
214
- /** Path to append-only audit log file (#56). */
215
- auditLogPath: Schema.string().default("").volatile(),
216
- /** Optional custom declarative rules YAML file path (#38). */
217
- rulesFile: Schema.string().default("").volatile(),
218
- /** Security engine instance (internal/testing). */
219
- engine: Schema.any().hidden(),
220
- });
221
-
222
- function logBlocked(ctx, hit, who) {
223
- const line = "[dsh-approval-gate] blocked rule=" + (hit.reason || "unknown") + who;
224
- if (ctx && ctx.logger && typeof ctx.logger.warn === "function") ctx.logger.warn(line);
225
- }
226
-
227
- export function apply(ctx, config) {
228
- const cfg = plainConfig(Config(plainConfig(config) || {}) || {});
229
- const toolName = cfg.toolName || DEFAULT_BASH_TOOL;
230
- const fileWriteTools = cfg.fileWriteTools || DEFAULT_FILE_WRITE_TOOLS.slice();
231
- const getLocale = function () {
232
- if (ctx && typeof ctx.get === "function") return ctx.get("locale");
233
- return ctx && ctx.locale;
234
- };
235
- const registerLocale = function () {
236
- const locale = getLocale();
237
- if (locale && typeof locale.register === "function") {
238
- try {
239
- return locale.register(LOCALE_NS, MESSAGES);
240
- } catch (err) {
241
- if (ctx && ctx.logger && typeof ctx.logger.warn === "function") {
242
- ctx.logger.warn("[dsh-approval-gate] locale registration skipped: " + (err && err.message || err));
243
- }
244
- }
245
- }
246
- };
247
- if (ctx && typeof ctx.effect === "function") ctx.effect(registerLocale, "dsh-approval-gate: dictionaries");
248
- else registerLocale();
249
-
250
- const translate = function (key) {
251
- const locale = getLocale();
252
- if (locale && typeof locale.bind === "function") return locale.bind(LOCALE_NS)(key);
253
- return key;
254
- };
255
- const unattendedPolicies = cfg.unattendedPolicies || DEFAULT_UNATTENDED_POLICIES.slice();
256
- const forcedUnattended = cfg.unattended === true;
257
- const engine = (config && config.engine) || cfg.engine || new SecurityEngine({
258
- inspectConfig: { toolName: toolName, fileWriteTools: fileWriteTools, workspaceDir: cfg.workspaceDir || "" },
259
- bands: new BandsEngine({ toolRegistry: ctx && ctx.tools }),
260
- defaultVerdict: cfg.defaultVerdict || "allow",
261
- dryRun: Boolean(cfg.dryRun || cfg.shadowMode),
262
- auditLogger: new AuditLogger({ logPath: cfg.auditLogPath || undefined }),
263
- declarativeRules: new DeclarativeRulesEngine({
264
- workspaceDir: cfg.workspaceDir || "",
265
- builtinCatalogPath: cfg.rulesFile || undefined,
266
- }),
267
- });
268
-
269
- const gateService = {
270
- engine,
271
- addGrant: (options) => engine.addGrant(options),
272
- resume: (sessionId, reason) => engine.resume(sessionId, reason),
273
- cache: engine.cache,
274
- retryGuard: engine.retryGuard,
275
- ledger: engine.ledger,
276
- declarativeRules: engine.declarativeRules,
277
- getLedgerSummary: (sessionId) => engine.getLedgerSummary(sessionId),
278
- };
279
- if (ctx && typeof ctx === "object") {
280
- if (typeof ctx.provide === "function") {
281
- ctx.provide("approvalGate", gateService);
282
- } else {
283
- ctx.approvalGate = gateService;
284
- }
285
- }
286
-
287
- const hasPreExecute = ctx && typeof ctx.on === "function";
288
- if (hasPreExecute) {
289
- ctx.on("tools/pre-execute", function (execution, next) {
290
- const knobs = readKnobs(ctx, execution);
291
- const who = sessionTag(execution);
292
- const sessionId = resolveSessionId(execution);
293
- let decision;
294
- try {
295
- decision = engine.decide(execution, { sessionId, knobs });
296
- } catch (err) {
297
- const hit = {
298
- ask: true,
299
- reason: "inspect-error",
300
- detail: String(err && err.message || err),
301
- snippet: (execution && execution.arguments && execution.arguments.command) || execution.name,
302
- };
303
- return { kind: "ask", reason: askMessage(hit, who, translate, ctx && ctx.logger) };
304
- }
305
-
306
- if (decision.dryRun) {
307
- if (ctx && ctx.logger && typeof ctx.logger.info === "function") {
308
- ctx.logger.info(`[dsh-approval-gate:DRY-RUN] would block rule=${decision.reason} (${decision.originalVerdict})${who}`);
309
- }
310
- return next();
311
- }
312
-
313
- if (decision.verdict === "ask") {
314
- if (decision.reason === "circuit-breaker-tripped") {
315
- const hit = decision.hit || {
316
- deny: true,
317
- reason: "circuit-breaker-tripped",
318
- detail: decision.detail,
319
- snippet: (execution && execution.arguments && execution.arguments.command) || execution.name,
320
- };
321
- logBlocked(ctx, hit, who);
322
- return denyMessage(hit, who, translate, ctx && ctx.logger);
323
- }
324
- if (forcedUnattended || isUnattended({ policy: knobs.policy, preset: knobs.preset, sandbox: knobs.sandbox, unattendedPolicies })) {
325
- return next();
326
- }
327
- const hit = decision.hit || {
328
- ask: true,
329
- reason: decision.reason,
330
- detail: decision.detail,
331
- snippet: decision.detail || (execution && execution.arguments && execution.arguments.command),
332
- };
333
- return { kind: "ask", reason: askMessage(hit, who, translate, ctx && ctx.logger) };
334
- }
335
- return next();
336
- });
337
- }
338
-
339
- const guard = function (execution) {
340
- const who = sessionTag(execution);
341
- const sessionId = resolveSessionId(execution);
342
- let decision;
343
- try {
344
- decision = engine.decide(execution, { sessionId });
345
- } catch (err) {
346
- const hit = {
347
- deny: true,
348
- reason: "inspect-error",
349
- detail: String(err && err.message || err),
350
- snippet: (execution && execution.arguments && execution.arguments.command) || execution.name,
351
- };
352
- logBlocked(ctx, hit, who);
353
- return denyMessage(hit, who, translate, ctx && ctx.logger);
354
- }
355
-
356
- if (decision.dryRun) {
357
- if (ctx && ctx.logger && typeof ctx.logger.info === "function") {
358
- ctx.logger.info(`[dsh-approval-gate:DRY-RUN] would block rule=${decision.reason} (${decision.originalVerdict})${who}`);
359
- }
360
- return undefined;
361
- }
362
-
363
- if (decision.verdict === "deny" || (decision.verdict === "ask" && !hasPreExecute)) {
364
- const hit = decision.hit || {
365
- deny: true,
366
- reason: decision.reason,
367
- snippet: decision.detail || (execution && execution.arguments && execution.arguments.command) || execution.name,
368
- };
369
- logBlocked(ctx, hit, who);
370
- return denyMessage(hit, who, translate, ctx && ctx.logger);
371
- }
372
- return undefined;
373
- };
374
-
375
- const mount = function () {
376
- return ctx.tools.guard(guard);
377
- };
378
- if (ctx && typeof ctx.effect === "function") {
379
- ctx.effect(mount, "dsh-approval-gate: tools guard");
380
- return;
381
- }
382
- return mount();
383
- }
384
-
385
- export { SecurityEngine } from "./engine.js";
386
- export { BandsEngine } from "./bands.js";
387
- export {
388
- CircuitBreaker,
389
- CIRCUIT_STATE_ACTIVE,
390
- CIRCUIT_STATE_TRIPPED,
391
- CIRCUIT_STATE_RESUMED,
392
- } from "./breaker.js";
393
- export {
394
- SessionGrantStore,
395
- DEFAULT_GRANT_TTL_MS,
396
- DEFAULT_GRANT_MAX_USES,
397
- matchGrantCriteria,
398
- } from "./grants.js";
399
- export {
400
- canonicalizeCall,
401
- canonicalString,
402
- hashCall,
403
- sortKeys,
404
- } from "./canonical.js";
405
- export { expandHome, resolveSafePath, isPathTraversal, canonicalizeFsPath } from "./paths.js";
406
- export { splitSubcommands } from "./tokenizer.js";
407
- export { redactCredentials, redactUrl } from "./redact.js";
408
- export {
409
- CommandDispatcher,
410
- isReviewerAgent,
411
- extractFilePath,
412
- TOOL_KIND_SHELL,
413
- TOOL_KIND_FILE_WRITE,
414
- TOOL_KIND_FILE_READ,
415
- TOOL_KIND_NETWORK,
416
- TOOL_KIND_GENERIC,
417
- } from "./dispatcher.js";
418
- export {
419
- parseGitArgv,
420
- classifyGitOperation,
421
- inspectGitCommand,
422
- GIT_CATEGORY_READ,
423
- GIT_CATEGORY_MUTATE_LOCAL,
424
- GIT_CATEGORY_REMOTE,
425
- GIT_CATEGORY_DESTRUCTIVE,
426
- } from "./parsers/git.js";
427
- export {
428
- isDevicePath,
429
- extractRedirects,
430
- inspectRedirectSecurity,
431
- inspectSilentErrors,
432
- inspectPipelines,
433
- } from "./parsers/shell-cmds.js";
434
- export {
435
- isLoopbackHost,
436
- isLoopbackUrl,
437
- isLoopbackCommand,
438
- } from "./loopback.js";
439
- export {
440
- computeFingerprint,
441
- computeFingerprintHash,
442
- normalizeCommandString,
443
- } from "./fingerprint.js";
444
- export {
445
- VerdictCache,
446
- DEFAULT_CACHE_MAX_SIZE,
447
- DEFAULT_CACHE_TTL_MS,
448
- } from "./cache.js";
449
- export {
450
- RetryExhaustionGuard,
451
- DEFAULT_MAX_RETRY_ATTEMPTS,
452
- } from "./retry-exhaustion.js";
453
- export {
454
- QualityLedger,
455
- MAX_LEDGER_EVENTS_PER_SESSION,
456
- } from "./ledger.js";
457
- export {
458
- AuditLogger,
459
- DEFAULT_AUDIT_LOG_RELATIVE,
460
- } from "./audit-log.js";
461
- export {
462
- parseYaml,
463
- parseScalar,
464
- stripComment,
465
- } from "./yaml-parser.js";
466
- export {
467
- matchConditionValue,
468
- evaluateWhen,
469
- } from "./when-matcher.js";
470
- export {
471
- HotwordsMatcher,
472
- DEFAULT_HOTWORDS,
473
- extractWords,
474
- } from "./hotwords.js";
475
- export {
476
- DeclarativeRulesEngine,
477
- BUILTIN_RULES_FILE,
478
- WORKSPACE_RULES_REL,
479
- USER_RULES_REL,
480
- } from "./rules-loader.js";
1
+ import { SecurityEngine } from "./engine.js";
2
+ import { BandsEngine } from "./bands.js";
3
+ import { AuditLogger } from "./audit-log.js";
4
+ import { DeclarativeRulesEngine } from "./rules-loader.js";
5
+ import { LlmClassifier } from "./classifier.js";
6
+ import Schema from "@deepseek-ai/schemastery";
7
+
8
+ if (typeof Schema.prototype?.volatile !== "function") {
9
+ if (Schema.prototype) {
10
+ Schema.prototype.volatile = function volatile() {
11
+ if (this.meta && this.meta.volatile) return this;
12
+ return typeof this.extra === "function" ? this.extra("volatile", true) : this;
13
+ };
14
+ }
15
+ }
16
+
17
+ function isVolatileRef(value) {
18
+ return value !== null && typeof value === "object" && !Array.isArray(value) && typeof value.get === "function";
19
+ }
20
+
21
+ export function plainConfig(value) {
22
+ if (isVolatileRef(value)) return plainConfig(value.get());
23
+ if (value === null || typeof value !== "object") return value;
24
+ if (Array.isArray(value)) return value.map(plainConfig);
25
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, plainConfig(v)]));
26
+ }
27
+ import {
28
+ DEFAULT_BASH_TOOL,
29
+ DEFAULT_FILE_WRITE_TOOLS,
30
+ askMessage,
31
+ denyMessage,
32
+ inspectExecution,
33
+ sessionTag,
34
+ } from "./inspect.js";
35
+ import { LOCALE_NS, MESSAGES } from "./messages.js";
36
+
37
+ export const name = "dsh-approval-gate";
38
+ export const inject = ["tools"];
39
+ export const provide = ["approvalGate"];
40
+
41
+ /** Policies under which a prompt cannot be answered, so raising one is a silent denial. */
42
+ export const DEFAULT_UNATTENDED_POLICIES = ["never"];
43
+
44
+ /**
45
+ * Whether a raised ask would be answerable in this session.
46
+ *
47
+ * Checks explicit unattended policies, as well as preset and sandbox modes.
48
+ * In 'danger-full-access' mode, approval prompts are disabled by host design.
49
+ * @param {{policy?: string, preset?: string, sandbox?: string, unattendedPolicies?: string[]}} knobs
50
+ * @returns {boolean}
51
+ */
52
+ export function isUnattended(knobs) {
53
+ if (!knobs) return false;
54
+ if (knobs.preset === "danger-full-access" || knobs.sandbox === "danger-full-access") return true;
55
+ const policy = knobs.policy;
56
+ const policies = (knobs && knobs.unattendedPolicies) || DEFAULT_UNATTENDED_POLICIES;
57
+ if (typeof policy !== "string" || policy === "") return false;
58
+ return policies.includes(policy);
59
+ }
60
+
61
+ /**
62
+ * Extracts a clean string session identifier from an execution object.
63
+ * @param {any} execution
64
+ * @returns {string}
65
+ */
66
+ export function resolveSessionId(execution) {
67
+ const agent = execution && execution.agent;
68
+ const session = agent && agent.session;
69
+ if (session) {
70
+ if (typeof session === "string") return session;
71
+ if (typeof session.id === "string") return session.id;
72
+ if (typeof session.sessionId === "string") return session.sessionId;
73
+ if (typeof session.sessionKey === "string") return session.sessionKey;
74
+ }
75
+ if (agent) {
76
+ if (typeof agent === "string") return agent;
77
+ if (typeof agent.id === "string") return agent.id;
78
+ }
79
+ return "default";
80
+ }
81
+
82
+ /**
83
+ * Effective permission knobs, as the harness exposes them.
84
+ * Resolves session-level approval policy, preset, and sandbox mode.
85
+ *
86
+ * @param {any} ctx - Cordis context
87
+ * @param {any} [execution] - Optional tool execution carrying agent/session
88
+ * @returns {{ policy: string, sandbox: string, preset: string }}
89
+ */
90
+ export function readKnobs(ctx, execution) {
91
+ const read = (name) => {
92
+ try {
93
+ return ctx && typeof ctx.get === "function" ? ctx.get(name) : undefined;
94
+ } catch {
95
+ return undefined;
96
+ }
97
+ };
98
+ const approval = read("approval");
99
+ const shell = read("shell");
100
+
101
+ const agent = execution && execution.agent;
102
+ const session = agent && (agent.session || (typeof agent === "object" && typeof agent.eventAt === "function" ? agent : undefined));
103
+
104
+ let policy = "";
105
+ let preset = "";
106
+ let sessionSandbox = "";
107
+
108
+ // 1. Inspect session events backwards (bounded to last 100 events) with sequence caching
109
+ if (session && typeof session.eventAt === "function" && typeof session.seq === "number") {
110
+ if (session._gateCachedSeq === session.seq && session._gateCachedPolicy !== undefined) {
111
+ policy = session._gateCachedPolicy;
112
+ preset = session._gateCachedPreset || "";
113
+ sessionSandbox = session._gateCachedSandbox || "";
114
+ } else {
115
+ try {
116
+ const minSeq = Math.max(0, session.seq - 100);
117
+ for (let seq = session.seq - 1; seq >= minSeq; seq -= 1) {
118
+ const event = session.eventAt(seq);
119
+ if (!policy && event && event.type === "approval/policy" && event.data && event.data.policy) {
120
+ policy = event.data.policy;
121
+ }
122
+ if (!preset && event && event.type === "permission/preset" && event.data && event.data.preset) {
123
+ preset = event.data.preset;
124
+ }
125
+ if (!sessionSandbox && event && event.type === "sandbox/mode" && event.data && event.data.mode) {
126
+ sessionSandbox = event.data.mode;
127
+ }
128
+ if (policy && preset && sessionSandbox) break;
129
+ }
130
+ session._gateCachedSeq = session.seq;
131
+ session._gateCachedPolicy = policy || "";
132
+ session._gateCachedPreset = preset || "";
133
+ session._gateCachedSandbox = sessionSandbox || "";
134
+ } catch {
135
+ // ignore
136
+ }
137
+ }
138
+ }
139
+
140
+ // 2. In-memory session.events array fallback (common in mocks / tests)
141
+ if (session && Array.isArray(session.events)) {
142
+ try {
143
+ const minIdx = Math.max(0, session.events.length - 100);
144
+ for (let i = session.events.length - 1; i >= minIdx; i -= 1) {
145
+ const event = session.events[i];
146
+ if (!policy && event && event.type === "approval/policy" && event.data && event.data.policy) {
147
+ policy = event.data.policy;
148
+ }
149
+ if (!preset && event && event.type === "permission/preset" && event.data && event.data.preset) {
150
+ preset = event.data.preset;
151
+ }
152
+ if (!sessionSandbox && event && event.type === "sandbox/mode" && event.data && event.data.mode) {
153
+ sessionSandbox = event.data.mode;
154
+ }
155
+ if (policy && preset && sessionSandbox) break;
156
+ }
157
+ } catch {
158
+ // ignore
159
+ }
160
+ }
161
+
162
+ // 3. Preset / sandbox direct values: danger-full-access implies "never" approval
163
+ if (preset === "danger-full-access" || sessionSandbox === "danger-full-access") {
164
+ policy = "never";
165
+ }
166
+
167
+ // 4. Direct session property fallback
168
+ if (!policy && session && typeof session.approvalPolicy === "string") {
169
+ policy = session.approvalPolicy;
170
+ }
171
+
172
+ // 5. Live approval service effectivePolicy(session)
173
+ if (!policy && approval && session && typeof approval.effectivePolicy === "function") {
174
+ try {
175
+ policy = approval.effectivePolicy(session) || "";
176
+ } catch {
177
+ // ignore
178
+ }
179
+ }
180
+
181
+ // 6. Global static fallback from approval service config
182
+ if (!policy && approval && approval.config && approval.config.policy) {
183
+ policy = approval.config.policy;
184
+ }
185
+
186
+ const sandbox = sessionSandbox || (shell && shell.sandboxMode) || "";
187
+ if (sandbox === "danger-full-access" && !policy) {
188
+ policy = "never";
189
+ }
190
+
191
+ return {
192
+ policy: policy || "",
193
+ sandbox: sandbox || "",
194
+ preset: preset || "",
195
+ };
196
+ }
197
+
198
+ export const Config = Schema.object({
199
+ /** Name of the tool whose `command` arg is inspected (the DSH bash tool). */
200
+ toolName: Schema.string().default(DEFAULT_BASH_TOOL).volatile(),
201
+ /** Extra tool names treated as file writes; paths are matched against secret files. */
202
+ fileWriteTools: Schema.array(Schema.string()).default(DEFAULT_FILE_WRITE_TOOLS.slice()).volatile(),
203
+ /** Approval policies under which an ask could not be answered, so it is not raised. */
204
+ unattendedPolicies: Schema.array(Schema.string()).default(DEFAULT_UNATTENDED_POLICIES.slice()).volatile(),
205
+ /** Force unattended behaviour where the approval policy is not visible. */
206
+ unattended: Schema.boolean().default(false).volatile(),
207
+ /** Workspace directory for path traversal boundary checks. */
208
+ workspaceDir: Schema.string().default("").volatile(),
209
+ /** Default verdict for unclassified commands: 'allow' (default) or 'ask' (fail-closed) (#163). */
210
+ defaultVerdict: Schema.union(["allow", "ask"]).default("allow").volatile(),
211
+ /** Dry-run / shadow mode for safe testing of rules on production (#128). */
212
+ dryRun: Schema.boolean().default(false).volatile(),
213
+ /** Shadow mode alias for dryRun (#128). */
214
+ shadowMode: Schema.boolean().default(false).volatile(),
215
+ /** Path to append-only audit log file (#56). */
216
+ auditLogPath: Schema.string().default("").volatile(),
217
+ /** Fail-closed audit: block execution if writing to the audit log fails (#53). */
218
+ auditFailClosed: Schema.boolean().default(false).volatile(),
219
+ /** Optional custom declarative rules YAML file path (#38). */
220
+ rulesFile: Schema.string().default("").volatile(),
221
+ /** Semantic LLM classifier endpoint URL (#127). */
222
+ classifierEndpoint: Schema.string().default("").volatile(),
223
+ /** API key for classifier endpoint (#115). */
224
+ classifierApiKey: Schema.string().default("").volatile(),
225
+ /** Model name for semantic classification. */
226
+ classifierModel: Schema.string().default("gpt-4o-mini").volatile(),
227
+ /** Request timeout in ms for classifier (#118). */
228
+ classifierTimeout: Schema.number().default(3000).volatile(),
229
+ /** Policy on classifier timeout: 'strict' or 'permissive' (#118). */
230
+ classifierTimeoutPolicy: Schema.union(["strict", "permissive"]).default("strict").volatile(),
231
+ /** Maximum context characters for classifier prompt (#117). */
232
+ classifierMaxContext: Schema.number().default(1000).volatile(),
233
+ /** Security engine instance (internal/testing). */
234
+ engine: Schema.any().hidden(),
235
+ });
236
+
237
+ function logBlocked(ctx, hit, who) {
238
+ const line = "[dsh-approval-gate] blocked rule=" + (hit.reason || "unknown") + who;
239
+ if (ctx && ctx.logger && typeof ctx.logger.warn === "function") ctx.logger.warn(line);
240
+ }
241
+
242
+ export function apply(ctx, config) {
243
+ const cfg = plainConfig(Config(plainConfig(config) || {}) || {});
244
+ const toolName = cfg.toolName || DEFAULT_BASH_TOOL;
245
+ const fileWriteTools = cfg.fileWriteTools || DEFAULT_FILE_WRITE_TOOLS.slice();
246
+ const getLocale = function () {
247
+ if (ctx && typeof ctx.get === "function") return ctx.get("locale");
248
+ return ctx && ctx.locale;
249
+ };
250
+ const registerLocale = function () {
251
+ const locale = getLocale();
252
+ if (locale && typeof locale.register === "function") {
253
+ try {
254
+ return locale.register(LOCALE_NS, MESSAGES);
255
+ } catch (err) {
256
+ if (ctx && ctx.logger && typeof ctx.logger.warn === "function") {
257
+ ctx.logger.warn("[dsh-approval-gate] locale registration skipped: " + (err && err.message || err));
258
+ }
259
+ }
260
+ }
261
+ };
262
+ if (ctx && typeof ctx.effect === "function") ctx.effect(registerLocale, "dsh-approval-gate: dictionaries");
263
+ else registerLocale();
264
+
265
+ const translate = function (key) {
266
+ const locale = getLocale();
267
+ if (locale && typeof locale.bind === "function") return locale.bind(LOCALE_NS)(key);
268
+ return key;
269
+ };
270
+ const unattendedPolicies = cfg.unattendedPolicies || DEFAULT_UNATTENDED_POLICIES.slice();
271
+ const forcedUnattended = cfg.unattended === true;
272
+ const classifier = cfg.classifierEndpoint
273
+ ? new LlmClassifier({
274
+ endpoint: cfg.classifierEndpoint,
275
+ apiKey: cfg.classifierApiKey,
276
+ model: cfg.classifierModel,
277
+ timeoutMs: cfg.classifierTimeout,
278
+ timeoutPolicy: cfg.classifierTimeoutPolicy,
279
+ maxContextChars: cfg.classifierMaxContext,
280
+ })
281
+ : null;
282
+
283
+ const engine = (config && config.engine) || cfg.engine || new SecurityEngine({
284
+ inspectConfig: { toolName: toolName, fileWriteTools: fileWriteTools, workspaceDir: cfg.workspaceDir || "" },
285
+ bands: new BandsEngine({ toolRegistry: ctx && ctx.tools }),
286
+ classifier,
287
+ timeoutPolicy: cfg.classifierTimeoutPolicy || "strict",
288
+ defaultVerdict: cfg.defaultVerdict || "allow",
289
+ dryRun: Boolean(cfg.dryRun || cfg.shadowMode),
290
+ auditFailClosed: Boolean(cfg.auditFailClosed),
291
+ auditLogger: new AuditLogger({
292
+ logPath: cfg.auditLogPath || undefined,
293
+ failClosed: Boolean(cfg.auditFailClosed),
294
+ }),
295
+ declarativeRules: new DeclarativeRulesEngine({
296
+ workspaceDir: cfg.workspaceDir || "",
297
+ builtinCatalogPath: cfg.rulesFile || undefined,
298
+ }),
299
+ });
300
+
301
+ const gateService = {
302
+ engine,
303
+ addGrant: (options) => engine.addGrant(options),
304
+ resume: (sessionId, reason) => engine.resume(sessionId, reason),
305
+ cache: engine.cache,
306
+ retryGuard: engine.retryGuard,
307
+ ledger: engine.ledger,
308
+ counters: engine.counters,
309
+ declarativeRules: engine.declarativeRules,
310
+ getCounters: () => engine.getCounters(),
311
+ resetCounters: () => engine.resetCounters(),
312
+ getLedgerSummary: (sessionId) => engine.getLedgerSummary(sessionId),
313
+ getSnapshot: () => ({
314
+ active: true,
315
+ dryRun: Boolean(cfg.dryRun || cfg.shadowMode),
316
+ auditFailClosed: Boolean(cfg.auditFailClosed),
317
+ classifierActive: Boolean(classifier || engine.classifier),
318
+ timeoutPolicy: engine.timeoutPolicy || "strict",
319
+ counters: engine.getCounters(),
320
+ rulesCount: engine.declarativeRules ? engine.declarativeRules.count() : 0,
321
+ cacheStats: engine.cache ? engine.cache.stats() : null,
322
+ breakerState: engine.breaker ? engine.breaker.getState() : null,
323
+ }),
324
+ };
325
+ if (ctx && typeof ctx === "object") {
326
+ if (typeof ctx.provide === "function") {
327
+ ctx.provide("approvalGate", gateService);
328
+ } else {
329
+ ctx.approvalGate = gateService;
330
+ }
331
+ }
332
+
333
+ const hasPreExecute = ctx && typeof ctx.on === "function";
334
+ if (hasPreExecute) {
335
+ ctx.on("tools/pre-execute", function (execution, next) {
336
+ const knobs = readKnobs(ctx, execution);
337
+ const who = sessionTag(execution);
338
+ const sessionId = resolveSessionId(execution);
339
+ let decision;
340
+ try {
341
+ decision = engine.decide(execution, { sessionId, knobs });
342
+ } catch (err) {
343
+ const hit = {
344
+ ask: true,
345
+ reason: "inspect-error",
346
+ detail: String(err && err.message || err),
347
+ snippet: (execution && execution.arguments && execution.arguments.command) || execution.name,
348
+ };
349
+ return { kind: "ask", reason: askMessage(hit, who, translate, ctx && ctx.logger) };
350
+ }
351
+
352
+ if (decision.dryRun) {
353
+ if (ctx && ctx.logger && typeof ctx.logger.info === "function") {
354
+ ctx.logger.info(`[dsh-approval-gate:DRY-RUN] would block rule=${decision.reason} (${decision.originalVerdict})${who}`);
355
+ }
356
+ return next();
357
+ }
358
+
359
+ if (decision.verdict === "ask") {
360
+ if (decision.reason === "circuit-breaker-tripped") {
361
+ const hit = decision.hit || {
362
+ deny: true,
363
+ reason: "circuit-breaker-tripped",
364
+ detail: decision.detail,
365
+ snippet: (execution && execution.arguments && execution.arguments.command) || execution.name,
366
+ };
367
+ logBlocked(ctx, hit, who);
368
+ return denyMessage(hit, who, translate, ctx && ctx.logger);
369
+ }
370
+ if (decision.lockedAsk) {
371
+ if (forcedUnattended || isUnattended({ policy: knobs.policy, preset: knobs.preset, sandbox: knobs.sandbox, unattendedPolicies })) {
372
+ const hit = decision.hit || {
373
+ deny: true,
374
+ reason: "locked-ask-unattended-denied",
375
+ detail: "Critical operation requires human confirmation and cannot be auto-approved in unattended mode (#52)",
376
+ snippet: (execution && execution.arguments && execution.arguments.command) || execution.name,
377
+ };
378
+ logBlocked(ctx, hit, who);
379
+ return denyMessage(hit, who, translate, ctx && ctx.logger);
380
+ }
381
+ }
382
+ if (forcedUnattended || isUnattended({ policy: knobs.policy, preset: knobs.preset, sandbox: knobs.sandbox, unattendedPolicies })) {
383
+ return next();
384
+ }
385
+ const hit = decision.hit || {
386
+ ask: true,
387
+ reason: decision.reason,
388
+ detail: decision.detail,
389
+ snippet: decision.detail || (execution && execution.arguments && execution.arguments.command),
390
+ };
391
+ return { kind: "ask", reason: askMessage(hit, who, translate, ctx && ctx.logger) };
392
+ }
393
+ return next();
394
+ });
395
+ }
396
+
397
+ const guard = function (execution) {
398
+ const who = sessionTag(execution);
399
+ const sessionId = resolveSessionId(execution);
400
+ let decision;
401
+ try {
402
+ decision = engine.decide(execution, { sessionId });
403
+ } catch (err) {
404
+ const hit = {
405
+ deny: true,
406
+ reason: "inspect-error",
407
+ detail: String(err && err.message || err),
408
+ snippet: (execution && execution.arguments && execution.arguments.command) || execution.name,
409
+ };
410
+ logBlocked(ctx, hit, who);
411
+ return denyMessage(hit, who, translate, ctx && ctx.logger);
412
+ }
413
+
414
+ if (decision.dryRun) {
415
+ if (ctx && ctx.logger && typeof ctx.logger.info === "function") {
416
+ ctx.logger.info(`[dsh-approval-gate:DRY-RUN] would block rule=${decision.reason} (${decision.originalVerdict})${who}`);
417
+ }
418
+ return undefined;
419
+ }
420
+
421
+ if (decision.verdict === "deny" || (decision.verdict === "ask" && !hasPreExecute)) {
422
+ const hit = decision.hit || {
423
+ deny: true,
424
+ reason: decision.reason,
425
+ snippet: decision.detail || (execution && execution.arguments && execution.arguments.command) || execution.name,
426
+ };
427
+ logBlocked(ctx, hit, who);
428
+ return denyMessage(hit, who, translate, ctx && ctx.logger);
429
+ }
430
+ return undefined;
431
+ };
432
+
433
+ const mount = function () {
434
+ return ctx.tools.guard(guard);
435
+ };
436
+ if (ctx && typeof ctx.effect === "function") {
437
+ ctx.effect(mount, "dsh-approval-gate: tools guard");
438
+ return;
439
+ }
440
+ return mount();
441
+ }
442
+
443
+ export { SecurityEngine } from "./engine.js";
444
+ export { BandsEngine } from "./bands.js";
445
+ export {
446
+ CircuitBreaker,
447
+ CIRCUIT_STATE_ACTIVE,
448
+ CIRCUIT_STATE_TRIPPED,
449
+ CIRCUIT_STATE_RESUMED,
450
+ } from "./breaker.js";
451
+ export {
452
+ SessionGrantStore,
453
+ DEFAULT_GRANT_TTL_MS,
454
+ DEFAULT_GRANT_MAX_USES,
455
+ matchGrantCriteria,
456
+ } from "./grants.js";
457
+ export {
458
+ canonicalizeCall,
459
+ canonicalString,
460
+ hashCall,
461
+ sortKeys,
462
+ } from "./canonical.js";
463
+ export { expandHome, resolveSafePath, isPathTraversal, canonicalizeFsPath } from "./paths.js";
464
+ export { splitSubcommands } from "./tokenizer.js";
465
+ export { redactCredentials, redactUrl } from "./redact.js";
466
+ export {
467
+ CommandDispatcher,
468
+ isReviewerAgent,
469
+ extractFilePath,
470
+ TOOL_KIND_SHELL,
471
+ TOOL_KIND_FILE_WRITE,
472
+ TOOL_KIND_FILE_READ,
473
+ TOOL_KIND_NETWORK,
474
+ TOOL_KIND_GENERIC,
475
+ } from "./dispatcher.js";
476
+ export {
477
+ parseGitArgv,
478
+ classifyGitOperation,
479
+ inspectGitCommand,
480
+ GIT_CATEGORY_READ,
481
+ GIT_CATEGORY_MUTATE_LOCAL,
482
+ GIT_CATEGORY_REMOTE,
483
+ GIT_CATEGORY_DESTRUCTIVE,
484
+ } from "./parsers/git.js";
485
+ export {
486
+ isDevicePath,
487
+ extractRedirects,
488
+ inspectRedirectSecurity,
489
+ inspectSilentErrors,
490
+ inspectPipelines,
491
+ } from "./parsers/shell-cmds.js";
492
+ export {
493
+ isLoopbackHost,
494
+ isLoopbackUrl,
495
+ isLoopbackCommand,
496
+ } from "./loopback.js";
497
+ export {
498
+ computeFingerprint,
499
+ computeFingerprintHash,
500
+ normalizeCommandString,
501
+ } from "./fingerprint.js";
502
+ export {
503
+ VerdictCache,
504
+ DEFAULT_CACHE_MAX_SIZE,
505
+ DEFAULT_CACHE_TTL_MS,
506
+ } from "./cache.js";
507
+ export {
508
+ RetryExhaustionGuard,
509
+ DEFAULT_MAX_RETRY_ATTEMPTS,
510
+ } from "./retry-exhaustion.js";
511
+ export {
512
+ QualityLedger,
513
+ MAX_LEDGER_EVENTS_PER_SESSION,
514
+ } from "./ledger.js";
515
+ export {
516
+ AuditLogger,
517
+ DEFAULT_AUDIT_LOG_RELATIVE,
518
+ DEFAULT_MAX_AUDIT_BYTES,
519
+ DEFAULT_MAX_AUDIT_LINES,
520
+ DEFAULT_RETAIN_LINES,
521
+ auditRotateContent,
522
+ } from "./audit-log.js";
523
+ export {
524
+ BlockCounters,
525
+ categorizeReason,
526
+ } from "./counters.js";
527
+ export {
528
+ isCriticalOperation,
529
+ } from "./engine.js";
530
+ export {
531
+ parseYaml,
532
+ parseScalar,
533
+ stripComment,
534
+ } from "./yaml-parser.js";
535
+ export {
536
+ matchConditionValue,
537
+ evaluateWhen,
538
+ } from "./when-matcher.js";
539
+ export {
540
+ HotwordsMatcher,
541
+ DEFAULT_HOTWORDS,
542
+ extractWords,
543
+ } from "./hotwords.js";
544
+ export {
545
+ DeclarativeRulesEngine,
546
+ BUILTIN_RULES_FILE,
547
+ WORKSPACE_RULES_REL,
548
+ USER_RULES_REL,
549
+ } from "./rules-loader.js";
550
+ export {
551
+ normalizeChatCompletionsUrl,
552
+ withLlmRetry,
553
+ sanitizeClassifierInput,
554
+ truncateClassifierContext,
555
+ isLowRiskExecution,
556
+ LlmClassifier,
557
+ } from "./classifier.js";