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,218 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Splunk SIEM integration for DevTorch governance events.
|
|
3
|
+
|
|
4
|
+
Streams `.GCC/events.log.jsonl` to the Splunk HTTP Event Collector (HEC)
|
|
5
|
+
endpoint `/services/collector/event` in batches.
|
|
6
|
+
|
|
7
|
+
NEVER includes: prompts, code, reasoning tokens, commit message bodies,
|
|
8
|
+
sensitivity signal text, or any LLM response content.
|
|
9
|
+
|
|
10
|
+
Events containing forbidden keys are dropped before sending.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import dataclasses
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import urllib.request
|
|
19
|
+
import urllib.error
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Dict, Iterator, List, Optional
|
|
22
|
+
|
|
23
|
+
from .webhook import WebhookResult
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# Forbidden content keys (privacy guard)
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
_FORBIDDEN_KEYS = frozenset({
|
|
31
|
+
"prompt",
|
|
32
|
+
"content",
|
|
33
|
+
"messages",
|
|
34
|
+
"reasoning",
|
|
35
|
+
"text",
|
|
36
|
+
"code",
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# Dataclasses
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
@dataclasses.dataclass
|
|
45
|
+
class SplunkConfig:
|
|
46
|
+
"""Configuration for Splunk HEC ingestion."""
|
|
47
|
+
hec_url: str
|
|
48
|
+
hec_token: str
|
|
49
|
+
source: str = "devtorch"
|
|
50
|
+
sourcetype: str = "_json"
|
|
51
|
+
timeout_seconds: int = 5
|
|
52
|
+
batch_size: int = 100
|
|
53
|
+
enabled: bool = True
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
# Config loader
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
def load_splunk_config() -> Optional[SplunkConfig]:
|
|
61
|
+
"""
|
|
62
|
+
Load Splunk configuration from environment variables.
|
|
63
|
+
|
|
64
|
+
Environment variables:
|
|
65
|
+
- DEVTORCH_SPLUNK_HEC_URL (required)
|
|
66
|
+
- DEVTORCH_SPLUNK_HEC_TOKEN (required)
|
|
67
|
+
- DEVTORCH_SPLUNK_SOURCE
|
|
68
|
+
|
|
69
|
+
Returns None if required variables are missing.
|
|
70
|
+
Never raises.
|
|
71
|
+
"""
|
|
72
|
+
try:
|
|
73
|
+
hec_url = os.environ.get("DEVTORCH_SPLUNK_HEC_URL", "").strip()
|
|
74
|
+
hec_token = os.environ.get("DEVTORCH_SPLUNK_HEC_TOKEN", "").strip()
|
|
75
|
+
if not hec_url or not hec_token:
|
|
76
|
+
return None
|
|
77
|
+
source = os.environ.get("DEVTORCH_SPLUNK_SOURCE", "devtorch").strip() or "devtorch"
|
|
78
|
+
return SplunkConfig(
|
|
79
|
+
hec_url=hec_url,
|
|
80
|
+
hec_token=hec_token,
|
|
81
|
+
source=source,
|
|
82
|
+
)
|
|
83
|
+
except Exception:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
# Event streaming
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
def stream_events_log(gcc_dir: Path) -> Iterator[Dict[str, Any]]:
|
|
92
|
+
"""
|
|
93
|
+
Yield parsed JSON objects from `.GCC/events.log.jsonl`.
|
|
94
|
+
|
|
95
|
+
Skips malformed lines silently. Never raises.
|
|
96
|
+
"""
|
|
97
|
+
event_log = gcc_dir / "events.log.jsonl"
|
|
98
|
+
if not event_log.exists():
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
with event_log.open("r", encoding="utf-8") as f:
|
|
103
|
+
for line in f:
|
|
104
|
+
line = line.strip()
|
|
105
|
+
if not line:
|
|
106
|
+
continue
|
|
107
|
+
try:
|
|
108
|
+
yield json.loads(line)
|
|
109
|
+
except json.JSONDecodeError:
|
|
110
|
+
continue
|
|
111
|
+
except Exception:
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
# Privacy guard
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
def _event_contains_forbidden_keys(event: Dict[str, Any]) -> bool:
|
|
120
|
+
"""Return True if the event payload contains any forbidden content keys."""
|
|
121
|
+
return not _FORBIDDEN_KEYS.isdisjoint(event.keys())
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
# Sender
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
def send_to_splunk(gcc_dir: Path, config: SplunkConfig) -> WebhookResult:
|
|
129
|
+
"""
|
|
130
|
+
Send governance events from `.GCC/events.log.jsonl` to Splunk HEC.
|
|
131
|
+
|
|
132
|
+
Events are sent in batches of up to config.batch_size. Events containing
|
|
133
|
+
forbidden keys (`prompt`, `content`, `messages`, `reasoning`, `text`, `code`)
|
|
134
|
+
are skipped.
|
|
135
|
+
|
|
136
|
+
Uses urllib.request — no external dependencies.
|
|
137
|
+
Never raises — returns WebhookResult with success=False on any error.
|
|
138
|
+
|
|
139
|
+
Headers sent:
|
|
140
|
+
Authorization: Splunk {hec_token}
|
|
141
|
+
Content-Type: application/json
|
|
142
|
+
"""
|
|
143
|
+
if not config.enabled:
|
|
144
|
+
return WebhookResult(success=False, status_code=0, error="Splunk export is disabled")
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
url = f"{config.hec_url.rstrip('/')}/services/collector/event"
|
|
148
|
+
headers = {
|
|
149
|
+
"Authorization": f"Splunk {config.hec_token}",
|
|
150
|
+
"Content-Type": "application/json",
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
batch: List[Dict[str, Any]] = []
|
|
154
|
+
total_sent = 0
|
|
155
|
+
last_status = 200
|
|
156
|
+
last_error = ""
|
|
157
|
+
|
|
158
|
+
for event in stream_events_log(gcc_dir):
|
|
159
|
+
if _event_contains_forbidden_keys(event):
|
|
160
|
+
continue
|
|
161
|
+
|
|
162
|
+
# Wrap each event in Splunk HEC envelope.
|
|
163
|
+
wrapped = {
|
|
164
|
+
"source": config.source,
|
|
165
|
+
"sourcetype": config.sourcetype,
|
|
166
|
+
"event": event,
|
|
167
|
+
}
|
|
168
|
+
batch.append(wrapped)
|
|
169
|
+
|
|
170
|
+
if len(batch) >= config.batch_size:
|
|
171
|
+
result = _send_batch(url, headers, batch, config.timeout_seconds)
|
|
172
|
+
if result.success:
|
|
173
|
+
total_sent += len(batch)
|
|
174
|
+
else:
|
|
175
|
+
last_status = result.status_code
|
|
176
|
+
last_error = result.error
|
|
177
|
+
batch = []
|
|
178
|
+
|
|
179
|
+
if batch:
|
|
180
|
+
result = _send_batch(url, headers, batch, config.timeout_seconds)
|
|
181
|
+
if result.success:
|
|
182
|
+
total_sent += len(batch)
|
|
183
|
+
else:
|
|
184
|
+
last_status = result.status_code
|
|
185
|
+
last_error = result.error
|
|
186
|
+
|
|
187
|
+
if last_error:
|
|
188
|
+
return WebhookResult(success=False, status_code=last_status, error=last_error)
|
|
189
|
+
return WebhookResult(success=True, status_code=last_status, error="")
|
|
190
|
+
|
|
191
|
+
except Exception as exc:
|
|
192
|
+
return WebhookResult(success=False, status_code=0, error=str(exc))
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _send_batch(url: str, headers: Dict[str, str], batch: List[Dict[str, Any]], timeout: int) -> WebhookResult:
|
|
196
|
+
"""POST a batch of Splunk HEC events."""
|
|
197
|
+
try:
|
|
198
|
+
# Splunk HEC accepts newline-separated JSON payloads.
|
|
199
|
+
body_lines = [json.dumps(item) for item in batch]
|
|
200
|
+
body_bytes = "\n".join(body_lines).encode("utf-8")
|
|
201
|
+
|
|
202
|
+
req = urllib.request.Request(
|
|
203
|
+
url=url,
|
|
204
|
+
data=body_bytes,
|
|
205
|
+
method="POST",
|
|
206
|
+
headers=headers,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
210
|
+
status_code = resp.status
|
|
211
|
+
return WebhookResult(success=True, status_code=status_code, error="")
|
|
212
|
+
|
|
213
|
+
except urllib.error.HTTPError as exc:
|
|
214
|
+
return WebhookResult(success=False, status_code=exc.code, error=str(exc))
|
|
215
|
+
except urllib.error.URLError as exc:
|
|
216
|
+
return WebhookResult(success=False, status_code=0, error=str(exc))
|
|
217
|
+
except Exception as exc:
|
|
218
|
+
return WebhookResult(success=False, status_code=0, error=str(exc))
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Enterprise metrics webhook sender.
|
|
3
|
+
|
|
4
|
+
Sends a METRICS-ONLY payload to a configured endpoint.
|
|
5
|
+
NEVER includes: prompts, code, reasoning tokens, commit message bodies,
|
|
6
|
+
sensitivity signal text, or any LLM response content.
|
|
7
|
+
|
|
8
|
+
Only sends: counts, scores, timestamps, branch names, node state.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import dataclasses
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import urllib.request
|
|
17
|
+
import urllib.error
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
from .report import ObservabilityReport
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Approved metric keys sent in the webhook payload (explicit allowlist)
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
_PAYLOAD_KEYS = frozenset({
|
|
28
|
+
"org_id",
|
|
29
|
+
"node_state",
|
|
30
|
+
"branch",
|
|
31
|
+
"commit_count",
|
|
32
|
+
"sensitivity_count",
|
|
33
|
+
"high_disclosure_count",
|
|
34
|
+
"dhs_score",
|
|
35
|
+
"mcs_score",
|
|
36
|
+
"rdp_epsilon_used",
|
|
37
|
+
"rdp_exhausted",
|
|
38
|
+
"variance_alert_count",
|
|
39
|
+
"generated_at",
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# Dataclasses
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
@dataclasses.dataclass
|
|
48
|
+
class WebhookConfig:
|
|
49
|
+
"""Configuration for the enterprise metrics webhook."""
|
|
50
|
+
endpoint_url: str # DEVTORCH_METRICS_WEBHOOK_URL
|
|
51
|
+
org_id: str # DEVTORCH_ORG_ID
|
|
52
|
+
api_key: str # DEVTORCH_METRICS_API_KEY
|
|
53
|
+
timeout_seconds: int = 10
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclasses.dataclass
|
|
57
|
+
class WebhookPayload:
|
|
58
|
+
"""
|
|
59
|
+
Metrics-only payload sent to the webhook endpoint.
|
|
60
|
+
|
|
61
|
+
Explicit field list — every field is named here, nothing else.
|
|
62
|
+
Contains: counts, scores, timestamps, branch name, node state only.
|
|
63
|
+
"""
|
|
64
|
+
org_id: str
|
|
65
|
+
node_state: str
|
|
66
|
+
branch: str
|
|
67
|
+
commit_count: int
|
|
68
|
+
sensitivity_count: int
|
|
69
|
+
high_disclosure_count: int
|
|
70
|
+
dhs_score: Optional[float]
|
|
71
|
+
mcs_score: Optional[float]
|
|
72
|
+
rdp_epsilon_used: float
|
|
73
|
+
rdp_exhausted: bool
|
|
74
|
+
variance_alert_count: int
|
|
75
|
+
generated_at: str
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclasses.dataclass
|
|
79
|
+
class WebhookResult:
|
|
80
|
+
"""Result of a webhook POST attempt."""
|
|
81
|
+
success: bool
|
|
82
|
+
status_code: int
|
|
83
|
+
error: str
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
# Config loader
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
def load_webhook_config() -> Optional[WebhookConfig]:
|
|
91
|
+
"""
|
|
92
|
+
Load webhook configuration from environment variables.
|
|
93
|
+
|
|
94
|
+
Returns None if DEVTORCH_METRICS_WEBHOOK_URL is not set.
|
|
95
|
+
Never raises.
|
|
96
|
+
"""
|
|
97
|
+
try:
|
|
98
|
+
endpoint_url = os.environ.get("DEVTORCH_METRICS_WEBHOOK_URL", "").strip()
|
|
99
|
+
if not endpoint_url:
|
|
100
|
+
return None
|
|
101
|
+
org_id = os.environ.get("DEVTORCH_ORG_ID", "").strip()
|
|
102
|
+
api_key = os.environ.get("DEVTORCH_METRICS_API_KEY", "").strip()
|
|
103
|
+
timeout_str = os.environ.get("DEVTORCH_METRICS_TIMEOUT_SECONDS", "10").strip()
|
|
104
|
+
try:
|
|
105
|
+
timeout_seconds = int(timeout_str)
|
|
106
|
+
except (ValueError, TypeError):
|
|
107
|
+
timeout_seconds = 10
|
|
108
|
+
return WebhookConfig(
|
|
109
|
+
endpoint_url=endpoint_url,
|
|
110
|
+
org_id=org_id,
|
|
111
|
+
api_key=api_key,
|
|
112
|
+
timeout_seconds=timeout_seconds,
|
|
113
|
+
)
|
|
114
|
+
except Exception:
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
# Payload builder
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
def build_webhook_payload(report: ObservabilityReport, org_id: str) -> WebhookPayload:
|
|
123
|
+
"""
|
|
124
|
+
Map ObservabilityReport fields to a WebhookPayload.
|
|
125
|
+
|
|
126
|
+
Explicit field mapping: every included field is named here, nothing else.
|
|
127
|
+
Intentionally omits: prompts, commit messages, sensitivity signal text,
|
|
128
|
+
theta concept names beyond counts, code, or any LLM output.
|
|
129
|
+
"""
|
|
130
|
+
rdp = report.rdp_budget
|
|
131
|
+
rdp_epsilon_used = float(rdp.get("epsilon_used", 0.0))
|
|
132
|
+
rdp_epsilon_max = float(rdp.get("epsilon_max", 1.0))
|
|
133
|
+
rdp_exhausted = bool(rdp.get("read_only", False))
|
|
134
|
+
|
|
135
|
+
variance = report.variance_state
|
|
136
|
+
variance_alert_count = int(variance.get("alert_count", 0))
|
|
137
|
+
|
|
138
|
+
return WebhookPayload(
|
|
139
|
+
org_id=org_id,
|
|
140
|
+
node_state=report.node_state,
|
|
141
|
+
branch=report.branch,
|
|
142
|
+
commit_count=report.commit_count,
|
|
143
|
+
sensitivity_count=report.sensitivity_event_count,
|
|
144
|
+
high_disclosure_count=report.high_disclosure_count,
|
|
145
|
+
dhs_score=None, # not computed here; callers may populate via separate DHS call
|
|
146
|
+
mcs_score=None, # not computed here; callers may populate via separate MCS call
|
|
147
|
+
rdp_epsilon_used=rdp_epsilon_used,
|
|
148
|
+
rdp_exhausted=rdp_exhausted,
|
|
149
|
+
variance_alert_count=variance_alert_count,
|
|
150
|
+
generated_at=report.generated_at,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
# Webhook sender
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
def send_webhook(payload: WebhookPayload, config: WebhookConfig) -> WebhookResult:
|
|
159
|
+
"""
|
|
160
|
+
POST the metrics payload to config.endpoint_url.
|
|
161
|
+
|
|
162
|
+
Uses urllib.request — no external dependencies.
|
|
163
|
+
Never raises — returns WebhookResult with success=False on any error.
|
|
164
|
+
|
|
165
|
+
Headers sent:
|
|
166
|
+
Authorization: Bearer {api_key}
|
|
167
|
+
Content-Type: application/json
|
|
168
|
+
X-DevTorch-Org: {org_id}
|
|
169
|
+
"""
|
|
170
|
+
try:
|
|
171
|
+
body_dict = {
|
|
172
|
+
"org_id": payload.org_id,
|
|
173
|
+
"node_state": payload.node_state,
|
|
174
|
+
"branch": payload.branch,
|
|
175
|
+
"commit_count": payload.commit_count,
|
|
176
|
+
"sensitivity_count": payload.sensitivity_count,
|
|
177
|
+
"high_disclosure_count": payload.high_disclosure_count,
|
|
178
|
+
"dhs_score": payload.dhs_score,
|
|
179
|
+
"mcs_score": payload.mcs_score,
|
|
180
|
+
"rdp_epsilon_used": payload.rdp_epsilon_used,
|
|
181
|
+
"rdp_exhausted": payload.rdp_exhausted,
|
|
182
|
+
"variance_alert_count": payload.variance_alert_count,
|
|
183
|
+
"generated_at": payload.generated_at,
|
|
184
|
+
}
|
|
185
|
+
body_bytes = json.dumps(body_dict).encode("utf-8")
|
|
186
|
+
|
|
187
|
+
req = urllib.request.Request(
|
|
188
|
+
url=config.endpoint_url,
|
|
189
|
+
data=body_bytes,
|
|
190
|
+
method="POST",
|
|
191
|
+
headers={
|
|
192
|
+
"Authorization": f"Bearer {config.api_key}",
|
|
193
|
+
"Content-Type": "application/json",
|
|
194
|
+
"X-DevTorch-Org": config.org_id,
|
|
195
|
+
},
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
with urllib.request.urlopen(req, timeout=config.timeout_seconds) as resp:
|
|
199
|
+
status_code = resp.status
|
|
200
|
+
return WebhookResult(success=True, status_code=status_code, error="")
|
|
201
|
+
|
|
202
|
+
except urllib.error.HTTPError as exc:
|
|
203
|
+
return WebhookResult(success=False, status_code=exc.code, error=str(exc))
|
|
204
|
+
except urllib.error.URLError as exc:
|
|
205
|
+
return WebhookResult(success=False, status_code=0, error=str(exc))
|
|
206
|
+
except Exception as exc:
|
|
207
|
+
return WebhookResult(success=False, status_code=0, error=str(exc))
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# ---------------------------------------------------------------------------
|
|
211
|
+
# Convenience helper
|
|
212
|
+
# ---------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
def send_metrics_if_configured(report: ObservabilityReport) -> Optional[WebhookResult]:
|
|
215
|
+
"""
|
|
216
|
+
Load config from environment; if not configured return None.
|
|
217
|
+
Otherwise build payload, send webhook, and return WebhookResult.
|
|
218
|
+
Never raises.
|
|
219
|
+
"""
|
|
220
|
+
try:
|
|
221
|
+
config = load_webhook_config()
|
|
222
|
+
if config is None:
|
|
223
|
+
return None
|
|
224
|
+
payload = build_webhook_payload(report, org_id=config.org_id)
|
|
225
|
+
return send_webhook(payload, config)
|
|
226
|
+
except Exception:
|
|
227
|
+
return None
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""
|
|
2
|
+
devtorch_core.parser
|
|
3
|
+
====================
|
|
4
|
+
Shared parser foundation for the S7 LLM Wrapper layer.
|
|
5
|
+
|
|
6
|
+
Extracts structured data from LLM responses:
|
|
7
|
+
|
|
8
|
+
* **blocks** — RACP XML blocks (``<devtorch:commit>``, ``<devtorch:sensitivity>``)
|
|
9
|
+
* **thinking** — extended-thinking / reasoning content blocks
|
|
10
|
+
* **inference** — heuristic fallback when no explicit RACP blocks were emitted
|
|
11
|
+
"""
|
|
12
|
+
from .blocks import extract_racp_blocks, strip_racp_blocks, CommitBlock, SensitivityBlock
|
|
13
|
+
from .thinking import extract_thinking_blocks, thinking_to_commit_message, ThinkingBlock
|
|
14
|
+
from .inference import infer_sensitivity, extract_decision_summary, InferredSensitivity
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
# blocks
|
|
18
|
+
"extract_racp_blocks",
|
|
19
|
+
"strip_racp_blocks",
|
|
20
|
+
"CommitBlock",
|
|
21
|
+
"SensitivityBlock",
|
|
22
|
+
# thinking
|
|
23
|
+
"extract_thinking_blocks",
|
|
24
|
+
"thinking_to_commit_message",
|
|
25
|
+
"ThinkingBlock",
|
|
26
|
+
# inference
|
|
27
|
+
"infer_sensitivity",
|
|
28
|
+
"extract_decision_summary",
|
|
29
|
+
"InferredSensitivity",
|
|
30
|
+
]
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""
|
|
2
|
+
devtorch_core.parser.blocks
|
|
3
|
+
===========================
|
|
4
|
+
Extract and strip RACP-structured XML blocks emitted by LLMs that have been
|
|
5
|
+
instructed with the DevTorch system prompt.
|
|
6
|
+
|
|
7
|
+
LLM output formats handled
|
|
8
|
+
---------------------------
|
|
9
|
+
Self-closing tags::
|
|
10
|
+
|
|
11
|
+
<devtorch:commit message="migrate schema to v3" concepts="schema,migration"
|
|
12
|
+
confidence="0.9"/>
|
|
13
|
+
<devtorch:sensitivity concept="schema_version"
|
|
14
|
+
signal="migration collapses if format changes"
|
|
15
|
+
confidence="0.91"
|
|
16
|
+
disclosure="PROTECTED"/>
|
|
17
|
+
|
|
18
|
+
Strategy
|
|
19
|
+
--------
|
|
20
|
+
1. Regex locates the raw tag strings in the text (tolerates attribute-order
|
|
21
|
+
variation and minor whitespace differences).
|
|
22
|
+
2. ``xml.etree.ElementTree`` parses each located string for attribute values.
|
|
23
|
+
3. Attribute values are validated at parse time: confidence is clamped to
|
|
24
|
+
[0, 1], disclosure is checked against the controlled vocabulary, and
|
|
25
|
+
required attributes (message, concept, signal) must be present.
|
|
26
|
+
4. Malformed or invalid tags are logged and skipped — never raised.
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import logging
|
|
31
|
+
import re
|
|
32
|
+
import xml.etree.ElementTree as ET
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
# Controlled vocabulary for disclosure levels
|
|
38
|
+
VALID_DISCLOSURE_LEVELS = frozenset({"PUBLIC", "PROTECTED", "PRIVATE"})
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# Data classes
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class CommitBlock:
|
|
46
|
+
message: str
|
|
47
|
+
confidence: float = 0.9
|
|
48
|
+
concepts: list[str] = field(default_factory=list)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class SensitivityBlock:
|
|
53
|
+
concept: str
|
|
54
|
+
signal: str
|
|
55
|
+
confidence: float = 0.9
|
|
56
|
+
disclosure: str = "PROTECTED"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# Internal regex patterns
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
# Matches self-closing <devtorch:commit ... /> tags.
|
|
64
|
+
_RE_COMMIT = re.compile(
|
|
65
|
+
r"<devtorch:commit\b([^>]*?)/>",
|
|
66
|
+
re.DOTALL | re.IGNORECASE,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Matches self-closing <devtorch:sensitivity ... /> tags.
|
|
70
|
+
_RE_SENSITIVITY = re.compile(
|
|
71
|
+
r"<devtorch:sensitivity\b([^>]*?)/>",
|
|
72
|
+
re.DOTALL | re.IGNORECASE,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Matches any paired <devtorch:*>...</devtorch:*> tags (for strip).
|
|
76
|
+
_RE_PAIRED = re.compile(
|
|
77
|
+
r"<devtorch:[^>]+>.*?</devtorch:[^>]+>",
|
|
78
|
+
re.DOTALL | re.IGNORECASE,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Matches all self-closing devtorch tags (for strip).
|
|
82
|
+
_RE_SELF_CLOSING = re.compile(
|
|
83
|
+
r"<devtorch:\w[\w:.-]*\b[^>]*?/>",
|
|
84
|
+
re.DOTALL | re.IGNORECASE,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# Helpers
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
def _parse_tag(raw: str) -> ET.Element | None:
|
|
93
|
+
"""
|
|
94
|
+
Parse a raw XML tag string via ElementTree.
|
|
95
|
+
|
|
96
|
+
The tag may use namespace prefixes (``devtorch:commit``) which are not
|
|
97
|
+
valid bare XML without a namespace declaration. We normalise the prefix
|
|
98
|
+
to a plain element name so ElementTree can handle it.
|
|
99
|
+
|
|
100
|
+
Returns the parsed Element, or None on error.
|
|
101
|
+
"""
|
|
102
|
+
# Normalise namespace prefix: devtorch:foo -> devtorch_foo
|
|
103
|
+
normalised = re.sub(r"<(/?)\s*devtorch:", r"<\1devtorch_", raw)
|
|
104
|
+
try:
|
|
105
|
+
return ET.fromstring(normalised)
|
|
106
|
+
except ET.ParseError as exc:
|
|
107
|
+
logger.warning("devtorch parser: malformed tag skipped — %s | raw=%r", exc, raw)
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
# Public API
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
def extract_racp_blocks(
|
|
116
|
+
text: str,
|
|
117
|
+
) -> tuple[list[CommitBlock], list[SensitivityBlock]]:
|
|
118
|
+
"""
|
|
119
|
+
Extract all ``<devtorch:commit>`` and ``<devtorch:sensitivity>`` blocks
|
|
120
|
+
from *text*.
|
|
121
|
+
|
|
122
|
+
Uses regex to locate tag boundaries, then ``xml.etree.ElementTree`` to
|
|
123
|
+
parse attribute values. Attribute order variations and minor whitespace
|
|
124
|
+
are handled gracefully. Malformed tags are logged and skipped.
|
|
125
|
+
|
|
126
|
+
Returns
|
|
127
|
+
-------
|
|
128
|
+
(commit_blocks, sensitivity_blocks)
|
|
129
|
+
"""
|
|
130
|
+
commits: list[CommitBlock] = []
|
|
131
|
+
sensitivities: list[SensitivityBlock] = []
|
|
132
|
+
|
|
133
|
+
# --- commit blocks ---
|
|
134
|
+
for match in _RE_COMMIT.finditer(text):
|
|
135
|
+
raw_tag = match.group(0)
|
|
136
|
+
elem = _parse_tag(raw_tag)
|
|
137
|
+
if elem is None:
|
|
138
|
+
continue
|
|
139
|
+
message = elem.get("message")
|
|
140
|
+
if not message:
|
|
141
|
+
logger.warning("devtorch parser: <devtorch:commit> missing 'message' attribute — skipped | raw=%r", raw_tag)
|
|
142
|
+
continue
|
|
143
|
+
try:
|
|
144
|
+
confidence = float(elem.get("confidence", 0.9))
|
|
145
|
+
if not 0.0 <= confidence <= 1.0:
|
|
146
|
+
logger.warning(
|
|
147
|
+
"devtorch parser: commit confidence %.2f out of range [0,1] — skipped | raw=%r",
|
|
148
|
+
confidence, raw_tag,
|
|
149
|
+
)
|
|
150
|
+
continue
|
|
151
|
+
except (ValueError, TypeError):
|
|
152
|
+
logger.warning("devtorch parser: invalid confidence value in commit block — defaulting to 0.9")
|
|
153
|
+
confidence = 0.9
|
|
154
|
+
concepts_raw = elem.get("concepts") or elem.get("concepts_used") or ""
|
|
155
|
+
concepts = [c.strip() for c in concepts_raw.split(",") if c.strip()]
|
|
156
|
+
commits.append(CommitBlock(message=message, confidence=confidence, concepts=concepts))
|
|
157
|
+
|
|
158
|
+
# --- sensitivity blocks ---
|
|
159
|
+
for match in _RE_SENSITIVITY.finditer(text):
|
|
160
|
+
raw_tag = match.group(0)
|
|
161
|
+
elem = _parse_tag(raw_tag)
|
|
162
|
+
if elem is None:
|
|
163
|
+
continue
|
|
164
|
+
concept = elem.get("concept")
|
|
165
|
+
signal = elem.get("signal")
|
|
166
|
+
if not concept or not signal:
|
|
167
|
+
logger.warning(
|
|
168
|
+
"devtorch parser: <devtorch:sensitivity> missing required attributes — skipped | raw=%r",
|
|
169
|
+
raw_tag,
|
|
170
|
+
)
|
|
171
|
+
continue
|
|
172
|
+
try:
|
|
173
|
+
confidence = float(elem.get("confidence", 0.9))
|
|
174
|
+
if not 0.0 <= confidence <= 1.0:
|
|
175
|
+
logger.warning(
|
|
176
|
+
"devtorch parser: sensitivity confidence %.2f out of range [0,1] — skipped | raw=%r",
|
|
177
|
+
confidence, raw_tag,
|
|
178
|
+
)
|
|
179
|
+
continue
|
|
180
|
+
except (ValueError, TypeError):
|
|
181
|
+
logger.warning("devtorch parser: invalid confidence value in sensitivity block — defaulting to 0.9")
|
|
182
|
+
confidence = 0.9
|
|
183
|
+
disclosure = (elem.get("disclosure") or "PROTECTED").upper()
|
|
184
|
+
if disclosure not in VALID_DISCLOSURE_LEVELS:
|
|
185
|
+
logger.warning(
|
|
186
|
+
"devtorch parser: invalid disclosure level '%s' in sensitivity block — defaulting to PROTECTED | raw=%r",
|
|
187
|
+
disclosure, raw_tag,
|
|
188
|
+
)
|
|
189
|
+
disclosure = "PROTECTED"
|
|
190
|
+
sensitivities.append(
|
|
191
|
+
SensitivityBlock(
|
|
192
|
+
concept=concept,
|
|
193
|
+
signal=signal,
|
|
194
|
+
confidence=confidence,
|
|
195
|
+
disclosure=disclosure,
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
return commits, sensitivities
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def strip_racp_blocks(text: str) -> str:
|
|
203
|
+
"""
|
|
204
|
+
Remove all ``<devtorch:commit .../>``, ``<devtorch:sensitivity .../>``,
|
|
205
|
+
and any ``<devtorch:*>...</devtorch:*>`` paired tags from *text*.
|
|
206
|
+
|
|
207
|
+
Returns clean text suitable for presentation to the user/caller.
|
|
208
|
+
Surrounding whitespace is collapsed but the overall structure is preserved.
|
|
209
|
+
"""
|
|
210
|
+
# Remove paired tags first (they span more characters).
|
|
211
|
+
cleaned = _RE_PAIRED.sub("", text)
|
|
212
|
+
# Remove self-closing tags.
|
|
213
|
+
cleaned = _RE_SELF_CLOSING.sub("", cleaned)
|
|
214
|
+
# Collapse runs of blank lines that may have been left behind.
|
|
215
|
+
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned)
|
|
216
|
+
return cleaned.strip()
|