@twinsearth/w2m-dsh-plugin 0.21.0 → 0.23.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 +95 -0
- package/package.json +1 -1
- package/src/agent/authority.mjs +60 -0
- package/src/agent/permission.mjs +111 -2
- package/src/plugin/tools.mjs +55 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,101 @@ 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.23.0] — 2026-10-15
|
|
8
|
+
|
|
9
|
+
**The gate is on the dispatch path. "分权限" now executes, and a machine that has not opted in behaves exactly as before.**
|
|
10
|
+
|
|
11
|
+
`src/plugin/tools.mjs` calls `gateDispatch()` immediately after the write-scope check — placed there so the
|
|
12
|
+
thing being graded is already known to be well-formed — and refuses the dispatch when the verdict is not
|
|
13
|
+
`allow`.
|
|
14
|
+
|
|
15
|
+
### `actorAuthorityLevel`, and why it is not shared config
|
|
16
|
+
|
|
17
|
+
Read from the plugin config, or `W2M_ACTOR_AUTHORITY` so a machine can be pointed at a level without editing a
|
|
18
|
+
profile. **Deliberately not part of `shared.values`**: this is a local statement about who is typing, and a
|
|
19
|
+
fleet-wide value would let one machine's level speak for another's operator.
|
|
20
|
+
|
|
21
|
+
**`null` is not `0`.** An unset level means there is no actor to judge, and the gate reports `enforced: false`
|
|
22
|
+
and changes nothing — which is why adding this cannot alter the behaviour of any machine that has not opted
|
|
23
|
+
in. A refusal for everyone who never configured a level would have been the easy implementation and the wrong
|
|
24
|
+
one.
|
|
25
|
+
|
|
26
|
+
### What a refusal looks like
|
|
27
|
+
|
|
28
|
+
Two distinct codes, because they need different responses:
|
|
29
|
+
|
|
30
|
+
| Code | When | What it means |
|
|
31
|
+
|---|---|---|
|
|
32
|
+
| `W2M_PERMISSION_DENIED` | verdict `deny` | the level is more than one band short, or the capability is undeclared |
|
|
33
|
+
| `W2M_PERMISSION_NEEDS_APPROVAL` | verdict `needs_approval` | the level is exactly one band short |
|
|
34
|
+
|
|
35
|
+
The `needs_approval` refusal **carries the approval request** that `approvalRequestFor()` built — subject
|
|
36
|
+
level, required level, capability, `auto_settleable` — and says plainly that it is refused rather than queued
|
|
37
|
+
**because no approval queue is wired to this call yet.** Refusing and saying why is honest; queueing a request
|
|
38
|
+
nothing reads would not be.
|
|
39
|
+
|
|
40
|
+
### A defect I introduced while wiring it, and how it surfaced
|
|
41
|
+
|
|
42
|
+
The first edit **corrupted the file**: the anchor for the config field matched only the first line of a
|
|
43
|
+
multi-line expression, so it cut `allowedCommands` off from its own `.filter().map().filter()` chain and left
|
|
44
|
+
the orphan chained onto my insertion. A second attempt then wrote literal backslashes into the source by
|
|
45
|
+
escaping backticks that did not need escaping.
|
|
46
|
+
|
|
47
|
+
Both were caught by `node --check` immediately, which is the argument for running it after every write rather
|
|
48
|
+
than after the batch. Neither reached a commit.
|
|
49
|
+
|
|
50
|
+
**120 tests across the tools, permission and entrypoint suites, 0 failures.**
|
|
51
|
+
|
|
52
|
+
## [0.22.0] — 2026-10-15
|
|
53
|
+
|
|
54
|
+
**The gate now grades a real dispatch, and connects to the approval queue.**
|
|
55
|
+
|
|
56
|
+
0.21.0 built the gate and admitted it had no caller. This release gives it one: `gateDispatch()` reads a
|
|
57
|
+
dispatch's **own** facts — `write` and a non-empty `write_scope`, both already in the payload — and grades it,
|
|
58
|
+
rather than asking the caller to name a capability. That distinction is the whole design: **a caller who must
|
|
59
|
+
name their own capability is a caller who can name the cheap one.**
|
|
60
|
+
|
|
61
|
+
### The unset level, which is the honest part
|
|
62
|
+
|
|
63
|
+
`tools.mjs` states plainly that **no parameter of any tool can widen what a machine will run**, and that there
|
|
64
|
+
is no way for a caller to set anything authority-shaped. So a plugin with no configured actor level has no
|
|
65
|
+
actor to judge. Two answers were available:
|
|
66
|
+
|
|
67
|
+
* treat an unset level as `0` — which would **refuse every writing dispatch on every machine that has not opted
|
|
68
|
+
in**, breaking a working fleet to enforce a policy nobody asked for;
|
|
69
|
+
* treat it as **unenforced, and say so**.
|
|
70
|
+
|
|
71
|
+
This does the second, and `enforced` is the whole point: **a gate that reports `ALLOW` and a gate that reports
|
|
72
|
+
"not checked" must never look alike, because the first is a decision and the second is the absence of one.**
|
|
73
|
+
A test asserts exactly that — same verdict, different `enforced`, different `reason_code`.
|
|
74
|
+
|
|
75
|
+
### The escalation reaches the queue it was built for
|
|
76
|
+
|
|
77
|
+
`approvalRequestFor()` returns `null` for anything that is not `NEEDS_APPROVAL`, and otherwise produces a
|
|
78
|
+
request shaped for the cross-session approval index built in 0.10.0 — **the two features finally meet**, which
|
|
79
|
+
is the point: a permission gate and an approval queue that do not connect are two things that each look
|
|
80
|
+
finished and jointly do nothing.
|
|
81
|
+
|
|
82
|
+
The request carries the **subject level recorded now**, not left to be re-derived later, for the reason
|
|
83
|
+
0.10.0's index already states: an approver who could supply the subject level could lower it. A destructive
|
|
84
|
+
request is marked `auto_settleable: false` explicitly, rather than leaving the queue to infer it from a
|
|
85
|
+
capability name.
|
|
86
|
+
|
|
87
|
+
### A `write` with no scope is graded as the dearer capability
|
|
88
|
+
|
|
89
|
+
The dispatch path refuses that combination separately. Grading it cheap here would mean that if that check
|
|
90
|
+
ever moved, a request intending to write would be handed the read-only permission.
|
|
91
|
+
|
|
92
|
+
### A test of mine was wrong, again in the same direction
|
|
93
|
+
|
|
94
|
+
`gateDispatch({write: true}, 0)` was asserted to be `deny`; it is `needs_approval`, **because an ordinary
|
|
95
|
+
actor is exactly one band below captain and that is the documented behaviour.** The implementation was right.
|
|
96
|
+
This is the fourth time in this project that a failing test was the test's fault — and it is the argument for
|
|
97
|
+
testing refusals rather than only the happy path, because a wrong refusal fails loudly enough to be told apart
|
|
98
|
+
from a wrong acceptance.
|
|
99
|
+
|
|
100
|
+
**11 new tests, 0 failures.**
|
|
101
|
+
|
|
7
102
|
## [0.21.0] — 2026-10-15
|
|
8
103
|
|
|
9
104
|
**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.23.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/authority.mjs
CHANGED
|
@@ -38,9 +38,69 @@ export const AUTHORITY_BANDS = Object.freeze([
|
|
|
38
38
|
{ name: 'system_admin', min: 90, max: null, may: 'create global goals, configure across organisations, override any lower constraint' },
|
|
39
39
|
]);
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* The band floors, as named constants, for callers that want a level rather than a range.
|
|
43
|
+
*
|
|
44
|
+
* `AUTHORITY_BANDS` above is the model — ranges with prose about what each band may do. This is the lookup a
|
|
45
|
+
* caller reaches for when it wants "the captain floor" as a number, and it exists here rather than in
|
|
46
|
+
* `scripts/layer-model.mjs` because it is a view of the same table and the two must not be able to drift.
|
|
47
|
+
*
|
|
48
|
+
* Derived from `AUTHORITY_BANDS` rather than written out, so adding a band cannot leave this behind.
|
|
49
|
+
*/
|
|
50
|
+
export const AUTHORITY_BAND_FLOORS = Object.freeze(
|
|
51
|
+
Object.fromEntries(AUTHORITY_BANDS.map((b) => [b.name.toUpperCase(), b.min])),
|
|
52
|
+
);
|
|
53
|
+
|
|
41
54
|
/** The highest level this model will accept. Above it, a level is a mistake rather than a promotion. */
|
|
42
55
|
export const AUTHORITY_MAX = 100;
|
|
43
56
|
|
|
57
|
+
/**
|
|
58
|
+
* What each operational capability costs in authority.
|
|
59
|
+
*
|
|
60
|
+
* ## Why this lives here and not in `scripts/`
|
|
61
|
+
*
|
|
62
|
+
* It was declared in `scripts/layer-model.mjs` from v0.12.0, and `src/agent/permission.mjs` imported it from
|
|
63
|
+
* there — which read cleanly and **broke the published package**: `scripts/` is not in `package.json#files`,
|
|
64
|
+
* so an installed copy could not resolve the import at all. The packed-tarball check caught it, and the check
|
|
65
|
+
* was right.
|
|
66
|
+
*
|
|
67
|
+
* The layering says the same thing: `scripts/` sits *above* `src/`, so a `src/` module reaching up into it
|
|
68
|
+
* inverts the dependency. The authority model already owns the bands, so the capability table belongs beside
|
|
69
|
+
* them; `scripts/layer-model.mjs` now imports and re-exports it, keeping that module's public surface intact
|
|
70
|
+
* for the suites that assert on it.
|
|
71
|
+
*
|
|
72
|
+
* ## Why a table and not a set of branches
|
|
73
|
+
*
|
|
74
|
+
* `AUTHORITY_BANDS` above says what a band *may* do in prose. This says what each concrete operation *costs*,
|
|
75
|
+
* as data, so a reader can enumerate it and a test can audit it. The two must not drift, which is why they sit
|
|
76
|
+
* in one file.
|
|
77
|
+
*/
|
|
78
|
+
export const CAPABILITIES = Object.freeze([
|
|
79
|
+
{ name: 'read_fleet_state', min_authority: 0, destructive: false, note: 'listing machines and tasks changes nothing' },
|
|
80
|
+
{ name: 'dispatch_readonly', min_authority: 0, destructive: false, note: 'a task that may not write' },
|
|
81
|
+
{ name: 'dispatch_writing', min_authority: 50, destructive: false, note: 'a task that may write inside a declared scope' },
|
|
82
|
+
{ name: 'publish_release', min_authority: 70, destructive: true, note: 'a published version cannot be unpublished from here' },
|
|
83
|
+
{ name: 'delete_permanent', min_authority: 90, destructive: true, note: 'overrides the storage tier refusal; forced removal only' },
|
|
84
|
+
{ name: 'amend_law', min_authority: 70, destructive: false, note: 'amendment keeps the chain, so it is not a destruction' },
|
|
85
|
+
{ name: 'write_config', min_authority: 90, destructive: true, note: 'allowedCommands and the relay URL live here' },
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The minimum authority for a capability, or `null` when it is not declared.
|
|
90
|
+
*
|
|
91
|
+
* `null` rather than a permissive default: a capability the table has not heard of must not be granted, and
|
|
92
|
+
* the gate in `permission.mjs` refuses on `null` rather than treating it as free.
|
|
93
|
+
*/
|
|
94
|
+
export function permissionFor(name) {
|
|
95
|
+
return CAPABILITIES.find((c) => c.name === name)?.min_authority ?? null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Whether a capability is declared destructive. An unknown capability is not assumed safe. */
|
|
99
|
+
export function isDestructive(name) {
|
|
100
|
+
const found = CAPABILITIES.find((c) => c.name === name);
|
|
101
|
+
return found === undefined ? null : found.destructive;
|
|
102
|
+
}
|
|
103
|
+
|
|
44
104
|
/** Verdicts an authority check can reach. */
|
|
45
105
|
export const AUTHORITY_VERDICT = Object.freeze({
|
|
46
106
|
ALLOW: 'allow',
|
package/src/agent/permission.mjs
CHANGED
|
@@ -30,8 +30,19 @@
|
|
|
30
30
|
* table grant whatever is added next, and answering `NEEDS_APPROVAL` makes it requestable. Both are worse than
|
|
31
31
|
* a refusal, because both hide the omission.
|
|
32
32
|
*/
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
// v0.23.0: imported from `authority.mjs`, which owns the bands and therefore the costs. It used to come
|
|
34
|
+
// from `scripts/layer-model.mjs`, and that broke the published package -- `scripts/` is not in the `files`
|
|
35
|
+
// list, so an installed copy could not resolve it. A `src/` module must not reach above itself either.
|
|
36
|
+
import {
|
|
37
|
+
AUTHORITY_BANDS,
|
|
38
|
+
AUTHORITY_MAX,
|
|
39
|
+
AUTHORITY_VERDICT,
|
|
40
|
+
bandOf,
|
|
41
|
+
isAuthorityLevel,
|
|
42
|
+
CAPABILITIES,
|
|
43
|
+
permissionFor,
|
|
44
|
+
isDestructive,
|
|
45
|
+
} from './authority.mjs';
|
|
35
46
|
|
|
36
47
|
export { AUTHORITY_VERDICT };
|
|
37
48
|
|
|
@@ -146,6 +157,104 @@ export function decide(capability, level) {
|
|
|
146
157
|
};
|
|
147
158
|
}
|
|
148
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Which capability a dispatch costs, decided from the dispatch's own facts.
|
|
162
|
+
*
|
|
163
|
+
* A dispatch that may write costs more than one that may not, and the fact is already in the payload
|
|
164
|
+
* (`write` plus a non-empty `write_scope`), so this reads it rather than asking the caller to declare a
|
|
165
|
+
* capability. That matters because a caller who must name their own capability is a caller who can name the
|
|
166
|
+
* cheap one.
|
|
167
|
+
*
|
|
168
|
+
* @param {{write?: boolean, write_scope?: string[]}} args
|
|
169
|
+
* @returns {{capability: string, detail: string}}
|
|
170
|
+
*/
|
|
171
|
+
export function capabilityForDispatch(args) {
|
|
172
|
+
const writes = args?.write === true;
|
|
173
|
+
const scope = Array.isArray(args?.write_scope) ? args.write_scope.filter((s) => typeof s === 'string' && s.trim() !== '') : [];
|
|
174
|
+
if (!writes) return { capability: 'dispatch_readonly', detail: 'write is not true, so the machines cannot write' };
|
|
175
|
+
if (scope.length === 0) {
|
|
176
|
+
// A write with no scope is refused upstream by the dispatch path itself. Reporting it as the writing
|
|
177
|
+
// capability rather than the read-only one is the conservative direction: if that upstream check ever
|
|
178
|
+
// moved, this would not silently hand out the cheaper permission.
|
|
179
|
+
return { capability: 'dispatch_writing', detail: 'write is true with an empty scope, which the dispatch path refuses separately' };
|
|
180
|
+
}
|
|
181
|
+
return { capability: 'dispatch_writing', detail: `write is true inside ${scope.length} declared scope prefix(es)` };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The gate a dispatch passes through.
|
|
186
|
+
*
|
|
187
|
+
* ## Why an unset level is not the same as a low one
|
|
188
|
+
*
|
|
189
|
+
* `tools.mjs` states plainly that **no parameter of any tool can widen what a machine will run**, and that
|
|
190
|
+
* there is no way for a caller to set anything authority-shaped. So a plugin that has not been configured
|
|
191
|
+
* with an actor level has no actor to judge. Two answers were available and only one is honest:
|
|
192
|
+
*
|
|
193
|
+
* * treat an unset level as `0` — which would refuse every writing dispatch on every machine that has not
|
|
194
|
+
* opted in, breaking a working fleet to enforce a policy nobody asked for;
|
|
195
|
+
* * treat it as **unenforced**, and say so in the verdict, so the caller can tell "allowed" from "not
|
|
196
|
+
* checked".
|
|
197
|
+
*
|
|
198
|
+
* The second is what this does, and the `enforced` flag is the whole point: a gate that reports `ALLOW` and a
|
|
199
|
+
* gate that reports `not checked` must never look alike, because the first is a decision and the second is an
|
|
200
|
+
* absence of one.
|
|
201
|
+
*
|
|
202
|
+
* @param {{write?: boolean, write_scope?: string[]}} args
|
|
203
|
+
* @param {number|null|undefined} actorLevel
|
|
204
|
+
* @returns {{decision: object, enforced: boolean, capability: string, detail: string}}
|
|
205
|
+
*/
|
|
206
|
+
export function gateDispatch(args, actorLevel) {
|
|
207
|
+
const { capability, detail } = capabilityForDispatch(args);
|
|
208
|
+
if (actorLevel === null || actorLevel === undefined) {
|
|
209
|
+
return {
|
|
210
|
+
decision: {
|
|
211
|
+
verdict: AUTHORITY_VERDICT.ALLOW,
|
|
212
|
+
capability,
|
|
213
|
+
level: null,
|
|
214
|
+
required: permissionFor(capability),
|
|
215
|
+
shortfall: null,
|
|
216
|
+
destructive: isDestructive(capability),
|
|
217
|
+
reason_code: 'GATE_NOT_ENFORCED',
|
|
218
|
+
detail: 'no authority level is configured, so this dispatch was not checked',
|
|
219
|
+
},
|
|
220
|
+
enforced: false,
|
|
221
|
+
capability,
|
|
222
|
+
detail: `not enforced: ${detail}; no actor level is configured`,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
return { decision: decide(capability, actorLevel), enforced: true, capability, detail };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Build the approval request for a dispatch that needs one, or `null` when it does not.
|
|
230
|
+
*
|
|
231
|
+
* Shaped to hand straight to the cross-session approval index, which is the point: a permission gate and an
|
|
232
|
+
* approval queue that do not meet are two features that each look finished and jointly do nothing.
|
|
233
|
+
*
|
|
234
|
+
* @param {object} decision from {@link decide}
|
|
235
|
+
* @param {{taskId?: string, machineId?: string, requestedBy?: string}} context
|
|
236
|
+
*/
|
|
237
|
+
export function approvalRequestFor(decision, context = {}) {
|
|
238
|
+
if (decision === null || typeof decision !== 'object' || decision.verdict !== AUTHORITY_VERDICT.NEEDS_APPROVAL) {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
// The subject level travels WITH the request, recorded now, because it is a fact about who asked and must
|
|
243
|
+
// not be re-derived later from a caller who might be the approver.
|
|
244
|
+
subject_level: decision.level,
|
|
245
|
+
required_level: decision.required,
|
|
246
|
+
capability: decision.capability,
|
|
247
|
+
task_id: context.taskId ?? null,
|
|
248
|
+
machine_id: context.machineId ?? null,
|
|
249
|
+
requested_by: context.requestedBy ?? null,
|
|
250
|
+
destructive: decision.destructive === true,
|
|
251
|
+
// A destructive request is never auto-settleable, so it is marked here rather than left for the queue to
|
|
252
|
+
// infer from the capability name.
|
|
253
|
+
auto_settleable: mayAutoSettle(decision).auto,
|
|
254
|
+
detail: decision.detail,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
149
258
|
/**
|
|
150
259
|
* Whether a decision may be settled automatically.
|
|
151
260
|
*
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -122,6 +122,11 @@ import {
|
|
|
122
122
|
// another's, so a second implementation of the formula that drifted by one input would make every
|
|
123
123
|
// reverse dial time out while both ends insisted they were sending.
|
|
124
124
|
import { mintPunchId, punchSession } from '../agent/p2p.mjs';
|
|
125
|
+
// v0.22.0: the permission gate. Imported rather than reimplemented for the same reason as everything else
|
|
126
|
+
// here -- a second copy of the escalation rule is a second chance to disagree with the first about how far
|
|
127
|
+
// below a requirement an actor may still ask.
|
|
128
|
+
import { gateDispatch, approvalRequestFor } from '../agent/permission.mjs';
|
|
129
|
+
import { isAuthorityLevel } from '../agent/authority.mjs';
|
|
125
130
|
// v0.6.0: the device keypair and the offer's key distribution (`docs/P2P-CRYPTO.md` §4). Imported
|
|
126
131
|
// rather than reimplemented: the dispatcher and the executor have to derive the *same* keys, and two
|
|
127
132
|
// implementations of a handshake are two chances to disagree about it.
|
|
@@ -185,7 +190,7 @@ const MAX_STAGES = 16;
|
|
|
185
190
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
186
191
|
* overrides it at runtime for tests and packagers.
|
|
187
192
|
*/
|
|
188
|
-
const PLUGIN_VERSION = '0.
|
|
193
|
+
const PLUGIN_VERSION = '0.23.0';
|
|
189
194
|
|
|
190
195
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
191
196
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
@@ -680,6 +685,21 @@ function readConfig(config = {}) {
|
|
|
680
685
|
.filter((entry) => typeof entry === 'string')
|
|
681
686
|
.map((entry) => entry.trim())
|
|
682
687
|
.filter(Boolean),
|
|
688
|
+
/**
|
|
689
|
+
* v0.22.0: the authority level this machine dispatches AS, or null when nobody configured one.
|
|
690
|
+
*
|
|
691
|
+
* Not part of the shared config, deliberately: this is a local statement about who is typing, and a
|
|
692
|
+
* fleet-wide value would let one machine's level speak for another's operator. `W2M_ACTOR_AUTHORITY`
|
|
693
|
+
* is accepted so a machine can be pointed at a level without editing a profile.
|
|
694
|
+
*
|
|
695
|
+
* null is not 0. An unset level means there is no actor to judge, and the gate reports that as
|
|
696
|
+
* unenforced rather than refusing every writing dispatch on a machine that never opted in.
|
|
697
|
+
*/
|
|
698
|
+
actorAuthorityLevel: isAuthorityLevel(config.actorAuthorityLevel)
|
|
699
|
+
? config.actorAuthorityLevel
|
|
700
|
+
: (Number.isInteger(Number(process.env.W2M_ACTOR_AUTHORITY)) && isAuthorityLevel(Number(process.env.W2M_ACTOR_AUTHORITY))
|
|
701
|
+
? Number(process.env.W2M_ACTOR_AUTHORITY)
|
|
702
|
+
: null),
|
|
683
703
|
defaultTimeoutMs: shared.values.defaultTimeoutMs,
|
|
684
704
|
sharedConfig: shared,
|
|
685
705
|
pairingCode: typeof config.pairingCode === 'string' && config.pairingCode.trim() !== ''
|
|
@@ -3687,6 +3707,40 @@ export async function apply(ctx, config = {}) {
|
|
|
3687
3707
|
);
|
|
3688
3708
|
}
|
|
3689
3709
|
|
|
3710
|
+
/**
|
|
3711
|
+
* v0.22.0: the permission gate, placed AFTER the write-scope check so the thing being graded is
|
|
3712
|
+
* already known to be well-formed.
|
|
3713
|
+
*
|
|
3714
|
+
* It grades the dispatch from its own facts rather than from a capability the caller names, and with
|
|
3715
|
+
* no configured level it reports `enforced: false` and changes nothing -- which is why adding it
|
|
3716
|
+
* cannot alter the behaviour of any machine that has not opted in.
|
|
3717
|
+
*/
|
|
3718
|
+
const gate = gateDispatch({ write, write_scope: writeScope }, cfg.actorAuthorityLevel);
|
|
3719
|
+
if (gate.enforced) {
|
|
3720
|
+
if (gate.decision.verdict === 'deny') {
|
|
3721
|
+
throw new W2MError(
|
|
3722
|
+
'W2M_PERMISSION_DENIED',
|
|
3723
|
+
'W2M_PERMISSION_DENIED: ' + gate.decision.detail,
|
|
3724
|
+
{ hint: "raise this machine's actorAuthorityLevel, or use a capability its level reaches" },
|
|
3725
|
+
);
|
|
3726
|
+
}
|
|
3727
|
+
if (gate.decision.verdict === 'needs_approval') {
|
|
3728
|
+
const request = approvalRequestFor(gate.decision, {
|
|
3729
|
+
taskId: null,
|
|
3730
|
+
machineId: null,
|
|
3731
|
+
requestedBy: cfg.machineName,
|
|
3732
|
+
});
|
|
3733
|
+
throw new W2MError(
|
|
3734
|
+
'W2M_PERMISSION_NEEDS_APPROVAL',
|
|
3735
|
+
'W2M_PERMISSION_NEEDS_APPROVAL: ' + gate.decision.detail,
|
|
3736
|
+
{
|
|
3737
|
+
hint: 'this dispatch needs an approver; it is refused rather than queued because no approval queue is wired to this call yet',
|
|
3738
|
+
request,
|
|
3739
|
+
},
|
|
3740
|
+
);
|
|
3741
|
+
}
|
|
3742
|
+
}
|
|
3743
|
+
|
|
3690
3744
|
// §5: dispatching a task is the operator's privilege, not the device's. The operator token is
|
|
3691
3745
|
// required and there is no device-token fallbac - see requireOperatorToken.
|
|
3692
3746
|
const token = requireOperatorToken(cfg);
|