ROS2 Governance¶
The AutonomyOps ADK wraps every ROS2 operation in a governance layer that evaluates actions against an OPA policy before spawning any process.
First run (Community Edition)¶
The Community Edition distribution of the autonomy CLI dispatches ROS2
governance via autonomy run ros2.launch. (adk_ce_<version>_<os>_<arch>.tar.gz
is the CE package name; once installed the binary is on PATH as
autonomy.) No orchestrator, fleet, or paid-tier deployment is required:
autonomy run \
--image ghcr.io/autonomyops/adk-ros2-runtime:local \
ros2.launch launch demo_robot arm_demo.launch.py
End-to-end walkthrough with image build, PASS markers, and bundle inspection: Robotics Quickstart.
Mirrored Python + C++ demo (Tier 4)¶
The repository ships two minimal mirrored ROS 2 nodes — one Python, one C++ —
that prove the AutonomyOps host-side launcher is language-agnostic. The
two demo launches run side-by-side under the same shared OPA allowlist
(demo/bundles/ros2/policies/ros2_safety.rego) and produce the same
launcher PASS markers, the same host-side gate decision, and the same
persisted autonomy.decision frame. Both entry points produce the same
decision-frame shape, emitted from different layers:
autonomy run ros2.launch …(CE) — host-side gate inrunCmdevaluates the embedded ROS2 demo allowlist before dispatch.autonomy ros2 run …(paid) — runtime-layerevaluatePreExecevaluates theros2.DefaultEvaluator“dev-mode” policy (permissive ontool.ros2.*, hard-deny ontool.shell, fail-closed on unknown kinds) by default. This is the documented behaviour of the paid-tier--policyflag and applies whether you are running the demo or a non-demo workload likelaunch nav2_bringup ….
Tracking issue: #720.
Both are valid first-run robotics demos. Pick the language you already write your robot’s nodes in.
# Python (ament_python — demo_robot package):
autonomy run \
--image ghcr.io/autonomyops/adk-ros2-runtime:local \
ros2.launch launch demo_robot governed_arm_python.launch.py
# C++ (ament_cmake — demo_robot_cpp package):
autonomy run \
--image ghcr.io/autonomyops/adk-ros2-runtime:local \
ros2.launch launch demo_robot_cpp governed_arm_cpp.launch.py
Core message: same shared policy, same governance contract, same audit
evidence, different language, different package — because that is how ROS 2
packaging works. The mirrored demo deliberately uses two sibling packages
(demo_robot ament_python + demo_robot_cpp ament_cmake) rather than a
hybrid; that’s #720 Option A.
What Tier 4 proves today¶
Both launches produce the same six launcher-level WAL PASS markers, in the same order:
ros2-telemetry-activeros2-runtime-container-ready(container path only)ros2-launch-allowed— host-side gate decision markerros2-stack-startros2-policy-enforcedros2-wal-recording
Both launches also persist one autonomy.decision frame to the WAL,
emitted by the host-side launch-policy gate before the dispatch hand-off.
The frame shape is identical across languages:
attribute |
value |
|---|---|
|
|
|
|
|
|
Deny path: a non-allowlisted (package, launch_file) tuple short-circuits the
cascade — the launcher emits ros2-launch-denied, persists an
autonomy.decision frame with outcome=deny, and none of the post-gate
markers (ros2-stack-start, ros2-policy-enforced, ros2-wal-recording)
fire. runGoverned is never invoked.
Each launched node (on the allow path) also prints two deterministic stdout
markers from its own process — PASS ros2-{python|cpp}-start then
PASS ros2-{python|cpp}-wal-written — so an operator can confirm the node
was reached and ran a ROS 2 API call.
The cross-language equivalence claims are asserted in CI by
TestRos2Tier2Equivalence_LauncherMarkers_PythonAndCppAreIdentical and
TestRos2Tier2Equivalence_DecisionFrame_PythonAndCppAreIdentical in
cmd/autonomy/commands/ros2_mirrored_equivalence_test.go.
The shared policy that authorises both is
demo/bundles/ros2/policies/ros2_safety.rego
— a single Rego rule covers both (package, launch_file) tuples; there is no
language branch in the policy. The embedded copy used when no --policy
override is set is
policy/embed_ros2_assets/policy.rego,
held byte-for-byte equal to the canonical by
TestEmbeddedRos2_NoDriftFromCanonical.
When --policy <ref> is set, the host-side gate runs against the
user-supplied bundle resolved via
policy.NewCache(policy.DefaultCacheDir()) plus
cache.FetchOrCached(ctx, ref) — the same managed-cache path the plain
autonomy run --policy <ref> flow uses. Either way the gate produces the
same evidence shape: one autonomy.decision frame plus a
ros2-launch-allowed (or -denied) marker. policy_ref on the frame is
the bundle’s manifest.policy_ref, falling back to the user-supplied ref
when the manifest omits it (legacy bundles).
Sub-task D is also closed at the image layer: INV-ARCH-12 in
ci/test_arch_invariant.sh
pins that demo/ros2-runtime/Dockerfile installs ros-humble-rclcpp +
ros-humble-std-msgs AND invokes colcon build --packages-select … demo_robot_cpp …,
so a regression that drops the C++ runtime libs or the compiled C++
workspace fails CI before reaching the launcher.
Node-level governance via POST /v1/tool¶
Both entry points (autonomy run ros2.launch … and autonomy ros2 run …)
wire RunOptions.AutoRuntime=true by default whenever the host-side gate
runs, so every launched node exercises node-level governance by
POST-ing two requests to the AutonomyOps runtime URL injected as
AUTONOMY_RUNTIME_URL:
tool.ros2.topic.list— allowed by both the embedded ros2-demo allowlist ANDros2.DefaultEvaluator(prefix match ontool.ros2.) → expect HTTP 200 → emitPASS ros2-{python|cpp}-safe-action-allowed.tool.shell— hard-denied by every ros2 policy variant → expect HTTP 403 → emitPASS ros2-{python|cpp}-unsafe-action-denied.
Each POST produces one further autonomy.decision frame in the same WAL
(emitted by the in-process tool server itself; tool.ros2.topic.list
outcome=allow, tool.shell outcome=deny, both carrying the same
policy_ref as the launch-level frame). The full Tier 4 marker stream
per language thus extends to:
PASS ros2-{python|cpp}-start
PASS ros2-{python|cpp}-safe-action-allowed
PASS ros2-{python|cpp}-unsafe-action-denied
PASS ros2-{python|cpp}-wal-written
— and the WAL contains three autonomy.decision frames per
invocation (1 launch-level allow + 2 node-level: 1 allow, 1 deny).
The Python node uses urllib.request (stdlib, no image dep). The C++
node shells out to system("curl …") per the issue’s sub-task E
preference. The curl package install in the runtime Dockerfile is
pinned by INV-ARCH-13.
The C++ container is launched under Docker --network host so the
random-port in-process tool server (bound to 127.0.0.1:<port> on the
host) is reachable from inside the container. This is gated by the
typed allowlist in runtime/exec
(NetworkMode="host" is the only currently-allowed mode), so a typo
or unreviewed network mode is rejected before the spawn.
#720 series — closed¶
All four tiers of the mirrored Python + C++ ROS2 governed-node demo, plus every sub-task on the issue’s deferred-work list, have landed:
Tier / sub-task |
What it proved |
PR |
|---|---|---|
Tier 1 |
Same 5 launcher PASS markers, both languages |
#757 |
Tier 2 / sub-task A |
Allow/deny visible at host-side gate ( |
#759 |
Tier 3 / sub-tasks B+D |
Persisted |
#760, #761 |
Tier 4 / sub-tasks C+E |
In-process tool server + node-level |
#761 |
Close-the-gaps |
CE entry point Tier 4 wire-in; |
this PR |
The CE and paid entry points now produce the same evidence shape per
invocation: one launch-level autonomy.decision frame + two node-level
frames (allow + deny) + the four per-language PASS markers above. The
deferred-items list is empty.
Architecture overview¶
Both the CE dispatch path (autonomy run ros2.launch) and the paid-tier
subcommand (autonomy ros2 run) feed the same dual-path resolver and the
same pre-exec policy gate:
autonomy run ─┐
ros2.launch │
├─▶ runtime/dualpath.Resolve()
autonomy ros2 run ─┘ │
(paid tier) ▼
ModeContainer ──▶ docker run --rm <image> ros2 …
ModeNative ──▶ ros2 … (native subprocess)
│
pre-exec policy gate
(runtime.Evaluator.Eval)
│
┌───────┴──────────┐
Allow Deny
│ │
subprocess ErrPolicyDenied
starts returned, nothing
spawned
Dual-path execution¶
The dual-path resolver (runtime/dualpath) probes the host environment at
call time and returns one of two modes:
Mode |
Condition |
Security posture |
|---|---|---|
|
Docker daemon reachable |
Full isolation; active policy interception via governance sidecar |
|
Docker absent, |
Reduced governance; subprocess launched without container isolation |
When ModeNative is selected a REDUCED-GOVERNANCE warning is printed to
stderr:
[WARN] ros2: REDUCED-GOVERNANCE native mode — Docker unavailable; container
isolation and full policy interception are bypassed. …
Container path is required for production deployments.
ModeContainer requires --image to be set to a ROS2-capable image. The
adk-runtime base image does not bundle ROS2 toolchain binaries.
Pre-execution policy gate¶
Before any subprocess is spawned RunGoverned calls
opts.Evaluator.Eval(ctx, action) where action is derived from the args:
args[0] → action kind
args[1], args[2] → params (package, launch_file for ros2.launch)
A Deny decision returns ros2.ErrPolicyDenied immediately and no process is
started.
Default evaluator¶
ros2.DefaultEvaluator() enforces:
Action kind |
Decision |
|---|---|
|
Allow |
|
Allow |
|
Allow |
|
Deny (hard-coded, cannot be overridden) |
anything else |
Deny |
Use the default evaluator for development and CI demos.
Launch evaluator¶
ros2.NewLaunchEvaluator(opts) adds package-level and topic-level
allowlisting on top of the default rules. Packages are governed by
PackageRule (per-package allow flag + launch-file allowlist); topics are
governed by an ordered []TopicRule list (first match wins):
eval := ros2.NewLaunchEvaluator(ros2.LaunchPolicyOptions{
PackageRules: map[string]ros2.PackageRule{
"demo_robot": {
Allowed: true,
AllowedLaunchFiles: []string{"arm_demo.launch.py"},
},
"my_robot": {
Allowed: true, // all launch files permitted
},
},
TopicRules: []ros2.TopicRule{
{Pattern: "/arm/**", Allowed: true},
{Pattern: "/my_robot/**", Allowed: true},
},
})
When PackageRules is non-empty, any package not listed is denied
(fail-closed). Similarly when TopicRules is non-empty, a topic that matches
no rule is denied. A publish action for /unregistered/topic is denied even
if tool.ros2.* is otherwise allowed.
OPA policy layer¶
The bundle policy (a Rego file) is evaluated by the OPA engine inside the
governance sidecar. The demo policy (demo/bundles/ros2/policies/ros2_safety.rego)
is representative of a production policy:
package autonomy
import rego.v1
default allow := false
allow if {
input.kind == "tool.ros2.launch"
input.params.package == "demo_robot"
input.params.launch_file == "arm_demo.launch.py"
}
allow if {
input.kind == "tool.ros2.topic.subscribe"
startswith(input.params.topic, "/arm/")
}
allow if {
input.kind == "tool.ros2.topic.publish"
input.params.topic == "/arm/cmd_vel"
}
# … more rules …
# tool.shell is never allowed — explicit guard.
allow if { false } # tool.shell never matches any allow rule
All policies are fail-closed: default allow := false means an unrecognised
action kind is denied, not allowed.
Running the OPA policy tests¶
The demo policy ships with a Go-based test suite:
go test ./policy/... -run TestRos2DemoPolicy -v
If you have the OPA CLI installed, you can run tests directly:
opa test demo/bundles/ros2/policies/ -v
Writing a policy for your robot¶
Copy the template and fill in the CONFIGURE sections:
cp demo/bundles/ros2/policies/ros2_policy_template.rego \
demo/bundles/my_robot/policies/my_robot_safety.rego
Minimum changes:
Replace
demo_robotwith your package name.Replace
/arm/with your topic namespace.List your allowed service endpoints.
Run
opa testto verify no regressions.
RunGoverned — programmatic API (advanced integration)¶
The CLI commands above are the supported path for first use. Teams embedding
ROS2 governance into their own Go services can call runtime/ros2.RunGoverned
directly — the package is part of the AutonomyOps adk repository and is the
same code the CLI dispatches to. Use this path when the CLI’s flag surface
isn’t enough (custom evaluators, in-process supervision, integration tests).
import "github.com/autonomyops/adk/runtime/ros2"
opts := ros2.RunOptions{
Image: "ghcr.io/autonomyops/adk-ros2-runtime:v1.0.0",
Policy: "policies/arm_safety.rego",
RuntimeURL: "http://localhost:8080",
Evaluator: ros2.DefaultEvaluator(),
}
// RunGoverned signature: RunGoverned(ctx context.Context, args []string, opts RunOptions) error
args := []string{"launch", "demo_robot", "arm_demo.launch.py"}
if err := ros2.RunGoverned(ctx, args, opts); err != nil {
switch {
case errors.Is(err, ros2.ErrPolicyDenied):
log.Fatalf("policy denied: %v", err)
case errors.Is(err, ros2.ErrImageRequired):
log.Fatalf("image required for container path")
case errors.Is(err, ros2.ErrNeitherAvailable):
log.Fatalf("neither Docker nor ros2 available")
}
}
Governed node lifecycle¶
ros2.Lifecycle manages governed start/stop transitions for individual nodes:
lc := ros2.NewLifecycle(interceptor)
if err := lc.Start(ctx, "arm_controller"); err != nil {
// policy denied or interceptor error
}
// … later …
if err := lc.Stop(ctx, "arm_controller"); err != nil {
// …
}
Each Start / Stop call is evaluated by the interceptor with
lifecycle.start / lifecycle.stop before any subprocess action is taken. A
Deny result returns an error without starting or stopping any process.
See also¶
ROS 2 Governed Bridge runbook — operator procedure for enabling
--governed-bridgeon a real workload (per-message DDS governance)ROS 2 Governed Bridge Quickstart — walk through
autonomy demo ros2-bridgeend-to-end with allow + deny evidenceROS2 Markers and Observability — PASS markers and WAL
Bundle Workflows — pull, inspect, stage, activate
Hardware Adaptation — adapting to production hardware
runtime/ros2/— Go package source