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**
+[](https://pypi.org/project/sentinel-agent-gateway/)
[](https://github.com/Chirudeva-Reddy/sentinel-agent/actions)
-[](https://www.python.org/)
+[](https://www.python.org/)
[](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.
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" },