ct-toolkit 0.5.0__tar.gz → 0.5.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/PKG-INFO +5 -5
  2. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/README.md +3 -3
  3. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/__init__.py +1 -1
  4. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/core/tool_guard.py +44 -4
  5. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/provenance/log.py +15 -2
  6. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/proxy.py +57 -23
  7. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/server.py +27 -0
  8. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit.egg-info/PKG-INFO +5 -5
  9. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit.egg-info/requires.txt +1 -1
  10. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/pyproject.toml +2 -2
  11. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/LICENSE +0 -0
  12. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/__main__.py +0 -0
  13. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/cli.py +0 -0
  14. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/core/__init__.py +0 -0
  15. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/core/async_wrapper.py +0 -0
  16. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/core/circuit_breaker.py +0 -0
  17. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/core/compatibility.py +0 -0
  18. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/core/compression_guard.py +0 -0
  19. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/core/exceptions.py +0 -0
  20. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/core/integrity.py +0 -0
  21. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/core/kernel.py +0 -0
  22. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/core/tool_guard_settings.py +0 -0
  23. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/core/wrapper.py +0 -0
  24. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/divergence/__init__.py +0 -0
  25. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/divergence/analysis.py +0 -0
  26. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/divergence/engine.py +0 -0
  27. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/divergence/l2_judge.py +0 -0
  28. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/divergence/l3_icm.py +0 -0
  29. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/divergence/loss.py +0 -0
  30. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/divergence/scheduler.py +0 -0
  31. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/endorsement/__init__.py +0 -0
  32. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/endorsement/probes/__init__.py +0 -0
  33. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/endorsement/reflective.py +0 -0
  34. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/identity/__init__.py +0 -0
  35. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/identity/embedding.py +0 -0
  36. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/identity/templates/__init__.py +0 -0
  37. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/identity/templates/defense.yaml +0 -0
  38. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/identity/templates/finance.yaml +0 -0
  39. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/identity/templates/general.yaml +0 -0
  40. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/identity/templates/medical.yaml +0 -0
  41. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/kernels/__init__.py +0 -0
  42. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/kernels/default.yaml +0 -0
  43. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/kernels/defense.yaml +0 -0
  44. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/middleware/__init__.py +0 -0
  45. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/middleware/autogen.py +0 -0
  46. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/middleware/crewai.py +0 -0
  47. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/middleware/deepagents.py +0 -0
  48. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/middleware/langchain.py +0 -0
  49. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/middleware/litellm.py +0 -0
  50. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/profile_checksums.sha256 +0 -0
  51. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/provenance/__init__.py +0 -0
  52. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/provenance/ledger.py +0 -0
  53. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/provenance/tool_calls.py +0 -0
  54. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/provenance/vault/__init__.py +0 -0
  55. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/utils/__init__.py +0 -0
  56. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/utils/cache.py +0 -0
  57. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/utils/logger.py +0 -0
  58. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/utils/metrics.py +0 -0
  59. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/utils/sensitive_masker.py +0 -0
  60. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/utils/telemetry.py +0 -0
  61. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/utils/trajectory_scrub.py +0 -0
  62. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit.egg-info/SOURCES.txt +0 -0
  63. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit.egg-info/dependency_links.txt +0 -0
  64. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit.egg-info/entry_points.txt +0 -0
  65. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit.egg-info/top_level.txt +0 -0
  66. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/setup.cfg +0 -0
  67. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/tests/test_analysis.py +0 -0
  68. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/tests/test_async_and_streaming.py +0 -0
  69. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/tests/test_background_latency.py +0 -0
  70. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/tests/test_cli.py +0 -0
  71. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/tests/test_cli_extended.py +0 -0
  72. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/tests/test_deep_agents_integration.py +0 -0
  73. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/tests/test_distributed_headers_and_cli.py +0 -0
  74. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/tests/test_enterprise_db_and_otel.py +0 -0
  75. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/tests/test_l1_semantic.py +0 -0
  76. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/tests/test_loss.py +0 -0
  77. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/tests/test_optional_dependencies.py +0 -0
  78. {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/tests/test_summarization_drift.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ct-toolkit
3
- Version: 0.5.0
3
+ Version: 0.5.2
4
4
  Summary: Computational Theseus Toolkit — Identity Continuity Guardrails for Agentic Systems
5
5
  Author: Hakan Damar
6
6
  License: Apache-2.0
@@ -33,7 +33,7 @@ Requires-Dist: uvicorn>=0.29.0
33
33
  Provides-Extra: server
34
34
  Provides-Extra: cli
35
35
  Provides-Extra: observability
36
- Requires-Dist: opentelemetry-api>=1.25.0; extra == "observability"
36
+ Requires-Dist: opentelemetry-api>=1.45.2; extra == "observability"
37
37
  Requires-Dist: opentelemetry-sdk>=1.25.0; extra == "observability"
38
38
  Requires-Dist: langsmith>=0.8.18; extra == "observability"
39
39
  Provides-Extra: structured
@@ -157,7 +157,7 @@ $ ct-toolkit status
157
157
  | | | | | | |____ ____) | |____| |__| |____) | | |__| | |__| |/ ____ \| | \ \| |__| |
158
158
  |_| |_| |_|______|_____/|______|_____/|_____/ \_____|\____//_/ \_\_| \_\_____/
159
159
 
160
- Computational Theseus Toolkit (CT Toolkit) v0.5.0
160
+ Computational Theseus Toolkit (CT Toolkit) v0.5.2
161
161
  Identity Continuity Guardrails for Agentic Systems
162
162
 
163
163
  ╭──────────────────── CT Toolkit Audit & Telemetry Status ─────────────────────╮
@@ -249,9 +249,9 @@ For Deep Agents workflows, see [**ct-toolkit-deep-agents**](https://github.com/h
249
249
 
250
250
  | Metric | Status |
251
251
  | :--------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
252
- | **Tests** | ✅ 617 passed, 13 skipped |
252
+ | **Tests** | ✅ 635 passed, 13 skipped |
253
253
  | **Downloads** | [![PyPI Downloads](https://static.pepy.tech/personalized-badge/ct-toolkit?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=MAGENTA&left_text=downloads)](https://pepy.tech/projects/ct-toolkit) |
254
- | **Last Phase** | ✅ v0.5.0: provenance ledger of all proxied traffic, graded divergence scoring, paged dashboard tables |
254
+ | **Last Phase** | ✅ v0.5.2: security fix for OpenTelemetry API (GHSA-mf5v-w3w3-f6cg), dependencies updated to 1.45.2 |
255
255
 
256
256
  ### Framework & Model Support
257
257
 
@@ -90,7 +90,7 @@ $ ct-toolkit status
90
90
  | | | | | | |____ ____) | |____| |__| |____) | | |__| | |__| |/ ____ \| | \ \| |__| |
91
91
  |_| |_| |_|______|_____/|______|_____/|_____/ \_____|\____//_/ \_\_| \_\_____/
92
92
 
93
- Computational Theseus Toolkit (CT Toolkit) v0.5.0
93
+ Computational Theseus Toolkit (CT Toolkit) v0.5.2
94
94
  Identity Continuity Guardrails for Agentic Systems
95
95
 
96
96
  ╭──────────────────── CT Toolkit Audit & Telemetry Status ─────────────────────╮
@@ -182,9 +182,9 @@ For Deep Agents workflows, see [**ct-toolkit-deep-agents**](https://github.com/h
182
182
 
183
183
  | Metric | Status |
184
184
  | :--------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
185
- | **Tests** | ✅ 617 passed, 13 skipped |
185
+ | **Tests** | ✅ 635 passed, 13 skipped |
186
186
  | **Downloads** | [![PyPI Downloads](https://static.pepy.tech/personalized-badge/ct-toolkit?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=MAGENTA&left_text=downloads)](https://pepy.tech/projects/ct-toolkit) |
187
- | **Last Phase** | ✅ v0.5.0: provenance ledger of all proxied traffic, graded divergence scoring, paged dashboard tables |
187
+ | **Last Phase** | ✅ v0.5.2: security fix for OpenTelemetry API (GHSA-mf5v-w3w3-f6cg), dependencies updated to 1.45.2 |
188
188
 
189
189
  ### Framework & Model Support
190
190
 
@@ -23,7 +23,7 @@ from ct_toolkit.core.exceptions import (
23
23
  ChainIntegrityError,
24
24
  )
25
25
 
26
- __version__ = "0.5.0"
26
+ __version__ = "0.5.2"
27
27
 
28
28
  __all__ = [
29
29
  "TheseusWrapper",
@@ -110,12 +110,14 @@ RISKY_PATTERNS: tuple[tuple[Finding, re.Pattern[str]], ...] = (
110
110
 
111
111
  _NORMALIZE_STRIP = re.compile(r"[\"'\\]")
112
112
  _URL_HOST = re.compile(r"[a-z][a-z0-9+.\-]*://(?:[^/\s@\"'\\]*@)?([^/\s:\"'\\?#]+)", re.IGNORECASE)
113
+ _SCP_HOST = re.compile(r"(?<![\w.%+-])[\w.-]+@([a-z0-9][a-z0-9.-]*\.[a-z]{2,}):(?=[\w~/.])", re.IGNORECASE)
113
114
  _IPV4 = re.compile(r"(?<![\d.])(?:\d{1,3}\.){3}\d{1,3}(?![\d.])")
114
115
 
115
116
 
116
117
  def extract_hosts(text: str) -> set[str]:
117
- """Lowercased hosts of `scheme://` URLs and bare IPv4 literals in `text`."""
118
+ """Lowercased hosts of `scheme://` URLs, `user@host:path` remotes and bare IPv4 literals in `text`."""
118
119
  hosts = {match.group(1).lower() for match in _URL_HOST.finditer(text)}
120
+ hosts.update(match.group(1).lower() for match in _SCP_HOST.finditer(text))
119
121
  for address in _IPV4.findall(text):
120
122
  if all(int(octet) <= 255 for octet in address.split(".")):
121
123
  hosts.add(address)
@@ -135,15 +137,44 @@ def declared_tools(tools: Optional[Iterable[Any]]) -> set[str]:
135
137
  return names
136
138
 
137
139
 
140
+ # Text that the user and the harness wrote is trusted to name hosts. What the model wrote, and what tools returned
141
+ # (web pages, files, command output), is not: an injected instruction names the attacker's host in exactly that text.
142
+ UNTRUSTED_ROLES = ("assistant", "tool", "function")
143
+ UNTRUSTED_ITEM_TYPES = ("function_call", "function_call_output")
144
+ UNTRUSTED_BLOCK_TYPES = ("tool_use", "tool_result", "function_call", "function_call_output")
145
+
146
+
147
+ def trusted_request(request: dict) -> dict:
148
+ """A copy of a Chat Completions, Messages or Responses request without the model's own turns and the tool
149
+ results. System prompt, user text and tool declarations stay."""
150
+ result = dict(request)
151
+ for key in ("messages", "input"):
152
+ items = result.get(key)
153
+ if not isinstance(items, list):
154
+ continue
155
+ kept = []
156
+ for item in items:
157
+ if not isinstance(item, dict) or item.get("role") in UNTRUSTED_ROLES or item.get("type") in UNTRUSTED_ITEM_TYPES:
158
+ continue
159
+ content = item.get("content")
160
+ if isinstance(content, list):
161
+ item = {**item, "content": [b for b in content if not (isinstance(b, dict) and b.get("type") in UNTRUSTED_BLOCK_TYPES)]}
162
+ kept.append(item)
163
+ result[key] = kept
164
+ return result
165
+
166
+
138
167
  def conversation_text(message: str, history: Optional[Sequence[Any]] = None, system: Optional[str] = None) -> str:
139
- """Everything the request shows the model, except the model's own earlier turns."""
168
+ """Everything the request shows the model, except the model's own earlier turns and tool results."""
140
169
  parts = [system or "", message or ""]
141
170
  for item in history or []:
142
- if not isinstance(item, dict) or item.get("role") == "assistant":
171
+ if not isinstance(item, dict) or item.get("role") in UNTRUSTED_ROLES:
143
172
  continue
144
173
  content = item.get("content")
145
174
  if isinstance(content, str):
146
175
  parts.append(content)
176
+ elif isinstance(content, list):
177
+ parts.extend(b["text"] for b in content if isinstance(b, dict) and isinstance(b.get("text"), str))
147
178
  return "\n".join(parts)
148
179
 
149
180
 
@@ -416,6 +447,9 @@ def without_stream_calls(chunk: Any, blocked_keys: set[int], calls_left: int) ->
416
447
  calls = _field(delta, "tool_calls")
417
448
  if isinstance(calls, list) and calls:
418
449
  calls[:] = [call for call in calls if (_field(call, "index") or 0) not in blocked_keys]
450
+ for call in calls: # clients place calls by index: the ones after a removed call move up
451
+ index = _field(call, "index") or 0
452
+ _set_field(call, "index", index - sum(1 for key in blocked_keys if key < index))
419
453
  if _field(choice, "finish_reason") == "tool_calls" and calls_left == 0:
420
454
  _set_field(choice, "finish_reason", "stop")
421
455
  return result
@@ -487,6 +521,12 @@ class ToolGuard:
487
521
  return tuple(findings)
488
522
 
489
523
  def _known_host(self, host: str, context: str) -> bool:
490
- if host in context:
524
+ if _mentions_host(context, host):
491
525
  return True
492
526
  return any(host == allowed or host.endswith("." + allowed) for allowed in self.allowed_hosts)
527
+
528
+
529
+ def _mentions_host(text: str, host: str) -> bool:
530
+ """Whether `text` names `host` as a whole: `ample.com` is not named by `example.com`, nor `example.com` by
531
+ `example.com.evil.test`."""
532
+ return re.search(rf"(?<![a-z0-9.-]){re.escape(host)}(?![a-z0-9-]|\.[a-z0-9])", text) is not None
@@ -78,6 +78,8 @@ LEDGER_SORT = {
78
78
  FULL_VERIFY_INTERVAL_S = 300.0
79
79
  GENERATION_COUNTER = "meta:generation" # bumped by every write and rollback: a cache key for readers
80
80
  DERIVED_MARKER = "meta:derived"
81
+ # Set once the `event_type` column is filled for old entries. 0.5.0 filled the other derived columns but not this one.
82
+ EVENT_TYPE_MARKER = "meta:event_type"
81
83
  _ADD_COUNTER = (
82
84
  "INSERT INTO provenance_counters (name, value) VALUES (:name, :value) "
83
85
  "ON CONFLICT (name) DO UPDATE SET value = provenance_counters.value + excluded.value"
@@ -427,18 +429,29 @@ class ProvenanceLog:
427
429
  self._backfill_derived(run)
428
430
  self._rebuild_counters(run)
429
431
  run(_ADD_COUNTER, {"name": DERIVED_MARKER, "value": 1.0})
432
+ if not run(f"SELECT name FROM provenance_counters WHERE name = '{EVENT_TYPE_MARKER}'"):
433
+ self._backfill_event_type(run)
434
+ run(_ADD_COUNTER, {"name": EVENT_TYPE_MARKER, "value": 1.0})
430
435
 
431
436
  def _backfill_derived(self, run: Any) -> None:
432
437
  for row_id, meta_raw in run("SELECT id, metadata FROM provenance"):
433
438
  metadata = json.loads(meta_raw) if isinstance(meta_raw, str) else dict(meta_raw)
434
439
  run(
435
- "UPDATE provenance SET event = :event, verdict = :verdict, tool = :tool, mode = :mode WHERE id = :id",
440
+ "UPDATE provenance SET event = :event, verdict = :verdict, tool = :tool, mode = :mode, event_type = :event_type "
441
+ "WHERE id = :id",
436
442
  {**self._derived(metadata), "id": row_id},
437
443
  )
438
444
 
445
+ def _backfill_event_type(self, run: Any) -> None:
446
+ for row_id, meta_raw in run("SELECT id, metadata FROM provenance WHERE event_type IS NULL"):
447
+ metadata = json.loads(meta_raw) if isinstance(meta_raw, str) else dict(meta_raw)
448
+ if metadata.get("event_type") is not None:
449
+ run("UPDATE provenance SET event_type = :event_type WHERE id = :id",
450
+ {"event_type": str(metadata["event_type"]), "id": row_id})
451
+
439
452
  def _rebuild_counters(self, run: Any) -> None:
440
453
  """Recomputes the tool-call counters from the active entries. Used on migration and after a rollback."""
441
- run(f"DELETE FROM provenance_counters WHERE name NOT IN ('{DERIVED_MARKER}', '{GENERATION_COUNTER}')")
454
+ run(f"DELETE FROM provenance_counters WHERE name NOT IN ('{DERIVED_MARKER}', '{EVENT_TYPE_MARKER}', '{GENERATION_COUNTER}')")
442
455
  totals: dict[str, float] = {}
443
456
  for (meta_raw,) in run("SELECT metadata FROM provenance WHERE status = 'active' AND event = :event",
444
457
  {"event": TOOL_CALL_EVENT}):
@@ -27,12 +27,12 @@ from typing import Any, Optional
27
27
 
28
28
  import httpx
29
29
  from starlette.applications import Starlette
30
- from starlette.background import BackgroundTask
30
+ from starlette.background import BackgroundTask, BackgroundTasks
31
31
  from starlette.requests import Request
32
32
  from starlette.responses import JSONResponse, Response, StreamingResponse
33
33
  from starlette.routing import Route
34
34
 
35
- from ct_toolkit.core.tool_guard import ToolCallDecision, ToolGuard, ToolGuardMode, tool_calls_of, without_calls
35
+ from ct_toolkit.core.tool_guard import ToolCallDecision, ToolGuard, ToolGuardMode, tool_calls_of, trusted_request, without_calls
36
36
  from ct_toolkit.core.tool_guard_settings import SettingsWatcher, apply_to_guard
37
37
  from ct_toolkit.provenance.log import API_CALL_EVENT, MODEL_TURN_EVENT, ProvenanceLog
38
38
  from ct_toolkit.utils.logger import get_logger
@@ -45,12 +45,10 @@ SKIP_RESPONSE = {"content-length", "content-encoding", "transfer-encoding", "con
45
45
 
46
46
 
47
47
  def request_context(request: dict) -> tuple[str, str]:
48
- """(conversation, task) for a model-turn request. The conversation is everything but the model's own turns;
49
- the task is the user's own text."""
50
- context = dict(request)
51
- if isinstance(context.get("messages"), list):
52
- context["messages"] = [m for m in context["messages"] if not (isinstance(m, dict) and m.get("role") == "assistant")]
53
- conversation = json.dumps(context, ensure_ascii=False)
48
+ """(conversation, task) for a model-turn request. The conversation is what the user and the harness wrote: the
49
+ model's own turns and the tool results are left out, because injected instructions arrive in them; the task is
50
+ the user's own text."""
51
+ conversation = json.dumps(trusted_request(request), ensure_ascii=False)
54
52
  if isinstance(request.get("input"), str): # Responses API, plain text input
55
53
  return conversation, request["input"]
56
54
  task_parts = []
@@ -260,13 +258,23 @@ def _drop_blocked(name: str, lines: list[str], data: Any, blocked: Blocked, call
260
258
  return lines
261
259
  kind = data.get("type")
262
260
  changed = False
263
- if kind in ("content_block_start", "content_block_delta", "content_block_stop") and data.get("index") in blocked.anthropic:
264
- return None
261
+ if kind in ("content_block_start", "content_block_delta", "content_block_stop") and isinstance(data.get("index"), int):
262
+ if data["index"] in blocked.anthropic:
263
+ return None
264
+ shift = sum(1 for index in blocked.anthropic if index < data["index"])
265
+ if shift: # clients place blocks by index, so the blocks after a removed one move up
266
+ data["index"] -= shift
267
+ changed = True
265
268
  if kind in ("response.output_item.added", "response.output_item.done") and (data.get("item") or {}).get("type") == "function_call":
266
269
  if data.get("output_index") in blocked.responses:
267
270
  return None
268
271
  if kind in ("response.function_call_arguments.delta", "response.function_call_arguments.done") and data.get("output_index") in blocked.responses:
269
272
  return None
273
+ if isinstance(data.get("output_index"), int) and kind and kind.startswith("response."):
274
+ shift = sum(1 for index in blocked.responses if index < data["output_index"])
275
+ if shift:
276
+ data["output_index"] -= shift
277
+ changed = True
270
278
  if kind == "response.completed" and isinstance(data.get("response"), dict):
271
279
  output = data["response"].get("output")
272
280
  if isinstance(output, list):
@@ -285,6 +293,11 @@ def _drop_blocked(name: str, lines: list[str], data: Any, blocked: Blocked, call
285
293
  kept_calls = [call for call in calls if call.get("index", 0) not in blocked.openai]
286
294
  if len(kept_calls) != len(calls):
287
295
  changed = True
296
+ for call in kept_calls:
297
+ shift = sum(1 for index in blocked.openai if index < call.get("index", 0))
298
+ if shift:
299
+ call["index"] = call.get("index", 0) - shift
300
+ changed = True
288
301
  if kept_calls:
289
302
  delta["tool_calls"] = kept_calls
290
303
  else:
@@ -421,9 +434,14 @@ def create_app(
421
434
  if is_sse:
422
435
  if guard.mode is ToolGuardMode.ENFORCE:
423
436
  stream = _enforced_stream(upstream_response, request_json, reviewer, endpoint)
424
- else:
425
- stream = _observed_stream(upstream_response, request_json, reviewer, endpoint)
426
- return StreamingResponse(stream, status, response_headers, background=close)
437
+ return StreamingResponse(stream, status, response_headers, background=close)
438
+ # Observe mode forwards the stream as it comes; the verdicts are recorded once the body has been sent,
439
+ # so a slow judge never holds the stream open.
440
+ observed = _ObservedStream(upstream_response, request_json, reviewer, endpoint)
441
+ tasks = BackgroundTasks()
442
+ tasks.add_task(upstream_response.aclose)
443
+ tasks.add_task(observed.record)
444
+ return StreamingResponse(observed.forward(), status, response_headers, background=tasks)
427
445
 
428
446
  data = await upstream_response.aread()
429
447
  await upstream_response.aclose()
@@ -450,16 +468,32 @@ def create_app(
450
468
  return Starlette(routes=[Route("/{path:path}", forward, methods=METHODS)], lifespan=lifespan)
451
469
 
452
470
 
453
- async def _observed_stream(upstream_response: httpx.Response, request: dict, reviewer: ProxyReviewer, endpoint: str) -> AsyncIterator[bytes]:
454
- """Forwards the stream line by line as it arrives, then records the verdicts and the turn when it ends."""
455
- rebuilt = StreamToolCalls()
456
- async for line in upstream_response.aiter_lines():
457
- rebuilt.feed(line)
458
- yield (line + "\n").encode()
459
- await asyncio.to_thread(reviewer.review, request, rebuilt.response(), endpoint)
460
- await asyncio.to_thread(
461
- reviewer.record_turn, request, rebuilt.response(), rebuilt.text(), rebuilt.finish, endpoint, upstream_response.status_code
462
- )
471
+ class _ObservedStream:
472
+ """Observe mode: `forward` passes the stream on line by line; `record` reviews what was seen and writes the
473
+ verdicts and the turn to the provenance chain."""
474
+
475
+ def __init__(self, upstream_response: httpx.Response, request: dict, reviewer: ProxyReviewer, endpoint: str) -> None:
476
+ self._upstream = upstream_response
477
+ self._request = request
478
+ self._reviewer = reviewer
479
+ self._endpoint = endpoint
480
+ self._rebuilt = StreamToolCalls()
481
+ self._complete = False
482
+
483
+ async def forward(self) -> AsyncIterator[bytes]:
484
+ async for line in self._upstream.aiter_lines():
485
+ self._rebuilt.feed(line)
486
+ yield (line + "\n").encode()
487
+ self._complete = True
488
+
489
+ async def record(self) -> None:
490
+ if not self._complete:
491
+ return
492
+ rebuilt = self._rebuilt
493
+ await asyncio.to_thread(self._reviewer.review, self._request, rebuilt.response(), self._endpoint)
494
+ await asyncio.to_thread(
495
+ self._reviewer.record_turn, self._request, rebuilt.response(), rebuilt.text(), rebuilt.finish, self._endpoint, self._upstream.status_code
496
+ )
463
497
 
464
498
 
465
499
  async def _enforced_stream(upstream_response: httpx.Response, request: dict, reviewer: ProxyReviewer, endpoint: str) -> AsyncIterator[bytes]:
@@ -129,6 +129,33 @@ app = FastAPI(
129
129
  version="1.0.0"
130
130
  )
131
131
 
132
+
133
+ # Host header names a client may use to reach a server that has no API key. "testserver" is the TestClient's.
134
+ _LOOPBACK_HOST_HEADERS = _LOOPBACK_HOSTS | {"testserver"}
135
+
136
+
137
+ def _host_header_name(value: str) -> str:
138
+ """The host name in a Host header, without the port: `localhost:8000` -> `localhost`, `[::1]:8000` -> `::1`."""
139
+ value = value.strip().lower()
140
+ if value.startswith("["):
141
+ return value[1:].split("]", 1)[0]
142
+ return value.rsplit(":", 1)[0] if value.count(":") == 1 else value
143
+
144
+
145
+ @app.middleware("http")
146
+ async def _reject_foreign_host_header(request: Request, call_next):
147
+ """Without an API key the server trusts loopback clients. A page on another site can reach a loopback server
148
+ too, by pointing its own domain at 127.0.0.1 (DNS rebinding), but the browser then sends that domain in the
149
+ Host header. Such requests are refused."""
150
+ if not (_server_api_key or os.environ.get("CT_SERVER_API_KEY")):
151
+ host = request.headers.get("host")
152
+ if host and _host_header_name(host) not in _LOOPBACK_HOST_HEADERS:
153
+ return JSONResponse(
154
+ status_code=403,
155
+ content={"detail": "Forbidden: without CT_SERVER_API_KEY the server answers only to localhost host names."},
156
+ )
157
+ return await call_next(request)
158
+
132
159
  class GuardrailRequest(BaseModel):
133
160
  """LiteLLM Generic Guardrail API Request Schema."""
134
161
  texts: Optional[List[str]] = Field(None, description="Extracted text from the request.")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ct-toolkit
3
- Version: 0.5.0
3
+ Version: 0.5.2
4
4
  Summary: Computational Theseus Toolkit — Identity Continuity Guardrails for Agentic Systems
5
5
  Author: Hakan Damar
6
6
  License: Apache-2.0
@@ -33,7 +33,7 @@ Requires-Dist: uvicorn>=0.29.0
33
33
  Provides-Extra: server
34
34
  Provides-Extra: cli
35
35
  Provides-Extra: observability
36
- Requires-Dist: opentelemetry-api>=1.25.0; extra == "observability"
36
+ Requires-Dist: opentelemetry-api>=1.45.2; extra == "observability"
37
37
  Requires-Dist: opentelemetry-sdk>=1.25.0; extra == "observability"
38
38
  Requires-Dist: langsmith>=0.8.18; extra == "observability"
39
39
  Provides-Extra: structured
@@ -157,7 +157,7 @@ $ ct-toolkit status
157
157
  | | | | | | |____ ____) | |____| |__| |____) | | |__| | |__| |/ ____ \| | \ \| |__| |
158
158
  |_| |_| |_|______|_____/|______|_____/|_____/ \_____|\____//_/ \_\_| \_\_____/
159
159
 
160
- Computational Theseus Toolkit (CT Toolkit) v0.5.0
160
+ Computational Theseus Toolkit (CT Toolkit) v0.5.2
161
161
  Identity Continuity Guardrails for Agentic Systems
162
162
 
163
163
  ╭──────────────────── CT Toolkit Audit & Telemetry Status ─────────────────────╮
@@ -249,9 +249,9 @@ For Deep Agents workflows, see [**ct-toolkit-deep-agents**](https://github.com/h
249
249
 
250
250
  | Metric | Status |
251
251
  | :--------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
252
- | **Tests** | ✅ 617 passed, 13 skipped |
252
+ | **Tests** | ✅ 635 passed, 13 skipped |
253
253
  | **Downloads** | [![PyPI Downloads](https://static.pepy.tech/personalized-badge/ct-toolkit?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=MAGENTA&left_text=downloads)](https://pepy.tech/projects/ct-toolkit) |
254
- | **Last Phase** | ✅ v0.5.0: provenance ledger of all proxied traffic, graded divergence scoring, paged dashboard tables |
254
+ | **Last Phase** | ✅ v0.5.2: security fix for OpenTelemetry API (GHSA-mf5v-w3w3-f6cg), dependencies updated to 1.45.2 |
255
255
 
256
256
  ### Framework & Model Support
257
257
 
@@ -46,7 +46,7 @@ torch>=2.13.0
46
46
  fastembed>=0.3.0
47
47
 
48
48
  [observability]
49
- opentelemetry-api>=1.25.0
49
+ opentelemetry-api>=1.45.2
50
50
  opentelemetry-sdk>=1.25.0
51
51
  langsmith>=0.8.18
52
52
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ct-toolkit"
7
- version = "0.5.0"
7
+ version = "0.5.2"
8
8
  description = "Computational Theseus Toolkit — Identity Continuity Guardrails for Agentic Systems"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11, <3.14"
@@ -44,7 +44,7 @@ Issues = "https://github.com/Theseus-Guard/ct-toolkit/issues"
44
44
  server = []
45
45
  cli = []
46
46
  observability = [
47
- "opentelemetry-api>=1.25.0",
47
+ "opentelemetry-api>=1.45.2",
48
48
  "opentelemetry-sdk>=1.25.0",
49
49
  "langsmith>=0.8.18", # Fix: TracingMiddleware arbitrary file read (GHSA-f4xh-w4cj-qxq8, Dependabot #42)
50
50
  ]
File without changes
File without changes
File without changes
File without changes