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
21 changes: 21 additions & 0 deletions QUICKSTART.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,29 @@ sentinelguard scanners list

# Start API server
sentinelguard serve --port 8000

# Start OpenAI-compatible LLM gateway
export OPENAI_API_KEY="sk-..."
sentinelguard gateway --provider openai --port 8080

# Or use a native provider adapter
export ANTHROPIC_API_KEY="sk-ant-..."
sentinelguard gateway --provider anthropic --port 8080

export GEMINI_API_KEY="..."
sentinelguard gateway --provider gemini --port 8080
```

Point OpenAI-compatible apps or IDEs to:

```text
http://localhost:8080/v1
```

The gateway protects traffic only when the app or IDE sends model requests
through that URL.
Requests with `stream=true` are supported with safe buffered streaming.

## 7. YAML Configuration

Create `sentinelguard.yaml`:
Expand Down
94 changes: 94 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,100 @@ print(result.is_valid) # False
print(result.failed_scanners) # ['prompt_injection']
```

### Use as an LLM Gateway

SentinelGuard can also run as an OpenAI-compatible gateway in front of an LLM
provider. Your app sends chat completions to SentinelGuard, SentinelGuard scans
the last user message, forwards the safe request upstream, scans the assistant
response, and returns the safe response.

```bash
pip install sentinelguard[gateway]

export OPENAI_API_KEY="sk-..."
sentinelguard gateway --provider openai --port 8080
```

Native provider adapters are also available:

```bash
# Anthropic Claude
export ANTHROPIC_API_KEY="sk-ant-..."
sentinelguard gateway --provider anthropic --port 8080

# Google Gemini
export GEMINI_API_KEY="..."
sentinelguard gateway --provider gemini --port 8080
```

Then point an OpenAI-compatible client at the gateway:

```python
from openai import OpenAI

client = OpenAI(
api_key="not-used-when-gateway-uses-OPENAI_API_KEY",
base_url="http://localhost:8080/v1",
)

response = client.chat.completions.create(
model="gpt-4o-mini", # or the Claude/Gemini model routed by the gateway
messages=[{"role": "user", "content": "What is the weather today?"}],
)
```

For IDEs and AI tools, configure the tool's OpenAI-compatible base URL or
custom provider endpoint to use the gateway:

```text
http://localhost:8080/v1
```

When traffic is routed through this URL, SentinelGuard scans prompts before
they reach the upstream LLM and scans model responses before they are returned.
Registering SentinelGuard only as an MCP server gives the IDE optional scanning
tools; it does not automatically intercept every chat prompt.

Streaming clients are supported with `stream=true`. By default, SentinelGuard
uses buffered streaming: it collects the upstream response, scans or sanitizes
the complete output, then emits OpenAI-compatible server-sent events back to the
client. This avoids leaking unscanned output tokens.

Gateway behavior can be controlled with YAML:

```yaml
gateway:
enabled: true
provider: openai
upstream_url: https://api.openai.com/v1
api_key_env: OPENAI_API_KEY
default_max_tokens: 1024
streaming_mode: buffered
block_on_prompt_fail: true
block_on_output_fail: true
sanitize: true
```

Provider defaults:

| Provider | Default upstream | Default API key env |
| --- | --- | --- |
| `openai` | `https://api.openai.com/v1` | `OPENAI_API_KEY` |
| `anthropic` | `https://api.anthropic.com/v1` | `ANTHROPIC_API_KEY` |
| `gemini` | `https://generativelanguage.googleapis.com/v1beta` | `GEMINI_API_KEY` |

Gemini also checks `GOOGLE_API_KEY` when `GEMINI_API_KEY` is not set.

Run with the gateway config:

```bash
sentinelguard gateway --gateway-config gateway.yaml --port 8080
```

Set `enabled: false` to run the gateway in pass-through mode without scanning.
Package mode remains available at the same time through `from sentinelguard
import SentinelGuard`.

### OWASP-Compliant Configuration

```python
Expand Down
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ api = [
"uvicorn>=0.23.0",
"httpx>=0.24.0",
]
gateway = [
"fastapi>=0.100.0",
"uvicorn>=0.23.0",
"httpx>=0.24.0",
]
monitoring = [
"opentelemetry-api>=1.20.0",
"opentelemetry-sdk>=1.20.0",
Expand Down
36 changes: 34 additions & 2 deletions sentinelguard/api/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from __future__ import annotations

import logging
import copy
from typing import Any, Dict, List, Optional

from sentinelguard.core.config import GuardConfig
Expand Down Expand Up @@ -102,6 +103,35 @@ class ConfigUpdateRequest(BaseModel):
fail_fast: Optional[bool] = None
parallel: Optional[bool] = None

def _guard_for_request(request: ScanRequest, direction: str) -> SentinelGuard:
if request.scanners is None and request.threshold is None:
return guard

request_guard = SentinelGuard(config=copy.deepcopy(guard.config))
if request.threshold is not None:
for scanner in request_guard._prompt_pipeline.scanners:
scanner.threshold = request.threshold
for scanner in request_guard._output_pipeline.scanners:
scanner.threshold = request.threshold

if request.scanners is not None:
allowed = set(request.scanners)
pipeline = (
request_guard._prompt_pipeline
if direction == "prompt"
else request_guard._output_pipeline
)
pipeline.scanners = [
scanner for scanner in pipeline.scanners if scanner.scanner_name in allowed
]
pipeline.scanner_actions = {
name: action
for name, action in pipeline.scanner_actions.items()
if name in allowed
}

return request_guard

# ── Endpoints ──

@app.get("/health", response_model=HealthResponse)
Expand All @@ -118,7 +148,8 @@ async def health():
async def scan_prompt(request: ScanRequest):
"""Scan a prompt for security issues."""
try:
result = guard.scan_prompt(request.text)
request_guard = _guard_for_request(request, "prompt")
result = request_guard.scan_prompt(request.text)
return ScanResponse(
is_valid=result.is_valid,
results=[
Expand All @@ -144,7 +175,8 @@ async def scan_prompt(request: ScanRequest):
async def scan_output(request: ScanRequest):
"""Scan an LLM output for security issues."""
try:
result = guard.scan_output(request.text)
request_guard = _guard_for_request(request, "output")
result = request_guard.scan_output(request.text)
return ScanResponse(
is_valid=result.is_valid,
results=[
Expand Down
117 changes: 115 additions & 2 deletions sentinelguard/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
sentinelguard scan prompt "Your text here"
sentinelguard scan output "LLM output here"
sentinelguard serve --port 8000
sentinelguard gateway --provider openai --port 8080
sentinelguard gateway --provider anthropic --port 8080
sentinelguard gateway --provider gemini --port 8080
sentinelguard config show
sentinelguard scanners list
"""
Expand Down Expand Up @@ -62,6 +65,43 @@ def main(argv: Optional[List[str]] = None) -> int:
"--reload", action="store_true", help="Enable auto-reload"
)

# ── gateway command ──
gateway_parser = subparsers.add_parser(
"gateway", help="Start OpenAI-compatible LLM gateway"
)
gateway_parser.add_argument(
"--host", default="0.0.0.0", help="Host to bind to"
)
gateway_parser.add_argument(
"--port", type=int, default=8080, help="Port to listen on"
)
gateway_parser.add_argument(
"--config", type=str, help="Path to SentinelGuard scanner config YAML"
)
gateway_parser.add_argument(
"--gateway-config", type=str, help="Path to gateway config YAML"
)
gateway_parser.add_argument(
"--provider",
default=None,
help="Gateway provider: openai, anthropic, gemini, or OpenAI-compatible",
)
gateway_parser.add_argument(
"--upstream-url", default=None, help="OpenAI-compatible upstream base URL"
)
gateway_parser.add_argument(
"--api-key-env", default=None, help="Environment variable for upstream API key"
)
gateway_parser.add_argument(
"--enabled", choices=["true", "false"], default=None, help="Enable scanning"
)
gateway_parser.add_argument(
"--sanitize", choices=["true", "false"], default=None, help="Forward sanitized text"
)
gateway_parser.add_argument(
"--reload", action="store_true", help="Enable auto-reload"
)

# ── config command ──
config_parser = subparsers.add_parser("config", help="Manage configuration")
config_sub = config_parser.add_subparsers(dest="config_action")
Expand Down Expand Up @@ -94,6 +134,8 @@ def main(argv: Optional[List[str]] = None) -> int:
return _handle_scan(args)
elif args.command == "serve":
return _handle_serve(args)
elif args.command == "gateway":
return _handle_gateway(args)
elif args.command == "config":
return _handle_config(args)
elif args.command == "scanners":
Expand All @@ -112,6 +154,8 @@ def _handle_scan(args: argparse.Namespace) -> int:
config = GuardConfig.from_yaml(args.config)

guard = SentinelGuard(config=config)
if args.threshold is not None:
_apply_threshold(guard, args.threshold)

if args.type == "prompt":
result = guard.scan_prompt(args.text)
Expand Down Expand Up @@ -166,6 +210,14 @@ def _handle_scan(args: argparse.Namespace) -> int:
return 0 if result.is_valid else 1


def _apply_threshold(guard, threshold: float) -> None:
"""Apply a threshold override to all active scanners for this CLI run."""
for scanner in guard._prompt_pipeline.scanners:
scanner.threshold = threshold
for scanner in guard._output_pipeline.scanners:
scanner.threshold = threshold


def _handle_serve(args: argparse.Namespace) -> int:
"""Handle the serve command."""
try:
Expand Down Expand Up @@ -195,20 +247,81 @@ def _handle_serve(args: argparse.Namespace) -> int:
return 0


def _handle_gateway(args: argparse.Namespace) -> int:
"""Handle the gateway command."""
try:
import uvicorn
except ImportError:
print("Error: uvicorn is required. Install with: pip install sentinelguard[gateway]")
return 1

from sentinelguard.core.config import GuardConfig
from sentinelguard.gateway.config import GatewayConfig
from sentinelguard.gateway.providers import (
effective_api_key_env,
effective_provider,
effective_upstream_url,
)
from sentinelguard.gateway.server import create_gateway_app

guard_config = GuardConfig.from_yaml(args.config) if args.config else None
gateway_config = (
GatewayConfig.from_yaml(args.gateway_config)
if args.gateway_config
else GatewayConfig()
)

if args.provider is not None:
gateway_config.provider = args.provider
if args.upstream_url is not None:
gateway_config.upstream_url = args.upstream_url
if args.api_key_env is not None:
gateway_config.api_key_env = args.api_key_env
if args.enabled is not None:
gateway_config.enabled = _parse_bool(args.enabled)
if args.sanitize is not None:
gateway_config.sanitize = _parse_bool(args.sanitize)

app = create_gateway_app(
guard_config=guard_config,
gateway_config=gateway_config,
)

mode = "enabled" if gateway_config.enabled else "pass-through"
print(f"Starting SentinelGuard LLM gateway on {args.host}:{args.port}")
print(f"Gateway mode: {mode}")
print(f"Provider: {effective_provider(gateway_config)}")
print(f"Upstream: {effective_upstream_url(gateway_config)}")
print(f"API key env: {effective_api_key_env(gateway_config)}")
print(f"Streaming mode: {gateway_config.streaming_mode}")

uvicorn.run(
app,
host=args.host,
port=args.port,
reload=args.reload,
)
return 0


def _parse_bool(value: str) -> bool:
return value.lower() in {"1", "true", "yes", "on"}


def _handle_config(args: argparse.Namespace) -> int:
"""Handle the config command."""
from sentinelguard.core.config import GuardConfig

if args.config_action == "show":
config = GuardConfig()
config = GuardConfig.preset_standard()
print(json.dumps(config.to_dict(), indent=2))
elif args.config_action == "init":
if args.preset == "minimal":
config = GuardConfig.preset_minimal()
elif args.preset == "strict":
config = GuardConfig.preset_strict()
else:
config = GuardConfig()
config = GuardConfig.preset_standard()

config.save_yaml(args.output)
print(f"Configuration saved to {args.output}")
Expand Down
Loading
Loading