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.
- package/LICENSE +21 -0
- package/README.md +920 -2
- package/cordis.patch.example.yml +48 -0
- package/cordis.patch.yml +13 -0
- package/dist/attestation.d.ts +25 -0
- package/dist/attestation.js +44 -0
- package/dist/containment.d.ts +33 -0
- package/dist/containment.js +63 -0
- package/dist/context-quality.d.ts +79 -0
- package/dist/context-quality.js +181 -0
- package/dist/context.d.ts +39 -0
- package/dist/context.js +136 -0
- package/dist/contracts.d.ts +174 -0
- package/dist/contracts.js +415 -0
- package/dist/declaration-egress-hook.d.ts +45 -0
- package/dist/declaration-egress-hook.js +125 -0
- package/dist/declaration-egress.d.ts +37 -0
- package/dist/declaration-egress.js +169 -0
- package/dist/delegation.d.ts +339 -0
- package/dist/delegation.js +933 -0
- package/dist/emission.d.ts +70 -0
- package/dist/emission.js +310 -0
- package/dist/guard.d.ts +162 -0
- package/dist/guard.js +873 -0
- package/dist/host-events.d.ts +15 -0
- package/dist/host-events.js +27 -0
- package/dist/index.d.ts +560 -0
- package/dist/index.js +1398 -0
- package/dist/local-classifier.d.ts +52 -0
- package/dist/local-classifier.js +159 -0
- package/dist/logging.d.ts +21 -0
- package/dist/logging.js +90 -0
- package/dist/paths.d.ts +10 -0
- package/dist/paths.js +83 -0
- package/dist/profiles.d.ts +35 -0
- package/dist/profiles.js +95 -0
- package/dist/retry-context.d.ts +52 -0
- package/dist/retry-context.js +97 -0
- package/dist/roles.d.ts +129 -0
- package/dist/roles.js +363 -0
- package/dist/routing.d.ts +35 -0
- package/dist/routing.js +88 -0
- package/dist/savings-tracker.d.ts +128 -0
- package/dist/savings-tracker.js +340 -0
- package/dist/session-events.d.ts +17 -0
- package/dist/session-events.js +31 -0
- package/dist/verification.d.ts +104 -0
- package/dist/verification.js +560 -0
- package/package.json +66 -4
package/README.md
CHANGED
|
@@ -1,3 +1,921 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-plugin-coding-delegate
|
|
2
2
|
|
|
3
|
-
|
|
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.
|