From a4fbdbffac5464bf94b775e96ba4c549c7717d7f Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Thu, 6 Aug 2026 10:39:27 -0300 Subject: [PATCH] feat(copilot): add approval gate for runOmniRouteCli commands (#8461) (#9495) Validated in local merge-train (diegosouzapw batch) --- .../8461-runomniroutecli-approval-gate.md | 1 + src/lib/copilot/commandClassification.ts | 78 +++++++++++++ src/lib/copilot/tools.ts | 11 ++ tests/unit/approvalGate.test.ts | 52 +++++++++ tests/unit/commandClassification.test.ts | 104 ++++++++++++++++++ 5 files changed, 246 insertions(+) create mode 100644 changelog.d/features/8461-runomniroutecli-approval-gate.md create mode 100644 src/lib/copilot/commandClassification.ts create mode 100644 tests/unit/approvalGate.test.ts create mode 100644 tests/unit/commandClassification.test.ts diff --git a/changelog.d/features/8461-runomniroutecli-approval-gate.md b/changelog.d/features/8461-runomniroutecli-approval-gate.md new file mode 100644 index 0000000000..bf08a096dc --- /dev/null +++ b/changelog.d/features/8461-runomniroutecli-approval-gate.md @@ -0,0 +1 @@ +- feat(copilot): add approval gate for runOmniRouteCli commands (#8461) diff --git a/src/lib/copilot/commandClassification.ts b/src/lib/copilot/commandClassification.ts new file mode 100644 index 0000000000..724199cc76 --- /dev/null +++ b/src/lib/copilot/commandClassification.ts @@ -0,0 +1,78 @@ +/** + * Command classification for runOmniRouteCli approval gate (#8461). + * + * Defines a classification table mapping CLI subcommand patterns to safety + * categories, plus the classifier function. Read-only commands execute + * directly; all others are blocked with a warning asserting operator intent. + * Unknown commands are denied by default (no matching rule → blocked). + */ + +export type CommandCategory = + | "read-only" + | "mutating" + | "destructive" + | "secret-affecting"; + +export interface ClassificationRule { + pattern: RegExp; + category: CommandCategory; + reason: string; +} + +const CLASSIFICATION_RULES: ClassificationRule[] = [ + // ── Destructive (highest priority) ── + { + pattern: /\b(?:delete|remove|rm|drop|uninstall|reset)\b/i, + category: "destructive", + reason: + "This operation permanently removes or resets data and cannot be undone.", + }, + + // ── Secret-affecting ── + { + pattern: + /\b(?:show.*(?:secret|key|token|credential)|key.*show|export|auth.*token)\b/i, + category: "secret-affecting", + reason: + "This operation may expose secrets or credentials in the output.", + }, + + // ── Mutating ── + { + pattern: + /\b(?:set|create|add|update|config\s+set|config\s+unset|providers?\s+add|keys?\s+create|keys?\s+revoke|settings?\s+update)\b/i, + category: "mutating", + reason: + "This operation changes configuration or creates resources.", + }, + + // ── Read-only (lowest priority — checked last) ── + { + pattern: + /\b(?:status|doctor|health|version|help|list|show|get|config\s+list|providers?\s+list|keys?\s+list|logs|models?)\b/i, + category: "read-only", + reason: + "This operation only reads data and does not make changes.", + }, +]; + +/** + * Classify a CLI command argv array into a category and matching rule. + * Iterates rules in priority order (destructive → secret-affecting → + * mutating → read-only). Returns null when no rule matches (unknown + * command — denied by default). + */ +export function classifyCommand(argv: string[]): { + category: CommandCategory; + rule: ClassificationRule; +} | null { + const cmdLine = argv.join(" "); + + for (const rule of CLASSIFICATION_RULES) { + if (rule.pattern.test(cmdLine)) { + return { category: rule.category, rule }; + } + } + + return null; +} diff --git a/src/lib/copilot/tools.ts b/src/lib/copilot/tools.ts index 2ebf72e2da..a4bc59321c 100644 --- a/src/lib/copilot/tools.ts +++ b/src/lib/copilot/tools.ts @@ -10,6 +10,7 @@ import { promisify } from "node:util"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; const execFileAsync = promisify(execFile); +import { classifyCommand } from "./commandClassification"; import { createCombo, getCombos, updateCombo } from "@/lib/db/combos"; import { getProviderConnections } from "@/lib/db/providers"; import { createApiKey, revokeApiKey, getApiKeys } from "@/lib/db/apiKeys"; @@ -390,6 +391,16 @@ export const COPILOT_TOOLS: CopilotTool[] = [ const argv = (trimmedCmd.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || []).map((arg) => arg.replace(/^["']|["']$/g, "") ); + + // 🔒 Approval gate: classify command before executing + const classified = classifyCommand(argv); + if (!classified) { + return `Command \`${trimmedCmd}\` is not recognized and cannot be executed. Use an allowed command or rephrase your request.`; + } + if (classified.category !== "read-only") { + return `⚠️ **${classified.category.toUpperCase()}** command blocked: \`${trimmedCmd}\`\n${classified.rule.reason}\n\nThis command was not executed. If you need to run it, please use the terminal directly.`; + } + const { stdout } = await execFileAsync(cliPath, argv, { encoding: "utf-8", timeout: 30000, diff --git a/tests/unit/approvalGate.test.ts b/tests/unit/approvalGate.test.ts new file mode 100644 index 0000000000..824e70d928 --- /dev/null +++ b/tests/unit/approvalGate.test.ts @@ -0,0 +1,52 @@ +import { describe, it } from "node:test"; +import { ok, equal } from "node:assert/strict"; + +describe("Approval gate integration (#8461)", () => { + it("classifyCommand types are exported", async () => { + const mod = await import("@/lib/copilot/commandClassification"); + equal(typeof mod.classifyCommand, "function"); + }); + + it("classification module has the expected categories", async () => { + const mod = await import("@/lib/copilot/commandClassification"); + const r = mod.classifyCommand(["status"]); + ok(r !== null, "should classify status"); + ok(["read-only", "mutating", "destructive", "secret-affecting"].includes(r.category)); + }); + + it("read-only command returns category and rule", async () => { + const { classifyCommand } = await import( + "@/lib/copilot/commandClassification" + ); + const r = classifyCommand(["help"]); + equal(r?.category, "read-only"); + ok(r?.rule.reason.length > 0, "rule should have a reason"); + }); + + it("mutating command returns warning reason", async () => { + const { classifyCommand } = await import( + "@/lib/copilot/commandClassification" + ); + const r = classifyCommand(["create", "something"]); + equal(r?.category, "mutating"); + ok(r?.rule.reason.includes("changes"), "reason should explain the risk"); + }); + + it("destructive command returns a stronger reason", async () => { + const { classifyCommand } = await import( + "@/lib/copilot/commandClassification" + ); + const r = classifyCommand(["delete", "provider"]); + equal(r?.category, "destructive"); + ok(r?.rule.reason.includes("permanently"), "reason should warn about permanence"); + }); + + it("secret-affecting command warns about credentials", async () => { + const { classifyCommand } = await import( + "@/lib/copilot/commandClassification" + ); + const r = classifyCommand(["keys", "show"]); + equal(r?.category, "secret-affecting"); + ok(r?.rule.reason.includes("secrets"), "reason should mention secrets"); + }); +}); \ No newline at end of file diff --git a/tests/unit/commandClassification.test.ts b/tests/unit/commandClassification.test.ts new file mode 100644 index 0000000000..a8dfb71ad1 --- /dev/null +++ b/tests/unit/commandClassification.test.ts @@ -0,0 +1,104 @@ +import { describe, it } from "node:test"; +import { equal, deepEqual } from "node:assert/strict"; + +describe("Command classification (#8461)", () => { + it("classifies read-only commands", async () => { + const { classifyCommand } = await import( + "@/lib/copilot/commandClassification" + ); + const r = classifyCommand(["status"]); + equal(r?.category, "read-only"); + }); + + it("classifies version as read-only", async () => { + const { classifyCommand } = await import( + "@/lib/copilot/commandClassification" + ); + equal(classifyCommand(["version"])?.category, "read-only"); + }); + + it("classifies config list as read-only", async () => { + const { classifyCommand } = await import( + "@/lib/copilot/commandClassification" + ); + equal(classifyCommand(["config", "list"])?.category, "read-only"); + }); + + it("classifies models as read-only", async () => { + const { classifyCommand } = await import( + "@/lib/copilot/commandClassification" + ); + equal(classifyCommand(["models"])?.category, "read-only"); + }); + + it("classifies health as read-only", async () => { + const { classifyCommand } = await import( + "@/lib/copilot/commandClassification" + ); + equal(classifyCommand(["health"])?.category, "read-only"); + }); + + it("classifies config set as mutating", async () => { + const { classifyCommand } = await import( + "@/lib/copilot/commandClassification" + ); + equal(classifyCommand(["config", "set", "key", "value"])?.category, "mutating"); + }); + + it("classifies providers add as mutating", async () => { + const { classifyCommand } = await import( + "@/lib/copilot/commandClassification" + ); + equal(classifyCommand(["providers", "add", "openai"])?.category, "mutating"); + }); + + it("classifies providers delete as destructive", async () => { + const { classifyCommand } = await import( + "@/lib/copilot/commandClassification" + ); + equal(classifyCommand(["providers", "delete", "my-provider"])?.category, "destructive"); + }); + + it("classifies reset as destructive", async () => { + const { classifyCommand } = await import( + "@/lib/copilot/commandClassification" + ); + equal(classifyCommand(["reset"])?.category, "destructive"); + }); + + it("classifies keys show as secret-affecting", async () => { + const { classifyCommand } = await import( + "@/lib/copilot/commandClassification" + ); + equal(classifyCommand(["keys", "show"])?.category, "secret-affecting"); + }); + + it("classifies auth token as secret-affecting", async () => { + const { classifyCommand } = await import( + "@/lib/copilot/commandClassification" + ); + equal(classifyCommand(["auth", "token"])?.category, "secret-affecting"); + }); + + it("returns null for unknown commands (denied by default)", async () => { + const { classifyCommand } = await import( + "@/lib/copilot/commandClassification" + ); + equal(classifyCommand(["nonexistent-command"]), null); + }); + + it("returns null for gibberish input", async () => { + const { classifyCommand } = await import( + "@/lib/copilot/commandClassification" + ); + equal(classifyCommand(["xyzzy", "--foobar"]), null); + }); + + it("destructive priority over mutating", async () => { + const { classifyCommand } = await import( + "@/lib/copilot/commandClassification" + ); + // "delete" pattern matches destructive first, even though it also matches mutating + equal(classifyCommand(["providers", "delete", "x"])?.category, "destructive"); + }); +}); \ No newline at end of file