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.
- voidx/__init__.py +3 -0
- voidx/agent/__init__.py +0 -0
- voidx/agent/agents.py +439 -0
- voidx/agent/attachments.py +235 -0
- voidx/agent/graph.py +463 -0
- voidx/agent/graph_components/__init__.py +1 -0
- voidx/agent/graph_components/compaction.py +268 -0
- voidx/agent/graph_components/permissions.py +139 -0
- voidx/agent/graph_components/run_loop.py +532 -0
- voidx/agent/graph_components/runtime.py +14 -0
- voidx/agent/graph_components/streaming.py +351 -0
- voidx/agent/graph_components/subagent.py +278 -0
- voidx/agent/graph_components/tool_execution.py +208 -0
- voidx/agent/runtime_context.py +368 -0
- voidx/agent/slash.py +466 -0
- voidx/agent/slash_components/__init__.py +1 -0
- voidx/agent/slash_components/code_ide.py +68 -0
- voidx/agent/slash_components/lsp.py +105 -0
- voidx/agent/slash_components/mcp.py +332 -0
- voidx/agent/slash_components/model.py +419 -0
- voidx/agent/slash_components/runtime.py +55 -0
- voidx/agent/slash_components/skills.py +94 -0
- voidx/agent/state.py +32 -0
- voidx/agent/task_state.py +278 -0
- voidx/agent/tool_filters.py +27 -0
- voidx/config.py +707 -0
- voidx/llm/__init__.py +0 -0
- voidx/llm/catalog.py +188 -0
- voidx/llm/compaction.py +267 -0
- voidx/llm/context.py +43 -0
- voidx/llm/instruction.py +220 -0
- voidx/llm/provider.py +312 -0
- voidx/llm/usage.py +341 -0
- voidx/lsp/__init__.py +30 -0
- voidx/lsp/client.py +259 -0
- voidx/lsp/config.py +172 -0
- voidx/lsp/detector.py +512 -0
- voidx/lsp/errors.py +19 -0
- voidx/lsp/manager.py +280 -0
- voidx/lsp/schema.py +179 -0
- voidx/lsp/service.py +103 -0
- voidx/main.py +154 -0
- voidx/mcp/__init__.py +33 -0
- voidx/mcp/client.py +458 -0
- voidx/mcp/manager.py +267 -0
- voidx/mcp/schema.py +112 -0
- voidx/mcp/tool.py +122 -0
- voidx/mcp_servers/__init__.py +1 -0
- voidx/mcp_servers/web.py +104 -0
- voidx/memory/__init__.py +0 -0
- voidx/memory/context_frames.py +188 -0
- voidx/memory/model_profiles.py +98 -0
- voidx/memory/runtime_state.py +240 -0
- voidx/memory/session.py +272 -0
- voidx/memory/store.py +245 -0
- voidx/memory/transcript.py +137 -0
- voidx/permission/__init__.py +28 -0
- voidx/permission/engine.py +430 -0
- voidx/permission/evaluate.py +114 -0
- voidx/permission/sandbox.py +280 -0
- voidx/permission/schema.py +24 -0
- voidx/permission/service.py +314 -0
- voidx/permission/wildcard.py +34 -0
- voidx/skills/__init__.py +18 -0
- voidx/skills/bundled/superpowers/receiving-code-review/SKILL.md +30 -0
- voidx/skills/bundled/superpowers/requesting-code-review/SKILL.md +27 -0
- voidx/skills/bundled/superpowers/systematic-debugging/SKILL.md +36 -0
- voidx/skills/bundled/superpowers/test-driven-development/SKILL.md +33 -0
- voidx/skills/bundled/superpowers/verification-before-completion/SKILL.md +31 -0
- voidx/skills/bundled/superpowers/writing-plans/SKILL.md +27 -0
- voidx/skills/policy.py +97 -0
- voidx/skills/registry.py +162 -0
- voidx/skills/schema.py +47 -0
- voidx/skills/service.py +199 -0
- voidx/tools/__init__.py +0 -0
- voidx/tools/agent.py +81 -0
- voidx/tools/base.py +86 -0
- voidx/tools/bash.py +105 -0
- voidx/tools/file_ops.py +193 -0
- voidx/tools/lsp.py +155 -0
- voidx/tools/registry.py +104 -0
- voidx/tools/repomap.py +238 -0
- voidx/tools/search.py +162 -0
- voidx/tools/task_status.py +57 -0
- voidx/tools/task_tracker.py +81 -0
- voidx/tools/todo.py +82 -0
- voidx/tools/web_content.py +357 -0
- voidx/tools/web_mcp.py +107 -0
- voidx/tools/webfetch.py +155 -0
- voidx/tools/websearch.py +276 -0
- voidx/ui/__init__.py +0 -0
- voidx/ui/app.py +1033 -0
- voidx/ui/app_components/__init__.py +1 -0
- voidx/ui/app_components/clipboard_image.py +245 -0
- voidx/ui/app_components/commands.py +18 -0
- voidx/ui/app_components/controls.py +29 -0
- voidx/ui/app_components/file_picker.py +115 -0
- voidx/ui/app_components/formatting.py +187 -0
- voidx/ui/app_components/git_changes.py +51 -0
- voidx/ui/app_components/rendering.py +1169 -0
- voidx/ui/browse.py +160 -0
- voidx/ui/capture.py +169 -0
- voidx/ui/code_ide.py +251 -0
- voidx/ui/commands.py +83 -0
- voidx/ui/console.py +381 -0
- voidx/ui/console_components/__init__.py +1 -0
- voidx/ui/console_components/formatting.py +96 -0
- voidx/ui/console_components/streaming.py +253 -0
- voidx/ui/diff.py +331 -0
- voidx/ui/dock.py +372 -0
- voidx/ui/dock_components/__init__.py +1 -0
- voidx/ui/dock_components/formatting.py +123 -0
- voidx/ui/dock_components/nodes.py +401 -0
- voidx/ui/dock_components/state.py +51 -0
- voidx/ui/event_components/__init__.py +1 -0
- voidx/ui/event_components/schema.py +249 -0
- voidx/ui/events.py +341 -0
- voidx/ui/session_changes.py +163 -0
- voidx/ui/startup.py +161 -0
- voidx/ui/transcript.py +148 -0
- voidx/ui/tree.py +316 -0
- voidx-1.0.0.dist-info/METADATA +59 -0
- voidx-1.0.0.dist-info/RECORD +126 -0
- voidx-1.0.0.dist-info/WHEEL +5 -0
- voidx-1.0.0.dist-info/entry_points.txt +2 -0
- 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))
|
voidx/skills/__init__.py
ADDED
|
@@ -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
|
+
]
|