Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
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
17 changes: 17 additions & 0 deletions client/src/app/chat/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,17 @@
// Message type for deploy page chat
export interface PolicyChange {
action: 'disable_deny_rule' | 'add_allow_rule';
rule_id?: number | null;
pattern?: string | null;
description?: string | null;
editable: boolean;
}

export interface YesAlwaysEffect {
summary: string;
changes: PolicyChange[];
}

export interface ToolCall {
id: string;
tool_name: string;
Expand All @@ -9,6 +22,10 @@ export interface ToolCall {
timestamp: string;
confirmation_id?: string;
confirmation_message?: string;
// Set when the confirmation originates from the command gate. Drives the
// Yes-Always button visibility and the policy-change disclosure.
block_layer?: string;
yes_always_effect?: YesAlwaysEffect;
command?: string; // Add command field to store final_command
isExpanded?: boolean; // Track whether the tool output is expanded
}
Expand Down
3 changes: 2 additions & 1 deletion client/src/components/SecuritySettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,8 @@ function AddRuleForm({
<Button
size="sm"
className="h-6 text-xs"
onClick={() => { if (pattern.trim()) onAdd(pattern.trim(), desc.trim()); }}
disabled={!pattern.trim() || !desc.trim()}
onClick={() => { if (pattern.trim() && desc.trim()) onAdd(pattern.trim(), desc.trim()); }}
>
Add
</Button>
Expand Down
217 changes: 165 additions & 52 deletions client/src/components/tool-calls/ToolExecutionWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
import { Card } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
import { Button } from "@/components/ui/button"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { cn } from "@/lib/utils"
import { ChevronDown, ChevronUp, X, Check, AlertCircle } from "lucide-react"
import { ChevronDown, ChevronUp, AlertCircle, Settings2 } from "lucide-react"
import CommandLogo from "./CommandLogo"
import { useTheme } from "next-themes"
import { GitHubCommitTool } from "@/components/GitHubCommitTool"
Expand Down Expand Up @@ -233,7 +234,13 @@
// cloud_exec parsing
else if (tool.tool_name === "cloud_exec") {
const parsed = parseCloudExecCommand(normalizedInput, tool.output, defaultCliCommand)
command = parsed.command
// Prefer the authoritative command from the gate's confirmation payload
// when the tool is paused for approval -- parseCloudExecCommand returns a
// placeholder ("cloud exec") when there's no final_command in the output
// yet, which is exactly the state we're in while awaiting confirmation.
command = tool.command && parsed.command === defaultCliCommand
? tool.command
: parsed.command
}
// GitHub MCP tools parsing
else if (tool.tool_name.startsWith("mcp_") && typeof command === "string" && command.trim().startsWith("{")) {
Expand Down Expand Up @@ -466,56 +473,14 @@

{/* Show message when awaiting confirmation */}
{tool.status === "awaiting_confirmation" && !tool.output && !tool.error && (
<div className="border-t border-border bg-muted/30 px-4 py-3 flex items-center justify-between gap-3">
<span className="text-sm text-muted-foreground">
{(tool as any).confirmation_message || "This action requires confirmation"}
</span>
<div className="flex items-center gap-2 flex-shrink-0">
<Button
size="sm"
variant="ghost"
className="h-6 px-2 text-xs font-medium hover:bg-background"
onClick={() => {
const confirmationId = (tool as any).confirmation_id
if (!confirmationId || !sendRaw || !userId) return

sendRaw(JSON.stringify({
type: 'confirmation_response',
confirmation_id: confirmationId,
decision: 'cancel',
user_id: userId,
session_id: sessionId,
}))

onToolUpdate?.({ status: 'completed', output: 'Operation cancelled by user' })
}}
>
<X className="h-3 w-3 mr-1" />
Decline
</Button>
<Button
size="sm"
className="h-6 px-2 text-xs font-medium bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => {
const confirmationId = (tool as any).confirmation_id
if (!confirmationId || !sendRaw || !userId) return

sendRaw(JSON.stringify({
type: 'confirmation_response',
confirmation_id: confirmationId,
decision: 'execute',
user_id: userId,
session_id: sessionId,
}))

onToolUpdate?.({ status: 'running' })
}}
>
<Check className="h-3 w-3 mr-1" />
Confirm
</Button>
</div>
</div>
<ConfirmationPanel
tool={tool}
command={command}
userId={userId}
sessionId={sessionId}
sendRaw={sendRaw}
onToolUpdate={onToolUpdate}
/>
)}

{/* Show shimmer effect while tool is running and no output yet */}
Expand Down Expand Up @@ -586,3 +551,151 @@
}

export default ToolExecutionWidget

interface ConfirmationPanelProps {
tool: ToolCall
command: string
userId?: string
sessionId?: string
sendRaw?: (data: string) => boolean
onToolUpdate?: (updatedTool: Partial<ToolCall>) => void
}

// Compact human summary of a shell command: CLI name + first non-flag
// subcommand (e.g. "aws ec2 describe-instances --query ..." -> "aws ec2
// describe-instances"). Falls back to the raw command if it doesn't parse.
const summarizeCommand = (cmd: string): string => {
if (!cmd) return "this command"
const trimmed = cmd.trim()
const tokens = trimmed.split(/\s+/)
const parts: string[] = []
for (const tok of tokens) {
if (tok.startsWith("-")) break
parts.push(tok)
if (parts.length >= 3) break
}
return parts.join(" ") || trimmed.slice(0, 40)
}

const ConfirmationPanel = ({ tool, command, userId, sessionId, sendRaw, onToolUpdate }: ConfirmationPanelProps) => {
const effect = tool.yes_always_effect
const allowYesAlways = !!(effect && effect.changes.length > 0)
const summary = summarizeCommand(command)

const [edited, setEdited] = React.useState<Record<string, string>>(() => {
const m: Record<string, string> = {}
if (effect) {
effect.changes.forEach((c, i) => {
if (c.editable && c.pattern) m[String(i)] = c.pattern
})
}
return m
})
const [alwaysOpen, setAlwaysOpen] = React.useState(false)

const respond = (decision: 'execute' | 'cancel' | 'execute_always') => {
const confirmationId = tool.confirmation_id
if (!confirmationId || !sendRaw || !userId) return
const payload: Record<string, unknown> = {
type: 'confirmation_response',
confirmation_id: confirmationId,
decision,
user_id: userId,
session_id: sessionId,
Comment thread
OlivierTrudeau marked this conversation as resolved.
}
if (decision === 'execute_always') payload.edited_patterns = edited
sendRaw(JSON.stringify(payload))
if (decision === 'cancel') {
onToolUpdate?.({ status: 'completed', output: 'Operation cancelled by user' })
} else {
onToolUpdate?.({ status: 'running' })
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return (
<div className="border-t border-border bg-muted/30 px-4 py-2 flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0 text-sm text-muted-foreground">
<AlertCircle className="h-4 w-4 flex-shrink-0" />
<span className="truncate">
Approval needed for <code className="font-mono text-foreground">{summary}</code>
</span>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Button
size="sm"
variant="ghost"
className="h-7 px-3 text-xs font-medium text-muted-foreground hover:text-foreground"
onClick={() => respond('cancel')}
>
Deny
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 px-3 text-xs font-medium text-foreground hover:text-foreground"
onClick={() => respond('execute')}
>
Allow
</Button>
{allowYesAlways && effect && (
<Popover open={alwaysOpen} onOpenChange={setAlwaysOpen}>
<PopoverTrigger asChild>
<Button
size="sm"
className="h-7 px-3 text-xs font-medium gap-1 bg-blue-600 text-white hover:bg-blue-600/90 dark:bg-blue-500 dark:hover:bg-blue-500/90"
>
Always
<Settings2 className="h-3 w-3" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" sideOffset={6} className="w-80 p-3 flex flex-col gap-2">
<div className="text-xs font-medium">{effect.summary}</div>
<ul className="flex flex-col gap-2">
{effect.changes.map((c, i) => (
<li key={i} className="flex flex-col gap-1">

Check warning on line 655 in client/src/components/tool-calls/ToolExecutionWidget.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not use Array index in keys

See more on https://sonarcloud.io/project/issues?id=Arvo-AI_aurora&issues=AZ3USKECNd9f7yoDcUrz&open=AZ3USKECNd9f7yoDcUrz&pullRequest=328
{c.action === 'disable_deny_rule' ? (
<>
<div className="text-xs text-muted-foreground">
Disable deny rule: <span className="text-foreground">{c.description || 'rule'}</span>
</div>
{c.pattern && (
<code className="rounded border border-border bg-muted/40 px-2 py-1 font-mono text-[11px] break-all">
{c.pattern}
</code>
)}
</>
) : (
<>
<label className="text-xs text-muted-foreground" htmlFor={`allow-pattern-${i}`}>
Pattern to allow
</label>
<input
id={`allow-pattern-${i}`}
type="text"
className="w-full rounded border border-border bg-transparent px-2 py-1 font-mono text-[11px] focus:outline-none focus:ring-1 focus:ring-ring"
value={edited[String(i)] ?? c.pattern ?? ''}
onChange={(e) => setEdited(prev => ({ ...prev, [String(i)]: e.target.value }))}
spellCheck={false}
aria-label="Allow-rule regex pattern"
/>
</>
)}
</li>
))}
</ul>
<div className="flex justify-end pt-1">
<Button
size="sm"
className="h-7 px-3 text-xs font-medium bg-blue-600 text-white hover:bg-blue-600/90 dark:bg-blue-500 dark:hover:bg-blue-500/90"
onClick={() => { setAlwaysOpen(false); respond('execute_always') }}
>
Save
</Button>
</div>
</PopoverContent>
</Popover>
)}
</div>
</div>
)
}
35 changes: 34 additions & 1 deletion client/src/hooks/useChatHistory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@
error?: string | null;
status: 'running' | 'completed' | 'error' | 'cancelled' | 'awaiting_confirmation';
timestamp: string;
confirmation_id?: string;
confirmation_message?: string;
command?: string;
block_layer?: string;
yes_always_effect?: unknown;
}>;
images?: Array<{
data: string;
Expand Down Expand Up @@ -223,8 +228,36 @@
const rawMessages = data.messages || [];
const cleanedMessages = cleanupStaleToolCalls(rawMessages, data.updated_at);
const cleanupTime = performance.now() - cleanupStart;
// If the server has a live HITL confirmation pending for this session,
// append a synthetic bot message with an awaiting_confirmation tool
// call so the user sees the prompt immediately on reload. The card is
// driven entirely by chat_sessions.pending_turn -- history remains
// append-only and contains no mid-turn snapshot.
const pending = data.pending_turn;
const messagesWithPending: ChatMessage[] = pending && pending.confirmation_id

Check warning on line 237 in client/src/hooks/useChatHistory.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=Arvo-AI_aurora&issues=AZ3UbZ-P62Ic5jupYRLN&open=AZ3UbZ-P62Ic5jupYRLN&pullRequest=328
Comment thread
OlivierTrudeau marked this conversation as resolved.
? [
...(cleanedMessages as ChatMessage[]),
{
id: Date.now(),
sender: 'bot',
text: '',
toolCalls: [{
id: `pending-${pending.confirmation_id}`,
tool_name: pending.tool_name || 'action',
input: '',
status: 'awaiting_confirmation',
timestamp: pending.created_at || new Date().toISOString(),
confirmation_id: pending.confirmation_id,
confirmation_message: pending.message,
command: pending.command,
block_layer: pending.block_layer,
yes_always_effect: pending.yes_always_effect,
}],
} as ChatMessage,
]
: (cleanedMessages as ChatMessage[]);
// DON'T refresh sessions when just loading - this prevents sessions from moving to top
return { messages: cleanedMessages as ChatMessage[], uiState, incidentId: data.incident_id || null, status: data.status || null };
return { messages: messagesWithPending, uiState, incidentId: data.incident_id || null, status: data.status || null };
} catch (err) {
console.error('[useChatHistory] Error loading chat session:', err);
setCrudError(err instanceof Error ? err.message : 'Failed to load chat session');
Expand Down
5 changes: 4 additions & 1 deletion client/src/hooks/useMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,10 @@ export const useMessageHandler = ({
...tc,
status: 'awaiting_confirmation' as const,
confirmation_id: confirmationId,
confirmation_message: message.data.message
confirmation_message: message.data.message,
command: message.data.command ?? tc.command,
block_layer: message.data.block_layer,
yes_always_effect: message.data.yes_always_effect,
};
}
return tc;
Expand Down
21 changes: 9 additions & 12 deletions server/chat/backend/agent/tools/cloud_exec_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -1409,25 +1409,22 @@ 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
# Unified gate: signature + org policy + LLM judge + HITL (foreground).
# 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)
gated_cmd = f"{prefix} {command}" if prefix and not command.strip().startswith(prefix) else command
from utils.auth.command_gate import gate_command
gate = gate_command(user_id=user_id, tool_name="cloud_exec", command=gated_cmd)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if not gate.allowed:
Comment thread
damianloch marked this conversation as resolved.
logger.warning("cloud_exec blocked for user %s (%s): %s",
user_id, gate.code, gate.block_reason[:200])
return json.dumps({
"success": False,
"error": f"Command blocked by organization policy: {reason}",
"code": "POLICY_DENIED",
"error": gate.block_reason,
"code": gate.code,
"final_command": command,
"provider": provider.lower(),
})
Expand Down
Loading
Loading