Skip to content

Commit 34167e1

Browse files
authored
Merge pull request #6 from superagent-ai/homanp/unify-sandbox-modes
feat: Unify runtime sandbox modes
2 parents cf016d2 + 5fcfc54 commit 34167e1

9 files changed

Lines changed: 233 additions & 29 deletions

File tree

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,8 +160,8 @@ command, optional Dockerfile build metadata, start and healthcheck commands, env
160160
personas, services, and mocks. Secret values must come from the shell, CI secret store, or Cursor-style
161161
environment secrets; do not commit them. If `--execute-app` is passed and required setup is missing,
162162
PITHOS writes `verify/RUNTIME-SETUP.md` instead of starting the target app.
163-
Local live runtime setup defaults to `environment.sandbox: docker`; use `subprocess` only for tests or
164-
explicit local smoke checks.
163+
Live runtime setup defaults to `environment.sandbox: docker`; use `environment.sandbox: local`
164+
only in a disposable outer sandbox, ephemeral CI runner, or explicit local smoke check.
165165
Live verification does not load `.env.local` or other env files; users must explicitly export every
166166
required variable before running PITHOS.
167167
Use `pithos runtime init` to create a starter `.pithos/runtime.yaml`; it inspects `.env.example` for

docs/output.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,10 @@ verification:
7676
execute_app: true
7777
```
7878
79+
`environment.sandbox` accepts `docker` or `local`. Docker is isolated and remains
80+
the default; local runs setup commands and live verification agents directly in
81+
the current environment, so use it only inside a disposable outer sandbox.
82+
7983
Secret values should come from the shell, CI secret store, or Cursor-style
8084
environment secrets. The profile should name variables, not contain secret
8185
values.

pithos/cli.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,8 +152,9 @@ def main(argv: list[str] | None = None) -> int:
152152
choices=SANDBOX_MODES,
153153
default=os.environ.get("PITHOS_SANDBOX_MODE", DEFAULT_SANDBOX_MODE),
154154
help=(
155-
"Static agent isolation mode: docker runs agents in containers; "
156-
"local runs pi directly and trusts the outer environment"
155+
"Sandbox mode for static scan and live verification: docker runs agents "
156+
"and app commands in containers; local runs them directly and trusts the "
157+
"outer environment"
157158
),
158159
)
159160
p_run.add_argument("--votes", type=int, default=DEFAULT_REPO_VOTES)
@@ -358,6 +359,7 @@ def _cmd_run(args: argparse.Namespace) -> int:
358359
model=args.model,
359360
agent_env=agent_env,
360361
pi_config_dir=pi_config_dir,
362+
sandbox_mode=args.sandbox_mode,
361363
event_sink=event_sink,
362364
)
363365
except KeyboardInterrupt:

pithos/runtime_plugins.py

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,14 @@
1212
import asyncio
1313
import json
1414
import re
15+
import shutil
1516
import time
1617
import urllib.request
1718
from pathlib import Path
1819
from typing import Protocol
1920

2021
from . import agent_image, docker_ops, sandbox as agent_sandbox
21-
from .agent import parse_xml_tag, run_agent
22+
from .agent import parse_xml_tag, run_agent, run_agent_process
2223
from .events import NULL_EVENT_SINK
2324
from .runtime_artifacts import (
2425
RUNTIME_BLOCKED,
@@ -946,6 +947,34 @@ def _run_pi_agent(
946947
return self._blocked(finding, plan, "verification agent provider/model is missing")
947948
with EphemeralAppSandbox(self.profile, finding_dir) as app:
948949
assert app.repo_copy is not None
950+
assert app.root is not None
951+
transcript = finding_dir / "live-agent-transcript.jsonl"
952+
if app.backend == "local":
953+
result = asyncio.run(
954+
run_agent_process(
955+
_localize_live_agent_paths(
956+
_live_agent_prompt(self.profile, finding),
957+
repo_path=app.repo_copy,
958+
artifacts_path=finding_dir,
959+
),
960+
command_prefix=["pi"],
961+
provider=provider,
962+
model=model,
963+
session_dir=str(app.root / ".pi-sessions"),
964+
cwd=str(app.root),
965+
env=_local_pi_env(app, agent_cfg),
966+
transcript_path=str(transcript),
967+
progress_prefix=f"[verify {finding.id}]",
968+
tools=["read", "bash"],
969+
system_prompt=_localize_live_agent_paths(
970+
_live_agent_system_prompt(),
971+
repo_path=app.repo_copy,
972+
artifacts_path=finding_dir,
973+
),
974+
event_sink=getattr(self, "event_sink", NULL_EVENT_SINK),
975+
)
976+
)
977+
return self._verdict_from_agent_result(finding, finding_dir, plan, result)
949978
mounts = agent_sandbox.provider_mounts(
950979
Path(str(agent_cfg["pi_config_dir"])) if agent_cfg.get("pi_config_dir") else None
951980
)
@@ -968,7 +997,6 @@ def _run_pi_agent(
968997
mounts=mounts,
969998
)
970999
try:
971-
transcript = finding_dir / "live-agent-transcript.jsonl"
9721000
result = asyncio.run(
9731001
run_agent(
9741002
_live_agent_prompt(self.profile, finding),
@@ -984,6 +1012,15 @@ def _run_pi_agent(
9841012
)
9851013
finally:
9861014
docker_ops.rm(container)
1015+
return self._verdict_from_agent_result(finding, finding_dir, plan, result)
1016+
1017+
def _verdict_from_agent_result(
1018+
self,
1019+
finding: RuntimeFinding,
1020+
finding_dir: Path,
1021+
plan: VerificationPlan,
1022+
result,
1023+
) -> RuntimeVerdict:
9871024
output = result.find_tagged_message("runtime_verdict_json")
9881025
(finding_dir / "live-agent-output.txt").write_text(
9891026
result.last_assistant_message or output,
@@ -1223,6 +1260,30 @@ def _unsafe_without_mock(profile: AppRuntimeProfile, finding: RuntimeFinding) ->
12231260
return False
12241261

12251262

1263+
def _local_pi_env(app: EphemeralAppSandbox, agent_cfg: dict[str, object]) -> dict[str, str]:
1264+
env = app.env()
1265+
env["PI_OFFLINE"] = "1"
1266+
env["PI_SKIP_VERSION_CHECK"] = "1"
1267+
env["PI_TELEMETRY"] = "0"
1268+
if agent_cfg.get("pi_config_dir"):
1269+
assert app.root is not None
1270+
pi_config_dir = Path(str(agent_cfg["pi_config_dir"]))
1271+
target = app.root / "home" / ".pi" / "agent"
1272+
target.mkdir(parents=True, exist_ok=True)
1273+
for name in ("auth.json", "models.json"):
1274+
source = pi_config_dir / name
1275+
if source.is_file():
1276+
shutil.copy2(source, target / name)
1277+
env["HOME"] = str(app.root / "home")
1278+
return env
1279+
1280+
1281+
def _localize_live_agent_paths(text: str, *, repo_path: Path, artifacts_path: Path) -> str:
1282+
return text.replace("/work/repo", str(repo_path)).replace(
1283+
"/work/artifacts", str(artifacts_path)
1284+
)
1285+
1286+
12261287
def _extract_verdict_payload(text: str) -> dict[str, object] | None:
12271288
raw = parse_xml_tag(text, "runtime_verdict_json") or text.strip()
12281289
if raw.startswith("```"):

pithos/runtime_sandbox.py

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@
1919
from . import agent_image, docker_ops, sandbox as agent_sandbox
2020
from .runtime_profile import AppRuntimeProfile, required_env_names
2121

22+
RUNTIME_SANDBOX_BACKENDS = ("docker", "local")
23+
LEGACY_RUNTIME_SANDBOX_BACKENDS = {"subprocess": "local"}
24+
RUNTIME_SANDBOX_ENV = "PITHOS_RUNTIME_SANDBOX_BACKEND"
25+
2226

2327
@dataclass(frozen=True)
2428
class CommandResult:
@@ -276,16 +280,7 @@ def _write_command_log(self, result: CommandResult) -> None:
276280

277281
@property
278282
def backend(self) -> str:
279-
raw = (
280-
self.profile.environment.get("sandbox")
281-
or self.profile.environment.get("backend")
282-
or os.environ.get("PITHOS_RUNTIME_SANDBOX_BACKEND")
283-
or "docker"
284-
)
285-
backend = str(raw)
286-
if backend not in {"docker", "subprocess"}:
287-
raise RuntimeError(f"unsupported runtime sandbox backend: {backend}")
288-
return backend
283+
return normalize_runtime_sandbox_backend(runtime_sandbox_backend_value(self.profile))
289284

290285
def _ensure_container(self) -> str:
291286
if self._container:
@@ -375,6 +370,39 @@ def _resolve_env_value(value: str) -> str:
375370
return value
376371

377372

373+
def runtime_sandbox_backend_value(
374+
profile: AppRuntimeProfile, *, default: str | None = None
375+
) -> object:
376+
return (
377+
profile.environment.get("sandbox")
378+
or profile.environment.get("backend")
379+
or os.environ.get(RUNTIME_SANDBOX_ENV)
380+
or default
381+
or "docker"
382+
)
383+
384+
385+
def normalize_runtime_sandbox_backend(raw: object) -> str:
386+
backend = str(raw).strip().lower()
387+
backend = LEGACY_RUNTIME_SANDBOX_BACKENDS.get(backend, backend)
388+
if backend not in RUNTIME_SANDBOX_BACKENDS:
389+
allowed = ", ".join(RUNTIME_SANDBOX_BACKENDS)
390+
raise RuntimeError(f"unsupported runtime sandbox backend: {raw}; choose {allowed}")
391+
return backend
392+
393+
394+
def legacy_runtime_sandbox_warning(raw: object) -> str | None:
395+
backend = str(raw).strip().lower()
396+
if backend in LEGACY_RUNTIME_SANDBOX_BACKENDS:
397+
replacement = LEGACY_RUNTIME_SANDBOX_BACKENDS[backend]
398+
return (
399+
f"{backend} runtime sandbox backend is deprecated; use {replacement}. "
400+
f"{replacement} runs app commands and live agents in the current environment "
401+
"and is not isolated."
402+
)
403+
return None
404+
405+
378406
def _service_commands(services: dict[str, Any]) -> list[tuple[str, str]]:
379407
commands: list[tuple[str, str]] = []
380408
for name, value in services.items():

pithos/runtime_verifier.py

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@
1818
from .runtime_artifacts import RuntimeFinding, RuntimeVerdict, write_runtime_json
1919
from .runtime_plugins import select_plugin
2020
from .runtime_profile import AppRuntimeProfile, load_runtime_profile, required_env_names
21+
from .runtime_sandbox import (
22+
legacy_runtime_sandbox_warning,
23+
normalize_runtime_sandbox_backend,
24+
runtime_sandbox_backend_value,
25+
)
2126

2227

2328
@dataclass(frozen=True)
@@ -55,6 +60,7 @@ def run_verify_repo(
5560
model: str | None = None,
5661
agent_env: dict[str, str] | None = None,
5762
pi_config_dir: Path | None = None,
63+
sandbox_mode: str | None = None,
5864
event_sink: EventSinkLike = NULL_EVENT_SINK,
5965
) -> RuntimeVerifyResult:
6066
start = time.time()
@@ -79,6 +85,7 @@ def run_verify_repo(
7985
allow_inferred_runtime=allow_inferred_runtime,
8086
provider=provider,
8187
model=model,
88+
sandbox_mode=sandbox_mode,
8289
)
8390
if execute_app and not preflight["ready"]:
8491
_write_setup_guide(out_dir / "RUNTIME-SETUP.md", profile, preflight)
@@ -104,8 +111,12 @@ def run_verify_repo(
104111
"pi_config_dir": str(pi_config_dir) if pi_config_dir else None,
105112
"auth_env_keys": sorted((agent_env or {}).keys()),
106113
}
114+
environment = dict(profile.environment)
115+
if preflight["sandbox_backend"] in {"docker", "local"}:
116+
environment["sandbox"] = preflight["sandbox_backend"]
107117
profile = replace(
108118
profile,
119+
environment=environment,
109120
verification={
110121
**profile.verification,
111122
"execute_app": execute_app,
@@ -326,23 +337,32 @@ def _environment_preflight(
326337
allow_inferred_runtime: bool,
327338
provider: str | None,
328339
model: str | None,
340+
sandbox_mode: str | None,
329341
) -> dict[str, Any]:
330342
required = required_env_names(profile)
331343
missing = [name for name in required if not os.environ.get(name)]
332344
issues: list[str] = []
333345
warnings: list[str] = []
334-
backend = str(
335-
profile.environment.get("sandbox")
336-
or profile.environment.get("backend")
337-
or os.environ.get("PITHOS_RUNTIME_SANDBOX_BACKEND")
338-
or "docker"
339-
)
340-
if execute_app and backend not in {"docker", "subprocess"}:
341-
issues.append(f"unsupported runtime sandbox backend: {backend}")
346+
raw_backend = runtime_sandbox_backend_value(profile, default=sandbox_mode)
347+
try:
348+
backend = normalize_runtime_sandbox_backend(raw_backend)
349+
except RuntimeError:
350+
backend = str(raw_backend)
351+
if execute_app:
352+
issues.append(
353+
f"unsupported runtime sandbox backend: {raw_backend}; choose docker or local"
354+
)
342355
if execute_app and backend == "docker" and not shutil.which("docker"):
343356
issues.append("Docker runtime sandbox requested but docker is not installed")
344-
if execute_app and backend == "subprocess":
345-
warnings.append("subprocess runtime backend is for local tests only and is not isolated")
357+
if execute_app and backend == "local":
358+
legacy_warning = legacy_runtime_sandbox_warning(raw_backend)
359+
warnings.append(
360+
legacy_warning
361+
or (
362+
"local runtime sandbox runs app commands and live agents in the current "
363+
"environment and is not isolated"
364+
)
365+
)
346366
if execute_app and profile.inferred and not allow_inferred_runtime:
347367
issues.append(
348368
"live verification requires an explicit .pithos/runtime.yaml profile "

tests/test_cli.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ def fake_verify(**kwargs):
6363
assert kwargs["triage_path"] == triage_dir / "TRIAGE.json"
6464
assert kwargs["results_dir"] == triage_dir / "verify"
6565
assert kwargs["execute_app"] is False
66+
assert kwargs["sandbox_mode"] == "docker"
6667
return VerifyResult()
6768

6869
monkeypatch.setattr(cli, "_resolve_agent_env", lambda provider, pi_config_dir=None: ({}, None))
@@ -120,7 +121,12 @@ async def fake_scan(**kwargs):
120121
monkeypatch.setattr(cli, "_provider_auth_status", lambda provider, pi_config_dir=None: True)
121122
monkeypatch.setattr(cli, "resolve_repo_source", lambda *a, **k: Source())
122123
monkeypatch.setattr(cli, "run_repo_static", fake_scan)
123-
monkeypatch.setattr(cli, "run_verify_repo", lambda **kwargs: VerifyResult())
124+
125+
def fake_verify(**kwargs):
126+
assert kwargs["sandbox_mode"] == "local"
127+
return VerifyResult()
128+
129+
monkeypatch.setattr(cli, "run_verify_repo", fake_verify)
124130

125131
rc = cli.main(
126132
[
@@ -285,6 +291,7 @@ def fake_verify(**kwargs):
285291
assert kwargs["allow_inferred_runtime"] is True
286292
assert kwargs["provider"] == "anthropic"
287293
assert kwargs["model"] == "claude-sonnet-4-5"
294+
assert kwargs["sandbox_mode"] == "docker"
288295
return VerifyResult()
289296

290297
monkeypatch.setattr(cli, "_resolve_agent_env", lambda provider, pi_config_dir=None: ({}, None))

tests/test_runtime_sandbox.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ def test_sandbox_uses_explicit_env_and_runs_install(tmp_path, monkeypatch):
1515
profile = AppRuntimeProfile(
1616
repo_path=repo,
1717
install_command=f"python -c \"import os; open('{marker}', 'w').write(os.environ['FROM_FILE'])\"",
18-
environment={"sandbox": "subprocess"},
18+
environment={"sandbox": "local"},
1919
env_files=[".env.test"],
2020
env={"INLINE_VALUE": "inline", "FROM_FILE": "${FROM_FILE}"},
2121
required_env=["REQUIRED_TOKEN"],

0 commit comments

Comments
 (0)