@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/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 || "allow";
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
- failClosed: this.auditFailClosed,
113
- });
114
- if (this.auditFailClosed && this.auditLogger) {
115
- this.auditLogger.failClosed = true;
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
- if (this.defaultVerdict === "ask") {
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
- decision.riskBucket = categorizeRiskBucket(execution, context);
542
- }
543
- if (!decision.riskTags || !Array.isArray(decision.riskTags)) {
544
- decision.riskTags = extractRiskTags(execution, context);
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
- getLatencyMetrics() {
550
- return this.latencyTracker ? this.latencyTracker.getMetrics() : null;
551
- }
552
-
553
- resetLatencyTracker() {
554
- if (this.latencyTracker) this.latencyTracker.reset();
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: 'allow' (default) or 'ask' (fail-closed) (#163). */
211
- defaultVerdict: Schema.union(["allow", "ask"]).default("allow").volatile(),
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 || "allow",
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
- if (ctx && typeof ctx === "object") {
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
- SessionGrantStore,
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
- CommandDispatcher,
493
- isReviewerAgent,
494
- extractFilePath,
495
- TOOL_KIND_SHELL,
496
- TOOL_KIND_FILE_WRITE,
497
- TOOL_KIND_FILE_READ,
498
- TOOL_KIND_NETWORK,
499
- TOOL_KIND_GENERIC,
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
- DeclarativeRulesEngine,
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
+ }