@goodandready/dsh-approval-gate 0.1.19 → 0.1.21
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/agents-parser.js +183 -0
- package/lib/artifacts.js +162 -0
- package/lib/bands.js +243 -239
- package/lib/compat.js +124 -0
- package/lib/engine.js +78 -60
- package/lib/index.js +58 -97
- package/lib/measure-kinds.js +135 -0
- package/lib/messages.js +28 -1
- package/lib/snapshots.js +268 -0
- package/lib/trash.js +119 -0
- package/lib/version-guard.js +177 -0
- package/package.json +55 -55
package/lib/engine.js
CHANGED
|
@@ -1,11 +1,5 @@
|
|
|
1
1
|
// lib/engine.js
|
|
2
|
-
// 5-tier Security Decision Engine for dsh-approval-gate.
|
|
3
|
-
// Waterfall:
|
|
4
|
-
// P0: Hard Deny (Bands Band 0 + Declarative Rules Deny + inspectExecution hard denials + protected files + retry exhaustion + fail-closed audit)
|
|
5
|
-
// P1: Session Grants & Declarative Allow (scoped temporary allowances and whitelists)
|
|
6
|
-
// P2: Static Rules / Safe Allow (Bands Band 1 + safe read-only commands)
|
|
7
|
-
// P3: Optional Semantic Classifier
|
|
8
|
-
// P4: Human Ask Fallback (uncertain syntax / ambiguous commands / tripped breaker / declarative ask / lockedAsk)
|
|
2
|
+
// 5-tier Security Decision Engine for dsh-approval-gate (P0-P4 Waterfall).
|
|
9
3
|
|
|
10
4
|
import { BandsEngine } from "./bands.js";
|
|
11
5
|
import { CircuitBreaker } from "./breaker.js";
|
|
@@ -19,9 +13,15 @@ import { DeclarativeRulesEngine } from "./rules-loader.js";
|
|
|
19
13
|
import { BlockCounters } from "./counters.js";
|
|
20
14
|
import { ContractGuard } from "./contract-guard.js";
|
|
21
15
|
import { OverengineeringGuard } from "./overengineering.js";
|
|
22
|
-
import { extractFilePath, DEFAULT_FILE_WRITE_TOOLS } from "./dispatcher.js";
|
|
16
|
+
import { extractFilePath, DEFAULT_BASH_TOOL, DEFAULT_FILE_WRITE_TOOLS } from "./dispatcher.js";
|
|
23
17
|
import { categorizeRiskBucket, extractRiskTags } from "./risk.js";
|
|
24
18
|
import { ClassifierLatencyTracker } from "./latency.js";
|
|
19
|
+
import { ArtifactRegistry } from "./artifacts.js";
|
|
20
|
+
import { isRmCommand, extractRmTargets, suggestTrashAlternative } from "./trash.js";
|
|
21
|
+
import { VersionGuard } from "./version-guard.js";
|
|
22
|
+
import { loadAgentsRules, evaluateAgentsRules } from "./agents-parser.js";
|
|
23
|
+
import { classifyMeasureKind } from "./measure-kinds.js";
|
|
24
|
+
import { SnapshotManager } from "./snapshots.js";
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
27
|
* Determine whether an operation belongs to a critical risk category requiring lockedAsk (#52).
|
|
@@ -73,26 +73,7 @@ export function isCriticalOperation(toolName, command, execution) {
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
export class SecurityEngine {
|
|
76
|
-
/**
|
|
77
|
-
* @param {{
|
|
78
|
-
* bands?: BandsEngine,
|
|
79
|
-
* breaker?: CircuitBreaker,
|
|
80
|
-
* cache?: VerdictCache,
|
|
81
|
-
* retryGuard?: RetryExhaustionGuard,
|
|
82
|
-
* ledger?: QualityLedger,
|
|
83
|
-
* auditLogger?: AuditLogger,
|
|
84
|
-
* declarativeRules?: DeclarativeRulesEngine,
|
|
85
|
-
* counters?: BlockCounters,
|
|
86
|
-
* auditFailClosed?: boolean,
|
|
87
|
-
* timeoutPolicy?: "strict" | "permissive",
|
|
88
|
-
* inspectConfig?: { toolName?: string, fileWriteTools?: string[], workspaceDir?: string },
|
|
89
|
-
* classifier?: ((execution: any, context?: any) => { verdict: "allow"|"deny", reason?: string }),
|
|
90
|
-
* grantRegistry?: { decide: (tool: string, args: any, context?: any) => "allow"|"no-match" },
|
|
91
|
-
* dryRun?: boolean,
|
|
92
|
-
* shadowMode?: boolean,
|
|
93
|
-
* defaultVerdict?: "allow" | "ask"
|
|
94
|
-
* }} [options]
|
|
95
|
-
*/
|
|
76
|
+
/** @param {Record<string, any>} [options] */
|
|
96
77
|
constructor(options = {}) {
|
|
97
78
|
this.bands = options.bands || new BandsEngine(options);
|
|
98
79
|
this.breaker = options.breaker || new CircuitBreaker(options);
|
|
@@ -100,7 +81,7 @@ export class SecurityEngine {
|
|
|
100
81
|
this.classifier = options.classifier || null;
|
|
101
82
|
this.timeoutPolicy = options.timeoutPolicy === "permissive" ? "permissive" : "strict";
|
|
102
83
|
this.grantRegistry = options.grantRegistry || new SessionGrantStore(options);
|
|
103
|
-
this.defaultVerdict = options.defaultVerdict || "
|
|
84
|
+
this.defaultVerdict = options.defaultVerdict || "ask";
|
|
104
85
|
|
|
105
86
|
this.dryRun = Boolean(options.dryRun || options.shadowMode);
|
|
106
87
|
this.auditFailClosed = options.auditFailClosed === true;
|
|
@@ -108,24 +89,16 @@ export class SecurityEngine {
|
|
|
108
89
|
this.retryGuard = options.retryGuard !== undefined ? options.retryGuard : new RetryExhaustionGuard();
|
|
109
90
|
this.ledger = options.ledger !== undefined ? options.ledger : new QualityLedger();
|
|
110
91
|
this.counters = options.counters || new BlockCounters();
|
|
111
|
-
this.auditLogger = options.auditLogger !== undefined ? options.auditLogger : new AuditLogger({
|
|
112
|
-
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
117
|
-
this.declarativeRules = options.declarativeRules || new DeclarativeRulesEngine({
|
|
118
|
-
workspaceDir: this.inspectConfig?.workspaceDir,
|
|
119
|
-
});
|
|
120
|
-
this.contractGuard = options.contractGuard !== undefined
|
|
121
|
-
? options.contractGuard
|
|
122
|
-
: (options.taskContract ? new ContractGuard(options.taskContract) : null);
|
|
123
|
-
this.overengineeringGuard = options.overengineeringGuard !== undefined
|
|
124
|
-
? options.overengineeringGuard
|
|
125
|
-
: (options.maxFilesPerSession !== undefined || options.maxCreatedFilesPerSession !== undefined
|
|
126
|
-
? new OverengineeringGuard(options)
|
|
127
|
-
: null);
|
|
92
|
+
this.auditLogger = options.auditLogger !== undefined ? options.auditLogger : new AuditLogger({ failClosed: this.auditFailClosed });
|
|
93
|
+
if (this.auditFailClosed && this.auditLogger) this.auditLogger.failClosed = true;
|
|
94
|
+
this.declarativeRules = options.declarativeRules || new DeclarativeRulesEngine({ workspaceDir: this.inspectConfig?.workspaceDir });
|
|
95
|
+
this.contractGuard = options.contractGuard !== undefined ? options.contractGuard : (options.taskContract ? new ContractGuard(options.taskContract) : null);
|
|
96
|
+
this.overengineeringGuard = options.overengineeringGuard !== undefined ? options.overengineeringGuard : (options.maxFilesPerSession !== undefined || options.maxCreatedFilesPerSession !== undefined ? new OverengineeringGuard(options) : null);
|
|
128
97
|
this.latencyTracker = options.latencyTracker || new ClassifierLatencyTracker(options);
|
|
98
|
+
this.artifactRegistry = options.artifactRegistry !== undefined ? options.artifactRegistry : new ArtifactRegistry({ workspaceDir: this.inspectConfig?.workspaceDir });
|
|
99
|
+
this.versionGuard = options.versionGuard !== undefined ? options.versionGuard : new VersionGuard({ workspaceDir: this.inspectConfig?.workspaceDir });
|
|
100
|
+
this.agentsRules = options.agentsRules !== undefined ? options.agentsRules : (loadAgentsRules(this.inspectConfig?.workspaceDir)?.rules || null);
|
|
101
|
+
this.snapshotManager = options.snapshotManager !== undefined ? options.snapshotManager : new SnapshotManager({ workspaceDir: this.inspectConfig?.workspaceDir });
|
|
129
102
|
}
|
|
130
103
|
|
|
131
104
|
/**
|
|
@@ -194,6 +167,10 @@ export class SecurityEngine {
|
|
|
194
167
|
if (this.retryGuard) this.retryGuard.recordVerdict(sessionId, execution, "allow");
|
|
195
168
|
if (this.ledger) this.ledger.record(sessionId, "allow", { reason: decision.reason, detail: decision.detail, tool: toolName });
|
|
196
169
|
if (this.cache && cacheKey) this.cache.set(cacheKey, decision, { sessionId });
|
|
170
|
+
if (this.artifactRegistry) {
|
|
171
|
+
const targetPath = extractFilePath(execution && execution.arguments);
|
|
172
|
+
if (targetPath) this.artifactRegistry.recordArtifact(sessionId, targetPath);
|
|
173
|
+
}
|
|
197
174
|
} else if (decision.verdict === "ask") {
|
|
198
175
|
if (this.ledger) this.ledger.record(sessionId, "ask", { reason: decision.reason, detail: decision.detail, tool: toolName });
|
|
199
176
|
}
|
|
@@ -341,6 +318,16 @@ export class SecurityEngine {
|
|
|
341
318
|
}
|
|
342
319
|
}
|
|
343
320
|
|
|
321
|
+
// AGENTS.md rules (#91)
|
|
322
|
+
if (this.agentsRules) {
|
|
323
|
+
const ag = evaluateAgentsRules(this.agentsRules, execution, extractFilePath(execution && execution.arguments));
|
|
324
|
+
if (ag.match) {
|
|
325
|
+
this.breaker.countDeny(sessionId);
|
|
326
|
+
const agHit = { deny: true, reason: ag.reason, detail: ag.rule?.text, snippet: command || toolName };
|
|
327
|
+
return { verdict: "deny", stage: "P0", reason: ag.reason, detail: ag.rule?.text, hit: agHit };
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
344
331
|
// Inspect execution for hard denials and syntax checks
|
|
345
332
|
const hit = inspectExecution(execution, this.inspectConfig);
|
|
346
333
|
if (hit.deny) {
|
|
@@ -384,6 +371,31 @@ export class SecurityEngine {
|
|
|
384
371
|
}
|
|
385
372
|
}
|
|
386
373
|
|
|
374
|
+
// VersionGuard: major version bump control (#95)
|
|
375
|
+
if (this.versionGuard && typeof toolName === "string" && fileTools.has(toolName)) {
|
|
376
|
+
const targetPath = extractFilePath(execution ? execution.arguments : undefined);
|
|
377
|
+
const content = execution?.arguments?.content || execution?.arguments?.code || execution?.arguments?.CodeContent || execution?.arguments?.text;
|
|
378
|
+
if (targetPath && content) {
|
|
379
|
+
const vg = this.versionGuard.inspectWrite(targetPath, content);
|
|
380
|
+
if (vg.majorBump) {
|
|
381
|
+
const vgHit = { ask: true, reason: "major-version-bump", detail: vg.detail, snippet: targetPath };
|
|
382
|
+
const res = { verdict: "ask", stage: "P4", reason: "major-version-bump", detail: vg.detail, hit: vgHit };
|
|
383
|
+
this._applyLockedAskIfCritical(res, toolName, command, execution);
|
|
384
|
+
return res;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// P1: Session Artifact Cleanup Whitelist (#100)
|
|
390
|
+
if (command && isRmCommand(command)) {
|
|
391
|
+
const targets = extractRmTargets(command);
|
|
392
|
+
if (targets.length > 0 && this.artifactRegistry && this.artifactRegistry.areAllSessionArtifacts(sessionId, targets)) {
|
|
393
|
+
this.breaker.resetConsecutive(sessionId);
|
|
394
|
+
const artHit = { allow: true, reason: "session-artifact-cleanup", snippet: command };
|
|
395
|
+
return { verdict: "allow", stage: "P1", reason: "session-artifact-cleanup", hit: artHit };
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
387
399
|
// P1: Session Grants
|
|
388
400
|
if (this.grantRegistry && typeof this.grantRegistry.decide === "function") {
|
|
389
401
|
const grantMatch = this.grantRegistry.decide(toolName, execution.arguments, context);
|
|
@@ -466,8 +478,10 @@ export class SecurityEngine {
|
|
|
466
478
|
}
|
|
467
479
|
}
|
|
468
480
|
|
|
469
|
-
// Default verdict for unclassified commands (#163)
|
|
470
|
-
|
|
481
|
+
// Default verdict for unclassified commands (#163, #172)
|
|
482
|
+
const bashTool = this.inspectConfig?.toolName || DEFAULT_BASH_TOOL;
|
|
483
|
+
const isBashTool = toolName === bashTool || !toolName;
|
|
484
|
+
if (isBashTool && this.defaultVerdict === "ask") {
|
|
471
485
|
const askHit = { ask: true, reason: "unmatched-ask", snippet: command || toolName };
|
|
472
486
|
const res = { verdict: "ask", stage: "P4", reason: "unmatched-ask", hit: askHit };
|
|
473
487
|
this._applyLockedAskIfCritical(res, toolName, command, execution);
|
|
@@ -537,21 +551,25 @@ export class SecurityEngine {
|
|
|
537
551
|
*/
|
|
538
552
|
_enrichDecision(decision, execution, context) {
|
|
539
553
|
if (!decision) return decision;
|
|
540
|
-
if (!decision.riskBucket)
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
554
|
+
if (!decision.riskBucket) decision.riskBucket = categorizeRiskBucket(execution, context);
|
|
555
|
+
if (!decision.riskTags || !Array.isArray(decision.riskTags)) decision.riskTags = extractRiskTags(execution, context);
|
|
556
|
+
if (!decision.measureKind) decision.measureKind = classifyMeasureKind(execution, context);
|
|
557
|
+
const command = execution && execution.arguments && execution.arguments.command;
|
|
558
|
+
if (command && isRmCommand(command)) {
|
|
559
|
+
decision.trashAlternative = suggestTrashAlternative(command, this.inspectConfig?.workspaceDir, context?.sessionId);
|
|
545
560
|
}
|
|
546
561
|
return decision;
|
|
547
562
|
}
|
|
548
563
|
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
}
|
|
564
|
+
getArtifacts(sessionId) { return this.artifactRegistry ? this.artifactRegistry.getArtifacts(sessionId) : []; }
|
|
565
|
+
clearArtifacts(sessionId) { if (this.artifactRegistry) this.artifactRegistry.clearSession(sessionId); }
|
|
566
|
+
getLatencyMetrics() { return this.latencyTracker ? this.latencyTracker.getMetrics() : null; }
|
|
567
|
+
resetLatencyTracker() { if (this.latencyTracker) this.latencyTracker.reset(); }
|
|
568
|
+
takeSnapshot(targetPath, meta) { return this.snapshotManager ? this.snapshotManager.takeSnapshot(targetPath, meta) : null; }
|
|
569
|
+
rollbackSnapshot(id) { return this.snapshotManager ? this.snapshotManager.rollbackSnapshot(id) : { success: false, error: "no-snapshot-manager" }; }
|
|
570
|
+
getSnapshots(sessionId) { return this.snapshotManager ? this.snapshotManager.getSnapshots(sessionId) : []; }
|
|
571
|
+
clearSnapshots(sessionId) { return this.snapshotManager ? this.snapshotManager.clearSnapshots(sessionId) : { cleared: 0 }; }
|
|
572
|
+
clearAllSnapshots() { return this.snapshotManager ? this.snapshotManager.clearAllSnapshots() : { cleared: 0 }; }
|
|
556
573
|
}
|
|
557
574
|
|
|
575
|
+
|
package/lib/index.js
CHANGED
|
@@ -4,6 +4,10 @@ import { AuditLogger } from "./audit-log.js";
|
|
|
4
4
|
import { DeclarativeRulesEngine } from "./rules-loader.js";
|
|
5
5
|
import { LlmClassifier } from "./classifier.js";
|
|
6
6
|
import { ClassifierLatencyTracker } from "./latency.js";
|
|
7
|
+
import { assertHarnessCompatibility, provideServicePolyfill } from "./compat.js";
|
|
8
|
+
import { extractFilePath } from "./dispatcher.js";
|
|
9
|
+
import { SnapshotManager } from "./snapshots.js";
|
|
10
|
+
import { MEASURE_KINDS, classifyMeasureKind } from "./measure-kinds.js";
|
|
7
11
|
import Schema from "@deepseek-ai/schemastery";
|
|
8
12
|
|
|
9
13
|
if (typeof Schema.prototype?.volatile !== "function") {
|
|
@@ -207,8 +211,12 @@ export const Config = Schema.object({
|
|
|
207
211
|
unattended: Schema.boolean().default(false).volatile(),
|
|
208
212
|
/** Workspace directory for path traversal boundary checks. */
|
|
209
213
|
workspaceDir: Schema.string().default("").volatile(),
|
|
210
|
-
/** Default verdict for unclassified commands: '
|
|
211
|
-
defaultVerdict: Schema.union(["allow", "ask"]).default("
|
|
214
|
+
/** Default verdict for unclassified commands: 'ask' (default, fail-closed) or 'allow' (#163, #172). */
|
|
215
|
+
defaultVerdict: Schema.union(["allow", "ask"]).default("ask").volatile(),
|
|
216
|
+
/** Maximum retained file snapshots per session (#78). */
|
|
217
|
+
maxSnapshotsPerSession: Schema.number().default(50).volatile(),
|
|
218
|
+
/** Maximum file size in bytes for pre-execution snapshots (#77). */
|
|
219
|
+
maxSnapshotBytes: Schema.number().default(1048576).volatile(),
|
|
212
220
|
/** Dry-run / shadow mode for safe testing of rules on production (#128). */
|
|
213
221
|
dryRun: Schema.boolean().default(false).volatile(),
|
|
214
222
|
/** Shadow mode alias for dryRun (#128). */
|
|
@@ -241,6 +249,10 @@ export const Config = Schema.object({
|
|
|
241
249
|
classifierSlaMs: Schema.number().default(2000).volatile(),
|
|
242
250
|
/** Task contract boundary rules (#87). */
|
|
243
251
|
taskContract: Schema.any().volatile(),
|
|
252
|
+
/** Enforce project rules and constraints parsed from AGENTS.md (#91). */
|
|
253
|
+
agentsRules: Schema.boolean().default(true).volatile(),
|
|
254
|
+
/** Enforce human confirmation on breaking major dependency bumps in package.json (#95). */
|
|
255
|
+
versionGuard: Schema.boolean().default(true).volatile(),
|
|
244
256
|
/** Security engine instance (internal/testing). */
|
|
245
257
|
engine: Schema.any().hidden(),
|
|
246
258
|
});
|
|
@@ -251,6 +263,7 @@ function logBlocked(ctx, hit, who) {
|
|
|
251
263
|
}
|
|
252
264
|
|
|
253
265
|
export function apply(ctx, config) {
|
|
266
|
+
const compat = assertHarnessCompatibility(ctx);
|
|
254
267
|
const cfg = plainConfig(Config(plainConfig(config) || {}) || {});
|
|
255
268
|
const toolName = cfg.toolName || DEFAULT_BASH_TOOL;
|
|
256
269
|
const fileWriteTools = cfg.fileWriteTools || DEFAULT_FILE_WRITE_TOOLS.slice();
|
|
@@ -307,7 +320,8 @@ export function apply(ctx, config) {
|
|
|
307
320
|
latencyTracker: new ClassifierLatencyTracker({
|
|
308
321
|
slaMs: cfg.classifierSlaMs !== undefined ? cfg.classifierSlaMs : 2000,
|
|
309
322
|
}),
|
|
310
|
-
defaultVerdict: cfg.defaultVerdict || "
|
|
323
|
+
defaultVerdict: cfg.defaultVerdict || "ask",
|
|
324
|
+
snapshotManager: new SnapshotManager({ workspaceDir: cfg.workspaceDir || "", maxSnapshotsPerSession: cfg.maxSnapshotsPerSession, maxSnapshotBytes: cfg.maxSnapshotBytes }),
|
|
311
325
|
dryRun: Boolean(cfg.dryRun || cfg.shadowMode),
|
|
312
326
|
auditFailClosed: Boolean(cfg.auditFailClosed),
|
|
313
327
|
auditLogger: new AuditLogger({
|
|
@@ -334,6 +348,14 @@ export function apply(ctx, config) {
|
|
|
334
348
|
getLedgerSummary: (sessionId) => engine.getLedgerSummary(sessionId),
|
|
335
349
|
getLatencyMetrics: () => (engine.getLatencyMetrics ? engine.getLatencyMetrics() : null),
|
|
336
350
|
resetLatencyTracker: () => (engine.resetLatencyTracker ? engine.resetLatencyTracker() : undefined),
|
|
351
|
+
getArtifacts: (sessionId) => (engine.getArtifacts ? engine.getArtifacts(sessionId) : []),
|
|
352
|
+
clearArtifacts: (sessionId) => (engine.clearArtifacts ? engine.clearArtifacts(sessionId) : undefined),
|
|
353
|
+
takeSnapshot: (targetPath, meta) => (engine.takeSnapshot ? engine.takeSnapshot(targetPath, meta) : null),
|
|
354
|
+
rollbackSnapshot: (id) => (engine.rollbackSnapshot ? engine.rollbackSnapshot(id) : { success: false, error: "no-engine" }),
|
|
355
|
+
getSnapshots: (sessionId) => (engine.getSnapshots ? engine.getSnapshots(sessionId) : []),
|
|
356
|
+
clearSnapshots: (sessionId) => (engine.clearSnapshots ? engine.clearSnapshots(sessionId) : { cleared: 0 }),
|
|
357
|
+
clearAllSnapshots: () => (engine.clearAllSnapshots ? engine.clearAllSnapshots() : { cleared: 0 }),
|
|
358
|
+
getSnapshotStats: () => (engine.snapshotManager ? engine.snapshotManager.getStats() : null),
|
|
337
359
|
getSnapshot: () => ({
|
|
338
360
|
active: true,
|
|
339
361
|
dryRun: Boolean(cfg.dryRun || cfg.shadowMode),
|
|
@@ -345,15 +367,11 @@ export function apply(ctx, config) {
|
|
|
345
367
|
cacheStats: engine.cache ? engine.cache.stats() : null,
|
|
346
368
|
breakerState: engine.breaker ? engine.breaker.getState() : null,
|
|
347
369
|
latencyMetrics: engine.getLatencyMetrics ? engine.getLatencyMetrics() : null,
|
|
370
|
+
snapshots: engine.snapshotManager ? engine.snapshotManager.getStats() : null,
|
|
371
|
+
harnessCompat: compat.capabilities,
|
|
348
372
|
}),
|
|
349
373
|
};
|
|
350
|
-
|
|
351
|
-
if (typeof ctx.provide === "function") {
|
|
352
|
-
ctx.provide("approvalGate", gateService);
|
|
353
|
-
} else {
|
|
354
|
-
ctx.approvalGate = gateService;
|
|
355
|
-
}
|
|
356
|
-
}
|
|
374
|
+
provideServicePolyfill(ctx, "approvalGate", gateService);
|
|
357
375
|
|
|
358
376
|
const hasPreExecute = ctx && typeof ctx.on === "function";
|
|
359
377
|
if (hasPreExecute) {
|
|
@@ -415,6 +433,10 @@ export function apply(ctx, config) {
|
|
|
415
433
|
};
|
|
416
434
|
return { kind: "ask", reason: askMessage(hit, who, translate, ctx && ctx.logger) };
|
|
417
435
|
}
|
|
436
|
+
if (engine.snapshotManager) {
|
|
437
|
+
const targetPath = extractFilePath(execution && execution.arguments);
|
|
438
|
+
if (targetPath) engine.takeSnapshot(targetPath, { sessionId, callId: execution && (execution.callId || execution.id), tool: execution && execution.name });
|
|
439
|
+
}
|
|
418
440
|
return next();
|
|
419
441
|
});
|
|
420
442
|
}
|
|
@@ -452,6 +474,10 @@ export function apply(ctx, config) {
|
|
|
452
474
|
logBlocked(ctx, hit, who);
|
|
453
475
|
return denyMessage(hit, who, translate, ctx && ctx.logger);
|
|
454
476
|
}
|
|
477
|
+
if (engine.snapshotManager) {
|
|
478
|
+
const targetPath = extractFilePath(execution && execution.arguments);
|
|
479
|
+
if (targetPath) engine.takeSnapshot(targetPath, { sessionId, callId: execution && (execution.callId || execution.id), tool: execution && execution.name });
|
|
480
|
+
}
|
|
455
481
|
return undefined;
|
|
456
482
|
};
|
|
457
483
|
|
|
@@ -473,105 +499,40 @@ export {
|
|
|
473
499
|
CIRCUIT_STATE_TRIPPED,
|
|
474
500
|
CIRCUIT_STATE_RESUMED,
|
|
475
501
|
} from "./breaker.js";
|
|
476
|
-
export {
|
|
477
|
-
|
|
478
|
-
DEFAULT_GRANT_TTL_MS,
|
|
479
|
-
DEFAULT_GRANT_MAX_USES,
|
|
480
|
-
matchGrantCriteria,
|
|
481
|
-
} from "./grants.js";
|
|
482
|
-
export {
|
|
483
|
-
canonicalizeCall,
|
|
484
|
-
canonicalString,
|
|
485
|
-
hashCall,
|
|
486
|
-
sortKeys,
|
|
487
|
-
} from "./canonical.js";
|
|
502
|
+
export { SessionGrantStore, DEFAULT_GRANT_TTL_MS, DEFAULT_GRANT_MAX_USES, matchGrantCriteria } from "./grants.js";
|
|
503
|
+
export { canonicalizeCall, canonicalString, hashCall, sortKeys } from "./canonical.js";
|
|
488
504
|
export { expandHome, resolveSafePath, isPathTraversal, canonicalizeFsPath } from "./paths.js";
|
|
489
505
|
export { splitSubcommands } from "./tokenizer.js";
|
|
490
506
|
export { redactCredentials, redactUrl } from "./redact.js";
|
|
491
|
-
export {
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
} from "./dispatcher.js";
|
|
501
|
-
export {
|
|
502
|
-
parseGitArgv,
|
|
503
|
-
classifyGitOperation,
|
|
504
|
-
inspectGitCommand,
|
|
505
|
-
GIT_CATEGORY_READ,
|
|
506
|
-
GIT_CATEGORY_MUTATE_LOCAL,
|
|
507
|
-
GIT_CATEGORY_REMOTE,
|
|
508
|
-
GIT_CATEGORY_DESTRUCTIVE,
|
|
509
|
-
} from "./parsers/git.js";
|
|
510
|
-
export {
|
|
511
|
-
isDevicePath,
|
|
512
|
-
extractRedirects,
|
|
513
|
-
inspectRedirectSecurity,
|
|
514
|
-
inspectSilentErrors,
|
|
515
|
-
inspectPipelines,
|
|
516
|
-
} from "./parsers/shell-cmds.js";
|
|
517
|
-
export {
|
|
518
|
-
isLoopbackHost,
|
|
519
|
-
isLoopbackUrl,
|
|
520
|
-
isLoopbackCommand,
|
|
521
|
-
} from "./loopback.js";
|
|
522
|
-
export {
|
|
523
|
-
computeFingerprint,
|
|
524
|
-
computeFingerprintHash,
|
|
525
|
-
normalizeCommandString,
|
|
526
|
-
} from "./fingerprint.js";
|
|
527
|
-
export {
|
|
528
|
-
VerdictCache,
|
|
529
|
-
DEFAULT_CACHE_MAX_SIZE,
|
|
530
|
-
DEFAULT_CACHE_TTL_MS,
|
|
531
|
-
} from "./cache.js";
|
|
532
|
-
export {
|
|
533
|
-
RetryExhaustionGuard,
|
|
534
|
-
DEFAULT_MAX_RETRY_ATTEMPTS,
|
|
535
|
-
} from "./retry-exhaustion.js";
|
|
536
|
-
export {
|
|
537
|
-
QualityLedger,
|
|
538
|
-
MAX_LEDGER_EVENTS_PER_SESSION,
|
|
539
|
-
} from "./ledger.js";
|
|
540
|
-
export {
|
|
541
|
-
AuditLogger,
|
|
542
|
-
DEFAULT_AUDIT_LOG_RELATIVE,
|
|
543
|
-
DEFAULT_MAX_AUDIT_BYTES,
|
|
544
|
-
DEFAULT_MAX_AUDIT_LINES,
|
|
545
|
-
DEFAULT_RETAIN_LINES,
|
|
546
|
-
auditRotateContent,
|
|
547
|
-
} from "./audit-log.js";
|
|
507
|
+
export { CommandDispatcher, isReviewerAgent, extractFilePath, TOOL_KIND_SHELL, TOOL_KIND_FILE_WRITE, TOOL_KIND_FILE_READ, TOOL_KIND_NETWORK, TOOL_KIND_GENERIC } from "./dispatcher.js";
|
|
508
|
+
export { parseGitArgv, classifyGitOperation, inspectGitCommand, GIT_CATEGORY_READ, GIT_CATEGORY_MUTATE_LOCAL, GIT_CATEGORY_REMOTE, GIT_CATEGORY_DESTRUCTIVE } from "./parsers/git.js";
|
|
509
|
+
export { isDevicePath, extractRedirects, inspectRedirectSecurity, inspectSilentErrors, inspectPipelines } from "./parsers/shell-cmds.js";
|
|
510
|
+
export { isLoopbackHost, isLoopbackUrl, isLoopbackCommand } from "./loopback.js";
|
|
511
|
+
export { computeFingerprint, computeFingerprintHash, normalizeCommandString } from "./fingerprint.js";
|
|
512
|
+
export { VerdictCache, DEFAULT_CACHE_MAX_SIZE, DEFAULT_CACHE_TTL_MS } from "./cache.js";
|
|
513
|
+
export { RetryExhaustionGuard, DEFAULT_MAX_RETRY_ATTEMPTS } from "./retry-exhaustion.js";
|
|
514
|
+
export { QualityLedger, MAX_LEDGER_EVENTS_PER_SESSION } from "./ledger.js";
|
|
515
|
+
export { AuditLogger, DEFAULT_AUDIT_LOG_RELATIVE, DEFAULT_MAX_AUDIT_BYTES, DEFAULT_MAX_AUDIT_LINES, DEFAULT_RETAIN_LINES, auditRotateContent } from "./audit-log.js";
|
|
548
516
|
export { BlockCounters, categorizeReason } from "./counters.js";
|
|
549
517
|
export { isCriticalOperation } from "./engine.js";
|
|
550
518
|
export { parseYaml, parseScalar, stripComment } from "./yaml-parser.js";
|
|
551
519
|
export { matchConditionValue, evaluateWhen } from "./when-matcher.js";
|
|
552
520
|
export { HotwordsMatcher, DEFAULT_HOTWORDS, extractWords } from "./hotwords.js";
|
|
553
|
-
export {
|
|
554
|
-
|
|
555
|
-
BUILTIN_RULES_FILE,
|
|
556
|
-
WORKSPACE_RULES_REL,
|
|
557
|
-
USER_RULES_REL,
|
|
558
|
-
} from "./rules-loader.js";
|
|
559
|
-
export {
|
|
560
|
-
normalizeChatCompletionsUrl,
|
|
561
|
-
withLlmRetry,
|
|
562
|
-
sanitizeClassifierInput,
|
|
563
|
-
truncateClassifierContext,
|
|
564
|
-
isLowRiskExecution,
|
|
565
|
-
isShapeRejectError,
|
|
566
|
-
extractJsonPayload,
|
|
567
|
-
ClassifierVerdictSchema,
|
|
568
|
-
validateClassifierResponse,
|
|
569
|
-
LlmClassifier,
|
|
570
|
-
} from "./classifier.js";
|
|
521
|
+
export { DeclarativeRulesEngine, BUILTIN_RULES_FILE, WORKSPACE_RULES_REL, USER_RULES_REL } from "./rules-loader.js";
|
|
522
|
+
export { normalizeChatCompletionsUrl, withLlmRetry, sanitizeClassifierInput, truncateClassifierContext, isLowRiskExecution, isShapeRejectError, extractJsonPayload, ClassifierVerdictSchema, validateClassifierResponse, LlmClassifier } from "./classifier.js";
|
|
571
523
|
export { ContractGuard } from "./contract-guard.js";
|
|
572
524
|
export { OverengineeringGuard, DEFAULT_MAX_FILES_PER_SESSION } from "./overengineering.js";
|
|
573
525
|
export { resolveTempRoot, isGlobalTempPath, checkTempIsolation } from "./paths.js";
|
|
574
526
|
export { computePayloadSize, scanPromptInjection } from "./inspect.js";
|
|
575
527
|
export { RISK_BUCKETS, RISK_TAGS, categorizeRiskBucket, extractRiskTags } from "./risk.js";
|
|
576
528
|
export { ClassifierLatencyTracker, DEFAULT_CLASSIFIER_SLA_MS } from "./latency.js";
|
|
529
|
+
export { resolveTrashDir, isRmCommand, extractRmTargets, buildTrashCommand, suggestTrashAlternative } from "./trash.js";
|
|
530
|
+
export { ArtifactRegistry, normalizeArtifactPath } from "./artifacts.js";
|
|
531
|
+
export { parseAgentsSecurityRules, loadAgentsRules, evaluateAgentsRules } from "./agents-parser.js";
|
|
532
|
+
export { detectHarnessCapabilities, assertHarnessCompatibility, provideServicePolyfill, registerHookPolyfill } from "./compat.js";
|
|
533
|
+
export { parseSemver, isMajorBump, inspectPackageJsonDiff, VersionGuard } from "./version-guard.js";
|
|
534
|
+
export { SnapshotManager, DEFAULT_MAX_SNAPSHOTS_PER_SESSION, DEFAULT_MAX_SNAPSHOT_BYTES } from "./snapshots.js";
|
|
535
|
+
export { MEASURE_KINDS, classifyMeasureKind, isSystemLevelExecution, isWorkspaceWideExecution } from "./measure-kinds.js";
|
|
536
|
+
|
|
537
|
+
|
|
577
538
|
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// lib/measure-kinds.js
|
|
2
|
+
// Multi-level operational impact scale categorization (#97).
|
|
3
|
+
// Classifies execution into:
|
|
4
|
+
// - 'local-single': operation affects exactly one file or inspects local state
|
|
5
|
+
// - 'workspace-wide': operation affects the entire workspace/repository or multiple files
|
|
6
|
+
// - 'system-level': operation accesses or mutates OS-level, network, or environment state
|
|
7
|
+
|
|
8
|
+
export const MEASURE_KINDS = Object.freeze({
|
|
9
|
+
LOCAL_SINGLE: "local-single",
|
|
10
|
+
WORKSPACE_WIDE: "workspace-wide",
|
|
11
|
+
SYSTEM_LEVEL: "system-level",
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const SYSTEM_BINARIES = new Set([
|
|
15
|
+
"sudo", "su", "doas",
|
|
16
|
+
"systemctl", "service", "journalctl", "init",
|
|
17
|
+
"apt", "apt-get", "dpkg", "apk", "dnf", "yum", "pacman", "zypper",
|
|
18
|
+
"curl", "wget", "nc", "netcat", "ncat", "telnet", "ssh", "scp", "sftp", "rsync",
|
|
19
|
+
"iptables", "nftables", "ufw", "firewall-cmd",
|
|
20
|
+
"crontab", "at",
|
|
21
|
+
"reboot", "shutdown", "poweroff", "halt",
|
|
22
|
+
"mount", "umount", "fdisk", "parted", "mkfs", "dd",
|
|
23
|
+
"insmod", "rmmod", "modprobe",
|
|
24
|
+
"docker", "podman", "containerd", "runc", "crictl", "kubectl",
|
|
25
|
+
"passwd", "useradd", "userdel", "usermod", "groupadd",
|
|
26
|
+
"kill", "killall", "pkill", "xkill",
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
const SYSTEM_PATH_REGEX = /((\/|\b)(etc|boot|sys|proc|dev|usr|var\/log|root)(\/|\b)|~?\/\.(ssh|gnupg|bashrc|zshrc|profile)(\/|\b))/i;
|
|
30
|
+
|
|
31
|
+
const WORKSPACE_COMMAND_PATTERNS = [
|
|
32
|
+
/^(npm|pnpm|yarn|bun)\s+(install|add|remove|uninstall|update|upgrade|ci|build|pack|publish|run)/i,
|
|
33
|
+
/^(cargo|go|dotnet|mvn|gradle)\s+(build|test|clean|install|run)/i,
|
|
34
|
+
/^git\s+(checkout|switch|merge|rebase|pull|push|reset|clean|branch|tag|stash|restore|clone)/i,
|
|
35
|
+
/^(find|grep|ripgrep|rg|ack|ag)\s+/i,
|
|
36
|
+
/\b(make|ninja|cmake|meson)\b/i,
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const SINGLE_FILE_TOOLS = new Set([
|
|
40
|
+
"view_file", "read_file", "view",
|
|
41
|
+
"write_to_file", "create_file", "write",
|
|
42
|
+
"replace_file_content", "edit_file", "patch_file",
|
|
43
|
+
"inspect_file",
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
const SYSTEM_LEVEL_TOOLS = new Set([
|
|
47
|
+
"read_url_content", "search_web", "web_search",
|
|
48
|
+
"browse", "http_request", "network_fetch",
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Classifies the operational impact scale for a tool call execution.
|
|
53
|
+
* @param {Record<string, any>} execution
|
|
54
|
+
* @param {Record<string, any>} [context]
|
|
55
|
+
* @returns {'local-single' | 'workspace-wide' | 'system-level'}
|
|
56
|
+
*/
|
|
57
|
+
export function classifyMeasureKind(execution, context = {}) {
|
|
58
|
+
if (!execution) return MEASURE_KINDS.LOCAL_SINGLE;
|
|
59
|
+
|
|
60
|
+
const toolName = execution.name || execution.tool || "";
|
|
61
|
+
const args = execution.arguments || {};
|
|
62
|
+
|
|
63
|
+
// 1. Tool-level system categorization
|
|
64
|
+
if (SYSTEM_LEVEL_TOOLS.has(toolName)) {
|
|
65
|
+
return MEASURE_KINDS.SYSTEM_LEVEL;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 2. Tool-level single-file mutations / reads
|
|
69
|
+
if (SINGLE_FILE_TOOLS.has(toolName)) {
|
|
70
|
+
const target = args.TargetFile || args.targetPath || args.path || args.file || "";
|
|
71
|
+
if (typeof target === "string" && SYSTEM_PATH_REGEX.test(target)) {
|
|
72
|
+
return MEASURE_KINDS.SYSTEM_LEVEL;
|
|
73
|
+
}
|
|
74
|
+
return MEASURE_KINDS.LOCAL_SINGLE;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 3. Shell / command inspection
|
|
78
|
+
const command = typeof args.command === "string" ? args.command.trim() : "";
|
|
79
|
+
if (!command) {
|
|
80
|
+
return MEASURE_KINDS.LOCAL_SINGLE;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Fast check: first word
|
|
84
|
+
const firstWord = command.split(/\s+/)[0]?.toLowerCase();
|
|
85
|
+
if (SYSTEM_BINARIES.has(firstWord)) {
|
|
86
|
+
return MEASURE_KINDS.SYSTEM_LEVEL;
|
|
87
|
+
}
|
|
88
|
+
if ((command.includes("/") || command.includes(".")) && SYSTEM_PATH_REGEX.test(command)) {
|
|
89
|
+
return MEASURE_KINDS.SYSTEM_LEVEL;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Check subcommands or pipes for system binaries only if operators present
|
|
93
|
+
if (/[|;&]/.test(command)) {
|
|
94
|
+
const tokens = command.split(/[\s|;&]+/);
|
|
95
|
+
for (const token of tokens) {
|
|
96
|
+
if (SYSTEM_BINARIES.has(token.toLowerCase())) {
|
|
97
|
+
return MEASURE_KINDS.SYSTEM_LEVEL;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Check workspace-wide patterns
|
|
103
|
+
for (const pattern of WORKSPACE_COMMAND_PATTERNS) {
|
|
104
|
+
if (pattern.test(command)) {
|
|
105
|
+
return MEASURE_KINDS.WORKSPACE_WIDE;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Multi-target rm or recursive rm
|
|
110
|
+
if (/^rm\s+/i.test(command)) {
|
|
111
|
+
if (/\s-[a-zA-Z]*r/i.test(command) || command.includes("*") || command.split(/\s+/).length > 3) {
|
|
112
|
+
return MEASURE_KINDS.WORKSPACE_WIDE;
|
|
113
|
+
}
|
|
114
|
+
return MEASURE_KINDS.LOCAL_SINGLE;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Simple single-target commands
|
|
118
|
+
if (/^(cat|head|tail|sed|awk|echo|touch|ls|stat|file)\b/i.test(command)) {
|
|
119
|
+
if (command.includes("*") || command.includes(";")) {
|
|
120
|
+
return MEASURE_KINDS.WORKSPACE_WIDE;
|
|
121
|
+
}
|
|
122
|
+
return MEASURE_KINDS.LOCAL_SINGLE;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Default fallback for unrecognized operations
|
|
126
|
+
return MEASURE_KINDS.LOCAL_SINGLE;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function isSystemLevelExecution(execution, context) {
|
|
130
|
+
return classifyMeasureKind(execution, context) === MEASURE_KINDS.SYSTEM_LEVEL;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function isWorkspaceWideExecution(execution, context) {
|
|
134
|
+
return classifyMeasureKind(execution, context) === MEASURE_KINDS.WORKSPACE_WIDE;
|
|
135
|
+
}
|