import { describe, test, expect, beforeEach, afterEach } from "bun:test"; import { initGatewayDb, resetGatewayDb } from "../db/connection.js"; import { TrustRuleStore } from "../db/trust-rule-store.js"; import { initTrustRuleCache, resetTrustRuleCache, } from "../risk/trust-rule-cache.js"; import { BashRiskClassifier, classifySegment, } from "../risk/bash-risk-classifier.js"; import { DEFAULT_COMMAND_REGISTRY } from "../risk/command-registry/index.js"; import type { CommandSegment } from "../risk/shell-parser.js"; import type { UserRule } from "../risk/risk-types.js"; import "./test-preload.js"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** Build a minimal CommandSegment for testing. */ function segment(command: string): CommandSegment { const parts = command.split(/\s+/); return { command, program: parts[0], args: parts.slice(1), operator: "", }; } // --------------------------------------------------------------------------- // Risk rule cache integration // --------------------------------------------------------------------------- describe("risk rule cache integration", () => { let store: TrustRuleStore; // initGatewayDb() seeds the trust_rules table from DEFAULT_COMMAND_REGISTRY // via seedTrustRulesFromRegistry(). Tests that modify existing patterns // (like "git push") must update the seeded rows rather than creating new ones. // Seeded IDs follow the pattern: default:bash: // e.g., "git push" -> "default:bash:git-push" beforeEach(async () => { resetGatewayDb(); await initGatewayDb(); store = new TrustRuleStore(); }); afterEach(() => { resetTrustRuleCache(); resetGatewayDb(); }); test("user-modified risk override changes baseRisk", () => { // Modify the seeded "git push" rule's risk to "low" store.update("default:bash:git-push", { risk: "low" }); initTrustRuleCache(store); const result = classifySegment( segment("git push"), [], DEFAULT_COMMAND_REGISTRY, ); expect(result.risk).toBe("low"); }); test("matchType is user_rule when a user-modified rule determines risk", () => { // Modify the seeded "git push" rule — userModified becomes true store.update("default:bash:git-push", { risk: "low" }); initTrustRuleCache(store); const result = classifySegment( segment("git push"), [], DEFAULT_COMMAND_REGISTRY, ); expect(result.matchType).toBe("user_rule"); }); test("matchType is user_rule when a user-defined rule determines risk", () => { // Modify the seeded "ls" rule — sets userModified and origin stays "default" // but userModified=true triggers user_rule matchType store.update("default:bash:ls", { risk: "low" }); initTrustRuleCache(store); const result = classifySegment(segment("ls"), [], DEFAULT_COMMAND_REGISTRY); expect(result.matchType).toBe("user_rule"); }); test("arg rules still escalate on top of cached baseRisk", () => { // Set git push base risk to "low" via cache update store.update("default:bash:git-push", { risk: "low" }); initTrustRuleCache(store); // git push --force should still escalate via arg rules (--force → high) const result = classifySegment( segment("git push --force"), [], DEFAULT_COMMAND_REGISTRY, ); expect(result.risk).toBe("high"); }); test("fallback when cache is not initialized — classifier uses registry", () => { // Don't initialize the cache — resetTrustRuleCache() was called in afterEach // and we haven't called initTrustRuleCache() here resetTrustRuleCache(); const result = classifySegment( segment("git push"), [], DEFAULT_COMMAND_REGISTRY, ); // Should still work using registry baseRisk expect(result.risk).toBe("medium"); expect(result.matchType).toBe("registry"); }); test("subcommand resolution — git push looks up 'git push' in cache, not just 'git'", () => { // Modify the seeded "git" to low, keep "git push" at its seeded risk. // Then modify "git push" to high. The classifier should find "git push" // (the more specific pattern), not "git". store.update("default:bash:git", { risk: "low" }); store.update("default:bash:git-push", { risk: "high" }); initTrustRuleCache(store); const result = classifySegment( segment("git push"), [], DEFAULT_COMMAND_REGISTRY, ); // The cache should match "git push" (the more specific subcommand pattern), // not just "git" expect(result.risk).toBe("high"); }); test("cache override does not affect matchType when rule is not user-modified", () => { // The seeded "git" rule has NOT been user-modified — it's a default rule. // Don't call store.update() so userModified stays false. initTrustRuleCache(store); const result = classifySegment( segment("git status"), [], DEFAULT_COMMAND_REGISTRY, ); // Default rule, not user-modified — matchType should remain "registry" expect(result.matchType).toBe("registry"); }); test("multi-level subcommand — git stash drop looks up 'git stash drop' in cache", () => { // Modify the seeded "git stash drop" rule (ID: default:bash:git-stash-drop) // to "low". The classifier should build the full subcommand path // "git stash drop" and find this specific rule in the cache. store.update("default:bash:git-stash-drop", { risk: "low" }); initTrustRuleCache(store); const result = classifySegment( segment("git stash drop"), [], DEFAULT_COMMAND_REGISTRY, ); // The cache should match the full path "git stash drop", overriding // the registry's high baseRisk for git stash drop. expect(result.risk).toBe("low"); expect(result.matchType).toBe("user_rule"); }); test("multi-level subcommand — falls back to parent when specific sub not cached", () => { // Modify the seeded "git stash" rule to "low", but leave "git stash drop" // at its seeded value. Then soft-delete "git stash drop" so the cache // falls back to the parent "git stash" pattern. store.update("default:bash:git-stash", { risk: "low" }); store.remove("default:bash:git-stash-drop"); initTrustRuleCache(store); // "git stash drop" should look up "git stash drop" first, not find it // (soft-deleted), then the cache's findBaseRisk falls back to "git stash" const result = classifySegment( segment("git stash drop"), [], DEFAULT_COMMAND_REGISTRY, ); expect(result.risk).toBe("low"); expect(result.matchType).toBe("user_rule"); }); test("matchType resets to registry when arg rules escalate above cached base risk", () => { // Set git push base risk to "low" via a user-modified cache rule store.update("default:bash:git-push", { risk: "low" }); initTrustRuleCache(store); // git push --force: cache sets base to "low" (user_rule matchType), // but --force arg rule escalates to "high". Since the registry's arg // rules determined the final risk, matchType should be "registry". const result = classifySegment( segment("git push --force"), [], DEFAULT_COMMAND_REGISTRY, ); expect(result.risk).toBe("high"); expect(result.matchType).toBe("registry"); }); test("generalized action: rule with positional tokens applies (beyond registry subcommands)", () => { // `ls` has no registry subcommands, but the rule editor offers positional // action patterns such as `action:ls vellumtestfile` (for `ls vellumtestfile *`). // The matcher must probe positional-derived action keys, not just the // registry subcommand pattern (which would only see `ls`), or the saved // rule is silently ignored. store.create({ tool: "bash", pattern: "action:ls vellumtestfile", risk: "high", description: "Generalized ls rule", }); initTrustRuleCache(store); const result = classifySegment( segment("ls vellumtestfile extra"), [], DEFAULT_COMMAND_REGISTRY, ); // The more specific positional action rule wins over the seeded `ls` // program-level default. expect(result.risk).toBe("high"); expect(result.matchType).toBe("user_rule"); }); test("generalized action: rule with a path positional applies", () => { // Regression guard for the suspected save/match divergence on path-like // positionals. `generateScopeOptions` drops positionals from the RIGHT and // keeps order, so for `cat /etc/passwd notes.txt` the saveable ladder // includes `action:cat /etc/passwd` (a leading-prefix action key that // retains the path). The matcher builds the positional pattern // `cat /etc/passwd notes.txt` and `findBaseRisk` walks leading prefixes, so // `action:cat /etc/passwd` must resolve. Paths are NOT stripped on either // side (that exclusion lives only in the dead `deriveShellActionKeys` // path), so this matches. store.create({ tool: "bash", pattern: "action:cat /etc/passwd", risk: "high", description: "Generalized cat rule with a path token", }); initTrustRuleCache(store); const result = classifySegment( segment("cat /etc/passwd notes.txt"), [], DEFAULT_COMMAND_REGISTRY, ); expect(result.risk).toBe("high"); expect(result.matchType).toBe("user_rule"); }); // ── Step 1 user-defined short-circuit ────────────────────────────────────── test("user-defined rule short-circuits arg-rule escalation", () => { // Use action: prefix so the new row doesn't conflict with the seeded "rm" row. // "rm" is untouched by other tests in this file, so isUserRule(literal) stays // false and findBaseRisk returns the action:rm user_defined rule instead. // Without the short-circuit, rm -rf /tmp would escalate to "high" via arg rules. store.create({ tool: "bash", pattern: "action:rm", risk: "low", description: "rm is always safe in this environment", }); initTrustRuleCache(store); const result = classifySegment( segment("rm -rf /tmp/test"), [], DEFAULT_COMMAND_REGISTRY, ); // Step 1b short-circuits: user_defined rule bypasses the -rf arg escalation. expect(result.risk).toBe("low"); expect(result.matchType).toBe("user_rule"); }); test("user-defined rule specificity: exact action match wins over prefix", () => { // "action:rm /tmp" (exact match at step 1 of findBaseRisk) beats "action:rm" // (prefix walk step 3). Specificity is inherent in the lookup order: exact // before prefix, and longer prefixes before shorter ones in the prefix walk. store.create({ tool: "bash", pattern: "action:rm", risk: "high", description: "rm is dangerous", }); store.create({ tool: "bash", pattern: "action:rm /tmp", risk: "low", description: "rm in /tmp is safe", }); initTrustRuleCache(store); // "rm /tmp" hits the exact action: lookup ("action:rm /tmp") at step 1, // never falling through to the broader prefix "action:rm". const result = classifySegment( segment("rm /tmp"), [], DEFAULT_COMMAND_REGISTRY, ); expect(result.risk).toBe("low"); expect(result.matchType).toBe("user_rule"); }); test("user-modified default rule does not short-circuit arg-rule escalation", () => { // userModified=true but origin="default": step 4b handles this, not step 1. // Arg rules still apply and can escalate above the user-set base risk. store.update("default:bash:git-push", { risk: "low" }); initTrustRuleCache(store); const result = classifySegment( segment("git push --force"), [], DEFAULT_COMMAND_REGISTRY, ); // --force arg rule still escalates through step 4b expect(result.risk).toBe("high"); }); test("exact compound-command rule short-circuits at classify() level", async () => { // The "This exact command" allowlist option stores the full raw command // string as the trust-rule pattern (e.g. "rm -rf /tmp && echo done"). // classifySegment never sees that full string — it receives each segment // ("rm -rf /tmp" and "echo done") separately. The pre-parse check in // classify() is the only place the exact compound pattern can match. const compound = "rm -rf /tmp && echo done"; store.create({ tool: "bash", pattern: compound, risk: "low", description: "Approved: clean /tmp then echo done", }); initTrustRuleCache(store); const classifier = new BashRiskClassifier(); const result = await classifier.classify({ command: compound, toolName: "bash", }); // Without the classify()-level check, rm -rf would escalate to "high" // via arg rules. The exact user_defined rule must short-circuit first. expect(result.riskLevel).toBe("low"); expect(result.matchType).toBe("user_rule"); }); }); // ── userRules param specificity ordering ────────────────────────────────────── describe("userRules param specificity ordering", () => { beforeEach(() => { resetGatewayDb(); resetTrustRuleCache(); }); afterEach(() => { resetTrustRuleCache(); resetGatewayDb(); }); test("longer regex pattern wins over shorter when both match", () => { const rules: UserRule[] = [ { id: "r1", pattern: "^git", risk: "high", label: "all git high", createdAt: "", source: "manual", }, { id: "r2", pattern: "^git push", risk: "low", label: "git push low", createdAt: "", source: "manual", }, ]; // Cache not initialized — only userRules[] param is active in step 1a. const result = classifySegment( segment("git push origin"), rules, DEFAULT_COMMAND_REGISTRY, ); // "^git push" (length 9) beats "^git" (length 4) expect(result.risk).toBe("low"); expect(result.matchType).toBe("user_rule"); }); });