# Desktop Hand — Configuration Reference > This document describes **every field** of `desktop-hand.config.json`, the loader-facing > **plugin `Config`** overrides in `index.js`, the configuration discovery order, path/variable > expansion and merge rules, and when a change takes effect. > > Sources of truth: `lib/config.js` (defaults, discovery, validation, normalization), > `index.js` (plugin `Config` schema), `lib/fs/policy.js` (filesystem policy semantics) and > `lib/security/limits.js` (action limits). When this document and the code disagree, > **the code wins** — please report the discrepancy. > > Other docs: [README (English)](../README.md) · [README (中文)](../README.zh.md) · > [Manual acceptance tests (中文)](./ACCEPTANCE.md) --- ## 1. Where the config file lives The file is plain JSON (`//` comments are **not** allowed in the real file — the `jsonc` example below is only for readability). ### 1.1 Discovery order At plugin activation `lib/config.js#findConfigFile()` looks for the first existing file: | # | Location | Notes | |---|----------|-------| | 1 | The path given by the plugin `Config` field **`configPath`** | Must exist; a missing explicit file **fails activation** (`CONFIG_MISSING`). Relative paths resolve against the plugin package directory. | | 2 | `/desktop-hand.config.json` | **Recommended.** `install.ps1` seeds this file on first install so it survives upgrades. Usually `C:\Users\\.dsh\desktop-hand.config.json`. | | 3 | `/desktop-hand.config.json` | The copy shipped inside the package (a usable example config). | | 4 | `/desktop-hand/config.json` | Legacy/short form. | | — | none found | Built-in defaults are used and a warning is logged (`no desktop-hand config file found; using built-in defaults`). | `DSH_HOME` comes from the `DSH_HOME` environment variable, falling back to `%USERPROFILE%\.dsh` (`~/.dsh`). The actual source path is shown in `/desktop-hand status` and by the `desktop_hand_status` tool. ### 1.2 When changes take effect The config is read **once per plugin activation**; there is **no file watcher**. After editing the file you must reload the plugin / restart DeepSeek Harness for the new policy, limits, approval modes or hotkey to apply. Grants and session tightening live in RAM only and always start empty after a restart. --- ## 2. Full annotated example The block below mirrors the shipped `desktop-hand.config.json` with comments added. It is a valid *starting point*, not a minimal file — every section is optional and is deep-merged over the built-in defaults. ```jsonc { "version": 1, "policy": { // Read-only zones: read/list/move/delete are denied for matching paths. "readonly": [ { "path": "${USERPROFILE}\\Documents\\私人", "reason": "用户配置的隐私目录(示例;按需修改)" }, { "glob": "**/.ssh/**", "reason": "SSH 密钥与配置" }, { "glob": "**/*.key", "reason": "私钥/密钥文件" }, { "glob": "**/id_rsa*", "reason": "SSH 私钥" }, { "glob": "**/id_ed25519*", "reason": "SSH 私钥" } ], // Write-protected zones: create/overwrite/move/delete are denied; reading is allowed. "writeProtected": [ { "glob": "**/.ssh/**", "reason": "SSH 配置禁止修改" }, { "glob": "**/*.key", "reason": "密钥文件禁止修改" } ], "delete": { // "trash" (recommended) | "forbid" | "direct" "mode": "trash", // Empty => /desktop-hand/trash. Relative paths resolve against DSH_HOME. "trashDir": "D:\\DSH_Trash", // When true, "direct" deletes additionally need a files grant with allowDirectDelete. "directDeleteRequiresUnlock": true, // Per-path delete-mode pins; the first matching entry beats "mode" above. "pathOverrides": [ { "path": "${USERPROFILE}\\Documents\\私人", "mode": "forbid", "reason": "隐私目录禁止删除" } ], // Deny deleting drive roots, the user home, the trash dir, the plugin dir and // the session workspace root/ancestors (equality only). "protectRoots": true }, // Protect OS installation roots from writes and deletion (reads are allowed). "protectSystemRoots": true, // Protect the plugin's own directory, audit log and trash store from all mutation. "protectPluginData": true, // Optional defense-in-depth: also apply read/write zones to DSH's own // read/read_image/list_dir/write/edit tools. Off by default. "enforceOnDshFsTools": false }, "limits": { "maxActionsPerCall": 4, // validator cap for one call (tools currently do 1 action) "maxClicksPerCall": 3, // click count cap "maxDragDurationMs": 10000, // drag duration cap "maxScrollAmount": 50, // wheel notches cap "maxTextChars": 2000, // typed text length cap (UTF-16 code units) "maxComboKeys": 4, // chord part count cap "maxMoveDurationMs": 5000, // pointer-move duration cap "sensitiveZones": { "enabled": true, // false => zones can NEVER be unlocked by any grant "unlockable": true, // Absolute virtual-desktop pixels, or edge-anchored (resolved once per monitor). "rects": [ { "name": "taskbar-bottom-strip", "edge": "bottom", "height": 56 }, { "name": "screen-top-left-corner", "x": 0, "y": 0, "width": 8, "height": 8 }, { "name": "screen-top-right-corner", "edge": "top-right", "width": 8, "height": 8 } ], // Regexes matched (case-insensitive) against the foreground window title. // A match makes type_text ask for approval instead of typing blindly. "foregroundTitlePatterns": [] } }, "input": { // "auto" | "windows" | "linux" | "macos" | "fake" | "disabled" "provider": "auto", "windows": { // Empty => try powershell.exe, then pwsh.exe on PATH. "powershellPath": "", // Resolved against the plugin package directory. "agentScript": "scripts/input-agent.ps1" }, "linux": { "xdotoolPath": "xdotool" }, "macos": { "cliclickPath": "cliclick" }, "clickHoldMs": 25, // how long a synthetic click holds the button "defaultMoveDurationMs": 0, // 0 = jump to the target "textIntervalMs": 8, // delay between typed characters "agentStartTimeoutMs": 10000 // PowerShell agent startup timeout }, "emergency": { "enabled": true, // Single key or "+"-joined chord. Pause is deliberate: F9 collides with // Blender's "Adjust Last Operation". "hotkey": "Pause", "pollIntervalMs": 60 }, "session": { // Defaults for /desktop-hand authorize when minutes/actions are omitted. "inputDefaultMinutes": 10, "filesDefaultMinutes": 5, "maxGrantMinutes": 30, "inputDefaultActions": 200, "filesDefaultActions": 50, "maxGrantActions": 1000 }, "approval": { // "ask" => tools/pre-execute asks the user for every matching call // "grant" => a live session grant is consent enough (no per-call prompt) // "deny" => always deny (read-only deployments) "fileMutations": "ask", // write_file / move_path / delete_path / restore_path "directDelete": "ask", // permanent (direct-mode) deletes "drag": "ask", // high-risk: drags can move files/windows "combo": "ask", // high-risk key chords, e.g. shift+a "text": "grant", // type_text (sensitive window titles still force an ask) "authorize": "ask", // desktop_hand_authorize tool "policyOverride": "ask" // desktop_hand_policy_override tool }, "audit": { "enabled": true, "dir": "", // empty => /desktop-hand/audit "maxFileBytes": 8388608, // rotate at 8 MiB; .1 and .2 generations are kept "logTypedText": false // false => typed text is stored as {length, preview} }, "screenshots": { "dir": "", // empty => /.desktop-hand/screenshots "maxBytes": 8388608 // oversized captures fail the call after capture } } ``` > **Note on the shipped example:** the `${USERPROFILE}\Documents\私人` example entries point at > a directory that may not exist. A prefix zone that matches nothing is harmless; edit or > delete the sample entries when you deploy so the policy reflects your machine. --- ## 3. Merge, expansion and path-resolution rules 1. **Deep merge.** The file is deep-merged over `DEFAULT_CONFIG` (`lib/config.js`). Objects merge recursively; **arrays replace** the default array entirely; `undefined` leaves the default. Unknown keys are preserved. 2. **Plugin `Config` overrides are applied after the file** (see §7) and before validation, so they win over the file for the fields they expose. 3. **Validation errors fail load** with a `ConfigError`; plugin activation fails loudly rather than silently disabling protection (`index.js` re-throws). 4. **Variable expansion** happens in every path/zone field and supports: `~`, `$VAR`, `${VAR}`, `${env:VAR}` and `%VAR%`. An unknown variable is left literal (it then becomes part of the path); Windows-style `%VAR%` works on every platform. 5. **Relative path bases** differ per field: | Field | Resolved against | |-------|------------------| | `policy.readonly[]`, `policy.writeProtected[]`, `policy.delete.pathOverrides[]` | the directory containing the config file (or the plugin dir if defaults are used) | | `policy.delete.trashDir` | `DSH_HOME` (the built-in default is already absolute: `/desktop-hand/trash`) | | `audit.dir` | `DSH_HOME` (default `/desktop-hand/audit`) | | `screenshots.dir` | the directory containing the config file (or the plugin dir when no config file is used); the default is empty ⇒ `/.desktop-hand/screenshots` | | `input.windows.agentScript` | the plugin package directory | --- ## 4. Zone entry syntax (readonly / writeProtected / delete.pathOverrides) Any entry in `policy.readonly`, `policy.writeProtected` or `policy.delete.pathOverrides` accepts: | Form | Example | Semantics | |------|---------|-----------| | Directory prefix (string) | `"C:\\Users\\me\\private"` | Denies the path itself and everything below it (case-insensitive on Windows). | | Glob (string) | `"**/.ssh/**"`, `"**/*.key"` | `*` and `?` never cross `/`; `**` matches zero or more directories; a trailing `/**` also matches the directory itself; a pattern without `/` matches the basename at any depth. | | Regex (string) | `"re:^D:\\\\secrets"` or `"/^D:[\\\\/]secrets/"` | Tested against separator-normalized paths (and also the native form on Windows). Invalid regexes fail config load. | | Object | `{ "path": "...", "reason": "why" }` | Same as the string forms, plus `reason` shown in denial messages and audit entries. The object may use `path`, `prefix`, `glob` or `regex` as the selector key. | `policy.delete.pathOverrides[]` entries additionally require `"mode": "trash" | "forbid" | "direct"`. **The first matching override wins**, before the global `policy.delete.mode`. --- ## 5. Field reference ### 5.1 Top level | Field | Type | Default | Meaning | |-------|------|---------|---------| | `version` | number | `1` | Config schema version. Any value other than `1` fails load. | | `policy` | object | see below | Filesystem policy (zones, delete mode, protections). | | `limits` | object | see below | Input action limits and sensitive screen zones. | | `input` | object | see below | Input provider backend and timing knobs. | | `emergency` | object | see below | Emergency-stop hotkey. | | `session` | object | see below | Grant defaults and clamps. | | `approval` | object | see below | Per-call approval policy for `tools/pre-execute`. | | `audit` | object | see below | Append-only audit log. | | `screenshots` | object | see below | Screenshot storage. | ### 5.2 `policy` | Field | Type | Default | Meaning | |-------|------|---------|---------| | `readonly` | array | `[]` | Read-only zones. A hit denies **read, list, write, move and delete** — before any delete-mode logic. | | `writeProtected` | array | `[]` | Write-protected zones. A hit denies **create, overwrite, append, move (both ends) and delete**; reads and listings stay allowed. | | `delete.mode` | `"trash"` \| `"forbid"` \| `"direct"` | `"trash"` | Global delete mode. `forbid` denies all deletes; `trash` moves into quarantine; `direct` permanently deletes (with unlock + confirmation). | | `delete.trashDir` | string | `""` ⇒ `/desktop-hand/trash` | Quarantine root. Must be non-empty (default path is filled in) or `trash` mode denies with `trash-unavailable`. Also protected from mutation by `protectPluginData`. | | `delete.directDeleteRequiresUnlock` | boolean | `true` | When true, `direct` mode additionally requires a files grant with `allowDirectDelete`. Set to `false` only for a deliberately destructive deployment. | | `delete.pathOverrides` | array | `[]` | Per-path mode pins; first match wins over `delete.mode`. | | `delete.protectRoots` | boolean | `true` | Deny deleting drive roots, the user home, the trash dir, the plugin dir and the session workspace root + its ancestors (equality only). | | `protectSystemRoots` | boolean | `true` | Deny writes/deletes inside OS roots: on Windows `C:\Windows`, `C:\Program Files`, `C:\Program Files (x86)`, `C:\ProgramData`, `C:\$Recycle.Bin`, `C:\System Volume Information`, `C:\Recovery`, `C:\bootmgr`, `C:\pagefile.sys`, `C:\hiberfil.sys`, `C:\swapfile.sys` plus `%SystemRoot%`, `%ProgramFiles%`, `%ProgramData%`, `%SystemDrive%` variants; on Linux/macOS `/etc`, `/usr`, `/bin`, `/sbin`, `/lib`, `/lib64`, `/boot`, `/proc`, `/sys`, `/dev`, `/System`, `/Library`. | | `protectPluginData` | boolean | `true` | Deny all mutation of the plugin package directory, `audit.dir` and `delete.trashDir` (the trash payload must stay mutable internally; model-facing tools are refused). | | `enforceOnDshFsTools` | boolean | `false` | Defense-in-depth: when true, DSH's own `read`, `read_image`, `list_dir`, `write`, `edit`, `write_file` calls are also checked against these zones. Off by default because those tools belong to other bundles. | ### 5.3 `limits` | Field | Type | Default | Valid range | Meaning | |-------|------|---------|-------------|---------| | `maxActionsPerCall` | integer | `4` | 1–100 | Cap for primitive actions requested by one call (`limits.validateActions`). The shipped tools each perform exactly one primitive action per call. | | `maxClicksPerCall` | integer | `3` | 1–10 | Max `count` for `desktop_hand_click`. | | `maxDragDurationMs` | integer | `10000` | 1–60000 | Max `duration_ms` for `desktop_hand_drag`. | | `maxScrollAmount` | integer | `50` | 1–1000 | Max wheel notches per `desktop_hand_scroll`. | | `maxTextChars` | integer | `2000` | 1–100000 | Max length of `desktop_hand_type_text` (UTF-16 code units; an empty string is rejected). | | `maxComboKeys` | integer | `4` | 1–10 | Max parts in a key chord (`desktop_hand_press_key`). | | `maxMoveDurationMs` | integer | `5000` | 0–60000 | Max `duration_ms` for `desktop_hand_move_mouse`. | | `sensitiveZones.enabled` | boolean | `true` | — | Enables sensitive screen-area protection for clicks and drag starts. | | `sensitiveZones.unlockable` | boolean | `true` | — | When false, a hit is always denied — no grant can unlock it. | | `sensitiveZones.rects` | array | taskbar strip + two 8×8 corners | — | Each entry is `{name, ...}`. Absolute rects use integer `x`/`y`/`width`/`height`; edge-anchored rects use `edge` (`bottom`, `top`, `left`, `right`, `top-left`, `top-right`, `bottom-left`, `bottom-right`) plus `width`/`height` where applicable and are resolved once per monitor. Defaults: `{name:"taskbar-bottom-strip", edge:"bottom", height:56}`, `{name:"screen-top-left-corner", x:0, y:0, width:8, height:8}`, `{name:"screen-top-right-corner", edge:"top-right", width:8, height:8}`. | | `sensitiveZones.foregroundTitlePatterns` | array of regex strings | `[]` | — | Case-insensitive regexes matched against the foreground window title. A match makes `desktop_hand_type_text` ask for approval (unless sensitive zones are unlocked) and is reported by `desktop_hand_foreground_window`/`press_key` as `SENSITIVE`. | ### 5.4 `input` | Field | Type | Default | Meaning | |-------|------|---------|---------| | `provider` | `"auto"` \| `"windows"` \| `"linux"` \| `"macos"` \| `"fake"` \| `"disabled"` | `"auto"` | `auto` = Windows → `windows`, Linux → `linux`, macOS → `macos`. `fake` is an in-memory test provider (real PNG screenshots, no real input). `disabled` makes every input tool fail with `input provider disabled`. | | `windows.powershellPath` | string | `""` | Empty ⇒ try `powershell.exe` then `pwsh.exe`. When set, it is the only candidate (a typo fails loudly). | | `windows.agentScript` | string | `"scripts/input-agent.ps1"` | Persistent JSON-lines PowerShell agent (resolved against the plugin dir). | | `linux.xdotoolPath` | string | `"xdotool"` | `xdotool` binary name or absolute path (required on Linux). | | `macos.cliclickPath` | string | `"cliclick"` | `cliclick` binary name or absolute path (required on macOS). | | `clickHoldMs` | integer | `25` | 0–2000. Button hold time inside a synthetic click. | | `defaultMoveDurationMs` | integer | `0` | 0–60000. Default pointer-move duration when the tool omits `duration_ms`. | | `textIntervalMs` | integer | `8` | 0–2000. Default delay between typed characters. | | `agentStartTimeoutMs` | integer | `10000` | 1000–120000. How long to wait for the Windows agent's `ready` event. | ### 5.5 `emergency` | Field | Type | Default | Meaning | |-------|------|---------|---------| | `enabled` | boolean | `true` | Start the system-wide hotkey listener at activation. When false, the user can still stop via `/desktop-hand stop` or `desktop_hand_emergency_stop`. | | `hotkey` | string | `"Pause"` | Non-empty single key or `+`-joined chord, e.g. `Pause`, `F9`, `Ctrl+Alt+Shift+F12`. Invalid names are rejected at startup (the plugin keeps running without a hotkey and logs a warning). **Only implemented on Windows.** | | `pollIntervalMs` | integer | `60` | 20–5000. `GetAsyncKeyState` polling interval of the hidden monitor process. | ### 5.6 `session` | Field | Type | Default | Valid range | Meaning | |-------|------|---------|-------------|---------| | `inputDefaultMinutes` | integer | `10` | 1–100000 | Default lifetime of an input grant. Must be ≤ `maxGrantMinutes`. | | `filesDefaultMinutes` | integer | `5` | 1–100000 | Default lifetime of a files grant. Must be ≤ `maxGrantMinutes`. | | `maxGrantMinutes` | integer | `30` | 1–100000 | Hard cap: requested minutes are clamped to `[1, maxGrantMinutes]`. | | `inputDefaultActions` | integer | `200` | 1–100000 | Default input action budget. | | `filesDefaultActions` | integer | `50` | 1–100000 | Default files action budget. | | `maxGrantActions` | integer | `1000` | 1–100000 | Hard cap: requested actions are clamped to `[1, maxGrantActions]`. | ### 5.7 `approval` Each value is `"ask"` (prompt per call through DSH's approval channel), `"grant"` (a live session grant is sufficient consent) or `"deny"` (always refuse). | Field | Type | Default | Applies to | |-------|------|---------|-----------| | `fileMutations` | `"ask"` \| `"grant"` \| `"deny"` | `"ask"` | `desktop_hand_write_file`, `move_path`, `delete_path` (trash mode), `restore_path` — and it is also consulted for direct deletes after `directDelete`. | | `directDelete` | `"ask"` \| `"grant"` \| `"deny"` | `"ask"` | `desktop_hand_delete_path` when the resolved mode is `direct`. | | `drag` | `"ask"` \| `"grant"` \| `"deny"` | `"ask"` | `desktop_hand_drag` (a drag can move files/windows, so it is treated as high risk). | | `combo` | `"ask"` \| `"grant"` \| `"deny"` | `"ask"` | `desktop_hand_press_key` when `keys` contains `+` (e.g. `shift+a`). Single keys are not gated. | | `text` | `"ask"` \| `"grant"` \| `"deny"` | `"grant"` | `desktop_hand_type_text`. A foreground title matching `limits.sensitiveZones.foregroundTitlePatterns` adds an approval ask even under `"grant"` (unless sensitive zones are unlocked). | | `authorize` | `"ask"` \| `"grant"` \| `"deny"` | `"ask"` | `desktop_hand_authorize` (the model-facing grant request). | | `policyOverride` | `"ask"` \| `"grant"` \| `"deny"` | `"ask"` | `desktop_hand_policy_override` (session tightening). | > **Important — `"ask"` needs a live approval channel.** If the DSH session approval policy is > `never` (or approval prompts are otherwise unavailable), **every `"ask"` category fails > closed**: the call is rejected before the tool body runs and nothing is touched. "Ask" is not > an unconditional success path. To run the Blender acceptance flow (`shift+a` is a `combo`), the > user must either (a) switch the DSH approval policy to `ask` so prompts appear — recommended — > or (b) change the relevant categories (`combo`, `drag`, and `fileMutations`/`directDelete` for > file work) to `"grant"` and rely on the time/action-bounded session grant from > `/desktop-hand authorize`. > > For a `direct` delete the approval flow checks `directDelete` **and then** `fileMutations`; a > `deny`/`ask` from either applies (with the defaults you get one direct-delete prompt). ### 5.8 `audit` | Field | Type | Default | Meaning | |-------|------|---------|---------| | `enabled` | boolean | `true` | When false, `record()` is a no-op that creates nothing. | | `dir` | string | `""` ⇒ `/desktop-hand/audit` | Directory for `audit-.jsonl` files; created lazily. | | `maxFileBytes` | integer | `8388608` (8 MiB) | 1024–1 GiB. A new line that would exceed the cap rotates the file to `.1`; `.1`→`.2`; older generations are dropped (two generations kept). | | `logTypedText` | boolean | `false` | When false, typed text is stored as `{length, preview}` (first 40 chars + `…`); when true the clear text is stored. | ### 5.9 `screenshots` | Field | Type | Default | Meaning | |-------|------|---------|---------| | `dir` | string | `""` ⇒ `/.desktop-hand/screenshots` | Where `desktop_hand_screenshot` writes `shot-.png`. Created lazily. | | `maxBytes` | integer | `8388608` (8 MiB) | 1024–1 GiB. Checked **after** capture: an oversized PNG makes the tool call fail (the file may already be on disk). | --- ## 6. Plugin `Config` overrides (`index.js`) The profile patch row can set a small set of deployment-scoped knobs. They are applied **over** the JSON file (see §3) and validated by the plugin's Schemastery `Config`; the plugin **does not** read policy/limits/approval from here. | Field | Type | Default | Meaning | |-------|------|---------|---------| | `enabled` | boolean | `true` | `false` disables the plugin entirely (no tools, no command, no hotkey). | | `configPath` | string | `""` | Explicit config file. Empty = auto-discover. A missing file fails activation. | | `provider` | string | `"auto"` | Overrides `input.provider`. Ignored when empty or `"auto"`. One of `auto`, `windows`, `linux`, `macos`, `fake`, `disabled`. | | `trashDir` | string | `""` | Overrides `policy.delete.trashDir`. Ignored when empty. | | `auditDir` | string | `""` | Overrides `audit.dir`. Ignored when empty. | | `screenshotsDir` | string | `""` | Overrides `screenshots.dir`. Ignored when empty. | | `hotkey` | string | `""` | Overrides `emergency.hotkey`. Ignored when empty. | | `emergencyStop` | boolean | `true` | `false` overrides `emergency.enabled` to false. (There is no way to force-enable a disabled file setting from here.) | Example profile patch row (what `install.ps1` writes, extended): ```yaml - insert: - id: desktop-hand name: desktop-hand config: enabled: true # configPath: 'C:\path\to\desktop-hand.config.json' # provider: windows # hotkey: 'Ctrl+Alt+Shift+F12' ``` --- ## 7. Runtime-injected fields (not user configuration) After loading, `loadConfig()` additionally sets these read-only fields used by the runtime: `config.configPath`, `config.configDir`, `config.pluginDir`, `config.platform`, `config.dshHome`, `config.policy.delete.trashDir` (defaulted/expanded), `config.audit.dir` (defaulted/expanded). Every zone entry is normalized to `{kind: 'prefix'|'glob'|'regex', pattern, display, reason?}`. Do not put these in the JSON file; they are overwritten. Two extra keys are accepted by the code but are **not** part of the shipped schema and are not documented as stable API: - `input.fake` — options for the fake provider when `input.provider` is `"fake"` (for example `{ "screen": { "screens": [...] } }`). - `input.hotkeyMonitorScript` — overrides the bundled `scripts/hotkey-monitor.ps1` path. --- ## 8. Validation rules (what fails the plugin) The plugin fails activation with a clear `ConfigError` for: unsupported `version`; non-array `readonly`/`writeProtected`/`pathOverrides`/`sensitiveZones.rects`/ `foregroundTitlePatterns`; unknown `delete.mode`/`approval.*` value/`input.provider`; out-of-range numeric limits; an empty `emergency.hotkey`; a grant default larger than `session.maxGrantMinutes`; an invalid zone regex; or an unreadable/unparseable config file. Malformed configuration is never silently downgraded to defaults. --- ## 9. Practical presets | Goal | Settings | |------|----------| | Read-only assistant (no file changes at all) | `approval.fileMutations: "deny"`, `approval.directDelete: "deny"`, `policy.delete.mode: "forbid"`. Input tools still need grants. | | Normal safe desktop use | Defaults (trash delete; `fileMutations`/`directDelete`/`drag`/`combo` ask; `text` grant). Requires a live approval channel for those categories. | | GUI acceptance flow (Blender etc.) with approval policy `never` | Either switch the DSH approval policy to `ask`, or set `approval.combo: "grant"` (and `drag` if the flow drags) so an input session grant is sufficient; keep `fileMutations` deny/grant according to taste. | | Headless/no approval channel (file work allowed) | Set **every** category you rely on to `"grant"` — at minimum `fileMutations`, and `directDelete` only if needed; `drag`/`combo` too for those input actions. Grants still expire and stay action-budgeted. | | No real desktop (UI tests) | `input.provider: "fake"`. | | Absolute kill switch | `input.provider: "disabled"` and/or `enabled: false` in the patch row. | | Harden against DSH's own file tools | `policy.enforceOnDshFsTools: true` (still not an OS sandbox; shell tools bypass it). |