opencommand 0.1.2__tar.gz → 0.1.3__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.
Files changed (43) hide show
  1. {opencommand-0.1.2 → opencommand-0.1.3}/PKG-INFO +2 -1
  2. {opencommand-0.1.2 → opencommand-0.1.3}/pyproject.toml +2 -1
  3. {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/cli.py +49 -2
  4. {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/core/config.py +4 -0
  5. opencommand-0.1.3/src/opencommand/modules/skills/desktop_linux.md +41 -0
  6. opencommand-0.1.3/src/opencommand/modules/skills/desktop_macos.md +42 -0
  7. opencommand-0.1.3/src/opencommand/modules/skills/desktop_windows.md +44 -0
  8. {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/modules/system/cron.py +19 -0
  9. opencommand-0.1.3/src/opencommand/modules/tools/desktop.py +202 -0
  10. opencommand-0.1.3/src/opencommand/modules/tools/desktop_safety.py +162 -0
  11. opencommand-0.1.3/src/opencommand/modules/tools/system.py +285 -0
  12. opencommand-0.1.3/src/opencommand/modules/tools/yara_rules/default.yar +61 -0
  13. opencommand-0.1.2/src/opencommand/modules/tools/desktop.py +0 -127
  14. opencommand-0.1.2/src/opencommand/modules/tools/system.py +0 -163
  15. {opencommand-0.1.2 → opencommand-0.1.3}/README.md +0 -0
  16. {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/__init__.py +0 -0
  17. {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/core/agent.py +0 -0
  18. {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/core/errors.py +0 -0
  19. {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/core/logging.py +0 -0
  20. {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/core/supervisor.py +0 -0
  21. {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/core/tools.py +0 -0
  22. {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/engine/__init__.py +0 -0
  23. {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/engine/runner.py +0 -0
  24. {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/modules/knowledge.py +0 -0
  25. {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/modules/memory/MEMORY.md +0 -0
  26. {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/modules/skills/bash.md +0 -0
  27. {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/modules/skills/powershell.md +0 -0
  28. {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/modules/skills/python.md +0 -0
  29. {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/modules/skills/system.md +0 -0
  30. {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/modules/system/automatic.py +0 -0
  31. {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/modules/system/notify.py +0 -0
  32. {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/modules/system/platform.py +0 -0
  33. {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/modules/templates.py +0 -0
  34. {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/modules/tools/files.py +0 -0
  35. {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/modules/tools/instructions.py +0 -0
  36. {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/modules/tools/memory.py +0 -0
  37. {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/modules/tools/playwright.py +0 -0
  38. {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/modules/tools/research.py +0 -0
  39. {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/modules/tools/terminal.py +0 -0
  40. {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/providers/provider.py +0 -0
  41. {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/swarm/agents/commander.py +0 -0
  42. {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/swarm/agents/worker.py +0 -0
  43. {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/tui/dashboard.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: opencommand
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: Local-first swarm agent framework: a Commander plans and dispatches a swarm of Workers that research, code, test, and interactively drive the computer to complete long-horizon codebase goals.
5
5
  Author: DSiENT
6
6
  Author-email: DSiENT <w.caskey7@gmail.com>
@@ -16,6 +16,7 @@ Requires-Dist: llama-cpp-python>=0.3.33
16
16
  Requires-Dist: huggingface-hub>=0.30.0
17
17
  Requires-Dist: watchdog>=6.0.0
18
18
  Requires-Dist: ntfy>=2.7.1
19
+ Requires-Dist: yara-python>=4.5.0
19
20
  Requires-Python: >=3.14
20
21
  Project-URL: Homepage, https://github.com/D5-Interactive/OpenCommand
21
22
  Project-URL: Repository, https://github.com/D5-Interactive/OpenCommand
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "opencommand"
3
- version = "0.1.2"
3
+ version = "0.1.3"
4
4
  description = "Local-first swarm agent framework: a Commander plans and dispatches a swarm of Workers that research, code, test, and interactively drive the computer to complete long-horizon codebase goals."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -20,6 +20,7 @@ dependencies = [
20
20
  "huggingface_hub>=0.30.0",
21
21
  "watchdog>=6.0.0",
22
22
  "ntfy>=2.7.1",
23
+ "yara-python>=4.5.0",
23
24
  ]
24
25
 
25
26
  [project.scripts]
@@ -47,7 +47,10 @@ def _build_tools(engine: Engine, cfg: OpenCommandConfig) -> ToolRegistry:
47
47
  reg.register(PlaywrightTool())
48
48
  reg.register(SystemTool())
49
49
  vision_role = cfg.vision_model if cfg.vision else None
50
- reg.register(DesktopTool(engine.runner(vision_role) if vision_role else None))
50
+ from .modules.tools.desktop_safety import DesktopPolicy
51
+ desktop_policy = DesktopPolicy.from_dict(cfg.desktop_policy)
52
+ reg.register(DesktopTool(engine.runner(vision_role) if vision_role else None,
53
+ policy=desktop_policy))
51
54
  return reg
52
55
 
53
56
 
@@ -57,7 +60,7 @@ def _load_cfg(path: str | None) -> OpenCommandConfig:
57
60
 
58
61
 
59
62
  @click.group()
60
- @click.version_option(version="0.1.1")
63
+ @click.version_option(version="0.1.3")
61
64
  @click.option("-v", "--verbose", is_flag=True, help="Enable verbose (DEBUG) logging.")
62
65
  def cli(verbose: bool) -> None:
63
66
  """OpenCommand - all-in-one local model runner & command center."""
@@ -149,6 +152,8 @@ def _build_rebuild_command(cmake_args: str) -> tuple:
149
152
  @click.argument("path", required=False)
150
153
  def config(path: str | None) -> None:
151
154
  """Interactive menu to set advanced swarm settings (persisted to config.json)."""
155
+ from .modules.tools.desktop_safety import DesktopPolicy
156
+
152
157
  cfg = _load_cfg(path)
153
158
  click.echo(click.style("OpenCommand configuration", fg="cyan", bold=True))
154
159
  click.echo(f"Workspace: {cfg.workspace}\n")
@@ -179,6 +184,21 @@ def config(path: str | None) -> None:
179
184
  cfg.goal_file = click.prompt(
180
185
  "Session goal spec file name", default=cfg.goal_file)
181
186
 
187
+ # --- Desktop takeover safety policy ------------------------------------
188
+ click.echo(click.style("\nDesktop takeover safety policy", fg="cyan", bold=True))
189
+ click.echo("Defines exactly what the desktop agent is allowed to do.")
190
+ pol = DesktopPolicy.from_dict(cfg.desktop_policy)
191
+ pol.allow_mouse = click.confirm(" Allow mouse control (click)?", default=pol.allow_mouse)
192
+ pol.allow_keyboard = click.confirm(" Allow keyboard control (key)?", default=pol.allow_keyboard)
193
+ pol.allow_type = click.confirm(" Allow typing text?", default=pol.allow_type)
194
+ pol.require_confirm = click.confirm(
195
+ " Require confirmation for risky-but-permitted actions?", default=pol.require_confirm)
196
+ pol.max_steps = click.prompt(
197
+ " Max desktop interaction steps per run", type=int, default=pol.max_steps)
198
+ pol.pause_seconds = click.prompt(
199
+ " Pause (seconds) between actions", type=float, default=pol.pause_seconds)
200
+ cfg.desktop_policy = pol.to_dict()
201
+
182
202
  cfg.save()
183
203
  click.echo(click.style(f"\nSaved config -> {cfg._path()}", fg="green"))
184
204
 
@@ -267,6 +287,33 @@ def cron_remove(name: str, path: str | None) -> None:
267
287
  click.echo(f"Removed cron job '{name}'.")
268
288
 
269
289
 
290
+ @cron.command("edit")
291
+ @click.argument("name")
292
+ @click.option("--schedule", default=None, help="New schedule expression.")
293
+ @click.option("--command", default=None, help="New shell command.")
294
+ @click.option("--goal", default=None, help="New swarm goal (replaces command).")
295
+ @click.option("--path", default=None, help="Workspace directory.")
296
+ def cron_edit(name: str, schedule: str | None, command: str | None,
297
+ goal: str | None, path: str | None) -> None:
298
+ """Edit an existing cron job NAME (change schedule/command/goal)."""
299
+ from .modules.system.cron import CronScheduler
300
+
301
+ if schedule is None and command is None and goal is None:
302
+ click.echo("Nothing to change: pass --schedule, --command, or --goal.")
303
+ return
304
+ cfg = _load_cfg(path)
305
+ cron_file = cfg.workspace / ".opencommand" / "cron.json"
306
+ sched = CronScheduler.load(cron_file)
307
+ try:
308
+ sched.edit(name, schedule=schedule, command=command, goal=goal)
309
+ except KeyError:
310
+ click.echo(f"No cron job named '{name}'.")
311
+ return
312
+ sched.save(cron_file)
313
+ click.echo(f"Updated cron job '{name}'.")
314
+ click.echo(f"Persisted to {cron_file}")
315
+
316
+
270
317
  @cron.command("status")
271
318
  @click.option("--path", default=None, help="Workspace directory.")
272
319
  def cron_status(path: str | None) -> None:
@@ -24,6 +24,8 @@ class OpenCommandConfig:
24
24
  batch_size: int = 4 # workers spawned per swarm batch
25
25
  n_gpu_layers: int = 0 # 0 = CPU; -1 = all GPU layers
26
26
  goal_file: str = "Goal.md" # session goal spec file name
27
+ # --- Desktop takeover safety policy (see modules/tools/desktop_safety.py) -
28
+ desktop_policy: dict = field(default_factory=dict) # empty -> safe defaults
27
29
 
28
30
  def save(self) -> None:
29
31
  self._path().parent.mkdir(parents=True, exist_ok=True)
@@ -45,6 +47,7 @@ class OpenCommandConfig:
45
47
  "batch_size": self.batch_size,
46
48
  "n_gpu_layers": self.n_gpu_layers,
47
49
  "goal_file": self.goal_file,
50
+ "desktop_policy": self.desktop_policy,
48
51
  }, indent=2)
49
52
 
50
53
  @classmethod
@@ -68,6 +71,7 @@ class OpenCommandConfig:
68
71
  batch_size=d.get("batch_size", 4),
69
72
  n_gpu_layers=d.get("n_gpu_layers", 0),
70
73
  goal_file=d.get("goal_file", "Goal.md"),
74
+ desktop_policy=d.get("desktop_policy", {}),
71
75
  )
72
76
 
73
77
  def _path(self) -> Path:
@@ -0,0 +1,41 @@
1
+ ---
2
+ name: desktop-linux
3
+ category: desktop-safety
4
+ updated: 2026-07-12
5
+ tags: [desktop, linux, safety, pyautogui]
6
+ ---
7
+
8
+ # Desktop Agent — Safe Operation on Linux
9
+
10
+ Guidance for the OpenCommand desktop takeover agent when driving a **Linux**
11
+ host (X11/Wayland). The goal is to accomplish UI goals without destabilizing
12
+ the session.
13
+
14
+ ## Hard rules (enforced by policy, do not bypass)
15
+ - Never trigger `Ctrl+Alt+Del`/`Ctrl+Alt+Backspace` (session kill),
16
+ `Ctrl+Alt+F1..F12` (VT switch), or `Super+L` (lock) unless the goal requires it.
17
+ - Never type destructive shell commands: `rm -rf`, `dd if=`, `mkfs`, `shutdown`,
18
+ `reboot`, `:(){:|:&};:` (fork bomb), or anything piped to `| sh` / `| bash`.
19
+ - Do not escalate to root (no `sudo`, no `pkexec`). If a polkit prompt appears,
20
+ STOP and report.
21
+
22
+ ## pyautogui on Linux
23
+ - `pyautogui.FAILSAFE = True` is always on: move the mouse to the top-left
24
+ corner to abort. Respect that escape hatch.
25
+ - Screenshot support needs `scrot` (or `gnome-screenshot`); install it if
26
+ `locateOnScreen` is needed. Wayland may require `gnome-screenshot`/`grim`
27
+ instead of `scrot`.
28
+ - `type()` targets the focused window. Click into the field first and confirm
29
+ focus before typing.
30
+ - Prefer `click(x, y)` via image matching over blind key combos.
31
+
32
+ ## Safe interaction patterns
33
+ 1. Capture screen → locate the target control (image match or window title).
34
+ 2. Click the control; `wait(1)` for the UI to settle.
35
+ 3. For text entry, click the field, then `type()` a small specific string.
36
+ 4. Verify with another capture before `done`.
37
+
38
+ ## Verification before `done`
39
+ - Confirm the intended window/state is visible and no error dialog is open.
40
+ - If an unexpected confirmation/save dialog appeared, report it rather than
41
+ dismissing system dialogs blindly.
@@ -0,0 +1,42 @@
1
+ ---
2
+ name: desktop-macos
3
+ category: desktop-safety
4
+ updated: 2026-07-12
5
+ tags: [desktop, macos, safety, pyautogui]
6
+ ---
7
+
8
+ # Desktop Agent — Safe Operation on macOS
9
+
10
+ Guidance for the OpenCommand desktop takeover agent when driving a **macOS**
11
+ host. The goal is to accomplish UI goals without destabilizing the session.
12
+
13
+ ## Hard rules (enforced by policy, do not bypass)
14
+ - Never trigger `Cmd+Q` (quit all / app), `Cmd+Shift+Q` (log out),
15
+ `Cmd+Ctrl+Power` (force restart), `Ctrl+Power` (dialog), or `Cmd+Option+Esc`
16
+ (force quit) unless the goal explicitly requires it.
17
+ - Never type destructive shell commands: `rm -rf`, `sudo`, `diskutil erase`,
18
+ `shutdown`, `reboot`, or anything piped to `| sh` / `| bash`.
19
+ - Do not approve a Touch ID / admin password prompt on the agent's behalf. If
20
+ one appears, STOP and report.
21
+
22
+ ## pyautogui on macOS
23
+ - `pyautogui.FAILSAFE = True` is always on: move the mouse to the top-left
24
+ corner to abort. Respect that escape hatch.
25
+ - macOS key names differ: use `command` (not `cmd`), `option` (not `alt`),
26
+ `control`, `shift`, `return`, `escape`, `space`, `delete` (backspace),
27
+ `backspace` (forward delete). The policy normalizes these.
28
+ - `type()` targets the focused window. Click into the field first and confirm
29
+ focus before typing. Note `typewrite` is the underlying call; accented
30
+ characters may need explicit key names.
31
+ - Prefer `click(x, y)` via image matching over blind key combos.
32
+
33
+ ## Safe interaction patterns
34
+ 1. Capture screen → locate the target control (image match or window title).
35
+ 2. Click the control; `wait(1)` for the UI to settle.
36
+ 3. For text entry, click the field, then `type()` a small specific string.
37
+ 4. Verify with another capture before `done`.
38
+
39
+ ## Verification before `done`
40
+ - Confirm the intended window/state is visible and no error dialog is open.
41
+ - If an unexpected confirmation/save dialog appeared, report it rather than
42
+ dismissing system dialogs blindly.
@@ -0,0 +1,44 @@
1
+ ---
2
+ name: desktop-windows
3
+ category: desktop-safety
4
+ updated: 2026-07-12
5
+ tags: [desktop, windows, safety, pyautogui]
6
+ ---
7
+
8
+ # Desktop Agent — Safe Operation on Windows
9
+
10
+ Guidance for the OpenCommand desktop takeover agent when driving a **Windows**
11
+ host. The goal is to accomplish UI goals without ever destabilizing the system.
12
+
13
+ ## Hard rules (enforced by policy, do not bypass)
14
+ - Never trigger `Win+L` (lock), `Win+R` (Run), `Win+X` (admin menu), `Win+I`
15
+ (Settings), `Alt+F4` on the desktop/shell, or `Ctrl+Alt+Del`.
16
+ - Never type destructive shell commands: `rmdir /s`, `del /q`, `format`,
17
+ `shutdown`, `deltree`, or anything piped to `iex`/`powershell -enc`.
18
+ - Never open an admin/UAC prompt on the agent's own behalf. If a UAC dialog
19
+ appears, STOP and report — do not click "Yes".
20
+
21
+ ## pyautogui on Windows
22
+ - `pyautogui.FAILSAFE = True` is always on: a human can slam the mouse into the
23
+ top-left corner to abort the agent instantly. Respect that escape hatch.
24
+ - `pyautogui.PAUSE` is set (default 0.5s) so actions are paced and interruptible.
25
+ - Use `click(x, y)` with coordinates from `locateOnScreen`/`locateCenterOnScreen`
26
+ rather than blind `key` combos wherever possible.
27
+ - Prefer `pyautogui.hotkey('ctrl', 'c')` over `key('ctrl+c')` for combos; the
28
+ policy only permits safe combos.
29
+ - `type()` writes to whatever has focus — only type after you have clicked into
30
+ the correct field and confirmed focus.
31
+
32
+ ## Safe interaction patterns
33
+ 1. Capture screen → identify the target control by image match or by reading
34
+ the window.
35
+ 2. Click the control (single left click). Wait for the UI to settle (use
36
+ `wait(1)`).
37
+ 3. If text entry is needed, click the field first, then `type()` a small,
38
+ specific string. Avoid pasting long shell one-liners.
39
+ 4. Verify the result with another screen capture before declaring done.
40
+
41
+ ## Verification before `done`
42
+ - Confirm the intended window/state is visible and no error dialog is open.
43
+ - If a confirmation or save dialog appeared unexpectedly, report it; do not
44
+ dismiss system dialogs blindly.
@@ -131,6 +131,25 @@ class CronScheduler:
131
131
  job.goal = goal
132
132
  return job
133
133
 
134
+ def edit(self, name: str, schedule: str | None = None,
135
+ command: str | None = None, goal: str | None = None) -> CronJob:
136
+ """Edit an existing job's schedule/command/goal in place.
137
+
138
+ Returns the updated job, or raises KeyError if no such job exists.
139
+ At least one of schedule/command/goal must be provided.
140
+ """
141
+ for job in self._jobs:
142
+ if job.name == name:
143
+ if schedule is not None:
144
+ job.schedule = schedule
145
+ job._parse()
146
+ if command is not None:
147
+ job.command = command
148
+ if goal is not None:
149
+ job.goal = goal
150
+ return job
151
+ raise KeyError(f"No cron job named {name!r}")
152
+
134
153
  def save(self, path: Path) -> None:
135
154
  """Persist serializable jobs to <workspace>/.opencommand/cron.json."""
136
155
  path.parent.mkdir(parents=True, exist_ok=True)
@@ -0,0 +1,202 @@
1
+ """Desktop control tool: realtime vision + mouse/keyboard action loop.
2
+
3
+ Captures the screen, asks a small embedded vision-language model what is
4
+ happening and what to do next, then executes the chosen action via
5
+ pyautogui/pynput. Used for fully interactive testing (e.g. clicking UI,
6
+ verifying behavior). The vision model is the built-in Qwen3-VL (or MiniCPM-V)
7
+ the Engine (no external server).
8
+
9
+ Safety: every action is vetted by a `DesktopPolicy` (configured via
10
+ `opencommand config`) BEFORE it is executed. Destructive actions (restart,
11
+ shutdown, deleting unspecified files, dangerous key combos) are refused. The
12
+ native action execution runs inside a subinterpreter pool (Python 3.14) so a
13
+ segfault in pyautogui/pynput cannot take down the supervisor; on older Python
14
+ it falls back to a thread pool. pyautogui FAILSAFE + PAUSE are always enabled
15
+ so a human can abort by slamming the mouse to the top-left corner.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import base64
21
+ import io
22
+ from dataclasses import dataclass
23
+
24
+ from ...core.tools import BaseTool
25
+ from ...engine.runner import LocalRunner
26
+ from .desktop_safety import DesktopPolicy
27
+
28
+ try:
29
+ import pyautogui
30
+ import mss
31
+ _HAVE_DESKTOP = True
32
+ except Exception: # pragma: no cover
33
+ _HAVE_DESKTOP = False
34
+
35
+
36
+ @dataclass
37
+ class DesktopStep:
38
+ observation: str
39
+ action: str
40
+ ok: bool
41
+ reason: str = ""
42
+
43
+
44
+ def capture_screen() -> bytes:
45
+ """Return a PNG screenshot of the primary monitor as bytes."""
46
+ if not _HAVE_DESKTOP:
47
+ raise RuntimeError("pyautogui/mss not available")
48
+ with mss.mss() as sct:
49
+ img = sct.grab(sct.monitors[1])
50
+ import PIL.Image
51
+ buf = io.BytesIO()
52
+ PIL.Image.frombytes("RGB", img.size, img.rgb).save(buf, format="PNG")
53
+ return buf.getvalue()
54
+
55
+
56
+ def _raw_act(action: str) -> None:
57
+ """Execute a high-level action string with NO policy checks.
58
+
59
+ This is the only function that touches pyautogui directly. It is run inside
60
+ an isolated subinterpreter so a native crash is contained. Callers must pass
61
+ already-vetted actions (see `act`).
62
+ """
63
+ if not _HAVE_DESKTOP:
64
+ raise RuntimeError("pyautogui not available")
65
+ action = action.strip()
66
+ if action.startswith("click("):
67
+ import re
68
+ m = re.match(r"click\((\d+),\s*(\d+)\)", action)
69
+ if m:
70
+ pyautogui.click(int(m.group(1)), int(m.group(2)))
71
+ elif action.startswith("type("):
72
+ pyautogui.write(action[len("type("):-1], interval=0.02)
73
+ elif action.startswith("key("):
74
+ pyautogui.press(action[len("key("):-1])
75
+ elif action.startswith("wait("):
76
+ import time
77
+ time.sleep(float(action[len("wait("):-1]))
78
+ else:
79
+ raise ValueError(f"unknown action: {action}")
80
+
81
+
82
+ # --- Native action isolation ------------------------------------------------
83
+ # The desktop tool drives real hardware through native extension modules
84
+ # (pyautogui/pynput). A crash there must not kill the supervisor. On Python
85
+ # 3.14 we use InterpreterPoolExecutor (each worker has its own interpreter +
86
+ # GIL); otherwise we fall back to a plain thread pool. Model runners are NOT
87
+ # isolated here (llama-cpp-python is not multi-interpreter safe) — only the
88
+ # native desktop action execution is.
89
+ def _make_executor(max_workers: int = 1):
90
+ if not _HAVE_DESKTOP:
91
+ return None
92
+ try:
93
+ from concurrent.futures import InterpreterPoolExecutor # type: ignore
94
+ return InterpreterPoolExecutor(max_workers=max_workers)
95
+ except Exception:
96
+ from concurrent.futures import ThreadPoolExecutor
97
+ return ThreadPoolExecutor(max_workers=max_workers)
98
+
99
+
100
+ _EXECUTOR = _make_executor()
101
+
102
+
103
+ def act(action: str, policy: DesktopPolicy | None = None) -> tuple[bool, str]:
104
+ """Vet ``action`` against ``policy`` and execute it if allowed.
105
+
106
+ Returns ``(ok, reason)``. When ``policy`` is None a safe default policy is
107
+ used (everything except the built-in forbidden set). Execution happens in
108
+ the isolated executor so native crashes are contained.
109
+ """
110
+ policy = policy or DesktopPolicy()
111
+ allowed, reason = policy.check(action)
112
+ if not allowed:
113
+ return False, f"BLOCKED: {reason}"
114
+ if not _HAVE_DESKTOP:
115
+ return False, "desktop control libs (pyautogui/mss) not installed"
116
+ if _EXECUTOR is None:
117
+ try:
118
+ _raw_act(action)
119
+ except Exception as e: # noqa: BLE001
120
+ return False, f"ERROR: {e}"
121
+ return True, "ok"
122
+ import concurrent.futures as cf
123
+ fut = _EXECUTOR.submit(_raw_act, action)
124
+ try:
125
+ fut.result()
126
+ except Exception as e: # noqa: BLE001
127
+ return False, f"ERROR: {e}"
128
+ _ = cf # keep import referenced when executor path is taken
129
+ return True, "ok"
130
+
131
+
132
+ class DesktopTool(BaseTool):
133
+ name = "desktop"
134
+ description = (
135
+ "Realtime computer control: capture screen, ask a small vision model what "
136
+ "to do, and execute mouse/keyboard actions. Every action is vetted by a "
137
+ "safety policy before execution; destructive actions are refused."
138
+ )
139
+ parameters = {
140
+ "type": "object",
141
+ "properties": {
142
+ "goal": {"type": "string", "description": "What to accomplish on screen."},
143
+ "steps": {"type": "integer", "description": "Max interaction steps (default 10)."},
144
+ },
145
+ "required": ["goal"],
146
+ }
147
+
148
+ def __init__(self, vision_runner: LocalRunner | None = None,
149
+ policy: DesktopPolicy | None = None) -> None:
150
+ self.vision = vision_runner
151
+ self.policy = policy or DesktopPolicy()
152
+
153
+ def run(self, goal: str, steps: int = 10) -> str:
154
+ if not _HAVE_DESKTOP:
155
+ return "ERROR: desktop control libs (pyautogui/mss) not installed"
156
+ if self.vision is None:
157
+ return "ERROR: no vision model configured for desktop tool"
158
+ # Pace actions and keep the human abort escape hatch enabled.
159
+ import pyautogui as _pg
160
+ _pg.FAILSAFE = True
161
+ _pg.PAUSE = self.policy.pause_seconds
162
+ max_steps = min(steps, self.policy.max_steps)
163
+ history: list[DesktopStep] = []
164
+ for i in range(max_steps):
165
+ b64 = base64.b64encode(capture_screen()).decode()
166
+ content = [
167
+ {"type": "text", "text": (
168
+ f"Goal: {goal}\nStep {i+1}. Describe the screen and decide the "
169
+ "next single action as: click(x,y) | type(text) | key(name) | "
170
+ "wait(secs) | done. Reply with ACTION: <action> and a short WHY.")},
171
+ {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}},
172
+ ]
173
+ resp = self.vision.chat([{"role": "user", "content": content}],
174
+ temperature=0.1, max_tokens=256)
175
+ text = resp["choices"][0]["message"].get("content") or ""
176
+ action = self._extract_action(text)
177
+ if action == "done" or action == "":
178
+ history.append(DesktopStep(text, "done", True, "completion"))
179
+ break
180
+ # Commander-style watch: record intent + enforce policy.
181
+ ok, reason = act(action, self.policy)
182
+ history.append(DesktopStep(text, action, ok, reason))
183
+ if not ok:
184
+ # Surface the block/error and stop rather than retry blindly.
185
+ return (f"STOPPED at step {i+1}: {reason}\n"
186
+ f"{self._summarize(history)}")
187
+ return self._summarize(history)
188
+
189
+ @staticmethod
190
+ def _extract_action(text: str) -> str:
191
+ for line in text.splitlines():
192
+ if line.strip().upper().startswith("ACTION:"):
193
+ return line.split(":", 1)[1].strip()
194
+ return ""
195
+
196
+ @staticmethod
197
+ def _summarize(history: list[DesktopStep]) -> str:
198
+ lines = [f"desktop loop: {len(history)} steps"]
199
+ for i, s in enumerate(history, 1):
200
+ status = "ok" if s.ok else "BLOCKED"
201
+ lines.append(f" {i}. {s.action} -> {status} ({s.reason})")
202
+ return "\n".join(lines)
@@ -0,0 +1,162 @@
1
+ """Desktop takeover safety policy for OpenCommand.
2
+
3
+ The desktop agent drives the real mouse/keyboard, so it is the single most
4
+ dangerous capability in the swarm. This module defines a declarative policy
5
+ that the Commander configures (via `opencommand config`) and that the
6
+ `DesktopTool` enforces *before* any action is executed. The policy is a
7
+ deny-list + allow-list hybrid:
8
+
9
+ * ``forbidden_actions`` — action types that must NEVER run (e.g. restart,
10
+ shutdown, sleep, logoff).
11
+ * ``forbidden_keys`` — key names / combos that are destructive or
12
+ system-level (e.g. ``win+r`` to open Run, ``ctrl+alt+del``, ``alt+f4``
13
+ on the desktop, ``cmd``-entering-admin prompts).
14
+ * ``forbidden_paths`` — files/dirs the agent may never delete or move
15
+ unless the goal explicitly names them (handled by the files tool; here we
16
+ just refuse ``key``/``type`` sequences that would trigger a destructive
17
+ shell command).
18
+ * ``allow_mouse`` / ``allow_keyboard`` / ``allow_type`` — coarse capability
19
+ switches so the user can lock the agent to observe-only or click-only.
20
+ * ``require_confirm`` — when True, destructive-but-permitted actions are
21
+ surfaced to the Commander for a confirmation step instead of auto-running.
22
+ * ``max_steps`` / ``pause_seconds`` — pacing so a runaway loop can be
23
+ interrupted (pyautogui FAILSAFE still lets a human yank the mouse to the
24
+ top-left corner to abort).
25
+
26
+ The policy is serializable to/from the workspace config so it persists and is
27
+ auditable. ``DesktopPolicy.check(action)`` returns ``(allowed, reason)`` and is
28
+ the single choke point the tool calls for every parsed action.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import re
34
+ from dataclasses import dataclass, field
35
+
36
+ # Action verbs the parser understands. Anything else is rejected by default.
37
+ KNOWN_ACTIONS = ("click", "type", "key", "wait", "done")
38
+
39
+ # System-level actions that are never safe for an autonomous agent to trigger.
40
+ DEFAULT_FORBIDDEN_ACTIONS = (
41
+ "restart", "shutdown", "sleep", "hibernate", "logoff", "lock",
42
+ "signout", "forcequit", "kill", "terminate",
43
+ )
44
+
45
+ # Key names / combos that are destructive or escape the agent's sandbox.
46
+ # Matched case-insensitively against the full key string the parser produces.
47
+ DEFAULT_FORBIDDEN_KEYS = (
48
+ "ctrl+alt+del", "ctrl+alt+delete", "alt+f4", "alt+shift+f4",
49
+ "win+l", "win+r", "win+x", "win+i", "cmd+shift+q", "cmd+q",
50
+ "cmd+ctrl+power", "power", "eject", "volume_mute",
51
+ )
52
+
53
+ # Key names that are individually forbidden (no combo needed).
54
+ DEFAULT_FORBIDDEN_KEY_NAMES = (
55
+ "win", "cmd", "super", "meta", "f4", "f13", "f14", "f15",
56
+ )
57
+
58
+ # Shell-ish tokens that, if typed, would escalate to a destructive command.
59
+ # The agent must not be allowed to type these into a terminal it controls.
60
+ DANGEROUS_TYPE_TOKENS = (
61
+ "rm -rf", "rm -r", "del /q", "deltree", "format ", "mkfs",
62
+ "shutdown", "restart", "poweroff", "halt", ":(){", "dd if=",
63
+ "sudo ", "runas ", "curl | sh", "wget | sh", "iwr | iex",
64
+ )
65
+
66
+
67
+ @dataclass
68
+ class DesktopPolicy:
69
+ """Declarative safety policy for the desktop takeover agent."""
70
+
71
+ allow_mouse: bool = True
72
+ allow_keyboard: bool = True
73
+ allow_type: bool = True
74
+ require_confirm: bool = False
75
+ max_steps: int = 10
76
+ pause_seconds: float = 0.5
77
+ forbidden_actions: tuple[str, ...] = DEFAULT_FORBIDDEN_ACTIONS
78
+ forbidden_keys: tuple[str, ...] = DEFAULT_FORBIDDEN_KEYS
79
+ forbidden_key_names: tuple[str, ...] = DEFAULT_FORBIDDEN_KEY_NAMES
80
+
81
+ # --- Serialization ----------------------------------------------------
82
+
83
+ def to_dict(self) -> dict:
84
+ return {
85
+ "allow_mouse": self.allow_mouse,
86
+ "allow_keyboard": self.allow_keyboard,
87
+ "allow_type": self.allow_type,
88
+ "require_confirm": self.require_confirm,
89
+ "max_steps": self.max_steps,
90
+ "pause_seconds": self.pause_seconds,
91
+ "forbidden_actions": list(self.forbidden_actions),
92
+ "forbidden_keys": list(self.forbidden_keys),
93
+ "forbidden_key_names": list(self.forbidden_key_names),
94
+ }
95
+
96
+ @classmethod
97
+ def from_dict(cls, d: dict) -> "DesktopPolicy":
98
+ if not d:
99
+ return cls()
100
+ return cls(
101
+ allow_mouse=d.get("allow_mouse", True),
102
+ allow_keyboard=d.get("allow_keyboard", True),
103
+ allow_type=d.get("allow_type", True),
104
+ require_confirm=d.get("require_confirm", False),
105
+ max_steps=d.get("max_steps", 10),
106
+ pause_seconds=d.get("pause_seconds", 0.5),
107
+ forbidden_actions=tuple(d.get("forbidden_actions", DEFAULT_FORBIDDEN_ACTIONS)),
108
+ forbidden_keys=tuple(d.get("forbidden_keys", DEFAULT_FORBIDDEN_KEYS)),
109
+ forbidden_key_names=tuple(d.get("forbidden_key_names", DEFAULT_FORBIDDEN_KEY_NAMES)),
110
+ )
111
+
112
+ # --- Enforcement ------------------------------------------------------
113
+
114
+ def check(self, action: str) -> tuple[bool, str]:
115
+ """Return (allowed, reason). ``action`` is the raw parsed verb+args.
116
+
117
+ The Commander is expected to call this for every action the vision
118
+ model proposes and to refuse anything not explicitly allowed. This is
119
+ the single choke point that keeps the takeover agent inside its lane.
120
+ """
121
+ action = action.strip()
122
+ if not action or action == "done":
123
+ return True, "no-op / completion"
124
+ verb = action.split("(", 1)[0].strip().lower()
125
+ if verb not in KNOWN_ACTIONS:
126
+ return False, f"unknown action verb '{verb}' (not in allow-list)"
127
+
128
+ if verb == "click":
129
+ if not self.allow_mouse:
130
+ return False, "mouse control disabled by policy"
131
+ return True, "click permitted"
132
+ if verb == "wait":
133
+ return True, "wait permitted"
134
+ if verb == "type":
135
+ if not self.allow_type:
136
+ return False, "typing disabled by policy"
137
+ text = action[action.find("(") + 1:action.rfind(")")].lower()
138
+ for tok in DANGEROUS_TYPE_TOKENS:
139
+ if tok in text:
140
+ return False, f"typed text contains destructive token '{tok.strip()}'"
141
+ return True, "type permitted"
142
+ if verb == "key":
143
+ if not self.allow_keyboard:
144
+ return False, "keyboard control disabled by policy"
145
+ keys = self._normalize_keys(action)
146
+ for name in keys:
147
+ if name in self.forbidden_key_names:
148
+ return False, f"forbidden key '{name}'"
149
+ combo = "+".join(keys)
150
+ for fk in self.forbidden_keys:
151
+ if fk in combo:
152
+ return False, f"forbidden key combo '{fk}'"
153
+ return True, "key permitted"
154
+ return False, f"action '{verb}' not permitted"
155
+
156
+ @staticmethod
157
+ def _normalize_keys(action: str) -> list[str]:
158
+ """Extract individual key tokens from a ``key(a+b+c)`` action."""
159
+ inner = action[action.find("(") + 1:action.rfind(")")]
160
+ # pyautogui hotkey uses '+' or ','; normalize both.
161
+ parts = re.split(r"[+,]", inner)
162
+ return [p.strip().lower() for p in parts if p.strip()]