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
39 changes: 39 additions & 0 deletions .github/workflows/publish-pypi.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: Publish to PyPI

on:
release:
types: [published]
workflow_dispatch:

permissions:
contents: read

jobs:
publish:
runs-on: ubuntu-latest
defaults:
run:
working-directory: sdk
steps:
- uses: actions/checkout@v4

- uses: astral-sh/setup-uv@v5
with:
enable-cache: true

- uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install and test
run: |
uv sync --dev
uv run pytest -q

- name: Build
run: uv build

- name: Publish unplug-ai
env:
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
run: uv publish
17 changes: 17 additions & 0 deletions context/product/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,20 @@
2. Semantic intent verification before tool execution
3. Dynamic risk scoring across agent trajectories (catches crescendo attacks)
- These move us from "prompt injection scanner" to "agent runtime security platform"

## 2026-05-28: OpenClaw agent hardening wired into SDK
- **Boundaries:** `auto_wrap_untrusted` on `RETRIEVED` / `TOOL_OUTPUT` in `InputPipeline`; public `Guard.wrap_for_context()`
- **Session policy:** existing CaMeL-lite taint + tool profiles unchanged; now complemented by trajectory + intent gates
- **Crescendo:** `TrajectoryConfig` — escalating `risk_trajectory` slope → REVIEW/BLOCK findings on all pipelines
- **Intent:** `IntentConfig` — informational user intent + side-effect tool → REVIEW
- **Hermes/persona:** named persona + red-team regex patterns in injection scanner
- **Out of SDK scope:** container sandbox, channel pairing, filesystem/network egress (host responsibility)
- **Detail:** `.context/research/openclaw-agent-security.md`

- **Question:** Use Microsoft Presidio to filter agent-exposed PII?
- **Answer:** Useful as optional server-side benchmark / interim `PrivacyFilterService` backend only — not a replacement for Unplug or the planned unplug-safeguard PF head.
- **Already covered:** `LeakageScanner` regex (email, phone, SSN, keys) + output pipeline span redaction + `ScanPolicy` coverage BLOCK.
- **Presidio adds:** names, addresses, international/custom entities — where regex under-recalls.
- **Does not add:** injection, destructive tools, taint, fail-closed guard architecture.
- **Production path unchanged:** unplug-safeguard Privacy Filter on server (see span-pipeline spec Stage 3).
- **Detail:** `.context/research/presidio-pii.md`
33 changes: 33 additions & 0 deletions sdk/PUBLISH.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Publish unplug-ai to PyPI

Package: **`unplug-ai`** · Import: **`from unplug import Guard`**

## One-time setup

1. Create a [PyPI account](https://pypi.org/account/register/) (org account recommended).
2. Create an API token with **Upload** scope for project `unplug-ai` (or entire account for first release).
3. In [UnplugAI/Unplug](https://github.com/UnplugAI/Unplug) → **Settings → Secrets → Actions**, add:

| Secret | Value |
|------------------|--------------|
| `PYPI_API_TOKEN` | `pypi-...` |

## Publish

**CI (recommended):** Actions → **Publish to PyPI** → Run workflow
Or tag a GitHub Release — workflow runs on `release: published`.

**Local:**

```bash
cd sdk
uv sync --dev
uv run pytest -q
uv build
UV_PUBLISH_TOKEN=pypi-... uv publish
```

## After publish

- Site links: `pip install unplug-ai` → https://pypi.org/project/unplug-ai/
- Bump `sdkVersion` in `unplug-site/public/js/core/site-config.jsx` when releasing new versions.
29 changes: 29 additions & 0 deletions sdk/README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,32 @@
# Unplug SDK

Pull the plug on bad AI. Runtime enforcement layer for AI agents.

```bash
pip install unplug-ai
```

```python
from unplug import Guard

guard = Guard() # local mode, offline
result = guard.scan("Ignore all previous instructions", source="user")

if not result.safe:
text = result.redacted_text
```

## Agent host checklist (OpenClaw-style)

Use this flow when wiring Unplug into an agent that fetches external content or calls tools:

1. **Scan user input** — `guard.scan(text, source="user")` (captures `user_intent` for later gates).
2. **Wrap untrusted content** before inserting into LLM context — `guard.wrap_for_context(rag_chunk, source="retrieved")`. Auto-wrap also runs on `scan(..., source="retrieved")` when `[boundaries] auto_wrap_untrusted = true`.
3. **After fetch/read tools** — `guard.notify_taint_source("web_fetch")` so side-effect tools require review.
4. **Before every tool call** — `guard.check_tool_call(name, args, taint_sources=[...])`. Destructive calls block; tainted session + side-effect → `REVIEW`.
5. **Scan agent output** — `guard.scan_output(text)`. Set `strip_on_output = true` to remove boundary markers from redacted output.
6. **New trusted turn** — `guard.reset_session_taint()` when the user starts a fresh instruction with no untrusted context.

Optional: run `unplug-audit --probes` after swapping in a new ML checkpoint.

Docs: [github.com/UnplugAI/Unplug](https://github.com/UnplugAI/Unplug) · Site: [unplug-ai.org](https://unplug-ai.org)
19 changes: 13 additions & 6 deletions sdk/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[project]
name = "unplug"
name = "unplug-ai"
version = "0.3.0"
description = "Pull the plug on bad AI. Fast prompt injection detection and redaction for LLM apps, agents, and RAG pipelines."
readme = "README.md"
Expand All @@ -24,31 +24,35 @@ dependencies = [
[project.optional-dependencies]
ml = [
"onnxruntime>=1.17",
"transformers>=4.40",
"transformers>=4.44,<4.45",
"sentencepiece>=0.2",
"numpy>=1.26",
"torch>=2.0",
]
scrape = [
"firecrawl-py>=1.0",
"python-dotenv>=1.2.2",
]
all = ["unplug[ml,scrape]"]
all = ["unplug-ai[ml,scrape]"]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.23",
"ruff>=0.4",
]

[project.urls]
Homepage = "https://github.com/chiruu12/Unplug"
Repository = "https://github.com/chiruu12/Unplug"
Issues = "https://github.com/chiruu12/Unplug/issues"
Homepage = "https://unplug-ai.org"
Repository = "https://github.com/UnplugAI/Unplug"
Issues = "https://github.com/UnplugAI/Unplug/issues"
Documentation = "https://github.com/UnplugAI/Unplug#readme"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/unplug"]
include = ["src/unplug/audit/data/*.json"]

[tool.ruff]
target-version = "py311"
Expand All @@ -61,6 +65,9 @@ select = ["E", "F", "I", "N", "W", "UP"]
testpaths = ["tests"]
asyncio_mode = "auto"

[project.scripts]
unplug-audit = "unplug.cli.audit:main"

[dependency-groups]
dev = [
"datasets>=4.8.5",
Expand Down
68 changes: 68 additions & 0 deletions sdk/scripts/smoke_local_ml.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""Smoke test: local Guard with active span model + FP probe queries."""

from __future__ import annotations

import argparse
import json
import os
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parents[3]
DEFAULT_CKPT = (
ROOT / "repos/unplug_exp/dist/vm-v10-750k-diagnostic-bundle/"
"experiments/unplug-tiny-v10-350k/checkpoint-24615"
)
DEFAULT_PROBES = ROOT / "repos/unplug_exp/configs/fp_probe_queries.json"


def main() -> None:
parser = argparse.ArgumentParser(description="Smoke test Guard + span model")
parser.add_argument("--checkpoint", type=Path, default=None)
parser.add_argument("--probes", type=Path, default=DEFAULT_PROBES)
args = parser.parse_args()

ckpt = args.checkpoint or Path(os.environ.get("UNPLUG_MODEL_PATH", DEFAULT_CKPT))
if not ckpt.is_dir():
print(f"checkpoint not found: {ckpt}", file=sys.stderr)
sys.exit(1)

os.environ.setdefault("UNPLUG_ACTIVE_MODEL", "small")
os.environ["UNPLUG_MODEL_PATH"] = str(ckpt)

from unplug import Guard
from unplug.config.loader import load

cfg = load()
guard = Guard(config=cfg, mode="local")
print(f"scanners: {guard.scanners_loaded}")
print(f"ml_loaded: {guard.ml_model_loaded}")
print(f"model_version: {guard._model_version_for_cache()}") # noqa: SLF001

probes = json.loads(args.probes.read_text(encoding="utf-8"))
fp = fn = tp = tn = 0
for probe in probes:
result = guard.scan(probe["text"])
detected = not result.safe or bool(result.findings)
expect = bool(probe.get("expect_detected"))
if expect and detected:
tp += 1
tag = "TP"
elif expect and not detected:
fn += 1
tag = "FN"
elif not expect and detected:
fp += 1
tag = "FP"
else:
tn += 1
tag = "TN"
print(f" [{tag}] {probe['id']}: action={result.action.value} risk={result.risk_score:.2f}")

print(f"\nprobes: tp={tp} fp={fp} tn={tn} fn={fn} pass={fp == 0 and fn == 0}")
sys.exit(0 if fp == 0 and fn == 0 else 1)


if __name__ == "__main__":
main()
21 changes: 21 additions & 0 deletions sdk/src/unplug/api/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from pydantic import BaseModel, Field, model_validator

from unplug.api.enums import Action, Source
from unplug.config.policy import RedactionMode


class Finding(BaseModel):
Expand All @@ -27,6 +28,18 @@ def validate_span(self) -> Self:
return self


class ApprovalRequest(BaseModel):
"""Payload for host/UI when a tool call needs operator approval."""

tool_name: str
arguments: dict = Field(default_factory=dict)
reason: str
risk_score: float = Field(ge=0.0, le=1.0)
action: Action = Action.REVIEW
findings: list[str] = Field(default_factory=list)
session_tainted: bool = False


class ScanResult(BaseModel):
safe: bool = Field(description="Whether the text is safe")
action: Action = Field(description="Recommended action")
Expand All @@ -35,13 +48,21 @@ class ScanResult(BaseModel):
redacted_text: str | None = Field(default=None)
latency_ms: float = Field(description="Total scan time in milliseconds")
stages_run: list[str] = Field(default_factory=list)
approval: ApprovalRequest | None = Field(
default=None,
description="Populated when action=review for side-effect tools in tainted sessions",
)


class ScanRequest(BaseModel):
text: str = Field(description="Text to scan")
source: Source = Field(default=Source.USER)
scanners: list[str] | None = Field(default=None)
redact: bool = Field(default=True)
redaction_mode: RedactionMode | None = Field(
default=None,
description="Override default redaction style; ignored when redact=false",
)
session_id: str | None = Field(default=None, description="Client session for logging")
agent_id: str | None = Field(default=None, description="Agent identifier")
turn_id: int | None = Field(default=None, description="Turn index within session")
Expand Down
8 changes: 8 additions & 0 deletions sdk/src/unplug/audit/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""Unplug audit package."""

from __future__ import annotations

from unplug.audit.boundary import run_boundary_probe_suite
from unplug.audit.runner import run_audit

__all__ = ["run_audit", "run_boundary_probe_suite"]
Loading
Loading