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:
200
tests/test_sofiia_safe_executor.py
Normal file
200
tests/test_sofiia_safe_executor.py
Normal 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()
|
||||
Reference in New Issue
Block a user