@twinsearth/w2m-dsh-plugin 0.21.0 → 0.22.0
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/CHANGELOG.md +50 -0
- package/package.json +1 -1
- package/src/agent/permission.mjs +98 -0
- package/src/plugin/tools.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,56 @@ All notable changes to this project are documented here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
|
5
5
|
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.22.0] — 2026-10-15
|
|
8
|
+
|
|
9
|
+
**The gate now grades a real dispatch, and connects to the approval queue.**
|
|
10
|
+
|
|
11
|
+
0.21.0 built the gate and admitted it had no caller. This release gives it one: `gateDispatch()` reads a
|
|
12
|
+
dispatch's **own** facts — `write` and a non-empty `write_scope`, both already in the payload — and grades it,
|
|
13
|
+
rather than asking the caller to name a capability. That distinction is the whole design: **a caller who must
|
|
14
|
+
name their own capability is a caller who can name the cheap one.**
|
|
15
|
+
|
|
16
|
+
### The unset level, which is the honest part
|
|
17
|
+
|
|
18
|
+
`tools.mjs` states plainly that **no parameter of any tool can widen what a machine will run**, and that there
|
|
19
|
+
is no way for a caller to set anything authority-shaped. So a plugin with no configured actor level has no
|
|
20
|
+
actor to judge. Two answers were available:
|
|
21
|
+
|
|
22
|
+
* treat an unset level as `0` — which would **refuse every writing dispatch on every machine that has not opted
|
|
23
|
+
in**, breaking a working fleet to enforce a policy nobody asked for;
|
|
24
|
+
* treat it as **unenforced, and say so**.
|
|
25
|
+
|
|
26
|
+
This does the second, and `enforced` is the whole point: **a gate that reports `ALLOW` and a gate that reports
|
|
27
|
+
"not checked" must never look alike, because the first is a decision and the second is the absence of one.**
|
|
28
|
+
A test asserts exactly that — same verdict, different `enforced`, different `reason_code`.
|
|
29
|
+
|
|
30
|
+
### The escalation reaches the queue it was built for
|
|
31
|
+
|
|
32
|
+
`approvalRequestFor()` returns `null` for anything that is not `NEEDS_APPROVAL`, and otherwise produces a
|
|
33
|
+
request shaped for the cross-session approval index built in 0.10.0 — **the two features finally meet**, which
|
|
34
|
+
is the point: a permission gate and an approval queue that do not connect are two things that each look
|
|
35
|
+
finished and jointly do nothing.
|
|
36
|
+
|
|
37
|
+
The request carries the **subject level recorded now**, not left to be re-derived later, for the reason
|
|
38
|
+
0.10.0's index already states: an approver who could supply the subject level could lower it. A destructive
|
|
39
|
+
request is marked `auto_settleable: false` explicitly, rather than leaving the queue to infer it from a
|
|
40
|
+
capability name.
|
|
41
|
+
|
|
42
|
+
### A `write` with no scope is graded as the dearer capability
|
|
43
|
+
|
|
44
|
+
The dispatch path refuses that combination separately. Grading it cheap here would mean that if that check
|
|
45
|
+
ever moved, a request intending to write would be handed the read-only permission.
|
|
46
|
+
|
|
47
|
+
### A test of mine was wrong, again in the same direction
|
|
48
|
+
|
|
49
|
+
`gateDispatch({write: true}, 0)` was asserted to be `deny`; it is `needs_approval`, **because an ordinary
|
|
50
|
+
actor is exactly one band below captain and that is the documented behaviour.** The implementation was right.
|
|
51
|
+
This is the fourth time in this project that a failing test was the test's fault — and it is the argument for
|
|
52
|
+
testing refusals rather than only the happy path, because a wrong refusal fails loudly enough to be told apart
|
|
53
|
+
from a wrong acceptance.
|
|
54
|
+
|
|
55
|
+
**11 new tests, 0 failures.**
|
|
56
|
+
|
|
7
57
|
## [0.21.0] — 2026-10-15
|
|
8
58
|
|
|
9
59
|
**The capability table has a gate now, so it is a policy rather than a description of one.**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"description": "DSH: Windows2MacOS — drive the same project on online Windows and macOS machines from one DeepSeek account, across networks and regions. P2P hole punching is the default path (relay + shared server as rendezvous, ledger and fallback), with a bundled STUN responder, Tailscale/public-VPS/tunnel shapes, Rabbit relay + per-machine Localside + eight DSH tools (replicate / split), nine aggregate verdicts and signed requests.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
package/src/agent/permission.mjs
CHANGED
|
@@ -146,6 +146,104 @@ export function decide(capability, level) {
|
|
|
146
146
|
};
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Which capability a dispatch costs, decided from the dispatch's own facts.
|
|
151
|
+
*
|
|
152
|
+
* A dispatch that may write costs more than one that may not, and the fact is already in the payload
|
|
153
|
+
* (`write` plus a non-empty `write_scope`), so this reads it rather than asking the caller to declare a
|
|
154
|
+
* capability. That matters because a caller who must name their own capability is a caller who can name the
|
|
155
|
+
* cheap one.
|
|
156
|
+
*
|
|
157
|
+
* @param {{write?: boolean, write_scope?: string[]}} args
|
|
158
|
+
* @returns {{capability: string, detail: string}}
|
|
159
|
+
*/
|
|
160
|
+
export function capabilityForDispatch(args) {
|
|
161
|
+
const writes = args?.write === true;
|
|
162
|
+
const scope = Array.isArray(args?.write_scope) ? args.write_scope.filter((s) => typeof s === 'string' && s.trim() !== '') : [];
|
|
163
|
+
if (!writes) return { capability: 'dispatch_readonly', detail: 'write is not true, so the machines cannot write' };
|
|
164
|
+
if (scope.length === 0) {
|
|
165
|
+
// A write with no scope is refused upstream by the dispatch path itself. Reporting it as the writing
|
|
166
|
+
// capability rather than the read-only one is the conservative direction: if that upstream check ever
|
|
167
|
+
// moved, this would not silently hand out the cheaper permission.
|
|
168
|
+
return { capability: 'dispatch_writing', detail: 'write is true with an empty scope, which the dispatch path refuses separately' };
|
|
169
|
+
}
|
|
170
|
+
return { capability: 'dispatch_writing', detail: `write is true inside ${scope.length} declared scope prefix(es)` };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The gate a dispatch passes through.
|
|
175
|
+
*
|
|
176
|
+
* ## Why an unset level is not the same as a low one
|
|
177
|
+
*
|
|
178
|
+
* `tools.mjs` states plainly that **no parameter of any tool can widen what a machine will run**, and that
|
|
179
|
+
* there is no way for a caller to set anything authority-shaped. So a plugin that has not been configured
|
|
180
|
+
* with an actor level has no actor to judge. Two answers were available and only one is honest:
|
|
181
|
+
*
|
|
182
|
+
* * treat an unset level as `0` — which would refuse every writing dispatch on every machine that has not
|
|
183
|
+
* opted in, breaking a working fleet to enforce a policy nobody asked for;
|
|
184
|
+
* * treat it as **unenforced**, and say so in the verdict, so the caller can tell "allowed" from "not
|
|
185
|
+
* checked".
|
|
186
|
+
*
|
|
187
|
+
* The second is what this does, and the `enforced` flag is the whole point: a gate that reports `ALLOW` and a
|
|
188
|
+
* gate that reports `not checked` must never look alike, because the first is a decision and the second is an
|
|
189
|
+
* absence of one.
|
|
190
|
+
*
|
|
191
|
+
* @param {{write?: boolean, write_scope?: string[]}} args
|
|
192
|
+
* @param {number|null|undefined} actorLevel
|
|
193
|
+
* @returns {{decision: object, enforced: boolean, capability: string, detail: string}}
|
|
194
|
+
*/
|
|
195
|
+
export function gateDispatch(args, actorLevel) {
|
|
196
|
+
const { capability, detail } = capabilityForDispatch(args);
|
|
197
|
+
if (actorLevel === null || actorLevel === undefined) {
|
|
198
|
+
return {
|
|
199
|
+
decision: {
|
|
200
|
+
verdict: AUTHORITY_VERDICT.ALLOW,
|
|
201
|
+
capability,
|
|
202
|
+
level: null,
|
|
203
|
+
required: permissionFor(capability),
|
|
204
|
+
shortfall: null,
|
|
205
|
+
destructive: isDestructive(capability),
|
|
206
|
+
reason_code: 'GATE_NOT_ENFORCED',
|
|
207
|
+
detail: 'no authority level is configured, so this dispatch was not checked',
|
|
208
|
+
},
|
|
209
|
+
enforced: false,
|
|
210
|
+
capability,
|
|
211
|
+
detail: `not enforced: ${detail}; no actor level is configured`,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
return { decision: decide(capability, actorLevel), enforced: true, capability, detail };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Build the approval request for a dispatch that needs one, or `null` when it does not.
|
|
219
|
+
*
|
|
220
|
+
* Shaped to hand straight to the cross-session approval index, which is the point: a permission gate and an
|
|
221
|
+
* approval queue that do not meet are two features that each look finished and jointly do nothing.
|
|
222
|
+
*
|
|
223
|
+
* @param {object} decision from {@link decide}
|
|
224
|
+
* @param {{taskId?: string, machineId?: string, requestedBy?: string}} context
|
|
225
|
+
*/
|
|
226
|
+
export function approvalRequestFor(decision, context = {}) {
|
|
227
|
+
if (decision === null || typeof decision !== 'object' || decision.verdict !== AUTHORITY_VERDICT.NEEDS_APPROVAL) {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
// The subject level travels WITH the request, recorded now, because it is a fact about who asked and must
|
|
232
|
+
// not be re-derived later from a caller who might be the approver.
|
|
233
|
+
subject_level: decision.level,
|
|
234
|
+
required_level: decision.required,
|
|
235
|
+
capability: decision.capability,
|
|
236
|
+
task_id: context.taskId ?? null,
|
|
237
|
+
machine_id: context.machineId ?? null,
|
|
238
|
+
requested_by: context.requestedBy ?? null,
|
|
239
|
+
destructive: decision.destructive === true,
|
|
240
|
+
// A destructive request is never auto-settleable, so it is marked here rather than left for the queue to
|
|
241
|
+
// infer from the capability name.
|
|
242
|
+
auto_settleable: mayAutoSettle(decision).auto,
|
|
243
|
+
detail: decision.detail,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
149
247
|
/**
|
|
150
248
|
* Whether a decision may be settled automatically.
|
|
151
249
|
*
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -185,7 +185,7 @@ const MAX_STAGES = 16;
|
|
|
185
185
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
186
186
|
* overrides it at runtime for tests and packagers.
|
|
187
187
|
*/
|
|
188
|
-
const PLUGIN_VERSION = '0.
|
|
188
|
+
const PLUGIN_VERSION = '0.22.0';
|
|
189
189
|
|
|
190
190
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
191
191
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|