deepseek-coordination 0.1.0 → 0.1.2

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.
package/README.md CHANGED
@@ -2,59 +2,95 @@
2
2
 
3
3
  Cross-platform **atomic OS-interaction surface** for the DeepSeek Harness.
4
4
 
5
- The problem this project solves is narrow and concrete: an agent that talks to the operating
6
- system through *strings* gets the wrong answer whenever a path contains a space, a file carries a
7
- UTF-8 BOM, a line ending is CRLF, a document is not UTF-8, or a command is assembled by
8
- concatenation. This project replaces string assembly with an **argument-stable verb set** and makes
9
- encoding, byte order marks and line endings explicit and byte-exact.
5
+ An agent that talks to the operating system through *strings* gets the wrong answer whenever a path
6
+ contains a space, a file carries a UTF-8 BOM, a line ending is CRLF, a document is not UTF-8, or a
7
+ command is assembled by concatenation. This project replaces string assembly with an **argument-stable
8
+ verb set** and makes encoding, byte order marks and line endings explicit and byte-exact.
10
9
 
11
- ## What is here
10
+ The same verb surface is implemented natively on Windows and on POSIX, and can be driven either by an
11
+ agent through a single harness tool or by a person directly in a terminal.
12
12
 
13
- | Path | What it is |
14
- |---|---|
15
- | `src/host/lib/powershell5.1/*.ps1` | **The source of truth** for Windows PowerShell 5.1: the 16 modules listed in `tools/build.ps1`, plus the standalone `git.ps1` |
16
- | `src/host/lib/powershell7.6/*.ps1` | **The source of truth** for PowerShell 7.6: the same 16 modules ported to .NET, plus the standalone `git.ps1` |
17
- | `src/host/lib/*.sh` | **The source of truth** for POSIX: the 16 shell modules, plus the standalone `git.sh` |
18
- | `src/host/bin/dsh-os.ps1` | **Generated** Windows PowerShell 5.1 implementation (218,514 bytes) |
19
- | `src/host/bin/dsh-os7.ps1` | **Generated** PowerShell 7.6 implementation (255,565 bytes) |
20
- | `src/host/bin/dsh-os.sh` | **Generated** POSIX implementation (142,744 bytes) |
21
- | `src/host/bin/dsh-os.bat` | ASCII-only `cmd.exe` shim: sets code page 65001 and forwards arguments to the `.ps1`. Hand-written, not generated |
22
- | `src/host/command.txt` | The authoritative verb contract, one line per verb |
23
- | `src/host/spec/verbs.tsv` | Machine-readable verb table: positionals, flags, permission class, error codes. **86 verbs** |
24
- | `src/host/spec/charsets.tsv` | Charset table: IANA name to .NET code page to `iconv` name, with BOM bytes. **15 charsets** |
25
- | `src/host/docs/os-spec.md` | Full specification: contracts, error codes, platform constraints, golden matrix |
26
- | `src/host/docs/permission-model.md` | Authorization model: the four decisions, the file schema, the two enforcement layers |
27
- | `src/host/lib/powershell.md` | PowerShell 5.1 to 7.6 API-change reference. Not referenced by code, tests or `package.json`; not published |
28
- | `tools/build.ps1` | Assembles the generated scripts from the modules; `-Check` fails if they drift |
29
- | `tools/parse-check.ps1` | Parse-checks the PowerShell modules under the runtime that will execute them; also reports non-ASCII bytes and a BOM. Skips files that are not build inputs |
30
- | `tools/json-field.mjs` | Reads one field of an envelope by dotted path. Used by the shell harnesses, because `sed` cannot see into a nested object |
31
- | `tools/verify-bundle.mjs` | Verifies the loader's resolution contract without touching a live profile. **10 cases** |
32
- | `tools/verify-plugin-load.mjs` | Runs the real `apply()` path against a stub registry. **13 cases** |
33
- | `tools/verify-editions.mjs` | Proves both PowerShell artefacts are reachable and that the default is unchanged. **14 cases** |
34
- | `tests/golden.ps1` | Windows golden cases driving the real script through argv. **49 cases** |
35
- | `tests/golden-sh.sh` | POSIX golden cases, including that a failing call still emits an envelope. **116 cases** |
36
- | `tests/smoke-desktop-sh.sh` | Asserts what the POSIX desktop verbs actually *inject*, using the fakes below. **52 cases** |
37
- | `tests/fake-xdotool`, `tests/fake-wmctrl` | Recording stand-ins for the X tools, so the desktop verbs can be verified on a machine with no X server |
38
- | `tests/encoding.ps1` | Encoding-contract guard (BOM, ASCII-only, line endings, BOM-free stdout). **32 build modules + the generated artefacts** |
39
- | `tests/conformance.ps1` | Asserts `command.txt` and `spec/verbs.tsv` describe the same surface: same verbs both ways, same count, same R/W class, same positional names. **5 checks** |
40
- | `tests/plugin.test.mjs` | Plugin execute path, argv handling, and bundle manifest shape. **13 cases** |
41
- | `tests/permission.test.mjs` | Consent delegate: rule matching, deny-first, trust, audit bounds, atomic write. **18 cases** |
42
- | `src/plugin/index.js` | Host plugin: registers the `dsh_os` tool |
43
- | `src/permission/index.js` | Consent delegate: the only writer of `permission.json` |
44
- | `src/client/` | Web client surface (Vite scaffold, not yet wired to the host) |
45
-
46
- The two PowerShell runtimes are kept in separate directories because they differ in ways that are not
47
- cosmetic: .NET Framework 4.8 has no `ProcessStartInfo.ArgumentList` and needs an ANSI-aware encoding
48
- path, while .NET 10 has both. Neither file has to carry the other's workarounds.
49
-
50
- ## Modules
51
-
52
- `src/host/lib/` holds one module pair per concern. `bin/dsh-os.ps1` and `bin/dsh-os.sh` are
53
- generated from them by `tools/build.ps1`, so never edit the generated files.
13
+ ```console
14
+ $ dsh-os selfcheck
15
+ {"ok":true,"verb":"selfcheck","result":{"script":"bin/dsh-os.ps1","capabilities":{...}},"meta":{...}}
16
+
17
+ $ dsh-os file charset "C:\path with spaces\notes.txt"
18
+ {"ok":true,"verb":"file.charset","result":{"charset":"utf-8-bom","bom":true,"eol":"crlf"},"meta":{...}}
19
+ ```
20
+
21
+ * **Fully open source**, MIT licensed. No telemetry, no account, no network request.
22
+ * **Zero runtime dependencies.** On Windows it is `Add-Type` P/Invoke; on POSIX it uses system tools
23
+ such as coreutils. Nothing is fetched at run time.
24
+ * **One self-contained file per platform**, assembled from modules by a deterministic build.
25
+
26
+ > [!WARNING]
27
+ > This layer can **read your screen, capture screenshots, read and write the clipboard, synthesise
28
+ > mouse and keyboard input, start and kill processes, delete files and modify environment variables**.
29
+ > Before granting it any permission, read [Dangerous permissions](#dangerous-permissions).
30
+
31
+ ---
32
+
33
+ ## Why it is needed
34
+
35
+ The failure mode is always the same shape: a value becomes part of a string and is then reinterpreted
36
+ downstream.
37
+
38
+ | Fault | What goes wrong | What this project does |
39
+ |---|---|---|
40
+ | `C:\Program Files\a b.exe` | a space splits one path into two arguments | values are always **separate argv elements**, never spliced into a shell string |
41
+ | a UTF-8 BOM in the middle of text | every consumer sees `\uFEFF` | the BOM is stripped on read and **never** leaks into a result; it is restored on write |
42
+ | a CRLF file rewritten as LF | diff noise, broken scripts | reads report `eol`; writes restore the original convention |
43
+ | a GBK document read as UTF-8 | silent mojibake (or a silent fall back to `latin1`) | the encoding is declared explicitly; strict UTF-8 is tried first, then a fall back |
44
+ | a command built by concatenation | injection, quoting bugs | `execute.command` takes a real **argv array** and never goes through a shell |
45
+ | a byte offset landing inside a character | text is cut to pieces | offsets are validated against the encoding's character boundaries |
46
+
47
+ Every call returns **exactly one JSON envelope**, and a caller branches on `error.code` rather than
48
+ parsing prose — and a **failing** call emits a parseable envelope too, because a bare exit code tells
49
+ an agent nothing.
50
+
51
+ ---
52
+
53
+ ## Repository layout
54
+
55
+ ```
56
+ deepseek-coordination/
57
+ ├─ src/host/command.txt authoritative verb contract, one verb per line
58
+ ├─ src/host/spec/verbs.tsv machine-readable verb table (single source of truth), 86 verbs
59
+ ├─ src/host/spec/charsets.tsv IANA name -> .NET code page -> iconv name -> BOM bytes, 15 charsets
60
+ ├─ src/host/lib/powershell5.1/*.ps1 source: Windows PowerShell 5.1 side, the 16 modules build.ps1 lists plus the standalone git.ps1
61
+ ├─ src/host/lib/powershell7.6/*.ps1 source: PowerShell 7.6 side, the same 16 modules plus the standalone git.ps1
62
+ ├─ src/host/lib/*.sh source: POSIX side, 16 shell modules plus the standalone git.sh
63
+ ├─ src/host/lib/powershell.md the 5.1 -> 7.6 API-change reference (referenced by no code, test or package.json; not published)
64
+ ├─ src/host/bin/dsh-os.ps1 [generated] Windows PowerShell 5.1 implementation
65
+ ├─ src/host/bin/dsh-os7.ps1 [generated] PowerShell 7.6 implementation
66
+ ├─ src/host/bin/dsh-os.sh [generated] POSIX implementation
67
+ ├─ src/host/bin/dsh-os.bat ASCII-only cmd.exe shim (hand-written, not generated)
68
+ ├─ src/host/docs/os-spec.md full specification: contracts, error codes, golden matrix
69
+ ├─ src/host/docs/permission-model.md the authorization model and its residual risk
70
+ ├─ src/plugin/index.js host plugin: registers the `dsh_os` tool
71
+ ├─ src/permission/index.js consent delegate: the only writer of permission.json, registers five `os_permission_*` tools
72
+ ├─ tools/build.ps1 assembles the three generated scripts; `-Check` fails on drift
73
+ ├─ tools/parse-check.ps1 parse-checks each module under the runtime that will execute it, and reports non-ASCII bytes and a BOM
74
+ ├─ tools/json-field.mjs reads one field of an envelope by dotted path (used by the shell suites; `sed` cannot see into a nested object)
75
+ ├─ tools/verify-bundle.mjs verifies the loader's resolution contract (10 cases)
76
+ ├─ tools/verify-plugin-load.mjs runs the real `apply()` path (13 cases)
77
+ ├─ tools/verify-editions.mjs proves both PowerShell artefacts are reachable (14 cases)
78
+ ├─ tests/ golden (Windows/POSIX/desktop injection), table conformance, encoding, plugin, permission suites
79
+ │ ├─ conformance.ps1 command.txt and spec/verbs.tsv must describe the same surface (5 cases)
80
+ │ ├─ smoke-desktop-sh.sh what the POSIX desktop verbs actually inject (52 cases)
81
+ │ └─ fake-xdotool, fake-wmctrl recording stand-ins, so the desktop verbs can be verified with no X server
82
+ └─ permission.json authorization data (an empty file in the repository; that is the initial state)
83
+ ```
84
+
85
+ The two PowerShell runtimes live in separate directories because the differences are not cosmetic:
86
+ .NET Framework 4.8 has no `ProcessStartInfo.ArgumentList` and needs an ANSI-aware encoding path, while
87
+ .NET 10 has both. Keeping them apart means neither side has to carry the other's workarounds.
88
+
89
+ `src/host/lib/` holds one module per concern, mirrored across the three targets:
54
90
 
55
91
  | Module | Concern |
56
92
  |---|---|
57
- | `preamble` | strict mode, encoding pins, layout and repository-root discovery |
93
+ | `preamble` | strict mode, encoding pins, repository-root discovery |
58
94
  | `json` | error model, JSON serialisation, the envelope writer |
59
95
  | `command` | argument parsing (positional and `name="value"`), assertions, coercion |
60
96
  | `charset` | charset table, BOM policy, byte-level text model |
@@ -66,69 +102,581 @@ generated from them by `tools/build.ps1`, so never edit the generated files.
66
102
  | `task` | `task.*` |
67
103
  | `environment` | `environment.*` and `environment attribute.*` |
68
104
  | `service` | `service.*` |
69
- | `permission` | `permission.*` (three read-only verbs, three delegate-only writes) and `selfcheck` |
70
- | `window` | `window.*` / `mouse.*` / `keyboard.*` / `clipboard.*` / `wait.*` / observe |
105
+ | `permission` | `permission.*` (three read-only verbs and three delegate-only writes) and `selfcheck` |
106
+ | `window` | `window.*`, `mouse.*`, `keyboard.*`, `clipboard.*`, `wait.*`, the observation verbs |
71
107
  | `dispatch` | verb resolution and the dispatch table |
72
108
  | `entry` | run, emit, exit |
73
109
 
74
- A build step rather than dot-sourcing the modules at runtime, deliberately: the shipped script must
75
- stay one self-contained file that the harness and a human can invoke directly, with no module
76
- resolution and no relative-path assumptions. On Windows PowerShell 5.1 a sourced file without a BOM
77
- is decoded in the ANSI code page, which is the exact encoding fault this project exists to remove.
110
+ The shipped script is **one self-contained file** rather than dot-sourced modules at run time. On
111
+ Windows PowerShell 5.1 a sourced file without a BOM is decoded in the ANSI code page — which is
112
+ precisely the class of encoding fault this project exists to remove.
78
113
 
79
- ## Design rules
114
+ ---
80
115
 
81
- 1. **Values are separate argv elements.** Commands are never assembled into a shell string. This is
82
- what removes the Windows space-in-path problem: `C:\Program Files\a b.exe` is one argument.
83
- 2. **Byte-exact text handling.** Reads strip the BOM; every write restores the file's original BOM
84
- presence and line-ending convention. A BOM is never allowed to leak into text a caller sees.
85
- 3. **Assertions report, they do not throw.** `file verify charset` returns `match: false` alongside
86
- the observed value, so a caller can tell "the file is wrong" from "I asked wrongly".
87
- 4. **Read-only verbs never prompt; mutating verbs require explicit authorization** (`--allow`).
88
- 5. **Unsupported is never silent.** An unimplemented verb or an unavailable platform capability
89
- returns a typed error code, rather than succeeding with an empty result.
116
+ ## Getting started
90
117
 
91
- ## Usage
118
+ Not yet published to npm, so use it from source:
119
+
120
+ ```console
121
+ git clone https://github.com/eloyhere/deepseek-coordination.git
122
+ cd deepseek-coordination
123
+ ```
124
+
125
+ The scripts need no build or installation; run them directly:
92
126
 
93
127
  ```powershell
94
128
  # Windows
95
129
  src\host\bin\dsh-os.ps1 selfcheck
96
130
  src\host\bin\dsh-os.ps1 file charset "C:\path with spaces\file.txt"
97
131
  src\host\bin\dsh-os.ps1 file verify charset .\file.txt utf-8
98
- src\host\bin\dsh-os.ps1 environment attr get PATH --scope process
132
+ src\host\bin\dsh-os.ps1 environment attribute get PATH --scope process
133
+ ```
134
+
135
+ ```sh
136
+ # POSIX
137
+ src/host/bin/dsh-os.sh selfcheck
138
+ src/host/bin/dsh-os.sh file charset "./a file.txt"
139
+ ```
140
+
141
+ `dsh-os.bat` is for a person typing in `cmd.exe`. Callers that can pass an argv array — the harness,
142
+ Node's `execFile`, CI — should call the `.ps1` or `.sh` **directly** and bypass `cmd.exe` entirely,
143
+ because batch-file argument handling is itself a source of quoting bugs.
144
+
145
+ ### Using it from the harness
146
+
147
+ `package.json` declares `dsh.bundle.patch`, so the package installs into a DSH profile as a bundle and
148
+ registers one model-facing tool:
149
+
150
+ | Tool | Purpose |
151
+ |---|---|
152
+ | `dsh_os` | Run one verb of the surface and return its JSON envelope verbatim |
153
+
154
+ One tool rather than one per verb, for three reasons: the verb table is the contract and lives in
155
+ `src/host/spec/verbs.tsv`, so no schema needs regenerating when a verb is added; an agent discovers the
156
+ whole surface with a single `verb: "selfcheck"` call; and the envelope is already the stable result
157
+ shape, so there is no second contract to drift from the script's.
158
+
159
+ ```jsonc
160
+ // call shape
161
+ { "verb": "file charset", "arguments": ["path=\"./a.txt\""], "allow": false }
162
+ { "verb": "environment attribute get", "arguments": ["PATH"], "flags": ["--scope", "process"] }
163
+ ```
164
+
165
+ The implementation is spawned by absolute path with an argv array, never through a shell string, so a
166
+ value containing spaces stays one value.
167
+
168
+ **Choosing the PowerShell edition.** The default remains the 5.1 artefact (`bin/dsh-os.ps1`),
169
+ **deliberately unchanged**: a deployment that already works should not change behaviour underneath
170
+ itself because a plugin was upgraded. To select the 7.6 artefact (`bin/dsh-os7.ps1`):
171
+
172
+ * `powershellEdition: '7.6'` — plugin configuration;
173
+ * `DSH_OS_POWERSHELL_EDITION=7.6` — for a deployment that cannot edit its profile;
174
+ * `'auto'` — uses 7.6 only when both the artefact and a working `pwsh` are present.
175
+
176
+ Configuration beats the environment variable, and an explicit `scriptPath` disables the choice
177
+ entirely. `pwsh` is looked up as an explicit `DSH_OS_POWERSHELL`, then on `PATH`, then in the standard
178
+ install locations, because `pwsh` is not always on `PATH` — it is not on this repository's own
179
+ environment, while 7.6.6 is plainly installed in the standard location.
180
+
181
+ > [!IMPORTANT]
182
+ > **The bundle now loads in a live desktop session.** The plugin has since been installed into the
183
+ > active profile, and the six tools it registers — `dsh_os` plus the five `os_permission_*` tools —
184
+ > are present and callable in a running session. What is still **not** proven is the part that
185
+ > `tools/verify-bundle.mjs` cannot cover: that the *harness's own loader* composes the bundle by the
186
+ > same contract the verifier checks, rather than by a shortcut that happens to work here. The
187
+ > verifier remains the evidence; the live session is corroboration.
188
+
189
+ ---
190
+
191
+ ## JSON contract
192
+
193
+ Every call writes **exactly one** JSON envelope to stdout, UTF-8 **without** a BOM, terminated by a
194
+ single LF.
195
+
196
+ ```jsonc
197
+ // success
198
+ { "ok": true, "verb": "file.charset", "result": { "charset": "utf-8", "bom": false, "eol": "crlf" },
199
+ "meta": { "durationMs": 3, "platform": "win32", "shell": "pwsh" } }
200
+
201
+ // failure — still exactly one envelope, still on stdout
202
+ { "ok": false, "verb": "file.charset",
203
+ "error": { "code": "E_NOT_A_FILE", "message": "…", "hint": "…" },
204
+ "meta": { "durationMs": 2, "platform": "win32", "shell": "pwsh" } }
205
+ ```
206
+
207
+ * `error.hint` appears only when there really is an actionable suggestion.
208
+ * `--pretty` indents the output; `--quiet` emits only `result` (or only `error`).
209
+ * **stderr carries human-readable diagnostics only and never takes part in parsing.** On Windows,
210
+ setting `DSH_OS_TRACE=1` puts a failure trace on stderr.
211
+
212
+ ### `error.code` is authoritative
213
+
214
+ Exit codes are a coarse class, so that a shell can branch cheaply. **Never parse `error.message`.**
215
+
216
+ | Exit code | Class | Representative codes |
217
+ |---|---|---|
218
+ | 0 | success | — |
219
+ | 1 | usage | `E_USAGE` |
220
+ | 2 | type / value | `E_TYPE`, `E_CHARSET_UNKNOWN`, `E_BOUNDARY`, `E_RANGE`, `E_WINDOW_AMBIGUOUS` |
221
+ | 3 | authorization | `E_PERMISSION`, `E_ELEVATION_REQUIRED`, `E_PERMISSION_PROTECTED_PATH`, `E_PERMISSION_DELEGATE_ONLY`, `E_PERMISSION_FILE_UNTRUSTED`, `E_PERMISSION_DENIED_BY_RULE`, `E_PERMISSION_NO_ANSWERER` |
222
+ | 4 | platform unsupported | `E_NOT_SUPPORTED`, `E_NO_DISPLAY`, `E_ACCESSIBILITY_DENIED`, `E_ACCESSIBILITY_UNAVAILABLE` |
223
+ | 5 | target / encoding / IO | `E_NOT_FOUND`, `E_NOT_A_FILE`, `E_EXISTS`, `E_BINARY_CONTENT`, `E_CHARSET_MISMATCH`, `E_TRUNCATED`, `E_PARTIAL_WRITE`, `E_WINDOW_NOT_FOUND`, `E_IO` |
224
+ | 6 | timeout | `E_TIMEOUT` |
225
+ | 7 | internal / aborted | `E_INTERNAL`, `E_ABORTED` |
226
+
227
+ The Windows implementation registers **36** error codes; the POSIX implementation groups them with
228
+ `case`. The two agree on the class of every **named** code, but not on unnamed ones: Windows returns
229
+ 7, POSIX returns 5.
230
+
231
+ ### Calling convention
232
+
233
+ Values are always separate argv elements and are **never** spliced into a shell string. The two
234
+ spellings below are equivalent, but the same parameter must not be given twice in different forms:
235
+
236
+ ```sh
237
+ # positional form
238
+ dsh-os file create "./a.txt" "123" "utf-8-bom"
239
+
240
+ # named form — order is free; the effective order comes from the verb table's parameter list
241
+ dsh-os file create content="123" charset="utf-8-bom" path="./a.txt"
99
242
  ```
100
243
 
101
- Every call writes exactly one JSON envelope to stdout, UTF-8 without a BOM:
244
+ `E_USAGE` rejects: an unknown flag, an empty `--`, a value-taking flag with no value, a repeated named
245
+ argument, an unknown name, the same parameter given both positionally and by name, and surplus
246
+ positional arguments. Read operations are never asked for `--allow`; write operations always are.
102
247
 
103
- ```json
104
- {"ok":true,"verb":"file.charset","result":{"charset":"utf-8","bom":false,"eol":"crlf"},"meta":{}}
248
+ A verb is one to three tokens deep — `selfcheck`, `file charset`, `file verify charset` — and the
249
+ parser consumes the next token only when extending the verb is genuinely known to be possible, so a
250
+ path argument that happens to begin with a word is not swallowed into the verb.
251
+
252
+ ### Encoding, BOM and line endings
253
+
254
+ `src/host/spec/charsets.tsv` defines 15 canonical encoding names, each with aliases, a .NET code page,
255
+ an `iconv` name and BOM bytes:
256
+
257
+ `utf-8`, `utf-8-bom`, `utf-16le`, `utf-16be`, `utf-32le`, `utf-32be`, `gbk`, `gb18030`, `big5`,
258
+ `shift_jis`, `euc-jp`, `euc-kr`, `latin1`, `windows-1252`, `ascii`.
259
+
260
+ Detection sniffs the BOM first, then tries **strict** UTF-8, then GBK, then latin1. A read strips the
261
+ BOM and normalises `\r\n` to `\n` for output; **every write restores the file's original BOM presence
262
+ and line-ending convention.** Offsets are byte offsets in the half-open range `[start, end)` and must
263
+ fall on a character boundary, otherwise the call fails with `E_BOUNDARY` rather than cutting a
264
+ character in half.
265
+
266
+ A file is judged binary when a NUL byte appears in the first 8 KiB of the payload, which reports
267
+ `E_BINARY_CONTENT` instead of returning mojibake.
268
+
269
+ ---
270
+
271
+ ## The verb surface
272
+
273
+ `src/host/spec/verbs.tsv` **declares 86 verbs** and is the single source of truth. Some are declared
274
+ but not implemented; before relying on one, read
275
+ [What is actually available](#what-is-actually-available).
276
+
277
+ | Group | Verbs |
278
+ |---|---|
279
+ | `file` | `create` `charset` `encode` `length` `information` `list` `delete` `move` `copy` `mkdir` `append` `insert` `view` `search` |
280
+ | `file verify` | `charset` `encoding` `completion` |
281
+ | `execute` | `command` `synchronous` `asynchronous` `query` `kill` |
282
+ | `system` | `brand` `cpu` `gpu` `version` `info` |
283
+ | `task` | `list` `query` `show` `kill` `launch` |
284
+ | `service` | `list` `query` `start` `stop` `restart` |
285
+ | `environment` | `list` `get` `add` `set` `delete` |
286
+ | `environment attribute` | `list` `get` `add` `set` `delete` |
287
+ | `permission` | `query` `list` `audit` `grant` `revoke` `acl init` |
288
+ | `window` | `find` `list` `active` `close` `top` `bottom` `move` `resize` `minimize` `maximize` `restore` |
289
+ | `mouse` | `click` `context` `over` `wheel` `down` `up` `cursor` |
290
+ | `keyboard` | `down` `up` `press` `combination` `type` `codes` |
291
+ | `clipboard` | `read` `write` `paste` |
292
+ | `wait` | `window` `ms` |
293
+ | observation | `screenshot` `text` `structure` |
294
+ | self-check | `selfcheck` |
295
+
296
+ A few design points worth knowing before you start:
297
+
298
+ * **`file verify.*` asserts; it does not throw.** `file verify charset` returns `match: false`
299
+ alongside the observed value, so a caller can tell "the file is wrong" from "I asked wrongly".
300
+ `file verify completion` returns `complete: null` when no expectation was given — deliberately not
301
+ `false`.
302
+ * **`file insert`'s `--charset` is a flag, not a positional.** Inserted between `content` and `offset`
303
+ it would **silently displace `offset`**.
304
+ * **`file.delete` goes to the recycle bin on Windows by default.** On POSIX it needs `trash-put`,
305
+ otherwise it reports `E_NOT_SUPPORTED` — it does **not** silently delete for real.
306
+ * **`environment` has two levels.** A variable holds one string; an **attribute** (PATH, PATHEXT, LIB,
307
+ INCLUDE) holds an ordered list. A write returns `visibleToRunningProcesses`, because a `user` or
308
+ `machine` level write is invisible to processes that are already running.
309
+ * **`keyboard type` is Unicode-first.** Non-ASCII text goes through a clipboard paste, because
310
+ `KEYEVENTF_UNICODE` is unreliable when an input method is active.
311
+
312
+ ---
313
+
314
+ ## Dangerous permissions
315
+
316
+ This chapter is required reading before granting any permission. The layer is deliberately capable,
317
+ and the guarantees it offers are narrower than the feature list suggests.
318
+
319
+ ### What a fully authorized agent can do
320
+
321
+ | Capability | Verbs involved | Authorization |
322
+ |---|---|---|
323
+ | **Read the contents of your screen** | `screenshot`, `text`, `structure` | **read-only, never prompts** |
324
+ | **Read and write your clipboard** | `clipboard.read`, `clipboard.write` | `read` never prompts; `write` needs `--allow` |
325
+ | **Synthesise input** — clicks, drags, typing, chords | `mouse.*`, `keyboard.*` | `--allow` |
326
+ | **Manipulate your windows** — move, resize, close, raise | `window.*` | `--allow` |
327
+ | **Start and kill processes** | `task.kill`, `execute.*` | `--allow` |
328
+ | **Run an arbitrary command line** | `execute.synchronous`, `execute.asynchronous` | `--allow` |
329
+ | **Delete, move and overwrite files** | `file.delete`, `file.move`, `file.copy` | `--allow` |
330
+ | **Persistently modify your environment variables** | `environment.set`, `.add`, `.delete`, `attribute.*` | `--allow` |
331
+ | **Control system services** (declared, not implemented) | `service.start`, `.stop`, `.restart` | `--allow` + elevation |
332
+ | **Read process and system inventories** | `task.*`, `system.*` | **read-only, never prompts** |
333
+
334
+ Put plainly: `mouse.*` and `keyboard.*` mean an authorized agent can operate **any** program you can
335
+ operate, with your rights. And because it can read the screen, it can also watch what it has just
336
+ done. Inside the desktop there is no sandbox.
337
+
338
+ ### How authorization actually works
339
+
340
+ **Layer B — structural enforcement (always on, cross-platform).**
341
+
342
+ * Every mutating verb requires `--allow`. Without it the call returns `E_PERMISSION` and **nothing
343
+ happens**.
344
+ * **`permission.json` has three write verbs, but they go through the delegate and not through the
345
+ script's own authorization.** The table declares `permission grant`, `permission revoke` and
346
+ `permission acl.init` (all class `W`), while `permission query`, `permission list` and
347
+ `permission audit` are read-only. The flags of `grant` and `revoke` accept only `delegate` (besides
348
+ `allow`), so they can be called **only by the consent delegate**; the script itself cannot create a
349
+ persistent authorization with `--allow`, and reports `E_PERMISSION_DELEGATE_ONLY` when no delegate
350
+ is present.
351
+ * A hard **protected-path boundary** rejects any modification of the files that decide what this layer
352
+ may do: `permission.json` (and `.bak`), `src/host/command.txt`, the **four** `src/host/bin/dsh-os.*`
353
+ entry points (`.ps1`, `dsh-os7.ps1`, `.sh`, `.bat`), `src/host/spec/verbs.tsv` and
354
+ `src/host/spec/charsets.tsv`, plus any same-named temporary staged beside one of them. A violation
355
+ reports `E_PERMISSION_PROTECTED_PATH` with no side effect. Reads remain allowed so that you can
356
+ review the rules. The POSIX list has eight entries — the same list **without `dsh-os7.ps1`**.
357
+ * The check runs on the **resolved and normalised** path, compared segment by segment rather than by
358
+ regular expression, because `..`, an 8.3 short name or a trailing dot all walk around a regex.
359
+ `file.move` and `file.copy` check **both ends**, since moving a protected file is also a way of
360
+ destroying it.
361
+
362
+ **Layer A — kernel ACL (Windows, explicitly enabled, requires elevation).**
363
+
364
+ `permission acl init` appends an explicit **DENY** for `Everyone` (`S-1-1-0`) to the DACL of
365
+ `permission.json`, covering `FILE_GENERIC_WRITE | DELETE | WRITE_DAC | WRITE_OWNER`. It is idempotent,
366
+ and reports `E_ELEVATION_REQUIRED` when it cannot act rather than failing vaguely.
367
+
368
+ `selfcheck` reports `aclEnforced` by **reading the file's real security descriptor** and walking the
369
+ DACL. An earlier version read a flag from a state file, which claimed an enforcement that was not in
370
+ effect; that implementation has been deleted. `aclReason` explains a `false`, so the result can be
371
+ acted on rather than merely wondered at.
372
+
373
+ ### Four limitations that must be stated honestly
374
+
375
+ These are documented in `src/host/docs/permission-model.md` and repeated here because they change what
376
+ the guarantees above mean.
377
+
378
+ 1. **Persistent rules are recorded but not enforced at the verb layer.** The rules in `permission.json`
379
+ are matched by the consent delegate and its `os_permission_query` tool; the script reads the file
380
+ for **display only** (`permission list/query/audit`). No verb consults a rule before acting: the
381
+ real gates are `--allow`, the protected-path check, and (if enabled) layer A. A `deny-always` rule
382
+ does **not** currently stop a verb that arrives with `--allow`.
383
+ 2. **`E_PERMISSION_DENIED_BY_RULE` is registered but never thrown.** It is in the error table and maps
384
+ to exit code 3, but no code path produces it — consistent with point 1.
385
+ 3. **The in-script rule reader is not deny-first.** The script's `permission query` takes the **first**
386
+ matching rule regardless of whether it allows or denies. The delegate implements deny-first
387
+ correctly and has test coverage, so the two readers can disagree, and the script side is the weaker
388
+ one.
389
+ 4. **`execute` is a way around the protected-path boundary.** That boundary guards `file.*` and
390
+ `environment.*`. It cannot constrain a child process: `execute.synchronous` hands the command line
391
+ to `cmd.exe /d /s /c` on Windows and to `sh -c` on POSIX, so the program it starts can write
392
+ anything its own rights permit, `permission.json` included. Only layer A prevents that. When
393
+ `aclEnforced: false`, layer B cannot.
394
+
395
+ The consent delegate has one more **design gap** worth stating plainly: it registers five tools
396
+ (`os_permission_query`, `os_permission_grant`, `os_permission_revoke`, `os_permission_list`,
397
+ `os_permission_audit`), but it does **not** register an `approval/request` answerer. Its own design
398
+ document says it should, and that `permission grant` **must** go through a user-confirmed prompt. As
399
+ the code stands, the enforcement is a **tool description** telling the model to call
400
+ `os_permission_grant` only after the user has chosen; the code does not verify that a prompt ever
401
+ happened. Treat the delegate as trusted infrastructure, not as a check on the agent.
402
+
403
+ Two further points about layer A: it requires the harness and the delegate to run as **different
404
+ identities** (otherwise it degrades to layer B), and its POSIX equivalent is weaker — a mode change
405
+ that the same user can undo. `permission acl init` says as much in its own result.
406
+
407
+ ### Reviewing what has been granted
408
+
409
+ ```sh
410
+ dsh-os permission list # rules, defaults, and the MEASURED aclEnforced
411
+ dsh-os permission query file.delete "R:\project\**"
412
+ dsh-os permission audit --limit 50 # the append-only tail; the delegate trims to 200 entries
105
413
  ```
106
414
 
107
- `error.code` is authoritative; exit codes are a coarse class (0 ok, 1 usage, 2 type, 3 authorization,
108
- 4 unsupported, 5 target/encoding, 6 timeout, 7 internal).
415
+ If `permission.json`'s `updatedBy` is not `consent-delegate`, the file is treated as **untrusted**: its
416
+ rules are ignored, everything falls back to the defaults, and the reason is reported. A malicious rule
417
+ hand-edited into that file therefore does **not** take effect — it is ignored, and you are told.
418
+
419
+ To **create** a persistent decision you must use the delegate's tools. A wildcard target
420
+ (`target.value === "*"`) is refused unless `acknowledgeBroad: true` is passed explicitly, because
421
+ "agree to everything, permanently" has to be a separate decision and not the side effect of one click.
422
+
423
+ ---
424
+
425
+ ## What is actually available
426
+
427
+ Method: the platform dispatch tables are extracted and compared against the declared verb table, entry
428
+ by entry. An unimplemented verb **never fails silently**: it returns a named `E_NOT_SUPPORTED`
429
+ envelope.
430
+
431
+ | Platform | Declared | Implemented |
432
+ |---|---|---|
433
+ | Windows PowerShell 5.1 (`src/host/bin/dsh-os.ps1`) | 86 | **82** |
434
+ | PowerShell 7.6 (`src/host/bin/dsh-os7.ps1`) | 86 | **82** |
435
+ | POSIX (`src/host/bin/dsh-os.sh`) | 86 | **61** |
436
+
437
+ The two PowerShell runtimes have **identical** implemented sets, established by comparing the two
438
+ dispatch tables entry by entry rather than by eye.
439
+
440
+ ### Windows (5.1 and 7.6 alike)
441
+
442
+ **Implemented (82):** every `file.*` (including `file.encode`); `file verify.*`; `file.search`; all
443
+ five `execute` verbs (`command`, `synchronous`, `asynchronous`, `query`, `kill`); `system.*`; all of
444
+ `task.*` (including `task.launch`); `service.list/query`; the whole `environment` surface (including
445
+ `attribute.*`); the whole `permission` surface (including `grant`, `revoke`, `acl.init`); the whole
446
+ `window` surface; `screenshot`; `clipboard.read/write`; `wait.window/ms`; the whole `mouse` and
447
+ `keyboard` surfaces; `text`; `structure`; `selfcheck`.
448
+
449
+ **Not implemented (4):** `clipboard.paste`, `service.start`, `service.stop`, `service.restart`.
450
+
451
+ ### POSIX
452
+
453
+ **Implemented (61):** the whole `file` surface except `file.encode`; `file verify.*`; `file.search`;
454
+ all five `execute` verbs (including `execute.command`, which the Windows side also has); `system.*`;
455
+ `task.list`/`query`/`show`/`kill` (**not `task.launch`**); the whole `environment` variable level
456
+ (including persistent writes) and all five `attribute.*` verbs; the whole `permission` surface;
457
+ `wait.ms`; the whole `mouse` surface (7 verbs) and `keyboard` surface (6 verbs); `selfcheck`.
458
+
459
+ Of those 61, **13 need an X display** (the 7 `mouse.*` and 6 `keyboard.*`); the other 48 do not.
460
+
461
+ **Not implemented (25):** `file.encode`; all eleven `window.*`; `screenshot`, `text`, `structure`;
462
+ `clipboard.read`, `clipboard.write`, `clipboard.paste`; `wait.window`; `task.launch`; and all five
463
+ `service.*` (`list`, `query`, `start`, `stop`, `restart`). These fall through the dispatch table's
464
+ default branch to a **typed** `E_NOT_SUPPORTED`; they are not silently absent.
465
+
466
+ > [!NOTE]
467
+ > POSIX has no kernel API for persisting a variable, so `environment add/set/delete --scope user`
468
+ > edits `${HOME}/.profile` through a **managed block** delimited by
469
+ > `# >>> dsh-os managed environment >>>` and `# <<< dsh-os managed environment <<<`. Every change
470
+ > regenerates everything between the markers, so these operations are idempotent and a user can undo
471
+ > the lot by deleting the block. Writing bare assignments into the file directly would append
472
+ > duplicates on the second call and could not be undone. `--scope machine` is unavailable and reports
473
+ > `E_NOT_SUPPORTED`.
474
+ >
475
+ > `environment attribute add/set/delete` are **process scope only**; a persistent scope is refused
476
+ > with `E_NOT_SUPPORTED`. That is deliberate: POSIX has no kernel API for a list-valued variable, so
477
+ > persisting one means rewriting a shell startup file, and a wrongly reconstructed `PATH` there can
478
+ > make a machine's shells unusable.
479
+
480
+ > [!IMPORTANT]
481
+ > **On POSIX only `mouse.*` (7) and `keyboard.*` (6) need an X display.** With no `DISPLAY` they return
482
+ > a **typed** `E_NO_DISPLAY`; with a display but no `xdotool` they return `E_NOT_SUPPORTED` naming the
483
+ > missing tool.
484
+ >
485
+ > **`wait.ms` does not need one** — it is a sleep, so it can be accepted in full on this machine. The
486
+ > five `environment attribute.*` verbs do not either (they act on environment variables, not on the
487
+ > desktop).
488
+ >
489
+ > On this machine (no `DISPLAY`, no `xdotool`) the golden suite therefore asserts them as
490
+ > `E_NO_DISPLAY`: **that asserts the absence path, not that they work.** The injection path is covered
491
+ > by `tests/smoke-desktop-sh.sh` with stand-in tools; see [Tests](#tests).
492
+
493
+ > [!NOTE]
494
+ > What remains **structurally absent** on POSIX is `window.*` (11), `screenshot`, `text`, `structure`,
495
+ > `clipboard.*`, `wait.window` and all of `service.*`. `native.sh` (515 bytes) and `service.sh`
496
+ > (333 bytes) are deliberate empty stubs whose headers explain why: their POSIX counterparts are
497
+ > external programs (`wmctrl`, `import`, `xclip`, `scrot`) rather than linkable libraries.
498
+ >
499
+ > **`window.sh` is no longer a stub** (26 KB): it now implements `wait.ms`, all of `mouse.*` and all of
500
+ > `keyboard.*`, driving X11 through `xdotool` and checking for the display and the tools before
501
+ > touching anything. The remaining desktop verbs still return `E_NOT_SUPPORTED` from the dispatch
502
+ > table's default branch.
503
+
504
+ ### The two implementations are not equivalent
505
+
506
+ Same verb names and the same calling convention, but behaviour differs in several places. The
507
+ important ones:
508
+
509
+ | Aspect | Windows | POSIX |
510
+ |---|---|---|
511
+ | `execute.command` | implemented | implemented (real argv, no shell) |
512
+ | `file.encode` | implemented | not implemented |
513
+ | `file.delete` default | recycle bin | fails without `trash-put` |
514
+ | `file.search` literal match | case-insensitive | case-sensitive |
515
+ | `file.view` character boundaries | enforced (`E_BOUNDARY`) | **not checked at all** |
516
+ | `file.view --max-bytes` | honoured | ignored (`truncated` is always `false`) |
517
+ | files over 32 MiB | `E_TRUNCATED` | no limit |
518
+ | `--eol crlf` on create/append | exact bytes | adds one extra trailing CRLF |
519
+ | appending to a mixed-EOL file | existing endings untouched | the whole file is renormalised |
520
+ | a character that cannot be represented | silently written as `?` | `E_ENCODING_INVALID` |
521
+ | `--pretty` / `--quiet` | honoured | accepted and ignored |
522
+ | `--` | `E_USAGE` | end-of-options marker |
523
+ | protected-path comparison | case-insensitive, normalised | case-sensitive, as given |
524
+ | unregistered error code | exit code 7 | exit code 5 |
525
+ | `meta.durationMs` on failure | the real duration | `0` |
526
+
527
+ Measured here, because prose is not evidence: on POSIX,
528
+ `file view <utf-8 file> utf-8 0 2` returns `ok` with `"text":"\ufffd"` — an offset of 2 lands inside
529
+ the first three-byte character and the character is split, where Windows fails the same call with
530
+ `E_BOUNDARY`.
531
+
532
+ `file.search`'s `--type` is a latent defect on both implementations: the code reads it, but it is not
533
+ registered as a known flag, so behaviour depends on the parser's fallback path.
534
+
535
+ ---
536
+
537
+ ## Installation status and known defects
538
+
539
+ Stated plainly, so that nobody is caught out.
540
+
541
+ ### The bundle's load path is now covered by three verifiers
542
+
543
+ This section previously recorded that the loader's end-to-end path was **unverified**. That gap has
544
+ been closed by three verifiers, and they found one real defect:
545
+
546
+ | Verifier | What it proves | Cases |
547
+ |---|---|---|
548
+ | `tools/verify-bundle.mjs` | the loader's **resolution** contract: the manifest header, the patch file, every row resolving from a profile-style `node_modules`, every resolved module exporting the Cordis shape the loader calls (`apply`/`inject`/`name`), the two rows resolving to **different** modules (a shared specifier registers the same tool twice and aborts the whole entry), and the version gate | 10 |
549
+ | `tools/verify-plugin-load.mjs` | the real `apply()` path: the host plugin registers `dsh_os` with a parameters schema whose **root type is `object`**, the permission plugin registers its five tools, and **the registered handler really does spawn the script** and return a real envelope (`selfcheck` → ok, `verbCount` 86); a mutating verb without `allow` is refused with a typed `E_PERMISSION` rather than throwing | 13 |
550
+ | `tools/verify-editions.mjs` | edition selection: the default is still PowerShell 5.1; `powershellEdition: '7.6'` and `DSH_OS_POWERSHELL_EDITION` select the 7.6 artefact; `'auto'` uses 7.6 only when the artefact and a working `pwsh` are present **together**; configuration beats the environment; an explicit `scriptPath` disables the choice. `-ExecutionPolicy` is passed only to the 5.1 executable | 14 |
551
+
552
+ The bundle verifier links the package into a throwaway profile sandbox by junction, so resolution
553
+ happens exactly as it would in a profile, and the **active profile is never written to**.
554
+
555
+ The defect it found: `bin/dsh-os7.ps1` was previously **unreachable from the plugin** — it existed,
556
+ built and tested, but `resolveInvocation` only ever looked for `dsh-os.ps1`, so all the 7.6 work was
557
+ dead code from the plugin's point of view. It is now selectable, and **the default is unchanged**
558
+ (still 5.1), because a deployment that already works should not change behaviour merely because a
559
+ plugin was upgraded.
560
+
561
+ **Now observed live, though not through this verifier:** the package has since been installed into an
562
+ active desktop profile, and the six registered tools (`dsh_os` and the five `os_permission_*` tools)
563
+ are present and callable in a running session. That corroborates the verifier; it does not replace it,
564
+ because it does not establish that the loader composed the bundle by the same contract the verifier
565
+ checks.
566
+
567
+ ### The Windows golden suite's order dependency is fixed
568
+
569
+ This was recorded as `passed: 47 failed: 1` on a clean checkout, the failing case being
570
+ `parser/one-two-three-token-verbs`, which asserted `file length` against a fixture directory created
571
+ by a **later** case.
572
+
573
+ That defect is **fixed**: the parser case in `tests/golden.ps1` now creates its own fixture
574
+ (`$fixtureDir = Join-Path $sandbox 'parser\verify'`, then `New-Item` and `Set-Content`) and asserts
575
+ against it. The current measurement is **50 passed, 0 failed**.
576
+
577
+ ### Two real POSIX defects found by `tests/smoke-desktop-sh.sh`
578
+
579
+ Neither was visible after writing, and not by review either — because this machine has no `DISPLAY`,
580
+ they normally showed up only as "an `E_NO_DISPLAY` either way". Opening the injection path with
581
+ stand-in tools exposed both:
582
+
583
+ 1. **Validation ran after injection.** `mouse click 10 20 --count abc` **returned an error, but the
584
+ pointer had already been moved** — a call that reports failure had changed the state of the
585
+ machine. The Windows side validates every argument *before* injecting; the POSIX side now matches
586
+ it. (The same held for `mouse over abc 20`.)
587
+
588
+ 2. **A `fail` inside `$(...)` did not stop the verb.** The coordinate validator called `fail`, but the
589
+ caller wrote `x="$(window_int_arg ...)"`, so the `exit` ended only the subshell: the verb continued
590
+ with an empty value and **injected anyway** — `mouse over abc 20` **moved the pointer and returned
591
+ `ok`**. The three validators that reject input now **assign a variable** (`WINDOW_INT`,
592
+ `WINDOW_BUTTON`, `WINDOW_KEY`) which is read in the **caller's own shell**.
593
+
594
+ **This trap was already documented in this repository's `environment` module** (the comment on
595
+ `env_resolve_scope` explains precisely why it assigns rather than prints), and it was still
596
+ repeated here. Both defects are now pinned by three assertions each: invalid input must be
597
+ **rejected with nothing injected**.
598
+
599
+ ### Other gaps
600
+
601
+ * **`tests/conformance.ps1` did not exist**, although the header of `spec/verbs.tsv` claimed it did and
602
+ that it checked consistency with `command.txt`. **The file now exists** (see
603
+ [Conformance of the two tables](#conformance-of-the-two-tables)), so the header's claim is now a
604
+ fact. A comment naming a guard that is not there is worse than no comment, because it is read as a
605
+ guarantee.
606
+ * **The consent delegate never registers an approval answerer**, as described above.
607
+ * **The delegate reports `aclEnforced: false` unconditionally** in its `query` result instead of
608
+ measuring the ACL as the script does, so it **under-reports** protection when layer A is on.
609
+ * **This section previously recorded that `--shell`, `--env`, `--stdin` and `--background` were
610
+ accepted but never read, and that `--encoding` on Windows built a value that was never applied. Both
611
+ records were out of date, and both had been real defects; they are now fixed.**
612
+ * **Windows:** `--shell`, `--env`, `--stdin` and `--background` really are read (there are branches
613
+ for them in `execute.ps1`). `--encoding` genuinely **was inert** — it built `$enc` and never used
614
+ it, and `StandardOutputEncoding` appeared nowhere in the module set, so child output was always
615
+ decoded with the runtime default. It is now wired to the output and error streams. **Measured:**
616
+ with a child emitting the GBK bytes `D6 D0 CE C4`, the default decoding gives `����` (four
617
+ replacement characters) and `--encoding gbk` gives `中文` (two characters).
618
+ * **POSIX:** these flags genuinely were never read. They are now **explicitly refused** —
619
+ `--shell`, `--env`, `--stdin`, `--background` and `--encoding` all return `E_NOT_SUPPORTED` (exit
620
+ code 4) instead of being silently ignored. That is what this project's own contract requires: **an
621
+ unavailable capability must be an explicit error, never an empty success**, and a caller that
622
+ passed `--stdin`, had nothing fed in, and received a success envelope has been misled.
623
+ * **Some declared error codes really are unreachable.** A full-text search of both implementations
624
+ confirms **zero occurrences** of `E_ENCODING_INVALID` (for `file.encode`), `E_IS_A_DIR` (for
625
+ `file.delete`) and `E_ABORTED`. By contrast `E_CHARSET_MISMATCH` (3 sites), `E_BINARY_CONTENT` and
626
+ `E_TRUNCATED` all have real throw sites — so the table is not uniformly unreliable, but those three
627
+ should be read as unimplemented intentions rather than dependable return codes.
628
+ * **This section previously recorded that "`E_TIMEOUT` is never thrown by either side" — that record
629
+ was out of date and wrong.** It is thrown in `execute.ps1` (an `execute command` timeout) and in
630
+ `window.ps1` (a `wait window` timeout). Measured:
631
+ `execute command cmd.exe /c ping -n 10 127.0.0.1 --timeout 300 --allow` returns
632
+ `{"ok":false,...,"code":"E_TIMEOUT",...,"hint":"captured so far: stdout=86 bytes, stderr=0 bytes"}`
633
+ with exit code **6**. POSIX has `E_TIMEOUT` too (`json.sh` maps it to exit code 6).
634
+ * **`src/client/` is an untouched Vite scaffold.** It is not wired to the host: `index.html` refers to
635
+ `./src/main.ts` while the real file is `./src/client/main.ts`, and there is no `node_modules`, so
636
+ `npm run build` and `npm run dev` are currently meaningless.
637
+ * **`git` is not a required dependency, but it is not present in every target environment either**, so
638
+ no acceptance step depends on it. (The plugin's *installation* path does: the package manager
639
+ resolves the repository with `git ls-remote`, so a machine where the harness cannot find `git` on
640
+ `PATH` cannot install the bundle.)
641
+ * **PowerShell 7.6 is exercised, but not across the whole surface.** The 7.6 artefact definitely runs:
642
+ driving the plugin's real `resolveInvocation` path with `DSH_OS_POWERSHELL_EDITION=7.6` selects
643
+ `bin/dsh-os7.ps1` and the runtime that answers is **PowerShell 7.6.6**, while the default stays on
644
+ 5.1.19041.3031 — that is what `tools/verify-editions.mjs` asserts and what the 14 cases measured here
645
+ confirm. What has *not* been done is a run of every verb against `dsh-os7.ps1`; its fidelity as a port
646
+ rests on the identical 82-verb dispatch sets, a per-module function-name comparison and the window
647
+ group's golden cases. That is a real gap in the evidence, not a claim of full parity.
648
+
649
+ ### What is not proven
650
+
651
+ * That the loader composes the bundle **by the contract `tools/verify-bundle.mjs` checks**, rather than
652
+ by a path that happens to work on this machine. The weaker claim — that `dsh_os` and the five
653
+ `os_permission_*` tools appear and are callable in an active session — has since been observed.
654
+ * Complete verb parity between the two platforms — in neither direction.
655
+ * The enforcement effect of layer A on a machine where the harness and the delegate share one identity
656
+ — by construction that case degrades to layer B.
657
+ * Anything about macOS or non-Linux POSIX beyond code review: the POSIX suites are written for a GNU
658
+ userland and were not run anywhere else.
659
+
660
+ ---
109
661
 
110
662
  ## Tests
111
663
 
112
- ```powershell
113
- npm run test:all # encoding guard, Windows golden, plugin, permission, the three verifiers, desktop smoke
664
+ ```console
665
+ npm run test:all # runs everything below, in order
114
666
  ```
115
667
 
116
- `test:all` chains `test:conformance`, `test:encoding`, `test` (the Windows golden suite), `test:plugin`,
117
- `test:permission`, `test:bundle` (`tools/verify-bundle.mjs`), `test:load`
668
+ `test:all` chains `test:conformance`, `test:encoding`, `test` (the Windows golden suite),
669
+ `test:plugin`, `test:permission`, `test:bundle` (`tools/verify-bundle.mjs`), `test:load`
118
670
  (`tools/verify-plugin-load.mjs` then `tools/verify-editions.mjs`) and `test:desktop`
119
- (`bash tests/smoke-desktop-sh.sh`), in that order. The POSIX golden suite is run directly with
120
- `bash tests/golden-sh.sh`, because it is a POSIX acceptance suite and not part of the Windows-side `npm`
121
- chain.
671
+ (`bash tests/smoke-desktop-sh.sh`).
122
672
 
123
- Current state, measured on this checkout:
124
-
125
- | Suite | Command | Cases | Result |
673
+ | Suite | Command | Cases | Measured result |
126
674
  |---|---|---|---|
127
675
  | Table conformance | `powershell -File tests/conformance.ps1` | 5 | **5 passed, 0 failed** |
128
676
  | Encoding contracts | `powershell -File tests/encoding.ps1` | 32 build modules | **all contracts hold** |
129
- | Windows golden | `powershell -File tests/golden.ps1` | 49 | **49 passed, 0 failed** |
677
+ | Windows golden | `powershell -File tests/golden.ps1` | 50 | **50 passed, 0 failed** |
130
678
  | POSIX golden | `bash tests/golden-sh.sh` | 116 | **116 passed, 0 failed** |
131
- | POSIX desktop smoke | `bash tests/smoke-desktop-sh.sh` | 52 | **52 passed, 0 failed** |
679
+ | POSIX desktop injection | `bash tests/smoke-desktop-sh.sh` | 52 | **52 passed, 0 failed** |
132
680
  | Plugin | `node tests/plugin.test.mjs` | 13 | **13 passed, 0 failed** |
133
681
  | Permission delegate | `node tests/permission.test.mjs` | 18 | **18 passed, 0 failed** |
134
682
  | Bundle resolution | `node tools/verify-bundle.mjs` | 10 | **10 passed, 0 failed** |
@@ -136,290 +684,151 @@ Current state, measured on this checkout:
136
684
  | Edition selection | `node tools/verify-editions.mjs` | 14 | **14 passed, 0 failed** |
137
685
  | Build drift | `powershell -File tools/build.ps1 -Check` | 3 artefacts | **no drift** |
138
686
 
139
- 86 verbs are declared in the table; **82 are implemented on Windows PowerShell 5.1 and on PowerShell
140
- 7.6 (identical sets), and 61 on POSIX**.
141
-
142
- The POSIX desktop verbs need an X display, which this machine does not have. `tests/golden-sh.sh`
143
- therefore asserts the **absence** path — with no `DISPLAY`, every one of them must return a typed
144
- `E_NO_DISPLAY` rather than an empty success. `tests/smoke-desktop-sh.sh` covers the other half without a
145
- display server: it puts the recording `tests/fake-xdotool` on `PATH` and sets `DISPLAY`, so the verbs run
146
- their real code and the assertions are about the **exact argument vector** handed to the tool — that
147
- `--button right` became button `3`, that `Enter` became the keysym `Return`, that a name outside the
148
- table (`XF86AudioMute`) is not mangled, that `--clearmodifiers` and `--repeat` land in the right place,
149
- and that an `xdotool` failure is reported as `E_IO` rather than a false success. That harness is what
150
- found two real defects in the POSIX implementation (see below). Neither suite is evidence that the verbs
151
- drive a real window manager.
152
-
153
- `tests/encoding.ps1` requires ASCII-only, BOM-free files **of the 32 modules `tools/build.ps1`
154
- concatenates**, because a non-ASCII byte there would be decoded in the ANSI code page and corrupt the
155
- generated artefact. It deliberately does not require it of the two standalone `git.ps1` scripts, which
156
- are never concatenated into anything and are UTF-8 **with** a BOM so their Chinese prose parses under
157
- Windows PowerShell 5.1. An earlier version of the guard applied the rule to every `*.ps1` under `lib/`
158
- and reported four violations that were a contract conflict rather than a defect; the guard now scopes
159
- the rule to build inputs and prints which files it skipped, so the narrowing is visible rather than
160
- silent.
161
-
162
- `tests/conformance.ps1` exists because `spec/verbs.tsv` had claimed since it was written that
163
- "tests/conformance.ps1 asserts the two agree" while no such file existed — a comment naming a guard that
164
- is not there is worse than no comment, because it is read as a guarantee. It resolves each `command.txt`
165
- line to a verb by the longest word-prefix that names a known verb (so `file verify charset path charset`
166
- resolves to `file.verify.charset` rather than to a nonexistent `file.verify`), then checks that the two
167
- tables list the same verbs, the same count, the same R/W class and the same positional names in order.
168
- Writing it found **five real inconsistencies**, all of them in `command.txt` except one:
169
-
170
- | Verb | Was declared | Actual, per the implementation |
687
+ ### Conformance of the two tables
688
+
689
+ The header of `spec/verbs.tsv` had always claimed that "`tests/conformance.ps1` asserts the two
690
+ agree", while that file **did not exist** — it has now been **added**, so the claim is a fact. The
691
+ guard resolves each line of `command.txt` to a verb by the **longest word-prefix that names a known
692
+ verb** (so `file verify charset path charset` resolves to `file.verify.charset` and not to a
693
+ nonexistent `file.verify`), then checks that the two tables hold each other's verbs, that the counts
694
+ are equal, that the R/W class agrees, and that the positional names agree in order.
695
+
696
+ **Writing it found five real inconsistencies**, and all but one of them were in `command.txt` (which is
697
+ the authoritative contract):
698
+
699
+ | Verb | Was declared | What the implementation actually does |
171
700
  |---|---|---|
172
- | `file.append` | `path content charset` | charset is an **optional** third positional, so the declaration implied it was required |
173
- | `file.insert` | `path content charset offset` | charset is `--charset`, deliberately **not** a positional — an omitted positional there would silently shift `offset` |
701
+ | `file.append` | `path content charset` | charset is an **optional** third positional; the old form implied it was required |
702
+ | `file.insert` | `path content charset offset` | charset is `--charset` and **deliberately not** a positional — as a positional, omitting it would silently shift `offset` |
174
703
  | `wait.ms` | no positionals | takes `ms` |
175
- | `keyboard.combination` | two lines, `... down a b c d` / `... up a b c d` | one verb with a **`phase`** positional; the two-line form read as two verbs |
176
- | `execute.command` | tsv `command\|arguments` | the entire positional list is argv, so there is no separate `command` |
704
+ | `keyboard.combination` | two lines, `... down a b c d` / `... up a b c d` | it is **one** verb with a `phase` positional; the two-line form read as two verbs |
705
+ | `execute.command` | tsv wrote `command\|arguments` | the entire positional list is argv; there is no separate `command` |
706
+
707
+ Four were corrected in `command.txt`, and `verbs.tsv`'s `execute.command` row became `arguments...`.
708
+ **Nothing about the running surface changed** — only what the two documents say — which is exactly
709
+ what such a conformance guard is for.
710
+
711
+ `tests/encoding.ps1` guards the contracts that make everything else meaningful: the `.ps1` artefacts
712
+ carry a BOM, the `.sh` artefacts do not, line endings follow `.gitattributes`, `command.txt` and the
713
+ `.tsv` tables stay pure ASCII + LF, and **stdout never carries a BOM**.
714
+
715
+ **The "four encoding violations" recorded earlier are gone, and this is not a relaxation of the
716
+ guard.** The rule itself never moved; its **scope** did. It used to require **every** `*.ps1` under
717
+ `src/host/lib/` to be BOM-free and ASCII-only, and two of those files are the standalone `git.ps1`
718
+ scripts — never concatenated into anything, and carrying a BOM because that is what makes their
719
+ Chinese prompt literals parse under Windows PowerShell 5.1. Applying that rule to files the build
720
+ never reads was a **false positive**. The guard now checks only the **32 modules `tools/build.ps1`
721
+ actually concatenates**, and **prints which files it skipped**, so the narrowing is visible rather
722
+ than silent.
723
+
724
+ > [!NOTE]
725
+ > **Neither the "116 passed" nor the "52 passed" for the POSIX desktop verbs is evidence that they
726
+ > drive a real window manager.** The two suites cover the same verbs from two directions, and this
727
+ > machine has no X server:
728
+ > * `tests/golden-sh.sh` asserts the **absence** path — with no `DISPLAY`, every desktop verb must
729
+ > return a typed `E_NO_DISPLAY` rather than an empty success.
730
+ > * `tests/smoke-desktop-sh.sh` asserts the **injection** path — it puts the recording
731
+ > `tests/fake-xdotool` on `PATH` and sets `DISPLAY`, so the verbs run their real code and the
732
+ > assertions are about the **exact argument vector** handed to the tool: whether `--button right`
733
+ > became button `3`, whether `Enter` became the keysym `Return`, whether a name outside the table
734
+ > (`XF86AudioMute`) is passed through unchanged, whether `--clearmodifiers` and `--repeat` land in
735
+ > the right place, and whether an `xdotool` failure is reported as `E_IO` rather than a false
736
+ > success.
737
+ >
738
+ > **That harness is what found the two real defects in the POSIX implementation** (see
739
+ > [What is actually available](#what-is-actually-available) and
740
+ > [Installation status](#installation-status-and-known-defects)). Neither suite proves that they
741
+ > operate a real desktop.
742
+
743
+ > [!WARNING]
744
+ > **The two golden suites must be run serially.** Both implementations stage a failure envelope in a
745
+ > **fixed-path** temporary file — `.temporary/dsh-os.err`, with no PID and no lock — so concurrent
746
+ > calls overwrite each other's pending result. Running `golden.ps1` and `golden-sh.sh` in parallel was
747
+ > measured to report 2 **false** failures, in which the exit code contradicted the envelope's own
748
+ > `error.code`; run serially, both disappear. Treat this as a defect, not as flakiness: that staging
749
+ > path is racy by construction.
750
+
751
+ ---
752
+
753
+ ## Contributing
754
+
755
+ `src/host/lib/powershell5.1/*`, `src/host/lib/powershell7.6/*` and `src/host/lib/*.sh` are the source.
756
+ **Never hand-edit `src/host/bin/dsh-os.ps1`, `dsh-os7.ps1` or `.sh`** — they are generated. Edit the
757
+ modules, then:
758
+
759
+ ```console
760
+ powershell -File tools/build.ps1 # regenerate all three scripts
761
+ powershell -File tools/build.ps1 -Check # fails as soon as a committed script drifts
762
+ ```
177
763
 
178
- `command.txt` is the authoritative contract, so four were corrected there; `verbs.tsv`'s
179
- `execute.command` row was corrected to `arguments...`. Nothing about the running surface changed — only
180
- what the two documents say — which is exactly the class of drift a conformance guard is for.
764
+ There are three separate artefacts rather than "one script with a runtime branch" because the two
765
+ runtimes differ in ways a branch cannot paper over cleanly: .NET Framework has no
766
+ `ProcessStartInfo.ArgumentList`, and .NET 10 has no reason to keep carrying the hand-rolled Windows
767
+ command-line escaping this project needed in order to work around its absence.
181
768
 
182
- ## Using it from the harness
769
+ `git.ps1` / `git.sh` are **not** in `tools/build.ps1`'s module lists, so they are never concatenated
770
+ into any artefact; they are separate, self-contained interactive tools — see
771
+ [Standalone tools](#standalone-tools).
183
772
 
184
- `package.json` declares `dsh.bundle.patch`, so the package installs into a DSH profile as a bundle and
185
- registers one model-facing tool:
186
-
187
- | Tool | Purpose |
188
- |---|---|
189
- | `dsh_os` | Run one verb of the surface and return its JSON envelope verbatim |
773
+ `.gitattributes` is part of the contract rather than a preference: `command.txt` and the `.tsv` tables
774
+ must be LF so that both implementations parse byte-identical input; `dsh-os.bat` must be CRLF and
775
+ ASCII-only, or `cmd.exe` mis-parses it; and `dsh-os.ps1` must be CRLF and UTF-8 **with** a BOM, or
776
+ PowerShell 5.1 reads its non-ASCII literals in the ANSI code page.
190
777
 
191
- One tool rather than one per verb: the verb table is the contract and lives in `spec/verbs.tsv`, so
192
- the schema does not need regenerating when a verb is added or renamed; an agent discovers the whole
193
- surface with a single `verb: "selfcheck"` call; and the envelope is already the stable result shape,
194
- so there is no second contract to drift from the script's.
778
+ Two lessons learned the hard way in this repository, worth inheriting:
195
779
 
196
- ```jsonc
197
- // call shape
198
- { "verb": "file charset", "arguments": ["path=\"./a.txt\""], "allow": false }
199
- { "verb": "environment attribute get", "arguments": ["PATH"], "flags": ["--scope", "process"] }
200
- ```
780
+ * **A security boundary derived from a path must fail closed.** A refactor once made the repository
781
+ root one level too high, so no protected path matched. The failure mode was not an error but
782
+ **protection silently ceasing to apply** — the protected files really were deleted.
783
+ * **Temporary files live under `.temporary/`**, golden-test fixtures included.
201
784
 
202
- The implementation is spawned by absolute path with an argv array, never through a shell string, so a
203
- value containing spaces stays one value. On Windows the `.ps1` is launched through `powershell.exe -File`
204
- rather than the `.bat`: `cmd.exe` interprets a batch file, and spawning one with `shell: false` fails
205
- with `EINVAL`. On POSIX the `.sh` is launched through `/bin/bash` so it need not be marked executable.
206
-
207
- ## Status
208
-
209
- - Implemented and tested on Windows: `file`, `file verify`, `file search`, `system`, `task`
210
- (list/query), `service` (list/query), `environment` including `environment attribute`,
211
- `permission` (read-only) and `selfcheck`.
212
- - Implemented and tested on POSIX: the same surface except `service`, `window`, `screenshot`,
213
- `clipboard`, `wait`, `mouse` and `keyboard`, plus `file search`, `execute` (command / synchronous /
214
- asynchronous), the process verbs `execute.query`, `execute.kill`, `task.query`, `task.show` and
215
- `task.kill`, and the whole `permission.*` group including `permission acl init`.
216
- - POSIX layer A is a mode change rather than a Windows deny ACE, and the equivalence is weaker: the
217
- same user can restore write access. `permission acl init` says so in its own result, and its
218
- `restore` field names the command that undoes it.
219
- - `selfcheck` reports on the two platforms differently, and the difference is worth knowing before
220
- reading its output as a parity check. On POSIX it derives the implemented list from its own dispatch
221
- table and emits an `implementedCount`. On **Windows it does not**: `Invoke-Selfcheck` walks every row
222
- of `spec/verbs.tsv` and emits only `verbCount`, with no implemented/unimplemented distinction at all.
223
- So a Windows `selfcheck` naming a verb does not mean that verb is implemented — the dispatch tables
224
- above are the authority.
225
- - Implemented and tested on Windows for `window`, `screenshot`, `clipboard` and `wait`: enumeration,
226
- matching, move, resize, minimize, maximize, restore, z-order, close, screen capture to png/jpeg/bmp,
227
- clipboard text round-trip including non-ASCII, an explicit sleep, and waiting for a window to appear
228
- or to go away.
229
- - Both goldens include the failure path, which is the point: a failing call must still deliver exactly
230
- one parseable envelope on stdout, because a bare exit code tells an agent nothing.
231
- - Implemented and tested on Windows for `mouse` and `keyboard`: cursor read-back, move, click,
232
- right-click, wheel, button down/up, key down/up, a self-releasing press, chords in both phases, and
233
- text entry by Unicode key events (so it is layout-independent and accepts non-ASCII).
234
- - Implemented and tested on Windows for `text` and `structure` through UI Automation: the element at a
235
- point, its text via ValuePattern / TextPattern / LegacyIAccessiblePattern / Name in that order, and
236
- the control tree around it. Both are honest about the environment - a missing UIA stack or a refusal
237
- is a typed E_ACCESSIBILITY_UNAVAILABLE / E_ACCESSIBILITY_DENIED, never an empty result.
238
- - Missing on Windows: `clipboard.paste` and the `service` lifecycle verbs (`start`, `stop`, `restart`)
239
- — four verbs. Everything else in the table is dispatched on both PowerShell runtimes, with identical
240
- verb sets. Every listed verb is declared in the table and returns a typed `E_NOT_SUPPORTED` rather
241
- than a silent success.
242
- - Missing on POSIX: 25 verbs — `file.encode`; all eleven `window.*`; `screenshot`, `text`, `structure`;
243
- `clipboard.read`, `clipboard.write`, `clipboard.paste`; `wait.window`; `task.launch`; and all five
244
- `service.*`. These fall through the dispatch table to a typed `E_NOT_SUPPORTED`; they are not silently
245
- absent.
246
- - Implemented on POSIX and needing **no display server**: the five `environment attribute` verbs
247
- (`add`, `set`, `delete`, `list`, `get`) and `wait.ms`. The attribute mutations are `--scope process`
248
- only, and refuse a persistent scope with `E_NOT_SUPPORTED` rather than half-applying it, because POSIX
249
- has no kernel API for a list variable and rewriting `PATH` in a startup file can make a machine's
250
- shells unusable.
251
- - Implemented on POSIX and needing **an X display**: all seven `mouse.*` and all six `keyboard.*`, which
252
- drive X11 through `xdotool`. With no `DISPLAY` each returns a typed `E_NO_DISPLAY`; with no `xdotool`
253
- installed each returns `E_NOT_SUPPORTED` naming the missing tool. Key names are translated from the
254
- Windows spelling to the X keysym spelling (`Enter`→`Return`, `PgUp`→`Prior`, `win`→`super`) and
255
- anything outside the table is passed through unchanged, so any X keysym name works. A Windows virtual
256
- key *number* is refused, because `13` means Return to a Windows caller and nothing to X.
257
- - Both goldens include the failure path, which is the point: a failing call must still deliver exactly
258
- one parseable envelope on stdout, because a bare exit code tells an agent nothing.
259
- - Windows and PowerShell 7.6 are at exact parity: both dispatch the same 82 verbs, checked by
260
- comparing the two dispatch tables rather than by eye.
261
- - Every Windows verb that a caller can reach is also reachable on POSIX or reports a typed refusal. The
262
- POSIX-only weak spot is that the desktop verbs are verified against recording fakes rather than a real
263
- X server; see *Tests*.
264
- - The four `execute` verbs that the README previously listed as unwritten are in fact implemented on
265
- Windows — `execute.command`, `execute.query`, `execute.kill` and `task.launch` are dispatched to real
266
- bodies. So are `permission.grant`, `permission.revoke` and `permission.acl.init` on **both**
267
- platforms. `permission.json` therefore does have writing verbs; see the authorization section.
268
- - The consent delegate (`src/permission/index.js`, row id `dsh-os-permission`) is written and tested:
269
- it is the only component that writes `permission.json`, matching is deny-first regardless of rule
270
- order, a file whose `updatedBy` is not the delegate is untrusted and falls back to defaults, a
271
- wildcard target needs explicit acknowledgement, and writes go through a temp file plus rename so a
272
- crash cannot leave a half-written file.
273
- - The A layer (kernel ACL enforcement) is implemented on Windows: `permission acl init` appends an
274
- explicit deny for Everyone to the authorization file DACL, is idempotent, requires elevation
275
- (reporting `E_ELEVATION_REQUIRED` rather than failing silently), and `selfcheck` reports the strength
276
- it MEASURED from the file security descriptor plus a reason, never a stored flag.
277
-
278
- ### Verified or not
279
-
280
- Proven by tests and verifiers in this repository:
281
-
282
- - the scripts on both platforms, including that a failing call still emits exactly one envelope;
283
- - the build's determinism: `tools/build.ps1 -Check` reports the three generated artefacts matching
284
- their modules byte for byte (218,514 + 255,565 + 142,744 bytes);
285
- - the plugin's execute path: entry-point resolution, argv construction, a path containing spaces
286
- reaching the script as one argument, flag forwarding, authorization, and error propagation;
287
- - the loader's **resolution** contract, by `tools/verify-bundle.mjs`: the manifest header, the patch
288
- file, each row resolving from a profile-style `node_modules`, each resolved module exporting the
289
- Cordis shape the loader calls, the two rows resolving to **distinct** modules, and the version gate.
290
- The package is linked into a throwaway profile sandbox by junction, so resolution happens as it
291
- would in a profile and the live profile is never written to;
292
- - the real `apply()` path, by `tools/verify-plugin-load.mjs`: the host plugin registers `dsh_os` with a
293
- parameters schema whose root type is `object`, the permission plugin registers its five tools, the
294
- **registered handler actually spawns the script** and returns a real envelope (`selfcheck` → ok,
295
- `verbCount` 86), and a mutating verb without `allow` is refused with a typed `E_PERMISSION` rather
296
- than throwing;
297
- - edition selection, by `tools/verify-editions.mjs`: the default stays on PowerShell 5.1,
298
- `powershellEdition: '7.6'` and `DSH_OS_POWERSHELL_EDITION` select the 7.6 artefact, `'auto'` uses
299
- 7.6 only when both the artefact and a working `pwsh` are present, config beats environment, and an
300
- explicit `scriptPath` disables the choice. `-ExecutionPolicy` is passed only to the 5.1 executable,
301
- being a Windows PowerShell option.
302
-
303
- Measured on this checkout, stated exactly as observed:
304
-
305
- | Suite | Cases | Result |
306
- |---|---|---|
307
- | Table conformance | 5 | **5 passed, 0 failed** |
308
- | Windows golden | 49 | **49 passed, 0 failed** |
309
- | POSIX golden | 116 | **116 passed, 0 failed** |
310
- | POSIX desktop smoke | 52 | **52 passed, 0 failed** |
311
- | Plugin | 13 | **13 passed, 0 failed** |
312
- | Permission delegate | 18 | **18 passed, 0 failed** |
313
- | Bundle resolution | 10 | **10 passed, 0 failed** |
314
- | Plugin load | 13 | **13 passed, 0 failed** |
315
- | Edition selection | 14 | **14 passed, 0 failed** |
316
- | Encoding contracts | 32 modules | **all contracts hold** |
317
-
318
- - Every suite above passes. The four encoding violations this section used to report are gone, because
319
- the guard now scopes the ASCII-only rule to the files the assembler actually reads; the rule was never
320
- relaxed, its scope was corrected, and the guard prints which files it skipped.
321
- - The PowerShell 7.6 artefact builds, is reachable, and the version of PowerShell 7 on this machine is
322
- **7.6.6**, which is what the edition verifier drove. Beyond that, the 7.6 modules are established as a
323
- faithful port by comparing the two dispatch tables (identical 82-verb sets), by a per-module
324
- function-name comparison, and by running the window group's golden cases against `dsh-os7.ps1` — rather
325
- than by a per-verb run of the whole surface on 7.6. That is a real gap in the evidence, not a claim of
326
- full parity.
327
- - Two defects found by `tests/smoke-desktop-sh.sh` after the POSIX mouse and keyboard verbs were written,
328
- both recorded here because they are the kind review does not catch:
329
- 1. **Validation ran after injection.** `mouse click 10 20 --count abc` returned an error *and had
330
- already moved the pointer*. The Windows side validates every argument before injecting; the POSIX
331
- side now does too.
332
- 2. **A rejection inside `$(...)` did not stop the verb.** The coordinate validator called `fail`, but
333
- the caller invoked it as `x="$(window_int_arg ...)"`, so the `exit` ended only the subshell: the verb
334
- carried on with an empty value and injected anyway — `mouse over abc 20` moved the pointer and
335
- returned `ok`. The validators now assign a variable (`WINDOW_INT`, `WINDOW_BUTTON`, `WINDOW_KEY`)
336
- and are called in the caller's own shell. This trap was already documented in the environment
337
- module, and it was still repeated here.
338
-
339
- Not proven, and not claimed: that the **harness's own loader** composes these bundles in a live
340
- profile. That would mean installing this package into the user's active desktop profile and restarting
341
- the harness, which has not been done.
342
-
343
- ## Authorization
344
-
345
- Read-only verbs never prompt. Every mutating verb requires an explicit `--allow`; without it the call
346
- returns `E_PERMISSION` and nothing happens.
347
-
348
- `permission.json` is written by the consent delegate (`src/permission/index.js`, row id
349
- `dsh-os-permission`) and by nothing else. The table declares three write verbs —
350
- `permission.grant`, `permission.revoke` and `permission.acl.init`, all `W` — and three read-only ones
351
- (`query`, `list`, `audit`). `grant` and `revoke` accept only the `delegate` flag besides `allow`, so
352
- the script itself cannot create a persistent authorization: with no delegate present they return
353
- `E_PERMISSION_DELEGATE_ONLY`.
354
-
355
- Two enforcement layers protect "the files that decide what this layer may do":
356
-
357
- - **Layer B, structural** — a hard protected-path boundary rejects any modification of
358
- `permission.json` (and `.bak`), `src/host/command.txt`, the three `src/host/bin/dsh-os.*` entry
359
- points, `src/host/spec/verbs.tsv` and `src/host/spec/charsets.tsv`, plus any same-named temporary
360
- staged beside one of them. Violations report `E_PERMISSION_PROTECTED_PATH` with no side effect;
361
- reads are still allowed so you can review the rules. The check runs on the **resolved, normalised**
362
- path, compared segment by segment rather than by regex, because `..`, 8.3 short names or a trailing
363
- dot all walk around a regex. `file.move` and `file.copy` check **both ends**, since moving a
364
- protected file is a way of destroying it. The POSIX list has eight entries: the same list without
365
- `dsh-os7.ps1`. `dsh-os7.ps1` was added to the Windows list only after the gap was found destructively —
366
- the golden suite's protected-path case moved the artefact into a sandbox it then deleted, because the
367
- list named the 5.1 and shell entry points but not the 7.6 one. Every entry point belongs to the same
368
- boundary, and the two lists now protect both PowerShell scripts.
369
- - **Layer A, kernel ACL** — Windows only, opt-in and requiring elevation. `permission acl init`
370
- appends an explicit `DENY` for `Everyone` (`S-1-1-0`) to the DACL of `permission.json`, covering
371
- `FILE_GENERIC_WRITE | DELETE | WRITE_DAC | WRITE_OWNER`. It is idempotent and reports
372
- `E_ELEVATION_REQUIRED` rather than failing opaquely. `selfcheck` reports `aclEnforced` by reading
373
- the file's real security descriptor and walking the DACL, never from a stored flag, and `aclReason`
374
- explains a `false`. The POSIX equivalent is weaker by construction: it is a mode change, and the
375
- same user can restore write access. `permission acl init` says so in its own result and its
376
- `restore` field names the command that undoes it.
377
-
378
- Four limitations, stated because they change what the guarantees above mean:
379
-
380
- 1. **Persistent rules are recorded but not enforced at the verb layer.** The rules in
381
- `permission.json` are matched by the consent delegate and its `os_permission_query` tool. The
382
- script reads the file for **display only** (`permission list/query/audit`); no verb consults a rule
383
- before acting. The real gates are `--allow`, the protected-path check and (if enabled) layer A. A
384
- `deny-always` rule does **not** currently stop a verb that arrives with `--allow`.
385
- 2. **`E_PERMISSION_DENIED_BY_RULE` is declared but never thrown.** It is in the error table and maps
386
- to exit class 3, but no code path produces it — consistent with point 1.
387
- 3. **The in-script rule reader is not deny-first.** `permission query` takes the **first** matching
388
- rule without regard to whether it allows or denies. The delegate implements deny-first correctly
389
- and has test coverage; the two readers can therefore disagree, and the script side is the weaker
390
- one.
391
- 4. **`execute` is a way around the protected-path boundary.** That boundary guards `file.*` and
392
- `environment.*`. It cannot constrain a child process: `execute.synchronous` hands the command line
393
- to `cmd.exe /d /s /c` on Windows and to `sh -c` on POSIX, so the program it starts can write
394
- anything its own rights permit, `permission.json` included. Only layer A prevents that, and layer B
395
- cannot when `aclEnforced` is false.
785
+ Module headers still refer to a `tools/split-once.ps1` that no longer exists; the current assembler is
786
+ `tools/build.ps1`.
396
787
 
397
- The consent delegate has a **design gap** worth stating plainly: it registers five tools
398
- (`os_permission_query`, `os_permission_grant`, `os_permission_revoke`, `os_permission_list`,
399
- `os_permission_audit`) but does **not** register an `approval/request` answerer, although its own
400
- header explains that reaching the user is why it is a plugin rather than a script subcommand. As the
401
- code stands, the enforcement is a **tool description** telling the model to call `os_permission_grant`
402
- only after the user chooses; the code does not verify that a prompt ever happened. Treat the delegate
403
- as trusted infrastructure rather than as a check on the agent. It also reports
404
- `aclEnforced: false` unconditionally in its `query` result instead of measuring the ACL as the script
405
- does, so it **under-reports** protection when layer A is on.
788
+ ---
406
789
 
407
790
  ## Standalone tools
408
791
 
409
792
  `src/host/lib/git.sh`, `src/host/lib/powershell5.1/git.ps1` and `src/host/lib/powershell7.6/git.ps1`
410
793
  are interactive git runners. They are deliberately **not** part of the verb surface: they are absent
411
794
  from `$modulesPs1` and `$modulesSh` in `tools/build.ps1`, so they are never concatenated into
412
- `bin/dsh-os.ps1`, `bin/dsh-os7.ps1` or `bin/dsh-os.sh`, and they emit no JSON envelope. Each one is a
795
+ `bin/dsh-os.ps1`, `bin/dsh-os7.ps1` or `bin/dsh-os.sh`, and they emit no JSON envelope. Each is a
413
796
  single self-contained file, byte-independent of the others, kept in step by review rather than by a
414
797
  generator.
415
798
 
416
- They locate git in three phases — `GIT_EXE`/`GIT_HOME`/`PATH`, then the known install locations
417
- (including the Git-for-Windows registry keys), then a depth- and time-bounded walk of every mounted
418
- file system — and exit 127 with the searched locations when nothing is found. Arguments reach git as
419
- separate argv elements; no shell string is ever assembled. `git add` with an empty answer becomes
420
- `git add .`. All three are ASCII-or-BOM per their own runtime's encoding contract, which is why the
421
- shell module is English-only and the PowerShell ones are not.
799
+ They locate git in three phases: `GIT_EXE`/`GIT_HOME`/`PATH`, then the known install locations
800
+ (including the Git-for-Windows registry keys), then a **depth- and time-bounded** walk of every
801
+ mounted file system. When nothing is found they list what was searched and exit 127. Arguments reach
802
+ git as separate argv elements; no shell string is ever assembled. `git add` with an empty answer
803
+ (directly pressing Enter) is equivalent to `git add .`. Each of the three obeys its own runtime's
804
+ encoding contract — which is precisely why `git.sh` is plain ASCII (English prompts) and the two
805
+ `git.ps1` files are not.
806
+
807
+ ---
808
+
809
+ ## Licence
810
+
811
+ **MIT**, declared in `package.json` (`"license": "MIT"`, author Eloy Kim) and distributed with the
812
+ repository as a separate [`LICENSE`](LICENSE) file. Fully open source: the whole verb surface, the
813
+ specification documents and the test suites are all in the repository. No telemetry, no account, no
814
+ network access at run time.
815
+
816
+ ```
817
+ MIT License
818
+
819
+ Copyright (c) 2026 Eloy Kim
820
+ ```
821
+
822
+ The licence file is pure ASCII, LF-terminated and BOM-free, consistent with the rest of the
823
+ repository's encoding contracts. One earlier description needs correcting: it is **not** listed in
824
+ `package.json`'s `files` whitelist (which has 14 entries; it includes `src/host/bin/dsh-os7.ps1` but
825
+ neither `LICENSE` nor `README.zh-CN.md`), so the file stays in a checkout but is not distributed with
826
+ the published package.
422
827
 
423
- ## License
828
+ The verb contract and the specification documents are part of the source: a behaviour change should be
829
+ reflected in `src/host/spec/verbs.tsv`, `src/host/command.txt` and `src/host/docs/os-spec.md`, not only
830
+ in code.
424
831
 
425
- MIT
832
+ One further note: the specification documents (`src/host/docs/os-spec.md`,
833
+ `src/host/docs/permission-model.md`, `src/host/command.txt`) are written in **Chinese**, while this
834
+ README and the verb table are in English.