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,33 @@
|
|
|
1
|
+
"""Remote command filter — allowlist for commands accepted from remote input."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import logging
|
|
4
|
+
from src.remote.models import InputMessage
|
|
5
|
+
|
|
6
|
+
__all__ = ["RemoteCommandFilter"]
|
|
7
|
+
|
|
8
|
+
log = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RemoteCommandFilter:
|
|
12
|
+
"""Blocks disallowed slash-commands from remote clients.
|
|
13
|
+
|
|
14
|
+
Non-command text (anything not starting with '/') is always allowed.
|
|
15
|
+
Only explicitly whitelisted commands pass through.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
ALLOWED_COMMANDS: frozenset = frozenset(
|
|
19
|
+
{"/help", "/status", "/cost", "/cancel", "/approve", "/deny"}
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
def is_allowed(self, text: str) -> bool:
|
|
23
|
+
"""Return True for non-command text or whitelisted commands."""
|
|
24
|
+
if not text.startswith("/"):
|
|
25
|
+
return True
|
|
26
|
+
return text.strip() in self.ALLOWED_COMMANDS
|
|
27
|
+
|
|
28
|
+
def filter(self, msg: InputMessage) -> InputMessage | None:
|
|
29
|
+
"""Return the message if allowed, or None if blocked (logs a warning)."""
|
|
30
|
+
if self.is_allowed(msg.text):
|
|
31
|
+
return msg
|
|
32
|
+
log.warning("RemoteCommandFilter: blocked command %r", msg.text)
|
|
33
|
+
return None
|
src/remote/models.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Data models for the remote server module."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
__all__ = ["RemoteEvent", "InputMessage", "SessionState", "PermissionRequest"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class RemoteEvent:
|
|
10
|
+
type: str
|
|
11
|
+
payload: dict = field(default_factory=dict)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class InputMessage:
|
|
16
|
+
text: str
|
|
17
|
+
command: bool = False
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class SessionState:
|
|
22
|
+
status: str
|
|
23
|
+
turn: int
|
|
24
|
+
cost_usd: float
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class PermissionRequest:
|
|
29
|
+
id: str
|
|
30
|
+
description: str
|
|
31
|
+
risk_level: str
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Per-connection permission prompt handler for remote WebSocket clients."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import secrets
|
|
7
|
+
|
|
8
|
+
__all__ = ["RemotePermissionPromptHandler"]
|
|
9
|
+
|
|
10
|
+
log = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RemotePermissionPromptHandler:
|
|
14
|
+
"""Sends a PermissionPrompt over WebSocket and awaits the client's response.
|
|
15
|
+
|
|
16
|
+
Usage::
|
|
17
|
+
handler = RemotePermissionPromptHandler(send_queue)
|
|
18
|
+
approved = await handler("Delete /etc/hosts?", "high")
|
|
19
|
+
# When the client responds:
|
|
20
|
+
handler.resolve(prompt_id, "approve") # called by the WS receiver
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, send_queue: asyncio.Queue) -> None:
|
|
24
|
+
self._send_queue = send_queue
|
|
25
|
+
self._pending: dict[str, asyncio.Future] = {}
|
|
26
|
+
|
|
27
|
+
async def __call__(self, description: str, risk_level: str) -> bool:
|
|
28
|
+
"""Send a permission prompt and block until approved/denied or timeout.
|
|
29
|
+
|
|
30
|
+
Returns True if approved, False on denial, timeout, or disconnect.
|
|
31
|
+
"""
|
|
32
|
+
req_id = secrets.token_hex(16)
|
|
33
|
+
loop = asyncio.get_running_loop()
|
|
34
|
+
future: asyncio.Future = loop.create_future()
|
|
35
|
+
self._pending[req_id] = future
|
|
36
|
+
|
|
37
|
+
msg = json.dumps(
|
|
38
|
+
{
|
|
39
|
+
"type": "permission",
|
|
40
|
+
"id": req_id,
|
|
41
|
+
"description": description,
|
|
42
|
+
"risk_level": risk_level,
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
await _put_send_queue(self._send_queue, msg)
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
return await asyncio.wait_for(future, timeout=60.0)
|
|
49
|
+
except asyncio.TimeoutError:
|
|
50
|
+
log.warning("Permission prompt %s timed out → deny", req_id)
|
|
51
|
+
return False
|
|
52
|
+
finally:
|
|
53
|
+
self._pending.pop(req_id, None)
|
|
54
|
+
|
|
55
|
+
def resolve(self, req_id: str, decision: str) -> None:
|
|
56
|
+
"""Called by the WS receiver when the client sends a permission_response."""
|
|
57
|
+
future = self._pending.get(req_id)
|
|
58
|
+
if future and not future.done():
|
|
59
|
+
future.set_result(decision == "approve")
|
|
60
|
+
|
|
61
|
+
def cancel_all(self) -> None:
|
|
62
|
+
"""Deny all pending prompts (called on disconnect)."""
|
|
63
|
+
for future in list(self._pending.values()):
|
|
64
|
+
if not future.done():
|
|
65
|
+
future.set_result(False)
|
|
66
|
+
self._pending.clear()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def _put_send_queue(send_queue: asyncio.Queue, item: str) -> None:
|
|
70
|
+
"""Put item into send queue, dropping the oldest entry if full."""
|
|
71
|
+
if send_queue.full():
|
|
72
|
+
try:
|
|
73
|
+
send_queue.get_nowait()
|
|
74
|
+
except asyncio.QueueEmpty:
|
|
75
|
+
pass
|
|
76
|
+
try:
|
|
77
|
+
send_queue.put_nowait(item)
|
|
78
|
+
except asyncio.QueueFull:
|
|
79
|
+
pass
|
src/remote/phone_ui.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""phone_ui — HTML asset loader, ANSI stripper, audit log for RemoteServer."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import datetime
|
|
5
|
+
import pathlib
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
CSP_HEADER = (
|
|
10
|
+
"default-src 'self'; "
|
|
11
|
+
"script-src 'self' 'unsafe-inline'; "
|
|
12
|
+
"style-src 'self' 'unsafe-inline'"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
_ASSETS_DIR = pathlib.Path(__file__).parent.parent.parent / "assets" / "remote"
|
|
16
|
+
|
|
17
|
+
# ANSI escape sequences: CSI (colors/cursor), OSC (window title), bare ESC
|
|
18
|
+
_ANSI_RE = re.compile(
|
|
19
|
+
r"\x1b\[[0-9;]*[A-Za-z]"
|
|
20
|
+
r"|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)"
|
|
21
|
+
r"|\x1b[^[\]]"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_html() -> str:
|
|
26
|
+
"""Return the contents of assets/remote/index.html."""
|
|
27
|
+
return (_ASSETS_DIR / "index.html").read_text(encoding="utf-8")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def strip_ansi(text: str) -> str:
|
|
31
|
+
"""Remove ANSI escape sequences from *text*."""
|
|
32
|
+
return _ANSI_RE.sub("", text)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AuditLog:
|
|
36
|
+
"""In-memory append-only audit log for remote session events."""
|
|
37
|
+
|
|
38
|
+
def __init__(self) -> None:
|
|
39
|
+
self._entries: list[dict[str, Any]] = []
|
|
40
|
+
|
|
41
|
+
def record(self, event: str, detail: dict[str, Any] | None = None) -> None:
|
|
42
|
+
ts = datetime.datetime.now(datetime.timezone.utc).isoformat(
|
|
43
|
+
timespec="seconds"
|
|
44
|
+
).replace("+00:00", "Z")
|
|
45
|
+
self._entries.append({"ts": ts, "event": event, **(detail or {})})
|
|
46
|
+
|
|
47
|
+
def entries(self) -> list[dict[str, Any]]:
|
|
48
|
+
return list(self._entries)
|
src/remote/protocol.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Canonical WebSocket message schema — all message types as TypedDicts."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from typing import Literal
|
|
4
|
+
from typing_extensions import TypedDict
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"PingMessage",
|
|
8
|
+
"PongMessage",
|
|
9
|
+
"TranscriptEvent",
|
|
10
|
+
"InputCmd",
|
|
11
|
+
"PermissionPrompt",
|
|
12
|
+
"PermissionResponse",
|
|
13
|
+
"SessionStateMsg",
|
|
14
|
+
"ErrorMsg",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PingMessage(TypedDict):
|
|
19
|
+
type: Literal["ping"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PongMessage(TypedDict):
|
|
23
|
+
type: Literal["pong"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TranscriptEvent(TypedDict):
|
|
27
|
+
type: Literal["transcript"]
|
|
28
|
+
text: str
|
|
29
|
+
turn: int
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class InputCmd(TypedDict):
|
|
33
|
+
type: Literal["input"]
|
|
34
|
+
text: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PermissionPrompt(TypedDict):
|
|
38
|
+
type: Literal["permission"]
|
|
39
|
+
id: str
|
|
40
|
+
description: str
|
|
41
|
+
risk_level: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class PermissionResponse(TypedDict):
|
|
45
|
+
type: Literal["permission_response"]
|
|
46
|
+
id: str
|
|
47
|
+
decision: Literal["approve", "deny"]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SessionStateMsg(TypedDict):
|
|
51
|
+
type: Literal["state"]
|
|
52
|
+
status: str
|
|
53
|
+
turn: int
|
|
54
|
+
cost_usd: float
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ErrorMsg(TypedDict):
|
|
58
|
+
type: Literal["error"]
|
|
59
|
+
message: str
|
src/remote/qr.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""QR code rendering for gdm remote pairing URLs.
|
|
2
|
+
|
|
3
|
+
Security contract
|
|
4
|
+
-----------------
|
|
5
|
+
The pairing *token* MUST live in the URL **fragment** (``#token=...``),
|
|
6
|
+
never in the path or query string. Fragment identifiers are never sent
|
|
7
|
+
to the server in HTTP requests, so the token stays client-side only.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from urllib.parse import urlencode, urlparse, urlunparse
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def make_pairing_url(tunnel_url: str, token: str) -> str:
|
|
16
|
+
"""Embed *token* in the fragment of *tunnel_url*.
|
|
17
|
+
|
|
18
|
+
>>> url = make_pairing_url("https://example.trycloudflare.com", "abc123")
|
|
19
|
+
>>> url.startswith("https://example.trycloudflare.com")
|
|
20
|
+
True
|
|
21
|
+
>>> "#token=" in url
|
|
22
|
+
True
|
|
23
|
+
>>> "?" not in url # token is NOT in query string
|
|
24
|
+
True
|
|
25
|
+
"""
|
|
26
|
+
parsed = urlparse(tunnel_url)
|
|
27
|
+
fragment = urlencode({"token": token})
|
|
28
|
+
return urlunparse(parsed._replace(fragment=fragment))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def render_qr(url: str, border: int = 2) -> str:
|
|
32
|
+
"""Render *url* as a Unicode block QR code string.
|
|
33
|
+
|
|
34
|
+
Uses ``qrcode`` (``pip install qrcode``). Each dark module is
|
|
35
|
+
rendered as ``'\\u2588\\u2588'`` (full block × 2) for a square aspect
|
|
36
|
+
ratio in most terminal fonts. Light modules are two spaces.
|
|
37
|
+
|
|
38
|
+
Returns the QR code as a multi-line string.
|
|
39
|
+
"""
|
|
40
|
+
try:
|
|
41
|
+
import qrcode # type: ignore[import-untyped]
|
|
42
|
+
import qrcode.constants # type: ignore[import-untyped]
|
|
43
|
+
except ImportError as exc:
|
|
44
|
+
raise ImportError(
|
|
45
|
+
"qrcode is required for QR rendering: pip install qrcode"
|
|
46
|
+
) from exc
|
|
47
|
+
|
|
48
|
+
qr = qrcode.QRCode(
|
|
49
|
+
error_correction=qrcode.constants.ERROR_CORRECT_M,
|
|
50
|
+
box_size=1,
|
|
51
|
+
border=border,
|
|
52
|
+
)
|
|
53
|
+
qr.add_data(url)
|
|
54
|
+
qr.make(fit=True)
|
|
55
|
+
|
|
56
|
+
lines: list[str] = []
|
|
57
|
+
for row in qr.modules:
|
|
58
|
+
line = "".join("\u2588\u2588" if cell else " " for cell in row)
|
|
59
|
+
lines.append(line)
|
|
60
|
+
return "\n".join(lines)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def print_qr(url: str, border: int = 2) -> None:
|
|
64
|
+
"""Print a QR code for *url* to stdout."""
|
|
65
|
+
print(render_qr(url, border=border))
|