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 in runCmd evaluates the embedded ROS2 demo allowlist before dispatch.

  • autonomy ros2 run (paid) — runtime-layer evaluatePreExec evaluates the ros2.DefaultEvaluator “dev-mode” policy (permissive on tool.ros2.*, hard-deny on tool.shell, fail-closed on unknown kinds) by default. This is the documented behaviour of the paid-tier --policy flag and applies whether you are running the demo or a non-demo workload like launch 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:

  1. ros2-telemetry-active

  2. ros2-runtime-container-ready (container path only)

  3. ros2-launch-allowed — host-side gate decision marker

  4. ros2-stack-start

  5. ros2-policy-enforced

  6. ros2-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

tool

tool.ros2.launch

outcome

allow (or deny if the policy rejects the tuple)

policy_ref

embedded:ros2-demo on the CE entry point (the demo allowlist); builtin:ros2-dev-mode on the paid entry point’s default; the user-supplied path/OCI ref when --policy is set

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:

  1. tool.ros2.topic.list — allowed by both the embedded ros2-demo allowlist AND ros2.DefaultEvaluator (prefix match on tool.ros2.) → expect HTTP 200 → emit PASS ros2-{python|cpp}-safe-action-allowed.

  2. tool.shell — hard-denied by every ros2 policy variant → expect HTTP 403 → emit PASS 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 (ros2-launch-{allowed,denied} markers)

#759

Tier 3 / sub-tasks B+D

Persisted autonomy.decision launch frame; rclcpp + curl pinned in the runtime image (INV-ARCH-12/-13)

#760, #761

Tier 4 / sub-tasks C+E

In-process tool server + node-level POST /v1/tool governance; C++ via direct execvp(curl, argv)

#761

Close-the-gaps

CE entry point Tier 4 wire-in; --policy <ref> routed through the host-side gate on both entry points; RunOptions.Evaluator split into Evaluator + ToolServerEvaluator to eliminate dup-frame on CE

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

ModeContainer

Docker daemon reachable

Full isolation; active policy interception via governance sidecar

ModeNative

Docker absent, ros2 in PATH

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

lifecycle.start, lifecycle.stop

Allow

tool.ros2.*

Allow

telemetry.*

Allow

tool.shell

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:

  1. Replace demo_robot with your package name.

  2. Replace /arm/ with your topic namespace.

  3. List your allowed service endpoints.

  4. Run opa test to 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.

Share snippet

A compact, copy-pasteable summary of this demo. Suitable for an email, issue, sales note, or proof artifact.

Prerequisites

  • Go toolchain on PATH (the demo policy ships with a Go-based test suite)

  • Optional: opa CLI if you prefer to run the Rego suite directly

Run it

go test ./policy/... -run TestRos2DemoPolicy -v
# Or with the OPA CLI if installed:
opa test demo/bundles/ros2/policies/ -v

Expected proof markers

  • --- PASS: lines for each TestRos2DemoPolicy_* case (governed launch allowed, wrong package denied, wrong launch file denied, etc.)

  • A final PASS summary with a non-zero count of executed tests

  • No subprocess or Docker invocation is required — the Rego policy is evaluated by the test harness directly

What this proves

The ROS2 governance layer fails closed by default: any action kind not explicitly allowed by the bundle’s Rego policy is denied before a process spawns. tool.shell is hard-coded deny and cannot be overridden, and package- and topic-level allowlists are evaluated as ordered first-match-wins rules — the same rules RunGoverned uses at runtime.

See also