diff --git a/client/src/app/api/org/command-policies/[id]/route.ts b/client/src/app/api/org/command-policies/[id]/route.ts
new file mode 100644
index 000000000..05305ae1d
--- /dev/null
+++ b/client/src/app/api/org/command-policies/[id]/route.ts
@@ -0,0 +1,39 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getAuthenticatedUser } from "@/lib/auth-helper";
+
+const API_BASE_URL = process.env.BACKEND_URL;
+const TIMEOUT_MS = 20000;
+
+async function proxy(method: string, path: string, body?: unknown) {
+ if (!API_BASE_URL) return NextResponse.json({ error: "BACKEND_URL not configured" }, { status: 500 });
+ const authResult = await getAuthenticatedUser();
+ if (authResult instanceof NextResponse) return authResult;
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
+ try {
+ const res = await fetch(`${API_BASE_URL}${path}`, {
+ method,
+ headers: { ...authResult.headers, "Content-Type": "application/json" },
+ body: body ? JSON.stringify(body) : undefined,
+ signal: controller.signal,
+ });
+ const data = await res.json().catch(() => ({}));
+ return NextResponse.json(data, { status: res.status });
+ } catch (e) {
+ if (e instanceof Error && e.name === "AbortError") return NextResponse.json({ error: "Timeout" }, { status: 504 });
+ return NextResponse.json({ error: "Backend request failed" }, { status: 500 });
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params;
+ const body = await req.json();
+ return proxy("PUT", `/api/org/command-policies/${id}`, body);
+}
+
+export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params;
+ return proxy("DELETE", `/api/org/command-policies/${id}`);
+}
diff --git a/client/src/app/api/org/command-policies/route.ts b/client/src/app/api/org/command-policies/route.ts
new file mode 100644
index 000000000..babf5c783
--- /dev/null
+++ b/client/src/app/api/org/command-policies/route.ts
@@ -0,0 +1,42 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getAuthenticatedUser } from "@/lib/auth-helper";
+
+const API_BASE_URL = process.env.BACKEND_URL;
+const TIMEOUT_MS = 20000;
+
+async function proxyRequest(req: NextRequest, method: string, path: string, body?: unknown) {
+ if (!API_BASE_URL) {
+ return NextResponse.json({ error: "BACKEND_URL not configured" }, { status: 500 });
+ }
+ const authResult = await getAuthenticatedUser();
+ if (authResult instanceof NextResponse) return authResult;
+
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
+ try {
+ const res = await fetch(`${API_BASE_URL}${path}`, {
+ method,
+ headers: { ...authResult.headers, "Content-Type": "application/json" },
+ body: body ? JSON.stringify(body) : undefined,
+ signal: controller.signal,
+ });
+ const data = await res.json().catch(() => ({}));
+ return NextResponse.json(data, { status: res.status });
+ } catch (e) {
+ if (e instanceof Error && e.name === "AbortError") {
+ return NextResponse.json({ error: "Request timeout" }, { status: 504 });
+ }
+ return NextResponse.json({ error: "Backend request failed" }, { status: 500 });
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+export async function GET() {
+ return proxyRequest(new NextRequest("http://localhost"), "GET", "/api/org/command-policies");
+}
+
+export async function POST(req: NextRequest) {
+ const body = await req.json();
+ return proxyRequest(req, "POST", "/api/org/command-policies", body);
+}
diff --git a/client/src/app/api/org/command-policies/test/route.ts b/client/src/app/api/org/command-policies/test/route.ts
new file mode 100644
index 000000000..520d6f911
--- /dev/null
+++ b/client/src/app/api/org/command-policies/test/route.ts
@@ -0,0 +1,18 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getAuthenticatedUser } from "@/lib/auth-helper";
+
+const API_BASE_URL = process.env.BACKEND_URL;
+
+export async function POST(req: NextRequest) {
+ if (!API_BASE_URL) return NextResponse.json({ error: "BACKEND_URL not configured" }, { status: 500 });
+ const authResult = await getAuthenticatedUser();
+ if (authResult instanceof NextResponse) return authResult;
+ const body = await req.json();
+ const res = await fetch(`${API_BASE_URL}/api/org/command-policies/test`, {
+ method: "POST",
+ headers: { ...authResult.headers, "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ const data = await res.json().catch(() => ({}));
+ return NextResponse.json(data, { status: res.status });
+}
diff --git a/client/src/app/api/org/command-policy-templates/active/route.ts b/client/src/app/api/org/command-policy-templates/active/route.ts
new file mode 100644
index 000000000..19dcdda27
--- /dev/null
+++ b/client/src/app/api/org/command-policy-templates/active/route.ts
@@ -0,0 +1,16 @@
+import { NextResponse } from "next/server";
+import { getAuthenticatedUser } from "@/lib/auth-helper";
+
+const API_BASE_URL = process.env.BACKEND_URL;
+
+export async function DELETE() {
+ if (!API_BASE_URL) return NextResponse.json({ error: "BACKEND_URL not configured" }, { status: 500 });
+ const authResult = await getAuthenticatedUser();
+ if (authResult instanceof NextResponse) return authResult;
+ const res = await fetch(`${API_BASE_URL}/api/org/command-policy-templates/active`, {
+ method: "DELETE",
+ headers: authResult.headers,
+ });
+ const data = await res.json().catch(() => ({}));
+ return NextResponse.json(data, { status: res.status });
+}
diff --git a/client/src/app/api/org/command-policy-templates/apply/route.ts b/client/src/app/api/org/command-policy-templates/apply/route.ts
new file mode 100644
index 000000000..027470d95
--- /dev/null
+++ b/client/src/app/api/org/command-policy-templates/apply/route.ts
@@ -0,0 +1,18 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getAuthenticatedUser } from "@/lib/auth-helper";
+
+const API_BASE_URL = process.env.BACKEND_URL;
+
+export async function POST(req: NextRequest) {
+ if (!API_BASE_URL) return NextResponse.json({ error: "BACKEND_URL not configured" }, { status: 500 });
+ const authResult = await getAuthenticatedUser();
+ if (authResult instanceof NextResponse) return authResult;
+ const body = await req.json();
+ const res = await fetch(`${API_BASE_URL}/api/org/command-policy-templates/apply`, {
+ method: "POST",
+ headers: { ...authResult.headers, "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ const data = await res.json().catch(() => ({}));
+ return NextResponse.json(data, { status: res.status });
+}
diff --git a/client/src/app/api/org/command-policy-templates/route.ts b/client/src/app/api/org/command-policy-templates/route.ts
new file mode 100644
index 000000000..50f5c86e3
--- /dev/null
+++ b/client/src/app/api/org/command-policy-templates/route.ts
@@ -0,0 +1,16 @@
+import { NextResponse } from "next/server";
+import { getAuthenticatedUser } from "@/lib/auth-helper";
+
+const API_BASE_URL = process.env.BACKEND_URL;
+
+export async function GET() {
+ if (!API_BASE_URL) return NextResponse.json({ error: "BACKEND_URL not configured" }, { status: 500 });
+ const authResult = await getAuthenticatedUser();
+ if (authResult instanceof NextResponse) return authResult;
+ const res = await fetch(`${API_BASE_URL}/api/org/command-policy-templates`, {
+ method: "GET",
+ headers: { ...authResult.headers, "Content-Type": "application/json" },
+ });
+ const data = await res.json().catch(() => ({}));
+ return NextResponse.json(data, { status: res.status });
+}
diff --git a/client/src/app/api/org/command-policy-toggle/route.ts b/client/src/app/api/org/command-policy-toggle/route.ts
new file mode 100644
index 000000000..37fba9b3c
--- /dev/null
+++ b/client/src/app/api/org/command-policy-toggle/route.ts
@@ -0,0 +1,18 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getAuthenticatedUser } from "@/lib/auth-helper";
+
+const API_BASE_URL = process.env.BACKEND_URL;
+
+export async function PUT(req: NextRequest) {
+ if (!API_BASE_URL) return NextResponse.json({ error: "BACKEND_URL not configured" }, { status: 500 });
+ const authResult = await getAuthenticatedUser();
+ if (authResult instanceof NextResponse) return authResult;
+ const body = await req.json();
+ const res = await fetch(`${API_BASE_URL}/api/org/command-policy-toggle`, {
+ method: "PUT",
+ headers: { ...authResult.headers, "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ const data = await res.json().catch(() => ({}));
+ return NextResponse.json(data, { status: res.status });
+}
diff --git a/client/src/app/globals.css b/client/src/app/globals.css
index 0383c4075..687ed5df6 100644
--- a/client/src/app/globals.css
+++ b/client/src/app/globals.css
@@ -140,6 +140,23 @@
-ms-overflow-style: none;
}
+.scrollbar-thin::-webkit-scrollbar {
+ height: 1px;
+}
+.scrollbar-thin::-webkit-scrollbar-track {
+ background: transparent;
+}
+.scrollbar-thin::-webkit-scrollbar-thumb {
+ background: transparent;
+ border-radius: 2px;
+}
+.scrollbar-thin:hover::-webkit-scrollbar-thumb {
+ background: hsl(var(--muted-foreground) / 0.25);
+}
+.scrollbar-thin {
+ scrollbar-width: thin;
+}
+
/* Code block styles - using Prism okaidia theme */
/* Only override font properties, preserve Prism.js colors */
pre[class*="language-"] {
diff --git a/client/src/components/SecuritySettings.tsx b/client/src/components/SecuritySettings.tsx
new file mode 100644
index 000000000..6fa605793
--- /dev/null
+++ b/client/src/components/SecuritySettings.tsx
@@ -0,0 +1,601 @@
+"use client";
+
+import React, { useState, useEffect, useCallback } from "react";
+import { Card, CardContent } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Switch } from "@/components/ui/switch";
+import { Badge } from "@/components/ui/badge";
+import { Label } from "@/components/ui/label";
+import { useToast } from "@/components/ui/use-toast";
+import { useUser } from "@/hooks/useAuthHooks";
+import { isAdmin } from "@/lib/roles";
+import { Trash2, Plus, Terminal, ChevronRight, ChevronDown, Loader2, Lock, CheckCircle2, XCircle, Shield, ShieldCheck, ShieldX, BookOpen } from "lucide-react";
+import { commandPolicyService, type CommandPolicyRule, type PolicyTemplate } from "@/lib/services/command-policies";
+
+function RuleList({
+ rules,
+ onToggle,
+ onDelete,
+}: {
+ rules: CommandPolicyRule[];
+ onToggle: (rule: CommandPolicyRule) => void;
+ onDelete: (id: number) => void;
+}) {
+ if (rules.length === 0) {
+ return (
+
No rules yet
+ );
+ }
+ return (
+
+ {rules.map((rule) => (
+
+ onToggle(rule)}
+ className="shrink-0 scale-90"
+ aria-label={`Toggle rule: ${rule.pattern}`}
+ />
+
+ {rule.pattern}
+
+
+ {rule.description}
+
+ {rule.source === "template" && (
+ tpl
+ )}
+ onDelete(rule.id)}
+ aria-label={`Delete rule: ${rule.pattern}`}
+ >
+
+
+
+ ))}
+
+ );
+}
+
+function AddRuleForm({
+ mode,
+ onAdd,
+ onCancel,
+}: {
+ mode: "allow" | "deny";
+ onAdd: (pattern: string, description: string) => void;
+ onCancel: () => void;
+}) {
+ const [pattern, setPattern] = useState("");
+ const [desc, setDesc] = useState("");
+
+ return (
+
+
+
+ { if (pattern.trim()) onAdd(pattern.trim(), desc.trim()); }}
+ >
+ Add
+
+ Cancel
+
+
+ );
+}
+
+function TemplatePicker({
+ templates,
+ applying,
+ activeId,
+ onApply,
+ onRemove,
+}: {
+ templates: PolicyTemplate[];
+ applying: string | null;
+ activeId: string | null;
+ onApply: (id: string) => void;
+ onRemove: () => void;
+}) {
+ const [confirmId, setConfirmId] = useState(null);
+ const [expandedId, setExpandedId] = useState(null);
+
+ if (templates.length === 0) return null;
+
+ return (
+
+
+
+
+
+
+
Policy Templates
+
Pre-built security profiles for common use cases
+
+
+
+ {templates.map((tpl) => {
+ const expanded = expandedId === tpl.id;
+ const active = activeId === tpl.id;
+ return (
+
+
+
+
+ {tpl.name}
+
+
+ {tpl.description}
+
+
+
+ {active ? (
+
+
+ Active
+
+ onRemove()}>
+ Remove
+
+
+ ) : confirmId === tpl.id ? (
+
+ { onApply(tpl.id); setConfirmId(null); }}
+ >
+ {applying === tpl.id ? (
+
+ ) : (
+ "Confirm"
+ )}
+
+ setConfirmId(null)}
+ >
+ Cancel
+
+
+ ) : (
+
setConfirmId(tpl.id)}
+ >
+ Apply
+
+ )}
+
+
+
+ setExpandedId(expanded ? null : tpl.id)}
+ >
+ {expanded ? : }
+ Preview rules
+
+
+
+ {tpl.allow_count} allow
+
+
+
+ {tpl.deny_count} deny
+
+
+ {expanded && (
+
+ {tpl.allow.length > 0 && (
+
+
Allow
+ {tpl.allow.map((r, i) => (
+
+
+ {r.pattern}
+
+ {r.description}
+
+ ))}
+
+ )}
+ {tpl.deny.length > 0 && (
+
+
Deny
+ {tpl.deny.map((r, i) => (
+
+
+ {r.pattern}
+
+ {r.description}
+
+ ))}
+
+ )}
+
+ )}
+
+ );
+ })}
+
+
+ );
+}
+
+export function SecuritySettings() {
+ const { user } = useUser();
+ const { toast } = useToast();
+ const admin = isAdmin(user?.role);
+
+ const [allowRules, setAllowRules] = useState([]);
+ const [denyRules, setDenyRules] = useState([]);
+ const [allowlistEnabled, setAllowlistEnabled] = useState(false);
+ const [denylistEnabled, setDenylistEnabled] = useState(false);
+ const [loading, setLoading] = useState(true);
+
+ const [showAddAllow, setShowAddAllow] = useState(false);
+ const [showAddDeny, setShowAddDeny] = useState(false);
+
+ const [testCmd, setTestCmd] = useState("");
+ const [testResult, setTestResult] = useState<{ allowed: boolean; rule_description: string | null } | null>(null);
+ const [testLoading, setTestLoading] = useState(false);
+
+ const [templates, setTemplates] = useState([]);
+ const [applyingTemplate, setApplyingTemplate] = useState(null);
+ const [activeTemplateId, setActiveTemplateId] = useState(null);
+
+ const fetchPolicies = useCallback(async () => {
+ try {
+ const data = await commandPolicyService.getPolicies();
+ setAllowRules(data.allow_rules);
+ setDenyRules(data.deny_rules);
+ setAllowlistEnabled(data.allowlist_enabled);
+ setDenylistEnabled(data.denylist_enabled);
+ setActiveTemplateId(data.active_template_id ?? null);
+ } catch {
+ toast({ title: "Failed to load policies", variant: "destructive" });
+ } finally {
+ setLoading(false);
+ }
+ }, [toast]);
+
+ const fetchTemplates = useCallback(async () => {
+ try {
+ const data = await commandPolicyService.getTemplates();
+ setTemplates(data);
+ } catch {
+ // Templates are optional; don't block the UI
+ }
+ }, []);
+
+ useEffect(() => { fetchPolicies(); fetchTemplates(); }, [fetchPolicies, fetchTemplates]);
+
+ const handleToggleList = async (list: "allowlist" | "denylist", enabled: boolean) => {
+ try {
+ const res = await commandPolicyService.toggleList(list, enabled);
+ setAllowlistEnabled(res.allowlist_enabled);
+ setDenylistEnabled(res.denylist_enabled);
+ await fetchPolicies();
+ toast({ title: `${list === "allowlist" ? "Allowlist" : "Denylist"} ${enabled ? "enabled" : "disabled"}` });
+ } catch {
+ toast({ title: "Failed to toggle list", variant: "destructive" });
+ }
+ };
+
+ const handleAddRule = async (mode: "allow" | "deny", pattern: string, description: string) => {
+ try {
+ await commandPolicyService.createPolicy({ mode, pattern, description, priority: 50 });
+ mode === "allow" ? setShowAddAllow(false) : setShowAddDeny(false);
+ await fetchPolicies();
+ toast({ title: "Rule added" });
+ } catch (e) {
+ toast({ title: e instanceof Error ? e.message : "Failed to add rule", variant: "destructive" });
+ }
+ };
+
+ const handleToggleRule = async (rule: CommandPolicyRule) => {
+ try {
+ await commandPolicyService.updatePolicy(rule.id, { enabled: !rule.enabled });
+ await fetchPolicies();
+ } catch {
+ toast({ title: "Failed to update rule", variant: "destructive" });
+ }
+ };
+
+ const handleDeleteRule = async (id: number) => {
+ try {
+ await commandPolicyService.deletePolicy(id);
+ await fetchPolicies();
+ } catch {
+ toast({ title: "Failed to delete rule", variant: "destructive" });
+ }
+ };
+
+ const handleTest = async () => {
+ if (!testCmd.trim()) return;
+ setTestLoading(true);
+ try {
+ const result = await commandPolicyService.testCommand(testCmd.trim());
+ setTestResult(result);
+ } catch {
+ toast({ title: "Test failed", variant: "destructive" });
+ } finally {
+ setTestLoading(false);
+ }
+ };
+
+ const handleApplyTemplate = async (templateId: string) => {
+ setApplyingTemplate(templateId);
+ try {
+ const res = await commandPolicyService.applyTemplate(templateId);
+ setAllowlistEnabled(res.allowlist_enabled);
+ setDenylistEnabled(res.denylist_enabled);
+ await fetchPolicies();
+ const tpl = templates.find(t => t.id === templateId);
+ toast({ title: `Applied "${tpl?.name ?? templateId}" template` });
+ } catch {
+ toast({ title: "Failed to apply template", variant: "destructive" });
+ } finally {
+ setApplyingTemplate(null);
+ }
+ };
+
+ const handleRemoveTemplate = async () => {
+ try {
+ await commandPolicyService.clearActiveTemplate();
+ await fetchPolicies();
+ toast({ title: "Template removed" });
+ } catch {
+ toast({ title: "Failed to remove template", variant: "destructive" });
+ }
+ };
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!admin) {
+ return (
+
+
+
+
+
+
+
Command Policies
+
Organization-level security rules
+
+
+
+
+
+
+
Requires Admin role to manage policies.
+
+ {denylistEnabled && Denylist on }
+ {allowlistEnabled && Allowlist on }
+ {!denylistEnabled && !allowlistEnabled && (
+ No active lists
+ )}
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
Command Policies
+
Control what commands the Aurora agent can execute
+
+
+
+
+ Command policies audit what the agent can execute — they do not change your connector
+ permissions or read-only mode settings. A command allowed here can still fail if the
+ underlying credentials lack access. If the denylist is on, matching commands are blocked.
+ If the allowlist is on, non-matching commands are blocked. Enable both for maximum control.
+
+
+ {/* Policy Templates */}
+ {templates.length > 0 && (
+
+ )}
+
+ {/* Denylist */}
+
+
+
+
+
+
+
+
Denylist
+
+ {denylistEnabled ? "Commands matching these patterns are blocked" : "Disabled -- no commands are blocked by this list"}
+
+
+
+
+ {denylistEnabled && (
+
setShowAddDeny(!showAddDeny)}>
+ Add
+
+ )}
+
handleToggleList("denylist", v)}
+ className="scale-90"
+ aria-label="Toggle denylist"
+ />
+
+
+ {denylistEnabled && (
+ <>
+
+ {showAddDeny && (
+
handleAddRule("deny", p, d)}
+ onCancel={() => setShowAddDeny(false)}
+ />
+ )}
+ >
+ )}
+
+
+ {/* Allowlist */}
+
+
+
+
+
+
+
+
Allowlist
+
+ {allowlistEnabled ? "Only commands matching these patterns are allowed" : "Disabled -- commands are not filtered by this list"}
+
+
+
+
+ {allowlistEnabled && (
+
setShowAddAllow(!showAddAllow)}>
+ Add
+
+ )}
+
handleToggleList("allowlist", v)}
+ className="scale-90"
+ aria-label="Toggle allowlist"
+ />
+
+
+ {allowlistEnabled && (
+ <>
+
+ {showAddAllow && (
+
handleAddRule("allow", p, d)}
+ onCancel={() => setShowAddAllow(false)}
+ />
+ )}
+ >
+ )}
+
+
+ {/* Test Command */}
+
+
+
+
+
+
+
Test Command
+
Check how a command would be evaluated
+
+
+
+
+ { setTestCmd(e.target.value); setTestResult(null); }}
+ placeholder="Enter a command to test..."
+ className="font-mono text-xs bg-background h-8"
+ onKeyDown={(e) => e.key === "Enter" && handleTest()}
+ />
+
+ {testLoading ? (
+
+ ) : (
+ <>Test >
+ )}
+
+
+ {testResult && (
+
+ {testResult.allowed ? (
+
+ ) : (
+
+ )}
+
+
+ {testResult.allowed ? "Allowed" : "Denied"}
+
+
+ {testResult.rule_description || "No matching rule"}
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/client/src/components/SettingsModal.tsx b/client/src/components/SettingsModal.tsx
index 6616ea275..8e4bf1d8c 100644
--- a/client/src/components/SettingsModal.tsx
+++ b/client/src/components/SettingsModal.tsx
@@ -3,13 +3,14 @@
import React, { useState } from 'react';
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle, DialogDescription } from "@/components/ui/dialog";
-import { Settings, User, BookOpen, FileText, Building2 } from "lucide-react";
+import { Settings, User, BookOpen, FileText, Building2, Shield } from "lucide-react";
import { cn } from "@/lib/utils";
import { GeneralSettings } from "@/components/GeneralSettings";
import { ProfileSettings } from "@/components/ProfileSettings";
import { KnowledgeBaseSettings } from "@/components/KnowledgeBaseSettings";
import { PostmortemsSettings } from "@/components/PostmortemsSettings";
import { OrgSettings } from "@/components/OrgSettings";
+import { SecuritySettings } from "@/components/SecuritySettings";
import { useUser } from "@/hooks/useAuthHooks";
@@ -18,7 +19,7 @@ interface SettingsModalProps {
onClose: () => void;
}
-type SettingsTab = 'organization' | 'general' | 'profile' | 'knowledge-base' | 'postmortems';
+type SettingsTab = 'organization' | 'general' | 'profile' | 'knowledge-base' | 'postmortems' | 'security';
export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
const [activeTab, setActiveTab] = useState('organization');
@@ -55,6 +56,12 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
icon: FileText,
description: 'View generated postmortems'
},
+ {
+ id: 'security' as SettingsTab,
+ label: 'Security',
+ icon: Shield,
+ description: 'Agent command policies'
+ },
];
const renderContent = () => {
@@ -105,6 +112,13 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
);
+ case 'security':
+ return (
+
+
+
+ );
+
default:
return null;
}
@@ -151,7 +165,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
{/* Right Content Area */}
-
+
{renderContent()}
diff --git a/client/src/lib/services/command-policies.ts b/client/src/lib/services/command-policies.ts
new file mode 100644
index 000000000..b917b7b7b
--- /dev/null
+++ b/client/src/lib/services/command-policies.ts
@@ -0,0 +1,75 @@
+import { apiGet, apiPost, apiPut, apiDelete } from "./api-client";
+
+export interface CommandPolicyRule {
+ id: number;
+ mode: "allow" | "deny";
+ pattern: string;
+ description: string;
+ priority: number;
+ enabled: boolean;
+ created_at?: string;
+ updated_at?: string;
+ updated_by?: string;
+ source?: "template" | "custom";
+}
+
+export interface PoliciesResponse {
+ allow_rules: CommandPolicyRule[];
+ deny_rules: CommandPolicyRule[];
+ allowlist_enabled: boolean;
+ denylist_enabled: boolean;
+ active_template_id: string | null;
+}
+
+export interface TestResult {
+ allowed: boolean;
+ rule_description: string | null;
+ command: string;
+}
+
+export interface PolicyTemplateRule {
+ pattern: string;
+ description: string;
+ priority: number;
+}
+
+export interface PolicyTemplate {
+ id: string;
+ name: string;
+ description: string;
+ allow_count: number;
+ deny_count: number;
+ allow: PolicyTemplateRule[];
+ deny: PolicyTemplateRule[];
+}
+
+export const commandPolicyService = {
+ getPolicies: () => apiGet
("/api/org/command-policies"),
+
+ createPolicy: (rule: Pick) =>
+ apiPost<{ id: number }>("/api/org/command-policies", rule),
+
+ updatePolicy: (id: number, fields: Partial) =>
+ apiPut<{ status: string }>(`/api/org/command-policies/${id}`, fields),
+
+ deletePolicy: (id: number) =>
+ apiDelete<{ status: string }>(`/api/org/command-policies/${id}`),
+
+ testCommand: (command: string) =>
+ apiPost("/api/org/command-policies/test", { command }),
+
+ toggleList: (list: "allowlist" | "denylist", enabled: boolean) =>
+ apiPut<{ status: string; allowlist_enabled: boolean; denylist_enabled: boolean }>(
+ "/api/org/command-policy-toggle", { list, enabled }
+ ),
+
+ getTemplates: () => apiGet("/api/org/command-policy-templates"),
+
+ applyTemplate: (templateId: string) =>
+ apiPost<{ status: string; template_id: string; allowlist_enabled: boolean; denylist_enabled: boolean; active_template_id: string | null }>(
+ "/api/org/command-policy-templates/apply", { template_id: templateId }
+ ),
+
+ clearActiveTemplate: () =>
+ apiDelete<{ status: string; allowlist_enabled: boolean; denylist_enabled: boolean; active_template_id: null }>("/api/org/command-policy-templates/active"),
+};
diff --git a/server/chat/backend/agent/prompt/composer.py b/server/chat/backend/agent/prompt/composer.py
index 01f6651f9..dc07a40ab 100644
--- a/server/chat/backend/agent/prompt/composer.py
+++ b/server/chat/backend/agent/prompt/composer.py
@@ -112,6 +112,22 @@ def build_prompt_segments(
if state and hasattr(state, 'user_id'):
knowledge_base_memory = build_knowledge_base_memory_segment(state.user_id)
+ # Build org-level command policy segment
+ security_policy = ""
+ if state and hasattr(state, 'user_id'):
+ try:
+ from utils.auth.stateless_auth import get_org_id_for_user
+ from utils.auth.command_policy import get_policy_prompt_text
+ org_id = get_org_id_for_user(state.user_id)
+ if org_id:
+ security_policy = get_policy_prompt_text(org_id)
+ except Exception as e:
+ logging.error("Failed to build security policy segment: %s", e)
+ security_policy = (
+ "IMPORTANT: This organization has command policies but they could not be loaded. "
+ "Warn the user before running commands, as they may be denied by policy enforcement."
+ )
+
return PromptSegments(
system_invariant=system_invariant,
provider_constraints=provider_constraints,
@@ -127,11 +143,15 @@ def build_prompt_segments(
manual_vm_access=manual_vm_access,
knowledge_base_memory=knowledge_base_memory,
integration_index=integration_index,
+ security_policy=security_policy,
)
def assemble_system_prompt(segments: PromptSegments) -> str: # main prompt builder
parts: List[str] = []
+ # Security policy included early for visibility
+ if segments.security_policy:
+ parts.append(segments.security_policy)
# Background mode comes first if present (important RCA context)
if segments.background_mode:
parts.append(segments.background_mode)
@@ -160,4 +180,6 @@ def assemble_system_prompt(segments: PromptSegments) -> str: # main prompt buil
parts.append(segments.terraform_validation)
if segments.failure_recovery and not segments.background_mode:
parts.append(segments.failure_recovery)
+ if segments.security_policy:
+ parts.append("REMINDER: Commands that violate the organization policy will be rejected. Do not attempt workarounds.")
return "\n".join(parts)
diff --git a/server/chat/backend/agent/prompt/provider_rules.py b/server/chat/backend/agent/prompt/provider_rules.py
index 2fc46152c..b2e72efce 100644
--- a/server/chat/backend/agent/prompt/provider_rules.py
+++ b/server/chat/backend/agent/prompt/provider_rules.py
@@ -249,9 +249,7 @@ def build_regional_rules() -> str:
def build_ephemeral_rules(mode: Optional[str]) -> str:
- normalized_mode = (mode or "agent").strip().lower()
-
- if normalized_mode == "ask":
+ if (mode or "agent").strip().lower() == "ask":
return (
"━━━ CRITICAL: CURRENT MODE ━━━\n"
"MODE: ASK (READ-ONLY)\n\n"
diff --git a/server/chat/backend/agent/prompt/schema.py b/server/chat/backend/agent/prompt/schema.py
index d57010b76..f978ebec1 100644
--- a/server/chat/backend/agent/prompt/schema.py
+++ b/server/chat/backend/agent/prompt/schema.py
@@ -17,3 +17,4 @@ class PromptSegments:
background_mode: str = "" # Background chat autonomous operation instructions
knowledge_base_memory: str = "" # User's knowledge base memory context
integration_index: str = "" # Skills-based: compact index of connected integrations
+ security_policy: str = "" # Org-level command allow/deny policy
diff --git a/server/chat/backend/agent/tools/cloud_exec_tool.py b/server/chat/backend/agent/tools/cloud_exec_tool.py
index e35e496b7..9c9703f60 100644
--- a/server/chat/backend/agent/tools/cloud_exec_tool.py
+++ b/server/chat/backend/agent/tools/cloud_exec_tool.py
@@ -1607,6 +1607,30 @@ def cloud_exec(provider: str, command: str, user_id: Optional[str] = None, sessi
normalized_provider = _normalize_cloud_exec_provider(provider)
provider = normalized_provider
+
+ # Org command policy check -- must run before any execution path branches
+ # Prepend CLI prefix so patterns like ^aws\s+ match (cloud_exec receives
+ # the subcommand without the provider prefix, e.g. "ecs list-clusters").
+ _CLI_PREFIX = {"aws": "aws", "gcp": "gcloud", "azure": "az",
+ "scaleway": "scw", "ovh": "ovhcloud"}
+ from utils.auth.command_policy import evaluate_compound_command
+ from utils.auth.stateless_auth import get_org_id_for_user
+ org_id = get_org_id_for_user(user_id) if user_id else None
+ prefix = _CLI_PREFIX.get(provider.lower(), "")
+ policy_cmd = f"{prefix} {command}" if prefix and not command.strip().startswith(prefix) else command
+ verdict = evaluate_compound_command(org_id, policy_cmd)
+ if not verdict.allowed:
+ reason = (verdict.rule_description or "Matched organization policy")[:200]
+ logger.warning("Policy denied cloud command for user %s (%s)",
+ user_id, reason)
+ return json.dumps({
+ "success": False,
+ "error": f"Command blocked by organization policy: {reason}",
+ "code": "POLICY_DENIED",
+ "final_command": command,
+ "provider": provider.lower(),
+ })
+
# Set up ISOLATED environment based on provider - NO GLOBAL STATE!
isolated_env = None
auth_command = None
@@ -2008,6 +2032,7 @@ def cloud_exec(provider: str, command: str, user_id: Optional[str] = None, sessi
"provider": provider.lower(),
})
+
# If command is potentially action, ask for confirmation first (after command processing)
if not is_read_only_command(command):
summary_msg = summarize_cloud_command(command)
diff --git a/server/chat/backend/agent/tools/kubectl_onprem_tool.py b/server/chat/backend/agent/tools/kubectl_onprem_tool.py
index 3c43988a9..bc542e3ab 100644
--- a/server/chat/backend/agent/tools/kubectl_onprem_tool.py
+++ b/server/chat/backend/agent/tools/kubectl_onprem_tool.py
@@ -41,7 +41,27 @@ def on_prem_kubectl(
command = command.strip()
if command.lower().startswith('kubectl '):
command = command[8:].strip()
-
+
+ # Org command policy check against full kubectl command
+ from utils.auth.command_policy import evaluate_compound_command
+ from utils.auth.stateless_auth import get_org_id_for_user
+ full_command = f"kubectl {command}"
+ org_id = get_org_id_for_user(user_id) if user_id else None
+ verdict = evaluate_compound_command(org_id, full_command)
+ if not verdict.allowed:
+ reason = (verdict.rule_description or "Matched organization policy")[:200]
+ logger.warning("Policy denied kubectl_onprem command for user %s (%s)",
+ user_id, reason)
+ return json.dumps({
+ 'success': False,
+ 'error': f"Command blocked by organization policy: {reason}",
+ 'code': 'POLICY_DENIED',
+ 'chat_output': f"$ {full_command}\nBlocked by organization policy: {reason}",
+ 'command': full_command,
+ 'return_code': 1,
+ 'provider': 'onprem_kubectl',
+ })
+
# Call internal API on chatbot service
try:
chatbot_url = os.getenv('CHATBOT_INTERNAL_URL')
diff --git a/server/chat/backend/agent/tools/tailscale_ssh_tool.py b/server/chat/backend/agent/tools/tailscale_ssh_tool.py
index 8fe869dbd..261aaf213 100644
--- a/server/chat/backend/agent/tools/tailscale_ssh_tool.py
+++ b/server/chat/backend/agent/tools/tailscale_ssh_tool.py
@@ -15,6 +15,7 @@
import json
import logging
import os
+import re
from typing import Optional
logger = logging.getLogger(__name__)
@@ -210,12 +211,34 @@ def tailscale_ssh(
"error": "Device hostname is required"
})
+ if not re.match(r'^[A-Za-z0-9._:\-]+$', device_hostname):
+ return json.dumps({
+ "success": False,
+ "error": "Invalid device hostname"
+ })
+
if not command or not command.strip():
return json.dumps({
"success": False,
"error": "Command cannot be empty"
})
+ # Org command policy check (shared allow/deny firewall across all tools)
+ from utils.auth.command_policy import evaluate_compound_command
+ from utils.auth.stateless_auth import get_org_id_for_user
+ org_id = get_org_id_for_user(user_id) if user_id else None
+ verdict = evaluate_compound_command(org_id, command)
+ if not verdict.allowed:
+ reason = (verdict.rule_description or "Matched organization policy")[:200]
+ logger.warning("Policy denied tailscale_ssh command for user %s (%s)",
+ user_id, reason)
+ return json.dumps({
+ "success": False,
+ "error": f"Command blocked by organization policy: {reason}",
+ "code": "POLICY_DENIED",
+ "provider": "tailscale_ssh",
+ })
+
# Validate SSH user (basic sanitization)
if not ssh_user or (not ssh_user.isalnum() and ssh_user not in ["root"]):
ssh_user = "root"
diff --git a/server/chat/backend/agent/tools/terminal_exec_tool.py b/server/chat/backend/agent/tools/terminal_exec_tool.py
index bd3c1dbde..8ac47c310 100644
--- a/server/chat/backend/agent/tools/terminal_exec_tool.py
+++ b/server/chat/backend/agent/tools/terminal_exec_tool.py
@@ -262,7 +262,22 @@ def terminal_exec(
cmd_lower = command.lower().strip()
has_shell_syntax = _has_shell_metacharacters(command)
allow_routing = not force_shell and not has_shell_syntax
-
+
+ # Org command policy check (shared allow/deny firewall across all tools)
+ from utils.auth.command_policy import evaluate_compound_command
+ from utils.auth.stateless_auth import get_org_id_for_user
+ org_id = get_org_id_for_user(user_id) if user_id else None
+ verdict = evaluate_compound_command(org_id, command)
+ if not verdict.allowed:
+ reason = (verdict.rule_description or "Matched organization policy")[:200]
+ logger.warning("Policy denied terminal command for user %s (%s)",
+ user_id, reason)
+ return json.dumps({
+ "success": False,
+ "error": f"Command blocked by organization policy: {reason}",
+ "code": "POLICY_DENIED",
+ })
+
# Define routing table for cloud commands
# Provider=None means "use user's provider preference" (for kubectl which works with any cloud)
CLOUD_ROUTES = [
diff --git a/server/main_compute.py b/server/main_compute.py
index eeed854fa..65aa91411 100644
--- a/server/main_compute.py
+++ b/server/main_compute.py
@@ -265,6 +265,10 @@ def enforce_user_org_binding():
from routes.org_routes import org_bp
app.register_blueprint(org_bp)
+# --- Command Policy Routes ---
+from routes.command_policies import command_policies_bp
+app.register_blueprint(command_policies_bp)
+
# --- GitHub Integration Routes ---
from routes.github.github import github_bp
from routes.github.github_user_repos import github_user_repos_bp
diff --git a/server/routes/command_policies.py b/server/routes/command_policies.py
new file mode 100644
index 000000000..be1cc7b70
--- /dev/null
+++ b/server/routes/command_policies.py
@@ -0,0 +1,387 @@
+"""API routes for command policy management (allow/deny firewall rules).
+
+Blueprint: command_policies_bp
+Prefix: /api/org
+"""
+
+import json
+import logging
+from datetime import datetime
+
+from flask import Blueprint, jsonify, request
+
+from utils.auth.rbac_decorators import require_permission, require_auth_only
+from utils.auth.stateless_auth import (
+ get_org_id_from_request,
+ get_user_preference,
+ store_user_preference,
+)
+from utils.auth.command_policy import (
+ evaluate_compound_command,
+ get_policy_templates,
+ get_seed_rules,
+ invalidate_cache,
+ validate_pattern,
+)
+
+logger = logging.getLogger(__name__)
+
+command_policies_bp = Blueprint("command_policies", __name__, url_prefix="/api/org")
+
+
+def _list_states(org_id: str) -> dict:
+ """Read allowlist/denylist toggle states from user_preferences."""
+ org_key = f"__org__{org_id}"
+ al = get_user_preference(org_key, "command_policy_allowlist") or "off"
+ dl = get_user_preference(org_key, "command_policy_denylist") or "off"
+ at = get_user_preference(org_key, "command_policy_active_template")
+ return {
+ "allowlist_enabled": str(al).lower() == "on",
+ "denylist_enabled": str(dl).lower() == "on",
+ "active_template_id": at or None,
+ }
+
+
+@command_policies_bp.route("/command-policies", methods=["GET", "OPTIONS"])
+@require_auth_only
+def list_policies(user_id):
+ if request.method == "OPTIONS":
+ return jsonify({}), 200
+
+ org_id = get_org_id_from_request()
+ if not org_id:
+ return jsonify({"error": "No organization context"}), 403
+
+ from utils.db.connection_pool import db_pool
+ with db_pool.get_admin_connection() as conn:
+ with conn.cursor() as cur:
+ cur.execute(
+ "SELECT id, mode, pattern, description, priority, enabled, "
+ "created_at, updated_at, updated_by, source "
+ "FROM org_command_policies WHERE org_id = %s ORDER BY priority DESC",
+ (org_id,),
+ )
+ rows = cur.fetchall()
+
+ allow_rules = []
+ deny_rules = []
+ for r in rows:
+ rule = {
+ "id": r[0], "mode": r[1], "pattern": r[2],
+ "description": r[3] or "", "priority": r[4],
+ "enabled": r[5],
+ "created_at": r[6].isoformat() if r[6] else None,
+ "updated_at": r[7].isoformat() if r[7] else None,
+ "updated_by": r[8],
+ "source": r[9] or "custom",
+ }
+ (allow_rules if r[1] == "allow" else deny_rules).append(rule)
+
+ states = _list_states(org_id)
+ return jsonify({
+ "allow_rules": allow_rules,
+ "deny_rules": deny_rules,
+ **states,
+ })
+
+
+@command_policies_bp.route("/command-policies", methods=["POST"])
+@require_permission("admin", "access")
+def create_policy(user_id):
+ org_id = get_org_id_from_request()
+ if not org_id:
+ return jsonify({"error": "No organization context"}), 403
+
+ data = request.get_json() or {}
+ mode = data.get("mode")
+ pattern = data.get("pattern", "").strip()
+ description = data.get("description", "").strip()
+ try:
+ priority = int(data.get("priority", 0))
+ except (TypeError, ValueError):
+ return jsonify({"error": "priority must be an integer"}), 400
+
+ if mode not in ("allow", "deny"):
+ return jsonify({"error": "mode must be 'allow' or 'deny'"}), 400
+ if not pattern:
+ return jsonify({"error": "pattern is required"}), 400
+
+ err = validate_pattern(pattern)
+ if err:
+ return jsonify({"error": "Invalid regex pattern"}), 400
+
+ from utils.db.connection_pool import db_pool
+ try:
+ with db_pool.get_admin_connection() as conn:
+ with conn.cursor() as cur:
+ cur.execute(
+ "INSERT INTO org_command_policies "
+ "(org_id, mode, pattern, description, priority, updated_by) "
+ "VALUES (%s, %s, %s, %s, %s, %s) RETURNING id",
+ (org_id, mode, pattern, description, priority, user_id),
+ )
+ new_id = cur.fetchone()[0]
+ conn.commit()
+ except Exception as e:
+ if "unique" in str(e).lower() or "duplicate" in str(e).lower():
+ return jsonify({"error": "A rule with this mode and pattern already exists"}), 409
+ raise
+
+ invalidate_cache(org_id)
+ return jsonify({"id": new_id, "status": "created"}), 201
+
+
+@command_policies_bp.route("/command-policies/", methods=["PUT", "OPTIONS"])
+@require_permission("admin", "access")
+def update_policy(user_id, rule_id):
+ if request.method == "OPTIONS":
+ return jsonify({}), 200
+
+ org_id = get_org_id_from_request()
+ if not org_id:
+ return jsonify({"error": "No organization context"}), 403
+
+ data = request.get_json() or {}
+ updates = []
+ params = []
+
+ for field, col in [("mode", "mode"), ("pattern", "pattern"),
+ ("description", "description"), ("priority", "priority"),
+ ("enabled", "enabled")]:
+ if field in data:
+ if field == "mode" and data[field] not in ("allow", "deny"):
+ return jsonify({"error": "mode must be 'allow' or 'deny'"}), 400
+ if field == "pattern":
+ err = validate_pattern(data[field])
+ if err:
+ return jsonify({"error": "Invalid regex pattern"}), 400
+ updates.append(f"{col} = %s")
+ params.append(data[field])
+
+ if not updates:
+ return jsonify({"error": "No fields to update"}), 400
+
+ updates.append("updated_at = %s")
+ params.append(datetime.utcnow())
+ updates.append("updated_by = %s")
+ params.append(user_id)
+ params.extend([rule_id, org_id])
+
+ from utils.db.connection_pool import db_pool
+ with db_pool.get_admin_connection() as conn:
+ with conn.cursor() as cur:
+ cur.execute(
+ f"UPDATE org_command_policies SET {', '.join(updates)} "
+ "WHERE id = %s AND org_id = %s",
+ params,
+ )
+ if cur.rowcount == 0:
+ return jsonify({"error": "Rule not found"}), 404
+ conn.commit()
+
+ invalidate_cache(org_id)
+ store_user_preference(f"__org__{org_id}", "command_policy_active_template", None)
+ return jsonify({"status": "updated"})
+
+
+@command_policies_bp.route("/command-policies/", methods=["DELETE"])
+@require_permission("admin", "access")
+def delete_policy(user_id, rule_id):
+ org_id = get_org_id_from_request()
+ if not org_id:
+ return jsonify({"error": "No organization context"}), 403
+
+ from utils.db.connection_pool import db_pool
+ with db_pool.get_admin_connection() as conn:
+ with conn.cursor() as cur:
+ cur.execute(
+ "DELETE FROM org_command_policies WHERE id = %s AND org_id = %s",
+ (rule_id, org_id),
+ )
+ if cur.rowcount == 0:
+ return jsonify({"error": "Rule not found"}), 404
+ conn.commit()
+
+ invalidate_cache(org_id)
+ store_user_preference(f"__org__{org_id}", "command_policy_active_template", None)
+ return jsonify({"status": "deleted"})
+
+
+@command_policies_bp.route("/command-policies/test", methods=["POST", "OPTIONS"])
+@require_auth_only
+def test_command(user_id):
+ if request.method == "OPTIONS":
+ return jsonify({}), 200
+
+ org_id = get_org_id_from_request()
+ if not org_id:
+ return jsonify({"error": "No organization context"}), 403
+
+ data = request.get_json() or {}
+ command = data.get("command", "").strip()
+ if not command:
+ return jsonify({"error": "command is required"}), 400
+
+ verdict = evaluate_compound_command(org_id, command)
+ return jsonify({
+ "allowed": verdict.allowed,
+ "rule_description": verdict.rule_description,
+ "command": command,
+ })
+
+
+@command_policies_bp.route("/command-policy-toggle", methods=["PUT", "OPTIONS"])
+@require_permission("admin", "access")
+def toggle_list(user_id):
+ if request.method == "OPTIONS":
+ return jsonify({}), 200
+
+ org_id = get_org_id_from_request()
+ if not org_id:
+ return jsonify({"error": "No organization context"}), 403
+
+ data = request.get_json() or {}
+ list_name = data.get("list")
+ enabled = data.get("enabled")
+
+ if list_name not in ("allowlist", "denylist"):
+ return jsonify({"error": "list must be 'allowlist' or 'denylist'"}), 400
+ if not isinstance(enabled, bool):
+ return jsonify({"error": "enabled must be a boolean"}), 400
+
+ pref_key = f"command_policy_{list_name}"
+ org_key = f"__org__{org_id}"
+ store_user_preference(org_key, pref_key, "on" if enabled else "off")
+
+ # Auto-seed rules on first enable if list is empty
+ if enabled:
+ mode = "allow" if list_name == "allowlist" else "deny"
+ from utils.db.connection_pool import db_pool
+ with db_pool.get_admin_connection() as conn:
+ with conn.cursor() as cur:
+ cur.execute(
+ "SELECT COUNT(*) FROM org_command_policies "
+ "WHERE org_id = %s AND mode = %s",
+ (org_id, mode),
+ )
+ count = cur.fetchone()[0]
+
+ if count == 0:
+ seeds = get_seed_rules().get(mode, [])
+ for seed in seeds:
+ cur.execute(
+ "INSERT INTO org_command_policies "
+ "(org_id, mode, pattern, description, priority, updated_by) "
+ "VALUES (%s, %s, %s, %s, %s, %s)",
+ (org_id, mode, seed["pattern"],
+ seed["description"], seed["priority"], user_id),
+ )
+ conn.commit()
+
+ invalidate_cache(org_id)
+ return jsonify({"status": "updated", **_list_states(org_id)})
+
+
+# ---------------------------------------------------------------------------
+# Template library endpoints
+# ---------------------------------------------------------------------------
+
+@command_policies_bp.route("/command-policy-templates", methods=["GET", "OPTIONS"])
+@require_auth_only
+def list_templates(user_id):
+ if request.method == "OPTIONS":
+ return jsonify({}), 200
+
+ templates = get_policy_templates()
+ result = []
+ for tpl in templates:
+ result.append({
+ "id": tpl["id"],
+ "name": tpl["name"],
+ "description": tpl["description"],
+ "allow_count": len(tpl["allow"]),
+ "deny_count": len(tpl["deny"]),
+ "allow": tpl["allow"],
+ "deny": tpl["deny"],
+ })
+ return jsonify(result)
+
+
+@command_policies_bp.route("/command-policy-templates/apply", methods=["POST", "OPTIONS"])
+@require_permission("admin", "access")
+def apply_template(user_id):
+ if request.method == "OPTIONS":
+ return jsonify({}), 200
+
+ org_id = get_org_id_from_request()
+ if not org_id:
+ return jsonify({"error": "No organization context"}), 403
+
+ data = request.get_json() or {}
+ template_id = data.get("template_id")
+ if not template_id:
+ return jsonify({"error": "template_id is required"}), 400
+
+ templates = {t["id"]: t for t in get_policy_templates()}
+ tpl = templates.get(template_id)
+ if not tpl:
+ return jsonify({"error": f"Unknown template: {template_id}"}), 400
+
+ org_key = f"__org__{org_id}"
+ pref_upsert = (
+ "INSERT INTO user_preferences (user_id, org_id, preference_key, preference_value) "
+ "VALUES (%s, %s, %s, %s) "
+ "ON CONFLICT (user_id, org_id, preference_key) WHERE org_id IS NOT NULL DO UPDATE "
+ "SET preference_value = EXCLUDED.preference_value, updated_at = CURRENT_TIMESTAMP"
+ )
+ from utils.db.connection_pool import db_pool
+ with db_pool.get_admin_connection() as conn:
+ with conn.cursor() as cur:
+ cur.execute(
+ "DELETE FROM org_command_policies WHERE org_id = %s AND source = 'template'",
+ (org_id,),
+ )
+ for mode_key in ("allow", "deny"):
+ for rule in tpl[mode_key]:
+ cur.execute(
+ "INSERT INTO org_command_policies "
+ "(org_id, mode, pattern, description, priority, updated_by, source) "
+ "VALUES (%s, %s, %s, %s, %s, %s, 'template')",
+ (org_id, mode_key, rule["pattern"],
+ rule["description"], rule["priority"], user_id),
+ )
+ cur.execute(pref_upsert, (org_key, org_id, "command_policy_allowlist", json.dumps("on")))
+ cur.execute(pref_upsert, (org_key, org_id, "command_policy_denylist", json.dumps("on")))
+ cur.execute(pref_upsert, (org_key, org_id, "command_policy_active_template", json.dumps(template_id)))
+ conn.commit()
+
+ invalidate_cache(org_id)
+ return jsonify({"status": "applied", "template_id": template_id, **_list_states(org_id)})
+
+
+@command_policies_bp.route("/command-policy-templates/active", methods=["DELETE", "OPTIONS"])
+@require_permission("admin", "access")
+def clear_active_template(user_id):
+ if request.method == "OPTIONS":
+ return jsonify({}), 200
+
+ org_id = get_org_id_from_request()
+ if not org_id:
+ return jsonify({"error": "No organization context"}), 403
+
+ org_key = f"__org__{org_id}"
+ pref_upsert = (
+ "INSERT INTO user_preferences (user_id, org_id, preference_key, preference_value) "
+ "VALUES (%s, %s, %s, %s) "
+ "ON CONFLICT (user_id, org_id, preference_key) WHERE org_id IS NOT NULL DO UPDATE "
+ "SET preference_value = EXCLUDED.preference_value, updated_at = CURRENT_TIMESTAMP"
+ )
+ from utils.db.connection_pool import db_pool
+ with db_pool.get_admin_connection() as conn:
+ with conn.cursor() as cur:
+ cur.execute("DELETE FROM org_command_policies WHERE org_id = %s AND source = 'template'", (org_id,))
+ cur.execute(pref_upsert, (org_key, org_id, "command_policy_active_template", json.dumps(None)))
+ conn.commit()
+
+ invalidate_cache(org_id)
+ return jsonify({"status": "cleared", **_list_states(org_id)})
diff --git a/server/utils/auth/command_policy.py b/server/utils/auth/command_policy.py
new file mode 100644
index 000000000..210a0c918
--- /dev/null
+++ b/server/utils/auth/command_policy.py
@@ -0,0 +1,590 @@
+"""Org-level command policy engine (allowlist / denylist).
+
+Evaluates every command before execution against org-configured regex rules.
+Two independent lists, each independently togglable:
+
+ 1. Denylist (if enabled) - checked first. Match -> DENIED.
+ 2. Allowlist (if enabled) - checked second. Match -> ALLOWED, no match -> DENIED.
+ 3. Both off -> ALLOWED.
+
+Compound shell expressions (;, &&, ||, |, subshells) are decomposed and each
+atomic command is evaluated independently. One denied sub-command blocks the
+entire expression.
+
+Default for new orgs: both lists OFF (no enforcement until configured).
+Fail-open on DB error: if rules cannot be fetched, commands are allowed.
+"""
+
+import logging
+import re
+import time
+from dataclasses import dataclass, field
+from typing import Dict, List, Optional, Tuple
+
+logger = logging.getLogger(__name__)
+
+_CACHE_TTL = 30 # seconds
+
+_CacheEntry = Tuple[
+ List["PolicyRule"], # allow rules
+ List["PolicyRule"], # deny rules
+ "ListStates",
+ float, # monotonic timestamp
+]
+_cache: Dict[str, _CacheEntry] = {}
+
+
+@dataclass(frozen=True)
+class CommandVerdict:
+ allowed: bool
+ rule_description: Optional[str] = None
+
+
+@dataclass(frozen=True)
+class PolicyRule:
+ id: int
+ mode: str
+ pattern: str
+ description: str
+ priority: int
+ compiled: re.Pattern = field(repr=False)
+
+
+@dataclass(frozen=True)
+class ListStates:
+ allowlist_enabled: bool
+ denylist_enabled: bool
+
+
+def _compile_safe(pattern: str) -> Optional[re.Pattern]:
+ try:
+ return re.compile(pattern)
+ except re.error:
+ logger.warning("Invalid regex in policy rule, skipping: %s", pattern)
+ return None
+
+
+def _fetch(org_id: str) -> Tuple[List[PolicyRule], List[PolicyRule], ListStates]:
+ """Load policy rules and list states for *org_id*."""
+ allow_rules: List[PolicyRule] = []
+ deny_rules: List[PolicyRule] = []
+ states = ListStates(allowlist_enabled=False, denylist_enabled=False)
+
+ try:
+ from utils.db.connection_pool import db_pool
+ from utils.auth.stateless_auth import get_user_preference
+
+ with db_pool.get_admin_connection() as conn:
+ with conn.cursor() as cur:
+ cur.execute(
+ "SELECT id, mode, pattern, description, priority "
+ "FROM org_command_policies "
+ "WHERE org_id = %s AND enabled = true "
+ "ORDER BY priority DESC",
+ (org_id,),
+ )
+ for row in cur.fetchall():
+ compiled = _compile_safe(row[2])
+ if compiled is None:
+ continue
+ rule = PolicyRule(
+ id=row[0], mode=row[1], pattern=row[2],
+ description=row[3] or "", priority=row[4],
+ compiled=compiled,
+ )
+ if rule.mode == "allow":
+ allow_rules.append(rule)
+ else:
+ deny_rules.append(rule)
+
+ org_pref_key = f"__org__{org_id}"
+ al_raw = get_user_preference(org_pref_key, "command_policy_allowlist") or "off"
+ dl_raw = get_user_preference(org_pref_key, "command_policy_denylist") or "off"
+ states = ListStates(
+ allowlist_enabled=(str(al_raw).lower() == "on"),
+ denylist_enabled=(str(dl_raw).lower() == "on"),
+ )
+ except Exception:
+ logger.exception("Failed to fetch command policies for org %s, fail-open", org_id)
+
+ return allow_rules, deny_rules, states
+
+
+def _get_cached(org_id: str) -> Tuple[List[PolicyRule], List[PolicyRule], ListStates]:
+ entry = _cache.get(org_id)
+ if entry is not None:
+ allow, deny, states, ts = entry
+ if time.monotonic() - ts < _CACHE_TTL:
+ return allow, deny, states
+
+ allow, deny, states = _fetch(org_id)
+ _cache[org_id] = (allow, deny, states, time.monotonic())
+ return allow, deny, states
+
+
+def evaluate_command(org_id: Optional[str], command: str) -> CommandVerdict:
+ """Core gate. Returns whether *command* is allowed for *org_id*."""
+ if not org_id:
+ logger.info("policy_check_skipped reason=no_org_context func=evaluate_command")
+ return CommandVerdict(allowed=True)
+
+ allow_rules, deny_rules, states = _get_cached(org_id)
+
+ if not states.denylist_enabled and not states.allowlist_enabled:
+ return CommandVerdict(allowed=True, rule_description="Policy lists are disabled")
+
+ if states.denylist_enabled:
+ for rule in deny_rules:
+ if rule.compiled.search(command):
+ return CommandVerdict(allowed=False, rule_description=rule.description)
+
+ if states.allowlist_enabled:
+ for rule in allow_rules:
+ if rule.compiled.search(command):
+ return CommandVerdict(allowed=True, rule_description=rule.description)
+ return CommandVerdict(allowed=False, rule_description="No matching allow rule")
+
+ return CommandVerdict(allowed=True)
+
+
+_UNSPLITTABLE_SHELL_RE = re.compile(r"<<-?\s*\w+|<\(|>\(")
+
+
+def _split_compound_command(compound: str) -> List[str]:
+ """Quote-aware split of a shell expression into atomic commands.
+
+ Splits on ; && || | while respecting single/double quotes and backslash
+ escapes. Recursively extracts commands from $(...) and backtick subshells
+ so they are evaluated independently.
+
+ Falls back to evaluating the full string when heredocs or process
+ substitution are detected, since these constructs hide arbitrary content
+ from a naive splitter.
+ """
+ if _UNSPLITTABLE_SHELL_RE.search(compound):
+ return [compound]
+
+ commands: List[str] = []
+ buf: List[str] = []
+ sq = dq = False
+ i, n = 0, len(compound)
+
+ def _flush() -> None:
+ s = "".join(buf).strip()
+ if s:
+ commands.append(s)
+ buf.clear()
+
+ while i < n:
+ c = compound[i]
+
+ # Backslash escape (not inside single quotes)
+ if c == "\\" and not sq and i + 1 < n:
+ buf += [c, compound[i + 1]]
+ i += 2
+ continue
+
+ # Quote toggling
+ if c == "'" and not dq:
+ sq = not sq
+ buf.append(c)
+ i += 1
+ continue
+ if c == '"' and not sq:
+ dq = not dq
+ buf.append(c)
+ i += 1
+ continue
+
+ # Everything inside quotes is literal
+ if sq or dq:
+ buf.append(c)
+ i += 1
+ continue
+
+ # -- Outside quotes: detect operators and subshells --
+ two = compound[i : i + 2]
+
+ if two in ("&&", "||"):
+ _flush()
+ i += 2
+ continue
+
+ if c in (";", "|"):
+ _flush()
+ i += 1
+ continue
+
+ # $(...) subshell - extract inner commands for separate evaluation
+ if c == "$" and i + 1 < n and compound[i + 1] == "(":
+ j, depth = i + 2, 1
+ while j < n and depth:
+ if compound[j] == "(":
+ depth += 1
+ elif compound[j] == ")":
+ depth -= 1
+ j += 1
+ commands.extend(_split_compound_command(compound[i + 2 : j - 1]))
+ buf.append(compound[i:j])
+ i = j
+ continue
+
+ # Backtick subshell
+ if c == "`":
+ j = compound.find("`", i + 1)
+ if j != -1:
+ commands.extend(_split_compound_command(compound[i + 1 : j]))
+ buf.append(compound[i : j + 1])
+ i = j + 1
+ continue
+
+ buf.append(c)
+ i += 1
+
+ _flush()
+ return commands
+
+
+def evaluate_compound_command(
+ org_id: Optional[str], command: str
+) -> CommandVerdict:
+ """Evaluate a potentially compound shell command.
+
+ Decomposes the expression into atomic commands and evaluates each one
+ independently. ALL sub-commands must pass policy; the first denial is
+ returned immediately.
+ """
+ if not org_id:
+ logger.info("policy_check_skipped reason=no_org_context func=evaluate_compound_command")
+ return CommandVerdict(allowed=True)
+
+ parts = _split_compound_command(command)
+ if not parts:
+ return evaluate_command(org_id, command)
+
+ last_verdict = CommandVerdict(allowed=True)
+ for part in parts:
+ verdict = evaluate_command(org_id, part)
+ if not verdict.allowed:
+ return verdict
+ last_verdict = verdict
+
+ return last_verdict
+
+
+def validate_pattern(pattern: str) -> Optional[str]:
+ """Return an error string if *pattern* is not valid regex, else None."""
+ try:
+ re.compile(pattern)
+ return None
+ except re.error as exc:
+ return str(exc)
+
+
+def get_policy_prompt_text(org_id: str) -> str:
+ """Render active policy as system-prompt text for the LLM."""
+ allow_rules, deny_rules, states = _get_cached(org_id)
+
+ if not states.denylist_enabled and not states.allowlist_enabled:
+ return ""
+
+ lines = [
+ "## Organization Command Policy",
+ "The following command policy is enforced. Commands violating this policy " +
+ "will be rejected at execution time. Do not attempt blocked commands.",
+ "",
+ ]
+
+ if states.denylist_enabled and deny_rules:
+ lines.append("DENIED commands (never run these):")
+ for r in deny_rules:
+ lines.append(f" - {r.description} (pattern: {r.pattern})")
+ lines.append("")
+
+ if states.allowlist_enabled and allow_rules:
+ lines.append("ALLOWED commands (only these are permitted):")
+ for r in allow_rules:
+ lines.append(f" - {r.description} (pattern: {r.pattern})")
+ lines.append("")
+
+ return "\n".join(lines)
+
+
+def get_seed_rules() -> Dict[str, list]:
+ """Default seed templates, inserted on first enable of each list.
+
+ Returns the 'observability_only' template for backward compatibility.
+ """
+ tpl = get_policy_templates()[0]
+ return {"allow": tpl["allow"], "deny": tpl["deny"]}
+
+
+# ---------------------------------------------------------------------------
+# Deny rules shared across ALL templates (dangerous regardless of access level)
+# ---------------------------------------------------------------------------
+_UNIVERSAL_DENY_RULES: list = [
+ {"priority": 100, "pattern": r"\brm\s+-rf\s+/",
+ "description": "Recursive root deletion"},
+ {"priority": 95, "pattern": r"\b(gcc|g\+\+|cc|make|as|ld)\b",
+ "description": "Native code compilation"},
+ {"priority": 92, "pattern": r"(? List[dict]:
+ """Return the library of pre-built policy templates.
+
+ Each template is a dict with keys: id, name, description, allow, deny.
+ Templates are ordered from most restrictive to most permissive.
+ """
+ return [
+ # -- 1. Observability Only ----------------------------------------
+ {
+ "id": "observability_only",
+ "name": "Observability Only",
+ "description": (
+ "Read-only access aligned with cloud provider read-only "
+ "credentials (AWS ReadOnlyAccess session policy, GCP "
+ "roles/viewer, Azure Reader). Blocks all write, SSH, and "
+ "interactive operations."
+ ),
+ "allow": [
+ # Filesystem inspection
+ {"priority": 200, "pattern": r"^(ls|cat|head|tail|wc|grep|find|stat|file|du|df|sort|uniq|awk|sed|tr|cut|tee|less|more|xargs|realpath|readlink|basename|dirname)\b",
+ "description": "Read-only filesystem inspection"},
+ # Kubernetes read-only
+ {"priority": 190, "pattern": r"^(kubectl|oc)\s+(get|describe|logs|top|explain|api-resources|api-versions|cluster-info|config\s+(view|get-contexts|current-context|use-context))\b",
+ "description": "Read-only Kubernetes queries"},
+ # AWS CLI -- matches all read-only verbs and subcommands that the session policy permits
+ {"priority": 180, "pattern": r"^aws\s+\S+\s+(ls|list|describe[-\w]*|get[-\w]*|show[-\w]*|head[-\w]*|filter[-\w]*|start-query|stop-query|test-metric-filter|update-kubeconfig|wait)\b",
+ "description": "AWS read-only operations (EC2, EKS, S3, RDS, Lambda, IAM, CloudWatch, Logs, ECS, CloudFormation)"},
+ {"priority": 179, "pattern": r"^aws\s+s3\s+(ls|cp\s+s3://|presign|sync\s+s3://)",
+ "description": "AWS S3 read operations (ls, download, presign)"},
+ {"priority": 178, "pattern": r"^aws\s+sts\s+get-caller-identity\b",
+ "description": "AWS STS identity check"},
+ {"priority": 177, "pattern": r"^aws\s+logs\s+(describe-log-groups|describe-log-streams|get-log-events|get-query-results|filter-log-events|start-query|stop-query|tail)\b",
+ "description": "AWS CloudWatch Logs read operations"},
+ # GCP / gcloud -- roles/viewer, logging.viewer, monitoring.viewer, container.viewer, storage.objectViewer
+ {"priority": 170, "pattern": r"^gcloud\s+.+\b(list|describe|get|show|read|get-credentials|get-server-config)\b",
+ "description": "GCP read-only operations (Compute, GKE, Cloud SQL, Cloud Run, IAM, DNS)"},
+ {"priority": 169, "pattern": r"^gcloud\s+(logging|monitoring|asset|projects|organizations|config)\s",
+ "description": "GCP logging, monitoring, asset inventory, and config"},
+ {"priority": 168, "pattern": r"^gsutil\s+(ls|cat|stat|du|cp\s+gs://|rsync\s+-n)\b",
+ "description": "GCP Storage read operations (ls, cat, stat, download)"},
+ {"priority": 167, "pattern": r"^bq\s+(ls|show|head|query\s+--dry_run|mk\s+--dry_run)\b",
+ "description": "BigQuery read-only operations"},
+ # Azure -- Reader role + Log Analytics Reader + Monitoring Reader
+ {"priority": 160, "pattern": r"^az\s+.+\b(list|show|get|describe|display|query|download)\b",
+ "description": "Azure read-only operations (VMs, AKS, Storage, SQL, Key Vault, NSGs)"},
+ {"priority": 159, "pattern": r"^az\s+(monitor|advisor|security|consumption|costmanagement|account)\s",
+ "description": "Azure monitoring, cost, security, and account queries"},
+ {"priority": 158, "pattern": r"^az\s+aks\s+get-credentials\b",
+ "description": "Azure AKS kubeconfig retrieval"},
+ # OVH / Scaleway
+ {"priority": 150, "pattern": r"^ovhcloud\s+.+\b(list|show|get|describe)\b",
+ "description": "OVH read-only operations"},
+ {"priority": 149, "pattern": r"^scw\s+.+\b(list|get|describe|inspect)\b",
+ "description": "Scaleway read-only operations"},
+ # Tailscale
+ {"priority": 140, "pattern": r"^tailscale\s+(status|device\s+(list|get)|dns|acl\s+(get|show)|routes|settings|auth-key\s+list)\b",
+ "description": "Tailscale read-only operations"},
+ # Terraform / IaC read-only
+ {"priority": 130, "pattern": r"^(terraform|tofu)\s+(init|plan|validate|fmt|output|show|state\s+(list|show|pull)|version)\b",
+ "description": "Non-destructive Terraform operations"},
+ {"priority": 129, "pattern": r"^(helm)\s+(list|get|show|status|history|search|version)\b",
+ "description": "Helm read-only operations"},
+ # Network diagnostics
+ {"priority": 120, "pattern": r"^(ping|dig|nslookup|traceroute|tracepath|mtr|curl|wget|host|whois|nmap)\b",
+ "description": "Network diagnostics"},
+ # Git read-only
+ {"priority": 110, "pattern": r"^git\s+(status|log|diff|show|branch|tag|remote|stash\s+list|rev-parse|config\s+--get|ls-files|ls-remote|blame|shortlog)\b",
+ "description": "Read-only git operations"},
+ # Docker/container inspection
+ {"priority": 100, "pattern": r"^(docker|podman)\s+(ps|images|inspect|logs|stats|top|port|diff|history|version|info|network\s+(ls|inspect)|volume\s+(ls|inspect))\b",
+ "description": "Container inspection (read-only)"},
+ # System diagnostics
+ {"priority": 90, "pattern": r"^(uptime|whoami|hostname|uname|env|printenv|id|date|cal|free|vmstat|iostat|mpstat|sar|lsof|ss|netstat|ps|top|htop|lscpu|lsmem|lsblk|mount|dmesg|journalctl|systemctl\s+(status|is-active|is-enabled|list-units|list-timers))\b",
+ "description": "System diagnostics and status"},
+ # Process / text utilities
+ {"priority": 80, "pattern": r"^(jq|yq|column|printf|echo|test|true|false|which|type|command|whereis|file|xxd|hexdump|sha256sum|md5sum|base64)\b",
+ "description": "Text processing and utility commands"},
+ ],
+ "deny": [
+ *_UNIVERSAL_DENY_RULES,
+ {"priority": 50, "pattern": r"\bkubectl\s+(exec|cp|run|attach|port-forward|apply|delete|create|edit|patch|replace|scale|rollout|drain|cordon|uncordon|taint)\b",
+ "description": "Mutating kubectl operations"},
+ {"priority": 48, "pattern": r"^(ssh|scp|sftp)\s",
+ "description": "SSH access (use Standard Operations template to enable)"},
+ ],
+ },
+
+ # -- 2. Standard Operations ----------------------------------------
+ {
+ "id": "standard_ops",
+ "name": "Standard Operations",
+ "description": (
+ "Allows SSH, kubectl exec, cloud CLI config commands, and "
+ "container inspection on top of full read-only access. "
+ "Suitable for incident response and debugging. Still blocks "
+ "infrastructure mutations and dangerous patterns. "
+ "Note: kubectl run is allowed for ad-hoc pods; image constraints "
+ "should be enforced by cluster admission controllers."
+ ),
+ "allow": [
+ # Filesystem
+ {"priority": 200, "pattern": r"^(ls|cat|head|tail|wc|grep|find|stat|file|du|df|sort|uniq|awk|sed|tr|cut|tee|less|more|xargs|realpath|readlink|basename|dirname)\b",
+ "description": "Filesystem inspection"},
+ # Kubernetes read + interactive
+ {"priority": 190, "pattern": r"^(kubectl|oc)\s+(get|describe|logs|top|explain|api-resources|api-versions|cluster-info|config\s+(view|get-contexts|current-context|use-context)|exec|cp|attach|port-forward|run\s+.*--rm\b.*--restart=Never)\b",
+ "description": "Kubernetes read and interactive debug operations"},
+ # AWS full read + config
+ {"priority": 180, "pattern": r"^aws\s+\S+\s+(ls|list|describe[-\w]*|get[-\w]*|show[-\w]*|head[-\w]*|filter[-\w]*|start-query|stop-query|test-metric-filter|update-kubeconfig|configure|wait)\b",
+ "description": "AWS read and config operations"},
+ {"priority": 179, "pattern": r"^aws\s+s3\s+(ls|cp\s+s3://|presign|sync\s+s3://)",
+ "description": "AWS S3 read operations"},
+ {"priority": 178, "pattern": r"^aws\s+sts\s+(get-caller-identity|assume-role|get-session-token)\b",
+ "description": "AWS STS operations"},
+ {"priority": 177, "pattern": r"^aws\s+logs\s+(describe-log-groups|describe-log-streams|get-log-events|get-query-results|filter-log-events|start-query|stop-query|tail)\b",
+ "description": "AWS CloudWatch Logs operations"},
+ # GCP full read + credentials
+ {"priority": 170, "pattern": r"^gcloud\s+.+\b(list|describe|get|show|read|get-credentials|get-server-config)\b",
+ "description": "GCP read and credential operations"},
+ {"priority": 169, "pattern": r"^gcloud\s+(logging|monitoring|asset|projects|organizations|config|auth)\s",
+ "description": "GCP logging, monitoring, asset inventory, auth, and config"},
+ {"priority": 168, "pattern": r"^gsutil\s+(ls|cat|stat|du|cp\s+gs://|rsync\s+-n)\b",
+ "description": "GCP Storage read operations"},
+ {"priority": 167, "pattern": r"^bq\s+(ls|show|head|query|mk\s+--dry_run)\b",
+ "description": "BigQuery operations"},
+ # Azure full read + credentials
+ {"priority": 160, "pattern": r"^az\s+.+\b(list|show|get|describe|display|query|download|browse)\b",
+ "description": "Azure read operations"},
+ {"priority": 159, "pattern": r"^az\s+(monitor|advisor|security|consumption|costmanagement|account|aks\s+get-credentials)\s",
+ "description": "Azure monitoring, cost, security, and AKS credentials"},
+ # OVH / Scaleway
+ {"priority": 150, "pattern": r"^ovhcloud\s+.+\b(list|show|get|describe)\b",
+ "description": "OVH read-only operations"},
+ {"priority": 149, "pattern": r"^scw\s+.+\b(list|get|describe|inspect)\b",
+ "description": "Scaleway read-only operations"},
+ # Tailscale
+ {"priority": 140, "pattern": r"^tailscale\s+(status|device\s+(list|get)|dns|acl\s+(get|show)|routes|settings|auth-key\s+list)\b",
+ "description": "Tailscale read-only operations"},
+ # SSH access
+ {"priority": 135, "pattern": r"^(ssh|scp|sftp)\s",
+ "description": "SSH, SCP, and SFTP access"},
+ # Terraform / IaC read-only
+ {"priority": 130, "pattern": r"^(terraform|tofu)\s+(init|plan|validate|fmt|output|show|state\s+(list|show|pull)|version)\b",
+ "description": "Non-destructive Terraform operations"},
+ {"priority": 129, "pattern": r"^(helm)\s+(list|get|show|status|history|search|version|template)\b",
+ "description": "Helm read-only operations"},
+ # Network diagnostics
+ {"priority": 120, "pattern": r"^(ping|dig|nslookup|traceroute|tracepath|mtr|curl|wget|host|whois|nmap)\b",
+ "description": "Network diagnostics"},
+ # Git read-only
+ {"priority": 110, "pattern": r"^git\s+(status|log|diff|show|branch|tag|remote|stash\s+list|rev-parse|config\s+--get|ls-files|ls-remote|blame|shortlog)\b",
+ "description": "Read-only git operations"},
+ # Docker/container inspection + exec
+ {"priority": 100, "pattern": r"^(docker|podman)\s+(ps|images|inspect|logs|stats|top|port|diff|history|version|info|exec|network\s+(ls|inspect)|volume\s+(ls|inspect))\b",
+ "description": "Container inspection and exec"},
+ # System diagnostics
+ {"priority": 90, "pattern": r"^(uptime|whoami|hostname|uname|env|printenv|id|date|cal|free|vmstat|iostat|mpstat|sar|lsof|ss|netstat|ps|top|htop|lscpu|lsmem|lsblk|mount|dmesg|journalctl|systemctl\s+(status|is-active|is-enabled|list-units|list-timers))\b",
+ "description": "System diagnostics and status"},
+ # Text utilities
+ {"priority": 80, "pattern": r"^(jq|yq|column|printf|echo|test|true|false|which|type|command|whereis|file|xxd|hexdump|sha256sum|md5sum|base64)\b",
+ "description": "Text processing and utility commands"},
+ ],
+ "deny": [
+ *_UNIVERSAL_DENY_RULES,
+ {"priority": 50, "pattern": r"\bkubectl\s+(apply|delete|create|edit|patch|replace|scale|rollout|drain|cordon|uncordon|taint)\b",
+ "description": "Mutating kubectl operations (use Full Cloud Access to enable)"},
+ ],
+ },
+
+ # -- 3. Full Cloud Access ------------------------------------------
+ {
+ "id": "full_cloud_access",
+ "name": "Full Cloud Access",
+ "description": (
+ "Broad command access for orgs with admin-level cloud "
+ "credentials. Allows cloud write operations, Terraform "
+ "apply, kubectl mutations, SSH, and Docker management. "
+ "Only blocks universally dangerous patterns."
+ ),
+ "allow": [
+ # Filesystem -- broad
+ {"priority": 200, "pattern": r"^(ls|cat|head|tail|wc|grep|find|stat|file|du|df|sort|uniq|awk|sed|tr|cut|tee|less|more|xargs|realpath|readlink|basename|dirname|mkdir|cp|mv|touch|ln|chmod|chown|rm)\b",
+ "description": "Filesystem operations"},
+ # Kubernetes -- full
+ {"priority": 190, "pattern": r"^(kubectl|oc)\s+\w",
+ "description": "All kubectl/oc operations"},
+ # AWS -- broad
+ {"priority": 180, "pattern": r"^aws\s+\w",
+ "description": "All AWS CLI operations"},
+ # GCP -- broad
+ {"priority": 170, "pattern": r"^(gcloud|gsutil|bq)\s+\w",
+ "description": "All GCP CLI operations"},
+ # Azure -- broad
+ {"priority": 160, "pattern": r"^az\s+\w",
+ "description": "All Azure CLI operations"},
+ # OVH / Scaleway -- broad
+ {"priority": 150, "pattern": r"^(ovhcloud|scw)\s+\w",
+ "description": "All OVH and Scaleway CLI operations"},
+ # Tailscale
+ {"priority": 140, "pattern": r"^tailscale\s+\w",
+ "description": "All Tailscale operations"},
+ # SSH
+ {"priority": 135, "pattern": r"^(ssh|scp|sftp)\s",
+ "description": "SSH, SCP, and SFTP access"},
+ # Terraform -- full
+ {"priority": 130, "pattern": r"^(terraform|tofu|pulumi)\s+\w",
+ "description": "All Terraform/Tofu/Pulumi operations"},
+ {"priority": 129, "pattern": r"^(helm|helmfile)\s+\w",
+ "description": "All Helm operations"},
+ {"priority": 128, "pattern": r"^(ansible|ansible-playbook|ansible-galaxy)\s",
+ "description": "All Ansible operations"},
+ # Network diagnostics
+ {"priority": 120, "pattern": r"^(ping|dig|nslookup|traceroute|tracepath|mtr|curl|wget|host|whois|nmap)\b",
+ "description": "Network diagnostics"},
+ # Git -- full
+ {"priority": 110, "pattern": r"^git\s+\w",
+ "description": "All git operations"},
+ # Docker/container -- full
+ {"priority": 100, "pattern": r"^(docker|podman|docker-compose|ctr|crictl)\s+\w",
+ "description": "All container operations"},
+ # System diagnostics + management
+ {"priority": 90, "pattern": r"^(uptime|whoami|hostname|uname|env|printenv|id|date|cal|free|vmstat|iostat|mpstat|sar|lsof|ss|netstat|ps|top|htop|lscpu|lsmem|lsblk|mount|dmesg|journalctl|systemctl)\b",
+ "description": "System diagnostics and service management"},
+ # Text/utility
+ {"priority": 80, "pattern": r"^(jq|yq|column|printf|echo|test|true|false|which|type|command|whereis|file|xxd|hexdump|sha256sum|md5sum|base64|tar|gzip|gunzip|zip|unzip|xz)\b",
+ "description": "Text processing and archive utilities"},
+ # Package managers (read)
+ {"priority": 70, "pattern": r"^(pip|npm|yarn|go|cargo|apt|yum|dnf|brew)\s+(list|show|info|search|outdated|version|--version)\b",
+ "description": "Package manager queries"},
+ ],
+ "deny": list(_UNIVERSAL_DENY_RULES),
+ },
+ ]
+
+
+def invalidate_cache(org_id: str) -> None:
+ _cache.pop(org_id, None)
diff --git a/server/utils/auth/stateless_auth.py b/server/utils/auth/stateless_auth.py
index 6d8711ff3..d4d58334e 100644
--- a/server/utils/auth/stateless_auth.py
+++ b/server/utils/auth/stateless_auth.py
@@ -360,7 +360,7 @@ def store_user_preference(user_id: str, key: str, value: Any):
cursor.execute("""
INSERT INTO user_preferences (user_id, org_id, preference_key, preference_value)
VALUES (%s, %s, %s, %s)
- ON CONFLICT (user_id, org_id, preference_key) DO UPDATE SET
+ ON CONFLICT (user_id, org_id, preference_key) WHERE org_id IS NOT NULL DO UPDATE SET
preference_value = EXCLUDED.preference_value,
updated_at = CURRENT_TIMESTAMP
""", (user_id, org_id, key, json.dumps(value)))
diff --git a/server/utils/db/db_utils.py b/server/utils/db/db_utils.py
index 1dc3cbc90..bf57f8618 100644
--- a/server/utils/db/db_utils.py
+++ b/server/utils/db/db_utils.py
@@ -378,6 +378,24 @@ def initialize_tables():
UNIQUE(user_id, org_id, preference_key)
);
""",
+ "org_command_policies": """
+ CREATE TABLE IF NOT EXISTS org_command_policies (
+ id SERIAL PRIMARY KEY,
+ org_id VARCHAR(255) NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+ mode VARCHAR(20) NOT NULL CHECK (mode IN ('allow', 'deny')),
+ pattern TEXT NOT NULL,
+ description TEXT,
+ priority INT DEFAULT 0,
+ enabled BOOLEAN DEFAULT true,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_by VARCHAR(255),
+ source VARCHAR(20) DEFAULT 'custom' NOT NULL,
+ UNIQUE(org_id, mode, pattern, source)
+ );
+ CREATE INDEX IF NOT EXISTS idx_ocp_org
+ ON org_command_policies(org_id);
+ """,
"workspaces": """
CREATE TABLE IF NOT EXISTS workspaces (
id VARCHAR(50) PRIMARY KEY,
@@ -1173,6 +1191,7 @@ def initialize_tables():
rls_tables.append("incident_lifecycle_events")
rls_tables.append("github_connected_repos")
rls_tables.append("execution_steps")
+ rls_tables.append("org_command_policies")
# Migration: Add rca_celery_task_id column to incidents table if it doesn't exist