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.
Files changed (68) hide show
  1. vetter/__init__.py +3 -0
  2. vetter/__main__.py +5 -0
  3. vetter/agents/__init__.py +2 -0
  4. vetter/agents/hook.py +298 -0
  5. vetter/agents/importing.py +219 -0
  6. vetter/agents/install.py +214 -0
  7. vetter/agents/learn.py +173 -0
  8. vetter/agents/log.py +393 -0
  9. vetter/agents/written.py +131 -0
  10. vetter/build.py +86 -0
  11. vetter/check.py +38 -0
  12. vetter/cli.py +327 -0
  13. vetter/engine.py +111 -0
  14. vetter/gitconfig.py +141 -0
  15. vetter/glob.py +89 -0
  16. vetter/jsontext.py +128 -0
  17. vetter/paths.py +190 -0
  18. vetter/places.py +191 -0
  19. vetter/policy/__init__.py +4 -0
  20. vetter/policy/actions.py +642 -0
  21. vetter/policy/builtin/prelude.vet +32 -0
  22. vetter/policy/builtin/stock.vet +232 -0
  23. vetter/policy/checks.py +198 -0
  24. vetter/policy/language.py +1220 -0
  25. vetter/policy/policy.zgram +66 -0
  26. vetter/policy/trace.py +93 -0
  27. vetter/shells/__init__.py +2 -0
  28. vetter/shells/bash/__init__.py +2 -0
  29. vetter/shells/bash/arith.py +428 -0
  30. vetter/shells/bash/bash.zgram +159 -0
  31. vetter/shells/bash/expand.py +966 -0
  32. vetter/shells/bash/resolve.py +2210 -0
  33. vetter/shells/bash/scan.py +423 -0
  34. vetter/shells/bash/syntax.py +153 -0
  35. vetter/shells/cmd/__init__.py +2 -0
  36. vetter/shells/cmd/resolve.py +1083 -0
  37. vetter/shells/cmd/syntax.py +698 -0
  38. vetter/shells/powershell/__init__.py +2 -0
  39. vetter/shells/powershell/cmdlets.py +108 -0
  40. vetter/shells/powershell/grammar.py +237 -0
  41. vetter/shells/powershell/resolve.py +1536 -0
  42. vetter/shells/powershell/syntax.py +166 -0
  43. vetter/shells/powershell/values.py +547 -0
  44. vetter/shells/python/__init__.py +0 -0
  45. vetter/shells/python/python.zgram +174 -0
  46. vetter/shells/python/resolve.py +3273 -0
  47. vetter/shells/python/scan.py +186 -0
  48. vetter/shells/python/stdlib.py +359 -0
  49. vetter/shells/python/syntax.py +20 -0
  50. vetter/specs/README.md +112 -0
  51. vetter/specs/__init__.py +2257 -0
  52. vetter/specs/dev.toml +352 -0
  53. vetter/specs/env.toml +312 -0
  54. vetter/specs/files.toml +701 -0
  55. vetter/specs/git.toml +223 -0
  56. vetter/specs/load.py +71 -0
  57. vetter/specs/network.toml +110 -0
  58. vetter/specs/powershell-params.toml +677 -0
  59. vetter/specs/powershell.toml +386 -0
  60. vetter/specs/toolchains.toml +317 -0
  61. vetter/specs/windows.toml +136 -0
  62. vetter/toolconfig.py +206 -0
  63. vetter-0.2.0.dist-info/METADATA +192 -0
  64. vetter-0.2.0.dist-info/RECORD +68 -0
  65. vetter-0.2.0.dist-info/WHEEL +4 -0
  66. vetter-0.2.0.dist-info/entry_points.txt +2 -0
  67. vetter-0.2.0.dist-info/licenses/LICENSE +202 -0
  68. vetter-0.2.0.dist-info/licenses/NOTICE +7 -0
vetter/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """vetter: vets every command an AI agent wants to run."""
2
+
3
+ __version__ = "0.2.0"
vetter/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,2 @@
1
+ """The agents' side: their hooks (requests in, decisions out, installing),
2
+ and the log of decided commands."""
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)