@twinsearth/w2m-dsh-plugin 0.20.0 → 0.21.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 +51 -0
- package/package.json +5 -4
- package/src/agent/permission.mjs +198 -0
- package/src/plugin/tools.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,57 @@ 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.21.0] — 2026-10-15
|
|
8
|
+
|
|
9
|
+
**The capability table has a gate now, so it is a policy rather than a description of one.**
|
|
10
|
+
|
|
11
|
+
`scripts/layer-model.mjs` has declared since 0.12.0 what each operational capability costs in authority —
|
|
12
|
+
`read_fleet_state` at ordinary, `dispatch_writing` at captain, `publish_release` at org admin,
|
|
13
|
+
`write_config` at system admin — and **nothing consulted it**. A table nothing reads is true on the day it is
|
|
14
|
+
written and unenforced afterwards; that is the same failure this project already paid for with the layering,
|
|
15
|
+
where a prose description was quietly violated while nothing failed.
|
|
16
|
+
|
|
17
|
+
`src/agent/permission.mjs` compares the table against `authority.mjs`'s bands and returns one of three
|
|
18
|
+
verdicts. The interesting part is not `ALLOW`:
|
|
19
|
+
|
|
20
|
+
### The escalation window is exactly one band
|
|
21
|
+
|
|
22
|
+
`NEEDS_APPROVAL` is granted to an actor **one band below** the requirement and to nobody further down.
|
|
23
|
+
`DENY` covers everything else.
|
|
24
|
+
|
|
25
|
+
**Two bands below is refused rather than escalated**, and that is a security decision rather than a
|
|
26
|
+
convenience one: if any shortfall could be forwarded, an ordinary actor could request a system-admin action
|
|
27
|
+
and **the request itself would be the attack** — one inattentive approver away from a write to
|
|
28
|
+
`allowedCommands`. One band means an actor can ask for the next thing up and no more, which is what
|
|
29
|
+
"escalate to your supervisor" actually means.
|
|
30
|
+
|
|
31
|
+
### An undeclared capability is DENIED
|
|
32
|
+
|
|
33
|
+
Not `ALLOW`, which would make the table grant whatever is added next; not `NEEDS_APPROVAL`, which would make
|
|
34
|
+
it requestable. Either would turn an **omission** into a permission. `decide('teleport', 100)` refuses, because
|
|
35
|
+
authority answers *how much may this actor do*, not *is this a thing that exists*.
|
|
36
|
+
|
|
37
|
+
### A level nobody defined is refused rather than rounded
|
|
38
|
+
|
|
39
|
+
`50.5`, `-1`, `101`, `NaN` and `'50'` are all denied with the requirement still reported. Treating them as `0`
|
|
40
|
+
would silently downgrade an actor; treating them as high would silently promote one. Neither is a decision
|
|
41
|
+
this module is entitled to make.
|
|
42
|
+
|
|
43
|
+
### Nothing destructive settles itself
|
|
44
|
+
|
|
45
|
+
`mayAutoSettle()` returns false for any destructive capability **even on an `ALLOW` at maximum authority** —
|
|
46
|
+
the level answers "may this actor", not "should this happen now". A release published on a machine's own
|
|
47
|
+
judgement is a release nobody reviewed.
|
|
48
|
+
|
|
49
|
+
### The table is audited, not trusted
|
|
50
|
+
|
|
51
|
+
`auditCapabilities()` reports any entry **no band can reach**, because an unreachable capability reads as
|
|
52
|
+
policy while doing nothing — worse than an absent one, since it looks like a control.
|
|
53
|
+
|
|
54
|
+
**18 tests, 0 failures.** One property is asserted across the whole table rather than by example: for every
|
|
55
|
+
declared capability and every level from 0 to 100, a `NEEDS_APPROVAL` verdict implies the actor is exactly one
|
|
56
|
+
band below.
|
|
57
|
+
|
|
7
58
|
## [0.20.0] — 2026-10-15
|
|
8
59
|
|
|
9
60
|
**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.
|
|
3
|
+
"version": "0.21.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,198 @@
|
|
|
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
|
+
* Whether a decision may be settled automatically.
|
|
151
|
+
*
|
|
152
|
+
* Only `ALLOW` and only for a non-destructive capability. A destructive action that reaches an approver's
|
|
153
|
+
* queue is a decision for a person even when the approver's level is far above the requirement — the level
|
|
154
|
+
* answers "may this actor", not "should this happen now".
|
|
155
|
+
*
|
|
156
|
+
* @returns {{auto: boolean, detail: string}}
|
|
157
|
+
*/
|
|
158
|
+
export function mayAutoSettle(decision) {
|
|
159
|
+
if (decision === null || typeof decision !== 'object') return { auto: false, detail: 'no decision supplied' };
|
|
160
|
+
if (decision.verdict !== AUTHORITY_VERDICT.ALLOW) {
|
|
161
|
+
return { auto: false, detail: `verdict is ${decision.verdict}, which is not an allow` };
|
|
162
|
+
}
|
|
163
|
+
if (decision.destructive === true) {
|
|
164
|
+
return { auto: false, detail: `${decision.capability} is destructive, so a person settles it however high the level` };
|
|
165
|
+
}
|
|
166
|
+
return { auto: true, detail: `${decision.capability} is allowed and not destructive` };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Check that every declared capability is reachable by somebody.
|
|
171
|
+
*
|
|
172
|
+
* **An unreachable capability is a defect in the table, not a safety feature.** If its minimum authority
|
|
173
|
+
* exceeds `AUTHORITY_MAX`, no actor can ever exercise it and the entry is dead — which reads as policy while
|
|
174
|
+
* doing nothing. Called at test time rather than at load time, because a table with a mistake in it should
|
|
175
|
+
* fail its own tests rather than make a machine refuse to start.
|
|
176
|
+
*
|
|
177
|
+
* @returns {{ok: boolean, unreachable: string[], detail: string}}
|
|
178
|
+
*/
|
|
179
|
+
export function auditCapabilities() {
|
|
180
|
+
const unreachable = [];
|
|
181
|
+
for (const c of CAPABILITIES) {
|
|
182
|
+
if (!Number.isInteger(c.min_authority) || c.min_authority < 0 || c.min_authority > AUTHORITY_MAX) {
|
|
183
|
+
unreachable.push(`${c.name} (min_authority ${c.min_authority} is outside 0..${AUTHORITY_MAX})`);
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
// Reachability by a real actor, not merely by the number: a level in a declared band must exist at or
|
|
187
|
+
// above the requirement.
|
|
188
|
+
const reachable = AUTHORITY_BANDS.some((b) => b.min >= c.min_authority || (b.max ?? AUTHORITY_MAX) >= c.min_authority);
|
|
189
|
+
if (!reachable) unreachable.push(`${c.name} (no band reaches ${c.min_authority})`);
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
ok: unreachable.length === 0,
|
|
193
|
+
unreachable,
|
|
194
|
+
detail: unreachable.length === 0
|
|
195
|
+
? `all ${CAPABILITIES.length} declared capability(ies) are reachable by some band`
|
|
196
|
+
: `${unreachable.length} declared capability(ies) no actor can exercise: ${unreachable.join('; ')}`,
|
|
197
|
+
};
|
|
198
|
+
}
|
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.21.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;
|