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,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)
|