Skip to content

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

Merged
jensenpat merged 1 commit into
mainfrom
mcp-readonly-profile
Jul 19, 2026
Merged

feat(automation): operator-controlled observe-only bridge mode (#4188)#4208
jensenpat merged 1 commit into
mainfrom
mcp-readonly-profile

Conversation

@ten9876

@ten9876 ten9876 commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator

Observe-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 app or radio.

Design

Read-only is operator-controlled (never client-flippable) and enforced in the bridge after authentication but before dispatch, so a client talking to the socket directly cannot bypass it.

  • AutomationServer — reports readOnly from ping/whoami and defaults to denying every verb outside the introspection policy.
  • Mixed diagnostic verbs — admission is action-aware. log categories/get/tail/subscribe/unsubscribe and streams inventory reads remain available; log set/reset and streams reset/resync/refresh are blocked. This prevents observe-only clients from changing runtime diagnostics or sending a radio inventory resync.
  • AutomationBridgeSettings — persists readOnly in the nested {enabled,txAllowed,txAck,readOnly} bridge configuration (Principle V).
  • Radio Setup → Network — adds a live Observe only checkbox; AETHER_AUTOMATION_READONLY=1 pins the gate for headless/CI use.
  • Python MCP server — reflects the authoritative bridge state as bridge_read_only plus a read_only_note.

Screenshot

Observe only in Radio Setup → Network

Verification

  • Rebased onto current origin/main (05a2222c).
  • Full macOS RelWithDebInfo build passed with 8 compile jobs.
  • CTest: 128/128 passed; one quarantined acceptance test intentionally skipped.
  • tools/test_aether_mcp.py passed.
  • tools/check_engine_boundary.py --strict passed with no blocking findings.
  • Live offscreen bridge test with autoconnect disabled and observe-only pinned:
    • allowed log categories/tail and streams default/radio/inventory;
    • blocked log set/reset, streams reset/resync/refresh, and tune;
    • raw bare-line requests were blocked identically, proving JSON is not a bypass.
  • Single SSH-signed commit; signature verified locally.

🤖 Original implementation generated with Claude Code.

👨🏼‍💻 Generated with OpenAI Codex (GPT-5.6 Sol) and tested by @jensenpat

@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

Comment thread src/core/AutomationServer.cpp Outdated

@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 thread src/core/AutomationServer.cpp Outdated

@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 thread src/core/AutomationServer.cpp Outdated
@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
@jensenpat jensenpat self-assigned this Jul 19, 2026
Add an operator-controlled, bridge-enforced read-only profile for automation clients, persist it in the nested bridge configuration, expose it in bridge status, and provide a live Radio Setup toggle.

Apply action-aware admission to mixed diagnostic verbs: observe-only clients may read log state and stream inventories, but cannot change log categories, clear stream diagnostics, or request a radio inventory resync. Document and screenshot the operator control.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jensenpat
jensenpat force-pushed the mcp-readonly-profile branch from aa15dcb to 2851b1a Compare July 19, 2026 15:18
@ten9876

ten9876 commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator Author

First-pass triage review (not exhaustive — surfacing blockers to keep this moving):

Blockers

None spotted in a first pass. The observe-only gate is soundly designed:

  • The gate (AutomationServer.cpp:2953) sits after the auth check and before dispatch, on the resolved canonical spec->name, so both the JSON and bare-line request paths converge on it (both reach the shared findVerb/gate at handleLine ~2913) — a client talking to the socket directly can't bypass it, matching the PR's claim.
  • isReadOnlyRequest is a whitelist (fail-safe): anything not explicitly listed is refused, so an unrecognized or future verb is blocked by default rather than leaking through.
  • All fully-allowed names (ping/verbs/whoami/dumpTree/floors/grab/hitTest/get) match the registry's canonical spellings, so there's no alias-mismatch that would silently over-block them; hitTest is canonical (alias hittest), get is a pure model snapshot.
  • The only mixed read/write verbs (log, streams) are action-gated, and the action-set check is itself a whitelist keyed on the same a.action the dispatch uses, in both request forms.
  • Live toggle is not a data race: AutomationServer has no moveToThread, so it runs on the GUI thread and setReadOnly follows the existing setTxAllowed bool-set precedent on that same thread.

Secondary notes

  • The security-critical C++ logic (the isReadOnlyRequest gate) has no automated test — the added coverage (test_aether_mcp.py::test_read_only_reflected) only exercises the Python MCP mirror of bridge_read_only. The actual allow/deny matrix is validated by manual live testing in the PR body only. A small C++ table test asserting the safe set passes and a representative mutating verb (+ log set/streams reset) is refused would lock this down against future verb additions. Not a blocker.
  • record (WAV capture) and audioCapture are correctly blocked in observe-only; blocking them is the defensible choice, just noting they're read-ish verbs an operator might expect. grab still writes a PNG to a client-specified path in observe-only, but that's pre-existing behavior, not introduced here.

Gating logic looks correct and bypass-resistant; the only real gap is test coverage of the gate itself, which is a follow-up rather than a merge blocker.

@jensenpat jensenpat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approved after cleanup and verification. The observe-only gate is now action-aware: log mutations (set/reset), stream mutations (reset/resync/refresh), and radio-affecting verbs are rejected, while diagnostic log and stream reads remain available. Verified through both JSON and raw bridge requests with autoconnect disabled, plus the full 128-test local suite, bridge checks, strict engine-boundary check, macOS build, and all GitHub checks including C++ CodeQL. The branch is rebased onto current main as one signed commit (2851b1a). Principles VII and VIII.

@jensenpat
jensenpat merged commit f4ee25d into main Jul 19, 2026
9 checks passed
@jensenpat
jensenpat deleted the mcp-readonly-profile branch July 19, 2026 16:01
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