diff --git a/src/automation/__init__.py b/src/automation/__init__.py index 915c1c8..703e853 100644 --- a/src/automation/__init__.py +++ b/src/automation/__init__.py @@ -31,6 +31,7 @@ from .engine import ( AutomationDefinition, AutomationEngine, + EngineNotStartedError, RunResult, RunStatus, TriggerEvent, @@ -48,6 +49,10 @@ GeneratorVariable, VariableRegistry, ) +from .guardrails import ( + DEFAULT_TEXT_EXTENSIONS, + TextFileGuardrail, +) __all__ = [ # Engine @@ -56,6 +61,7 @@ "TriggerEvent", "RunResult", "RunStatus", + "EngineNotStartedError", "default_engine", "trigger", # Components @@ -67,4 +73,7 @@ # Variables "GeneratorVariable", "VariableRegistry", + # Guardrails + "DEFAULT_TEXT_EXTENSIONS", + "TextFileGuardrail", ] diff --git a/src/automation/__pycache__/__init__.cpython-312.pyc b/src/automation/__pycache__/__init__.cpython-312.pyc index fbd9601..f646cb4 100644 Binary files a/src/automation/__pycache__/__init__.cpython-312.pyc and b/src/automation/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/automation/__pycache__/components.cpython-312.pyc b/src/automation/__pycache__/components.cpython-312.pyc index 3a8789d..03008df 100644 Binary files a/src/automation/__pycache__/components.cpython-312.pyc and b/src/automation/__pycache__/components.cpython-312.pyc differ diff --git a/src/automation/__pycache__/engine.cpython-312.pyc b/src/automation/__pycache__/engine.cpython-312.pyc index c5802b8..d83cac8 100644 Binary files a/src/automation/__pycache__/engine.cpython-312.pyc and b/src/automation/__pycache__/engine.cpython-312.pyc differ diff --git a/src/automation/__pycache__/variables.cpython-312.pyc b/src/automation/__pycache__/variables.cpython-312.pyc index 24d37b5..303be42 100644 Binary files a/src/automation/__pycache__/variables.cpython-312.pyc and b/src/automation/__pycache__/variables.cpython-312.pyc differ diff --git a/src/automation/engine.py b/src/automation/engine.py index a62a526..b973f3c 100644 --- a/src/automation/engine.py +++ b/src/automation/engine.py @@ -56,6 +56,10 @@ class RunStatus(Enum): SKIPPED = "skipped" +class EngineNotStartedError(RuntimeError): + """Raised when :meth:`AutomationEngine.trigger` is called before :meth:`start`.""" + + @dataclass class TriggerEvent: """Represents an event that can trigger an automation run.""" @@ -171,11 +175,54 @@ def __init__(self) -> None: self._automations: Dict[str, AutomationDefinition] = {} self._history: List[RunResult] = [] + self._running: bool = False # Middleware hooks: callables invoked before/after each run self._before_run: List[Callable[[RunResult, TriggerEvent], None]] = [] self._after_run: List[Callable[[RunResult], None]] = [] + # ------------------------------------------------------------------ + # Lifecycle: start / stop + # ------------------------------------------------------------------ + + def start(self) -> "AutomationEngine": + """ + Start the engine, allowing automation runs. + + Must be called before :meth:`trigger` / :meth:`trigger_type`. + Calling :meth:`start` on an already-running engine is a no-op. + Returns *self* for chaining. + """ + if not self._running: + self._running = True + logger.info("AutomationEngine started") + return self + + def stop(self) -> "AutomationEngine": + """ + Stop the engine and tear down all registered components. + + After :meth:`stop` returns, :meth:`trigger` / :meth:`trigger_type` + will raise :class:`EngineNotStartedError` until :meth:`start` is + called again. All component teardown hooks are invoked; any + exceptions they raise are logged and suppressed. + Returns *self* for chaining. + """ + if self._running: + self._running = False + for name in list(self.components.names()): + try: + self.components.deregister(name) + except Exception: + logger.exception("teardown of component %r raised an exception", name) + logger.info("AutomationEngine stopped") + return self + + @property + def is_running(self) -> bool: + """``True`` while the engine is between :meth:`start` and :meth:`stop`.""" + return self._running + # ------------------------------------------------------------------ # Component & variable shortcuts # ------------------------------------------------------------------ @@ -247,9 +294,19 @@ def trigger(self, event: TriggerEvent) -> List[RunResult]: """ Dispatch *event* to all matching, enabled automations. + Raises: + EngineNotStartedError: if the engine has not been started via + :meth:`start`. + Returns the list of :class:`RunResult` objects produced (one per matching automation). """ + if not self._running: + raise EngineNotStartedError( + "AutomationEngine must be started before triggering events. " + "Call engine.start() first." + ) + results: List[RunResult] = [] for automation in self._automations.values(): @@ -391,7 +448,10 @@ def __repr__(self) -> str: # --------------------------------------------------------------------------- #: Default engine instance – use this for simple single-engine setups. +#: Pre-started for convenience; create a fresh :class:`AutomationEngine` and +#: call :meth:`~AutomationEngine.start` explicitly for production use. default_engine = AutomationEngine() +default_engine.start() def trigger(event_type: str, payload: Any = None, **metadata: Any) -> List[RunResult]: diff --git a/src/automation/guardrails.py b/src/automation/guardrails.py new file mode 100644 index 0000000..7e71d96 --- /dev/null +++ b/src/automation/guardrails.py @@ -0,0 +1,132 @@ +""" +Built-in guardrail components for the serverless automation engine. + +Guardrails are :class:`~automation.components.Component` subclasses that act as +safety gates in a pipeline. They validate their input and return an error +output (without raising) when the input violates a policy, letting the engine +record the failure and invoke after-run hooks as normal. + +Included guardrails +------------------- +* :class:`TextFileGuardrail` – validates payloads that reference text-based + file paths (any extension in :data:`DEFAULT_TEXT_EXTENSIONS`, including + ``.txt``, ``.log``, ``.csv``, etc.), blocking path-traversal attacks, null + bytes, absolute paths, and disallowed file extensions. +""" + +from __future__ import annotations + +import os +from typing import FrozenSet + +from .components import Component, ComponentInput, ComponentOutput + +# --------------------------------------------------------------------------- +# Allowed text-file extensions +# --------------------------------------------------------------------------- + +#: Extensions that are treated as significant text files and subjected to +#: guardrail checks when a payload string ends with one of these suffixes. +#: The set is intentionally conservative; extend via subclassing or by +#: passing *allowed_extensions* to :class:`TextFileGuardrail`. +DEFAULT_TEXT_EXTENSIONS: FrozenSet[str] = frozenset( + {".txt", ".text", ".log", ".csv", ".tsv", ".md", ".rst", ".ini", ".cfg"} +) + + +# --------------------------------------------------------------------------- +# TextFileGuardrail +# --------------------------------------------------------------------------- + +class TextFileGuardrail(Component): + """ + Safety guardrail for text-file path payloads. + + When this component is included as a step in an automation pipeline it + inspects the current ``payload`` (expected to be a file path string) and + rejects it if any of the following conditions are true: + + * The payload is not a string. + * The file extension is not in *allowed_extensions*. + * The path contains ``..`` (directory traversal). + * The path contains a null byte. + * The path is an absolute path (configurable via *allow_absolute*). + + On success the payload is passed through unchanged so that subsequent + pipeline steps can continue processing it. + + Args: + allowed_extensions: Set of lowercase file extensions (including the + leading dot) that are permitted. Defaults to + :data:`DEFAULT_TEXT_EXTENSIONS`. + allow_absolute: When ``False`` (default) absolute paths are rejected. + + Example:: + + engine = AutomationEngine() + engine.start() + engine.register_fn("read_file", lambda inp: open(inp.payload).read()) + engine.components.register("guard", TextFileGuardrail()) + + engine.define(AutomationDefinition( + name="safe_read", + triggers=["file.read"], + steps=["guard", "read_file"], + )) + + results = engine.trigger_type("file.read", payload="notes.txt") + """ + + name = "text_file_guardrail" + description = ( + "Validates text-file path payloads: rejects path traversal, null bytes, " + "absolute paths, and disallowed extensions." + ) + + def __init__( + self, + allowed_extensions: FrozenSet[str] = DEFAULT_TEXT_EXTENSIONS, + allow_absolute: bool = False, + ) -> None: + self._allowed_extensions = frozenset(ext.lower() for ext in allowed_extensions) + self._allow_absolute = allow_absolute + + def validate(self, input_: ComponentInput) -> tuple[bool, str]: + payload = input_.payload + + if not isinstance(payload, str): + return False, ( + f"TextFileGuardrail: payload must be a string file path, " + f"got {type(payload).__name__!r}" + ) + + if "\x00" in payload: + return False, "TextFileGuardrail: payload contains a null byte" + + if not self._allow_absolute and os.path.isabs(payload): + return False, ( + "TextFileGuardrail: absolute paths are not permitted; " + f"got {payload!r}" + ) + + # Normalise separators then check for traversal components + normalised = payload.replace("\\", "/") + parts = normalised.split("/") + if ".." in parts: + return False, ( + "TextFileGuardrail: path traversal ('..') detected in " + f"{payload!r}" + ) + + _, ext = os.path.splitext(payload) + if ext.lower() not in self._allowed_extensions: + return False, ( + f"TextFileGuardrail: file extension {ext!r} is not in the " + f"allowed set {sorted(self._allowed_extensions)}" + ) + + return True, "" + + def execute(self, input_: ComponentInput) -> ComponentOutput: + """Pass the validated payload through unchanged.""" + return self._ok(input_.payload) diff --git a/tests/test_automation_review_regressions.py b/tests/test_automation_review_regressions.py index 844291f..1fa18d3 100644 --- a/tests/test_automation_review_regressions.py +++ b/tests/test_automation_review_regressions.py @@ -1,6 +1,10 @@ +import pytest + from automation import __doc__ as automation_doc +from automation import EngineNotStartedError from automation.components import Component, ComponentInput, ComponentRegistry, FunctionComponent from automation.engine import AutomationDefinition, AutomationEngine, RunStatus +from automation.guardrails import DEFAULT_TEXT_EXTENSIONS, TextFileGuardrail from automation.variables import GeneratorVariable @@ -54,6 +58,7 @@ def broken(input_): def test_missing_variables_fail_run_instead_of_being_silently_dropped(): engine = AutomationEngine() + engine.start() engine.register_fn("echo", lambda input_: input_.payload) engine.define( AutomationDefinition( @@ -81,3 +86,179 @@ def test_quick_start_uses_package_imports(): assert automation_doc.strip() assert "from automation import AutomationEngine" in automation_doc assert "from src.automation import" not in automation_doc + + +# --------------------------------------------------------------------------- +# Lifecycle guardrail: start / stop +# --------------------------------------------------------------------------- + +def test_trigger_before_start_raises_engine_not_started_error(): + engine = AutomationEngine() + engine.register_fn("echo", lambda inp: inp.payload) + engine.define(AutomationDefinition(name="a", triggers=["x"], steps=["echo"])) + + with pytest.raises(EngineNotStartedError, match="start\\(\\)"): + engine.trigger_type("x") + + +def test_trigger_after_start_succeeds(): + engine = AutomationEngine() + engine.start() + engine.register_fn("echo", lambda inp: inp.payload) + engine.define(AutomationDefinition(name="a", triggers=["x"], steps=["echo"])) + + results = engine.trigger_type("x", payload="hello") + assert len(results) == 1 + assert results[0].status is RunStatus.SUCCESS + + +def test_trigger_after_stop_raises_engine_not_started_error(): + engine = AutomationEngine() + engine.start() + engine.stop() + + with pytest.raises(EngineNotStartedError): + engine.trigger_type("x") + + +def test_start_is_idempotent(): + engine = AutomationEngine() + engine.start() + engine.start() # second call must not raise + assert engine.is_running is True + + +def test_stop_calls_component_teardown(): + events = [] + + class TrackedComponent(Component): + def teardown(self): + events.append("teardown") + + def execute(self, input_: ComponentInput): + return self._ok(input_.payload) + + engine = AutomationEngine() + engine.start() + engine.components.register("tracked", TrackedComponent()) + engine.stop() + + assert "teardown" in events + assert engine.is_running is False + + +def test_restart_after_stop_works(): + engine = AutomationEngine() + engine.start() + engine.stop() + engine.start() + engine.register_fn("echo", lambda inp: inp.payload) + engine.define(AutomationDefinition(name="a", triggers=["x"], steps=["echo"])) + + results = engine.trigger_type("x", payload="hi") + assert results[0].status is RunStatus.SUCCESS + + +# --------------------------------------------------------------------------- +# TextFileGuardrail +# --------------------------------------------------------------------------- + +def test_text_file_guardrail_passes_valid_txt(): + guardrail = TextFileGuardrail() + inp = ComponentInput(name="guard", payload="notes.txt") + valid, reason = guardrail.validate(inp) + assert valid is True + assert reason == "" + + +def test_text_file_guardrail_passes_through_payload_on_execute(): + guardrail = TextFileGuardrail() + inp = ComponentInput(name="guard", payload="report.csv") + out = guardrail.execute(inp) + assert out.success is True + assert out.result == "report.csv" + + +def test_text_file_guardrail_rejects_path_traversal(): + guardrail = TextFileGuardrail() + for path in ["../secrets.txt", "a/../../etc/passwd.txt", "..\\config.txt"]: + inp = ComponentInput(name="guard", payload=path) + valid, reason = guardrail.validate(inp) + assert valid is False, f"Expected rejection for {path!r}" + assert "traversal" in reason.lower() or ".." in reason + + +def test_text_file_guardrail_rejects_disallowed_extension(): + guardrail = TextFileGuardrail() + inp = ComponentInput(name="guard", payload="script.exe") + valid, reason = guardrail.validate(inp) + assert valid is False + assert "extension" in reason.lower() + + +def test_text_file_guardrail_rejects_absolute_path(): + guardrail = TextFileGuardrail(allow_absolute=False) + inp = ComponentInput(name="guard", payload="/etc/hosts.txt") + valid, reason = guardrail.validate(inp) + assert valid is False + assert "absolute" in reason.lower() + + +def test_text_file_guardrail_allows_absolute_when_configured(): + guardrail = TextFileGuardrail(allow_absolute=True) + inp = ComponentInput(name="guard", payload="/var/log/app.log") + valid, reason = guardrail.validate(inp) + assert valid is True + + +def test_text_file_guardrail_rejects_null_byte(): + guardrail = TextFileGuardrail() + inp = ComponentInput(name="guard", payload="file\x00.txt") + valid, reason = guardrail.validate(inp) + assert valid is False + assert "null" in reason.lower() + + +def test_text_file_guardrail_rejects_non_string_payload(): + guardrail = TextFileGuardrail() + inp = ComponentInput(name="guard", payload=12345) + valid, reason = guardrail.validate(inp) + assert valid is False + assert "string" in reason.lower() + + +def test_text_file_guardrail_in_pipeline_blocks_bad_payload(): + engine = AutomationEngine() + engine.start() + engine.components.register("guard", TextFileGuardrail()) + engine.register_fn("process", lambda inp: f"processed:{inp.payload}") + engine.define(AutomationDefinition( + name="safe_process", + triggers=["file.process"], + steps=["guard", "process"], + )) + + results = engine.trigger_type("file.process", payload="../bad.txt") + assert len(results) == 1 + assert results[0].status is RunStatus.FAILED + + +def test_text_file_guardrail_in_pipeline_passes_valid_payload(): + engine = AutomationEngine() + engine.start() + engine.components.register("guard", TextFileGuardrail()) + engine.register_fn("process", lambda inp: f"processed:{inp.payload}") + engine.define(AutomationDefinition( + name="safe_process", + triggers=["file.process"], + steps=["guard", "process"], + )) + + results = engine.trigger_type("file.process", payload="data.txt") + assert len(results) == 1 + assert results[0].status is RunStatus.SUCCESS + + +def test_default_text_extensions_contains_txt(): + assert ".txt" in DEFAULT_TEXT_EXTENSIONS +