Skip to content

Commit 8ed8def

Browse files
authored
Merge pull request #222 from NOAA-GSL/staging
Security Patches
2 parents dfa1d41 + 417a4c8 commit 8ed8def

File tree

7 files changed

+874
-579
lines changed

7 files changed

+874
-579
lines changed

poetry.lock

Lines changed: 676 additions & 558 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "zyra"
3-
version = "0.1.43"
3+
version = "0.1.44"
44
description = "A tool to ingest data from various sources and formats, create imagery or video based on that data, and send the results to various locations for dissemination."
55
authors = ["Eric Hackathorn <eric.j.hackathorn@noaa.gov>"]
66
include = [
@@ -34,13 +34,13 @@ pyyaml = "^6.0.2"
3434
numpy = "^1.26"
3535
fastapi = { version = ">=0.120.2,<0.122.0", optional = true }
3636
uvicorn = { version = "^0.30.0", optional = true }
37-
python-multipart = { version = ">=0.0.18", optional = true }
37+
python-multipart = { version = ">=0.0.22", optional = true }
3838
redis = { version = "^5.0.0", optional = true }
3939
rq = { version = "^1.15.1", optional = true }
4040
python-magic = { version = "^0.4.27", optional = true }
4141
websockets = { version = "^11.0.3", optional = true }
4242
prompt_toolkit = { version = "^3.0.50", optional = true }
43-
guardrails-ai = { version = "^0.5.0", optional = true }
43+
guardrails-ai = { version = "^0.8.0", optional = true }
4444
google-auth = { version = "^2.29.0", optional = true }
4545

4646
# Optional feature dependencies (installed via extras)

src/zyra/pipeline_runner.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -393,17 +393,28 @@ def flush(self): # type: ignore[override]
393393
sys.stdout = old_stdout
394394

395395

396+
_DEFAULT_SUBPROCESS_TIMEOUT = 120
397+
398+
396399
def _run_cli_subprocess(
397400
argv: list[str], input_bytes: bytes | None
398401
) -> tuple[int, bytes, str]:
399402
import subprocess
400403
import sys
401404

402-
proc = subprocess.run(
403-
[sys.executable, "-m", "zyra.cli", *argv],
404-
input=input_bytes or b"",
405-
capture_output=True,
405+
timeout = int(
406+
os.getenv("ZYRA_CLI_SUBPROCESS_TIMEOUT", str(_DEFAULT_SUBPROCESS_TIMEOUT))
407+
or _DEFAULT_SUBPROCESS_TIMEOUT
406408
)
409+
try:
410+
proc = subprocess.run(
411+
[sys.executable, "-m", "zyra.cli", *argv],
412+
input=input_bytes or b"",
413+
capture_output=True,
414+
timeout=timeout,
415+
)
416+
except subprocess.TimeoutExpired:
417+
return 2, b"", f"subprocess timed out after {timeout}s"
407418
stderr = (proc.stderr or b"").decode("utf-8", errors="ignore")
408419
return int(proc.returncode), proc.stdout or b"", stderr
409420

src/zyra/swarm/guardrails.py

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ def _load_guard(self):
5050
if self._guard:
5151
return self._guard
5252
path = Path(self.schema_path)
53-
self._guard = _guardrails.Guard.from_rail(str(path)) # type: ignore[attr-defined]
53+
self._guard = _guardrails.Guard.for_rail(str(path)) # type: ignore[attr-defined]
5454
return self._guard
5555

5656
def validate(
@@ -61,25 +61,28 @@ def validate(
6161
for key, value in outputs.items():
6262
raw = value if isinstance(value, str) else json.dumps(value)
6363
try:
64-
# guard.parse returns the validated structure (string or dict)
65-
result = guard.parse(raw)
64+
# guard.validate returns a ValidationOutcome with
65+
# .validation_passed (bool) and .validated_output
66+
result = guard.validate(raw)
6667
except Exception as exc:
6768
msg = f"guardrails validation failed for {agent.spec.id}:{key}: {exc}"
6869
if self.strict:
6970
raise RuntimeError(msg) from exc
7071
LOG.warning("%s", msg)
7172
validated_value = value
7273
else:
73-
validated_value = getattr(result, "validated_output", result)
74-
# If validation failed but strict mode is enabled, raise
75-
if (
76-
hasattr(result, "validation_passed")
77-
and not result.validation_passed
78-
and self.strict
79-
):
80-
raise RuntimeError(
81-
f"guardrails validation failed for {agent.spec.id}:{key}"
74+
passed = getattr(result, "validation_passed", True)
75+
if not passed:
76+
msg = (
77+
f"guardrails validation did not pass for "
78+
f"{agent.spec.id}:{key}"
8279
)
80+
if self.strict:
81+
raise RuntimeError(msg)
82+
LOG.warning("%s – falling back to original value", msg)
83+
validated_value = value
84+
else:
85+
validated_value = getattr(result, "validated_output", result)
8386
validated[key] = validated_value
8487
return validated
8588

src/zyra/swarm/planner.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1959,8 +1959,34 @@ def _run_guardrails(schema_path: str, manifest: dict[str, Any]) -> None:
19591959
"guardrails library not installed; pip install guardrails-ai"
19601960
) from exc
19611961
text = Path(schema_path).read_text(encoding="utf-8")
1962-
guard = Guard.from_rail(text) # type: ignore
1963-
guard.parse(json.dumps(manifest, sort_keys=True))
1962+
guard = Guard.for_rail_string(text) # type: ignore
1963+
result = guard.validate(json.dumps(manifest, sort_keys=True))
1964+
if hasattr(result, "validation_passed") and not result.validation_passed:
1965+
details = _guardrails_failure_details(result)
1966+
detail_suffix = f": {details}" if details else ""
1967+
raise RuntimeError(
1968+
f"guardrails validation did not pass for {schema_path}{detail_suffix}"
1969+
)
1970+
1971+
1972+
def _guardrails_failure_details(result: Any) -> str:
1973+
"""Extract a human-readable failure summary from a ValidationOutcome."""
1974+
parts: list[str] = []
1975+
for attr in ("error", "error_message"):
1976+
val = getattr(result, attr, None)
1977+
if isinstance(val, str) and val.strip():
1978+
parts.append(val.strip())
1979+
break
1980+
errors = getattr(result, "validation_errors", None)
1981+
if isinstance(errors, list):
1982+
for err in errors[:5]:
1983+
text = str(err).strip() if err else ""
1984+
if text:
1985+
parts.append(text)
1986+
reask = getattr(result, "reask", None)
1987+
if reask is not None and not parts:
1988+
parts.append(f"reask={reask}")
1989+
return "; ".join(parts)
19641990

19651991

19661992
def _load_llm_client(): # pragma: no cover - environment dependent

tests/swarm/test_planner.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import json
55
from argparse import Namespace
66

7+
import pytest
8+
79
import zyra.swarm.value_engine as value_engine
810
from zyra.swarm import planner as planner_cli
911
from zyra.swarm.planner import (
@@ -20,6 +22,7 @@
2022
_map_to_capabilities,
2123
_normalize_args_for_command,
2224
_propagate_inferred_args,
25+
_run_guardrails,
2326
_scan_frames_plan_details,
2427
_strip_internal_fields,
2528
_validate_manifest,
@@ -836,3 +839,135 @@ class FakeCaps:
836839
assert specs[0].command == "ftp"
837840
assert specs[1].behavior == "proposal"
838841
assert specs[1].metadata["proposal_options"] == ["swarm", "describe"]
842+
843+
844+
# --- guardrails integration via planner ---
845+
846+
_PASS_RAIL = """\
847+
<rail version="0.1">
848+
849+
<output>
850+
<list name="agents" description="Pipeline agent definitions">
851+
<object>
852+
<string name="id" />
853+
<string name="stage" />
854+
</object>
855+
</list>
856+
</output>
857+
858+
<prompt>
859+
Validate plan agents.
860+
{{#block hidden=True}}
861+
{{input}}
862+
{{/block}}
863+
</prompt>
864+
865+
</rail>
866+
"""
867+
868+
_STRICT_RAIL = """\
869+
<rail version="0.1">
870+
871+
<output>
872+
<object name="plan">
873+
<list name="agents">
874+
<object>
875+
<string name="id" />
876+
<string name="stage" />
877+
<integer name="priority" description="required priority field" />
878+
</object>
879+
</list>
880+
</object>
881+
</output>
882+
883+
<prompt>
884+
Validate plan agents have a priority field.
885+
{{#block hidden=True}}
886+
{{input}}
887+
{{/block}}
888+
</prompt>
889+
890+
</rail>
891+
"""
892+
893+
894+
@pytest.mark.guardrails
895+
def test_run_guardrails_validates_manifest(tmp_path, monkeypatch):
896+
"""_run_guardrails should accept a valid manifest without raising."""
897+
pytest.importorskip("guardrails")
898+
monkeypatch.setenv("OTEL_SDK_DISABLED", "true")
899+
schema = tmp_path / "plan.rail"
900+
schema.write_text(_PASS_RAIL, encoding="utf-8")
901+
manifest = {
902+
"agents": [
903+
{"id": "fetch", "stage": "acquire"},
904+
{"id": "narrate", "stage": "narrate"},
905+
]
906+
}
907+
# Should not raise
908+
_run_guardrails(str(schema), manifest)
909+
910+
911+
@pytest.mark.guardrails
912+
def test_run_guardrails_rejects_invalid_manifest(tmp_path, monkeypatch):
913+
"""_run_guardrails should raise when the manifest fails validation."""
914+
pytest.importorskip("guardrails")
915+
monkeypatch.setenv("OTEL_SDK_DISABLED", "true")
916+
schema = tmp_path / "strict.rail"
917+
schema.write_text(_STRICT_RAIL, encoding="utf-8")
918+
# Manifest lacks the required "priority" integer field and is not
919+
# wrapped in a "plan" key, so validation_passed will be False.
920+
manifest = {
921+
"agents": [
922+
{"id": "fetch", "stage": "acquire"},
923+
]
924+
}
925+
with pytest.raises(RuntimeError, match="validation did not pass"):
926+
_run_guardrails(str(schema), manifest)
927+
928+
929+
@pytest.mark.guardrails
930+
def test_cmd_plan_with_guardrails_flag(tmp_path, capsys, monkeypatch):
931+
"""The --guardrails CLI flag should invoke validation without error."""
932+
pytest.importorskip("guardrails")
933+
monkeypatch.setenv("OTEL_SDK_DISABLED", "true")
934+
schema = tmp_path / "plan.rail"
935+
schema.write_text(_PASS_RAIL, encoding="utf-8")
936+
ns = Namespace(
937+
intent="mock swarm plan",
938+
intent_file=None,
939+
output="-",
940+
guardrails=str(schema),
941+
strict=False,
942+
memory=None,
943+
no_clarify=True,
944+
verbose=False,
945+
)
946+
rc = planner_cli._cmd_plan(ns)
947+
assert rc == 0
948+
out = capsys.readouterr().out
949+
payload = json.loads(out)
950+
assert payload["agents"][0]["stage"] == "simulate"
951+
952+
953+
@pytest.mark.guardrails
954+
def test_cmd_plan_strict_guardrails_rejects(tmp_path, capsys, monkeypatch):
955+
"""--guardrails + --strict should return exit code 2 on failure."""
956+
pytest.importorskip("guardrails")
957+
monkeypatch.setenv("OTEL_SDK_DISABLED", "true")
958+
schema = tmp_path / "strict.rail"
959+
schema.write_text(_STRICT_RAIL, encoding="utf-8")
960+
ns = Namespace(
961+
intent="mock swarm plan",
962+
intent_file=None,
963+
output="-",
964+
guardrails=str(schema),
965+
strict=True,
966+
memory=None,
967+
no_clarify=True,
968+
verbose=False,
969+
)
970+
rc = planner_cli._cmd_plan(ns)
971+
assert rc == 2
972+
err = capsys.readouterr().err
973+
assert "guardrails validation failed" in err

tests/swarm/test_swarm_cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,13 +86,15 @@ def test_log_events_and_dump_memory(tmp_path, capsys) -> None:
8686
"id": "narrate",
8787
"stage": "narrate",
8888
"command": "describe",
89+
"behavior": "mock",
8990
"outputs": ["summary"],
9091
"args": {"topic": "demo"},
9192
},
9293
{
9394
"id": "export",
9495
"stage": "disseminate",
9596
"command": "local",
97+
"behavior": "mock",
9698
"stdin_from": "summary",
9799
"outputs": ["artifact"],
98100
"args": {"input": "-", "path": str(tmp_path / "out.txt")},

0 commit comments

Comments
 (0)