Desktop Hand
Real desktop mouse/keyboard control for DeepSeek Harness, plus a policy-guarded file toolkit with quarantined deletion, session authorization, an emergency-stop hotkey and an append-only audit log.
⚠️ HIGH RISK — READ THIS FIRST
Desktop Hand lets the model operate your physical desktop: move the real mouse pointer, click, drag, type, press shortcuts, take screenshots — and write, move, delete and restore real files on your machine.
- Real input and file mutations are OFF by default. They require an explicit, session-scoped, time- and action-budgeted grant that a human approves (default
approval.authorize: "ask"; if your DSH approval policy auto-approves prompts, that safeguard is weakened — see docs/CONFIG.md).- This is not a sandbox. It is not a VM, not a separate user account, and not an OS permission boundary. Desktop Hand runs with the same privileges as DeepSeek Harness and can touch anything that process can touch, except for the paths protected by policy.
- The filesystem policy governs Desktop Hand tools only (unless you opt in to
policy.enforceOnDshFsTools). Shell tools (bash,pwsh, …) are not governed and can bypass it.- Deletion defaults to trash/quarantine mode (recoverable), but
directmode is permanently destructive when a user unlocks it.- Start with the fake provider or a throwaway test account, walk through docs/ACCEPTANCE.md, and keep a hand on the emergency-stop hotkey. The default hotkey is Pause (deliberately not
F9: Blender usesF9for Adjust Last Operation, so F9 would fire while modelling).- Real GUI automation of applications such as Blender was not exercised end-to-end during development; the automated suite uses fake providers and unit tests only. Treat real-app acceptance as a manual step.
Contents
- What it is
- Architecture
- Requirements and platform support
- Installation
- Quick start
- Tools
- Slash commands
- Configuration
- Filesystem policy, delete modes and trash
- Security model and threat model
- Troubleshooting
- Development and tests
- License
What it is
Desktop Hand is a DeepSeek Harness (DSH) plugin that gives the model two carefully separated capabilities:
- Real desktop input — observe the screen (
get_pointer,foreground_window,screenshot) and act on the physical desktop (move_mouse,click,drag,scroll,press_key,type_text). The model works in a see → act → see loop: screenshot, view it with DSH'sread_image, perform one action, screenshot again. - Policy-guarded file operations —
read_file,list_dir,write_file,move_path,delete_path,restore_path,list_trash. Reads are unprivileged but still subject to protected zones; writes/moves/deletes need a separate files grant. Deletion defaults to a recoverable quarantine ("trash") with metadata, an append-only index and one-call restore.
Both capabilities are governed by: independent input / files session grants, per-call approval modes, action and coordinate limits, filesystem zones (read-only / write-protected / protected roots / sensitive screen areas), a system-wide emergency stop and a JSONL audit trail.
What it is not: a remote-desktop protocol, a sandbox, a background daemon, or a replacement
for DSH's own read/write/edit tools (use those for ordinary workspace editing).
Architecture
DSH host process
│
│ profile patch row ── cordis.patch.yml / install.ps1 (bundled patch)
▼
index.js ── plugin entry
│ • Config schema (deployment overrides: enabled/configPath/provider/trashDir/…
│ • registers 21 tools, the /desktop-hand command, the system-prompt guidance
│ • tools/pre-execute: authorization + approval + emergency-stop gate
│ • starts the emergency hotkey monitor
▼
lib/runtime.js ── per-activation wiring (created once, before tools)
│ ├── lib/config.js defaults · discovery · deep merge · validation · path expansion
│ │
│ ├── lib/tools/
│ │ input-tools.js observe + mouse/keyboard/text/screenshot (input grant)
│ │ fs-tools.js read/list/write/move/delete/restore/trash (files grant for writes)
│ │ control-tools.js authorize · revoke · status · policy_override · emergency_stop
│ │
│ ├── lib/security/
│ │ authorization.js per-session grants, budgets, unlocks, tightening
│ │ limits.js coordinate/duration/count caps, sensitive zones, title patterns
│ │ emergency.js halt/resume, AbortSignal, listeners
│ │
│ ├── lib/fs/
│ │ policy.js zones + protected roots → allow/deny decisions, delete modes
│ │ trash.js <trash>/items/<id>/payload + meta.json + index.jsonl
│ │ audit.js audit-<session>.jsonl, rotation, typed-text redaction
│ │
│ └── lib/input/
│ index.js provider factory (auto | windows | linux | macos | fake | disabled)
│ windows.js persistent PowerShell JSON-lines agent (scripts/input-agent.ps1)
│ linux.js xdotool one-shot commands
│ macos.js cliclick + osascript + screencapture
│ fake.js deterministic in-memory provider (tests / dry runs)
│ hotkey.js hidden scripts/hotkey-monitor.ps1 poller (Windows only)
│
└── audit files, trash store, screenshots (workspace .desktop-hand/screenshots)
Requirements and platform support
| Platform | Real input | Screenshots | Emergency hotkey | Notes |
|---|---|---|---|---|
| Windows 10/11 | ✅ full | ✅ full | ✅ default Pause | Primary target. Uses Windows PowerShell 5.1 (powershell.exe) with pwsh.exe as fallback, a persistent agent and a hidden hotkey monitor. No extra installs required. |
| Linux | ⚠️ best effort | ⚠️ best effort | ❌ none | Requires xdotool on PATH; screenshots need ImageMagick import, scrot or gnome-screenshot. Multi-monitor geometry is reported as one display. Emergency stop still works via /desktop-hand stop / desktop_hand_emergency_stop; the provider also checks the stop-flag file. |
| macOS | ⚠️ best effort | ⚠️ best effort | ❌ none | Requires cliclick; uses osascript, pbcopy+Cmd+V for typing (this changes the clipboard) and screencapture. Scrolling and middle-click are unsupported; drag is left-button only; Accessibility/Automation permissions must be granted manually. No hotkey monitor. |
| fake | — (simulated) | ✅ writes a real PNG | ❌ none | Deterministic in-memory provider for tests/dry runs (input.provider: "fake"). Never touches the real desktop. |
| disabled | ❌ | ❌ | ❌ | input.provider: "disabled"; input tools fail closed. |
Runtime requirements: Node.js ≥ 22 (the plugin is ESM; package.json engines), DSH with a
profile, and for Windows PowerShell 5.1 or PowerShell 7. The test suite in this repo was run on
Node v26.13.0 on Windows.
Installation
The plugin package directory is desktop-hand/ (this repository/folder). It contains
index.js, lib/, scripts/, locale/, cordis.patch.yml, desktop-hand.config.json,
install.ps1, uninstall.ps1 and these docs.
Recommended: install.ps1
# DSH runs with DSH_PROFILE_DIR already set; otherwise pass it explicitly.
pwsh -File .\install.ps1 -ProfileDir "$env:DSH_PROFILE_DIR"
Parameters:
| Parameter | Default | Meaning |
|---|---|---|
-ProfileDir |
$env:DSH_PROFILE_DIR |
Target DSH profile (must contain node_modules/ and cordis.patch.yml). |
-SourceDir |
the script's directory | Plugin package to install. |
-DshHome |
$env:DSH_HOME or $HOME\.dsh |
Where desktop-hand.config.json is seeded. |
-SkipConfig |
off | Do not seed the config file. |
-Force |
off | Replace an existing installed copy (e.g. the app may be running). |
What it does:
- Validates the source package (
package.json,index.js, namedesktop-hand). - Backs up
cordis.patch.ymltocordis.patch.yml.bak-<timestamp>. - Refuses to continue if a hand-written
desktop-handrow already exists outside its managed markers — remove/move it first. - Copies the package to
<profile>/node_modules/desktop-hand(skipsnode_modules,.git,.desktop-hand,test,coverage). - Seeds
<DSH_HOME>/desktop-hand.config.jsononly if it does not exist (so your policy survives upgrades). - Writes an idempotent managed block into the profile's
cordis.patch.yml:
# >>> desktop-hand managed block (install.ps1) >>>
- insert:
- id: desktop-hand
name: desktop-hand
config: {}
# <<< desktop-hand managed block <<<
Manual profile patch
If you prefer not to run the script:
- Copy the package folder to
<profile>/node_modules/desktop-hand. - Add the
- insert:row shown above (without the markers is fine) to the end of the profile'scordis.patch.yml— the user patch layer applied after bundle layers. - Restart DSH (or reload the profile).
Development overlay (--patch)
DSH's launcher accepts extra patch layers via --patch <file> (launcher flags must come before
app arguments). A patch file is a YAML list with the same format as cordis.patch.yml; layers
compose in order: bundle layers → profile patch → --patch overlays.
For development you can keep the plugin package installed in the profile (run install.ps1
once, then -Force after each change) and enable/disable or reconfigure it per run through an
overlay instead of editing the profile patch:
# dev.patch.yml
- insert:
- id: desktop-hand
name: desktop-hand
config:
enabled: true
provider: windows
# Example: start a separate DSH instance with the overlay. Use the launcher command/flags of
# your DSH build; the launcher flag must come before app arguments.
dsh --patch .\dev.patch.yml
The overlay is a launcher feature, not a package manager: it copies nothing. An overlay row that
points at a local index.js path (instead of the package name) can work for the row itself in
some setups, but the plugin's bare @deepseek-ai/* imports still need a profile-installed
copy to resolve. So install the package first, then use --patch only for the row/config.
Restart / HMR
install.ps1 finishes with: "Restart DeepSeek Harness (or wait for profile HMR to pick up the
new package)." For a reliable deployment restart DSH. The configuration file is read once
per plugin activation (there is no watcher), so config edits also need a reload/restart.
Uninstall
pwsh -File .\uninstall.ps1 -ProfileDir "$env:DSH_PROFILE_DIR"
Removes the managed block (backing up the patch first) and the installed package. User data is
intentionally preserved: desktop-hand.config.json, the audit directory and the trash
directory are kept so old deletions can still be audited or restored.
Package layout
desktop-hand/
├── index.js plugin entry: Config, tools, approval gate, system prompt
├── lib/ runtime, config, tools, security, fs, input
├── scripts/
│ ├── input-agent.ps1 persistent Windows JSON-lines input agent
│ ├── hotkey-monitor.ps1 hidden GetAsyncKeyState poller (emergency hotkey)
│ └── live-smoke.mjs safe read-only live backend check (no input injection)
├── locale/{en,zh}.json plugin market metadata
├── cordis.patch.yml bundled bundle-layer patch
├── desktop-hand.config.json example/user-editable config seeded by install.ps1
├── install.ps1 / uninstall.ps1
├── docs/{CONFIG.md,ACCEPTANCE.md}
└── test/ node:test unit tests (fake provider)
Quick start
- Install and restart DSH, then open a session in a throwaway workspace.
- Check status (user-only, always allowed):
Verify/desktop-hand statusprovider,config,hotkey: Pause,delete mode: trash, and that both grants shownone. - Grant input for a bounded time and action budget:
(Add/desktop-hand authorize input 10 200sensitiveto unlock taskbar/system corners:/desktop-hand authorize input 10 200 sensitive.) Ask for a short budget; grants expire and are action-limited. - See the screen, then act one step at a time:
- the model calls
desktop_hand_get_pointeranddesktop_hand_foreground_windowto learn coordinates and confirm the target window; desktop_hand_screenshotsaves a PNG under<session cwd>/.desktop-hand/screenshots/;- the model must call DSH's
read_imagewith that path to actually see it; - one
desktop_hand_click/press_key/type_textper tool call, then screenshot again.
- the model calls
- File mutations need a separate grant:
With the default/desktop-hand authorize files 5 50approval.fileMutations: "ask", each write/move/delete/restore also needs a live approval prompt. If DSH approvals are disabled (never), these calls fail closed; see docs/CONFIG.md for"grant"mode. Drags and key chords (approval.drag/approval.combo) also ask by default — for the Blendershift+astep you need a working approval prompt, or those categories switched to"grant". - Stop instantly with
Pause, or type/desktop-hand stop. Only a human can/desktop-hand resume.
Dry run without touching the real desktop: set
"input": { "provider": "fake" }in the config, reload, and walk the same flow against the deterministic fake screen.
Tools
21 model-visible tools (input grant = session input authorization; files grant = session
file-mutation authorization; read-only tools need no grant but obey filesystem zones and are
still blocked while emergency-stopped, except status, revoke, emergency_stop).
| # | Tool | What it does | Grant / approval | Limits and notes |
|---|---|---|---|---|
| 1 | desktop_hand_get_pointer |
Current pointer position, every monitor's bounds, virtual-desktop bounds, foreground title/hwnd. | read-only — no grant | Also a useful coordinate probe before a click. |
| 2 | desktop_hand_foreground_window |
Title, native handle, pid of the foreground window; reports whether its title matches a configured sensitive pattern. | read-only — no grant | Use before typing/shortcuts to confirm the target app. |
| 3 | desktop_hand_move_mouse |
Move the real pointer to absolute virtual-desktop coordinates. | input grant | x,y required; duration_ms default 0 (jump), capped by limits.maxMoveDurationMs (5000). Consumes 1 input action. |
| 4 | desktop_hand_click |
Click a real mouse button at coordinates, or at the pointer when x/y are omitted. |
input grant | button left/right/middle (default left), count default 1 capped by limits.maxClicksPerCall (3), interval_ms default 80. Clicking a sensitive zone is refused unless the grant unlocked sensitive zones. Consumes 1 action. |
| 5 | desktop_hand_drag |
Press, move along a duration, release. | input grant; approval drag (default ask) |
from_*, to_* required; duration_ms default 500 capped at limits.maxDragDurationMs (10000); sensitive-zone check on the start point. Consumes 1 action. |
| 6 | desktop_hand_scroll |
Wheel scroll up/down at the pointer or at x/y. |
input grant | direction required; amount default 3 capped at limits.maxScrollAmount (50). Consumes 1 action. |
| 7 | desktop_hand_press_key |
Press a key or chord on the real keyboard, e.g. shift+a, ctrl+z, f12, win+r. |
input grant; approval combo for chords with + (default ask) |
keys required; hold_ms default 40; chord parts capped by limits.maxComboKeys (4). Consumes 1 action. |
| 8 | desktop_hand_type_text |
Type a Unicode string as real keyboard input into the foreground app. | input grant; approval text (default grant); a sensitive foreground title adds an approval ask |
text required (non-empty), capped by limits.maxTextChars (2000 UTF-16 code units); interval_ms default input.textIntervalMs (8). Typed text is redacted in the audit unless audit.logTypedText is true. Consumes 1 action. |
| 9 | desktop_hand_screenshot |
Capture all monitors or a region to a PNG in the session workspace. | input grant (screen content is private) | Optional region_x/y/width/height; PNG ≤ screenshots.maxBytes (8 MiB); consumes no action budget. Call DSH's read_image with the returned path to see it. |
| 10 | desktop_hand_read_file |
Read a UTF-8 text file with size/mtime metadata. | read-only — no grant | file_path; max_bytes default/cap 1 MiB; offset bytes; files > 64 MiB and binary files are refused; read-only zones deny with an explicit reason. No action consumed. |
| 11 | desktop_hand_list_dir |
List immediate entries (type/size/mtime, hidden included). | read-only — no grant | dir_path; max_entries default/cap 500; read-only zones deny. |
| 12 | desktop_hand_write_file |
Create or modify a UTF-8 text file. | files grant; approval fileMutations (default ask) |
file_path,content; mode overwrite (default)/append; create_dirs default false; zones and system/plugin-data protections apply. Consumes 1 files action. |
| 13 | desktop_hand_move_path |
Move or rename a file/directory. | files grant; approval fileMutations (default ask) |
source_path,dest_path; overwrite default false; checks both source (removal) and destination (creation) zones. Consumes 1 action. |
| 14 | desktop_hand_delete_path |
Delete a file/directory under the configured delete mode. | files grant; approval directDelete then fileMutations for direct; fileMutations for trash |
path; recursive; confirm_direct (required for permanent delete); reason (audited + stored). Trash mode moves the target into quarantine and returns trashId/trashPath; forbid denies; direct needs unlock + confirm_direct=true (+ recursive=true for dirs). Consumes 1 action. |
| 15 | desktop_hand_restore_path |
Restore one quarantined item by id. | files grant; approval fileMutations (default ask) |
trash_id; destination (default original path); overwrite default false. Destination is re-checked against zones. Consumes 1 action. |
| 16 | desktop_hand_list_trash |
List recent quarantine entries (id, original path, deleted time, size, reason). | read-only — no grant | limit default 50, cap 200; does not read payload contents. |
| 17 | desktop_hand_authorize |
Request a session grant from the model side. | approval authorize (default ask) |
mode input/files; minutes/max_actions clamped to session maxima; allow_sensitive_zones; allow_direct_delete; note. Prefer the human /desktop-hand authorize path. |
| 18 | desktop_hand_revoke |
Revoke this session's input and/or files grant immediately. | no grant — always allowed | mode input/files/all (default all). Works even while emergency-stopped. |
| 19 | desktop_hand_status |
Report grants, emergency state, provider, policy summary, dirs, approval modes. | read-only — always allowed | Includes a warning that it is high risk; it only reads state. |
| 20 | desktop_hand_policy_override |
Tighten this session's filesystem policy only. | approval policyOverride (default ask) |
add_readonly[], add_write_protected[], force_delete_mode: "forbid", reason. It is impossible to remove zones or relax the mode. |
| 21 | desktop_hand_emergency_stop |
Immediately halt all Desktop Hand operations and kill the input backend. | no grant — always allowed | reason (audited). Everything except status/revoke/emergency_stop is blocked until a human runs /desktop-hand resume. |
Notes:
- One primitive action per call. The system prompt tells the model to perform exactly one click/key-chord/text/drag per call and verify with a screenshot before the next. Grants are consumed after a successful primitive action (screenshot and read-only tools consume none).
- Grant groups are independent. An input grant never permits file mutations and vice versa.
- Tool actions and policy denials are audited. Most calls append an audit entry, and denials
(
input-denied,fs-denied) are recorded with the matching rule/reason.tools/pre-executedenies before the tool body runs when authorization/approval/emergency policy says no.
Slash commands
/desktop-hand is a user-only slash command. It never enters the model conversation, which is
why it is the authoritative consent path.
| Command | Effect |
|---|---|
/desktop-hand or /desktop-hand status |
Show halt state, input/files grants (expiry, actions used/max), provider, config path, audit/trash dirs, delete mode, hotkey, policy summary. |
/desktop-hand authorize input [minutes] [actions] [sensitive] |
Grant real mouse/keyboard/text/screenshot for this session. Defaults: session.inputDefaultMinutes (10) and inputDefaultActions (200); both clamped to the configured maxima. sensitive unlocks sensitive screen zones for this grant. |
/desktop-hand authorize files [minutes] [actions] [direct-delete] |
Grant file write/move/delete/restore. Defaults 5 min / 50 actions. direct-delete unlocks permanent deletion (each call still needs confirm_direct=true, and per-call approval when configured). |
/desktop-hand revoke [input|files|all] |
Revoke immediately (default all). |
/desktop-hand tighten readonly <pattern> |
Add a session-only read-only zone (same syntax as the config: path, glob, or /regex/). |
/desktop-hand tighten write <pattern> |
Add a session-only write-protected zone. |
/desktop-hand tighten delete=forbid |
Force delete mode forbid for this session. |
/desktop-hand resume (alias reset) |
Clear an emergency stop. Only the human can do this. |
/desktop-hand stop (alias halt) |
Emergency stop now. |
/desktop-hand policy |
Print the effective filesystem policy (configured zones, delete overrides). |
/desktop-hand help |
Show the grammar. |
Session tightening is additive only: it can add zones and force forbid, never remove zones
or relax the delete mode. It lives in RAM and disappears with the session/restart.
Configuration
The full annotated reference is docs/CONFIG.md. Summary:
- The JSON config is discovered in this order: plugin
Config.configPath(must exist) →<DSH_HOME>/desktop-hand.config.json(recommended; seeded by the installer) →<plugin-dir>/desktop-hand.config.json→<DSH_HOME>/desktop-hand/config.json→ built-in defaults (with a warning).DSH_HOMEdefaults to~/.dsh. - The file is deep-merged over defaults; arrays replace.
~,$VAR,${VAR},${env:VAR}and%VAR%are expanded in path fields. Invalid configuration fails plugin activation loudly rather than silently dropping protection. - A profile patch row can override a small set of deployment knobs (
enabled,configPath,provider,trashDir,auditDir,screenshotsDir,hotkey,emergencyStop); policy, limits and approval modes live in the JSON file. - The config is read once per activation; restart/reload after editing.
Key defaults you should know:
| Setting | Default | Why it matters |
|---|---|---|
policy.delete.mode |
trash |
Deletions are recoverable quarantine moves, not unlinks. The shipped config uses D:\DSH_Trash as the trash root. |
approval.fileMutations |
ask |
Every write/move/delete/restore needs a live approval prompt. |
approval.drag / approval.combo |
ask |
A drag and a key chord (e.g. Blender's shift+a) are high-risk primitives and each asks for approval by default. |
approval.text |
grant |
Typing relies on the input session grant, except when the foreground title matches limits.sensitiveZones.foregroundTitlePatterns — then it asks anyway. |
approval.directDelete |
ask |
Permanent deletes need an explicit per-call decision. |
emergency.hotkey |
Pause |
System-wide stop. F9 is deliberately avoided because Blender uses it for Adjust Last Operation. |
session.* |
10 min/200 input, 5 min/50 files; max 30 min/1000 | Grants are time- and action-budgeted. |
input.provider |
auto |
Windows → windows, Linux → linux, macOS → macos. Use fake for dry runs. |
"ask"is not unconditional success. If the DSH session approval policy isnever(or approval prompts are otherwise unavailable), every"ask"category fails closed — the call is rejected before the tool body runs and nothing is touched. For the Blender acceptance flow (shift+ais acombo), either switch the DSH approval policy toask(recommended) or set the relevant categories (combo,drag, andfileMutations/directDeletefor file work) to"grant"and rely on the time/action-bounded grant from/desktop-hand authorize.
Filesystem policy, delete modes and trash
Zones and precedence
Every Desktop Hand file tool normalizes the caller's path (relative paths against the session
working directory; ~/env expansion; Win32 namespace prefixes stripped; symlinks/junctions
resolved) and then checks zones against both the lexical and the real path, so a link cannot
be used to escape a zone.
Precedence is deliberate, security first:
- Read-only zone hit → deny read, list, write, move (source and destination), and delete.
This happens before any delete-mode logic, so a
directpin can never bypass it. - Write-protected zone hit → deny create/overwrite/append, move and delete; reads and listings are allowed.
- OS system roots (
protectSystemRoots, default true) → deny writes and deletes; reads are allowed. - Plugin data (
protectPluginData, default true): the plugin directory, the audit directory and the trash directory cannot be mutated by the tools. - Protected delete roots (
delete.protectRoots, default true): drive roots, the user home, the trash dir, the plugin dir, and the session workspace root and its ancestors cannot be deleted (or moved away). Equality only — children are fine. - Delete-mode resolution: session-forced
forbid> per-pathdelete.pathOverrides> globaldelete.mode. Indirectmode, a files grant withallowDirectDeleteis required unlessdirectDeleteRequiresUnlockis false; a non-empty directory also requiresrecursive=true. Intrashmode a configuredtrashDiris required. - Additionally,
desktop_hand_delete_pathrefuses to delete the session workspace root or one of its ancestors.
Delete modes
| Mode | Behaviour | Evidence / recovery |
|---|---|---|
forbid |
Every delete is denied (delete-forbidden). |
Audit fs-denied. |
trash (default) |
The file/directory is moved into <trashDir>/items/<id>/payload; a meta.json sidecar and an index.jsonl line are written first/after; the tool returns a trash id and restore hint. |
desktop_hand_list_trash, desktop_hand_restore_path. Cross-volume moves fall back to copy-then-remove. |
direct |
Permanent rm. Requires an unlocked files grant (allowDirectDelete), confirm_direct=true, recursive=true for non-empty directories, and (by default) a per-call directDelete approval; approval.fileMutations is also consulted. |
Audit fs/delete_direct; no recovery. |
Trash layout and restore
D:\DSH_Trash\ # policy.delete.trashDir (shipped config)
├── index.jsonl # append-only journal: quarantine + restore records
└── items\
└── 20261031T162738-1a2b3c4d\ # id = UTC timestamp + random hex
├── meta.json # id, originalPath, displayPath, payloadPath, size, type,
│ # mtime, deletedAt, sessionId, callId, tool, reason,
│ # policyRule, mode, moveKind, restoredAt, restoredTo
└── payload # the moved file or directory (link stays a link)
desktop_hand_delete_pathreturnsmode: "trash",trashId,trashPathand arestoreHintsuch asdesktop_hand_restore_path({ trash_id: "..." }).desktop_hand_restore_pathmoves the payload back to its recorded original path (or an explicit destination), re-checks the destination against the zones, and refuses to overwrite unlessoverwrite=true. On restoremeta.jsongainsrestoredAt,restoredTo,restoreCount,lastRestoreKind, and the index gets arestorerecord.meta.jsonis written before the payload is moved, so a crash cannot leave recoverable data without metadata.- The trash payload is never unlinked automatically; retention is your responsibility.
Session tightening
/desktop-hand tighten readonly|write <pattern> and /desktop-hand tighten delete=forbid
(and desktop_hand_policy_override) can only make the session more restrictive. There is no
tool or command that relaxes config zones or re-enables deletions.
Security model and threat model
Controls
- Two independent session grants.
inputandfilesare separate, in-memory, expire after the requested minutes and after an action budget. Revocation is immediate (/desktop-hand revoke,desktop_hand_revoke). Restarting DSH clears every grant. - Human consent path.
/desktop-hand authorize …is a user-only slash command; the model-facingdesktop_hand_authorizeis additionally gated byapproval.authorize(defaultask), so under the default approval mode the model cannot silently grant itself rights. A deployment that auto-approves prompts weakens this safeguard — keepapproval.authorizeonask/denyfor unattended-risk environments. - Approval gate (
tools/pre-execute). High-risk calls returnask/denybefore the tool body runs: authorization tools, policy overrides, file mutations, direct deletes, drags, key chords and text. Defaults:fileMutations,directDelete,dragandcomboareask;textisgrant(the input session grant is consent) but a sensitive foreground-title match asks anyway. If the DSH approval policy isnever, everyaskcategory fails closed — configure the categories asgrantor enable approvals deliberately. - Emergency stop. The default Pause hotkey is polled by a hidden PowerShell monitor that
writes
<DSH_HOME>/desktop-hand/emergency-stop.json; the runtime then halts, aborts in-flight input viaAbortSignal, kills the input backend and notifies the model. While halted, every Desktop Hand tool exceptstatus,revokeandemergency_stopis denied. Only a human can/desktop-hand resume.F9is not used because Blender binds it to Adjust Last Operation. - Hard limits. Coordinate validation (integer, inside a real monitor, not in a virtual-desktop
gap), action caps (
maxClicksPerCall,maxComboKeys, durations, scroll, text length), and sensitive-screen rectangles (taskbar strip, screen corners; clicks/drag starts denied unless the input grant unlocked them). Sensitive foreground-title patterns can force an approval before typing. - Filesystem policy. Zones, delete modes, protected roots and symlink-realpath checks as described above; a denial is an explicit, coded error with the matching rule and reason, never a silent skip.
- Audit. Append-only JSONL per session with rotation, plus typed-text redaction by default.
Tool-level denials (
input-denied,fs-denied) are recorded with the matching rule/reason, as are authorization changes and emergency stops. Authorization/approval denials decided intools/pre-executehappen before the tool body and therefore are not in the Desktop Hand audit log — use the rejection reason,/desktop-hand statusand DSH logs there.
Threat model and honest limitations
Desktop Hand assumes the human user is the principal and the model may be wrong, manipulated by prompt injection, or simply confused:
- A compromise is contained by the grant's time/action budget, the emergency hotkey, the trash's recoverability and the audit trail — not by an OS sandbox.
- Policy scope:
policy.*governs Desktop Hand's own tools only. Unless you setpolicy.enforceOnDshFsTools: true(which also guards DSH'sread/read_image/list_dir/write/edit/write_file), a shell command (bash,pwsh, …) can read or modify the same files, including paths inside read-only zones, and bypasses the delete modes entirely. Aforbidmode is not a filesystem permission. - Not an OS sandbox. The plugin runs with the DSH process's user privileges. It cannot protect you from a malicious process running as the same user, nor from the model being socially engineered into obtaining a broad grant.
- Screen privacy: screenshots capture everything visible on all monitors (or the requested
region).
type_textcan type secrets. Text is redacted in the audit by default (audit.logTypedText: false) but is still sent as real input. - Destructive paths remain: a human who unlocks
direct-delete, suppliesconfirm_directand approves can permanently delete files;trashDircontents are not auto-purged but can be deleted manually outside the plugin, and the plugin cannot protect them if you configure the trash on an untrusted/unprotected volume. - Approval dependency:
approval.fileMutations,approval.directDelete,approval.dragandapproval.combodefault toask; deployments without a live approval channel (e.g. DSH approval policynever, headless runs) will reject those operations. This is deliberate fail-closed behavior, not a bug. - Platform gaps: the emergency hotkey monitor is Windows-only; on Linux/macOS the
/desktop-hand stopcommand and the stop-flag checks are the emergency path. The Linux/macOS providers are best-effort, and macOS typing changes the clipboard. - Real-app verification: the automated suite uses fake providers. Real GUI automation (Blender, Office, browsers, …) has not been exercised end-to-end during development; use docs/ACCEPTANCE.md and your own careful pilot before trusting it with valuable work.
- Filesystem guard rails are not a backup: always keep independent backups of important data.
Troubleshooting
| Symptom | Likely cause / fix |
|---|---|
input provider ... unavailable / INPUT_MISSING_BINARY |
Windows: ensure PowerShell works (powershell.exe or pwsh.exe). Linux: install xdotool (or set input.linux.xdotoolPath). macOS: install cliclick and grant Accessibility/Automation permissions. Use input.provider: "fake" for a dry run. |
| Input tools denied with "authorization is not active" | Run /desktop-hand authorize input <minutes> <actions> (or unlock sensitive zones). Grants expire and are action-budgeted — check /desktop-hand status. |
| File writes/deletes denied by approval | Default approval.fileMutations: "ask" needs a live approval prompt; with DSH approval policy never it fails closed. Set "grant" in desktop-hand.config.json and restart if a session grant should suffice. |
A drag or key chord (e.g. shift+a) is denied by approval |
approval.drag and approval.combo default to ask; under DSH approval policy never they fail closed. Switch the approval policy to ask (get prompts) or set those categories to "grant" and rely on the input session grant. |
| Safe backend check without injecting input | Run node scripts/live-smoke.mjs from the package directory. It starts the Windows backend, reads screen/pointer/foreground, saves a PNG under %TEMP%\dh-live-home\live-shot.png and verifies the stop-flag guard; it never moves the mouse, clicks or types. |
file operation denied by policy: ... read-only zone ... |
The path matches policy.readonly (or a session tightening). This is intentional; remove the zone in the config or use a different path. Do not try to bypass with shell commands — that defeats the point of the policy. |
delete-forbidden |
Delete mode is forbid (globally, by override, or session tighten delete=forbid). Change the config/reload, or note the session tightening is permanent for that session. |
direct-delete-locked / confirm_direct required |
Authorize files with direct-delete, pass confirm_direct=true, and use recursive=true for non-empty directories; per-call approval may still be required. |
grant expired / action budget exhausted |
Re-run /desktop-hand authorize …; a re-grant replaces the grant with a fresh budget (it does not stack). desktop_hand_screenshot does not consume actions. |
| Screenshot saved but "I can't see it" | The model must call DSH's read_image with the returned path; screenshots are files under <session cwd>/.desktop-hand/screenshots/. |
| Emergency hotkey does nothing | Only implemented on Windows. Check the DSH log for emergency-stop hotkey warnings (invalid key, missing PowerShell/script, or platform unsupported). Use /desktop-hand stop instead. After a stop, only a human /desktop-hand resume clears it. |
| Every tool is blocked after a stop | That is the design; status, revoke and emergency_stop are the only allowed calls. Run /desktop-hand resume. |
| Plugin fails to activate after editing config | The config is validated strictly; read the error (invalid mode/limit/regex/version, unreadable file). A broken policy never falls back silently to defaults. |
| Restore refuses | desktop_hand_restore_path will not overwrite by default; pass overwrite=true (destination is re-checked against zones) or restore to a different destination. |
Development and tests
# from the desktop-hand package directory
npm test # node --test test/
npm run test:watch # node --test --watch test/
node --test test/
- Node.js ≥ 22 (ESM,
node:test). The suite currently contains 167 passing tests and requires no DSH runtime:defineToolis injected, providers are faked, and the config/policy/ auth/limits/emergency/audit/trash modules are unit-tested directly. - Test layout (
test/):config.test.js(defaults, discovery, merge, validation),policy.test.js(zones, precedence, delete modes, protected roots, symlinks, tightening),auth.test.js(grants, expiry, budgets, revoke, sensitive/direct unlocks, tightening),limits.test.js(geometry, sensitive zones, caps),emergency.test.js(halt/reset/abort),audit.test.js(JSONL, rotation, redaction),trash.test.js(quarantine/restore/EXDEV fallback),input.test.js+keys.test.js(provider contract/backends and key parsing),tools-fs.test.jsandtools-input.test.js(tool handlers against fake runtimes), andintegration.test.js(runtime wiring, end-to-end flows and/desktop-handcommand handling). scripts/live-smoke.mjsis a safe, read-only live check of the Windows backend (node scripts/live-smoke.mjs): it reads screen/pointer/foreground, saves a PNG to%TEMP%\dh-live-home\live-shot.png, verifies the emergency-stop guard refuses an action, and never moves the mouse, clicks or types.- Real-desktop GUI automation is a manual acceptance procedure:
docs/ACCEPTANCE.md. The development configuration for dry runs is
input.provider: "fake".
Verified during development (cite as evidence, not as a blanket guarantee):
- The full automated suite passes: 167/167.
- A scratch DSH profile (headless template) installed the package into its
node_modulesand booted the plugin without activation warnings. - Safe live smoke on the development machine succeeded: 2560×1600 screen reported, pointer/foreground read, a 2560×1600 PNG screenshot written, the stop-flag guard refused an action, and the Pause hotkey monitor started/stopped cleanly.
- Real Blender/GUI input was NOT exercised — see docs/ACCEPTANCE.md.
Contributions: keep the code style, add tests for new tools/policy behavior, and update docs/CONFIG.md when you add a config field.
License
MIT — see the license field in package.json. (This package does not currently ship a
standalone LICENSE file; the package metadata declares MIT.)
Related docs: Configuration reference (docs/CONFIG.md) · Manual acceptance tests (docs/ACCEPTANCE.md) · 中文 README
No comments yet. Be the first to write one.