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

@@ -86,23 +86,65 @@ def _parse_sections(markdown: str) -> List[tuple]:
return sections
def _rehearsal_script_steps(offset: int) -> List[RunbookStep]:
"""PR3: 3 allowlisted script steps for rehearsal v1 (after http_checks)."""
return [
RunbookStep(
step_index=offset,
title="Preflight check (STRICT=1)",
section="05 min — Preflight",
action_type="script",
action_json={
"script": "ops/preflight_sofiia_console.sh",
"env": {"STRICT": "1"},
"timeout_s": 120,
},
),
RunbookStep(
step_index=offset + 1,
title="Redis idempotency smoke test",
section="1015 min — Smoke",
action_type="script",
action_json={
"script": "ops/redis_idempotency_smoke.sh",
"env": {},
"timeout_s": 60,
},
),
RunbookStep(
step_index=offset + 2,
title="Generate release evidence",
section="2530 min — Evidence",
action_type="script",
action_json={
"script": "ops/generate_release_evidence.sh",
"env": {},
"timeout_s": 60,
},
),
]
def parse_runbook(
runbook_path: str,
markdown: str,
sofiia_url: str = "http://127.0.0.1:8002",
) -> List[RunbookStep]:
"""
Parse markdown into steps. For rehearsal-v1 prepend 3 http_check steps;
rest are manual (one per H2 section with instructions_md).
Parse markdown into steps.
For rehearsal-v1: prepend 3 http_check + 3 script steps; rest are manual.
"""
path_lower = runbook_path.lower()
steps: List[RunbookStep] = []
offset = 0
if "rehearsal" in path_lower and "30min" in path_lower:
builtin = _rehearsal_http_check_steps(sofiia_url)
steps.extend(builtin)
offset = len(builtin)
http_steps = _rehearsal_http_check_steps(sofiia_url)
steps.extend(http_steps)
offset = len(http_steps)
script_steps = _rehearsal_script_steps(offset)
steps.extend(script_steps)
offset += len(script_steps)
sections = _parse_sections(markdown)
for i, (title, content) in enumerate(sections):