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

@@ -1,6 +1,6 @@
"""
Runbook runner — create run, next_step (execute http_check or return manual), complete_step, abort.
PR2: guided execution, allowlisted HTTP only; audit integration.
Runbook runner — create run, next_step (execute http_check/script or return manual), complete_step, abort.
PR3: adds script action_type via SafeExecutor (allowlisted, no shell=True).
"""
from __future__ import annotations
@@ -16,6 +16,7 @@ from . import db as _db
from . import docs_store as _docs_store
from .audit import audit_log, AuditEvent
from .runbook_parser import RunbookStep, parse_runbook
from . import safe_executor as _safe_exec
logger = logging.getLogger(__name__)
@@ -212,11 +213,26 @@ async def next_step(run_id: str, operator_id: str = "") -> Optional[Dict[str, An
)
)
if action_type == "http_check":
result = await _execute_http_check(sofiia_url or "http://127.0.0.1:8002", action_json)
if action_type in ("http_check", "script"):
if action_type == "http_check":
result = await _execute_http_check(sofiia_url or "http://127.0.0.1:8002", action_json)
auto_ok = result.get("ok", False)
else:
# script via SafeExecutor
script = action_json.get("script", "")
env = action_json.get("env") or {}
timeout_s = int(action_json.get("timeout_s", 120))
result = await _safe_exec.run_script(script, env=env, timeout_s=timeout_s)
auto_ok = result.get("ok", False)
finished_at = _now_ts()
duration_ms = int((finished_at - started_at) * 1000)
step_status = "ok" if result.get("ok") else "fail"
# Non-root warning elevates to "warn" status (not "fail") if script exited 0
if result.get("warning") == "running_as_root" and auto_ok:
step_status = "warn"
else:
step_status = "ok" if auto_ok else "fail"
await conn.execute(
"UPDATE runbook_steps SET status = ?, result_json = ?, finished_at = ? WHERE run_id = ? AND step_index = ?",
(step_status, json.dumps(result, separators=(",", ":")), finished_at, run_id, step_index),
@@ -243,15 +259,18 @@ async def next_step(run_id: str, operator_id: str = "") -> Optional[Dict[str, An
"run_id": run_id,
"step_index": step_index,
"action_type": action_type,
"result_ok": result.get("ok"),
"result_ok": auto_ok,
"exit_code": result.get("exit_code"),
"timeout": result.get("timeout"),
},
)
)
return {
"type": "http_check",
"type": action_type,
"step_index": step_index,
"title": title,
"result": result,
"step_status": step_status,
"next_step": next_current,
"completed": next_current >= total,
}