@twinsearth/w2m-dsh-plugin 0.20.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 CHANGED
@@ -4,6 +4,107 @@ 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
+
57
+ ## [0.21.0] — 2026-10-15
58
+
59
+ **The capability table has a gate now, so it is a policy rather than a description of one.**
60
+
61
+ `scripts/layer-model.mjs` has declared since 0.12.0 what each operational capability costs in authority —
62
+ `read_fleet_state` at ordinary, `dispatch_writing` at captain, `publish_release` at org admin,
63
+ `write_config` at system admin — and **nothing consulted it**. A table nothing reads is true on the day it is
64
+ written and unenforced afterwards; that is the same failure this project already paid for with the layering,
65
+ where a prose description was quietly violated while nothing failed.
66
+
67
+ `src/agent/permission.mjs` compares the table against `authority.mjs`'s bands and returns one of three
68
+ verdicts. The interesting part is not `ALLOW`:
69
+
70
+ ### The escalation window is exactly one band
71
+
72
+ `NEEDS_APPROVAL` is granted to an actor **one band below** the requirement and to nobody further down.
73
+ `DENY` covers everything else.
74
+
75
+ **Two bands below is refused rather than escalated**, and that is a security decision rather than a
76
+ convenience one: if any shortfall could be forwarded, an ordinary actor could request a system-admin action
77
+ and **the request itself would be the attack** — one inattentive approver away from a write to
78
+ `allowedCommands`. One band means an actor can ask for the next thing up and no more, which is what
79
+ "escalate to your supervisor" actually means.
80
+
81
+ ### An undeclared capability is DENIED
82
+
83
+ Not `ALLOW`, which would make the table grant whatever is added next; not `NEEDS_APPROVAL`, which would make
84
+ it requestable. Either would turn an **omission** into a permission. `decide('teleport', 100)` refuses, because
85
+ authority answers *how much may this actor do*, not *is this a thing that exists*.
86
+
87
+ ### A level nobody defined is refused rather than rounded
88
+
89
+ `50.5`, `-1`, `101`, `NaN` and `'50'` are all denied with the requirement still reported. Treating them as `0`
90
+ would silently downgrade an actor; treating them as high would silently promote one. Neither is a decision
91
+ this module is entitled to make.
92
+
93
+ ### Nothing destructive settles itself
94
+
95
+ `mayAutoSettle()` returns false for any destructive capability **even on an `ALLOW` at maximum authority** —
96
+ the level answers "may this actor", not "should this happen now". A release published on a machine's own
97
+ judgement is a release nobody reviewed.
98
+
99
+ ### The table is audited, not trusted
100
+
101
+ `auditCapabilities()` reports any entry **no band can reach**, because an unreachable capability reads as
102
+ policy while doing nothing — worse than an absent one, since it looks like a control.
103
+
104
+ **18 tests, 0 failures.** One property is asserted across the whole table rather than by example: for every
105
+ declared capability and every level from 0 to 100, a `NEEDS_APPROVAL` verdict implies the actor is exactly one
106
+ band below.
107
+
7
108
  ## [0.20.0] — 2026-10-15
8
109
 
9
110
  **Version skew is refuted too, and the direct-path failure is now counted instead of read one at a time.**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "0.20.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",
@@ -113,13 +113,13 @@
113
113
  }
114
114
  },
115
115
  "scripts": {
116
- "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/layer-model.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/chatops-fleet-join.test.mjs",
116
+ "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/layer-model.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/chatops-fleet-join.test.mjs",
117
117
  "test:e2e": "node --test --test-force-exit test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/p2p-agent.test.mjs",
118
118
  "test:relay": "node --test --test-force-exit test/relay.test.mjs",
119
119
  "test:agent": "node --test --test-force-exit test/agent.test.mjs test/memory.test.mjs",
120
120
  "test:tools": "node --test --test-force-exit test/tools.test.mjs",
121
121
  "test:crossnetwork": "node --test --test-force-exit test/crossnetwork.test.mjs",
122
- "test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/layer-model.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-agent.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/p2p-crypto.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/chatops-fleet-join.test.mjs",
122
+ "test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/layer-model.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-agent.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/p2p-crypto.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/chatops-fleet-join.test.mjs",
123
123
  "pack": "node scripts/pack.mjs",
124
124
  "verify:join": "node scripts/verify-join.mjs",
125
125
  "lint": "eslint .",
@@ -144,7 +144,8 @@
144
144
  "test:fleet-scale": "node --test --test-force-exit test/fleet-scale.test.mjs",
145
145
  "test:fleet-state": "node --test --test-force-exit test/fleet-state.test.mjs",
146
146
  "test:crypto-blake2s": "node --test --test-force-exit test/crypto-blake2s.test.mjs",
147
- "test:installed-version": "node --test --test-force-exit test/installed-version.test.mjs"
147
+ "test:installed-version": "node --test --test-force-exit test/installed-version.test.mjs",
148
+ "test:permission": "node --test --test-force-exit test/permission.test.mjs"
148
149
  },
149
150
  "keywords": [
150
151
  "dsh-bundle",
@@ -0,0 +1,296 @@
1
+ /**
2
+ * The permission gate: turning a declared capability table into a decision.
3
+ *
4
+ * ## Why this exists, and what it is not
5
+ *
6
+ * `scripts/layer-model.mjs` declares what each operational capability costs in authority, and **nothing used
7
+ * it**. A table nothing consults is not a policy; it is a description of a policy that happens to be true on
8
+ * the day it is written. This module is the thing that consults it.
9
+ *
10
+ * What it is **not**: it does not know what a capability *means*. Classification — is this a write, a scope
11
+ * change, a registration — belongs to whatever knows the action, which is `authority.mjs`'s own stated rule
12
+ * and the reason that file declines to enumerate actions. So the two files are split by that line:
13
+ * `layer-model.mjs` says what each capability costs, `authority.mjs` owns the bands, and this module compares
14
+ * the two and returns a verdict.
15
+ *
16
+ * ## The three answers, and why there are exactly three
17
+ *
18
+ * `ALLOW` — the actor's level meets the capability's cost.
19
+ * `NEEDS_APPROVAL` — the actor is **one band below**, so somebody above them may authorise it.
20
+ * `DENY` — anything else, including a capability nobody declared.
21
+ *
22
+ * The `NEEDS_APPROVAL` window is deliberately **one band**, not "anything below". If any shortfall could be
23
+ * escalated, an ordinary actor could request a system-admin action and a single inattentive approver would
24
+ * grant it; the request itself would be the attack. One band means an actor can ask for the *next* thing up
25
+ * and no more, which is what "escalate to your supervisor" actually means.
26
+ *
27
+ * ## The refusal that matters most
28
+ *
29
+ * **An undeclared capability is DENIED.** Answering `ALLOW` for something the table has not heard of makes the
30
+ * table grant whatever is added next, and answering `NEEDS_APPROVAL` makes it requestable. Both are worse than
31
+ * a refusal, because both hide the omission.
32
+ */
33
+ import { CAPABILITIES, permissionFor, isDestructive } from '../../scripts/layer-model.mjs';
34
+ import { AUTHORITY_BANDS, AUTHORITY_MAX, AUTHORITY_VERDICT, bandOf, isAuthorityLevel } from './authority.mjs';
35
+
36
+ export { AUTHORITY_VERDICT };
37
+
38
+ /** Why a decision came out the way it did. Codes, so a caller branches on a value rather than on prose. */
39
+ export const GATE_REFUSAL = Object.freeze({
40
+ UNKNOWN_CAPABILITY: 'GATE_UNKNOWN_CAPABILITY',
41
+ NOT_A_LEVEL: 'GATE_NOT_A_LEVEL',
42
+ BELOW_BAND: 'GATE_BELOW_BAND',
43
+ SUFFICIENT: 'GATE_SUFFICIENT',
44
+ ONE_BAND_BELOW: 'GATE_ONE_BAND_BELOW',
45
+ });
46
+
47
+ /**
48
+ * The band index a level falls in, or `-1` when the level is unusable.
49
+ *
50
+ * Uses `bandOf` from `authority.mjs` rather than re-deriving the bands, so a change to the band table cannot
51
+ * make this module disagree with the one that owns it.
52
+ */
53
+ function bandIndex(level) {
54
+ if (!isAuthorityLevel(level)) return -1;
55
+ const band = bandOf(level);
56
+ if (band === null || band === undefined) return -1;
57
+ const index = AUTHORITY_BANDS.findIndex((b) => b.name === (band.name ?? band));
58
+ return index;
59
+ }
60
+
61
+ /**
62
+ * Decide whether an actor may exercise a capability.
63
+ *
64
+ * @param {string} capability as named in the capability table
65
+ * @param {number} level the actor's authority level
66
+ * @returns {{
67
+ * verdict: string, capability: string|null, level: number|null,
68
+ * required: number|null, shortfall: number|null, destructive: boolean|null,
69
+ * reason_code: string, detail: string
70
+ * }}
71
+ */
72
+ export function decide(capability, level) {
73
+ const required = permissionFor(capability);
74
+
75
+ if (required === null) {
76
+ // The most important branch. A capability the table has never heard of is refused, not permitted and not
77
+ // escalated: either answer would turn an omission into a grant.
78
+ return {
79
+ verdict: AUTHORITY_VERDICT.DENY,
80
+ capability: typeof capability === 'string' ? capability : null,
81
+ level: isAuthorityLevel(level) ? level : null,
82
+ required: null,
83
+ shortfall: null,
84
+ destructive: null,
85
+ reason_code: GATE_REFUSAL.UNKNOWN_CAPABILITY,
86
+ detail: `no capability named ${JSON.stringify(capability)} is declared, so nothing may exercise it; an undeclared capability is refused rather than granted`,
87
+ };
88
+ }
89
+
90
+ if (!isAuthorityLevel(level)) {
91
+ // A level nobody defined cannot be compared, and treating it as 0 would silently downgrade an actor while
92
+ // treating it as high would silently promote one. Neither is a decision this module is entitled to make.
93
+ return {
94
+ verdict: AUTHORITY_VERDICT.DENY,
95
+ capability,
96
+ level: null,
97
+ required,
98
+ shortfall: null,
99
+ destructive: isDestructive(capability),
100
+ reason_code: GATE_REFUSAL.NOT_A_LEVEL,
101
+ detail: `${JSON.stringify(level)} is not an authority level (an integer in 0..${AUTHORITY_MAX}), so it cannot be compared against the required ${required}`,
102
+ };
103
+ }
104
+
105
+ const destructive = isDestructive(capability);
106
+
107
+ if (level >= required) {
108
+ return {
109
+ verdict: AUTHORITY_VERDICT.ALLOW,
110
+ capability,
111
+ level,
112
+ required,
113
+ shortfall: 0,
114
+ destructive,
115
+ reason_code: GATE_REFUSAL.SUFFICIENT,
116
+ detail: `level ${level} meets the ${required} required for ${capability}${destructive ? ' (destructive)' : ''}`,
117
+ };
118
+ }
119
+
120
+ const actorBand = bandIndex(level);
121
+ const requiredBand = bandIndex(required);
122
+ const oneBelow = actorBand >= 0 && requiredBand >= 0 && requiredBand - actorBand === 1;
123
+
124
+ if (oneBelow) {
125
+ return {
126
+ verdict: AUTHORITY_VERDICT.NEEDS_APPROVAL,
127
+ capability,
128
+ level,
129
+ required,
130
+ shortfall: required - level,
131
+ destructive,
132
+ reason_code: GATE_REFUSAL.ONE_BAND_BELOW,
133
+ detail: `level ${level} is one band below the ${required} required for ${capability}, so it may be escalated to an approver${destructive ? '; the action is destructive and must not be auto-approved' : ''}`,
134
+ };
135
+ }
136
+
137
+ return {
138
+ verdict: AUTHORITY_VERDICT.DENY,
139
+ capability,
140
+ level,
141
+ required,
142
+ shortfall: required - level,
143
+ destructive,
144
+ reason_code: GATE_REFUSAL.BELOW_BAND,
145
+ detail: `level ${level} is ${required - level} below the ${required} required for ${capability}, which is more than one band; the request itself is refused rather than escalated`,
146
+ };
147
+ }
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
+
247
+ /**
248
+ * Whether a decision may be settled automatically.
249
+ *
250
+ * Only `ALLOW` and only for a non-destructive capability. A destructive action that reaches an approver's
251
+ * queue is a decision for a person even when the approver's level is far above the requirement — the level
252
+ * answers "may this actor", not "should this happen now".
253
+ *
254
+ * @returns {{auto: boolean, detail: string}}
255
+ */
256
+ export function mayAutoSettle(decision) {
257
+ if (decision === null || typeof decision !== 'object') return { auto: false, detail: 'no decision supplied' };
258
+ if (decision.verdict !== AUTHORITY_VERDICT.ALLOW) {
259
+ return { auto: false, detail: `verdict is ${decision.verdict}, which is not an allow` };
260
+ }
261
+ if (decision.destructive === true) {
262
+ return { auto: false, detail: `${decision.capability} is destructive, so a person settles it however high the level` };
263
+ }
264
+ return { auto: true, detail: `${decision.capability} is allowed and not destructive` };
265
+ }
266
+
267
+ /**
268
+ * Check that every declared capability is reachable by somebody.
269
+ *
270
+ * **An unreachable capability is a defect in the table, not a safety feature.** If its minimum authority
271
+ * exceeds `AUTHORITY_MAX`, no actor can ever exercise it and the entry is dead — which reads as policy while
272
+ * doing nothing. Called at test time rather than at load time, because a table with a mistake in it should
273
+ * fail its own tests rather than make a machine refuse to start.
274
+ *
275
+ * @returns {{ok: boolean, unreachable: string[], detail: string}}
276
+ */
277
+ export function auditCapabilities() {
278
+ const unreachable = [];
279
+ for (const c of CAPABILITIES) {
280
+ if (!Number.isInteger(c.min_authority) || c.min_authority < 0 || c.min_authority > AUTHORITY_MAX) {
281
+ unreachable.push(`${c.name} (min_authority ${c.min_authority} is outside 0..${AUTHORITY_MAX})`);
282
+ continue;
283
+ }
284
+ // Reachability by a real actor, not merely by the number: a level in a declared band must exist at or
285
+ // above the requirement.
286
+ const reachable = AUTHORITY_BANDS.some((b) => b.min >= c.min_authority || (b.max ?? AUTHORITY_MAX) >= c.min_authority);
287
+ if (!reachable) unreachable.push(`${c.name} (no band reaches ${c.min_authority})`);
288
+ }
289
+ return {
290
+ ok: unreachable.length === 0,
291
+ unreachable,
292
+ detail: unreachable.length === 0
293
+ ? `all ${CAPABILITIES.length} declared capability(ies) are reachable by some band`
294
+ : `${unreachable.length} declared capability(ies) no actor can exercise: ${unreachable.join('; ')}`,
295
+ };
296
+ }
@@ -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.20.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;