devorch 0.1.2__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.
config/permissions.py ADDED
@@ -0,0 +1,285 @@
1
+ """
2
+ Permission management for tool execution safety.
3
+
4
+ Supports:
5
+ - Tool-level permissions (allow/deny/ask)
6
+ - Command pattern matching (e.g., "git *" always allowed)
7
+ - Session-only vs persistent permissions
8
+ - Safe command categories
9
+ """
10
+
11
+ import fnmatch
12
+ import re
13
+ from dataclasses import dataclass, field
14
+ from enum import Enum
15
+ from pathlib import Path
16
+
17
+ try:
18
+ import yaml
19
+
20
+ YAML_AVAILABLE = True
21
+ except ImportError:
22
+ YAML_AVAILABLE = False
23
+
24
+ CONFIG_DIR = Path.home() / ".devorch"
25
+ PERMISSIONS_FILE = CONFIG_DIR / "permissions.yaml"
26
+
27
+
28
+ class PermissionLevel(str, Enum):
29
+ ALLOW = "allow" # Always allow without asking
30
+ DENY = "deny" # Always deny
31
+ ASK = "ask" # Ask every time (default)
32
+
33
+
34
+ class PermissionChoice(str, Enum):
35
+ ALLOW_ONCE = "allow_once"
36
+ ALLOW_SESSION = "allow_session"
37
+ ALLOW_ALWAYS = "allow_always"
38
+ DENY = "deny"
39
+
40
+
41
+ # Commands that are generally safe (read-only or low risk)
42
+ SAFE_COMMANDS = [
43
+ # Version/info commands
44
+ "git --version",
45
+ "git status",
46
+ "git log*",
47
+ "git diff*",
48
+ "git branch*",
49
+ "git remote -v",
50
+ "git show*",
51
+ "python --version",
52
+ "python3 --version",
53
+ "pip --version",
54
+ "pip list",
55
+ "node --version",
56
+ "npm --version",
57
+ "npm list*",
58
+ "cargo --version",
59
+ "rustc --version",
60
+ "go version",
61
+ # List/read commands
62
+ "ls*",
63
+ "dir*",
64
+ "pwd",
65
+ "cd*",
66
+ "echo*",
67
+ "cat*",
68
+ "head*",
69
+ "tail*",
70
+ "which*",
71
+ "where*",
72
+ "type*",
73
+ "file*",
74
+ "wc*",
75
+ "grep*",
76
+ "find*",
77
+ "tree*",
78
+ # Environment
79
+ "env",
80
+ "printenv*",
81
+ "hostname",
82
+ "whoami",
83
+ "date",
84
+ "uptime",
85
+ ]
86
+
87
+ # Commands that are potentially dangerous
88
+ DANGEROUS_PATTERNS = [
89
+ "rm -rf *",
90
+ "rm -r *",
91
+ "rmdir*",
92
+ "del /s*",
93
+ "rd /s*",
94
+ "format*",
95
+ "mkfs*",
96
+ "dd if=*",
97
+ "> /dev/*",
98
+ "chmod 777*",
99
+ "sudo*",
100
+ "curl*|*sh",
101
+ "wget*|*sh", # Pipe to shell
102
+ "*; rm*",
103
+ "*&& rm*", # Chained destructive
104
+ ]
105
+
106
+
107
+ @dataclass
108
+ class ToolPermission:
109
+ """Permission settings for a specific tool."""
110
+
111
+ level: PermissionLevel = PermissionLevel.ASK
112
+ allowed_patterns: list[str] = field(default_factory=list)
113
+ denied_patterns: list[str] = field(default_factory=list)
114
+
115
+
116
+ @dataclass
117
+ class Permissions:
118
+ """Global permission settings."""
119
+
120
+ tools: dict[str, ToolPermission] = field(default_factory=dict)
121
+
122
+ # Session-only permissions (not persisted)
123
+ session_allowed: list[str] = field(default_factory=list)
124
+ session_denied: list[str] = field(default_factory=list)
125
+
126
+ @classmethod
127
+ def load(cls) -> "Permissions":
128
+ """Load permissions from config file."""
129
+ permissions = cls()
130
+
131
+ # Set defaults
132
+ permissions.tools = {
133
+ "shell": ToolPermission(
134
+ level=PermissionLevel.ASK,
135
+ allowed_patterns=SAFE_COMMANDS.copy(),
136
+ denied_patterns=DANGEROUS_PATTERNS.copy(),
137
+ ),
138
+ "filesystem": ToolPermission(level=PermissionLevel.ASK),
139
+ "search": ToolPermission(level=PermissionLevel.ALLOW), # Search is safe
140
+ }
141
+
142
+ # Load from file if exists
143
+ if YAML_AVAILABLE and PERMISSIONS_FILE.exists():
144
+ try:
145
+ with open(PERMISSIONS_FILE) as f:
146
+ data = yaml.safe_load(f) or {}
147
+
148
+ for tool_name, tool_data in data.get("tools", {}).items():
149
+ if tool_name not in permissions.tools:
150
+ permissions.tools[tool_name] = ToolPermission()
151
+
152
+ perm = permissions.tools[tool_name]
153
+ perm.level = PermissionLevel(tool_data.get("level", "ask"))
154
+ perm.allowed_patterns = tool_data.get("allowed_patterns", perm.allowed_patterns)
155
+ perm.denied_patterns = tool_data.get("denied_patterns", perm.denied_patterns)
156
+
157
+ except Exception:
158
+ pass # Use defaults on error
159
+
160
+ return permissions
161
+
162
+ def save(self):
163
+ """Save permissions to config file."""
164
+ if not YAML_AVAILABLE:
165
+ return
166
+
167
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
168
+
169
+ data = {"tools": {}}
170
+
171
+ for tool_name, perm in self.tools.items():
172
+ data["tools"][tool_name] = {
173
+ "level": perm.level.value,
174
+ "allowed_patterns": perm.allowed_patterns,
175
+ "denied_patterns": perm.denied_patterns,
176
+ }
177
+
178
+ with open(PERMISSIONS_FILE, "w") as f:
179
+ yaml.safe_dump(data, f, default_flow_style=False)
180
+
181
+ def check_permission(
182
+ self, tool_name: str, command: str | None = None
183
+ ) -> tuple[PermissionLevel, str | None]:
184
+ """
185
+ Check if a tool/command is allowed.
186
+
187
+ Returns:
188
+ (PermissionLevel, reason) - The permission level and optional reason
189
+ """
190
+ tool_perm = self.tools.get(tool_name, ToolPermission())
191
+
192
+ # Check session permissions first (for commands)
193
+ if command:
194
+ # Session denied takes priority
195
+ for pattern in self.session_denied:
196
+ if self._match_command(command, pattern):
197
+ return PermissionLevel.DENY, f"Denied this session: {pattern}"
198
+
199
+ # Session allowed
200
+ for pattern in self.session_allowed:
201
+ if self._match_command(command, pattern):
202
+ return PermissionLevel.ALLOW, f"Allowed this session: {pattern}"
203
+
204
+ # Check denied patterns (dangerous commands)
205
+ for pattern in tool_perm.denied_patterns:
206
+ if self._match_command(command, pattern):
207
+ return PermissionLevel.DENY, f"Matches dangerous pattern: {pattern}"
208
+
209
+ # Check allowed patterns (safe commands)
210
+ for pattern in tool_perm.allowed_patterns:
211
+ if self._match_command(command, pattern):
212
+ return PermissionLevel.ALLOW, f"Matches safe pattern: {pattern}"
213
+
214
+ # Fall back to tool-level permission
215
+ return tool_perm.level, None
216
+
217
+ def _match_command(self, command: str, pattern: str) -> bool:
218
+ """Check if a command matches a pattern."""
219
+ command = command.strip().lower()
220
+ pattern = pattern.strip().lower()
221
+
222
+ # Handle wildcard patterns
223
+ if "*" in pattern:
224
+ # Convert glob to regex
225
+ regex = fnmatch.translate(pattern)
226
+ return bool(re.match(regex, command))
227
+
228
+ # Exact match or prefix match
229
+ return command == pattern or command.startswith(pattern + " ")
230
+
231
+ def add_allowed_pattern(self, tool_name: str, pattern: str, session_only: bool = False):
232
+ """Add a pattern to the allowed list."""
233
+ if session_only:
234
+ if pattern not in self.session_allowed:
235
+ self.session_allowed.append(pattern)
236
+ else:
237
+ if tool_name not in self.tools:
238
+ self.tools[tool_name] = ToolPermission()
239
+ if pattern not in self.tools[tool_name].allowed_patterns:
240
+ self.tools[tool_name].allowed_patterns.append(pattern)
241
+ self.save()
242
+
243
+ def add_denied_pattern(self, tool_name: str, pattern: str, session_only: bool = False):
244
+ """Add a pattern to the denied list."""
245
+ if session_only:
246
+ if pattern not in self.session_denied:
247
+ self.session_denied.append(pattern)
248
+ else:
249
+ if tool_name not in self.tools:
250
+ self.tools[tool_name] = ToolPermission()
251
+ if pattern not in self.tools[tool_name].denied_patterns:
252
+ self.tools[tool_name].denied_patterns.append(pattern)
253
+ self.save()
254
+
255
+ def set_tool_permission(self, tool_name: str, level: PermissionLevel):
256
+ """Set the default permission level for a tool."""
257
+ if tool_name not in self.tools:
258
+ self.tools[tool_name] = ToolPermission()
259
+ self.tools[tool_name].level = level
260
+ self.save()
261
+
262
+ def clear_session_permissions(self):
263
+ """Clear session-only permissions."""
264
+ self.session_allowed.clear()
265
+ self.session_denied.clear()
266
+
267
+
268
+ # Global permissions instance
269
+ _permissions: Permissions | None = None
270
+
271
+
272
+ def get_permissions() -> Permissions:
273
+ """Get the global permissions instance."""
274
+ global _permissions
275
+ if _permissions is None:
276
+ _permissions = Permissions.load()
277
+ return _permissions
278
+
279
+
280
+ def reset_permissions():
281
+ """Reset permissions to defaults."""
282
+ global _permissions
283
+ _permissions = None
284
+ if PERMISSIONS_FILE.exists():
285
+ PERMISSIONS_FILE.unlink()
config/profiles.py ADDED
File without changes
config/settings.py ADDED
@@ -0,0 +1,185 @@
1
+ import os
2
+ from dataclasses import dataclass, field
3
+ from pathlib import Path
4
+
5
+ try:
6
+ import yaml
7
+
8
+ YAML_AVAILABLE = True
9
+ except ImportError:
10
+ YAML_AVAILABLE = False
11
+
12
+ try:
13
+ import keyring
14
+
15
+ KEYRING_AVAILABLE = True
16
+ except ImportError:
17
+ KEYRING_AVAILABLE = False
18
+
19
+ CONFIG_DIR = Path.home() / ".devorch"
20
+ CONFIG_FILE = CONFIG_DIR / "config.yaml"
21
+ KEYRING_SERVICE = "devorch"
22
+
23
+
24
+ @dataclass
25
+ class ProviderConfig:
26
+ api_key: str | None = None
27
+ default_model: str = ""
28
+ base_url: str | None = None
29
+ key_encrypted: bool = False # True if key is stored in keyring
30
+
31
+
32
+ @dataclass
33
+ class Settings:
34
+ default_provider: str = "openai"
35
+ providers: dict[str, ProviderConfig] = field(default_factory=dict)
36
+
37
+ @classmethod
38
+ def load(cls) -> "Settings":
39
+ """Load settings from config file, keyring, and env vars."""
40
+ settings = cls()
41
+
42
+ # Load from YAML if exists and yaml is available
43
+ if YAML_AVAILABLE and CONFIG_FILE.exists():
44
+ try:
45
+ with open(CONFIG_FILE) as f:
46
+ data = yaml.safe_load(f) or {}
47
+ settings.default_provider = data.get("default_provider", "openai")
48
+ for name, config in data.get("providers", {}).items():
49
+ settings.providers[name] = ProviderConfig(**config)
50
+ except Exception:
51
+ pass # Fall back to defaults if config is invalid
52
+
53
+ # Environment variable mappings: (env_var_name, default_model)
54
+ env_mappings = {
55
+ "openai": ("OPENAI_API_KEY", "gpt-4o"),
56
+ "anthropic": ("ANTHROPIC_API_KEY", "claude-sonnet-4-20250514"),
57
+ "gemini": ("GOOGLE_API_KEY", "gemini-1.5-pro"),
58
+ "local": (None, "llama3"),
59
+ "groq": ("GROQ_API_KEY", "llama-3.1-70b-versatile"),
60
+ "openrouter": ("OPENROUTER_API_KEY", "openai/gpt-4o"),
61
+ "mistral": ("MISTRAL_API_KEY", "mistral-large-latest"),
62
+ "together": ("TOGETHER_API_KEY", "meta-llama/Llama-3-70b-chat-hf"),
63
+ "lmstudio": (None, ""),
64
+ "github_copilot": ("GITHUB_TOKEN", "gpt-4o"),
65
+ "deepseek": ("DEEPSEEK_API_KEY", "deepseek-chat"),
66
+ "kimi": ("MOONSHOT_API_KEY", "moonshot-v1-32k"),
67
+ "custom": ("CUSTOM_API_KEY", ""),
68
+ }
69
+
70
+ for provider, (env_var, default_model) in env_mappings.items():
71
+ if provider not in settings.providers:
72
+ settings.providers[provider] = ProviderConfig(default_model=default_model)
73
+
74
+ # Set default model if not configured
75
+ if not settings.providers[provider].default_model:
76
+ settings.providers[provider].default_model = default_model
77
+
78
+ # Priority: 1. Keyring, 2. Env var, 3. Config file
79
+ # Try keyring first (encrypted storage)
80
+ if KEYRING_AVAILABLE and not settings.providers[provider].api_key:
81
+ try:
82
+ key = keyring.get_password(KEYRING_SERVICE, provider)
83
+ if key:
84
+ settings.providers[provider].api_key = key
85
+ settings.providers[provider].key_encrypted = True
86
+ except Exception:
87
+ pass # Keyring not available or failed
88
+
89
+ # Override with environment variable if present
90
+ if env_var and os.environ.get(env_var):
91
+ settings.providers[provider].api_key = os.environ[env_var]
92
+ settings.providers[provider].key_encrypted = False
93
+
94
+ # Set default base_url for local provider
95
+ if "local" in settings.providers and not settings.providers["local"].base_url:
96
+ settings.providers["local"].base_url = "http://localhost:11434/v1"
97
+
98
+ return settings
99
+
100
+ def get_api_key(self, provider: str) -> str | None:
101
+ """Get API key for a provider."""
102
+ config = self.providers.get(provider)
103
+ return config.api_key if config else None
104
+
105
+ def get_default_model(self, provider: str) -> str:
106
+ """Get default model for a provider."""
107
+ config = self.providers.get(provider)
108
+ return config.default_model if config else ""
109
+
110
+ def get_base_url(self, provider: str) -> str | None:
111
+ """Get base URL for a provider (used for local/custom endpoints)."""
112
+ config = self.providers.get(provider)
113
+ return config.base_url if config else None
114
+
115
+ def is_key_encrypted(self, provider: str) -> bool:
116
+ """Check if the API key is stored encrypted."""
117
+ config = self.providers.get(provider)
118
+ return config.key_encrypted if config else False
119
+
120
+
121
+ def ensure_config_dir():
122
+ """Create config directory if it doesn't exist."""
123
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
124
+
125
+
126
+ def set_api_key(provider: str, api_key: str) -> bool:
127
+ """
128
+ Store an API key securely using keyring.
129
+ Returns True if stored encrypted, False if keyring unavailable.
130
+ """
131
+ if not KEYRING_AVAILABLE:
132
+ return False
133
+
134
+ try:
135
+ keyring.set_password(KEYRING_SERVICE, provider, api_key)
136
+ return True
137
+ except Exception:
138
+ return False
139
+
140
+
141
+ def delete_api_key(provider: str) -> bool:
142
+ """Delete an API key from keyring."""
143
+ if not KEYRING_AVAILABLE:
144
+ return False
145
+
146
+ try:
147
+ keyring.delete_password(KEYRING_SERVICE, provider)
148
+ return True
149
+ except Exception:
150
+ return False
151
+
152
+
153
+ def save_config(settings: Settings):
154
+ """Save settings to config file (excluding API keys - those go in keyring)."""
155
+ if not YAML_AVAILABLE:
156
+ raise RuntimeError("PyYAML is required to save config. Install with: pip install pyyaml")
157
+
158
+ ensure_config_dir()
159
+
160
+ data = {"default_provider": settings.default_provider, "providers": {}}
161
+
162
+ for name, config in settings.providers.items():
163
+ provider_data = {}
164
+ # Don't save API keys to file - they should be in keyring or env vars
165
+ if config.default_model:
166
+ provider_data["default_model"] = config.default_model
167
+ if config.base_url:
168
+ provider_data["base_url"] = config.base_url
169
+ if provider_data:
170
+ data["providers"][name] = provider_data
171
+
172
+ with open(CONFIG_FILE, "w") as f:
173
+ yaml.safe_dump(data, f, default_flow_style=False)
174
+
175
+
176
+ def keyring_available() -> bool:
177
+ """Check if keyring is available and working."""
178
+ if not KEYRING_AVAILABLE:
179
+ return False
180
+ try:
181
+ # Try a test operation
182
+ keyring.get_password(KEYRING_SERVICE, "__test__")
183
+ return True
184
+ except Exception:
185
+ return False