gdmcode 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gdmcode-0.1.0.dist-info/METADATA +240 -0
- gdmcode-0.1.0.dist-info/RECORD +131 -0
- gdmcode-0.1.0.dist-info/WHEEL +4 -0
- gdmcode-0.1.0.dist-info/entry_points.txt +2 -0
- src/__init__.py +1 -0
- src/_internal/__init__.py +0 -0
- src/_internal/constants.py +244 -0
- src/_internal/domain_skills.py +339 -0
- src/agent/__init__.py +0 -0
- src/agent/commit_classifier.py +91 -0
- src/agent/context_budget.py +391 -0
- src/agent/daemon.py +681 -0
- src/agent/dag_validator.py +153 -0
- src/agent/debug_loop.py +473 -0
- src/agent/impact_analyzer.py +149 -0
- src/agent/impact_graph.py +117 -0
- src/agent/loop.py +1410 -0
- src/agent/orchestrator.py +141 -0
- src/agent/regression_guard.py +251 -0
- src/agent/review_gate.py +648 -0
- src/agent/risk_scorer.py +169 -0
- src/agent/self_healing.py +145 -0
- src/agent/smart_test_selector.py +89 -0
- src/agent/system_prompt.py +226 -0
- src/agent/task_tracker.py +320 -0
- src/agent/test_validator.py +210 -0
- src/agent/tool_orchestrator.py +402 -0
- src/agent/transcript.py +230 -0
- src/agent/verification_loop.py +133 -0
- src/agent/work_director.py +136 -0
- src/agent/worktree_manager.py +53 -0
- src/artifacts/__init__.py +16 -0
- src/artifacts/artifact_store.py +456 -0
- src/artifacts/verification_graph.py +75 -0
- src/auth.py +411 -0
- src/cli.py +1290 -0
- src/commands.py +1398 -0
- src/config.py +762 -0
- src/cost_tracker.py +348 -0
- src/db/__init__.py +4 -0
- src/db/migrations.py +337 -0
- src/enterprise/__init__.py +3 -0
- src/enterprise/audit_log.py +182 -0
- src/enterprise/identity.py +90 -0
- src/enterprise/rbac.py +100 -0
- src/enterprise/team_config.py +125 -0
- src/enterprise/usage_analytics.py +261 -0
- src/exceptions.py +207 -0
- src/git_workflow.py +651 -0
- src/integrations/__init__.py +6 -0
- src/integrations/github_actions.py +106 -0
- src/integrations/mcp_server.py +333 -0
- src/integrations/sentry_integration.py +100 -0
- src/integrations/sentry_server.py +82 -0
- src/integrations/webhook_security.py +19 -0
- src/main.py +27 -0
- src/memory/__init__.py +0 -0
- src/memory/code_index.py +376 -0
- src/memory/compressor.py +378 -0
- src/memory/context_memory.py +135 -0
- src/memory/continuous_memory.py +234 -0
- src/memory/conventions.py +495 -0
- src/memory/db.py +1119 -0
- src/memory/document_index.py +205 -0
- src/memory/file_cache.py +128 -0
- src/memory/project_scanner.py +178 -0
- src/memory/session_store.py +201 -0
- src/models/__init__.py +0 -0
- src/models/client.py +715 -0
- src/models/definitions.py +459 -0
- src/models/router.py +418 -0
- src/models/schemas.py +389 -0
- src/permissions.py +294 -0
- src/remote/__init__.py +5 -0
- src/remote/command_filter.py +33 -0
- src/remote/models.py +31 -0
- src/remote/permission_handler.py +79 -0
- src/remote/phone_ui.py +48 -0
- src/remote/protocol.py +59 -0
- src/remote/qr.py +65 -0
- src/remote/server.py +586 -0
- src/remote/token_manager.py +61 -0
- src/remote/tunnel.py +212 -0
- src/repl.py +475 -0
- src/runtime/__init__.py +1 -0
- src/runtime/branch_farm.py +372 -0
- src/runtime/replay.py +351 -0
- src/sandbox/__init__.py +2 -0
- src/sandbox/hermetic.py +214 -0
- src/sandbox/policy.py +44 -0
- src/sdk/__init__.py +3 -0
- src/sdk/plugin_base.py +39 -0
- src/sdk/plugin_host.py +100 -0
- src/sdk/plugin_loader.py +101 -0
- src/security.py +409 -0
- src/server/__init__.py +7 -0
- src/server/bridge.py +427 -0
- src/server/bridge_cli.py +103 -0
- src/server/bridge_client.py +170 -0
- src/server/protocol_version.py +103 -0
- src/session/__init__.py +10 -0
- src/session/event_fanout.py +46 -0
- src/session/input_broker.py +38 -0
- src/session/permission_bridge.py +100 -0
- src/tools/__init__.py +160 -0
- src/tools/_atomic.py +72 -0
- src/tools/agent_tools.py +423 -0
- src/tools/ask_user_tool.py +83 -0
- src/tools/bash_tool.py +384 -0
- src/tools/browser_tool.py +352 -0
- src/tools/browser_tools.py +179 -0
- src/tools/dep_tools.py +210 -0
- src/tools/document_reader.py +167 -0
- src/tools/document_tool.py +240 -0
- src/tools/document_writer.py +171 -0
- src/tools/impact_tools.py +240 -0
- src/tools/playwright_tool.py +172 -0
- src/tools/quality_tools.py +366 -0
- src/tools/read_tools.py +318 -0
- src/tools/result_cache.py +157 -0
- src/tools/search_tools.py +310 -0
- src/tools/shell_tools.py +311 -0
- src/tools/write_tools.py +337 -0
- src/voice/__init__.py +25 -0
- src/voice/audio_capture.py +92 -0
- src/voice/audio_playback.py +68 -0
- src/voice/errors.py +14 -0
- src/voice/models.py +35 -0
- src/voice/providers.py +143 -0
- src/voice/vad.py +55 -0
- src/voice/voice_loop.py +156 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Tamper-evident audit log using hash chain."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
import threading
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AuditEventType(str, Enum):
|
|
15
|
+
TOOL_CALL = "tool_call"
|
|
16
|
+
FILE_EDIT = "file_edit"
|
|
17
|
+
MODEL_CALL = "model_call"
|
|
18
|
+
SESSION_START = "session_start"
|
|
19
|
+
SESSION_END = "session_end"
|
|
20
|
+
PERMISSION_CHECK = "permission_check"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class AuditEvent:
|
|
25
|
+
event_type: AuditEventType
|
|
26
|
+
actor: str # user or team name
|
|
27
|
+
details: dict[str, Any]
|
|
28
|
+
timestamp: float = field(default_factory=time.time)
|
|
29
|
+
event_id: str = "" # auto-generated SHA256 hash
|
|
30
|
+
prev_hash: str = "" # hash of previous event (chain)
|
|
31
|
+
hash: str = "" # hash of this event
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AuditLog:
|
|
35
|
+
"""Append-only tamper-evident audit log. Each entry hashes the previous."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, log_path: str | Path, actor: str = "unknown"):
|
|
38
|
+
self._path = Path(log_path)
|
|
39
|
+
self._actor = actor
|
|
40
|
+
self._lock = threading.Lock()
|
|
41
|
+
self._last_hash = "0" * 64 # genesis hash
|
|
42
|
+
|
|
43
|
+
def log(self, event_type: AuditEventType, details: dict[str, Any]) -> AuditEvent:
|
|
44
|
+
"""Append event to log. Thread-safe."""
|
|
45
|
+
with self._lock:
|
|
46
|
+
event = AuditEvent(
|
|
47
|
+
event_type=event_type,
|
|
48
|
+
actor=self._actor,
|
|
49
|
+
details=details,
|
|
50
|
+
)
|
|
51
|
+
# event_id: fingerprint of immutable content (no chain fields)
|
|
52
|
+
event.event_id = self._compute_event_id(event)
|
|
53
|
+
event.prev_hash = self._last_hash
|
|
54
|
+
event.hash = self._compute_hash(event)
|
|
55
|
+
self._last_hash = event.hash
|
|
56
|
+
self._write(event)
|
|
57
|
+
return event
|
|
58
|
+
|
|
59
|
+
def log_tool_call(self, tool_name: str, args: dict, result_summary: str) -> AuditEvent:
|
|
60
|
+
return self.log(AuditEventType.TOOL_CALL, {
|
|
61
|
+
"tool_name": tool_name,
|
|
62
|
+
"args": args,
|
|
63
|
+
"result_summary": result_summary,
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
def log_file_edit(self, file_path: str, change_type: str, lines_changed: int) -> AuditEvent:
|
|
67
|
+
return self.log(AuditEventType.FILE_EDIT, {
|
|
68
|
+
"file_path": file_path,
|
|
69
|
+
"change_type": change_type,
|
|
70
|
+
"lines_changed": lines_changed,
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
def log_model_call(self, model: str, tokens_in: int, tokens_out: int) -> AuditEvent:
|
|
74
|
+
return self.log(AuditEventType.MODEL_CALL, {
|
|
75
|
+
"model": model,
|
|
76
|
+
"tokens_in": tokens_in,
|
|
77
|
+
"tokens_out": tokens_out,
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
def log_session_start(self, session_id: str) -> AuditEvent:
|
|
81
|
+
return self.log(AuditEventType.SESSION_START, {"session_id": session_id})
|
|
82
|
+
|
|
83
|
+
def log_session_end(self, session_id: str, summary: str) -> AuditEvent:
|
|
84
|
+
return self.log(AuditEventType.SESSION_END, {
|
|
85
|
+
"session_id": session_id,
|
|
86
|
+
"summary": summary,
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
def verify_chain(self) -> tuple[bool, str]:
|
|
90
|
+
"""Verify hash chain integrity. Returns (valid, error_message)."""
|
|
91
|
+
if not self._path.exists():
|
|
92
|
+
return (True, "")
|
|
93
|
+
|
|
94
|
+
lines = [ln for ln in self._path.read_text(encoding="utf-8").splitlines() if ln.strip()]
|
|
95
|
+
if not lines:
|
|
96
|
+
return (True, "")
|
|
97
|
+
|
|
98
|
+
prev_hash = "0" * 64
|
|
99
|
+
for idx, line in enumerate(lines):
|
|
100
|
+
try:
|
|
101
|
+
data = json.loads(line)
|
|
102
|
+
except json.JSONDecodeError as exc:
|
|
103
|
+
return (False, f"line {idx}: invalid JSON: {exc}")
|
|
104
|
+
|
|
105
|
+
event = _event_from_dict(data)
|
|
106
|
+
stored_hash = event.hash
|
|
107
|
+
stored_event_id = event.event_id
|
|
108
|
+
|
|
109
|
+
# Verify prev_hash linkage
|
|
110
|
+
if event.prev_hash != prev_hash:
|
|
111
|
+
return (False, f"event {idx}: prev_hash mismatch")
|
|
112
|
+
|
|
113
|
+
# Verify event_id
|
|
114
|
+
expected_id = self._compute_event_id(event)
|
|
115
|
+
if event.event_id != expected_id:
|
|
116
|
+
return (False, f"event {idx}: event_id mismatch")
|
|
117
|
+
|
|
118
|
+
# Verify hash
|
|
119
|
+
expected_hash = self._compute_hash(event)
|
|
120
|
+
if stored_hash != expected_hash:
|
|
121
|
+
return (False, f"event {idx}: hash mismatch")
|
|
122
|
+
|
|
123
|
+
prev_hash = stored_hash
|
|
124
|
+
|
|
125
|
+
return (True, "")
|
|
126
|
+
|
|
127
|
+
def tail(self, n: int = 20) -> list[AuditEvent]:
|
|
128
|
+
"""Return last N events."""
|
|
129
|
+
if not self._path.exists():
|
|
130
|
+
return []
|
|
131
|
+
lines = [ln for ln in self._path.read_text(encoding="utf-8").splitlines() if ln.strip()]
|
|
132
|
+
return [_event_from_dict(json.loads(ln)) for ln in lines[-n:]]
|
|
133
|
+
|
|
134
|
+
def _compute_event_id(self, event: AuditEvent) -> str:
|
|
135
|
+
"""SHA256 of immutable event identity fields (excludes chain fields)."""
|
|
136
|
+
payload = json.dumps({
|
|
137
|
+
"event_type": event.event_type,
|
|
138
|
+
"actor": event.actor,
|
|
139
|
+
"details": event.details,
|
|
140
|
+
"timestamp": event.timestamp,
|
|
141
|
+
}, sort_keys=True, separators=(",", ":"))
|
|
142
|
+
return hashlib.sha256(payload.encode()).hexdigest()
|
|
143
|
+
|
|
144
|
+
def _compute_hash(self, event: AuditEvent) -> str:
|
|
145
|
+
"""SHA256 of canonical JSON of the event fields."""
|
|
146
|
+
payload = json.dumps({
|
|
147
|
+
"event_type": event.event_type,
|
|
148
|
+
"actor": event.actor,
|
|
149
|
+
"details": event.details,
|
|
150
|
+
"timestamp": event.timestamp,
|
|
151
|
+
"event_id": event.event_id,
|
|
152
|
+
"prev_hash": event.prev_hash,
|
|
153
|
+
}, sort_keys=True, separators=(",", ":"))
|
|
154
|
+
return hashlib.sha256(payload.encode()).hexdigest()
|
|
155
|
+
|
|
156
|
+
def _write(self, event: AuditEvent) -> None:
|
|
157
|
+
"""Append event as JSON line to log file."""
|
|
158
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
159
|
+
record = {
|
|
160
|
+
"event_type": event.event_type,
|
|
161
|
+
"actor": event.actor,
|
|
162
|
+
"details": event.details,
|
|
163
|
+
"timestamp": event.timestamp,
|
|
164
|
+
"event_id": event.event_id,
|
|
165
|
+
"prev_hash": event.prev_hash,
|
|
166
|
+
"hash": event.hash,
|
|
167
|
+
}
|
|
168
|
+
with self._path.open("a", encoding="utf-8") as fh:
|
|
169
|
+
fh.write(json.dumps(record, sort_keys=True, separators=(",", ":")) + "\n")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _event_from_dict(data: dict) -> AuditEvent:
|
|
173
|
+
"""Reconstruct AuditEvent from a JSON dict."""
|
|
174
|
+
return AuditEvent(
|
|
175
|
+
event_type=AuditEventType(data["event_type"]),
|
|
176
|
+
actor=data["actor"],
|
|
177
|
+
details=data["details"],
|
|
178
|
+
timestamp=data["timestamp"],
|
|
179
|
+
event_id=data.get("event_id", ""),
|
|
180
|
+
prev_hash=data.get("prev_hash", ""),
|
|
181
|
+
hash=data.get("hash", ""),
|
|
182
|
+
)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
import subprocess
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class IdentityNotFoundError(Exception):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class Identity:
|
|
15
|
+
actor_id: str
|
|
16
|
+
display_name: str
|
|
17
|
+
roles: list[str]
|
|
18
|
+
token_exp: Optional[float] = None # epoch; None = no expiry
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class IdentityProvider(ABC):
|
|
22
|
+
@abstractmethod
|
|
23
|
+
def resolve(self) -> Identity: ...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class LocalIdentity(IdentityProvider):
|
|
27
|
+
"""Dev-mode identity from ~/.config/gdm/identity.toml. Falls back to git config."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, identity_file: Path = None):
|
|
30
|
+
self._path = identity_file or Path.home() / ".config" / "gdm" / "identity.toml"
|
|
31
|
+
|
|
32
|
+
def resolve(self) -> Identity:
|
|
33
|
+
if not self._path.exists():
|
|
34
|
+
try:
|
|
35
|
+
email = subprocess.check_output(
|
|
36
|
+
["git", "config", "user.email"], text=True,
|
|
37
|
+
stderr=subprocess.DEVNULL
|
|
38
|
+
).strip()
|
|
39
|
+
except Exception:
|
|
40
|
+
email = "unknown@local"
|
|
41
|
+
return Identity(actor_id=email, display_name=email, roles=[])
|
|
42
|
+
try:
|
|
43
|
+
import tomllib
|
|
44
|
+
except ImportError:
|
|
45
|
+
import tomli as tomllib # Python < 3.11 fallback
|
|
46
|
+
with open(self._path, "rb") as f:
|
|
47
|
+
raw = tomllib.load(f)
|
|
48
|
+
data = raw.get("identity", raw)
|
|
49
|
+
return Identity(
|
|
50
|
+
actor_id=data["actor_id"],
|
|
51
|
+
display_name=data.get("display_name", data["actor_id"]),
|
|
52
|
+
roles=data.get("roles", []),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class OIDCIdentity(IdentityProvider):
|
|
57
|
+
"""Production OIDC/JWT identity validation. Requires PyJWT[crypto]."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, issuer: str, audience: str, roles_claim: str = "gdm_roles"):
|
|
60
|
+
self._issuer = issuer
|
|
61
|
+
self._audience = audience
|
|
62
|
+
self._roles_claim = roles_claim
|
|
63
|
+
|
|
64
|
+
def resolve(self) -> Identity:
|
|
65
|
+
import os
|
|
66
|
+
token = os.environ.get("GDM_OIDC_TOKEN") or self._load_token_file()
|
|
67
|
+
try:
|
|
68
|
+
import jwt as pyjwt
|
|
69
|
+
except ImportError:
|
|
70
|
+
raise ImportError("PyJWT[cryptography] required for OIDC. pip install 'PyJWT[cryptography]'")
|
|
71
|
+
# In real use: fetch JWKS from issuer and validate signature
|
|
72
|
+
# For now: decode without verification (test mode) or with key
|
|
73
|
+
options = {"verify_signature": False} # override in production with JWKS key
|
|
74
|
+
claims = pyjwt.decode(token, options=options, algorithms=["RS256", "ES256", "HS256"])
|
|
75
|
+
if claims.get("exp") and claims["exp"] < __import__("time").time():
|
|
76
|
+
raise IdentityNotFoundError("OIDC token expired")
|
|
77
|
+
return Identity(
|
|
78
|
+
actor_id=claims["sub"],
|
|
79
|
+
display_name=claims.get("name", claims["sub"]),
|
|
80
|
+
roles=claims.get(self._roles_claim, []),
|
|
81
|
+
token_exp=claims.get("exp"),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def _load_token_file(self) -> str:
|
|
85
|
+
path = Path.home() / ".config" / "gdm" / "oidc_token"
|
|
86
|
+
if not path.exists():
|
|
87
|
+
raise IdentityNotFoundError(
|
|
88
|
+
"No OIDC token found. Set GDM_OIDC_TOKEN or run `gdm login`."
|
|
89
|
+
)
|
|
90
|
+
return path.read_text().strip()
|
src/enterprise/rbac.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import re
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Optional
|
|
5
|
+
from src.enterprise.identity import Identity
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RBACDeniedError(Exception):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class NetworkPolicyViolationError(Exception):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class RolePermissions:
|
|
18
|
+
max_autonomy: int
|
|
19
|
+
allowed_tools: list[str] # ["*"] = all
|
|
20
|
+
denied_tools: list[str] = field(default_factory=list)
|
|
21
|
+
can_push: bool = False
|
|
22
|
+
can_admin_config: bool = False
|
|
23
|
+
max_cost_per_session: float = 50.0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
BUILTIN_ROLES: dict[str, RolePermissions] = {
|
|
27
|
+
"admin": RolePermissions(
|
|
28
|
+
max_autonomy=5, allowed_tools=["*"], can_push=True, can_admin_config=True,
|
|
29
|
+
),
|
|
30
|
+
"developer": RolePermissions(
|
|
31
|
+
max_autonomy=4, allowed_tools=["*"], can_push=True,
|
|
32
|
+
),
|
|
33
|
+
"reviewer": RolePermissions(
|
|
34
|
+
max_autonomy=1,
|
|
35
|
+
allowed_tools=["read_file", "find_symbol", "grep", "review", "list_dir"],
|
|
36
|
+
can_push=False,
|
|
37
|
+
),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class RBACManager:
|
|
42
|
+
def __init__(self, team_policy_roles: dict = None):
|
|
43
|
+
self._roles = {**BUILTIN_ROLES, **(team_policy_roles or {})}
|
|
44
|
+
|
|
45
|
+
def get_permissions(self, identity: Identity) -> RolePermissions:
|
|
46
|
+
if not identity.roles:
|
|
47
|
+
return RolePermissions(max_autonomy=0, allowed_tools=[], max_cost_per_session=0.0)
|
|
48
|
+
if "admin" in identity.roles:
|
|
49
|
+
return self._roles["admin"]
|
|
50
|
+
best = max(
|
|
51
|
+
(self._roles[r] for r in identity.roles if r in self._roles),
|
|
52
|
+
key=lambda p: p.max_autonomy,
|
|
53
|
+
default=RolePermissions(max_autonomy=0, allowed_tools=[]),
|
|
54
|
+
)
|
|
55
|
+
return best
|
|
56
|
+
|
|
57
|
+
def check_tool(self, identity: Identity, tool_name: str) -> bool:
|
|
58
|
+
perms = self.get_permissions(identity)
|
|
59
|
+
if "*" in perms.allowed_tools:
|
|
60
|
+
return tool_name not in perms.denied_tools
|
|
61
|
+
return tool_name in perms.allowed_tools and tool_name not in perms.denied_tools
|
|
62
|
+
|
|
63
|
+
def assert_tool(self, identity: Identity, tool_name: str) -> None:
|
|
64
|
+
"""Raise RBACDeniedError if tool not permitted."""
|
|
65
|
+
if not self.check_tool(identity, tool_name):
|
|
66
|
+
raise RBACDeniedError(
|
|
67
|
+
f"Actor '{identity.actor_id}' with roles {identity.roles} "
|
|
68
|
+
f"is not permitted to use tool '{tool_name}'"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class NetworkPolicyEnforcer:
|
|
73
|
+
_PRIVATE_PATTERNS = [
|
|
74
|
+
re.compile(r"^127\."),
|
|
75
|
+
re.compile(r"^10\."),
|
|
76
|
+
re.compile(r"^172\.(1[6-9]|2\d|3[01])\."),
|
|
77
|
+
re.compile(r"^169\.254\."),
|
|
78
|
+
re.compile(r"^::1$"),
|
|
79
|
+
re.compile(r"^localhost$", re.I),
|
|
80
|
+
re.compile(r"^0\.0\.0\.0$"),
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
def __init__(self, allowlist: list[str] = None, allow_all: bool = True):
|
|
84
|
+
self._allowlist = allowlist or []
|
|
85
|
+
self._allow_all = allow_all
|
|
86
|
+
|
|
87
|
+
def check(self, url: str) -> None:
|
|
88
|
+
from urllib.parse import urlparse
|
|
89
|
+
host = urlparse(url).hostname or ""
|
|
90
|
+
# Always block private/SSRF targets
|
|
91
|
+
for pattern in self._PRIVATE_PATTERNS:
|
|
92
|
+
if pattern.search(host):
|
|
93
|
+
raise NetworkPolicyViolationError(
|
|
94
|
+
f"Blocked SSRF attempt to private address: {host}"
|
|
95
|
+
)
|
|
96
|
+
if not self._allow_all and self._allowlist:
|
|
97
|
+
if not any(host == a or host.endswith("." + a) for a in self._allowlist):
|
|
98
|
+
raise NetworkPolicyViolationError(
|
|
99
|
+
f"Host '{host}' not in network_allowlist. Allowed: {self._allowlist}"
|
|
100
|
+
)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Team configuration for enterprise gdm deployments."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from fnmatch import fnmatch
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
import tomllib
|
|
11
|
+
except ImportError:
|
|
12
|
+
import tomli as tomllib # type: ignore
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class ToolPolicy:
|
|
17
|
+
allowlist: list[str] = field(default_factory=list) # empty = allow all
|
|
18
|
+
blocklist: list[str] = field(default_factory=list)
|
|
19
|
+
require_approval: list[str] = field(default_factory=list)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class ModelOverride:
|
|
24
|
+
default_model: str | None = None
|
|
25
|
+
max_tokens: int | None = None
|
|
26
|
+
temperature: float | None = None
|
|
27
|
+
allowed_models: list[str] = field(default_factory=list) # empty = allow all
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class TeamConfig:
|
|
32
|
+
team_name: str = "default"
|
|
33
|
+
tool_policy: ToolPolicy = field(default_factory=ToolPolicy)
|
|
34
|
+
model_override: ModelOverride = field(default_factory=ModelOverride)
|
|
35
|
+
enforce_conventions: bool = False
|
|
36
|
+
audit_log_enabled: bool = False
|
|
37
|
+
allowed_file_patterns: list[str] = field(default_factory=list) # empty = allow all
|
|
38
|
+
blocked_file_patterns: list[str] = field(default_factory=list)
|
|
39
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TeamConfigLoader:
|
|
43
|
+
"""Load and validate team.toml configuration files."""
|
|
44
|
+
|
|
45
|
+
DEFAULT_PATHS = ["team.toml", ".gdm/team.toml", "~/.gdm/team.toml"]
|
|
46
|
+
|
|
47
|
+
def __init__(self, config_path: str | Path | None = None):
|
|
48
|
+
self._path = Path(config_path) if config_path else None
|
|
49
|
+
|
|
50
|
+
def load(self) -> TeamConfig:
|
|
51
|
+
"""Load team config from file. Returns default TeamConfig if no file found."""
|
|
52
|
+
paths = [self._path] if self._path else [
|
|
53
|
+
Path(p).expanduser() for p in self.DEFAULT_PATHS
|
|
54
|
+
]
|
|
55
|
+
for path in paths:
|
|
56
|
+
if path and path.exists():
|
|
57
|
+
content = path.read_text(encoding="utf-8")
|
|
58
|
+
data = self._parse_toml(content)
|
|
59
|
+
return self._build_config(data)
|
|
60
|
+
return TeamConfig()
|
|
61
|
+
|
|
62
|
+
def _build_config(self, data: dict) -> TeamConfig:
|
|
63
|
+
"""Build TeamConfig from parsed TOML dict."""
|
|
64
|
+
tp = data.get("tool_policy", {})
|
|
65
|
+
mo = data.get("model_override", {})
|
|
66
|
+
|
|
67
|
+
tool_policy = ToolPolicy(
|
|
68
|
+
allowlist=tp.get("allowlist", []),
|
|
69
|
+
blocklist=tp.get("blocklist", []),
|
|
70
|
+
require_approval=tp.get("require_approval", []),
|
|
71
|
+
)
|
|
72
|
+
model_override = ModelOverride(
|
|
73
|
+
default_model=mo.get("default_model"),
|
|
74
|
+
max_tokens=mo.get("max_tokens"),
|
|
75
|
+
temperature=mo.get("temperature"),
|
|
76
|
+
allowed_models=mo.get("allowed_models", []),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
known_keys = {
|
|
80
|
+
"team_name", "tool_policy", "model_override",
|
|
81
|
+
"enforce_conventions", "audit_log_enabled",
|
|
82
|
+
"allowed_file_patterns", "blocked_file_patterns",
|
|
83
|
+
}
|
|
84
|
+
extra = {k: v for k, v in data.items() if k not in known_keys}
|
|
85
|
+
|
|
86
|
+
return TeamConfig(
|
|
87
|
+
team_name=data.get("team_name", "default"),
|
|
88
|
+
tool_policy=tool_policy,
|
|
89
|
+
model_override=model_override,
|
|
90
|
+
enforce_conventions=data.get("enforce_conventions", False),
|
|
91
|
+
audit_log_enabled=data.get("audit_log_enabled", False),
|
|
92
|
+
allowed_file_patterns=data.get("allowed_file_patterns", []),
|
|
93
|
+
blocked_file_patterns=data.get("blocked_file_patterns", []),
|
|
94
|
+
extra=extra,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def is_tool_allowed(self, tool_name: str, config: TeamConfig) -> bool:
|
|
98
|
+
"""Check if tool is allowed by policy."""
|
|
99
|
+
policy = config.tool_policy
|
|
100
|
+
if tool_name in policy.blocklist:
|
|
101
|
+
return False
|
|
102
|
+
if policy.allowlist and tool_name not in policy.allowlist:
|
|
103
|
+
return False
|
|
104
|
+
return True
|
|
105
|
+
|
|
106
|
+
def is_model_allowed(self, model_name: str, config: TeamConfig) -> bool:
|
|
107
|
+
"""Check if model is allowed by override policy."""
|
|
108
|
+
allowed = config.model_override.allowed_models
|
|
109
|
+
if allowed and model_name not in allowed:
|
|
110
|
+
return False
|
|
111
|
+
return True
|
|
112
|
+
|
|
113
|
+
def is_file_allowed(self, file_path: str, config: TeamConfig) -> bool:
|
|
114
|
+
"""Check file against allowed/blocked patterns using fnmatch."""
|
|
115
|
+
for pattern in config.blocked_file_patterns:
|
|
116
|
+
if fnmatch(file_path, pattern):
|
|
117
|
+
return False
|
|
118
|
+
if config.allowed_file_patterns:
|
|
119
|
+
return any(fnmatch(file_path, p) for p in config.allowed_file_patterns)
|
|
120
|
+
return True
|
|
121
|
+
|
|
122
|
+
@staticmethod
|
|
123
|
+
def _parse_toml(content: str) -> dict:
|
|
124
|
+
"""Parse TOML content."""
|
|
125
|
+
return tomllib.loads(content)
|