feat(automation): operator-controlled observe-only bridge mode (#4188)#4208
feat(automation): operator-controlled observe-only bridge mode (#4188)#4208ten9876 wants to merge 1 commit into
Conversation
Add a read-only ("Observe only") profile for the agent automation
bridge so an operator can hand an MCP client look-but-don't-touch
visibility. The gate is enforced IN THE BRIDGE, not the MCP server, so
a client talking to the socket directly cannot bypass it, and it is
operator-driven (never client-flippable).
Design mirrors the existing TX gate:
- AutomationServer gains m_readOnly + setReadOnly(); handleLine refuses
every verb outside a pure-introspection allowlist (ping, verbs,
whoami, dumpTree, grab, get, log, floors, streams, hitTest) with a
message pointing back to the checkbox. ping/whoami report `readOnly`.
- AutomationBridgeSettings persists `readOnly` in the nested config
{enabled,txAllowed,txAck,readOnly} (Principle V).
- MainWindow applies the persisted opt-in after start() and pushes
toggles live (no restart); an AETHER_AUTOMATION_READONLY env pins it
for headless/CI.
- Radio Setup -> Network gains an "Observe only" checkbox that persists
and takes effect immediately on the running bridge — enabling the
intended flow: start the app with the bridge off, arm observe-only,
then start the bridge.
- The Python MCP server reflects the bridge state in bridge_status
(bridge_read_only + a read_only_note); enforcement stays in the app.
Live-verified offscreen: with observe-only armed, ping/whoami report
readOnly:true, reads (get/dumpTree/log tail) succeed, and mutating
verbs (tune/invoke/slice) are refused. Python regression test added.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Clean, well-scoped change. The design is right: the gate is enforced in handleLine (server-side, after the auth check, before dispatch) rather than in the MCP client, it's operator-only, and it reuses the established nested-JSON AutomationBridgeSettings pattern (Principle V) and the live-push idiom that mirrors the existing TX gate. CI is green and the commit is signed. The Python test is a good addition and correctly cleans up the env var it sets. Just one non-blocking note about allowlist granularity below.
Non-blocking notes
logis on the observe-only allowlist, but itsset/resetsubcommands mutate process-wideLogManagerstate — whilemark(also a log-ring writer) is correctly blocked. Slight tension with the "refuses every mutating verb" wording. Detail inline.- Minor:
audioCaptureis a pure-observe verb but isn't allowlisted, so it's refused in observe-only mode. Probably fine as a conservative default, but you may want it available for a genuine look-only session. (Body note — the verb isn't in this diff.)
Nice work, @ten9876 — the enforcement placement and the env-override for headless/CI are exactly right.
🤖 aethersdr-agent · cost: $4.5147 · model: claude-opus-4-8
| QStringLiteral("ping"), QStringLiteral("verbs"), | ||
| QStringLiteral("whoami"), QStringLiteral("dumpTree"), | ||
| QStringLiteral("grab"), QStringLiteral("get"), | ||
| QStringLiteral("log"), QStringLiteral("floors"), |
There was a problem hiding this comment.
log is allowlisted wholesale, but it isn't pure introspection: log set calls lm.setEnabled(...) / setAllEnabled(...) and log reset calls lm.loadSettings() (AutomationServer.cpp:7317-7338), both of which mutate the process-wide LogManager. That's a small crack in the "refuses every mutating verb" claim — and it's inconsistent with mark, which writes to the log ring and is blocked.
Low severity (bridge-local logging state, not radio state), so not a merge blocker. Two clean options if you want to tighten it: gate the mutating log subcommands inside doLog() when m_readOnly is set, or narrow the doc/comment wording to "mutating radio/UI verbs" so the log carve-out is intentional and documented.
There was a problem hiding this comment.
Nicely done — this cleanly mirrors the existing TX gate and gets the important things right: enforcement lives in handleLine (not the MCP server) so a socket-direct client can't bypass it, it's deny-by-default (any verb not explicitly allowlisted is refused, so future verbs are safe automatically), it gates on the resolved canonical spec->name so aliases are covered, and the gate sits after the auth check. The env override deliberately doesn't write the persisted value, and the live-toggle path matches the TX idiom. Persistence stays in the nested AutomationBridge key (Principle V), and the Python test covers both the reflected state and the absence of the note. All 9 checks are green and the commit is signed.
Non-blocking notes
- The observe-only allowlist is verb-level, but two allowlisted verbs (
log,streams) have mutating sub-actions that slip through what the comment/docs call "pure introspection" — see inline. Low severity (neither drives the radio), but worth a wording tweak or a sub-action guard. - Mirror-image, and I think correctly the safe side:
connect list/show,scale(report),menu list,dss snapshot,qrz statusare read sub-actions that are now fully blocked because their parent verb mutates. That's the right fail-safe default — just flagging it's intentional so it isn't a surprise later.
No changes required from my side; take or leave the note above.
🤖 aethersdr-agent · cost: $4.6173 · model: claude-opus-4-8
| QStringLiteral("log"), QStringLiteral("floors"), | ||
| QStringLiteral("streams"), QStringLiteral("hitTest"), |
There was a problem hiding this comment.
The allowlist is verb-level, but log and streams each carry mutating sub-actions that now pass the gate:
log set/log reset/log subscribe/log unsubscribe— change log categories and register the socket for push (log <categories|get|set|reset|tail|subscribe|unsubscribe>).streams reset— resets diagnostic counters (streams [radio|reset]).
None of these drive the radio, so the core threat model holds and this is non-blocking. But it's slightly at odds with the "pure introspection" framing in the comment and docs/automation-bridge.md — and a touch inconsistent that mark (write a log annotation) is refused while log subscribe is allowed. Two options: soften the "pure introspection" wording to "read + log/diagnostic control," or gate the mutating sub-actions inside the allowlisted verbs so observe-only really is read-only. Your call — just want it to be a deliberate choice.
There was a problem hiding this comment.
Nice work — this is a clean, tightly-scoped feature that mirrors the existing TX-gate pattern faithfully. The design decisions are all the right ones: enforcement in the bridge (handleLine) rather than the MCP server so a client can't bypass it, operator-only control, a default-deny allowlist, persistence via the nested AutomationBridge config key (Principle V), and a live toggle that matches the txAllowed path exactly. I verified the read-only gate sits after the token check and operates on the resolved canonical verb name, so there's no auth-handshake lockout and aliases are covered. ping stays open on both the auth and read-only paths. I also cross-checked the allowlist against the full verb registry: every excluded verb is either mutating or mixed-mode, and no mutating verb leaked into the safe set. Docs, a Python regression test, and bridge_status reflection are all present. CI is green (9/9) and the commit is signed.
Non-blocking notes
- The allowlist correctly errs on the side of default-deny; a couple of mixed-mode verbs (
layout get,scalereport) lose their read sub-action in observe-only. Worth a one-line confirm that's intended (inline).
No changes required from me — happy to see this merge. Thanks for the thorough PR description and the offscreen verification.
🤖 aethersdr-agent · cost: $6.0403 · model: claude-opus-4-8
| static const QSet<QString> kSafe = { | ||
| QStringLiteral("ping"), QStringLiteral("verbs"), | ||
| QStringLiteral("whoami"), QStringLiteral("dumpTree"), | ||
| QStringLiteral("grab"), QStringLiteral("get"), | ||
| QStringLiteral("log"), QStringLiteral("floors"), | ||
| QStringLiteral("streams"), QStringLiteral("hitTest"), |
There was a problem hiding this comment.
The default-deny allowlist is the right call for a security gate. One thing to confirm is intentional: a few verbs have a read-only sub-action that gets refused wholesale here — layout get and scale (report with no arg) both report state, and tooltip reads widget text (though it also force-shows the tooltip, so blocking it is defensible). None of these are important reads, and losing them is a fine trade for a simple, auditable allowlist — just flagging so the omission is a decision rather than an oversight. No change needed if it's deliberate.
|
Holding for next weeks release. |
Observe-only ("read-only") bridge profile — #4188 area 6
Adds a look-but-don't-touch mode for the agent automation bridge so an
operator can hand an MCP client visibility without letting it drive the radio.
Design
Read-only is operator-controlled (never client-flippable) and enforced in
the bridge (not the MCP server), so a client talking to the socket directly
cannot bypass it. It mirrors the existing TX gate:
AutomationServer—m_readOnly+setReadOnly();handleLinerefusesevery verb outside a pure-introspection allowlist (
ping,verbs,whoami,dumpTree,grab,get,log,floors,streams,hitTest) with amessage pointing back to the checkbox.
ping/whoamireportreadOnly.AutomationBridgeSettings— persistsreadOnlyin the nested config{enabled,txAllowed,txAck,readOnly}(Principle V).MainWindow— applies the persisted opt-in afterstart()and pushestoggles live (no restart);
AETHER_AUTOMATION_READONLY=1pins it forheadless/CI.
effect immediately on the running bridge.
bridge_status(
bridge_read_only+ aread_only_note); enforcement stays in the app.Intended flow
Start the app with the bridge off → check Observe only → start the bridge.
The live toggle means no restart is needed to arm or lift the gate.
Verification
ping/whoamireportedreadOnly:true, reads (get/dumpTree/log tail) succeeded, and mutatingverbs (
tune/invoke/slice) were refused.aether_mcp_field_mappingctest passes.AetherSDRbuild clean; commit GPG-signed.🤖 Generated with Claude Code