This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new c754310d6f88 CAMEL-25444: camel-jbang - keys sent over MCP never
answer a dialog that asks the user to confirm
c754310d6f88 is described below
commit c754310d6f88194d967d78028498840f7a5f3a00
Author: Claus Ibsen <[email protected]>
AuthorDate: Wed Oct 14 12:18:27 2026 +0200
CAMEL-25444: camel-jbang - keys sent over MCP never answer a dialog that
asks the user to confirm
The keys of tui_send_keys, and those of a tape, went through the same
handler as the user's keys, so they could answer a dialog that asks the user
to confirm an action: quitting, stopping or killing an integration, Stop
All, a file an AI writes, an AI edit waiting to be saved, a tool an ACP
agent wants to run, deleting or converting a file, discarding changes. Such
a key is now dropped while one of these dialogs is open, and tui_send_keys
says so instead of sending.
While a tape drives the monitor (a model records one with tui_tape_start, a
user with F2, or --record plays one) nothing asks at all, as the model or
the tape does everything itself: these actions go ahead at once, and a file
an AI writes is written without the confirm dialog or the live replay.
Leaving the editor with unsaved changes still asks.
chore: docs - the security model describes the developer tools and their AI
assistants
Camel CLI and Camel TUI run on the developer's own machine and are not part
of a deployment. A new "Developer tools" section of the security model says
so, and that the AI assistants of the TUI (the AI panel's model, an ACP
agent, an MCP client) run its tools without asking, under a short contract:
file writes only in the integration's source directory through the write
mode; a confirmation before a message to an endpoint the integration does
not
have, SQL that is not a SELECT, a row update, and an expression in a
language
that runs code; an ACP agent's own tools put in front of the developer; keys
sent over MCP never answering a confirmation. A tool breaking the contract
is
in scope; an assistant doing what the tools allow, also after a prompt
injection, is not. Out of scope and the known non-findings get matching
entries, and AGENTS.md summarises it, so that scanner reports of an AI tool
running without asking are triaged against it.
chore: camel-jbang - the AI tools ask only before calls that leave the
integration, change data or run code
Camel TUI is a developer tool (see "Developer tools" in the security model),
and the fast feedback loop of the AI panel depends on the model showing and
telling at once. The tools of the TUI now run without asking, for the AI
panel's own model, for an ACP agent (whose own shell, file and fetch
requests
still go to the Permission popup) and for an MCP client. The TUI asks the
user itself, in the popup it already used for file writes (now
ToolConfirmPopup), before the few calls that reach beyond the integration,
may destroy data or run code (ToolConfirms):
- tui_send_message to an endpoint the integration does not have, as it could
write a file anywhere (file:), run a command (exec:) or call any service;
a
route, direct:, seda: and the integration's own endpoints go ahead
- tui_execute_sql with a statement other than a single SELECT, SHOW or
DESCRIBE, and tui_update_row
- camel_eval_expression in a language that runs code, such as Groovy
(CAMEL-25443)
tui_http_request already reaches only the integration's own server. Nothing
asks while a tape records or plays, and keys sent over MCP never answer the
popup (CAMEL-25444). Two tools asking at once now wait in turn instead of
one replacing the other's question. The ACP handler checks that a tool name
from a title is a real tool of the TUI before approving it.
chore: camel-jbang - a message to an internal endpoint such as log: goes
ahead without asking
tui_send_message no longer asks before a message to an internal endpoint:
direct:, seda:, disruptor:, disruptor-vm:, log:, mock:, stub:, browse:,
dataset: and dataset-test: stay inside the integration. This is a list of
its own, not every component the catalog marks remote=false: exec, class,
bean, language and the components that load a template or a stylesheet by
URI are not remote either, but run code, or read a file or a URL.
An internal endpoint whose URI has a bean reference (#class:...(args) runs
any constructor) or a property placeholder (which can put a secret in the
log) still asks.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
---
AGENTS.md | 6 +
.../ROOT/pages/camel-jbang-tui-ai-agents.adoc | 20 +--
.../modules/ROOT/pages/camel-jbang-tui-ai.adoc | 21 +++
.../ROOT/pages/camel-jbang-tui-recording.adoc | 5 +
.../modules/ROOT/pages/security-model.adoc | 61 ++++++-
dsl/camel-jbang/camel-jbang-plugin-tui/AGENTS.md | 1 +
.../core/commands/tui/AbstractDiagramTab.java | 5 +
.../dsl/jbang/core/commands/tui/ActionsPopup.java | 8 +-
.../camel/dsl/jbang/core/commands/tui/AiPanel.java | 28 ++--
.../dsl/jbang/core/commands/tui/CamelMonitor.java | 49 +++---
.../dsl/jbang/core/commands/tui/Confirmations.java | 111 +++++++++++++
.../jbang/core/commands/tui/FileActionsPopup.java | 30 +++-
.../dsl/jbang/core/commands/tui/FilesBrowser.java | 5 +
.../dsl/jbang/core/commands/tui/McpFacade.java | 67 +++++++-
.../jbang/core/commands/tui/MonitorContext.java | 15 ++
.../dsl/jbang/core/commands/tui/MonitorTab.java | 8 +
.../core/commands/tui/ProcessControlPopup.java | 4 +-
.../dsl/jbang/core/commands/tui/SourceTab.java | 7 +
.../dsl/jbang/core/commands/tui/SourceViewer.java | 10 +-
.../dsl/jbang/core/commands/tui/StopAllPopup.java | 10 ++
.../{FileWritePopup.java => ToolConfirmPopup.java} | 112 +++++++++++--
.../dsl/jbang/core/commands/tui/ToolConfirms.java | 180 +++++++++++++++++++++
.../core/commands/tui/TuiToolDefinitions.java | 40 ++---
.../jbang/core/commands/tui/TuiToolRegistry.java | 31 +++-
.../jbang/core/commands/tui/AiPanelAcpTest.java | 74 ++++++---
.../jbang/core/commands/tui/ConfirmationsTest.java | 172 ++++++++++++++++++++
.../core/commands/tui/McpFacadeConfirmTest.java | 134 +++++++++++++++
.../core/commands/tui/McpFacadeWriteFileTest.java | 22 ++-
.../jbang/core/commands/tui/SourceSizeTest.java | 4 +-
...itePopupTest.java => ToolConfirmPopupTest.java} | 55 ++++++-
.../jbang/core/commands/tui/ToolConfirmsTest.java | 119 ++++++++++++++
31 files changed, 1274 insertions(+), 140 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 1c15c66801a8..e19d3f786154 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -454,6 +454,12 @@ The following are explicitly **not** framework
vulnerabilities and will be close
- Third-party transitive dependency CVEs that are not reachable through any
Camel-exposed code
path.
- Automated scanner reports without a PoC demonstrating an actual
trust-boundary breach.
+- An **AI assistant in Camel TUI** (the AI panel's model, an ACP agent, an MCP
client) using the
+ TUI's tools without asking, even after a prompt injection. Camel CLI and
Camel TUI are developer
+ tools on the developer's own machine; the TUI asks first only before a file
write (its write
+ mode), a message to an endpoint the integration does not have, SQL that is
not a `SELECT`, and a
+ code-running expression language such as Groovy. A tool breaking that
contract is in scope. See
+ "Developer tools" in the security model.
### Operator hardening checklist
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai-agents.adoc
b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai-agents.adoc
index f599e0bf20c6..5d3d7f01f6ef 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai-agents.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai-agents.adoc
@@ -110,14 +110,13 @@ required. *F2* -> _MCP Info_ shows the port and the tool
calls the agent makes.
`--mcp`, it is bound to `127.0.0.1` with no authentication, and it rejects
requests that carry an `Origin`
header (so a web page cannot reach it) or that are not JSON.
-Permissions: calls to the TUI's read-only tools (the `tui_get_*` tools,
catalog documentation, expression
-evaluation, locate, validate) are approved without asking. So is
`camel_write_file` while the write mode is
-`confirm` or `live`, because the TUI itself asks before the file is touched
(the diff dialog or the live replay
-in the Source editor); with `/write auto` the permission popup is the only
question and stays. A tool that
-changes anything else, such as `camel_control`, `tui_send_message` or
`tui_execute_sql`, opens the same popup as
-any other request; answering "Always allow" makes it a one-time question per
tool. For anything else the agent
-wants to do, such as editing a file with its own tools or running a command, a
popup shows the agent's options;
-*Enter* selects, *Esc* rejects that one call and lets the turn continue, and
*Ctrl+C* cancels the whole turn.
+Permissions: calls to the TUI's own tools are approved without asking, as for
the AI panel's own model. The
+TUI asks you itself before the few calls that need it, such as a file write, a
message to an endpoint the
+integration does not have, SQL that is not a `SELECT`, or a Groovy expression
(see
+xref:camel-jbang-tui-ai.adoc#_what_asks_first[What asks first]). For anything
the agent wants to do with its own
+tools, such as editing a file or running a command, a popup shows the agent's
options; *Enter* selects, *Esc*
+rejects that one call and lets the turn continue, and *Ctrl+C* cancels the
whole turn. While a tape records, or
+`--record` plays one, nothing asks.
The preamble tells the agent to edit route sources through `camel_write_file`
rather than its own file tools, so
that you see the diff or watch the edit; when it still asks to edit a file
directly, the popup says so and *Esc*
sends it back to `camel_write_file`.
@@ -175,7 +174,10 @@ by purpose:
* *Observe* -- read the screen, get structured state, query
tables/logs/errors/traces/topology/diagram/files,
list the running infra services (brokers, databases) and read their logs,
read the performance of the
local Ollama server (`tui_get_ollama`: loaded model, tokens per second,
context fill, host load, request log)
-* *Navigate* -- switch tabs, select integrations, select routes, send
keystrokes, apply filters
+* *Navigate* -- switch tabs, select integrations, select routes, send
keystrokes, apply filters. Keystrokes
+ never answer a dialog that asks the user to confirm an action (quitting,
stopping an integration, a file write
+ or another call of an AI tool, discarding changes): `tui_send_keys` says so
while one is open, and keys already sent are dropped. Only the user
+ answers it
* *Act* -- send test messages to endpoints, start/stop/restart routes, change
log levels,
list and launch the bundled examples, start/stop/restart infra services, run
any entry of
the *F2* actions menu by its label
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai.adoc
b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai.adoc
index b4b7d1d2051e..4ded91aaf6ff 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai.adoc
@@ -185,6 +185,27 @@ average time per question (and per request in brackets),
the model table has an
the average per request for that model, and the *Time per question* chart next
to the token chart shows the
two side by side for each question, so you can see whether a slow answer was
the model or the tools.
+== What asks first
+
+Camel TUI is a developer tool: the model works on your own machine, with your
own integrations, and runs the
+TUI's tools without asking, so it can show and tell at once (see
+xref:security-model.adoc#_developer_tools[Developer tools] in the security
model). It reads any tab, selects
+integrations and routes, starts and stops routes and infrastructure, sends
test messages to the integration's
+endpoints and evaluates expressions. The few calls that reach beyond the
integration, may destroy data or run
+code ask you first, in a dialog that shows the call:
+
+* writing a file: the diff dialog, or the live replay in the Source editor
(see `/write` above);
+* sending a message to an endpoint the integration does not have, such as a
file, a command, an HTTP service or
+ another topic; a message to a route, to an internal endpoint (`direct:`,
`seda:`, `log:`, `mock:`, `stub:`
+ and the like), or to an endpoint the integration uses goes ahead;
+* SQL other than a single `SELECT`, `SHOW` or `DESCRIBE`, and updating a row;
+* evaluating an expression in a language that runs code, such as Groovy, Java,
JavaScript or Python.
+
+*Enter* runs the call and *Esc* rejects it; the model is told that you
rejected it. An HTTP request
+(`tui_http_request`) reaches only the integration's own server on `localhost`.
While a tape records, or
+`--record` plays one, nothing asks. The same applies to a coding agent driving
the panel and to an MCP client
+(see xref:camel-jbang-tui-ai-agents.adoc[AI agents]).
+
== AI log
Every request the panel makes is recorded in the AI log (*F2* -> _AI & MCP_ ->
_AI Log_, or the
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-recording.adoc
b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-recording.adoc
index 39cd6942897a..5d6ed7eaaa3c 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-recording.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-recording.adoc
@@ -59,6 +59,11 @@ or converted to an animated GIF.
An AI agent connected over MCP records a tape of what it does with
`tui_tape_start` and `tui_tape_stop`.
+While a tape records, and while `--record` plays one, actions that ask first
go ahead at once: quitting, stopping
+an integration, a file an AI writes, a tool an ACP agent runs, deleting or
converting a file. The model or the tape
+does everything itself, and the keys a tape plays, like those an MCP client
sends, never answer a dialog that asks the
+user to confirm. Leaving the editor with unsaved changes still asks, so save
before leaving it.
+
== Scripted Recording
Use the `--record` flag to play a `.tape` file in the TUI and record what
happens on screen:
diff --git a/docs/user-manual/modules/ROOT/pages/security-model.adoc
b/docs/user-manual/modules/ROOT/pages/security-model.adoc
index 6d9adeb1838b..67df8d7d7a4d 100644
--- a/docs/user-manual/modules/ROOT/pages/security-model.adoc
+++ b/docs/user-manual/modules/ROOT/pages/security-model.adoc
@@ -113,8 +113,8 @@ input, and the contract is:
* It always finishes, in bounded time and memory, whatever the source.
* What it returns (endpoint URIs, descriptions, labels, the model) is text the
source's author chose. Consumers treat it as data, never as instructions,
- including when it goes into an AI prompt. What an AI proposes from it goes
- through the TUI's confirmation dialogs before anything is written.
+ including when it goes into an AI prompt. What an AI proposes from it is
+ written only through the TUI's write mode (see _Developer tools_).
A crafted source that makes the tooling break this contract is in scope: for
example parsing that executes code, loads a class from the source, defines a
@@ -125,6 +125,50 @@ case described above, not a finding. How `LwJavaParser`
enforces each rule
classes, the limits) is described in
https://github.com/apache/camel/blob/main/design/java-dsl-parser.adoc[`design/java-dsl-parser.adoc`].
+=== Developer tools
+
+Camel CLI (`camel`) and Camel TUI (`camel tui`) are developer tools. They run
+on the developer's own machine, for the developer's own integrations, in the
+inner loop of writing and trying routes; they are not part of a production
+deployment. The developer is fully trusted, as a route author and an operator
+are, and so is the local machine: its user account, its loopback interface and
+the processes the developer starts.
+
+Camel TUI has AI assistants: the model of the AI panel (a local or a hosted
+LLM), a coding agent driving the panel over ACP, and an MCP client connected
+to `camel tui --mcp`. They act for the developer, and the TUI's tools let them
+do what the developer does in the TUI: read the screens, start and stop routes
+and infrastructure, send test messages to the integration, query its
+database, evaluate expressions and edit the project's files. To keep the
+feedback loop fast these tools run without asking. The guardrails are
+deliberately few, and they are the contract:
+
+* The file tools write only inside the integration's source directory, and
+ each write goes through the AI panel's write mode: a confirmation dialog
+ with the diff, or a replay in the source editor that the developer saves,
+ unless the developer chose `/write auto`.
+* The developer confirms a call that reaches beyond the integration, may
+ destroy data or runs code: a message to an endpoint the integration does not
+ have (a route, an internal endpoint such as `direct:`, `seda:` or `log:`,
+ and the integration's own endpoints are inside it), a SQL statement other
+ than a single `SELECT`, `SHOW` or `DESCRIBE`, a row update, and an
+ expression in a language that runs code, such as Groovy. An HTTP request
+ goes only to the integration's own server on `localhost`.
+* A coding agent's own tools - its shell commands, its file changes, its
+ network fetches - are put in front of the developer in the TUI.
+* Keys sent over MCP never answer a dialog that asks the developer to confirm
+ something.
+* While the developer records a tape, or plays one with `--record`, nothing
+ asks: the developer chose to let the model, the agent or the tape act alone.
+
+A tool that breaks this contract is in scope: for example a file written
+outside the source directory, a message sent to an endpoint the integration
+does not have without the developer's confirmation, or a key sent over MCP
+that answers a confirmation. An assistant doing what the tools allow is the
+trusted developer's case, also when untrusted data shown in the TUI - a
+message body, a log line, an example's README - steered the model to it
+(prompt injection); see _Out of scope_.
+
=== Adversary model
The adversary the framework defends against is the *External message senders*
@@ -982,6 +1026,13 @@ be closed as `not a vulnerability`.
shell out to, such as `docker`/`podman`/`camel run`) reachable from
`127.0.0.1` is the documented contract for both flags, not a framework
vulnerability.
+* *An AI assistant in Camel TUI using the TUI's tools.* The AI panel's model,
+ a coding agent over ACP or an MCP client that stops a route, sends a message
+ to the integration's endpoints, queries its database, evaluates an
+ expression or writes the project's files through the write mode acts for the
+ trusted developer, under the contract in _Developer tools_. That such a call
+ runs without asking, or that untrusted data in a message or a log steered
+ the model to make it, is not a finding; a tool that breaks the contract is.
* *Vulnerabilities in third-party transitive dependencies.* Camel fixes
CVEs in Camel code, not in third-party JARs; the report belongs
upstream. Camel may upgrade the dependency to pick up an upstream fix,
@@ -1217,6 +1268,12 @@ Disposition is `KNOWN-NON-FINDING` (see _Triage
dispositions_).
authored an expression or route over untrusted input, or wired an
untrusted source straight through without `+removeHeaders("Camel*")+`,
the disposition is the route-author position in _Out of scope_.
+* *"The AI panel of Camel TUI, or an agent driving it, runs a tool that
+ changes something without asking the user"*, also after a prompt
+ injection. Camel TUI is a developer tool whose tools run without asking
+ by design; the few calls the developer confirms are listed in _Developer
+ tools_, which is the contract. See the AI-assistant entry in _Out of
+ scope_.
=== Triage dispositions
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/AGENTS.md
b/dsl/camel-jbang/camel-jbang-plugin-tui/AGENTS.md
index 6fc2b821ec3d..02ec79058eba 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/AGENTS.md
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/AGENTS.md
@@ -37,6 +37,7 @@ and use each other's package-private members, so there are no
sub-packages. Clas
| `AbstractDiagramTab` | what the Diagram and Routes tabs share of the route
diagram: its state, keys, drilling into a route and back |
| `AiPanel`, `AiSlashCommandRegistry` | the F8 AI panel; its input line is
`AiInputLine`, its tools and system prompt `AiToolSet`, its usage statistics
`AiUsageStats`, and an external coding agent (ACP) `AcpSession` over
`AcpAgentClient` |
| `LiveEdit`, `EditReplay` | `/write live`: a file an AI tool writes is typed
into the source editor, to watch and then save or discard |
+| `ToolConfirms`, `ToolConfirmPopup` | the AI tools run without asking (a
developer tool); these are the few calls the user confirms first (a file write,
a message to an endpoint the integration does not have, SQL that is not a
SELECT, Groovy), and the popup that asks |
| `TuiMcpServer`, `TuiToolDefinitions`, `TuiToolRegistry`, `McpFacade` | the
MCP server: the tools (`TuiTool`: schema, handler, flags), their
implementations, the bridge to the screen; the shared file tools are
`TuiFileTools`, which writes only to an integration's source directory, through
the AI panel's write mode |
| `Theme`, `TuiIcons` | colours (themes in `src/main/resources/tui/themes`)
and icons |
| `TuiSettings`, `SettingsPopup` | persisted settings |
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AbstractDiagramTab.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AbstractDiagramTab.java
index ca477e42c540..f60cd28aa4ff 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AbstractDiagramTab.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AbstractDiagramTab.java
@@ -71,6 +71,11 @@ abstract class AbstractDiagramTab extends AbstractTab {
return (sourceViewer.isVisible() && sourceViewer.isTextInputActive())
|| gotoNodePopup.isVisible();
}
+ @Override
+ public boolean isConfirming() {
+ return sourceViewer.isVisible() && sourceViewer.isDiscardPending();
+ }
+
@Override
public boolean handlePaste(String text) {
if (!sourceViewer.isVisible() || !sourceViewer.isTextInputActive()) {
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java
index 3ec314ff7dda..b1e5710cfc84 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java
@@ -1171,11 +1171,17 @@ class ActionsPopup {
}
void openStopAll() {
- stopAllPopup.setConfirmActions(ctx != null && ctx.confirmActions);
+ stopAllPopup.setConfirmActions(ctx != null && ctx.confirmFirst());
+ stopAllPopup.setUnattended(ctx != null &&
ctx.tapeDriven.getAsBoolean());
stopAllPopup.open();
checkStopAllNotification();
}
+ /** Whether the Stop All dialog asks what to stop. */
+ boolean isStopAllVisible() {
+ return stopAllPopup.isVisible();
+ }
+
boolean hasRunningProcesses() {
return stopAllPopup.hasBothGroups()
|| integrations.get().stream().anyMatch(i -> !i.vanishing &&
!i.phantom)
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
index 59f890d48152..0f98a6d490ea 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
@@ -1784,9 +1784,11 @@ class AiPanel {
}
/**
- * Policy A from the design: calls to the TUI's read-only tools are
approved silently (allow-always preferred),
- * everything else, including a TUI tool that changes something, is put in
front of the user. Runs on the ACP
- * request thread and blocks until the user answers or the turn is
cancelled.
+ * The TUI's own tools are approved silently (allow-always preferred):
Camel TUI is a developer tool, and the TUI
+ * asks the user itself before the few calls that need it (a file write in
its write mode, and
+ * {@link ToolConfirms}). The agent's own tools (its shell commands, file
changes and fetches) are put in front of
+ * the user. While a tape drives the monitor nothing asks at all
(CAMEL-25444). Runs on the ACP request thread and
+ * blocks until the user answers or the turn is cancelled.
*/
private final class AcpPanelPermissionHandler implements
AcpAgentClient.PermissionHandler {
@Override
@@ -1795,12 +1797,8 @@ class AiPanel {
String title = String.valueOf(toolCall.getStringOrDefault("title",
""));
String kind = String.valueOf(toolCall.getStringOrDefault("kind",
""));
String tool = tuiToolName(name, title, kind);
- boolean readOnly = tool != null &&
TuiToolRegistry.READ_ONLY_TOOLS.contains(tool);
- // the facade asks the user before a camel_write_file call touches
a file (the confirm dialog or the live
- // replay), so a second question at the ACP layer would only make
the user answer twice; in auto mode
- // nothing else asks and the popup stays
- boolean confirmedByTui = TuiToolRegistry.WRITE_TOOL.equals(tool)
&& writeMode != McpFacade.WriteMode.AUTO;
- if (readOnly || confirmedByTui) {
+ boolean tuiTool = tool != null && TuiToolRegistry.isTool(tool);
+ if (tuiTool || ctx != null && ctx.tapeDriven.getAsBoolean()) {
String optionId = firstOptionOfKind(options, "allow_always");
if (optionId == null) {
optionId = firstOptionOfKind(options, "allow_once");
@@ -1808,9 +1806,9 @@ class AiPanel {
if (optionId == null && !options.isEmpty()) {
optionId = options.get(0).getString("optionId");
}
- log(LogLevel.TOOL, readOnly
- ? "Auto-approved read-only TUI tool"
- : "Auto-approved camel_write_file (the TUI confirms
the write itself)", title);
+ log(LogLevel.TOOL, tuiTool
+ ? "Auto-approved TUI tool (the TUI asks itself when it
must)"
+ : "Auto-approved while a tape drives the monitor",
title);
return optionId;
}
CompletableFuture<String> decision = new CompletableFuture<>();
@@ -1835,8 +1833,8 @@ class AiPanel {
* The camel-tui tool a permission request is about, or null when it is
not one: the name the Claude adapter sends
* ({@code mcp__camel-tui__tui_get_state}) or a title of the form {@code
tui_get_state (camel-tui MCP Server)}.
* Kinds that touch files or run commands never qualify, whatever the
title says: a path containing "camel-tui" is
- * not a tool identity. The caller decides what the tool may do: only
{@link TuiToolRegistry#READ_ONLY_TOOLS} and a
- * write the TUI confirms itself are approved silently, everything else
goes to the user.
+ * not a tool identity. The caller approves a tool of the TUI silently, as
the TUI asks itself when it must;
+ * everything else goes to the user.
*/
private static String tuiToolName(String name, String title, String kind) {
if (FILE_OR_SHELL_KINDS.contains(kind)) {
@@ -2817,7 +2815,7 @@ class AiPanel {
return acpSession.sessionId();
}
- boolean isPermissionPopupVisibleForTesting() {
+ boolean isPermissionPopupVisible() {
return permissionPopup.isVisible();
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
index 9628368c2276..055aa71e488e 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
@@ -30,9 +30,7 @@ import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Queue;
-import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentLinkedQueue;
-import java.util.concurrent.TimeUnit;
import java.util.function.BiConsumer;
import java.util.function.Function;
import java.util.function.Predicate;
@@ -170,7 +168,7 @@ public class CamelMonitor extends CamelCommand {
private long monitorNotificationExpiry;
private final WaveTextState notificationWaveState = new WaveTextState();
private String lastWaveNotification;
- private boolean mcpInjectedKey;
+ private Confirmations confirmations;
private volatile TuiMcpServer mcpServer;
private Path mcpJsonFile;
private TuiWebServer webServer;
@@ -189,7 +187,7 @@ public class CamelMonitor extends CamelCommand {
private ActionsPopup actionsPopup;
private ProcessControl processControl;
private ProcessControlPopup processControlPopup;
- private final FileWritePopup fileWritePopup = new FileWritePopup();
+ private final ToolConfirmPopup toolConfirmPopup = new ToolConfirmPopup();
private final LiveEdit liveEdit = new LiveEdit(new LiveEdit.Host() {
@Override
public EditReplay.Editor openFileForReplay(Path file) {
@@ -635,7 +633,7 @@ public class CamelMonitor extends CamelCommand {
* through here too, so quitting behaves the same however it is asked for.
*/
void quitTui(boolean confirm) {
- if (confirm && ctx.confirmActions) {
+ if (confirm && ctx.confirmFirst()) {
LaunchManager launches = actionsPopup.getLaunchManager();
long running = launches.runningLaunchCount();
if (running > 0) {
@@ -918,14 +916,12 @@ public class CamelMonitor extends CamelCommand {
@Override
public boolean confirmFileWrite(McpFacade.FileWrite
request) {
- CompletableFuture<Boolean> answer = new
CompletableFuture<>();
- fileWritePopup.open(request, answer);
- try {
- return answer.get(2, TimeUnit.MINUTES);
- } catch (Exception e) {
- fileWritePopup.close();
- return false;
- }
+ return toolConfirmPopup.ask(request);
+ }
+
+ @Override
+ public boolean confirmToolCall(ToolConfirms.Call call) {
+ return toolConfirmPopup.ask(call);
}
@Override
@@ -938,6 +934,10 @@ public class CamelMonitor extends CamelCommand {
CamelMonitor.this.resetIntegrationTabState();
}
});
+ confirmations = Confirmations.of(
+ pendingKeys, popupManager, toolConfirmPopup, actionsPopup,
liveEdit, aiPanel, filesBrowser, tabRegistry);
+ ctx.tapeDriven = () -> record != null ||
recordingManager.isTapeRecording();
+ mcpFacade.setConfirmations(confirmations);
mcpFacade.setSourceValidator(tabRegistry.sourceTab().editAssist()::validateSource);
mcpFacade.setPropertyLineValidator(tabRegistry.sourceTab().editAssist()::validateSpringBootPropertyLine);
mcpFacade.setLaunchManager(actionsPopup.getLaunchManager());
@@ -1186,7 +1186,7 @@ public class CamelMonitor extends CamelCommand {
private boolean handleEvent(Event event, TuiRunner runner) {
if (event instanceof KeyEvent ke) {
- recordingManager.recordKey(ke, mcpInjectedKey);
+ recordingManager.recordKey(ke, confirmations.isDelivering());
if (captionOverlay.isVisible()) {
if (captionOverlay.handleKeyEvent(ke)) {
return true;
@@ -1218,8 +1218,8 @@ public class CamelMonitor extends CamelCommand {
if (processControlPopup.isVisible()) {
return processControlPopup.handleKeyEvent(ke);
}
- if (fileWritePopup.isVisible()) {
- return fileWritePopup.handleKeyEvent(ke);
+ if (toolConfirmPopup.isVisible()) {
+ return toolConfirmPopup.handleKeyEvent(ke);
}
if (liveEdit.handleKeyEvent(ke)) {
return true;
@@ -1786,16 +1786,7 @@ public class CamelMonitor extends CamelCommand {
private boolean handleTickEvent(TuiRunner runner) {
long now = MonotonicClock.millis();
- boolean keyProcessed = false;
- McpFacade.PendingKey pk;
- while ((pk = pendingKeys.peek()) != null && pk.isDue(now)) {
- pendingKeys.poll();
- mcpInjectedKey = true;
- handleEvent(pk.event(), runner);
- mcpInjectedKey = false;
- keyProcessed = true;
- }
- if (keyProcessed) {
+ if (confirmations.deliverDue(now, key -> handleEvent(key, runner))) {
return true;
}
actionsPopup.tick(now);
@@ -1963,7 +1954,7 @@ public class CamelMonitor extends CamelCommand {
}
actionsPopup.render(frame, contentArea);
processControlPopup.render(frame, contentArea);
- fileWritePopup.render(frame, contentArea);
+ toolConfirmPopup.render(frame, contentArea);
if (captionOverlay.isCaptionVisible()) {
captionOverlay.render(frame, contentArea);
}
@@ -2453,8 +2444,8 @@ public class CamelMonitor extends CamelCommand {
actionsPopup.renderFooter(spans);
} else if (processControlPopup.isVisible()) {
processControlPopup.renderFooter(spans);
- } else if (fileWritePopup.isVisible()) {
- fileWritePopup.renderFooter(spans);
+ } else if (toolConfirmPopup.isVisible()) {
+ toolConfirmPopup.renderFooter(spans);
} else if (liveEdit.capturesKeys()) {
liveEdit.renderFooter(spans);
} else if (aiPanel.isOpen()) {
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/Confirmations.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/Confirmations.java
new file mode 100644
index 000000000000..cdd68f238084
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/Confirmations.java
@@ -0,0 +1,111 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Queue;
+import java.util.function.BooleanSupplier;
+import java.util.function.Consumer;
+
+import dev.tamboui.tui.event.KeyEvent;
+
+/**
+ * The dialogs that ask the user to confirm an action: quitting, stopping an
integration, a file write or another call
+ * of an AI tool ({@link ToolConfirms}), an AI edit waiting to be saved, a
tool of its own an ACP agent wants to run,
+ * deleting a file or discarding changes. Only the user answers them
(CAMEL-25444): a key an MCP client sends
+ * (tui_send_keys) or a tape plays never reaches one, and is dropped while one
is open. While a tape drives the monitor
+ * nothing asks at all ({@link MonitorContext#confirmFirst}), as the model or
the tape does everything itself.
+ */
+final class Confirmations {
+
+ /** A dialog that asks to confirm, and whether it is open now. */
+ private record Dialog(String name, BooleanSupplier open) {
+ }
+
+ private final List<Dialog> dialogs = new ArrayList<>();
+ private final Queue<McpFacade.PendingKey> pendingKeys;
+ private boolean delivering;
+
+ Confirmations(Queue<McpFacade.PendingKey> pendingKeys) {
+ this.pendingKeys = pendingKeys;
+ }
+
+ /** The dialogs of the monitor that ask to confirm. */
+ static Confirmations of(
+ Queue<McpFacade.PendingKey> pendingKeys, PopupManager popups,
ToolConfirmPopup toolConfirm,
+ ActionsPopup actions, LiveEdit liveEdit, AiPanel aiPanel,
FilesBrowser filesBrowser, TabRegistry tabs) {
+ return new Confirmations(pendingKeys)
+ .add("a confirm dialog", popups::isConfirmVisible)
+ .add("the kill confirmation", popups::isKillConfirmVisible)
+ .add("the confirmation of an AI tool call",
toolConfirm::isVisible)
+ .add("the Stop All dialog", actions::isStopAllVisible)
+ .add("an AI edit that waits to be saved or discarded",
liveEdit::isShown)
+ .add("a permission request of an ACP agent",
aiPanel::isPermissionPopupVisible)
+ .add("a confirm dialog of the file browser",
filesBrowser::isConfirming)
+ .add("a confirm dialog of the tab", () -> {
+ MonitorTab tab = tabs.activeTab();
+ return tab != null && tab.isConfirming();
+ });
+ }
+
+ /** Adds a dialog that asks to confirm, named as the user sees it. */
+ Confirmations add(String name, BooleanSupplier open) {
+ dialogs.add(new Dialog(name, open));
+ return this;
+ }
+
+ /** The dialog that asks to confirm and is open, or null when none is. */
+ String open() {
+ for (Dialog dialog : dialogs) {
+ if (dialog.open().getAsBoolean()) {
+ return dialog.name();
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Delivers the keys an MCP client or a tape sent that are due now, one by
one, as a key can open a dialog. A key
+ * that comes while a dialog asks to confirm is dropped: only the user
answers it.
+ *
+ * @return whether a key was taken from the queue
+ */
+ boolean deliverDue(long now, Consumer<KeyEvent> handler) {
+ boolean taken = false;
+ McpFacade.PendingKey key;
+ while ((key = pendingKeys.peek()) != null && key.isDue(now)) {
+ pendingKeys.poll();
+ taken = true;
+ if (open() != null) {
+ continue;
+ }
+ delivering = true;
+ try {
+ handler.accept(key.event());
+ } finally {
+ delivering = false;
+ }
+ }
+ return taken;
+ }
+
+ /** Whether the key being handled was sent by an MCP client or a tape, not
typed by the user. */
+ boolean isDelivering() {
+ return delivering;
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FileActionsPopup.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FileActionsPopup.java
index 78bad4229dc8..28611727c212 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FileActionsPopup.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FileActionsPopup.java
@@ -18,6 +18,7 @@ package org.apache.camel.dsl.jbang.core.commands.tui;
import java.util.ArrayList;
import java.util.List;
+import java.util.function.BooleanSupplier;
import dev.tamboui.layout.Padding;
import dev.tamboui.layout.Rect;
@@ -90,6 +91,8 @@ class FileActionsPopup {
private Rect popupRect;
private Request result;
+ // no one to ask, as a tape drives the monitor: delete or convert at once
+ private BooleanSupplier unattended = () -> false;
void open(String selectedName, boolean hasTarget) {
open(selectedName, hasTarget, false);
@@ -120,6 +123,15 @@ class FileActionsPopup {
return visible;
}
+ /** Whether it asks the user to confirm deleting or converting the file
(see {@link Confirmations}). */
+ boolean isConfirming() {
+ return visible && phase == Phase.CONFIRM;
+ }
+
+ void setUnattended(BooleanSupplier unattended) {
+ this.unattended = unattended;
+ }
+
Request consumeResult() {
Request r = result;
result = null;
@@ -195,14 +207,16 @@ class FileActionsPopup {
case NEW_FOLDER -> startInput(action, "New folder", "");
case RENAME -> startInput(action, "Rename", targetName);
case DUPLICATE -> startInput(action, "Duplicate",
SourceFileOps.suggestDuplicateName(targetName));
- case DELETE -> {
- confirmAction = action;
- phase = Phase.CONFIRM;
- }
- // the converted file has the same routes: replace the original,
or keep both (and rename one later)
- case CONVERT_YAML, CONVERT_XML, CONVERT_JAVA -> {
- confirmAction = action;
- phase = Phase.CONFIRM;
+ // asks first: delete, or replace the original with the converted
file, which has the same routes (or keep
+ // both, and rename one later); while a tape drives the monitor no
one is there to ask
+ case DELETE, CONVERT_YAML, CONVERT_XML, CONVERT_JAVA -> {
+ if (unattended.getAsBoolean()) {
+ result = new Request(action, action == Action.DELETE ?
null : REPLACE);
+ close();
+ } else {
+ confirmAction = action;
+ phase = Phase.CONFIRM;
+ }
}
case COPY_PATH -> {
result = new Request(action, null);
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FilesBrowser.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FilesBrowser.java
index 25c950d69a8f..0bd03eabb2e8 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FilesBrowser.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FilesBrowser.java
@@ -60,6 +60,11 @@ class FilesBrowser {
return visible;
}
+ /** Whether its editor asks the user to confirm discarding the changes
(see {@link Confirmations}). */
+ boolean isConfirming() {
+ return visible && sourceViewer.isDiscardPending();
+ }
+
boolean isSourceViewerTextInputActive() {
return sourceViewer.isTextInputActive();
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
index 048cebe3cecb..76f9c7b08612 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
@@ -109,6 +109,15 @@ class McpFacade {
return false;
}
+ /**
+ * Asks the user to confirm a call of an AI tool that reaches beyond
the integration, may destroy data or runs
+ * code ({@link ToolConfirms}), blocking the calling (tool) thread
until the user answers. Returns false when
+ * the user declines, or when no one answers.
+ */
+ default boolean confirmToolCall(ToolConfirms.Call call) {
+ return false;
+ }
+
/**
* Replays a file write in the source editor so the user watches the
edit happen and then saves or discards it
* (write mode {@code live}), blocking the calling (tool) thread until
then. Returns null when the replay could
@@ -227,6 +236,7 @@ class McpFacade {
// the F2 menu's launcher: starts examples (tui_run_example) and infra
services (tui_infra start). It is held
// here, not in the tool registry, so the AI panel's registry and the MCP
server's registry both see it.
private volatile LaunchManager launchManager;
+ private volatile Confirmations confirmations;
// asks the data refresh for a scan of every integration, and counts the
scans done: on the other tabs only the
// selected integration is refreshed, so the Overview rows of the others
go stale while an agent reads them
private volatile Runnable fullScanRequest;
@@ -274,6 +284,19 @@ class McpFacade {
this.launchManager = launchManager;
}
+ void setConfirmations(Confirmations confirmations) {
+ this.confirmations = confirmations;
+ }
+
+ /**
+ * The dialog that asks the user to confirm an action and is open, or
null: the keys an MCP client sends never
+ * answer it (CAMEL-25444).
+ */
+ String openConfirmation() {
+ Confirmations c = confirmations;
+ return c != null ? c.open() : null;
+ }
+
void setFullScan(Runnable request, LongSupplier done) {
this.fullScanRequest = request;
this.fullScansDone = done;
@@ -1255,6 +1278,34 @@ class McpFacade {
return wait;
}
+ /**
+ * Asks the user to confirm a call of an AI tool ({@link ToolConfirms}),
on the calling (tool) thread. Returns null
+ * when the call goes ahead: there is nothing to confirm, the user
confirmed it, or a tape drives the monitor (then
+ * nothing asks, CAMEL-25444). Otherwise returns the result that tells the
model the user rejected it.
+ */
+ JsonObject confirm(ToolConfirms.Call call) {
+ if (call == null || ctx != null && ctx.tapeDriven.getAsBoolean()) {
+ return null;
+ }
+ long waitStart = MonotonicClock.millis();
+ boolean confirmed = bridge != null && bridge.confirmToolCall(call);
+ lastConfirmWaitMs = MonotonicClock.millis() - waitStart;
+ if (confirmed) {
+ return null;
+ }
+ JsonObject result = new JsonObject();
+ result.put("status", "rejected");
+ result.put("message", "The user did not confirm this in the TUI, so it
did not run (" + call.title() + "). Do not"
+ + " retry it unless the user asks for it: tell
the user what you wanted to do, and why.");
+ return result;
+ }
+
+ /** The endpoint URIs of the selected integration, as its status lists
them. */
+ List<String> selectedEndpointUris() {
+ IntegrationInfo info = ctx == null ? null :
ctx.findSelectedIntegration();
+ return info == null ? List.of() : info.endpoints.stream().map(e ->
e.uri).filter(uri -> uri != null).toList();
+ }
+
private static void describeSourceDirectory(IntegrationInfo info, Path
dir, JsonObject result) {
boolean temporary = FilesBrowser.isTemporaryDirectory(dir);
result.put("directory", dir.toString());
@@ -1400,7 +1451,9 @@ class McpFacade {
return result;
}
}
- if (writeMode == WriteMode.LIVE && exists && bridge != null) {
+ // while a tape drives the monitor nothing asks the user: the model or
the tape writes the file itself
+ boolean ask = ctx == null || !ctx.tapeDriven.getAsBoolean();
+ if (writeMode == WriteMode.LIVE && exists && bridge != null && ask) {
long waitStart = MonotonicClock.millis();
ReplayOutcome outcome = bridge.replayFileWrite(request);
lastConfirmWaitMs = MonotonicClock.millis() - waitStart;
@@ -1409,7 +1462,7 @@ class McpFacade {
}
// could not replay (for example unsaved edits in the editor):
fall back to the dialog
}
- if (confirm || writeMode != WriteMode.AUTO) {
+ if ((confirm || writeMode != WriteMode.AUTO) && ask) {
long waitStart = MonotonicClock.millis();
boolean confirmed = bridge != null &&
bridge.confirmFileWrite(request);
lastConfirmWaitMs = MonotonicClock.millis() - waitStart;
@@ -1578,7 +1631,8 @@ class McpFacade {
} catch (NumberFormatException e) {
return null;
}
- return RuntimeHelper.sendMessage(pid, endpoint, body, headers);
+ JsonObject rejected = confirm(ToolConfirms.sendMessage(endpoint,
selectedEndpointUris()));
+ return rejected != null ? rejected : RuntimeHelper.sendMessage(pid,
endpoint, body, headers);
}
JsonObject executeSql(String sql, String datasource, int maxRows, int
queryTimeout) {
@@ -1591,7 +1645,8 @@ class McpFacade {
} catch (NumberFormatException e) {
return null;
}
- return RuntimeHelper.executeSqlQuery(pid, sql, datasource, maxRows,
queryTimeout);
+ JsonObject rejected = confirm(ToolConfirms.sql(sql));
+ return rejected != null ? rejected :
RuntimeHelper.executeSqlQuery(pid, sql, datasource, maxRows, queryTimeout);
}
JsonObject updateRow(String table, String datasource, String pkValuesJson,
String colValuesJson) {
@@ -1604,7 +1659,9 @@ class McpFacade {
} catch (NumberFormatException e) {
return null;
}
- return RuntimeHelper.executeRowUpdate(pid, table, datasource,
pkValuesJson, colValuesJson);
+ JsonObject rejected = confirm(ToolConfirms.updateRow(table,
colValuesJson));
+ return rejected != null
+ ? rejected : RuntimeHelper.executeRowUpdate(pid, table,
datasource, pkValuesJson, colValuesJson);
}
String controlIntegration(String action) {
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java
index 311d4c85eacb..b7a0e34ece34 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java
@@ -29,6 +29,7 @@ import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.BiConsumer;
+import java.util.function.BooleanSupplier;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -71,6 +72,11 @@ class MonitorContext {
boolean logPinVisible;
boolean ratePerMinute;
boolean confirmActions;
+ /**
+ * Whether a tape drives the monitor: a model records one
(tui_tape_start), or {@code --record} plays one. Nothing
+ * asks the user to confirm then (see {@link Confirmations}).
+ */
+ BooleanSupplier tapeDriven = () -> false;
boolean validateOnSave = true;
// whether the source editor shows the route tree at first (the Route Tree
setting)
boolean routeTree;
@@ -149,6 +155,15 @@ class MonitorContext {
return focused && !bottomPanelFocused ? Style.EMPTY.fg(Theme.accent())
: unfocused;
}
+ /**
+ * Whether to ask the user before an action such as quitting or stopping
an integration (the Confirm actions
+ * setting). While a tape drives the monitor nothing asks: the model or
the tape does everything itself, and the
+ * keys it sends would not answer the dialog anyway (CAMEL-25444).
+ */
+ boolean confirmFirst() {
+ return confirmActions && !tapeDriven.getAsBoolean();
+ }
+
String selectedName() {
IntegrationInfo info = findSelectedIntegration();
if (info != null) {
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorTab.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorTab.java
index a8abee9ab3a9..0cd774be2449 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorTab.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorTab.java
@@ -63,6 +63,14 @@ interface MonitorTab {
return false;
}
+ /**
+ * Whether the tab shows a dialog that asks the user to confirm an action,
such as discarding changes or deleting a
+ * file. Only the user answers it: the keys an MCP client sends never
reach it (see {@link Confirmations}).
+ */
+ default boolean isConfirming() {
+ return false;
+ }
+
/**
* Pastes text into the text field that has the keyboard; returns {@code
false} when none has, as the default does.
*/
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ProcessControlPopup.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ProcessControlPopup.java
index e2963cfb68e2..4ad008f5166c 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ProcessControlPopup.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ProcessControlPopup.java
@@ -218,7 +218,7 @@ class ProcessControlPopup {
case STOP -> confirmThen("Confirm Stop", "Stop", () ->
actions.stopSelectedProcess(false));
case KILL -> {
// Kill has a dedicated (error-styled) confirm dialog that
performs the force-kill itself.
- if (ctx.confirmActions) {
+ if (ctx.confirmFirst()) {
actions.showKillConfirm();
} else {
actions.stopSelectedProcess(true);
@@ -233,7 +233,7 @@ class ProcessControlPopup {
* Kill all terminate a running process, so they share the same "are you
sure?" gate as Quit and Stop All.
*/
private void confirmThen(String title, String verb, Runnable action) {
- if (!ctx.confirmActions) {
+ if (!ctx.confirmFirst()) {
action.run();
return;
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceTab.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceTab.java
index 3d8848fcec8b..1d0fbcdc8cd3 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceTab.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceTab.java
@@ -151,6 +151,8 @@ class SourceTab extends AbstractTab {
SourceTab(MonitorContext ctx) {
super(ctx);
+ // while a tape drives the monitor nothing asks: a file is deleted or
converted at once
+ fileActionsPopup.setUnattended(() -> ctx.tapeDriven.getAsBoolean());
sourceViewer.setNotificationCallback((msg, error) -> {
if (ctx.notificationCallback != null) {
ctx.notificationCallback.accept(msg, error);
@@ -189,6 +191,11 @@ class SourceTab extends AbstractTab {
return sourceViewer.isTextInputActive() ||
fileActionsPopup.isVisible() || isGotoPopupVisible();
}
+ @Override
+ public boolean isConfirming() {
+ return sourceViewer.isDiscardPending() ||
fileActionsPopup.isConfirming();
+ }
+
@Override
public boolean handlePaste(String text) {
if (!isTextInputActive()) {
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java
index 8a865ea16e53..284e56c1100c 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java
@@ -480,6 +480,10 @@ class SourceViewer {
return plainMode;
}
+ boolean isDiscardPending() {
+ return pendingDiscard;
+ }
+
/** Whether a dialog over the editor has the keys (discard, the problems
of a save, the diff, refactor). */
boolean showsDialog() {
return pendingDiscard || editMode && (validationErrors != null ||
diffOverlay || refactor.isPopupOpen());
@@ -2200,7 +2204,7 @@ class SourceViewer {
renderValidationPopup(frame, area);
}
if (pendingDiscard) {
- renderDiscardPopup(frame, area);
+ DialogHelper.renderConfirm(frame, area, "Discard Changes?",
"Unsaved changes will be lost.", false);
}
refactor.render(frame, area);
}
@@ -2316,10 +2320,6 @@ class SourceViewer {
}
}
- private void renderDiscardPopup(Frame frame, Rect area) {
- DialogHelper.renderConfirm(frame, area, "Discard Changes?", "Unsaved
changes will be lost.", false);
- }
-
private static void wrapText(String text, int width, List<Line> out) {
if (width <= 0) {
width = 40;
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StopAllPopup.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StopAllPopup.java
index 858e3d06d0c2..de7b54ff8c57 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StopAllPopup.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StopAllPopup.java
@@ -48,6 +48,7 @@ class StopAllPopup {
private boolean visible;
private boolean confirmActions;
+ private boolean unattended;
private boolean checkIntegrations = true;
private boolean checkInfra = true;
private int selectedRow;
@@ -72,6 +73,11 @@ class StopAllPopup {
this.confirmActions = confirmActions;
}
+ /** Whether no one is there to choose, as a tape drives the monitor: then
all of it is stopped at once. */
+ void setUnattended(boolean unattended) {
+ this.unattended = unattended;
+ }
+
boolean hasBothGroups() {
List<IntegrationInfo> ints = integrations.get();
List<InfraInfo> infras = infraServices.get();
@@ -106,6 +112,10 @@ class StopAllPopup {
checkIntegrations = integrationCount > 0;
checkInfra = infraCount > 0;
+ if (unattended) {
+ executeStop();
+ return;
+ }
selectedRow = 0;
visible = true;
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FileWritePopup.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ToolConfirmPopup.java
similarity index 62%
rename from
dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FileWritePopup.java
rename to
dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ToolConfirmPopup.java
index 28a40b13808d..06775b163358 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FileWritePopup.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ToolConfirmPopup.java
@@ -19,6 +19,7 @@ package org.apache.camel.dsl.jbang.core.commands.tui;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
import dev.tamboui.layout.Rect;
import dev.tamboui.style.Style;
@@ -39,15 +40,26 @@ import static
org.apache.camel.dsl.jbang.core.commands.tui.TuiHelper.hint;
import static org.apache.camel.dsl.jbang.core.commands.tui.TuiHelper.hintLast;
/**
- * Asks the user to apply a file write requested by an AI tool. The summary
names the file, the directory and the size
- * of the change; {@code d} switches to a unified diff (removed lines on red,
added lines on green, like the source
- * editor's F7 overlay), so the user sees what the model wants to change
before deciding. The diff is a view only:
- * Enter, Esc and d return to the summary, where Enter applies the write and
Esc rejects it.
+ * Asks the user to confirm what an AI tool wants to do: write a file, or one
of the calls {@link ToolConfirms} names (a
+ * message to an endpoint the integration does not have, SQL that is not a
SELECT, an expression in a language that runs
+ * code). For a file the summary names the file, the directory and the size of
the change; {@code d} switches to a
+ * unified diff (removed lines on red, added lines on green, like the source
editor's F7 overlay), so the user sees what
+ * the model wants to change before deciding. The diff is a view only: Enter,
Esc and d return to the summary. For a
+ * call the popup shows the endpoint, the statement or the expression, and why
it asks. Enter applies or runs it, Esc
+ * rejects it.
*/
-final class FileWritePopup {
+final class ToolConfirmPopup {
- private boolean visible;
+ // how long a tool waits for the user before it gives up, as rejected
+ private static final long WAIT_MINUTES = 2;
+ // the lines of a statement or an expression the popup shows
+ private static final int MAX_TARGET_LINES = 6;
+
+ // two tools that ask at once wait in turn, rather than one replacing the
other's question
+ private final Object asking = new Object();
+ private volatile boolean visible;
private McpFacade.FileWrite request;
+ private ToolConfirms.Call call;
private CompletableFuture<Boolean> answer;
private List<EditDiff.DiffEntry> entries = List.of();
private String summary = "";
@@ -63,19 +75,61 @@ final class FileWritePopup {
return visible && showDiff;
}
+ /** Asks to apply a file write, blocking the calling (tool) thread until
the user answers or no one does. */
+ boolean ask(McpFacade.FileWrite request) {
+ synchronized (asking) {
+ CompletableFuture<Boolean> pending = new CompletableFuture<>();
+ open(request, pending);
+ return await(pending);
+ }
+ }
+
+ /** Asks to run a call, blocking the calling (tool) thread until the user
answers or no one does. */
+ boolean ask(ToolConfirms.Call call) {
+ synchronized (asking) {
+ CompletableFuture<Boolean> pending = new CompletableFuture<>();
+ open(call, pending);
+ return await(pending);
+ }
+ }
+
+ private boolean await(CompletableFuture<Boolean> pending) {
+ try {
+ return pending.get(WAIT_MINUTES, TimeUnit.MINUTES);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ close();
+ return false;
+ } catch (Exception e) {
+ close();
+ return false;
+ }
+ }
+
void open(McpFacade.FileWrite request, CompletableFuture<Boolean> answer) {
- this.request = request;
- this.answer = answer;
List<String> before = request.oldContent() == null ||
request.oldContent().isEmpty()
? List.of() : request.oldContent().lines().toList();
List<String> after = request.newContent().isEmpty() ? List.of() :
request.newContent().lines().toList();
this.entries = EditDiff.unifiedDiff(before, after, 3);
this.summary = EditDiff.summary(entries);
+ this.call = null;
+ this.request = request;
+ this.answer = answer;
this.showDiff = false;
this.diffScrollY = 0;
this.visible = true;
}
+ void open(ToolConfirms.Call call, CompletableFuture<Boolean> answer) {
+ this.entries = List.of();
+ this.summary = "";
+ this.request = null;
+ this.call = call;
+ this.answer = answer;
+ this.showDiff = false;
+ this.visible = true;
+ }
+
/** Closes without an answer (the tool gave up waiting); a pending future
is completed as rejected. */
void close() {
visible = false;
@@ -111,7 +165,7 @@ final class FileWritePopup {
finish(true);
} else if (ke.isCancel()) {
finish(false);
- } else if (ke.isChar('d') || ke.isChar('D')) {
+ } else if (request != null && (ke.isChar('d') || ke.isChar('D'))) {
showDiff = true;
diffScrollY = 0;
}
@@ -128,7 +182,14 @@ final class FileWritePopup {
}
void render(Frame frame, Rect area) {
- if (!visible || request == null) {
+ if (!visible) {
+ return;
+ }
+ if (call != null) {
+ renderCall(frame, area);
+ return;
+ }
+ if (request == null) {
return;
}
Style accent = Theme.warning();
@@ -156,7 +217,33 @@ final class FileWritePopup {
hint(hints, "d", "diff");
hintLast(hints, "Esc", "reject");
lines.add(Line.from(hints));
+ renderBox(frame, area, accent, titleText, lines);
+ }
+
+ /** The endpoint, the statement or the expression, why it asks, and the
keys. */
+ private void renderCall(Frame frame, Rect area) {
+ Style accent = Theme.warning();
+ String titleText = " AI wants to " + call.title() + " ";
+ int width = Math.max(10, area.width() - 10);
+ List<Line> lines = new ArrayList<>();
+ lines.add(Line.empty());
+ List<String> target = call.target() == null ? List.of() :
call.target().strip().lines().toList();
+ for (String line : target.subList(0, Math.min(target.size(),
MAX_TARGET_LINES))) {
+ lines.add(Line.from(Span.styled(TuiHelper.truncate(line, width),
accent.bold())));
+ }
+ if (target.size() > MAX_TARGET_LINES) {
+ lines.add(Line.from(Span.styled("... " + (target.size() -
MAX_TARGET_LINES) + " more lines", Theme.muted())));
+ }
+ lines.add(Line.from(Span.styled(call.reason(), Style.EMPTY.dim())));
+ lines.add(Line.empty());
+ List<Span> hints = new ArrayList<>();
+ hint(hints, "Enter", "run");
+ hintLast(hints, "Esc", "reject");
+ lines.add(Line.from(hints));
+ renderBox(frame, area, accent, titleText, lines);
+ }
+ private static void renderBox(Frame frame, Rect area, Style accent, String
titleText, List<Line> lines) {
int longest = lines.stream().mapToInt(l ->
l.toString().length()).max().orElse(20);
int popupW = Math.max(1, Math.min(area.width() - 4, Math.max(longest +
6, titleText.length() + 4)));
int popupH = Math.min(area.height() - 2, lines.size() + 2);
@@ -189,7 +276,10 @@ final class FileWritePopup {
}
void renderFooter(List<Span> spans) {
- if (showDiff) {
+ if (call != null) {
+ hint(spans, "Enter", "run");
+ hintLast(spans, "Esc", "reject");
+ } else if (showDiff) {
hint(spans, "↑↓/PgUp/PgDn", "scroll");
hintLast(spans, "Enter/Esc", "back");
} else {
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ToolConfirms.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ToolConfirms.java
new file mode 100644
index 000000000000..876235757f41
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ToolConfirms.java
@@ -0,0 +1,180 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.net.URISyntaxException;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.util.Collection;
+import java.util.Locale;
+import java.util.Set;
+
+import org.apache.camel.util.URISupport;
+
+/**
+ * The calls of the AI tools that the user confirms first. Camel TUI is a
developer tool: the AI panel's model, an ACP
+ * agent and an MCP client run its tools without asking, so the AI can show
and tell at once (see "Developer tools" in
+ * the security model). The exceptions are the calls that reach beyond the
integration, may destroy data or run code:
+ * <ul>
+ * <li>tui_send_message to an endpoint the integration does not have; a route
id, a pattern and an internal endpoint
+ * (direct:, seda:, log:, mock: and the like) stay inside it;</li>
+ * <li>tui_execute_sql with a statement that is not a single SELECT (or SHOW,
DESCRIBE), and tui_update_row;</li>
+ * <li>camel_eval_expression in a language that runs code, such as Groovy.</li>
+ * </ul>
+ * A file write has its own confirmation (the write mode), and
tui_http_request reaches only the integration's own
+ * server. While a tape drives the monitor nothing asks ({@link
McpFacade#confirm}).
+ */
+final class ToolConfirms {
+
+ /** A call the user confirms: what the AI wants to do, the call (an
endpoint, a statement, an expression), why. */
+ record Call(String title, String target, String reason) {
+ }
+
+ /** The expression languages that run code, in the integration (or the TUI
when none is selected). */
+ static final Set<String> CODE_LANGUAGES
+ = Set.of("groovy", "java", "joor", "js", "javascript", "python",
"mvel", "ognl", "spel");
+
+ /**
+ * The components that stay inside the integration: its routes and
in-memory queues, its log and the test endpoints.
+ * Not every component the catalog marks remote=false: exec, class, bean,
language and the components that load a
+ * template or a stylesheet by URI are not remote either, but run code, or
read a file or a URL.
+ */
+ static final Set<String> INTERNAL_SCHEMES = Set.of(
+ "direct", "seda", "disruptor", "disruptor-vm", "log", "mock",
"stub", "browse", "dataset", "dataset-test");
+
+ private ToolConfirms() {
+ }
+
+ /** A message to an endpoint the integration does not have, or null when
it stays inside the integration. */
+ static Call sendMessage(String endpoint, Collection<String> endpointUris) {
+ if (staysInside(endpoint, endpointUris)) {
+ return null;
+ }
+ return new Call(
+ "send a message to an endpoint the integration does not have",
endpoint,
+ "The message may leave the integration: to a file, a broker or
a service");
+ }
+
+ /**
+ * Whether a send stays inside the integration: a route id or a pattern
(the send console picks one of the routes),
+ * an internal endpoint, or an endpoint the integration has (its endpoint
registry, as its status lists it).
+ */
+ static boolean staysInside(String endpoint, Collection<String>
endpointUris) {
+ // the same tests as SendDevConsole: without a scheme, or ending with
*, it names a route of the integration
+ if (!endpoint.contains(":") || endpoint.endsWith("*")) {
+ return true;
+ }
+ // an option can still reach out: #class:...(args) runs any
constructor (a FileOutputStream truncates a file),
+ // and a {{placeholder}} can put a secret in the log
+ String decoded = decode(endpoint);
+ if (INTERNAL_SCHEMES.contains(endpoint.substring(0,
endpoint.indexOf(':')))
+ && !decoded.contains("#") && !decoded.contains("{{")) {
+ return true;
+ }
+ String uri = normalize(endpoint);
+ return uri != null && endpointUris.stream().anyMatch(e ->
uri.equals(normalize(e)));
+ }
+
+ /**
+ * The normalized URI, decoded: normalizing keeps an option as it is
written, localhost%3A9092 or localhost:9092.
+ */
+ private static String normalize(String uri) {
+ try {
+ return uri != null ? decode(URISupport.normalizeUri(uri)) : null;
+ } catch (URISyntaxException | RuntimeException e) {
+ return null;
+ }
+ }
+
+ private static String decode(String uri) {
+ try {
+ return URLDecoder.decode(uri.replace("+", "%2B"),
StandardCharsets.UTF_8);
+ } catch (IllegalArgumentException e) {
+ // a lone % in an option
+ return uri;
+ }
+ }
+
+ /** A statement that is not a single SELECT (or SHOW, DESCRIBE), or null
for one. */
+ static Call sql(String query) {
+ if (readsOnly(query)) {
+ return null;
+ }
+ return new Call("run SQL that is not a SELECT", query, "It may change
or drop data in the integration's database");
+ }
+
+ static Call updateRow(String table, String columnValues) {
+ return new Call("update a row of " + table, columnValues, "It changes
data in the integration's database");
+ }
+
+ /**
+ * Whether a statement only reads: a single SELECT, SHOW or DESCRIBE, with
no INTO (which creates a table or writes
+ * a file). EXPLAIN is not one, as EXPLAIN ANALYZE runs the statement.
Comments and the text of literals do not
+ * count, so {@code SELECT ';'} is one statement, and {@code SELECT 1;
DROP TABLE t} is two.
+ */
+ static boolean readsOnly(String sql) {
+ String code = codeOnly(sql).strip();
+ while (code.endsWith(";")) {
+ code = code.substring(0, code.length() - 1).strip();
+ }
+ while (code.startsWith("(")) {
+ code = code.substring(1).strip();
+ }
+ String lower = code.toLowerCase(Locale.ROOT);
+ return lower.matches("(select|show|describe|desc)\\b[\\s\\S]*") &&
!lower.contains(";")
+ && !lower.matches("[\\s\\S]*\\binto\\b[\\s\\S]*");
+ }
+
+ /** The statement without comments, and with each literal or quoted name
as a blank. */
+ private static String codeOnly(String sql) {
+ StringBuilder code = new StringBuilder();
+ int i = 0;
+ int n = sql == null ? 0 : sql.length();
+ while (i < n) {
+ char c = sql.charAt(i);
+ if (c == '-' && i + 1 < n && sql.charAt(i + 1) == '-') {
+ i = sql.indexOf('\n', i);
+ i = i < 0 ? n : i;
+ } else if (c == '/' && i + 1 < n && sql.charAt(i + 1) == '*') {
+ int end = sql.indexOf("*/", i + 2);
+ i = end < 0 ? n : end + 2;
+ code.append(' ');
+ } else if (c == '\'' || c == '"' || c == '`') {
+ // to the closing quote; a doubled quote is part of the text
+ int j = i + 1;
+ while (j < n && (sql.charAt(j) != c || j + 1 < n &&
sql.charAt(j + 1) == c)) {
+ j += sql.charAt(j) == c ? 2 : 1;
+ }
+ i = j + 1;
+ code.append(' ');
+ } else {
+ code.append(c);
+ i++;
+ }
+ }
+ return code.toString();
+ }
+
+ /** An expression in a language that runs code, or null. */
+ static Call evalExpression(String language, String expression) {
+ String lang = language == null ? "" : language.strip();
+ if (!CODE_LANGUAGES.contains(lang.toLowerCase(Locale.ROOT))) {
+ return null;
+ }
+ return new Call("evaluate a " + lang + " expression", expression, "It
runs code on this machine");
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolDefinitions.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolDefinitions.java
index 2311e88b89fb..e03028d2cc5d 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolDefinitions.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolDefinitions.java
@@ -59,9 +59,9 @@ final class TuiToolDefinitions {
/**
* The neutral authoring tools shared with camel-jbang-mcp (CAMEL-24695):
defined once in the shared registry and
- * exposed here under the same {@code camel_} names, so an agent gets the
same Camel through either MCP server. Two
- * of them get a TUI extra: camel_control also knows the TUI's stop-all
and close actions, and camel_get_log also
- * reads the log of an infra service.
+ * exposed here under the same {@code camel_} names, so an agent gets the
same Camel through either MCP server.
+ * Three of them get a TUI extra: camel_control also knows the TUI's
stop-all and close actions, camel_get_log also
+ * reads the log of an infra service, and camel_eval_expression asks the
user first in a language that runs code.
*/
private static void addSharedTools(List<TuiTool> tools, TuiToolRegistry r)
{
for (ToolDescriptor td : ToolRegistry.authoringTools()) {
@@ -77,6 +77,8 @@ final class TuiToolDefinitions {
} else if (TuiToolRegistry.LOG_TOOL.equals(td.name())) {
description += " With infra=<alias> the log of an infra
service instead.";
properties.put("infra", propDef("string", "Infra service alias
whose log to read instead"));
+ } else if (TuiToolRegistry.EVAL_TOOL.equals(td.name())) {
+ description += " A language that runs code, such as groovy,
asks the user first.";
}
String name = td.name();
tools.add(new TuiTool(
@@ -136,11 +138,11 @@ final class TuiToolDefinitions {
tools.add(tool(toolDef(
"tui_send_keys",
- "Sends key presses to the TUI. A human is watching the screen,
"
- + "so keys should be paced naturally like a
skilled user would type. "
- + "Key names: Enter, Esc, Tab, Backspace,
Delete, Up, Down, Left, Right, "
- + "Home, End, PgUp, PgDn, Space, F1-F12, or
any single character. "
- + "Modifiers: Ctrl+x, Shift+x, Ctrl+Shift+x.",
+ "Sends key presses to the TUI. A human is watching the screen,
so keys should be paced naturally "
+ + "like a skilled user would type. Key names:
Enter, Esc, Tab, Backspace, Delete, Up, "
+ + "Down, Left, Right, Home, End, PgUp, PgDn,
Space, F1-F12, or any single character. "
+ + "Modifiers: Ctrl+x, Shift+x, Ctrl+Shift+x.
Keys never answer a dialog that asks the "
+ + "user to confirm an action (quit, a file
write): only the user does.",
Map.of("keys", propDef("array", "Array of key name strings to
send"),
"delay", propDef("integer",
"Delay in milliseconds between keys (default
150, minimum 80)"),
@@ -343,11 +345,11 @@ final class TuiToolDefinitions {
Map.of()), args -> r.callGetTopology(), READ_ONLY, CORE));
tools.add(tool(toolDef(
"tui_send_message",
- "Sends a message to any Camel endpoint URI from inside the
selected integration: direct:/seda: "
- + "to feed a route, or a producer such as
paho-mqtt5:, kafka:, jms:, http:, file: "
- + "to publish to the system a route
consumes from. A route that only consumes from "
- + "a broker has no direct: endpoint;
publish to the broker with the same component "
- + "and options the route uses.",
+ "Sends a message from inside the selected integration:
direct:/seda: to feed a route, or a "
+ + "producer such as paho-mqtt5:, kafka:,
jms: to publish to the system a route "
+ + "consumes from. A route that only
consumes from a broker has no direct: endpoint; "
+ + "publish to the broker with the same
component and options the route uses. An "
+ + "endpoint neither internal (log:, mock:)
nor the integration's own asks first.",
Map.of("endpoint", propDef("string",
"Endpoint URI, e.g. 'direct:myRoute',
'paho-mqtt5:temperature?brokerUrl=tcp://localhost:1883'"),
"body", propDef("string", "Message body to send"),
@@ -371,10 +373,9 @@ final class TuiToolDefinitions {
List.of("path")), args -> r.executeShared("http_request",
args)));
tools.add(tool(toolDef(
"tui_execute_sql",
- "Executes a SQL query against a DataSource in the selected
integration. "
- + "Returns structured JSON with columns,
rows, and metadata for SELECT queries, "
- + "or an update count for
INSERT/UPDATE/DELETE. "
- + "Requires dev console to be enabled in
the running application.",
+ "Executes SQL against a DataSource of the selected
integration: columns and rows for a SELECT, "
+ + "an update count otherwise. A statement
other than SELECT, SHOW or DESCRIBE "
+ + "asks the user first. Needs the dev
console of the integration.",
Map.of("query", propDef("string", "The SQL query to execute"),
"datasource", propDef("string",
"Name of the DataSource bean (auto-detected if
only one exists)"),
@@ -385,9 +386,8 @@ final class TuiToolDefinitions {
List.of("query")), r::callExecuteSql));
tools.add(tool(toolDef(
"tui_update_row",
- "Updates a single row in a database table. Use after
tui_execute_sql returns "
- + "editable=true with tableName and
primaryKeys. Builds and executes "
- + "an UPDATE statement using
PreparedStatement for safety.",
+ "Updates one row of a table with a PreparedStatement, after
tui_execute_sql returned "
+ + "editable=true with tableName and
primaryKeys; asks the user first.",
Map.of("table", propDef("string", "The table name to update"),
"primaryKeyValues", propDef("string",
"JSON object of primary key column-value
pairs, e.g. {\"id\": 1}"),
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
index 99b2be49a690..f843e5e8fac8 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
@@ -109,6 +109,14 @@ class TuiToolRegistry {
byName.put("name", d);
args = byName;
}
+ if (EVAL_TOOL.equals(name) && facade != null) {
+ // Groovy, and the other languages that run code, ask the user
first
+ JsonObject rejected = facade.confirm(ToolConfirms.evalExpression(
+ args.get("language") instanceof String l ? l : null,
String.valueOf(args.get("expression"))));
+ if (rejected != null) {
+ return Jsoner.serialize(rejected);
+ }
+ }
if (fileTools != null && TuiFileTools.TOOLS.contains(name)) {
return fileTools.execute(name, args);
}
@@ -180,6 +188,7 @@ class TuiToolRegistry {
static final String EDIT_TOOL = "camel_edit_file";
static final String VALIDATE_TOOL = "camel_validate_source";
static final String SAVE_SUMMARY_TOOL = "camel_save_project_summary";
+ static final String EVAL_TOOL = "camel_eval_expression";
/**
* The tools needed to answer questions and troubleshoot from the built-in
AI panel, those flagged
@@ -192,13 +201,21 @@ class TuiToolRegistry {
/**
* Tools that inspect information without executing application actions,
editing data or changing the visible view,
- * those flagged {@link TuiTool.Flag#READ_ONLY}. The ACP permission
handler approves calls to these without asking;
- * anything else, including camel_control, tui_send_message and
tui_execute_sql, is put in front of the user.
- * camel_eval_expression is read-only because it evaluates an expression
against a scratch exchange and sends
- * nothing through a route.
+ * those flagged {@link TuiTool.Flag#READ_ONLY}. Nothing asks before a
tool runs, read-only or not: the few calls
+ * the user confirms are in {@link ToolConfirms}. camel_eval_expression is
read-only because it evaluates an
+ * expression against a scratch exchange and sends nothing through a
route; in a language that runs code it asks
+ * first.
*/
static final Set<String> READ_ONLY_TOOLS =
namesWith(TuiTool.Flag.READ_ONLY);
+ private static final Set<String> ALL_TOOLS = TuiToolDefinitions.all(new
TuiToolRegistry(null)).stream()
+ .map(TuiTool::name).collect(Collectors.toUnmodifiableSet());
+
+ /** Whether a name is a tool of the TUI: one it lists, or a shared camel_
tool it runs (see execute). */
+ static boolean isTool(String name) {
+ return ALL_TOOLS.contains(name) || name.startsWith(SHARED_PREFIX) &&
ToolRegistry.findTool(name) != null;
+ }
+
private static Set<String> namesWith(TuiTool.Flag flag) {
// the flags do not depend on the facade: a registry without one
defines the same tools
return TuiToolDefinitions.all(new TuiToolRegistry(null)).stream()
@@ -478,6 +495,12 @@ class TuiToolRegistry {
if (keys.isEmpty()) {
return "Error: keys array is empty";
}
+ String confirmation = facade.openConfirmation();
+ if (confirmation != null) {
+ // only the user answers a dialog that asks to confirm an action
(CAMEL-25444)
+ return "Not sent: " + confirmation + " is open, and only the user
answers it. Wait for the user, or tell"
+ + " them what you need: keys sent while it is open are
dropped.";
+ }
int delay = 150;
Object delayArg = args.get("delay");
if (delayArg instanceof Number n) {
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelAcpTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelAcpTest.java
index 6681b0ee5793..3f733f6aa63c 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelAcpTest.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelAcpTest.java
@@ -19,7 +19,9 @@ package org.apache.camel.dsl.jbang.core.commands.tui;
import java.io.IOException;
import java.nio.file.Path;
import java.time.Duration;
+import java.util.List;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
import dev.tamboui.buffer.Buffer;
import dev.tamboui.layout.Rect;
@@ -512,7 +514,7 @@ class AiPanelAcpTest {
askPermissionDuringPrompt(permissionParams("mcp__camel-tui__tui_get_state",
"tui_get_state"));
ask(panel, "hi");
awaitIdle(panel);
- assertFalse(panel.isPermissionPopupVisibleForTesting());
+ assertFalse(panel.isPermissionPopupVisible());
assertTrue(hasEntry(panel, AiRole.SYSTEM, "(stopped: opt-always)"),
"allow_always preferred");
}
@@ -526,14 +528,42 @@ class AiPanelAcpTest {
}
@Test
- void mutatingTuiToolOpensThePopupEvenByName() throws Exception {
+ void aTuiToolThatChangesSomethingIsApprovedToo() throws Exception {
+ // the TUI is a developer tool: its tools run without asking, and it
asks itself before the few calls that
+ // need it (ToolConfirms)
AiPanel panel = acpPanel();
askPermissionDuringPrompt(permissionParams("mcp__camel-tui__camel_control",
"camel_control"));
ask(panel, "hi");
- await().atMost(5, TimeUnit.SECONDS).untilAsserted(() ->
assertTrue(panel.isPermissionPopupVisibleForTesting()));
+ awaitIdle(panel);
+ assertFalse(panel.isPermissionPopupVisible());
+ assertTrue(hasEntry(panel, AiRole.SYSTEM, "(stopped: opt-always)"));
+ }
+
+ @Test
+ void aTitleThatNamesNoToolOfTheTuiOpensThePopup() throws Exception {
+ AiPanel panel = acpPanel();
+ askPermissionDuringPrompt(permissionParams(null, "anything (camel-tui
MCP Server)"));
+ ask(panel, "hi");
+ await().atMost(5, TimeUnit.SECONDS).untilAsserted(() ->
assertTrue(panel.isPermissionPopupVisible()));
panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.ESCAPE,
KeyModifiers.NONE));
awaitIdle(panel);
- assertTrue(hasEntry(panel, AiRole.SYSTEM, "(stopped: opt-reject)"),
"camel_control is not read-only");
+ assertTrue(hasEntry(panel, AiRole.SYSTEM, "(stopped: opt-reject)"),
"anything is not a tool of the TUI");
+ }
+
+ @Test
+ void
aShellCommandOfTheAgentIsApprovedWithoutAskingWhileATapeDrivesTheMonitor()
throws Exception {
+ // CAMEL-25444: a model records a tape, or --record plays one, and no
one is there to answer
+ AiPanel panel = acpPanel();
+ MonitorContext ctx = new MonitorContext(new
AtomicReference<>(List.of()), new AtomicReference<>(List.of()));
+ ctx.tapeDriven = () -> true;
+ panel.setContext(ctx);
+ JsonObject permission = permissionParams(null, "camel run *");
+ permission.getJsonObject("toolCall").put("kind", "execute");
+ askPermissionDuringPrompt(permission);
+ ask(panel, "hi");
+ awaitIdle(panel);
+ assertFalse(panel.isPermissionPopupVisible());
+ assertTrue(hasEntry(panel, AiRole.SYSTEM, "(stopped: opt-always)"));
}
@Test
@@ -543,7 +573,7 @@ class AiPanelAcpTest {
permission.getJsonObject("toolCall").put("kind", "execute");
askPermissionDuringPrompt(permission);
ask(panel, "hi");
- await().atMost(5, TimeUnit.SECONDS).untilAsserted(() ->
assertTrue(panel.isPermissionPopupVisibleForTesting()));
+ await().atMost(5, TimeUnit.SECONDS).untilAsserted(() ->
assertTrue(panel.isPermissionPopupVisible()));
panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.ESCAPE,
KeyModifiers.NONE));
awaitIdle(panel);
assertTrue(hasEntry(panel, AiRole.SYSTEM, "(stopped: opt-reject)"), "a
shell call is never a TUI tool call");
@@ -556,7 +586,7 @@ class AiPanelAcpTest {
permission.getJsonObject("toolCall").put("kind", "edit");
askPermissionDuringPrompt(permission);
ask(panel, "hi");
- await().atMost(5, TimeUnit.SECONDS).untilAsserted(() ->
assertTrue(panel.isPermissionPopupVisibleForTesting()));
+ await().atMost(5, TimeUnit.SECONDS).untilAsserted(() ->
assertTrue(panel.isPermissionPopupVisible()));
panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.ESCAPE,
KeyModifiers.NONE));
awaitIdle(panel);
assertTrue(hasEntry(panel, AiRole.SYSTEM, "(stopped: opt-reject)"), "a
path is not a tool identity");
@@ -569,31 +599,31 @@ class AiPanelAcpTest {
askPermissionDuringPrompt(permissionParams("mcp__camel-tui__camel_write_file",
"camel_write_file"));
ask(panel, "hi");
awaitIdle(panel);
- assertFalse(panel.isPermissionPopupVisibleForTesting());
+ assertFalse(panel.isPermissionPopupVisible());
assertTrue(hasEntry(panel, AiRole.SYSTEM, "(stopped: opt-always)"),
"the confirm dialog asks, a second question here would make
the user answer twice");
}
@Test
- void writeFileOpensThePopupWhenWritesAreAutomatic() throws Exception {
+ void writeFileIsApprovedWhenWritesAreAutomatic() throws Exception {
AiPanel panel = acpPanel();
ask(panel, "/write auto");
assertTrue(panel.describeWriteModeForTesting().startsWith("auto"));
askPermissionDuringPrompt(permissionParams("mcp__camel-tui__camel_write_file",
"camel_write_file"));
ask(panel, "hi");
- await().atMost(5, TimeUnit.SECONDS).untilAsserted(() ->
assertTrue(panel.isPermissionPopupVisibleForTesting()));
- panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.ESCAPE,
KeyModifiers.NONE));
awaitIdle(panel);
- assertTrue(hasEntry(panel, AiRole.SYSTEM, "(stopped: opt-reject)"),
"nothing else asks in auto mode");
+ assertFalse(panel.isPermissionPopupVisible());
+ assertTrue(hasEntry(panel, AiRole.SYSTEM, "(stopped: opt-always)"),
"the user chose to write without asking");
}
@Test
- void evalExpressionIsReadOnly() throws Exception {
+ void evalExpressionIsApproved() throws Exception {
+ // in a language that runs code the TUI asks itself, when the tool
runs (ToolConfirms)
AiPanel panel = acpPanel();
askPermissionDuringPrompt(permissionParams("mcp__camel-tui__camel_eval_expression",
"camel_eval_expression"));
ask(panel, "hi");
awaitIdle(panel);
- assertFalse(panel.isPermissionPopupVisibleForTesting());
+ assertFalse(panel.isPermissionPopupVisible());
assertTrue(hasEntry(panel, AiRole.SYSTEM, "(stopped: opt-always)"));
}
@@ -612,7 +642,7 @@ class AiPanelAcpTest {
AiPanel panel = acpPanel();
askPermissionDuringPrompt(permissionParams(null, "rm -rf / (camel-tui
MCP Server)"));
ask(panel, "hi");
- await().atMost(5, TimeUnit.SECONDS).untilAsserted(() ->
assertTrue(panel.isPermissionPopupVisibleForTesting()));
+ await().atMost(5, TimeUnit.SECONDS).untilAsserted(() ->
assertTrue(panel.isPermissionPopupVisible()));
panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.ESCAPE,
KeyModifiers.NONE));
awaitIdle(panel);
assertTrue(hasEntry(panel, AiRole.SYSTEM, "(stopped: opt-reject)"),
"the TUI registers no such tool");
@@ -626,7 +656,7 @@ class AiPanelAcpTest {
AcpAgentClientTest.option("opt-reject", "Reject",
"reject_once")));
ask(panel, "hi");
awaitIdle(panel);
- assertFalse(panel.isPermissionPopupVisibleForTesting());
+ assertFalse(panel.isPermissionPopupVisible());
assertTrue(hasEntry(panel, AiRole.SYSTEM, "(stopped: opt-allow)"));
}
@@ -637,7 +667,7 @@ class AiPanelAcpTest {
AcpAgentClientTest.option("opt-reject", "Reject",
"reject_once")));
ask(panel, "hi");
awaitIdle(panel);
- assertFalse(panel.isPermissionPopupVisibleForTesting());
+ assertFalse(panel.isPermissionPopupVisible());
assertTrue(hasEntry(panel, AiRole.SYSTEM, "(stopped: opt-reject)"));
}
@@ -646,11 +676,11 @@ class AiPanelAcpTest {
AiPanel panel = acpPanel();
askPermissionDuringPrompt(permissionParams("Bash", "rm -rf build"));
ask(panel, "hi");
- await().atMost(5, TimeUnit.SECONDS).untilAsserted(() ->
assertTrue(panel.isPermissionPopupVisibleForTesting()));
+ await().atMost(5, TimeUnit.SECONDS).untilAsserted(() ->
assertTrue(panel.isPermissionPopupVisible()));
assertTrue(panel.isThinkingForTesting(), "turn is still in progress
while the popup waits");
enter(panel);
awaitIdle(panel);
- assertFalse(panel.isPermissionPopupVisibleForTesting());
+ assertFalse(panel.isPermissionPopupVisible());
assertTrue(hasEntry(panel, AiRole.SYSTEM, "(stopped: opt-allow)"));
}
@@ -659,7 +689,7 @@ class AiPanelAcpTest {
AiPanel panel = acpPanel();
askPermissionDuringPrompt(permissionParams("Bash", "rm -rf build"));
ask(panel, "hi");
- await().atMost(5, TimeUnit.SECONDS).untilAsserted(() ->
assertTrue(panel.isPermissionPopupVisibleForTesting()));
+ await().atMost(5, TimeUnit.SECONDS).untilAsserted(() ->
assertTrue(panel.isPermissionPopupVisible()));
panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.ESCAPE,
KeyModifiers.NONE));
awaitIdle(panel);
assertTrue(hasEntry(panel, AiRole.SYSTEM, "(stopped: opt-reject)"));
@@ -670,7 +700,7 @@ class AiPanelAcpTest {
AiPanel panel = acpPanel();
askPermissionDuringPrompt(permissionParams("Bash", "rm -rf build"));
ask(panel, "hi");
- await().atMost(5, TimeUnit.SECONDS).untilAsserted(() ->
assertTrue(panel.isPermissionPopupVisibleForTesting()));
+ await().atMost(5, TimeUnit.SECONDS).untilAsserted(() ->
assertTrue(panel.isPermissionPopupVisible()));
Rect area = new Rect(0, 0, 100, 30);
Buffer buffer = Buffer.empty(area);
panel.render(Frame.forTesting(buffer), area);
@@ -709,10 +739,10 @@ class AiPanelAcpTest {
AiPanel panel = acpPanel();
askPermissionDuringPrompt(permissionParams("Bash", "rm -rf build"));
ask(panel, "hi");
- await().atMost(5, TimeUnit.SECONDS).untilAsserted(() ->
assertTrue(panel.isPermissionPopupVisibleForTesting()));
+ await().atMost(5, TimeUnit.SECONDS).untilAsserted(() ->
assertTrue(panel.isPermissionPopupVisible()));
panel.handleKeyEvent(KeyEvent.ofChar('c', KeyModifiers.CTRL));
awaitIdle(panel);
- assertFalse(panel.isPermissionPopupVisibleForTesting());
+ assertFalse(panel.isPermissionPopupVisible());
assertTrue(hasEntry(panel, AiRole.SYSTEM, "(cancelled)"));
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ConfirmationsTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ConfirmationsTest.java
new file mode 100644
index 000000000000..ec38d5b61f3d
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ConfirmationsTest.java
@@ -0,0 +1,172 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Queue;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+
+import dev.tamboui.tui.event.KeyCode;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.tui.event.KeyModifiers;
+import org.apache.camel.dsl.jbang.core.common.CommandLineHelper;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.api.parallel.Isolated;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * CAMEL-25444: the keys an MCP client sends (tui_send_keys) or a tape plays
never answer a dialog that asks the user to
+ * confirm an action; while a tape drives the monitor nothing asks at all.
+ */
+@Isolated
+class ConfirmationsTest {
+
+ private final Queue<McpFacade.PendingKey> pending = new
ConcurrentLinkedQueue<>();
+ private final AtomicBoolean quitDialog = new AtomicBoolean();
+ private final Confirmations confirmations = new
Confirmations(pending).add("the quit confirmation", quitDialog::get);
+
+ @Test
+ void theKeysOfAnMcpClientAreDeliveredWhileNoDialogAsksToConfirm() {
+ pending.add(due(KeyEvent.ofChar('a')));
+ pending.add(due(KeyEvent.ofChar('b')));
+ List<String> delivered = new ArrayList<>();
+
+ boolean taken = confirmations.deliverDue(0, key -> {
+ assertThat(confirmations.isDelivering()).isTrue();
+ delivered.add(key.string());
+ });
+
+ assertThat(taken).isTrue();
+ assertThat(delivered).containsExactly("a", "b");
+ assertThat(confirmations.isDelivering()).isFalse();
+ }
+
+ @Test
+ void aKeyThatComesWhileADialogAsksToConfirmIsDropped() {
+ // q opens the quit confirmation; the Enter queued right behind it
must not answer it
+ pending.add(due(KeyEvent.ofChar('q')));
+ pending.add(due(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE)));
+ pending.add(new McpFacade.PendingKey(KeyEvent.ofChar('x'), 1000));
+ List<String> delivered = new ArrayList<>();
+
+ confirmations.deliverDue(0, key -> {
+ delivered.add(key.isKey(KeyCode.ENTER) ? "Enter" : key.string());
+ if (key.isChar('q')) {
+ quitDialog.set(true);
+ }
+ });
+
+ assertThat(delivered).containsExactly("q");
+ assertThat(confirmations.open()).isEqualTo("the quit confirmation");
+ assertThat(pending).hasSize(1).as("a key not due yet waits");
+
+ // the user answered it: later keys are delivered again
+ quitDialog.set(false);
+ confirmations.deliverDue(1000, key -> delivered.add(key.string()));
+ assertThat(delivered).containsExactly("q", "x");
+ }
+
+ @Test
+ void sendKeysSaysThatOnlyTheUserAnswersADialogThatAsksToConfirm() {
+ McpFacade facade = new McpFacade(
+ null, new AtomicReference<>(List.of()), null, null, null,
null, null, null, null, null, null, pending,
+ null);
+ facade.setConfirmations(confirmations);
+ quitDialog.set(true);
+
+ String answer = new
TuiToolRegistry(facade).callSendKeys(Map.of("keys", List.of("Enter")));
+
+ assertThat(answer).startsWith("Not sent: the quit confirmation is
open, and only the user answers it");
+ assertThat(pending).isEmpty();
+ }
+
+ @Test
+ void whileATapeDrivesTheMonitorNothingAsksToConfirm() {
+ MonitorContext ctx = new MonitorContext(new
AtomicReference<>(List.of()), new AtomicReference<>(List.of()));
+ ctx.confirmActions = true;
+ assertThat(ctx.confirmFirst()).isTrue();
+
+ ctx.tapeDriven = () -> true;
+ assertThat(ctx.confirmFirst()).isFalse();
+
+ ctx.confirmActions = false;
+ ctx.tapeDriven = () -> false;
+ assertThat(ctx.confirmFirst()).isFalse();
+ }
+
+ @Test
+ void deletingAFileAsksFirstUnlessATapeDrivesTheMonitor() {
+ FileActionsPopup popup = new FileActionsPopup();
+ popup.open("orders.camel.yaml", true);
+ deleteFromTheMenu(popup);
+ assertThat(popup.isConfirming()).isTrue();
+ assertThat(popup.consumeResult()).isNull();
+
+ popup.setUnattended(() -> true);
+ popup.open("orders.camel.yaml", true);
+ deleteFromTheMenu(popup);
+ assertThat(popup.isConfirming()).isFalse();
+ assertThat(popup.consumeResult()).isEqualTo(new
FileActionsPopup.Request(FileActionsPopup.Action.DELETE, null));
+ }
+
+ @Test
+ void stopAllStopsBothWithoutAskingWhileATapeDrivesTheMonitor(@TempDir Path
home) {
+ String original = CommandLineHelper.getHomeDir().toString();
+ CommandLineHelper.useHomeDir(home.toString());
+ try {
+ // a pid no process has, so nothing is really stopped
+ IntegrationInfo integration = new IntegrationInfo();
+ integration.pid = String.valueOf(Long.MAX_VALUE);
+ InfraInfo infra = new InfraInfo();
+ infra.pid = String.valueOf(Long.MAX_VALUE);
+ infra.alias = "kafka";
+ StopAllPopup popup = new StopAllPopup(() -> List.of(integration),
() -> List.of(infra), () -> {
+ }, new HashSet<>());
+
+ popup.open();
+ assertThat(popup.isVisible()).as("both run: it asks what to
stop").isTrue();
+ popup.close();
+
+ popup.setUnattended(true);
+ popup.open();
+ assertThat(popup.isVisible()).isFalse();
+ assertThat(popup.consumeNotification()).isEqualTo("Stopping 1
integration and stopping 1 infra service");
+ } finally {
+ CommandLineHelper.useHomeDir(original);
+ }
+ }
+
+ private static void deleteFromTheMenu(FileActionsPopup popup) {
+ // New file, New folder, Rename, Duplicate, Delete
+ for (int i = 0; i < 4; i++) {
+ popup.handleKeyEvent(KeyEvent.ofKey(KeyCode.DOWN,
KeyModifiers.NONE));
+ }
+ popup.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE));
+ }
+
+ private static McpFacade.PendingKey due(KeyEvent key) {
+ return new McpFacade.PendingKey(key, 0);
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeConfirmTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeConfirmTest.java
new file mode 100644
index 000000000000..276cddebfa99
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeConfirmTest.java
@@ -0,0 +1,134 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * The AI tools ask the user, through the monitor, before a call that reaches
beyond the integration, may destroy data
+ * or runs code (ToolConfirms), and not while a tape drives the monitor.
+ */
+class McpFacadeConfirmTest {
+
+ /** Answers the confirmation without a UI, and remembers what it was
asked. */
+ static final class AnsweringBridge extends
McpFacadeWriteFileTest.ConfirmingBridge {
+ final List<ToolConfirms.Call> calls = new ArrayList<>();
+
+ AnsweringBridge(boolean answer) {
+ super(answer);
+ }
+
+ @Override
+ public boolean confirmToolCall(ToolConfirms.Call call) {
+ calls.add(call);
+ return answer;
+ }
+ }
+
+ private static MonitorContext context() {
+ IntegrationInfo info = new IntegrationInfo();
+ info.name = "shop";
+ info.pid = "1";
+ EndpointInfo kafka = new EndpointInfo();
+ kafka.uri = "kafka://orders?brokers=localhost%3A9092";
+ info.endpoints.add(kafka);
+ MonitorContext ctx = new MonitorContext(new
AtomicReference<>(List.of(info)), new AtomicReference<>(List.of()));
+ ctx.selectedPid = "1";
+ return ctx;
+ }
+
+ private static McpFacade facade(MonitorContext ctx, AnsweringBridge
bridge) {
+ return new McpFacade(
+ ctx, new AtomicReference<>(List.of()), null, null, null, null,
null, null, null, null, null, null,
+ bridge);
+ }
+
+ @Test
+ void aMessageOutsideTheIntegrationIsSentOnlyWhenTheUserConfirmsIt() {
+ AnsweringBridge bridge = new AnsweringBridge(false);
+ McpFacade facade = facade(context(), bridge);
+
+ JsonObject result =
facade.sendMessage("file:/Users/me?fileName=.zshrc", "x", null);
+
+ assertThat(result.getString("status")).isEqualTo("rejected");
+ assertThat(result.getString("message")).contains("The user did not
confirm this in the TUI, so it did not run");
+
assertThat(bridge.calls).singleElement().extracting(ToolConfirms.Call::target)
+ .isEqualTo("file:/Users/me?fileName=.zshrc");
+ // the endpoints of the integration come from its status
+
assertThat(facade.selectedEndpointUris()).containsExactly("kafka://orders?brokers=localhost%3A9092");
+
assertThat(facade.confirm(ToolConfirms.sendMessage("kafka:orders?brokers=localhost:9092",
+ facade.selectedEndpointUris()))).isNull();
+ assertThat(bridge.calls).hasSize(1);
+ }
+
+ @Test
+ void sqlThatIsNotASelectAndARowUpdateAskFirst() {
+ AnsweringBridge bridge = new AnsweringBridge(false);
+ McpFacade facade = facade(context(), bridge);
+
+ assertThat(facade.executeSql("DROP TABLE orders", null, 10,
5).getString("status")).isEqualTo("rejected");
+ assertThat(facade.updateRow("orders", null, "{\"id\":1}",
"{\"status\":\"x\"}").getString("status"))
+ .isEqualTo("rejected");
+
+ assertThat(bridge.calls).extracting(ToolConfirms.Call::title)
+ .containsExactly("run SQL that is not a SELECT", "update a row
of orders");
+ }
+
+ @Test
+ void groovyAsksFirstThroughTheTool() throws Exception {
+ AnsweringBridge bridge = new AnsweringBridge(false);
+ TuiToolRegistry registry = new TuiToolRegistry(facade(context(),
bridge));
+
+ JsonObject result = (JsonObject)
Jsoner.deserialize(registry.execute("camel_eval_expression",
+ new JsonObject(Map.of("language", "groovy", "expression",
"'touch /tmp/x'.execute()"))));
+
+ assertThat(result.getString("status")).isEqualTo("rejected");
+
assertThat(bridge.calls).singleElement().extracting(ToolConfirms.Call::title)
+ .isEqualTo("evaluate a groovy expression");
+ }
+
+ @Test
+ void whatTheUserConfirmsGoesAhead() {
+ AnsweringBridge bridge = new AnsweringBridge(true);
+ McpFacade facade = facade(context(), bridge);
+
+ assertThat(facade.confirm(ToolConfirms.sql("DELETE FROM
orders"))).isNull();
+ assertThat(bridge.calls).hasSize(1);
+ }
+
+ @Test
+ void nothingAsksWhileATapeDrivesTheMonitor() {
+ // CAMEL-25444: a model records a tape, or --record plays one, and no
one is there to answer
+ AnsweringBridge bridge = new AnsweringBridge(false);
+ MonitorContext ctx = context();
+ ctx.tapeDriven = () -> true;
+ McpFacade facade = facade(ctx, bridge);
+
+ assertThat(facade.confirm(ToolConfirms.sql("DROP TABLE
orders"))).isNull();
+ assertThat(facade.confirm(ToolConfirms.evalExpression("groovy",
"1"))).isNull();
+ assertThat(bridge.calls).isEmpty();
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeWriteFileTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeWriteFileTest.java
index 9f0eac3bbeff..d2823a0a6df1 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeWriteFileTest.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeWriteFileTest.java
@@ -105,6 +105,10 @@ class McpFacadeWriteFileTest {
}
static McpFacade facade(Path sourceDir, boolean devMode, ConfirmingBridge
bridge) {
+ return facade(sourceDir, devMode, bridge, null);
+ }
+
+ static McpFacade facade(Path sourceDir, boolean devMode, ConfirmingBridge
bridge, MonitorContext ctx) {
IntegrationInfo info = new IntegrationInfo();
info.name = "demo";
info.pid = "1";
@@ -114,10 +118,26 @@ class McpFacadeWriteFileTest {
cp.value = "file:" + sourceDir.resolve("demo.camel.yaml");
info.configProperties.add(cp);
return new McpFacade(
- null, new AtomicReference<>(List.of(info)), null, null, null,
null, null, null, null, null, null,
+ ctx, new AtomicReference<>(List.of(info)), null, null, null,
null, null, null, null, null, null,
null, bridge);
}
+ @Test
+ void writesWithoutAskingWhileATapeDrivesTheMonitor(@TempDir Path dir)
throws IOException {
+ // CAMEL-25444: a model records a tape, or --record plays one, and no
one is there to confirm
+ Files.writeString(dir.resolve("demo.camel.yaml"), "- route: {}\n");
+ ConfirmingBridge bridge = new ConfirmingBridge(false);
+ MonitorContext ctx = new MonitorContext(new
AtomicReference<>(List.of()), new AtomicReference<>(List.of()));
+ ctx.tapeDriven = () -> true;
+ McpFacade facade = facade(dir, true, bridge, ctx);
+
+ JsonObject result = facade.writeFile("demo", "demo.camel.yaml", "-
route:\n id: x\n", true);
+
+ assertEquals("overwritten", result.getString("status"));
+ assertEquals(0, bridge.asked, "nothing asks while a tape drives the
monitor");
+ assertEquals("- route:\n id: x\n",
Files.readString(dir.resolve("demo.camel.yaml"), StandardCharsets.UTF_8));
+ }
+
@Test
void writesAfterConfirmationAndDescribesTheDirectory(@TempDir Path dir)
throws IOException {
Files.writeString(dir.resolve("demo.camel.yaml"), "- route: {}\n");
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceSizeTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceSizeTest.java
index d87f27ea1da0..c66c34d57373 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceSizeTest.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceSizeTest.java
@@ -56,10 +56,10 @@ class SourceSizeTest {
/** The files and the methods (class.method) over the limits, and the most
lines each may have. */
private static final String OVER_THE_LIMITS = """
- AiPanel.java 3189
+ AiPanel.java 3187
AiPanel.runAgentLoop 159
AiUsageStats.render 166
- CamelMonitor.java 2789
+ CamelMonitor.java 2780
DiagramDetailSupport.renderEipInfoPanel 159
DiagramDetailSupport.renderRouteInfoPanel 154
DiagramSupport.java 2460
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/FileWritePopupTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ToolConfirmPopupTest.java
similarity index 63%
rename from
dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/FileWritePopupTest.java
rename to
dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ToolConfirmPopupTest.java
index 894430f7bdf2..2538404809e6 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/FileWritePopupTest.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ToolConfirmPopupTest.java
@@ -17,18 +17,25 @@
package org.apache.camel.dsl.jbang.core.commands.tui;
import java.nio.file.Path;
+import java.util.List;
import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+import dev.tamboui.buffer.Buffer;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.terminal.Frame;
import dev.tamboui.tui.event.KeyCode;
import dev.tamboui.tui.event.KeyEvent;
import dev.tamboui.tui.event.KeyModifiers;
import org.junit.jupiter.api.Test;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
-class FileWritePopupTest {
+class ToolConfirmPopupTest {
private static McpFacade.FileWrite request() {
return new McpFacade.FileWrite(
@@ -41,7 +48,7 @@ class FileWritePopupTest {
@Test
void enterAppliesAndReportsTheChangeSize() {
- FileWritePopup popup = new FileWritePopup();
+ ToolConfirmPopup popup = new ToolConfirmPopup();
CompletableFuture<Boolean> answer = new CompletableFuture<>();
popup.open(request(), answer);
@@ -55,7 +62,7 @@ class FileWritePopupTest {
@Test
void theDiffIsOnlyAViewAndTheSummaryDecides() {
- FileWritePopup popup = new FileWritePopup();
+ ToolConfirmPopup popup = new ToolConfirmPopup();
CompletableFuture<Boolean> answer = new CompletableFuture<>();
popup.open(request(), answer);
@@ -86,7 +93,7 @@ class FileWritePopupTest {
@Test
void closingWithoutAnAnswerRejects() {
- FileWritePopup popup = new FileWritePopup();
+ ToolConfirmPopup popup = new ToolConfirmPopup();
CompletableFuture<Boolean> answer = new CompletableFuture<>();
popup.open(new McpFacade.FileWrite("new.yaml", Path.of("/tmp/demo"),
null, "- route: {}\n", true, false),
answer);
@@ -98,4 +105,44 @@ class FileWritePopupTest {
assertFalse(answer.join());
assertFalse(popup.handleKeyEvent(KeyEvent.ofChar('d')), "a closed
popup ignores keys");
}
+
+ @Test
+ void aCallShowsWhatTheAiWantsToRunAndEnterRunsIt() {
+ ToolConfirmPopup popup = new ToolConfirmPopup();
+ CompletableFuture<Boolean> answer = new CompletableFuture<>();
+ popup.open(ToolConfirms.sendMessage("file:/home/me?fileName=.zshrc",
List.of("kafka:orders")), answer);
+
+ assertThat(render(popup, 100, 14))
+ .contains("AI wants to send a message to an endpoint the
integration does not have")
+ .contains("file:/home/me?fileName=.zshrc")
+ .contains("The message may leave the integration");
+ // only a file has a diff
+ popup.handleKeyEvent(KeyEvent.ofChar('d'));
+ assertThat(popup.isDiffVisible()).isFalse();
+ popup.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE));
+
+ assertThat(popup.isVisible()).isFalse();
+ assertThat(answer.join()).isTrue();
+ }
+
+ @Test
+ void aToolWaitsOnItsOwnThreadUntilTheUserAnswers() throws Exception {
+ ToolConfirmPopup popup = new ToolConfirmPopup();
+ CompletableFuture<Boolean> asked
+ = CompletableFuture.supplyAsync(() ->
popup.ask(ToolConfirms.sql("DROP TABLE orders")));
+
+ await().atMost(5, TimeUnit.SECONDS).until(popup::isVisible);
+ assertThat(render(popup, 100, 14)).contains("AI wants to run SQL that
is not a SELECT")
+ .contains("DROP TABLE orders");
+ popup.handleKeyEvent(KeyEvent.ofKey(KeyCode.ESCAPE,
KeyModifiers.NONE));
+
+ assertThat(asked.get(5, TimeUnit.SECONDS)).isFalse();
+ }
+
+ private static String render(ToolConfirmPopup popup, int width, int
height) {
+ Rect area = new Rect(0, 0, width, height);
+ Buffer buffer = Buffer.empty(area);
+ popup.render(Frame.forTesting(buffer), area);
+ return TuiTestHelper.bufferToString(buffer);
+ }
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ToolConfirmsTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ToolConfirmsTest.java
new file mode 100644
index 000000000000..f1b64d82e52f
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ToolConfirmsTest.java
@@ -0,0 +1,119 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * The AI tools run without asking, except a call that reaches beyond the
integration, may destroy data or runs code.
+ */
+class ToolConfirmsTest {
+
+ // as the status of an integration lists its endpoints
(Endpoint.getEndpointUri)
+ private static final List<String> ENDPOINTS = List.of(
+ "kafka://orders?brokers=localhost%3A9092&groupId=shop",
"direct://charge", "timer://tick?period=1000");
+
+ @Test
+ void aMessageToAnEndpointOfTheIntegrationIsSentWithoutAsking() {
+ // the same endpoint, written another way: the URIs are compared
normalized
+
assertThat(ToolConfirms.sendMessage("kafka:orders?groupId=shop&brokers=localhost:9092",
ENDPOINTS)).isNull();
+ assertThat(ToolConfirms.sendMessage("timer:tick?period=1000",
ENDPOINTS)).isNull();
+ // a route id or a pattern: the send console picks one of the
integration's routes
+ assertThat(ToolConfirms.sendMessage("checkout", ENDPOINTS)).isNull();
+ assertThat(ToolConfirms.sendMessage("kafka:*", ENDPOINTS)).isNull();
+ // an internal endpoint stays inside the integration: its routes, its
log, the test endpoints
+ assertThat(ToolConfirms.sendMessage("direct:unknown",
ENDPOINTS)).isNull();
+ assertThat(ToolConfirms.sendMessage("seda:work", List.of())).isNull();
+ assertThat(ToolConfirms.sendMessage("log:demo?showAll=true",
List.of())).isNull();
+ assertThat(ToolConfirms.sendMessage("mock:result",
List.of())).isNull();
+ assertThat(ToolConfirms.sendMessage("stub:kafka:payments",
List.of())).isNull();
+ }
+
+ @Test
+ void aMessageToAnEndpointTheIntegrationDoesNotHaveAsks() {
+ ToolConfirms.Call call =
ToolConfirms.sendMessage("file:/Users/me?fileName=.zshrc", ENDPOINTS);
+
+ assertThat(call).isNotNull();
+ assertThat(call.target()).isEqualTo("file:/Users/me?fileName=.zshrc");
+ assertThat(call.title()).isEqualTo("send a message to an endpoint the
integration does not have");
+ assertThat(ToolConfirms.sendMessage("exec:rm?args=-rf /tmp/x",
ENDPOINTS)).isNotNull();
+ assertThat(ToolConfirms.sendMessage("http://example.com/hook",
ENDPOINTS)).isNotNull();
+ // another topic, or the endpoint of the integration with an option of
its own, is not the same endpoint
+
assertThat(ToolConfirms.sendMessage("kafka:payments?brokers=localhost:9092&groupId=shop",
ENDPOINTS)).isNotNull();
+
assertThat(ToolConfirms.sendMessage("kafka:orders?brokers=evil:9092&groupId=shop",
ENDPOINTS)).isNotNull();
+ // nothing known of the integration: everything outside it asks
+ assertThat(ToolConfirms.sendMessage("kafka:orders",
List.of())).isNotNull();
+ }
+
+ @Test
+ void anInternalEndpointThatReachesOutAsks() {
+ // a bean option runs any constructor, a placeholder can log a secret
+
assertThat(ToolConfirms.sendMessage("log:x?exchangeFormatter=#class:java.io.FileOutputStream('/tmp/x')",
+ List.of())).isNotNull();
+ assertThat(ToolConfirms.sendMessage("seda:x?queue=%23bean:queue",
List.of())).isNotNull();
+ assertThat(ToolConfirms.sendMessage("log:{{aws.secretKey}}",
List.of())).isNotNull();
+ // not remote, but not internal: they run code, or read a file or a URL
+
assertThat(ToolConfirms.sendMessage("bean:orderService?method=deleteAll",
List.of())).isNotNull();
+ assertThat(ToolConfirms.sendMessage("class:java.lang.Runtime",
List.of())).isNotNull();
+ assertThat(ToolConfirms.sendMessage("language:groovy:'touch
/tmp/x'.execute()", List.of())).isNotNull();
+
assertThat(ToolConfirms.sendMessage("velocity:file:/Users/me/.ssh/id_rsa",
List.of())).isNotNull();
+ }
+
+ @Test
+ void sqlThatOnlyReadsRunsWithoutAsking() {
+ assertThat(ToolConfirms.sql("SELECT * FROM orders WHERE status =
'open'")).isNull();
+ assertThat(ToolConfirms.sql(" select count(*) from orders;
")).isNull();
+ assertThat(ToolConfirms.sql("-- the open orders\n/* all */ (SELECT id
FROM orders)")).isNull();
+ // a semicolon or a keyword in a literal or a quoted name is text
+ assertThat(ToolConfirms.sql("SELECT * FROM notes WHERE text = 'a; DROP
TABLE x' OR \"into\" = 1")).isNull();
+ assertThat(ToolConfirms.sql("SHOW TABLES")).isNull();
+ assertThat(ToolConfirms.sql("DESCRIBE orders")).isNull();
+ }
+
+ @Test
+ void sqlThatIsNotASelectAsks() {
+ assertThat(ToolConfirms.sql("DROP TABLE orders")).isNotNull();
+ assertThat(ToolConfirms.sql("delete from orders")).isNotNull();
+ assertThat(ToolConfirms.sql("UPDATE orders SET status =
'x'")).isNotNull();
+ assertThat(ToolConfirms.sql("INSERT INTO orders VALUES
(1)")).isNotNull();
+ // a second statement, a SELECT that creates a table, and EXPLAIN
ANALYZE, which runs the statement
+ assertThat(ToolConfirms.sql("SELECT 1; DROP TABLE
orders")).isNotNull();
+ assertThat(ToolConfirms.sql("SELECT * INTO backup FROM
orders")).isNotNull();
+ assertThat(ToolConfirms.sql("EXPLAIN ANALYZE DELETE FROM
orders")).isNotNull();
+ assertThat(ToolConfirms.sql("WITH gone AS (DELETE FROM orders
RETURNING *) SELECT * FROM gone")).isNotNull();
+ assertThat(ToolConfirms.sql("")).isNotNull();
+ assertThat(ToolConfirms.updateRow("orders",
"{\"status\":\"x\"}").title()).isEqualTo("update a row of orders");
+ }
+
+ @Test
+ void anExpressionInALanguageThatRunsCodeAsks() {
+ ToolConfirms.Call call = ToolConfirms.evalExpression("Groovy", "'rm
-rf /tmp/x'.execute()");
+
+ assertThat(call).isNotNull();
+ assertThat(call.title()).isEqualTo("evaluate a Groovy expression");
+ assertThat(ToolConfirms.evalExpression("java", "1 + 1")).isNotNull();
+ assertThat(ToolConfirms.evalExpression("js", "1 + 1")).isNotNull();
+ // the languages that only read the message do not
+ assertThat(ToolConfirms.evalExpression("simple", "${body}")).isNull();
+ assertThat(ToolConfirms.evalExpression("jsonpath", "$.id")).isNull();
+ assertThat(ToolConfirms.evalExpression(null, "${body}")).isNull();
+ }
+}