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,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
|