dsh-plugin-coding-delegate 0.0.0-stage → 0.2.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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +920 -2
  3. package/cordis.patch.example.yml +48 -0
  4. package/cordis.patch.yml +13 -0
  5. package/dist/attestation.d.ts +25 -0
  6. package/dist/attestation.js +44 -0
  7. package/dist/containment.d.ts +33 -0
  8. package/dist/containment.js +63 -0
  9. package/dist/context-quality.d.ts +79 -0
  10. package/dist/context-quality.js +181 -0
  11. package/dist/context.d.ts +39 -0
  12. package/dist/context.js +136 -0
  13. package/dist/contracts.d.ts +174 -0
  14. package/dist/contracts.js +415 -0
  15. package/dist/declaration-egress-hook.d.ts +45 -0
  16. package/dist/declaration-egress-hook.js +125 -0
  17. package/dist/declaration-egress.d.ts +37 -0
  18. package/dist/declaration-egress.js +169 -0
  19. package/dist/delegation.d.ts +339 -0
  20. package/dist/delegation.js +933 -0
  21. package/dist/emission.d.ts +70 -0
  22. package/dist/emission.js +310 -0
  23. package/dist/guard.d.ts +162 -0
  24. package/dist/guard.js +873 -0
  25. package/dist/host-events.d.ts +15 -0
  26. package/dist/host-events.js +27 -0
  27. package/dist/index.d.ts +560 -0
  28. package/dist/index.js +1398 -0
  29. package/dist/local-classifier.d.ts +52 -0
  30. package/dist/local-classifier.js +159 -0
  31. package/dist/logging.d.ts +21 -0
  32. package/dist/logging.js +90 -0
  33. package/dist/paths.d.ts +10 -0
  34. package/dist/paths.js +83 -0
  35. package/dist/profiles.d.ts +35 -0
  36. package/dist/profiles.js +95 -0
  37. package/dist/retry-context.d.ts +52 -0
  38. package/dist/retry-context.js +97 -0
  39. package/dist/roles.d.ts +129 -0
  40. package/dist/roles.js +363 -0
  41. package/dist/routing.d.ts +35 -0
  42. package/dist/routing.js +88 -0
  43. package/dist/savings-tracker.d.ts +128 -0
  44. package/dist/savings-tracker.js +340 -0
  45. package/dist/session-events.d.ts +17 -0
  46. package/dist/session-events.js +31 -0
  47. package/dist/verification.d.ts +104 -0
  48. package/dist/verification.js +560 -0
  49. package/package.json +66 -4
package/README.md CHANGED
@@ -1,3 +1,921 @@
1
- # Temporary Holding Version
1
+ # dsh-plugin-coding-delegate
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ > **Your plan is for thinking. Your GPU does the typing.**
4
+
5
+ A DeepSeek Harness (Cordis) plugin that decides where a request may go, and decides by
6
+ **permission rather than preference**. A credential is not permitted to reach the cloud. The
7
+ cloud model is not permitted to author source code. Beyond those two rules the destination is
8
+ the cloud, and moving work off it is the architect's explicit choice — not a heuristic's guess.
9
+
10
+ Everything runs **in-process**: there is no separate decision daemon, no HTTP hop, and no
11
+ timeout on the routing path. (Earlier versions called an external Python "Laya" scoring service;
12
+ that dependency was removed, and the credential rules are now TypeScript in this package.)
13
+
14
+ > **This plugin does not modify your provider configuration.**
15
+ >
16
+ > The `cordis.patch.yml` that DSH applies on install registers the plugin itself and nothing
17
+ > else — it adds no provider, no model and no API key. Provider setup is entirely yours.
18
+ > `cordis.patch.example.yml` ships as an **unapplied** starting point you can copy from.
19
+
20
+ ## Contents
21
+
22
+ - [The policy](#the-policy) · [Requirements](#requirements) · [Install](#install)
23
+ - [Configuring providers](#configuring-providers) · [Routing](#routing) · [Configuration](#configuration)
24
+ - [Rule 8: source may not reach the cloud](#rule-8-source-may-not-reach-the-cloud)
25
+ - [The `delegate_worker` tool](#the-delegate_worker-tool) · [Local-code guard](#local-code-guard)
26
+ - [Delegated verification and file writes](#delegated-verification-and-file-writes) · [State](#state)
27
+ - [Throughput reference](#throughput-reference) · [Development](#development)
28
+ - [The host contract](#the-host-contract) · [Judging a transcribed contract](#judging-a-transcribed-contract)
29
+ - [Limitations](#limitations)
30
+
31
+ ## The policy
32
+
33
+ **The architect may reason but not author. The worker may author but not stray. Neither may execute
34
+ without consent.**
35
+
36
+ Every rule in this plugin is an instance of that sentence, and every one fails closed — if a rule
37
+ cannot be evaluated, or the approval service cannot be reached, the answer is no.
38
+
39
+ | # | Rule | Enforced by |
40
+ | --- | --- | --- |
41
+ | 1 | A credential may not reach the cloud | DLP gate on every outbound request; refused, or pinned local |
42
+ | 2 | The architect may not author or delete source | code guard on `tools/pre-execute`; denied, or approval-gated |
43
+ | 3 | The architect may not read back what it delegated | the **settled rule**: a file a passing unit left unchanged reads silently, and failed, unverified and since-edited ones ask. `delegateReadPolicy: 'allow'` relaxes all of it |
44
+ | 4 | The worker may not write outside the workspace | containment on every emitted path, symlinks included |
45
+ | 5 | The worker's code may not run inside the server | subprocess only; the in-process fallback is off |
46
+ | 6 | A command the architect proposes may not run unchecked | approval seam via `verificationApproval` |
47
+ | 7 | A delegated result is a verdict, not a claim | files written without verification report `UNVERIFIED`, never `SUCCESS` |
48
+ | 8 | Source may not reach the cloud | cloud-bound requests carrying fenced source are refused by default; `sourceEgress` decides |
49
+
50
+ The division is meant to be mutual and is enforced in both directions: rule 2 stops the architect
51
+ typing code into your repository, rules 3–5 stop the worker reaching outside the work it was given,
52
+ and rule 7 stops an unchecked result being reported as a pass.
53
+
54
+ What the policy does **not** cover is listed under [Limitations](#limitations) rather than left
55
+ implied: code in prose is not mediated, files this plugin did not write remain freely readable, and
56
+ the guard is a deterrent at the tool layer, not an airtight boundary.
57
+
58
+ ## Requirements
59
+
60
+ - Node.js 22+
61
+ - **At least one configured LLM provider** — local, cloud, or both (see
62
+ [Configuring providers](#configuring-providers))
63
+ - A local OpenAI-compatible server if you want local routing or `delegate_worker`
64
+ - A cloud API key (e.g. `DEEPSEEK_API_KEY`) if you route to a cloud provider
65
+
66
+ No container runtime is required. Verification runs as an ordinary child process, and the
67
+ in-process fallback is off by default; a container is optional hardening, not a prerequisite.
68
+
69
+ ## Install
70
+
71
+ ```bash
72
+ npm install dsh-plugin-coding-delegate
73
+ ```
74
+
75
+ The package has **no runtime dependencies**: routing, credential detection, the code guard
76
+ and `delegate_worker` all run in-process against the host's `ctx`.
77
+
78
+ ## Configuring providers
79
+
80
+ This plugin is a **router**: it decides *where* a request should go, but it does not create
81
+ providers. It selects between providers by **id**, so whatever you configure must be
82
+ reachable under the ids you set in `localProvider` / `cloudProvider` (and the matching
83
+ `localModel` / `cloudModel`).
84
+
85
+ ### Option 1 — local worker plus cloud (the default)
86
+
87
+ A local LM Studio server plus DeepSeek Cloud. Copy `cordis.patch.example.yml` into your
88
+ profile, or add the equivalent to `~/.dsh/settings.yaml`:
89
+
90
+ ```yaml
91
+ llm-pi-ai:
92
+ providers:
93
+ lm-studio:
94
+ api: openai-completions
95
+ baseURL: http://127.0.0.1:1234/v1
96
+ apiKeyEnv: LM_STUDIO_API_KEY
97
+ models:
98
+ - id: qwen/qwen3.8-27b
99
+ maxTokens: 16384
100
+
101
+ llm-deepseek:
102
+ apiKeyEnv: DEEPSEEK_API_KEY
103
+ ```
104
+
105
+ ### Option 2 — any other OpenAI-compatible endpoint
106
+
107
+ `lm-studio` is only a provider *id*; the transport is generic OpenAI-completions. Point it at
108
+ vLLM, Ollama, llama.cpp, OpenRouter, a hosted gateway, or a different LM Studio model — local
109
+ or on another machine:
110
+
111
+ ```yaml
112
+ llm-pi-ai:
113
+ providers:
114
+ lm-studio:
115
+ api: openai-completions
116
+ baseURL: http://192.168.1.50:8000/v1 # vLLM on another box
117
+ apiKeyEnv: LOCAL_ENDPOINT_API_KEY
118
+ models:
119
+ - id: Qwen/Qwen3-Coder-30B-A3B-Instruct
120
+ maxTokens: 32768
121
+ ```
122
+
123
+ Then set `localModel` to that `id`, and set `localEndpoint` to the same base URL:
124
+
125
+ ```yaml
126
+ localModel: Qwen/Qwen3-Coder-30B-A3B-Instruct
127
+ localEndpoint: http://192.168.1.50:8000/v1
128
+ ```
129
+
130
+ Both matter, for different paths. `localModel` covers the local *reroute*, which travels through
131
+ the host's provider registry. `delegate_worker` talks to the server directly rather than through
132
+ that registry, so it needs `localEndpoint` — without it, dispatch would still go to LM Studio's
133
+ default port no matter what `baseURL` said. With both set, routing, the DLP gate and
134
+ `delegate_worker` all work against whatever the endpoint serves.
135
+
136
+ One caveat — if the endpoint is remote, "local" now means "not the cloud architect" rather than
137
+ "on this machine", and `dlpAction: 'local'` would send a credential-bearing payload *to that
138
+ endpoint*. Prefer `'block'` unless you trust it as you would your own machine.
139
+
140
+ ### Option 3 — cloud only, no local model
141
+
142
+ Point both sides at cloud providers and set `dlpAction: 'block'`:
143
+
144
+ ```yaml
145
+ dsh-plugin-coding-delegate:
146
+ localProvider: 'deepseek-official'
147
+ cloudProvider: 'deepseek-official'
148
+ localModel: 'deepseek-chat'
149
+ cloudModel: 'deepseek-chat'
150
+ dlpAction: 'block'
151
+ ```
152
+
153
+ Two things to know here:
154
+
155
+ - **`dlpAction: 'local'` is refused in this setup, not honoured.** `localProvider` and `cloudProvider`
156
+ are the same id, so a "local" reroute would send the payload to the same provider it is being kept
157
+ away from. The request is **blocked** and the reason names both providers. Configure a genuinely
158
+ distinct local provider if you want a reroute to land somewhere safe.
159
+ - A **high-confidence** match is blocked whenever it is not rerouted, including when `dlpAction` is
160
+ unset. Only an entropy-only hit (medium confidence) is allowed to fall back to a reroute, or an
161
+ explicit `dlpAction: 'local'` with a distinct local provider — that is the single rule the gate
162
+ applies, and it is the same rule whether or not a local model exists.
163
+ - `delegate_worker` and the local-code guard both assume a local model. In a cloud-only setup
164
+ `delegate_worker` has no worker to dispatch to, so set `localCodeGuard: false` if you do
165
+ not want source writes refused.
166
+
167
+ ## Routing
168
+
169
+ Routing enforces **permission rather than preference**: no model is chosen because it is cheaper or faster.
170
+ This section is the rule that inspects the payload; the architect pin is the other one, and it is under
171
+ [Running a lead alongside the architect](#running-a-lead-alongside-the-architect).
172
+
173
+ | Condition | Result |
174
+ | --- | --- |
175
+ | The outbound payload matches a credential rule | **refused** (`dlpAction: 'block'`) or **pinned to the local provider** (`'local'`) |
176
+ | High-entropy token, no keyword and no vendor prefix | **always pinned local**, never refused — a digest or a base64 payload looks identical |
177
+ | Otherwise | **the cloud provider** |
178
+
179
+ So the plugin does not pick a model because one is cheaper or faster. It decides what is
180
+ *allowed*: a credential has no cloud clearance, and absent that, the destination is the cloud.
181
+ Moving work off the cloud beyond this is the architect's own decision — it calls
182
+ `delegate_worker`, or it does not.
183
+
184
+ The scan covers the whole conversation rather than only the newest message: user text is
185
+ accumulated per session and re-scanned, so a credential from an earlier turn keeps the gate
186
+ closed instead of scrolling out of view.
187
+
188
+ ### The classifier is available but not wired in
189
+
190
+ `LocalRouter.predictRoute` implements a three-layer heuristic — `scanDLP`, a token-count
191
+ threshold, then `classifyLocally` (a privacy score and a complexity score over a bounded window).
192
+ It is exported, unit-tested, and has golden fixtures in `tests/fixtures/classifier-goldens.json`.
193
+
194
+ **The plugin's hooks never call it, so it does not affect where requests go.** An earlier version
195
+ of this README presented it as the routing path; that was wrong, and it is corrected here. It is
196
+ kept because it is useful on its own and the fixtures document behaviour worth preserving. If you
197
+ want complexity-based escalation, call it yourself — but note that making it the routing rule
198
+ would turn this into a cost/latency router with a permission gate attached, which is not the
199
+ premise this plugin is built on.
200
+
201
+ ### The DLP firewall is enforced on every outbound request
202
+
203
+ Before a request is dispatched, `scanDLP` inspects the payload. If credentials are found,
204
+ `dlpAction` decides what happens:
205
+
206
+ | `dlpAction` | Behaviour |
207
+ | --- | --- |
208
+ | `'block'` (default) | The request is **refused**. Nothing is transmitted; the caller receives an error naming the offending pattern. |
209
+ | `'local'` | The request is **pinned to the local worker** for that call, so it never reaches the WAN. |
210
+
211
+ Caveat worth knowing before choosing `'block'`: the scan covers the conversation, so once a
212
+ credential is in the session history, every subsequent request keeps failing until it is
213
+ removed. Use `'local'` if you would rather the session continue on the local model.
214
+
215
+ #### Detection is tiered by confidence
216
+
217
+ | Detector | Confidence | Behaviour |
218
+ | --- | --- | --- |
219
+ | Vendor shape or keyword-assigned value (`sk-`, `AKIA`, `ghp_`, PEM, `api_key: …`) | high | follows `dlpAction` — **block** by default |
220
+ | **High-entropy string** (Shannon entropy, no keyword, no prefix) | medium | **always rerouted local**, never hard-blocked |
221
+ | Allowlisted / below threshold | — | passes through |
222
+
223
+ The entropy backstop exists because pattern matching can only catch shapes it already knows:
224
+ `export TOKEN=Zk8Qm3Xr7Tp2Wv9Bn4Ld6Hs1Jy5Gc0Fa` carries no keyword and no vendor prefix, so no
225
+ rule would see it.
226
+
227
+ It is deliberately medium confidence, because a high-entropy token may equally be a digest or
228
+ a base64 payload — legitimate content that merely looks random. That is why such hits are
229
+ pinned to the local worker rather than failing the request.
230
+
231
+ Defaults: `entropyCheck: true`, `entropyMinBitsPerChar: 4.5`, `entropyMinLength: 20`. Those
232
+ thresholds are **measured, not guessed**: on a corpus containing sha256 digests, base64
233
+ payloads, UUIDs, git SHAs, long file paths, prose and minified CSS, 4.5 bits/char at length
234
+ >= 20 caught every sampled secret with zero false positives (random keys scored ~5.2, the
235
+ noise ~4.2-4.4). Tune against your own code before trusting that boundary — the constants live
236
+ in `src/local-classifier.ts`.
237
+
238
+ The entropy scan is applied to the **gate only**, not to `classifyLocally`, so the unwired
239
+ classifier stays behaviour-compatible with its frozen reference corpus.
240
+
241
+ When you call `predictRoute` directly, its decision output is
242
+ `{ provider, model, route, gate, rationale, scores, latencyMs }`, where `route` is
243
+ `WORKER_LOCAL` or `ARCHITECT_CLOUD`. Nothing in the plugin acts on it.
244
+
245
+ ## Configuration
246
+
247
+ | Key | Default | Purpose |
248
+ | --- | --- | --- |
249
+ | `localProvider` | `lm-studio` | Provider id used for local execution |
250
+ | `cloudProvider` | `deepseek-official` | Provider id used for escalated execution |
251
+ | `localModel` | `qwen/qwen3.8-27b` | Local model id, used for the local reroute *and* for `delegate_worker` |
252
+ | `localEndpoint` | `http://127.0.0.1:1234/v1` | Base URL `delegate_worker` posts to (a full `/chat/completions` URL is also accepted). Operator configuration — a caller-supplied `endpoint` argument is ignored, so a model cannot redirect the task |
253
+ | `cloudModel` | `deepseek-chat` | Cloud model id |
254
+ | `contextThreshold` / `contextTokenThreshold` | `30000` | **Inert.** Read only inside `LocalRouter.predictRoute`, which nothing in `src/` calls — see [The classifier is available but not wired in](#the-classifier-is-available-but-not-wired-in). Setting it changes nothing |
255
+ | `timeoutMs` | `2000` | **Inert, and never read at all.** The value is stored on the router's config and no code path reads it. The only timeout that binds a delegation is `verificationTimeoutMs` |
256
+ | `enforceDLP` | `true` | Enable the credential firewall |
257
+ | `dlpAction` | `'block'` | `block` refuses a credential-bearing request; `local` pins it to the local worker |
258
+ | `entropyCheck` | `true` | Enable the high-entropy backstop |
259
+ | `entropyMinBitsPerChar` | `4.5` | Entropy threshold, in bits per character |
260
+ | `entropyMinLength` | `20` | Minimum token length before entropy is scored |
261
+ | `localCodeGuard` | `true` | Refuse cloud-authored source writes |
262
+ | `guardMode` | `deny` | `deny` or `ask` for guard hits |
263
+ | `guardAskPaths` | `["tests/", "tools/"]` | Paths downgraded from deny to an approval prompt |
264
+ | `verificationApproval` | `'ask'` | `ask`, `allow` or `deny` for a delegated `runVerification` command |
265
+ | `verificationAllowlist` | `[]` | Programs whose verification commands skip the prompt (matches the program, not its arguments) |
266
+ | `verificationTimeoutMs` | `30000` | How long a verification command may run before it is killed. Raise it for a suite or build that legitimately needs longer; a value that is not a positive finite number falls back to the default rather than removing the bound |
267
+ | `coherenceVerification` | — | The **project's** own check — a build, a full suite — run after each unit's contract and given the power to void it. `INCOHERENT` means the unit passed and the project did not. Operator configuration rather than model input, so it is absent from the tool schema and not approval-gated; a spawn the sandbox denies counts as a failure. Skipped when the unit wrote no files, and when `verificationApproval` is `deny`. **It runs with `cwd` set to the delegation's own workspace, so every path in it must be absolute** — a relative `cd plugin` resolves against whatever workspace the unit happened to target, fails there, and reports `INCOHERENT` for a unit whose contract passed. The runner's shell is `cmd.exe`, so use cmd syntax: `cd /d C:\path\to\plugin && npm run build && …`. Measured: the relative form exits 1 from `experiments/contract-first`, the absolute form exits 0 with the full gate green |
268
+ | `unitScope` | `'enforce'` | Whether a unit's declared `targetFiles` is a boundary. `'enforce'` refuses a write to any path the unit did not declare — a declared directory covers its subtree, and a unit that declares nothing is unrestricted. `'off'` restores the permissive behaviour. Enforced by default because it is what makes an `INCOHERENT` verdict attributable to a unit |
269
+ | `retryContext` | `'auto'` | Whether a failed unit's failure locations are read back and offered to the next attempt in the same workspace as context. `'auto'` adds one window per file the failure named, consumed once and cleared by any unit that then passes; `'off'` disables it. The ordinary cause of a unit that failed "for no visible reason" is that the worker was never shown the code it had to change, and the failure already names the file. Best-effort by construction: the widened injection replaces the declared one only if it resolves cleanly, so it can never turn a runnable delegation into `CONTEXT_REFUSED`. The code goes to the **worker**; the architect is told which files were added, never their contents |
270
+ | `emitAllowlist` | `[]` | Extra directories a delegated worker may write into besides the workspace |
271
+ | `allowInProcessFallback` | `false` | Run a denied spawn's verification module inside the server process |
272
+ | `leadProviders` | `[]` | Provider ids that are **not** the architect. Their requests are left exactly as configured |
273
+ | `leadTier` | `false` | Derive `leadProviders` from the `LEAD` profile instead of naming providers |
274
+ | `delegateReadPolicy` | `'ask'` | `ask`, `allow` or `deny` for reading a file a worker wrote. `allow` weakens rule 3 |
275
+ | `sourceEgress` | `'deny'` | `deny`, `ask` or `allow` for a cloud-bound request carrying source (rule 8) |
276
+ | `sourceEgressMinLines` | `3` | Lines a fenced source block needs before it counts. Lower is more false positives |
277
+
278
+ `guardAskPaths` **replaces** the built-in default rather than adding to it. Setting it to
279
+ `["plugin/src/"]` therefore makes `tests/` and `tools/` hard denies: list every path you want to
280
+ stay approval-eligible, e.g. `["plugin/src/", "tests/", "tools/"]`.
281
+
282
+ ### Running a lead alongside the architect
283
+
284
+ The `agent/request` hook treats every request as the architect: it pins the provider, uncaps the
285
+ context window, and supplies the architect's instruction plus `delegate_worker`. That is right for the
286
+ architect and wrong for anything else — a lead agent you configured to run locally would be redirected
287
+ to the cloud and told it was the architect, silently undoing the preset.
288
+
289
+ A request **pinned to the local worker** (a DLP reroute under `dlpAction: 'local'`) also drops the
290
+ request's `reasoningEffort`. A session's model selection is sticky — the last `model/selection` record
291
+ carries its effort into every later request — and a local model commonly advertises no efforts at all,
292
+ so the host would otherwise refuse the pinned request before any network I/O
293
+ (`UNSUPPORTED_REASONING_EFFORT`) and refuse every later turn of that session with it. The cloud route
294
+ keeps the effort: it is a capability of the model it was configured for.
295
+
296
+ `leadProviders` names the provider ids that are **not** the architect. A request the host has already
297
+ resolved to one of them is passed through untouched: same provider, same model, same context window,
298
+ same instruction, and no `delegate_worker`. The DLP firewall still runs on it, because opting a
299
+ provider out of the architect role does not opt it out of the credential gate.
300
+
301
+ ```yaml
302
+ - id: local-router
303
+ config:
304
+ leadTier: true # derive the list from the LEAD profile
305
+ # or name providers explicitly:
306
+ # leadProviders: ['lm-studio'] # anything resolved to lm-studio is left alone
307
+ ```
308
+
309
+ It is an **allowlist rather than an inference** on purpose. Guessing the role from "the provider is not
310
+ the architect's" would stop pinning the architect the moment a profile named its provider something
311
+ else, and the failure would be silent — and pointed at the cloud. Empty by default, which means every
312
+ request is the architect, exactly as before this option existed.
313
+
314
+ `leadTier: true` is the shortcut: it derives the list from the `LEAD` profile, which describes the lead
315
+ tier in one place — a local thinking model on the worker's endpoint, deliberately without
316
+ `delegate_worker`, since the lead authors contracts and the architect dispatches them. An explicit
317
+ `leadProviders` list always wins over the shortcut.
318
+
319
+ The other half — a DSH agent preset that gives the lead its instruction and repository-read tools — is
320
+ the operator's to write. [`../docs/lead-tier.md`](../docs/lead-tier.md) records the shape, the host
321
+ capabilities involved, and why the tier was retired after measurement.
322
+
323
+ ## Rule 8: source may not reach the cloud
324
+
325
+ The other gates cover the tool routes: the read guard stops delegated code coming back, and
326
+ `contextFiles` injects into the worker rather than the cloud. What is left is the blunt route — source
327
+ sitting in the outbound payload because it was typed into a cloud-bound conversation.
328
+
329
+ Before a request leaves for the cloud, the payload is scanned for fenced blocks carrying a source
330
+ language tag and at least `sourceEgressMinLines` lines (default 3). `sourceEgress` then decides:
331
+ `deny` (default) refuses the request, `ask` puts it to the approval seam, `allow` transmits it. A
332
+ request bound for the local worker is not egress and is never affected — which is the whole reason the
333
+ lead tier runs locally.
334
+
335
+ **The detector is a heuristic, and its limits are worth knowing before you rely on it.** Prose about
336
+ code does not trip it, and neither does an **untagged** fenced block. That second one is a real false
337
+ negative, asserted in `tests/oracles/source-egress.test.cjs` so that it cannot later be mistaken for a
338
+ proof. Like the rest of the guard, this is a deterrent rather than a boundary.
339
+
340
+ Because it is the one rule here that can refuse a request you typed yourself, the refusal names the
341
+ setting that resolves it. The gate reads every user message the session has sent, so a block from an
342
+ earlier turn keeps it closed until the session is restarted — the same behaviour as the DLP firewall,
343
+ and for the same reason: the host re-sends the conversation, so a clean latest message is not evidence
344
+ of a clean payload.
345
+
346
+ `ask` needs a reachable approval service. If none is available it **refuses** rather than transmitting,
347
+ which is the same fail-closed direction as `verificationApproval`. That path has not been verified live
348
+ from `agent/request` — `deny` is the default precisely so the unverified path is not the one you get by
349
+ default.
350
+
351
+ ## The `delegate_worker` tool
352
+
353
+ Dispatches a discrete implementation task to the local worker and returns a structured
354
+ receipt: `{ success, status, filesWritten, filesWrittenRelative, resolvedWorkspace,
355
+ workspaceSource, testResults, tokens, summary }`, plus `contextInjected` when the unit declared
356
+ context, and `contractFiles` and `contractViolations` when it declared a contract.
357
+
358
+ The worker receives the `instruction`, the `targetFiles` **paths**, the verification command, and any
359
+ `contextFiles` the architect declared — and nothing else. It has no repository read, so it cannot
360
+ discover anything; it only ever sees what it was shown. What remains unsolved is cross-unit coherence:
361
+ nothing decides which files a unit needs, and nothing checks that two units agree.
362
+
363
+ ### Where the files go
364
+
365
+ - **`workspaceDir`** selects the destination explicitly. Automatic resolution
366
+ cannot see the DSH session workspace (the Cordis `Agent` exposes only an id, and the path
367
+ lives in session metadata behind a store the plugin cannot reach), so omitting it resolves
368
+ to the server's working directory and says so in `summary`.
369
+
370
+ Because the argument is model-visible, it **replaces the base directory for every containment
371
+ check**, so it is gated by policy rather than taken on trust:
372
+
373
+ | destination | policy |
374
+ | --- | --- |
375
+ | omitted | trusted — the session workspace |
376
+ | inside the session workspace | trusted |
377
+ | outside it, but under an `emitAllowlist` root | trusted — the operator's explicit grant |
378
+ | outside it and not allowlisted | **operator prompt**; refused when no approval service is reachable |
379
+
380
+ So passing an unconfigured external directory now **asks** rather than writing silently. A refusal
381
+ is a `CONTEXT_REFUSED` verdict and the worker is never called. Auto-resolved directories are gated on
382
+ the same four rows: resolution reads environment variables and context services, and a compromise
383
+ there should not widen containment either. The allowlist is the way to authorise a directory once
384
+ instead of approving it every time.
385
+ - **File emission** is driven by fenced code blocks whose header names the target, e.g.
386
+ ```` ```ts file="src/thing.ts" ```` or a `// FILE: src/thing.ts` first line.
387
+ - **Safety**: output that does not look like source code is refused rather than written. That check exists
388
+ because an earlier version silently replaced a working module with a tool-call transcript.
389
+
390
+ ### Creating and modifying
391
+
392
+ - **A patch edits in place; a code block creates.** To change part of an existing file the worker emits
393
+ a fenced block headed `patch file="…"` whose body holds `<<<<<<< SEARCH`, `=======` and
394
+ `>>>>>>> REPLACE` markers. The SEARCH text must match the file exactly and **exactly once**: zero
395
+ matches, two matches, an empty search, and a trivially short search are each refused, and every block
396
+ applies or none does. There is deliberately no fuzzy matching — a near miss is the mechanism by which
397
+ a wrong edit lands silently, so it fails and the unit is re-delegated instead. The file's own line
398
+ endings survive the edit, a malformed patch is refused rather than falling through as a whole-file
399
+ write, and the receipt reports which files were patched and how many hunks each took.
400
+ - **A whole-file emission may create a file, never modify one.** Writing over a path that already exists is
401
+ refused and reported, even when the new content is larger. The worker has no repository read, so it cannot
402
+ have seen that file unless `contextFiles` injected it, and a file it has not seen can only be replaced
403
+ blindly — which is exactly what happened in the contract-first experiment, where a "make exactly one
404
+ change" unit returned a rewritten 86-line module in place of a 129-line one and the write landed. This
405
+ used to be guarded by a size heuristic that refused an overwrite only when the new content was under half
406
+ the old file's bytes, and a substitution keeping most of the bytes passed it regardless of how much of the
407
+ file had actually changed. Nothing is inexpressible now: a whole file can still be replaced wholesale, by
408
+ sending a patch whose search text is its entire current content. It just has to be said, and it has to
409
+ match what was there. Verified live after a restart, in all three directions: creating a new file
410
+ succeeded, a whole-file rewrite of that same path was refused with `filesWritten: []`, and the same change
411
+ sent as one patch applied.
412
+
413
+ ### Declaring what the worker may see
414
+
415
+ - **`contextFiles` shows the worker the code it must change, without showing it to you.** Entries are
416
+ `{ path, startLine?, endLine? }` with 1-based inclusive ranges. The plugin reads them into the worker
417
+ prompt and returns a record of what it injected — path, range, lines, bytes, sha256 — and never the
418
+ contents. Containment matches emission, so paths outside the workspace are refused, and an injection
419
+ that would exceed its byte budget is refused rather than quietly truncated. A credential found in the
420
+ declared context refuses the delegation rather than transmitting it: the endpoint may be a vLLM port
421
+ on another machine, and a "local" endpoint that is remote is a cloud.
422
+
423
+ ### Declaring the contract that judges it
424
+
425
+ - **`contractFiles` declares the contract's tests, and the architect owns them.** Each is hashed
426
+ before the worker runs, refused as a worker emission target, and re-hashed afterwards. Any change
427
+ voids the verdict and reports `status: 'CONTRACT_MODIFIED'`, which outranks even a passing
428
+ verification — because what passed was no longer the contract. A declared file that does not exist
429
+ is a violation too, so a typo fails closed rather than passing quietly. The receipt carries paths,
430
+ hashes and an `unchanged` flag, never contents.
431
+
432
+ Enforcement is **opt-in**, and that is a real limit. A unit that declares no `contractFiles` gets no
433
+ protection, and nothing detects that the architect should have declared one. The mechanism makes a
434
+ declared contract unrewritable; it cannot make declaring one mandatory.
435
+
436
+ **The pattern that makes opt-in survivable: one shared interface contract per boundary.** The expensive
437
+ failure is not a worker breaking its own contract — it is two units agreeing on an interface neither was
438
+ shown. The worker has no repository read, so if the architect does not inject unit A's file into unit B's
439
+ `contextFiles`, B's worker invents a plausible signature; B's contract was written by the same architect
440
+ from the same mental model, so B's tests agree with the invention and B reports `SUCCESS`.
441
+
442
+ Author one test per boundary asserting the agreed surface — that the module exports these names, with
443
+ these arities and these shapes — and declare it as `contractFiles` on **every** unit that touches that
444
+ boundary. Then unit A fails if it does not emit the agreed surface, because that test is among those
445
+ judging it; unit B is measured against the same pinned artifact whether or not A's file was injected, so
446
+ its assumption rests on something checked rather than something remembered; and no worker can edit it, so
447
+ a change voids the verdict as `CONTRACT_MODIFIED`. Put it in the suite `coherenceVerification` runs and it
448
+ is evaluated after every unit, so breaking the surface reports `INCOHERENT` even when a unit's own tests
449
+ pass. What remains missing is what this section opened with: nothing detects that a unit touching an
450
+ existing boundary declared no interface contract. The pattern is available; adopting it is judgement.
451
+
452
+ **A programmatic check for that was designed and rejected, because it aimed at the wrong thing.** The
453
+ obvious version compares a unit's declared `targetFiles` against the delegated registry and reports a
454
+ boundary that was neither injected nor pinned. It does not fire on the failure it was meant to catch: when
455
+ unit B calls an interface unit A wrote, B's targets name B's own new file and never mention A, so there is
456
+ nothing to compare. It would fire only when a unit's target *is* an already-delegated file — and that case
457
+ is closed already, by the emission rule two sections up: a whole-file emission cannot modify an existing
458
+ file, and a patch must match content the worker was shown, so a worker cannot rewrite or blind-edit a file
459
+ it never saw. What is left is a choice — declare the boundary or inject it — which is judgement, and the
460
+ two paragraphs above are where the judgement is written down. The enforcement that does exist is the one
461
+ that counts: the contract, once declared, is hashed, unwritable and re-checked.
462
+
463
+ ### Verification
464
+
465
+ - **Verification** runs `runVerification` with the resolved workspace as cwd, and the
466
+ command's **exit code is authoritative** — unrecognised output can never be scored a pass,
467
+ because `tsc`-style failures would otherwise report success.
468
+ - **A unit that writes files without a command is reported `UNVERIFIED`**, never `SUCCESS`. The command is
469
+ the caller's to declare and is approval-gated because it is model-selected; there is no default one.
470
+
471
+ ### Failure reports are redacted
472
+
473
+ Every retained field — subtest name, location, code and prose message — is secret-scanned with
474
+ the same rules the DLP gate uses and length-bounded before it can travel, because a TAP label
475
+ can carry a credential as easily as a diff can. An offending value is replaced by
476
+ `[redacted: …]`; counts and structure still travel.
477
+
478
+ Raw verification output is a **source-egress channel**: compiler errors quote the offending
479
+ line, assertion blocks carry `expected`/`actual` values, and stack frames name code. Routed to
480
+ a cloud architect, that leaks source incrementally — and persistently, since a conversation's
481
+ whole history is re-sent on every request.
482
+
483
+ So what travels back is **structure, not source**:
484
+
485
+ | Kept | Dropped |
486
+ | --- | --- |
487
+ | pass/fail counts, exit code | assertion diffs (`expected`/`actual`) |
488
+ | subtest names | stack frames and code excerpts |
489
+ | `file:line:col` | diff markers (`+`/`-` blocks) |
490
+ | error kind and code (`TS1434`, `ERR_ASSERTION`) | any assertion message that reads as code |
491
+
492
+ The **full raw text stays local**, written to
493
+ `$DSH_HOME/local-router/last-verification.log` (path returned as `rawOutputPath`), because the
494
+ delegated worker needs complete detail to fix mechanical failures. The redaction happens on
495
+ the way out, not on the way in.
496
+
497
+ Set `DSH_LOCAL_ROUTER_RAW_VERIFICATION=1` (or `redactVerification: false`) to return raw output
498
+ — useful locally, but it puts source back into whatever context reads the receipt.
499
+
500
+ Note this is a *mitigation*, not immunity: an assertion message written in prose can still
501
+ describe intent, and `file:line` still reveals structure. It removes the bulk carrier, not
502
+ every signal.
503
+
504
+ ### Verification runs as a subprocess — a container is optional hardening
505
+
506
+ `runVerification` is executed as a command string with the authority of the DSH process, and the
507
+ code under test is written by the local model.
508
+
509
+ **By default it runs as an ordinary child process, and no container is required.** Output is
510
+ captured through file descriptors rather than pipes, because DSH's confined sandbox modes refuse
511
+ a piped spawn outright (`spawn EPERM`). That refusal used to make the subprocess path unusable in
512
+ the default configuration, leaving the in-process fallback as the only one that worked — the
513
+ wrong trade in every direction, since that fallback runs model-written code inside the server.
514
+ Descriptor capture means verification needs no opt-in and no runtime.
515
+
516
+ `allowInProcessFallback` stays `false`, and enabling it is worse, not better: the failures seen in
517
+ practice were ordinary, not crafted — a fixture calling `process.exit()` terminated the host
518
+ running the tests, and `process.exitCode` corrupted the server's own exit code, so ten tests
519
+ passed while the runner exited 1. Both are now isolated, but an infinite loop still hangs the
520
+ server and in-process code can still mutate globals. **In-process execution cannot be made safe;
521
+ it can only be made less bad.**
522
+
523
+ A child process is bounded by your user account rather than by the plugin — the files it can
524
+ write, the network it can reach. The approval prompt is the practical control. To bound it more
525
+ tightly, run the verification inside a **container**: optional hardening for those who have a
526
+ runtime, not a prerequisite. Pass the container command as `runVerification` (it is a
527
+ `delegate_worker` argument, not a plugin config key, so the caller supplies it per delegation):
528
+
529
+ ```js
530
+ delegate_worker({
531
+ instruction: '...',
532
+ targetFiles: ['src/thing.ts'],
533
+ workspaceDir: '/path/to/workspace',
534
+ runVerification:
535
+ 'docker run --rm --network none --read-only --tmpfs /tmp ' +
536
+ '--cap-drop ALL --pids-limit 256 --memory 1g ' +
537
+ '-v /path/to/workspace:/w -w /w node:22-alpine node /w/tests/thing.test.cjs',
538
+ })
539
+ ```
540
+
541
+ What each flag buys: `--network none` stops executed code from exfiltrating; `--read-only`,
542
+ `--tmpfs /tmp` and `--cap-drop ALL` bound what it can touch; `--pids-limit` and `--memory`
543
+ bound resource abuse; `--rm` makes every run ephemeral. Mount only the workspace — and note a
544
+ read-write mount still lets the worker write code there, because isolation bounds *execution*,
545
+ not authorship.
546
+
547
+ Stated plainly, so this is not over-read:
548
+
549
+ - Most operators have no container runtime, and that is fine: the default path is a plain child
550
+ process and works without one. Containers are for bounding execution more tightly than your own
551
+ account does. Check with `docker version` (or `podman info`) before relying on the example
552
+ above.
553
+ - The plugin cannot conjure a boundary the host does not have. If even a descriptor spawn is
554
+ refused, verification fails closed and reports a refusal rather than executing anything.
555
+ - Nothing forces the caller to use a container. The command is model-supplied, so the approval
556
+ prompt is what catches a plain host command — read it before approving.
557
+ - Docker shares the host kernel. It is a strong boundary, not a guarantee — kernel escapes,
558
+ `--privileged`, and careless mounts all defeat it. For code from genuinely untrusted
559
+ sources, a VM or gVisor-class sandbox is stronger.
560
+ - It does **not** address egress. What the architect reads is a separate channel, handled by
561
+ the redaction above.
562
+ - It does **not** change the guard's detection limits, nor stop code appearing in prose.
563
+
564
+ ## Local-code guard
565
+
566
+ Once enabled, `write`/`edit`-style tool calls targeting source extensions are refused with a
567
+ message directing the work to `delegate_worker`.
568
+
569
+ ### Reading back what was delegated
570
+
571
+ The guard runs in both directions. A `read`-style tool call — or a shell command that reads through
572
+ `Get-Content`, `cat`, `Select-String`, `grep` and the like — is **asked** when the target is a file
573
+ this plugin wrote on a delegation's behalf. The reason is the one the delegation exists for: pulling
574
+ that code back into the architect's context defeats the point of having delegated it. There is no
575
+ route that gets the file summarised back either, because the worker has no repository read and cannot
576
+ inspect a file it was not given. Either approve the read and accept the context cost, or re-plan the
577
+ unit so that it does not need the contents.
578
+
579
+ `delegateReadPolicy` changes what "asked" means. `deny` refuses the read outright rather than prompting.
580
+ `allow` permits it, and that is the setting a lead tier needs: the lead's job is to read the code it
581
+ writes contracts about, and the guard cannot yet tell a lead from the architect because the host does
582
+ not expose which agent is which.
583
+
584
+ **`allow` is an honest weakening of rule 3, not a fix.** It relaxes the rule for *every* agent, so a
585
+ prompt that would have caught the architect reading back delegated code stops appearing. The fix is the
586
+ host exposing agent lineage; `allow` is what you use until then, with that cost written down rather than
587
+ implied. `deny` is the other end — useful when the architect must never see the code again and a prompt
588
+ would only be a temptation.
589
+
590
+ ### The architect *can* read source, and every such read is recorded
591
+
592
+ Worth stating plainly, because it is the one boundary in this plugin that is not a boundary. The read
593
+ guard gates files a worker wrote; everything else is readable by any agent, so **the architect can read
594
+ every source file in your repository**. What discourages it is one sentence in its system instruction,
595
+ and a sentence is a convention.
596
+
597
+ One option narrows this. With `sourceReadEgress: 'declarations'` armed, a read of a covered `.ts`, `.tsx`,
598
+ `.js` or `.jsx` file returns the compiled type declaration instead of the body — see
599
+ **Declarations mode** below. It is opt-in, it covers those four extensions only, and it substitutes on the
600
+ tool return rather than checking a permission, so everything outside the four stays readable.
601
+
602
+ Rather than assert otherwise, the plugin records it. Every read that passes the guard and targets a
603
+ source extension is written to the `SOURCE_READ` trace, with the role when the agent can be identified
604
+ and `unknown` when it cannot.
605
+
606
+ The point is to settle a design question with evidence. If the trace stays empty across real work, the
607
+ architect's read access can be closed — and that would make "the lead is the only tier that sees code" a
608
+ fact rather than an intention. If it does not stay empty, then the lead is not carrying the reading it is
609
+ supposed to, and closing the access would break the workflow that actually exists.
610
+
611
+ Attribution is an inference, not lineage, and it is labelled as such. The host does not say which agent
612
+ is which, so the plugin correlates the agent ids it sees on requests with the ids it sees on tool calls.
613
+ An unmatched id is recorded as `unknown`: the read is still counted, and the record does not pretend to
614
+ know who made it.
615
+
616
+ The registry is persisted to `delegated-registry.json` in the plugin data directory — one entry per
617
+ path, recording the path as it was written plus the sha256 of the content — and reloaded at startup, so
618
+ a restart does not silently forget what was delegated. It is bounded at 500 entries, newest first, and
619
+ entries whose file no longer exists are pruned on load. The hash does not relax the gate: a delegated
620
+ file stays protected however it later changes, and the hash exists to make the record answerable.
621
+
622
+ It used to be in-memory only, and that was documented as deliberate. A live run showed the cost:
623
+ reloading the host emptied it, and the architect could then read previously delegated source without
624
+ being asked at all.
625
+
626
+ Only files this plugin wrote are covered; everything else reads freely, and a delegated path is only
627
+ recognised in a shell command when the command names it in full.
628
+
629
+ Paths matching `guardAskPaths` (default `tests/`, `tools/`) are **asked** rather than refused:
630
+ the guard calls `ctx.approval.request(...)` on `@deepseek-ai/dsh-user-approval`, and only an
631
+ `allowed-once` outcome lets the write through. That seam fails closed — no approval service, no
632
+ agent, an idle conversation, or a throwing answerer all resolve to `unavailable`, which the guard
633
+ treats as a refusal. Set `guardMode: 'deny'` to skip asking on those paths entirely.
634
+
635
+ Note that a `tools/pre-execute` listener returning `{ kind: 'ask' }` does **not** prompt anyone;
636
+ the pipeline understands only `deny`, and every other kind is an allow. That is why the guard
637
+ reaches for the approval service explicitly instead of returning `ask`.
638
+
639
+ Known limits:
640
+
641
+ - The guard sees tool calls, not prose — it cannot stop code being typed into a reply.
642
+ - The read guard covers files **this plugin wrote**. Every other file is still freely readable, and a
643
+ delegated file is recognised in a shell command only when the command names it in full — a path
644
+ built at runtime, or a glob that happens to match one, is not detected.
645
+ - Shell writes **and deletions** are detected heuristically. Two scans run: a script named on
646
+ the command line **for execution** is read and followed up to depth 2 (with cycle protection),
647
+ and the **command line itself** is checked for a source-extension reference combined with a
648
+ mutation signal — a write verb (`Set-Content`, `cp`, `mv`, `Move-Item`, `sed -i`,
649
+ `git checkout`/`git apply`, …), a delete verb (`Remove-Item`, `rm`, `del`, `rmdir`, `git rm`,
650
+ `os.remove`, …), a real redirection, or inline program text (`python -c`, `node -e`). A file
651
+ named in order to be *read* is not treated as invoked, so inspecting a `.js` file with
652
+ `Select-String`, `grep` or `Get-Content` does not ask. Destroying source is gated the same way
653
+ as authoring it, and both verb lists are word-anchored — unanchored, `Move-Item` matches
654
+ inside `Remove-Item` and a plain delete is misread as a write. Because this is a text scan, a
655
+ command that merely *mentions* a mutation is gated as well — over-asking is deliberate, since
656
+ that is the safe direction. Where several source paths appear, the longest is reported as the
657
+ target, so extension-shaped prose cannot displace the real path.
658
+ - **Relative script paths can evade inspection.** A relative path is resolved against the
659
+ server's working directory, not the session workspace, which the plugin cannot see (the
660
+ Cordis `Agent` exposes only an id, and the path lives in session metadata behind a store the
661
+ plugin cannot reach). Invoke scripts by **absolute path** — that is the form the guard can
662
+ read, and the form its message steers you toward. Absolute invocations are inspected;
663
+ relative ones may not be.
664
+ - Writes made *indirectly* are not detected: if a script or a command delegates the work to a
665
+ library, the write primitive never appears in the inspected text.
666
+ - A write target computed at runtime (`f = 'src/' + name`) cannot be matched by a text scan.
667
+ - Config files (`.yaml`, `.json`, `.env`) are out of scope — only source extensions are gated.
668
+ - `delegate_worker` does not go through this guard, but it carries its own limits: a path that
669
+ resolves outside its workspace is refused, and its model-supplied `runVerification` command
670
+ is gated (see below).
671
+
672
+ Treat it as a strong deterrent at the tool layer, not an airtight boundary.
673
+
674
+ ### Declarations mode: what the read control actually does
675
+
676
+ `sourceReadEgress: 'declarations'` is **opt-in**; the default is `'source'`, under which a read returns the
677
+ file. Arming it changes what a read of a *covered* file returns. Five facts bound what that means, each
678
+ stated at the level it is enforced — describing this control by its intent rather than its mechanism is
679
+ how it gets quoted for properties it does not have.
680
+
681
+ **1. The substitution happens on the tool-return payload, after the read.** The mechanism is registered on
682
+ **`tools/post-execute`**. The tool execution layer reads the file from disk exactly as it otherwise would;
683
+ the hook then replaces the returned payload with the compiled declaration before it enters the model's
684
+ context. Body bytes are withheld from the **model**, not from the **filesystem** — any process on the
685
+ host, including this plugin's own worker, can still read the file. Blocking mutation is a separate
686
+ mechanism (`tools/pre-execute`); this one is egress only.
687
+
688
+ **2. A refusal names the condition, and there are three of them.** When no declaration can be served the
689
+ read is refused rather than silently falling back to source:
690
+
691
+ | condition | what the reader is told |
692
+ | --- | --- |
693
+ | no skeleton was emitted for the path | *"…was refused: sourceReadEgress is 'declarations' and no type skeleton exists at '…'"* |
694
+ | the skeleton is older than its source | *"…its type skeleton at '…' is OLDER than the source, so it may describe a signature that has since changed"* |
695
+ | a code extension no build can map (`.py`, `.go`, `.rs`, …) | *"…was refused: sourceReadEgress is 'declarations' and no type skeleton can be produced for …"* |
696
+
697
+ Staleness is decided on **file mtime**, not on content. It is evidence that a rebuild happened after an
698
+ edit, and it is defeated by a `touch`, a `git checkout` or clock skew: it closes the common case —
699
+ planning without building — and does not prove the declaration matches the source. This is **provenance,
700
+ not verification**.
701
+
702
+ **3. Coverage is exactly four extensions.** `declarationPathFor` returns a path for `.ts`, `.tsx`, `.js`
703
+ and `.jsx`, and `null` for everything else, so reads of `.json`, `.cjs`, `.mjs`, `.md`, `.yml`, `.py`,
704
+ `.go`, `.sh`, `.ps1`, `.sql` — or any other extension — are **served raw, in both modes**. The control
705
+ stops at the four, and the benchmark record measures it: the fixture-audit task returns the same nine
706
+ matches in both arms precisely because both of its files are outside the covered set.
707
+
708
+ **4. The substitution does not prompt; the controls around it do.** The post-execute redirect is
709
+ deterministic and non-interactive — no *allow this read?* dialog appears for a covered source file. That
710
+ holds for *this hook only*. Around it, `delegateReadPolicy: 'ask'` (the default) prompts when reading back
711
+ a file a worker wrote, `guardMode: 'ask'` and `guardAskPaths` downgrade a deny to an approval prompt, and
712
+ the approval seam fails closed when no answerer is reachable.
713
+
714
+ **5. Three egress paths sit outside this hook.** It filters tool *result* payloads, so it never sees:
715
+
716
+ - **`contextFiles` injected into a worker's prompt** — governed by containment and credential checks, not
717
+ by `sourceReadEgress`, and the architect receives a record of what was injected rather than contents;
718
+ - **files outside the four extensions** (point 3) — returned whole;
719
+ - **the outbound payload the desktop host assembles** — the host builds and dispatches the final request
720
+ *after* this plugin's hooks have run, so assistant output and tool results are not inspected here.
721
+
722
+ **Bottom line, as a bound rather than a slogan.** With the control armed, a read of a covered `.ts`,
723
+ `.tsx`, `.js` or `.jsx` file returns the compiled type declaration or is refused; it does not return the
724
+ body. That is not the claim that the model cannot reach source — it is the claim that *this read path, for
725
+ these four extensions*, does not carry it.
726
+
727
+ ## Delegated verification and file writes
728
+
729
+ `delegate_worker` accepts `runVerification`, a shell command the **cloud model** chooses, and it
730
+ runs with the full authority of the DSH process. It is therefore gated:
731
+
732
+ | `verificationApproval` | Behaviour |
733
+ | --- | --- |
734
+ | `'ask'` (default) | The operator approves the exact command through the approval seam before anything runs. No reachable approval service means refusal. |
735
+ | `'allow'` | Runs unattended. Under `'ask'`, `verificationAllowlist` lets named programs skip the prompt; it matches the program only, so allowing `node` also allows `node -e "<anything>"`. |
736
+ | `'deny'` | Verification never runs. |
737
+
738
+ A command that does not run is reported as `status: 'VERIFICATION_NOT_APPROVED'` with a
739
+ `verificationSkipped` reason and `success: false`.
740
+
741
+ **A result is a verdict on a contract, and the verification command *is* the contract.** So a
742
+ delegation that wrote files without being given a command to check them reports
743
+ `status: 'UNVERIFIED'` and `success: false`, with the summary saying the contract was never checked.
744
+ It previously reported `SUCCESS`, which was the same false green as counting unrecognised test output
745
+ as a pass. A delegation that wrote no files — a question answered, say — has nothing to verify and
746
+ still reports `SUCCESS`. `runVerification` is not confined to the workspace, so read the approval prompt, and see
747
+ [verification runs as a subprocess](#verification-runs-as-a-subprocess--a-container-is-optional-hardening)
748
+ for the container option.
749
+
750
+ **File writes are contained.** A fenced path or `targetFiles` hint that resolves outside the
751
+ resolved workspace — an absolute path, a `..` segment, or a symlink pointing out of it — is
752
+ refused and reported in `errors` instead of being written. `emitAllowlist` extends the permitted
753
+ roots explicitly.
754
+
755
+ **`allowInProcessFallback`** (default `false`) decides what happens when the sandbox refuses a
756
+ piped spawn. Off, verification fails closed. On, the target module is `require`d inside the DSH
757
+ **server** process, running model-influenced code with full host authority — it can terminate
758
+ the server. Enable it only where the spawn restriction cannot be lifted, and prefer a container.
759
+
760
+ ## State
761
+
762
+ All local state derives from one directory, never a hard-coded path:
763
+
764
+ 1. `DSH_LOCAL_ROUTER_DATA_DIR` if set
765
+ 2. otherwise `$DSH_HOME/local-router`
766
+ 3. otherwise `~/.dsh/local-router`
767
+
768
+ It holds `router-debug.log` and `savings-ledger.json` — per-step and cumulative token accounting,
769
+ split local versus cloud, plus a cost column — along with `delegated-registry.json` (the recorded verdict
770
+ per delegated file), and `last-verification.log` and `last-coherence.log`, the raw output of the two
771
+ commands that judge a unit. The dollar figure is the **cloud-equivalent** of work run locally: it measures
772
+ the metered plan's exposure, and it is not money saved, because the GPU is a fixed cost this plugin neither
773
+ pays for nor reduces.
774
+
775
+ ## Throughput reference
776
+
777
+ At startup the plugin logs a measured decode baseline, e.g.
778
+ `[WORKER_BENCH] qwen/qwen3.8-27b 130.6 tok/s`. Each `[LEDGER_AUDIT]` line then reports the
779
+ **actual** end-to-end rate for that call, plus the model name. Re-measure after changing
780
+ quantisation or presets, and update `WORKER_BENCHMARKS` in `src/profiles.ts`.
781
+
782
+ Note that the ledger's `Rate` is end-to-end and therefore includes prompt prefill: short
783
+ generations read far below the decode baseline (that is expected, not a regression).
784
+
785
+ ## Development
786
+
787
+ ```bash
788
+ npm run build # tsc -> dist
789
+ npm test # vitest
790
+ ```
791
+
792
+ ## The host contract
793
+
794
+ `src/session-events.ts` holds every DSH session event this plugin reads, checked at compile time
795
+ against the host's own `SessionEventMap`:
796
+
797
+ ```ts
798
+ ] as const satisfies readonly SessionEventType[]
799
+ ```
800
+
801
+ So a DSH release that renames an event fails `npm run build` and names the offending literal, instead of
802
+ the plugin quietly ceasing to count. The payload fields each counter reads are typed from the same map, so
803
+ a moved *field* fails the build too.
804
+
805
+ That is worth exactly as much as the pinned types are accurate, so two devDependencies are pinned
806
+ **exactly** — `@deepseek-ai/dsh-session` and `@deepseek-ai/dsh-compaction`, no caret — and
807
+ `scripts/check-host-types-pin.cjs` fails when they stop matching the core that is actually running. It
808
+ resolves the host through `%APPDATA%/dsh-tauri/dependencies.json`, the same way the Desktop does, because
809
+ `dsh` on `PATH` may be a different installation entirely.
810
+
811
+ Both packages are `devDependencies`: nothing they provide survives into `dist`, and the plugin has no
812
+ runtime dependency on the harness. When you move DSH, re-pin and rebuild:
813
+
814
+ ```bash
815
+ cd plugin
816
+ npm install --save-dev --save-exact @deepseek-ai/dsh-session@<host version>
817
+ npm install --save-dev --save-exact @deepseek-ai/dsh-compaction@<host version>
818
+ npm run build && npm run test:oracles
819
+ ```
820
+
821
+ Hook **names** are checked too. Subscriptions go through `onHost(ctx, 'agent/request', handler)`, which
822
+ constrains the name to `keyof Events` — with `Events` augmented by the pinned host packages — so a
823
+ subscription the host does not offer cannot compile. That is what caught `ctx.on('tool/call')`: not a
824
+ Cordis hook at all, but a session event type, and a "fallback" that had never run and could not have.
825
+
826
+ Hook **payloads** are checked as well, because the handlers no longer annotate their parameters — both are
827
+ inferred from the host's signature for that event. That is how `payload.session` was found: a fallback read
828
+ in the routing hook for a field `agent/request` has never carried, which had never once matched.
829
+
830
+ Three limits, stated rather than left to be discovered:
831
+
832
+ - **The two unreachable guards are asserted, not checked.** `agent/request` and `agent/pre-step` each keep a
833
+ runtime fallback for a condition the host's types say cannot happen (`next` is always a function). Those
834
+ two branches now say `as any` out loud instead of quietly widening the whole handler.
835
+ - **Return values are asserted, not proven.** `applyAgentRole` hands back a looser record than the host's
836
+ `LlmCallConfig`, so the routing hook cannot demonstrate that it returns a valid config — and DSH does not
837
+ verify it either. The payload on the way *in* is checked; the config on the way out is trusted.
838
+ - **Only the four handlers this plugin registers are covered.** Anything added later keeps the check only if
839
+ it goes through `onHost` and leaves its parameters unannotated.
840
+
841
+ ## Judging a transcribed contract
842
+
843
+ Contracts are specified by the architect and transcribed by the worker, because the guard forbids
844
+ cloud-authored source. The architect then cannot read the contract back — that would put
845
+ implementation-shaped code into the metered context — so it cannot tell a contract that caught a real bug
846
+ from a contract that is itself broken. Both look like "tests failed".
847
+
848
+ Run it from the repository root (`scripts/` is not part of the published package):
849
+
850
+ ```bash
851
+ node scripts/check-contract.cjs --contract path/to/spec.test.js --module path/to/module.js
852
+ ```
853
+
854
+ It judges the contract against a **null implementation** that answers every property with itself, every
855
+ call with itself, and never throws. That condition is the design: the module cannot be blamed there, so a
856
+ test that fails by *malfunctioning* is the contract's own fault, which against a real module it might not
857
+ be. Two checks, only the first deciding:
858
+
859
+ | | against the null implementation | rejected when |
860
+ | --- | --- | --- |
861
+ | discriminates | at least one assertion failure | it passes a module that returns itself for everything |
862
+ | well-formed | no malfunction failures | a test never reached an assertion |
863
+
864
+ The run against the real module is printed and deliberately not judged — a malfunction there may be the
865
+ module throwing rather than the contract failing, and nothing in the output distinguishes the two. A
866
+ contract can be well-formed, discriminating, and still test the wrong behaviours; this guard removes one
867
+ class of failure, not all of them. It exonerated a contract the architect had already published a
868
+ misdiagnosis of — see [`../docs/experiment.md`](../docs/experiment.md).
869
+
870
+ **The second check closes the failure that costs the most.** Add `--spec`, and the guard also runs a
871
+ **specification conformance suite** the architect owns: the same trick, run backwards. The null
872
+ implementation proves the module cannot be blamed; the suite proves the module *can* be trusted — and then a
873
+ contract that still rejects a conforming module is the artifact at fault.
874
+
875
+ ```bash
876
+ node scripts/check-contract.cjs --contract path/to/spec.test.js --module path/to/module.js \
877
+ --spec path/to/spec-conformance.test.cjs
878
+ ```
879
+
880
+ That is not hypothetical either; it is the defect this project actually shipped. The experiment's
881
+ transcribed contract asserted `has(b) === false` for an expired entry on one line and `has(b) === true` for
882
+ an expired entry on another, so no implementation could ever have satisfied it — while the null run called
883
+ it sound every time. Measured against the specification suite, the guard now says so, and names what did it:
884
+
885
+ ```
886
+ C. the specification suite against the real module: 16 test(s), 16 passed, 0 failed by assertion, 0 failed by malfunction
887
+ D. the specification suite against a null implementation: 16 test(s), 0 passed, 16 failed by assertion, 0 failed by malfunction
888
+ ok: it asserts, and every failure is a behavioural disagreement
889
+
890
+ DISAGREEMENT: the module satisfies the specification suite, and the contract still rejects it.
891
+ Either the contract demands more than the specification states, or the specification suite is incomplete.
892
+ Resolve which before delegating another fix. Contract tests that failed against a conforming module:
893
+ not ok 9 - eviction chooses strictly by recency, not by expiry
894
+ not ok 10 - replacing an existing key refreshes expiry and recency, not counted as eviction
895
+ ```
896
+
897
+ The suite gets judged too, by the rule the contract is judged by: one that passes a module returning itself
898
+ for everything is refused, because it constrains nothing and cannot serve as the reference. And the verdict
899
+ deliberately names both possibilities rather than condemning the contract, because it is only as strong as
900
+ the suite — an incomplete suite makes a non-conforming module look conformant, and the contract may simply
901
+ require more than the specification states. Those seven assertions are in
902
+ [`tests/oracles/contract-spec-conformance.test.cjs`](tests/oracles/contract-spec-conformance.test.cjs).
903
+
904
+ ## Limitations
905
+
906
+ - Reading a file into the architect's context is also egress; local-first routing does not
907
+ prevent a cloud model from *seeing* source it is asked to review.
908
+ - `delegate_worker` executes a caller-supplied verification command, by default as a child
909
+ process with your account's authority. Treat that string as trusted input, and see
910
+ "Verification runs as a subprocess" above for the container option.
911
+ - Credential detection is pattern-based plus an entropy backstop. It cannot recognise
912
+ confidential material that looks ordinary — proprietary code, customer data or PII are
913
+ caught by neither layer.
914
+ - **The read gate covers search, and what it does not cover is the leak itself.** An agent was refused by
915
+ the read guard on four source files and then read the same content out of them with the search tool, so
916
+ the gate now treats `grep`, `rg`, `findstr`, `select-string` and shell readers as reads: an unsettled
917
+ delegated file can no longer be pulled into the architect's context by searching for it. But a search
918
+ whose reachable delegated files have all passed and are unchanged still returns them silently — the same
919
+ rule that lets the architect read finished work — and results cannot be filtered, because
920
+ `tools/pre-execute` returns a decision rather than a result. A search the operator allows delivers its
921
+ matches.