Skip to content

feat(automation): operator-controlled observe-only bridge mode (#4188)#4208

Open
ten9876 wants to merge 1 commit into
mainfrom
mcp-readonly-profile
Open

feat(automation): operator-controlled observe-only bridge mode (#4188)#4208
ten9876 wants to merge 1 commit into
mainfrom
mcp-readonly-profile

Conversation

@ten9876

@ten9876 ten9876 commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator

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:

  • AutomationServerm_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); AETHER_AUTOMATION_READONLY=1 pins it for
    headless/CI.
  • Radio Setup → Network — an "Observe only" checkbox that persists and takes
    effect immediately on the running bridge.
  • Python MCP server — reflects the state in bridge_status
    (bridge_read_only + a read_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

  • Live, offscreen: with observe-only armed, ping/whoami reported
    readOnly:true, reads (get/dumpTree/log tail) succeeded, and mutating
    verbs (tune/invoke/slice) were refused.
  • Python regression test added; aether_mcp_field_mapping ctest passes.
  • Full AetherSDR build clean; commit GPG-signed.

🤖 Generated with Claude Code

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>
@ten9876
ten9876 requested review from a team as code owners July 12, 2026 18:18

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • log is on the observe-only allowlist, but its set/reset subcommands mutate process-wide LogManager state — while mark (also a log-ring writer) is correctly blocked. Slight tension with the "refuses every mutating verb" wording. Detail inline.
  • Minor: audioCapture is 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"),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 status are 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

Comment on lines +2130 to +2131
QStringLiteral("log"), QStringLiteral("floors"),
QStringLiteral("streams"), QStringLiteral("hitTest"),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, scale report) 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

Comment on lines +2126 to +2131
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"),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@jensenpat

Copy link
Copy Markdown
Collaborator

Holding for next weeks release.

@jensenpat
jensenpat marked this pull request as draft July 17, 2026 16:25
@jensenpat
jensenpat marked this pull request as ready for review July 19, 2026 03:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants