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.
- yes_claudio-0.1.0/PKG-INFO +43 -0
- yes_claudio-0.1.0/README.md +33 -0
- yes_claudio-0.1.0/pyproject.toml +28 -0
- yes_claudio-0.1.0/setup.cfg +4 -0
- yes_claudio-0.1.0/src/__init__.py +0 -0
- yes_claudio-0.1.0/src/denylist.py +80 -0
- yes_claudio-0.1.0/src/gui/__init__.py +0 -0
- yes_claudio-0.1.0/src/gui/hook_config.py +83 -0
- yes_claudio-0.1.0/src/gui/panel.py +195 -0
- yes_claudio-0.1.0/src/gui/tray.py +51 -0
- yes_claudio-0.1.0/src/handler.py +54 -0
- yes_claudio-0.1.0/src/license.py +127 -0
- yes_claudio-0.1.0/src/main.py +46 -0
- yes_claudio-0.1.0/src/storage.py +71 -0
- yes_claudio-0.1.0/tests/test_denylist.py +120 -0
- yes_claudio-0.1.0/tests/test_handler.py +94 -0
- yes_claudio-0.1.0/tests/test_hook_config.py +141 -0
- yes_claudio-0.1.0/tests/test_license.py +161 -0
- yes_claudio-0.1.0/yes_claudio.egg-info/PKG-INFO +43 -0
- yes_claudio-0.1.0/yes_claudio.egg-info/SOURCES.txt +22 -0
- yes_claudio-0.1.0/yes_claudio.egg-info/dependency_links.txt +1 -0
- yes_claudio-0.1.0/yes_claudio.egg-info/entry_points.txt +2 -0
- yes_claudio-0.1.0/yes_claudio.egg-info/requires.txt +2 -0
- yes_claudio-0.1.0/yes_claudio.egg-info/top_level.txt +1 -0
|
@@ -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"]
|
|
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
|