What's New in v0.4.3
🇧🇷 Versão em português · 📋 Full CHANGELOG · 📦 Download
The release where Garra reached your personal WhatsApp. Until now the
WhatsApp channel required a Business account, a number registered with Meta and
a public URL — a bar most home users never clear. Now garra whatsapp draws a
QR code in your terminal, you scan it with your phone, and the number already in
your pocket talks to the agent, with the session encrypted on disk and the
gateway supervising the bridge.
Alongside it came the largest security pass of any release so far: five MCP
fail-opens closed, a directory jail on the file tools, the indirect-injection
guard extended to MCP, files and devices, and the per-tool sandbox
(agent.sandbox) that existed in full and no install could turn on. In the
runtime: a single default LLM (z-ai/glm-5.3-flash via OpenRouter), the mode
auto-router on every entry point, a local fallback when the network drops, and
a garra chat that survives crashes, timeouts and Ctrl+C.
Personal WhatsApp by linked device
Before: only the Meta Cloud API. Now there are two paths, and they don't mix.
garra whatsapp
1) Connect my personal WhatsApp (scan a QR code) → garra whatsapp link
2) WhatsApp Business through Meta's official Cloud API → garra whatsapp cloud
| Command | What it does | Exit code |
|---|---|---|
garra whatsapp | two-option menu | 0, or 1 if cancelled |
garra whatsapp link | link by QR | 0 · 1 cancelled · 69 no Node, no terminal or QR not scanned · 70 internal error |
garra whatsapp cloud | Cloud API wizard | 0 · 1 cancelled · 69 no terminal · 70 internal error |
garra whatsapp status | says whether a link exists and whether the session opens | 0 linked · 69 not linked or unreadable |
garra whatsapp logout | deletes the session and disables the channel | 0 · 1 cancelled |
garra whatsapp restore | puts session.enc.prev back in place | 0 · 69 nothing archived or session in use · 70 internal error |
Without a terminal (ssh server 'garra whatsapp'), the menu prints both
options with their commands and exits 0, like garra init; if you already
chose link, you get the reason, the way out (ssh -t …) and exit 69 instead
of a false success (#1238).
How it works inside
garra whatsapp link / gateway (serve)
| NDJSON v1 over stdio · ping/pong · env_clear + allowlist · PDEATHSIG · Drop kills the child
bridge/whatsapp/bridge.mjs Node + Baileys 7.0.0-rc14, STATELESS — never writes to disk
|
WhatsApp Web (linked device)
- The bridge is deliberately dumb. Node handles only the WhatsApp
protocol; Rust draws the QR, encrypts and stores the session, applies the
allowlist and routes to the agent. Auth state lives in the bridge's memory
and comes back as a full snapshot (
session_update) for Rust to persist (#1238, ADR 0023). - Session encrypted at rest. AES-256-GCM (the same stack as the
CredentialVault) in<data_dir>/whatsapp/default/session.enc,0600files in a0700directory, atomic writes. The key derives fromGARRAIA_VAULT_PASSPHRASEwhen present; otherwise it lives in a localsession.key, andstatussays so plainly. - Nothing destructive before the last confirmation. A consent screen
precedes the first QR (unofficial client, the account may be banned, use a
secondary number). A re-link that never gets to write a new session — QR
expired, Ctrl+C, bridge dying, WhatsApp refusing — restores the previous
one;
enabled = truereaches the config only after the session exists on disk, so an aborted pairing never leaves the gateway paying a timeout on every boot. - Every clock has a ceiling. Handshake, QR, "connecting",
authenticated-but-never-connects, the courtesy
shutdown(500 ms),npm ci(600 s, no orphan): each pairing phase says how long is left and gives up with the right message. A test enumerates every phase and fails to compile if someone adds one without saying what its ceiling is.
The whatsapp_linked channel in the gateway
The message scanned in by QR reaches the agent through its own pull channel, and its threat model is different: the message comes from anyone who knows the operator's personal number. Three decisions follow, none of them configurable downwards (#1238):
- Its own allowlist, fail-closed. You get in with a
/paircode or via the config'sallowlist; an empty gate means nobody. The global allowlist won't do here because every sibling channel fills it on its own by auto-claiming the first sender. - Read-only tools by default. A session with no chosen mode resolves to
the
searchprofile until the operator raises it with/mode. - Indirect-injection guard on the incoming text.
Groups only get answers with explicit opt-in, and your own messages
(from_me) never start a turn. The gateway supervises the bridge in
mode: "serve" with proof of life in the protocol: after connecting, a ping
every 15 s with a reply required within 45 s, otherwise it falls to the
reconnect backoff (#1283). Ctrl+C on the gateway cancels the supervisor —
the Node bridge no longer outlives "shut down gracefully". garra whatsapp status, GET /api/channels and the whatsapp.linked check in
GET /api/diagnostics classify through the same function, each with an
actionable next step ("run garra whatsapp link", "run npm ci in
What lands in your terminal
The tail of Node's stderr is the only raw external-tool output in the flow,
and it is redacted before it shows: long sequences that look like key material
(standard and url-safe base64, including ones glued to field names such as
creds.noiseKey= or npm ERR! _auth=) become <redigido: N caracteres>,
measured over 2000 keys with ~1.3 characters of residual leak (#1238, #1276).
The account name refuses con, prn, aux, nul, com1–com9,
lpt1–lpt9, which cannot become directories on Windows.
- The stderr redaction also covers percent-encoded base64 and base64 with
the slash escaped as
\/, the shapes a librarythrowor serialized JSON produce (#1276).
What needs Node: only the QR path. node and npm are looked up on the
PATH, and the requirement is Node.js 20 or newer (the bridge's
engines: >=20). The Cloud API needs no Node. The bridge lives in
bridge/whatsapp/ — a directory, not a crate — and the Rust tests run
against a fake Python bridge, with no Node and no phone; pairing with a real
device is manual validation, described step by step in the docs.
Security
Per-tool sandbox: agent.sandbox
The containment from #1222 existed in full — SandboxPolicy, backends,
fail-closed when the backend is missing, eleven tests — and no install could
turn it on: all three production constructors of BashTool pinned off,
and the config key the error messages cited did not exist in the schema. Now
the section exists, read by the same function at the three production points
(gateway, garra chat, garra mcp-agent), and garra config check rejects a
sandbox that looks on but isn't (#1225).
| Key | Values | Note |
|---|---|---|
mode | off (default) · all · allowlist | missing section = off, byte for byte the previous behaviour |
backend | docker · podman · ssh | mode != off without a backend is an error |
image | container image | default debian:bookworm-slim |
ssh_host | remote host | required for ssh |
sandboxed_tools / elevated | tool lists | elevated escapes and runs on the host |
mount_workdir / network_disabled | bool, default true | ignored by ssh |
What each backend guarantees — and does not — is the table in threat model
§5.13. Three limits hold for all of them: only the bash tool is wrapped
today (run_tests, git_diff, code_review and repo_search still run on
the host even with mode: all); the ssh backend is not a sandbox, it is
remote execution that isolates the local host and nothing else; and in
practice this is Unix — on Windows wrap_command fails closed and
config check reports an error.
Two holes in the first version closed with it: shell metacharacters escaped to
the host because the command was quoted with {:?} instead of POSIX quoting
(#1231), and a config value starting with - (ssh_host: "-oProxyCommand=…")
became an option of ssh/docker rather than an argument — now refused
in three layers, never logging the value (#1225). The sandboxed command goes
through redact_secrets and truncation before reaching the log, at debug!.
backend: sshwithnetwork_disabledormount_workdiron is now refused fail-closed: since ssh ignores both, the config has to sayfalseexplicitly so the operator acknowledges there is no containment (#1225 S3).mode: allwarns once per entry point (gateway,garra chat,garra mcp-server) that it covers onlybash;run_tests,git_diff,code_reviewandrepo_searchare host-only, andconfig checkflags a config that lists one of them insandboxed_tools/elevated(#1225 S2).- An integration test with real Docker proves
--network noneon Linux CI (#1225 S4).
📖 docs/security/threat-model.md §5.13 · config.hardened.example.yml
MCP: five fail-opens closed
- Restarting a server through the admin API no longer wipes its tool
allowlist (#1242). The restart reconnected with an empty list — which
means "allow everything" — after
disconnecthad discarded the only copy. The allowlist is now resolved before teardown, against the same merge (mcp.json+config.yml) the boot reads, and a restart never widens what is in force. DELETE /admin/api/mcp/{id}actually revokes (#1262): it drops the live connection, clearspending(from which the health monitor used to resurrect the deleted server with the freshly revoked secrets) and releases the tools from the runtime.- The agent mode whitelist now applies to MCP tools (#1264).
ToolGateexempted any name containing__; a read-only mode let a write tool from an MCP server through. Permission is now declared:my-server/*allows the server, a full name allows just that tool,deniedwins over everything. - HTTP entries in
mcp.jsonare no longer dropped by the loader (#1274) — and with them theallowed_toolsthe operator wrote is no longer lost. - An admin write no longer erases
allowed_tools,inherit_envandenabledfrom the other servers inmcp.json(#1273): the two types sharing that name now agree on a single schema.
And at the child-process boundary:
- Stdio MCP servers stop inheriting the gateway's entire environment
(#1075). Every
npx -y some-serverreceivedGARRAIA_JWT_SECRET, the provider keys and the whole.envwithout a single tool call. The environment is now built from a minimal allowlist (PATH,HOME, locale,TMPDIR, CA bundles) plus that server'senvmap;inherit_env: trueis the per-server escape hatch, with awarn!and unavailable through the admin API. vault:references in an MCP server'senvare resolved at boot, fail-closed (#1237): an unresolvable reference keeps the server from starting, naming server and key, never the value. Before, the literal stringvault:…reached the child as if it were the secret.- MCP tool output enters the context through the indirect-injection guard, with a 256 KiB cap and visible truncation (#1243).
POST /api/mcp/marketplace/installrequires an authenticated admin withPermission::ManagePluginsand refusesenvthat decides which code the child runs:PATH,LD_PRELOAD,NODE_OPTIONS,npm_config_*(#1245).
The api_key gate and exposed binds
gateway.api_keynow covers the conversation plane and A2A (#1240):POST /v1/chat/completions,/v1/messages,/v1/messages/count_tokensand/a2a/*sat outside the gate, which tested only the/api/prefix. The parrot socket (/ws/parrot) came along, and Garra Desktop now actually sends the credential. With nogateway.api_keyconfigured, nothing changes.garra initon a VM as root no longer leaves the gateway on the internet with no credential (#1241): a non-loopback bind generates 32 CSPRNG bytes intogateway.api_key,config.ymlis born0600, and the key is not printed to the terminal. The boot warns once when the bound address reaches the network and there is no credential — including ingarra start -d, before the fork.- A random web page can no longer fire POST/PATCH/DELETE at the local
gateway (#1182): a generic anti-CSRF guard over the whole mutating
surface,
Originchecked on the/wsand/ws/parrothandshake, and CORS closed by default — an emptygateway.allowed_originsnow means no cross-origin origin at all (see "Upgrading"). /pairworks again on every channel (#1189) — each channel built its own always-emptyPairingManager— and the pairing code gains attempt limits (5 per user / 15 min, 20 global attempts burn the code), constant-time comparison and a heads-up to the owner when something was burned (#1191).
The agent's tools
- Directory jail on the file tools (#1244).
file_read,file_writeandlist_diraccepted~and absolute paths with no confinement: a prompt over Telegram could read~/.ssh/id_rsa.FileJailnow confines to the union ofagent.file_roots(+GARRAIA_FILE_ROOTS) and the session'sworking_dir; an empty set denies everything. It canonicalizes before comparing (.., symlinks,~, absolute) and refuses dangling symlinks; the refusal is a single sentence, with no path, so the tool cannot become a file-existence oracle. Declared residuals: TOCTOU and hardlinks. - Indirect-injection guard on
file_readand the hardware tools (#1243): file contents and whatever comes off the bus (device id, Home Assistantstate, MQTT/serial payload) reach the model framed as untrusted data when suspicious; clean text passes byte for byte. - The model's query no longer lands in flag position:
repo_searchbuildsrg/grep/findstrwith the query after--—--pre=/bin/shmade ripgrep itself execute a.txt(#1266);git_diffputsfile_pathafter--and refuses revisions starting with-—--ext-diffreopened external execution via a planted.git/config(#1269). The children ofrun_testsandbashstop inheriting the gateway's stdin (#1270); the full inventory of the 14Commandcall sites is in the threat model. POST /api/providersconnects with an HTTP client pinned to the SSRF-validated IPs and redirects off (#1248), closing redirect laundering to169.254.169.254and DNS rebinding; the providers' default constructors also stop following redirects.
Also: rustls 0.23.45 closes RUSTSEC-2026-0285 (#1206); deny.toml drops a
dead ignore after poise 0.7 (#1202); ConfigLoader::save became atomic
(0600 tmp + rename + fsync) and an empty config.yml no longer silently
turns into defaults (#1238).
Agents and runtime
One default LLM
z-ai/glm-5.3-flash via OpenRouter is now the official default on every
surface (#1180, ADR 0022). The question "which LLM does Garra use when the user
picks nothing?" had five answers depending on the door: openrouter/auto in
chat and the wizard, openrouter/free in mcp-server, openai/gpt-4o in the
gateway, lmstudio on the desktop. Now there is one constant, locked by a
test. Local (Ollama) stays intact as the second option in
agent.fallback_providers; garra chat autodetect tries cloud providers with
credentials before Ollama; and the wizard highlights "Cloud-first" without
pre-selecting the ~18 GB local download. If you pinned
GARRAIA_MCP_MODEL_ALLOWLIST=openrouter/free, you need to adjust (see
"Upgrading").
The turn that doesn't die
-
A dropped network triggers the local fallback (#1249).
connection refused, DNS and timeouts matched no retry pattern and killed the turn before the fallback loop.Error::Transportis now typed: it spends one attempt and falls straight to the next provider, without burning ~3.5 s of backoff. -
A stream that breaks midway redoes the turn through the batch path, when nothing has reached the sink yet (#1176).
-
OpenRouter's 404
No allowed providers are availablebecomes a routing configuration error with the model, the effective restriction and a recovery path, and is never retried (#1299). -
A missing tool parameter becomes a soft observation, with the schema and a request to resend — the model corrects itself within the turn instead of the turn failing with
agent error:(#1296). -
The mode auto-router applies on every entry point (#1223):
garra chat,POST /api/chat, the webchat, the app and the channels. It only acts whenagent.auto_router_llm_enabledis on (default off), the session has no mode and there is text; an inferred mode grants no tool. -
The loop detector's error says what repeated: the tool, the count inside the window and the repeated input, truncated (#1295).
garra chat
-
--resumewith no value (orlatest) resumes the most recent session, and the question is written before the turn runs, with a marker[turno interrompido: timeout|cancelado|erro]when it ends without an answer — crashes, timeouts and Ctrl+C no longer erase the turn. New/resume [id]in the REPL (#1300). -
/modelbecame transactional (#1298): it validates against the provider's real catalog (OpenRouter: the fullGET /models) before switching; a missing model or an unresponsive provider keeps the previous state. -
The REPL no longer shows raw tracing — retry, fallback and circuit breaker stay in
garraia.log;--verbose,--debugandRUST_LOGwin (#1301). -
The project context stops spending prompt on
target/and now says which project it is (fromREADME.md) and on which branch (#1219). -
memory.auto_extractandmemory.max_factsfinally exist: turn off just the per-turn fact extraction call without turning off semantic memory (#1221). -
Line editor with rustyline: arrow keys and in-line editing; history on disk only with
--persist/--resume(0600in~/.garraia/history; in memory otherwise), Ctrl+D exits; pipes and CI stay onread_line(#1297).
Runtime: run ledger and single dispatch
-
Sub-agent runs leave a trail in the
agent_runstable (#1224), and the ledger has its first production writer (#1227): gateway and CLI startup convertrunningruns left by a crash intointerrupted(ids in the log, never thegoal), and every scheduler execution writes a row with a terminal outcome. Ledger failure is fail-soft. -
The four copies of the turn loop dispatch tools through a single function (
dispatch_tool_call): budget, loop detection, mode gate, timeout and confirmation pause live in one place (#1226). -
git_diffandcode_reviewanswer about the right repository: git runs in the session'sworking_dir, and without one the answer says which repository it spoke about (#1258). -
ToolRegistry::execute_program— a JSON program of N steps in a single turn, all-or-nothing variable substitution,max_steps16 (#1224). Still no production caller. -
The scheduler claims tasks with a lease (
pending → running), and the re-poll after a crash is explicit and logged (#1227 S2). -
DbRunLedgeraccepts the gateway's tokioMutex;SubAgentConfig.session_id(#1227 S3).
Hardware, desktop, mobile and storage
- The hardware skills catalog now applies at runtime (#1250). The rule
"effective risk = max(adapter, skill)" existed and no deployment exercised
it. The boot now loads the catalog before starting any adapter, every device
is wrapped in a decorator that only raises risk, and
device_listshows the presets' pt/enaliases— "living room light" maps to the id the gate sees. - ADR 0021 (Desktop Control Center) accepted, with the first two
milestones delivered (#1181): the
garraia-desktop-corecrate — the Tauri-free core that enters the CI gates (state,detect,supervise,locate) — andgarra desktop [--status] [--no-launch], which locates and launches the installed app (exit 0/69/70) without the CLI gaining a Tauri dependency. Resolution never returns the CLI's own executable, which in the.debis the app's sibling. - Garra Mobile in pt-BR and English with a language selector in Settings
(255 keys,
flutter gen_l10n, a test that fails on new hard-coded strings) (#1178), and the launcher icon drops the Flutter default for the WolfMark (#1177). - S3 multipart upload above 16 MiB (#1214): 8 MiB parts, peak memory down
from 5 GiB to 8 MiB per upload, any failure aborts the multipart. SHA-256
checksum verified per part by the server (#1229), with
request_checksum_calculationpinned so a deployment cannot downgrade it. And the MinIO suite starts a real container again (quay.io, #1230).
Operations and CI
-
Every crate declares the MSRV (
rust-version = "1.95", directly or inherited from the workspace), and thechangelog.d/fragment format became a CI gate (#1228). -
The CodeQL ledger anchors by content, not by line number (#1263): the checker derives the line from the
sink_snippet, and the gate only turns red when the statement changed, vanished or became ambiguous. Thereapplyaccepts multi-line statements (alert 173). -
#[cfg(windows)]code under themcpfeature compiles in CI on a native Windows step (#1253), and the docs say file-permission hardening (0600/0700) is Unix-only. -
garra config checkis an opt-in command, not a boot gate — the prose claiming otherwise was corrected (#1247), and the idle clause ofvalidate_session_tokenwent through a bind, notformat!. -
Docs: the real bind precedence (
--host>HOST> default127.0.0.1:3888; the file'sgateway.host/gateway.portdo not feedgarra start) (#1261); real TTS providers indocs/voice.md(#1246); thegarraia-mediaPDF and magic-bytes tests run again (#1208, #1209). -
The Quality Ratchet reports the delta against the PR's merge-base (a distinguishable signal) and warns when the baseline is older than 90 days; the baseline was not re-frozen (#1254).
Deprecated
ToolRegistry::execute_programingarraia-toolsis markeddeprecated; the #1224 fragment was corrected (#1226 S-E).
Upgrading
garra update # existing installs
Or reinstall: curl -fsSL https://garraia.org/install.sh | sh
(Windows: irm https://garraia.org/install.ps1 | iex).
No data migration is needed. Three things change meaning, and one section is new:
agent.sandboxis new and defaults tooff— a missing section reproduces the previous behaviour byte for byte.- If you already turned on
backend: ssh, you must declarenetwork_disabled: falseandmount_workdir: falseexplicitly, or the config is refused (#1225 S3). - An empty
gateway.allowed_originsnow means "no cross-origin origin" (#1182). If you reach the Web Console by DNS name — reverse proxy,nas.local, MagicDNS, ingress — list the origin (https://garraia.yourdomain.com). IP,localhostand the default profile do not change. GARRAIA_MCP_MODEL_ALLOWLIST=openrouter/freebreaks (#1180): the new default is evaluated before the allowlist. Includez-ai/glm-5.3-flashor remove the variable.garra chatautodetect tries cloud before Ollama (#1180). If you relied on Ollama beating an exported cloud key, pinagent.default_provider: ollama.
Known limitations
- The sandbox wraps only the
bashtool;run_tests,git_diff,code_reviewandrepo_searchstill run on the host.backend: sshis not a sandbox. On Windows the sandbox fails closed. - The WhatsApp bridge requires Node.js 20+ and
npmon the host; the Cloud API does not. - The bridge has no memory ceiling:
RLIMIT_AS, which MCP applies, kills Node's V8 right at boot (it reserves several GiB of virtual memory), so containment is the clean environment, PDEATHSIG andDrop. - Personal WhatsApp is one account only (
default). - Pairing with a real device is manual validation — CI covers the protocol, the state machine, the encrypted store and the child lifecycle against a fake bridge.
- ARM64 and the per-platform installers remain best-effort, as in v0.4.2: a release may ship without one of them.