Skip to main content

Overview

The GAIA C++ framework executes tool calls requested by the LLM. Without guardrails, a prompt injection could trick the model into calling a sensitive tool with malicious arguments. This guide describes the security primitives available to restrict, validate, and confirm tool calls before they execute.

Threat Model


Tool Policies

Every ToolInfo has a policy field that determines what happens when the registry is asked to execute it:
The default policy for locally registered tools is ToolPolicy::ALLOW — they execute without prompting unless you explicitly set CONFIRM, change the default with setDefaultPolicy(), or mark individual tools DENY. New agents should be explicit about policy on every tool with side effects.MCP tools are the exception: they are CONFIRM-gated automatically unless the server proves them read-only. See MCP tools are CONFIRM-gated by default.

Example: require confirmation before a side-effecting tool

The policy field is set on the ToolInfo struct before you declare the tool’s callback and before you register it with the registry. This is where you choose what gate, if any, the framework places between the LLM’s request and execution.
See Confirmation Callbacks below for details on the prompt flow.

MCP tools are CONFIRM-gated by default

A tool discovered from an MCP server is named by that server, so it can never be matched against a static list of known-dangerous names. connectMcpServer() therefore classifies every discovered tool and registers it with ToolPolicy::CONFIRM unless the server proves it read-only. A tool is exempt only when both hold:
  1. the tool’s annotations.readOnlyHint is the JSON boolean true — a missing annotations object, a non-object value, false, or the string "true" all count as unproven; and
  2. the tool name contains no state-changing verb token (delete, write, push, execute, start, interact, …), matched across snake_case, kebab-case and camelCase (including WRITEFile).
So a server that advertises delete_file with readOnlyHint: true still gets gated — the name overrides the claim.
Because CONFIRM is fail-closed, an agent with silentMode = true (no confirm callback) cannot execute any gated MCP tool. For headless automation that legitimately needs them, install an explicit callback with setToolConfirmCallback() — an intentional opt-in, visible in your code — or pre-approve specific tools through the always-allow store.
The classifier’s verdict is a floor. setDefaultPolicy(ToolPolicy::CONFIRM) or DENY can still raise a proven-read-only MCP tool to a stricter policy; nothing lowers a gated tool back to ALLOW. What the exemption does not cover. readOnlyHint is the server’s own claim, and the verb check is the only counterweight — it knows a fixed vocabulary, so it protects against a careless server, not a hostile one. Two consequences worth knowing:
  • A read-only tool can still be an exfiltration channel. A gated-out web_search or fetch_url reads your context and puts it in a query string; the classifier does not consult openWorldHint.
  • For a server you have not vetted, call setDefaultPolicy(ToolPolicy::CONFIRM) before connectMcpServer() so nothing from it is exempt.

Changing the Default Policy

The framework default is ToolPolicy::ALLOW. For a high-security agent — one where every side-effecting local tool should prompt — you can change the blanket default:
Call setDefaultPolicy() before registering tools or calling connectMcpServer(). Tools registered afterwards inherit the new default; tools already registered are unaffected. For MCP tools the default is applied as a raise, never a lower: a tool the classifier gated at CONFIRM stays gated even under the framework default of ALLOW. Use ToolPolicy::DENY for tools the LLM should never invoke — for example, internal-only tools registered for programmatic use but not accessible to the model.

Argument Validation

The LLM controls what arguments it passes to your tools. A prompt injection or confused model could supply malicious paths, oversized payloads, or unexpected types. The validateArgs callback intercepts arguments before the tool callback runs — and before the user sees a confirmation prompt — giving you a chance to sanitize or reject them.

Example: restrict file access to a safe directory

Throwing std::invalid_argument causes executeTool() to return an error JSON. Any other exception propagates normally.

Path Validation

Tools that accept file paths from the LLM are vulnerable to path traversal attacks. An argument like ../../etc/passwd can escape a sandboxed directory. validatePath() canonicalizes both paths using the OS (resolving .., symlinks on POSIX) and verifies containment.
On POSIX this uses realpath(), which resolves symlinks and .. components. On Windows it uses GetFullPathName(), which normalizes .. components but does not follow symlinks. Returns false if either path cannot be resolved.

Shell Argument Safety

If your tool builds a shell command string from LLM-supplied arguments, a single semicolon can turn a filename into arbitrary code execution. isSafeShellArg() rejects strings containing any shell metacharacter, ensuring the argument is safe to interpolate into a command.
Rejected characters include: spaces, tabs, newlines, ;, |, &, <, >, $, `, ", ', !, {, }, (, ), [, ], ~, *, ?, #, ^, %, =. Backslash (\) is not rejected because it is a path separator on Windows. If you are building POSIX-only commands, add an explicit backslash check in your validateArgs callback.

Confirmation Callbacks

When a tool’s policy is CONFIRM, the registry calls a ToolConfirmCallback before executing:

Zero-config terminal agents

For agents running in a terminal (silentMode = false, which is the default), the Agent constructor automatically installs a stdin/stderr confirm callback. Setting policy = ToolPolicy::CONFIRM on a tool is all you need:
When the LLM calls flush_dns, the user sees:

Silent / headless agents

Agents constructed with config.silentMode = true (e.g., unit tests, background automation) receive no confirm callback. Any tool with CONFIRM policy is denied automatically (fail-closed). There is no user to ask in a headless context, so blocking is the safe default. This includes MCP tools, which are gated automatically — a headless agent that calls connectMcpServer() will be denied every discovered tool the server did not prove read-only. If your headless agent needs to approve specific tools automatically, pre-populate AllowedToolsStore before the agent starts:
Alternatively, install an auto-approving callback — an explicit, greppable opt-in rather than a silent default:

Custom UI agents

The default stdin callback is designed for interactive terminals. GUI applications, Electron apps, or remote approval workflows need their own callback. Call setToolConfirmCallback() after construction to replace the default:

Standalone ToolRegistry usage

If you use ToolRegistry without an Agent — for unit tests, CLI tools, or embedding tool execution in a non-agent application — install the built-in callback yourself:
Fail-closed: if a tool’s policy is CONFIRM and no callback is set, the tool is denied. This prevents accidental execution of sensitive tools in contexts where no user is present.

Persistent Permissions

ALWAYS_ALLOW decisions are persisted to disk so the user is not asked again across sessions. Storage location:
  • POSIX: ~/.gaia/security/allowed_tools.json
  • Windows: %USERPROFILE%\.gaia\security\allowed_tools.json
The file format is:
The Agent constructor creates an AllowedToolsStore automatically. You can also manage it directly:
The store is global — all GAIA agents on the machine share one allowed set.
Known limitation: Permissions are stored by tool name only, with no per-agent namespacing. If two agents both register a tool named read_file, a user’s “Always Allow” decision for one agent applies to both. Use distinct tool names if you need per-agent permission boundaries.
The security_demo example in cpp/examples/security_demo.cpp provides an interactive four-mode demo of all features on this page. It requires no Lemonade Server — build it with the rest of the examples and run ./security_demo.

Recommendations

  1. Call setDefaultPolicy(ToolPolicy::CONFIRM) in production agents that want stricter blanket defaults for their local tools. MCP tools are already gated automatically.
  2. Use validateArgs for every tool that touches the file system or executes shell commands.
  3. Combine validatePath + isSafeShellArg for tools that build shell command strings from LLM-supplied arguments.
  4. Use DENY for tools the LLM should never invoke — for example, internal-only tools registered for programmatic use but not accessible to the model.
  5. Review ~/.gaia/security/allowed_tools.json periodically to audit permanently-allowed tools.