control_panel.py
#!/usr/bin/env python3
"""
Agent Toolkit — Control Panel
================================
Manages ~/.lmstudio-toolkit/permissions.json (read by the LM Studio plugin).
GUI:
python3 control_panel.py
CLI helpers:
python3 control_panel.py show
python3 control_panel.py set-rule /abs/path read_write|read|deny
python3 control_panel.py del-rule /abs/path
python3 control_panel.py set-default read_write|read|deny
python3 control_panel.py enable-commands | disable-commands
"""
from __future__ import annotations
import json, os, sys
from pathlib import Path
CONFIG_DIR = Path.home() / ".lmstudio-toolkit"
CONFIG_PATH = CONFIG_DIR / "permissions.json"
RULE_LABELS = {"read_write": "Read & Write", "read": "Read only", "deny": "Deny"}
LABEL_RULE = {v: k for k, v in RULE_LABELS.items()}
# (bg, fg) pairs per permission level — dark theme
COLOURS: dict[str, tuple[str, str]] = {
"read_write": ("#1a3d1a", "#7fff7f"),
"read": ("#3d3300", "#ffe066"),
"deny": ("#3d1010", "#ff8080"),
"inherit": ("#252526", "#888888"),
}
DEFAULT_DENY_COMMANDS: list[str] = [
# Privilege escalation
"sudo*", "su", "doas", "pkexec", "runas",
# System auth
"passwd*", "chpasswd", "chsh", "chfn",
"usermod", "useradd", "userdel", "groupmod", "groupadd", "groupdel",
"visudo", "sudoedit",
# Shutdown / power
"shutdown*", "reboot*", "halt*", "poweroff*", "init",
"systemctl", "launchctl",
# Disk / filesystem destruction
"mkfs*", "mke2fs", "mkswap",
"fdisk", "parted", "gdisk", "gparted", "diskutil",
"dd", "dc3dd", "dcfldd",
"shred", "wipe", "srm",
# Process nuking
"kill", "killall", "pkill",
# Network/firewall config
"iptables*", "ip6tables*", "nft", "pfctl", "ufw",
# Cron / persistence
"crontab", "at", "atq", "atrm",
# History tampering
"history",
# macOS security
"csrutil", "nvram", "spctl",
]
DEFAULT_PERMS: dict = {
"version": 2,
"default": "deny",
"rules": {},
"command_executor": {
"enabled": False,
"allow_commands": [],
"deny_commands": DEFAULT_DENY_COMMANDS,
"timeout_ms": 30000,
"max_output_chars": 20000,
},
}
# ─────────────────────────────────────────── persistence ──────────────────────
def load() -> dict:
if not CONFIG_PATH.exists():
return json.loads(json.dumps(DEFAULT_PERMS))
try:
data = json.loads(CONFIG_PATH.read_text())
except Exception:
return json.loads(json.dumps(DEFAULT_PERMS))
merged = json.loads(json.dumps(DEFAULT_PERMS))
for k, v in data.items():
if k == "command_executor" and isinstance(v, dict):
merged["command_executor"].update(v)
else:
merged[k] = v
return merged
def save(cfg: dict) -> None:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
CONFIG_PATH.write_text(json.dumps(cfg, indent=2) + "\n")
def norm(p: str) -> str:
return str(Path(p).expanduser().resolve())
# ──────────────────────────────────────────────── CLI ─────────────────────────
def cli(argv: list[str]) -> int:
cfg = load()
cmd = argv[0]
if cmd == "show":
print(json.dumps(cfg, indent=2))
print(f"\n(file: {CONFIG_PATH})")
return 0
if cmd == "set-rule" and len(argv) >= 3:
p, rule = norm(argv[1]), argv[2]
if rule not in RULE_LABELS:
print(f"Unknown rule '{rule}'. Use: read_write, read, deny")
return 1
cfg["rules"][p] = rule
save(cfg)
print(f"OK {rule:12} {p}")
return 0
if cmd == "del-rule" and len(argv) >= 2:
p = norm(argv[1])
if p in cfg["rules"]:
del cfg["rules"][p]
save(cfg)
print(f"Removed rule for {p}")
else:
print(f"No rule for {p}")
return 0
if cmd == "set-default" and len(argv) >= 2:
if argv[1] not in RULE_LABELS:
print("Use: read_write, read, deny")
return 1
cfg["default"] = argv[1]
save(cfg)
print(f"default = {argv[1]}")
return 0
if cmd in ("enable-commands", "disable-commands"):
cfg["command_executor"]["enabled"] = (cmd == "enable-commands")
save(cfg)
print("command_executor.enabled =", cfg["command_executor"]["enabled"])
return 0
print(__doc__)
return 1
# ──────────────────────────────────────────────── GUI ─────────────────────────
def gui() -> None:
import tkinter as tk
from tkinter import ttk, messagebox
cfg = load()
root = tk.Tk()
root.title("Agent Toolkit — Control Panel")
root.geometry("960x700")
root.configure(bg="#1e1e1e")
style = ttk.Style()
style.theme_use("default")
style.configure(".", background="#1e1e1e", foreground="#d4d4d4",
fieldbackground="#2d2d2d", troughcolor="#2d2d2d")
style.configure("TNotebook", background="#1e1e1e", borderwidth=0)
style.configure("TNotebook.Tab", background="#2d2d2d", foreground="#cccccc",
padding=[14, 5])
style.map("TNotebook.Tab",
background=[("selected", "#3c3c3c")],
foreground=[("selected", "#ffffff")])
style.configure("TFrame", background="#1e1e1e")
style.configure("TLabel", background="#1e1e1e", foreground="#d4d4d4")
style.configure("TButton", background="#3c3c3c", foreground="#d4d4d4",
borderwidth=1, relief="flat", padding=[8, 4])
style.map("TButton", background=[("active", "#505050")])
style.configure("TCombobox", fieldbackground="#2d2d2d",
background="#2d2d2d", foreground="#d4d4d4")
style.configure("TEntry", fieldbackground="#2d2d2d",
foreground="#d4d4d4", insertcolor="#d4d4d4")
style.configure("TCheckbutton", background="#1e1e1e", foreground="#d4d4d4")
# Treeview custom style
style.configure("Dark.Treeview",
background="#252526", foreground="#d4d4d4",
fieldbackground="#252526", rowheight=22, borderwidth=0)
style.configure("Dark.Treeview.Heading",
background="#2d2d2d", foreground="#cccccc", relief="flat")
style.map("Dark.Treeview",
background=[("selected", "#094771")],
foreground=[("selected", "#ffffff")])
nb = ttk.Notebook(root)
nb.pack(fill="both", expand=True, padx=8, pady=8)
# ── permission resolver (mirrors TypeScript resolveRule) ───────────────
def resolve_rule(path: str) -> str:
rpath = str(Path(path).resolve())
best_len = -1
best_rule = None
for rp, rule in cfg["rules"].items():
rrp = str(Path(rp).resolve())
if rpath == rrp or rpath.startswith(rrp + os.sep):
if len(rrp) > best_len:
best_len = len(rrp)
best_rule = rule
return best_rule if best_rule is not None else cfg["default"]
def own_rule(path: str) -> str | None:
return cfg["rules"].get(str(Path(path).resolve()))
# ── TAB 1: File Manager ───────────────────────────────────────────────
fm_frame = ttk.Frame(nb)
nb.add(fm_frame, text=" File Manager ")
# Top bar: default permission combobox + legend chips
top = ttk.Frame(fm_frame)
top.pack(fill="x", padx=8, pady=(8, 4))
ttk.Label(top, text="Unset paths default:").pack(side="left")
default_var = tk.StringVar(value=RULE_LABELS[cfg["default"]])
default_cb = ttk.Combobox(top, textvariable=default_var, width=14,
values=list(RULE_LABELS.values()), state="readonly")
default_cb.pack(side="left", padx=(6, 24))
def on_default_change(event=None):
lbl = default_var.get()
cfg["default"] = LABEL_RULE.get(lbl, "deny")
repaint_tree()
default_cb.bind("<<ComboboxSelected>>", on_default_change)
for rule_key in ("read_write", "read", "deny"):
bg, fg = COLOURS[rule_key]
tk.Label(top, text=f" {RULE_LABELS[rule_key]} ",
bg=bg, fg=fg, relief="flat", padx=6, pady=2).pack(side="left", padx=2)
# Tree + scrollbars
tree_wrap = ttk.Frame(fm_frame)
tree_wrap.pack(fill="both", expand=True, padx=8, pady=4)
vsb = ttk.Scrollbar(tree_wrap, orient="vertical")
hsb = ttk.Scrollbar(tree_wrap, orient="horizontal")
tree = ttk.Treeview(tree_wrap,
columns=("own_rule", "effective"),
style="Dark.Treeview",
yscrollcommand=vsb.set,
xscrollcommand=hsb.set,
selectmode="extended")
vsb.config(command=tree.yview)
hsb.config(command=tree.xview)
vsb.pack(side="right", fill="y")
hsb.pack(side="bottom", fill="x")
tree.pack(fill="both", expand=True)
tree.heading("#0", text="Path", anchor="w")
tree.heading("own_rule", text="Rule set here", anchor="w")
tree.heading("effective", text="Effective", anchor="w")
tree.column("#0", width=440, stretch=True)
tree.column("own_rule", width=140, stretch=False)
tree.column("effective", width=140, stretch=False)
for rule_key, (bg, fg) in COLOURS.items():
tree.tag_configure(rule_key, background=bg, foreground=fg)
DUMMY = "\x00dummy"
def _tag(path: str) -> str:
return resolve_rule(path)
def _values(path: str) -> tuple[str, str]:
own = own_rule(path)
own_lbl = RULE_LABELS.get(own, "") if own else ""
eff_lbl = RULE_LABELS.get(resolve_rule(path), "")
return (own_lbl, eff_lbl)
def _insert(parent: str, path: str):
label = os.path.basename(path) or path
tag = _tag(path)
own, eff = _values(path)
node = tree.insert(parent, "end", iid=path,
text=label, values=(own, eff), tags=(tag,))
if os.path.isdir(path):
try:
if os.listdir(path):
tree.insert(node, "end", iid=path + DUMMY, text="")
except PermissionError:
pass
return node
def repaint_tree():
"""Refresh colours/labels for all currently loaded nodes."""
def _recurse(iid: str):
if iid.endswith(DUMMY):
return
path = iid
tag = _tag(path)
own, eff = _values(path)
tree.item(iid, values=(own, eff), tags=(tag,))
for child in tree.get_children(iid):
_recurse(child)
for root_iid in tree.get_children():
_recurse(root_iid)
def populate_root():
tree.delete(*tree.get_children())
_insert("", "/")
def on_open(event):
iid = tree.focus()
children = tree.get_children(iid)
if len(children) == 1 and children[0] == iid + DUMMY:
tree.delete(iid + DUMMY)
path = iid
try:
names = sorted(os.listdir(path),
key=lambda x: (not os.path.isdir(os.path.join(path, x)),
x.lower()))
for name in names:
full = os.path.join(path, name)
_insert(iid, full)
except PermissionError:
tree.insert(iid, "end", text="(permission denied)", tags=("deny",))
tree.bind("<<TreeviewOpen>>", on_open)
# Inline permission bar (applies to selected nodes)
perm_bar = ttk.Frame(fm_frame)
perm_bar.pack(fill="x", padx=8, pady=(2, 6))
ttk.Label(perm_bar, text="Set selected:").pack(side="left")
def apply_rule(rule: str | None):
sels = list(tree.selection()) or ([tree.focus()] if tree.focus() else [])
for iid in sels:
if iid.endswith(DUMMY):
continue
p = str(Path(iid).resolve())
if rule is None:
cfg["rules"].pop(p, None)
else:
cfg["rules"][p] = rule
repaint_tree()
refresh_rules_tab()
for rule_key in ("read_write", "read", "deny"):
bg, fg = COLOURS[rule_key]
tk.Button(perm_bar, text=RULE_LABELS[rule_key],
bg=bg, fg=fg, activebackground=bg, activeforeground=fg,
relief="flat", padx=10, pady=3, cursor="hand2",
command=lambda r=rule_key: apply_rule(r)).pack(side="left", padx=3)
tk.Button(perm_bar, text="Clear (inherit)", bg="#2d2d2d", fg="#999999",
activebackground="#3c3c3c", relief="flat", padx=10, pady=3,
cursor="hand2", command=lambda: apply_rule(None)).pack(side="left", padx=3)
# Right-click context menu
menu = tk.Menu(root, tearoff=0, bg="#2d2d2d", fg="#d4d4d4",
activebackground="#094771", activeforeground="#ffffff",
relief="flat", bd=0)
def on_right_click(event):
iid = tree.identify_row(event.y)
if not iid or iid.endswith(DUMMY):
return
if iid not in tree.selection():
tree.selection_set(iid)
tree.focus(iid)
sels = list(tree.selection())
n = len(sels)
desc = sels[0] if n == 1 else f"{n} items"
menu.delete(0, "end")
menu.add_command(label=desc, state="disabled")
menu.add_separator()
for rule_key in ("read_write", "read", "deny"):
bg, fg = COLOURS[rule_key]
menu.add_command(label=f" {RULE_LABELS[rule_key]}",
background=bg, foreground=fg,
activebackground=bg, activeforeground=fg,
command=lambda r=rule_key: apply_rule(r))
menu.add_separator()
menu.add_command(label=" Clear rule (inherit default)",
command=lambda: apply_rule(None))
menu.post(event.x_root, event.y_root)
tree.bind("<Button-2>", on_right_click)
tree.bind("<Button-3>", on_right_click)
# ── TAB 2: System Prompt ─────────────────────────────────────────────
sp_frame = ttk.Frame(nb)
nb.add(sp_frame, text=" System Prompt ")
ttk.Label(sp_frame,
text="Injected at the top of the first message in every new chat.").pack(
anchor="w", padx=14, pady=(12, 2))
ttk.Label(sp_frame,
text="Leave empty to disable. Changes take effect on the next new conversation.",
foreground="#666").pack(anchor="w", padx=14, pady=(0, 6))
sp_wrap = ttk.Frame(sp_frame)
sp_wrap.pack(fill="both", expand=True, padx=14, pady=(0, 8))
sp_vsb = ttk.Scrollbar(sp_wrap, orient="vertical")
sp_txt = tk.Text(sp_wrap, bg="#252526", fg="#d4d4d4",
insertbackground="#d4d4d4", relief="flat",
highlightthickness=1, highlightcolor="#555",
highlightbackground="#333", yscrollcommand=sp_vsb.set,
font=("Menlo", 12), wrap="word")
sp_vsb.config(command=sp_txt.yview)
sp_vsb.pack(side="right", fill="y")
sp_txt.pack(side="left", fill="both", expand=True)
sp_txt.insert("1.0", cfg.get("custom_system_prompt", ""))
# ── TAB 3: Command Executor ───────────────────────────────────────────
ce_frame = ttk.Frame(nb)
nb.add(ce_frame, text=" Command Executor ")
ce = cfg["command_executor"]
enabled_var = tk.BooleanVar(value=ce["enabled"])
ttk.Checkbutton(ce_frame, text="Enable run_command tool",
variable=enabled_var).pack(anchor="w", padx=14, pady=(14, 2))
syscmd_var = tk.BooleanVar(value=ce.get("allow_system_commands", False))
ttk.Checkbutton(
ce_frame,
text="Allow system / launcher commands (open, say, osascript, pbcopy, screencapture, afplay, …)"
" — these run without a working directory",
variable=syscmd_var,
).pack(anchor="w", padx=28, pady=(0, 4))
# Tier explanation card
info = tk.Frame(ce_frame, bg="#2a2a2a", padx=12, pady=8)
info.pack(fill="x", padx=14, pady=(4, 10))
for line in [
"Command tier is set by the working directory's file permission (File Manager tab):",
" Deny → no commands at all (pass a different working_directory)",
" Read → read-only commands: ls, cat, grep, find, stat, head, tail, …",
" Read & Write → any command not in the deny list below;",
" write-impact cmds (rm, mv, cp, …) also check path permissions",
]:
fg = "#d4d4d4" if not line.startswith(" ") else "#aaaaaa"
tk.Label(info, text=line, bg="#2a2a2a", fg=fg,
font=("Menlo", 11), anchor="w", justify="left").pack(anchor="w")
def refresh_cmd_tab():
pass # no working_dirs list to refresh
# ── command pattern textareas ──────────────────────────────────────────
# One pattern per line. * matches any sequence of characters.
# Examples: sudo* rm* passwd* shutdown*
def make_cmd_area(parent, label, entries: list[str]) -> tk.Text:
ttk.Label(parent, text=label).pack(anchor="w", padx=14, pady=(8, 2))
hint = ttk.Label(parent,
text=" One pattern per line. * = any characters. e.g. sudo* mkfs* rm*",
foreground="#666")
hint.pack(anchor="w", padx=14)
wrap = ttk.Frame(parent)
wrap.pack(fill="x", padx=14, pady=(2, 0))
sb = ttk.Scrollbar(wrap, orient="vertical")
txt = tk.Text(wrap, height=6, bg="#252526", fg="#d4d4d4",
insertbackground="#d4d4d4", relief="flat",
highlightthickness=1, highlightcolor="#555",
highlightbackground="#333", yscrollcommand=sb.set,
font=("Menlo", 12))
sb.config(command=txt.yview)
sb.pack(side="right", fill="y")
txt.pack(side="left", fill="x", expand=True)
txt.insert("1.0", "\n".join(entries))
return txt
allow_txt = make_cmd_area(ce_frame, "Allow list (empty = allow any binary not in deny list):",
ce["allow_commands"])
deny_txt = make_cmd_area(ce_frame, "Deny list (always checked first):",
ce["deny_commands"])
def row_entry(parent, label, value):
row = ttk.Frame(parent)
row.pack(fill="x", padx=14, pady=4)
ttk.Label(row, text=label, width=30).pack(side="left")
var = tk.StringVar(value=value)
ttk.Entry(row, textvariable=var).pack(side="left", fill="x", expand=True)
return var
timeout_var = row_entry(ce_frame, "Timeout (ms):", str(ce["timeout_ms"]))
maxout_var = row_entry(ce_frame, "Max output (chars):", str(ce["max_output_chars"]))
refresh_cmd_tab()
# ── TAB 3: Active Rules ───────────────────────────────────────────────
rules_frame = ttk.Frame(nb)
nb.add(rules_frame, text=" Active Rules ")
rules_tv = ttk.Treeview(rules_frame, columns=("rule",),
style="Dark.Treeview",
show="tree headings",
selectmode="extended")
rules_tv.heading("#0", text="Path", anchor="w")
rules_tv.heading("rule", text="Rule", anchor="w")
rules_tv.column("#0", width=600, stretch=True)
rules_tv.column("rule", width=160, stretch=False)
rules_tv.pack(fill="both", expand=True, padx=8, pady=8)
for rule_key, (bg, fg) in COLOURS.items():
rules_tv.tag_configure(rule_key, background=bg, foreground=fg)
def refresh_rules_tab():
rules_tv.delete(*rules_tv.get_children())
for path in sorted(cfg["rules"]):
rule = cfg["rules"][path]
rules_tv.insert("", "end", iid=path, text=path,
values=(RULE_LABELS.get(rule, rule),), tags=(rule,))
def delete_selected_rules():
for iid in rules_tv.selection():
cfg["rules"].pop(iid, None)
refresh_rules_tab()
repaint_tree()
rules_bar = ttk.Frame(rules_frame)
rules_bar.pack(fill="x", padx=8, pady=(0, 8))
ttk.Button(rules_bar, text="Delete selected rule(s)",
command=delete_selected_rules).pack(side="left")
# ── Bottom: save ─────────────────────────────────────────────────────
bot = ttk.Frame(root)
bot.pack(fill="x", padx=8, pady=(0, 8))
status_var = tk.StringVar(value=str(CONFIG_PATH))
ttk.Label(bot, textvariable=status_var, foreground="#666").pack(side="left")
def do_save():
cfg["default"] = LABEL_RULE.get(default_var.get(), "deny")
cfg["custom_system_prompt"] = sp_txt.get("1.0", tk.END).rstrip("\n")
ce["enabled"] = enabled_var.get()
ce["allow_system_commands"] = syscmd_var.get()
ce["allow_commands"] = [x.strip() for x in allow_txt.get("1.0", tk.END).splitlines() if x.strip()]
ce["deny_commands"] = [x.strip() for x in deny_txt.get("1.0", tk.END).splitlines() if x.strip()]
try:
ce["timeout_ms"] = int(timeout_var.get())
ce["max_output_chars"] = int(maxout_var.get())
except ValueError:
messagebox.showerror("Invalid number", "Timeout and max output must be integers.")
return
save(cfg)
refresh_rules_tab()
status_var.set(f"✓ Saved — {CONFIG_PATH}")
root.after(3000, lambda: status_var.set(str(CONFIG_PATH)))
ttk.Button(bot, text=" Save ", command=do_save).pack(side="right")
# On tab switch refresh rules list
def on_tab_change(event):
if nb.index(nb.select()) == 3:
refresh_rules_tab()
nb.bind("<<NotebookTabChanged>>", on_tab_change)
# Bootstrap: load tree
populate_root()
# Pre-expand root so user sees something
if tree.get_children():
root_iid = tree.get_children()[0]
tree.item(root_iid, open=True)
on_open(None)
root.mainloop()
if __name__ == "__main__":
if len(sys.argv) > 1:
sys.exit(cli(sys.argv[1:]))
gui()