feat(copilot): add approval gate for runOmniRouteCli commands (#8461) (#9495)

Validated in local merge-train (diegosouzapw batch)
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-08-06 10:39:27 -03:00
committed by GitHub
parent 5ea43c7a9d
commit a4fbdbffac
5 changed files with 246 additions and 0 deletions

View File

@@ -0,0 +1 @@
- feat(copilot): add approval gate for runOmniRouteCli commands (#8461)

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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");
});
});

View File

@@ -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");
});
});