autonomy run

Run a subprocess under policy governance

Synopsis

Starts an in-process policy-gated runtime on a random localhost port,
exports AUTONOMY_RUNTIME_URL to the subprocess environment, then exec's
<command> with any additional arguments.

When --policy is omitted the embedded demo policy is used (allows tool.echo
and tool.http_get at the policy layer; denies everything else) — except when
--mavlink-endpoint is set, where the default is instead the embedded MAVLink
demo policy that governs tool.mavlink.* (see --mavlink-endpoint). tool.http_get
still requires an allowlisted endpoint at runtime and is fail-closed by default.
Pass --policy <oci-ref> to use a custom bundle already present in the local
managed cache.

The runtime server shuts down gracefully when the subprocess exits.

WAL audit trail:

  Every tool decision the agent makes is recorded in a local WAL — including
  the layered-governance trail (policy.allow + runtime.deny under the same
  audit_id when a runtime-enforcement layer rejects, e.g. AllowedDomains for
  tool.http_get). By default the WAL is written to a temp directory and
  removed on exit. A "System footprint" panel is printed to stderr at exit
  naming the WAL path.

  Three ways to preserve the WAL:
    --dir <path>                 pin the WAL to <path> (CLI flag form)
    AUTONOMY_RUN_WAL_DIR=<path>  same effect, env-var form (--dir wins)
    --keep                       keep the auto-generated $TMPDIR path

  IMPORTANT: --dir and --keep MUST appear BEFORE the subprocess command.
  Anything after the subprocess command is forwarded to the subprocess
  (cobra's --no-interspersed mode), e.g.:
    autonomy run --dir ~/wal python3 agent.py            ✓ pinned
    autonomy run python3 agent.py --dir ~/wal            ✗ --dir → python3
  The footprint panel detects this exact mistake and prints a corrected
  invocation. Inspect a preserved WAL with: autonomy wal inspect --dir <path>.

ROS2 governed launch (ros2.launch dispatch):

  When the first argument is "ros2.launch", execution is dispatched through
  runtime/ros2.RunGoverned instead of starting a subprocess.  Use --image to
  select the container path; native path is used as a fallback when Docker is
  unavailable and ros2 is in PATH.

  autonomy run [--image <img>] [--workspace <dir>] [--runtime-url <url>] \
      ros2.launch launch <pkg> <launch_file> [ros2-args...]

  autonomy run ros2.launch topic list

Python governed run (python.run dispatch) — #961 Phase 5b:

  When the first argument is "python.run", execution is dispatched through
  runtime/exec.Run with the container path forced — the operator's command
  runs inside the named --image with the AUTONOMY_PRELOAD_* env vars wired
  from the corresponding hardening flags. The runtime container (typically
  ghcr.io/autonomyops/adk-python-runtime) ships with the autonomyops Python
  shim + the LD_PRELOAD libc shim both pre-installed; policy + audit flow
  through the in-container shim's per-call POST /v1/tool round-trip
  against an Autonomy runtime.

  Two runtime modes:
    1) In-process (default, when --runtime-url is unset): the dispatcher
       loads --policy (or the embedded default), creates an evaluator +
       WAL emitter, and starts an in-process tool server on a random
       loopback port. Announces the bound URL on stderr. Container runs
       with --network host so the in-container shim can reach the
       loopback server. The 'autonomy run' subprocess path uses the same
       in-process server pattern; python.run inherits it.
    2) External (when --runtime-url <url> is set): the dispatcher skips
       in-process startup; --runtime-url is forwarded as
       AUTONOMY_RUNTIME_URL on the container env. Container runs with
       the default bridge network — no host-network exposure.

  autonomy run python.run \
      --image ghcr.io/autonomyops/adk-python-runtime:<ver> \
      --ld-preload /usr/local/lib/libautonomy_preload.so \
      --preload-exec-allowlist /usr/bin/python3.12 \
      --preload-connect-allowlist 10.42.0.5:443 \
      --dlopen-allowlist-from-bundle ./bundle.tar \
      --policy ./bundle.tar \
      -- python /work/script.py

  Everything after 'python.run' is the workload command, passed verbatim
  to the container. The dispatcher does NOT prepend 'python' — the
  operator names what runs (e.g. 'python script.py', 'python -m mymod',
  or even 'sh -c "..."' for image debugging).

Usage

autonomy run <command> [args...] [flags]

Options

      --agent-domain int                                       [ros2.launch] ROS_DOMAIN_ID the bridge subscribes on and the launched subprocess publishes on. Honored only with --governed-bridge. 0 → runtime default (99). Must differ from --real-domain.
      --allow-reduced-governance                               [ros2.launch] explicitly accept the REDUCED-GOVERNANCE native path (no container isolation or message interception) when Docker is unavailable. Without it a native launch fails closed; the acceptance is logged and audited.
      --bridge-enclave name                                    [ros2.launch] name SROS 2 enclave the bridge participant adopts on real (must start with '/'; e.g. /governed_ros2_bridge_real). All-or-nothing with --bridge-keystore + --workload-enclave.
      --bridge-keystore dir                                    [ros2.launch] dir absolute path to the SROS 2 keystore (created by 'autonomy ros2 keystore init'). #938 Phase 3-B. Enables DDS-Security: bridge + workload both get ROS_SECURITY_* injected and the keystore bind-mounted read-only. REQUIRES --governed-bridge — passing this flag without it errors out (SROS 2 is defense-in-depth on the bridge, not a standalone substitute). Requires --bridge-enclave AND --workload-enclave together (all-or-nothing — any partial set errors out before any side effect).
      --bridge-topics strings                                  [ros2.launch] #939 4-A: the (topic, type) set the governed bridge subscribes on. Comma-separated 'topic:type' pairs OR repeated --bridge-topics flags. Example: --bridge-topics '/cmd_vel:geometry_msgs/msg/Twist,/disable_safety:std_msgs/msg/Bool'. Honored only with --governed-bridge. EMPTY (the default) makes the bridge fall back to its compiled-in '/agent_chat' typed std_msgs/msg/String — which means workload publishes on any other topic are silently ungoverned (stderr warning surfaces this). Always pass the workload's topics in production.
      --cap-drop cap                                           [ros2.launch / python.run] Linux cap to drop from the workload container, rendered as --cap-drop <name> on the docker spawn. Repeat the flag or comma-separate (e.g. --cap-drop NET_RAW,SYS_PTRACE). Each entry is a capability name like NET_RAW, SYS_PTRACE, SYS_ADMIN, or the magic value ALL to drop every capability docker normally grants. Unknown names are rejected at the autonomy layer (docker would silently accept typos, leaving the cap set wider than intended). Honored ONLY by the ros2.launch dispatch path — the plain subprocess path is a native spawn that inherits the autonomy CLI's own capability set with no per-spawn narrowing, and the runtime refuses --cap-drop in that combination rather than silently ignoring it. (#960 Phase 5a)
      --dir path                                               path to pin the WAL directory to (created if missing; never auto-removed; alias for AUTONOMY_RUN_WAL_DIR, flag wins on disagreement). MUST appear BEFORE the subprocess command — autonomy run uses --no-interspersed, so flags after the subprocess command are forwarded to the subprocess.
      --dlopen-allowlist-from-bundle path                      [ros2.launch / python.run] path to a workload bundle (.tar or directory containing manifest.json) whose v1.5 manifest declares a dlopen_allowlist block. The CLI loads the manifest, validates it under Phase 2a's rules, joins the declared paths with `:`, and sets the result as AUTONOMY_PRELOAD_DLOPEN_ALLOWLIST on the workload container so the LD_PRELOAD shim's dlopen wrapper (Phase 2b-1) enforces it. Empty manifest field or no block declared → env unset on the workload → shim's opt-in default-allow pass-through. REQUIRES --ld-preload <path-to-libautonomy_preload.so> alongside — the env var enforces nothing if no shim is loaded to read it, and RunGoverned REFUSES this flag without --ld-preload via ErrDlopenAllowlistRequiresShim (the canonical 'looks enforced, isn't' trap, same shape as #962/#963/#964 reviewer fixes). Mirrors the --from-bundle precedent from #955. Honored ONLY by the ros2.launch dispatch path; refused on the plain subprocess path. When unset AND --policy is supplied, the CLI auto-sources the allowlist from the same v1.5 manifest the policy gate already loaded — operators using --policy don't need to repeat the bundle reference here (#960 Phase 2b-3). The explicit flag still wins when set, letting the operator point at a different bundle's allowlist than --policy uses. (#960 Phase 2b-2)
      --governed-bridge                                        [ros2.launch] #913 Phase 2: spawn the long-lived governed_ros2_bridge process and run the launched ros2 subprocess on the agent DDS domain. The bridge subscribes there, POSTs every message to /v1/tool, and on allow republishes on the real DDS domain. Requires the in-process tool server (wired automatically when a host-side gate runs). Default off → existing AutoRuntime + ExecBridge behavior.
      --image string                                           [ros2.launch / python.run] OCI image for container-path execution. For ros2.launch, must include the ROS2 toolchain (e.g. ghcr.io/autonomyops/adk-ros2-runtime:v1.0.0). For python.run (#961 Phase 5b), must be a CPython image with the autonomyops runtime_shim baked in (e.g. ghcr.io/autonomyops/adk-python-runtime:<ver>); the shim is responsible for routing tool intents to /v1/tool.
      --keep                                                   preserve the auto-generated WAL directory on exit (default: remove it; ignored when --dir or AUTONOMY_RUN_WAL_DIR is set, since named directories are always preserved)
      --ld-preload path                                        [ros2.launch / python.run] absolute IN-CONTAINER path to a shared library the dynamic linker preloads on workload start (sets LD_PRELOAD on the docker spawn). Interposes on libc functions (execve/connect/open/dlopen) for syscall-adjacent mediation seccomp can't statically discriminate. Phase 4a ships the PLUMBING only — operators can make the .so reachable in the container either by baking it into a custom image, or by passing --writable-mount <host>:<container>:ro pointing at the directory holding the .so (--writable-mount honors --read-only-rootfs alongside; #1005). The runtime-shipped canonical shim under runtime/preload/ lands in Phase 4b. The path is container-side; the autonomy layer validates absoluteness but CANNOT validate existence (no visibility into the container's filesystem) — a missing .so surfaces at workload startup as a dynamic-linker error. Honored ONLY by the ros2.launch dispatch path — on the plain subprocess path it would set the env on the autonomy CLI's own spawn, running the operator's .so with HOST privileges (a different + more dangerous threat model); the runtime refuses that combination rather than silently mis-applying it. Container path also refuses on native fallback via ErrHardeningRequiresContainer. (#960 Phase 4a)
      --mavlink-endpoint string                                register a MAVLink supervisor on this runtime's autopilot connection, enabling the governed tool.mavlink.* surface (e.g. tcp:127.0.0.1:5760, udp:127.0.0.1:14550, serial:/dev/ttyACM0:57600). With no --policy this defaults to the embedded MAVLink demo policy (which governs tool.mavlink.*); pass --policy for production rules. Absent: tool.mavlink.* is fail-closed (ErrMavlinkNotConfigured). Not supported with ros2.launch, and not nestable under another runtime (it is a sibling of 'autonomy demo mavlink-sitl').
      --mavlink-environment string                             sitl|real — REQUIRED when --mavlink-endpoint is set. Immutable for this run; set via flag (auditable in shell history) not env. The supervisor injects this as the trusted 'environment'; the agent can never supply it.
      --no-seccomp                                             [ros2.launch / python.run] opt out of the default-on seccomp profile for the workload container. By default this CLI applies the embedded starter profile (#960 acceptance criterion); --no-seccomp skips it entirely. Emits a loud stderr warning (containing the literal 'Audit event: seccomp-opt-out') AND a WAL marker so operator log scrapers + the audit pipeline both record the opt-out. Mutually exclusive with --seccomp-profile (the runtime errors before any side effect). REFUSED on the plain subprocess path (no container layer + no profile to opt out of) AND on ros2.launch with --force-native or docker unavailable (warn+audit contract can't be honored without a container path; silently no-oping would create a 'looks audited, isn't' trap). Use only for development or workloads that legitimately need denied syscalls (debuggers via ptrace, profilers via perf_event_open, namespace-managing tools via setns/unshare). (#960 Phase 1)
      --policy string                                          OCI reference of the policy bundle from the local managed cache (default: embedded demo policy); for ros2.launch: forwarded as the ros2 policy ref
      --preload-connect-allowlist destination                  [ros2.launch / python.run] destination the workload is permitted to connect() to. Five accepted forms: <ipv4>:<port> (exact), <ipv4>/<prefix>:<port> (IPv4 CIDR), [<ipv6>]:<port> (exact IPv6, bracket form), [<ipv6>/<prefix>]:<port> (IPv6 CIDR — prefix INSIDE brackets), or any of the above with `:*` for any-port. Repeat or comma-separate (e.g. --preload-connect-allowlist 10.0.0.5:443,10.42.0.0/16:8080,[fe80::1]:443,[2001:db8::/32]:*). Joined with `,` (NO_PROXY-style) and set as AUTONOMY_PRELOAD_CONNECT_ALLOWLIST on the workload container for the LD_PRELOAD shim's connect() wrapper to enforce. Empty (default) leaves the env unset → shim's opt-in default-allow pass-through. LOOPBACK always allowed (IPv4 127.0.0.0/8, IPv6 ::1, IPv4-mapped ::ffff:127.0.0.0/96) — the autonomy runtime's /v1/tool endpoint lives there. AF_UNIX (local IPC sockets) pass through unconditionally; AF_INET6 is NOW GATED (post-#960 — was pass-through in Phase 3; existing IPv4-only configs unaffected, operators upgrading should add IPv6 destinations explicitly). Hostnames NOT supported (DNS attack surface). REQUIRES --ld-preload <path-to-libautonomy_preload.so> alongside; RunGoverned REFUSES this flag without --ld-preload via ErrPreloadConnectAllowlistRequiresShim (same 'looks enforced, isn't' trap shape we've fixed five times — caught proactively). Operator-supplied list (egress policy is per-deployment, not per-bundle, so no manifest auto-source). Honored ONLY by the ros2.launch dispatch path; refused on the plain subprocess path. (#960 Phase 3 + post-#960 CIDR/IPv6)
      --preload-exec-allowlist path                            [ros2.launch / python.run] absolute binary path the workload is permitted to execve(). Repeat or comma-separate (e.g. --preload-exec-allowlist /bin/echo,/usr/bin/ros2). Joined with `:` and set as AUTONOMY_PRELOAD_EXEC_ALLOWLIST on the workload container for the LD_PRELOAD shim's execve wrapper (Phase 4b-1) to enforce. Empty (default) leaves the env unset → shim's opt-in default-allow pass-through. REQUIRES --ld-preload <path-to-libautonomy_preload.so> alongside — the env var enforces nothing without a shim to read it, and RunGoverned REFUSES this flag without --ld-preload via ErrPreloadExecAllowlistRequiresShim (same 'looks enforced, isn't' trap shape as #962/#963/#964/#969 reviewer fixes; caught proactively this slice). Operator-supplied list (no bundle-manifest auto-source yet because no v1.5 analogue for execve exists — a future schema extension could mirror Phase 2b-2's --dlopen-allowlist-from-bundle pattern). Honored ONLY by the ros2.launch dispatch path; refused on the plain subprocess path. (#960 Phase 4b-2)
      --read-only-rootfs                                       [ros2.launch / python.run] mount the workload container's root filesystem read-only via docker's --read-only flag. Workloads needing writable in-container scratch declare it explicitly via --tmpfs <path> (ephemeral, in-memory) or --writable-mount <host>:<container>[:ro|:rw] (persistent, backed by the host filesystem). Both work alongside --read-only-rootfs; choose --tmpfs for /tmp-style scratch the workload doesn't need to survive container exit, --writable-mount for logs/caches/fleet-shared state that must persist. False (default) preserves Docker's default read-write rootfs. Honored ONLY by the ros2.launch dispatch path — the plain subprocess path is a native spawn with no container rootfs to mount read-only, and the runtime refuses --read-only-rootfs in that combination rather than silently ignoring it. Container path also refuses on native fallback (--force-native or docker unavailable + no --image) via ErrHardeningRequiresContainer. (#960 Phase 5b)
      --real-domain int                                        [ros2.launch] ROS_DOMAIN_ID the bridge republishes allowed messages onto. Honored only with --governed-bridge. 0 → runtime default (42, matching the adk-ros2-runtime image's own ROS_DOMAIN_ID=42 default). Must differ from --agent-domain.
      --runtime-url string                                     [ros2.launch / python.run] AutonomyOps runtime HTTP address for governance callbacks (e.g. http://10.0.0.5:7777). When SET, the dispatcher SKIPS its in-process tool server and forwards this URL as AUTONOMY_RUNTIME_URL on the workload container; the operator's external runtime gates the calls + the container runs with the default bridge network. When UNSET, the dispatcher starts an in-process tool server on a random loopback port, announces the bound URL on stderr, injects it into the container, and runs the container with --network host so the in-container shim can reach the loopback server. For ros2.launch the flag is forwarded into ros2.RunOptions verbatim. (#961 Phase 5b + #994 finding fix)
      --seccomp-profile path                                   [ros2.launch / python.run] absolute path to a seccomp profile JSON applied to the workload container via `docker --security-opt seccomp=<path>`. When set, overrides the bundled starter profile that the CLI applies by default (#960 acceptance criterion: default-on seccomp). The shipped starter (runtime/seccomp/runtime-starter.seccomp.json, embedded in the binary) narrows Docker default by denying container-escape primitives (mount, setns, unshare, kexec_load, ptrace, etc.); use --no-seccomp to skip seccomp entirely (emits stderr warning + WAL seccomp-opt-out audit marker). Honored ONLY by the ros2.launch dispatch path — the plain `autonomy run <command>` subprocess path is a native spawn with no container layer to thread seccomp through, and the runtime refuses --seccomp-profile in that combination rather than silently ignoring it. Native-path syscall mediation is tracked under #960 Phase 4 (LD_PRELOAD). (#960 Phase 1)
      --tmpfs path                                             [ros2.launch / python.run] absolute container path to mount as an in-memory tmpfs via docker --tmpfs. Repeat or comma-separate (e.g. --tmpfs /tmp,/run). The supported escape hatch for read-only-rootfs workloads that need ephemeral writable scratch. Each path must be absolute; relative or empty entries are rejected at the autonomy layer (docker --tmpfs accepts them but silently ignores). Empty (default) preserves Docker's default rootfs layout. Honored ONLY by the ros2.launch dispatch path — the plain subprocess path is a native spawn with no container layer to host an isolated tmpfs, and the runtime refuses --tmpfs in that combination rather than letting the workload's writes silently hit the host. Container path also refuses on native fallback via ErrHardeningRequiresContainer. For persistent (not ephemeral) writable storage backed by the host filesystem, use --writable-mount instead. (#960 Phase 5b)
      --workload-enclave name                                  [ros2.launch] name SROS 2 enclave the launched workload adopts (must start with '/'; e.g. /demo_robot/arm_controller). Deliberately separate from --bridge-enclave. All-or-nothing with --bridge-keystore + --bridge-enclave.
      --workspace string                                       [ros2.launch] ROS2 workspace directory forwarded as AUTONOMY_ROS2_WORKSPACE (native path only)
      --writable-mount <host-path>:<container-path>[:ro|:rw]   [ros2.launch / python.run] host→container bind mount declared as <host-path>:<container-path>[:ro|:rw], rendered as docker --volume on the container spawn. Repeat the flag or comma-separate (e.g. --writable-mount /var/log/myapp:/var/log/myapp,/srv/data:/data:ro). The supported escape hatch for read-only-rootfs workloads that need PERSISTENT writable surface (logs, caches, fleet-shared state) rather than the ephemeral tmpfs --tmpfs provides. Both host and container paths must be absolute, the host path must exist at parse time, and `..` segments in the container path are rejected (escape-attempt guard; specify the intended path directly). Mode token is optional: `:rw` (default) for read-write; `:ro` for operator-supplied material the workload should NOT modify (signed certs, immutable config). Honored ONLY by the ros2.launch and python.run dispatch paths — the plain subprocess path is a native spawn with no container layer to mount into, and the runtime refuses --writable-mount in that combination. Each declared mount emits a WAL audit marker (kind=writable_mount_declared, attrs={host, container, mode}) BEFORE dispatch so the writable surface is durably recorded even if the workload crashes on startup. CAVEAT: on python.run, the host-side audit emit is SKIPPED when --runtime-url is set (external-runtime mode — the dispatcher doesn't own a host WAL; the operator's external runtime owns its own audit trail). The dispatcher prints a loud WARN on stderr in that combination. (#1005)