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,182 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sprint 4 – Invariant engine (I1 Information Persistence, I3 Semantic Grounding).
|
|
3
|
+
|
|
4
|
+
InvariantEngine registers check functions and evaluates them against a context,
|
|
5
|
+
returning actionable InvariantFailure results. ConceptStore persists concept
|
|
6
|
+
definitions with hashes for I3 semantic handshake.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Callable, Dict, List, Optional
|
|
16
|
+
|
|
17
|
+
CONCEPTS_DIR_NAME = "concepts"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class InvariantFailure:
|
|
22
|
+
"""A single invariant violation with an actionable fix hint."""
|
|
23
|
+
invariant_id: str
|
|
24
|
+
message: str
|
|
25
|
+
actionable_fix: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class InvariantContext:
|
|
30
|
+
"""Context passed to invariant check functions (e.g. for merge or lock-release)."""
|
|
31
|
+
operation: str # "merge", "lock_release", "finalize"
|
|
32
|
+
branch_tips: Optional[Dict[str, str]] = None # branch -> commit_id
|
|
33
|
+
concepts_used: Optional[List[str]] = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _content_hash(text: str) -> str:
|
|
37
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ConceptStore:
|
|
41
|
+
"""
|
|
42
|
+
Persist concept definitions under .GCC/concepts/<name>.json with content hash.
|
|
43
|
+
I3 semantic grounding requires handshake: operations touching a concept
|
|
44
|
+
must have a matching definition hash.
|
|
45
|
+
"""
|
|
46
|
+
def __init__(self, gcc_dir: Path) -> None:
|
|
47
|
+
self.concepts_dir = gcc_dir / CONCEPTS_DIR_NAME
|
|
48
|
+
|
|
49
|
+
def ensure_dir(self) -> None:
|
|
50
|
+
self.concepts_dir.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
|
|
52
|
+
def add(self, name: str, definition: str) -> str:
|
|
53
|
+
"""Store concept definition; return its content hash."""
|
|
54
|
+
self.ensure_dir()
|
|
55
|
+
h = _content_hash(definition)
|
|
56
|
+
path = self.concepts_dir / f"{name}.json"
|
|
57
|
+
path.write_text(
|
|
58
|
+
json.dumps({"name": name, "definition": definition, "hash": h}, indent=2) + "\n",
|
|
59
|
+
encoding="utf-8",
|
|
60
|
+
)
|
|
61
|
+
return h
|
|
62
|
+
|
|
63
|
+
def get(self, name: str) -> Optional[dict]:
|
|
64
|
+
"""Return {"name", "definition", "hash"} or None."""
|
|
65
|
+
path = self.concepts_dir / f"{name}.json"
|
|
66
|
+
if not path.exists():
|
|
67
|
+
return None
|
|
68
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
69
|
+
|
|
70
|
+
def hash_for(self, name: str) -> Optional[str]:
|
|
71
|
+
"""Return stored content hash for concept or None."""
|
|
72
|
+
obj = self.get(name)
|
|
73
|
+
return obj.get("hash") if obj else None
|
|
74
|
+
|
|
75
|
+
def list_concepts(self) -> List[str]:
|
|
76
|
+
"""Return all concept names."""
|
|
77
|
+
self.ensure_dir()
|
|
78
|
+
return [p.stem for p in self.concepts_dir.glob("*.json")]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class InvariantEngine:
|
|
82
|
+
"""
|
|
83
|
+
Register and evaluate invariants. Each invariant is a function
|
|
84
|
+
(repo, context) -> list[InvariantFailure].
|
|
85
|
+
"""
|
|
86
|
+
def __init__(self) -> None:
|
|
87
|
+
self._checkers: List[tuple[str, Callable]] = []
|
|
88
|
+
|
|
89
|
+
def register(self, invariant_id: str, check_fn: Callable) -> None:
|
|
90
|
+
self._checkers.append((invariant_id, check_fn))
|
|
91
|
+
|
|
92
|
+
def evaluate(self, repo, context: InvariantContext) -> List[InvariantFailure]:
|
|
93
|
+
failures: List[InvariantFailure] = []
|
|
94
|
+
for inv_id, check_fn in self._checkers:
|
|
95
|
+
try:
|
|
96
|
+
result = check_fn(repo, context)
|
|
97
|
+
if result:
|
|
98
|
+
failures.extend(result)
|
|
99
|
+
except Exception as e:
|
|
100
|
+
failures.append(
|
|
101
|
+
InvariantFailure(
|
|
102
|
+
inv_id,
|
|
103
|
+
str(e),
|
|
104
|
+
"Fix the underlying error and re-run the operation.",
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
return failures
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# --- I1: Information Persistence -------------------------------------------------
|
|
111
|
+
# Decision-finalizing operations must be backed by committed history: the branch
|
|
112
|
+
# tip must exist as a commit object and the decision must be reflected in the event log.
|
|
113
|
+
|
|
114
|
+
def check_i1_commit_backed(repo, context: InvariantContext) -> List[InvariantFailure]:
|
|
115
|
+
"""
|
|
116
|
+
I1: Every branch tip in context must exist in commits/ and have a COMMIT event.
|
|
117
|
+
"""
|
|
118
|
+
if not context.branch_tips:
|
|
119
|
+
return []
|
|
120
|
+
failures: List[InvariantFailure] = []
|
|
121
|
+
commits_dir = repo.gcc_dir / "commits"
|
|
122
|
+
event_log = repo.gcc_dir / "events.log.jsonl"
|
|
123
|
+
commit_ids_in_log = set()
|
|
124
|
+
if event_log.exists():
|
|
125
|
+
for line in event_log.open("r", encoding="utf-8"):
|
|
126
|
+
line = line.strip()
|
|
127
|
+
if not line:
|
|
128
|
+
continue
|
|
129
|
+
try:
|
|
130
|
+
obj = json.loads(line)
|
|
131
|
+
if obj.get("event_type") == "COMMIT":
|
|
132
|
+
cid = (obj.get("payload") or {}).get("commit_id")
|
|
133
|
+
if cid:
|
|
134
|
+
commit_ids_in_log.add(cid)
|
|
135
|
+
except json.JSONDecodeError:
|
|
136
|
+
continue
|
|
137
|
+
for branch, tip in context.branch_tips.items():
|
|
138
|
+
if not tip:
|
|
139
|
+
continue
|
|
140
|
+
commit_path = commits_dir / f"{tip}.json"
|
|
141
|
+
if not commit_path.exists():
|
|
142
|
+
failures.append(
|
|
143
|
+
InvariantFailure(
|
|
144
|
+
"I1",
|
|
145
|
+
f"Branch '{branch}' tip {tip} has no commit object in .GCC/commits/.",
|
|
146
|
+
"Ensure the branch has at least one devtorch commit before merge or lock-release.",
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
elif tip not in commit_ids_in_log:
|
|
150
|
+
failures.append(
|
|
151
|
+
InvariantFailure(
|
|
152
|
+
"I1",
|
|
153
|
+
f"Commit {tip} is not recorded in the event log (no COMMIT event).",
|
|
154
|
+
"Do not modify .GCC/commits/ or refs manually; use devtorch commit.",
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
return failures
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# --- I3: Semantic Grounding ------------------------------------------------------
|
|
161
|
+
# Operations that touch defined concepts require a handshake: the concept must exist
|
|
162
|
+
# in ConceptStore with a matching hash.
|
|
163
|
+
|
|
164
|
+
def make_i3_semantic_handshake(concept_store: ConceptStore) -> Callable:
|
|
165
|
+
"""Return a checker that requires all concepts_used to be defined and hash-matched."""
|
|
166
|
+
|
|
167
|
+
def check_i3(repo, context: InvariantContext) -> List[InvariantFailure]:
|
|
168
|
+
if not context.concepts_used:
|
|
169
|
+
return []
|
|
170
|
+
failures: List[InvariantFailure] = []
|
|
171
|
+
for name in context.concepts_used:
|
|
172
|
+
if concept_store.get(name) is None:
|
|
173
|
+
failures.append(
|
|
174
|
+
InvariantFailure(
|
|
175
|
+
"I3",
|
|
176
|
+
f"Concept '{name}' has no definition in .GCC/concepts/.",
|
|
177
|
+
f"Run: devtorch concept add {name} '<definition>'",
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
return failures
|
|
181
|
+
|
|
182
|
+
return check_i3
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
11
|
+
from starlette.requests import Request
|
|
12
|
+
from starlette.responses import JSONResponse
|
|
13
|
+
|
|
14
|
+
EXEMPT_PATHS = frozenset({"/health", "/readyz"})
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class APIKeyMiddleware(BaseHTTPMiddleware):
|
|
18
|
+
"""
|
|
19
|
+
Validates X-DevTorch-Key header against SHA-256 hashes stored in R2
|
|
20
|
+
at {org_id}/.devtorch/api_keys.json.
|
|
21
|
+
|
|
22
|
+
Injects request.state.org_id and request.state.allowed_repos on success.
|
|
23
|
+
Org is taken from URL path: /{org_id}/{repo_id}/...
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
API_KEYS_KEY = ".devtorch/api_keys.json"
|
|
27
|
+
|
|
28
|
+
def __init__(self, app: Any, backend: Any) -> None:
|
|
29
|
+
super().__init__(app)
|
|
30
|
+
self._backend = backend
|
|
31
|
+
|
|
32
|
+
async def dispatch(self, request: Request, call_next: Any) -> Any:
|
|
33
|
+
if request.url.path in EXEMPT_PATHS:
|
|
34
|
+
return await call_next(request)
|
|
35
|
+
raw_key = request.headers.get("X-DevTorch-Key")
|
|
36
|
+
if raw_key is None:
|
|
37
|
+
return JSONResponse({"detail": "Missing X-DevTorch-Key"}, status_code=401)
|
|
38
|
+
|
|
39
|
+
key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
data = json.loads(self._backend.read_bytes(self.API_KEYS_KEY))
|
|
43
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
44
|
+
return JSONResponse({"detail": "Unauthorized"}, status_code=401)
|
|
45
|
+
|
|
46
|
+
entry = next(
|
|
47
|
+
(v for k, v in data.items() if hmac.compare_digest(k, key_hash)),
|
|
48
|
+
None,
|
|
49
|
+
)
|
|
50
|
+
if entry is None:
|
|
51
|
+
return JSONResponse({"detail": "Unauthorized"}, status_code=401)
|
|
52
|
+
|
|
53
|
+
token_org = entry.get("org_id", "")
|
|
54
|
+
allowed_repos = entry.get("allowed_repos") # None means all repos
|
|
55
|
+
|
|
56
|
+
# Validate org from URL path
|
|
57
|
+
path_parts = request.url.path.strip("/").split("/")
|
|
58
|
+
if request.url.path not in EXEMPT_PATHS:
|
|
59
|
+
if len(path_parts) < 2:
|
|
60
|
+
return JSONResponse({"detail": "Forbidden"}, status_code=403)
|
|
61
|
+
url_org, url_repo = path_parts[0], path_parts[1]
|
|
62
|
+
if url_org != token_org:
|
|
63
|
+
return JSONResponse({"detail": "Forbidden"}, status_code=403)
|
|
64
|
+
if allowed_repos is not None and url_repo not in allowed_repos:
|
|
65
|
+
return JSONResponse({"detail": "Forbidden"}, status_code=403)
|
|
66
|
+
|
|
67
|
+
request.state.org_id = token_org
|
|
68
|
+
request.state.allowed_repos = allowed_repos
|
|
69
|
+
return await call_next(request)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class OAuthMiddleware(BaseHTTPMiddleware):
|
|
73
|
+
"""
|
|
74
|
+
Validates Authorization: Bearer <jwt> against JWKS endpoints.
|
|
75
|
+
Supports multiple providers via DEVTORCH_JWKS_URLS (comma-separated).
|
|
76
|
+
Caches JWKS in memory with a 5-minute TTL.
|
|
77
|
+
Trusted providers include GitHub Actions OIDC (https://token.actions.githubusercontent.com).
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
CACHE_TTL = 300 # 5 minutes
|
|
81
|
+
|
|
82
|
+
def __init__(self, app: Any, jwks_urls: list[str]) -> None:
|
|
83
|
+
super().__init__(app)
|
|
84
|
+
self._jwks_urls = jwks_urls
|
|
85
|
+
self._jwks_cache: dict[str, Any] = {} # kid → public key
|
|
86
|
+
self._cache_lock = threading.Lock()
|
|
87
|
+
self._cache_expiry: float = 0.0
|
|
88
|
+
|
|
89
|
+
async def dispatch(self, request: Request, call_next: Any) -> Any:
|
|
90
|
+
if request.url.path in EXEMPT_PATHS:
|
|
91
|
+
return await call_next(request)
|
|
92
|
+
|
|
93
|
+
auth = request.headers.get("Authorization", "")
|
|
94
|
+
if not auth.startswith("Bearer "):
|
|
95
|
+
return JSONResponse({"detail": "Missing Bearer token"}, status_code=401)
|
|
96
|
+
|
|
97
|
+
token = auth.removeprefix("Bearer ").strip()
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
import jwt as _jwt
|
|
101
|
+
from jwt.exceptions import PyJWTError
|
|
102
|
+
except ImportError:
|
|
103
|
+
return JSONResponse({"detail": "Server misconfiguration: PyJWT not installed"}, status_code=500)
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
header = _jwt.get_unverified_header(token)
|
|
107
|
+
kid = header.get("kid")
|
|
108
|
+
key = self._get_key(kid)
|
|
109
|
+
if key is None:
|
|
110
|
+
return JSONResponse({"detail": "Unknown key id"}, status_code=401)
|
|
111
|
+
payload = _jwt.decode(token, key, algorithms=["RS256", "ES256"])
|
|
112
|
+
except PyJWTError:
|
|
113
|
+
return JSONResponse({"detail": "Invalid or expired token"}, status_code=401)
|
|
114
|
+
|
|
115
|
+
org_id = payload.get("org") or payload.get("https://devtorch.ai/org")
|
|
116
|
+
if not org_id:
|
|
117
|
+
return JSONResponse({"detail": "Missing org claim"}, status_code=401)
|
|
118
|
+
|
|
119
|
+
path_parts = request.url.path.strip("/").split("/")
|
|
120
|
+
if len(path_parts) >= 1 and path_parts[0] != org_id:
|
|
121
|
+
return JSONResponse({"detail": "Forbidden"}, status_code=403)
|
|
122
|
+
|
|
123
|
+
request.state.org_id = org_id
|
|
124
|
+
request.state.user_id = payload.get("sub")
|
|
125
|
+
return await call_next(request)
|
|
126
|
+
|
|
127
|
+
def _get_key(self, kid: Optional[str]) -> Optional[Any]:
|
|
128
|
+
with self._cache_lock:
|
|
129
|
+
expiry = self._cache_expiry
|
|
130
|
+
current_cache = dict(self._jwks_cache)
|
|
131
|
+
|
|
132
|
+
# Refresh outside the lock — may be done by multiple threads simultaneously
|
|
133
|
+
# (idempotent: worst case is a redundant fetch)
|
|
134
|
+
if time.monotonic() > expiry:
|
|
135
|
+
new_cache = self._fetch_jwks()
|
|
136
|
+
if new_cache:
|
|
137
|
+
with self._cache_lock:
|
|
138
|
+
merged = dict(self._jwks_cache)
|
|
139
|
+
merged.update(new_cache)
|
|
140
|
+
self._jwks_cache = merged
|
|
141
|
+
self._cache_expiry = time.monotonic() + self.CACHE_TTL
|
|
142
|
+
current_cache = dict(self._jwks_cache)
|
|
143
|
+
else:
|
|
144
|
+
# Back off 30s before next attempt to avoid thundering herd
|
|
145
|
+
with self._cache_lock:
|
|
146
|
+
self._cache_expiry = time.monotonic() + 30
|
|
147
|
+
|
|
148
|
+
return current_cache.get(kid) if kid else None
|
|
149
|
+
|
|
150
|
+
def _fetch_jwks(self) -> dict[str, Any]:
|
|
151
|
+
"""Fetch all JWKS URLs and return a dict of kid → public_key. Returns empty dict on total failure."""
|
|
152
|
+
import urllib.request
|
|
153
|
+
import json as _json
|
|
154
|
+
|
|
155
|
+
new_cache: dict[str, Any] = {}
|
|
156
|
+
for url in self._jwks_urls:
|
|
157
|
+
try:
|
|
158
|
+
with urllib.request.urlopen(url, timeout=5) as resp:
|
|
159
|
+
data = _json.loads(resp.read())
|
|
160
|
+
for jwk in data.get("keys", []):
|
|
161
|
+
try:
|
|
162
|
+
key = self._jwk_to_public_key(jwk)
|
|
163
|
+
new_cache[jwk["kid"]] = key
|
|
164
|
+
except Exception:
|
|
165
|
+
pass
|
|
166
|
+
except Exception:
|
|
167
|
+
pass # keep going, try next URL
|
|
168
|
+
return new_cache
|
|
169
|
+
|
|
170
|
+
@staticmethod
|
|
171
|
+
def _jwk_to_public_key(jwk: dict) -> Any:
|
|
172
|
+
from jwt.algorithms import RSAAlgorithm, ECAlgorithm
|
|
173
|
+
import json as _json
|
|
174
|
+
alg = jwk.get("kty", "RSA")
|
|
175
|
+
if alg == "RSA":
|
|
176
|
+
return RSAAlgorithm.from_jwk(_json.dumps(jwk))
|
|
177
|
+
return ECAlgorithm.from_jwk(_json.dumps(jwk))
|