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.
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/PKG-INFO +5 -5
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/README.md +3 -3
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/__init__.py +1 -1
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/core/tool_guard.py +44 -4
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/provenance/log.py +15 -2
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/proxy.py +57 -23
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/server.py +27 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit.egg-info/PKG-INFO +5 -5
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit.egg-info/requires.txt +1 -1
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/pyproject.toml +2 -2
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/LICENSE +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/__main__.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/cli.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/core/__init__.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/core/async_wrapper.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/core/circuit_breaker.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/core/compatibility.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/core/compression_guard.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/core/exceptions.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/core/integrity.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/core/kernel.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/core/tool_guard_settings.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/core/wrapper.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/divergence/__init__.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/divergence/analysis.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/divergence/engine.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/divergence/l2_judge.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/divergence/l3_icm.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/divergence/loss.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/divergence/scheduler.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/endorsement/__init__.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/endorsement/probes/__init__.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/endorsement/reflective.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/identity/__init__.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/identity/embedding.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/identity/templates/__init__.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/identity/templates/defense.yaml +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/identity/templates/finance.yaml +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/identity/templates/general.yaml +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/identity/templates/medical.yaml +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/kernels/__init__.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/kernels/default.yaml +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/kernels/defense.yaml +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/middleware/__init__.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/middleware/autogen.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/middleware/crewai.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/middleware/deepagents.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/middleware/langchain.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/middleware/litellm.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/profile_checksums.sha256 +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/provenance/__init__.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/provenance/ledger.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/provenance/tool_calls.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/provenance/vault/__init__.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/utils/__init__.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/utils/cache.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/utils/logger.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/utils/metrics.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/utils/sensitive_masker.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/utils/telemetry.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit/utils/trajectory_scrub.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit.egg-info/SOURCES.txt +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit.egg-info/dependency_links.txt +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit.egg-info/entry_points.txt +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/ct_toolkit.egg-info/top_level.txt +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/setup.cfg +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/tests/test_analysis.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/tests/test_async_and_streaming.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/tests/test_background_latency.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/tests/test_cli.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/tests/test_cli_extended.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/tests/test_deep_agents_integration.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/tests/test_distributed_headers_and_cli.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/tests/test_enterprise_db_and_otel.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/tests/test_l1_semantic.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/tests/test_loss.py +0 -0
- {ct_toolkit-0.5.0 → ct_toolkit-0.5.2}/tests/test_optional_dependencies.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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** | ✅
|
|
252
|
+
| **Tests** | ✅ 635 passed, 13 skipped |
|
|
253
253
|
| **Downloads** | [](https://pepy.tech/projects/ct-toolkit) |
|
|
254
|
-
| **Last Phase** | ✅ v0.5.
|
|
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.
|
|
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** | ✅
|
|
185
|
+
| **Tests** | ✅ 635 passed, 13 skipped |
|
|
186
186
|
| **Downloads** | [](https://pepy.tech/projects/ct-toolkit) |
|
|
187
|
-
| **Last Phase** | ✅ v0.5.
|
|
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
|
|
|
@@ -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")
|
|
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
|
|
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
|
|
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
|
|
49
|
-
the
|
|
50
|
-
|
|
51
|
-
|
|
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")
|
|
264
|
-
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
454
|
-
"""
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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** | ✅
|
|
252
|
+
| **Tests** | ✅ 635 passed, 13 skipped |
|
|
253
253
|
| **Downloads** | [](https://pepy.tech/projects/ct-toolkit) |
|
|
254
|
-
| **Last Phase** | ✅ v0.5.
|
|
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
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ct-toolkit"
|
|
7
|
-
version = "0.5.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|