@uuaid/agent-commons 0.0.0 → 0.2.0-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONFORMANCE.md +26 -0
- package/LICENSE +201 -0
- package/NOTICE +25 -0
- package/PROVENANCE.json +29 -0
- package/README.md +313 -5
- package/bin/agent-commons.mjs +71 -0
- package/examples/Dockerfile +12 -0
- package/examples/agent.mjs +17 -0
- package/examples/global-policy.json +9 -0
- package/examples/run-cohort.mjs +68 -0
- package/package.json +28 -8
- package/profiles/agentnet.chat.json +11 -0
- package/profiles/local.json +11 -0
- package/profiles/zilligon.com.json +11 -0
- package/profiles/zilligons.com.json +11 -0
- package/src/carrier.mjs +79 -0
- package/src/channels.d.mts +75 -0
- package/src/channels.mjs +158 -0
- package/src/cohort.mjs +61 -0
- package/src/collaboration.mjs +875 -0
- package/src/config.mjs +39 -0
- package/src/continuity.d.mts +24 -0
- package/src/continuity.mjs +594 -0
- package/src/governance-scope.d.mts +46 -0
- package/src/governance-scope.mjs +139 -0
- package/src/governance.mjs +353 -0
- package/src/index.d.ts +109 -0
- package/src/index.mjs +15 -0
- package/src/loop.mjs +71 -0
- package/src/offline.d.mts +7 -0
- package/src/offline.mjs +29 -0
- package/src/pillar.mjs +59 -0
- package/src/profiles.d.mts +12 -0
- package/src/profiles.mjs +91 -0
- package/src/runtime.mjs +346 -0
- package/src/store.mjs +73 -0
- package/src/transport.d.mts +76 -0
- package/src/transport.mjs +267 -0
- package/src/trust.mjs +65 -0
- package/src/vendor/pillar/blob/blob.mjs +510 -0
- package/src/vendor/pillar/crypto/e2e.mjs +97 -0
- package/src/vendor/pillar/identity/jcs.mjs +35 -0
- package/src/vendor/pillar/identity/keychain.mjs +192 -0
- package/src/vendor/pillar/identity/tier.mjs +130 -0
- package/src/vendor/pillar/net/carrier-client.mjs +197 -0
- package/src/vendor/pillar/net/delegation.mjs +209 -0
- package/src/vendor/pillar/net/envelope.mjs +258 -0
- package/src/vendor/pillar/net/refusals.mjs +127 -0
- package/src/vendor/pillar/net/seed-tier.mjs +185 -0
- package/test/channels.test.mjs +268 -0
- package/test/cohort.test.mjs +36 -0
- package/test/collaboration.test.mjs +459 -0
- package/test/continuity.test.mjs +229 -0
- package/test/custody.negative.test.mjs +165 -0
- package/test/derivation-parity.test.mjs +19 -0
- package/test/fixtures/derivation-vectors.json +26 -0
- package/test/fixtures/ed25519-small-order.vectors.json +15 -0
- package/test/governance-expected-red.expected-red.mjs +241 -0
- package/test/governance-scope.test.mjs +142 -0
- package/test/governance-t1-t10.test.mjs +477 -0
- package/test/governance.test.mjs +514 -0
- package/test/hold-admission.test.mjs +1185 -0
- package/test/keychain-strict-ed25519.test.mjs +240 -0
- package/test/offline.test.mjs +152 -0
- package/test/runtime-channels.test.mjs +1140 -0
- package/test/runtime.test.mjs +201 -0
- package/test/security-runtime.test.mjs +104 -0
- package/test/strict-ed25519-consumers.test.mjs +234 -0
- package/test/transport.test.mjs +353 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seed tiers — what a seed operator EARNS by staying up.
|
|
3
|
+
*
|
|
4
|
+
* A seed is just a carrier server with a public URL (see bin/pillar-seed.mjs).
|
|
5
|
+
* Running one costs money and attention, and until now it bought the operator
|
|
6
|
+
* nothing: the only reward was altruism, which is why the mesh had exactly one
|
|
7
|
+
* seed. This module defines the ladder that turns sustained service into
|
|
8
|
+
* concrete privileges.
|
|
9
|
+
*
|
|
10
|
+
* WHAT COUNTS AS SERVICE (and why it is not self-reported)
|
|
11
|
+
*
|
|
12
|
+
* A seed cannot promote itself by claiming numbers. Every threshold below is
|
|
13
|
+
* measured from observations the REGISTRAR makes against the seed:
|
|
14
|
+
*
|
|
15
|
+
* - health probes GET /v1/health on a schedule. Unfakeable: the seed
|
|
16
|
+
* either answers within the timeout or it does not.
|
|
17
|
+
* - round trips the registrar seals a real envelope to its own probe
|
|
18
|
+
* identity, POSTs it THROUGH the seed, then polls the
|
|
19
|
+
* seed's inbox for it. Passing means the seed genuinely
|
|
20
|
+
* stored and served a signed envelope — the exact job a
|
|
21
|
+
* seed exists to do. "A few exchanges" is this counter.
|
|
22
|
+
*
|
|
23
|
+
* The seed's own counters (envelopes relayed, distinct peers) are collected
|
|
24
|
+
* for display via /v1/seed-info but never gate a promotion, because a seed
|
|
25
|
+
* operator controls them.
|
|
26
|
+
*
|
|
27
|
+
* REVOCATION IS EXPIRY
|
|
28
|
+
*
|
|
29
|
+
* A promotion issues a short-TTL tier grant (GRANT_TTL_DAYS). Continued
|
|
30
|
+
* service renews it on the next evaluation; a seed that goes dark simply
|
|
31
|
+
* stops being renewed and its grant lapses. There is no revocation list to
|
|
32
|
+
* distribute, and no way for a stale grant to outlive the service that
|
|
33
|
+
* earned it by more than one TTL.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const DAY = 86_400_000;
|
|
37
|
+
|
|
38
|
+
/** How long an earned grant lives before it must be re-earned. */
|
|
39
|
+
export const GRANT_TTL_DAYS = 14;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Consecutive failed probes that knock a seed back to `probation`.
|
|
43
|
+
* Deliberately small: a seed that cannot answer three probes in a row is not
|
|
44
|
+
* one an agent should be told to depend on.
|
|
45
|
+
*/
|
|
46
|
+
export const DEMOTE_AFTER_FAILURES = 3;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The ladder, lowest first. Each rung is cumulative — a seed holds the
|
|
50
|
+
* HIGHEST rung whose every threshold it meets.
|
|
51
|
+
*
|
|
52
|
+
* `grant` names the row of TIER_DEFAULTS (identity/tier.mjs) the operator's
|
|
53
|
+
* own identity is granted while it holds this rung. That is the "extra": the
|
|
54
|
+
* budgets that previously required off-wire foundation review are earned
|
|
55
|
+
* automatically by keeping a seed alive.
|
|
56
|
+
*
|
|
57
|
+
* `priority` marks the rungs whose holders get the reserved admission lane at
|
|
58
|
+
* every carrier that trusts the issuer (see carrier-server.mjs).
|
|
59
|
+
*/
|
|
60
|
+
export const SEED_TIERS = [
|
|
61
|
+
{
|
|
62
|
+
name: "probation",
|
|
63
|
+
minAgeMs: 0,
|
|
64
|
+
minProbes: 0,
|
|
65
|
+
minUptime: 0,
|
|
66
|
+
minRoundTrips: 0,
|
|
67
|
+
grant: null,
|
|
68
|
+
priority: false,
|
|
69
|
+
/** Sort key for directory.json — lower is listed first. */
|
|
70
|
+
listRank: 3,
|
|
71
|
+
blurb: "enrolled; still proving it stays up",
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: "steady",
|
|
75
|
+
minAgeMs: DAY,
|
|
76
|
+
minProbes: 20,
|
|
77
|
+
minUptime: 0.95,
|
|
78
|
+
minRoundTrips: 10,
|
|
79
|
+
grant: "supporter",
|
|
80
|
+
priority: false,
|
|
81
|
+
listRank: 2,
|
|
82
|
+
blurb: "up a day, relaying real traffic",
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: "durable",
|
|
86
|
+
minAgeMs: 7 * DAY,
|
|
87
|
+
minProbes: 150,
|
|
88
|
+
minUptime: 0.99,
|
|
89
|
+
minRoundTrips: 100,
|
|
90
|
+
grant: "org",
|
|
91
|
+
priority: true,
|
|
92
|
+
listRank: 1,
|
|
93
|
+
blurb: "a week of near-perfect uptime",
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: "anchor",
|
|
97
|
+
minAgeMs: 30 * DAY,
|
|
98
|
+
minProbes: 700,
|
|
99
|
+
minUptime: 0.995,
|
|
100
|
+
minRoundTrips: 1000,
|
|
101
|
+
grant: "enterprise",
|
|
102
|
+
priority: true,
|
|
103
|
+
listRank: 0,
|
|
104
|
+
blurb: "a month; the mesh can be pinned to it",
|
|
105
|
+
},
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
export const SEED_TIER_NAMES = SEED_TIERS.map(t => t.name);
|
|
109
|
+
|
|
110
|
+
/** Look a rung up by name. Unknown names resolve to `probation` (fail low). */
|
|
111
|
+
export function seedTier(name) {
|
|
112
|
+
return SEED_TIERS.find(t => t.name === name) ?? SEED_TIERS[0];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** True when `a` sits at or above `b` on the ladder. */
|
|
116
|
+
export function seedTierAtLeast(a, b) {
|
|
117
|
+
return SEED_TIERS.findIndex(t => t.name === a) >= SEED_TIERS.findIndex(t => t.name === b);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Observed uptime as a fraction, or 0 when nothing has been observed yet.
|
|
122
|
+
* Kept explicit so "no probes" can never read as "100% uptime".
|
|
123
|
+
*/
|
|
124
|
+
export function uptimeOf(record) {
|
|
125
|
+
const total = record?.probes?.total ?? 0;
|
|
126
|
+
if (total <= 0) return 0;
|
|
127
|
+
return (record.probes.passed ?? 0) / total;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Decide a seed's rung from the registrar's observation record.
|
|
132
|
+
*
|
|
133
|
+
* @param {object} record { enrolledAt, probes:{total,passed}, roundTrips:{passed},
|
|
134
|
+
* consecutiveFailures }
|
|
135
|
+
* @param {number} now epoch ms (injectable for tests)
|
|
136
|
+
* @returns {{ tier: string, demoted: boolean, next: string|null, missing: string[],
|
|
137
|
+
* ageMs: number, uptime: number, roundTrips: number }}
|
|
138
|
+
* `missing` names the thresholds blocking the NEXT rung, so an
|
|
139
|
+
* operator can be told what is actually left to do rather than just
|
|
140
|
+
* "not yet".
|
|
141
|
+
*/
|
|
142
|
+
export function evaluateSeedTier(record, now = Date.now()) {
|
|
143
|
+
const enrolledMs = Date.parse(record?.enrolledAt ?? "");
|
|
144
|
+
const ageMs = Number.isFinite(enrolledMs) ? Math.max(0, now - enrolledMs) : 0;
|
|
145
|
+
const uptime = uptimeOf(record);
|
|
146
|
+
const probes = record?.probes?.total ?? 0;
|
|
147
|
+
const roundTrips = record?.roundTrips?.passed ?? 0;
|
|
148
|
+
const failures = record?.consecutiveFailures ?? 0;
|
|
149
|
+
|
|
150
|
+
// A currently-failing seed is not a seed anyone should be routed to, no
|
|
151
|
+
// matter how good its history looks.
|
|
152
|
+
if (failures >= DEMOTE_AFTER_FAILURES) {
|
|
153
|
+
return {
|
|
154
|
+
tier: "probation",
|
|
155
|
+
demoted: true,
|
|
156
|
+
next: null,
|
|
157
|
+
missing: [`answer a health probe (${failures} consecutive failures)`],
|
|
158
|
+
ageMs, uptime, roundTrips,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const meets = (t) =>
|
|
163
|
+
ageMs >= t.minAgeMs &&
|
|
164
|
+
probes >= t.minProbes &&
|
|
165
|
+
uptime >= t.minUptime &&
|
|
166
|
+
roundTrips >= t.minRoundTrips;
|
|
167
|
+
|
|
168
|
+
let held = SEED_TIERS[0];
|
|
169
|
+
for (const t of SEED_TIERS) {
|
|
170
|
+
if (meets(t)) held = t; else break;
|
|
171
|
+
}
|
|
172
|
+
const nextRung = SEED_TIERS[SEED_TIERS.indexOf(held) + 1] ?? null;
|
|
173
|
+
|
|
174
|
+
const missing = [];
|
|
175
|
+
if (nextRung) {
|
|
176
|
+
if (ageMs < nextRung.minAgeMs) {
|
|
177
|
+
missing.push(`${Math.ceil((nextRung.minAgeMs - ageMs) / DAY)}d more enrolled`);
|
|
178
|
+
}
|
|
179
|
+
if (probes < nextRung.minProbes) missing.push(`${nextRung.minProbes - probes} more health probes`);
|
|
180
|
+
if (uptime < nextRung.minUptime) missing.push(`uptime ${(uptime * 100).toFixed(1)}% < ${(nextRung.minUptime * 100).toFixed(1)}%`);
|
|
181
|
+
if (roundTrips < nextRung.minRoundTrips) missing.push(`${nextRung.minRoundTrips - roundTrips} more relay round-trips`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return { tier: held.name, demoted: false, next: nextRung?.name ?? null, missing, ageMs, uptime, roundTrips };
|
|
185
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import {
|
|
4
|
+
classifyThread,
|
|
5
|
+
assertChannelSendAdmission,
|
|
6
|
+
assertChannelReceiveAdmission,
|
|
7
|
+
assertForwardingPermission,
|
|
8
|
+
validateTenantBinding,
|
|
9
|
+
CANONICAL_CHANNEL_REGEX,
|
|
10
|
+
RESERVED_CHANNEL_PREFIX
|
|
11
|
+
} from "../src/channels.mjs";
|
|
12
|
+
|
|
13
|
+
test("Channel classifier: C2 Input / Mode Decision Table (two-pole)", () => {
|
|
14
|
+
const canonicalUris = [
|
|
15
|
+
"channel://commons/dm/agent-session-123",
|
|
16
|
+
"channel://zilligon/community/welcome",
|
|
17
|
+
"channel://local/topic/node-events",
|
|
18
|
+
"channel://commons/session/delib-456"
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
// 1. Canonical structured channels succeed across all policy modes
|
|
22
|
+
for (const uri of canonicalUris) {
|
|
23
|
+
for (const channelsEnabled of [false, true, undefined]) {
|
|
24
|
+
const result = classifyThread(uri, { channelsEnabled });
|
|
25
|
+
assert.equal(result.ok, true, `Expected ${uri} to succeed with channelsEnabled=${channelsEnabled}`);
|
|
26
|
+
assert.equal(result.classification, "structured");
|
|
27
|
+
assert.equal(result.channelUri, uri);
|
|
28
|
+
assert.ok(result.parsed.provider);
|
|
29
|
+
assert.ok(result.parsed.channelType);
|
|
30
|
+
assert.ok(result.parsed.channelId);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 2. Non-canonical channel-like spellings fail closed with INVALID_CHANNEL_URI
|
|
35
|
+
const nonCanonicalUris = [
|
|
36
|
+
"CHANNEL://commons/dm/agent-123", // Uppercase scheme (must NOT fall back to legacy bypass)
|
|
37
|
+
"channel:/commons/dm/123", // Missing slash
|
|
38
|
+
"channel://commons", // Missing segments
|
|
39
|
+
"channel://commons/dm", // Only two segments
|
|
40
|
+
"channel://comm@ns/dm/123", // Disallowed characters
|
|
41
|
+
"Channel://local/topic/test", // Mixed case scheme
|
|
42
|
+
"CHANNEL://LOCAL/TOPIC/TEST" // All uppercase
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
for (const badUri of nonCanonicalUris) {
|
|
46
|
+
for (const channelsEnabled of [false, true, undefined]) {
|
|
47
|
+
const result = classifyThread(badUri, { channelsEnabled });
|
|
48
|
+
// Control pole: Must fail closed with INVALID_CHANNEL_URI
|
|
49
|
+
assert.equal(
|
|
50
|
+
result.ok,
|
|
51
|
+
false,
|
|
52
|
+
`Expected non-canonical ${badUri} to fail with channelsEnabled=${channelsEnabled}`
|
|
53
|
+
);
|
|
54
|
+
assert.equal(result.classification, "reserved-malformed");
|
|
55
|
+
assert.equal(result.error, "INVALID_CHANNEL_URI");
|
|
56
|
+
|
|
57
|
+
// Mutant pole check: verify that a naive startsWith("channel://") would have incorrectly bypassed
|
|
58
|
+
const naivePrefixMatches = badUri.startsWith("channel://");
|
|
59
|
+
const reservedPrefixMatches = RESERVED_CHANNEL_PREFIX.test(badUri);
|
|
60
|
+
assert.equal(
|
|
61
|
+
reservedPrefixMatches,
|
|
62
|
+
true,
|
|
63
|
+
`Reserved regex must catch channel-like spelling ${badUri}`
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 3. Ordinary legacy strings under default / disabled mode: ALLOWED
|
|
69
|
+
const legacyStrings = ["commons", "app-topic", "default-thread", "session-789"];
|
|
70
|
+
for (const legacy of legacyStrings) {
|
|
71
|
+
for (const channelsEnabled of [false, undefined]) {
|
|
72
|
+
const result = classifyThread(legacy, { channelsEnabled });
|
|
73
|
+
assert.equal(result.ok, true);
|
|
74
|
+
assert.equal(result.classification, "legacy-allowed");
|
|
75
|
+
assert.equal(result.thread, legacy);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 4. Ordinary legacy strings under channelsEnabled === true: DENIED
|
|
80
|
+
for (const legacy of legacyStrings) {
|
|
81
|
+
const result = classifyThread(legacy, { channelsEnabled: true });
|
|
82
|
+
// Control pole: Denied with STRUCTURED_CHANNELS_REQUIRED
|
|
83
|
+
assert.equal(result.ok, false);
|
|
84
|
+
assert.equal(result.classification, "legacy-denied");
|
|
85
|
+
assert.equal(result.error, "STRUCTURED_CHANNELS_REQUIRED");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 5. Invalid thread inputs
|
|
89
|
+
assert.equal(classifyThread("").error, "INVALID_THREAD_LENGTH");
|
|
90
|
+
assert.equal(classifyThread("a".repeat(121)).error, "INVALID_THREAD_LENGTH");
|
|
91
|
+
assert.equal(classifyThread(null).error, "INVALID_THREAD_LENGTH");
|
|
92
|
+
assert.equal(classifyThread(undefined).error, "INVALID_THREAD_LENGTH");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("Bidirectional channel admission: outbound send assertions (two-pole)", () => {
|
|
96
|
+
const channelUri = "channel://commons/dm/thread-1";
|
|
97
|
+
const policy = {
|
|
98
|
+
channels: {
|
|
99
|
+
[channelUri]: {
|
|
100
|
+
visibility: "private",
|
|
101
|
+
members: ["agent-alice", "agent-bob"]
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// Control pole: sender and all recipients admitted
|
|
107
|
+
assert.doesNotThrow(() => {
|
|
108
|
+
assertChannelSendAdmission(channelUri, "agent-alice", ["agent-bob"], policy);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Mutant pole 1: unauthorized sender fails closed
|
|
112
|
+
assert.throws(
|
|
113
|
+
() => assertChannelSendAdmission(channelUri, "agent-eve", ["agent-bob"], policy),
|
|
114
|
+
(err) => err.code === "UNAUTHORIZED_CHANNEL_SENDER"
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
// Mutant pole 2: unauthorized recipient fails closed
|
|
118
|
+
assert.throws(
|
|
119
|
+
() => assertChannelSendAdmission(channelUri, "agent-alice", ["agent-eve"], policy),
|
|
120
|
+
(err) => err.code === "UNAUTHORIZED_CHANNEL_RECIPIENT"
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
// Mutant pole 3: unconfigured channel fails closed
|
|
124
|
+
assert.throws(
|
|
125
|
+
() => assertChannelSendAdmission("channel://commons/dm/unconfigured", "agent-alice", ["agent-bob"], policy),
|
|
126
|
+
(err) => err.code === "UNCONFIGURED_CHANNEL"
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("Bidirectional channel admission: inbound receive assertions (two-pole)", () => {
|
|
131
|
+
const channelUri = "channel://commons/dm/thread-1";
|
|
132
|
+
const policy = {
|
|
133
|
+
channels: {
|
|
134
|
+
[channelUri]: {
|
|
135
|
+
visibility: "private",
|
|
136
|
+
members: ["agent-alice", "agent-bob"]
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// Control pole: decrypted sender and local receiver admitted
|
|
142
|
+
assert.doesNotThrow(() => {
|
|
143
|
+
assertChannelReceiveAdmission(channelUri, "agent-alice", "agent-bob", policy);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// Mutant pole 1: unauthorized sender fails closed
|
|
147
|
+
assert.throws(
|
|
148
|
+
() => assertChannelReceiveAdmission(channelUri, "agent-eve", "agent-bob", policy),
|
|
149
|
+
(err) => err.code === "UNAUTHORIZED_CHANNEL_SENDER"
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
// Mutant pole 2: unauthorized receiver fails closed
|
|
153
|
+
assert.throws(
|
|
154
|
+
() => assertChannelReceiveAdmission(channelUri, "agent-alice", "agent-eve", policy),
|
|
155
|
+
(err) => err.code === "UNAUTHORIZED_CHANNEL_RECEIVER"
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
// Mutant pole 3: unconfigured inbound channel fails closed with UNCONFIGURED_CHANNEL
|
|
159
|
+
assert.throws(
|
|
160
|
+
() => assertChannelReceiveAdmission("channel://commons/dm/unconfigured", "agent-alice", "agent-bob", policy),
|
|
161
|
+
(err) => err.code === "UNCONFIGURED_CHANNEL"
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("Cross-channel forwarding assertions (two-pole)", () => {
|
|
166
|
+
const sourceChannel = "channel://commons/dm/source-1";
|
|
167
|
+
const targetChannel = "channel://zilligon/community/announcements";
|
|
168
|
+
|
|
169
|
+
const policy = {
|
|
170
|
+
agents: {
|
|
171
|
+
"agent-forwarder": { capabilities: ["commons:message", "commons:forward"] },
|
|
172
|
+
"agent-regular": { capabilities: ["commons:message"] }
|
|
173
|
+
},
|
|
174
|
+
channels: {
|
|
175
|
+
[targetChannel]: {
|
|
176
|
+
visibility: "public",
|
|
177
|
+
allowForwarding: true
|
|
178
|
+
},
|
|
179
|
+
"channel://commons/dm/strict-target": {
|
|
180
|
+
visibility: "private",
|
|
181
|
+
allowForwarding: false
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// Control pole: authorized forwarder and target permits forwarding
|
|
187
|
+
assert.doesNotThrow(() => {
|
|
188
|
+
assertForwardingPermission(sourceChannel, targetChannel, "agent-forwarder", policy);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// Mutant pole 1: sender lacks commons:forward capability
|
|
192
|
+
assert.throws(
|
|
193
|
+
() => assertForwardingPermission(sourceChannel, targetChannel, "agent-regular", policy),
|
|
194
|
+
(err) => err.code === "CROSS_CHANNEL_FORWARDING_DENIED"
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
// Mutant pole 2: target channel disallows forwarding
|
|
198
|
+
assert.throws(
|
|
199
|
+
() => assertForwardingPermission(sourceChannel, "channel://commons/dm/strict-target", "agent-forwarder", policy),
|
|
200
|
+
(err) => err.code === "CROSS_CHANNEL_FORWARDING_DENIED"
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("Tenant namespace binding assertions (two-pole)", () => {
|
|
205
|
+
const boundTenantProfile = {
|
|
206
|
+
id: "sha256-bound-profile-digest",
|
|
207
|
+
scope: "tenant"
|
|
208
|
+
};
|
|
209
|
+
const unboundTenantProfile = {
|
|
210
|
+
id: "sha256-unbound-tenant-profile",
|
|
211
|
+
scope: "tenant"
|
|
212
|
+
};
|
|
213
|
+
const unboundLocalProfile = {
|
|
214
|
+
id: "sha256-unbound-local-profile",
|
|
215
|
+
scope: "local"
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const policy = {
|
|
219
|
+
tenantProfiles: {
|
|
220
|
+
[boundTenantProfile.id]: "tenant-acme"
|
|
221
|
+
},
|
|
222
|
+
agents: {
|
|
223
|
+
"agent-acme-1": { tenantId: "tenant-acme" },
|
|
224
|
+
"agent-acme-2": { tenantId: "tenant-acme" },
|
|
225
|
+
"agent-corp-x": { tenantId: "tenant-corp" }
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
// Control pole 1: matching tenant passes
|
|
230
|
+
assert.doesNotThrow(() => {
|
|
231
|
+
validateTenantBinding("agent-acme-1", "agent-acme-2", boundTenantProfile, policy);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// Mutant pole 1: sender tenant mismatch fails closed
|
|
235
|
+
assert.throws(
|
|
236
|
+
() => validateTenantBinding("agent-corp-x", "agent-acme-2", boundTenantProfile, policy),
|
|
237
|
+
(err) => err.code === "TENANT_NAMESPACE_MISMATCH"
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
// Mutant pole 2: recipient tenant mismatch fails closed
|
|
241
|
+
assert.throws(
|
|
242
|
+
() => validateTenantBinding("agent-acme-1", "agent-corp-x", boundTenantProfile, policy),
|
|
243
|
+
(err) => err.code === "TENANT_NAMESPACE_MISMATCH"
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
// Control pole 2: unbound tenant profile MUST fail closed with UNBOUND_PROFILE_TENANT (R4)
|
|
247
|
+
assert.throws(
|
|
248
|
+
() => validateTenantBinding("agent-acme-1", "agent-acme-2", unboundTenantProfile, policy),
|
|
249
|
+
(err) => err.code === "UNBOUND_PROFILE_TENANT"
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
// Control pole 3: unbound local profile passes unconstrained without tenant binding requirement
|
|
253
|
+
assert.doesNotThrow(() => {
|
|
254
|
+
validateTenantBinding("agent-acme-1", "agent-corp-x", unboundLocalProfile, policy);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// Mutant pole: unbound bare digest string fails closed with UNBOUND_PROFILE_TENANT (R4)
|
|
258
|
+
assert.throws(
|
|
259
|
+
() => validateTenantBinding("agent-acme-1", "agent-corp-x", "sha256-unbound-digest", policy),
|
|
260
|
+
(err) => err.code === "UNBOUND_PROFILE_TENANT"
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
// Mutant pole: unbound scope-less ProfileRef fails closed with UNBOUND_PROFILE_TENANT (R4)
|
|
264
|
+
assert.throws(
|
|
265
|
+
() => validateTenantBinding("agent-acme-1", "agent-corp-x", { id: "profile-no-scope" }, policy),
|
|
266
|
+
(err) => err.code === "UNBOUND_PROFILE_TENANT"
|
|
267
|
+
);
|
|
268
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, rmSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { initializeCohort, cohortStatus, FOUNDING_ROSTER } from "../src/cohort.mjs";
|
|
7
|
+
|
|
8
|
+
test("Seven nodes persist distinct identity and scoped policy without remote enrollment",()=>{
|
|
9
|
+
const root=mkdtempSync(join(tmpdir(),"commons-cohort-")),home=join(root,"nodes");
|
|
10
|
+
try{
|
|
11
|
+
const manifest=initializeCohort({home,target:"zilligon.com"});
|
|
12
|
+
assert.equal(manifest.members.length,7);
|
|
13
|
+
assert.equal(new Set(manifest.members.map(m=>m.uuaid)).size,7);
|
|
14
|
+
assert.equal(manifest.registration,"not-performed");
|
|
15
|
+
assert.equal(manifest.certified,false);
|
|
16
|
+
const next=cohortStatus(home);
|
|
17
|
+
assert.deepEqual(next.members.map(m=>m.uuaid),manifest.members.map(m=>m.uuaid));
|
|
18
|
+
assert(next.members.every(m=>m.status.auditVerified));
|
|
19
|
+
const config=JSON.parse(readFileSync(join(home,"security","config.json"),"utf8"));
|
|
20
|
+
assert.equal(Object.keys(config.policy.agents).length,7);
|
|
21
|
+
const security=manifest.members.find(m=>m.id==="security");
|
|
22
|
+
assert(!config.policy.agents[security.uuaid].capabilities.includes("commons:evolve"));
|
|
23
|
+
assert(Object.values(config.policy.agents).every(a=>!a.capabilities.includes("commons:recover")));
|
|
24
|
+
assert.throws(()=>initializeCohort({home}),/not overwritten/);
|
|
25
|
+
}finally{rmSync(root,{recursive:true,force:true})}
|
|
26
|
+
});
|
|
27
|
+
test("Cohort manifest cannot redirect identity loading to arbitrary paths",()=>{
|
|
28
|
+
const root=mkdtempSync(join(tmpdir(),"commons-cohort-")),home=join(root,"nodes");
|
|
29
|
+
try{
|
|
30
|
+
const m=initializeCohort({home});
|
|
31
|
+
m.members[0].home=root;
|
|
32
|
+
writeFileSync(join(home,"cohort.json"),JSON.stringify(m));
|
|
33
|
+
assert.throws(()=>cohortStatus(home),/Unsafe/);
|
|
34
|
+
assert.equal(FOUNDING_ROSTER.length,7);
|
|
35
|
+
}finally{rmSync(root,{recursive:true,force:true})}
|
|
36
|
+
});
|