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
43 changes: 43 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,49 @@ All notable changes to this project are documented here. Format follows

## [Unreleased]

### Security
- The Cosmos store writes spans with `create_item`, which fails atomically if
the id exists. It read first and then upserted, so two writers of one span
id could both see it absent and the second silently replaced the first.
- The authorization engine denies a rule with no condition instead of failing
an `assert` outside its fail-closed path (and the asserts on the policy and
gateway paths are explicit checks, which `python -O` does not strip).

### Fixed
- Documentation: what "durably emitted" means per store; the trust penalties
that actually apply; that the evasion page is written against the local
shield; which keys `sentinel.yaml` accepts; that the non-bypassability test
covers the proxy's own path, not network topology; a positioning claim of
"the same class of guarantee" as CaMeL. `docs/design-notes.md` lists the
catalogue findings kept as they are, and why.
- The rule-based attack classifier labels any server's tools by the verbs in
their names (`delete_*`, `refundCharge`, `sendSlackMessage`...); it knew only
the demo's tools, so an operator's own blocks were mostly "unclassified".
- The before/after baseline uses the same driver (scripted or live model) and
web mode as the protected run; it was always the scripted transcript.
- The dashboard's kill chain and verdict come from the run's data. They named
`web_fetch`, `block-untrusted-origin` and "the exfiltration" whatever the
run did. Its trust gauge uses the configured quarantine threshold (now in
`/capabilities` and the trust endpoint) instead of a hard-coded 40.
- The examples no longer say an external `/mcp` run appears in the live view;
it is listed in the history tab.
- Cosmos DB persistence could not start: its async Azure clients need aiohttp,
which is not installed, and setting `AZURE_COSMOS_ENDPOINT` failed at startup
with a bare import error. Install `sentinel-prox[cosmos]`; without it the
error now says so. An unused duplicate Cosmos factory was removed.
- Restoring the run index after a restart no longer loads every span of every
trace to guess a scenario label from redacted input (it never matched).
- The gateway logs an error if the MCP SDK stops exposing the session index it
reads to release ended sessions, instead of silently never releasing them;
a test pins that SDK attribute.
- Replay reports a duplicated span id as a duplicate; it was reported as a
cycle, which with one parent per span it cannot be.
- SQLite stores each span's timestamp in an indexed column (existing databases
are migrated in place), so listing traces and retention purges no longer
parse every span's JSON while holding the store lock.
- The test suite's fake Cosmos container refuses queries other than the one it
can answer; it ignored the SQL entirely.

## [0.2.2] — 2026-10-19

**Upgrade from 0.2.1 or earlier.** Read *Changed (breaking)* first: catalogue
Expand Down
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ protects you from.
| **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. |
| **State lives in one process** | Trust scores, MCP sessions, the event feed, the run index and policies replaced through `POST /tenants/{t}/policy` are in memory. A restart forgets API policy reloads (put durable policy in `SENTINEL_POLICY_FILE` / `SENTINEL_TENANT_POLICIES`), and two replicas share none of it, which is why the Azure template runs one. |
| **Cosmos persistence is partial** | With Cosmos DB, spans are stored and replayable, but run ownership, the run index after a restart, and retention purging exist only for the default SQLite store. |
| **Cosmos persistence is partial** | It needs `pip install 'sentinel-prox[cosmos]'` (for aiohttp). With Cosmos DB, spans are stored and replayable, but run ownership, the run index after a restart, and retention purging exist only for the default SQLite store. |
| **Some state is visible across tenants** | Event ids on `/events` are one global counter, so a tenant can infer how busy the deployment is. `/downstream` and `/capabilities` describe the whole deployment to any authenticated caller. |
| **Calls the SDK rejects are not recorded** | Arguments that fail a tool's input schema are refused by the MCP SDK before SENTINEL sees the call, so they leave no forensic record. |

Expand Down Expand Up @@ -205,8 +205,13 @@ access-controlled forensic store. There is a test asserting the synthetic SSN an
API key never appear in log output.

Precedence is **CLI flag > environment variable > config file > default**, so a
container can override a checked-in file. Every key has an env equivalent
(`SENTINEL_MCP_SERVERS`, `SENTINEL_POLICY_FILE`, …) — see [`.env.example`](./.env.example).
container can override a checked-in file. The file takes only these keys, and an
unknown key is an error: `servers`, `policy`, `catalogue_strict`,
`catalogue_approvals`, `real_web_fetch`, `api_token` and
`downstream_timeout_seconds` (each also an environment variable:
`SENTINEL_MCP_SERVERS`, `SENTINEL_POLICY_FILE`, ...), plus `host`, `port`,
`dashboard`, `log_level` and `log_format` (also `sentinel serve` flags). Every other
setting is environment-only; see [`.env.example`](./.env.example).

### Writing policy

Expand Down Expand Up @@ -358,7 +363,8 @@ to wake). It runs in **anonymous demo mode with authentication disabled**, so tr
it as a public sandbox, not as an example of a secured deployment: everything in it
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
attacker; the Layer-1 filter misses the obfuscated variant (written to slip past its
local patterns, so the miss is staged) and authorization blocks it
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
Expand Down
7 changes: 5 additions & 2 deletions docs/COMPETITIVE_POSITIONING.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,11 @@ solved with provable security**, vs 84% undefended.

**Our defensible edge vs CaMeL: deployability.** CaMeL requires rearchitecting the agent into
a dual-LLM interpreter pattern — a research architecture, hard to retrofit onto an existing
product. SENTINEL gets the same class of guarantee by *topology*, with an unmodified agent.
That is the commercially important difference, and it is real.
product. SENTINEL gets a coarser guarantee of the same kind by *topology*, with an unmodified
agent: taint is tracked per MCP session and per tool result, not per value inside
the agent's program, so it blocks more benign flows than CaMeL would and cannot
follow data across sessions. Deployability is the commercially important
difference, and it is real; equivalence of guarantees is not claimed.

**Their edge over us: evidence.** They have a number. We have a demo. See §4.

Expand Down
35 changes: 35 additions & 0 deletions docs/design-notes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Design notes: findings kept as they are

The 2026-10-17 findings catalogue listed about 180 items. Most were fixed; see the
CHANGELOG. These were examined and deliberately left as they are. Each says why,
so a later review can disagree with the reasoning rather than rediscover the item.

| ID | What the catalogue said | Why it stays |
|---|---|---|
| PP-04, GW-9 | Taint lives in one MCP session; a new session starts clean. | The proxy has no identity for "the same task" across sessions. Carrying taint per credential would deny every guarded action for an agent forever after one untrusted read. Documented in the README's *Known limitations*. |
| PP-06 | Declassification never clears taint already in the session. | Declassification vouches for one value, not for what the agent did with earlier untrusted content. Clearing the session on it would let any declassifiable tool launder the lineage. |
| PP-11, DEMO-24 | The demo baseline and the Phase 1 skeleton reach tools without `_intercept`. | Both exist to show what happens without SENTINEL. The baseline is reachable only through the demo endpoints, which answer 404 unless `SENTINEL_DASHBOARD=1`; neither is reachable from `/mcp`. |
| PP-14 | The per-session lock is held across the downstream call. | It is what keeps a session's provenance updates ordered. The call is now bounded by `SENTINEL_DOWNSTREAM_TIMEOUT_SECONDS`, so the wait is bounded too. |
| PP-15 | The union-vs-walk test uses the union's own derivation rule. | True, and now stated in the test. It verifies the incremental computation; the rule (each call derives from everything observed before it) is the design. |
| TRUST-2 | The trust score never recovers. | Why quarantine is recorded, not enforced, by default. Documented in *Known limitations*. |
| TRUST-3 | Quarantine flags are set before the event is written. | Fail-closed: if the write fails the agent is still quarantined, and the log line is written first. Reversing it would let an agent through when recording fails. |
| TRUST-5 | Only some events cost trust. | Catalogue refusals are the downstream's change, the user's own prompt is not agent behaviour, and the local shield is a heuristic. Now stated in `trust/config.yaml`. |
| TRUST-6, CM-7, GW-2 | Trust and run state are per process. | Shared state would need a store SENTINEL does not have. The Azure template runs one replica; documented. |
| AUTHZ-14 | "Deny-overrides" is vacuous with deny-only rules. | The term still describes the behaviour correctly (any deny wins); it is simply trivially satisfied. |
| AUTHZ-17 | A module named `ast` shadows the standard library's name. | It is only ever imported as `sentinel.authorization.ast`. Renaming it would break imports for anyone using the package API, for no behaviour change. |
| DCC-18 (part) | `sentinel check` fails on flag-only findings that `serve` accepts. | A readiness check should not pass findings an operator still has to triage. |
| DCC-22 (part) | No way to authenticate to downstream servers. | A missing feature, not a defect; listed in *Known limitations*. |
| CM-13, GW-19 | Global event ids reveal activity volume; `/downstream` and `/capabilities` are deployment-wide. | Per-tenant counters would change the event-cursor protocol. Listed in *Known limitations*. |
| PP-16 | Calls the MCP SDK rejects for schema reasons are not recorded. | They never reach SENTINEL. Recording them would mean turning off the SDK's validation and doing it inside the proxy; listed in *Known limitations*. |
| COSMOS-1 | The Cosmos path has no ownership, restore or retention. | A larger piece of work on a paid path; listed in *Known limitations*. Cosmos can now at least start (it needs the `cosmos` extra). |
| TCD-04, OWN-6 | Real-HTTP tests are order-dependent and can hang. | The open teardown defect in `docs/mcp-streamable-http-teardown.md`, tracked by a strict xfail and the quarantined CI job. |
| RED-1 (rest) | Redaction keeps tool names and deny reasons in the clear. | Argument names and values are redacted. Tool names and reasons are what an investigator reads first, and they come from the catalogue and the policy, not from the caller's data. A caller-invented tool name is still logged with control characters escaped. |
| AUTHZ-15 (rest) | No static check of predicate variables or config types. | Loading now warns about comparands the config does not declare and config keys that shadow call fields, refuses duplicate rule ids, and a scalar where a collection is expected denies at evaluation. A full type checker for config values is not worth its complexity here. |
| DCC-23 (rest) | `verify_catalogue` and `DownstreamTopology.server_of`/`tool_names` are unused in production. | Small, tested, public helpers for anyone building on the package; the truly unused `build_web_server` was removed. |
| TCD-26 | The codebase's history starts with a large root commit. | A fact about the repository's history; nothing to change in the code. |

## Not done

| ID | What | Status |
|---|---|---|
| GW-12 | The browser session cookie is the raw bearer token; there is no server-side session, expiry or revocation. | Open. The cookie is `HttpOnly` and `SameSite=Strict`, and the dashboard is off unless `SENTINEL_DASHBOARD=1`, but a server-side session store remains to be built. |
5 changes: 3 additions & 2 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,9 @@ python examples/connect_over_http.py # a real mcp client drives the hero atta
SENTINEL-specific code** — to connect to `/mcp`, list tools, and run the hero
kill chain. The poisoned-lineage `send_email` comes back `isError=True` with
`SENTINEL blocked …`. Swap in your own client the same way; to your agent,
SENTINEL is just an MCP tool server. Each connection is its own `live-mcp` trace
on the dashboard.
SENTINEL is just an MCP tool server. Each connection is its own `live-mcp` run,
listed in the dashboard's history tab (the live view follows dashboard-launched
runs only).

For a **real LLM agent loop** (the model decides; SENTINEL is the backstop):

Expand Down
5 changes: 3 additions & 2 deletions examples/connect_over_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@
What you'll see: tool discovery and the legit read calls succeed, and the
poisoned-lineage ``send_email`` to the attacker is BLOCKED at the MCP boundary
with a clean error — the same result as the in-process demo, now over real HTTP.
Open the dashboard (http://127.0.0.1:8765) to watch the live run appear as a
``live-mcp`` trace.
The session is recorded as a ``live-mcp`` run: open the dashboard
(http://127.0.0.1:8765), go to the history tab and select it to replay its trace.
The live view follows runs launched from the dashboard itself.
"""
from __future__ import annotations

Expand Down
3 changes: 2 additions & 1 deletion examples/run_live_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
# ...or with NO credentials, using the offline susceptible stand-in model:
python examples/run_live_agent.py --offline

Watch http://localhost:8765 — the run appears live as a `live-mcp` trace.
The session is recorded as a `live-mcp` run; find it in the dashboard's history
tab (http://localhost:8765). The live view follows dashboard-launched runs only.

Exit status: 0 when SENTINEL blocked at least one action, 4 when nothing was
blocked, 3 when no live model is available, 2 when the run failed.
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ Demo = "https://sentinel-i63x.onrender.com"
sentinel = "sentinel.cli:main"

[project.optional-dependencies]
# Cosmos DB persistence uses the async Azure clients, which need aiohttp.
cosmos = ["aiohttp>=3.9,<4"]
dev = [
"pytest>=8.3",
"pytest-asyncio>=0.24",
Expand Down
2 changes: 1 addition & 1 deletion src/sentinel/authorization/ast.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
== != < >= in "not in"

(Note: ``<=`` and ``>`` are deliberately NOT supported; the parser rejects them
at load time.) Operands are typed literals (label / string / number / set) or
at load time.) Operands are typed literals (label / string / number / bool / set) or
variables resolved from an evaluation namespace (typed tool args + provenance +
config). Set membership and set equality are first-class so provenance
predicates like ``RETRIEVED_CONTENT in effective_provenance`` and
Expand Down
5 changes: 4 additions & 1 deletion src/sentinel/authorization/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,10 @@ def _evaluate(
) -> tuple[bool, str | None]:
if rule.deny_always:
return True, "deny_always"
assert rule.condition is not None # guaranteed by the compiler
if rule.condition is None:
# The compiler never produces this. An assert here sat outside the
# fail-closed try below (a crash, not a deny) and vanished under -O.
return True, "rule has no condition (fail-closed deny)"
try:
return rule.condition.evaluate(namespace), None
except Exception as exc: # noqa: BLE001 - any failure to evaluate is a deny
Expand Down
3 changes: 2 additions & 1 deletion src/sentinel/authorization/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,8 @@ def _tokenize(text: str) -> list[_Token]:
)
pos = match.end()
kind = match.lastgroup
assert kind is not None
if kind is None: # every alternative in _TOKEN_RE is named
raise PolicyParseError(f"unnamed token at position {pos} in {text!r}")
if kind == "WS":
continue
value = match.group()
Expand Down
3 changes: 2 additions & 1 deletion src/sentinel/authorization/policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,8 @@ def _compile(doc: _PolicyDoc) -> CompiledPolicy:
if rule.deny_always:
compiled.append(CompiledRule(id=rule.id, deny_always=True, condition=None))
else:
assert rule.deny_if is not None # guaranteed by _RuleDoc validator
if rule.deny_if is None: # _RuleDoc's validator guarantees this
raise PolicyLoadError(f"tool {tool_name!r} rule {rule.id!r}: no effect")
try:
condition = parse_condition(rule.deny_if)
except PolicyParseError as exc:
Expand Down
34 changes: 34 additions & 0 deletions src/sentinel/classifier/attack_classifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import asyncio
import logging
import re
from dataclasses import dataclass
from typing import TYPE_CHECKING, Final, Literal

Expand Down Expand Up @@ -62,6 +63,23 @@ class AttackLabel:
classifier: str # which backend produced it


_PRIVILEGED_VERBS: Final[frozenset[str]] = frozenset(
{"delete", "remove", "drop", "destroy", "purge", "wipe", "truncate", "revoke", "grant",
"transfer", "pay", "refund", "charge", "withdraw", "exec", "execute", "shell", "sudo",
"admin", "kill", "terminate"}
)
_OUTBOUND_VERBS: Final[frozenset[str]] = frozenset(
{"send", "email", "mail", "post", "publish", "upload", "share", "forward", "notify",
"message", "tweet", "webhook", "export", "sync", "push"}
)


def _name_words(tool_name: str) -> frozenset[str]:
"""``sendSlackMessage`` / ``delete-record`` / ``repo.push`` -> their lowercase words."""
spaced = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", tool_name)
return frozenset(w for w in re.split(r"[^A-Za-z0-9]+", spaced.lower()) if w)


def _rule_label(attempt: BlockedAttempt) -> AttackLabel:
"""Deterministic labelling (DEMO MODE) — classify by the ACTION attempted.

Expand Down Expand Up @@ -89,6 +107,22 @@ def _rule_label(attempt: BlockedAttempt) -> AttackLabel:
"attempted to pull sensitive data on an untrusted (tainted) lineage",
_CLASSIFIER_RULES,
)
# Any other server's tools, by the verbs in their names. The rules above
# know only the demo's tools, so an operator's own blocks were mostly
# "unclassified" (review CLS-2).
words = _name_words(tool)
if words & _PRIVILEGED_VERBS:
return AttackLabel(
"privilege_escalation",
"attempted a destructive or privileged action",
_CLASSIFIER_RULES,
)
if words & _OUTBOUND_VERBS and (attempt.is_tainted or attempt.blocked_by == "authorization"):
return AttackLabel(
"exfiltration",
"attempted to send data out through an outbound tool",
_CLASSIFIER_RULES,
)
if attempt.injection_detected or attempt.is_tainted:
return AttackLabel(
"instruction_override",
Expand Down
3 changes: 2 additions & 1 deletion src/sentinel/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@

Configuration precedence is the usual one: **CLI flag > environment variable >
config file > default**, so a container can override a checked-in file without
editing it.
editing it. The file accepts only the keys in ``_CONFIG_KEYS``; every other
setting is read from the environment alone.

The config file (default ``sentinel.yaml``)::

Expand Down
Loading
Loading