ROS 2 Governed Bridge Quickstart¶
This tutorial walks through autonomy demo ros2-bridge — the end-to-end
demo of the long-lived governed_ros2_bridge C++ process — and shows
the operator-visible evidence it produces: a per-publish allow/deny
narrative on stdout plus one autonomy.decision WAL frame per bridged
message, each tagged with bridge_origin=governed_ros2_bridge.
By the end you’ll have run the bridge against both an allowed topic
(/cmd_vel) and a denied topic (/disable_safety) and inspected the
WAL to confirm the policy enforcement happened at the bridge layer, not
just at launch time.
Operator-facing runbook for enabling the bridge on a real workload: ROS 2 Governed Bridge runbook.
What this demo proves¶
Per-message policy interception. Every DDS publish on the agent domain (
99) round-trips throughPOST /v1/toolbefore the bridge republishes it on the real domain (42). The runtime evaluates each message against the embeddedros2-bridge-demopolicy.Fail-closed deny path. A publish on a denied topic (
/disable_safety) is recorded asoutcome=denyand never republished on the real domain.Spoof-resistant origin tagging (#939 4-E.a). Bridge-routed publishes carry
bridge_origin=governed_ros2_bridgein the decision frame; direct node POSTs do not. The marker is gated byBridgeRoutableKindsso a non-tool.ros2.topic.publishrequest cannot forge it (#941 fix).Readiness-wait correctness (#937). The launch waits for the bridge to print
governed_ros2_bridge: readyon stdout before running the scripted publishes; the readiness gap that pre-#937 silently dropped early publishes is closed.
Prerequisites¶
dockeronPATH. The bridge runs in a container; the scripted publishers spawn per-message containers viadocker run.ghcr.io/autonomyops/adk-ros2-runtime:latestpresent locally. Pull once:docker pull ghcr.io/autonomyops/adk-ros2-runtime:latest
Or build from source (
docker build -t ghcr.io/autonomyops/adk-ros2-runtime:local demo/ros2-runtime/) and pass--image ghcr.io/autonomyops/adk-ros2-runtime:localto the demo.The
autonomybinary onPATH. CE works (autonomy demo ros2-bridgeis registered under both tiers).jqis helpful for the WAL inspection step but not required.
Step 1 — Run the demo (allow + deny in one run)¶
Default flags drive the multi-topic flow: the bridge subscribes to
/cmd_vel (policy allow) AND /disable_safety (policy deny) in one
run; the scripted publisher interleaves both so the transcript visibly
cycles between accepted and rejected publishes.
autonomy demo ros2-bridge
Expected output (annotated):
──────────────────────────────────────────────────────
AutonomyOps / ROS 2 / governed-bridge per-message governance demo
──────────────────────────────────────────────────────
policy : ros2-bridge-demo v1.0.0 (embedded:ros2-bridge-demo)
bridge image : ghcr.io/autonomyops/adk-ros2-runtime:latest
agent domain : 99
real domain : 42
bridge topics : /cmd_vel:std_msgs/msg/String, /disable_safety:std_msgs/msg/String
runtime URL : http://127.0.0.1:<random> (in-process, --network host on bridge)
mode : multi-topic — allow + deny in one run (#939 4-A generic-type subscription)
──────────────────────────────────────────────────────
[1/4] spawning governed_ros2_bridge container...
bridge ready (subscribed on agent domain, listening for POSTs to /v1/tool).
[2/4] running scripted agent publishes:
(DDS discovery warmup: 3s)
→ publish /cmd_vel "go-forward" (bridge will POST + republish on allow)
→ publish /disable_safety "shut-it-down" (bridge will POST + republish on allow)
→ publish /cmd_vel "go-left" (bridge will POST + republish on allow)
→ publish /disable_safety "trip-the-estop" (bridge will POST + republish on allow)
→ publish /cmd_vel "go-right" (bridge will POST + republish on allow)
[3/4] reading WAL for autonomy.decision frames written by this run...
[4/4] tearing down bridge...
──────────────────────────────────────────────────────
WAL summary (this run):
allow : 3 (publishes that reached the real DDS domain)
deny : 2 (publishes the bridge dropped on policy)
bridge_origin : 5 / 5 decision frames carried the governed_ros2_bridge marker (#939 4-E.a)
inspect : autonomy wal inspect --dir <wal-dir> --kind autonomy.decision
──────────────────────────────────────────────────────
The per-publish line says “bridge will POST + republish on allow” for
every entry; whether the bridge actually republishes (vs drops) is
decided by the policy AFTER the POST and is summarised in the WAL
counts. The 3 + 2 split is the load-bearing evidence: the bridge
intercepts every publish, the policy approves the /cmd_vel traffic,
the policy rejects the /disable_safety traffic, and only the
approved publishes reach the real DDS domain.
If the summary shows anything other than allow=3, deny=2, bridge_origin=5/5, see Troubleshooting below.
Step 2 — Read the WAL evidence¶
The demo writes the run’s WAL to a temp directory and tears it down on exit. Two ways to preserve and inspect it:
Option A — pin the WAL directory up front. Set
AUTONOMY_DEMO_WAL_DIR before running the demo, then point
autonomy wal inspect --dir at the same path:
AUTONOMY_DEMO_WAL_DIR=/tmp/ros2-bridge-wal autonomy demo ros2-bridge
# Preferred: first-class filter, no jq needed (#939 4-E.a).
autonomy wal inspect --dir /tmp/ros2-bridge-wal \
--kind autonomy.decision --bridge-only
Option B — preserve the ephemeral path with --keep. The
teardown line prints the preserved WAL dir; pass it to
autonomy wal inspect --dir:
autonomy demo ros2-bridge --keep
# (note the "preserved at" path the teardown prints, e.g. /tmp/autonomyops-demo-wal-XXXXXXXX)
autonomy wal inspect --dir <preserved-path> \
--kind autonomy.decision --bridge-only
--bridge-onlyfilters to entries whoseevent.attrs.bridge_originnames a recognized bridge (today:governed_ros2_bridge). It’s the first-class equivalent of the older--json | jq 'select(.event.attrs.bridge_origin == "governed_ros2_bridge")'incantation — keep using jq if you need richer field projection, but for “show me the bridge-routed decisions” the flag is enough. Composes with--kind,--since, and--json. Note the.event.hop:wal inspect --jsonemits the fulltelemetry.Entryshape ({seq, written_at, event: {kind, attrs}}), so attrs live one level down from the top-level object.
The env var AUTONOMY_DEMO_WAL_DIR is honoured only by the demo
commands (it pins where the demo writes); autonomy wal inspect
reads the directory it is given via --dir. Mixing the two is the
common gotcha — without --dir, wal inspect falls back to
XDG_CACHE_HOME/autonomyops/telemetry, not the demo’s preserved
path.
Expected: five JSON objects, each with event.attrs.tool=tool.ros2.topic.publish,
event.attrs.bridge_origin=governed_ros2_bridge, and
event.attrs.policy_ref=embedded:ros2-bridge-demo. Three have
event.attrs.outcome=allow (the /cmd_vel publishes), two have
event.attrs.outcome=deny (the /disable_safety publishes).
Direct node POSTs (had there been any) would show bridge_origin
absent. The demo’s bridge runs as the sole publisher so all five
frames carry the marker.
Step 3 — Pin to a single topic (back-compat override)¶
--topic <name> overrides the multi-topic default and drives the
legacy single-topic flow against the named topic — three
std_msgs/msg/String publishes onto it, no interleaving. Useful for
testing one topic in isolation or matching pre-#939-4-A demo
recordings.
# All allow — bridge subscribes only to /cmd_vel:
autonomy demo ros2-bridge --topic /cmd_vel
# All deny — bridge subscribes only to /disable_safety:
autonomy demo ros2-bridge --topic /disable_safety
Expected single-topic summaries: allow=3, deny=0, bridge_origin=3/3
for /cmd_vel; allow=0, deny=3, bridge_origin=3/3 for
/disable_safety. Each scripted publish reached the bridge, was POSTed
to /v1/tool, was decided by the policy, and (only on allow) was
republished on the real domain. Denies are first-class governance
evidence — they appear as decision frames in the WAL with
outcome=deny, not silent drops.
Subscribe-side proof (optional, in two separate terminals before the demo runs):
# Terminal A — sees go-forward / go-left / go-right echoes (allowed):
ROS_DOMAIN_ID=42 ros2 topic echo /cmd_vel
# Terminal B — sees NO echoes; the two /disable_safety publishes
# never reach the real domain because the policy denied them:
ROS_DOMAIN_ID=42 ros2 topic echo /disable_safety
Step 4 — Confirm the spoof gate¶
The bridge_origin marker is gated on the request kind so a node
calling tool.echo cannot forge it (#941 fix). The demo does not
itself spoof, but the contract is exercised by the test suite — to
verify on your branch:
go test ./runtime/... -run BridgeOrigin -v
Expected: tests including TestBridgeOriginFromRequest_NonRoutableKindIsIgnored
pass. The gate’s source is bridgeOriginFromRequest in
runtime/server.go; the closed set lives in
ros2bridge.BridgeRoutableKinds.
Troubleshooting¶
Symptom |
Likely cause |
Fix |
|---|---|---|
|
Cold image pull, missing binary, or stale image. |
Pre-pull ( |
|
Hard exit during startup — broken image or |
Check |
|
DDS discovery race — a publisher container exited before the bridge’s subscription matched. |
The demo paces publishes with |
|
The bridge is not in fact mediating — the publishes reached the runtime via a non-bridge path. |
Confirm the bridge container is alive ( |
|
The image isn’t on the host. |
Pull it ( |
Bridge container leaked after |
Docker didn’t get the SIGTERM cascade in time. |
See runbook Step 6. |
What’s next¶
Real-workload bring-up procedure: ROS 2 Governed Bridge runbook.
Launch-level governance (the “did this launch start at all?” question): ROS 2 Governance reference.
Markers + observability shape: ROS 2 Markers and Observability.
Generic-type subscription, multi-topic in one bridge: #939 4-A (landed — see the “Multi-topic + generic-type interception” section of the runbook for the
GOVERNED_BRIDGE_TOPICSenv contract).DDS-Security / SROS 2 (defense-in-depth on top of the bridge): SROS 2 quickstart + SROS 2 runbook. Provision the keystore + per-identity enclaves, wire to the bridge via
--bridge-keystore+--bridge-enclave+--workload-enclave, verify bypass-resistance end-to-end.