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 +759 -350
- package/README.zh-CN.md +2 -2
- package/package.json +3 -1
- package/src/host/bin/dsh-os.ps1 +10 -1
- package/src/host/bin/dsh-os7.ps1 +16 -1
- package/src/plugin/index.js +4 -213
- package/src/plugin/invoke.js +301 -0
- package/src/plugin/takeover.js +392 -0
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
|
39
|
-
|
|
40
|
-
| `
|
|
41
|
-
|
|
|
42
|
-
|
|
|
43
|
-
|
|
|
44
|
-
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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,
|
|
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
|
|
70
|
-
| `window` | `window
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
114
|
+
---
|
|
80
115
|
|
|
81
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
`
|
|
108
|
-
|
|
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
|
-
```
|
|
113
|
-
npm run test:all
|
|
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),
|
|
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`)
|
|
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
|
-
|
|
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` |
|
|
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
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
`
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
|
173
|
-
| `file.insert` | `path content charset offset` | charset is `--charset
|
|
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
|
|
176
|
-
| `execute.command` | tsv `command\|arguments` | the entire positional list is argv
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
417
|
-
(including the Git-for-Windows registry keys), then a depth- and time-bounded walk of every
|
|
418
|
-
file system
|
|
419
|
-
separate argv elements; no shell string is ever assembled. `git add` with an empty answer
|
|
420
|
-
`git add .`.
|
|
421
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|