diff --git a/CHANGELOG.md b/CHANGELOG.md index 13cc7d2..76b5406 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 6ba9af1..fb5ddf7 100644 --- a/README.md +++ b/README.md @@ -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. | @@ -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 @@ -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 diff --git a/docs/COMPETITIVE_POSITIONING.md b/docs/COMPETITIVE_POSITIONING.md index 40a2324..6ff5cfb 100644 --- a/docs/COMPETITIVE_POSITIONING.md +++ b/docs/COMPETITIVE_POSITIONING.md @@ -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. diff --git a/docs/design-notes.md b/docs/design-notes.md new file mode 100644 index 0000000..8d8db12 --- /dev/null +++ b/docs/design-notes.md @@ -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. | diff --git a/examples/README.md b/examples/README.md index 1198606..83fae5a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -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): diff --git a/examples/connect_over_http.py b/examples/connect_over_http.py index c37e050..756bfce 100644 --- a/examples/connect_over_http.py +++ b/examples/connect_over_http.py @@ -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 diff --git a/examples/run_live_agent.py b/examples/run_live_agent.py index 368c3ce..ff784c1 100644 --- a/examples/run_live_agent.py +++ b/examples/run_live_agent.py @@ -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. diff --git a/pyproject.toml b/pyproject.toml index 2af4ef2..76f6c45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/src/sentinel/authorization/ast.py b/src/sentinel/authorization/ast.py index 6b1d851..0a19fd3 100644 --- a/src/sentinel/authorization/ast.py +++ b/src/sentinel/authorization/ast.py @@ -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 diff --git a/src/sentinel/authorization/engine.py b/src/sentinel/authorization/engine.py index 62099db..c1ef147 100644 --- a/src/sentinel/authorization/engine.py +++ b/src/sentinel/authorization/engine.py @@ -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 diff --git a/src/sentinel/authorization/parser.py b/src/sentinel/authorization/parser.py index 08c7c58..efcc115 100644 --- a/src/sentinel/authorization/parser.py +++ b/src/sentinel/authorization/parser.py @@ -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() diff --git a/src/sentinel/authorization/policy.py b/src/sentinel/authorization/policy.py index 2ed1f29..cb5feaf 100644 --- a/src/sentinel/authorization/policy.py +++ b/src/sentinel/authorization/policy.py @@ -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: diff --git a/src/sentinel/classifier/attack_classifier.py b/src/sentinel/classifier/attack_classifier.py index 7bc8358..11df0a7 100644 --- a/src/sentinel/classifier/attack_classifier.py +++ b/src/sentinel/classifier/attack_classifier.py @@ -10,6 +10,7 @@ import asyncio import logging +import re from dataclasses import dataclass from typing import TYPE_CHECKING, Final, Literal @@ -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. @@ -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", diff --git a/src/sentinel/cli.py b/src/sentinel/cli.py index 79ff7b6..d4d0ddc 100644 --- a/src/sentinel/cli.py +++ b/src/sentinel/cli.py @@ -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``):: diff --git a/src/sentinel/control/app.py b/src/sentinel/control/app.py index 90151f7..bcf2ea8 100644 --- a/src/sentinel/control/app.py +++ b/src/sentinel/control/app.py @@ -179,16 +179,6 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: self._on_close() -def _expected_token() -> str | None: - """The bearer token every endpoint expects (None when auth is disabled). - - Resolved fresh per request rather than cached, so changing - ``SENTINEL_API_TOKEN`` and restarting takes effect. Deliberately tiny: the - auth posture is a single chokepoint. - """ - return get_settings().api_token - - def _anonymous_allowed() -> bool: """Whether running with no authentication has been explicitly requested.""" return get_settings().allow_anonymous @@ -448,10 +438,10 @@ def visible(trace: str) -> bool: def _owned_agent(agent_id: str, request: Request) -> None: """Refuse an agent that has never acted in the caller's tenant. - Trust scores are keyed by agent, not by tenant, so without this one - tenant could read — or RESET — another's quarantine. Reset is the - sharper end: clearing a quarantine you do not own re-enables an agent - someone else's policy stopped. 404 for the same reason as runs. + Trust is kept per (tenant, agent), so another tenant's agent of the + same id is a different score already. This check adds that a scoped + caller cannot probe agent ids its tenant never ran: 404, for the same + reason as runs. """ tenant = _scope(request) if tenant is None: @@ -891,5 +881,5 @@ def _to_sse(event: BroadcastEvent) -> ServerSentEvent: return ServerSentEvent( id=str(event.event_id), event="span", - data=json.dumps(event.to_payload()), + data=event.json_text, ) diff --git a/src/sentinel/control/events.py b/src/sentinel/control/events.py index c55e390..58694a1 100644 --- a/src/sentinel/control/events.py +++ b/src/sentinel/control/events.py @@ -19,6 +19,8 @@ import asyncio import bisect +import functools +import json from collections.abc import AsyncIterator from dataclasses import dataclass from typing import Any, Final @@ -43,6 +45,14 @@ class BroadcastEvent: def to_payload(self) -> dict[str, Any]: return {"event_id": self.event_id, "span": self.span.model_dump(mode="json")} + @functools.cached_property + def json_text(self) -> str: + """The event as JSON, serialized once however many subscribers stream it. + + Every live subscriber used to serialize every event itself. + """ + return json.dumps(self.to_payload()) + class EventCursorExpired(ValueError): """The requested resume point cannot be replayed without a gap.""" diff --git a/src/sentinel/control/manager.py b/src/sentinel/control/manager.py index 416fdd7..a9ca31c 100644 --- a/src/sentinel/control/manager.py +++ b/src/sentinel/control/manager.py @@ -16,6 +16,7 @@ import asyncio import functools +import importlib.util import logging import os import time @@ -48,7 +49,6 @@ AgentQuarantined, AuthorizationDecided, InjectionScanned, - InputReceived, ToolBlocked, ) from sentinel.forensics.replay import TraceReplay, replay @@ -64,21 +64,6 @@ from sentinel.trust.scorer import TrustScorer -def _infer_scenario(user_input: str) -> str: - """Best-effort scenario classification for a restored run record. - - Only used for display in the run index. Authoritative replay is from the - forensic spans; this just guesses a label from the user prompt because - the scenario name isn't itself stored as a span. - """ - text = user_input.lower() - if "bulk-delete" in text or "permanently remove" in text: - return "privilege" if "permanently remove" in text else "trust-collapse" - if "pricing page" in text or "research" in text: - return "hero-obvious" - return "restored" - - def _build_cosmos_container(settings: Settings) -> Any: # noqa: ANN401 - SDK container """Build the live async Cosmos container client (AZURE MODE). @@ -87,11 +72,20 @@ def _build_cosmos_container(settings: Settings) -> Any: # noqa: ANN401 - SDK co deploy time). Constructed lazily so the azure SDKs are imported only when Cosmos is actually configured. Not exercised offline (needs a live account). """ + # The async Cosmos client and credential need aiohttp, which SENTINEL does + # not install by default. Without this check, setting AZURE_COSMOS_ENDPOINT + # failed at startup with a bare "aiohttp package is not installed". + if importlib.util.find_spec("aiohttp") is None: + raise RuntimeError( + "Cosmos DB persistence needs aiohttp: pip install 'sentinel-prox[cosmos]' " + "(or unset AZURE_COSMOS_ENDPOINT to use the default SQLite store)" + ) from azure.cosmos.aio import CosmosClient # noqa: PLC0415 - Azure-only path from azure.identity.aio import DefaultAzureCredential # noqa: PLC0415 endpoint = settings.azure_cosmos_endpoint - assert endpoint is not None # precondition: only called when configured + if endpoint is None: # only called when configured + raise RuntimeError("AZURE_COSMOS_ENDPOINT is not set") client = CosmosClient(endpoint, DefaultAzureCredential()) database = client.get_database_client(settings.azure_cosmos_database) return database.get_container_client(settings.azure_cosmos_container) @@ -513,13 +507,18 @@ async def run_baseline(self, build: ScenarioBuild) -> dict[str, Any]: the dashboard — shows the two outboxes side-by-side so a viewer can FEEL the win, not just read the BLOCKED banner. """ + # The same driver choice and web mode as the protected run. The baseline + # used to be the scripted transcript always, so with a live model the + # dashboard compared a model's run against a script. (A live model may + # still behave differently on the second run; that is the model.) result = await run_demo_session( - build.driver, + self._select_driver(build), user_input=build.user_input, demo_mode=self._demo_mode, agent_id="baseline-no-sentinel", extra_pages=build.extra_pages, skip_sentinel=True, + real_web=self._settings.real_web_fetch, ) return { "outbox": result.outbox, @@ -588,17 +587,12 @@ async def restore_persisted_runs(self) -> int: for trace_id in trace_ids: if trace_id in self._runs: continue - spans = await self._inner_store.get_spans(trace_id) - if not spans: - continue + # The scenario and agent come from the ownership row. They used to be + # guessed from each trace's user input, which is redacted before it + # is stored, so the guess never matched, and every span of every + # trace was loaded at startup to make it. scenario = "restored" agent_id = f"agent-{trace_id[:8]}" - for s in spans: - if isinstance(s.payload, InputReceived): - # The user input doesn't tell us the scenario name, but the - # control plane only uses scenario for display. - scenario = _infer_scenario(s.payload.content) - break # Ownership comes from what was recorded when the run started. A # trace with no record predates that (or was written by something # else) and is attributed to NOBODY rather than to the default @@ -635,6 +629,7 @@ def trust(self, agent_id: str, tenant: str = DEFAULT_TENANT) -> dict[str, Any]: "quarantined": self._scorer.is_quarantined(agent_id, tenant=tenant), "below_threshold": self._scorer.crossed_threshold(agent_id, tenant=tenant), "quarantine_enforced": self._scorer.enforces_quarantine, + "quarantine_threshold": self._scorer.quarantine_threshold, } def reset(self, agent_id: str, tenant: str = DEFAULT_TENANT) -> dict[str, Any]: @@ -657,6 +652,11 @@ def capabilities(self) -> dict[str, Any]: store=self._inner_store, demo_mode=self._demo_mode, ) + # The dashboard draws its trust gauge against this; it used to hard-code 40. + summary["trust"] = { + "quarantine_threshold": self._scorer.quarantine_threshold, + "quarantine_enforced": self._scorer.enforces_quarantine, + } summary["deployment_verification"] = { "status": "unverified", "detail": ( diff --git a/src/sentinel/control/mcp_gateway.py b/src/sentinel/control/mcp_gateway.py index 01359a6..f86670b 100644 --- a/src/sentinel/control/mcp_gateway.py +++ b/src/sentinel/control/mcp_gateway.py @@ -432,6 +432,7 @@ def __init__( self._transport_sessions: dict[str, _ReservedTransportSession] = {} self._pending_transport_sessions: dict[str, int] = {} self._transport_session_lock = asyncio.Lock() + self._warned_no_session_index = False self._front = self._build_front_server() # stateless=False: keep one ServerSession per MCP session so a session's # provenance frontier persists across its successive tool calls. @@ -548,7 +549,8 @@ async def _enter(self) -> SentinelGateway: await self._connect_remote_downstream(urls) else: await self._connect_inmemory_downstream() - assert self._router is not None # every connect path sets it + if self._router is None: # every connect path sets it; never serve without one + raise RuntimeError("no downstream router after connect") # Preflight (health-check + cache schemas) so list_tools can't flake, AND # vet the catalogue itself: a poisoned tool DESCRIPTION subverts the agent # before anything is retrieved, so provenance alone cannot catch it. By @@ -791,16 +793,36 @@ def _record_session_owner(self, session_id: str, owner: str, *, tenant: str) -> # Forget sessions the transport no longer has. Only ever drops ids the # SDK has already discarded, so no live session loses its owner (which # would lock its creator out, since an unowned id is refused). - live = getattr(self._session_manager, "_server_instances", None) - if isinstance(live, dict): + live = self._live_session_ids() + if live is not None: for session in [s for s in self._session_owners if s not in live]: del self._session_owners[session] self._transport_sessions.pop(session, None) + def _live_session_ids(self) -> frozenset[str] | None: + """The session ids the SDK still holds, or None if it cannot be read. + + The SDK exposes no public way to ask, so this reads the private + ``_server_instances``. Without it, ended sessions are never released: + the owner table grows and each tenant hits its session cap for good. + tests/test_mcp_session_ownership.py pins the attribute, so an SDK that + drops it fails CI; at runtime the loss is logged rather than silent. + """ + live = getattr(self._session_manager, "_server_instances", None) + if isinstance(live, dict): + return frozenset(live) + if not self._warned_no_session_index: + self._warned_no_session_index = True + _LOG.error( + "the MCP SDK no longer exposes its session index; ended sessions " + "cannot be released, so tenants will reach their session cap" + ) + return None + def _prune_transport_sessions(self) -> None: """Drop reservations only after the SDK has ended the transport session.""" - live = getattr(self._session_manager, "_server_instances", None) - if not isinstance(live, dict): + live = self._live_session_ids() + if live is None: return for session_id in [sid for sid in self._transport_sessions if sid not in live]: self._transport_sessions.pop(session_id, None) diff --git a/src/sentinel/control/static/index.html b/src/sentinel/control/static/index.html index d5c5e13..2b30d58 100644 --- a/src/sentinel/control/static/index.html +++ b/src/sentinel/control/static/index.html @@ -410,6 +410,7 @@

SENTINEL_