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,280 @@
1
+ """Sandbox mode path validation — filesystem boundary enforcement."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import shlex
7
+ from pathlib import Path
8
+
9
+
10
+ def _allowed(
11
+ path: str,
12
+ workspace: str,
13
+ extra_paths: list[str],
14
+ current_dir: Path | None = None,
15
+ ) -> bool:
16
+ """Check if the resolved path is inside workspace or extra_paths."""
17
+ try:
18
+ raw = Path(path).expanduser()
19
+ if raw.is_absolute():
20
+ resolved = raw.resolve()
21
+ else:
22
+ base = current_dir if current_dir is not None else Path(workspace)
23
+ resolved = (base / raw).resolve()
24
+ except (OSError, ValueError):
25
+ return True # unresolvable → don't block, let the tool report errors
26
+
27
+ allowed = [Path(workspace).resolve()]
28
+ for ep in extra_paths:
29
+ allowed.append(Path(ep).expanduser().resolve())
30
+
31
+ for base in allowed:
32
+ try:
33
+ if resolved == base or resolved.is_relative_to(base):
34
+ return True
35
+ except (ValueError, OSError):
36
+ continue
37
+ return False
38
+
39
+
40
+ def check_sandbox_filepath(
41
+ file_path: str,
42
+ workspace: str,
43
+ extra_paths: list[str],
44
+ ) -> str | None:
45
+ """Validate that file_path is inside the allowed workspace + extra_paths.
46
+
47
+ Returns None if the path is allowed, or a human-readable rejection reason.
48
+ Invalid / unresolvable paths are NOT blocked (let the tool itself report
49
+ the error).
50
+ """
51
+ if _allowed(file_path, workspace, extra_paths):
52
+ return None
53
+
54
+ return (
55
+ f"SANDBOX: '{file_path}' is outside the allowed workspace.\n"
56
+ f" Allowed: {workspace}"
57
+ + (f" + {extra_paths}" if extra_paths else "")
58
+ )
59
+
60
+
61
+ # ── bash command write-target extraction ──────────────────────────────
62
+
63
+ # Patterns that extract write targets from common bash idioms.
64
+ # Each captures the target path in group 1.
65
+ _REDIR_PATTERNS = [
66
+ # standard redirect: cmd > /path/to/file, cmd >> /path/to/file
67
+ re.compile(r"\d?\s*>>?\s*(\S+)"),
68
+ # tee: cmd | tee /path/to/file, cmd | tee -a /path/to/file
69
+ re.compile(r"\|\s*tee(?:\s+-a)?\s+(\S+)"),
70
+ # dd of=/dev/… (already handled by _BLOCKED in bash.py, but belt-and-suspenders)
71
+ re.compile(r"\bof=(\S+)"),
72
+ ]
73
+
74
+ # Destructive filesystem commands whose arguments are potential write targets.
75
+ _FS_WRITE_COMMANDS = {
76
+ "rm": 1, # rm target
77
+ "cp": -1, # cp src… dst (last arg is destination)
78
+ "mv": -1, # mv src… dst (last arg is destination)
79
+ "ln": -1, # ln [-s] src… dst (last arg is destination)
80
+ "mkdir": 1, # mkdir dir
81
+ "touch": 1, # touch file
82
+ "install": -1, # install src… dst
83
+ "tee": 1, # tee [-a] file…
84
+ "chmod": 0, # chmod doesn't change filesystem layout; skip
85
+ "chown": 0, # chown doesn't change filesystem layout; skip
86
+ "chgrp": 0, # chgrp doesn't change filesystem layout; skip
87
+ }
88
+
89
+ # Git operations that write outside the repo (push to remote).
90
+ # These can't be checked by path inspection, so we only flag force-push
91
+ # which is already blocked by _BLOCKED in bash.py.
92
+
93
+
94
+ def check_sandbox_bash(
95
+ command: str,
96
+ workspace: str,
97
+ extra_paths: list[str],
98
+ ) -> str | None:
99
+ """Validate that a bash command's write targets are within the sandbox.
100
+
101
+ Extracts redirect targets, tee targets, and destructive file ops.
102
+ Returns None if all targets are safe, or a rejection reason.
103
+
104
+ This is a *best-effort* check — sophisticated command obfuscation can
105
+ bypass it. It is designed to catch honest mistakes, not adversarial
106
+ attacks. The hard blocklist in bash.py (_BLOCKED) covers the really
107
+ dangerous stuff.
108
+ """
109
+ stripped = command.strip()
110
+ if not stripped or stripped.startswith("#"):
111
+ return None
112
+
113
+ write_targets: list[str] = []
114
+
115
+ # ── redirections (> / >> / | tee) ────────────────────────────────
116
+ for pattern in _REDIR_PATTERNS:
117
+ for m in pattern.finditer(stripped):
118
+ target = m.group(1)
119
+ if target and not target.startswith("&") and not target.startswith("/dev/"):
120
+ write_targets.append(target)
121
+
122
+ # ── destructive commands (rm/cp/mv/…) ─────────────────────────────
123
+ words = _shell_words(stripped)
124
+ prog = _program(words).lower() if words else ""
125
+ segment_targets = _extract_segment_targets(words, workspace)
126
+ write_targets.extend(segment_targets)
127
+
128
+ # ── git push remote (non-force is blocked by sandbox if workspace-write) ──
129
+ if _is_git_push_outside(prog, words, workspace, extra_paths):
130
+ return (
131
+ f"SANDBOX: git push writes outside the allowed workspace."
132
+ f"\n Allowed: {workspace}"
133
+ + (f" + {extra_paths}" if extra_paths else "")
134
+ + "\n Use /sandbox danger-full-access to allow git push."
135
+ )
136
+
137
+ # ── check each target ────────────────────────────────────────────
138
+ blocked_targets: list[str] = []
139
+ for target in write_targets:
140
+ # Remove surrounding quotes if present
141
+ clean = target.strip('"').strip("'")
142
+ if not clean or clean.startswith("/dev/"):
143
+ continue
144
+ if not _allowed(clean, workspace, extra_paths):
145
+ blocked_targets.append(target)
146
+
147
+ if blocked_targets:
148
+ return (
149
+ f"SANDBOX: bash command writes outside the allowed workspace.\n"
150
+ f" Targets: {', '.join(blocked_targets[:5])}\n"
151
+ f" Allowed: {workspace}"
152
+ + (f" + {extra_paths}" if extra_paths else "")
153
+ )
154
+
155
+ return None
156
+
157
+
158
+ def _shell_words(command: str) -> list[str]:
159
+ try:
160
+ lexer = shlex.shlex(command, posix=True, punctuation_chars=True)
161
+ lexer.whitespace_split = True
162
+ return list(lexer)
163
+ except ValueError:
164
+ return command.split()
165
+
166
+
167
+ def _extract_segment_targets(words: list[str], workspace: str) -> list[str]:
168
+ targets: list[str] = []
169
+ current_dir = Path(workspace).resolve()
170
+ segment: list[str] = []
171
+ for token in [*words, ";"]:
172
+ if token in (";", "&&", "||"):
173
+ targets.extend(_extract_command_targets(segment, workspace, current_dir))
174
+ if segment and _program(segment).lower() == "cd":
175
+ next_dir = _first_non_flag_arg(segment[1:])
176
+ if next_dir:
177
+ raw = Path(next_dir).expanduser()
178
+ current_dir = (raw if raw.is_absolute() else current_dir / raw).resolve()
179
+ segment = []
180
+ continue
181
+ if token in ("|", "||", "&&"):
182
+ targets.extend(_extract_command_targets(segment, workspace, current_dir))
183
+ segment = []
184
+ continue
185
+ segment.append(token)
186
+ return targets
187
+
188
+
189
+ def _extract_command_targets(words: list[str], workspace: str, current_dir: Path) -> list[str]:
190
+ if not words:
191
+ return []
192
+ prog = _program(words).lower()
193
+ result: list[str] = []
194
+
195
+ for index, word in enumerate(words[:-1]):
196
+ if word in (">", ">>"):
197
+ result.extend(_resolve_targets([words[index + 1]], current_dir))
198
+ for word in words:
199
+ if word.startswith("of=") and len(word) > 3:
200
+ result.extend(_resolve_targets([word[3:]], current_dir))
201
+
202
+ arg_idx = _FS_WRITE_COMMANDS.get(prog)
203
+ if arg_idx is None:
204
+ return result
205
+ if arg_idx == 0:
206
+ return result
207
+
208
+ args = _program_args(words)
209
+ raw_targets: list[str] = []
210
+ if arg_idx > 0:
211
+ raw_targets = [w for w in args if not w.startswith("-")]
212
+ elif arg_idx == -1 and len(args) >= 2:
213
+ dest = args[-1]
214
+ if not dest.startswith("-"):
215
+ raw_targets = [dest]
216
+
217
+ result.extend(_resolve_targets(raw_targets, current_dir))
218
+ return result
219
+
220
+
221
+ def _resolve_targets(raw_targets: list[str], current_dir: Path) -> list[str]:
222
+ result: list[str] = []
223
+ for target in raw_targets:
224
+ clean = target.strip('"').strip("'")
225
+ if not clean or clean.startswith("&") or clean.startswith("/dev/"):
226
+ continue
227
+ path = Path(clean).expanduser()
228
+ result.append(str(path if path.is_absolute() else current_dir / path))
229
+ return result
230
+
231
+
232
+ def _program(words: list[str]) -> str:
233
+ for word in words:
234
+ if "=" in word and not word.startswith("=") and word.split("=", 1)[0].isidentifier():
235
+ continue
236
+ return word
237
+ return ""
238
+
239
+
240
+ def _program_args(words: list[str]) -> list[str]:
241
+ prog_seen = False
242
+ args: list[str] = []
243
+ for word in words:
244
+ if not prog_seen:
245
+ if "=" in word and not word.startswith("=") and word.split("=", 1)[0].isidentifier():
246
+ continue
247
+ prog_seen = True
248
+ continue
249
+ args.append(word)
250
+ return args
251
+
252
+
253
+ def _first_non_flag_arg(words: list[str]) -> str:
254
+ for word in words:
255
+ if not word.startswith("-"):
256
+ return word
257
+ return ""
258
+
259
+
260
+ def _is_git_push_outside(
261
+ prog: str,
262
+ words: list[str],
263
+ workspace: str,
264
+ extra_paths: list[str],
265
+ ) -> bool:
266
+ """Check if git push modifies something beyond the workspace.
267
+
268
+ In workspace-write mode, git push to any remote can write outside the
269
+ local filesystem. Extra local write paths do not authorize remote writes.
270
+ """
271
+ if prog != "git" or len(words) < 2:
272
+ return False
273
+ if words[1] != "push":
274
+ return False
275
+ return True
276
+
277
+
278
+ # ── export ───────────────────────────────────────────────────────────
279
+
280
+ __all__ = ["check_sandbox_filepath", "check_sandbox_bash"]
@@ -0,0 +1,24 @@
1
+ """Permission types — aligned with opencode PermissionV2."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+ Action = Literal["allow", "deny", "ask"]
10
+
11
+
12
+ class Rule(BaseModel):
13
+ """A single permission rule.
14
+
15
+ permission: tool name or "*" (matches any tool)
16
+ pattern: wildcard pattern for matching tool arguments (default "*")
17
+ action: allow | deny | ask
18
+ """
19
+ permission: str
20
+ pattern: str = "*"
21
+ action: Action = "ask"
22
+
23
+
24
+ Ruleset = list[Rule]
@@ -0,0 +1,314 @@
1
+ """Permission service — user-facing permission state and compatibility helpers.
2
+
3
+ Default rules: read-only tools are auto-allowed, write/bash/agent implement need approval.
4
+
5
+ Session whitelist: once user says "always", the tool is remembered.
6
+ Manage via /allow <tool>, /deny <tool>, /permissions commands.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ from dataclasses import dataclass
13
+
14
+ from pydantic import BaseModel
15
+
16
+ from voidx.config import ApprovalReviewer, PermissionMode, permission_mode_defaults, permission_mode_reviewer_default
17
+ from voidx.permission.engine import (
18
+ BASIC_RULES,
19
+ PermissionContext,
20
+ authorize_tool_call,
21
+ classify_tool_call,
22
+ sandbox_denial_reason,
23
+ tool_call_from_pattern,
24
+ )
25
+ from voidx.permission.schema import Action
26
+ from voidx.ui.console import VoidConsole
27
+
28
+ ui = VoidConsole()
29
+
30
+
31
+ DEFAULT_RULES = BASIC_RULES
32
+
33
+ # ── types ──────────────────────────────────────────────────────────────────
34
+
35
+ class PermissionRequest(BaseModel):
36
+ id: str
37
+ session_id: str
38
+ tool: str
39
+ patterns: list[str]
40
+ metadata: dict = {}
41
+
42
+
43
+ @dataclass
44
+ class PendingEntry:
45
+ info: PermissionRequest
46
+ future: asyncio.Future
47
+
48
+
49
+ class PermissionRejectedError(Exception):
50
+ def __init__(self, tool: str, pattern: str):
51
+ self.tool = tool
52
+ self.pattern = pattern
53
+ super().__init__(f"User rejected {tool} → {pattern}")
54
+
55
+
56
+ # ── service ────────────────────────────────────────────────────────────────
57
+
58
+ class PermissionService:
59
+ """Checks tool permissions with sandbox → defaults → session whitelist → ask flow."""
60
+
61
+ def __init__(
62
+ self,
63
+ permission_mode: str = "default",
64
+ sandbox_mode: str = "workspace-write",
65
+ sandbox_workspace_write: list[str] | None = None,
66
+ approval_policy: str = "untrusted",
67
+ approval_reviewer: str = "user",
68
+ ) -> None:
69
+ self._pending: dict[str, PendingEntry] = {}
70
+ # Session whitelist: tools the user has approved for this session
71
+ self._session_allow: set[str] = set()
72
+ self._session_deny: set[str] = set()
73
+ # Sandbox / approval — persistent filesystem + frequency controls
74
+ try:
75
+ parsed_mode = PermissionMode(permission_mode)
76
+ except ValueError:
77
+ parsed_mode = PermissionMode.CUSTOM
78
+ if (
79
+ parsed_mode == PermissionMode.DEFAULT
80
+ and (
81
+ sandbox_mode != "workspace-write"
82
+ or approval_policy != "untrusted"
83
+ or approval_reviewer != "user"
84
+ )
85
+ ):
86
+ parsed_mode = PermissionMode.CUSTOM
87
+ self.permission_mode = parsed_mode.value
88
+ if parsed_mode == PermissionMode.CUSTOM:
89
+ self.sandbox_mode = sandbox_mode
90
+ self.approval_policy = approval_policy
91
+ self.approval_reviewer = approval_reviewer
92
+ else:
93
+ mode_sandbox, mode_approval = permission_mode_defaults(parsed_mode)
94
+ self.sandbox_mode = mode_sandbox.value
95
+ self.approval_policy = mode_approval.value
96
+ self.approval_reviewer = permission_mode_reviewer_default(parsed_mode).value
97
+ self.sandbox_workspace_write = sandbox_workspace_write or []
98
+
99
+ # ── session whitelist management ─────────────────────────────────────
100
+
101
+ def allow(self, tool: str) -> None:
102
+ """Pre-approve a tool for the entire session. No more prompts."""
103
+ self._session_allow.add(tool)
104
+ self._session_deny.discard(tool)
105
+ ui.print(f"[dim]✓ {tool} now allowed for this session[/dim]")
106
+
107
+ def allow_silent(self, tool: str) -> None:
108
+ """Pre-approve a tool for the session without UI noise."""
109
+ self._session_allow.add(tool)
110
+ self._session_deny.discard(tool)
111
+
112
+ def deny(self, tool: str) -> None:
113
+ """Block a tool for the entire session."""
114
+ self._session_deny.add(tool)
115
+ self._session_allow.discard(tool)
116
+ ui.print(f"[dim]✗ {tool} now denied for this session[/dim]")
117
+
118
+ def deny_silent(self, tool: str) -> None:
119
+ """Block a tool for the session without UI noise."""
120
+ self._session_deny.add(tool)
121
+ self._session_allow.discard(tool)
122
+
123
+ def status_label(self) -> str:
124
+ if not self._session_allow and not self._session_deny:
125
+ return self.permission_mode_label()
126
+ parts: list[str] = [self.permission_mode_label()]
127
+ if self._session_allow:
128
+ parts.append(f"+{len(self._session_allow)}")
129
+ if self._session_deny:
130
+ parts.append(f"-{len(self._session_deny)}")
131
+ return " ".join(parts)
132
+
133
+ def set_permission_mode(self, mode: str) -> None:
134
+ try:
135
+ parsed = PermissionMode(mode)
136
+ except ValueError:
137
+ parsed = PermissionMode.CUSTOM
138
+ self.permission_mode = parsed.value
139
+ if parsed == PermissionMode.CUSTOM:
140
+ return
141
+ sandbox_mode, approval_policy = permission_mode_defaults(parsed)
142
+ self.sandbox_mode = sandbox_mode.value
143
+ self.approval_policy = approval_policy.value
144
+ self.approval_reviewer = permission_mode_reviewer_default(parsed).value
145
+ self.sandbox_workspace_write = []
146
+
147
+ def mark_custom_mode(self) -> None:
148
+ self.permission_mode = PermissionMode.CUSTOM.value
149
+
150
+ def permission_mode_label(self) -> str:
151
+ labels = {
152
+ PermissionMode.DEFAULT.value: "Default",
153
+ PermissionMode.READ_ONLY.value: "Read only",
154
+ PermissionMode.ACCEPT_EDITS.value: "Accept edits",
155
+ PermissionMode.AUTO_REVIEW.value: "Auto review",
156
+ PermissionMode.FULL_ACCESS.value: "Full access",
157
+ PermissionMode.CUSTOM.value: "Custom",
158
+ }
159
+ return labels.get(self.permission_mode, "Custom")
160
+
161
+ def status_details(self) -> tuple[str, str, str]:
162
+ """Return (sandbox_label, approval_label, session_label) for UI."""
163
+ return (
164
+ self._sandbox_label(),
165
+ self._approval_label(),
166
+ self._session_short(),
167
+ )
168
+
169
+ def _sandbox_label(self) -> str:
170
+ if self.sandbox_mode == "read-only":
171
+ return "r-o"
172
+ if self.sandbox_mode == "workspace-write":
173
+ return "w-write"
174
+ return "danger"
175
+
176
+ def _sandbox_short(self) -> str:
177
+ labels = {
178
+ "read-only": "r-o",
179
+ "workspace-write": "w-write",
180
+ "danger-full-access": "danger",
181
+ }
182
+ return labels.get(self.sandbox_mode, self.sandbox_mode)
183
+
184
+ def _approval_label(self) -> str:
185
+ labels = {
186
+ "untrusted": "ask",
187
+ "on-failure": "on-fail",
188
+ "on-request": "on-req",
189
+ "never": "auto",
190
+ }
191
+ return labels.get(self.approval_policy, self.approval_policy)
192
+
193
+ def _reviewer_label(self) -> str:
194
+ labels = {
195
+ ApprovalReviewer.USER.value: "user",
196
+ ApprovalReviewer.AUTO_REVIEW.value: "reviewer",
197
+ }
198
+ return labels.get(self.approval_reviewer, self.approval_reviewer)
199
+
200
+ def _session_short(self) -> str:
201
+ if self._session_allow and not self._session_deny:
202
+ return f"+{len(self._session_allow)}"
203
+ if self._session_deny and not self._session_allow:
204
+ return f"-{len(self._session_deny)}"
205
+ if self._session_allow and self._session_deny:
206
+ return f"+{len(self._session_allow)}/-{len(self._session_deny)}"
207
+ return "default"
208
+
209
+ def show_rules(self) -> str:
210
+ """Format current sandbox, approval, and session rules."""
211
+ lines = ["[bold]Session permissions:[/bold]"]
212
+ lines.append(f" Mode: [cyan]{self.permission_mode_label()}[/cyan] ({self.permission_mode})")
213
+ lines.append(
214
+ f" Sandbox: [cyan]{self.sandbox_mode}[/cyan] "
215
+ f"Approval: [cyan]{self.approval_policy}[/cyan] "
216
+ f"Reviewer: [cyan]{self.approval_reviewer}[/cyan]"
217
+ )
218
+ if self.sandbox_workspace_write:
219
+ lines.append(f" Extra write paths: [dim]{', '.join(self.sandbox_workspace_write)}[/dim]")
220
+ lines.append(" [green]Always allowed:[/green] read, glob, grep, webfetch, websearch, todo, task_status, repo_map, lsp read tools, read-only agents, read-only bash")
221
+ if self._session_allow:
222
+ lines.append(f" [green]Session allow:[/green] {', '.join(sorted(self._session_allow))}")
223
+ if self._session_deny:
224
+ lines.append(f" [red]Session deny:[/red] {', '.join(sorted(self._session_deny))}")
225
+ lines.append(" [yellow]Ask first:[/yellow] write, edit, write-capable bash, lsp_format, agent=implement, mcp__*")
226
+ lines.append("")
227
+ lines.append(" Commands: /permission-mode /allow <tool> /deny <tool> /sandbox [r-o|w-write|danger] /approval [ask|on-fail|auto]")
228
+ return "\n".join(lines)
229
+
230
+ # ── sandbox ──────────────────────────────────────────────────────────
231
+
232
+ def check_sandbox(self, tool_name: str, args: dict, workspace: str) -> str | None:
233
+ """Sandbox layer: filesystem boundary enforcement.
234
+
235
+ Returns None if the tool call is allowed, or a human-readable
236
+ rejection reason. Always returns None under danger-full-access.
237
+ """
238
+ if self.sandbox_mode == "danger-full-access":
239
+ return None
240
+
241
+ context = self._context(workspace=workspace)
242
+ return sandbox_denial_reason(
243
+ classify_tool_call({"name": tool_name, "args": args}),
244
+ context,
245
+ )
246
+
247
+ # ── check ────────────────────────────────────────────────────────────
248
+
249
+ async def check(
250
+ self,
251
+ tool: str,
252
+ pattern: str = "*",
253
+ session_id: str = "default",
254
+ ) -> Action:
255
+ """Check permission. Evaluates: defaults → session whitelist → ask.
256
+
257
+ Returns "allow" (proceed) or "deny" (block silently).
258
+ Raises PermissionRejectedError if user rejects the interactive ask.
259
+ """
260
+ action = self.decide(tool, pattern)
261
+ if action != "ask":
262
+ return action
263
+ return await self._ask_user(tool, pattern, session_id)
264
+
265
+ def decide(self, tool: str, pattern: str = "*") -> Action:
266
+ """Return the non-interactive permission decision for a tool call."""
267
+ return authorize_tool_call(tool_call_from_pattern(tool, pattern), self._context()).action
268
+
269
+ async def _ask_user(self, tool: str, pattern: str, session_id: str) -> Action:
270
+ """Interactive ask — simple text input, no raw key artifacts."""
271
+ ui.print("")
272
+ ui.print(f" [yellow]Allow [bold]{tool}[/bold] → {pattern}?[/yellow]")
273
+ ui.print(f" [a] Always [y] Yes once [n] No")
274
+ try:
275
+ choice = await asyncio.get_event_loop().run_in_executor(
276
+ None, lambda: input(" > ").strip().lower()
277
+ )
278
+ except (EOFError, KeyboardInterrupt):
279
+ return "deny"
280
+
281
+ if choice in ("a", "always"):
282
+ self._session_allow.add(tool)
283
+ ui.print(f"[dim]✓ {tool} allowed for this session[/dim]")
284
+ return "allow"
285
+ elif choice in ("y", "yes", ""):
286
+ return "allow"
287
+ else:
288
+ return "deny"
289
+
290
+ def list_pending(self) -> list[PermissionRequest]:
291
+ return [entry.info for entry in self._pending.values()]
292
+
293
+ def clear(self) -> None:
294
+ for entry in self._pending.values():
295
+ if not entry.future.done():
296
+ entry.future.set_result("deny")
297
+ self._pending.clear()
298
+
299
+ def clear_session_permissions(self) -> None:
300
+ """Reset session allow/deny whitelists."""
301
+ self._session_allow.clear()
302
+ self._session_deny.clear()
303
+
304
+ def _context(self, *, workspace: str = ".") -> PermissionContext:
305
+ return PermissionContext(
306
+ workspace=workspace,
307
+ permission_mode=self.permission_mode,
308
+ sandbox_mode=self.sandbox_mode,
309
+ sandbox_workspace_write=tuple(self.sandbox_workspace_write),
310
+ approval_policy=self.approval_policy,
311
+ approval_reviewer=self.approval_reviewer,
312
+ session_allow=frozenset(self._session_allow),
313
+ session_deny=frozenset(self._session_deny),
314
+ )
@@ -0,0 +1,34 @@
1
+ """Wildcard pattern matching — aligned with opencode Wildcard.match()."""
2
+
3
+ import re
4
+ import sys
5
+
6
+
7
+ def match(input_str: str, pattern: str) -> bool:
8
+ """Match string against wildcard pattern.
9
+
10
+ * → .*
11
+ ? → .
12
+ / ↔ \\ normalized for cross-platform
13
+
14
+ Examples:
15
+ match("bash", "*") → True
16
+ match("read", "edit") → False
17
+ match(".env", "*.env") → True
18
+ match("git push", "git *") → True
19
+ match("src/foo.py", "src/**/*.py") → True (** treated as *)
20
+ """
21
+ normalized = input_str.replace("\\", "/")
22
+
23
+ escaped = (
24
+ pattern.replace("\\", "/")
25
+ .replace("**", "*") # ** is same as * for our purposes
26
+ )
27
+
28
+ # Escape regex specials, then convert wildcards
29
+ escaped = re.escape(escaped)
30
+ escaped = escaped.replace(r"\*", ".*")
31
+ escaped = escaped.replace(r"\?", ".")
32
+
33
+ flags = re.IGNORECASE if sys.platform == "win32" else re.NOFLAG
34
+ return bool(re.match("^" + escaped + "$", normalized, flags))
@@ -0,0 +1,18 @@
1
+ """Local skill support for voidx."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from voidx.skills.registry import SkillParseError, SkillRegistry, parse_skill_file
6
+ from voidx.skills.schema import SkillDefinition, SkillMatch, SkillMeta, SkillSelectionConfig
7
+ from voidx.skills.service import SkillService
8
+
9
+ __all__ = [
10
+ "SkillDefinition",
11
+ "SkillMatch",
12
+ "SkillMeta",
13
+ "SkillParseError",
14
+ "SkillRegistry",
15
+ "SkillSelectionConfig",
16
+ "SkillService",
17
+ "parse_skill_file",
18
+ ]