yes-claudio 0.1.0__tar.gz

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.
@@ -0,0 +1,43 @@
1
+ Metadata-Version: 2.4
2
+ Name: yes-claudio
3
+ Version: 0.1.0
4
+ Summary: Auto-approve Claude Code permission prompts with a denylist and daily limits
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: pystray>=0.19
9
+ Requires-Dist: Pillow>=10.0
10
+
11
+ # yes-claudio
12
+
13
+ Auto-approve Claude Code permission prompts with a denylist and daily limits.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pip install yes-claudio
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ ```bash
24
+ # Activate the hook in Claude Code
25
+ yes-claudio
26
+
27
+ # Activate a paid license (unlimited approvals)
28
+ yes-claudio activate YOUR-SERIAL-KEY
29
+
30
+ # Run the hook manually (called by Claude Code automatically)
31
+ yes-claudio hook
32
+ ```
33
+
34
+ ## How it works
35
+
36
+ - **Free tier:** 10 auto-approvals per day
37
+ - **Paid tier ($2):** unlimited approvals — get your key at [gumroad.com](https://bravoevert.gumroad.com/l/yes-claudio)
38
+
39
+ Installs a `PreToolUse` hook in `~/.claude/settings.json`. Dangerous commands (`rm -rf`, force push, `DROP TABLE`, `sudo`, etc.) are always sent back to you for review.
40
+
41
+ ## License
42
+
43
+ MIT
@@ -0,0 +1,33 @@
1
+ # yes-claudio
2
+
3
+ Auto-approve Claude Code permission prompts with a denylist and daily limits.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install yes-claudio
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```bash
14
+ # Activate the hook in Claude Code
15
+ yes-claudio
16
+
17
+ # Activate a paid license (unlimited approvals)
18
+ yes-claudio activate YOUR-SERIAL-KEY
19
+
20
+ # Run the hook manually (called by Claude Code automatically)
21
+ yes-claudio hook
22
+ ```
23
+
24
+ ## How it works
25
+
26
+ - **Free tier:** 10 auto-approvals per day
27
+ - **Paid tier ($2):** unlimited approvals — get your key at [gumroad.com](https://bravoevert.gumroad.com/l/yes-claudio)
28
+
29
+ Installs a `PreToolUse` hook in `~/.claude/settings.json`. Dangerous commands (`rm -rf`, force push, `DROP TABLE`, `sudo`, etc.) are always sent back to you for review.
30
+
31
+ ## License
32
+
33
+ MIT
@@ -0,0 +1,28 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "yes-claudio"
7
+ version = "0.1.0"
8
+ description = "Auto-approve Claude Code permission prompts with a denylist and daily limits"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.11"
12
+ dependencies = [
13
+ "pystray>=0.19",
14
+ "Pillow>=10.0",
15
+ ]
16
+
17
+ [project.scripts]
18
+ yes-claudio = "autoconfirm.main:main"
19
+
20
+ [tool.setuptools]
21
+ packages = ["autoconfirm", "autoconfirm.gui"]
22
+
23
+ [tool.setuptools.package-dir]
24
+ "autoconfirm" = "src"
25
+ "autoconfirm.gui" = "src/gui"
26
+
27
+ [tool.pytest.ini_options]
28
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
@@ -0,0 +1,80 @@
1
+ import logging
2
+ import re
3
+ from typing import Optional
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+ # (padrão, motivo legível)
8
+ BASH_PATTERNS: list[tuple[str, str]] = [
9
+ (r"\brm\s+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r)\b", "remoção recursiva (rm -rf)"),
10
+ (r"\brm\s+.*--force\b.*-r\b", "remoção recursiva com --force"),
11
+ (r"\bgit\s+push\s+.*(-f\b|--force\b)", "git push --force"),
12
+ (r"\bgit\s+reset\s+--hard\b", "git reset --hard"),
13
+ (r"\bgit\s+clean\s+.*-f\b", "git clean -f"),
14
+ (r"\bDROP\s+(TABLE|DATABASE|SCHEMA|INDEX)\b", "DROP SQL destrutivo"),
15
+ (r"\bTRUNCATE\b", "TRUNCATE SQL"),
16
+ (r"\bsudo\b", "escalada de privilégio (sudo)"),
17
+ (r"\bdd\s+if=", "escrita direta em disco (dd)"),
18
+ (r"\bmkfs\b", "formatação de disco (mkfs)"),
19
+ (r"\bfdisk\b", "particionamento de disco (fdisk)"),
20
+ (r">\s*/dev/(sd[a-z]|hd[a-z]|nvme)", "redirecionamento para dispositivo de bloco"),
21
+ (r"\bshutdown\b", "desligamento do sistema"),
22
+ (r"\breboot\b", "reinicialização do sistema"),
23
+ (r"\bhalt\b", "parada do sistema"),
24
+ (r"\bpoweroff\b", "desligamento do sistema"),
25
+ (r":\(\)\s*\{", "fork bomb"),
26
+ (r"(curl|wget)\b.*\|\s*(ba|da|z|fi)?sh\b", "download e execução direta de script"),
27
+ (r"\bshred\b", "wipe seguro de arquivo (shred)"),
28
+ (r"\bchmod\s+.*777\s+(/|/home|/etc)", "chmod 777 em diretório de sistema"),
29
+ ]
30
+
31
+ FILE_PATH_PATTERNS: list[tuple[str, str]] = [
32
+ (r"^/etc/", "diretório de configuração do sistema (/etc)"),
33
+ (r"^/usr/", "diretório de sistema (/usr)"),
34
+ (r"^/bin/", "diretório de binários do sistema (/bin)"),
35
+ (r"^/sbin/", "diretório de binários de sistema (/sbin)"),
36
+ (r"^/boot/", "diretório de boot (/boot)"),
37
+ (r"^/sys/", "sistema de arquivos virtual (/sys)"),
38
+ (r"^/proc/", "sistema de arquivos virtual (/proc)"),
39
+ (r"^/dev/", "diretório de dispositivos (/dev)"),
40
+ ]
41
+
42
+ _bash_compiled = [(re.compile(p, re.IGNORECASE), reason) for p, reason in BASH_PATTERNS]
43
+ _path_compiled = [(re.compile(p), reason) for p, reason in FILE_PATH_PATTERNS]
44
+
45
+
46
+ def _load_custom_compiled() -> list[tuple[re.Pattern, str]]:
47
+ from .storage import load_custom_denylist
48
+ result = []
49
+ for raw in load_custom_denylist():
50
+ try:
51
+ result.append((re.compile(raw, re.IGNORECASE), f"padrão customizado: {raw}"))
52
+ except re.error:
53
+ logger.warning("Padrão inválido ignorado: %r", raw)
54
+ return result
55
+
56
+
57
+ def check_bash(command: str) -> Optional[str]:
58
+ for pattern, reason in _bash_compiled:
59
+ if pattern.search(command):
60
+ return reason
61
+ for pattern, reason in _load_custom_compiled():
62
+ if pattern.search(command):
63
+ return reason
64
+ return None
65
+
66
+
67
+ def check_file_path(path: str) -> Optional[str]:
68
+ for pattern, reason in _path_compiled:
69
+ if pattern.match(path):
70
+ return reason
71
+ return None
72
+
73
+
74
+ def is_dangerous(tool_name: str, tool_input: dict) -> Optional[str]:
75
+ """Retorna motivo legível se perigoso, None se seguro."""
76
+ if tool_name == "Bash":
77
+ return check_bash(tool_input.get("command", ""))
78
+ if tool_name in ("Edit", "Write"):
79
+ return check_file_path(tool_input.get("file_path", ""))
80
+ return None
File without changes
@@ -0,0 +1,83 @@
1
+ import json
2
+ import shutil
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ CLAUDE_SETTINGS = Path.home() / ".claude" / "settings.json"
7
+ HOOK_TAG = "autoconfirm-hook"
8
+
9
+
10
+ def _binary_command() -> str:
11
+ if getattr(sys, "frozen", False):
12
+ return f'"{sys.executable}" hook'
13
+ entry = shutil.which("yes-claudio")
14
+ if entry:
15
+ return f'"{entry}" hook'
16
+ return f'"{sys.executable}" -m src.main hook'
17
+
18
+
19
+ def _load() -> dict:
20
+ if not CLAUDE_SETTINGS.exists():
21
+ return {}
22
+ try:
23
+ return json.loads(CLAUDE_SETTINGS.read_text(encoding="utf-8"))
24
+ except (json.JSONDecodeError, OSError):
25
+ return {}
26
+
27
+
28
+ def _save(data: dict) -> None:
29
+ CLAUDE_SETTINGS.parent.mkdir(parents=True, exist_ok=True)
30
+ CLAUDE_SETTINGS.write_text(
31
+ json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8"
32
+ )
33
+
34
+
35
+ def _our_entries(pre_tool: list) -> list:
36
+ return [
37
+ e for e in pre_tool
38
+ if any(h.get("_tag") == HOOK_TAG for h in e.get("hooks", []))
39
+ ]
40
+
41
+
42
+ def _other_entries(pre_tool: list) -> list:
43
+ return [
44
+ e for e in pre_tool
45
+ if not any(h.get("_tag") == HOOK_TAG for h in e.get("hooks", []))
46
+ ]
47
+
48
+
49
+ def is_hook_active() -> bool:
50
+ pre_tool = _load().get("hooks", {}).get("PreToolUse", [])
51
+ return bool(_our_entries(pre_tool))
52
+
53
+
54
+ def enable_hook() -> None:
55
+ data = _load()
56
+ pre_tool = data.setdefault("hooks", {}).get("PreToolUse", [])
57
+ updated = _other_entries(pre_tool) + [{
58
+ "matcher": "",
59
+ "hooks": [{
60
+ "type": "command",
61
+ "command": _binary_command(),
62
+ "timeout": 10,
63
+ "_tag": HOOK_TAG,
64
+ }]
65
+ }]
66
+ data["hooks"]["PreToolUse"] = updated
67
+ _save(data)
68
+
69
+
70
+ def disable_hook() -> None:
71
+ if not CLAUDE_SETTINGS.exists():
72
+ return
73
+ data = _load()
74
+ pre_tool = data.get("hooks", {}).get("PreToolUse", [])
75
+ remaining = _other_entries(pre_tool)
76
+
77
+ if remaining:
78
+ data["hooks"]["PreToolUse"] = remaining
79
+ else:
80
+ data.get("hooks", {}).pop("PreToolUse", None)
81
+ if not data.get("hooks"):
82
+ data.pop("hooks", None)
83
+ _save(data)
@@ -0,0 +1,195 @@
1
+ import re
2
+ import tkinter as tk
3
+ from tkinter import messagebox, ttk
4
+
5
+ from ..denylist import BASH_PATTERNS
6
+ from ..license import activate, check_local
7
+ from ..storage import CONFIG_DIR, FREE_LIMIT, LICENSE_FILE, get_today_count, load_custom_denylist, save_custom_denylist
8
+ from .hook_config import disable_hook, enable_hook, is_hook_active
9
+
10
+
11
+ class Panel:
12
+ def __init__(self):
13
+ self.root = tk.Tk()
14
+ self.root.title("Auto-Confirm — Claude Code")
15
+ self.root.geometry("380x280")
16
+ self.root.resizable(False, False)
17
+ self._build()
18
+ self._refresh()
19
+
20
+ def _build(self):
21
+ pad = {"padx": 12, "pady": 6}
22
+
23
+ # --- Status ---
24
+ status = ttk.LabelFrame(self.root, text="Status")
25
+ status.pack(fill="x", **pad)
26
+
27
+ self.lbl_count = ttk.Label(status, text="")
28
+ self.lbl_count.pack(anchor="w", padx=8, pady=2)
29
+
30
+ self.lbl_license = ttk.Label(status, text="")
31
+ self.lbl_license.pack(anchor="w", padx=8, pady=2)
32
+
33
+ self.hook_var = tk.BooleanVar()
34
+ ttk.Checkbutton(
35
+ status,
36
+ text="Hook ativo (aprovação automática ligada)",
37
+ variable=self.hook_var,
38
+ command=self._toggle_hook,
39
+ ).pack(anchor="w", padx=8, pady=4)
40
+
41
+ # --- Licença ---
42
+ lic = ttk.LabelFrame(self.root, text="Ativar Licença")
43
+ lic.pack(fill="x", **pad)
44
+
45
+ self.serial_var = tk.StringVar()
46
+ row = ttk.Frame(lic)
47
+ row.pack(fill="x", padx=8, pady=6)
48
+ ttk.Entry(row, textvariable=self.serial_var, width=28).pack(side="left")
49
+ ttk.Button(row, text="Ativar", command=self._activate).pack(side="left", padx=6)
50
+
51
+ btn_row = ttk.Frame(self.root)
52
+ btn_row.pack(pady=4)
53
+ ttk.Button(btn_row, text="Atualizar", command=self._refresh).pack(side="left", padx=4)
54
+ ttk.Button(btn_row, text="Editar Denylist", command=self._open_denylist).pack(side="left", padx=4)
55
+
56
+ def _refresh(self):
57
+ count = get_today_count()
58
+ paid = check_local(LICENSE_FILE)
59
+
60
+ if paid:
61
+ self.lbl_count.config(text=f"Aprovações hoje: {count} (ilimitado)")
62
+ self.lbl_license.config(text="Licença: Paga ✓", foreground="green")
63
+ else:
64
+ remaining = max(0, FREE_LIMIT - count)
65
+ self.lbl_count.config(
66
+ text=f"Aprovações hoje: {count}/{FREE_LIMIT} ({remaining} restantes)"
67
+ )
68
+ self.lbl_license.config(text="Licença: Gratuita", foreground="#888888")
69
+
70
+ self.hook_var.set(is_hook_active())
71
+
72
+ def _toggle_hook(self):
73
+ if self.hook_var.get():
74
+ enable_hook()
75
+ else:
76
+ disable_hook()
77
+
78
+ def _activate(self):
79
+ key = self.serial_var.get().strip()
80
+ if not key:
81
+ messagebox.showwarning("Serial vazia", "Cole a serial recebida por email.")
82
+ return
83
+ ok, msg = activate(key, CONFIG_DIR)
84
+ if ok:
85
+ messagebox.showinfo("Licença ativada", msg)
86
+ self.serial_var.set("")
87
+ else:
88
+ messagebox.showerror("Erro na ativação", msg)
89
+ self._refresh()
90
+
91
+ def _open_denylist(self):
92
+ DenylistEditor(self.root)
93
+
94
+ def run(self):
95
+ self.root.mainloop()
96
+
97
+
98
+ class DenylistEditor(tk.Toplevel):
99
+ def __init__(self, parent):
100
+ super().__init__(parent)
101
+ self.title("Editar Denylist")
102
+ self.geometry("540x420")
103
+ self.resizable(False, False)
104
+ self.grab_set()
105
+ self._build()
106
+ self._populate()
107
+
108
+ def _build(self):
109
+ pad = {"padx": 10, "pady": 6}
110
+
111
+ # --- Lista de padrões ---
112
+ frame_list = ttk.LabelFrame(self, text="Padrões ativos")
113
+ frame_list.pack(fill="both", expand=True, **pad)
114
+
115
+ scroll = ttk.Scrollbar(frame_list)
116
+ scroll.pack(side="right", fill="y")
117
+ self.listbox = tk.Listbox(frame_list, yscrollcommand=scroll.set, width=60, height=12, selectmode="single")
118
+ self.listbox.pack(side="left", fill="both", expand=True, padx=(4, 0), pady=4)
119
+ scroll.config(command=self.listbox.yview)
120
+
121
+ ttk.Button(frame_list, text="Remover selecionado", command=self._remove).pack(pady=(0, 4))
122
+
123
+ # --- Adicionar padrão ---
124
+ frame_add = ttk.LabelFrame(self, text="Adicionar padrão (regex)")
125
+ frame_add.pack(fill="x", **pad)
126
+
127
+ self.new_pattern_var = tk.StringVar()
128
+ row = ttk.Frame(frame_add)
129
+ row.pack(fill="x", padx=8, pady=6)
130
+ ttk.Entry(row, textvariable=self.new_pattern_var, width=38).pack(side="left")
131
+ ttk.Button(row, text="Adicionar", command=self._add).pack(side="left", padx=6)
132
+
133
+ # --- Testar padrão ---
134
+ frame_test = ttk.LabelFrame(self, text="Testar contra comando")
135
+ frame_test.pack(fill="x", **pad)
136
+
137
+ self.test_cmd_var = tk.StringVar()
138
+ row2 = ttk.Frame(frame_test)
139
+ row2.pack(fill="x", padx=8, pady=6)
140
+ ttk.Entry(row2, textvariable=self.test_cmd_var, width=38).pack(side="left")
141
+ ttk.Button(row2, text="Testar", command=self._test).pack(side="left", padx=6)
142
+
143
+ # --- Salvar ---
144
+ ttk.Button(self, text="Salvar", command=self._save).pack(pady=6)
145
+
146
+ def _populate(self):
147
+ self.listbox.delete(0, tk.END)
148
+ for _, reason in BASH_PATTERNS:
149
+ self.listbox.insert(tk.END, f"[builtin] {reason}")
150
+ self._custom = load_custom_denylist()
151
+ for p in self._custom:
152
+ self.listbox.insert(tk.END, p)
153
+ self._builtin_count = len(BASH_PATTERNS)
154
+
155
+ def _add(self):
156
+ raw = self.new_pattern_var.get().strip()
157
+ if not raw:
158
+ return
159
+ try:
160
+ re.compile(raw)
161
+ except re.error as e:
162
+ messagebox.showerror("Regex inválido", f"Padrão não compila:\n{e}", parent=self)
163
+ return
164
+ self._custom.append(raw)
165
+ self.listbox.insert(tk.END, raw)
166
+ self.new_pattern_var.set("")
167
+
168
+ def _remove(self):
169
+ idx = self.listbox.curselection()
170
+ if not idx:
171
+ return
172
+ i = idx[0]
173
+ if i < self._builtin_count:
174
+ messagebox.showwarning("Padrão builtin", "Padrões builtin não podem ser removidos.", parent=self)
175
+ return
176
+ custom_idx = i - self._builtin_count
177
+ self._custom.pop(custom_idx)
178
+ self.listbox.delete(i)
179
+
180
+ def _test(self):
181
+ cmd = self.test_cmd_var.get()
182
+ if not cmd:
183
+ return
184
+ # testa todos os padrões (builtin + custom)
185
+ from ..denylist import check_bash
186
+ reason = check_bash(cmd)
187
+ if reason:
188
+ messagebox.showinfo("Bloqueado", f"Comando seria bloqueado:\n{reason}", parent=self)
189
+ else:
190
+ messagebox.showinfo("Permitido", "Comando seria aprovado (nenhum padrão bateu).", parent=self)
191
+
192
+ def _save(self):
193
+ save_custom_denylist(self._custom)
194
+ messagebox.showinfo("Salvo", f"{len(self._custom)} padrão(ões) custom salvos.", parent=self)
195
+ self.destroy()
@@ -0,0 +1,51 @@
1
+ import subprocess
2
+ import sys
3
+
4
+ import pystray
5
+ from PIL import Image, ImageDraw
6
+
7
+ from .hook_config import disable_hook, enable_hook, is_hook_active
8
+
9
+
10
+ def _icon_image(active: bool) -> Image.Image:
11
+ size = 64
12
+ img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
13
+ draw = ImageDraw.Draw(img)
14
+ fill = (34, 197, 94) if active else (107, 114, 128)
15
+ draw.ellipse([4, 4, size - 4, size - 4], fill=fill)
16
+ return img
17
+
18
+
19
+ def _panel_cmd() -> list[str]:
20
+ if getattr(sys, "frozen", False):
21
+ return [sys.executable, "panel"]
22
+ return [sys.executable, "-m", "src.main", "panel"]
23
+
24
+
25
+ def run_tray() -> None:
26
+ def open_panel(icon, item):
27
+ subprocess.Popen(_panel_cmd())
28
+
29
+ def toggle(icon, item):
30
+ if is_hook_active():
31
+ disable_hook()
32
+ else:
33
+ enable_hook()
34
+ icon.icon = _icon_image(is_hook_active())
35
+
36
+ def quit_app(icon, item):
37
+ icon.stop()
38
+
39
+ menu = pystray.Menu(
40
+ pystray.MenuItem("Abrir Painel", open_panel, default=True),
41
+ pystray.MenuItem("Ativar / Desativar Hook", toggle),
42
+ pystray.Menu.SEPARATOR,
43
+ pystray.MenuItem("Sair", quit_app),
44
+ )
45
+
46
+ pystray.Icon(
47
+ name="autoconfirm",
48
+ icon=_icon_image(is_hook_active()),
49
+ title="Auto-Confirm",
50
+ menu=menu,
51
+ ).run()
@@ -0,0 +1,54 @@
1
+ import json
2
+ import sys
3
+ from typing import Any
4
+
5
+ from .denylist import is_dangerous
6
+ from .storage import FREE_LIMIT, get_today_count, increment, is_paid
7
+
8
+
9
+ def _respond(decision: str, reason: str = "") -> None:
10
+ out: dict[str, Any] = {
11
+ "hookSpecificOutput": {
12
+ "hookEventName": "PreToolUse",
13
+ "permissionDecision": decision,
14
+ }
15
+ }
16
+ if reason:
17
+ out["hookSpecificOutput"]["permissionDecisionReason"] = reason
18
+ print(json.dumps(out), flush=True)
19
+
20
+
21
+ def run() -> int:
22
+ try:
23
+ payload = json.loads(sys.stdin.read())
24
+ tool_name: str = payload.get("tool_name", "")
25
+ tool_input: dict = payload.get("tool_input", {})
26
+
27
+ # 1. Denylist — devolve ao humano sem contar
28
+ danger = is_dangerous(tool_name, tool_input)
29
+ if danger:
30
+ _respond("ask", f"Bloqueado pela denylist: {danger}")
31
+ return 0
32
+
33
+ # 2. Licença paga → ilimitado
34
+ if is_paid():
35
+ increment(tool_name, tool_input)
36
+ _respond("allow")
37
+ return 0
38
+
39
+ # 3. Free: cap de 10/dia
40
+ if get_today_count() >= FREE_LIMIT:
41
+ _respond(
42
+ "ask",
43
+ f"Limite diário de {FREE_LIMIT} aprovações atingido. "
44
+ "Ative a licença para aprovar sem limite.",
45
+ )
46
+ return 0
47
+
48
+ increment(tool_name, tool_input)
49
+ _respond("allow")
50
+ return 0
51
+
52
+ except Exception as exc: # noqa: BLE001 — fail-safe: qualquer erro → ask
53
+ _respond("ask", f"Erro interno no handler: {exc}")
54
+ return 0