vetter 0.2.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.
- vetter/__init__.py +3 -0
- vetter/__main__.py +5 -0
- vetter/agents/__init__.py +2 -0
- vetter/agents/hook.py +298 -0
- vetter/agents/importing.py +219 -0
- vetter/agents/install.py +214 -0
- vetter/agents/learn.py +173 -0
- vetter/agents/log.py +393 -0
- vetter/agents/written.py +131 -0
- vetter/build.py +86 -0
- vetter/check.py +38 -0
- vetter/cli.py +327 -0
- vetter/engine.py +111 -0
- vetter/gitconfig.py +141 -0
- vetter/glob.py +89 -0
- vetter/jsontext.py +128 -0
- vetter/paths.py +190 -0
- vetter/places.py +191 -0
- vetter/policy/__init__.py +4 -0
- vetter/policy/actions.py +642 -0
- vetter/policy/builtin/prelude.vet +32 -0
- vetter/policy/builtin/stock.vet +232 -0
- vetter/policy/checks.py +198 -0
- vetter/policy/language.py +1220 -0
- vetter/policy/policy.zgram +66 -0
- vetter/policy/trace.py +93 -0
- vetter/shells/__init__.py +2 -0
- vetter/shells/bash/__init__.py +2 -0
- vetter/shells/bash/arith.py +428 -0
- vetter/shells/bash/bash.zgram +159 -0
- vetter/shells/bash/expand.py +966 -0
- vetter/shells/bash/resolve.py +2210 -0
- vetter/shells/bash/scan.py +423 -0
- vetter/shells/bash/syntax.py +153 -0
- vetter/shells/cmd/__init__.py +2 -0
- vetter/shells/cmd/resolve.py +1083 -0
- vetter/shells/cmd/syntax.py +698 -0
- vetter/shells/powershell/__init__.py +2 -0
- vetter/shells/powershell/cmdlets.py +108 -0
- vetter/shells/powershell/grammar.py +237 -0
- vetter/shells/powershell/resolve.py +1536 -0
- vetter/shells/powershell/syntax.py +166 -0
- vetter/shells/powershell/values.py +547 -0
- vetter/shells/python/__init__.py +0 -0
- vetter/shells/python/python.zgram +174 -0
- vetter/shells/python/resolve.py +3273 -0
- vetter/shells/python/scan.py +186 -0
- vetter/shells/python/stdlib.py +359 -0
- vetter/shells/python/syntax.py +20 -0
- vetter/specs/README.md +112 -0
- vetter/specs/__init__.py +2257 -0
- vetter/specs/dev.toml +352 -0
- vetter/specs/env.toml +312 -0
- vetter/specs/files.toml +701 -0
- vetter/specs/git.toml +223 -0
- vetter/specs/load.py +71 -0
- vetter/specs/network.toml +110 -0
- vetter/specs/powershell-params.toml +677 -0
- vetter/specs/powershell.toml +386 -0
- vetter/specs/toolchains.toml +317 -0
- vetter/specs/windows.toml +136 -0
- vetter/toolconfig.py +206 -0
- vetter-0.2.0.dist-info/METADATA +192 -0
- vetter-0.2.0.dist-info/RECORD +68 -0
- vetter-0.2.0.dist-info/WHEEL +4 -0
- vetter-0.2.0.dist-info/entry_points.txt +2 -0
- vetter-0.2.0.dist-info/licenses/LICENSE +202 -0
- vetter-0.2.0.dist-info/licenses/NOTICE +7 -0
vetter/__init__.py
ADDED
vetter/__main__.py
ADDED
vetter/agents/hook.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Each agent's hook (docs/agents.md): the agent's request on
|
|
2
|
+
stdin (JSON, read with rt.parse), the policy found (places.py), the
|
|
3
|
+
command decided, the answer on stdout in the agent's format, as json.dumps
|
|
4
|
+
writes it. A hook never fails open by crashing: any error is an ask (a
|
|
5
|
+
deny where the agent can't ask).
|
|
6
|
+
|
|
7
|
+
run(rt, "claude", None) # stdin -> stdout, as `vetter hook claude`
|
|
8
|
+
|
|
9
|
+
An agent's answer has `default` true when no rule decided (only the
|
|
10
|
+
policy's default, or nothing to vet): where the agent has its own
|
|
11
|
+
permission rules, vetter then says nothing and they decide. Agents whose
|
|
12
|
+
hooks can't ask (Codex, Gemini, Cline's extension) get ask as deny, with
|
|
13
|
+
the reason. A decision is logged (log.py) unless the policy says `log off`.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import sys
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from os import getcwd, getenv
|
|
19
|
+
|
|
20
|
+
import zgram
|
|
21
|
+
|
|
22
|
+
from .. import paths
|
|
23
|
+
from ..check import check
|
|
24
|
+
from ..jsontext import dumps, loads
|
|
25
|
+
from ..places import home_dir, load_policy, log_path, native_shell, temp_dirs, workspace_project, written_path
|
|
26
|
+
from ..policy.language import followed, where
|
|
27
|
+
from . import log, written
|
|
28
|
+
|
|
29
|
+
CLAUDE_TOOLS = {"Bash": "bash", "PowerShell": "powershell"}
|
|
30
|
+
COPILOT_TOOLS = {"bash": "bash", "powershell": "powershell", "Bash": "bash", "PowerShell": "powershell",
|
|
31
|
+
"run_in_terminal": "", "runInTerminal": "", "terminal": ""}
|
|
32
|
+
CLINE_TOOLS = ("execute_command", "run_commands")
|
|
33
|
+
AGENTS = ("claude", "codex", "gemini", "cursor", "copilot", "cline", "amp", "opencode", "json")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Call:
|
|
38
|
+
command: object
|
|
39
|
+
cwd: object
|
|
40
|
+
shell: object
|
|
41
|
+
# (Cursor's preToolUse, Cline's CLI: answered their way)
|
|
42
|
+
mode: str
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def workspace_of(agent, request):
|
|
46
|
+
"""The folder the user opened the agent in, as the agent tells its
|
|
47
|
+
hooks: Claude Code's CLAUDE_PROJECT_DIR (Gemini's GEMINI_PROJECT_DIR),
|
|
48
|
+
Cursor's and Copilot's workspace roots; None: not told."""
|
|
49
|
+
if agent == "claude":
|
|
50
|
+
return getenv("CLAUDE_PROJECT_DIR")
|
|
51
|
+
if agent == "gemini":
|
|
52
|
+
return getenv("GEMINI_PROJECT_DIR") or getenv("CLAUDE_PROJECT_DIR")
|
|
53
|
+
roots = request.get("workspace_roots") or request.get("workspaceRoots")
|
|
54
|
+
if isinstance(roots, list) and len(roots) > 0 and isinstance(roots[0], str):
|
|
55
|
+
return roots[0]
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def cant_ask(decision, reason):
|
|
60
|
+
if decision == "ask":
|
|
61
|
+
return ("deny", reason + " (vetter would ask, and this agent's hooks can't: run it yourself if it's fine)")
|
|
62
|
+
return (decision, reason)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def command_of(tool_input):
|
|
66
|
+
if not isinstance(tool_input, dict):
|
|
67
|
+
return None
|
|
68
|
+
for key in ("command", "commands", "cmd"):
|
|
69
|
+
v = tool_input.get(key)
|
|
70
|
+
if isinstance(v, str):
|
|
71
|
+
return v
|
|
72
|
+
if isinstance(v, list):
|
|
73
|
+
strs = True
|
|
74
|
+
for x in v:
|
|
75
|
+
if not isinstance(x, str):
|
|
76
|
+
strs = False
|
|
77
|
+
if strs:
|
|
78
|
+
return "\n".join(v)
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def read(rt, agent, request):
|
|
83
|
+
"""The shell call of a request (None: not one)."""
|
|
84
|
+
if agent == "claude":
|
|
85
|
+
shell = CLAUDE_TOOLS.get(request.get("tool_name"))
|
|
86
|
+
if shell is None:
|
|
87
|
+
return None
|
|
88
|
+
return Call(command_of(request.get("tool_input")), request.get("cwd"), shell, "")
|
|
89
|
+
if agent == "codex":
|
|
90
|
+
if request.get("tool_name") != "Bash":
|
|
91
|
+
return None
|
|
92
|
+
return Call(command_of(request.get("tool_input")), request.get("cwd"), native_shell(), "")
|
|
93
|
+
if agent == "gemini":
|
|
94
|
+
if request.get("tool_name") != "run_shell_command":
|
|
95
|
+
return None
|
|
96
|
+
ti = request.get("tool_input") or {}
|
|
97
|
+
cwd = request.get("cwd")
|
|
98
|
+
d = ti.get("dir_path") if isinstance(ti, dict) else None
|
|
99
|
+
if isinstance(d, str) and d:
|
|
100
|
+
cwd = paths.join(cwd, d) if cwd else d
|
|
101
|
+
return Call(command_of(ti), cwd, native_shell(), "")
|
|
102
|
+
if agent == "cursor":
|
|
103
|
+
if "tool_name" in request:
|
|
104
|
+
if request.get("tool_name") != "Shell":
|
|
105
|
+
return None
|
|
106
|
+
ti = request.get("tool_input") or {}
|
|
107
|
+
return Call(command_of(ti), ti.get("working_directory") or request.get("cwd"), native_shell(), "pre_tool")
|
|
108
|
+
command = request.get("command")
|
|
109
|
+
roots = request.get("workspace_roots") or [None]
|
|
110
|
+
return Call(command if isinstance(command, str) else None, request.get("cwd") or roots[0], native_shell(), "")
|
|
111
|
+
if agent == "copilot":
|
|
112
|
+
if "toolName" in request:
|
|
113
|
+
name = request.get("toolName")
|
|
114
|
+
args = request.get("toolArgs")
|
|
115
|
+
if isinstance(args, str):
|
|
116
|
+
try:
|
|
117
|
+
args = loads(rt, args)
|
|
118
|
+
except zgram.ParseError:
|
|
119
|
+
args = None
|
|
120
|
+
else:
|
|
121
|
+
name = request.get("tool_name")
|
|
122
|
+
args = request.get("tool_input")
|
|
123
|
+
if name not in COPILOT_TOOLS:
|
|
124
|
+
return None
|
|
125
|
+
return Call(command_of(args), request.get("cwd"), COPILOT_TOOLS[name] or native_shell(), "")
|
|
126
|
+
if agent == "cline":
|
|
127
|
+
roots = request.get("workspaceRoots") or [None]
|
|
128
|
+
if "tool_call" in request:
|
|
129
|
+
mode = "cli"
|
|
130
|
+
tc = request.get("tool_call") or {}
|
|
131
|
+
name = tc.get("name")
|
|
132
|
+
args = tc.get("input")
|
|
133
|
+
else:
|
|
134
|
+
mode = ""
|
|
135
|
+
p = request.get("preToolUse") or {}
|
|
136
|
+
name = p.get("toolName")
|
|
137
|
+
args = p.get("parameters")
|
|
138
|
+
if name not in CLINE_TOOLS:
|
|
139
|
+
return None
|
|
140
|
+
cwd = (args or {}).get("cwd") if isinstance(args, dict) else None
|
|
141
|
+
return Call(command_of(args), cwd if isinstance(cwd, str) else roots[0], native_shell(), mode)
|
|
142
|
+
command = request.get("command")
|
|
143
|
+
return Call(command if isinstance(command, str) else None, request.get("cwd"),
|
|
144
|
+
request.get("shell") or native_shell(), "")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# The agents' editing tools: the file they write (its key in the tool's input)
|
|
148
|
+
CLAUDE_EDITS = {"Write": "file_path", "Edit": "file_path", "MultiEdit": "file_path", "NotebookEdit": "notebook_path"}
|
|
149
|
+
GEMINI_EDITS = {"write_file": "file_path", "replace": "file_path"}
|
|
150
|
+
CLINE_EDITS = {"write_to_file": "path", "replace_in_file": "path"}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def edited_file(agent, request):
|
|
154
|
+
"""The file an editing tool's request writes, absolute and through this
|
|
155
|
+
machine's links as decisions see paths; None: not such a request."""
|
|
156
|
+
args = None
|
|
157
|
+
key = None
|
|
158
|
+
cwd = request.get("cwd")
|
|
159
|
+
if agent == "claude" or agent == "gemini":
|
|
160
|
+
edits = CLAUDE_EDITS if agent == "claude" else GEMINI_EDITS
|
|
161
|
+
name = request.get("tool_name")
|
|
162
|
+
if name in edits:
|
|
163
|
+
args = request.get("tool_input")
|
|
164
|
+
key = edits[name]
|
|
165
|
+
elif agent == "cline":
|
|
166
|
+
roots = request.get("workspaceRoots") or [None]
|
|
167
|
+
if "tool_call" in request:
|
|
168
|
+
tc = request.get("tool_call") or {}
|
|
169
|
+
name = tc.get("name")
|
|
170
|
+
given = tc.get("input")
|
|
171
|
+
else:
|
|
172
|
+
p = request.get("preToolUse") or {}
|
|
173
|
+
name = p.get("toolName")
|
|
174
|
+
given = p.get("parameters")
|
|
175
|
+
if name in CLINE_EDITS:
|
|
176
|
+
args = given
|
|
177
|
+
key = CLINE_EDITS[name]
|
|
178
|
+
cwd = roots[0]
|
|
179
|
+
if not isinstance(args, dict):
|
|
180
|
+
return None
|
|
181
|
+
v = args.get(key)
|
|
182
|
+
if not isinstance(v, str) or v == "":
|
|
183
|
+
return None
|
|
184
|
+
if not paths.is_abs(v):
|
|
185
|
+
if not isinstance(cwd, str) or not paths.is_abs(cwd):
|
|
186
|
+
return None
|
|
187
|
+
v = paths.join(cwd, v)
|
|
188
|
+
return followed(paths.norm(v), "write")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def hook_output(decision, reason):
|
|
192
|
+
return {"hookEventName": "PreToolUse", "permissionDecision": decision, "permissionDecisionReason": reason}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def answer(agent, mode, decision, reason, default):
|
|
196
|
+
"""What the agent is told (None: nothing)."""
|
|
197
|
+
if agent == "claude":
|
|
198
|
+
if default and decision != "deny":
|
|
199
|
+
return None
|
|
200
|
+
return {"hookSpecificOutput": hook_output(decision, reason)}
|
|
201
|
+
if agent == "codex":
|
|
202
|
+
if default and decision != "deny":
|
|
203
|
+
return None
|
|
204
|
+
decision, reason = cant_ask(decision, reason)
|
|
205
|
+
if decision != "deny":
|
|
206
|
+
return None
|
|
207
|
+
return {"hookSpecificOutput": hook_output("deny", reason)}
|
|
208
|
+
if agent == "gemini":
|
|
209
|
+
if default and decision != "deny":
|
|
210
|
+
return {}
|
|
211
|
+
decision, reason = cant_ask(decision, reason)
|
|
212
|
+
if decision == "deny":
|
|
213
|
+
return {"decision": "deny", "reason": reason}
|
|
214
|
+
return {"decision": "allow", "reason": reason}
|
|
215
|
+
if agent == "cursor":
|
|
216
|
+
if mode == "pre_tool":
|
|
217
|
+
decision, reason = cant_ask(decision, reason)
|
|
218
|
+
return {"permission": decision, "user_message": reason, "agent_message": reason}
|
|
219
|
+
if agent == "copilot":
|
|
220
|
+
if default and decision != "deny":
|
|
221
|
+
return None
|
|
222
|
+
return {"permissionDecision": decision, "permissionDecisionReason": reason,
|
|
223
|
+
"hookSpecificOutput": hook_output(decision, reason)}
|
|
224
|
+
if agent == "cline":
|
|
225
|
+
if mode == "cli":
|
|
226
|
+
if decision == "deny":
|
|
227
|
+
return {"cancel": True, "errorMessage": reason}
|
|
228
|
+
if decision == "ask" and not default:
|
|
229
|
+
return {"review": True, "context": reason}
|
|
230
|
+
return {}
|
|
231
|
+
decision, reason = cant_ask(decision, reason)
|
|
232
|
+
if decision == "deny":
|
|
233
|
+
return {"cancel": True, "errorMessage": reason}
|
|
234
|
+
return {"cancel": False}
|
|
235
|
+
return {"decision": decision, "reason": reason, "default": default}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def decide(rt, agent, request, policy_path):
|
|
239
|
+
"""What to answer: (the agent's answer or None, whether anything)"""
|
|
240
|
+
if not isinstance(request, dict):
|
|
241
|
+
return answer(agent, "", "ask", "vetter: a request it doesn't understand", False)
|
|
242
|
+
if (agent == "claude" or agent == "codex") and request.get("hook_event_name") == "PostToolUse":
|
|
243
|
+
it_ran(rt, agent, request, policy_path)
|
|
244
|
+
return None
|
|
245
|
+
edited = edited_file(agent, request)
|
|
246
|
+
if edited is not None:
|
|
247
|
+
# (an editing tool: what it writes remembered, nothing decided)
|
|
248
|
+
written.record([edited], "generated", written_path(home_dir()))
|
|
249
|
+
mode = "cli" if agent == "cline" and "tool_call" in request else ""
|
|
250
|
+
return answer(agent, mode, "allow", "", True)
|
|
251
|
+
call = read(rt, agent, request)
|
|
252
|
+
if call is None:
|
|
253
|
+
return None
|
|
254
|
+
if not isinstance(call.command, str):
|
|
255
|
+
return answer(agent, call.mode, "ask", "vetter: a shell call without a command", False)
|
|
256
|
+
cwd = call.cwd if isinstance(call.cwd, str) and call.cwd else getcwd()
|
|
257
|
+
home = home_dir()
|
|
258
|
+
policy = load_policy(rt, policy_path, cwd, home)
|
|
259
|
+
project = workspace_project(cwd, home, workspace_of(agent, request))
|
|
260
|
+
d = check(rt, policy, call.command, call.shell, cwd, home, project, temp_dirs(home, True))
|
|
261
|
+
call_id = request.get("tool_use_id")
|
|
262
|
+
if policy.log:
|
|
263
|
+
log.record_call(rt, agent, cwd, call.command, d, log_path(home), call_id if isinstance(call_id, str) else "",
|
|
264
|
+
project)
|
|
265
|
+
written.record_decision(d, policy.written, written_path(home))
|
|
266
|
+
at = " [" + where(d.rule) + "]" if d.rule is not None else ""
|
|
267
|
+
return answer(agent, call.mode, d.decision, "vetter: " + d.reason + at, d.by == "default" or d.by == "nothing")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def it_ran(rt, agent, request, policy_path):
|
|
271
|
+
"""A call that ran (PostToolUse): if vetter asked, the user said yes
|
|
272
|
+
(`vetter learn`'s); logged by its id, nothing answered."""
|
|
273
|
+
ident = text_field(request, "tool_use_id")
|
|
274
|
+
cwd = text_field(request, "cwd")
|
|
275
|
+
home = home_dir()
|
|
276
|
+
policy = load_policy(rt, policy_path, cwd if cwd != "" else getcwd(), home)
|
|
277
|
+
if ident != "" and policy.log:
|
|
278
|
+
log.record_ran(rt, agent, ident, log_path(home))
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def text_field(request, key):
|
|
282
|
+
"""request[key] if it's a str, else ""."""
|
|
283
|
+
v = request.get(key)
|
|
284
|
+
if isinstance(v, str):
|
|
285
|
+
return v
|
|
286
|
+
return ""
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def run(rt, agent, policy_path):
|
|
290
|
+
"""The request on stdin decided, the answer on stdout; never failing
|
|
291
|
+
open: an error is an ask (a deny where the agent can't ask)."""
|
|
292
|
+
try:
|
|
293
|
+
out = decide(rt, agent, loads(rt, sys.stdin.read()), policy_path)
|
|
294
|
+
except Exception as e: # noqa: BLE001 (whatever goes wrong, a decision: ask)
|
|
295
|
+
out = answer(agent, "", "ask", "vetter couldn't decide (" + type(e).__name__ + ": " + str(e) + ")", False)
|
|
296
|
+
if out is not None:
|
|
297
|
+
sys.stdout.write(dumps(out) + "\n")
|
|
298
|
+
return 0
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""`vetter import`: an agent's own permission rules as a vetter policy.
|
|
2
|
+
|
|
3
|
+
Their rules come first (Claude Code's deny, then ask, then allow; Codex's
|
|
4
|
+
in their order), then `use stock`: the stock policy's denies still come
|
|
5
|
+
before everything (what they forbid stays forbidden), its other rules
|
|
6
|
+
decide what the imported ones don't name. What can't be said in vetter's
|
|
7
|
+
terms is left as a comment saying why.
|
|
8
|
+
|
|
9
|
+
text = from_claude(settings_dict, "~/.claude/settings.json")
|
|
10
|
+
text = from_codex({"default.rules": source})
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import ast
|
|
14
|
+
import datetime
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
|
|
18
|
+
# Claude Code: permissions.allow / ask / deny
|
|
19
|
+
|
|
20
|
+
_ENTRY = re.compile(r"^(?P<tool>[A-Za-z]+)(?:\((?P<arg>.*)\))?$", re.S)
|
|
21
|
+
_OPERATORS = re.compile(r"[;&|<>`$()\n]")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _q(s):
|
|
25
|
+
return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _word(w):
|
|
29
|
+
"""A word as the policy language takes it: bare when it can be."""
|
|
30
|
+
return w if re.fullmatch(r"\$?[A-Za-z0-9_.+:@%/~=?*-]+", w) and not w.startswith("-") else _q(w)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _command_match(spec):
|
|
34
|
+
"""A Bash rule's command (`git diff:*`, `npm run build`, `ls *`) as a
|
|
35
|
+
program match, and a note when vetter's is broader; None and why when
|
|
36
|
+
it can't be said."""
|
|
37
|
+
prefix = spec.endswith(":*")
|
|
38
|
+
cmd = spec[:-2] if prefix else spec
|
|
39
|
+
cmd = cmd.strip()
|
|
40
|
+
if cmd.endswith(" *"):
|
|
41
|
+
cmd, prefix = cmd[:-2].rstrip(), True
|
|
42
|
+
if not cmd or cmd == "*":
|
|
43
|
+
return "any", None
|
|
44
|
+
if _OPERATORS.search(cmd):
|
|
45
|
+
return None, "a command with shell operators: vetter decides each part of a command on its own"
|
|
46
|
+
words = cmd.split()
|
|
47
|
+
if any("*" in w for w in words):
|
|
48
|
+
return None, "a wildcard inside the command"
|
|
49
|
+
names = []
|
|
50
|
+
for w in words:
|
|
51
|
+
if w.startswith("-"):
|
|
52
|
+
break
|
|
53
|
+
names.append(w)
|
|
54
|
+
rest = words[len(names):]
|
|
55
|
+
# (its options: each must be there, as in the agent's prefix)
|
|
56
|
+
options = [w for w in rest if w.startswith("-") and re.fullmatch(r"-[A-Za-z0-9_=-]*", w)]
|
|
57
|
+
note = None
|
|
58
|
+
if len(options) < len(rest):
|
|
59
|
+
note = "the words after its options left out: vetter matches a program by name, subcommands and options"
|
|
60
|
+
elif not prefix:
|
|
61
|
+
note = "with more arguments too: vetter matches a program by name, subcommands and options"
|
|
62
|
+
withs = "".join(f" with {o}" for o in options)
|
|
63
|
+
if "/" in names[0] or "\\" in names[0]:
|
|
64
|
+
path = names[0]
|
|
65
|
+
if path.startswith("./"):
|
|
66
|
+
path = "$project/" + path[2:]
|
|
67
|
+
return f"run exactly {_word(path)}", note
|
|
68
|
+
return " ".join(_word(n) for n in names) + withs, note
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _place(path):
|
|
72
|
+
"""A Read/Edit rule's path (gitignore-style, as Claude Code reads it) as
|
|
73
|
+
a place: ./x and /x from the project, //x absolute, ~/x home."""
|
|
74
|
+
if path.startswith("//"):
|
|
75
|
+
p = path[1:]
|
|
76
|
+
elif path.startswith("~/") or path == "~":
|
|
77
|
+
p = path
|
|
78
|
+
elif path.startswith("/"):
|
|
79
|
+
p = "$project" + path
|
|
80
|
+
elif path.startswith("./"):
|
|
81
|
+
p = "$project/" + path[2:]
|
|
82
|
+
else:
|
|
83
|
+
p = "$project/" + path
|
|
84
|
+
p = p.replace("/**/", "/*/").removesuffix("/**").removesuffix("/*") or p
|
|
85
|
+
return _word(p)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _claude_rule(decision, entry):
|
|
89
|
+
m = _ENTRY.match(entry.strip())
|
|
90
|
+
if not m:
|
|
91
|
+
return None, f"not a permission rule vetter reads: {entry}"
|
|
92
|
+
tool, arg = m["tool"], m["arg"]
|
|
93
|
+
reason = _q(f"claude: {entry}")
|
|
94
|
+
if tool in ("Bash", "PowerShell"):
|
|
95
|
+
match, note = _command_match(arg or "*")
|
|
96
|
+
if match is None:
|
|
97
|
+
return None, f"{entry}: {note}"
|
|
98
|
+
rule = f"{decision} {reason} {match}"
|
|
99
|
+
return rule + (f" # ({note})" if note else ""), None
|
|
100
|
+
if tool in ("Read", "Edit", "Write", "MultiEdit", "NotebookEdit"):
|
|
101
|
+
verb = "read" if tool == "Read" else "write"
|
|
102
|
+
if not arg:
|
|
103
|
+
return f"{decision} {reason} {verb}", None
|
|
104
|
+
return f"{decision} {reason} {verb} in {_place(arg)}", None
|
|
105
|
+
if tool == "WebFetch":
|
|
106
|
+
if arg and arg.startswith("domain:"):
|
|
107
|
+
# (a domain the agent may fetch from: the same for commands)
|
|
108
|
+
return f"{decision} {reason} download from {_word(arg[7:])}", None
|
|
109
|
+
return None, f"{entry}: WebFetch without a domain"
|
|
110
|
+
return None, f"{entry}: not a shell or file rule (vetter vets commands)"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def from_claude(settings, where="~/.claude/settings.json", today=None):
|
|
114
|
+
perms = (settings or {}).get("permissions") or {}
|
|
115
|
+
lines = _header("Claude Code", where, today)
|
|
116
|
+
skipped = []
|
|
117
|
+
for decision in ("deny", "ask", "allow"):
|
|
118
|
+
entries = perms.get(decision) or []
|
|
119
|
+
if entries:
|
|
120
|
+
lines.append(f"\n# {decision}")
|
|
121
|
+
for e in entries:
|
|
122
|
+
rule, why = _claude_rule(decision, e)
|
|
123
|
+
if rule is not None:
|
|
124
|
+
lines.append(rule)
|
|
125
|
+
else:
|
|
126
|
+
skipped.append(why)
|
|
127
|
+
return _footer(lines, skipped)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# Codex: execpolicy's prefix_rule(...) in ~/.codex/rules/*.rules
|
|
131
|
+
|
|
132
|
+
_CODEX_DECISIONS = {"allow": "allow", "prompt": "ask", "forbidden": "deny"}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _codex_rules(source, name):
|
|
136
|
+
"""The prefix_rule calls of a .rules file (Starlark, read as Python's
|
|
137
|
+
syntax: literals only)."""
|
|
138
|
+
out, skipped = [], []
|
|
139
|
+
try:
|
|
140
|
+
tree = ast.parse(source, name)
|
|
141
|
+
except SyntaxError as e:
|
|
142
|
+
return [], [f"{name}: not read ({e.msg}, line {e.lineno})"]
|
|
143
|
+
for node in ast.walk(tree):
|
|
144
|
+
if not (isinstance(node, ast.Call) and getattr(node.func, "id", None) == "prefix_rule"):
|
|
145
|
+
continue
|
|
146
|
+
try:
|
|
147
|
+
kw = {k.arg: ast.literal_eval(k.value) for k in node.keywords}
|
|
148
|
+
except ValueError:
|
|
149
|
+
skipped.append(f"{name}:{node.lineno}: a prefix_rule with values that aren't literals")
|
|
150
|
+
continue
|
|
151
|
+
pattern = kw.get("pattern")
|
|
152
|
+
if not pattern and node.args:
|
|
153
|
+
pattern = ast.literal_eval(node.args[0])
|
|
154
|
+
out.append((node.lineno, pattern, kw.get("decision", "allow"), kw.get("justification")))
|
|
155
|
+
return out, skipped
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def from_codex(files, where="~/.codex/rules", today=None):
|
|
159
|
+
lines = _header("Codex", where, today)
|
|
160
|
+
skipped = []
|
|
161
|
+
for name, source in sorted(files.items()):
|
|
162
|
+
rules, bad = _codex_rules(source, name)
|
|
163
|
+
skipped += bad
|
|
164
|
+
if rules:
|
|
165
|
+
lines.append(f"\n# {name}")
|
|
166
|
+
for lineno, pattern, decision, why in rules:
|
|
167
|
+
d = _CODEX_DECISIONS.get(decision)
|
|
168
|
+
if d is None or not isinstance(pattern, (list, tuple)) or not pattern:
|
|
169
|
+
skipped.append(f"{name}:{lineno}: a rule vetter doesn't read ({decision!r}, {pattern!r})")
|
|
170
|
+
continue
|
|
171
|
+
levels = []
|
|
172
|
+
withs = []
|
|
173
|
+
note = None
|
|
174
|
+
for token in pattern:
|
|
175
|
+
alts = token if isinstance(token, (list, tuple)) else [token]
|
|
176
|
+
if any(not isinstance(a, str) for a in alts):
|
|
177
|
+
note = "a pattern vetter doesn't read whole"
|
|
178
|
+
break
|
|
179
|
+
if any(a.startswith("-") for a in alts):
|
|
180
|
+
if all(re.fullmatch(r"-[A-Za-z0-9_=-]*", a) for a in alts):
|
|
181
|
+
# (alternatives: any of them; each token: all)
|
|
182
|
+
withs.append(" with " + ", ".join(alts))
|
|
183
|
+
continue
|
|
184
|
+
note = "a pattern vetter doesn't read whole"
|
|
185
|
+
break
|
|
186
|
+
if withs:
|
|
187
|
+
note = "the words after its options left out: vetter matches a program by name, subcommands " \
|
|
188
|
+
"and options"
|
|
189
|
+
break
|
|
190
|
+
levels.append(", ".join(_word(a) for a in alts))
|
|
191
|
+
if not levels:
|
|
192
|
+
skipped.append(f"{name}:{lineno}: {pattern!r}: no program name")
|
|
193
|
+
continue
|
|
194
|
+
reason = _q(f"codex: {why}" if why else f"codex: {' '.join(map(str, pattern))}")
|
|
195
|
+
lines.append(f"{d} {reason} " + " ".join(levels) + "".join(withs) + (f" # ({note})" if note else ""))
|
|
196
|
+
return _footer(lines, skipped)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _header(agent, where, today):
|
|
200
|
+
today = today or datetime.date.today().isoformat()
|
|
201
|
+
return [
|
|
202
|
+
"policy imported",
|
|
203
|
+
f"# {agent}'s permission rules from {where} ({today}), by `vetter import`.",
|
|
204
|
+
"# They come first; the stock policy's denies come before everything",
|
|
205
|
+
"# (what they forbid stays forbidden), its other rules decide the rest.",
|
|
206
|
+
]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _footer(lines, skipped):
|
|
210
|
+
lines.append("\nuse stock")
|
|
211
|
+
if skipped:
|
|
212
|
+
lines.append("\n# Not imported:")
|
|
213
|
+
lines += [f"# {s}" for s in skipped]
|
|
214
|
+
return "\n".join(lines) + "\n"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def read_claude(path):
|
|
218
|
+
with open(path) as f:
|
|
219
|
+
return json.load(f)
|