Tolokaforge evaluates untrusted LLM agents. This document describes the security boundaries that exist today and the threat model they address.
Tolokaforge runs all tool execution inside Docker containers. The orchestrator runs on the host and proxies tool execution to a containerised runner over gRPC. There are two ways the runner and its supporting services are materialised, and they carry different network postures.
When a run declares no environment_manifest, the orchestrator brings up the engine's built-in services with EngineStack (tolokaforge/docker/stack.py): core_stack = runner + db-service, or full_stack = runner + db-service + mock-web + rag-service. Every built-in service is attached to runner-net, a bridge network that is not internal.
- The runner runs with
cap_drop: ALL,cap_add: NET_BIND_SERVICE, andno-new-privileges(its defaultResourcePolicy; relaxed only on the Docker-socket / Docker-in-Docker terminal-bench paths). runner-nethas full egress by construction. The runner needs it: when a task declares anllm_judgegrading component,RunnerService.GradeTrialruns the rubric judge inside the runner container and must reach the LLM provider to grade. The runner's published gRPC port must also stay host-reachable so the orchestrator can connect to it.- This is not a threat vector on this path.
EngineStackmaterialises only first-party engine services — there is no untrusted task-declared compose to isolate.network_policyis a task-declared-stack concept; it lives onEnvironmentManifest, which a Case A run does not have. Egress of tools the agent runs inside the runner is a separate concern (tracked in #325); a container-network posture cannot address it.
See tolokaforge/docker/stacks/ for the predefined built-in stack definitions.
When a task declares its own environment_manifest.compose_file, the runtime backend materialises that task-declared compose stack instead — arbitrary services the task author chose. This is the path with an enforceable network posture: EnvironmentManifest.network_policy (default no_internet) is applied by enforce_network_policy (tolokaforge/core/compose_materialisation.py) as an in-place rewrite of the copied compose file before it is brought up.
- Under
no_internetevery task service joins an injectedinternal: truenetwork (tolokaforge_netpolicy_internal) — no application service can reach the public internet, while inter-service DNS stays intact. The runner is additionally attached to a non-internal edge network (tolokaforge_netpolicy_edge) so it keeps its control-plane and grading egress. The honest scope: task-declared application services get zero public egress; the runner retains its control-plane and grading egress. full_internetruns the compose file unchanged.limited_internetattaches application services to the internal network with no direct egress and points them at an injected digest-pinnedubuntu/squidforward-proxy sidecar that default-denies egress and forwards only to the hosts inlimited_internet_allowlist(CONNECT-only, no TLS interception; non-allowlisted egress is refused with HTTP 403). The runner keeps direct edge egress for grading, as underno_internet.
See ADR-0018 for the full case matrix, RUNTIME_BACKENDS.md for backend mechanics, and PROJECTS.md for the network_policy and per-service isolation authoring surface.
Tasks declare exactly which tools the agent and user can call:
tools:
agent:
enabled: ["db_query", "browser", "read_file"]
user:
enabled: ["user_check_device"]The ToolExecutor enforces:
- Only registered tools can be called
- Arguments are validated against JSON schemas (
additionalProperties: false) - Missing required parameters are rejected
- Per-tool rate limits are enforced (configurable via
ToolPolicy.rate_limit) - Per-tool timeouts are enforced (configurable via
ToolPolicy.timeout_s, default 30s)
Multiple timeout layers prevent runaway execution:
| Control | Default | Config path |
|---|---|---|
| Per-tool timeout | 30s | ToolPolicy.timeout_s |
| Per-turn timeout | 60s | orchestrator.timeouts.turn_s |
| Episode timeout | 1200s | orchestrator.timeouts.episode_s |
| Max turns | 50 | orchestrator.max_turns (always-on cap; #534 will flip to opt-in) |
| Request throttle | 1.0/s | orchestrator.max_requests_per_second |
orchestrator.max_budget_usd sets a hard spend limit. The orchestrator tracks cumulative estimated cost and stops leasing new work when the cap is reached.
Tool call arguments are logged with automatic redaction of keys containing password, token, secret, or api_key. See ToolExecutor._redact_sensitive() in tolokaforge/tools/registry.py.
- API keys are read only through
SecretManager(neveros.environdirectly). The orchestrator holds them on the host; the runner receives them serialised into the singleTOLOKAFORGE_SECRETS_JSONenvironment variable and reconstructs its ownSecretManagersingleton, because it runs in-container LLM-as-judge grading. This is the only place credentials cross the host→container boundary — never via build args, mounts, or image bake-in. Environment services (db-service, mock-web, rag-service) receive no secrets. - Grading assets (
grading.yaml, expected states) are read by the orchestrator after trial completion. They are never passed to the agent or exposed through tool outputs. .envis gitignored and never mounted into the runner or environment containers.
The agent never sees grading criteria or expected outputs:
grading.yamland expected state files stay on the host- Environment services reset per trial from fixtures
- Grading runs post-trial using host-side data only
- JSON DB state is namespaced per
{task_id}_{trial_idx}so trials cannot interfere with each other
| Threat | Mitigation |
|---|---|
| Agent calling unauthorized tools | Tool allowlisting + schema validation |
| Runaway execution (cost/time) | Budget cap, episode timeout, max turns, per-tool timeout |
| Agent accessing grading criteria | Grading data is host-side only, never in tool outputs |
| Environment state leaking between trials | Per-trial namespace isolation in JSON DB (built-in stack); per-trial containers, networks, and volumes under PerTrialRuntimeBackend (task-declared stack) |
| Task-declared services reaching external internet | network_policy: no_internet (the default) attaches them to an injected internal: true network via enforce_network_policy (Case B/C) |
| Runner privilege escalation (Docker mode) | cap_drop: ALL, cap_add: NET_BIND_SERVICE, no-new-privileges |
| Sensitive data in logs | Automatic key redaction in tool call logging |
| Threat | Notes |
|---|---|
| Built-in runner reaching external internet (Case A) | Accepted risk. EngineStack materialises only first-party engine services; the runner needs LLM-provider egress for in-container LLM-as-judge grading, so runner-net is full_internet by construction and a Case A run carries no network_policy surface. |
| Egress of tools the agent runs inside the runner | Not blocked by no_internet — such tools share the runner's edge access. Tracked in #325. |
| Host-level Docker escapes | Out of scope; assumes Docker daemon is trusted |
| Supply chain attacks in task code | Task authors are assumed trusted |
| Side-channel attacks | Not mitigated |
| Agent exfiltrating data via LLM output | The orchestrator relays model output; no content filtering |
- Tool allowlisting —
tests/unit/test_tool_security.py::TestToolAllowlisting: unregistered tool rejection, schema validation, rate limiting. No Docker needed. Network.internalprimitive + built-in non-internal invariant —tests/unit/test_network_internal.py:Network.create(..., internal=…)faithfully carries docker-py'sinternalflag, andEngineStack.create_networks()always creates its networks non-internal (the executable form of "Case A isfull_internetby construction"). No Docker needed.- Task-declared
network_policyenforcement (Case B/C) —tests/canonical/test_network_policy_enforcement.pysnapshots theno_internetcompose-file topology produced byenforce_network_policy;tests/canonical/test_environment_manifest_contract.py::TestNetworkPolicyContractpins the wire shape andno_internetdefault. No Docker needed. - Runner network isolation (real daemon) —
tests/integration/test_security.py::TestNetworkIsolationmoves a runner onto an internal network and asserts it reaches db-service but not the public internet. This demonstrates a runner can be network-isolated when its trial makes no in-container LLM-judge call; the built-inrunner-netis deliberately non-internal so that grading egress is available.
# Unit + canonical security tests (no Docker needed)
uv run pytest tests/unit/test_tool_security.py tests/unit/test_network_internal.py -v
uv run pytest tests/canonical/test_network_policy_enforcement.py -v
# Runner network-isolation test (requires Docker)
uv run pytest tests/integration/test_security.py -v -m requires_dockerBefore running evaluations:
- API keys in
.env, not committed to git -
orchestrator.max_budget_usdset for long runs - Task YAML uses minimal tool allowlist (don't enable
bashunless needed) - For task-declared stacks, leave
network_policyat itsno_internetdefault unless the task genuinely needs application-service egress (built-in-stack runs arefull_internetby construction and have nonetwork_policysurface)