devtorch-core 3.0.1__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.
- devtorch_core/__init__.py +158 -0
- devtorch_core/aggphi_textual.py +275 -0
- devtorch_core/alerts/__init__.py +23 -0
- devtorch_core/alerts/base.py +46 -0
- devtorch_core/alerts/config.py +60 -0
- devtorch_core/alerts/dispatcher.py +110 -0
- devtorch_core/alerts/jira.py +96 -0
- devtorch_core/alerts/linear.py +72 -0
- devtorch_core/alerts/pagerduty.py +66 -0
- devtorch_core/alerts/slack.py +81 -0
- devtorch_core/alerts/teams.py +70 -0
- devtorch_core/audit/__init__.py +43 -0
- devtorch_core/audit/exporter.py +297 -0
- devtorch_core/audit/privacy.py +101 -0
- devtorch_core/audit/scrubber.py +149 -0
- devtorch_core/audit/service.py +67 -0
- devtorch_core/audit/signing.py +127 -0
- devtorch_core/broadcast/__init__.py +4 -0
- devtorch_core/broadcast/broadcaster.py +100 -0
- devtorch_core/broadcast/watcher.py +71 -0
- devtorch_core/capability.py +639 -0
- devtorch_core/cloud/__init__.py +1 -0
- devtorch_core/cloud/client_config.py +472 -0
- devtorch_core/cloud/client_configs/.claude-opencode-fallback.json +8 -0
- devtorch_core/cloud/client_configs/.claude-stdio.json +13 -0
- devtorch_core/cloud/client_configs/.cursor-mcp.json +13 -0
- devtorch_core/cloud/client_configs/.opencode-bridge.json +13 -0
- devtorch_core/cloud/client_configs/.opencode.json +15 -0
- devtorch_core/cloud/client_configs/.vscode-mcp.json +13 -0
- devtorch_core/cloud/devtorch-mcp-bridge.js +357 -0
- devtorch_core/cloud/mcp_client.py +229 -0
- devtorch_core/cloud/setup.py +144 -0
- devtorch_core/cloud/sync.py +143 -0
- devtorch_core/cloud/sync_bundle.py +603 -0
- devtorch_core/cloud/sync_conflicts.py +159 -0
- devtorch_core/cloud/sync_state.py +159 -0
- devtorch_core/cloud/team_sync.py +283 -0
- devtorch_core/codex/__init__.py +9 -0
- devtorch_core/codex/__main__.py +97 -0
- devtorch_core/codex/capture.py +208 -0
- devtorch_core/codex/proxy.py +412 -0
- devtorch_core/concept_catalog.py +209 -0
- devtorch_core/consolidation/__init__.py +3 -0
- devtorch_core/consolidation/synthesizer.py +87 -0
- devtorch_core/consolidation/workflow.py +175 -0
- devtorch_core/daemon/__init__.py +27 -0
- devtorch_core/daemon/supervisor.py +293 -0
- devtorch_core/daemon/watcher.py +244 -0
- devtorch_core/dashboard_api.py +2012 -0
- devtorch_core/deltaf.py +97 -0
- devtorch_core/disclosure.py +50 -0
- devtorch_core/divergence/__init__.py +3 -0
- devtorch_core/divergence/detector.py +166 -0
- devtorch_core/gateway/__init__.py +32 -0
- devtorch_core/gateway/key_manager.py +124 -0
- devtorch_core/gateway/metrics_webhook.py +252 -0
- devtorch_core/gateway/policy.py +262 -0
- devtorch_core/gateway/server.py +727 -0
- devtorch_core/gateway/sso.py +233 -0
- devtorch_core/gcc.py +1246 -0
- devtorch_core/github/__init__.py +35 -0
- devtorch_core/github/app.py +240 -0
- devtorch_core/github/comment_builder.py +113 -0
- devtorch_core/github/pat.py +76 -0
- devtorch_core/github/pr_parser.py +82 -0
- devtorch_core/github/pr_reporter.py +555 -0
- devtorch_core/gitlab/__init__.py +177 -0
- devtorch_core/hitl/__init__.py +4 -0
- devtorch_core/hitl/channels.py +129 -0
- devtorch_core/hitl/orchestrator.py +95 -0
- devtorch_core/hooks/__init__.py +17 -0
- devtorch_core/hooks/claude_code.py +228 -0
- devtorch_core/hooks/git_capture.py +341 -0
- devtorch_core/hooks/git_commit.py +182 -0
- devtorch_core/hooks/installer.py +733 -0
- devtorch_core/hooks/pre_commit.py +157 -0
- devtorch_core/hooks/runner.py +344 -0
- devtorch_core/identity/__init__.py +4 -0
- devtorch_core/identity/agent.py +86 -0
- devtorch_core/identity/providers.py +85 -0
- devtorch_core/invariants.py +182 -0
- devtorch_core/mcp/__init__.py +10 -0
- devtorch_core/mcp/auth.py +177 -0
- devtorch_core/mcp/server.py +1049 -0
- devtorch_core/metrics/__init__.py +35 -0
- devtorch_core/metrics/aggregate.py +215 -0
- devtorch_core/metrics/calibrate.py +198 -0
- devtorch_core/metrics/calibration.py +125 -0
- devtorch_core/metrics/credibility.py +288 -0
- devtorch_core/metrics/delivery_time.py +70 -0
- devtorch_core/metrics/dhs.py +126 -0
- devtorch_core/metrics/mcs.py +96 -0
- devtorch_core/metrics/roi.py +88 -0
- devtorch_core/metrics/session_writer.py +81 -0
- devtorch_core/metrics/shadow_ai.py +117 -0
- devtorch_core/metrics/sprint_writer.py +243 -0
- devtorch_core/observability/__init__.py +78 -0
- devtorch_core/observability/datadog.py +157 -0
- devtorch_core/observability/formatter.py +119 -0
- devtorch_core/observability/report.py +264 -0
- devtorch_core/observability/servicenow.py +147 -0
- devtorch_core/observability/splunk.py +218 -0
- devtorch_core/observability/webhook.py +227 -0
- devtorch_core/parser/__init__.py +30 -0
- devtorch_core/parser/blocks.py +216 -0
- devtorch_core/parser/inference.py +159 -0
- devtorch_core/parser/thinking.py +112 -0
- devtorch_core/projects.py +169 -0
- devtorch_core/prompt_artifact.py +76 -0
- devtorch_core/proxy/__init__.py +9 -0
- devtorch_core/proxy/routes/__init__.py +1 -0
- devtorch_core/proxy/routes/anthropic.py +264 -0
- devtorch_core/proxy/routes/azure_openai.py +336 -0
- devtorch_core/proxy/routes/gemini.py +331 -0
- devtorch_core/proxy/routes/groq.py +284 -0
- devtorch_core/proxy/routes/ollama.py +279 -0
- devtorch_core/proxy/routes/openai.py +287 -0
- devtorch_core/proxy/server.py +356 -0
- devtorch_core/query/__init__.py +15 -0
- devtorch_core/query/grep.py +181 -0
- devtorch_core/query/hybrid.py +86 -0
- devtorch_core/query/semantic.py +157 -0
- devtorch_core/rdp.py +105 -0
- devtorch_core/reasoning/__init__.py +4 -0
- devtorch_core/reasoning/entry.py +31 -0
- devtorch_core/reasoning/store.py +122 -0
- devtorch_core/reasoning_plus/__init__.py +70 -0
- devtorch_core/reasoning_plus/augmenter.py +326 -0
- devtorch_core/reasoning_plus/capture.py +51 -0
- devtorch_core/reasoning_plus/config.py +256 -0
- devtorch_core/reasoning_plus/context.py +262 -0
- devtorch_core/reasoning_plus/learning/__init__.py +72 -0
- devtorch_core/reasoning_plus/learning/analytics.py +141 -0
- devtorch_core/reasoning_plus/learning/api.py +313 -0
- devtorch_core/reasoning_plus/learning/chain.py +285 -0
- devtorch_core/reasoning_plus/learning/composer.py +74 -0
- devtorch_core/reasoning_plus/learning/cross_project.py +234 -0
- devtorch_core/reasoning_plus/learning/embeddings.py +209 -0
- devtorch_core/reasoning_plus/learning/extractor.py +207 -0
- devtorch_core/reasoning_plus/learning/models.py +116 -0
- devtorch_core/reasoning_plus/learning/provenance.py +126 -0
- devtorch_core/reasoning_plus/learning/recorder.py +81 -0
- devtorch_core/reasoning_plus/learning/relevance.py +122 -0
- devtorch_core/reasoning_plus/learning/state.py +86 -0
- devtorch_core/reasoning_plus/learning/store.py +160 -0
- devtorch_core/reasoning_plus/learning/theta_learning_bridge.py +94 -0
- devtorch_core/reasoning_plus/prompt.py +90 -0
- devtorch_core/rep.py +134 -0
- devtorch_core/rep_network/__init__.py +25 -0
- devtorch_core/rep_network/merge.py +70 -0
- devtorch_core/rep_network/node.py +137 -0
- devtorch_core/rep_network/server.py +140 -0
- devtorch_core/rep_network/sync.py +207 -0
- devtorch_core/sensitivity.py +182 -0
- devtorch_core/serve.py +258 -0
- devtorch_core/session/__init__.py +39 -0
- devtorch_core/session/disagreement.py +188 -0
- devtorch_core/session/models.py +114 -0
- devtorch_core/session/orchestrator.py +182 -0
- devtorch_core/session/planner.py +169 -0
- devtorch_core/session/simulator.py +132 -0
- devtorch_core/signing.py +290 -0
- devtorch_core/sis.py +197 -0
- devtorch_core/storage.py +308 -0
- devtorch_core/templates/__init__.py +6 -0
- devtorch_core/templates/engine.py +122 -0
- devtorch_core/templates/go.py +18 -0
- devtorch_core/templates/infra.py +19 -0
- devtorch_core/templates/library/__init__.py +18 -0
- devtorch_core/templates/library/api_design.md +27 -0
- devtorch_core/templates/library/bug_fix.md +27 -0
- devtorch_core/templates/library/decision_record.md +27 -0
- devtorch_core/templates/library/engine.py +228 -0
- devtorch_core/templates/library/security_review.md +30 -0
- devtorch_core/templates/python.py +19 -0
- devtorch_core/templates/react.py +18 -0
- devtorch_core/templates/typescript.py +18 -0
- devtorch_core/theta.py +221 -0
- devtorch_core/theta_synthesis.py +268 -0
- devtorch_core/topics.py +320 -0
- devtorch_core/variance.py +219 -0
- devtorch_core/wrapper/__init__.py +52 -0
- devtorch_core/wrapper/anthropic.py +487 -0
- devtorch_core/wrapper/base.py +562 -0
- devtorch_core/wrapper/bedrock.py +342 -0
- devtorch_core/wrapper/gemini.py +422 -0
- devtorch_core/wrapper/ollama.py +527 -0
- devtorch_core/wrapper/openai.py +461 -0
- devtorch_core-3.0.1.dist-info/METADATA +867 -0
- devtorch_core-3.0.1.dist-info/RECORD +193 -0
- devtorch_core-3.0.1.dist-info/WHEEL +5 -0
- devtorch_core-3.0.1.dist-info/entry_points.txt +2 -0
- devtorch_core-3.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Audit report export engine.
|
|
2
|
+
|
|
3
|
+
Reads ``.GCC/events.log.jsonl``, applies date/project/workspace/user/event-type
|
|
4
|
+
filters, applies privacy policy + scrubbing, and emits JSON or PDF.
|
|
5
|
+
|
|
6
|
+
PDF generation uses ``reportlab`` (optional ``devtorch-core[audit]`` dependency).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import dataclasses
|
|
12
|
+
import datetime
|
|
13
|
+
import json
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Dict, List, Optional
|
|
16
|
+
|
|
17
|
+
from .privacy import PrivacyConfig, apply_privacy_policy
|
|
18
|
+
from .scrubber import RegexScrubber
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _parse_iso(value: str | datetime.datetime | None) -> Optional[datetime.datetime]:
|
|
22
|
+
"""Parse an ISO timestamp or pass through a datetime object."""
|
|
23
|
+
if value is None or isinstance(value, datetime.datetime):
|
|
24
|
+
return value
|
|
25
|
+
# Try common formats.
|
|
26
|
+
for fmt in ("%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%dT%H:%M:%S.%f%z", "%Y-%m-%d"):
|
|
27
|
+
try:
|
|
28
|
+
return datetime.datetime.strptime(value, fmt)
|
|
29
|
+
except ValueError:
|
|
30
|
+
continue
|
|
31
|
+
# Python 3.10+ isoformat with timezone.
|
|
32
|
+
try:
|
|
33
|
+
return datetime.datetime.fromisoformat(value)
|
|
34
|
+
except ValueError:
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclasses.dataclass
|
|
39
|
+
class AuditFilters:
|
|
40
|
+
"""Filters for audit export queries."""
|
|
41
|
+
|
|
42
|
+
since: Optional[datetime.datetime] = None
|
|
43
|
+
until: Optional[datetime.datetime] = None
|
|
44
|
+
project: Optional[str] = None
|
|
45
|
+
workspace: Optional[str] = None
|
|
46
|
+
user: Optional[str] = None
|
|
47
|
+
event_type: Optional[str] = None
|
|
48
|
+
actor: Optional[str] = None
|
|
49
|
+
limit: Optional[int] = None
|
|
50
|
+
|
|
51
|
+
def matches(self, event: Dict[str, Any]) -> bool:
|
|
52
|
+
ts = event.get("timestamp")
|
|
53
|
+
if ts:
|
|
54
|
+
parsed = _parse_iso(ts)
|
|
55
|
+
if parsed:
|
|
56
|
+
if self.since is not None and parsed < self.since:
|
|
57
|
+
return False
|
|
58
|
+
if self.until is not None and parsed > self.until:
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
if self.event_type and event.get("event_type") != self.event_type:
|
|
62
|
+
return False
|
|
63
|
+
if self.actor and event.get("actor") != self.actor:
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
payload = event.get("payload", {}) or {}
|
|
67
|
+
if isinstance(payload, dict):
|
|
68
|
+
if self.project and payload.get("project") != self.project:
|
|
69
|
+
return False
|
|
70
|
+
if self.workspace and payload.get("workspace") != self.workspace:
|
|
71
|
+
return False
|
|
72
|
+
if self.user and payload.get("user") != self.user:
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
return True
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class AuditExporter:
|
|
79
|
+
"""Export filtered, scrubbed audit events."""
|
|
80
|
+
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
gcc_dir: Path,
|
|
84
|
+
privacy_config: PrivacyConfig | None = None,
|
|
85
|
+
scrubber: RegexScrubber | None = None,
|
|
86
|
+
):
|
|
87
|
+
self.gcc_dir = gcc_dir
|
|
88
|
+
self.privacy_config = privacy_config or PrivacyConfig()
|
|
89
|
+
self.scrubber = scrubber or RegexScrubber()
|
|
90
|
+
self.event_log = gcc_dir / "events.log.jsonl"
|
|
91
|
+
|
|
92
|
+
def load_events(self, filters: AuditFilters | None = None) -> List[Dict[str, Any]]:
|
|
93
|
+
"""Read and filter events from the event log."""
|
|
94
|
+
filters = filters or AuditFilters()
|
|
95
|
+
events: List[Dict[str, Any]] = []
|
|
96
|
+
if not self.event_log.exists():
|
|
97
|
+
return events
|
|
98
|
+
|
|
99
|
+
with self.event_log.open("r", encoding="utf-8") as f:
|
|
100
|
+
for line in f:
|
|
101
|
+
line = line.strip()
|
|
102
|
+
if not line:
|
|
103
|
+
continue
|
|
104
|
+
try:
|
|
105
|
+
event = json.loads(line)
|
|
106
|
+
except json.JSONDecodeError:
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
if not filters.matches(event):
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
event = apply_privacy_policy(event, self.privacy_config, self.scrubber)
|
|
113
|
+
events.append(event)
|
|
114
|
+
|
|
115
|
+
if filters.limit is not None and len(events) >= filters.limit:
|
|
116
|
+
break
|
|
117
|
+
|
|
118
|
+
return events
|
|
119
|
+
|
|
120
|
+
def export_json(
|
|
121
|
+
self,
|
|
122
|
+
events: List[Dict[str, Any]],
|
|
123
|
+
output_path: Path,
|
|
124
|
+
filters: AuditFilters | None = None,
|
|
125
|
+
repo_name: str = "",
|
|
126
|
+
) -> None:
|
|
127
|
+
"""Write events to a JSON file."""
|
|
128
|
+
report = self._build_report(events, filters, repo_name)
|
|
129
|
+
output_path.write_text(json.dumps(report, indent=2), encoding="utf-8")
|
|
130
|
+
|
|
131
|
+
def export_pdf(
|
|
132
|
+
self,
|
|
133
|
+
events: List[Dict[str, Any]],
|
|
134
|
+
output_path: Path,
|
|
135
|
+
filters: AuditFilters | None = None,
|
|
136
|
+
repo_name: str = "",
|
|
137
|
+
) -> None:
|
|
138
|
+
"""Write events to a PDF file using reportlab."""
|
|
139
|
+
try:
|
|
140
|
+
from reportlab.lib import colors
|
|
141
|
+
from reportlab.lib.pagesizes import letter
|
|
142
|
+
from reportlab.platypus import (
|
|
143
|
+
SimpleDocTemplate,
|
|
144
|
+
Paragraph,
|
|
145
|
+
Spacer,
|
|
146
|
+
Table,
|
|
147
|
+
TableStyle,
|
|
148
|
+
)
|
|
149
|
+
from reportlab.lib.styles import getSampleStyleSheet
|
|
150
|
+
from reportlab.lib.units import inch
|
|
151
|
+
except ImportError as exc:
|
|
152
|
+
raise RuntimeError("reportlab is required for PDF export; install devtorch-core[audit]") from exc
|
|
153
|
+
|
|
154
|
+
report = self._build_report(events, filters, repo_name)
|
|
155
|
+
doc = SimpleDocTemplate(str(output_path), pagesize=letter)
|
|
156
|
+
styles = getSampleStyleSheet()
|
|
157
|
+
story: List[Any] = []
|
|
158
|
+
|
|
159
|
+
story.append(Paragraph("DevTorch Audit Report", styles["Title"]))
|
|
160
|
+
story.append(Paragraph(f"Repository: {report['repo_name']}", styles["Normal"]))
|
|
161
|
+
story.append(Paragraph(f"Generated: {report['generated_at']}", styles["Normal"]))
|
|
162
|
+
story.append(Paragraph(f"Event count: {report['event_count']}", styles["Normal"]))
|
|
163
|
+
story.append(Spacer(1, 0.2 * inch))
|
|
164
|
+
|
|
165
|
+
filters = report["filters"]
|
|
166
|
+
filter_text = (
|
|
167
|
+
f"Filters — since: {filters.get('since')}, until: {filters.get('until')}, "
|
|
168
|
+
f"project: {filters.get('project')}, workspace: {filters.get('workspace')}, "
|
|
169
|
+
f"user: {filters.get('user')}, event_type: {filters.get('event_type')}, "
|
|
170
|
+
f"actor: {filters.get('actor')}, limit: {filters.get('limit')}"
|
|
171
|
+
)
|
|
172
|
+
story.append(Paragraph(filter_text, styles["Normal"]))
|
|
173
|
+
story.append(Spacer(1, 0.2 * inch))
|
|
174
|
+
|
|
175
|
+
if events:
|
|
176
|
+
headers = ["Timestamp", "Actor", "Type", "Hash", "Payload"]
|
|
177
|
+
data = [headers]
|
|
178
|
+
for event in events:
|
|
179
|
+
payload = event.get("payload", "")
|
|
180
|
+
if isinstance(payload, dict):
|
|
181
|
+
payload_text = json.dumps(payload, indent=2)
|
|
182
|
+
else:
|
|
183
|
+
payload_text = str(payload)
|
|
184
|
+
data.append(
|
|
185
|
+
[
|
|
186
|
+
Paragraph(str(event.get("timestamp", "")), styles["Normal"]),
|
|
187
|
+
Paragraph(str(event.get("actor", "")), styles["Normal"]),
|
|
188
|
+
Paragraph(str(event.get("event_type", "")), styles["Normal"]),
|
|
189
|
+
Paragraph(str(event.get("hash", "")[:16]), styles["Normal"]),
|
|
190
|
+
Paragraph(payload_text, styles["Normal"]),
|
|
191
|
+
]
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
table = Table(data, colWidths=[1.1 * inch, 0.9 * inch, 0.9 * inch, 1.0 * inch, 3.2 * inch])
|
|
195
|
+
table.setStyle(
|
|
196
|
+
TableStyle(
|
|
197
|
+
[
|
|
198
|
+
("BACKGROUND", (0, 0), (-1, 0), colors.grey),
|
|
199
|
+
("TEXTCOLOR", (0, 0), (-1, 0), colors.whitesmoke),
|
|
200
|
+
("ALIGN", (0, 0), (-1, -1), "LEFT"),
|
|
201
|
+
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
|
202
|
+
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
|
203
|
+
("FONTSIZE", (0, 0), (-1, 0), 10),
|
|
204
|
+
("BOTTOMPADDING", (0, 0), (-1, 0), 12),
|
|
205
|
+
("BACKGROUND", (0, 1), (-1, -1), colors.beige),
|
|
206
|
+
("GRID", (0, 0), (-1, -1), 1, colors.black),
|
|
207
|
+
]
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
story.append(table)
|
|
211
|
+
else:
|
|
212
|
+
story.append(Paragraph("No events matched the selected filters.", styles["Normal"]))
|
|
213
|
+
|
|
214
|
+
doc.build(story, onFirstPage=self._footer, onLaterPages=self._footer)
|
|
215
|
+
|
|
216
|
+
def _build_report(
|
|
217
|
+
self,
|
|
218
|
+
events: List[Dict[str, Any]],
|
|
219
|
+
filters: AuditFilters | None,
|
|
220
|
+
repo_name: str,
|
|
221
|
+
) -> Dict[str, Any]:
|
|
222
|
+
return {
|
|
223
|
+
"repo_name": repo_name or str(self.gcc_dir.parent),
|
|
224
|
+
"generated_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
|
225
|
+
"event_count": len(events),
|
|
226
|
+
"filters": self._filters_dict(filters),
|
|
227
|
+
"events": events,
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
def _filters_dict(self, filters: AuditFilters | None) -> Dict[str, Any]:
|
|
231
|
+
if filters is None:
|
|
232
|
+
return {
|
|
233
|
+
"since": None,
|
|
234
|
+
"until": None,
|
|
235
|
+
"project": None,
|
|
236
|
+
"workspace": None,
|
|
237
|
+
"user": None,
|
|
238
|
+
"event_type": None,
|
|
239
|
+
"actor": None,
|
|
240
|
+
"limit": None,
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
def fmt(value):
|
|
244
|
+
if value is None:
|
|
245
|
+
return None
|
|
246
|
+
if isinstance(value, datetime.datetime):
|
|
247
|
+
return value.isoformat()
|
|
248
|
+
return str(value)
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
"since": fmt(filters.since),
|
|
252
|
+
"until": fmt(filters.until),
|
|
253
|
+
"project": filters.project,
|
|
254
|
+
"workspace": filters.workspace,
|
|
255
|
+
"user": filters.user,
|
|
256
|
+
"event_type": filters.event_type,
|
|
257
|
+
"actor": filters.actor,
|
|
258
|
+
"limit": filters.limit,
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
def _footer(self, canvas, doc) -> None:
|
|
262
|
+
"""Draw page number and timestamp in the footer."""
|
|
263
|
+
from reportlab.lib.units import inch
|
|
264
|
+
canvas.saveState()
|
|
265
|
+
canvas.setFont("Helvetica", 8)
|
|
266
|
+
footer_text = (
|
|
267
|
+
f"Page {doc.page} • DevTorch Audit Report • "
|
|
268
|
+
f"{datetime.datetime.now(datetime.timezone.utc).isoformat()}"
|
|
269
|
+
)
|
|
270
|
+
canvas.drawString(0.75 * inch, 0.5 * inch, footer_text)
|
|
271
|
+
canvas.restoreState()
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# Default convenience wrapper.
|
|
275
|
+
|
|
276
|
+
def export_audit(
|
|
277
|
+
gcc_dir: Path,
|
|
278
|
+
output_path: Path,
|
|
279
|
+
fmt: str = "json",
|
|
280
|
+
filters: AuditFilters | None = None,
|
|
281
|
+
privacy_config: PrivacyConfig | None = None,
|
|
282
|
+
repo_name: str = "",
|
|
283
|
+
) -> Dict[str, Any]:
|
|
284
|
+
"""One-shot export helper used by the CLI."""
|
|
285
|
+
exporter = AuditExporter(gcc_dir, privacy_config=privacy_config)
|
|
286
|
+
events = exporter.load_events(filters)
|
|
287
|
+
if fmt == "json":
|
|
288
|
+
exporter.export_json(events, output_path, filters=filters, repo_name=repo_name)
|
|
289
|
+
elif fmt == "pdf":
|
|
290
|
+
exporter.export_pdf(events, output_path, filters=filters, repo_name=repo_name)
|
|
291
|
+
else:
|
|
292
|
+
raise ValueError(f"Unsupported export format: {fmt}")
|
|
293
|
+
return {
|
|
294
|
+
"format": fmt,
|
|
295
|
+
"output_path": str(output_path),
|
|
296
|
+
"event_count": len(events),
|
|
297
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Privacy policy configuration and enforcement.
|
|
2
|
+
|
|
3
|
+
The configuration lives in ``.GCC/privacy.json`` and is versioned with the
|
|
4
|
+
repository. It controls whether audit logs, prompts, and telemetry may leave the
|
|
5
|
+
local environment, and whether redaction is applied before export.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import dataclasses
|
|
11
|
+
import json
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict
|
|
14
|
+
|
|
15
|
+
from .scrubber import RegexScrubber, Scrubber
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
PRIVACY_FILE_NAME = "privacy.json"
|
|
19
|
+
|
|
20
|
+
PROMPT_EVENT_TYPES = {"PROMPT", "THINKING", "REASONING", "CHAT", "LLM_REQUEST", "PROMPT_ARTIFACT"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclasses.dataclass
|
|
24
|
+
class PrivacyConfig:
|
|
25
|
+
"""Privacy policy knobs for a DevTorch repository."""
|
|
26
|
+
|
|
27
|
+
telemetry_enabled: bool = True
|
|
28
|
+
prompt_telemetry_enabled: bool = True
|
|
29
|
+
redaction_enabled: bool = True
|
|
30
|
+
allow_remote_endpoints: bool = False
|
|
31
|
+
retention_days: int = 365
|
|
32
|
+
|
|
33
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
34
|
+
return dataclasses.asdict(self)
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def from_dict(cls, data: Dict[str, Any]) -> "PrivacyConfig":
|
|
38
|
+
return cls(
|
|
39
|
+
telemetry_enabled=bool(data.get("telemetry_enabled", True)),
|
|
40
|
+
prompt_telemetry_enabled=bool(data.get("prompt_telemetry_enabled", True)),
|
|
41
|
+
redaction_enabled=bool(data.get("redaction_enabled", True)),
|
|
42
|
+
allow_remote_endpoints=bool(data.get("allow_remote_endpoints", False)),
|
|
43
|
+
retention_days=int(data.get("retention_days", 365)),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_privacy_config(gcc_dir: Path) -> PrivacyConfig:
|
|
48
|
+
"""Load privacy config from ``.GCC/privacy.json`` or return defaults."""
|
|
49
|
+
path = gcc_dir / PRIVACY_FILE_NAME
|
|
50
|
+
if not path.exists():
|
|
51
|
+
return PrivacyConfig()
|
|
52
|
+
try:
|
|
53
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
54
|
+
except (json.JSONDecodeError, OSError):
|
|
55
|
+
return PrivacyConfig()
|
|
56
|
+
return PrivacyConfig.from_dict(data)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def save_privacy_config(gcc_dir: Path, config: PrivacyConfig) -> None:
|
|
60
|
+
"""Persist privacy config to ``.GCC/privacy.json``."""
|
|
61
|
+
path = gcc_dir / PRIVACY_FILE_NAME
|
|
62
|
+
path.write_text(json.dumps(config.to_dict(), indent=2), encoding="utf-8")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _is_prompt_event(event: Dict[str, Any]) -> bool:
|
|
66
|
+
"""Heuristic: event likely contains prompt text."""
|
|
67
|
+
event_type = event.get("event_type", "")
|
|
68
|
+
if isinstance(event_type, str) and event_type.upper() in PROMPT_EVENT_TYPES:
|
|
69
|
+
return True
|
|
70
|
+
payload = event.get("payload", {})
|
|
71
|
+
if isinstance(payload, dict):
|
|
72
|
+
keys = {k.lower() for k in payload.keys()}
|
|
73
|
+
if keys & {"prompt", "system_prompt", "messages", "prompt_text"}:
|
|
74
|
+
return True
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def apply_privacy_policy(
|
|
79
|
+
event: Dict[str, Any],
|
|
80
|
+
config: PrivacyConfig,
|
|
81
|
+
scrubber: Scrubber | None = None,
|
|
82
|
+
) -> Dict[str, Any]:
|
|
83
|
+
"""Apply privacy policy to a single event.
|
|
84
|
+
|
|
85
|
+
* Redaction is applied when ``redaction_enabled`` is true.
|
|
86
|
+
* Prompt telemetry is suppressed when ``prompt_telemetry_enabled`` is false.
|
|
87
|
+
* Non-prompt telemetry is suppressed when ``telemetry_enabled`` is false.
|
|
88
|
+
* Suppressed events are returned with a ``_telemetry_excluded`` flag and the
|
|
89
|
+
payload replaced by ``{ "excluded": true }``.
|
|
90
|
+
"""
|
|
91
|
+
scrubber = scrubber or RegexScrubber()
|
|
92
|
+
if not config.telemetry_enabled:
|
|
93
|
+
return {**event, "_telemetry_excluded": True, "payload": {"excluded": True}}
|
|
94
|
+
|
|
95
|
+
if not config.prompt_telemetry_enabled and _is_prompt_event(event):
|
|
96
|
+
return {**event, "_telemetry_excluded": True, "payload": {"excluded": True}}
|
|
97
|
+
|
|
98
|
+
if config.redaction_enabled:
|
|
99
|
+
return scrubber.scrub(event)
|
|
100
|
+
|
|
101
|
+
return event
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""PII and secret scrubbing for audit exports.
|
|
2
|
+
|
|
3
|
+
Regex-based scrubber is the default. ``presidio-analyzer`` is supported as an
|
|
4
|
+
opt-in backend because pulling a full spaCy model into the OSS core is
|
|
5
|
+
prohibitively heavy.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from typing import Any, Dict, List, Pattern, Tuple
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
DEFAULT_REDACTION = "[REDACTED]"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Patterns are tuples of (label, compiled_regex, replacement_template).
|
|
18
|
+
# The replacement template may reference groups if the regex contains groups.
|
|
19
|
+
DEFAULT_PATTERNS: List[Tuple[str, Pattern[str], str]] = [
|
|
20
|
+
(
|
|
21
|
+
"email",
|
|
22
|
+
re.compile(r"(?i)\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b"),
|
|
23
|
+
"[REDACTED_EMAIL]",
|
|
24
|
+
),
|
|
25
|
+
(
|
|
26
|
+
"phone",
|
|
27
|
+
re.compile(
|
|
28
|
+
r"\b(?:\+\d{1,2}\s?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b"
|
|
29
|
+
),
|
|
30
|
+
"[REDACTED_PHONE]",
|
|
31
|
+
),
|
|
32
|
+
(
|
|
33
|
+
"ssn",
|
|
34
|
+
re.compile(r"\b\d{3}-\d{2}-\d{4}\b"),
|
|
35
|
+
"[REDACTED_SSN]",
|
|
36
|
+
),
|
|
37
|
+
(
|
|
38
|
+
"credit_card",
|
|
39
|
+
re.compile(r"\b(?:\d[ -]*?){13,16}\b"),
|
|
40
|
+
"[REDACTED_CC]",
|
|
41
|
+
),
|
|
42
|
+
(
|
|
43
|
+
"openai_key",
|
|
44
|
+
re.compile(r"\b(sk-[a-zA-Z0-9]{20,})\b"),
|
|
45
|
+
"[REDACTED_API_KEY]",
|
|
46
|
+
),
|
|
47
|
+
(
|
|
48
|
+
"aws_key",
|
|
49
|
+
re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
|
|
50
|
+
"[REDACTED_AWS_KEY]",
|
|
51
|
+
),
|
|
52
|
+
(
|
|
53
|
+
"github_token",
|
|
54
|
+
re.compile(
|
|
55
|
+
r"\b(ghp_[a-zA-Z0-9]{36}|github_pat_[a-zA-Z0-9_]{22,}|gho_[a-zA-Z0-9]{36})\b"
|
|
56
|
+
),
|
|
57
|
+
"[REDACTED_GITHUB_TOKEN]",
|
|
58
|
+
),
|
|
59
|
+
(
|
|
60
|
+
"bearer_token",
|
|
61
|
+
re.compile(r"\bBearer\s+[a-zA-Z0-9_\-\.]+\b", flags=re.IGNORECASE),
|
|
62
|
+
"[REDACTED_BEARER_TOKEN]",
|
|
63
|
+
),
|
|
64
|
+
(
|
|
65
|
+
"private_key",
|
|
66
|
+
re.compile(
|
|
67
|
+
r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----",
|
|
68
|
+
flags=re.DOTALL | re.IGNORECASE,
|
|
69
|
+
),
|
|
70
|
+
"[REDACTED_PRIVATE_KEY]",
|
|
71
|
+
),
|
|
72
|
+
(
|
|
73
|
+
"generic_secret",
|
|
74
|
+
re.compile(
|
|
75
|
+
r"(?i)(api[_-]?key|secret|password|token)\s*[:=]\s*['\"]?([a-zA-Z0-9_\-/+=]{8,})['\"]?"
|
|
76
|
+
),
|
|
77
|
+
r"\1=[REDACTED_SECRET]",
|
|
78
|
+
),
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Scrubber:
|
|
83
|
+
"""Abstract scrubber interface."""
|
|
84
|
+
|
|
85
|
+
def scrub(self, obj: Any) -> Any:
|
|
86
|
+
raise NotImplementedError
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class RegexScrubber(Scrubber):
|
|
90
|
+
"""Redact PII and secrets using regex patterns."""
|
|
91
|
+
|
|
92
|
+
def __init__(self, patterns: List[Tuple[str, Pattern[str], str]] | None = None):
|
|
93
|
+
self.patterns = patterns or DEFAULT_PATTERNS
|
|
94
|
+
|
|
95
|
+
def scrub(self, obj: Any) -> Any:
|
|
96
|
+
if isinstance(obj, str):
|
|
97
|
+
return self._scrub_str(obj)
|
|
98
|
+
if isinstance(obj, dict):
|
|
99
|
+
return {k: self.scrub(v) for k, v in obj.items()}
|
|
100
|
+
if isinstance(obj, list):
|
|
101
|
+
return [self.scrub(item) for item in obj]
|
|
102
|
+
return obj
|
|
103
|
+
|
|
104
|
+
def _scrub_str(self, text: str) -> str:
|
|
105
|
+
for _label, pattern, replacement in self.patterns:
|
|
106
|
+
text = pattern.sub(replacement, text)
|
|
107
|
+
return text
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class PresidioScrubber(Scrubber):
|
|
111
|
+
"""Lightweight wrapper around Microsoft Presidio analyzer.
|
|
112
|
+
|
|
113
|
+
Falls back to ``RegexScrubber`` for any entity type that Presidio does not
|
|
114
|
+
support (e.g., API keys, bearer tokens).
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
def __init__(self, additional_patterns: List[Tuple[str, Pattern[str], str]] | None = None):
|
|
118
|
+
from presidio_analyzer import AnalyzerEngine
|
|
119
|
+
self._analyzer = AnalyzerEngine()
|
|
120
|
+
self._fallback = RegexScrubber(additional_patterns or DEFAULT_PATTERNS)
|
|
121
|
+
|
|
122
|
+
def scrub(self, obj: Any) -> Any:
|
|
123
|
+
if not isinstance(obj, str):
|
|
124
|
+
return self._fallback.scrub(obj)
|
|
125
|
+
return self._scrub_str(obj)
|
|
126
|
+
|
|
127
|
+
def _scrub_str(self, text: str) -> str:
|
|
128
|
+
results = self._analyzer.analyze(text=text, language="en")
|
|
129
|
+
# Redact from end to start so indices stay stable.
|
|
130
|
+
redacted = text
|
|
131
|
+
for result in sorted(results, key=lambda r: r.start, reverse=True):
|
|
132
|
+
redacted = redacted[: result.start] + "[REDACTED]" + redacted[result.end :]
|
|
133
|
+
return self._fallback._scrub_str(redacted)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def make_scrubber(use_presidio: bool = False) -> Scrubber:
|
|
137
|
+
"""Factory: returns a regex scrubber unless ``use_presidio=True``."""
|
|
138
|
+
if use_presidio:
|
|
139
|
+
try:
|
|
140
|
+
return PresidioScrubber()
|
|
141
|
+
except Exception:
|
|
142
|
+
return RegexScrubber()
|
|
143
|
+
return RegexScrubber()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def scrub_dict(obj: Any, scrubber: Scrubber | None = None) -> Any:
|
|
147
|
+
"""Convenience helper for one-off scrubbing."""
|
|
148
|
+
scrubber = scrubber or RegexScrubber()
|
|
149
|
+
return scrubber.scrub(obj)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""High-level audit service used by the CLI and MCP server.
|
|
2
|
+
|
|
3
|
+
Keeps the orchestration logic in one place so both command-line users and
|
|
4
|
+
programmatic callers get the same behaviour.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Dict, List
|
|
11
|
+
|
|
12
|
+
from .exporter import AuditExporter, AuditFilters, export_audit
|
|
13
|
+
from .privacy import PrivacyConfig, apply_privacy_policy, load_privacy_config
|
|
14
|
+
from .scrubber import RegexScrubber
|
|
15
|
+
from .signing import ExportSigner
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AuditService:
|
|
19
|
+
"""Facade for audit export, scrubbing, and signing operations."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, gcc_dir: Path):
|
|
22
|
+
self.gcc_dir = gcc_dir
|
|
23
|
+
self.exporter = AuditExporter(gcc_dir)
|
|
24
|
+
self.signer = ExportSigner()
|
|
25
|
+
|
|
26
|
+
def get_privacy_config(self) -> PrivacyConfig:
|
|
27
|
+
return load_privacy_config(self.gcc_dir)
|
|
28
|
+
|
|
29
|
+
def query_events(self, filters: AuditFilters | None = None) -> List[Dict[str, Any]]:
|
|
30
|
+
"""Return filtered, policy-applied events."""
|
|
31
|
+
privacy = self.get_privacy_config()
|
|
32
|
+
return self.exporter.load_events(filters)
|
|
33
|
+
|
|
34
|
+
def export(
|
|
35
|
+
self,
|
|
36
|
+
output_path: Path,
|
|
37
|
+
fmt: str = "json",
|
|
38
|
+
filters: AuditFilters | None = None,
|
|
39
|
+
repo_name: str = "",
|
|
40
|
+
privacy_config: PrivacyConfig | None = None,
|
|
41
|
+
) -> Dict[str, Any]:
|
|
42
|
+
"""Export events to JSON or PDF."""
|
|
43
|
+
return export_audit(
|
|
44
|
+
self.gcc_dir,
|
|
45
|
+
output_path,
|
|
46
|
+
fmt=fmt,
|
|
47
|
+
filters=filters,
|
|
48
|
+
repo_name=repo_name,
|
|
49
|
+
privacy_config=privacy_config,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def sign_export(self, input_path: Path, output_path: Path, private_key_path: Path) -> None:
|
|
53
|
+
"""Sign an exported JSON file and write the signed envelope."""
|
|
54
|
+
self.signer.sign_file(input_path, output_path, private_key_path)
|
|
55
|
+
|
|
56
|
+
def verify_export(self, envelope_path: Path, public_key_path: Path) -> bool:
|
|
57
|
+
"""Verify a signed export envelope."""
|
|
58
|
+
return self.signer.verify_file(envelope_path, public_key_path)
|
|
59
|
+
|
|
60
|
+
def scrub(self, obj: Any) -> Any:
|
|
61
|
+
"""Run the default regex scrubber on an arbitrary object."""
|
|
62
|
+
return RegexScrubber().scrub(obj)
|
|
63
|
+
|
|
64
|
+
def apply_privacy(self, event: Dict[str, Any], config: PrivacyConfig | None = None) -> Dict[str, Any]:
|
|
65
|
+
"""Apply the repository privacy policy (or a supplied config) to an event."""
|
|
66
|
+
config = config or self.get_privacy_config()
|
|
67
|
+
return apply_privacy_policy(event, config, self.exporter.scrubber)
|