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
17 changes: 14 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,16 @@ SENTINEL_DASHBOARD=1
#
# The OPERATOR credential: the only one that may replace a policy or clear a
# quarantine. Keep it away from agents. Tenant tokens are what agents hold, and
# an agent that could administer could switch off its own guardrails. With
# per-tenant tokens and no admin token, nobody can administer.
# an agent that could administer could switch off its own guardrails. Without
# an admin token nobody can administer, single-token deployments included.
# SENTINEL_ADMIN_TOKEN=change-me-too
#
# Run with NO authentication. Local offline demo only, never on a network.
# SENTINEL_ALLOW_ANONYMOUS=1
#
# Unauthenticated deployments answer only to localhost (so a DNS-rebinding page
# cannot reach them). List any other host name they must answer to:
# SENTINEL_ALLOWED_HOSTS=sentinel.example.org

# --- Azure Foundry (Agent Service) ---
# Endpoint of the Foundry project. Only used in AZURE MODE.
Expand Down Expand Up @@ -72,9 +76,16 @@ OPENAI_MODEL=gpt-4o-mini
# OPENAI_MODEL=llama3.2 (no API key needed)
# Groq / Gemini compat: set OPENAI_BASE_URL to the provider's /v1 URL plus a
# free-tier OPENAI_API_KEY.
# Leave blank to use api.openai.com (or no live model at all).
# Leave blank to use api.openai.com (or no live model at all). OPENAI_API_KEY is
# sent to whatever OPENAI_BASE_URL names, so never pair a real OpenAI key with
# another provider's URL.
OPENAI_BASE_URL=

# Which agent drives demo runs: auto (a live model when credentials are present,
# else the scripted transcript), scripted (always the transcript, e.g. when Azure
# OpenAI is configured only for the classifier), or llm (require a model).
# SENTINEL_AGENT_DRIVER=auto

# --- Tool-catalogue integrity ---
# 1 (default, secure) = REFUSE to serve a downstream catalogue whose tool
# definitions carry prompt-injection markers (tool poisoning). A poisoned tool
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: release

# Publishes to PyPI when a version tag is pushed:
# git tag -a v0.2.1 -m "..." && git push origin v0.2.1
# git tag -a v0.2.2 -m "..." && git push origin v0.2.2
#
# Uses PyPI Trusted Publishing (OIDC): GitHub proves the workflow's identity to
# PyPI directly, so there is NO API token to store, rotate, or leak. This must be
Expand Down
39 changes: 36 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,17 @@ All notable changes to this project are documented here. Format follows

## [Unreleased]

## [0.2.2] — 2026-10-19

**Upgrade from 0.2.1 or earlier.** Read *Changed (breaking)* first: catalogue
approval fingerprints, the bundled policy, `sentinel.yaml` validation and
administration (`SENTINEL_ADMIN_TOKEN` only) all change.

### Changed (breaking)
- The bundled policy (version 6) trusts `get_customer_record`'s output, and
the hero demo's scripted agent emails only where the fetched page tells it
to. The hero block now depends on the poisoned page: before, the record
lookup alone tainted the session and the agent always sent the email.
- Declassification schemas are strict. `iso_date` takes `YYYY-MM-DD` only
(`date.fromisoformat` also took `20261018` and ISO week dates), and
`decimal_amount` takes plain ASCII notation up to 18 digits each side of
Expand Down Expand Up @@ -59,6 +69,11 @@ All notable changes to this project are documented here. Format follows
reload applies to them at once.

### Added
- `SENTINEL_AGENT_DRIVER` (`auto`, `scripted`, `llm`) chooses what drives demo
runs. Configuring Azure OpenAI for the classifier also switched every demo
run to a live model, with no way to keep the scripted transcript.
- `trusted_output: true` on a policy tool labels its results `SYSTEM` instead of
`RETRIEVED_CONTENT`, for tools whose output nobody outside can write to.
- `SENTINEL_DOWNSTREAM_TIMEOUT_SECONDS` (default 30, also
`downstream_timeout_seconds` in `sentinel.yaml`): a deadline for each
downstream operation. Before, a downstream server that accepted a connection
Expand All @@ -76,6 +91,13 @@ All notable changes to this project are documented here. Format follows
directly.

### Security
- The in-memory forensic store keeps and returns private copies of spans. A
caller holding a returned span could rewrite the stored payload's
arguments; the SQLite and Cosmos stores were never affected.
- An unauthenticated deployment answers only to loopback and the hosts in
`SENTINEL_ALLOWED_HOSTS`, and refuses state-changing requests from another
site's `Origin`. With no credential, a web page the operator had open could
submit forms to a local instance or, by DNS rebinding, drive `/mcp`.
- Tool approval uses the same `tools/list` snapshot as routing. Preflight
listed the servers a second time, so a tool could be approved from one
server's definition and routed to another's.
Expand Down Expand Up @@ -112,9 +134,12 @@ All notable changes to this project are documented here. Format follows
downstream server.
- `SENTINEL_MAX_SSE_SUBSCRIBERS` applies per tenant; one tenant could hold
every live-stream slot.
- `sentinel check` and `sentinel serve` warn when a single `SENTINEL_API_TOKEN`
with no `SENTINEL_ADMIN_TOKEN` makes the agents' credential the operator's.
The README claimed only the admin token could administer.
- `SENTINEL_API_TOKEN` no longer administers, even in a single-token
deployment: it is the credential agents hold, so agents could replace their
own policy and clear their own quarantine. Only `SENTINEL_ADMIN_TOKEN`
administers; without it nobody does, and `sentinel check` and `sentinel
serve` say so. (Breaking for single-token deployments that used the policy
or trust-reset endpoints: set `SENTINEL_ADMIN_TOKEN`.)
- `mcp` is upgraded to 1.30.0 (floor and lock) for CVE-2026-59951
(GHSA-84m7-p3x7-pcfv): the SDK's streamable-HTTP transport let sessions
accumulate without bound. SENTINEL's per-tenant session cap limited this on
Expand Down Expand Up @@ -164,6 +189,14 @@ All notable changes to this project are documented here. Format follows
names one with `?tenant=`.

### Fixed
- The trust model tracks the previous tool per trace. One value per agent
mixed concurrent sessions of the same principal, so interleaved calls made
transitions no session took and were scored as anomalies.
- The dashboard no longer says SENTINEL tracks "every byte" (lineage is per
tool result) or that its log streams live (it replays a run that finished
in milliseconds). DEPLOY.md says the Azure template's tool servers rely on
internal-only ingress, not authentication, and the README says catalogue
approval is trust on first use.
- A declassified result's execution span names the schema that cleared it
(`declassified_by`); only the `SYSTEM` label was recorded.
- Loading a policy warns when a config key has the same name as a call field
Expand Down
29 changes: 14 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,7 @@ because its lineage includes untrusted tool output.</i></sub>
</div>

```bash
# PyPI currently serves only insecure 0.1.1. Do NOT install `sentinel-prox`
# from PyPI until 0.2.1+ has been published.
git clone https://github.com/Harshith029/Sentinel.git
cd Sentinel
python -m pip install . -c versions.lock
pip install "sentinel-prox>=0.2.2" # never 0.1.x: those releases are insecure
sentinel init # write sentinel.yaml
sentinel check # run serve's startup checks without serving; non-zero if serve would fail
sentinel scaffold > policy.yaml
Expand All @@ -54,7 +50,7 @@ Read this before deploying anything.

| Area | State |
|---|---|
| PyPI distribution | **Blocked pending release:** PyPI currently serves vulnerable 0.1.1 only. Install this source checkout until a verified 0.2.1+ release is published. |
| PyPI distribution | `sentinel-prox` 0.2.2 and later carry the fixes in the CHANGELOG. 0.1.x is insecure (open `/mcp`, overridable policy config, unredacted forensics); do not install it. For the exact tested dependency set, install from a checkout with `-c versions.lock` |
| Provenance tracking + deterministic default-deny enforcement | Working, tested |
| Forensic spans, replay, audit trail | Working; payloads redacted before persistence. Each run's tenant is recorded, so history stays with its tenant across restarts. Traces are deleted after `SENTINEL_FORENSIC_RETENTION_DAYS` (default 90). Not encrypted at rest by SENTINEL (see *Forensic data*) |
| Catalogue integrity (poisoning, cross-server shadowing, rug pulls) | Working, tested. Checked at connect, on tool listing and every `SENTINEL_CATALOGUE_RECHECK_SECONDS`; findings appear in `GET /downstream` and the logs, not in the forensic store. Re-approving a changed catalogue means restarting. The poisoning scan is a heuristic that also flags ordinary descriptions; approve a reviewed definition with `catalogue_approvals` (the fingerprint `sentinel check` prints) rather than turning strict mode off |
Expand All @@ -63,7 +59,7 @@ Read this before deploying anything.
| Declassification (`StructuredExtractor`) | Wired into enforcement; opt-in per tool via policy |
| Stable agent identity across reconnect | Derived from the authenticated credential; trust and an enforced quarantine survive reconnect (per process, and only when authenticated) |
| Multi-tenant isolation | Per-tenant credentials (`SENTINEL_API_TOKENS`); runs, events, SSE and MCP sessions scoped to the credential's tenant; per-tenant policy via `SENTINEL_TENANT_POLICIES` |
| Operator separation | With `SENTINEL_ADMIN_TOKEN` set, only it can change policy or clear a quarantine; tenant (agent) credentials cannot. With a single `SENTINEL_API_TOKEN` and no admin token, that one token is the operator's too, so whatever holds it can administer; `sentinel check` and `sentinel serve` warn about this |
| Operator separation | Only `SENTINEL_ADMIN_TOKEN` can change policy or clear a quarantine; agent credentials (`SENTINEL_API_TOKEN`, `SENTINEL_API_TOKENS`) never can. Without an admin token nobody administers, and `sentinel check` and `sentinel serve` say so |
| Resource bounds | Request body, request rate, runs in flight, retained runs, live-stream subscribers, event page and tool-result size are all capped (see *Resource limits*). Counters are per process: replicas do not share them |
| Self-hosting (Docker / `deploy/compose.yaml`) | Verified locally; CI builds and starts the image on every push |
| Azure deployment (Bicep) | Optional. **Never deployed**; CI only proves the template compiles |
Expand All @@ -77,7 +73,7 @@ protects you from.
| Limitation | What it means for you |
|---|---|
| **Taint is per MCP session** | Lineage covers the calls made in one MCP session. A client that opens a new session for every tool call (LangChain's `MultiServerMCPClient` does by default) gets **no** taint carried from one call to the next, so a page read in one call does not taint an email sent in the next. A long-lived session has the opposite problem: after its first untrusted result, every guarded action in it is denied until it reconnects. |
| **Every tool result is untrusted** | All tool output is labelled retrieved content, including your own internal systems. A benign flow such as "look up the customer, then email them" is denied if the email tool has a taint rule. Only per-tool declassification (a value that validates against a strict schema) clears taint. |
| **Every tool result is untrusted** (by default) | All tool output is labelled retrieved content unless the policy marks the tool `trusted_output: true`, which you should do only for a tool whose output nobody outside can write to (your own records service, say). Otherwise a benign flow such as "look up the customer, then email them" is denied if the email tool has a taint rule. Per-tool declassification (a value that validates against a strict schema) also clears taint. |
| **Only guarded tools are guarded** | A tool without policy rules can carry tainted data out. The bundled policy treats `web_fetch` as an outbound capability and constrains its hostname through `allowed_fetch_hosts`; add equivalent rules for every outbound tool you introduce. Rules are deny-only and there is no notion of a general outbound "sink" yet. |
| **Quarantine is recorded, not enforced** (default) | The trust score never recovers, so agents making only allowed calls cross the threshold within tens of calls. By default that crossing is logged, not acted on. `SENTINEL_ENFORCE_QUARANTINE=1` enforces it, for every agent that shares a credential. |
| **Downstream servers: HTTP only** | No stdio servers, no per-server credentials or OAuth, one shared session per server, and no reconnect. Most published MCP servers are stdio. |
Expand Down Expand Up @@ -115,7 +111,7 @@ the request was phrased.
| **Data exfiltration** | A `send_email` built from a retrieved page is refused before it executes |
| **Tool poisoning** | Every agent-visible part of a tool definition (description, title, annotations, input and output schemas, metadata) is scanned at connect; a poisoned catalogue is refused |
| **Cross-server shadowing** | Two servers claiming one tool name fails closed — SENTINEL won't guess which is authoritative |
| **Rug pulls** | The catalogue is fingerprinted at approval and re-checked on tool listing (at most every 5 s) and on a schedule. A tool that changes after approval is refused at call time; so is any tool that was not in the approved catalogue |
| **Rug pulls** | The catalogue is fingerprinted at approval and re-checked on tool listing (at most every 5 s) and on a schedule. Approval is trust on first use: what a server presents when SENTINEL connects is what gets pinned, so review it with `sentinel check` before going live. A tool that changes after approval is refused at call time; so is any tool that was not in the approved catalogue |
| **Privilege escalation** | Unknown tools are default-denied until you write a rule |
| **Repeated abuse** | A trust score degrades on blocked calls and quarantines the agent |

Expand Down Expand Up @@ -265,8 +261,9 @@ Put your tool servers on an internal network reachable **only** by SENTINEL —
topology is what makes interception unbypassable.

Authentication is required: with no credential configured SENTINEL refuses to serve.
Set `SENTINEL_API_TOKEN`, or `SENTINEL_API_TOKENS` for per-tenant credentials plus
`SENTINEL_ADMIN_TOKEN` for the operator (see [`.env.example`](./.env.example)).
Set `SENTINEL_API_TOKEN`, or `SENTINEL_API_TOKENS` for per-tenant credentials, for your
agents, and `SENTINEL_ADMIN_TOKEN` for the operator if you need to replace policies or
clear quarantines through the API (see [`.env.example`](./.env.example)).

### Resource limits

Expand Down Expand Up @@ -362,10 +359,12 @@ it as a public sandbox, not as an example of a secured deployment: everything in
is synthetic and anyone can drive it. A real deployment must set
`SENTINEL_API_TOKEN`. A poisoned page induces the agent to email a synthetic customer record to an
attacker; the Layer-1 filter misses the obfuscated variant and authorization blocks it
anyway. Read the block for what it is: the session is already tainted by the
customer-record lookup before the page is fetched (every tool result is untrusted), and
the attacker's domain is outside the bundled allowlist, so the email would be refused
without the poisoned page too. All demo data is synthetic — the record is a labelled fake
anyway. The taint comes from the page: the bundled policy marks the records service's
output as trusted (`trusted_output`), so the record lookup alone taints nothing, and
without the page the agent never attempts the email. The attacker's domain is also
outside the bundled allowlist, a second, independent reason for the same refusal; the
tests show that an email to an allowed address is refused after the page and allowed
without it. All demo data is synthetic — the record is a labelled fake
(SSN `000-00-0000`, a non-functional `sk-synthetic-DO-NOT-USE` key) and `send_email`
writes to an in-memory sink. Nothing is ever sent.

Expand Down
8 changes: 5 additions & 3 deletions deck/DEMO_SCRIPT.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ trust 100→70, and the before/after exfiltration contrast.
> OpenTelemetry's id *format*; SENTINEL does not export OpenTelemetry traces.
> - Provenance is kept as a running union per session, not by a graph walk on
> each call.
> - In the hero scenario the `send_email` is already tainted by the customer-record
> lookup (every tool result is untrusted), and the attacker's domain is outside
> the bundled allowlist. The block does not depend on the poisoned page.
> - As submitted, the hero `send_email` was already tainted by the customer-record
> lookup, so the block did not depend on the poisoned page. Since 2026-10-18 the
> bundled policy trusts the records service's output and the scripted agent only
> acts on the page's instruction, so the page is the taint source. The attacker's
> domain is also outside the allowlist, an independent second reason.
> - Test counts are from submission time.
> - The record lookup's origin badge shows `{USER}`, not `{USER, AGENT}`, and the
> next call, the page fetch, already carries `RETRIEVED_CONTENT` from the record.
Expand Down
8 changes: 5 additions & 3 deletions deck/SUBMISSION.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ the working implementation (CI-green, 277 tests).
> OpenTelemetry's id *format*; SENTINEL does not export OpenTelemetry traces.
> - Provenance is kept as a running union per session, not by a graph walk on
> each call.
> - In the hero scenario the `send_email` is already tainted by the customer-record
> lookup (every tool result is untrusted), and the attacker's domain is outside
> the bundled allowlist. The block does not depend on the poisoned page.
> - As submitted, the hero `send_email` was already tainted by the customer-record
> lookup, so the block did not depend on the poisoned page. Since 2026-10-18 the
> bundled policy trusts the records service's output and the scripted agent only
> acts on the page's instruction, so the page is the taint source. The attacker's
> domain is also outside the allowlist, an independent second reason.
> - Test counts are from submission time.

---
Expand Down
10 changes: 9 additions & 1 deletion deploy/DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@ Or from a checkout: `make install`, then `make dashboard`.
`SENTINEL_DASHBOARD=1` serves the dashboard and the demo endpoints behind its
buttons; without it they answer 404, which is what a real deployment wants.
Anonymous mode is fine here: the tools are in-memory mocks with no side effects,
and the port is bound to loopback. Never use it in front of real tools.
and the port is bound to loopback. Never use it in front of real tools. An
unauthenticated SENTINEL answers only to `localhost`, `127.0.0.1` and `[::1]`, and
refuses state-changing requests from other sites, so a web page cannot drive it
through your browser. To reach an anonymous instance under another host name (a
tunnel, the hosted sandbox), list it in `SENTINEL_ALLOWED_HOSTS`.

## 2. Self-host for real (free, on any machine)

Expand Down Expand Up @@ -87,6 +91,10 @@ ingress, the tool servers internal-only, Key Vault with RBAC, Cosmos DB for span
which shows the template is valid, not that it deploys or works. If you do
deploy it, please report what happened.

The demo tool servers it deploys have no authentication of their own. What keeps
them behind SENTINEL is internal-only ingress, so anything else running in the same
Container Apps environment can reach them directly. Put nothing else there.

```bash
az group create -n sentinel-rg -l eastus
az acr create -n <acr> -g sentinel-rg --sku Basic
Expand Down
Loading
Loading