Flow execution sandbox — tiered roadmap #14

Open
opened 2026-05-05 10:25:35 +00:00 by timur · 1 comment
Owner

Context

agentic_calling.md (in hero_router) and Story 1 (#11) both rely on spawning python3 to execute flow code. Today that subprocess runs unsandboxed — it has whatever filesystem, network, and capability access the hero_logic server process has. That's fine for hand-authored flows by trusted developers and obviously not fine once the router agent (per agentic_calling.md) can ask an LLM to generate a Python script and execute it on the spot.

This issue tracks the sandbox roadmap. Threat model first; tiers second; non-goals last.

Threat model

Two consumers, two trust levels:

Consumer Trust What they execute
hero_logic Plays (this repo, #11) Medium — human-authored or saved-from-agent The WorkflowVersion.python_source of the running Play
hero_router agent execution (agentic_calling.md) Low — LLM-generated, per-request A throwaway script the LLM just produced from an interface file

Threats we want to mitigate, in priority order:

  • (A) Mistake — runaway memory, infinite loop, fork bomb. Frequent. Mitigation: ulimits + wall-clock timeout.
  • (B) Wrong RPC — flow calls an admin method it shouldn't. Mitigation primarily lives at the RPC layer (context-based authorization, see herolib_openrpc_authorize), not the sandbox. Sandbox can also block UDS connections to specific service sockets.
  • (C) Exfiltration — flow opens an outbound HTTP connection to send data out. Mitigation: network deny-all by default; allowlist for the rare flow that legitimately needs the internet.
  • (D) Local side effects — flow reads ~/.ssh, writes outside its workdir. Mitigation: filesystem namespace.
  • (E) Host escape — flow runs /bin/sh -c … to escalate. Mitigation: privilege drop, no SUID binaries on PATH, seccomp allowlist (Linux).

Tier 0 — Subprocess hardening (free, ship with #11)

No new dependencies. Land as part of the executor in #11.

  • Strip env vars to an allowlist: PYTHONPATH, PATH=/usr/bin:/bin, HERO_FLOW_SPAN_SOCK, HERO_* flow inputs.
  • Set CWD to ~/.hero/var/plays/{play_sid}/work (created per play, deleted on Play GC).
  • ulimits via prlimit/setrlimit: RLIMIT_AS (e.g. 512 MiB), RLIMIT_CPU (e.g. 60s wall ≈ 120s cpu), RLIMIT_NOFILE (256), RLIMIT_NPROC (32).
  • Wall-clock timeout from the executor side: kill -TERM, then kill -KILL 5s later.
  • Drop to an unprivileged uid if hero_proc is running as root; otherwise no-op + warning at startup.

Defends against (A) and the "obvious" parts of (D)/(E). Does nothing for network or filesystem isolation.

Tier 1 — FS + network sandbox (1–2 weeks, blocks router agent execution)

Per-platform sandbox shim invoked by the executor. Same Tier 0 hardening on top.

  • Linux: bwrap (bubblewrap). Bind-mount read-only: /usr, /lib*, /etc/resolv.conf (only if network allowed), ~/.hero/var/router/python, ~/.hero/var/flows/sdk, the flow .py file. Tmpfs /tmp. Bind-mount the per-play workdir read-write. No /proc (or a minimal one). UDS allowlist via bind-mount: only ~/hero/var/sockets/*/rpc.sock + the per-play span socket. Network: --unshare-net by default; allowlist override for flows that opt in.
  • macOS: sandbox-exec with a generated profile. (version 1) (deny default) (allow process*) (allow file-read* (subpath …)) (allow file-write* (subpath …)) (allow network-outbound (path-literal "…/rpc.sock") …) (deny network-outbound). macOS sandbox-exec is undocumented/deprecated but still functional.
  • Other platforms: warn + fall back to Tier 0.

Defends against (A)(C)(D)(E). RPC-level authz still needed for (B).

This is the prerequisite for shipping the router agent execution flow described in agentic_calling.md — once the LLM is generating scripts, Tier 0 is no longer enough.

Tier 2 — Per-play container (1–2 months, defer until needed)

Rootless podman per play. Strong boundary, ~1s cold start. A small Alpine + python3 + bundled SDK image, built once per hero_logic release. Bind-mount generated clients + workdir + sockets.

Adds: easy CPU/mem cgroup limits, image immutability, tear-down via podman rm. Tradeoff: cold-start cost, podman dependency, image build pipeline.

Defer until Tier 1 measurably falls short — the router agent's threat model probably stays inside Tier 1 indefinitely.

Tier 3 — microVM (months, speculative)

Firecracker / cloud-hypervisor. Strongest isolation, ~200 ms start, big infra. Don't plan around this until concrete pressure exists (e.g. multi-tenant hero_router serving untrusted users).

Non-goals

  • Sandboxing trusted Hero developers writing their own flows in their own context. Tier 0 is enough for them — the goal is to catch mistakes, not to defend against them.
  • Replacing RPC-level context/claim authorization. Sandbox is a secondary defense layer; the primary defense for "what RPCs can this flow call" lives at the service, not at the executor.
  • Sandboxing the hero_logic server itself.

Done when

  • Tier 0 lands as part of #11 — flows can't OOM the host, can't spawn 10k processes, can't run forever.
  • Tier 1 is reachable behind a executor.sandbox = "bwrap" | "sandbox-exec" | "none" config flag, with none only valid in dev mode + warned at startup.
  • A documented threat-model + tier matrix lives at hero_logic/docs/sandbox.md and is linked from agentic_calling.md so the router-agent design and the executor design stay in sync.

Refs

  • Parent epic: #10
  • Story 1 (the executor change that lands Tier 0): #11
  • Architectural source for agent execution: hero_router/docs/agentic_calling.md
  • Related RPC authz primitive: herolib_openrpc_authorize
  • Related cache-drift issue: hero_rpc#32 (independent, both must land before agent-generated execution is safe)
## Context `agentic_calling.md` (in hero_router) and Story 1 (#11) both rely on spawning `python3` to execute flow code. Today that subprocess runs unsandboxed — it has whatever filesystem, network, and capability access the hero_logic server process has. That's fine for hand-authored flows by trusted developers and obviously not fine once the router agent (per `agentic_calling.md`) can ask an LLM to generate a Python script and execute it on the spot. This issue tracks the sandbox roadmap. Threat model first; tiers second; non-goals last. ## Threat model Two consumers, two trust levels: | Consumer | Trust | What they execute | |---|---|---| | hero_logic Plays (this repo, #11) | Medium — human-authored or saved-from-agent | The `WorkflowVersion.python_source` of the running Play | | hero_router agent execution (`agentic_calling.md`) | Low — LLM-generated, per-request | A throwaway script the LLM just produced from an interface file | Threats we want to mitigate, in priority order: - **(A) Mistake** — runaway memory, infinite loop, fork bomb. Frequent. Mitigation: ulimits + wall-clock timeout. - **(B) Wrong RPC** — flow calls an admin method it shouldn't. Mitigation primarily lives at the **RPC layer** (context-based authorization, see herolib_openrpc_authorize), not the sandbox. Sandbox can also block UDS connections to specific service sockets. - **(C) Exfiltration** — flow opens an outbound HTTP connection to send data out. Mitigation: network deny-all by default; allowlist for the rare flow that legitimately needs the internet. - **(D) Local side effects** — flow reads `~/.ssh`, writes outside its workdir. Mitigation: filesystem namespace. - **(E) Host escape** — flow runs `/bin/sh -c …` to escalate. Mitigation: privilege drop, no SUID binaries on PATH, `seccomp` allowlist (Linux). ## Tier 0 — Subprocess hardening (free, ship with #11) No new dependencies. Land as part of the executor in #11. - Strip env vars to an allowlist: `PYTHONPATH`, `PATH=/usr/bin:/bin`, `HERO_FLOW_SPAN_SOCK`, `HERO_*` flow inputs. - Set CWD to `~/.hero/var/plays/{play_sid}/work` (created per play, deleted on Play GC). - ulimits via `prlimit`/`setrlimit`: `RLIMIT_AS` (e.g. 512 MiB), `RLIMIT_CPU` (e.g. 60s wall ≈ 120s cpu), `RLIMIT_NOFILE` (256), `RLIMIT_NPROC` (32). - Wall-clock timeout from the executor side: `kill -TERM`, then `kill -KILL` 5s later. - Drop to an unprivileged uid if hero_proc is running as root; otherwise no-op + warning at startup. Defends against (A) and the "obvious" parts of (D)/(E). Does *nothing* for network or filesystem isolation. ## Tier 1 — FS + network sandbox (1–2 weeks, blocks router agent execution) Per-platform sandbox shim invoked by the executor. Same Tier 0 hardening on top. - **Linux:** `bwrap` (bubblewrap). Bind-mount read-only: `/usr`, `/lib*`, `/etc/resolv.conf` (only if network allowed), `~/.hero/var/router/python`, `~/.hero/var/flows/sdk`, the flow `.py` file. Tmpfs `/tmp`. Bind-mount the per-play workdir read-write. No `/proc` (or a minimal one). UDS allowlist via bind-mount: only `~/hero/var/sockets/*/rpc.sock` + the per-play span socket. Network: `--unshare-net` by default; allowlist override for flows that opt in. - **macOS:** `sandbox-exec` with a generated profile. `(version 1) (deny default) (allow process*) (allow file-read* (subpath …)) (allow file-write* (subpath …)) (allow network-outbound (path-literal "…/rpc.sock") …) (deny network-outbound)`. macOS sandbox-exec is undocumented/deprecated but still functional. - **Other platforms:** warn + fall back to Tier 0. Defends against (A)(C)(D)(E). RPC-level authz still needed for (B). This is the **prerequisite for shipping the router agent execution flow** described in `agentic_calling.md` — once the LLM is generating scripts, Tier 0 is no longer enough. ## Tier 2 — Per-play container (1–2 months, defer until needed) Rootless `podman` per play. Strong boundary, ~1s cold start. A small Alpine + python3 + bundled SDK image, built once per hero_logic release. Bind-mount generated clients + workdir + sockets. Adds: easy CPU/mem cgroup limits, image immutability, tear-down via `podman rm`. Tradeoff: cold-start cost, podman dependency, image build pipeline. Defer until Tier 1 measurably falls short — the router agent's threat model probably stays inside Tier 1 indefinitely. ## Tier 3 — microVM (months, speculative) Firecracker / cloud-hypervisor. Strongest isolation, ~200 ms start, big infra. Don't plan around this until concrete pressure exists (e.g. multi-tenant hero_router serving untrusted users). ## Non-goals - Sandboxing trusted Hero developers writing their own flows in their own context. Tier 0 is enough for them — the goal is to catch mistakes, not to defend against them. - Replacing RPC-level context/claim authorization. Sandbox is a *secondary* defense layer; the primary defense for "what RPCs can this flow call" lives at the service, not at the executor. - Sandboxing the hero_logic server itself. ## Done when - Tier 0 lands as part of #11 — flows can't OOM the host, can't spawn 10k processes, can't run forever. - Tier 1 is reachable behind a `executor.sandbox = "bwrap" | "sandbox-exec" | "none"` config flag, with `none` only valid in dev mode + warned at startup. - A documented threat-model + tier matrix lives at `hero_logic/docs/sandbox.md` and is linked from `agentic_calling.md` so the router-agent design and the executor design stay in sync. ## Refs - Parent epic: #10 - Story 1 (the executor change that lands Tier 0): #11 - Architectural source for agent execution: hero_router/docs/agentic_calling.md - Related RPC authz primitive: `herolib_openrpc_authorize` - Related cache-drift issue: hero_rpc#32 (independent, both must land before agent-generated execution is safe)
Author
Owner

Taking ownership. Plan:

  • Tier 0 (env scrub + ulimits + per-play workdir + wall-clock timeout) lands as part of #11's executor — this is small enough to PR alongside the executor itself, not as a separate change.
  • Tier 1 (bwrap on Linux, sandbox-exec on macOS, fall back to Tier 0 elsewhere) is its own PR after #11 merges — gated behind a executor.sandbox = ... config flag, defaulting to bwrap on Linux and sandbox-exec on macOS, none only in dev mode with a startup warning.
  • Tiers 2/3 stay deferred. Won't pre-build for them.
  • hero_logic/docs/sandbox.md lands with Tier 0 as a permanent reference and gets cross-linked from hero_router/docs/agentic_calling.md §8.

Not blocking #11/#12/#13 on Tier 1 — those are human-authored flows where Tier 0 is enough. Tier 1 is the prerequisite for the router-agent execution path in agentic_calling.md, not for hero_logic stories themselves.

Taking ownership. Plan: - **Tier 0** (env scrub + ulimits + per-play workdir + wall-clock timeout) lands as part of #11's executor — this is small enough to PR alongside the executor itself, not as a separate change. - **Tier 1** (bwrap on Linux, sandbox-exec on macOS, fall back to Tier 0 elsewhere) is its own PR after #11 merges — gated behind a `executor.sandbox = ...` config flag, defaulting to `bwrap` on Linux and `sandbox-exec` on macOS, `none` only in dev mode with a startup warning. - **Tiers 2/3** stay deferred. Won't pre-build for them. - **`hero_logic/docs/sandbox.md`** lands with Tier 0 as a permanent reference and gets cross-linked from `hero_router/docs/agentic_calling.md` §8. Not blocking #11/#12/#13 on Tier 1 — those are human-authored flows where Tier 0 is enough. Tier 1 is the prerequisite for the router-agent execution path in `agentic_calling.md`, not for hero_logic stories themselves.
timur self-assigned this 2026-05-05 10:59:32 +00:00
mik-tf added this to the ACTIVE project 2026-05-06 17:32:04 +00:00
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lhumina_code/hero_logic#14
No description provided.