Saltar a contenido

ciel.security — Seguridad y aprobaciones

Políticas de aprobación de ejecución de herramientas y redacción de secretos / PII para aislamiento multi-tenant.

Incluye el contrato ApprovalPolicy y sus variantes (ManualApprovalPolicy, TenantIsolationPolicy, SmartApprovalPolicy, YoloApprovalPolicy), los DTOs ApprovalRequest / ApprovalDecision, el constructor from_name, y las utilidades de redacción (PIIScrubber, redact_string, redact_secrets).

ciel.security

_PII_PATTERNS = [re.compile('\\b\\d{7,9}[A-Za-z]\\b'), re.compile('\\b\\d{7,9}\\b'), re.compile('[A-Za-z0-9._%+\\-]+@[A-Za-z0-9.\\-]+\\.[A-Za-z]{2,}'), re.compile('\\+?\\d[\\d\\-\\s()]{7,}\\d')] module-attribute

_SECRET_PATTERNS = [re.compile('(?i)(api[_-]?key|apikey|token|secret|password|pass|pwd)\\s*[:=]\\s*["\\\']?([A-Za-z0-9._\\-\\"]{8,})["\\\']?'), re.compile('(?i)(sk-[A-Za-z0-9]{20,})')] module-attribute

__all__ = ['ApprovalPolicy', 'ApprovalRequest', 'ApprovalDecision', 'SmartApprovalPolicy', 'YoloApprovalPolicy', 'TenantIsolationPolicy', 'ManualApprovalPolicy', 'from_name', 'PIIScrubber', 'redact', 'redact_string', 'redact_secrets'] module-attribute

ApprovalDecision dataclass

Source code in src/ciel/security/__init__.py
@dataclass
class ApprovalDecision:
    request_id: str
    approved: bool
    approver: Optional[str] = None
    note: Optional[str] = None
    tenant: Optional[str] = None

ApprovalPolicy

Source code in src/ciel/security/__init__.py
class ApprovalPolicy:
    mode = "manual"

    def evaluate(self, request: ApprovalRequest) -> ApprovalDecision:
        raise NotImplementedError

ApprovalRequest dataclass

Source code in src/ciel/security/__init__.py
@dataclass
class ApprovalRequest:
    request_id: str
    actor: str
    tool: str
    arguments: Dict[str, Any]
    risk: str
    justification: Optional[str] = None
    tenant: Optional[str] = None

ManualApprovalPolicy

Bases: ApprovalPolicy

Source code in src/ciel/security/__init__.py
class ManualApprovalPolicy(ApprovalPolicy):
    mode = "manual"

    def evaluate(self, request: ApprovalRequest) -> ApprovalDecision:
        return ApprovalDecision(
            request_id=request.request_id,
            approved=False,
            note="manual approval required",
            tenant=request.tenant,
        )

PIIScrubber

Source code in src/ciel/security/redaction.py
class PIIScrubber:
    @staticmethod
    def scrub(value: str) -> str:
        return _scrub_pii(value)

    @staticmethod
    def safe_text(value: str, secrets: Optional[Sequence[str]] = None) -> str:
        cleaned = redact_string(value, secrets=secrets)
        return PIIScrubber.scrub(cleaned)

SmartApprovalPolicy

Bases: ApprovalPolicy

Source code in src/ciel/security/approvals.py
class SmartApprovalPolicy(ApprovalPolicy):
    mode = "smart"
    _safe_tools = {"echo", "read_doc", "list_dir"}
    _medium_risk = {"write_note", "summarize"}
    _high_risk = {"delete_file", "payment:capture", "deploy"}

    def evaluate(self, request: ApprovalRequest) -> ApprovalDecision:
        tool = request.tool
        risk = request.risk or "medium"
        if tool in self._safe_tools or risk == "low":
            return ApprovalDecision(
                request_id=request.request_id,
                approved=True,
                approver="smart-policy",
                note="auto-approved",
                tenant=request.tenant,
            )
        if tool in self._high_risk or risk == "high":
            return ApprovalDecision(
                request_id=request.request_id,
                approved=False,
                approver="smart-policy",
                note="blocked due to high risk",
                tenant=request.tenant,
            )
        return ApprovalDecision(
            request_id=request.request_id,
            approved=False,
            approver="smart-policy",
            note="manual approval required",
            tenant=request.tenant,
        )

TenantIsolationPolicy

Bases: ApprovalPolicy

Source code in src/ciel/security/__init__.py
class TenantIsolationPolicy(ApprovalPolicy):
    mode = "tenant_isolation"

    def evaluate(self, request: ApprovalRequest) -> ApprovalDecision:
        if not request.tenant:
            return ApprovalDecision(
                request_id=request.request_id,
                approved=False,
                note="missing tenant for isolation policy",
                tenant=request.tenant,
            )
        return ApprovalDecision(
            request_id=request.request_id,
            approved=True,
            note="tenant validated",
            tenant=request.tenant,
        )

    @staticmethod
    def validate_decision_tenant(request: ApprovalRequest, decision: ApprovalDecision) -> bool:
        if not request.tenant or not decision.tenant:
            return False
        return request.tenant == decision.tenant

YoloApprovalPolicy

Bases: ApprovalPolicy

Source code in src/ciel/security/approvals.py
class YoloApprovalPolicy(ApprovalPolicy):
    mode = "yolo"

    def evaluate(self, request: ApprovalRequest) -> ApprovalDecision:
        return ApprovalDecision(
            request_id=request.request_id,
            approved=True,
            approver="auto-yolo",
            note="auto-approved by yolo policy",
            tenant=request.tenant,
        )

_scrub_pii(value: str) -> str

Source code in src/ciel/security/redaction.py
def _scrub_pii(value: str) -> str:
    out = value
    for pattern in _PII_PATTERNS:
        out = pattern.sub("[REDACTED]", out)
    return out

from_name(name: str) -> ApprovalPolicy

Return an :class:ApprovalPolicy instance for a policy name.

Supported names: manual, smart, yolo, tenant_isolation. Raises ValueError for unknown policy names so misconfiguration fails loudly instead of silently defaulting.

Source code in src/ciel/security/approvals.py
def from_name(name: str) -> ApprovalPolicy:
    """Return an :class:`ApprovalPolicy` instance for a policy name.

    Supported names: ``manual``, ``smart``, ``yolo``, ``tenant_isolation``.
    Raises ``ValueError`` for unknown policy names so misconfiguration fails
    loudly instead of silently defaulting.
    """
    registry: Dict[str, type] = {
        "manual": ManualApprovalPolicy,
        "smart": SmartApprovalPolicy,
        "yolo": YoloApprovalPolicy,
        "tenant_isolation": TenantIsolationPolicy,
    }
    key = (name or "manual").lower()
    policy_cls = registry.get(key)
    if policy_cls is None:
        raise ValueError(
            f"Unknown approval policy: {name!r} (expected one of {sorted(registry)})"
        )
    return policy_cls()

redact_secrets(value: str, secrets: Optional[Sequence[str]] = None) -> str

Source code in src/ciel/security/redaction.py
def redact_string(value: str, secrets: Optional[Sequence[str]] = None) -> str:
    out = value
    for secret in secrets or []:
        if secret:
            out = out.replace(secret, "[REDACTED]")
    for pattern in _SECRET_PATTERNS:
        out = pattern.sub("[REDACTED]", out)
    return out

redact_string(value: str, secrets: Optional[Sequence[str]] = None) -> str

Source code in src/ciel/security/redaction.py
def redact_string(value: str, secrets: Optional[Sequence[str]] = None) -> str:
    out = value
    for secret in secrets or []:
        if secret:
            out = out.replace(secret, "[REDACTED]")
    for pattern in _SECRET_PATTERNS:
        out = pattern.sub("[REDACTED]", out)
    return out

__all__ = ['ApprovalPolicy', 'ApprovalRequest', 'ApprovalDecision', 'SmartApprovalPolicy', 'TenantIsolationPolicy', 'YoloApprovalPolicy', 'from_name'] module-attribute

ApprovalDecision dataclass

Source code in src/ciel/security/approvals.py
@dataclass
class ApprovalDecision:
    request_id: str
    approved: bool
    approver: Optional[str] = None
    note: Optional[str] = None
    tenant: Optional[str] = None

ApprovalPolicy

Source code in src/ciel/security/approvals.py
class ApprovalPolicy:
    mode = "manual"

    def evaluate(self, request: ApprovalRequest) -> ApprovalDecision:
        raise NotImplementedError

ApprovalRequest dataclass

Source code in src/ciel/security/approvals.py
@dataclass
class ApprovalRequest:
    request_id: str
    actor: str
    tool: str
    arguments: Dict[str, Any]
    risk: str
    justification: Optional[str] = None
    tenant: Optional[str] = None

ManualApprovalPolicy

Bases: ApprovalPolicy

Source code in src/ciel/security/approvals.py
class ManualApprovalPolicy(ApprovalPolicy):
    mode = "manual"

    def evaluate(self, request: ApprovalRequest) -> ApprovalDecision:
        return ApprovalDecision(
            request_id=request.request_id,
            approved=False,
            note="manual approval required",
            tenant=request.tenant,
        )

PIIScrubber

Source code in src/ciel/security/redaction.py
class PIIScrubber:
    @staticmethod
    def scrub(value: str) -> str:
        return _scrub_pii(value)

    @staticmethod
    def safe_text(value: str, secrets: Optional[Sequence[str]] = None) -> str:
        cleaned = redact_string(value, secrets=secrets)
        return PIIScrubber.scrub(cleaned)

SmartApprovalPolicy

Bases: ApprovalPolicy

Source code in src/ciel/security/approvals.py
class SmartApprovalPolicy(ApprovalPolicy):
    mode = "smart"
    _safe_tools = {"echo", "read_doc", "list_dir"}
    _medium_risk = {"write_note", "summarize"}
    _high_risk = {"delete_file", "payment:capture", "deploy"}

    def evaluate(self, request: ApprovalRequest) -> ApprovalDecision:
        tool = request.tool
        risk = request.risk or "medium"
        if tool in self._safe_tools or risk == "low":
            return ApprovalDecision(
                request_id=request.request_id,
                approved=True,
                approver="smart-policy",
                note="auto-approved",
                tenant=request.tenant,
            )
        if tool in self._high_risk or risk == "high":
            return ApprovalDecision(
                request_id=request.request_id,
                approved=False,
                approver="smart-policy",
                note="blocked due to high risk",
                tenant=request.tenant,
            )
        return ApprovalDecision(
            request_id=request.request_id,
            approved=False,
            approver="smart-policy",
            note="manual approval required",
            tenant=request.tenant,
        )

TenantIsolationPolicy

Bases: ApprovalPolicy

Source code in src/ciel/security/approvals.py
class TenantIsolationPolicy(ApprovalPolicy):
    mode = "tenant_isolation"

    def evaluate(self, request: ApprovalRequest) -> ApprovalDecision:
        if not request.tenant:
            return ApprovalDecision(
                request_id=request.request_id,
                approved=False,
                note="missing tenant for isolation policy",
                tenant=request.tenant,
            )
        return ApprovalDecision(
            request_id=request.request_id,
            approved=True,
            note="tenant validated",
            tenant=request.tenant,
        )

    @staticmethod
    def validate_decision_tenant(request: ApprovalRequest, decision: ApprovalDecision) -> bool:
        if not request.tenant or not decision.tenant:
            return False
        return request.tenant == decision.tenant

YoloApprovalPolicy

Bases: ApprovalPolicy

Source code in src/ciel/security/approvals.py
class YoloApprovalPolicy(ApprovalPolicy):
    mode = "yolo"

    def evaluate(self, request: ApprovalRequest) -> ApprovalDecision:
        return ApprovalDecision(
            request_id=request.request_id,
            approved=True,
            approver="auto-yolo",
            note="auto-approved by yolo policy",
            tenant=request.tenant,
        )

from_name(name: str) -> ApprovalPolicy

Return an :class:ApprovalPolicy instance for a policy name.

Supported names: manual, smart, yolo, tenant_isolation. Raises ValueError for unknown policy names so misconfiguration fails loudly instead of silently defaulting.

Source code in src/ciel/security/approvals.py
def from_name(name: str) -> ApprovalPolicy:
    """Return an :class:`ApprovalPolicy` instance for a policy name.

    Supported names: ``manual``, ``smart``, ``yolo``, ``tenant_isolation``.
    Raises ``ValueError`` for unknown policy names so misconfiguration fails
    loudly instead of silently defaulting.
    """
    registry: Dict[str, type] = {
        "manual": ManualApprovalPolicy,
        "smart": SmartApprovalPolicy,
        "yolo": YoloApprovalPolicy,
        "tenant_isolation": TenantIsolationPolicy,
    }
    key = (name or "manual").lower()
    policy_cls = registry.get(key)
    if policy_cls is None:
        raise ValueError(
            f"Unknown approval policy: {name!r} (expected one of {sorted(registry)})"
        )
    return policy_cls()

redact_secrets(value: str, secrets: Optional[Sequence[str]] = None) -> str

Source code in src/ciel/security/redaction.py
def redact_string(value: str, secrets: Optional[Sequence[str]] = None) -> str:
    out = value
    for secret in secrets or []:
        if secret:
            out = out.replace(secret, "[REDACTED]")
    for pattern in _SECRET_PATTERNS:
        out = pattern.sub("[REDACTED]", out)
    return out

_PII_PATTERNS = [re.compile('\\b\\d{7,9}[A-Za-z]\\b'), re.compile('\\b\\d{7,9}\\b'), re.compile('[A-Za-z0-9._%+\\-]+@[A-Za-z0-9.\\-]+\\.[A-Za-z]{2,}'), re.compile('\\+?\\d[\\d\\-\\s()]{7,}\\d')] module-attribute

_SECRET_PATTERNS = [re.compile('(?i)(api[_-]?key|apikey|token|secret|password|pass|pwd)\\s*[:=]\\s*["\\\']?([A-Za-z0-9._\\-\\"]{8,})["\\\']?'), re.compile('(?i)(sk-[A-Za-z0-9]{20,})')] module-attribute

PIIScrubber

Source code in src/ciel/security/redaction.py
class PIIScrubber:
    @staticmethod
    def scrub(value: str) -> str:
        return _scrub_pii(value)

    @staticmethod
    def safe_text(value: str, secrets: Optional[Sequence[str]] = None) -> str:
        cleaned = redact_string(value, secrets=secrets)
        return PIIScrubber.scrub(cleaned)

_scrub_pii(value: str) -> str

Source code in src/ciel/security/redaction.py
def _scrub_pii(value: str) -> str:
    out = value
    for pattern in _PII_PATTERNS:
        out = pattern.sub("[REDACTED]", out)
    return out

redact_secrets(text: str, secrets: Optional[Sequence[str]] = None) -> str

Source code in src/ciel/security/redaction.py
def redact_secrets(text: str, secrets: Optional[Sequence[str]] = None) -> str:
    out = text
    for secret in secrets or []:
        if secret:
            out = out.replace(secret, "[REDACTED]")
    return out

redact_string(value: str, secrets: Optional[Sequence[str]] = None) -> str

Source code in src/ciel/security/redaction.py
def redact_string(value: str, secrets: Optional[Sequence[str]] = None) -> str:
    out = value
    for secret in secrets or []:
        if secret:
            out = out.replace(secret, "[REDACTED]")
    for pattern in _SECRET_PATTERNS:
        out = pattern.sub("[REDACTED]", out)
    return out

Sandbox de ejecución (v0.9)

Guardrails y sandbox de ejecución de código del agente. Incluye SandboxExecutor con backends seleccionables (INPROCESS, LIGHT, DOCKER, GVISOR; degradación graceful a INPROCESS cuando el backend fuerte no está disponible), SandboxLimits / ExecResult, GuardrailMiddleware (rate-limit por tenant + redacción de salida + truncado) y SandboxContext (política SandboxPolicy sobre capacidades terminal/file, respaldada por ejecución real).

Sandbox de ejecución de código del agente (Fase 15, offline-safe).

Dos capas:

  • Guardrails / política (heredado de Fase anterior): :class:SandboxPolicy y :class:SandboxContext deciden qué capacidades (terminal/file) se permiten.
  • Ejecución aislada (Fase 15): :class:SandboxExecutor corre comandos con distintos niveles de aislamiento:

  • INPROCESS (default, cross-platform): subprocess con timeout. Es el fallback universal y reemplaza los antiguos stubs.

  • LIGHT (Linux): subprocess + setrlimit (CPU/mem) — deshabilitado en Windows, degrada a INPROCESS.
  • DOCKER (opt-in): contenedor efímero con red desactivada y límites de cpu/mem; degrada a INPROCESS si Docker no está disponible.
  • GVISOR (opt-in, Linux): igual que Docker con runtime runsc; degrada a Docker y luego a INPROCESS.

El default SIEMPRE es offline-safe: sin Docker/red/deps externas. Todo backend no disponible degrada con un log y nunca rompe el runtime.

__all__ = ['SandboxPolicy', 'SandboxBlockedError', 'SandboxContext', 'SandboxBackend', 'SandboxLimits', 'ExecResult', 'SandboxExecutor', 'GuardrailMiddleware'] module-attribute

logger = logging.getLogger('ciel.sandbox') module-attribute

ExecResult dataclass

Resultado de una ejecución en el sandbox.

Source code in src/ciel/sandbox/__init__.py
@dataclass
class ExecResult:
    """Resultado de una ejecución en el sandbox."""

    stdout: str = ""
    stderr: str = ""
    exit_code: int = 0
    timed_out: bool = False
    backend: str = SandboxBackend.INPROCESS.value
    duration_ms: int = 0
    limits_applied: bool = False

GuardrailMiddleware

Envoltura de guardrails para la ejecución de herramientas.

Reutiliza :class:TenantRateLimiter (rate-limit por tenant) y :func:redact_string (redacción de secretos en la salida). También trunca salidas excesivamente largas. Todos los componentes son opcionales.

Source code in src/ciel/sandbox/__init__.py
class GuardrailMiddleware:
    """Envoltura de guardrails para la ejecución de herramientas.

    Reutiliza :class:`TenantRateLimiter` (rate-limit por tenant) y
    :func:`redact_string` (redacción de secretos en la salida). También trunca
    salidas excesivamente largas. Todos los componentes son opcionales.
    """

    def __init__(
        self,
        *,
        rate_limiter=None,
        redact_output: bool = True,
        max_output_chars: int = 100_000,
        secrets=None,
    ) -> None:
        self.rate_limiter = rate_limiter
        self.redact_output = redact_output
        self.max_output_chars = max_output_chars
        self.secrets = list(secrets or [])

    def before(self, *, tenant_id: Optional[str] = None, user: Optional[str] = None) -> None:
        """Aplica rate-limit antes de ejecutar (lanza ``RateLimitError``)."""
        if self.rate_limiter is not None:
            self.rate_limiter.consume(tenant_id=tenant_id, user=user)

    def after_output(self, output: Any) -> Any:
        """Redacta y trunca la salida de una herramienta."""
        if not isinstance(output, str):
            return output
        text = output
        if self.redact_output:
            from ciel.security.redaction import redact_string

            text = redact_string(text, self.secrets)
        if self.max_output_chars and len(text) > self.max_output_chars:
            text = text[: self.max_output_chars] + "\n[...truncated...]"
        return text

after_output(output: Any) -> Any

Redacta y trunca la salida de una herramienta.

Source code in src/ciel/sandbox/__init__.py
def after_output(self, output: Any) -> Any:
    """Redacta y trunca la salida de una herramienta."""
    if not isinstance(output, str):
        return output
    text = output
    if self.redact_output:
        from ciel.security.redaction import redact_string

        text = redact_string(text, self.secrets)
    if self.max_output_chars and len(text) > self.max_output_chars:
        text = text[: self.max_output_chars] + "\n[...truncated...]"
    return text

before(*, tenant_id: Optional[str] = None, user: Optional[str] = None) -> None

Aplica rate-limit antes de ejecutar (lanza RateLimitError).

Source code in src/ciel/sandbox/__init__.py
def before(self, *, tenant_id: Optional[str] = None, user: Optional[str] = None) -> None:
    """Aplica rate-limit antes de ejecutar (lanza ``RateLimitError``)."""
    if self.rate_limiter is not None:
        self.rate_limiter.consume(tenant_id=tenant_id, user=user)

SandboxBackend

Bases: str, Enum

Source code in src/ciel/sandbox/__init__.py
class SandboxBackend(str, Enum):
    INPROCESS = "inprocess"
    LIGHT = "light"
    DOCKER = "docker"
    GVISOR = "gvisor"

SandboxBlockedError

Bases: Exception

Source code in src/ciel/sandbox/__init__.py
class SandboxBlockedError(Exception):
    def __init__(self, capability: str, reason: str = "denied by policy"):
        self.capability = capability
        self.reason = reason
        super().__init__(f"{capability} {reason}")

SandboxContext dataclass

Source code in src/ciel/sandbox/__init__.py
@dataclass
class SandboxContext:
    policy: Optional[SandboxPolicy] = None
    executor: Optional[SandboxExecutor] = None

    def __post_init__(self) -> None:
        if self.policy is None:
            self.policy = SandboxPolicy()
        if self.executor is None:
            self.executor = SandboxExecutor()

    def evaluate(self, capability: str, command: Optional[str] = None) -> bool:
        if capability == "terminal":
            if not self.policy.allow_terminal:
                return False
            if command:
                if self.policy.denied_commands and command in self.policy.denied_commands:
                    return False
                if self.policy.allowed_commands and command not in self.policy.allowed_commands:
                    return False
            return True
        if capability == "file_write":
            return self.policy.allow_file_write
        if capability == "file_read":
            return self.policy.allow_file_read
        return False

    def execute(self, command: str, arguments: Optional[Dict[str, Any]] = None) -> str:
        arguments = arguments or {}
        if not self.evaluate("terminal", command=command):
            raise SandboxBlockedError("terminal", f"command '{command}' denied")
        # Ejecución REAL vía el executor (reemplaza el antiguo stub).
        full = command
        args = arguments.get("args")
        if args:
            full = command + " " + (args if isinstance(args, str) else " ".join(map(str, args)))
        result = self.executor.run(full)
        if result.exit_code != 0 and result.stderr:
            return result.stderr
        return result.stdout

    def write_file(self, path: str, content: str) -> str:
        if not self.evaluate("file_write"):
            raise SandboxBlockedError("file_write", f"write to '{path}' denied")
        from pathlib import Path

        p = Path(path)
        p.parent.mkdir(parents=True, exist_ok=True)
        p.write_text(content, encoding="utf-8")
        return f"wrote {len(content)} bytes to {path}"

    def read_file(self, path: str) -> str:
        if not self.evaluate("file_read"):
            raise SandboxBlockedError("file_read", f"read from '{path}' denied")
        from pathlib import Path

        p = Path(path)
        if not p.is_file():
            raise FileNotFoundError(path)
        return p.read_text(encoding="utf-8")

SandboxExecutor

Ejecutor de comandos con aislamiento seleccionable.

Se construye con un backend deseado; si no está disponible, degrada (con log) al siguiente backend más seguro disponible hasta INPROCESS, que siempre existe.

Source code in src/ciel/sandbox/__init__.py
class SandboxExecutor:
    """Ejecutor de comandos con aislamiento seleccionable.

    Se construye con un ``backend`` deseado; si no está disponible, degrada
    (con log) al siguiente backend más seguro disponible hasta ``INPROCESS``,
    que siempre existe.
    """

    def __init__(
        self,
        *,
        backend: SandboxBackend = SandboxBackend.INPROCESS,
        limits: Optional[SandboxLimits] = None,
        docker_image: str = "python:3.11-slim",
        workdir: Optional[str] = None,
    ) -> None:
        self.requested_backend = SandboxBackend(backend)
        self.limits = limits or SandboxLimits()
        self.docker_image = docker_image
        self.workdir = workdir
        self.backend = self._resolve_backend(self.requested_backend)

    def _resolve_backend(self, backend: SandboxBackend) -> SandboxBackend:
        if backend == SandboxBackend.GVISOR:
            if _gvisor_available():
                return SandboxBackend.GVISOR
            logger.warning("gVisor no disponible; degradando a docker/inprocess")
            backend = SandboxBackend.DOCKER
        if backend == SandboxBackend.DOCKER:
            if _docker_available(self.docker_image):
                return SandboxBackend.DOCKER
            logger.warning("Docker no disponible; degradando a inprocess")
            return SandboxBackend.INPROCESS
        if backend == SandboxBackend.LIGHT:
            if sys.platform.startswith("win"):
                logger.warning("backend 'light' no soportado en Windows; degradando a inprocess")
                return SandboxBackend.INPROCESS
            return SandboxBackend.LIGHT
        return SandboxBackend.INPROCESS

    # -- ejecución ----------------------------------------------------------
    def run(self, command, *, stdin: Optional[str] = None) -> ExecResult:
        """Ejecuta ``command`` (lista o str) y devuelve un :class:`ExecResult`."""
        start = time.monotonic()
        if self.backend == SandboxBackend.DOCKER:
            result = self._run_docker(command, stdin, runtime=None)
        elif self.backend == SandboxBackend.GVISOR:
            result = self._run_docker(command, stdin, runtime="runsc")
        elif self.backend == SandboxBackend.LIGHT:
            result = self._run_light(command, stdin)
        else:
            result = self._run_inprocess(command, stdin)
        result.duration_ms = int((time.monotonic() - start) * 1000)
        result.backend = self.backend.value
        return result

    def _as_list(self, command):
        if isinstance(command, (list, tuple)):
            return list(command)
        # str: en Windows sin shell, usar split simple; el caller controla input.
        import shlex

        return shlex.split(command, posix=not sys.platform.startswith("win"))

    def _run_inprocess(self, command, stdin) -> ExecResult:
        args = self._as_list(command)
        try:
            proc = subprocess.run(
                args,
                capture_output=True,
                text=True,
                timeout=self.limits.timeout_s,
                input=stdin,
                cwd=self.workdir,
            )
            return ExecResult(
                stdout=proc.stdout or "",
                stderr=proc.stderr or "",
                exit_code=proc.returncode,
                limits_applied=False,
            )
        except subprocess.TimeoutExpired as exc:
            return ExecResult(
                stdout=(exc.stdout or b"").decode() if isinstance(exc.stdout, bytes) else (exc.stdout or ""),
                stderr="timeout",
                exit_code=124,
                timed_out=True,
                limits_applied=False,
            )
        except FileNotFoundError as exc:
            return ExecResult(stderr=str(exc), exit_code=127)

    def _run_light(self, command, stdin) -> ExecResult:
        import resource  # Linux-only

        args = self._as_list(command)
        limits = self.limits

        def _preexec():
            if limits.cpu_seconds:
                resource.setrlimit(resource.RLIMIT_CPU, (limits.cpu_seconds, limits.cpu_seconds))
            if limits.memory_mb:
                nbytes = limits.memory_mb * 1024 * 1024
                resource.setrlimit(resource.RLIMIT_AS, (nbytes, nbytes))

        try:
            proc = subprocess.run(
                args,
                capture_output=True,
                text=True,
                timeout=limits.timeout_s,
                input=stdin,
                cwd=self.workdir,
                preexec_fn=_preexec,
            )
            return ExecResult(
                stdout=proc.stdout or "",
                stderr=proc.stderr or "",
                exit_code=proc.returncode,
                limits_applied=True,
            )
        except subprocess.TimeoutExpired:
            return ExecResult(stderr="timeout", exit_code=124, timed_out=True, limits_applied=True)
        except FileNotFoundError as exc:
            return ExecResult(stderr=str(exc), exit_code=127, limits_applied=True)

    def _run_docker(self, command, stdin, *, runtime: Optional[str]) -> ExecResult:
        args = self._as_list(command)
        limits = self.limits
        docker_cmd = [
            "docker", "run", "--rm", "-i",
            "--network", "none" if not limits.network else "bridge",
            "--read-only",
            "--cap-drop", "ALL",
            "--security-opt", "no-new-privileges",
        ]
        if limits.memory_mb:
            docker_cmd += ["--memory", f"{limits.memory_mb}m"]
        if limits.pids:
            docker_cmd += ["--pids-limit", str(limits.pids)]
        if runtime:
            docker_cmd += ["--runtime", runtime]
        docker_cmd += [self.docker_image, *args]
        try:
            proc = subprocess.run(
                docker_cmd,
                capture_output=True,
                text=True,
                timeout=limits.timeout_s,
                input=stdin,
            )
            return ExecResult(
                stdout=proc.stdout or "",
                stderr=proc.stderr or "",
                exit_code=proc.returncode,
                limits_applied=True,
            )
        except subprocess.TimeoutExpired:
            return ExecResult(stderr="timeout", exit_code=124, timed_out=True, limits_applied=True)
        except FileNotFoundError as exc:
            return ExecResult(stderr=str(exc), exit_code=127, limits_applied=True)

run(command, *, stdin: Optional[str] = None) -> ExecResult

Ejecuta command (lista o str) y devuelve un :class:ExecResult.

Source code in src/ciel/sandbox/__init__.py
def run(self, command, *, stdin: Optional[str] = None) -> ExecResult:
    """Ejecuta ``command`` (lista o str) y devuelve un :class:`ExecResult`."""
    start = time.monotonic()
    if self.backend == SandboxBackend.DOCKER:
        result = self._run_docker(command, stdin, runtime=None)
    elif self.backend == SandboxBackend.GVISOR:
        result = self._run_docker(command, stdin, runtime="runsc")
    elif self.backend == SandboxBackend.LIGHT:
        result = self._run_light(command, stdin)
    else:
        result = self._run_inprocess(command, stdin)
    result.duration_ms = int((time.monotonic() - start) * 1000)
    result.backend = self.backend.value
    return result

SandboxLimits dataclass

Límites de recursos aplicados al comando (best-effort por backend).

Source code in src/ciel/sandbox/__init__.py
@dataclass
class SandboxLimits:
    """Límites de recursos aplicados al comando (best-effort por backend)."""

    timeout_s: float = 30.0
    cpu_seconds: Optional[int] = None
    memory_mb: Optional[int] = None
    pids: Optional[int] = 128
    network: bool = False  # False => sin red (aislamiento por defecto)

SandboxPolicy dataclass

Source code in src/ciel/sandbox/__init__.py
@dataclass
class SandboxPolicy:
    allow_file_read: bool = True
    allow_file_write: bool = False
    allow_terminal: bool = False
    allowed_commands: set[str] = field(default_factory=set)
    denied_commands: set[str] = field(default_factory=set)

_docker_available(image: Optional[str] = None) -> bool

Source code in src/ciel/sandbox/__init__.py
def _docker_available(image: Optional[str] = None) -> bool:
    if shutil.which("docker") is None:
        return False
    try:
        res = subprocess.run(
            ["docker", "info"],
            capture_output=True,
            timeout=8,
        )
        return res.returncode == 0
    except Exception:
        return False

_gvisor_available() -> bool

Source code in src/ciel/sandbox/__init__.py
def _gvisor_available() -> bool:
    if sys.platform.startswith("win"):
        return False
    if shutil.which("runsc") is None:
        return False
    return _docker_available()