Repository navigation
Findings-catalogue fixes, part 2 (gateway, policy language, fetch, CLI, proxy, store) - #5
Merged
Merged
Conversation
…e re-checks, per-tenant SSE
Findings-catalogue items, each verified against the code with a test that
fails before the change:
- GW-17: Mount("/mcp") routes websockets to the gateway, which passed any
non-HTTP scope straight to the MCP SDK before the credential check. Now
refused at the handshake (close 1008).
- GW-8: the owner record was dropped after any DELETE. One the SDK refused
(bad protocol version) left the session alive but its owner locked out.
Owners are forgotten only on a 2xx.
- GW-7: the SDK runs the tools/list handler on every tool-cache miss, and
that handler re-checked every downstream catalogue, so each call with an
unknown tool name cost N downstream round trips (5 calls -> 5 checks).
On-demand checks now run at most every 5s; the scheduled check is separate.
- GW-10: a session retired for idleness was told "at capacity, retry
shortly", which can never succeed. It is now told to open a new session.
- GW-15: the SSE subscriber cap was one count shared by every tenant; it is
now per tenant.
- GW-13: with one SENTINEL_API_TOKEN and no admin token, the agents'
credential is the operator's. check and serve now warn, and the README no
longer says only the admin token can administer.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… refusals Findings-catalogue items, each verified with a test that fails before: - AUTHZ-8: true/false parsed as variables, so a call argument named "true" decided `is_tainted == true`. They are literals now; null/None are refused. A boolean never equals a number (True == 1 matched). - AUTHZ-9: `==` compared "1000" and 1000 as different while `>=` coerced the string; numeric strings now compare numerically. Non-finite numbers are errors, so "-Infinity" no longer passes a cap. The bundled policy (v5) adds a lower bound to transfer_funds. - AUTHZ-6/7: deep parentheses or `not` chains raised RecursionError at load (a 500 on reload), and long and/or chains loaded but could never be evaluated, and crashed the engine's comparand scan. Predicates are capped at 64 levels at load; tree walks are iterative. - AUTHZ-15 (part): duplicate rule ids within a tool are refused. - AUTHZ-12: the agent's refusal carried the evaluation detail (config names, operand types); it now names only the rule. The forensic record keeps the full reason. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d hosts as errors Findings-catalogue items, each with a test that fails before the change: - DCC-14: the default resolver is blocking getaddrinfo, called on the event loop every session shares. Target preparation now runs in a thread. - DCC-13: `timeout` bounds each network operation, so a server sending a byte just inside it kept a fetch going indefinitely. `deadline` (30s) bounds the whole fetch, redirects included. - DCC-12: a label over 63 characters (UnicodeError from getaddrinfo) or an invalid IDNA label (idna.InvalidCodepoint from URL.host) raised out of fetch_url, which is documented never to raise. Both are error results now, and any other exception is caught at the top as the contract says. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…olding, explicit azure Findings-catalogue items, each with a test that fails before the change: - DCC-11: config-file booleans were converted by truthiness, so the quoted string "false" meant on. Only real booleans and true/false/yes/no/on/off/ 1/0 are accepted. - DCC-10 (part): unknown keys in sentinel.yaml were ignored silently; a misspelt key is now an error that lists the known ones. - DCC-09: a set-but-empty env var masked the file's value (an empty SENTINEL_POLICY_FILE selected the bundled policy). Empty counts as unset, as Settings reads it. - DCC-19: the commit-warning check ran git from the file's parent with the full relative path, so sub/sentinel.yaml was checked as sub/sub/.... - DCC-20: PyYAML folds long plain scalars at 80 characters, so a long tool name with spaces became a two-line key and the scaffold did not load. - DCC-21: demo mode (the default) turned an explicit SENTINEL_SHIELD=azure into the local regexes. It now affects "auto" only. - DCC-22 (part): connection errors printed the full server URL, user info and query string included. CHANGELOG entries for this and the web_fetch commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ol chars in addresses Findings-catalogue items, each with a test that fails before the change: - PP-08: _result_size counted content blocks only, so a downstream could hand the agent unbounded structuredContent past SENTINEL_MAX_RESULT_BYTES. - PP-09: start() took no lock and reset the lineage to the origin label, so a second call cleared a session's taint. It now runs once, under the lock. - PP-10 (part): SENTINEL authorizes on parsed addresses but forwards the raw value. A CRLF inside one passed the allowlist here and could add headers in the downstream mail tool; any control character now makes it unparseable. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d state, run accounting Findings-catalogue items, each with a test that fails before the change: - TRUST-7: trust state was keyed "tenant<US>agent", so names containing the separator could name another pair's state. Keys are tuples now. - TRUST-4 / DEMO-08: TrustScorer enforced quarantine by default, unlike the service and its own docstring, so the standalone demo enforced while the dashboard did not. It records by default; run_trust_collapse asks for enforcement explicitly and the enforcement tests say enforce=True. - STORE-1: cancelling a caller released the SQLite lock while its to_thread worker still used the connection. The lock is now held until the thread ends. STORE-3: spans use a plain INSERT, not INSERT OR REPLACE. - STORE-4 / CM-16: ownership rows for runs that never wrote a span were never purged. - CM-9: live MCP sessions counted against the per-tenant scenario quota. - CM-11: a run cancelled at shutdown stayed "running". - MEM-1: trust state is capped (LRU, never evicting a crossed agent or one mid-update) and the emitter frees a trace's seq counter when its run ends or its session is collected. Not changed, deliberately: TRUST-3. The quarantine flag is set before the event is written, so a failed write still quarantines (fail-closed), and the log line is written first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nt; exact labels Findings-catalogue items, each with a test that fails before the change: - SHIELD-3: an Azure Prompt Shields error propagated out of the result scan after the downstream tool had run, failing the call after its side effect (the user-prompt scan likewise failed session start). The scan is informational; a failure is now recorded as "scan failed" and the call completes, its result tainted as always. - CLS-1: the Azure classifier built a new DefaultAzureCredential and client per call and fetched the token synchronously on the event loop; an error failed the whole audit endpoint. One client is built and reused, tokens are fetched in a worker thread (the async credential needs aiohttp, which SENTINEL does not depend on), and errors fall back to the rule label. - CLS-2 (part): the reply parse matched substrings, so "not exfiltration" became exfiltration. A reply must be exactly one class. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Findings-catalogue documentation items, each checked against the code:
- TCD-07 / PP-05: README, CONTRIBUTING and the provenance package described
a per-call cycle-safe graph walk. Enforcement keeps a running union; the
walk is the reference it is tested against.
- DEMO-01 / OWN-2 / TCD-05 (docs): the hero block does not depend on the
poisoned page. Verified: with no page fetched, send_email is still denied
by block-untrusted-origin, because the record lookup taints the session.
The README now says so. (Redesigning the demo is left as a decision.)
- TCD-08 / TCD-09: the deck write-ups claimed App Insights, Key Vault, a
Foundry driver and OTel spans, and the demo script's origin badge was
wrong (the record lookup shows {USER}; the next call already carries
RETRIEVED_CONTENT). Kept as submitted, with a dated correction note.
- TCD-03: the pytest timeout comment called a hard process exit an ordinary
failure. TCD-16: a stale test count. TCD-17: the teardown doc said 20s
where the test bounds at 8s. TCD-18: the scoping doc said no code exists.
TCD-19: SDK_VERIFICATION claimed an empty-tool-list assertion that did not
exist; it does now. TCD-20: CONTRIBUTING said CI runs 3.11 only.
- DCC-17: docstrings claimed catalogue findings are forensic events, a
list_changed subscription, real records/email servers, and an SDK client
with no connect timeout. DEMO-12: "identical pipeline" is qualified to the
interception core. DEMO-15: Foundry's PROXIED_TOOLS listed transfer_funds,
which no demo server registers. DEMO-25: preflight overstated resilience.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…outes came from Findings-catalogue items, each with a test that fails before the change: - DCC-16: only the first page of tools/list was read, so a paginating server's later tools were never discovered, routed or scanned. Discovery and the router's merged listing follow nextCursor, up to 100 pages; a server that pages forever is refused. - DCC-15: routes came from discovery's listing and the approved, scanned catalogue from a second listing in preflight. A tool could then be approved from one server's definition and routed to another's. Preflight now approves topology.discovered(), the listing routes were built from, in both serve and check. Real-HTTP declared and legacy path diagnostics pass one per process. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
These schemas decide what clears taint, so they should be exactly as narrow
as documented. Each case has a test that fails before the change.
- PP-13: IsoDateSchema promised strict YYYY-MM-DD but used
date.fromisoformat, which since Python 3.11 also takes "20261018" and ISO
week dates ("2026-W42-1").
- PP-07 (part): DecimalAmountSchema passed strings to Decimal(), which takes
"1_000", "1e9999" and digits from any script. Strings must now be plain
ASCII notation, at most 18 digits either side of the point.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ins, uvloop pinned Findings-catalogue test and CI hygiene items: - TCD-14: the gitleaks test staged a fake GitHub token in this repository's own index and removed it in a finally. The suite's thread timeout ends the process without running finally, so a hang would have left it staged. It now plants and scans in a throwaway repo with this repo's pre-commit config (checked by hand: gitleaks reports github-pat there). - TCD-12: the /mcp bearer test counted any exception, a timeout included, as a refusal, and never closed its authenticated client. It now requires a 401 and closes the client. - TCD-23: the quarantined real-MCP job pinned checkout v4 and setup-python v5 while every other job uses v5 and v6. - TCD-11 (part): uvloop, which CI installs on Linux through uvicorn[standard], was unpinned. Pinned to 0.23.0, the version CI used; the lock checker tolerates its absence on Windows. Not changed: TCD-13 (strict xfail without raises=). The Linux CI log does not show how that test fails there, so narrowing the marker could break CI without a run to confirm it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…port the running mode Findings-catalogue items, each with a test that fails before the change: - CM-17 (part): allow, block, quarantine and injection-flag log lines carried no tenant, so a multi-tenant operator could not tell whose event a line was. They now include it (JSON field, and [tenant ...] in text). - CM-14: the capability mode label read settings.demo_mode, ignoring the manager's own demo_mode override. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sh dashboard
Findings-catalogue items, verified in a browser against sentinel serve
--dashboard:
- DEMO-11: launch() read the JSON without checking the HTTP status, so a
refused launch (unknown scenario, quota, auth) left an undefined trace and
a spinner that never stopped; a run failing before its first span did the
same. Refusals now show REFUSED with the server's reason, and a watcher
polls GET /runs/{id} and shows a failed run's error.
- DEMO-20: every page load launched a run and its baseline. Auto-play now
happens once per tab, and only when the dashboard has no runs.
Static checks pin the code paths, since CI has no browser.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…zero on no block - DEMO-13: with real web fetch on, the protected run fetched the live URL and ignored the custom attack's page, while the baseline (which never fetches for real) used it, so the before/after compared different pages. A custom page now wins for its URL. Test fails before the change. - DEMO-21: examples/run_live_agent.py exited 0 when nothing was blocked. It now exits 4 (2 stays "run failed"), and the exit codes are documented. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DEMO-22: run_demo_session's preflight did not pass the operator's SENTINEL_CATALOGUE_APPROVALS, so a demo run refused a tool definition that sentinel serve accepts. It now passes them as the gateway does. Test fails before the change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ved findings DCC-18, each with a test that fails before the change: - Flag-only findings were listed without the fingerprints needed to approve them; only the strict refusal printed those. - With approvals in place, check reported "no shadowing, no poisoning" about a catalogue the scanner had flagged. It now lists the approved findings. Kept deliberately: check still fails on flag-only findings that serve would accept. A readiness check should not pass findings an operator has to triage. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ig shadowing a call field Findings-catalogue items, each with a test that fails before the change: - PP-12: a declassified result's ToolExecuted span showed result_label SYSTEM and nothing about what cleared it. It now carries declassified_by (the schema name); older spans load with None. - AUTHZ-11: config is layered over arguments, so a config key named like a call field (say `amount`) replaced that field in every rule without a word. Loading such a policy now logs a warning naming the rule and key. The bundled policy triggers none. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…uick fixes Rows added to Known limitations, each checked against the code: - AUTHZ-4 / GW-2 / CM-7 / TRUST-6: trust, sessions, the event feed, the run index and API policy reloads are per process; a restart forgets API reloads and replicas share nothing. - COSMOS-1: the Cosmos store implements put and get_spans only; ownership, restore and retention are SQLite-only. - CM-13 / GW-19: global event ids reveal deployment activity volume; the downstream and capabilities endpoints are not tenant-scoped. - PP-16: calls the MCP SDK rejects for schema reasons never reach SENTINEL and are not recorded. Also: rug-pull re-checks on tools/list are now at most every 5 s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #4 (which is stacked on #3). Merge those first; GitHub retargets this PR to
mainand CI runs then. CI only runs on PRs intomain.More findings-catalogue fixes. Every fix comes with a test that fails on the code before the change; I checked each one by running it against the old code. Each commit message lists its catalogue IDs.
Commits by area
/mcpare refused before reaching the SDK, owners survive a refusedDELETE,tools/listre-checks are throttled, live-stream (SSE) caps are per tenant, retired sessions get a clear message, and a shared single token produces a warning. (GW-7/8/10/13/15/17)true/falseliterals;nullrefused; numbers compared consistently; NaN and infinity rejected; depth limits; duplicate rule ids refused; refusals sent to the agent no longer include internal detail. Bundled policy v5 adds a lower bound totransfer_funds. (AUTHZ-6/7/8/9/12/15)web_fetch: DNS lookups moved off the event loop, an overall deadline, and malformed hosts return errors instead of raising. (DCC-12/13/14)structuredContentis size-capped,start()runs once, and control characters in addresses are refused. (PP-08/09/10)tools/listpagination; approval uses the same snapshot as routing. (DCC-15/16)iso_dateand plaindecimal_amount. (PP-07/13)uvlooppinned. (TCD-11/12/14/23)/capabilitiesreports the running mode. (CM-14/17)declassified_byis recorded; a warning when config shadows a call field. (PP-12, AUTHZ-11)Breaking changes (see CHANGELOG)
sentinel.yamlrejects unknown keys and non-boolean booleans.TrustScorerrecords quarantine by default; it no longer enforces it unless asked.Deliberately not changed
sentinel checkstill fails on flag-only findings.Testing
mcp1.30.0 (a fresh venv from the lock): 660 passed, 12 skipped, 1 xfailed.ruff: clean.mypy --strict: clean. Lock checker: clean.🤖 Generated with Claude Code