diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ea3169..6d7b3b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v5 diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..75e9c44 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,39 @@ +name: Pages + +# The site runs the wheel built from this commit in the browser (Pyodide), so it always matches main. +on: + push: + branches: ["main"] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + - name: Build wheel and assemble site + run: | + uv build --wheel --out-dir dist + mkdir -p _site/wheels + cp site/index.html _site/ + cp dist/*.whl _site/wheels/ + (cd dist && ls *.whl) > _site/wheels/latest.txt + - uses: actions/configure-pages@v5 + - uses: actions/upload-pages-artifact@v3 + with: + path: _site + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..72396be --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,35 @@ +name: Release + +# Publishing a GitHub release uploads that version to PyPI via trusted publishing (no stored token). +on: + release: + types: [published] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + - name: Tag must match the package version + run: test "v$(uv version --short)" = "${{ github.event.release.tag_name }}" + - run: uv build + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + pypi: + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/project/sentinel-agent-gateway/ + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/README.md b/README.md index 0fc6ddb..52e08a2 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,13 @@ **A deny-by-default security gateway, human-approval sandbox and MCP proxy for AI agents** +[![PyPI](https://img.shields.io/pypi/v/sentinel-agent-gateway.svg)](https://pypi.org/project/sentinel-agent-gateway/) [![CI](https://github.com/Chirudeva-Reddy/sentinel-agent/actions/workflows/ci.yml/badge.svg)](https://github.com/Chirudeva-Reddy/sentinel-agent/actions) -[![Python 3.10+](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12%20%7C%203.13-blue.svg)](https://www.python.org/) +[![Python 3.10+](https://img.shields.io/badge/python-3.10%20%E2%80%93%203.14-blue.svg)](https://www.python.org/) [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) +**[Live demo: runs in your browser](https://chirudeva-reddy.github.io/sentinel-agent/)** · `pip install sentinel-agent-gateway` + [Why](#why) • [Quickstart](#quickstart) • [How it works](#how-it-works) • [Evaluation](#evaluation) • [Architecture](docs/ARCHITECTURE.md) @@ -40,8 +43,8 @@ between the agent and its tools and checks **both directions**: ## Quickstart ```bash -git clone https://github.com/Chirudeva-Reddy/sentinel-agent.git && cd sentinel-agent -uv sync --all-extras # or: pip install -e ".[server,dashboard,mcp]" +pip install "sentinel-agent-gateway[server,dashboard,mcp,demo]" # the import name is `sentinel` +# or from source: git clone https://github.com/Chirudeva-Reddy/sentinel-agent.git && cd sentinel-agent && uv sync --all-extras ``` ```bash diff --git a/pyproject.toml b/pyproject.toml index 15f3644..3097c7d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,19 @@ [project] -name = "sentinel-agent" -version = "0.1.0" -description = "Zero-Trust Security Gateway & Human-in-the-Loop Sandbox for Autonomous AI Agents" +name = "sentinel-agent-gateway" +version = "0.2.0" +description = "Deny-by-default security gateway, human-approval sandbox and MCP proxy for AI agents" readme = "README.md" authors = [ { name = "Chirudeva Reddy", email = "chirudevareddy03@gmail.com" } ] requires-python = ">=3.10" +license = "Apache-2.0" +keywords = ["ai-agents", "llm-security", "prompt-injection", "mcp", "guardrails"] +classifiers = [ + "Programming Language :: Python :: 3", + "Topic :: Security", + "Intended Audience :: Developers", +] dependencies = [ "pydantic>=2.7.0", "typer>=0.12.0", @@ -42,6 +49,10 @@ dev = [ "ruff>=0.4.0", ] +[project.urls] +Homepage = "https://chirudeva-reddy.github.io/sentinel-agent/" +Repository = "https://github.com/Chirudeva-Reddy/sentinel-agent" + [project.scripts] sentinel = "sentinel.cli:app" diff --git a/sentinel/detectors/__init__.py b/sentinel/detectors/__init__.py index b7151a2..8bafac5 100644 --- a/sentinel/detectors/__init__.py +++ b/sentinel/detectors/__init__.py @@ -28,10 +28,11 @@ def analyze(self, call: NormalizedCall) -> DetectorFinding: ... def load_detectors(policy: PolicyEngine) -> list[Detector]: - eps = sorted(entry_points(group=GROUP), key=lambda ep: ep.name) + # Dedupe by (name, target): stale metadata from an old install would otherwise double-count a detector. + eps = sorted({(ep.name, ep.value): ep for ep in entry_points(group=GROUP)}.values(), key=lambda ep: ep.name) if not eps: # Fail closed: a gateway with no detectors would allow everything. - raise RuntimeError(f"No detectors registered under '{GROUP}'. Is sentinel-agent installed (pip install -e .)?") + raise RuntimeError(f"No detectors registered under '{GROUP}'. Is sentinel-agent-gateway installed?") return [ep.load()(policy) for ep in eps] diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..3dd7931 --- /dev/null +++ b/site/index.html @@ -0,0 +1,265 @@ + + + + + +SentinelAgent + + + + +
+

🛡️ SentinelAgent

+

A deny-by-default security gateway for AI agents. It checks tool calls going out, guards tool + output coming back, and holds risky calls for a human. This page runs the real sentinel package + from main in your browser with Pyodide. No server, nothing leaves the page.

+ +
Loading the Python runtime…
+ +
+

Try the gateway

+

Pick an attack or benign call from the evaluation corpus, or write your own.

+
+
+ + + + + + + + + +
+
The decision will appear here.
+
+
+ +
+

Multi-agent demo

+

A researcher agent and a mailer agent summarise a release-notes page that contains a hidden + instruction to email the customer list to an outside address. A reviewer agent screens every held call and + can only reject or escalate. The model turns are scripted here, and the researcher is written to fall for the + injection. Everything else (gateway, taint tracking, approvals, audit ledger) is the real code. Escalations are + auto-approved as the human.

+ + +
+ +
+

Evaluation

+

Runs sentinel eval on the bundled corpora: attacks and benign calls under the default policy.

+ +
+
+ + +
+ + + + + diff --git a/tests/engine/test_detector_registry.py b/tests/engine/test_detector_registry.py index 4cb32fb..db455fa 100644 --- a/tests/engine/test_detector_registry.py +++ b/tests/engine/test_detector_registry.py @@ -62,3 +62,14 @@ def test_crashing_detector_fails_closed(monkeypatch): def test_explicit_detector_list_overrides_registry(): gw = SentinelGateway(detectors=[AlwaysCritical(None)]) assert [d.NAME for d in gw.detectors] == ["always_critical"] + + +def test_duplicate_entry_points_load_once(monkeypatch): + """Two installed dists registering the same detector (e.g. after a package rename) must not double-count.""" + real = registry.entry_points + monkeypatch.setattr(registry, "entry_points", lambda group: [*real(group=group), *real(group=group)]) + assert sorted(d.NAME for d in SentinelGateway().detectors) == [ + "argument_validator", + "blast_radius_detector", + "prompt_injection_detector", + ] diff --git a/uv.lock b/uv.lock index 23935e0..0609596 100644 --- a/uv.lock +++ b/uv.lock @@ -3040,8 +3040,8 @@ wheels = [ ] [[package]] -name = "sentinel-agent" -version = "0.1.0" +name = "sentinel-agent-gateway" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "pydantic" },