Governed Python Runtime

Audience: operators turning on, observing, or recovering the Python-language mediation layers that sit between an untrusted Python workload and /v1/toolsubprocess.Popen, socket.connect, urllib/http.client/requests, open / io.open / pathlib / os.open, plus the sys.meta_path anti-reflection finder that defeats reload-based bypass attempts. This is the language-level companion to the syscall-level work in Container Hardening + Syscall Mediation: that runbook covers execve / dlopen / connect at the libc layer; this runbook covers the Python interpreter boundary above libc.

The two compose. The adk-python-runtime image bakes both layers: the Python shim auto-activates via sitecustomize.py and the LD_PRELOAD libc shim is present at /usr/local/lib/libautonomy_preload.so for operators to enable per invocation. Operators turn the LD_PRELOAD layer on with --ld-preload on autonomy run python.run; the Python shim is on by default for every interpreter start inside the image.

Walking through it first? The demo/python-runtime/README.md walks the operator entry point end-to-end with the docker-run direct path and the canonical autonomy run python.run dispatcher. This runbook assumes you already have a workload to govern and need to ship it.

Prerequisites

  • docker on PATH. Every layer in this runbook applies via docker flags or container env injection — the Python shim runs inside the container (auto-activated by sitecustomize.py), and the hardening flags (LD_PRELOAD, seccomp, cap-drop, read-only rootfs, tmpfs) need docker to dispatch (autonomy run python.run fails loudly with ErrHardeningRequiresContainer rather than silently degrade — see Failure modes below).

  • ghcr.io/autonomyops/adk-python-runtime:<version> present locally. Pull with docker pull ghcr.io/autonomyops/adk-python-runtime:latest, or build from source via docker build -t ghcr.io/autonomyops/adk-python-runtime:local -f demo/python-runtime/Dockerfile . (#961 Phase 5a). The shipped image bakes the autonomyops.runtime_shim package + sitecustomize.py into the bare site-packages + the LD_PRELOAD shim at /usr/local/lib/libautonomy_preload.so. Custom images need to bake these themselves; the shim source lives under adapters/python/autonomyops/runtime_shim/.

  • A policy bundle (or the embedded default) the in-process runtime evaluates against. The in-process runtime is the default — operators who already run an external autonomy runtime pass --runtime-url on the launch and the in-process startup is skipped (see Operator entry point below).

Phases and threat model

The Python shim is layered defense-in-depth: each phase closes a specific class of bypass that the others cannot statically discriminate. The LD_PRELOAD libc shim is the bottom layer — it catches FFI escapes (ctypes.CDLL(None).execve(...)) that route around Python’s module surface entirely. Operators choose how many layers to apply based on the workload’s trust level.

Phase

Layer

Closes

Default

1

subprocess / os.system / os.exec* wrapper

Workload spawning processes (Popen, run, call, check_output, getoutput, getstatusoutput, system, execv*) without going through /v1/tool

onsitecustomize.py auto-activates inside adk-python-runtime

2a

socket.socket.connect wrapper

Direct raw-socket egress with an IP literal (and refuses hostname-based connect at the wrapper, not the gate — DNS poisoning vector)

on

2b

urllib.request.urlopen / http.client.HTTPConnection.request / requests.Session.request URL-layer wrappers

Higher-level HTTP clients that resolve hostnames off-box before reaching socket.connect. The URL layer gates BEFORE getaddrinfo, so the hostname is what policy sees

on

3

builtins.open / io.open / pathlib.Path.open / os.open wrappers

Workload reads/writes outside an allowlist (tool.fs.open) — open("/etc/passwd") fails before the syscall

on

4

sys.meta_path post-import finder + importlib.reload wrapper

Reflection-based bypass: del sys.modules["subprocess"]; import subprocess; subprocess.Popen(...) and importlib.reload(subprocess); subprocess.Popen(...) would otherwise strip the wrappers from Phase 1

on

5a

adk-python-runtime image

Ships CPython 3.12 + shim baked into bare site-packages + sitecustomize.py baked + LD_PRELOAD shim at /usr/local/lib/libautonomy_preload.so

shipped

5b

autonomy run python.run CLI dispatcher

Two-mode runtime resolution (in-process default vs --runtime-url external); inherits the ros2.launch hardening flag set

shipped

6

runtime/python_composition/bypass_resistance_test.go

Proof that all layers compose against a deliberately-malicious workload

hermetic test

7

Framework adapters: LangChain / AutoGen / CrewAI / LlamaIndex / DSPy

Tool-dispatch interception when the operator writes agents against any of the five supported frameworks. Each adapter has GuardedTool (local execution) and RuntimeTool (full delegation)

optional

What this does NOT do

  • Does not replace /v1/tool. The Python shim is a transport layer that POSTs intent to /v1/tool BEFORE the libc call; application-layer policy mediation remains the primary trust boundary. The shim catches calls the workload doesn’t explicitly route through /v1/tool.

  • Does not provide a guaranteed sandbox against a fully hostile attacker willing to write CPython interpreter exploits. That’s a hardened-interpreter / WASM-runtime problem out of scope. Defense-in-depth with the LD_PRELOAD layer (#960) is the realistic ceiling for a CPython shim.

  • Does not catch ctypes.CDLL("libc.so.6").execve(...)dlsym(libc_handle, "execve") finds libc’s execve symbol directly, bypassing LD_PRELOAD (LD_PRELOAD overrides only the global symbol resolution chain). Mitigation is seccomp via --seccomp-profile on autonomy run python.run. The shim catches ctypes.CDLL(None).execve(...) (process global namespace, RTLD_DEFAULT semantics) — the canonical LD_PRELOAD-intercepted FFI path. Both forms documented in demo/python-runtime/README.md.

  • Does not extend to compiled languages. C++/Rust/Go workloads need the syscall layer (Container Hardening + Syscall Mediation), not language-level shims.

  • Does not gate the runtime’s own Python code (the framework adapters’ HTTP client, etc.). Only the workload Python subprocess(es) autonomy run spawns under adk-python-runtime.

Operator entry point

The recommended path is autonomy run python.run (#961 Phase 5b), which mirrors autonomy run ros2.launch. The dispatcher has two modes for the /v1/tool runtime the in-container shim talks to:

Mode

When

What happens

In-process (default)

--runtime-url unset

Dispatcher loads --policy (or the embedded default), creates an evaluator + interceptor + WAL emitter, and starts an in-process tool server on a random loopback port. Announces the bound URL on stderr ([autonomy] runtime listening on http://127.0.0.1:<port>). Container runs with --network host so the in-container shim’s RuntimeClient can reach the loopback-bound server.

External

--runtime-url <url> set

Dispatcher skips in-process startup; --runtime-url is set as AUTONOMY_RUNTIME_URL on the container env and the operator’s external runtime gates the calls. Container runs with the default bridge network — no host-network exposure.

In-process mode is the “batteries-included” default — operators get a working policy gate without standing up a second process. External mode is for production deployments where the runtime is a long-lived service (likely on a different host).

Enable (in-process default)

autonomy run python.run \
    --image ghcr.io/autonomyops/adk-python-runtime:latest \
    --policy ./bundle.tar \
    --ld-preload /usr/local/lib/libautonomy_preload.so \
    --read-only-rootfs \
    --tmpfs /tmp \
    --seccomp-profile starter \
    --cap-drop NET_RAW \
    --preload-exec-allowlist /usr/local/bin/python3.12 \
    -- python /work/script.py

What happens, in order:

  1. The dispatcher loads --policy ./bundle.tar (or the embedded default if omitted), builds an evaluator + interceptor + WAL emitter, and starts an in-process tool server on a random loopback port. It announces [autonomy] runtime listening on http://127.0.0.1:<port> on stderr.

  2. The hardening flags resolve to docker dispatcher options: --read-only, --tmpfs /tmp, --security-opt seccomp=<starter-path>, --cap-drop NET_RAW, plus the LD_PRELOAD setup (LD_PRELOAD=/usr/local/lib/libautonomy_preload.so + AUTONOMY_PRELOAD_EXEC_ALLOWLIST=/usr/local/bin/python3.12 env).

  3. The container is spawned with --network host (so the in-container shim reaches the loopback runtime) and AUTONOMY_RUNTIME_URL=http://127.0.0.1:<port> injected.

  4. python /work/script.py starts CPython; CPython runs sitecustomize.py (baked into site-packages) which calls autonomyops.runtime_shim.install(). Every subprocess.Popen, socket.connect, open, urllib.urlopen, etc. the script does now POSTs to /v1/tool before any libc call.

Enable (external runtime)

autonomy run python.run \
    --image ghcr.io/autonomyops/adk-python-runtime:latest \
    --runtime-url http://10.42.0.5:7777 \
    --ld-preload /usr/local/lib/libautonomy_preload.so \
    --read-only-rootfs \
    --tmpfs /tmp \
    -- python /work/script.py

Same workload contract; the dispatcher skips in-process startup and injects AUTONOMY_RUNTIME_URL=http://10.42.0.5:7777 into the container instead. Default bridge network — no host-network exposure.

WAL flags do nothing in external mode. --dir / --keep / AUTONOMY_RUN_WAL_DIR only affect the WAL that the in-process runtime emits. In external mode the dispatcher doesn’t own a tool server, so it never observes decisions and never writes WAL frames. The external runtime owns its own audit trail — configure WAL storage on that runtime, not on the dispatcher. Combining WAL flags with --runtime-url triggers a stderr warning (autonomy run python.run: WARN: WAL flags ... are ignored when --runtime-url is set) so the misconfiguration doesn’t silently no-op into an empty WAL directory.

Verify the loop is closed

On the host (or wherever the runtime is running), watch the WAL for gated calls:

# Show every decision recorded by the in-process runtime since the
# launch. Re-run after each test invocation — `autonomy wal inspect`
# reads the WAL file end-to-end on each call.
autonomy wal inspect --kind autonomy.decision --json | jq '.'

# Filter to just the Python-shim-originated POSTs (every kind the
# shim emits starts with "tool."):
autonomy wal inspect --kind autonomy.decision --json \
  | jq 'select(.event.attrs.tool | startswith("tool."))'

You should see one frame per gated call, each carrying:

  • tool — the wire kind (e.g. tool.subprocess.spawn, tool.network.connect, tool.fs.open, tool.network.http_request, tool.os.exec, tool.os.system)

  • outcome=allow (or deny if the policy rejected it)

  • policy_ref matching the bundle’s manifest.policy_ref

  • params — the per-kind intent payload (argv for subprocess, host/port/family for connect, path/mode/intent for open, etc.)

If a known-gated call (e.g. subprocess.Popen) is NOT producing a WAL frame, the shim did not activate. See Step 1 — Shim did not activate below.

Common operator situations

1. Shim did not activate

Symptom: the workload runs subprocess.Popen([...]) against a disallowed binary and the child process starts — no PermissionError, no WAL frame.

Triage:

  1. Verify the image actually bakes the shim:

    docker run --rm ghcr.io/autonomyops/adk-python-runtime:latest \
        python -c 'from autonomyops.runtime_shim import is_installed; \
                   print("installed:", is_installed())'
    

    Expected: installed: True. The image’s build-time sanity check should have caught this at docker build time — if you’re seeing False here, you’re running a stale image (rebuild) or a custom image that didn’t bake the shim.

  2. Check sitecustomize.py is in bare site-packages:

    docker run --rm ghcr.io/autonomyops/adk-python-runtime:latest \
        python -c 'import sitecustomize; print(sitecustomize.__file__)'
    

    Expected: /usr/local/lib/python3.12/site-packages/sitecustomize.py. If ModuleNotFoundError: sitecustomize, the file wasn’t baked.

  3. Check for opt-out: the env var AUTONOMYOPS_RUNTIME_SHIM_DISABLE=1 on the container skips install(). If your operator wrapper is setting this for debug and forgot to unset it, the shim is intentionally inert.

2. Workload sees PermissionError: autonomyops runtime unreachable (fail-closed)

Symptom: every gated call raises PermissionError: autonomyops runtime unreachable (fail-closed); kind=....

The shim is fail-closed by design — when the runtime is unreachable (connection refused, timeout, malformed response), it raises PermissionError rather than allowing the call through.

Triage:

  1. Did the dispatcher start the in-process runtime? Look for the [autonomy] runtime listening on http://127.0.0.1:<port> line on the dispatcher’s stderr. The dispatcher only enters external mode when --runtime-url <url> is passed explicitly on the command line — it does NOT source the URL from any environment variable. (The AUTONOMY_RUNTIME_URL env var is read by the in-container Python shim’s RuntimeClient to reach the runtime; the host-side CLI’s mode selection is purely flag-driven.) Cases to check:

    • You did pass --runtime-url and expected in-process mode → drop the flag.

    • You did not pass --runtime-url but no listening line appears → the in-process startup failed (policy bundle parse error, port-bind failure, etc.). Re-run with verbose stderr and look for an error before the workload spawn line.

  2. Is --network host active? The default in-process mode injects --network host so the container reaches the loopback runtime. If you’re seeing connection refused with --runtime-url http://127.0.0.1:7777 set, the container’s loopback is the container’s own — the loopback runtime lives on the HOST. Either remove --runtime-url (default in-process picks the right URL automatically) or change to http://host.docker.internal:7777 on macOS/Windows or add --add-host=host.docker.internal:host-gateway on Linux.

  3. Did AUTONOMY_RUNTIME_URL get injected into the container? The shim reads it on the workload side at first gate call. Check the env on the container:

    docker exec <container-id> env | grep AUTONOMY_RUNTIME_URL
    

    The host-side dispatcher injects this from either the in-process bound URL (default mode) or the --runtime-url flag value (external mode); if it’s missing entirely, the dispatcher crashed before workload spawn — re-run and inspect stderr.

3. del sys.modules["subprocess"]; import subprocess succeeded — the wrapper got stripped

Symptom: the workload did del sys.modules["subprocess"]; import subprocess; subprocess.Popen(...) and the call succeeded against a disallowed binary.

This is Phase 4’s domain — the sys.meta_path post-import finder should re-apply the wrapper on the fresh import. If it didn’t:

  1. The Phase 4 finder is installed by runtime_shim.install() at sitecustomize time. Check the meta_path:

    docker exec <container-id> python -c \
        'import sys; print([type(f).__name__ for f in sys.meta_path])'
    

    Expected: _PostImportFinder somewhere in the list. If absent, the shim’s install() either crashed mid-init or was opted out via AUTONOMYOPS_RUNTIME_SHIM_DISABLE=1.

  2. The Phase 6 bypass-resistance test pins this exact contract. Run it locally to confirm the layers compose:

    go test ./runtime/python_composition/ -v -timeout 600s
    

    The test should PASS — every vector (including del + reimport) asserted DENIED.

4. ctypes.CDLL("libc.so.6").execve(...) succeeded — workload escaped

Symptom: the workload called ctypes.CDLL("libc.so.6").execve(b"/bin/sh", ...) and /bin/sh ran successfully.

This is expected when only the Python shim + the LD_PRELOAD layer are enabled. dlsym(libc_handle, "execve") finds libc’s execve symbol directly, bypassing LD_PRELOAD (LD_PRELOAD overrides only the global symbol resolution chain, not dlsym lookups against specific .so handles).

The operator-facing mitigation is seccomp — it gates the syscall, not the libc symbol, so the bypass at the library layer doesn’t escape kernel mediation:

autonomy run python.run \
    --image ghcr.io/autonomyops/adk-python-runtime:latest \
    --seccomp-profile starter \
    -- python /work/script.py

--seccomp-profile starter is the embedded starter profile (denies 21 syscalls — see Container Hardening §Phase 1). For tighter custom profiles, point at a JSON file: --seccomp-profile /path/to/myprofile.json.

The shim DOES catch ctypes.CDLL(None).execve(...) (the canonical LD_PRELOAD-intercepted FFI path). Verified by Phase 6’s vector 4 test.

5. A legitimate workload is being denied (false positive)

Symptom: an operator-authored Python tool that should run is being denied with PermissionError: autonomyops policy denied <kind>: <reason>.

Triage:

  1. Identify the wire kind from the error:

    • tool.subprocess.spawn — argv check failed

    • tool.network.connect — IP+port check failed

    • tool.network.http_request — URL-layer check failed

    • tool.fs.open — path/mode check failed

    • tool.os.system — shell-command check failed

    • tool.os.exec — exec-family check failed

  2. Find the most recent WAL frame for that kind:

    autonomy wal inspect --kind autonomy.decision --json \
      | jq 'select(.event.attrs.tool == "tool.subprocess.spawn")
            | {tool: .event.attrs.tool, outcome: .event.attrs.outcome,
               reason: .event.attrs.reason, argv: .event.attrs.argv}'
    
  3. Update the policy bundle to allow the legitimate intent. Re-deploy: pass the updated bundle via --policy on the next autonomy run python.run invocation.

For development iteration where the bundle is a moving target, operators run an external runtime with the live bundle attached and pass --runtime-url so the dispatcher doesn’t re-snapshot the bundle on every launch.

6. The dispatcher refuses to start with ErrHardeningRequiresContainer

Symptom: autonomy run python.run exits with ErrHardeningRequiresContainer: <flag> requires --image (or a similar variant).

The hardening flags (--seccomp-profile, --cap-drop, --read-only-rootfs, --tmpfs, --ld-preload) only apply via docker — they have no native-subprocess analog. The dispatcher fails loudly rather than silently dropping them.

Triage:

  1. Pass --image ghcr.io/autonomyops/adk-python-runtime:<ver> (or your custom image) explicitly. The python.run dispatch token has no embedded default image (this is intentional — the operator’s image choice is the trust anchor).

  2. If you need to debug without the hardening flags, drop them one at a time until the dispatcher accepts the invocation; that’s the layer that needed an image.

7. The framework adapter’s _run is not being gated

Symptom: an operator-authored LangChain / AutoGen / CrewAI / LlamaIndex / DSPy tool body runs without /v1/tool ever seeing the intent.

Triage:

  1. Identify which adapter is in use:

    • autonomyops.langchain.GuardedTool — wraps an existing LangChain BaseTool instance.

    • autonomyops.langchain.RuntimeTool — full-delegation; no Python body runs.

    • And similarly for autogen, crewai, llamaindex, dspy subpackages.

  2. Confirm the agent actually invokes the wrapped tool (not the pre-wrap one). The operator code path is:

    from autonomyops.dspy import GuardedTool  # or any framework
    safe = GuardedTool(tool=my_tool, action="tool.my_action")
    agent = SomeFramework.ReAct(signature, tools=[safe])  # NOT [my_tool]
    
  3. If the framework auto-discovers tools (e.g. by introspecting a registry), make sure the WRAPPED instance is registered and the bare one is not. The wrappers do NOT replace the bare tool in any external registry — that’s the operator’s job.

  4. Every adapter has 14–17 unit tests pinning the “deny is not overridable” invariant. Run the suite locally to confirm the adapter itself is intact:

    pytest adapters/python/tests/test_<framework>_tool_guard.py \
           adapters/python/tests/test_<framework>_runtime_tool.py -v
    

Failure modes and recovery

Mode A — shim fails to install at sitecustomize time

Trigger: from autonomyops.runtime_shim import install crashed during import (e.g. corrupt site-packages, missing transitive dependency, ABI mismatch between CPython and a baked extension).

The image’s Dockerfile runs a build-time sanity check (python -c "import autonomyops.runtime_shim; assert is_installed()") so if the wiring is broken at build time, the image build FAILS before shipping. If you’re hitting this at run time on a shipped image, the image’s environment was mutated post-build (most commonly: an operator-supplied wrapper pip install-ed a package that overwrote sitecustomize.py).

Recovery:

  1. Confirm the image’s stock sitecustomize.py is still in place:

    docker run --rm <your-image> \
        cat /usr/local/lib/python3.12/site-packages/sitecustomize.py
    
  2. If pip install from your operator wrapper is the culprit, move the install AHEAD of the shim install OR use a pth file that chains both sitecustomize modules (the bare one + yours).

  3. If the shim itself is broken (rare), pin to a known-good image tag and file a bug — the build-time sanity check should have caught it.

Mode B — runtime fail-closed on a transient network blip

Trigger: the in-process runtime briefly stalled (GC pause, file I/O on a slow disk, etc.) and a gated call timed out.

The shim fail-closes (PermissionError) — by design, the runtime is the sole policy authority and a missed verdict cannot be treated as allow. The workload’s call propagates the PermissionError to the caller.

Recovery:

  1. Inspect the runtime’s logs (stderr of the dispatcher) for gate_call warnings — the shim logs the transport failure before raising. Look for stalls of >10s.

  2. If the in-process runtime is on the same host as the workload (in-process mode default), CPU contention from the workload itself can starve the runtime. Pin the dispatcher’s process to a separate CPU set via taskset if you see chronic timeouts under load.

  3. For production deployments, run an external runtime and pass --runtime-url — that gives the runtime its own resource envelope.

Mode C — --read-only-rootfs blocks a legitimate write

Trigger: the workload tries to write under / (the rootfs is read-only) outside the --tmpfs /tmp mount, hitting EROFS (distinct from the Phase 3 tool.fs.open deny — that’s a policy verdict; this is a kernel-level read-only refusal).

The workload sees OSError: [Errno 30] Read-only file system from the syscall path. The Phase 3 wrapper still POSTs the tool.fs.open intent, so the operator sees the gated intent in the WAL even though the underlying syscall would have failed anyway.

Recovery — two operator-exposed knobs on autonomy run python.run cover the common cases:

  1. Ephemeral scratch (lost on container exit) → --tmpfs. Add the path to the tmpfs mount set: --tmpfs /tmp --tmpfs /var/cache/myapp. Each --tmpfs flag adds one writable in-memory mount. Pick this for /tmp-style scratch the workload doesn’t need to outlive the container.

  2. Persistent storage (must survive container exit) → --writable-mount (#1005). Bind-mount a host path into the container: --writable-mount /var/log/myapp:/var/log/myapp (writable by default) or --writable-mount /srv/data:/data:ro (read-only — operator-supplied material the workload should NOT modify, e.g. signed certs, immutable config). Each flag takes one <host>:<container>[:ro|:rw] spec; repeat for multiple mounts or comma-separate. Both paths must be absolute, the host path must exist at parse time, and .. segments in the container path are rejected. Works alongside --read-only-rootfs: the rootfs stays read-only while the named paths are writable.

    Host-side WAL audit — in the default (in-process runtime) mode the dispatcher emits one writable_mount_declared WAL event per mount BEFORE workload start, so autonomy wal inspect shows the (host, container, mode) tuple even if the workload crashes during startup.

    CAVEAT (external-runtime mode): when --runtime-url is set the dispatcher does NOT own a host WAL, so the writable_mount_declared emit is skipped — the mounts still land in the container, but the host-side audit trail does not. The dispatcher prints a loud WARN: --writable-mount ... SKIPPED when --runtime-url is set line on stderr so the operator notices. Mitigations: (a) configure equivalent audit on the external runtime’s own WAL, or (b) drop --runtime-url to use the in-process runtime that emits the per-mount audit event before dispatch. The autonomy ros2 run surface and autonomy run ros2.launch are unaffected — both always own their WAL emitter and audit unconditionally.

  3. Last-resort: omit --read-only-rootfs and accept the looser posture for this workload — the container rootfs becomes writable, reverting to Docker’s default. The Phase 3 tool.fs.open wrapper still gates all path-based opens through /v1/tool, so policy enforcement remains intact; only the kernel-level read-only enforcement is dropped. Pick this only when the workload’s writable surface is too ad-hoc to pre-declare via --tmpfs / --writable-mount (rare; usually means the workload is doing something unaudited).

Mode D — image build fails the sanity check

Trigger: docker build -t adk-python-runtime -f demo/python-runtime/Dockerfile . fails at the RUN python -c "import autonomyops.runtime_shim; assert is_installed()" line.

This is intentional — the image’s Dockerfile fails the build BEFORE shipping rather than at runtime in the operator’s container.

Recovery:

  1. Check the Dockerfile’s COPY paths against your local checkout. The build context is the repo root (not demo/python-runtime/) because the preload-builder stage COPYs runtime/preload/src/ and the runtime stage COPYs adapters/python/. Run from repo root:

    docker build -t adk-python-runtime:local \
        -f demo/python-runtime/Dockerfile .
    
  2. If adapters/python/ is missing files (typically a partial git clone or git sparse-checkout), the pip install ./autonomyops-adapter step fails earlier with a setuptools error. Re-clone or run git checkout HEAD -- adapters/python/.

  3. If the build succeeds but is_installed() returns False, the sitecustomize.py placement is off. Confirm the Dockerfile’s COPY adapters/python/autonomyops/runtime_shim/sitecustomize.py /usr/local/lib/python3.12/site-packages/sitecustomize.py line matches the actual location of sitecustomize.py in your checkout (it moved between #961 phases).

Verification

Every Python-runtime change has a regression-test pair: the hermetic Phase 6 composition test for the layers, and the per-framework adapter tests for the wrappers. Run them locally to confirm a clean checkout works end-to-end.

Hermetic bypass-resistance composition test

go test ./runtime/python_composition/ -v -timeout 600s

Builds the adk-python-runtime image at test time, derives a tiny test image with a deliberately-malicious Python workload, stands up an in-process mock tool server with a narrow allowlist, and asserts every documented bypass vector is caught by ONE of the shipped layers. Gated on linux + docker daemon + !testing.Short(); skips cleanly on hosts without docker. See runtime/python_composition/bypass_resistance_test.go for the full vector matrix.

Per-framework adapter unit tests

cd adapters/python
pytest tests/ -v

262 tests with pytest.mark.skipif gating on each optional framework extra. Hosts without a framework installed cleanly skip that framework’s adapter tests — no CI changes needed. The central “deny is not overridable” DoD invariant is pinned per-class for all five frameworks.

Image bake sanity check

docker run --rm ghcr.io/autonomyops/adk-python-runtime:latest \
    python -c 'from autonomyops.runtime_shim import is_installed; \
               assert is_installed(), "shim not active"'
echo "shim OK: $?"

If this exits non-zero, the image is broken — re-pull or re-build (#961 Phase 5a).

Cross-references