voidx 1.0.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 (126) hide show
  1. voidx/__init__.py +3 -0
  2. voidx/agent/__init__.py +0 -0
  3. voidx/agent/agents.py +439 -0
  4. voidx/agent/attachments.py +235 -0
  5. voidx/agent/graph.py +463 -0
  6. voidx/agent/graph_components/__init__.py +1 -0
  7. voidx/agent/graph_components/compaction.py +268 -0
  8. voidx/agent/graph_components/permissions.py +139 -0
  9. voidx/agent/graph_components/run_loop.py +532 -0
  10. voidx/agent/graph_components/runtime.py +14 -0
  11. voidx/agent/graph_components/streaming.py +351 -0
  12. voidx/agent/graph_components/subagent.py +278 -0
  13. voidx/agent/graph_components/tool_execution.py +208 -0
  14. voidx/agent/runtime_context.py +368 -0
  15. voidx/agent/slash.py +466 -0
  16. voidx/agent/slash_components/__init__.py +1 -0
  17. voidx/agent/slash_components/code_ide.py +68 -0
  18. voidx/agent/slash_components/lsp.py +105 -0
  19. voidx/agent/slash_components/mcp.py +332 -0
  20. voidx/agent/slash_components/model.py +419 -0
  21. voidx/agent/slash_components/runtime.py +55 -0
  22. voidx/agent/slash_components/skills.py +94 -0
  23. voidx/agent/state.py +32 -0
  24. voidx/agent/task_state.py +278 -0
  25. voidx/agent/tool_filters.py +27 -0
  26. voidx/config.py +707 -0
  27. voidx/llm/__init__.py +0 -0
  28. voidx/llm/catalog.py +188 -0
  29. voidx/llm/compaction.py +267 -0
  30. voidx/llm/context.py +43 -0
  31. voidx/llm/instruction.py +220 -0
  32. voidx/llm/provider.py +312 -0
  33. voidx/llm/usage.py +341 -0
  34. voidx/lsp/__init__.py +30 -0
  35. voidx/lsp/client.py +259 -0
  36. voidx/lsp/config.py +172 -0
  37. voidx/lsp/detector.py +512 -0
  38. voidx/lsp/errors.py +19 -0
  39. voidx/lsp/manager.py +280 -0
  40. voidx/lsp/schema.py +179 -0
  41. voidx/lsp/service.py +103 -0
  42. voidx/main.py +154 -0
  43. voidx/mcp/__init__.py +33 -0
  44. voidx/mcp/client.py +458 -0
  45. voidx/mcp/manager.py +267 -0
  46. voidx/mcp/schema.py +112 -0
  47. voidx/mcp/tool.py +122 -0
  48. voidx/mcp_servers/__init__.py +1 -0
  49. voidx/mcp_servers/web.py +104 -0
  50. voidx/memory/__init__.py +0 -0
  51. voidx/memory/context_frames.py +188 -0
  52. voidx/memory/model_profiles.py +98 -0
  53. voidx/memory/runtime_state.py +240 -0
  54. voidx/memory/session.py +272 -0
  55. voidx/memory/store.py +245 -0
  56. voidx/memory/transcript.py +137 -0
  57. voidx/permission/__init__.py +28 -0
  58. voidx/permission/engine.py +430 -0
  59. voidx/permission/evaluate.py +114 -0
  60. voidx/permission/sandbox.py +280 -0
  61. voidx/permission/schema.py +24 -0
  62. voidx/permission/service.py +314 -0
  63. voidx/permission/wildcard.py +34 -0
  64. voidx/skills/__init__.py +18 -0
  65. voidx/skills/bundled/superpowers/receiving-code-review/SKILL.md +30 -0
  66. voidx/skills/bundled/superpowers/requesting-code-review/SKILL.md +27 -0
  67. voidx/skills/bundled/superpowers/systematic-debugging/SKILL.md +36 -0
  68. voidx/skills/bundled/superpowers/test-driven-development/SKILL.md +33 -0
  69. voidx/skills/bundled/superpowers/verification-before-completion/SKILL.md +31 -0
  70. voidx/skills/bundled/superpowers/writing-plans/SKILL.md +27 -0
  71. voidx/skills/policy.py +97 -0
  72. voidx/skills/registry.py +162 -0
  73. voidx/skills/schema.py +47 -0
  74. voidx/skills/service.py +199 -0
  75. voidx/tools/__init__.py +0 -0
  76. voidx/tools/agent.py +81 -0
  77. voidx/tools/base.py +86 -0
  78. voidx/tools/bash.py +105 -0
  79. voidx/tools/file_ops.py +193 -0
  80. voidx/tools/lsp.py +155 -0
  81. voidx/tools/registry.py +104 -0
  82. voidx/tools/repomap.py +238 -0
  83. voidx/tools/search.py +162 -0
  84. voidx/tools/task_status.py +57 -0
  85. voidx/tools/task_tracker.py +81 -0
  86. voidx/tools/todo.py +82 -0
  87. voidx/tools/web_content.py +357 -0
  88. voidx/tools/web_mcp.py +107 -0
  89. voidx/tools/webfetch.py +155 -0
  90. voidx/tools/websearch.py +276 -0
  91. voidx/ui/__init__.py +0 -0
  92. voidx/ui/app.py +1033 -0
  93. voidx/ui/app_components/__init__.py +1 -0
  94. voidx/ui/app_components/clipboard_image.py +245 -0
  95. voidx/ui/app_components/commands.py +18 -0
  96. voidx/ui/app_components/controls.py +29 -0
  97. voidx/ui/app_components/file_picker.py +115 -0
  98. voidx/ui/app_components/formatting.py +187 -0
  99. voidx/ui/app_components/git_changes.py +51 -0
  100. voidx/ui/app_components/rendering.py +1169 -0
  101. voidx/ui/browse.py +160 -0
  102. voidx/ui/capture.py +169 -0
  103. voidx/ui/code_ide.py +251 -0
  104. voidx/ui/commands.py +83 -0
  105. voidx/ui/console.py +381 -0
  106. voidx/ui/console_components/__init__.py +1 -0
  107. voidx/ui/console_components/formatting.py +96 -0
  108. voidx/ui/console_components/streaming.py +253 -0
  109. voidx/ui/diff.py +331 -0
  110. voidx/ui/dock.py +372 -0
  111. voidx/ui/dock_components/__init__.py +1 -0
  112. voidx/ui/dock_components/formatting.py +123 -0
  113. voidx/ui/dock_components/nodes.py +401 -0
  114. voidx/ui/dock_components/state.py +51 -0
  115. voidx/ui/event_components/__init__.py +1 -0
  116. voidx/ui/event_components/schema.py +249 -0
  117. voidx/ui/events.py +341 -0
  118. voidx/ui/session_changes.py +163 -0
  119. voidx/ui/startup.py +161 -0
  120. voidx/ui/transcript.py +148 -0
  121. voidx/ui/tree.py +316 -0
  122. voidx-1.0.0.dist-info/METADATA +59 -0
  123. voidx-1.0.0.dist-info/RECORD +126 -0
  124. voidx-1.0.0.dist-info/WHEEL +5 -0
  125. voidx-1.0.0.dist-info/entry_points.txt +2 -0
  126. voidx-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,430 @@
1
+ """Central permission engine: capability classification, policies, and mode overlays."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, field
7
+ from enum import Enum
8
+
9
+ from voidx.config import ApprovalPolicy, ApprovalReviewer, PermissionMode
10
+ from voidx.permission.evaluate import evaluate
11
+ from voidx.permission.sandbox import check_sandbox_bash, check_sandbox_filepath
12
+ from voidx.permission.schema import Action, Rule, Ruleset
13
+ from voidx.permission.wildcard import match as wildcard_match
14
+
15
+
16
+ class PermissionCapability(str, Enum):
17
+ READ_TOOLS = "read_tools"
18
+ FILE_WRITE = "file_write"
19
+ FILE_FORMAT = "file_format"
20
+ BASH_READ = "bash_read"
21
+ BASH_WRITE = "bash_write"
22
+ AGENT_READONLY = "agent_readonly"
23
+ AGENT_IMPLEMENT = "agent_implement"
24
+ MCP_TOOLS = "mcp_tools"
25
+ OTHER = "other"
26
+
27
+
28
+ BASIC_RULES: Ruleset = [
29
+ Rule(permission="read", pattern="*", action="allow"),
30
+ Rule(permission="glob", pattern="*", action="allow"),
31
+ Rule(permission="grep", pattern="*", action="allow"),
32
+ Rule(permission="webfetch", pattern="*", action="allow"),
33
+ Rule(permission="websearch", pattern="*", action="allow"),
34
+ Rule(permission="todo", pattern="*", action="allow"),
35
+ Rule(permission="task_status", pattern="*", action="allow"),
36
+ Rule(permission="repo_map", pattern="*", action="allow"),
37
+ Rule(permission="lsp_diagnostics", pattern="*", action="allow"),
38
+ Rule(permission="lsp_symbols", pattern="*", action="allow"),
39
+ Rule(permission="lsp_definition", pattern="*", action="allow"),
40
+ Rule(permission="lsp_references", pattern="*", action="allow"),
41
+ Rule(permission="agent", pattern="*", action="allow"),
42
+ Rule(permission="write", pattern="*", action="ask"),
43
+ Rule(permission="edit", pattern="*", action="ask"),
44
+ Rule(permission="bash", pattern="*", action="ask"),
45
+ Rule(permission="lsp_format", pattern="*", action="ask"),
46
+ Rule(permission="agent", pattern="implement", action="ask"),
47
+ Rule(permission="mcp__*", pattern="*", action="ask"),
48
+ Rule(permission="mcp/*", pattern="*", action="ask"),
49
+ ]
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class PermissionContext:
54
+ workspace: str
55
+ interaction_mode: str = "auto"
56
+ permission_mode: str = PermissionMode.DEFAULT.value
57
+ sandbox_mode: str = "workspace-write"
58
+ sandbox_workspace_write: tuple[str, ...] = ()
59
+ approval_policy: str = ApprovalPolicy.UNTRUSTED.value
60
+ approval_reviewer: str = ApprovalReviewer.USER.value
61
+ session_allow: frozenset[str] = field(default_factory=frozenset)
62
+ session_deny: frozenset[str] = field(default_factory=frozenset)
63
+
64
+ @classmethod
65
+ def from_service(
66
+ cls,
67
+ service,
68
+ *,
69
+ workspace: str,
70
+ interaction_mode: str | None = None,
71
+ plan_mode: bool = False,
72
+ ) -> "PermissionContext":
73
+ mode = interaction_mode or "auto"
74
+ if plan_mode:
75
+ mode = "plan"
76
+ return cls(
77
+ workspace=workspace,
78
+ interaction_mode=mode,
79
+ permission_mode=getattr(service, "permission_mode", PermissionMode.DEFAULT.value),
80
+ sandbox_mode=getattr(service, "sandbox_mode", "workspace-write"),
81
+ sandbox_workspace_write=tuple(getattr(service, "sandbox_workspace_write", []) or []),
82
+ approval_policy=getattr(service, "approval_policy", ApprovalPolicy.UNTRUSTED.value),
83
+ approval_reviewer=getattr(service, "approval_reviewer", ApprovalReviewer.USER.value),
84
+ session_allow=frozenset(getattr(service, "_session_allow", set())),
85
+ session_deny=frozenset(getattr(service, "_session_deny", set())),
86
+ )
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class ClassifiedToolCall:
91
+ tool_call: dict
92
+ name: str
93
+ args: dict
94
+ pattern: str
95
+ capability: PermissionCapability
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class PermissionDecision:
100
+ action: Action
101
+ tool_call: dict
102
+ name: str
103
+ args: dict
104
+ pattern: str
105
+ capability: PermissionCapability
106
+ source: str
107
+ reason: str = ""
108
+ failure_check: bool = False
109
+
110
+
111
+ def authorize_tool_call(tool_call: dict, context: PermissionContext) -> PermissionDecision:
112
+ classified = classify_tool_call(tool_call)
113
+
114
+ reason = sandbox_denial_reason(classified, context)
115
+ if reason:
116
+ return _decision(classified, "deny", "sandbox", reason)
117
+
118
+ reason = mode_overlay_denial_reason(classified, context)
119
+ if reason:
120
+ return _decision(classified, "deny", "mode", reason)
121
+
122
+ session_action = session_action_for_tool(classified.name, context)
123
+ if session_action:
124
+ reason = _reason_for(classified, session_action)
125
+ return _decision(classified, session_action, "session", reason)
126
+
127
+ action = strategy_action_for_tool(classified, context)
128
+ if action != "ask":
129
+ return _decision(classified, action, "strategy", _reason_for(classified, action))
130
+
131
+ return resolve_approval(classified, context)
132
+
133
+
134
+ def decide_base_action(tool: str, pattern: str, context: PermissionContext) -> Action:
135
+ classified = classify_tool_call(tool_call_from_pattern(tool, pattern))
136
+ session_action = session_action_for_tool(classified.name, context)
137
+ if session_action:
138
+ return session_action
139
+ return strategy_action_for_tool(classified, context)
140
+
141
+
142
+ def classify_tool_call(tool_call: dict) -> ClassifiedToolCall:
143
+ name = repair_tool_name(str(tool_call.get("name", "")))
144
+ args = tool_call.get("args", {})
145
+ if not isinstance(args, dict):
146
+ args = {}
147
+ repaired = {**tool_call, "name": name, "args": args}
148
+ pattern = build_pattern(name, args)
149
+ return ClassifiedToolCall(
150
+ tool_call=repaired,
151
+ name=name,
152
+ args=args,
153
+ pattern=pattern,
154
+ capability=_capability(name, args),
155
+ )
156
+
157
+
158
+ def tool_call_from_pattern(tool: str, pattern: str = "*") -> dict:
159
+ name = repair_tool_name(tool)
160
+ if name == "bash":
161
+ args = {"command": pattern}
162
+ elif name == "agent":
163
+ args = {"agent": pattern}
164
+ elif name in _FILE_PATTERN_TOOLS:
165
+ args = {"file_path": pattern}
166
+ else:
167
+ args = {}
168
+ return {"name": name, "args": args}
169
+
170
+
171
+ def sandbox_denial_reason(classified: ClassifiedToolCall, context: PermissionContext) -> str | None:
172
+ if context.sandbox_mode == "danger-full-access":
173
+ return None
174
+
175
+ if context.sandbox_mode == "read-only":
176
+ if classified.capability in {
177
+ PermissionCapability.FILE_WRITE,
178
+ PermissionCapability.FILE_FORMAT,
179
+ PermissionCapability.BASH_WRITE,
180
+ }:
181
+ return f"SANDBOX READ-ONLY: '{classified.name}' is not allowed."
182
+ if classified.capability == PermissionCapability.AGENT_IMPLEMENT:
183
+ return "SANDBOX READ-ONLY: cannot delegate to implement."
184
+ return None
185
+
186
+ if context.sandbox_mode == "workspace-write":
187
+ if classified.capability in {PermissionCapability.FILE_WRITE, PermissionCapability.FILE_FORMAT}:
188
+ file_path = classified.args.get("file_path", "")
189
+ if file_path:
190
+ return check_sandbox_filepath(
191
+ file_path,
192
+ context.workspace,
193
+ list(context.sandbox_workspace_write),
194
+ )
195
+ if classified.name == "bash":
196
+ command = classified.args.get("command", "")
197
+ if command:
198
+ return check_sandbox_bash(
199
+ command,
200
+ context.workspace,
201
+ list(context.sandbox_workspace_write),
202
+ )
203
+ return None
204
+
205
+
206
+ def mode_overlay_denial_reason(classified: ClassifiedToolCall, context: PermissionContext) -> str | None:
207
+ if context.interaction_mode != "plan":
208
+ return None
209
+ if classified.capability in {
210
+ PermissionCapability.FILE_WRITE,
211
+ PermissionCapability.FILE_FORMAT,
212
+ PermissionCapability.BASH_WRITE,
213
+ }:
214
+ return f"BLOCKED by plan mode: '{classified.name}' is not allowed."
215
+ if classified.capability == PermissionCapability.AGENT_IMPLEMENT:
216
+ return "BLOCKED by plan mode: cannot delegate to implement."
217
+ return None
218
+
219
+
220
+ def session_action_for_tool(tool: str, context: PermissionContext) -> Action | None:
221
+ if any(_session_rule_matches(tool, rule) for rule in context.session_deny):
222
+ return "deny"
223
+ if any(_session_rule_matches(tool, rule) for rule in context.session_allow):
224
+ return "allow"
225
+ return None
226
+
227
+
228
+ def strategy_action_for_tool(classified: ClassifiedToolCall, context: PermissionContext) -> Action:
229
+ if context.permission_mode == PermissionMode.ACCEPT_EDITS.value and classified.capability in {
230
+ PermissionCapability.FILE_WRITE,
231
+ PermissionCapability.FILE_FORMAT,
232
+ }:
233
+ return "allow"
234
+ if classified.capability == PermissionCapability.BASH_READ:
235
+ return "allow"
236
+ return evaluate(classified.name, classified.pattern, BASIC_RULES).action
237
+
238
+
239
+ def resolve_approval(classified: ClassifiedToolCall, context: PermissionContext) -> PermissionDecision:
240
+ policy = context.approval_policy
241
+ if policy in {ApprovalPolicy.NEVER.value, ApprovalPolicy.ON_REQUEST.value}:
242
+ return _decision(classified, "allow", "approval_policy", _reason_for(classified, "allow"))
243
+
244
+ if policy == ApprovalPolicy.ON_FAILURE.value:
245
+ if classified.capability == PermissionCapability.BASH_WRITE:
246
+ return _decision(classified, "ask", "approval_policy", _reason_for(classified, "ask"))
247
+ return _decision(
248
+ classified,
249
+ "allow",
250
+ "approval_policy",
251
+ _reason_for(classified, "allow"),
252
+ failure_check=True,
253
+ )
254
+
255
+ if context.approval_reviewer == ApprovalReviewer.AUTO_REVIEW.value:
256
+ if classified.capability == PermissionCapability.BASH_WRITE:
257
+ return _decision(classified, "ask", "auto_review", _reason_for(classified, "ask"))
258
+ return _decision(classified, "allow", "auto_review", _reason_for(classified, "allow"))
259
+
260
+ return _decision(classified, "ask", "strategy", _reason_for(classified, "ask"))
261
+
262
+
263
+ def repair_tool_name(tool: str) -> str:
264
+ tool_map = {
265
+ "Read": "read", "Write": "write", "Edit": "edit",
266
+ "MultiEdit": "edit", "multiEdit": "edit", "multi_edit": "edit",
267
+ "Glob": "glob", "Grep": "grep", "Bash": "bash",
268
+ "Agent": "agent", "TodoWrite": "todo", "Todo": "todo",
269
+ "WebFetch": "webfetch", "WebSearch": "websearch",
270
+ "read_file": "read", "write_file": "write",
271
+ "edit_file": "edit", "shell": "bash",
272
+ "readfile": "read", "writefile": "write",
273
+ "search": "grep", "find": "glob",
274
+ "RepoMap": "repo_map", "repomap": "repo_map", "Repo_map": "repo_map",
275
+ "LspDiagnostics": "lsp_diagnostics", "LspSymbols": "lsp_symbols",
276
+ "LspDefinition": "lsp_definition", "LspReferences": "lsp_references",
277
+ "LspFormat": "lsp_format",
278
+ }
279
+ return tool_map.get(tool, tool_map.get(tool.lower(), tool))
280
+
281
+
282
+ def build_pattern(tool: str, args: dict) -> str:
283
+ if tool == "bash":
284
+ return str(args.get("command", "*"))
285
+ if tool in _FILE_PATTERN_TOOLS:
286
+ return str(args.get("file_path", "*"))
287
+ if tool == "agent":
288
+ return delegated_agent(args) or "*"
289
+ return "*"
290
+
291
+
292
+ def delegated_agent(args: dict) -> str:
293
+ return str(args.get("agent") or "")
294
+
295
+
296
+ def is_safe_bash(command: str) -> bool:
297
+ stripped = command.strip()
298
+ if not stripped or stripped.startswith("#"):
299
+ return True
300
+ if re.search(r" > ", stripped) or re.search(r" >> ", stripped):
301
+ return False
302
+ if re.search(r"\|\s*tee\b", stripped):
303
+ return False
304
+
305
+ words = stripped.split()
306
+ prog = words[0].lower()
307
+
308
+ if prog == "git" and len(words) > 1:
309
+ sub = words[1]
310
+ read_only_git = {
311
+ "status", "log", "diff", "show", "blame", "rev-parse", "rev-list",
312
+ "ls-files", "ls-tree", "describe", "shortlog", "reflog", "cherry",
313
+ "whatchanged", "notes", "grep", "bisect",
314
+ "config", "stash", "branch", "tag", "remote", "worktree",
315
+ }
316
+ if sub not in read_only_git:
317
+ return False
318
+ if sub == "stash":
319
+ return len(words) > 2 and words[2] in ("list", "show")
320
+ if sub == "bisect":
321
+ return len(words) > 2 and words[2] in ("log", "view", "visualize")
322
+ if sub in ("branch", "tag"):
323
+ return "-d" not in words and "-D" not in words
324
+ if sub == "remote":
325
+ return "-v" in words or "--verbose" in words or len(words) == 2
326
+ if sub == "worktree":
327
+ return len(words) > 2 and words[2] == "list"
328
+ return True
329
+
330
+ if prog == "gh" and len(words) > 1:
331
+ sub = words[1]
332
+ if sub == "pr":
333
+ return len(words) > 2 and words[2] in ("view", "list", "status", "checks", "diff")
334
+ if sub == "issue":
335
+ return len(words) > 2 and words[2] in ("view", "list", "status")
336
+ if sub == "api":
337
+ cmd_upper = stripped.upper()
338
+ if "-X" in cmd_upper or "--METHOD" in cmd_upper:
339
+ return "GET" in cmd_upper
340
+ return True
341
+ if sub in ("auth", "config", "completion", "secret"):
342
+ return len(words) == 2 or (len(words) > 2 and words[2] in ("list", "status", "view"))
343
+ return False
344
+
345
+ read_only = {
346
+ "ls", "dir", "cat", "head", "tail", "wc", "which", "where", "whereis",
347
+ "echo", "printf", "pwd", "date", "whoami", "uname", "env", "printenv",
348
+ "df", "du", "sort", "uniq", "cut", "tr", "column", "less", "more",
349
+ "find", "grep", "egrep", "fgrep", "rg", "file", "stat", "od",
350
+ "true", "false", "test", "[", "type", "basename", "dirname",
351
+ "realpath", "readlink", "hostname", "id", "groups", "logname",
352
+ "uptime", "free", "swapon", "lscpu", "lsblk", "lspci", "lsusb",
353
+ }
354
+ if prog in read_only:
355
+ return True
356
+
357
+ if prog in ("pip", "pip3") and len(words) > 1:
358
+ return words[1] in ("list", "show", "freeze", "config", "cache")
359
+ if prog in ("npm", "npx") and len(words) > 1:
360
+ return words[1] in ("list", "ls", "view", "info", "outdated")
361
+ if prog == "cargo" and len(words) > 1:
362
+ return words[1] in ("search", "doc", "readme")
363
+ if prog == "go" and len(words) > 1:
364
+ return words[1] in ("list", "doc", "version", "env")
365
+
366
+ return False
367
+
368
+
369
+ def _capability(tool: str, args: dict) -> PermissionCapability:
370
+ if tool in {
371
+ "read", "glob", "grep", "webfetch", "websearch", "todo", "task_status",
372
+ "repo_map", "lsp_diagnostics", "lsp_symbols", "lsp_definition",
373
+ "lsp_references",
374
+ }:
375
+ return PermissionCapability.READ_TOOLS
376
+ if tool in {"write", "edit"}:
377
+ return PermissionCapability.FILE_WRITE
378
+ if tool == "lsp_format":
379
+ return PermissionCapability.FILE_FORMAT
380
+ if tool == "bash":
381
+ return PermissionCapability.BASH_READ if is_safe_bash(str(args.get("command", ""))) else PermissionCapability.BASH_WRITE
382
+ if tool == "agent":
383
+ return PermissionCapability.AGENT_IMPLEMENT if delegated_agent(args) == "implement" else PermissionCapability.AGENT_READONLY
384
+ if tool.startswith("mcp__") or tool.startswith("mcp/"):
385
+ return PermissionCapability.MCP_TOOLS
386
+ return PermissionCapability.OTHER
387
+
388
+
389
+ def _session_rule_matches(tool: str, rule: str) -> bool:
390
+ if wildcard_match(tool, rule):
391
+ return True
392
+ if rule.startswith("mcp/"):
393
+ return wildcard_match(tool, rule.replace("/", "__"))
394
+ return False
395
+
396
+
397
+ def _decision(
398
+ classified: ClassifiedToolCall,
399
+ action: Action,
400
+ source: str,
401
+ reason: str = "",
402
+ *,
403
+ failure_check: bool = False,
404
+ ) -> PermissionDecision:
405
+ return PermissionDecision(
406
+ action=action,
407
+ tool_call=classified.tool_call,
408
+ name=classified.name,
409
+ args=classified.args,
410
+ pattern=classified.pattern,
411
+ capability=classified.capability,
412
+ source=source,
413
+ reason=reason,
414
+ failure_check=failure_check,
415
+ )
416
+
417
+
418
+ def _reason_for(classified: ClassifiedToolCall, action: Action) -> str:
419
+ if action == "deny":
420
+ return f"Permission denied: {classified.name} → {classified.pattern}"
421
+ if action == "allow":
422
+ return f"Permission allowed: {classified.name} → {classified.pattern}"
423
+ return f"Permission required: {classified.name} → {classified.pattern}"
424
+
425
+
426
+ _FILE_PATTERN_TOOLS = {
427
+ "read", "write", "edit",
428
+ "lsp_diagnostics", "lsp_symbols", "lsp_definition",
429
+ "lsp_references", "lsp_format",
430
+ }
@@ -0,0 +1,114 @@
1
+ """Permission evaluation — core algorithm, config parsing, rule merging.
2
+
3
+ Aligned with opencode/core/permission.ts:
4
+ - evaluate(): findLast matching rule across merged rulesets
5
+ - from_config(): YAML-like dict → Ruleset
6
+ - merge(): concatenate rulesets
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from pathlib import Path
13
+
14
+ from voidx.permission.schema import Action, Rule, Ruleset
15
+ from voidx.permission.wildcard import match as wildcard_match
16
+
17
+
18
+ def evaluate(permission: str, pattern: str, *rulesets: Ruleset) -> Rule:
19
+ """Evaluate permission: find the last matching rule across all rulesets.
20
+
21
+ Later rulesets override earlier ones (merge order).
22
+ Within a ruleset, later rules override earlier ones (findLast).
23
+
24
+ Returns a Rule with action="ask" if no rule matches (default-deny-lite).
25
+ """
26
+ all_rules: list[Rule] = []
27
+ for rs in rulesets:
28
+ all_rules.extend(rs)
29
+
30
+ for rule in reversed(all_rules):
31
+ if wildcard_match(permission, rule.permission) and wildcard_match(pattern, rule.pattern):
32
+ return rule
33
+
34
+ # Default: ask if no rule matches
35
+ return Rule(permission=permission, pattern=pattern, action="ask")
36
+
37
+
38
+ def from_config(config: dict) -> Ruleset:
39
+ """Convert a nested dictionary config to a flat Ruleset.
40
+
41
+ Config format (matches opencode ConfigPermission.Info):
42
+ {
43
+ "*": "allow", → Rule("*", "*", "allow")
44
+ "bash": {"git push*": "ask"}, → Rule("bash", "git push*", "ask")
45
+ "read": {"*": "allow", "*.env": "ask"}, → Rule("read", "*", "allow"), Rule("read", "*.env", "ask")
46
+ "external_directory": {"*": "ask", "~/.voidx/*": "allow"},
47
+ "edit": "deny", → Rule("edit", "*", "deny")
48
+ }
49
+ """
50
+ ruleset: Ruleset = []
51
+ for key, value in config.items():
52
+ if isinstance(value, str):
53
+ # Simple: "tool": "action"
54
+ ruleset.append(Rule(permission=key, pattern="*", action=_parse_action(value)))
55
+ elif isinstance(value, dict):
56
+ # Nested: "tool": {"pattern": "action", ...}
57
+ for pattern, action in value.items():
58
+ ruleset.append(Rule(
59
+ permission=key,
60
+ pattern=_expand_path(pattern),
61
+ action=_parse_action(action),
62
+ ))
63
+ elif isinstance(value, list):
64
+ for item in value:
65
+ if isinstance(item, str):
66
+ ruleset.append(Rule(permission=key, pattern="*", action=_parse_action(item)))
67
+ elif isinstance(item, dict):
68
+ ruleset.append(Rule(
69
+ permission=key,
70
+ pattern=_expand_path(item.get("pattern", "*")),
71
+ action=_parse_action(item.get("action", "ask")),
72
+ ))
73
+ return ruleset
74
+
75
+
76
+ def merge(*rulesets: Ruleset) -> Ruleset:
77
+ """Merge multiple rulesets. Later ones override earlier ones."""
78
+ result: Ruleset = []
79
+ for rs in rulesets:
80
+ result.extend(rs)
81
+ return result
82
+
83
+
84
+ def disabled_tools(all_tools: list[str], ruleset: Ruleset) -> set[str]:
85
+ """Find which tools are completely disabled (denied with pattern="*")."""
86
+ EDIT_TOOLS = {"edit", "write", "apply_patch"}
87
+ disabled: set[str] = set()
88
+ for tool in all_tools:
89
+ permission = "edit" if tool in EDIT_TOOLS else tool
90
+ rule = evaluate(permission, "*", ruleset)
91
+ if rule.action == "deny" and rule.pattern == "*":
92
+ disabled.add(tool)
93
+ return disabled
94
+
95
+
96
+ def _parse_action(value: str | bool) -> Action:
97
+ if isinstance(value, bool):
98
+ return "allow" if value else "deny"
99
+ if value in ("allow", "deny", "ask"):
100
+ return value # type: ignore[return-value]
101
+ raise ValueError(f"Invalid permission action: {value}")
102
+
103
+
104
+ def _expand_path(pattern: str) -> str:
105
+ """Expand ~ and $HOME in path patterns."""
106
+ if pattern.startswith("~/"):
107
+ return str(Path.home() / pattern[2:])
108
+ if pattern == "~":
109
+ return str(Path.home())
110
+ if pattern.startswith("$HOME/"):
111
+ return str(Path.home() / pattern[6:])
112
+ if pattern.startswith("$HOME"):
113
+ return str(Path.home() / pattern[5:])
114
+ return pattern