feat(sofiia-console): add safe script executor for allowlisted runbook steps

- adds safe_executor.py: REPO_ROOT confinement, strict script allowlist,
  env key allowlist (STRICT/SOFIIA_URL/BFF_A/BFF_B/NODE_ID/AGENT_ID),
  stdin=DEVNULL, 8KB output cap, timeout clamp (max 300s), non-root warn
- integrates script action_type into runbook_runner: next_step handles
  http_check and script branches; running_as_root -> step_status=warn
- extends runbook_parser: rehearsal-v1 now includes 3 built-in script steps
  (preflight, idempotency smoke, generate evidence) after http_checks
- adds tests/test_sofiia_safe_executor.py: 12 tests covering path traversal,
  absolute path, non-allowlist, env drop, timeout, exit_code, mocked subprocess

Made-with: Cursor
This commit is contained in:
Apple
2026-03-03 04:57:22 -08:00
parent ad8bddf595
commit 0603184524
4 changed files with 481 additions and 12 deletions

View File

@@ -0,0 +1,200 @@
"""
Tests for SafeExecutor (PR3): path validation, env filtering, subprocess mocking, timeout, exit_code.
All tests use monkeypatched subprocess — no real scripts executed.
"""
from __future__ import annotations
import asyncio
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
def test_path_traversal_rejected():
"""Script path with .. is rejected."""
import app.safe_executor as se
with pytest.raises(se.ScriptNotAllowedError, match="traversal"):
se._validate_script_path("ops/../etc/passwd")
def test_absolute_path_rejected():
"""Absolute script path is rejected."""
import app.safe_executor as se
with pytest.raises(se.ScriptNotAllowedError, match="Absolute"):
se._validate_script_path("/bin/bash")
def test_non_allowlisted_script_rejected():
"""Unknown script path is rejected even if no traversal."""
import app.safe_executor as se
with pytest.raises(se.ScriptNotAllowedError, match="allowlist"):
se._validate_script_path("ops/custom_hack.sh")
def test_allowlisted_script_resolves():
"""Allowlisted script resolves to a Path under REPO_ROOT without error."""
import app.safe_executor as se
resolved = se._validate_script_path("ops/preflight_sofiia_console.sh")
assert resolved.name == "preflight_sofiia_console.sh"
assert str(se._REPO_ROOT) in str(resolved)
def test_env_filter_drops_non_allowlisted_keys():
"""Non-allowlisted env keys are dropped and recorded."""
import app.safe_executor as se
filtered, dropped = se._filter_env({
"STRICT": "1",
"LD_PRELOAD": "/evil.so",
"PYTHONPATH": "/hack",
"SOFIIA_URL": "http://localhost:8002",
})
assert "STRICT" in filtered
assert "SOFIIA_URL" in filtered
assert "LD_PRELOAD" not in filtered
assert "PYTHONPATH" not in filtered
assert "LD_PRELOAD" in dropped
assert "PYTHONPATH" in dropped
def test_env_filter_empty():
"""Empty env dict produces empty filtered + no dropped."""
import app.safe_executor as se
filtered, dropped = se._filter_env({})
assert filtered == {}
assert dropped == []
def _fake_proc(returncode: int = 0, stdout: bytes = b"ok", stderr: bytes = b"") -> MagicMock:
"""Return a mock process where communicate() returns (stdout, stderr)."""
proc = MagicMock()
proc.returncode = returncode
proc.kill = MagicMock()
proc.wait = AsyncMock(return_value=None)
async def _communicate():
return stdout, stderr
proc.communicate = _communicate
return proc
def test_run_script_success(sofiia_module, monkeypatch):
"""Allowlisted script with exit_code=0 returns ok=True, exit_code=0, stdout_tail."""
import app.safe_executor as se
loop = asyncio.get_event_loop()
async def run():
with patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_exec:
mock_exec.return_value = _fake_proc(returncode=0, stdout=b"PASS\n", stderr=b"")
return await se.run_script("ops/preflight_sofiia_console.sh", env={"STRICT": "1"})
result = loop.run_until_complete(run())
assert result["ok"] is True
assert result["exit_code"] == 0
assert "PASS" in result["stdout_tail"]
assert result["timeout"] is False
def test_run_script_nonzero_exit(sofiia_module, monkeypatch):
"""Script with exit_code=1 returns ok=False, exit_code=1."""
import app.safe_executor as se
loop = asyncio.get_event_loop()
async def run():
with patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_exec:
mock_exec.return_value = _fake_proc(returncode=1, stderr=b"FAIL\n")
return await se.run_script("ops/preflight_sofiia_console.sh")
result = loop.run_until_complete(run())
assert result["ok"] is False
assert result["exit_code"] == 1
assert result["timeout"] is False
def test_run_script_timeout(sofiia_module, monkeypatch):
"""Script that hangs beyond timeout returns timeout=True, ok=False."""
import app.safe_executor as se
loop = asyncio.get_event_loop()
async def run():
async def slow_proc(*args, **kwargs):
proc = MagicMock()
proc.kill = MagicMock()
proc.wait = AsyncMock(return_value=None)
async def _hang():
await asyncio.sleep(9999)
return b"", b""
proc.communicate = _hang
return proc
with patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_exec:
mock_exec.side_effect = slow_proc
return await se.run_script("ops/preflight_sofiia_console.sh", timeout_s=1)
result = loop.run_until_complete(run())
assert result["timeout"] is True
assert result["ok"] is False
def test_run_script_timeout_clamped(sofiia_module, monkeypatch):
"""Timeout values above _TIMEOUT_MAX_S are clamped."""
import app.safe_executor as se
loop = asyncio.get_event_loop()
captured_timeout: list = []
async def run():
async def fake_wait_for(coro, timeout):
captured_timeout.append(timeout)
return await asyncio.wait_for(coro, timeout=999)
with patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_exec:
mock_exec.return_value = _fake_proc(returncode=0)
with patch("asyncio.wait_for", side_effect=fake_wait_for):
return await se.run_script(
"ops/preflight_sofiia_console.sh",
timeout_s=9999,
)
loop.run_until_complete(run())
if captured_timeout:
assert captured_timeout[0] <= se._TIMEOUT_MAX_S
def test_run_script_env_key_not_in_allowlist_dropped(sofiia_module, monkeypatch):
"""Non-allowlisted env keys are dropped; result still has dropped_env_keys."""
import app.safe_executor as se
loop = asyncio.get_event_loop()
async def run():
with patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_exec:
mock_exec.return_value = _fake_proc(returncode=0)
return await se.run_script(
"ops/preflight_sofiia_console.sh",
env={"STRICT": "1", "EVIL_KEY": "bad"},
)
result = loop.run_until_complete(run())
assert result.get("dropped_env_keys") is not None
assert "EVIL_KEY" in result["dropped_env_keys"]
def test_run_script_non_allowlisted_returns_error(sofiia_module):
"""Non-allowlisted script returns ok=False without executing anything."""
import app.safe_executor as se
loop = asyncio.get_event_loop()
async def run():
return await se.run_script("ops/some_random_script.sh")
result = loop.run_until_complete(run())
assert result["ok"] is False
assert "allowlist" in result.get("error", "").lower()