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.
Files changed (131) hide show
  1. gdmcode-0.1.0.dist-info/METADATA +240 -0
  2. gdmcode-0.1.0.dist-info/RECORD +131 -0
  3. gdmcode-0.1.0.dist-info/WHEEL +4 -0
  4. gdmcode-0.1.0.dist-info/entry_points.txt +2 -0
  5. src/__init__.py +1 -0
  6. src/_internal/__init__.py +0 -0
  7. src/_internal/constants.py +244 -0
  8. src/_internal/domain_skills.py +339 -0
  9. src/agent/__init__.py +0 -0
  10. src/agent/commit_classifier.py +91 -0
  11. src/agent/context_budget.py +391 -0
  12. src/agent/daemon.py +681 -0
  13. src/agent/dag_validator.py +153 -0
  14. src/agent/debug_loop.py +473 -0
  15. src/agent/impact_analyzer.py +149 -0
  16. src/agent/impact_graph.py +117 -0
  17. src/agent/loop.py +1410 -0
  18. src/agent/orchestrator.py +141 -0
  19. src/agent/regression_guard.py +251 -0
  20. src/agent/review_gate.py +648 -0
  21. src/agent/risk_scorer.py +169 -0
  22. src/agent/self_healing.py +145 -0
  23. src/agent/smart_test_selector.py +89 -0
  24. src/agent/system_prompt.py +226 -0
  25. src/agent/task_tracker.py +320 -0
  26. src/agent/test_validator.py +210 -0
  27. src/agent/tool_orchestrator.py +402 -0
  28. src/agent/transcript.py +230 -0
  29. src/agent/verification_loop.py +133 -0
  30. src/agent/work_director.py +136 -0
  31. src/agent/worktree_manager.py +53 -0
  32. src/artifacts/__init__.py +16 -0
  33. src/artifacts/artifact_store.py +456 -0
  34. src/artifacts/verification_graph.py +75 -0
  35. src/auth.py +411 -0
  36. src/cli.py +1290 -0
  37. src/commands.py +1398 -0
  38. src/config.py +762 -0
  39. src/cost_tracker.py +348 -0
  40. src/db/__init__.py +4 -0
  41. src/db/migrations.py +337 -0
  42. src/enterprise/__init__.py +3 -0
  43. src/enterprise/audit_log.py +182 -0
  44. src/enterprise/identity.py +90 -0
  45. src/enterprise/rbac.py +100 -0
  46. src/enterprise/team_config.py +125 -0
  47. src/enterprise/usage_analytics.py +261 -0
  48. src/exceptions.py +207 -0
  49. src/git_workflow.py +651 -0
  50. src/integrations/__init__.py +6 -0
  51. src/integrations/github_actions.py +106 -0
  52. src/integrations/mcp_server.py +333 -0
  53. src/integrations/sentry_integration.py +100 -0
  54. src/integrations/sentry_server.py +82 -0
  55. src/integrations/webhook_security.py +19 -0
  56. src/main.py +27 -0
  57. src/memory/__init__.py +0 -0
  58. src/memory/code_index.py +376 -0
  59. src/memory/compressor.py +378 -0
  60. src/memory/context_memory.py +135 -0
  61. src/memory/continuous_memory.py +234 -0
  62. src/memory/conventions.py +495 -0
  63. src/memory/db.py +1119 -0
  64. src/memory/document_index.py +205 -0
  65. src/memory/file_cache.py +128 -0
  66. src/memory/project_scanner.py +178 -0
  67. src/memory/session_store.py +201 -0
  68. src/models/__init__.py +0 -0
  69. src/models/client.py +715 -0
  70. src/models/definitions.py +459 -0
  71. src/models/router.py +418 -0
  72. src/models/schemas.py +389 -0
  73. src/permissions.py +294 -0
  74. src/remote/__init__.py +5 -0
  75. src/remote/command_filter.py +33 -0
  76. src/remote/models.py +31 -0
  77. src/remote/permission_handler.py +79 -0
  78. src/remote/phone_ui.py +48 -0
  79. src/remote/protocol.py +59 -0
  80. src/remote/qr.py +65 -0
  81. src/remote/server.py +586 -0
  82. src/remote/token_manager.py +61 -0
  83. src/remote/tunnel.py +212 -0
  84. src/repl.py +475 -0
  85. src/runtime/__init__.py +1 -0
  86. src/runtime/branch_farm.py +372 -0
  87. src/runtime/replay.py +351 -0
  88. src/sandbox/__init__.py +2 -0
  89. src/sandbox/hermetic.py +214 -0
  90. src/sandbox/policy.py +44 -0
  91. src/sdk/__init__.py +3 -0
  92. src/sdk/plugin_base.py +39 -0
  93. src/sdk/plugin_host.py +100 -0
  94. src/sdk/plugin_loader.py +101 -0
  95. src/security.py +409 -0
  96. src/server/__init__.py +7 -0
  97. src/server/bridge.py +427 -0
  98. src/server/bridge_cli.py +103 -0
  99. src/server/bridge_client.py +170 -0
  100. src/server/protocol_version.py +103 -0
  101. src/session/__init__.py +10 -0
  102. src/session/event_fanout.py +46 -0
  103. src/session/input_broker.py +38 -0
  104. src/session/permission_bridge.py +100 -0
  105. src/tools/__init__.py +160 -0
  106. src/tools/_atomic.py +72 -0
  107. src/tools/agent_tools.py +423 -0
  108. src/tools/ask_user_tool.py +83 -0
  109. src/tools/bash_tool.py +384 -0
  110. src/tools/browser_tool.py +352 -0
  111. src/tools/browser_tools.py +179 -0
  112. src/tools/dep_tools.py +210 -0
  113. src/tools/document_reader.py +167 -0
  114. src/tools/document_tool.py +240 -0
  115. src/tools/document_writer.py +171 -0
  116. src/tools/impact_tools.py +240 -0
  117. src/tools/playwright_tool.py +172 -0
  118. src/tools/quality_tools.py +366 -0
  119. src/tools/read_tools.py +318 -0
  120. src/tools/result_cache.py +157 -0
  121. src/tools/search_tools.py +310 -0
  122. src/tools/shell_tools.py +311 -0
  123. src/tools/write_tools.py +337 -0
  124. src/voice/__init__.py +25 -0
  125. src/voice/audio_capture.py +92 -0
  126. src/voice/audio_playback.py +68 -0
  127. src/voice/errors.py +14 -0
  128. src/voice/models.py +35 -0
  129. src/voice/providers.py +143 -0
  130. src/voice/vad.py +55 -0
  131. 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"]