traceforge-toolkit 0.1.0__py3-none-any.whl
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.
- traceforge/__init__.py +72 -0
- traceforge/__main__.py +5 -0
- traceforge/_generated.py +254 -0
- traceforge/adapters/__init__.py +12 -0
- traceforge/adapters/base.py +82 -0
- traceforge/adapters/genai_otel.py +164 -0
- traceforge/adapters/mapped_json.py +297 -0
- traceforge/adapters/otel.py +220 -0
- traceforge/boundary/__init__.py +46 -0
- traceforge/boundary/data/boundary-model.joblib +0 -0
- traceforge/boundary/decode.py +87 -0
- traceforge/boundary/features.py +137 -0
- traceforge/boundary/inference.py +267 -0
- traceforge/boundary/inferencer.py +146 -0
- traceforge/classify/__init__.py +95 -0
- traceforge/classify/cmd.py +109 -0
- traceforge/classify/coding.py +213 -0
- traceforge/classify/config.py +554 -0
- traceforge/classify/core.py +266 -0
- traceforge/classify/data/binary_info.yaml +358 -0
- traceforge/classify/data/canonical_tools.yaml +251 -0
- traceforge/classify/data/effect_overrides.yaml +480 -0
- traceforge/classify/data/mcp_profiles.yaml +711 -0
- traceforge/classify/data/recommendation_rules.yaml +111 -0
- traceforge/classify/data/risk.yaml +534 -0
- traceforge/classify/data/shell_defaults.yaml +95 -0
- traceforge/classify/data/shell_rules.yaml +1192 -0
- traceforge/classify/data/tool_classifications.yaml +215 -0
- traceforge/classify/data/verb_inference.yaml +218 -0
- traceforge/classify/mcp.py +181 -0
- traceforge/classify/phases.py +75 -0
- traceforge/classify/powershell.py +93 -0
- traceforge/classify/registry.py +138 -0
- traceforge/classify/risk.py +553 -0
- traceforge/classify/rules.py +215 -0
- traceforge/classify/schema.yaml +282 -0
- traceforge/classify/shell.py +503 -0
- traceforge/classify/tools.py +91 -0
- traceforge/classify/workflow.py +32 -0
- traceforge/cli/__init__.py +42 -0
- traceforge/cli/config_cmd.py +130 -0
- traceforge/cli/detect.py +46 -0
- traceforge/cli/download_cmd.py +74 -0
- traceforge/cli/factory.py +60 -0
- traceforge/cli/gate_cmd.py +30 -0
- traceforge/cli/init_cmd.py +86 -0
- traceforge/cli/replay.py +130 -0
- traceforge/cli/runner.py +181 -0
- traceforge/cli/score.py +217 -0
- traceforge/cli/status.py +65 -0
- traceforge/cli/watch.py +297 -0
- traceforge/config/__init__.py +80 -0
- traceforge/config/defaults.py +149 -0
- traceforge/config/loader.py +239 -0
- traceforge/config/mappings.py +104 -0
- traceforge/config/models.py +579 -0
- traceforge/enricher.py +576 -0
- traceforge/formatting/__init__.py +12 -0
- traceforge/formatting/budget.py +92 -0
- traceforge/formatting/density.py +154 -0
- traceforge/gate/__init__.py +17 -0
- traceforge/gate/client.py +145 -0
- traceforge/gate/external.py +305 -0
- traceforge/gate/registry.py +108 -0
- traceforge/gate/server.py +174 -0
- traceforge/gates/__init__.py +8 -0
- traceforge/gates/pii.py +352 -0
- traceforge/gates/pii_patterns.yaml +228 -0
- traceforge/governance/__init__.py +121 -0
- traceforge/governance/assessor.py +441 -0
- traceforge/governance/budget.py +59 -0
- traceforge/governance/canonical.py +56 -0
- traceforge/governance/codec.py +414 -0
- traceforge/governance/context.py +249 -0
- traceforge/governance/drift.py +196 -0
- traceforge/governance/emitter.py +234 -0
- traceforge/governance/envelope.py +138 -0
- traceforge/governance/ifc.py +364 -0
- traceforge/governance/integrity.py +162 -0
- traceforge/governance/labeler.py +250 -0
- traceforge/governance/mcp_drift.py +328 -0
- traceforge/governance/monitor.py +539 -0
- traceforge/governance/observer.py +276 -0
- traceforge/governance/persistence.py +401 -0
- traceforge/governance/phase1.py +77 -0
- traceforge/governance/pii.py +276 -0
- traceforge/governance/pipeline.py +840 -0
- traceforge/governance/registry.py +110 -0
- traceforge/governance/results.py +183 -0
- traceforge/governance/risk_wrapper.py +113 -0
- traceforge/governance/rules.py +368 -0
- traceforge/governance/scorer.py +262 -0
- traceforge/governance/shield.py +318 -0
- traceforge/governance/state.py +466 -0
- traceforge/governance/types.py +176 -0
- traceforge/mappings/__init__.py +1 -0
- traceforge/mappings/aider.yaml +216 -0
- traceforge/mappings/aider_markdown.yaml +113 -0
- traceforge/mappings/amazonq.yaml +56 -0
- traceforge/mappings/antigravity.yaml +88 -0
- traceforge/mappings/claude.yaml +93 -0
- traceforge/mappings/cline.yaml +286 -0
- traceforge/mappings/codex.yaml +158 -0
- traceforge/mappings/continue_dev.yaml +57 -0
- traceforge/mappings/copilot.yaml +266 -0
- traceforge/mappings/copilot_markdown.yaml +72 -0
- traceforge/mappings/copilot_vscode.yaml +181 -0
- traceforge/mappings/crewai.yaml +592 -0
- traceforge/mappings/goose.yaml +128 -0
- traceforge/mappings/langgraph.yaml +165 -0
- traceforge/mappings/maf.yaml +90 -0
- traceforge/mappings/maf_transcript.yaml +99 -0
- traceforge/mappings/openai_agents.yaml +144 -0
- traceforge/mappings/opencode.yaml +473 -0
- traceforge/mappings/openhands.yaml +320 -0
- traceforge/mappings/pydantic_ai.yaml +183 -0
- traceforge/mappings/smolagents.yaml +89 -0
- traceforge/mappings/sweagent.yaml +82 -0
- traceforge/migrations/__init__.py +1 -0
- traceforge/migrations/env.py +60 -0
- traceforge/migrations/models.py +145 -0
- traceforge/migrations/runner.py +44 -0
- traceforge/migrations/script.py.mako +25 -0
- traceforge/migrations/versions/0001_initial.py +176 -0
- traceforge/migrations/versions/__init__.py +4 -0
- traceforge/models.py +29 -0
- traceforge/parsers/__init__.py +21 -0
- traceforge/parsers/aider.py +416 -0
- traceforge/parsers/base.py +226 -0
- traceforge/parsers/copilot.py +314 -0
- traceforge/phase/__init__.py +50 -0
- traceforge/phase/data/phase-model.joblib +0 -0
- traceforge/phase/data/potion-base-8M/README.md +99 -0
- traceforge/phase/data/potion-base-8M/config.json +13 -0
- traceforge/phase/data/potion-base-8M/model.safetensors +0 -0
- traceforge/phase/data/potion-base-8M/modules.json +14 -0
- traceforge/phase/data/potion-base-8M/tokenizer.json +1 -0
- traceforge/phase/event_rows.py +107 -0
- traceforge/phase/features.py +468 -0
- traceforge/phase/inference.py +279 -0
- traceforge/phase/inferencer.py +171 -0
- traceforge/phase/segmentation.py +258 -0
- traceforge/pipeline.py +891 -0
- traceforge/preprocessors/__init__.py +34 -0
- traceforge/preprocessors/amazonq.py +224 -0
- traceforge/preprocessors/antigravity.py +116 -0
- traceforge/preprocessors/claude.py +95 -0
- traceforge/preprocessors/cline.py +36 -0
- traceforge/preprocessors/codex.py +311 -0
- traceforge/preprocessors/continue_dev.py +119 -0
- traceforge/preprocessors/copilot_vscode.py +171 -0
- traceforge/preprocessors/goose.py +156 -0
- traceforge/preprocessors/maf_transcript.py +84 -0
- traceforge/preprocessors/openai_agents.py +86 -0
- traceforge/preprocessors/opencode.py +85 -0
- traceforge/preprocessors/openhands.py +36 -0
- traceforge/preprocessors/pydantic_ai.py +62 -0
- traceforge/preprocessors/registry.py +24 -0
- traceforge/preprocessors/smolagents.py +90 -0
- traceforge/py.typed +0 -0
- traceforge/sdk/__init__.py +59 -0
- traceforge/sdk/gate_policy.py +63 -0
- traceforge/sdk/gate_types.py +140 -0
- traceforge/sdk/pipeline.py +265 -0
- traceforge/sdk/verdict.py +81 -0
- traceforge/sinks/__init__.py +23 -0
- traceforge/sinks/base.py +95 -0
- traceforge/sinks/callback.py +132 -0
- traceforge/sinks/console.py +112 -0
- traceforge/sinks/factory.py +94 -0
- traceforge/sinks/jsonl.py +125 -0
- traceforge/sinks/otel_exporter.py +212 -0
- traceforge/sinks/parquet.py +260 -0
- traceforge/sinks/s3.py +206 -0
- traceforge/sinks/sqlite_output.py +234 -0
- traceforge/sinks/webhook.py +136 -0
- traceforge/sources/__init__.py +18 -0
- traceforge/sources/auto_detect.py +173 -0
- traceforge/sources/base.py +45 -0
- traceforge/sources/file_poll.py +136 -0
- traceforge/sources/file_watch.py +221 -0
- traceforge/sources/http_poll.py +141 -0
- traceforge/sources/replay.py +63 -0
- traceforge/sources/sqlite.py +187 -0
- traceforge/sources/sse.py +198 -0
- traceforge/telemetry/__init__.py +192 -0
- traceforge/title/__init__.py +23 -0
- traceforge/title/_resolve.py +79 -0
- traceforge/title/context.py +295 -0
- traceforge/title/data/boilerplate_files.json +14 -0
- traceforge/title/heuristics.py +429 -0
- traceforge/title/hygiene.py +90 -0
- traceforge/title/inference.py +314 -0
- traceforge/title/inferencer.py +477 -0
- traceforge/title/naming.py +398 -0
- traceforge/trace.py +291 -0
- traceforge/tracking/__init__.py +30 -0
- traceforge/tracking/models.py +115 -0
- traceforge/tracking/phase_tracker.py +288 -0
- traceforge/types.py +315 -0
- traceforge_toolkit-0.1.0.dist-info/METADATA +188 -0
- traceforge_toolkit-0.1.0.dist-info/RECORD +205 -0
- traceforge_toolkit-0.1.0.dist-info/WHEEL +4 -0
- traceforge_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
- traceforge_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Gate endpoint registry — maps session_id → socket path in system.db.
|
|
2
|
+
|
|
3
|
+
The gate_endpoints table is managed by Alembic as part of the unified
|
|
4
|
+
system schema. This module provides convenience functions that operate
|
|
5
|
+
on a standalone connection for lightweight lookups (the gate client doesn't
|
|
6
|
+
always have access to a full SystemStore instance).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import sqlite3
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _gates_dir() -> Path:
|
|
17
|
+
"""Return ~/.traceforge/gates/, creating if needed."""
|
|
18
|
+
d = Path.home() / ".traceforge" / "gates"
|
|
19
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
return d
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _system_db_path() -> str:
|
|
24
|
+
return str(Path.home() / ".traceforge" / "system.db")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def register_session(session_id: str, sock_path: str, *, db_path: str | None = None) -> None:
|
|
28
|
+
"""Register a session_id → sock_path mapping."""
|
|
29
|
+
db = db_path or _system_db_path()
|
|
30
|
+
Path(db).parent.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
conn = sqlite3.connect(db)
|
|
32
|
+
try:
|
|
33
|
+
conn.execute(
|
|
34
|
+
"INSERT OR REPLACE INTO gate_endpoints (session_id, sock_path, pid) VALUES (?, ?, ?)",
|
|
35
|
+
(session_id, sock_path, os.getpid()),
|
|
36
|
+
)
|
|
37
|
+
conn.commit()
|
|
38
|
+
finally:
|
|
39
|
+
conn.close()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def lookup_session(session_id: str, *, db_path: str | None = None) -> str | None:
|
|
43
|
+
"""Look up the socket path for a session_id. Returns None if not found or stale."""
|
|
44
|
+
db = db_path or _system_db_path()
|
|
45
|
+
if not Path(db).exists():
|
|
46
|
+
return None
|
|
47
|
+
conn = sqlite3.connect(db)
|
|
48
|
+
try:
|
|
49
|
+
row = conn.execute(
|
|
50
|
+
"SELECT sock_path, pid FROM gate_endpoints WHERE session_id = ?",
|
|
51
|
+
(session_id,),
|
|
52
|
+
).fetchone()
|
|
53
|
+
if row is None:
|
|
54
|
+
return None
|
|
55
|
+
sock_path, pid = row
|
|
56
|
+
# Verify PID is alive
|
|
57
|
+
if not _pid_alive(pid):
|
|
58
|
+
conn.execute("DELETE FROM gate_endpoints WHERE session_id = ?", (session_id,))
|
|
59
|
+
conn.commit()
|
|
60
|
+
return None
|
|
61
|
+
return sock_path
|
|
62
|
+
finally:
|
|
63
|
+
conn.close()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def unregister_session(session_id: str, *, db_path: str | None = None) -> None:
|
|
67
|
+
"""Remove a session_id from the registry."""
|
|
68
|
+
db = db_path or _system_db_path()
|
|
69
|
+
if not Path(db).exists():
|
|
70
|
+
return
|
|
71
|
+
conn = sqlite3.connect(db)
|
|
72
|
+
try:
|
|
73
|
+
conn.execute("DELETE FROM gate_endpoints WHERE session_id = ?", (session_id,))
|
|
74
|
+
conn.commit()
|
|
75
|
+
finally:
|
|
76
|
+
conn.close()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def unregister_pid(*, db_path: str | None = None) -> None:
|
|
80
|
+
"""Remove all entries for the current PID (cleanup on exit)."""
|
|
81
|
+
db = db_path or _system_db_path()
|
|
82
|
+
if not Path(db).exists():
|
|
83
|
+
return
|
|
84
|
+
conn = sqlite3.connect(db)
|
|
85
|
+
try:
|
|
86
|
+
conn.execute("DELETE FROM gate_endpoints WHERE pid = ?", (os.getpid(),))
|
|
87
|
+
conn.commit()
|
|
88
|
+
finally:
|
|
89
|
+
conn.close()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _pid_alive(pid: int) -> bool:
|
|
93
|
+
"""Check if a PID is still running."""
|
|
94
|
+
if os.name == "nt":
|
|
95
|
+
import ctypes
|
|
96
|
+
|
|
97
|
+
kernel32 = ctypes.windll.kernel32
|
|
98
|
+
handle = kernel32.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
|
|
99
|
+
if handle:
|
|
100
|
+
kernel32.CloseHandle(handle)
|
|
101
|
+
return True
|
|
102
|
+
return False
|
|
103
|
+
else:
|
|
104
|
+
try:
|
|
105
|
+
os.kill(pid, 0)
|
|
106
|
+
return True
|
|
107
|
+
except OSError:
|
|
108
|
+
return False
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Gate IPC server — listens on a unix socket (or named pipe on Windows) for gate requests.
|
|
2
|
+
|
|
3
|
+
The server runs in a background thread inside the Pipeline process. When a gate
|
|
4
|
+
request arrives (from `traceforge gate --stdin`), it:
|
|
5
|
+
1. Deserializes the event JSON
|
|
6
|
+
2. Calls pipeline._score_and_gate_preflight(payload)
|
|
7
|
+
3. Returns the Verdict as JSON
|
|
8
|
+
|
|
9
|
+
Uses the same policy chain as all gate_* methods for consistent behavior.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import atexit
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import socket
|
|
18
|
+
import struct
|
|
19
|
+
import sys
|
|
20
|
+
import threading
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import TYPE_CHECKING
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from traceforge.governance.pipeline import GovernancePipeline
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class GateServer:
|
|
29
|
+
"""IPC server for cross-process gating.
|
|
30
|
+
|
|
31
|
+
Used by CLI-based frameworks (Claude Code, Copilot CLI, Codex CLI, etc.)
|
|
32
|
+
that can't inject Python hooks but can shell out to `traceforge gate --stdin`.
|
|
33
|
+
|
|
34
|
+
The server uses the pipeline's policy chain (same as gate_* methods) rather
|
|
35
|
+
than a standalone callback. This ensures consistent behavior across all paths.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
pipeline: "GovernancePipeline",
|
|
41
|
+
sock_path: str | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
self._pipeline = pipeline
|
|
44
|
+
self._sock_path = sock_path or self._default_sock_path()
|
|
45
|
+
self._server: socket.socket | None = None
|
|
46
|
+
self._thread: threading.Thread | None = None
|
|
47
|
+
self._running = False
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def _default_sock_path() -> str:
|
|
51
|
+
gates_dir = Path.home() / ".traceforge" / "gates"
|
|
52
|
+
gates_dir.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
return str(gates_dir / f"{os.getpid()}.sock")
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def sock_path(self) -> str:
|
|
57
|
+
return self._sock_path
|
|
58
|
+
|
|
59
|
+
def start(self) -> None:
|
|
60
|
+
"""Start the IPC server in a background daemon thread."""
|
|
61
|
+
if self._running:
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
# Clean up stale socket file
|
|
65
|
+
if os.path.exists(self._sock_path):
|
|
66
|
+
os.unlink(self._sock_path)
|
|
67
|
+
|
|
68
|
+
if sys.platform == "win32":
|
|
69
|
+
# Windows: use TCP on localhost with a random port
|
|
70
|
+
self._server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
71
|
+
self._server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
72
|
+
self._server.bind(("127.0.0.1", 0))
|
|
73
|
+
# Store actual port in sock_path for registry
|
|
74
|
+
_, port = self._server.getsockname()
|
|
75
|
+
self._sock_path = f"tcp://127.0.0.1:{port}"
|
|
76
|
+
else:
|
|
77
|
+
self._server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
78
|
+
self._server.bind(self._sock_path)
|
|
79
|
+
|
|
80
|
+
self._server.listen(16)
|
|
81
|
+
self._server.settimeout(1.0) # allow periodic check of _running
|
|
82
|
+
self._running = True
|
|
83
|
+
|
|
84
|
+
self._thread = threading.Thread(
|
|
85
|
+
target=self._serve_loop, daemon=True, name="traceforge-gate"
|
|
86
|
+
)
|
|
87
|
+
self._thread.start()
|
|
88
|
+
|
|
89
|
+
atexit.register(self.stop)
|
|
90
|
+
|
|
91
|
+
def stop(self) -> None:
|
|
92
|
+
"""Stop the IPC server and clean up."""
|
|
93
|
+
self._running = False
|
|
94
|
+
if self._server:
|
|
95
|
+
try:
|
|
96
|
+
self._server.close()
|
|
97
|
+
except OSError:
|
|
98
|
+
pass
|
|
99
|
+
if self._thread and self._thread.is_alive():
|
|
100
|
+
self._thread.join(timeout=2.0)
|
|
101
|
+
# Clean up socket file
|
|
102
|
+
if not self._sock_path.startswith("tcp://") and os.path.exists(self._sock_path):
|
|
103
|
+
try:
|
|
104
|
+
os.unlink(self._sock_path)
|
|
105
|
+
except OSError:
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
def register_session(self, session_id: str) -> None:
|
|
109
|
+
"""Register a session_id → this server's socket in the registry."""
|
|
110
|
+
from traceforge.gate.registry import register_session
|
|
111
|
+
|
|
112
|
+
register_session(session_id, self._sock_path)
|
|
113
|
+
|
|
114
|
+
def _serve_loop(self) -> None:
|
|
115
|
+
while self._running:
|
|
116
|
+
try:
|
|
117
|
+
conn, _ = self._server.accept()
|
|
118
|
+
except socket.timeout:
|
|
119
|
+
continue
|
|
120
|
+
except OSError:
|
|
121
|
+
break
|
|
122
|
+
threading.Thread(target=self._handle_conn, args=(conn,), daemon=True).start()
|
|
123
|
+
|
|
124
|
+
def _handle_conn(self, conn: socket.socket) -> None:
|
|
125
|
+
"""Handle a single gate request."""
|
|
126
|
+
try:
|
|
127
|
+
conn.settimeout(10.0)
|
|
128
|
+
data = self._recv_all(conn)
|
|
129
|
+
if not data:
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
request = json.loads(data)
|
|
133
|
+
payload = request.get("payload", request)
|
|
134
|
+
response = self._process_gate_request(payload)
|
|
135
|
+
resp_bytes = json.dumps(response).encode("utf-8")
|
|
136
|
+
conn.sendall(struct.pack("!I", len(resp_bytes)) + resp_bytes)
|
|
137
|
+
except Exception:
|
|
138
|
+
try:
|
|
139
|
+
err = json.dumps({"decision": "deny", "reason": "internal error"}).encode()
|
|
140
|
+
conn.sendall(struct.pack("!I", len(err)) + err)
|
|
141
|
+
except OSError:
|
|
142
|
+
pass
|
|
143
|
+
finally:
|
|
144
|
+
conn.close()
|
|
145
|
+
|
|
146
|
+
def _process_gate_request(self, payload: dict) -> dict:
|
|
147
|
+
"""Score and gate a tool call using the pipeline's policy chain."""
|
|
148
|
+
trace, verdict = self._pipeline._score_and_gate_preflight(payload)
|
|
149
|
+
return {
|
|
150
|
+
"decision": verdict.decision.value,
|
|
151
|
+
"reason": verdict.reason,
|
|
152
|
+
"score": trace.risk_score,
|
|
153
|
+
"level": trace.risk_band,
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
@staticmethod
|
|
157
|
+
def _recv_all(conn: socket.socket) -> bytes:
|
|
158
|
+
"""Read a length-prefixed message (4-byte big-endian length + payload)."""
|
|
159
|
+
header = b""
|
|
160
|
+
while len(header) < 4:
|
|
161
|
+
chunk = conn.recv(4 - len(header))
|
|
162
|
+
if not chunk:
|
|
163
|
+
return b""
|
|
164
|
+
header += chunk
|
|
165
|
+
length = struct.unpack("!I", header)[0]
|
|
166
|
+
if length > 10 * 1024 * 1024: # 10MB sanity limit
|
|
167
|
+
return b""
|
|
168
|
+
data = b""
|
|
169
|
+
while len(data) < length:
|
|
170
|
+
chunk = conn.recv(min(length - len(data), 65536))
|
|
171
|
+
if not chunk:
|
|
172
|
+
return b""
|
|
173
|
+
data += chunk
|
|
174
|
+
return data
|
traceforge/gates/pii.py
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""PII detection and redaction postflight gate — zero external dependencies.
|
|
2
|
+
|
|
3
|
+
Regex patterns extracted from Microsoft Presidio recognizers + traceforge additions
|
|
4
|
+
(API keys, secrets). Patterns live in pii_patterns.yaml alongside this module.
|
|
5
|
+
|
|
6
|
+
No NLP model, no spaCy, no Presidio dependency. Pure regex + context boost + checksum
|
|
7
|
+
validation (Luhn for credit cards, mod-97 for IBAN).
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
from traceforge.gates.pii import pii_postflight_gate, PiiGateConfig
|
|
11
|
+
from traceforge.sdk import GatePolicy
|
|
12
|
+
|
|
13
|
+
policy = GatePolicy().postflight(pii_postflight_gate())
|
|
14
|
+
|
|
15
|
+
# Custom: stricter threshold, only specific entities
|
|
16
|
+
config = PiiGateConfig(
|
|
17
|
+
score_threshold=0.7,
|
|
18
|
+
entities=("US_SSN", "CREDIT_CARD", "API_KEY"),
|
|
19
|
+
allow_list=("myservice.internal",),
|
|
20
|
+
)
|
|
21
|
+
policy = GatePolicy().postflight(pii_postflight_gate(config))
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import re
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import TYPE_CHECKING
|
|
30
|
+
|
|
31
|
+
import yaml
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from traceforge.sdk.gate_types import GateContext, PostflightVerdict, ToolCallResult
|
|
35
|
+
from traceforge.sdk.verdict import PostflightGate
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ─── Configuration ────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class PiiGateConfig:
|
|
43
|
+
"""Configuration for the PII postflight gate.
|
|
44
|
+
|
|
45
|
+
Attributes:
|
|
46
|
+
score_threshold: Minimum final confidence (0.0–1.0) for a detection to
|
|
47
|
+
count as a finding. Default 0.5.
|
|
48
|
+
entities: Which entity types to scan for. None = all loaded from YAML.
|
|
49
|
+
critical_entities: Entity types that trigger SUPPRESS. None = use YAML 'critical' field.
|
|
50
|
+
allow_list: Additional values to never flag (merged with YAML default_allow_list).
|
|
51
|
+
suppress_on_critical: If True, critical PII → SUPPRESS. If False, always REDACT.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
score_threshold: float = 0.5
|
|
55
|
+
entities: tuple[str, ...] | None = None
|
|
56
|
+
critical_entities: tuple[str, ...] | None = None
|
|
57
|
+
allow_list: tuple[str, ...] = ()
|
|
58
|
+
suppress_on_critical: bool = True
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ─── Pattern Data ─────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class _CompiledPattern:
|
|
66
|
+
name: str
|
|
67
|
+
regex: re.Pattern[str]
|
|
68
|
+
base_score: float
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True, slots=True)
|
|
72
|
+
class _EntityDef:
|
|
73
|
+
entity_type: str
|
|
74
|
+
critical: bool
|
|
75
|
+
context_words: frozenset[str]
|
|
76
|
+
patterns: tuple[_CompiledPattern, ...]
|
|
77
|
+
validator: str | None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True, slots=True)
|
|
81
|
+
class PiiMatch:
|
|
82
|
+
"""A single PII detection result."""
|
|
83
|
+
|
|
84
|
+
entity_type: str
|
|
85
|
+
start: int
|
|
86
|
+
end: int
|
|
87
|
+
score: float
|
|
88
|
+
pattern_name: str
|
|
89
|
+
text: str
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ─── YAML Loading (module-level singleton) ────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
_DATA: dict | None = None
|
|
95
|
+
_ENTITIES: tuple[_EntityDef, ...] = ()
|
|
96
|
+
_ALLOW_SET: frozenset[str] = frozenset()
|
|
97
|
+
_CONTEXT_BOOST: float = 0.25
|
|
98
|
+
_CONTEXT_WINDOW: int = 50
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _load_patterns() -> None:
|
|
102
|
+
"""Load and compile patterns from pii_patterns.yaml. Called once."""
|
|
103
|
+
global _DATA, _ENTITIES, _ALLOW_SET, _CONTEXT_BOOST, _CONTEXT_WINDOW
|
|
104
|
+
|
|
105
|
+
yaml_path = Path(__file__).parent / "pii_patterns.yaml"
|
|
106
|
+
with open(yaml_path, "r", encoding="utf-8") as f:
|
|
107
|
+
_DATA = yaml.safe_load(f)
|
|
108
|
+
|
|
109
|
+
_CONTEXT_BOOST = _DATA.get("context_boost", 0.25)
|
|
110
|
+
_CONTEXT_WINDOW = _DATA.get("context_window", 50)
|
|
111
|
+
_ALLOW_SET = frozenset(v.lower() for v in _DATA.get("default_allow_list", []))
|
|
112
|
+
|
|
113
|
+
entities: list[_EntityDef] = []
|
|
114
|
+
for ent in _DATA.get("entities", []):
|
|
115
|
+
compiled: list[_CompiledPattern] = []
|
|
116
|
+
for p in ent.get("patterns", []):
|
|
117
|
+
compiled.append(
|
|
118
|
+
_CompiledPattern(
|
|
119
|
+
name=p["name"],
|
|
120
|
+
regex=re.compile(p["regex"], re.IGNORECASE),
|
|
121
|
+
base_score=p["score"],
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
entities.append(
|
|
125
|
+
_EntityDef(
|
|
126
|
+
entity_type=ent["entity_type"],
|
|
127
|
+
critical=ent.get("critical", False),
|
|
128
|
+
context_words=frozenset(w.lower() for w in ent.get("context", [])),
|
|
129
|
+
patterns=tuple(compiled),
|
|
130
|
+
validator=ent.get("validator"),
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
_ENTITIES = tuple(entities)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _ensure_loaded() -> None:
|
|
137
|
+
if _DATA is None:
|
|
138
|
+
_load_patterns()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ─── Validators ───────────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _luhn_check(digits: str) -> bool:
|
|
145
|
+
"""Luhn algorithm for credit card validation."""
|
|
146
|
+
cleaned = re.sub(r"[\s\-]", "", digits)
|
|
147
|
+
if not cleaned.isdigit() or len(cleaned) < 12:
|
|
148
|
+
return False
|
|
149
|
+
total = 0
|
|
150
|
+
for i, ch in enumerate(reversed(cleaned)):
|
|
151
|
+
n = int(ch)
|
|
152
|
+
if i % 2 == 1:
|
|
153
|
+
n *= 2
|
|
154
|
+
if n > 9:
|
|
155
|
+
n -= 9
|
|
156
|
+
total += n
|
|
157
|
+
return total % 10 == 0
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _iban_check(value: str) -> bool:
|
|
161
|
+
"""IBAN mod-97 validation (ISO 7064)."""
|
|
162
|
+
cleaned = re.sub(r"\s", "", value).upper()
|
|
163
|
+
if len(cleaned) < 15 or not cleaned[:2].isalpha() or not cleaned[2:4].isdigit():
|
|
164
|
+
return False
|
|
165
|
+
# Move country + check digits to end
|
|
166
|
+
rearranged = cleaned[4:] + cleaned[:4]
|
|
167
|
+
# Convert letters to digits (A=10, B=11, ..., Z=35)
|
|
168
|
+
numeric = ""
|
|
169
|
+
for ch in rearranged:
|
|
170
|
+
if ch.isdigit():
|
|
171
|
+
numeric += ch
|
|
172
|
+
else:
|
|
173
|
+
numeric += str(ord(ch) - 55)
|
|
174
|
+
try:
|
|
175
|
+
return int(numeric) % 97 == 1
|
|
176
|
+
except (ValueError, OverflowError):
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
_VALIDATORS: dict[str, callable] = {
|
|
181
|
+
"luhn": _luhn_check,
|
|
182
|
+
"iban": _iban_check,
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ─── Detection Engine ─────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def scan_text(
|
|
190
|
+
text: str,
|
|
191
|
+
*,
|
|
192
|
+
entities: tuple[str, ...] | None = None,
|
|
193
|
+
score_threshold: float = 0.5,
|
|
194
|
+
allow_list: frozenset[str] = frozenset(),
|
|
195
|
+
) -> list[PiiMatch]:
|
|
196
|
+
"""Scan text for PII. Returns matches above score_threshold.
|
|
197
|
+
|
|
198
|
+
This is the core detection function — usable standalone or via the gate.
|
|
199
|
+
"""
|
|
200
|
+
_ensure_loaded()
|
|
201
|
+
|
|
202
|
+
merged_allow = _ALLOW_SET | frozenset(v.lower() for v in allow_list)
|
|
203
|
+
text_lower = text.lower()
|
|
204
|
+
matches: list[PiiMatch] = []
|
|
205
|
+
|
|
206
|
+
for entity_def in _ENTITIES:
|
|
207
|
+
if entities and entity_def.entity_type not in entities:
|
|
208
|
+
continue
|
|
209
|
+
|
|
210
|
+
for pat in entity_def.patterns:
|
|
211
|
+
for m in pat.regex.finditer(text):
|
|
212
|
+
matched_text = m.group()
|
|
213
|
+
|
|
214
|
+
# Allow list check
|
|
215
|
+
if matched_text.lower() in merged_allow:
|
|
216
|
+
continue
|
|
217
|
+
|
|
218
|
+
# Base score
|
|
219
|
+
score = pat.base_score
|
|
220
|
+
|
|
221
|
+
# Context boost: check surrounding text for context words
|
|
222
|
+
start_ctx = max(0, m.start() - _CONTEXT_WINDOW)
|
|
223
|
+
end_ctx = min(len(text), m.end() + _CONTEXT_WINDOW)
|
|
224
|
+
window = text_lower[start_ctx:end_ctx]
|
|
225
|
+
if entity_def.context_words:
|
|
226
|
+
for cw in entity_def.context_words:
|
|
227
|
+
if cw in window:
|
|
228
|
+
score = min(1.0, score + _CONTEXT_BOOST)
|
|
229
|
+
break # One context match is enough
|
|
230
|
+
|
|
231
|
+
# Validator (checksum) → promotes to 1.0 or demotes to 0
|
|
232
|
+
if entity_def.validator and entity_def.validator in _VALIDATORS:
|
|
233
|
+
validator_fn = _VALIDATORS[entity_def.validator]
|
|
234
|
+
if validator_fn(matched_text):
|
|
235
|
+
score = 1.0
|
|
236
|
+
else:
|
|
237
|
+
score = max(0.0, score - 0.3)
|
|
238
|
+
|
|
239
|
+
if score >= score_threshold:
|
|
240
|
+
matches.append(
|
|
241
|
+
PiiMatch(
|
|
242
|
+
entity_type=entity_def.entity_type,
|
|
243
|
+
start=m.start(),
|
|
244
|
+
end=m.end(),
|
|
245
|
+
score=score,
|
|
246
|
+
pattern_name=pat.name,
|
|
247
|
+
text=matched_text,
|
|
248
|
+
)
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
# Deduplicate overlapping spans — higher score wins
|
|
252
|
+
matches.sort(key=lambda x: (-x.score, x.start))
|
|
253
|
+
deduped: list[PiiMatch] = []
|
|
254
|
+
taken_ranges: list[tuple[int, int]] = []
|
|
255
|
+
for match in matches:
|
|
256
|
+
overlaps = any(match.start < end and match.end > start for start, end in taken_ranges)
|
|
257
|
+
if not overlaps:
|
|
258
|
+
deduped.append(match)
|
|
259
|
+
taken_ranges.append((match.start, match.end))
|
|
260
|
+
|
|
261
|
+
return deduped
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# ─── Gate Factory ─────────────────────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def pii_postflight_gate(config: PiiGateConfig | None = None) -> "PostflightGate":
|
|
268
|
+
"""Create a PII postflight gate with the given configuration.
|
|
269
|
+
|
|
270
|
+
Returns a callable matching the PostflightGate protocol.
|
|
271
|
+
Zero external dependencies — uses regex patterns from pii_patterns.yaml.
|
|
272
|
+
"""
|
|
273
|
+
from traceforge.sdk.gate_types import PostflightAction, PostflightVerdict
|
|
274
|
+
|
|
275
|
+
cfg = config or PiiGateConfig()
|
|
276
|
+
|
|
277
|
+
# Pre-resolve critical entities from YAML if not overridden
|
|
278
|
+
_ensure_loaded()
|
|
279
|
+
if cfg.critical_entities is not None:
|
|
280
|
+
critical_set = frozenset(cfg.critical_entities)
|
|
281
|
+
else:
|
|
282
|
+
critical_set = frozenset(e.entity_type for e in _ENTITIES if e.critical)
|
|
283
|
+
|
|
284
|
+
def _gate(result: "ToolCallResult", ctx: "GateContext") -> "PostflightVerdict":
|
|
285
|
+
text = _extract_text(result)
|
|
286
|
+
if not text or not text.strip():
|
|
287
|
+
return PostflightVerdict(action=PostflightAction.ACCEPT)
|
|
288
|
+
|
|
289
|
+
try:
|
|
290
|
+
findings = scan_text(
|
|
291
|
+
text,
|
|
292
|
+
entities=cfg.entities,
|
|
293
|
+
score_threshold=cfg.score_threshold,
|
|
294
|
+
allow_list=frozenset(cfg.allow_list),
|
|
295
|
+
)
|
|
296
|
+
except Exception as e:
|
|
297
|
+
# Fail-closed
|
|
298
|
+
return PostflightVerdict(
|
|
299
|
+
action=PostflightAction.SUPPRESS,
|
|
300
|
+
reason=f"PII gate: scan failed ({type(e).__name__}: {e}) — suppressing",
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
if not findings:
|
|
304
|
+
return PostflightVerdict(action=PostflightAction.ACCEPT)
|
|
305
|
+
|
|
306
|
+
# Check for critical entities
|
|
307
|
+
if cfg.suppress_on_critical:
|
|
308
|
+
critical_found = [f for f in findings if f.entity_type in critical_set]
|
|
309
|
+
if critical_found:
|
|
310
|
+
types = sorted({f.entity_type for f in critical_found})
|
|
311
|
+
return PostflightVerdict(
|
|
312
|
+
action=PostflightAction.SUPPRESS,
|
|
313
|
+
reason=f"Critical PII detected: {', '.join(types)}",
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
# REDACT: use matched text spans as redaction_keys
|
|
317
|
+
seen: set[str] = set()
|
|
318
|
+
redaction_keys: list[str] = []
|
|
319
|
+
for f in sorted(findings, key=lambda x: len(x.text), reverse=True):
|
|
320
|
+
if f.text not in seen:
|
|
321
|
+
seen.add(f.text)
|
|
322
|
+
redaction_keys.append(f.text)
|
|
323
|
+
|
|
324
|
+
types_summary = sorted({f.entity_type for f in findings})
|
|
325
|
+
return PostflightVerdict(
|
|
326
|
+
action=PostflightAction.REDACT,
|
|
327
|
+
reason=f"PII detected: {', '.join(types_summary)}",
|
|
328
|
+
redaction_keys=tuple(redaction_keys),
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
_gate.__qualname__ = "pii_postflight_gate.<locals>._gate"
|
|
332
|
+
_gate.__doc__ = f"PII postflight gate (threshold={cfg.score_threshold})"
|
|
333
|
+
return _gate # type: ignore[return-value]
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
# ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _extract_text(result: "ToolCallResult") -> str:
|
|
340
|
+
"""Extract scannable text from a ToolCallResult."""
|
|
341
|
+
parts: list[str] = []
|
|
342
|
+
if result.output:
|
|
343
|
+
for v in result.output.values():
|
|
344
|
+
if isinstance(v, str):
|
|
345
|
+
parts.append(v)
|
|
346
|
+
elif isinstance(v, (list, tuple)):
|
|
347
|
+
for item in v:
|
|
348
|
+
if isinstance(item, str):
|
|
349
|
+
parts.append(item)
|
|
350
|
+
if result.error:
|
|
351
|
+
parts.append(result.error)
|
|
352
|
+
return "\n".join(parts)
|