@goodandready/dsh-approval-gate 0.1.20 → 0.1.22

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
@@ -5,6 +5,12 @@ import { DeclarativeRulesEngine } from "./rules-loader.js";
5
5
  import { LlmClassifier } from "./classifier.js";
6
6
  import { ClassifierLatencyTracker } from "./latency.js";
7
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";
11
+ import { LineageManager } from "./lineage.js";
12
+ import { generateAgentGuidance, injectAgentGuidance, extractGuidance, GUIDANCE_MARKER } from "./guidance.js";
13
+ import { isRedundantSandboxRequest, buildRedundantSandboxRemediation, checkEscalationJustification } from "./escalation.js";
8
14
  import Schema from "@deepseek-ai/schemastery";
9
15
 
10
16
  if (typeof Schema.prototype?.volatile !== "function") {
@@ -180,6 +186,16 @@ export function readKnobs(ctx, execution) {
180
186
  }
181
187
  }
182
188
 
189
+ // 5b. Lineage authority inheritance (#98)
190
+ if (!policy && session && (session.parentSession || session.parentId || session.parentSessionId)) {
191
+ const parent = session.parentSession || { id: session.parentId || session.parentSessionId };
192
+ if (typeof parent.approvalPolicy === "string") {
193
+ policy = parent.approvalPolicy;
194
+ } else if (approval && typeof approval.effectivePolicy === "function") {
195
+ try { policy = approval.effectivePolicy(parent) || ""; } catch {}
196
+ }
197
+ }
198
+
183
199
  // 6. Global static fallback from approval service config
184
200
  if (!policy && approval && approval.config && approval.config.policy) {
185
201
  policy = approval.config.policy;
@@ -208,8 +224,12 @@ export const Config = Schema.object({
208
224
  unattended: Schema.boolean().default(false).volatile(),
209
225
  /** Workspace directory for path traversal boundary checks. */
210
226
  workspaceDir: Schema.string().default("").volatile(),
211
- /** Default verdict for unclassified commands: 'allow' (default) or 'ask' (fail-closed) (#163). */
212
- defaultVerdict: Schema.union(["allow", "ask"]).default("allow").volatile(),
227
+ /** Default verdict for unclassified commands: 'ask' (default, fail-closed) or 'allow' (#163, #172). */
228
+ defaultVerdict: Schema.union(["allow", "ask"]).default("ask").volatile(),
229
+ /** Maximum retained file snapshots per session (#78). */
230
+ maxSnapshotsPerSession: Schema.number().default(50).volatile(),
231
+ /** Maximum file size in bytes for pre-execution snapshots (#77). */
232
+ maxSnapshotBytes: Schema.number().default(1048576).volatile(),
213
233
  /** Dry-run / shadow mode for safe testing of rules on production (#128). */
214
234
  dryRun: Schema.boolean().default(false).volatile(),
215
235
  /** Shadow mode alias for dryRun (#128). */
@@ -246,6 +266,10 @@ export const Config = Schema.object({
246
266
  agentsRules: Schema.boolean().default(true).volatile(),
247
267
  /** Enforce human confirmation on breaking major dependency bumps in package.json (#95). */
248
268
  versionGuard: Schema.boolean().default(true).volatile(),
269
+ /** Require explicit justification for privilege escalation (#107). */
270
+ requireJustification: Schema.boolean().default(false).volatile(),
271
+ /** Enable automatic remediation advice for redundant sandbox privilege requests (#101). */
272
+ enableRedundantSandboxRecovery: Schema.boolean().default(true).volatile(),
249
273
  /** Security engine instance (internal/testing). */
250
274
  engine: Schema.any().hidden(),
251
275
  });
@@ -313,7 +337,10 @@ export function apply(ctx, config) {
313
337
  latencyTracker: new ClassifierLatencyTracker({
314
338
  slaMs: cfg.classifierSlaMs !== undefined ? cfg.classifierSlaMs : 2000,
315
339
  }),
316
- defaultVerdict: cfg.defaultVerdict || "allow",
340
+ defaultVerdict: cfg.defaultVerdict || "ask",
341
+ requireJustification: cfg.requireJustification === true,
342
+ enableRedundantSandboxRecovery: cfg.enableRedundantSandboxRecovery !== false,
343
+ snapshotManager: new SnapshotManager({ workspaceDir: cfg.workspaceDir || "", maxSnapshotsPerSession: cfg.maxSnapshotsPerSession, maxSnapshotBytes: cfg.maxSnapshotBytes }),
317
344
  dryRun: Boolean(cfg.dryRun || cfg.shadowMode),
318
345
  auditFailClosed: Boolean(cfg.auditFailClosed),
319
346
  auditLogger: new AuditLogger({
@@ -342,6 +369,26 @@ export function apply(ctx, config) {
342
369
  resetLatencyTracker: () => (engine.resetLatencyTracker ? engine.resetLatencyTracker() : undefined),
343
370
  getArtifacts: (sessionId) => (engine.getArtifacts ? engine.getArtifacts(sessionId) : []),
344
371
  clearArtifacts: (sessionId) => (engine.clearArtifacts ? engine.clearArtifacts(sessionId) : undefined),
372
+ takeSnapshot: (targetPath, meta) => (engine.takeSnapshot ? engine.takeSnapshot(targetPath, meta) : null),
373
+ rollbackSnapshot: (id) => (engine.rollbackSnapshot ? engine.rollbackSnapshot(id) : { success: false, error: "no-engine" }),
374
+ getSnapshots: (sessionId) => (engine.getSnapshots ? engine.getSnapshots(sessionId) : []),
375
+ clearSnapshots: (sessionId) => (engine.clearSnapshots ? engine.clearSnapshots(sessionId) : { cleared: 0 }),
376
+ clearAllSnapshots: () => (engine.clearAllSnapshots ? engine.clearAllSnapshots() : { cleared: 0 }),
377
+ getSnapshotStats: () => (engine.snapshotManager ? engine.snapshotManager.getStats() : null),
378
+ lineage: engine.lineage,
379
+ registerLineage: (child, parent, root) => engine.registerLineage(child, parent, root),
380
+ getLineage: (id) => engine.getLineage(id),
381
+ guidance: {
382
+ generate: (opts) => generateAgentGuidance(opts),
383
+ inject: (prompt, opts) => injectAgentGuidance(prompt, opts),
384
+ extract: (prompt) => extractGuidance(prompt),
385
+ },
386
+ getAgentGuidance: (opts) => generateAgentGuidance(opts),
387
+ redundantSandbox: {
388
+ check: (cmd, mode, opts) => isRedundantSandboxRequest(cmd, mode, opts),
389
+ remediation: (cmd, lang) => buildRedundantSandboxRemediation(cmd, lang),
390
+ },
391
+ checkEscalationJustification: (cmd, tool, args, opts) => checkEscalationJustification(cmd, tool, args, opts),
345
392
  getSnapshot: () => ({
346
393
  active: true,
347
394
  dryRun: Boolean(cfg.dryRun || cfg.shadowMode),
@@ -353,6 +400,7 @@ export function apply(ctx, config) {
353
400
  cacheStats: engine.cache ? engine.cache.stats() : null,
354
401
  breakerState: engine.breaker ? engine.breaker.getState() : null,
355
402
  latencyMetrics: engine.getLatencyMetrics ? engine.getLatencyMetrics() : null,
403
+ snapshots: engine.snapshotManager ? engine.snapshotManager.getStats() : null,
356
404
  harnessCompat: compat.capabilities,
357
405
  }),
358
406
  };
@@ -364,9 +412,12 @@ export function apply(ctx, config) {
364
412
  const knobs = readKnobs(ctx, execution);
365
413
  const who = sessionTag(execution);
366
414
  const sessionId = resolveSessionId(execution);
415
+ const callId = execution?.callId || execution?.id || null;
416
+ const agentId = execution?.agent?.id || execution?.agentId || null;
417
+ const subagentId = execution?.agent?.subagentId || execution?.subagentId || null;
367
418
  let decision;
368
419
  try {
369
- decision = engine.decide(execution, { sessionId, knobs });
420
+ decision = engine.decide(execution, { sessionId, knobs, callId, agentId, subagentId, sandboxMode: knobs.sandbox, preset: knobs.preset });
370
421
  } catch (err) {
371
422
  const hit = {
372
423
  ask: true,
@@ -418,6 +469,10 @@ export function apply(ctx, config) {
418
469
  };
419
470
  return { kind: "ask", reason: askMessage(hit, who, translate, ctx && ctx.logger) };
420
471
  }
472
+ if (engine.snapshotManager) {
473
+ const targetPath = extractFilePath(execution && execution.arguments);
474
+ if (targetPath) engine.takeSnapshot(targetPath, { sessionId, callId: execution && (execution.callId || execution.id), tool: execution && execution.name });
475
+ }
421
476
  return next();
422
477
  });
423
478
  }
@@ -425,9 +480,12 @@ export function apply(ctx, config) {
425
480
  const guard = function (execution) {
426
481
  const who = sessionTag(execution);
427
482
  const sessionId = resolveSessionId(execution);
483
+ const callId = execution?.callId || execution?.id || null;
484
+ const agentId = execution?.agent?.id || execution?.agentId || null;
485
+ const subagentId = execution?.agent?.subagentId || execution?.subagentId || null;
428
486
  let decision;
429
487
  try {
430
- decision = engine.decide(execution, { sessionId });
488
+ decision = engine.decide(execution, { sessionId, callId, agentId, subagentId });
431
489
  } catch (err) {
432
490
  const hit = {
433
491
  deny: true,
@@ -455,6 +513,10 @@ export function apply(ctx, config) {
455
513
  logBlocked(ctx, hit, who);
456
514
  return denyMessage(hit, who, translate, ctx && ctx.logger);
457
515
  }
516
+ if (engine.snapshotManager) {
517
+ const targetPath = extractFilePath(execution && execution.arguments);
518
+ if (targetPath) engine.takeSnapshot(targetPath, { sessionId, callId: execution && (execution.callId || execution.id), tool: execution && execution.name });
519
+ }
458
520
  return undefined;
459
521
  };
460
522
 
@@ -476,96 +538,27 @@ export {
476
538
  CIRCUIT_STATE_TRIPPED,
477
539
  CIRCUIT_STATE_RESUMED,
478
540
  } from "./breaker.js";
479
- export {
480
- SessionGrantStore,
481
- DEFAULT_GRANT_TTL_MS,
482
- DEFAULT_GRANT_MAX_USES,
483
- matchGrantCriteria,
484
- } from "./grants.js";
485
- export {
486
- canonicalizeCall,
487
- canonicalString,
488
- hashCall,
489
- sortKeys,
490
- } from "./canonical.js";
541
+ export { SessionGrantStore, DEFAULT_GRANT_TTL_MS, DEFAULT_GRANT_MAX_USES, matchGrantCriteria } from "./grants.js";
542
+ export { canonicalizeCall, canonicalString, hashCall, sortKeys } from "./canonical.js";
491
543
  export { expandHome, resolveSafePath, isPathTraversal, canonicalizeFsPath } from "./paths.js";
492
544
  export { splitSubcommands } from "./tokenizer.js";
493
545
  export { redactCredentials, redactUrl } from "./redact.js";
494
- export {
495
- CommandDispatcher,
496
- isReviewerAgent,
497
- extractFilePath,
498
- TOOL_KIND_SHELL,
499
- TOOL_KIND_FILE_WRITE,
500
- TOOL_KIND_FILE_READ,
501
- TOOL_KIND_NETWORK,
502
- TOOL_KIND_GENERIC,
503
- } from "./dispatcher.js";
504
- export {
505
- parseGitArgv,
506
- classifyGitOperation,
507
- inspectGitCommand,
508
- GIT_CATEGORY_READ,
509
- GIT_CATEGORY_MUTATE_LOCAL,
510
- GIT_CATEGORY_REMOTE,
511
- GIT_CATEGORY_DESTRUCTIVE,
512
- } from "./parsers/git.js";
513
- export {
514
- isDevicePath,
515
- extractRedirects,
516
- inspectRedirectSecurity,
517
- inspectSilentErrors,
518
- inspectPipelines,
519
- } from "./parsers/shell-cmds.js";
520
- export {
521
- isLoopbackHost,
522
- isLoopbackUrl,
523
- isLoopbackCommand,
524
- } from "./loopback.js";
525
- export {
526
- computeFingerprint,
527
- computeFingerprintHash,
528
- normalizeCommandString,
529
- } from "./fingerprint.js";
530
- export {
531
- VerdictCache,
532
- DEFAULT_CACHE_MAX_SIZE,
533
- DEFAULT_CACHE_TTL_MS,
534
- } from "./cache.js";
535
- export {
536
- RetryExhaustionGuard,
537
- DEFAULT_MAX_RETRY_ATTEMPTS,
538
- } from "./retry-exhaustion.js";
539
- export {
540
- QualityLedger,
541
- MAX_LEDGER_EVENTS_PER_SESSION,
542
- } from "./ledger.js";
543
- export {
544
- AuditLogger,
545
- DEFAULT_AUDIT_LOG_RELATIVE,
546
- DEFAULT_MAX_AUDIT_BYTES,
547
- DEFAULT_MAX_AUDIT_LINES,
548
- DEFAULT_RETAIN_LINES,
549
- auditRotateContent,
550
- } from "./audit-log.js";
546
+ export { CommandDispatcher, isReviewerAgent, extractFilePath, TOOL_KIND_SHELL, TOOL_KIND_FILE_WRITE, TOOL_KIND_FILE_READ, TOOL_KIND_NETWORK, TOOL_KIND_GENERIC } from "./dispatcher.js";
547
+ export { parseGitArgv, classifyGitOperation, inspectGitCommand, GIT_CATEGORY_READ, GIT_CATEGORY_MUTATE_LOCAL, GIT_CATEGORY_REMOTE, GIT_CATEGORY_DESTRUCTIVE } from "./parsers/git.js";
548
+ export { isDevicePath, extractRedirects, inspectRedirectSecurity, inspectSilentErrors, inspectPipelines } from "./parsers/shell-cmds.js";
549
+ export { isLoopbackHost, isLoopbackUrl, isLoopbackCommand } from "./loopback.js";
550
+ export { computeFingerprint, computeFingerprintHash, normalizeCommandString } from "./fingerprint.js";
551
+ export { VerdictCache, DEFAULT_CACHE_MAX_SIZE, DEFAULT_CACHE_TTL_MS } from "./cache.js";
552
+ export { RetryExhaustionGuard, DEFAULT_MAX_RETRY_ATTEMPTS } from "./retry-exhaustion.js";
553
+ export { QualityLedger, MAX_LEDGER_EVENTS_PER_SESSION } from "./ledger.js";
554
+ export { AuditLogger, DEFAULT_AUDIT_LOG_RELATIVE, DEFAULT_MAX_AUDIT_BYTES, DEFAULT_MAX_AUDIT_LINES, DEFAULT_RETAIN_LINES, auditRotateContent } from "./audit-log.js";
551
555
  export { BlockCounters, categorizeReason } from "./counters.js";
552
556
  export { isCriticalOperation } from "./engine.js";
553
557
  export { parseYaml, parseScalar, stripComment } from "./yaml-parser.js";
554
558
  export { matchConditionValue, evaluateWhen } from "./when-matcher.js";
555
559
  export { HotwordsMatcher, DEFAULT_HOTWORDS, extractWords } from "./hotwords.js";
556
560
  export { DeclarativeRulesEngine, BUILTIN_RULES_FILE, WORKSPACE_RULES_REL, USER_RULES_REL } from "./rules-loader.js";
557
- export {
558
- normalizeChatCompletionsUrl,
559
- withLlmRetry,
560
- sanitizeClassifierInput,
561
- truncateClassifierContext,
562
- isLowRiskExecution,
563
- isShapeRejectError,
564
- extractJsonPayload,
565
- ClassifierVerdictSchema,
566
- validateClassifierResponse,
567
- LlmClassifier,
568
- } from "./classifier.js";
561
+ export { normalizeChatCompletionsUrl, withLlmRetry, sanitizeClassifierInput, truncateClassifierContext, isLowRiskExecution, isShapeRejectError, extractJsonPayload, ClassifierVerdictSchema, validateClassifierResponse, LlmClassifier } from "./classifier.js";
569
562
  export { ContractGuard } from "./contract-guard.js";
570
563
  export { OverengineeringGuard, DEFAULT_MAX_FILES_PER_SESSION } from "./overengineering.js";
571
564
  export { resolveTempRoot, isGlobalTempPath, checkTempIsolation } from "./paths.js";
@@ -577,5 +570,11 @@ export { ArtifactRegistry, normalizeArtifactPath } from "./artifacts.js";
577
570
  export { parseAgentsSecurityRules, loadAgentsRules, evaluateAgentsRules } from "./agents-parser.js";
578
571
  export { detectHarnessCapabilities, assertHarnessCompatibility, provideServicePolyfill, registerHookPolyfill } from "./compat.js";
579
572
  export { parseSemver, isMajorBump, inspectPackageJsonDiff, VersionGuard } from "./version-guard.js";
573
+ export { SnapshotManager, DEFAULT_MAX_SNAPSHOTS_PER_SESSION, DEFAULT_MAX_SNAPSHOT_BYTES } from "./snapshots.js";
574
+ export { MEASURE_KINDS, classifyMeasureKind, isSystemLevelExecution, isWorkspaceWideExecution } from "./measure-kinds.js";
575
+ export { LineageManager } from "./lineage.js";
576
+ export { generateAgentGuidance, injectAgentGuidance, extractGuidance, GUIDANCE_MARKER } from "./guidance.js";
577
+ export { isRedundantSandboxRequest, buildRedundantSandboxRemediation, checkEscalationJustification, REDUNDANT_SANDBOX_REASON, MISSING_JUSTIFICATION_REASON, INSUFFICIENT_JUSTIFICATION_REASON } from "./escalation.js";
578
+
580
579
 
581
580
 
package/lib/lineage.js ADDED
@@ -0,0 +1,182 @@
1
+ // lib/lineage.js
2
+ // Subagent hierarchy and parentSession lineage authority (#98).
3
+ // Tracks session ancestry trees and propagates authority/policies down the lineage chain.
4
+
5
+ export class LineageManager {
6
+ constructor() {
7
+ /** @type {Map<string, string>} childSessionId -> parentSessionId */
8
+ this.parentMap = new Map();
9
+ /** @type {Map<string, string>} childSessionId -> explicit rootSessionId */
10
+ this.rootMap = new Map();
11
+ /** @type {Map<string, Set<string>>} parentSessionId -> Set of childSessionIds */
12
+ this.childrenMap = new Map();
13
+ }
14
+
15
+ /**
16
+ * Registers parent-child session relationship.
17
+ * Prevents circular parent-child references.
18
+ *
19
+ * @param {string} childSessionId
20
+ * @param {string} parentSessionId
21
+ * @param {string} [rootSessionId]
22
+ * @returns {boolean} True if successfully registered, false if invalid or circular
23
+ */
24
+ registerLineage(childSessionId, parentSessionId, rootSessionId = null) {
25
+ if (!childSessionId || !parentSessionId) return false;
26
+ if (childSessionId === parentSessionId) return false;
27
+
28
+ // Check circular references: walking upwards from parent must not reach child
29
+ let curr = parentSessionId;
30
+ const visited = new Set([childSessionId]);
31
+ while (curr) {
32
+ if (visited.has(curr)) return false;
33
+ visited.add(curr);
34
+ curr = this.parentMap.get(curr);
35
+ }
36
+
37
+ this.parentMap.set(childSessionId, parentSessionId);
38
+ if (rootSessionId && rootSessionId !== childSessionId) {
39
+ this.rootMap.set(childSessionId, rootSessionId);
40
+ }
41
+
42
+ if (!this.childrenMap.has(parentSessionId)) {
43
+ this.childrenMap.set(parentSessionId, new Set());
44
+ }
45
+ this.childrenMap.get(parentSessionId).add(childSessionId);
46
+
47
+ return true;
48
+ }
49
+
50
+ /**
51
+ * Retrieves the direct parent session ID for a session.
52
+ * @param {string} sessionId
53
+ * @returns {string|null}
54
+ */
55
+ getParent(sessionId) {
56
+ if (!sessionId) return null;
57
+ return this.parentMap.get(sessionId) || null;
58
+ }
59
+
60
+ /**
61
+ * Retrieves the ultimate root session ID for a session.
62
+ * @param {string} sessionId
63
+ * @returns {string}
64
+ */
65
+ getRoot(sessionId) {
66
+ if (!sessionId) return "";
67
+ if (this.rootMap.has(sessionId)) {
68
+ return this.rootMap.get(sessionId);
69
+ }
70
+
71
+ let curr = sessionId;
72
+ const visited = new Set();
73
+ while (curr && !visited.has(curr)) {
74
+ visited.add(curr);
75
+ const parent = this.parentMap.get(curr);
76
+ if (!parent) break;
77
+ curr = parent;
78
+ }
79
+ return curr;
80
+ }
81
+
82
+ /**
83
+ * Returns ancestry array from current session up to root: [sessionId, parentId, ..., rootId].
84
+ * @param {string} sessionId
85
+ * @returns {string[]}
86
+ */
87
+ getLineage(sessionId) {
88
+ if (!sessionId) return [];
89
+ const chain = [];
90
+ let curr = sessionId;
91
+ const visited = new Set();
92
+
93
+ while (curr && !visited.has(curr)) {
94
+ visited.add(curr);
95
+ chain.push(curr);
96
+ curr = this.parentMap.get(curr);
97
+ }
98
+
99
+ return chain;
100
+ }
101
+
102
+ /**
103
+ * Checks whether ancestorSessionId is an ancestor of childSessionId.
104
+ * @param {string} childSessionId
105
+ * @param {string} ancestorSessionId
106
+ * @returns {boolean}
107
+ */
108
+ hasLineageAuthority(childSessionId, ancestorSessionId) {
109
+ if (!childSessionId || !ancestorSessionId) return false;
110
+ if (childSessionId === ancestorSessionId) return true;
111
+
112
+ const lineage = this.getLineage(childSessionId);
113
+ return lineage.includes(ancestorSessionId);
114
+ }
115
+
116
+ /**
117
+ * Resolves effective policy walking up the lineage hierarchy.
118
+ * Evaluates getPolicyFn(sessionId). If undefined or null, falls back to parent, then root.
119
+ *
120
+ * @param {string} sessionId
121
+ * @param {(id: string) => any} getPolicyFn
122
+ * @returns {any}
123
+ */
124
+ resolveEffectivePolicy(sessionId, getPolicyFn) {
125
+ if (!sessionId || typeof getPolicyFn !== "function") return null;
126
+
127
+ const lineage = this.getLineage(sessionId);
128
+ for (const sid of lineage) {
129
+ try {
130
+ const policy = getPolicyFn(sid);
131
+ if (policy !== undefined && policy !== null) {
132
+ return {
133
+ policy,
134
+ sourceSessionId: sid,
135
+ inherited: sid !== sessionId,
136
+ };
137
+ }
138
+ } catch {
139
+ // Continue upward traversal on error
140
+ }
141
+ }
142
+
143
+ return null;
144
+ }
145
+
146
+ /**
147
+ * Removes tracking for a session and unlinks from its parent.
148
+ * @param {string} sessionId
149
+ * @returns {boolean}
150
+ */
151
+ clearSession(sessionId) {
152
+ if (!sessionId) return false;
153
+ const parent = this.parentMap.get(sessionId);
154
+ if (parent && this.childrenMap.has(parent)) {
155
+ this.childrenMap.get(parent).delete(sessionId);
156
+ }
157
+ this.parentMap.delete(sessionId);
158
+ this.rootMap.delete(sessionId);
159
+ this.childrenMap.delete(sessionId);
160
+ return true;
161
+ }
162
+
163
+ /**
164
+ * Clears all lineage mappings.
165
+ */
166
+ clearAll() {
167
+ this.parentMap.clear();
168
+ this.rootMap.clear();
169
+ this.childrenMap.clear();
170
+ }
171
+
172
+ /**
173
+ * Returns current statistics for debugging and auditing.
174
+ * @returns {{ trackedSessions: number, rootSessions: number }}
175
+ */
176
+ getStats() {
177
+ return {
178
+ trackedSessions: this.parentMap.size,
179
+ parentCount: this.childrenMap.size,
180
+ };
181
+ }
182
+ }
@@ -0,0 +1,138 @@
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
+ if (typeof execution === "string") {
60
+ execution = { name: "bash", arguments: { command: execution } };
61
+ }
62
+
63
+ const toolName = execution.name || execution.tool || "";
64
+ const args = execution.arguments || {};
65
+
66
+ // 1. Tool-level system categorization
67
+ if (SYSTEM_LEVEL_TOOLS.has(toolName)) {
68
+ return MEASURE_KINDS.SYSTEM_LEVEL;
69
+ }
70
+
71
+ // 2. Tool-level single-file mutations / reads
72
+ if (SINGLE_FILE_TOOLS.has(toolName)) {
73
+ const target = args.TargetFile || args.targetPath || args.path || args.file || "";
74
+ if (typeof target === "string" && SYSTEM_PATH_REGEX.test(target)) {
75
+ return MEASURE_KINDS.SYSTEM_LEVEL;
76
+ }
77
+ return MEASURE_KINDS.LOCAL_SINGLE;
78
+ }
79
+
80
+ // 3. Shell / command inspection
81
+ const command = typeof args.command === "string" ? args.command.trim() : "";
82
+ if (!command) {
83
+ return MEASURE_KINDS.LOCAL_SINGLE;
84
+ }
85
+
86
+ // Fast check: first word
87
+ const firstWord = command.split(/\s+/)[0]?.toLowerCase();
88
+ if (SYSTEM_BINARIES.has(firstWord)) {
89
+ return MEASURE_KINDS.SYSTEM_LEVEL;
90
+ }
91
+ if ((command.includes("/") || command.includes(".")) && SYSTEM_PATH_REGEX.test(command)) {
92
+ return MEASURE_KINDS.SYSTEM_LEVEL;
93
+ }
94
+
95
+ // Check subcommands or pipes for system binaries only if operators present
96
+ if (/[|;&]/.test(command)) {
97
+ const tokens = command.split(/[\s|;&]+/);
98
+ for (const token of tokens) {
99
+ if (SYSTEM_BINARIES.has(token.toLowerCase())) {
100
+ return MEASURE_KINDS.SYSTEM_LEVEL;
101
+ }
102
+ }
103
+ }
104
+
105
+ // Check workspace-wide patterns
106
+ for (const pattern of WORKSPACE_COMMAND_PATTERNS) {
107
+ if (pattern.test(command)) {
108
+ return MEASURE_KINDS.WORKSPACE_WIDE;
109
+ }
110
+ }
111
+
112
+ // Multi-target rm or recursive rm
113
+ if (/^rm\s+/i.test(command)) {
114
+ if (/\s-[a-zA-Z]*r/i.test(command) || command.includes("*") || command.split(/\s+/).length > 3) {
115
+ return MEASURE_KINDS.WORKSPACE_WIDE;
116
+ }
117
+ return MEASURE_KINDS.LOCAL_SINGLE;
118
+ }
119
+
120
+ // Simple single-target commands
121
+ if (/^(cat|head|tail|sed|awk|echo|touch|ls|stat|file)\b/i.test(command)) {
122
+ if (command.includes("*") || command.includes(";")) {
123
+ return MEASURE_KINDS.WORKSPACE_WIDE;
124
+ }
125
+ return MEASURE_KINDS.LOCAL_SINGLE;
126
+ }
127
+
128
+ // Default fallback for unrecognized operations
129
+ return MEASURE_KINDS.LOCAL_SINGLE;
130
+ }
131
+
132
+ export function isSystemLevelExecution(execution, context) {
133
+ return classifyMeasureKind(execution, context) === MEASURE_KINDS.SYSTEM_LEVEL;
134
+ }
135
+
136
+ export function isWorkspaceWideExecution(execution, context) {
137
+ return classifyMeasureKind(execution, context) === MEASURE_KINDS.WORKSPACE_WIDE;
138
+ }