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,154 @@
1
+ """Density-based event formatting for terminal output and reports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from enum import StrEnum
7
+
8
+ from traceforge.trace import EventTrace
9
+ from traceforge.types import SessionEvent
10
+
11
+
12
+ class Density(StrEnum):
13
+ """Output verbosity level."""
14
+
15
+ MINIMAL = "minimal" # one-line: "[risk_band] tool_name → effect"
16
+ STANDARD = "standard" # two-line: identity + classification summary
17
+ VERBOSE = "verbose" # full: all fields, raw_event excerpt
18
+
19
+
20
+ def format_event(event: SessionEvent, density: Density = Density.STANDARD) -> str:
21
+ """Format a single SessionEvent at the given density level."""
22
+ if density == Density.MINIMAL:
23
+ return _format_event_minimal(event)
24
+ elif density == Density.STANDARD:
25
+ return _format_event_standard(event)
26
+ else:
27
+ return _format_event_verbose(event)
28
+
29
+
30
+ def format_trace(trace: EventTrace, density: Density = Density.STANDARD) -> str:
31
+ """Format an EventTrace at the given density level."""
32
+ if density == Density.MINIMAL:
33
+ return _format_trace_minimal(trace)
34
+ elif density == Density.STANDARD:
35
+ return _format_trace_standard(trace)
36
+ else:
37
+ return _format_trace_verbose(trace)
38
+
39
+
40
+ # ─── SessionEvent formatters ────────────────────────────────────────────────
41
+
42
+
43
+ def _format_event_minimal(event: SessionEvent) -> str:
44
+ risk = ""
45
+ tool = ""
46
+ if event.metadata and event.metadata.classification:
47
+ cls = event.metadata.classification
48
+ risk = f"[{cls.risk_band}] " if hasattr(cls, "risk_band") and cls.risk_band else ""
49
+ if event.metadata and event.metadata.tool_display:
50
+ tool = event.metadata.tool_display
51
+ elif "tool_name" in event.payload:
52
+ tool = event.payload["tool_name"]
53
+ else:
54
+ tool = event.kind
55
+
56
+ effect = ""
57
+ if event.metadata and event.metadata.classification:
58
+ cls = event.metadata.classification
59
+ if hasattr(cls, "effect") and cls.effect:
60
+ effect = f" → {cls.effect}"
61
+
62
+ return f"{risk}{tool}{effect}"
63
+
64
+
65
+ def _format_event_standard(event: SessionEvent) -> str:
66
+ line1 = f"[{event.kind}] session={event.session_id} @ {event.timestamp.isoformat()}"
67
+ parts: list[str] = []
68
+ if event.metadata:
69
+ if event.metadata.tool_display:
70
+ parts.append(f"tool={event.metadata.tool_display}")
71
+ if event.metadata.classification:
72
+ cls = event.metadata.classification
73
+ if hasattr(cls, "effect") and cls.effect:
74
+ parts.append(f"effect={cls.effect}")
75
+ if hasattr(cls, "risk_band") and cls.risk_band:
76
+ parts.append(f"risk={cls.risk_band}")
77
+ line2 = " " + " ".join(parts) if parts else ""
78
+ return f"{line1}\n{line2}".rstrip()
79
+
80
+
81
+ def _format_event_verbose(event: SessionEvent) -> str:
82
+ lines = [
83
+ f"[{event.kind}] id={event.id}",
84
+ f" session_id: {event.session_id}",
85
+ f" timestamp: {event.timestamp.isoformat()}",
86
+ ]
87
+ if event.metadata:
88
+ if event.metadata.tool_display:
89
+ lines.append(f" tool: {event.metadata.tool_display}")
90
+ if event.metadata.classification:
91
+ lines.append(f" classification: {event.metadata.classification}")
92
+ if event.metadata.governance:
93
+ lines.append(f" governance: {event.metadata.governance}")
94
+ # Truncated raw payload excerpt
95
+ payload_str = json.dumps(event.payload, default=str)
96
+ if len(payload_str) > 200:
97
+ payload_str = payload_str[:200] + "..."
98
+ lines.append(f" payload: {payload_str}")
99
+ return "\n".join(lines)
100
+
101
+
102
+ # ─── EventTrace formatters ──────────────────────────────────────────────────
103
+
104
+
105
+ def _format_trace_minimal(trace: EventTrace) -> str:
106
+ risk = f"[{trace.risk_band.value}] " if trace.risk_band else ""
107
+ tool = trace.tool_name or trace.kind.value
108
+ effect = f" → {trace.effect.value}" if trace.effect else ""
109
+ return f"{risk}{tool}{effect}"
110
+
111
+
112
+ def _format_trace_standard(trace: EventTrace) -> str:
113
+ line1 = f"[{trace.kind.value}] session={trace.session_id} tool={trace.tool_name or '-'}"
114
+ parts: list[str] = []
115
+ if trace.mechanism:
116
+ parts.append(f"mechanism={trace.mechanism.value}")
117
+ if trace.effect:
118
+ parts.append(f"effect={trace.effect.value}")
119
+ if trace.risk_band:
120
+ parts.append(f"risk={trace.risk_band.value}")
121
+ if trace.suggested_action:
122
+ parts.append(f"action={trace.suggested_action.value}")
123
+ line2 = " " + " ".join(parts) if parts else ""
124
+ return f"{line1}\n{line2}".rstrip()
125
+
126
+
127
+ def _format_trace_verbose(trace: EventTrace) -> str:
128
+ lines = [
129
+ f"[{trace.kind.value}] id={trace.id}",
130
+ f" session_id: {trace.session_id}",
131
+ f" timestamp: {trace.timestamp.isoformat()}",
132
+ f" tool_name: {trace.tool_name or '-'}",
133
+ f" stage: {trace.stage.value}",
134
+ ]
135
+ if trace.mechanism:
136
+ lines.append(f" mechanism: {trace.mechanism.value}")
137
+ if trace.effect:
138
+ lines.append(f" effect: {trace.effect.value}")
139
+ if trace.scope:
140
+ lines.append(f" scope: {', '.join(s.value for s in trace.scope)}")
141
+ if trace.risk_score is not None:
142
+ lines.append(f" risk_score: {trace.risk_score}")
143
+ if trace.risk_band:
144
+ lines.append(f" risk_band: {trace.risk_band.value}")
145
+ if trace.suggested_action:
146
+ lines.append(f" action: {trace.suggested_action.value}")
147
+ if trace.reason:
148
+ lines.append(f" reason: {trace.reason}")
149
+ # Raw event excerpt
150
+ raw_str = json.dumps(dict(trace.raw_event) if trace.raw_event else {}, default=str)
151
+ if len(raw_str) > 200:
152
+ raw_str = raw_str[:200] + "..."
153
+ lines.append(f" raw_event: {raw_str}")
154
+ return "\n".join(lines)
@@ -0,0 +1,17 @@
1
+ """Traceforge gate — IPC server, registry, and CLI relay for cross-process gating."""
2
+
3
+ from traceforge.gate.registry import (
4
+ register_session,
5
+ lookup_session,
6
+ unregister_session,
7
+ unregister_pid,
8
+ )
9
+ from traceforge.gate.server import GateServer
10
+
11
+ __all__ = [
12
+ "GateServer",
13
+ "register_session",
14
+ "lookup_session",
15
+ "unregister_session",
16
+ "unregister_pid",
17
+ ]
@@ -0,0 +1,145 @@
1
+ """Gate CLI client — connects to a running Pipeline's IPC server.
2
+
3
+ Used by `traceforge gate --stdin` to relay tool call events from external hooks
4
+ (e.g., Claude Code PreToolUse) to the Pipeline process for scoring and gating.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import socket
11
+ import struct
12
+ import sys
13
+
14
+
15
+ def send_gate_request(sock_path: str, payload: dict) -> dict:
16
+ """Send a gate request to the IPC server and return the verdict dict."""
17
+ if sock_path.startswith("tcp://"):
18
+ # Windows TCP fallback
19
+ addr = sock_path[len("tcp://") :]
20
+ host, port_str = addr.rsplit(":", 1)
21
+ conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
22
+ conn.connect((host, int(port_str)))
23
+ else:
24
+ conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
25
+ conn.connect(sock_path)
26
+
27
+ try:
28
+ conn.settimeout(30.0)
29
+ data = json.dumps(payload).encode("utf-8")
30
+ conn.sendall(struct.pack("!I", len(data)) + data)
31
+
32
+ # Read length-prefixed response
33
+ header = b""
34
+ while len(header) < 4:
35
+ chunk = conn.recv(4 - len(header))
36
+ if not chunk:
37
+ return {"decision": "deny", "reason": "connection closed"}
38
+ header += chunk
39
+ length = struct.unpack("!I", header)[0]
40
+ resp_data = b""
41
+ while len(resp_data) < length:
42
+ chunk = conn.recv(min(length - len(resp_data), 65536))
43
+ if not chunk:
44
+ break
45
+ resp_data += chunk
46
+ return json.loads(resp_data)
47
+ finally:
48
+ conn.close()
49
+
50
+
51
+ def gate_from_stdin(*, format: str = "claude-code") -> None:
52
+ """Read event JSON from stdin, relay to Pipeline, output verdict to stdout.
53
+
54
+ Args:
55
+ format: Output format. "claude-code" outputs Claude Code hook JSON.
56
+ "json" outputs raw verdict JSON.
57
+ """
58
+ from traceforge.gate.registry import lookup_session
59
+
60
+ # Read event from stdin
61
+ event_raw = sys.stdin.read()
62
+ if not event_raw.strip():
63
+ # Empty input = deny (fail-closed — agent hook failed to produce data)
64
+ _output_deny(format, "empty event (fail-closed)")
65
+ return
66
+
67
+ try:
68
+ event = json.loads(event_raw)
69
+ except json.JSONDecodeError:
70
+ # Malformed JSON = deny (fail-closed)
71
+ _output_deny(format, "malformed event JSON")
72
+ return
73
+
74
+ # Extract session_id
75
+ session_id = event.get("session_id")
76
+ if not session_id:
77
+ # No session_id = deny (fail-closed)
78
+ _output_deny(format, "no session_id in event")
79
+ return
80
+
81
+ # Look up socket
82
+ sock_path = lookup_session(session_id)
83
+ if not sock_path:
84
+ # Fall back to default session (traceforge watch registers as "_default")
85
+ sock_path = lookup_session("_default")
86
+ if not sock_path:
87
+ # Session not registered = deny (fail-closed)
88
+ _output_deny(format, f"session {session_id} not registered with any pipeline")
89
+ return
90
+
91
+ # Build payload for score_tool_call
92
+ payload = {
93
+ "tool_name": event.get("tool_name") or event.get("tool", {}).get("name", ""),
94
+ "tool_input": event.get("tool_input") or event.get("tool", {}).get("input", {}),
95
+ "session_id": session_id,
96
+ }
97
+ if event.get("tool_call_id"):
98
+ payload["tool_call_id"] = event["tool_call_id"]
99
+ if event.get("server_namespace"):
100
+ payload["server_namespace"] = event["server_namespace"]
101
+ if event.get("mcp_server_name"):
102
+ payload["mcp_server_name"] = event["mcp_server_name"]
103
+
104
+ # Send to Pipeline IPC
105
+ try:
106
+ verdict = send_gate_request(sock_path, payload)
107
+ except (ConnectionRefusedError, FileNotFoundError, OSError) as exc:
108
+ _output_deny(format, f"pipeline unreachable: {exc}")
109
+ return
110
+
111
+ # Output verdict
112
+ decision = verdict.get("decision", "allow")
113
+ if decision == "deny":
114
+ _output_deny(format, verdict.get("reason", ""))
115
+ elif decision == "escalate":
116
+ _output_deny(format, verdict.get("reason", "") or "escalated — requires human approval")
117
+ else:
118
+ _output_allow(format)
119
+
120
+
121
+ def _output_allow(format: str) -> None:
122
+ """Output an allow verdict in the specified format."""
123
+ if format == "claude-code":
124
+ # Empty JSON = pass to normal permission flow
125
+ print("{}")
126
+ else:
127
+ print(json.dumps({"decision": "allow"}))
128
+
129
+
130
+ def _output_deny(format: str, reason: str) -> None:
131
+ """Output a deny verdict in the specified format."""
132
+ if format == "claude-code":
133
+ print(
134
+ json.dumps(
135
+ {
136
+ "hookSpecificOutput": {
137
+ "hookEventName": "PreToolUse",
138
+ "permissionDecision": "deny",
139
+ "permissionDecisionReason": reason or "denied by traceforge policy",
140
+ }
141
+ }
142
+ )
143
+ )
144
+ else:
145
+ print(json.dumps({"decision": "deny", "reason": reason}))
@@ -0,0 +1,305 @@
1
+ """External preflight gates — call an out-of-process decider (PDP) for a verdict.
2
+
3
+ Where the in-process ``GatePolicy`` runs Python callbacks, these two gates delegate
4
+ the ALLOW/DENY decision to an *external* Policy Decision Point so gating can be
5
+ configured entirely from YAML with zero Python:
6
+
7
+ * :class:`HttpGate` — POSTs a JSON request to a persistent HTTP PDP (e.g. an OPA
8
+ REST server) and reads a verdict. Primary / recommended mode.
9
+ * :class:`SubprocessGate` — spawns a command per call, writes the JSON request to
10
+ its stdin and reads the JSON verdict from its stdout. For portability, OPA
11
+ ``eval``, or air-gapped use.
12
+
13
+ Both satisfy the sync :class:`~traceforge.sdk.verdict.PreflightGate` protocol
14
+ (``(ToolCallRequest, GateContext) -> Verdict``) so they plug straight into a
15
+ :class:`~traceforge.sdk.gate_policy.GatePolicy` with no framework-adapter changes.
16
+
17
+ Security posture — FAIL CLOSED BY DEFAULT
18
+ -----------------------------------------
19
+ ``fail_open`` defaults to ``False`` on both gates: any error, timeout, non-2xx
20
+ response, non-zero exit, or unparseable output DENIES the call. A gate that fails
21
+ open on error silently disables enforcement exactly when something is wrong, which
22
+ is a security anti-pattern — so the safe default is non-negotiable. Set
23
+ ``fail_open=True`` only for availability-over-safety deployments where you have
24
+ consciously accepted that a broken decider means unfiltered tool calls.
25
+
26
+ Dependency-light & thread-safe
27
+ ------------------------------
28
+ No third-party dependencies: HTTP uses stdlib ``urllib.request`` and subprocess uses
29
+ stdlib ``subprocess``. Both gates are stateless callables over thread-safe stdlib,
30
+ so LangGraph/CrewAI may invoke them from multiple threads concurrently.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import json
36
+ import os
37
+ import shlex
38
+ import subprocess
39
+ import urllib.error
40
+ import urllib.request
41
+ from dataclasses import dataclass
42
+ from enum import Enum
43
+ from types import MappingProxyType
44
+ from typing import TYPE_CHECKING, Any
45
+
46
+ from traceforge.sdk.verdict import Verdict
47
+
48
+ if TYPE_CHECKING:
49
+ from traceforge.sdk.gate_types import GateContext, ToolCallRequest
50
+
51
+ __all__ = ["HttpGate", "SubprocessGate"]
52
+
53
+
54
+ # ─── Wire serialization ───────────────────────────────────────────────────────
55
+
56
+
57
+ def _stringify(value: Any) -> Any:
58
+ """Render an enum (or plain value) as its wire string.
59
+
60
+ StrEnum members are already ``str`` subclasses, but we normalize to a plain
61
+ ``str`` so the emitted payload never carries enum instances.
62
+ """
63
+ if isinstance(value, Enum):
64
+ return str(value.value)
65
+ return str(value)
66
+
67
+
68
+ def _cap_str(text: str, max_input_bytes: int) -> str:
69
+ """Cap a string to ``max_input_bytes`` UTF-8 bytes with a truncation marker.
70
+
71
+ Redaction cap: tool inputs may be large and may contain secrets. Capping bounds
72
+ the payload sent to an external decider (which the operator must trust).
73
+ """
74
+ encoded = text.encode("utf-8")
75
+ if len(encoded) <= max_input_bytes:
76
+ return text
77
+ truncated = encoded[:max_input_bytes].decode("utf-8", errors="ignore")
78
+ return f"{truncated}...[truncated {len(encoded)} bytes]"
79
+
80
+
81
+ def _json_safe(value: Any, max_input_bytes: int) -> Any:
82
+ """Recursively coerce ``value`` into a JSON-serializable form.
83
+
84
+ Enums are stringified (checked before ``str`` because StrEnum IS a str), string
85
+ leaves are byte-capped, containers are converted to dict/list, and anything else
86
+ falls back to a capped ``str(...)`` — so a stray non-serializable object can never
87
+ reach the wire or crash :func:`json.dumps`.
88
+ """
89
+ if isinstance(value, Enum):
90
+ return _stringify(value)
91
+ if isinstance(value, str):
92
+ return _cap_str(value, max_input_bytes)
93
+ if isinstance(value, bool):
94
+ return value
95
+ if isinstance(value, (int, float)) or value is None:
96
+ return value
97
+ if isinstance(value, (MappingProxyType, dict)):
98
+ return {str(k): _json_safe(v, max_input_bytes) for k, v in value.items()}
99
+ if isinstance(value, (list, tuple, set, frozenset)):
100
+ return [_json_safe(v, max_input_bytes) for v in value]
101
+ return _cap_str(str(value), max_input_bytes)
102
+
103
+
104
+ def _serialize_request(
105
+ request: "ToolCallRequest", ctx: "GateContext", max_input_bytes: int
106
+ ) -> dict[str, Any]:
107
+ """Build a JSON-safe dict describing the tool call for an external decider.
108
+
109
+ Includes the rich policy surface (tool, capped input, target, classification,
110
+ risk, identity) plus a nested ``context`` block. Enums are stringified.
111
+
112
+ CRITICAL: ``event_trace`` (and any other non-JSON-serializable object) is NEVER
113
+ included — the escape-hatch EventTrace stays in-process. Only the flat, redacted
114
+ projection crosses the wire.
115
+ """
116
+ return {
117
+ "tool": _stringify(request.tool),
118
+ "input": _json_safe(request.input, max_input_bytes),
119
+ "target": request.target,
120
+ "mechanism": _stringify(request.mechanism),
121
+ "effect": _stringify(request.effect),
122
+ "capabilities": [_stringify(c) for c in request.capabilities],
123
+ "scope": [_stringify(s) for s in request.scope],
124
+ "role": [_stringify(r) for r in request.role],
125
+ "action": [_stringify(a) for a in request.action],
126
+ "risk_score": request.risk_score,
127
+ "risk_band": _stringify(request.risk_band),
128
+ "suggested_action": _stringify(request.suggested_action),
129
+ "reason": request.reason,
130
+ "session_id": request.session_id,
131
+ "tool_call_id": request.tool_call_id,
132
+ "context": {
133
+ "session_id": ctx.session_id,
134
+ "tool_call_count": ctx.tool_call_count,
135
+ "denied_count": ctx.denied_count,
136
+ "agent_id": ctx.agent_id,
137
+ "user_id": ctx.user_id,
138
+ },
139
+ }
140
+
141
+
142
+ def _parse_response(data: Any) -> Verdict:
143
+ """Map a decoded decider response into a Verdict.
144
+
145
+ Contract: ``{"decision": "deny", "reason": "..."}`` -> DENY; anything else -> ALLOW.
146
+ Liberal in what it accepts: the decision match is case-insensitive, extra fields
147
+ (e.g. ``score``/``level`` that the built-in gate server also returns) are ignored,
148
+ and an OPA-style ``{"result": {...}}`` envelope is unwrapped automatically.
149
+
150
+ NOTE: this only interprets a *successful* response body. Transport-level failures
151
+ (timeout, non-2xx, unparseable output) are handled by the gate's fail-open policy,
152
+ never here.
153
+ """
154
+ if not isinstance(data, dict):
155
+ return Verdict.allow()
156
+ # Unwrap OPA-style {"result": {...}} envelopes for turnkey integration.
157
+ if "decision" not in data and isinstance(data.get("result"), dict):
158
+ data = data["result"]
159
+ decision = data.get("decision")
160
+ if isinstance(decision, str) and decision.strip().lower() == "deny":
161
+ reason = data.get("reason")
162
+ return Verdict.deny(str(reason) if reason else "denied by external policy")
163
+ return Verdict.allow()
164
+
165
+
166
+ def _fail(fail_open: bool, reason: str) -> Verdict:
167
+ """Resolve an error/timeout according to the fail-open policy.
168
+
169
+ Fail-CLOSED (the default) turns any error into a DENY; fail-open turns it into an
170
+ ALLOW. See the module docstring for why closed is the safe default.
171
+ """
172
+ if fail_open:
173
+ return Verdict.allow()
174
+ return Verdict.deny(f"external gate error (fail-closed): {reason}")
175
+
176
+
177
+ # ─── Gates ────────────────────────────────────────────────────────────────────
178
+
179
+
180
+ @dataclass
181
+ class SubprocessGate:
182
+ """Preflight gate that shells out to a decider command, once per call.
183
+
184
+ The JSON request is written to the process's stdin; the JSON verdict is read from
185
+ its stdout. Suitable for OPA ``eval``, custom scripts, or air-gapped setups where
186
+ a persistent HTTP server is undesirable.
187
+
188
+ Args:
189
+ command: The decider command line. Split with :func:`shlex.split` using
190
+ ``posix=(os.name != 'nt')`` so quoted arguments survive; on Windows this
191
+ is best-effort (native cmd quoting differs from POSIX).
192
+ timeout: Per-call wall-clock timeout in seconds.
193
+ fail_open: If True, ALLOW on any error/timeout/bad output. DEFAULT FALSE
194
+ (fail-closed = DENY) — a security-critical default; do not flip lightly.
195
+ max_input_bytes: Per-string cap applied to tool input values before sending.
196
+ """
197
+
198
+ command: str
199
+ timeout: float = 10.0
200
+ fail_open: bool = False
201
+ max_input_bytes: int = 65536
202
+
203
+ def __call__(self, request: "ToolCallRequest", ctx: "GateContext") -> Verdict:
204
+ try:
205
+ payload = json.dumps(_serialize_request(request, ctx, self.max_input_bytes))
206
+ except Exception as exc: # serialization must never crash the caller
207
+ return _fail(
208
+ self.fail_open, f"request serialization failed: {type(exc).__name__}: {exc}"
209
+ )
210
+
211
+ try:
212
+ argv = shlex.split(self.command, posix=(os.name != "nt"))
213
+ except ValueError as exc:
214
+ return _fail(self.fail_open, f"invalid command: {exc}")
215
+ if not argv:
216
+ return _fail(self.fail_open, "empty command")
217
+
218
+ try:
219
+ proc = subprocess.run(
220
+ argv,
221
+ input=payload,
222
+ capture_output=True,
223
+ text=True,
224
+ timeout=self.timeout,
225
+ )
226
+ except subprocess.TimeoutExpired:
227
+ return _fail(self.fail_open, f"decider timed out after {self.timeout}s")
228
+ except (OSError, ValueError) as exc:
229
+ return _fail(self.fail_open, f"decider failed to launch: {type(exc).__name__}: {exc}")
230
+ except Exception as exc: # airtight: never propagate to the framework
231
+ return _fail(self.fail_open, f"decider error: {type(exc).__name__}: {exc}")
232
+
233
+ if proc.returncode != 0:
234
+ stderr = (proc.stderr or "").strip()[:200]
235
+ return _fail(self.fail_open, f"decider exited {proc.returncode}: {stderr}")
236
+
237
+ try:
238
+ data = json.loads(proc.stdout)
239
+ except (ValueError, TypeError) as exc:
240
+ return _fail(self.fail_open, f"unparseable decider stdout: {type(exc).__name__}")
241
+
242
+ return _parse_response(data)
243
+
244
+
245
+ @dataclass
246
+ class HttpGate:
247
+ """Preflight gate that POSTs the request to a persistent HTTP PDP.
248
+
249
+ Recommended mode: a long-lived server (e.g. OPA REST) avoids per-call process
250
+ spawn cost. Uses stdlib ``urllib.request`` only — no new dependencies.
251
+
252
+ Args:
253
+ endpoint: Absolute URL of the decision endpoint.
254
+ timeout: Per-request timeout in seconds.
255
+ fail_open: If True, ALLOW on any error/timeout/non-2xx. DEFAULT FALSE
256
+ (fail-closed = DENY) — a security-critical default; do not flip lightly.
257
+ headers: Extra headers merged over ``Content-Type: application/json`` (e.g.
258
+ an ``Authorization`` bearer token for the PDP).
259
+ max_input_bytes: Per-string cap applied to tool input values before sending.
260
+ """
261
+
262
+ endpoint: str
263
+ timeout: float = 2.0
264
+ fail_open: bool = False
265
+ headers: dict[str, str] | None = None
266
+ max_input_bytes: int = 65536
267
+
268
+ def __call__(self, request: "ToolCallRequest", ctx: "GateContext") -> Verdict:
269
+ try:
270
+ body = json.dumps(_serialize_request(request, ctx, self.max_input_bytes)).encode(
271
+ "utf-8"
272
+ )
273
+ except Exception as exc: # serialization must never crash the caller
274
+ return _fail(
275
+ self.fail_open, f"request serialization failed: {type(exc).__name__}: {exc}"
276
+ )
277
+
278
+ headers = {"Content-Type": "application/json"}
279
+ if self.headers:
280
+ headers.update(self.headers)
281
+ req = urllib.request.Request(self.endpoint, data=body, headers=headers, method="POST")
282
+
283
+ try:
284
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
285
+ status = getattr(resp, "status", None)
286
+ if status is None:
287
+ status = resp.getcode()
288
+ raw = resp.read()
289
+ except urllib.error.HTTPError as exc:
290
+ return _fail(self.fail_open, f"policy endpoint returned HTTP {exc.code}")
291
+ except (urllib.error.URLError, TimeoutError, OSError) as exc:
292
+ reason = getattr(exc, "reason", exc)
293
+ return _fail(self.fail_open, f"policy endpoint unreachable: {reason}")
294
+ except Exception as exc: # airtight: never propagate to the framework
295
+ return _fail(self.fail_open, f"policy call failed: {type(exc).__name__}: {exc}")
296
+
297
+ if status is not None and not (200 <= int(status) < 300):
298
+ return _fail(self.fail_open, f"policy endpoint returned HTTP {status}")
299
+
300
+ try:
301
+ data = json.loads(raw.decode("utf-8"))
302
+ except (ValueError, TypeError, UnicodeDecodeError) as exc:
303
+ return _fail(self.fail_open, f"invalid JSON from policy endpoint: {type(exc).__name__}")
304
+
305
+ return _parse_response(data)