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:
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user