Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
218 changes: 166 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,152 @@
}

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
const sent = sendRaw(JSON.stringify(payload))
if (!sent) return
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 656 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.is_own ? data.pending_turn : null;
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
31 changes: 9 additions & 22 deletions server/chat/backend/agent/tools/bitbucket/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,13 +145,6 @@ def build_success_response(**kwargs) -> str:
return json.dumps(result, default=str)


def get_session_id():
"""Get session_id from the current agent state context."""
from chat.backend.agent.tools.cloud_tools import get_state_context

return getattr(get_state_context(), "session_id", None)


def build_cancelled_response() -> str:
"""Build the standard cancellation response for a rejected confirmation."""
return build_success_response(message="Operation cancelled by user", cancelled=True)
Expand All @@ -160,19 +153,13 @@ def build_cancelled_response() -> str:
def confirm_or_cancel(user_id: str, message: str, tool_name: str) -> Optional[str]:
"""Request human approval for a destructive action.

Retrieves the session ID automatically and prompts the user for
confirmation. Returns ``None`` if approved, or a JSON cancellation
response string if the user declines.
Returns ``None`` if approved, or a JSON cancellation response string
if the user declines. Delegates to the unified command gate so
Bitbucket confirmations share the same UI/WS/taint plumbing as the
shell-command gate.
"""
from utils.cloud.infrastructure_confirmation import wait_for_user_confirmation

session_id = get_session_id()
approved = wait_for_user_confirmation(
user_id=user_id,
message=message,
tool_name=tool_name,
session_id=session_id,
)
if not approved:
return build_cancelled_response()
return None
from utils.auth.command_gate import gate_action

if gate_action(user_id=user_id, tool_name=tool_name, summary=message).allowed:
return None
return build_cancelled_response()
Comment thread
OlivierTrudeau marked this conversation as resolved.
Loading
Loading