Skip to content

Findings-catalogue fixes, part 2 (gateway, policy language, fetch, CLI, proxy, store) - #5

Merged
Harshith029 merged 19 commits into
mainfrom
catalogue-remaining
Oct 18, 2026
Merged

Harshith029 merged 19 commits into
mainfrom
catalogue-remaining

Conversation

@Harshith029

Copy link
Copy Markdown
Owner

Stacked on #4 (which is stacked on #3). Merge those first; GitHub retargets this PR to main and CI runs then. CI only runs on PRs into main.

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

  • Gateway: websockets on /mcp are refused before reaching the SDK, owners survive a refused DELETE, tools/list re-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)
  • Policy language: true/false literals; null refused; 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 to transfer_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)
  • CLI and config: strict booleans, unknown keys rejected, empty env vars treated as unset, the nested-path git check fixed, long scaffold keys no longer folded, an explicit Azure shield honoured, and credentials stripped from URLs in errors. (DCC-09/10/11/19/20/21/22)
  • Proxy: structuredContent is size-capped, start() runs once, and control characters in addresses are refused. (PP-08/09/10)
  • Trust, store and manager: tuple trust keys, safe cancellation in SQLite, bounded memory, live sessions excluded from the run quota, cancelled runs marked failed, and orphan rows purged. (TRUST-4/7, STORE-1/3/4, CM-9/11/16, MEM-1, DEMO-08)
  • Shield and classifier: outages no longer fail calls, the Azure client is reused, and labels must match exactly. (SHIELD-3, CLS-1/2)
  • Downstream: tools/list pagination; approval uses the same snapshot as routing. (DCC-15/16)
  • Sanitizer: strict iso_date and plain decimal_amount. (PP-07/13)
  • Tests and CI: the planted secret moved to a temporary repo, a real 401 is required, action pins aligned, uvloop pinned. (TCD-11/12/14/23)
  • Observability: the tenant appears in security logs; /capabilities reports the running mode. (CM-14/17)
  • Dashboard: refused and failed runs are shown; auto-play runs once. Verified in a browser. (DEMO-11/20)
  • Demo: custom pages work under real fetch; the live example exits non-zero; approvals are honoured. (DEMO-13/21/22)
  • Forensics and policy loading: declassified_by is recorded; a warning when config shadows a call field. (PP-12, AUTHZ-11)
  • Docs: the docs and docstrings now match the code; new README known-limitations rows; dated correction notes on the deck write-ups. (TCD-03/07/08/09/16-20, DCC-17, DEMO-01/12/15/25, PP-05, COSMOS-1, CM-13, GW-19, PP-16)

Breaking changes (see CHANGELOG)

  • Bundled policy v5.
  • Policy language: literals, number rules, a depth limit, and unique rule ids.
  • sentinel.yaml rejects unknown keys and non-boolean booleans.
  • TrustScorer records quarantine by default; it no longer enforces it unless asked.
  • Stricter declassification schemas.

Deliberately not changed

  • TRUST-3: the quarantine flag is set before the event write, so it fails closed.
  • TCD-13: the Linux CI log doesn't show how the xfail fails there, so I didn't narrow it.
  • DCC-18: sentinel check still fails on flag-only findings.
  • DEMO-01: redesigning the hero demo so the block depends on the poisoned page is a product decision. The docs now say what the block does depend on.

Testing

  • Windows, Python 3.11, mcp 1.30.0 (a fresh venv from the lock): 660 passed, 12 skipped, 1 xfailed.
  • All 12 real-MCP diagnostics pass, one per process.
  • ruff: clean. mypy --strict: clean. Lock checker: clean.
  • Python 3.12 is CI-only (not installed on this machine).

🤖 Generated with Claude Code

Harshith029 and others added 18 commits October 18, 2026 04:25
…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>
@Harshith029
Harshith029 changed the base branch from drop-otel-deps to main October 18, 2026 10:41
@Harshith029
Harshith029 merged commit 906225a into main Oct 18, 2026
7 checks passed
@Harshith029
Harshith029 deleted the catalogue-remaining branch October 19, 2026 17:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant