generated from coulomb/repo-seed
command-runner support and first OpenCMIS TCK wrapper boundary
This commit is contained in:
@@ -99,30 +99,60 @@ def _findings_for_evidence(run_id: str, evidence: list[dict[str, Any]]) -> list[
|
||||
for item in evidence:
|
||||
if item["result"] not in {"blocked", "fail", "infrastructure_error"}:
|
||||
continue
|
||||
classification = {
|
||||
"blocked": "runner_not_implemented",
|
||||
"fail": "check_failed",
|
||||
"infrastructure_error": "infrastructure_error",
|
||||
}[item["result"]]
|
||||
findings.append(
|
||||
{
|
||||
"id": f"finding:{item['check_id']}",
|
||||
"run_id": run_id,
|
||||
"status": item["result"],
|
||||
"severity": "info" if item["result"] == "blocked" else "medium",
|
||||
"classification": classification,
|
||||
"severity": _severity_for_item(item),
|
||||
"classification": _classification_for_item(item),
|
||||
"requirement_refs": item["requirement_refs"],
|
||||
"evidence_refs": [item["id"]],
|
||||
"expected": item["result"] == "blocked",
|
||||
"expected": _expected_for_item(item),
|
||||
"waiver_ref": None,
|
||||
"remediation": _remediation_for_result(item["result"]),
|
||||
"remediation": _remediation_for_item(item),
|
||||
}
|
||||
)
|
||||
return findings
|
||||
|
||||
|
||||
def _remediation_for_result(result: str) -> str:
|
||||
def _classification_for_item(item: dict[str, Any]) -> str:
|
||||
result = item["result"]
|
||||
if result == "blocked":
|
||||
blocked_reason = item.get("facts", {}).get("blocked_reason")
|
||||
if isinstance(blocked_reason, str):
|
||||
return blocked_reason
|
||||
return "runner_not_implemented"
|
||||
if result == "fail":
|
||||
return "check_failed"
|
||||
return "infrastructure_error"
|
||||
|
||||
|
||||
def _severity_for_item(item: dict[str, Any]) -> str:
|
||||
if item["result"] == "blocked":
|
||||
return "info"
|
||||
return "medium"
|
||||
|
||||
|
||||
def _expected_for_item(item: dict[str, Any]) -> bool:
|
||||
if item["result"] != "blocked":
|
||||
return False
|
||||
blocked_reason = item.get("facts", {}).get("blocked_reason")
|
||||
return blocked_reason in {
|
||||
"missing_command",
|
||||
"missing_dependency",
|
||||
"tck_invocation_not_configured",
|
||||
}
|
||||
|
||||
|
||||
def _remediation_for_item(item: dict[str, Any]) -> str:
|
||||
result = item["result"]
|
||||
if result == "blocked":
|
||||
blocked_reason = item.get("facts", {}).get("blocked_reason")
|
||||
if blocked_reason == "missing_dependency":
|
||||
return "Install the missing runner dependencies and rerun the assessment."
|
||||
if blocked_reason == "tck_invocation_not_configured":
|
||||
return "Configure the final harness invocation, group mapping, and raw artifact capture."
|
||||
return "Implement or configure the declared extension runner."
|
||||
if result == "infrastructure_error":
|
||||
return "Fix the target, network, credentials, or harness runtime and rerun the assessment."
|
||||
|
||||
@@ -3,12 +3,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Any, Callable
|
||||
|
||||
from guide_board.errors import ValidationError
|
||||
from guide_board.io import load_json
|
||||
from guide_board.io import load_json, write_json
|
||||
|
||||
|
||||
RunnerCallable = Callable[[dict[str, Any]], dict[str, Any]]
|
||||
@@ -44,17 +47,7 @@ def run_step(
|
||||
"artifact_refs": [],
|
||||
}
|
||||
if entrypoint["kind"] == "command":
|
||||
return {
|
||||
"result": "blocked",
|
||||
"observations": [
|
||||
f"Runner {runner_ref!r} is declared as a command runner; command execution is not enabled yet."
|
||||
],
|
||||
"facts": {
|
||||
"runner_ref": runner_ref,
|
||||
"runner_kind": "command",
|
||||
},
|
||||
"artifact_refs": [],
|
||||
}
|
||||
return _run_command(root, run_dir, run_id, plan, step, extension_path, entrypoint)
|
||||
raise ValidationError(f"{runner_ref}: unsupported runner kind {entrypoint['kind']!r}")
|
||||
|
||||
|
||||
@@ -136,6 +129,138 @@ def _run_python_module(
|
||||
}
|
||||
|
||||
|
||||
def _run_command(
|
||||
root: Path,
|
||||
run_dir: Path,
|
||||
run_id: str,
|
||||
plan: dict[str, Any],
|
||||
step: dict[str, Any],
|
||||
extension_path: Path,
|
||||
entrypoint: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
command_template = entrypoint.get("command")
|
||||
if not isinstance(command_template, list) or not command_template:
|
||||
raise ValidationError(f"{entrypoint['id']}: command runners need a non-empty command")
|
||||
|
||||
context_path = run_dir / "artifacts" / "runner-contexts" / f"{_safe_id(step['id'])}.json"
|
||||
context = {
|
||||
"root": str(root),
|
||||
"run_dir": str(run_dir),
|
||||
"run_id": run_id,
|
||||
"plan": plan,
|
||||
"step": step,
|
||||
"target_profile": plan["target_profile_snapshot"],
|
||||
"assessment_profile": plan["assessment_profile_snapshot"],
|
||||
"extension_path": str(extension_path),
|
||||
"runner": entrypoint,
|
||||
}
|
||||
write_json(context_path, context)
|
||||
|
||||
command = [
|
||||
_expand_command_arg(arg, root, run_dir, extension_path, context_path)
|
||||
for arg in command_template
|
||||
]
|
||||
timeout = _timeout_seconds(plan)
|
||||
env = os.environ.copy()
|
||||
src_path = str(root / "src")
|
||||
env["PYTHONPATH"] = (
|
||||
src_path
|
||||
if not env.get("PYTHONPATH")
|
||||
else f"{src_path}{os.pathsep}{env['PYTHONPATH']}"
|
||||
)
|
||||
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
cwd=extension_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
return {
|
||||
"result": "blocked",
|
||||
"observations": [
|
||||
f"Command runner {entrypoint['id']!r} could not start: {exc.filename} was not found."
|
||||
],
|
||||
"facts": {
|
||||
"runner_ref": entrypoint["id"],
|
||||
"runner_kind": "command",
|
||||
"blocked_reason": "missing_command",
|
||||
"command": command,
|
||||
},
|
||||
"artifact_refs": [str(context_path.relative_to(run_dir))],
|
||||
}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {
|
||||
"result": "infrastructure_error",
|
||||
"observations": [
|
||||
f"Command runner {entrypoint['id']!r} timed out after {timeout} seconds."
|
||||
],
|
||||
"facts": {
|
||||
"runner_ref": entrypoint["id"],
|
||||
"runner_kind": "command",
|
||||
"timeout_seconds": timeout,
|
||||
"command": command,
|
||||
},
|
||||
"artifact_refs": [str(context_path.relative_to(run_dir))],
|
||||
}
|
||||
|
||||
parsed = _parse_runner_stdout(completed.stdout)
|
||||
if parsed is None:
|
||||
result = "infrastructure_error" if completed.returncode else "unknown"
|
||||
return {
|
||||
"result": result,
|
||||
"observations": [
|
||||
f"Command runner {entrypoint['id']!r} did not return a JSON result on stdout."
|
||||
],
|
||||
"facts": {
|
||||
"runner_ref": entrypoint["id"],
|
||||
"runner_kind": "command",
|
||||
"returncode": completed.returncode,
|
||||
"stdout": completed.stdout[-4000:],
|
||||
"stderr": completed.stderr[-4000:],
|
||||
"command": command,
|
||||
},
|
||||
"artifact_refs": [str(context_path.relative_to(run_dir))],
|
||||
}
|
||||
|
||||
facts = parsed.get("facts", {})
|
||||
if not isinstance(facts, dict):
|
||||
facts = {}
|
||||
facts.update(
|
||||
{
|
||||
"runner_ref": entrypoint["id"],
|
||||
"runner_kind": "command",
|
||||
"returncode": completed.returncode,
|
||||
"stderr": completed.stderr[-4000:],
|
||||
}
|
||||
)
|
||||
observations = parsed.get("observations", [])
|
||||
if not isinstance(observations, list):
|
||||
observations = [str(observations)]
|
||||
artifact_refs = parsed.get("artifact_refs", [])
|
||||
if not isinstance(artifact_refs, list):
|
||||
artifact_refs = []
|
||||
artifact_refs.append(str(context_path.relative_to(run_dir)))
|
||||
|
||||
result = parsed.get("result", "unknown")
|
||||
if completed.returncode != 0 and result in {"pass", "warning", "manual", "skipped"}:
|
||||
result = "infrastructure_error"
|
||||
observations.append(
|
||||
f"Command runner {entrypoint['id']!r} exited with {completed.returncode}."
|
||||
)
|
||||
|
||||
return {
|
||||
"result": result,
|
||||
"observations": observations,
|
||||
"facts": facts,
|
||||
"artifact_refs": artifact_refs,
|
||||
}
|
||||
|
||||
|
||||
def _load_module(path: Path, runner_id: str) -> ModuleType:
|
||||
if not path.exists():
|
||||
raise ValidationError(f"{runner_id}: module not found: {path}")
|
||||
@@ -160,3 +285,43 @@ def _runner_entrypoint(manifest: dict[str, Any], runner_ref: str) -> dict[str, A
|
||||
if entrypoint["id"] == runner_ref:
|
||||
return entrypoint
|
||||
raise ValidationError(f"{manifest['id']}: runner {runner_ref!r} is not declared")
|
||||
|
||||
|
||||
def _expand_command_arg(
|
||||
arg: str,
|
||||
root: Path,
|
||||
run_dir: Path,
|
||||
extension_path: Path,
|
||||
context_path: Path,
|
||||
) -> str:
|
||||
return (
|
||||
arg.replace("{root}", str(root))
|
||||
.replace("{run_dir}", str(run_dir))
|
||||
.replace("{extension_path}", str(extension_path))
|
||||
.replace("{context_json}", str(context_path))
|
||||
)
|
||||
|
||||
|
||||
def _timeout_seconds(plan: dict[str, Any]) -> float:
|
||||
runtime_policy = plan.get("runtime_policy", {})
|
||||
timeout = runtime_policy.get("timeout_seconds", 300)
|
||||
if not isinstance(timeout, (int, float)):
|
||||
return 300.0
|
||||
return max(1.0, float(timeout))
|
||||
|
||||
|
||||
def _parse_runner_stdout(stdout: str) -> dict[str, Any] | None:
|
||||
stripped = stdout.strip()
|
||||
if not stripped:
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(stripped)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
if not isinstance(parsed, dict):
|
||||
return None
|
||||
return parsed
|
||||
|
||||
|
||||
def _safe_id(value: str) -> str:
|
||||
return "".join(char if char.isalnum() or char in {"-", "_"} else "_" for char in value)
|
||||
|
||||
Reference in New Issue
Block a user