activity-core/src/activity_core/bounded_operations.py
tegwick 26934e25b9
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Container Image / build-and-push (push) Successful in 21s
Enforce bounded operation guardrails
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a028de-e2c8-7732-8521-46a7fc5db82f
2026-08-23 12:31:13 +02:00

292 lines
11 KiB
Python

"""Admission policy for ACT-ADR-007 bounded operations.
Definitions may select only code-registered operations. This module validates
their safety envelope during markdown parsing and exposes immutable runtime
metadata to the workflow. It never accepts a command or import path from a
definition.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Any, Iterable
@dataclass(frozen=True)
class BoundedOperationSpec:
operation_id: str
source_type: str
query: str
resolve_before_execute: bool
max_timeout_seconds: int
temporal_max_attempts: int
idempotency: str
credential_route: str
evidence_mode: str
_SPECS = (
BoundedOperationSpec(
operation_id="sbom_nexus_ingest",
source_type="sbom-nexus",
query="catch_up",
resolve_before_execute=True,
max_timeout_seconds=900,
temporal_max_attempts=10,
idempotency="activity-run-id + repository; heartbeat acknowledged outcomes",
credential_route="activity-core-sbom-nexus",
evidence_mode="instruction-report:sbom_catchup",
),
BoundedOperationSpec(
operation_id="forgejo_package_prune",
source_type="shell",
query="forgejo_package_prune",
resolve_before_execute=False,
max_timeout_seconds=900,
temporal_max_attempts=1,
idempotency="single Temporal attempt; operator reconciles ambiguous failure",
credential_route="forgejo-admin-api-token",
evidence_mode="context-evidence-sink",
),
BoundedOperationSpec(
operation_id="cnpg_option_a_backup",
source_type="shell",
query="cnpg_option_a_backup",
resolve_before_execute=False,
max_timeout_seconds=7200,
temporal_max_attempts=1,
idempotency="single Temporal attempt; platform backup receipt is authoritative",
credential_route="railiance-cnpg-option-a-backup",
evidence_mode="context-evidence-sink",
),
)
BOUNDED_OPERATION_REGISTRY = {spec.operation_id: spec for spec in _SPECS}
_BY_SOURCE_QUERY = {(spec.source_type, spec.query): spec for spec in _SPECS}
# The generic ``shell`` adapter is retained only as a compatibility namespace
# for these known read-only queries. Adding a name here requires code review;
# definition data cannot supply an arbitrary command.
READ_ONLY_SHELL_QUERIES = frozenset(
{
"reuse_surface_report_gaps",
"discover_kaizen_scheduled_repos",
"discover_kaizen_projects",
}
)
_CANONICAL_SCRIPT_PATHS = {
"forgejo_package_prune": "/opt/railiance-platform/tools/cmd/forgejo-package-prune",
"cnpg_option_a_backup": "/opt/railiance-platform/tools/cmd/cnpg-option-a-backup",
}
_TARGET_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
def operation_spec_for_source(source: dict[str, Any]) -> BoundedOperationSpec | None:
return _BY_SOURCE_QUERY.get((str(source.get("type") or ""), str(source.get("query") or "")))
def operation_sources(
context_sources: Iterable[dict[str, Any]],
) -> list[tuple[dict[str, Any], BoundedOperationSpec]]:
found: list[tuple[dict[str, Any], BoundedOperationSpec]] = []
for source in context_sources:
spec = operation_spec_for_source(source)
if spec is not None:
found.append((source, spec))
return found
def pending_operation_value(spec: BoundedOperationSpec) -> dict[str, str]:
return {"operation": spec.operation_id, "status": "pending"}
def normalize_operation_result(operation_id: str, raw: Any) -> dict[str, Any]:
"""Project subprocess output to the operation's non-secret evidence shape."""
result = raw if isinstance(raw, dict) else {}
if operation_id == "forgejo_package_prune":
return {
"kind": operation_id,
"apply": bool(result.get("apply")),
"candidate_count": _safe_count(result.get("candidate_count")),
"deleted_count": _safe_count(result.get("deleted_count")),
"skipped_protected_count": _safe_count(
result.get("skipped_protected_count")
),
"error_count": len(result.get("errors") or [])
if isinstance(result.get("errors"), list)
else 0,
}
if operation_id == "cnpg_option_a_backup":
return {
"kind": operation_id,
"overall": str(result.get("overall") or "unknown")[:40],
"dry_run": bool(result.get("dry_run")),
"dumped": _safe_count(result.get("dumped")),
"uploaded": _safe_count(result.get("uploaded")),
"failed": _safe_count(result.get("failed")),
"script_exit_code": _safe_count(result.get("script_exit_code")),
}
raise ValueError(f"no result projection for bounded operation {operation_id!r}")
def _safe_count(value: Any) -> int:
if isinstance(value, bool) or not isinstance(value, int):
return 0
return max(0, value)
def validate_bounded_operations(
context_sources: list[dict[str, Any]],
instructions: list[dict[str, Any]],
) -> None:
"""Fail closed on unknown shell queries and incomplete operation envelopes."""
found = operation_sources(context_sources)
if len(found) > 1:
names = ", ".join(spec.operation_id for _, spec in found)
raise ValueError(
"an ActivityDefinition may declare at most one bounded operation; "
f"found: {names}"
)
for source in context_sources:
source_type = str(source.get("type") or "")
query = str(source.get("query") or "")
spec = operation_spec_for_source(source)
if source_type == "shell" and spec is None and query not in READ_ONLY_SHELL_QUERIES:
raise ValueError(
f"shell query {query!r} is not registered as read-only or as a "
"bounded operation"
)
if spec is None:
if source.get("operation") is not None:
raise ValueError(
f"context source {source_type!r}/{query!r} declares unknown "
f"operation {source.get('operation')!r}"
)
continue
declared = source.get("operation")
if declared != spec.operation_id:
raise ValueError(
f"context source {source_type!r}/{query!r} must declare "
f"operation: {spec.operation_id}"
)
params = source.get("params")
if not isinstance(params, dict):
raise ValueError(f"bounded operation {spec.operation_id} params must be a mapping")
_validate_timeout(spec, params)
if spec.operation_id == "sbom_nexus_ingest":
_validate_sbom(params, instructions)
elif spec.operation_id == "forgejo_package_prune":
_validate_prune(params)
elif spec.operation_id == "cnpg_option_a_backup":
_validate_backup(params)
def _validate_timeout(spec: BoundedOperationSpec, params: dict[str, Any]) -> None:
raw = params.get("timeout_seconds", spec.max_timeout_seconds)
if isinstance(raw, bool) or not isinstance(raw, (int, float)):
raise ValueError(f"bounded operation {spec.operation_id} timeout_seconds must be numeric")
if raw <= 0 or raw > spec.max_timeout_seconds:
raise ValueError(
f"bounded operation {spec.operation_id} timeout_seconds must be in "
f"1..{spec.max_timeout_seconds}"
)
def _explicit_bool(params: dict[str, Any], field: str, operation_id: str) -> bool:
if field not in params or not isinstance(params[field], bool):
raise ValueError(
f"bounded operation {operation_id} must declare boolean {field} explicitly"
)
return bool(params[field])
def _validate_sbom(
params: dict[str, Any],
instructions: list[dict[str, Any]],
) -> None:
_explicit_bool(params, "apply", "sbom_nexus_ingest")
limit = params.get("limit")
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 3:
raise ValueError("bounded operation sbom_nexus_ingest limit must be in 1..3")
if not _has_instruction_evidence(instructions, "sbom_catchup"):
raise ValueError(
"bounded operation sbom_nexus_ingest requires an instruction report "
"sink with event_type: sbom_catchup"
)
def _validate_prune(params: dict[str, Any]) -> None:
apply = _explicit_bool(params, "apply", "forgejo_package_prune")
max_versions = params.get("max_versions")
if (
isinstance(max_versions, bool)
or not isinstance(max_versions, int)
or not 1 <= max_versions <= 10
):
raise ValueError("bounded operation forgejo_package_prune max_versions must be in 1..10")
_validate_canonical_script(params, "prune_script", "forgejo_package_prune")
if apply and not str(params.get("live_images_file") or "").strip():
raise ValueError(
"bounded operation forgejo_package_prune apply=true requires live_images_file"
)
_require_context_evidence(params, "forgejo_package_prune")
def _validate_backup(params: dict[str, Any]) -> None:
_explicit_bool(params, "dry_run", "cnpg_option_a_backup")
_validate_canonical_script(params, "backup_script", "cnpg_option_a_backup")
raw_targets = params.get("targets")
if not isinstance(raw_targets, str):
raise ValueError("bounded operation cnpg_option_a_backup requires explicit targets")
targets = [item.strip() for item in raw_targets.split(",") if item.strip()]
if not targets or len(targets) > 10 or any(_TARGET_RE.fullmatch(item) is None for item in targets):
raise ValueError(
"bounded operation cnpg_option_a_backup targets must contain 1..10 safe names"
)
_require_context_evidence(params, "cnpg_option_a_backup")
def _validate_canonical_script(
params: dict[str, Any], field: str, operation_id: str
) -> None:
expected = _CANONICAL_SCRIPT_PATHS[operation_id]
if params.get(field) != expected:
raise ValueError(
f"bounded operation {operation_id} {field} must be the canonical path {expected!r}"
)
def _require_context_evidence(params: dict[str, Any], operation_id: str) -> None:
sinks = params.get("evidence_sinks")
if not isinstance(sinks, list) or not any(
isinstance(item, dict)
and item.get("type") == "state-hub-progress"
and item.get("event_type") == operation_id
for item in sinks
):
raise ValueError(
f"bounded operation {operation_id} requires a state-hub-progress "
f"evidence sink with event_type: {operation_id}"
)
def _has_instruction_evidence(
instructions: list[dict[str, Any]], event_type: str
) -> bool:
for instruction in instructions:
sinks = instruction.get("report_sinks")
if not isinstance(sinks, list):
continue
if any(
isinstance(sink, dict)
and sink.get("type") == "state-hub-progress"
and sink.get("event_type") == event_type
for sink in sinks
):
return True
return False