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.
Files changed (205) hide show
  1. traceforge/__init__.py +72 -0
  2. traceforge/__main__.py +5 -0
  3. traceforge/_generated.py +254 -0
  4. traceforge/adapters/__init__.py +12 -0
  5. traceforge/adapters/base.py +82 -0
  6. traceforge/adapters/genai_otel.py +164 -0
  7. traceforge/adapters/mapped_json.py +297 -0
  8. traceforge/adapters/otel.py +220 -0
  9. traceforge/boundary/__init__.py +46 -0
  10. traceforge/boundary/data/boundary-model.joblib +0 -0
  11. traceforge/boundary/decode.py +87 -0
  12. traceforge/boundary/features.py +137 -0
  13. traceforge/boundary/inference.py +267 -0
  14. traceforge/boundary/inferencer.py +146 -0
  15. traceforge/classify/__init__.py +95 -0
  16. traceforge/classify/cmd.py +109 -0
  17. traceforge/classify/coding.py +213 -0
  18. traceforge/classify/config.py +554 -0
  19. traceforge/classify/core.py +266 -0
  20. traceforge/classify/data/binary_info.yaml +358 -0
  21. traceforge/classify/data/canonical_tools.yaml +251 -0
  22. traceforge/classify/data/effect_overrides.yaml +480 -0
  23. traceforge/classify/data/mcp_profiles.yaml +711 -0
  24. traceforge/classify/data/recommendation_rules.yaml +111 -0
  25. traceforge/classify/data/risk.yaml +534 -0
  26. traceforge/classify/data/shell_defaults.yaml +95 -0
  27. traceforge/classify/data/shell_rules.yaml +1192 -0
  28. traceforge/classify/data/tool_classifications.yaml +215 -0
  29. traceforge/classify/data/verb_inference.yaml +218 -0
  30. traceforge/classify/mcp.py +181 -0
  31. traceforge/classify/phases.py +75 -0
  32. traceforge/classify/powershell.py +93 -0
  33. traceforge/classify/registry.py +138 -0
  34. traceforge/classify/risk.py +553 -0
  35. traceforge/classify/rules.py +215 -0
  36. traceforge/classify/schema.yaml +282 -0
  37. traceforge/classify/shell.py +503 -0
  38. traceforge/classify/tools.py +91 -0
  39. traceforge/classify/workflow.py +32 -0
  40. traceforge/cli/__init__.py +42 -0
  41. traceforge/cli/config_cmd.py +130 -0
  42. traceforge/cli/detect.py +46 -0
  43. traceforge/cli/download_cmd.py +74 -0
  44. traceforge/cli/factory.py +60 -0
  45. traceforge/cli/gate_cmd.py +30 -0
  46. traceforge/cli/init_cmd.py +86 -0
  47. traceforge/cli/replay.py +130 -0
  48. traceforge/cli/runner.py +181 -0
  49. traceforge/cli/score.py +217 -0
  50. traceforge/cli/status.py +65 -0
  51. traceforge/cli/watch.py +297 -0
  52. traceforge/config/__init__.py +80 -0
  53. traceforge/config/defaults.py +149 -0
  54. traceforge/config/loader.py +239 -0
  55. traceforge/config/mappings.py +104 -0
  56. traceforge/config/models.py +579 -0
  57. traceforge/enricher.py +576 -0
  58. traceforge/formatting/__init__.py +12 -0
  59. traceforge/formatting/budget.py +92 -0
  60. traceforge/formatting/density.py +154 -0
  61. traceforge/gate/__init__.py +17 -0
  62. traceforge/gate/client.py +145 -0
  63. traceforge/gate/external.py +305 -0
  64. traceforge/gate/registry.py +108 -0
  65. traceforge/gate/server.py +174 -0
  66. traceforge/gates/__init__.py +8 -0
  67. traceforge/gates/pii.py +352 -0
  68. traceforge/gates/pii_patterns.yaml +228 -0
  69. traceforge/governance/__init__.py +121 -0
  70. traceforge/governance/assessor.py +441 -0
  71. traceforge/governance/budget.py +59 -0
  72. traceforge/governance/canonical.py +56 -0
  73. traceforge/governance/codec.py +414 -0
  74. traceforge/governance/context.py +249 -0
  75. traceforge/governance/drift.py +196 -0
  76. traceforge/governance/emitter.py +234 -0
  77. traceforge/governance/envelope.py +138 -0
  78. traceforge/governance/ifc.py +364 -0
  79. traceforge/governance/integrity.py +162 -0
  80. traceforge/governance/labeler.py +250 -0
  81. traceforge/governance/mcp_drift.py +328 -0
  82. traceforge/governance/monitor.py +539 -0
  83. traceforge/governance/observer.py +276 -0
  84. traceforge/governance/persistence.py +401 -0
  85. traceforge/governance/phase1.py +77 -0
  86. traceforge/governance/pii.py +276 -0
  87. traceforge/governance/pipeline.py +840 -0
  88. traceforge/governance/registry.py +110 -0
  89. traceforge/governance/results.py +183 -0
  90. traceforge/governance/risk_wrapper.py +113 -0
  91. traceforge/governance/rules.py +368 -0
  92. traceforge/governance/scorer.py +262 -0
  93. traceforge/governance/shield.py +318 -0
  94. traceforge/governance/state.py +466 -0
  95. traceforge/governance/types.py +176 -0
  96. traceforge/mappings/__init__.py +1 -0
  97. traceforge/mappings/aider.yaml +216 -0
  98. traceforge/mappings/aider_markdown.yaml +113 -0
  99. traceforge/mappings/amazonq.yaml +56 -0
  100. traceforge/mappings/antigravity.yaml +88 -0
  101. traceforge/mappings/claude.yaml +93 -0
  102. traceforge/mappings/cline.yaml +286 -0
  103. traceforge/mappings/codex.yaml +158 -0
  104. traceforge/mappings/continue_dev.yaml +57 -0
  105. traceforge/mappings/copilot.yaml +266 -0
  106. traceforge/mappings/copilot_markdown.yaml +72 -0
  107. traceforge/mappings/copilot_vscode.yaml +181 -0
  108. traceforge/mappings/crewai.yaml +592 -0
  109. traceforge/mappings/goose.yaml +128 -0
  110. traceforge/mappings/langgraph.yaml +165 -0
  111. traceforge/mappings/maf.yaml +90 -0
  112. traceforge/mappings/maf_transcript.yaml +99 -0
  113. traceforge/mappings/openai_agents.yaml +144 -0
  114. traceforge/mappings/opencode.yaml +473 -0
  115. traceforge/mappings/openhands.yaml +320 -0
  116. traceforge/mappings/pydantic_ai.yaml +183 -0
  117. traceforge/mappings/smolagents.yaml +89 -0
  118. traceforge/mappings/sweagent.yaml +82 -0
  119. traceforge/migrations/__init__.py +1 -0
  120. traceforge/migrations/env.py +60 -0
  121. traceforge/migrations/models.py +145 -0
  122. traceforge/migrations/runner.py +44 -0
  123. traceforge/migrations/script.py.mako +25 -0
  124. traceforge/migrations/versions/0001_initial.py +176 -0
  125. traceforge/migrations/versions/__init__.py +4 -0
  126. traceforge/models.py +29 -0
  127. traceforge/parsers/__init__.py +21 -0
  128. traceforge/parsers/aider.py +416 -0
  129. traceforge/parsers/base.py +226 -0
  130. traceforge/parsers/copilot.py +314 -0
  131. traceforge/phase/__init__.py +50 -0
  132. traceforge/phase/data/phase-model.joblib +0 -0
  133. traceforge/phase/data/potion-base-8M/README.md +99 -0
  134. traceforge/phase/data/potion-base-8M/config.json +13 -0
  135. traceforge/phase/data/potion-base-8M/model.safetensors +0 -0
  136. traceforge/phase/data/potion-base-8M/modules.json +14 -0
  137. traceforge/phase/data/potion-base-8M/tokenizer.json +1 -0
  138. traceforge/phase/event_rows.py +107 -0
  139. traceforge/phase/features.py +468 -0
  140. traceforge/phase/inference.py +279 -0
  141. traceforge/phase/inferencer.py +171 -0
  142. traceforge/phase/segmentation.py +258 -0
  143. traceforge/pipeline.py +891 -0
  144. traceforge/preprocessors/__init__.py +34 -0
  145. traceforge/preprocessors/amazonq.py +224 -0
  146. traceforge/preprocessors/antigravity.py +116 -0
  147. traceforge/preprocessors/claude.py +95 -0
  148. traceforge/preprocessors/cline.py +36 -0
  149. traceforge/preprocessors/codex.py +311 -0
  150. traceforge/preprocessors/continue_dev.py +119 -0
  151. traceforge/preprocessors/copilot_vscode.py +171 -0
  152. traceforge/preprocessors/goose.py +156 -0
  153. traceforge/preprocessors/maf_transcript.py +84 -0
  154. traceforge/preprocessors/openai_agents.py +86 -0
  155. traceforge/preprocessors/opencode.py +85 -0
  156. traceforge/preprocessors/openhands.py +36 -0
  157. traceforge/preprocessors/pydantic_ai.py +62 -0
  158. traceforge/preprocessors/registry.py +24 -0
  159. traceforge/preprocessors/smolagents.py +90 -0
  160. traceforge/py.typed +0 -0
  161. traceforge/sdk/__init__.py +59 -0
  162. traceforge/sdk/gate_policy.py +63 -0
  163. traceforge/sdk/gate_types.py +140 -0
  164. traceforge/sdk/pipeline.py +265 -0
  165. traceforge/sdk/verdict.py +81 -0
  166. traceforge/sinks/__init__.py +23 -0
  167. traceforge/sinks/base.py +95 -0
  168. traceforge/sinks/callback.py +132 -0
  169. traceforge/sinks/console.py +112 -0
  170. traceforge/sinks/factory.py +94 -0
  171. traceforge/sinks/jsonl.py +125 -0
  172. traceforge/sinks/otel_exporter.py +212 -0
  173. traceforge/sinks/parquet.py +260 -0
  174. traceforge/sinks/s3.py +206 -0
  175. traceforge/sinks/sqlite_output.py +234 -0
  176. traceforge/sinks/webhook.py +136 -0
  177. traceforge/sources/__init__.py +18 -0
  178. traceforge/sources/auto_detect.py +173 -0
  179. traceforge/sources/base.py +45 -0
  180. traceforge/sources/file_poll.py +136 -0
  181. traceforge/sources/file_watch.py +221 -0
  182. traceforge/sources/http_poll.py +141 -0
  183. traceforge/sources/replay.py +63 -0
  184. traceforge/sources/sqlite.py +187 -0
  185. traceforge/sources/sse.py +198 -0
  186. traceforge/telemetry/__init__.py +192 -0
  187. traceforge/title/__init__.py +23 -0
  188. traceforge/title/_resolve.py +79 -0
  189. traceforge/title/context.py +295 -0
  190. traceforge/title/data/boilerplate_files.json +14 -0
  191. traceforge/title/heuristics.py +429 -0
  192. traceforge/title/hygiene.py +90 -0
  193. traceforge/title/inference.py +314 -0
  194. traceforge/title/inferencer.py +477 -0
  195. traceforge/title/naming.py +398 -0
  196. traceforge/trace.py +291 -0
  197. traceforge/tracking/__init__.py +30 -0
  198. traceforge/tracking/models.py +115 -0
  199. traceforge/tracking/phase_tracker.py +288 -0
  200. traceforge/types.py +315 -0
  201. traceforge_toolkit-0.1.0.dist-info/METADATA +188 -0
  202. traceforge_toolkit-0.1.0.dist-info/RECORD +205 -0
  203. traceforge_toolkit-0.1.0.dist-info/WHEEL +4 -0
  204. traceforge_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
  205. 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
@@ -0,0 +1,8 @@
1
+ """Built-in gate implementations for common policy patterns.
2
+
3
+ Usage:
4
+ from traceforge.gates.pii import pii_postflight_gate, PiiGateConfig
5
+ from traceforge.sdk import GatePolicy
6
+
7
+ policy = GatePolicy().postflight(pii_postflight_gate())
8
+ """
@@ -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)