Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/publish-pypi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,4 @@ jobs:
- name: Publish unplug-ai
env:
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}
run: uv publish
run: uv publish --check-url https://pypi.org/pypi/unplug-ai/json
7 changes: 5 additions & 2 deletions sdk/src/unplug/core/encodings.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from typing import TYPE_CHECKING, Protocol

from unplug.api.types import Finding
from unplug.core.normalize import Normalizer
from unplug.core.normalize import _MAX_BASE64_DECODED_SIZE, Normalizer
from unplug.safeguards.injection.patterns import INJECTION_PATTERNS

if TYPE_CHECKING:
Expand Down Expand Up @@ -141,7 +141,10 @@ def iter_base64_blobs(text: str) -> list[EncodingBlob]:
continue
decoded: str | None = None
try:
decoded = base64.b64decode(raw, validate=True).decode("utf-8")
decoded_bytes = base64.b64decode(raw, validate=True)
if len(decoded_bytes) > _MAX_BASE64_DECODED_SIZE:
continue
decoded = decoded_bytes.decode("utf-8")
except Exception:
continue
if not _is_plausible_decoded_payload(decoded):
Expand Down
24 changes: 9 additions & 15 deletions sdk/src/unplug/core/normalize.py
Original file line number Diff line number Diff line change
Expand Up @@ -450,24 +450,18 @@ def _strip_delimiters(text: str, offset_table: list[int]) -> tuple[str, list[int
break
current_text, current_offsets = new_text, result_offsets

pipe_between = re.compile(r"(?<=[a-zA-Z])\|(?=[a-zA-Z])")
if pipe_between.search(current_text):
result_chars = []
result_offsets = []
for i, ch in enumerate(current_text):
if ch == "|" and i > 0 and i + 1 < len(current_text):
if current_text[i - 1].isalpha() and current_text[i + 1].isalpha():
continue
result_chars.append(ch)
result_offsets.append(current_offsets[i])
current_text = "".join(result_chars)
current_offsets = result_offsets

if "|" in current_text:
pipe_evasion = (
re.compile(r"(?<=[a-zA-Z])\|(?=[a-zA-Z])"),
re.compile(r"(?<=[a-zA-Z])\|(?=\s)"),
re.compile(r"(?<=\s)\|(?=[a-zA-Z])"),
)
for pattern in pipe_evasion:
if not pattern.search(current_text):
continue
result_chars = []
result_offsets = []
for i, ch in enumerate(current_text):
if ch == "|":
if ch == "|" and pattern.match(current_text, i):
continue
result_chars.append(ch)
result_offsets.append(current_offsets[i])
Expand Down
14 changes: 13 additions & 1 deletion sdk/src/unplug/core/secrets.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,18 @@ def redact(self, text: str) -> str:
return result


def _merge_spans(spans: list[tuple[int, int]]) -> list[tuple[int, int]]:
if not spans:
return []
merged: list[tuple[int, int]] = []
for start, end in sorted(spans):
if merged and start <= merged[-1][1]:
merged[-1] = (merged[-1][0], max(merged[-1][1], end))
else:
merged.append((start, end))
return merged


class SecretsSanitizer:
"""Sanitizes text by replacing all detected secrets."""

Expand All @@ -184,7 +196,7 @@ def sanitize(self, text: str) -> SanitizeResult:
for m in pat.finditer(clean):
generic_spans.append((m.start(), m.end()))

for start, end in sorted(generic_spans, reverse=True):
for start, end in sorted(_merge_spans(generic_spans), reverse=True):
clean = clean[:start] + "[REDACTED]" + clean[end:]

return SanitizeResult(clean_text=clean, secrets_found=matches)
14 changes: 2 additions & 12 deletions sdk/src/unplug/pipelines/input.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

from __future__ import annotations

import asyncio
from typing import Any

from unplug.config.agent_policy import BoundaryConfig, DegradationConfig, TrajectoryConfig
from unplug.core.asyncio_compat import run_coroutine_sync
from unplug.core.boundaries import maybe_wrap_untrusted
from unplug.core.config import PipelineConfig
from unplug.core.context import ExecutionContext
Expand Down Expand Up @@ -122,17 +122,7 @@ def _maybe_judge(
return []
judge_ctx = JudgeContext(scanner_findings=findings)
try:
try:
loop = asyncio.get_running_loop()
except RuntimeError:
result = asyncio.run(self._judge.judge(input_data.text, judge_ctx))
else:
future = asyncio.run_coroutine_threadsafe(
self._judge.judge(input_data.text, judge_ctx),
loop,
)
timeout = self._config.judge_timeout
result = future.result(timeout=timeout)
result = run_coroutine_sync(self._judge.judge(input_data.text, judge_ctx))
except Exception as exc:
_log.error("input pipeline judge failed: %s", exc)
return [
Expand Down
6 changes: 4 additions & 2 deletions sdk/src/unplug/safeguards/leakage.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,10 @@
("jwt_token", re.compile(r"eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+")),
("email_address", re.compile(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}")),
("phone_number", re.compile(r"\b(\+?1?\s?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4})\b")),
("ssn", re.compile(r"\b\d{3}[\s.-]?\d{2}[\s.-]?\d{4}\b")),
("ssn_compact", re.compile(r"\b\d{3}\d{2}\d{4}\b")),
(
"ssn",
re.compile(r"\b(?!000|666|9\d{2})\d{3}[\s.-]?\d{2}[\s.-]?\d{4}\b"),
),
(
"system_prompt_leak",
re.compile(
Expand Down
22 changes: 22 additions & 0 deletions sdk/tests/test_asyncio_compat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""Tests for sync/async bridge helpers."""

from __future__ import annotations

import asyncio

from unplug.core.asyncio_compat import run_coroutine_sync


async def _return_value(value: str) -> str:
return value


class TestRunCoroutineSync:
def test_runs_without_active_loop(self) -> None:
assert run_coroutine_sync(_return_value("ok")) == "ok"

def test_runs_from_active_loop_thread(self) -> None:
async def _runner() -> str:
return run_coroutine_sync(_return_value("nested"))

assert asyncio.run(_runner()) == "nested"
6 changes: 6 additions & 0 deletions sdk/tests/test_encodings.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ def test_short_blob_below_min_length_ignored(self) -> None:
text = f"token={short}"
assert iter_base64_blobs(text) == []

def test_oversized_decode_skipped(self) -> None:
payload = "x" * 10_001
blob = base64.b64encode(payload.encode()).decode()
text = f"payload={blob}"
assert iter_base64_blobs(text) == []


class TestEncodingClassifiers:
def test_heuristic_classifier(self) -> None:
Expand Down
15 changes: 15 additions & 0 deletions sdk/tests/test_normalize.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,21 @@ def test_preserves_offsets(self):
assert offsets[0] == 0 # 'i' at pos 0
assert offsets[1] == 2 # 'g' at pos 2

def test_pipe_separated_evasion(self):
text = "i|g|n|o|r|e"
result, _ = _strip_delimiters(text, _make_table(text))
assert result == "ignore"

def test_shell_pipe_preserved(self):
text = "ls | grep secret"
result, _ = _strip_delimiters(text, _make_table(text))
assert result == text

def test_cross_word_pipe_evasion(self):
text = "i|g|n|o|r|e| |p|r|e|v|i|o|u|s"
result, _ = _strip_delimiters(text, _make_table(text))
assert result == "ignore previous"


class TestMatchCrossLanguage:
def test_spanish_ignore(self):
Expand Down
12 changes: 11 additions & 1 deletion sdk/tests/test_scanners.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,17 @@ def test_scans_external(self):
def test_detects_spaced_ssn(self):
text = _make_text("SSN 123 45 6789", trust=TrustLevel.RETRIEVED)
findings = self.scanner.scan(text, self.ctx)
assert any(f.subcategory in ("ssn", "ssn_compact") for f in findings)
assert any(f.subcategory == "ssn" for f in findings)

def test_compact_ssn_without_redundant_pattern(self):
text = _make_text("SSN 123456789", trust=TrustLevel.RETRIEVED)
findings = self.scanner.scan(text, self.ctx)
assert any(f.subcategory == "ssn" for f in findings)

def test_nine_digit_order_id_not_ssn(self):
text = _make_text("order id 987654321", trust=TrustLevel.TOOL_OUTPUT)
findings = self.scanner.scan(text, self.ctx)
assert not any(f.subcategory in ("ssn", "ssn_compact") for f in findings)

def test_detects_zero_width_email(self):
text = _make_text("contact test\u200b@example.com today", trust=TrustLevel.RETRIEVED)
Expand Down
9 changes: 9 additions & 0 deletions sdk/tests/test_secrets.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,15 @@ def test_sanitize_multiple_generic_patterns(self):
assert "sk-" not in result.clean_text
assert result.clean_text.count("[REDACTED]") >= 2

def test_sanitize_overlapping_generic_spans(self):
reg = SecretsRegistry()
sanitizer = SecretsSanitizer(reg)
text = "export api_key=sk-abcdefghijklmnopqrstuvwxyz1234567890abcdef"
result = sanitizer.sanitize(text)
assert "sk-" not in result.clean_text
assert "api_key=" not in result.clean_text
assert "[REDACTED]" in result.clean_text

def test_register_redos_pattern_rejected(self):
reg = SecretsRegistry()
with pytest.raises(ValueError, match="backtracking"):
Expand Down
Loading