Skip to content
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
3524e7c
feat: add centralized tool permissions for background action gate bypass
damianloch May 8, 2026
ee48302
feat: add Tool Permissions UI card to SecuritySettings
damianloch May 8, 2026
1f14e0c
fix: add missing seed proxy route and empty state fallback
damianloch May 8, 2026
90d59f4
edit ui
damianloch May 9, 2026
d5211c4
add missing gatings
damianloch May 9, 2026
ef26b35
fix: docker.sock mount, spinnaker bypass, wildcard permissions, UI po…
damianloch May 9, 2026
df0c48f
fix: resolve SonarQube code smells
damianloch May 9, 2026
ec39a03
feat: apply tool permissions to all background chats
damianloch May 9, 2026
b5a9c80
fix: restore celery_beat common-env and FRONTEND_URL
damianloch May 9, 2026
7f2037f
revert: remove docker.sock mounts from PR
damianloch May 9, 2026
f8b0951
fix: address CodeRabbit review findings
damianloch May 9, 2026
92ff8ba
fix: address SonarQube issues
damianloch May 9, 2026
91d51f6
fix: address remaining CodeRabbit findings on tool_permissions
damianloch May 9, 2026
5b964ac
bypass normal chats too
damianloch May 9, 2026
f717f5e
uodate UI
damianloch May 9, 2026
1a053ed
fix: apply tool permissions to all chats, not just background
damianloch May 9, 2026
ad2a769
fix: address PR review feedback from beng360
damianloch May 9, 2026
653e155
fix: replace empty except blocks with debug logging
damianloch May 9, 2026
f22a2be
fix: version-based cache invalidation, fail closed, update UI copy
damianloch May 9, 2026
894a12f
feat: group tool permissions by risk level in UI
damianloch May 9, 2026
5178438
feat: add tier-level toggles to bulk-enable/disable risk groups
damianloch May 9, 2026
b440ec4
edit grouping of permissions
damianloch May 9, 2026
f19aeae
refactor: explicit GitHub tiers, kill wildcard, address CR feedback
damianloch May 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions client/src/app/api/org/tool-permissions/[toolKey]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { NextRequest } from "next/server";
import { forwardRequest } from "@/lib/backend-proxy";

export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ toolKey: string }> },
) {
const { toolKey } = await params;
return forwardRequest(request, "PUT", `/api/org/tool-permissions/${toolKey}`, "tool-permissions-toggle");
}
6 changes: 6 additions & 0 deletions client/src/app/api/org/tool-permissions/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { forwardRequest } from "@/lib/backend-proxy";

export async function GET(request: NextRequest) {
return forwardRequest(request, "GET", "/api/org/tool-permissions", "tool-permissions");
}
6 changes: 6 additions & 0 deletions client/src/app/api/org/tool-permissions/seed/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { forwardRequest } from "@/lib/backend-proxy";

export async function POST(request: NextRequest) {
return forwardRequest(request, "POST", "/api/org/tool-permissions/seed", "tool-permissions-seed");
}
173 changes: 172 additions & 1 deletion client/src/components/SecuritySettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,19 @@
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";
import { toolPermissionService, type ToolPermission } from "@/lib/services/tool-permissions";
import { getConnectedAccounts, fetchConnectedAccounts, subscribe as subscribeAccounts } from "@/lib/connected-accounts-cache";
import Image from "next/image";

const CONNECTOR_ICONS: Record<string, string> = {
github: "/github-mark.svg",
bitbucket: "/bitbucket.svg",
terraform: "/terraform-icon-svgrepo-com.svg",
notion: "/notion.svg",
spinnaker: "/spinnaker.svg",
};

const ALWAYS_SHOW_CONNECTORS = new Set(["terraform"]);

function RuleList({
rules,
Expand Down Expand Up @@ -282,6 +295,12 @@
const [applyingTemplate, setApplyingTemplate] = useState<string | null>(null);
const [activeTemplateId, setActiveTemplateId] = useState<string | null>(null);

const [toolPerms, setToolPerms] = useState<Record<string, ToolPermission[]>>({});
const [toolPermsLoading, setToolPermsLoading] = useState(true);
const [togglingTools, setTogglingTools] = useState<Set<string>>(new Set());
const [expandedConnectors, setExpandedConnectors] = useState<Set<string>>(new Set());
const [connectedProviders, setConnectedProviders] = useState<Set<string>>(new Set());

const fetchPolicies = useCallback(async () => {
try {
const data = await commandPolicyService.getPolicies();
Expand All @@ -306,7 +325,33 @@
}
}, []);

useEffect(() => { fetchPolicies(); fetchTemplates(); }, [fetchPolicies, fetchTemplates]);
const fetchToolPerms = useCallback(async () => {
try {
let data = await toolPermissionService.getPermissions();
if (!data.seeded) {
await toolPermissionService.seedDefaults();
data = await toolPermissionService.getPermissions();
}
setToolPerms(data.tools_by_connector);
} catch {
// Non-blocking
} finally {
setToolPermsLoading(false);
}
}, []);

useEffect(() => { fetchPolicies(); fetchTemplates(); if (admin) fetchToolPerms(); }, [fetchPolicies, fetchTemplates, fetchToolPerms, admin]);

useEffect(() => {
fetchConnectedAccounts().then(() => {
const { providerIds } = getConnectedAccounts();
setConnectedProviders(new Set(providerIds));
});
return subscribeAccounts(() => {
const { providerIds } = getConnectedAccounts();
setConnectedProviders(new Set(providerIds));
});
}, []);

const handleToggleList = async (list: "allowlist" | "denylist", enabled: boolean) => {
try {
Expand Down Expand Up @@ -388,6 +433,95 @@
}
};

const handleToggleTool = async (toolKey: string, enabled: boolean) => {
if (togglingTools.has(toolKey)) return;
setTogglingTools((prev) => new Set(prev).add(toolKey));
setToolPerms((prev) => {
const next = { ...prev };
for (const connector of Object.keys(next)) {
next[connector] = next[connector].map((t) =>
t.tool_key === toolKey ? { ...t, enabled } : t
);
}
return next;
});
try {
await toolPermissionService.toggleTool(toolKey, enabled);
} catch {
toast({ title: "Failed to update tool permission", variant: "destructive" });
await fetchToolPerms();
} finally {
setTogglingTools((prev) => {
const next = new Set(prev);
next.delete(toolKey);
return next;
});
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};

const toggleConnectorExpanded = (connector: string) => {
setExpandedConnectors((prev) => {
const next = new Set(prev);
next.has(connector) ? next.delete(connector) : next.add(connector);
return next;
});
};

const renderConnectorGroup = (connector: string, tools: ToolPermission[]) => {
const expanded = expandedConnectors.has(connector);
const enabledCount = tools.filter((t) => t.enabled).length;
const riskOrder = ["low", "medium", "high", "critical"];
const grouped = riskOrder
.map((risk) => ({ risk, items: tools.filter((t) => t.risk === risk) }))
.filter(({ items }) => items.length > 0);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
return (
<div key={connector} className="rounded-lg border bg-card overflow-hidden">
<button
type="button"
className="flex items-center justify-between w-full px-3.5 py-2.5 hover:bg-muted/30 transition-colors"
onClick={() => toggleConnectorExpanded(connector)}
>
<div className="flex items-center gap-2">
{expanded ? <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" /> : <ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />}
{CONNECTOR_ICONS[connector] && (
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-white dark:bg-white/10 shrink-0">
<Image src={CONNECTOR_ICONS[connector]} alt={connector} width={16} height={16} className={connector === "github" ? "dark:invert" : ""} />
</div>
)}
<span className="text-sm font-medium capitalize">{connector}</span>
<Badge variant="secondary" className="text-[10px] font-normal">
{enabledCount}/{tools.length}
</Badge>
</div>
</button>
{expanded && (
<div className="border-t">
{grouped.map(({ risk, items }) => (
<div key={risk}>
<div className="px-3.5 py-1.5 bg-muted/40 border-b">
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">{risk} risk</span>
</div>
<div className="divide-y divide-border">
{items.map((tool) => (
<div key={tool.tool_key} className="flex items-center gap-3 px-3.5 py-2 hover:bg-muted/20">
<Switch
checked={tool.enabled}
onCheckedChange={(v) => handleToggleTool(tool.tool_key, v)}

Check failure on line 509 in client/src/components/SecuritySettings.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this code to not nest functions more than 4 levels deep.

See more on https://sonarcloud.io/project/issues?id=Arvo-AI_aurora&issues=AZ4OFEEAQ47wmykSy6G9&open=AZ4OFEEAQ47wmykSy6G9&pullRequest=375
className="shrink-0 scale-90"
disabled={!admin || togglingTools.has(tool.tool_key)}
/>
<span className="text-xs flex-1 min-w-0 truncate">{tool.label}</span>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
);
};

if (loading) {
return (
<div className="flex items-center justify-center py-16">
Expand Down Expand Up @@ -597,6 +731,43 @@
)}
</div>
</div>

{/* Tool Permissions */}
<div className="pt-4 border-t">
<div className="flex items-center gap-2.5 mb-3">
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-primary/10">
<Shield className="h-4 w-4 text-primary" />
</div>
<div>
<h2 className="text-sm font-semibold">Action Tool Permissions</h2>
<p className="text-xs text-muted-foreground">Tools enabled here can run without confirmation in chats and background actions</p>
</div>
</div>

{(() => {
if (toolPermsLoading) {
return (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
);
}
if (Object.keys(toolPerms).length === 0) {
return (
<div className="rounded-lg border bg-card p-4 text-center">
<p className="text-xs text-muted-foreground">Could not load tool permissions. Ensure the backend is running.</p>
</div>
);
}
return (
<div className="space-y-2">
{Object.entries(toolPerms)
.filter(([connector]) => ALWAYS_SHOW_CONNECTORS.has(connector) || connectedProviders.has(connector))
Comment thread
beng360 marked this conversation as resolved.
.map(([connector, tools]) => renderConnectorGroup(connector, tools))}
</div>
);
})()}
</div>
</div>
);
}
21 changes: 21 additions & 0 deletions client/src/lib/services/tool-permissions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { apiGet, apiPut, apiPost } from "./api-client";
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

export interface ToolPermission {
tool_key: string;
connector: string;
label: string;
risk: string;
enabled: boolean;
}

export interface ToolPermissionsResponse {
tools_by_connector: Record<string, ToolPermission[]>;
seeded: boolean;
}

export const toolPermissionService = {
getPermissions: () => apiGet<ToolPermissionsResponse>("/api/org/tool-permissions"),
toggleTool: (toolKey: string, enabled: boolean) =>
apiPut<{ tool_key: string; enabled: boolean }>(`/api/org/tool-permissions/${toolKey}`, { enabled }),
seedDefaults: () => apiPost<{ seeded: number }>("/api/org/tool-permissions/seed"),
};
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ def bitbucket_branches(
return build_error_response("name (branch name) is required")
if cancelled := confirm_or_cancel(user_id,
f"Delete branch '{name}' in {ws}/{repo}",
"bitbucket_branches"):
"bitbucket:delete_branch"):
return cancelled
result = client.delete_branch(ws, repo, name)
if err := forward_if_error(result):
Expand Down
4 changes: 2 additions & 2 deletions server/chat/backend/agent/tools/bitbucket/pipelines_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ def bitbucket_pipelines(
return build_error_response("target_branch is required")
if cancelled := confirm_or_cancel(user_id,
f"Trigger pipeline on branch '{branch}' in {ws}/{repo}",
"bitbucket_pipelines"):
"bitbucket:trigger_pipeline"):
return cancelled
result = client.trigger_pipeline(ws, repo, branch, pattern=pattern, variables=variables)
if err := forward_if_error(result):
Expand All @@ -126,7 +126,7 @@ def bitbucket_pipelines(
return build_error_response(err)
if cancelled := confirm_or_cancel(user_id,
f"Stop pipeline {pipeline_uuid} in {ws}/{repo}",
"bitbucket_pipelines"):
"bitbucket:stop_pipeline"):
return cancelled
result = client.stop_pipeline(ws, repo, pipeline_uuid)
if err := forward_if_error(result):
Expand Down
4 changes: 2 additions & 2 deletions server/chat/backend/agent/tools/bitbucket/prs_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ def bitbucket_pull_requests(
strategy = merge_strategy or "merge_commit"
if cancelled := confirm_or_cancel(user_id,
f"Merge PR #{pr_id} in {ws}/{repo} (strategy: {strategy})",
"bitbucket_pull_requests"):
"bitbucket:merge_pr"):
return cancelled
result = client.merge_pull_request(
ws, repo, pr_id,
Expand Down Expand Up @@ -190,7 +190,7 @@ def bitbucket_pull_requests(
return build_error_response(err)
if cancelled := confirm_or_cancel(user_id,
f"Decline PR #{pr_id} in {ws}/{repo}",
"bitbucket_pull_requests"):
"bitbucket:decline_pr"):
return cancelled
result = client.decline_pull_request(ws, repo, pr_id)
if err := forward_if_error(result):
Expand Down
4 changes: 2 additions & 2 deletions server/chat/backend/agent/tools/bitbucket/repos_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ def bitbucket_repos(
return build_error_response("branch is required")
if cancelled := confirm_or_cancel(user_id,
f"Commit file '{path}' to branch '{branch}' in {ws}/{repo}",
"bitbucket_repos"):
"bitbucket:commit_file"):
return cancelled
result = client.create_or_update_file(ws, repo, path, content, message, branch)
if err := forward_if_error(result):
Expand All @@ -139,7 +139,7 @@ def bitbucket_repos(
return build_error_response("branch is required")
if cancelled := confirm_or_cancel(user_id,
f"Delete file '{path}' from branch '{branch}' in {ws}/{repo}",
"bitbucket_repos"):
"bitbucket:delete_file"):
return cancelled
result = client.delete_file(ws, repo, path, message, branch)
if err := forward_if_error(result):
Expand Down
7 changes: 4 additions & 3 deletions server/chat/backend/agent/tools/iac/iac_commands_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,8 @@ def iac_plan(
try:
vars_dict = json.loads(vars) if isinstance(vars, str) else vars
for key, value in vars_dict.items():
plan_command += f" -var={shlex.quote(f'{key}={value}')}"
serialized = json.dumps(value) if not isinstance(value, str) else value
plan_command += f" -var={shlex.quote(f'{key}={serialized}')}"
except (json.JSONDecodeError, TypeError):
plan_command += f" -var={shlex.quote(str(vars))}"

Expand Down Expand Up @@ -237,7 +238,7 @@ def iac_apply(

if not gate_action(
user_id=user_id or "",
tool_name="iac_tool",
tool_name="iac_tool:apply",
summary=plan_summary_msg,
).allowed:
tool_capture = get_tool_capture()
Expand Down Expand Up @@ -533,7 +534,7 @@ def iac_destroy(

if not gate_action(
user_id=user_id or "",
tool_name="iac_tool",
tool_name="iac_tool:destroy",
summary=plan_summary_msg,
).allowed:
tool_capture = get_tool_capture()
Expand Down
4 changes: 2 additions & 2 deletions server/chat/backend/agent/tools/mcp_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
"merge_pull_request", "update_pull_request_branch", "fork_repository",
"add_issue_comment", "add_comment_to_pending_review", "add_project_item",
"delete_file", "delete_pending_review", "cancel_workflow_run",
"rerun_workflow", "rerun_workflow_failed_jobs", "assign_copilot_to_issue",
"rerun_workflow_run", "rerun_failed_jobs", "assign_copilot_to_issue",
"request_copilot_review", "update_issue", "update_project_item_field_value",
"close_pull_request_review", "manage_pull_request_review",
}
Expand Down Expand Up @@ -1235,7 +1235,7 @@ def mcp_tool_wrapper(**kwargs):
# Generate consistent tool_call_id for start/completion matching
import hashlib
import json
tool_name = f"mcp_{original_tool_name}"
tool_name = f"mcp_{server_type}_{original_tool_name}"
# Use JSON serialization with sorted keys for deterministic hashing
signature = f"{tool_name}_{json.dumps(kwargs, sort_keys=True, default=str)}"
# Use longer hash (16 chars) to reduce collision risk
Expand Down
6 changes: 4 additions & 2 deletions server/chat/backend/agent/tools/spinnaker_rca_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,12 +208,14 @@ def spinnaker_rca(

# Mutating action: trigger_pipeline requires human-in-the-loop confirmation
if action == "trigger_pipeline":
# Block in background/ask mode — no user to confirm
# Block in background/ask mode — unless org has explicitly permitted this tool
try:
from utils.auth.command_gate import _is_org_tool_permitted
from chat.backend.agent.tools.cloud_tools import get_state_context
state = get_state_context()
if state and getattr(state, "is_background", False):
return json.dumps({"error": "trigger_pipeline is not available in background mode. Only read-only actions can run automatically."})
if not _is_org_tool_permitted("spinnaker_rca"):
return json.dumps({"error": "trigger_pipeline is not available in background mode. Only read-only actions can run automatically."})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
except Exception as e:
logger.debug("[SPINNAKER_RCA] Could not check background state: %s", e)

Expand Down
1 change: 1 addition & 0 deletions server/chat/backend/agent/utils/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,6 @@ class State(BaseModel):
None # Pending RCA context updates for UI injection
)
guardrail_blocked: bool = False # Set by workflow when input rail blocks the message
permitted_tools: Optional[set] = None

model_config = ConfigDict(arbitrary_types_allowed=True)
Loading
Loading