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
src/models/schemas.py
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"""Pydantic models for ALL structured outputs from Grok API calls.
|
|
2
|
+
|
|
3
|
+
Rule: every LLM response that contains structured data MUST be parsed
|
|
4
|
+
through one of these models via `client.beta.chat.completions.parse()`.
|
|
5
|
+
Zero `json.loads(raw_text)` calls anywhere in this codebase.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from typing import Annotated, Literal
|
|
11
|
+
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, Field, field_validator
|
|
15
|
+
|
|
16
|
+
from src.models.router import FailureType
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"Severity",
|
|
20
|
+
"AgentPerspective",
|
|
21
|
+
"DebateReport",
|
|
22
|
+
"ReviewFinding",
|
|
23
|
+
"ReviewReport",
|
|
24
|
+
"Subtask",
|
|
25
|
+
"TaskPlan",
|
|
26
|
+
"CommitMessage",
|
|
27
|
+
"SymbolRef",
|
|
28
|
+
"ImpactReport",
|
|
29
|
+
"CostSnapshot",
|
|
30
|
+
"Convention",
|
|
31
|
+
"ConventionSet",
|
|
32
|
+
"SymbolMatch",
|
|
33
|
+
"TestResult",
|
|
34
|
+
"PermissionDecision",
|
|
35
|
+
"ProjectScan",
|
|
36
|
+
"SessionDigest",
|
|
37
|
+
"EnsemblePatch",
|
|
38
|
+
"DebateMode",
|
|
39
|
+
"ADRReport",
|
|
40
|
+
"ThreatModelReport",
|
|
41
|
+
"DebugHypotheses",
|
|
42
|
+
"EscalationRecord",
|
|
43
|
+
"FailureType",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# Shared enums
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
class Severity(str, Enum):
|
|
52
|
+
CRITICAL = "critical"
|
|
53
|
+
HIGH = "high"
|
|
54
|
+
MEDIUM = "medium"
|
|
55
|
+
LOW = "low"
|
|
56
|
+
INFO = "info"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class PermissionVerdict(str, Enum):
|
|
60
|
+
ALLOW = "allow"
|
|
61
|
+
DENY = "deny"
|
|
62
|
+
ALLOW_SESSION = "allow_session" # allow for rest of session, don't persist
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TaskStatus(str, Enum):
|
|
66
|
+
PENDING = "pending"
|
|
67
|
+
IN_PROGRESS = "in_progress"
|
|
68
|
+
DONE = "done"
|
|
69
|
+
BLOCKED = "blocked"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
# Review gate
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
class ReviewFinding(BaseModel):
|
|
77
|
+
"""A single finding from the multi-agent review gate."""
|
|
78
|
+
|
|
79
|
+
severity: Severity
|
|
80
|
+
category: Annotated[str, Field(description="e.g. 'security', 'correctness', 'style'")]
|
|
81
|
+
file: str | None = None
|
|
82
|
+
line: int | None = None
|
|
83
|
+
message: str
|
|
84
|
+
suggestion: str | None = None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ReviewReport(BaseModel):
|
|
88
|
+
"""Output of the 4-agent review gate."""
|
|
89
|
+
|
|
90
|
+
approved: bool
|
|
91
|
+
confidence: Annotated[float, Field(ge=0.0, le=1.0)]
|
|
92
|
+
findings: list[ReviewFinding] = Field(default_factory=list)
|
|
93
|
+
summary: str
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def blocking_findings(self) -> list[ReviewFinding]:
|
|
97
|
+
return [f for f in self.findings if f.severity in (Severity.CRITICAL, Severity.HIGH)]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
# Task planning
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
class Subtask(BaseModel):
|
|
105
|
+
id: str
|
|
106
|
+
title: str
|
|
107
|
+
description: str
|
|
108
|
+
files_to_touch: list[str] = Field(default_factory=list)
|
|
109
|
+
depends_on: list[str] = Field(default_factory=list)
|
|
110
|
+
status: TaskStatus = TaskStatus.PENDING
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class TaskPlan(BaseModel):
|
|
114
|
+
"""Decomposed task plan from the planning agent."""
|
|
115
|
+
|
|
116
|
+
goal: str
|
|
117
|
+
subtasks: list[Subtask]
|
|
118
|
+
estimated_cost_usd: float | None = None
|
|
119
|
+
estimated_turns: int | None = None
|
|
120
|
+
risks: list[str] = Field(default_factory=list)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
# Git workflow
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
class CommitMessage(BaseModel):
|
|
128
|
+
"""Semantic commit message in Conventional Commits format."""
|
|
129
|
+
|
|
130
|
+
type: Annotated[str, Field(description="feat|fix|refactor|test|docs|chore")]
|
|
131
|
+
scope: str | None = None
|
|
132
|
+
description: str
|
|
133
|
+
body: str | None = None
|
|
134
|
+
breaking_change: bool = False
|
|
135
|
+
|
|
136
|
+
@field_validator("type")
|
|
137
|
+
@classmethod
|
|
138
|
+
def validate_type(cls, v: str) -> str:
|
|
139
|
+
valid = {"feat", "fix", "refactor", "test", "docs", "chore", "perf", "ci"}
|
|
140
|
+
if v not in valid:
|
|
141
|
+
raise ValueError(f"commit type must be one of {valid}")
|
|
142
|
+
return v
|
|
143
|
+
|
|
144
|
+
def format(self) -> str:
|
|
145
|
+
scope_part = f"({self.scope})" if self.scope else ""
|
|
146
|
+
bang = "!" if self.breaking_change else ""
|
|
147
|
+
header = f"{self.type}{scope_part}{bang}: {self.description}"
|
|
148
|
+
if self.body:
|
|
149
|
+
return f"{header}\n\n{self.body}"
|
|
150
|
+
return header
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
# Impact analysis
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
class SymbolRef(BaseModel):
|
|
158
|
+
file: str
|
|
159
|
+
line: int
|
|
160
|
+
symbol: str
|
|
161
|
+
kind: Annotated[str, Field(description="function|class|method|variable|import")]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class ImpactReport(BaseModel):
|
|
165
|
+
"""Blast radius of a proposed change to a symbol."""
|
|
166
|
+
|
|
167
|
+
symbol: str
|
|
168
|
+
callers_count: int = 0
|
|
169
|
+
callers: list[dict] = Field(default_factory=list)
|
|
170
|
+
affected_tests: list[str] = Field(default_factory=list)
|
|
171
|
+
is_public_api: bool = False
|
|
172
|
+
semver_recommendation: str = ""
|
|
173
|
+
change_type: str = "signature_change"
|
|
174
|
+
# Legacy fields kept for backward compatibility
|
|
175
|
+
changed_symbol: str = ""
|
|
176
|
+
changed_file: str = ""
|
|
177
|
+
direct_callers: list[SymbolRef] = Field(default_factory=list)
|
|
178
|
+
indirect_callers: list[SymbolRef] = Field(default_factory=list)
|
|
179
|
+
breaking: bool = False
|
|
180
|
+
migration_notes: str | None = None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ---------------------------------------------------------------------------
|
|
184
|
+
# Cost tracking
|
|
185
|
+
# ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
class CostSnapshot(BaseModel):
|
|
188
|
+
"""Point-in-time cost snapshot for a session."""
|
|
189
|
+
|
|
190
|
+
session_id: str
|
|
191
|
+
scout_usd: float = 0.0
|
|
192
|
+
coder_usd: float = 0.0
|
|
193
|
+
thinker_usd: float = 0.0
|
|
194
|
+
reasoner_usd: float = 0.0
|
|
195
|
+
debate_usd: float = 0.0
|
|
196
|
+
tool_calls_usd: float = 0.0
|
|
197
|
+
cached_savings_usd: float = 0.0
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def total_usd(self) -> float:
|
|
201
|
+
return (
|
|
202
|
+
self.scout_usd + self.coder_usd + self.thinker_usd
|
|
203
|
+
+ self.reasoner_usd + self.debate_usd + self.tool_calls_usd
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
# Convention extraction
|
|
209
|
+
# ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
class Convention(BaseModel):
|
|
212
|
+
key: str
|
|
213
|
+
value: str
|
|
214
|
+
confidence: Annotated[float, Field(ge=0.0, le=1.0)] = 1.0
|
|
215
|
+
source_file: str | None = None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class ConventionSet(BaseModel):
|
|
219
|
+
"""Extracted coding conventions from a codebase scan."""
|
|
220
|
+
|
|
221
|
+
language: str
|
|
222
|
+
naming: list[Convention] = Field(default_factory=list)
|
|
223
|
+
imports: list[Convention] = Field(default_factory=list)
|
|
224
|
+
error_handling: list[Convention] = Field(default_factory=list)
|
|
225
|
+
testing: list[Convention] = Field(default_factory=list)
|
|
226
|
+
style: list[Convention] = Field(default_factory=list)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ---------------------------------------------------------------------------
|
|
230
|
+
# Code search
|
|
231
|
+
# ---------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
class SymbolMatch(BaseModel):
|
|
234
|
+
"""A symbol found by the Tree-sitter code index."""
|
|
235
|
+
|
|
236
|
+
file: str
|
|
237
|
+
line: int
|
|
238
|
+
symbol: str
|
|
239
|
+
kind: str
|
|
240
|
+
signature: str | None = None
|
|
241
|
+
score: float = 1.0
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# ---------------------------------------------------------------------------
|
|
245
|
+
# Testing
|
|
246
|
+
# ---------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
class TestResult(BaseModel):
|
|
249
|
+
"""Result of running the test suite."""
|
|
250
|
+
|
|
251
|
+
passed: int
|
|
252
|
+
failed: int
|
|
253
|
+
errors: int
|
|
254
|
+
skipped: int
|
|
255
|
+
duration_s: float
|
|
256
|
+
failed_tests: list[str] = Field(default_factory=list)
|
|
257
|
+
coverage_pct: float | None = None
|
|
258
|
+
|
|
259
|
+
@property
|
|
260
|
+
def all_passed(self) -> bool:
|
|
261
|
+
return self.failed == 0 and self.errors == 0
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# ---------------------------------------------------------------------------
|
|
265
|
+
# Permissions
|
|
266
|
+
# ---------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
class PermissionDecision(BaseModel):
|
|
269
|
+
"""Model-generated permission recommendation for a tool + path."""
|
|
270
|
+
|
|
271
|
+
tool_name: str
|
|
272
|
+
path: str | None
|
|
273
|
+
verdict: PermissionVerdict
|
|
274
|
+
reason: str
|
|
275
|
+
remember_for_session: bool = False
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# ---------------------------------------------------------------------------
|
|
279
|
+
# Project scan
|
|
280
|
+
# ---------------------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
class ProjectScan(BaseModel):
|
|
283
|
+
"""Initial project scan result stored in gdm.db on first run."""
|
|
284
|
+
|
|
285
|
+
tech_stack: list[str]
|
|
286
|
+
entry_points: list[str]
|
|
287
|
+
test_framework: str | None = None
|
|
288
|
+
package_manager: str | None = None
|
|
289
|
+
key_modules: dict[str, str] = Field(default_factory=dict)
|
|
290
|
+
conventions: list[str] = Field(default_factory=list)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# ---------------------------------------------------------------------------
|
|
294
|
+
# Session compression
|
|
295
|
+
# ---------------------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
class SessionDigest(BaseModel):
|
|
298
|
+
"""Compressed summary of a session window (every 5 turns)."""
|
|
299
|
+
|
|
300
|
+
decisions: list[str] = Field(default_factory=list)
|
|
301
|
+
files_modified: list[str] = Field(default_factory=list)
|
|
302
|
+
errors_fixed: list[str] = Field(default_factory=list)
|
|
303
|
+
user_preferences: list[str] = Field(default_factory=list)
|
|
304
|
+
summary: str
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# ---------------------------------------------------------------------------
|
|
308
|
+
# Ensemble patching
|
|
309
|
+
# ---------------------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
class EnsemblePatch(BaseModel):
|
|
312
|
+
"""One candidate patch from an ensemble patching strategy."""
|
|
313
|
+
|
|
314
|
+
strategy: Annotated[str, Field(description="e.g. 'minimal_fix', 'refactor', 'defensive'")]
|
|
315
|
+
diff: str
|
|
316
|
+
explanation: str
|
|
317
|
+
tests_passed: int = 0
|
|
318
|
+
tests_failed: int = 0
|
|
319
|
+
|
|
320
|
+
@property
|
|
321
|
+
def score(self) -> float:
|
|
322
|
+
total = self.tests_passed + self.tests_failed
|
|
323
|
+
return self.tests_passed / total if total > 0 else 0.0
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# ---------------------------------------------------------------------------
|
|
327
|
+
# Debate modes
|
|
328
|
+
# ---------------------------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
class DebateMode(str, Enum):
|
|
331
|
+
CODE_REVIEW = "code_review"
|
|
332
|
+
ADR = "adr"
|
|
333
|
+
SECURITY = "security"
|
|
334
|
+
DEBUG_HYPOTHESIS = "debug_hypothesis"
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
class ADRReport(BaseModel):
|
|
338
|
+
decision: str
|
|
339
|
+
rationale: str
|
|
340
|
+
alternatives_rejected: list[str]
|
|
341
|
+
risks: list[str]
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
class ThreatModelReport(BaseModel):
|
|
345
|
+
attack_surfaces: list[str]
|
|
346
|
+
mitigations: list[str]
|
|
347
|
+
severity: Literal["critical", "high", "medium", "low"]
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
class DebugHypotheses(BaseModel):
|
|
351
|
+
hypotheses: list[str] # ranked by likelihood
|
|
352
|
+
suggested_next_action: str
|
|
353
|
+
dead_ends_identified: list[str]
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
class EscalationRecord(BaseModel):
|
|
357
|
+
"""Structured record of a single escalation event."""
|
|
358
|
+
failure_type: FailureType
|
|
359
|
+
from_tier: str
|
|
360
|
+
to_tier: str | None
|
|
361
|
+
attempt: int
|
|
362
|
+
cost_usd: float = 0.0
|
|
363
|
+
timestamp: datetime
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
# ---------------------------------------------------------------------------
|
|
367
|
+
# 4-agent debate schemas
|
|
368
|
+
# ---------------------------------------------------------------------------
|
|
369
|
+
|
|
370
|
+
class AgentPerspective(BaseModel):
|
|
371
|
+
"""One debate agent's perspective on the change under review."""
|
|
372
|
+
|
|
373
|
+
role: str # "architect" | "security" | "performance" | "devil_advocate"
|
|
374
|
+
findings: list[ReviewFinding] = Field(default_factory=list)
|
|
375
|
+
confidence: Annotated[float, Field(ge=0.0, le=1.0)]
|
|
376
|
+
summary: str
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
class DebateReport(BaseModel):
|
|
380
|
+
"""Aggregated output of the 4-agent debate run."""
|
|
381
|
+
|
|
382
|
+
perspectives: list[AgentPerspective] = Field(default_factory=list) # 2-4 entries
|
|
383
|
+
consensus_points: list[str] = Field(default_factory=list)
|
|
384
|
+
disagreements: list[str] = Field(default_factory=list)
|
|
385
|
+
missing_evidence: list[str] = Field(default_factory=list)
|
|
386
|
+
recommendation: str
|
|
387
|
+
approved: bool
|
|
388
|
+
confidence: Annotated[float, Field(ge=0.0, le=1.0)]
|
|
389
|
+
debate_skipped: bool = False
|
src/permissions.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Tool permissions — user-controlled allow/deny for dangerous tool calls.
|
|
2
|
+
|
|
3
|
+
Every potentially destructive operation goes through PermissionContext.
|
|
4
|
+
Users can pre-approve tools for the session, or deny them permanently.
|
|
5
|
+
All decisions are persisted to gdm.db so they survive crashes.
|
|
6
|
+
|
|
7
|
+
Decision hierarchy (first match wins):
|
|
8
|
+
1. Always-deny list (hardcoded in constants — never overridable)
|
|
9
|
+
2. Session-scoped deny (user said "no" this session)
|
|
10
|
+
3. Session-scoped allow (user said "yes" this session)
|
|
11
|
+
4. Persisted allow (user approved on a prior run with --remember)
|
|
12
|
+
5. Ask the user (interactive prompt → blocks until answered)
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from enum import StrEnum
|
|
19
|
+
from typing import TYPE_CHECKING
|
|
20
|
+
|
|
21
|
+
from src._internal.constants import _ALWAYS_DENY_TOOLS
|
|
22
|
+
from src.exceptions import ToolPermissionError
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from src.memory.db import GdmDatabase
|
|
26
|
+
from src.session.permission_bridge import PermissionBridge
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"Decision", "PermissionContext", "AutonomyPolicy",
|
|
30
|
+
"set_permission_bridge", "clear_permission_bridge",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class AutonomyPolicy:
|
|
36
|
+
"""Encodes which autonomous actions are permitted at a given level."""
|
|
37
|
+
|
|
38
|
+
level: int = 1
|
|
39
|
+
auto_apply_edits: bool = False
|
|
40
|
+
auto_run_tests: bool = False
|
|
41
|
+
auto_commit: bool = False
|
|
42
|
+
auto_push: bool = False
|
|
43
|
+
auto_fix_failures: bool = False
|
|
44
|
+
git_remote_allowlist: list[str] = field(default_factory=list)
|
|
45
|
+
max_fix_attempts: int = 3
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def from_level(cls, level: int) -> "AutonomyPolicy":
|
|
49
|
+
defaults = {
|
|
50
|
+
1: dict(auto_apply_edits=False, auto_run_tests=False, auto_commit=False, auto_push=False, auto_fix_failures=False),
|
|
51
|
+
2: dict(auto_apply_edits=True, auto_run_tests=False, auto_commit=False, auto_push=False, auto_fix_failures=False),
|
|
52
|
+
3: dict(auto_apply_edits=True, auto_run_tests=True, auto_commit=False, auto_push=False, auto_fix_failures=False),
|
|
53
|
+
4: dict(auto_apply_edits=True, auto_run_tests=True, auto_commit=True, auto_push=False, auto_fix_failures=False),
|
|
54
|
+
5: dict(auto_apply_edits=True, auto_run_tests=True, auto_commit=True, auto_push=True, auto_fix_failures=True),
|
|
55
|
+
}
|
|
56
|
+
return cls(level=level, **defaults.get(level, defaults[1]))
|
|
57
|
+
|
|
58
|
+
log = logging.getLogger(__name__)
|
|
59
|
+
|
|
60
|
+
# Module-level permission bridge (set by session controller)
|
|
61
|
+
_active_bridge: "PermissionBridge | None" = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def set_permission_bridge(bridge: "PermissionBridge") -> None:
|
|
65
|
+
"""Activate a PermissionBridge so _prompt_user() delegates to it."""
|
|
66
|
+
global _active_bridge
|
|
67
|
+
_active_bridge = bridge
|
|
68
|
+
log.debug("PermissionBridge activated")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def clear_permission_bridge() -> None:
|
|
72
|
+
"""Deactivate the bridge; _prompt_user() falls back to rich.Prompt.ask()."""
|
|
73
|
+
global _active_bridge
|
|
74
|
+
_active_bridge = None
|
|
75
|
+
log.debug("PermissionBridge cleared")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class Decision(StrEnum):
|
|
79
|
+
ALLOW = "allow"
|
|
80
|
+
ALLOW_SESSION = "allow_session"
|
|
81
|
+
ALLOW_ALWAYS = "allow_always"
|
|
82
|
+
DENY = "deny"
|
|
83
|
+
DENY_SESSION = "deny_session"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class PermissionContext:
|
|
87
|
+
"""Manages per-tool permission decisions for a session.
|
|
88
|
+
|
|
89
|
+
Usage::
|
|
90
|
+
|
|
91
|
+
perms = PermissionContext(db=db, session_id=sid)
|
|
92
|
+
perms.check("bash", args={"cmd": "rm -rf /tmp/foo"})
|
|
93
|
+
# Raises ToolPermissionError if denied
|
|
94
|
+
# Prompts user if unknown
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
db: GdmDatabase,
|
|
100
|
+
session_id: str,
|
|
101
|
+
non_interactive: bool = False,
|
|
102
|
+
policy_enforced: bool = False,
|
|
103
|
+
) -> None:
|
|
104
|
+
self._db = db
|
|
105
|
+
self._session_id = session_id
|
|
106
|
+
self._non_interactive = non_interactive
|
|
107
|
+
self._all_allowed: bool = False
|
|
108
|
+
self.policy_enforced: bool = policy_enforced
|
|
109
|
+
# In-memory session caches (reset on new session)
|
|
110
|
+
self._session_allow: frozenset[str] = frozenset()
|
|
111
|
+
self._session_deny: frozenset[str] = frozenset()
|
|
112
|
+
self._pre_approved: set[str] = set()
|
|
113
|
+
self._pre_denied: set[str] = set()
|
|
114
|
+
self._load_persisted_denies()
|
|
115
|
+
|
|
116
|
+
# ------------------------------------------------------------------
|
|
117
|
+
# Public API
|
|
118
|
+
# ------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
def check(self, tool: str, args: dict) -> Decision: # type: ignore[type-arg]
|
|
121
|
+
"""Evaluate permission for *tool*. Returns Decision or raises.
|
|
122
|
+
|
|
123
|
+
Raises:
|
|
124
|
+
ToolPermissionError: if the tool is denied.
|
|
125
|
+
"""
|
|
126
|
+
# Layer 1: absolute deny (hardcoded — never bypassable)
|
|
127
|
+
if tool in _ALWAYS_DENY_TOOLS:
|
|
128
|
+
log.warning("Tool '%s' is in always-deny list — blocked", tool)
|
|
129
|
+
raise ToolPermissionError(tool=tool, reason="always-deny list")
|
|
130
|
+
|
|
131
|
+
# Layer 2: session deny (takes priority even over allow_all)
|
|
132
|
+
if tool in self._session_deny:
|
|
133
|
+
raise ToolPermissionError(tool=tool, reason="denied this session")
|
|
134
|
+
|
|
135
|
+
# Layer 2.5: blanket session allow (/allow-all or --yes)
|
|
136
|
+
if self._all_allowed:
|
|
137
|
+
log.info("Tool '%s' auto-approved (allow_all_session active)", tool)
|
|
138
|
+
return Decision.ALLOW_SESSION
|
|
139
|
+
|
|
140
|
+
# Layer 3: session allow
|
|
141
|
+
if tool in self._session_allow:
|
|
142
|
+
return Decision.ALLOW_SESSION
|
|
143
|
+
|
|
144
|
+
# Layer 4: persisted allow
|
|
145
|
+
if self._is_persisted_allow(tool):
|
|
146
|
+
return Decision.ALLOW_ALWAYS
|
|
147
|
+
|
|
148
|
+
# Layer 5: ask
|
|
149
|
+
return self._prompt_user(tool, args)
|
|
150
|
+
|
|
151
|
+
def pre_approve(self, tools: list[str]) -> None:
|
|
152
|
+
"""Bulk-approve tools for this session (e.g. from --yes flag)."""
|
|
153
|
+
self._pre_approved.update(tools)
|
|
154
|
+
self._session_allow = self._session_allow | frozenset(tools)
|
|
155
|
+
|
|
156
|
+
def pre_deny(self, tools: list[str]) -> None:
|
|
157
|
+
"""Bulk-deny tools for this session."""
|
|
158
|
+
self._pre_denied.update(tools)
|
|
159
|
+
self._session_deny = self._session_deny | frozenset(tools)
|
|
160
|
+
|
|
161
|
+
def allow_all_session(self) -> None:
|
|
162
|
+
"""Grant blanket session-level permission for all tools (/allow-all or --yes).
|
|
163
|
+
|
|
164
|
+
The hardcoded _ALWAYS_DENY_TOOLS list still applies — those can never
|
|
165
|
+
be bypassed regardless of this flag.
|
|
166
|
+
|
|
167
|
+
A persistent audit-log warning is written so the user knows bypass is active.
|
|
168
|
+
"""
|
|
169
|
+
if getattr(self, "policy_enforced", False):
|
|
170
|
+
raise RuntimeError("Blanket session approval is disabled by enterprise policy.")
|
|
171
|
+
from rich.console import Console
|
|
172
|
+
Console(stderr=True).print(
|
|
173
|
+
"\n[bold yellow]⚠ Permission bypass active.[/bold yellow] "
|
|
174
|
+
"All tool calls are auto-approved this session "
|
|
175
|
+
"(except the permanent safety blocklist). "
|
|
176
|
+
"Audit log remains active.\n"
|
|
177
|
+
)
|
|
178
|
+
self._all_allowed = True
|
|
179
|
+
log.warning("allow_all_session activated — all non-hardcoded tools auto-approved")
|
|
180
|
+
|
|
181
|
+
def set_autonomy_policy(self, policy: "AutonomyPolicy") -> None:
|
|
182
|
+
"""Apply an AutonomyPolicy — pre-approve/deny tools accordingly."""
|
|
183
|
+
self._autonomy_policy = policy
|
|
184
|
+
if policy.auto_apply_edits:
|
|
185
|
+
self.pre_approve(["write_file", "patch_file"])
|
|
186
|
+
else:
|
|
187
|
+
self.pre_deny(["write_file", "patch_file"])
|
|
188
|
+
if policy.auto_run_tests:
|
|
189
|
+
self.pre_approve(["bash_test"])
|
|
190
|
+
if policy.auto_commit:
|
|
191
|
+
self.pre_approve(["git_commit"])
|
|
192
|
+
if policy.auto_push:
|
|
193
|
+
self.pre_approve(["git_push"])
|
|
194
|
+
|
|
195
|
+
def can(self, gate: str) -> bool:
|
|
196
|
+
"""Return True if the active AutonomyPolicy enables *gate*."""
|
|
197
|
+
policy = getattr(self, "_autonomy_policy", None)
|
|
198
|
+
return bool(policy and getattr(policy, gate, False))
|
|
199
|
+
|
|
200
|
+
# ------------------------------------------------------------------
|
|
201
|
+
# Persistence helpers
|
|
202
|
+
# ------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
def _is_persisted_allow(self, tool: str) -> bool:
|
|
205
|
+
row = self._db.execute_one(
|
|
206
|
+
"SELECT decision FROM permissions WHERE tool = ? AND decision = ?",
|
|
207
|
+
(tool, Decision.ALLOW_ALWAYS),
|
|
208
|
+
)
|
|
209
|
+
return row is not None
|
|
210
|
+
|
|
211
|
+
def _persist_decision(self, tool: str, decision: Decision) -> None:
|
|
212
|
+
self._db.execute(
|
|
213
|
+
"""INSERT INTO permissions (session_id, tool, decision)
|
|
214
|
+
VALUES (?, ?, ?)
|
|
215
|
+
ON CONFLICT(tool) DO UPDATE SET decision = excluded.decision""",
|
|
216
|
+
(self._session_id, tool, str(decision)),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
def _load_persisted_denies(self) -> None:
|
|
220
|
+
"""Load any permanently denied tools from the database into session deny cache."""
|
|
221
|
+
try:
|
|
222
|
+
rows = self._db.execute_many(
|
|
223
|
+
"SELECT tool FROM permissions WHERE decision = ?",
|
|
224
|
+
(str(Decision.DENY),),
|
|
225
|
+
)
|
|
226
|
+
if rows:
|
|
227
|
+
self._session_deny = self._session_deny | frozenset(r[0] for r in rows)
|
|
228
|
+
except Exception:
|
|
229
|
+
pass # DB may not be initialised yet; silently skip
|
|
230
|
+
|
|
231
|
+
# ------------------------------------------------------------------
|
|
232
|
+
# Interactive prompt
|
|
233
|
+
# ------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
def _prompt_user(self, tool: str, args: dict) -> Decision: # type: ignore[type-arg]
|
|
236
|
+
"""Ask user to allow/deny. Non-interactive mode auto-denies."""
|
|
237
|
+
if self._non_interactive:
|
|
238
|
+
log.info("Non-interactive mode — denying unknown tool '%s'", tool)
|
|
239
|
+
raise ToolPermissionError(tool=tool, reason="non-interactive mode; not pre-approved")
|
|
240
|
+
|
|
241
|
+
# Delegate to PermissionBridge when one is active (remote/voice sessions)
|
|
242
|
+
if _active_bridge is not None:
|
|
243
|
+
prompt = f"Allow tool '{tool}'?"
|
|
244
|
+
if args:
|
|
245
|
+
import json
|
|
246
|
+
prompt += f" Args: {json.dumps(args)[:200]}"
|
|
247
|
+
approved = _active_bridge.request(prompt)
|
|
248
|
+
if not approved:
|
|
249
|
+
raise ToolPermissionError(tool=tool, reason="denied via permission bridge")
|
|
250
|
+
self._session_allow = self._session_allow | {tool}
|
|
251
|
+
return Decision.ALLOW_SESSION
|
|
252
|
+
|
|
253
|
+
# Rich prompt — imported lazily to avoid circular dep at module init
|
|
254
|
+
from rich.console import Console
|
|
255
|
+
from rich.prompt import Prompt
|
|
256
|
+
|
|
257
|
+
console = Console()
|
|
258
|
+
console.print(f"\n[bold yellow]⚠ Permission request[/bold yellow]")
|
|
259
|
+
console.print(f"Tool: [cyan]{tool}[/cyan]")
|
|
260
|
+
if args:
|
|
261
|
+
import json
|
|
262
|
+
console.print(f"Args: [dim]{json.dumps(args, indent=2)[:400]}[/dim]")
|
|
263
|
+
|
|
264
|
+
choice = Prompt.ask(
|
|
265
|
+
"Allow?",
|
|
266
|
+
choices=["y", "n", "always", "session", "never"],
|
|
267
|
+
default="y",
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
decision_map = {
|
|
271
|
+
"y": Decision.ALLOW_SESSION,
|
|
272
|
+
"session": Decision.ALLOW_SESSION,
|
|
273
|
+
"always": Decision.ALLOW_ALWAYS,
|
|
274
|
+
"n": Decision.DENY_SESSION,
|
|
275
|
+
"never": Decision.DENY,
|
|
276
|
+
}
|
|
277
|
+
decision = decision_map.get(choice, Decision.DENY_SESSION)
|
|
278
|
+
|
|
279
|
+
if decision == Decision.DENY_SESSION:
|
|
280
|
+
self._session_deny = self._session_deny | {tool}
|
|
281
|
+
raise ToolPermissionError(tool=tool, reason="denied by user")
|
|
282
|
+
|
|
283
|
+
if decision == Decision.DENY:
|
|
284
|
+
self._persist_decision(tool, decision)
|
|
285
|
+
raise ToolPermissionError(tool=tool, reason="permanently denied by user")
|
|
286
|
+
|
|
287
|
+
if decision == Decision.ALLOW_SESSION:
|
|
288
|
+
self._session_allow = self._session_allow | {tool}
|
|
289
|
+
|
|
290
|
+
if decision == Decision.ALLOW_ALWAYS:
|
|
291
|
+
self._persist_decision(tool, decision)
|
|
292
|
+
self._session_allow = self._session_allow | {tool}
|
|
293
|
+
|
|
294
|
+
return decision
|
src/remote/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
"""Remote server — LAN WebSocket/HTTP bridge for phone-based control."""
|
|
2
|
+
from src.remote.server import RemoteServer
|
|
3
|
+
from src.remote.models import RemoteEvent, InputMessage, SessionState, PermissionRequest
|
|
4
|
+
|
|
5
|
+
__all__ = ["RemoteServer", "RemoteEvent", "InputMessage", "SessionState", "PermissionRequest"]
|