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,77 @@
1
+ """Phase 1 of governance enrichment: session-state mutation.
2
+
3
+ Phase 1 is the single mutable step in the pipeline. It advances the per-session
4
+ accumulator in place — phase window, budget counters, information-flow taint,
5
+ and pressure — from an EnrichmentContext. It is isolated here so the durable
6
+ writer (process_event) and the non-persisted preview (preflight_event, which
7
+ runs it against a detached clone) share exactly one definition of what Phase 1
8
+ does; the caller alone decides whether the mutated state is persisted.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import TYPE_CHECKING
14
+
15
+ if TYPE_CHECKING:
16
+ from traceforge.governance.budget import BudgetTracker
17
+ from traceforge.governance.labeler import GovernanceLabeler
18
+ from traceforge.governance.state import SessionState
19
+ from traceforge.governance.types import EnrichmentContext
20
+
21
+
22
+ class Phase1:
23
+ """Apply Phase-1 state mutations (phase window, budget, IFC taint, pressure)."""
24
+
25
+ def __init__(self, budget: "BudgetTracker", labeler: "GovernanceLabeler") -> None:
26
+ self._budget = budget
27
+ self._labeler = labeler
28
+
29
+ def infer_phase(self, ctx: "EnrichmentContext") -> str | None:
30
+ """Infer session phase from classification/event."""
31
+ from traceforge.governance.types import ToolCallEvent
32
+
33
+ cls = ctx.base_classification
34
+ # Network capability takes priority
35
+ if "network_outbound" in cls.capability:
36
+ return "network"
37
+ if cls.effect == "read_only":
38
+ return "exploration"
39
+ if cls.effect == "destructive":
40
+ return "destructive"
41
+ if cls.effect == "mutating":
42
+ tool_name = ""
43
+ if isinstance(ctx.event, ToolCallEvent):
44
+ tool_name = (ctx.event.tool_name or "").lower()
45
+ if "test" in tool_name or "verify" in tool_name or "check" in tool_name:
46
+ return "testing"
47
+ if "deploy" in tool_name or "publish" in tool_name:
48
+ return "deployment"
49
+ return "implementation"
50
+ if cls.effect == "informational":
51
+ return "exploration"
52
+ return "exploration"
53
+
54
+ def apply(self, ctx: "EnrichmentContext", state: "SessionState") -> None:
55
+ """Run the Phase-1 mutation sequence against ``state`` in place.
56
+
57
+ Persistence is the caller's concern: process_event applies this to the
58
+ registry-resident state and then commits it atomically; preflight_event
59
+ applies it to a detached clone and never persists.
60
+ """
61
+ phase = self.infer_phase(ctx)
62
+ if phase:
63
+ state.update_phase_window(phase)
64
+ self._budget.increment(ctx, state)
65
+ if self._labeler.has_ifc:
66
+ ifc_src_labels: set[str] = set()
67
+ self._labeler.check_ifc(ctx, ifc_src_labels, state)
68
+ state.record_event(None)
69
+ self._budget.check_pressure(state)
70
+ # Motivation tracking (#10): record the id of the last assistant-authored
71
+ # governed event. Every event that reaches Phase 1 is an assistant-initiated
72
+ # tool call — session lifecycle is handled by process_lifecycle, and user/
73
+ # assistant *messages* are ungoverned at the observe choke point and never
74
+ # reach here — so the tool call's own event id is the assistant motivation
75
+ # pointer. There is no user-authored event in the tool-call observation path,
76
+ # so the user pointer is intentionally left unset (no phantom id).
77
+ state.set_last_assistant(ctx.event.event_id)
@@ -0,0 +1,276 @@
1
+ """PII and credential scanning for governance enrichment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass
8
+ from enum import StrEnum
9
+ from typing import TYPE_CHECKING
10
+
11
+ if TYPE_CHECKING:
12
+ from traceforge.governance.types import EnrichmentContext
13
+
14
+
15
+ class PIICategory(StrEnum):
16
+ SSN = "ssn"
17
+ EMAIL = "email"
18
+ PHONE = "phone"
19
+ CREDIT_CARD = "credit_card"
20
+ API_KEY = "api_key"
21
+ PASSWORD = "password"
22
+ PRIVATE_KEY = "private_key"
23
+ AWS_KEY = "aws_key"
24
+ JWT = "jwt"
25
+ CONNECTION_STRING = "connection_string"
26
+
27
+
28
+ # ─── Reserved / documentation ranges ──────────────────────────────────────────
29
+ # Standards bodies reserve these identifiers for documentation and testing, so a
30
+ # match against one is a placeholder, never real PII, and must not be flagged.
31
+
32
+ # RFC 2606 / RFC 6761 reserved TLDs and second-level example domains.
33
+ _RESERVED_EMAIL_TLDS = frozenset({"test", "example", "invalid", "localhost"})
34
+ _RESERVED_EMAIL_SLDS = frozenset({"example.com", "example.net", "example.org"})
35
+
36
+ # Obvious non-secret values that appear in docs, configs, and type annotations.
37
+ _PLACEHOLDER_SECRETS = frozenset(
38
+ {
39
+ "null",
40
+ "none",
41
+ "nil",
42
+ "true",
43
+ "false",
44
+ "redacted",
45
+ "changeme",
46
+ "example",
47
+ "placeholder",
48
+ "password",
49
+ "passphrase",
50
+ "secret",
51
+ "your_password",
52
+ "yourpassword",
53
+ "test",
54
+ }
55
+ )
56
+
57
+
58
+ def _digits(text: str) -> str:
59
+ return "".join(ch for ch in text if ch.isdigit())
60
+
61
+
62
+ def _luhn_valid(number: str) -> bool:
63
+ """Return True when a digit string satisfies the Luhn checksum."""
64
+ if len(number) < 13:
65
+ return False
66
+ total = 0
67
+ parity = len(number) % 2
68
+ for index, char in enumerate(number):
69
+ digit = int(char)
70
+ if index % 2 == parity:
71
+ digit *= 2
72
+ if digit > 9:
73
+ digit -= 9
74
+ total += digit
75
+ return total % 10 == 0
76
+
77
+
78
+ def _valid_credit_card(match: re.Match[str]) -> bool:
79
+ """Card-shaped numbers only count when they pass Luhn (placeholders do not)."""
80
+ return _luhn_valid(_digits(match.group(0)))
81
+
82
+
83
+ def _valid_ssn(match: re.Match[str]) -> bool:
84
+ """Reject SSNs the SSA never issues (documentation placeholders)."""
85
+ digits = _digits(match.group(0))
86
+ if len(digits) != 9:
87
+ return False
88
+ area, group, serial = digits[:3], digits[3:5], digits[5:]
89
+ if area in {"000", "666"} or area[0] == "9":
90
+ return False
91
+ return group != "00" and serial != "0000"
92
+
93
+
94
+ def _valid_email(match: re.Match[str]) -> bool:
95
+ """Suppress RFC 2606 / RFC 6761 example and test domains."""
96
+ domain = match.group(0).rsplit("@", 1)[-1].lower().rstrip(".")
97
+ tld = domain.rsplit(".", 1)[-1]
98
+ if tld in _RESERVED_EMAIL_TLDS:
99
+ return False
100
+ return not any(domain == sld or domain.endswith("." + sld) for sld in _RESERVED_EMAIL_SLDS)
101
+
102
+
103
+ def _valid_phone(match: re.Match[str]) -> bool:
104
+ """Suppress the NANP 555-0100..555-0199 range reserved for fictional use."""
105
+ subscriber = _digits(match.group(0))[-10:][-7:]
106
+ return not (subscriber[:3] == "555" and subscriber[3:5] == "01")
107
+
108
+
109
+ def _valid_secret_kv(match: re.Match[str]) -> bool:
110
+ """A key/value pair is a cleartext secret only when the value is a literal.
111
+
112
+ Quoted values are treated as literal secrets. Bare values must carry a
113
+ non-alpha entropy signal, so type annotations (``password: str``) and plain
114
+ identifiers are not mistaken for credentials.
115
+ """
116
+ raw = match.group("value")
117
+ quoted = len(raw) >= 2 and raw[0] in "'\"" and raw[-1] == raw[0]
118
+ value = (raw[1:-1] if quoted else raw).strip()
119
+ if len(value) < 4 or value.lower() in _PLACEHOLDER_SECRETS:
120
+ return False
121
+ if quoted:
122
+ return True
123
+ return any(not char.isalpha() for char in value)
124
+
125
+
126
+ def _always_valid(match: re.Match[str]) -> bool:
127
+ return True
128
+
129
+
130
+ # Keys, longest/most-specific first, whose value may hold a cleartext secret.
131
+ _SECRET_KEYWORDS = (
132
+ r"passphrase|passwd|password|pwd|"
133
+ r"secret[_-]?key|client[_-]?secret|secret|"
134
+ r"api[_-]?key|apikey|"
135
+ r"access[_-]?key|access[_-]?token|refresh[_-]?token|auth[_-]?token|"
136
+ r"private[_-]?key|credentials?|token"
137
+ )
138
+
139
+
140
+ @dataclass(frozen=True)
141
+ class _Detector:
142
+ """One PII/credential detector: a regex plus optional per-match validation."""
143
+
144
+ category: PIICategory
145
+ pattern: re.Pattern[str]
146
+ is_credential: bool
147
+ validator: Callable[[re.Match[str]], bool] = _always_valid
148
+
149
+ def found_in(self, content: str) -> bool:
150
+ """True when content holds at least one *valid* (non-placeholder) match."""
151
+ return any(self.validator(match) for match in self.pattern.finditer(content))
152
+
153
+
154
+ _DETECTORS: tuple[_Detector, ...] = (
155
+ _Detector(
156
+ PIICategory.SSN,
157
+ re.compile(r"\b\d{3}-\d{2}-\d{4}\b"),
158
+ is_credential=False,
159
+ validator=_valid_ssn,
160
+ ),
161
+ _Detector(
162
+ PIICategory.EMAIL,
163
+ re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b"),
164
+ is_credential=False,
165
+ validator=_valid_email,
166
+ ),
167
+ _Detector(
168
+ PIICategory.PHONE,
169
+ # Two shapes: E.164 (`+` then 6-15 digits) or a grouped NANP-style number
170
+ # with mandatory separators, so plain digit runs, SSNs (3-2-4) and credit
171
+ # cards (4-4-4-4) are never misread as phone numbers.
172
+ re.compile(
173
+ r"(?<![\w.])"
174
+ r"(?:"
175
+ r"\+\d{6,15}"
176
+ r"|"
177
+ r"(?:\+\d{1,3}[ .\-]?)?"
178
+ r"(?:\(\d{3}\)[ .\-]?|\d{3}[ .\-])"
179
+ r"\d{3}[ .\-]\d{4}"
180
+ r")"
181
+ r"(?!\d)"
182
+ ),
183
+ is_credential=False,
184
+ validator=_valid_phone,
185
+ ),
186
+ _Detector(
187
+ PIICategory.CREDIT_CARD,
188
+ re.compile(
189
+ r"\b(?:"
190
+ r"(?:4\d{3}|5[1-5]\d{2}|6(?:011|5\d{2}))[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}"
191
+ r"|3[47]\d{2}[- ]?\d{6}[- ]?\d{5}"
192
+ r")\b"
193
+ ),
194
+ is_credential=False,
195
+ validator=_valid_credit_card,
196
+ ),
197
+ _Detector(
198
+ PIICategory.API_KEY,
199
+ re.compile(r"\b(?:sk|pk|api|key|token|secret)[-_][A-Za-z0-9-_]{20,}\b", re.IGNORECASE),
200
+ is_credential=True,
201
+ ),
202
+ _Detector(
203
+ PIICategory.PASSWORD,
204
+ # `key = value` / `key: value` secrets, tolerating a JSON key's closing
205
+ # quote (`"password": ...`). A separator is mandatory, so bare paths such
206
+ # as ``/etc/passwd`` never match.
207
+ re.compile(
208
+ r"\b(?:" + _SECRET_KEYWORDS + r")\b['\"]?\s*[:=]\s*"
209
+ r"(?P<value>'[^'\r\n]{4,}'|\"[^\"\r\n]{4,}\"|[^\s'\";,}]{6,})",
210
+ re.IGNORECASE,
211
+ ),
212
+ is_credential=True,
213
+ validator=_valid_secret_kv,
214
+ ),
215
+ _Detector(
216
+ PIICategory.PRIVATE_KEY,
217
+ re.compile(r"-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----"),
218
+ is_credential=True,
219
+ ),
220
+ _Detector(
221
+ PIICategory.AWS_KEY,
222
+ re.compile(r"\b(?:AKIA|ASIA)[A-Z0-9]{16}\b"),
223
+ is_credential=True,
224
+ ),
225
+ _Detector(
226
+ PIICategory.JWT,
227
+ re.compile(r"\beyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b"),
228
+ is_credential=True,
229
+ ),
230
+ _Detector(
231
+ PIICategory.CONNECTION_STRING,
232
+ re.compile(r"(?:mongodb|postgres|mysql|redis|amqp)://[^\s]+", re.IGNORECASE),
233
+ is_credential=True,
234
+ ),
235
+ )
236
+
237
+
238
+ class PIIScanner:
239
+ """Stateless PII/credential scanner. Adds capability labels."""
240
+
241
+ def scan(self, ctx: "EnrichmentContext", cap: set[str], struct: set[str]) -> None:
242
+ """Scan event content for PII. Mutates cap/struct sets."""
243
+ from traceforge.governance.types import ToolCallEvent, ToolResultEvent
244
+
245
+ content = ""
246
+ if isinstance(ctx.event, ToolCallEvent):
247
+ content = ctx.event.tool_args_json
248
+ elif isinstance(ctx.event, ToolResultEvent):
249
+ content = ctx.event.result_payload_json or ""
250
+
251
+ if not content:
252
+ return
253
+
254
+ found_pii = False
255
+ found_credential = False
256
+
257
+ for detector in _DETECTORS:
258
+ if not detector.found_in(content):
259
+ continue
260
+ if detector.is_credential:
261
+ found_credential = True
262
+ else:
263
+ found_pii = True
264
+ if found_pii and found_credential:
265
+ break
266
+
267
+ if found_credential:
268
+ cap.add("credential_exposure")
269
+ if found_pii:
270
+ cap.add("pii_exposure")
271
+
272
+ # Tainted flow: PII going to network-capable tool
273
+ if (
274
+ found_pii or found_credential
275
+ ) and "network_outbound" in ctx.base_classification.capability:
276
+ struct.add("tainted_flow")