@passioncode-ai/fabric-agent-adapter 0.19.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.
Files changed (20) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +3 -3
  3. package/package.json +1 -1
  4. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  5. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
  7. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +7 -4
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +14 -0
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +90 -0
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/profiles.md +23 -0
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +1 -1
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +26 -4
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +443 -0
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +140 -6
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +8 -2
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +32 -6
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +826 -0
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +217 -8
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +7 -1
  20. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
@@ -0,0 +1,443 @@
1
+ // `estate-record/1` (Fabric Agent Contract DEC-0055): verify an estate revision and check its rules — the twin of
2
+ // fabric_estate.py. The contract's src/estate-record-rules.ts, src/webauthn.ts and src/p256.ts at the pin, held to the
3
+ // contract's own vectors (test/fixtures/contract/estate-vectors.json):
4
+ //
5
+ // - verifyEstateRecord(record, { at, parent } | { at, authorities }) — the verdict a machine applies a revision on, after
6
+ // it has checked the revision against estate-record.schema.json (the verifier does not check the schema);
7
+ // - estateRecordRules(kind, value) — FAC-SEM-072…076;
8
+ // - hubKeyCertified(record, kid, x, at) — DL-14: a delegation verifier that reads the estate record trusts a token's
9
+ // `kid` only while a current revision certifies it for a machine (fabric-delegation.mjs calls it).
10
+ //
11
+ // Node's own crypto: ES256 through createPublicKey (JWK) and verify (DER), EdDSA through Ed25519.
12
+
13
+ import crypto from 'node:crypto';
14
+ import { jcs } from './fabric-delegation.mjs';
15
+ import { secretValue } from './fabric-secrets.mjs';
16
+
17
+ export const ESTATE_RECORD_SCHEMA = 'estate-record/1';
18
+ export const ESTATE_CHALLENGE_TYPE = 'estate-revision/1';
19
+ export const MAX_VALIDITY_SECONDS = 30 * 24 * 60 * 60;
20
+ export const ASSERTION_ALGORITHMS = Object.freeze(['ES256', 'EdDSA']);
21
+ export const FLAGS = Object.freeze({ UP: 0x01, UV: 0x04, BE: 0x08, BS: 0x10, AT: 0x40, ED: 0x80 });
22
+
23
+ const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
24
+ const isString = (value) => typeof value === 'string';
25
+ const list = (value) => (Array.isArray(value) ? value.filter(isObject) : []);
26
+ const sha256 = (bytes) => crypto.createHash('sha256').update(bytes).digest();
27
+ const B64URL = /^[A-Za-z0-9_-]+$/;
28
+ const bytes = (text) => Buffer.from(text, 'base64url');
29
+ const canonical = (text) => Buffer.from(text, 'base64url').toString('base64url') === text;
30
+
31
+ const SMALL_ORDER = new Set([
32
+ '0000000000000000000000000000000000000000000000000000000000000000', '0100000000000000000000000000000000000000000000000000000000000000',
33
+ '26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05', 'c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a',
34
+ 'ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f', 'edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f',
35
+ 'eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f',
36
+ ]);
37
+ /** The contract's isSmallOrderKey: a key that is not 32 bytes counts as one too. */
38
+ function smallOrder(raw) {
39
+ if (raw.length !== 32) return true;
40
+ const cleared = Buffer.from(raw);
41
+ cleared[31] &= 0x7f;
42
+ return SMALL_ORDER.has(cleared.toString('hex'));
43
+ }
44
+
45
+ // ---- P-256 (DEC-0053; SEC 1 v2 §3.2.2) ----
46
+
47
+ const P = 0xffffffff00000001000000000000000000000000ffffffffffffffffffffffffn;
48
+ const B = 0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604bn;
49
+ const toBig = (part) => BigInt(`0x${Buffer.from(part).toString('hex')}`);
50
+
51
+ /** Whether `raw` is a valid uncompressed P-256 public key: 0x04 ‖ X ‖ Y, both below p, on the curve. */
52
+ export function isValidP256Key(raw) {
53
+ if (raw.length !== 65 || raw[0] !== 0x04) return false;
54
+ const x = toBig(raw.subarray(1, 33));
55
+ const y = toBig(raw.subarray(33, 65));
56
+ if (x >= P || y >= P) return false;
57
+ const left = (y * y) % P;
58
+ const right = (((x * x) % P) * x - 3n * x + B) % P;
59
+ return left === ((right % P) + P) % P;
60
+ }
61
+
62
+ // ---- WebAuthn Level 3 §7.2, offline ----
63
+
64
+ /** The credential's public key, validated before use: `{key}` or `{problem: "unusable-key" | "small-order-key"}`. */
65
+ export function credentialKey(publicKey, alg) {
66
+ if (!ASSERTION_ALGORITHMS.includes(alg)) return { problem: 'unusable-key' };
67
+ if (!isObject(publicKey) || publicKey.d !== undefined || !isString(publicKey.x) || !B64URL.test(publicKey.x) || !canonical(publicKey.x)) return { problem: 'unusable-key' };
68
+ if (alg === 'ES256') {
69
+ if (publicKey.kty !== 'EC' || publicKey.crv !== 'P-256' || !isString(publicKey.y) || !B64URL.test(publicKey.y) || !canonical(publicKey.y)) return { problem: 'unusable-key' };
70
+ const x = bytes(publicKey.x);
71
+ const y = bytes(publicKey.y);
72
+ if (x.length !== 32 || y.length !== 32 || !isValidP256Key(Buffer.concat([Buffer.from([4]), x, y]))) return { problem: 'unusable-key' };
73
+ return { key: crypto.createPublicKey({ key: { kty: 'EC', crv: 'P-256', x: publicKey.x, y: publicKey.y }, format: 'jwk' }) };
74
+ }
75
+ if (publicKey.kty !== 'OKP' || publicKey.crv !== 'Ed25519') return { problem: 'unusable-key' };
76
+ const raw = bytes(publicKey.x);
77
+ if (raw.length !== 32) return { problem: 'unusable-key' };
78
+ // DEC-0045 (CT-29): a small-order key verifies forged signatures on Node 20 and 22; it is refused before any use.
79
+ if (smallOrder(raw)) return { problem: 'small-order-key' };
80
+ return { key: crypto.createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: publicKey.x }, format: 'jwk' }) };
81
+ }
82
+
83
+ /**
84
+ * Verify a passkey assertion under a credential the caller pinned (`credentialId`, `alg`, `publicKey`, `rpId`,
85
+ * `origins`) for the expected challenge bytes: `{ok: true, flags, signCount}` or `{ok: false, problem}`.
86
+ */
87
+ export function verifyAssertion(assertion, credential, challenge) {
88
+ if (!isObject(assertion)) return { ok: false, problem: 'malformed' };
89
+ const { credentialId, alg, authenticatorData, clientDataJSON, sig } = assertion;
90
+ for (const field of [credentialId, alg, authenticatorData, clientDataJSON, sig]) if (!isString(field)) return { ok: false, problem: 'malformed' };
91
+ if (![credentialId, authenticatorData, clientDataJSON, sig].every((field) => B64URL.test(field))) return { ok: false, problem: 'malformed' };
92
+ if (credentialId !== credential.credentialId) return { ok: false, problem: 'malformed' };
93
+ if (!ASSERTION_ALGORITHMS.includes(alg)) return { ok: false, problem: 'unknown-algorithm' };
94
+ if (alg !== credential.alg) return { ok: false, problem: 'key-algorithm-mismatch' };
95
+ const found = credentialKey(credential.publicKey, alg);
96
+ if ('problem' in found) return { ok: false, problem: found.problem };
97
+
98
+ const cData = bytes(clientDataJSON);
99
+ let client;
100
+ try {
101
+ client = JSON.parse(new TextDecoder('utf-8', { fatal: true, ignoreBOM: false }).decode(cData));
102
+ } catch {
103
+ return { ok: false, problem: 'malformed' };
104
+ }
105
+ if (!isObject(client)) return { ok: false, problem: 'malformed' };
106
+ if (client.type !== 'webauthn.get') return { ok: false, problem: 'wrong-type' };
107
+ if (client.challenge !== Buffer.from(challenge).toString('base64url')) return { ok: false, problem: 'wrong-challenge' };
108
+ if (!isString(client.origin) || !credential.origins.includes(client.origin)) return { ok: false, problem: 'wrong-origin' };
109
+ if (client.crossOrigin === true || client.topOrigin !== undefined) return { ok: false, problem: 'cross-origin' };
110
+
111
+ const authData = bytes(authenticatorData);
112
+ if (authData.length < 37) return { ok: false, problem: 'malformed' };
113
+ if (!authData.subarray(0, 32).equals(sha256(Buffer.from(credential.rpId, 'utf8')))) return { ok: false, problem: 'wrong-rp' };
114
+ const flags = authData[32];
115
+ if ((flags & FLAGS.AT) !== 0 || ((flags & FLAGS.BS) !== 0 && (flags & FLAGS.BE) === 0)) return { ok: false, problem: 'bad-flags' };
116
+ if ((flags & FLAGS.UP) === 0) return { ok: false, problem: 'no-user-presence' };
117
+ if ((flags & FLAGS.UV) === 0) return { ok: false, problem: 'no-user-verification' };
118
+
119
+ const signed = Buffer.concat([authData, sha256(cData)]);
120
+ const signature = bytes(sig);
121
+ let good;
122
+ try {
123
+ good = alg === 'ES256'
124
+ ? crypto.verify('sha256', signed, { key: found.key, dsaEncoding: 'der' }, signature)
125
+ : signature.length === 64 && crypto.verify(null, signed, found.key, signature);
126
+ } catch {
127
+ good = false;
128
+ }
129
+ if (!good) return { ok: false, problem: 'bad-signature' };
130
+ return { ok: true, flags, signCount: authData.readUInt32BE(33) };
131
+ }
132
+
133
+ // ---- the record ----
134
+
135
+ /** `sha256:` and the hex SHA-256 of the UTF-8 RFC 8785 form of the record without `digest` and `signature`. */
136
+ export function estateRecordDigest(record) {
137
+ const { digest: _digest, signature: _signature, ...covered } = record;
138
+ return `sha256:${sha256(Buffer.from(jcs(covered), 'utf8')).toString('hex')}`;
139
+ }
140
+
141
+ export const estateChallengeInput = (digest) => Buffer.from(jcs({ type: ESTATE_CHALLENGE_TYPE, digest }), 'utf8');
142
+ /** The WebAuthn challenge a signer passes to navigator.credentials.get(): SHA-256 of estateChallengeInput. */
143
+ export const estateChallenge = (digest) => sha256(estateChallengeInput(digest));
144
+
145
+ const RFC3339 = /^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/;
146
+ /** An RFC 3339 date-time as milliseconds, or NaN: `T`, an offset and a day that exists; a space and `:60` refused (ER-10). */
147
+ export function estateTime(value) {
148
+ const match = isString(value) ? RFC3339.exec(value) : null;
149
+ if (!match) return Number.NaN;
150
+ const [year, month, day, hour, minute, second] = match.slice(1, 7).map(Number);
151
+ const date = new Date(0);
152
+ date.setUTCFullYear(year, month - 1, day);
153
+ if (date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day || hour > 23 || minute > 59 || second > 59) return Number.NaN;
154
+ return Date.parse(value);
155
+ }
156
+ const revocationKey = (entry) => `${String(entry.kind)} ${String(entry.ref)}`;
157
+ const clockOf = (at) => (at instanceof Date ? at.getTime() : (typeof at === 'number' && Number.isFinite(at) ? at * 1000 : Number.NaN));
158
+
159
+ /**
160
+ * The reference verifier, in the order of estate-record.md's ER table. `at` is the clock (a Date, or epoch seconds); pass
161
+ * the verified `parent`, or for revision 1 the `authorities` the machine pinned at enrollment.
162
+ */
163
+ export function verifyEstateRecord(record, trust) {
164
+ const at = isObject(trust) ? clockOf(trust.at) : Number.NaN;
165
+ if (!Number.isFinite(at)) return { ok: false, problem: 'no-clock' };
166
+ if (!isObject(record) || !isString(record.digest) || !Number.isSafeInteger(record.revision) || !isObject(record.signature)) return { ok: false, problem: 'malformed' };
167
+ const signature = record.signature;
168
+ if (signature.format !== 'webauthn-assertion' || !isString(signature.credentialId)) return { ok: false, problem: 'malformed' };
169
+ let digest;
170
+ try {
171
+ digest = estateRecordDigest(record);
172
+ } catch {
173
+ return { ok: false, problem: 'malformed' };
174
+ }
175
+ if (digest !== record.digest) return { ok: false, problem: 'digest-mismatch' };
176
+
177
+ let trusted;
178
+ let revoked = new Set();
179
+ if (trust.parent !== undefined) {
180
+ const parent = trust.parent;
181
+ if (!isObject(parent)) return { ok: false, problem: 'broken-chain' };
182
+ let parentDigest;
183
+ try {
184
+ parentDigest = estateRecordDigest(parent);
185
+ } catch {
186
+ return { ok: false, problem: 'broken-chain' };
187
+ }
188
+ if (parent.estate !== record.estate || record.revision !== Number(parent.revision) + 1 || record.parent !== parentDigest) return { ok: false, problem: 'broken-chain' };
189
+ if (estateTime(record.issuedAt) < estateTime(parent.issuedAt)) return { ok: false, problem: 'broken-chain' };
190
+ const kept = new Set(list(record.revocations).map(revocationKey));
191
+ if (list(parent.revocations).some((entry) => !kept.has(revocationKey(entry)))) return { ok: false, problem: 'broken-chain' };
192
+ // A passkey the parent revokes signs nothing after it; a revision may still revoke its own signer (rotation).
193
+ revoked = new Set(list(parent.revocations).filter((entry) => entry.kind === 'authority').map((entry) => entry.ref));
194
+ trusted = list(parent.authorities);
195
+ } else {
196
+ if (record.revision !== 1 || record.parent !== undefined) return { ok: false, problem: 'broken-chain' };
197
+ trusted = Array.isArray(trust.authorities) ? trust.authorities : [];
198
+ }
199
+ if (trusted.length === 0) return { ok: false, problem: 'no-trusted-authority' };
200
+ const matches = trusted.filter((entry) => isObject(entry) && entry.credentialId === signature.credentialId);
201
+ if (matches.length > 1) return { ok: false, problem: 'duplicate-authority' };
202
+ const authority = matches[0];
203
+ if (!authority) return { ok: false, problem: 'unknown-authority' };
204
+ if (revoked.has(authority.credentialId)) return { ok: false, problem: 'revoked-authority' };
205
+ const credential = {
206
+ credentialId: String(authority.credentialId), alg: String(authority.alg), publicKey: isObject(authority.publicKey) ? authority.publicKey : {},
207
+ rpId: String(authority.rpId), origins: Array.isArray(authority.origins) ? authority.origins.filter(isString) : [],
208
+ };
209
+ const assertion = verifyAssertion(signature, credential, estateChallenge(digest));
210
+ if (!assertion.ok) return { ok: false, problem: assertion.problem };
211
+
212
+ const issuedAt = estateTime(record.issuedAt);
213
+ const validUntil = estateTime(record.validUntil);
214
+ if (!Number.isFinite(issuedAt) || !Number.isFinite(validUntil) || validUntil <= issuedAt || validUntil - issuedAt > MAX_VALIDITY_SECONDS * 1000) return { ok: false, problem: 'too-long-lived' };
215
+ if (at < issuedAt) return { ok: false, problem: 'not-yet-valid' };
216
+ if (at > validUntil) return { ok: false, problem: 'expired' };
217
+ const codes = [...new Set(recordFindings(record).map((finding) => finding.code))];
218
+ if (codes.length > 0) return { ok: false, problem: 'rule-violation', codes };
219
+ return { ok: true, digest, revision: record.revision, signedBy: credential.credentialId, signCount: assertion.signCount };
220
+ }
221
+
222
+ /** What a package verifier trusts for a package placed on a machine; undefined when the record places no such agent there. */
223
+ export function placementTrust(record, machineId, agentRef) {
224
+ const machine = list(record.machines).find((entry) => entry.id === machineId);
225
+ const placement = list(machine?.packages).find((entry) => entry.agentRef === agentRef);
226
+ if (!placement) return undefined;
227
+ return { publisher: list(record.publisherKeys).filter((key) => key.kid === placement.publisherKid) };
228
+ }
229
+
230
+ /**
231
+ * DL-14 with DEC-0055: trust a token's `kid` only while a current revision certifies it for a machine and does not revoke
232
+ * it. `record` is a revision the caller verified; `x` the key the published key set gives for `kid`; `at` the clock.
233
+ */
234
+ export function hubKeyCertified(record, kid, x, at) {
235
+ const now = clockOf(at);
236
+ if (!isObject(record) || !Number.isFinite(now)) return false;
237
+ const issuedAt = estateTime(record.issuedAt);
238
+ const validUntil = estateTime(record.validUntil);
239
+ if (!(Number.isFinite(issuedAt) && Number.isFinite(validUntil) && issuedAt <= now && now <= validUntil)) return false;
240
+ const revoked = new Set(list(record.revocations).map(revocationKey));
241
+ return list(record.machines).some((machine) => isObject(machine.hubKey) && machine.hubKey.kid === kid && machine.hubKey.x === x
242
+ && !revoked.has(`hub-key ${kid}`) && !revoked.has(`machine ${String(machine.id)}`));
243
+ }
244
+
245
+ // ---- FAC-SEM-072…076 ----
246
+
247
+ const flagger = (findings, code) => (instancePath, message) => findings.push({ code, instancePath, message });
248
+
249
+ function revisionWhole(value, flag) {
250
+ try {
251
+ const digest = estateRecordDigest(value);
252
+ if (digest !== value.digest) flag('/digest', `the digest is ${digest}: the record changed after it was drafted, or was hashed over another form than RFC 8785`);
253
+ } catch {
254
+ flag('/digest', 'the record has no RFC 8785 form, so it has no digest');
255
+ }
256
+ const issuedAt = estateTime(value.issuedAt);
257
+ const validUntil = estateTime(value.validUntil);
258
+ if (!Number.isFinite(issuedAt) || !Number.isFinite(validUntil)) {
259
+ flag(Number.isFinite(issuedAt) ? '/validUntil' : '/issuedAt', 'issuedAt and validUntil are RFC 3339 date-times with an offset, on days that exist');
260
+ } else if (validUntil <= issuedAt || validUntil - issuedAt > MAX_VALIDITY_SECONDS * 1000) {
261
+ flag('/validUntil', 'an estate record is valid after it is issued and for at most 30 days');
262
+ }
263
+ }
264
+
265
+ function revisionChain(value) {
266
+ const findings = [];
267
+ const flag = flagger(findings, 'FAC-SEM-072');
268
+ const parent = isObject(value.parent) ? value.parent : {};
269
+ const next = isObject(value.next) ? value.next : {};
270
+ if (next.estate !== parent.estate) flag('/next/estate', "a revision continues its parent's estate");
271
+ if (next.revision !== Number(parent.revision) + 1) flag('/next/revision', `the revision after ${String(parent.revision)} is ${Number(parent.revision) + 1}: a revision never skips or repeats one`);
272
+ let parentDigest;
273
+ try {
274
+ parentDigest = estateRecordDigest(parent);
275
+ } catch {
276
+ parentDigest = undefined;
277
+ }
278
+ if (next.parent !== parentDigest) flag('/next/parent', 'parent names the digest of the revision before it');
279
+ if (estateTime(next.issuedAt) < estateTime(parent.issuedAt)) flag('/next/issuedAt', 'a revision is never issued before its parent');
280
+ const kept = new Set(list(next.revocations).map(revocationKey));
281
+ list(parent.revocations).forEach((entry, index) => {
282
+ if (!kept.has(revocationKey(entry))) flag(`/parent/revocations/${index}`, `the ${String(entry.kind)} revocation of ${String(entry.ref)} is dropped: a revision keeps every revocation before it`);
283
+ });
284
+ return findings;
285
+ }
286
+
287
+ const validDeviceKey = (key) => {
288
+ const raw = Buffer.from(String(key.publicKey ?? ''), 'base64');
289
+ return key.alg === 'ed25519' ? raw.length === 32 && !smallOrder(raw) : isValidP256Key(raw);
290
+ };
291
+
292
+ function keys(value, flag) {
293
+ const ids = new Set();
294
+ const hubKids = new Set();
295
+ const hubKeys = new Set();
296
+ list(value.machines).forEach((machine, m) => {
297
+ const at = `/machines/${m}`;
298
+ if (ids.has(machine.id)) flag(`${at}/id`, `machine ${String(machine.id)} is listed twice`);
299
+ ids.add(machine.id);
300
+ const deviceKeys = list(machine.deviceKeys);
301
+ if (deviceKeys.filter((key) => key.use === 'sig').length !== 1) flag(`${at}/deviceKeys`, 'a machine has exactly one signing device key, <id>#sig');
302
+ if (deviceKeys.filter((key) => key.use === 'kem').length > 1) flag(`${at}/deviceKeys`, 'a machine has at most one key-agreement device key, <id>#kem');
303
+ const seen = new Set();
304
+ deviceKeys.forEach((key, k) => {
305
+ if (key.kid !== `${String(machine.id)}#${String(key.use)}`) flag(`${at}/deviceKeys/${k}/kid`, 'a device key is listed as <machine id>#sig or <machine id>#kem, by its role');
306
+ if (!validDeviceKey(key)) flag(`${at}/deviceKeys/${k}/publicKey`, 'an Ed25519 key is never of small order (DEC-0045) and a P-256 key is a valid point (SEC 1 §3.2.2)');
307
+ if (seen.has(key.publicKey)) flag(`${at}/deviceKeys/${k}/publicKey`, 'one key is never used for the other role');
308
+ seen.add(key.publicKey);
309
+ });
310
+ if (isObject(machine.hubKey)) {
311
+ const hub = machine.hubKey;
312
+ if (hubKids.has(hub.kid)) flag(`${at}/hubKey/kid`, `hub key ${String(hub.kid)} is certified for two machines: one kid per machine`);
313
+ hubKids.add(hub.kid);
314
+ if (hubKeys.has(hub.x)) flag(`${at}/hubKey/x`, 'one hub public key is certified for two machines under two kids: one key per machine');
315
+ hubKeys.add(hub.x);
316
+ if (smallOrder(Buffer.from(String(hub.x ?? ''), 'base64url'))) flag(`${at}/hubKey/x`, 'a hub key of small order verifies forged tokens and is never certified (DEC-0045)');
317
+ }
318
+ });
319
+ const credentials = new Set();
320
+ list(value.authorities).forEach((authority, index) => {
321
+ if (credentials.has(authority.credentialId)) flag(`/authorities/${index}/credentialId`, 'a passkey is listed once');
322
+ credentials.add(authority.credentialId);
323
+ if ('problem' in credentialKey(authority.publicKey, String(authority.alg))) flag(`/authorities/${index}/publicKey`, "a passkey's public key is a valid P-256 point for ES256 or an Ed25519 key of full order for EdDSA");
324
+ });
325
+ }
326
+
327
+ function placements(value, flag) {
328
+ const kids = new Set();
329
+ list(value.publisherKeys).forEach((key, index) => {
330
+ if (kids.has(key.kid)) flag(`/publisherKeys/${index}/kid`, `publisher key ${String(key.kid)} is pinned twice`);
331
+ kids.add(key.kid);
332
+ });
333
+ list(value.machines).forEach((machine, m) => {
334
+ const agents = new Set();
335
+ list(machine.packages).forEach((placement, p) => {
336
+ const at = `/machines/${m}/packages/${p}`;
337
+ if (agents.has(placement.agentRef)) flag(`${at}/agentRef`, `${String(placement.agentRef)} is placed twice on one machine`);
338
+ agents.add(placement.agentRef);
339
+ if (!kids.has(placement.publisherKid)) flag(`${at}/publisherKid`, `publisher key ${String(placement.publisherKid)} is not in publisherKeys, so the machine pins no key to verify the package under`);
340
+ });
341
+ });
342
+ }
343
+
344
+ function secretReferences(value, flag) {
345
+ const machineIds = new Set(list(value.machines).map((machine) => machine.id));
346
+ list(value.machines).forEach((machine, m) => {
347
+ const placed = new Set(list(machine.packages).map((placement) => placement.agentRef));
348
+ const slots = new Set();
349
+ list(machine.secrets).forEach((secret, s) => {
350
+ const at = `/machines/${m}/secrets/${s}`;
351
+ for (const field of ['slot', 'from', 'agentRef']) {
352
+ const text = secret[field];
353
+ if (isString(text) && secretValue(text)) flag(`${at}/${field}`, 'a secret placement names a slot, never a value: this looks like a credential');
354
+ }
355
+ if (!placed.has(secret.agentRef)) flag(`${at}/agentRef`, 'a secret goes only where a placed package declares it (v4 Path 2): this agent is not placed on this machine');
356
+ if (!machineIds.has(secret.from)) flag(`${at}/from`, 'the slot is filled from a machine of this estate');
357
+ const key = `${String(secret.agentRef)} ${String(secret.slot)}`;
358
+ if (slots.has(key)) flag(`${at}/slot`, `slot ${String(secret.slot)} is placed twice for one agent on one machine`);
359
+ slots.add(key);
360
+ });
361
+ });
362
+ }
363
+
364
+ function grantsAndRevocations(value, flag) {
365
+ const grantIds = new Set();
366
+ list(value.shareGrants).forEach((grant, index) => {
367
+ if (grant.principalKind !== 'external') flag(`/shareGrants/${index}/principalKind`, 'a share grant is to a principal outside the estate: principalKind external');
368
+ const id = isObject(grant.meta) ? grant.meta.id : undefined;
369
+ if (grantIds.has(id)) flag(`/shareGrants/${index}/meta/id`, `grant ${String(id)} is listed twice`);
370
+ grantIds.add(id);
371
+ });
372
+ const machines = list(value.machines);
373
+ // A Map, so a kind such as `constructor` or `__proto__` reads nothing from Object.prototype.
374
+ const listed = new Map(Object.entries({
375
+ authority: new Set(list(value.authorities).map((entry) => entry.credentialId)),
376
+ machine: new Set(machines.map((machine) => machine.id)),
377
+ 'device-key': new Set(machines.flatMap((machine) => list(machine.deviceKeys).map((key) => key.kid))),
378
+ 'hub-key': new Set(machines.flatMap((machine) => (isObject(machine.hubKey) ? [machine.hubKey.kid] : []))),
379
+ 'publisher-key': new Set(list(value.publisherKeys).map((key) => key.kid)),
380
+ package: new Set(machines.flatMap((machine) => list(machine.packages).map((placement) => placement.digest))),
381
+ grant: grantIds,
382
+ }));
383
+ const seen = new Set();
384
+ list(value.revocations).forEach((revocation, index) => {
385
+ const key = `${String(revocation.kind)} ${String(revocation.ref)}`;
386
+ if (seen.has(key)) flag(`/revocations/${index}`, `the ${String(revocation.kind)} ${String(revocation.ref)} is revoked twice`);
387
+ seen.add(key);
388
+ if (listed.get(String(revocation.kind))?.has(revocation.ref)) flag(`/revocations/${index}/ref`, `the record still lists the ${String(revocation.kind)} it revokes: revocation outranks addition`);
389
+ });
390
+ }
391
+
392
+ function recordFindings(value) {
393
+ const findings = [];
394
+ revisionWhole(value, flagger(findings, 'FAC-SEM-072'));
395
+ keys(value, flagger(findings, 'FAC-SEM-073'));
396
+ placements(value, flagger(findings, 'FAC-SEM-074'));
397
+ secretReferences(value, flagger(findings, 'FAC-SEM-075'));
398
+ grantsAndRevocations(value, flagger(findings, 'FAC-SEM-076'));
399
+ return findings;
400
+ }
401
+
402
+ function packageDigest(pkg) {
403
+ const { digest: _digest, signatures: _signatures, ...covered } = pkg;
404
+ return `sha256:${sha256(Buffer.from(jcs(covered), 'utf8')).toString('hex')}`;
405
+ }
406
+
407
+ function installation(value) {
408
+ const findings = [];
409
+ const record = isObject(value.record) ? value.record : {};
410
+ const pkg = isObject(value.package) ? value.package : {};
411
+ const machineIndex = list(record.machines).findIndex((entry) => entry.id === value.machine);
412
+ const machine = list(record.machines)[machineIndex];
413
+ let digest;
414
+ try {
415
+ digest = packageDigest(pkg);
416
+ } catch {
417
+ digest = undefined;
418
+ }
419
+ const flag074 = flagger(findings, 'FAC-SEM-074');
420
+ if (!machine) {
421
+ flag074('/machine', `machine ${String(value.machine)} is not in the estate record`);
422
+ return findings;
423
+ }
424
+ if (digest === undefined || digest !== pkg.digest) flag074('/package/digest', 'the package is not the one its digest names: it changed after it was packed');
425
+ const placement = list(machine.packages).find((entry) => entry.agentRef === pkg.agentRef);
426
+ if (!placement) flag074('/package/agentRef', `the record places no ${String(pkg.agentRef)} on machine ${String(value.machine)}`);
427
+ else if (placement.digest !== digest) flag074('/package', `the record places ${String(placement.digest)} of this agent on this machine, not this package`);
428
+ const declared = new Set(list(pkg.secrets).map((slot) => slot.name));
429
+ const flag075 = flagger(findings, 'FAC-SEM-075');
430
+ list(machine.secrets).forEach((secret, index) => {
431
+ if (secret.agentRef === pkg.agentRef && !declared.has(secret.slot)) flag075(`/record/machines/${machineIndex}/secrets/${index}/slot`, `the package declares no slot ${String(secret.slot)}: a secret goes only where a placed package declares it`);
432
+ });
433
+ return findings;
434
+ }
435
+
436
+ /** FAC-SEM-072…076 for an `estate-record`, an `estate-record-revision` `{parent, next}` or an `estate-placement`. */
437
+ export function estateRecordRules(kind, value) {
438
+ const doc = isObject(value) ? value : {};
439
+ if (kind === 'estate-record') return recordFindings(doc);
440
+ if (kind === 'estate-record-revision') return revisionChain(doc);
441
+ if (kind === 'estate-placement') return installation(doc);
442
+ return undefined;
443
+ }
@@ -126,6 +126,10 @@ function which(name, searchPath) {
126
126
  * runners.md C2: is the catalogued runner installed, which version answers, and is it signed in. Only a bare
127
127
  * catalogued binary on PATH is run, only with the catalogued version (and auth) argv, without a shell, within its
128
128
  * timeout (at most 5 s). An entry outside these rules is refused and nothing runs.
129
+ *
130
+ * `probe` is the contract's closed probe result for a walk (DEC-0026): `not-installed`, `not-responding` (the version
131
+ * probe did not answer, failed or did not match), `not-connected` (signed out by the `auth` probe), or null when the
132
+ * runner can serve. The twin of fabric_profiles.runner_check.
129
133
  */
130
134
  export function runnerCheck(entry, { searchPath = process.env.PATH, run = spawnSync } = {}) {
131
135
  const { kind, binaries } = entry ?? {};
@@ -136,10 +140,10 @@ export function runnerCheck(entry, { searchPath = process.env.PATH, run = spawnS
136
140
  const timeoutMs = version.timeoutMs ?? MAX_PROBE_MS;
137
141
  if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_PROBE_MS) throw new ProfileError(`version.timeoutMs is at most ${MAX_PROBE_MS}`);
138
142
  const authArgv = 'auth' in entry ? argvOf(entry.auth, 'auth') : null;
139
- const out = { kind, installed: false, binary: null, version: null, signedIn: null, problems: [] };
143
+ const out = { kind, installed: false, binary: null, version: null, signedIn: null, probe: 'not-installed', problems: [] };
140
144
  const binary = binaries.map((name) => which(name, searchPath)).find(Boolean) ?? null;
141
145
  if (!binary) { out.problems.push(`${kind} is not installed: none of ${binaries.join(', ')} is on PATH`); return out; }
142
- Object.assign(out, { installed: true, binary });
146
+ Object.assign(out, { installed: true, binary, probe: 'not-responding' });
143
147
  const probe = (argv) => {
144
148
  const result = run(binary, argv, { encoding: 'utf8', timeout: timeoutMs, shell: false });
145
149
  if (result.error?.code === 'ETIMEDOUT' || result.signal === 'SIGTERM') { out.problems.push(`${kind} ${argv.join(' ')} did not answer within ${timeoutMs} ms`); return null; }
@@ -153,10 +157,12 @@ export function runnerCheck(entry, { searchPath = process.env.PATH, run = spawnS
153
157
  if (match && match.length > 1) out.version = match[1];
154
158
  else out.problems.push(`${kind} answered, but its version did not match the catalogue's pattern`);
155
159
  }
156
- if (authArgv) {
157
- const signed = probe(authArgv);
158
- if (signed) { out.signedIn = signed.status === 0; if (!out.signedIn) out.problems.push(`${kind} is installed but not signed in`); }
159
- }
160
+ const signed = authArgv ? probe(authArgv) : null;
161
+ if (signed) { out.signedIn = signed.status === 0; if (!out.signedIn) out.problems.push(`${kind} is installed but not signed in`); }
162
+ // The walk's order (runners.md): installed, then responding, then connected. The doctor still reports every problem.
163
+ if (out.version === null || (authArgv && !signed)) out.probe = 'not-responding';
164
+ else if (out.signedIn === false) out.probe = 'not-connected';
165
+ else out.probe = null;
160
166
  return out;
161
167
  }
162
168
 
@@ -194,3 +200,131 @@ export function configurationSummary(doc) {
194
200
  if (resolved.modelProfile) tiles.push({ label: 'Model', value: [resolved.modelProfile, (resolved.models ?? [])[0]].filter(Boolean).join(' · ') });
195
201
  return tiles;
196
202
  }
203
+
204
+ // ---- runner routes (DEC-0026, DEC-0029; FAC-SEM-028…034) ----
205
+ // The contract's routeRules (src/route-rules.ts), finding for finding: code, path and message. The twin of
206
+ // fabric_profiles.route_problems.
207
+
208
+ /** The closed probe results a walk records for a candidate it passed over (runner-route-event.schema.json). */
209
+ export const PROBE_RESULTS = Object.freeze(['not-catalogued', 'not-admitted', 'not-installed', 'not-responding', 'not-connected', 'no-held-session', 'quota-unknown', 'refused', 'spawn-failed']);
210
+
211
+ const routeCandidates = (value) => (Array.isArray(value?.candidates) ? value.candidates.filter(isObject) : []);
212
+ const objectAt = (value, key) => (isObject(value?.[key]) ? value[key] : {});
213
+ const refKey = (ref) => (isObject(ref) ? `${String(ref.id)}@${String(ref.revision)}#${String(ref.contentHash)}` : '');
214
+ const sameRef = (a, b) => refKey(a) !== '' && refKey(a) === refKey(b);
215
+
216
+ function runnerRoute(value) {
217
+ const findings = [];
218
+ const kinds = new Set();
219
+ routeCandidates(value).forEach((candidate, index) => {
220
+ const kind = String(candidate.runnerKind);
221
+ if (kinds.has(kind)) findings.push(finding('FAC-SEM-028', `/candidates/${index}/runnerKind`, `runner kind ${kind} appears in the route more than once`));
222
+ kinds.add(kind);
223
+ const session = objectAt(candidate, 'session');
224
+ if (session.attach === 'never' && session.spawn === 'never') findings.push(finding('FAC-SEM-029', `/candidates/${index}/session`, `candidate ${kind} can never run: attach and spawn are both "never"`));
225
+ });
226
+ return findings;
227
+ }
228
+
229
+ function routeBundle(value) {
230
+ const route = objectAt(value, 'route');
231
+ const admissions = Array.isArray(value.admissions) ? value.admissions.filter(isObject) : [];
232
+ const admitted = new Set(admissions.filter((a) => a.state === 'admitted' && a.capability === route.capability).map((a) => refKey(a.provider)));
233
+ const findings = [];
234
+ routeCandidates(route).forEach((candidate, index) => {
235
+ const key = refKey(candidate.provider);
236
+ if (!admitted.has(key)) findings.push(finding('FAC-SEM-030', `/route/candidates/${index}/provider`, `candidate ${String(candidate.runnerKind)} names provider ${key}, which is not a revision admitted for ${String(route.capability)}`));
237
+ });
238
+ if (isObject(value.binding)) {
239
+ const binding = value.binding;
240
+ const disagree = (where, message) => findings.push(finding('FAC-SEM-032', `/binding/${where}`, message));
241
+ if (binding.project !== route.project) disagree('project', `the binding is for ${String(binding.project)}, the route for ${String(route.project)}`);
242
+ if (binding.capability !== route.capability) disagree('capability', `the binding binds ${String(binding.capability)}, the route orders ${String(route.capability)}`);
243
+ if (binding.profileKind !== 'local-runner') disagree('profileKind', `a runner route serves the local-runner profile, not ${String(binding.profileKind)}`);
244
+ if (!routeCandidates(route).some((c) => sameRef(c.provider, binding.provider))) disagree('provider', `the binding's provider ${refKey(binding.provider)} is not a candidate of the route`);
245
+ if (binding.runnerRoute !== undefined && !sameRef(binding.runnerRoute, route.meta)) disagree('runnerRoute', `the binding pins ${refKey(binding.runnerRoute)}, not this route ${refKey(route.meta)}`);
246
+ }
247
+ if (Array.isArray(value.catalogue)) {
248
+ const byKind = new Map(value.catalogue.filter(isObject).map((entry) => [String(entry.kind), entry]));
249
+ routeCandidates(route).forEach((candidate, index) => {
250
+ const kind = String(candidate.runnerKind);
251
+ const entry = byKind.get(kind);
252
+ if (!entry) { findings.push(finding('FAC-SEM-033', `/route/candidates/${index}/runnerKind`, `runner kind ${kind} has no entry in the host's catalogue`)); return; }
253
+ if (candidate.drive !== undefined && !Object.hasOwn(objectAt(entry, 'drives'), String(candidate.drive))) findings.push(finding('FAC-SEM-033', `/route/candidates/${index}/drive`, `the catalogue entry for ${kind} offers no drive ${String(candidate.drive)}`));
254
+ });
255
+ }
256
+ return findings;
257
+ }
258
+
259
+ function routeEvent(value) {
260
+ const route = objectAt(value, 'route');
261
+ const event = objectAt(value, 'event');
262
+ const list = routeCandidates(route);
263
+ const findings = [];
264
+ const wrong = (where, message) => findings.push(finding('FAC-SEM-034', `/event${where}`, message));
265
+ if (!sameRef(event.route, route.meta)) wrong('/route', `the event names route ${refKey(event.route)}, not ${refKey(route.meta)}`);
266
+ if (event.project !== route.project) wrong('/project', `the event is for ${String(event.project)}, the route for ${String(route.project)}`);
267
+ if (event.capability !== route.capability) wrong('/capability', `the event names ${String(event.capability)}, the route orders ${String(route.capability)}`);
268
+ const indexOf = (ref) => (typeof ref.index === 'number' && Number.isInteger(ref.index) ? ref.index : -1);
269
+ const candidateAt = (ref, where) => {
270
+ const index = indexOf(ref);
271
+ const candidate = index >= 0 ? list[index] : undefined;
272
+ if (!candidate || candidate.runnerKind !== ref.runnerKind || (ref.provider !== undefined && !sameRef(candidate.provider, ref.provider))) {
273
+ wrong(where, `candidate ${String(ref.index)} (${String(ref.runnerKind)}) is not that position of the route`);
274
+ return undefined;
275
+ }
276
+ return candidate;
277
+ };
278
+ const probes = Array.isArray(event.probes) ? event.probes.filter(isObject) : [];
279
+ probes.forEach((probe, position) => {
280
+ const candidate = candidateAt(probe, `/probes/${position}`);
281
+ if (candidate && probe.result === 'no-held-session' && objectAt(candidate, 'session').spawn !== 'never') wrong(`/probes/${position}/result`, `${String(probe.runnerKind)} may spawn, so finding no held session does not pass it over`);
282
+ if (candidate && probe.result === 'spawn-failed' && objectAt(candidate, 'session').spawn !== 'allowed') wrong(`/probes/${position}/result`, `${String(probe.runnerKind)} has spawn: never, so it cannot have failed to spawn`);
283
+ });
284
+ const expectProbes = (count) => {
285
+ const indexes = probes.map(indexOf);
286
+ const expected = Array.from({ length: Math.max(count, 0) }, (_, index) => index);
287
+ if (indexes.length !== expected.length || indexes.some((index, position) => index !== expected[position])) {
288
+ wrong('/probes', `the walk must record candidates ${expected.join(', ') || 'none'} in order; it records ${indexes.join(', ') || 'none'}`);
289
+ }
290
+ };
291
+ if (event.kind === 'runner-exhausted') {
292
+ expectProbes(list.length);
293
+ if (event.answer === 'held' && route.exhausted !== 'hold') wrong('/answer', 'the route does not declare exhausted: hold, so an exhausted walk answers capability-unavailable');
294
+ return findings;
295
+ }
296
+ const selected = objectAt(event, 'selected');
297
+ const chosen = candidateAt(selected, '/selected');
298
+ if (chosen) {
299
+ const session = objectAt(chosen, 'session');
300
+ if (selected.session === 'attached' && session.attach !== 'preferred') wrong('/selected/session', `${String(selected.runnerKind)} has attach: never, so the host cannot attach it`);
301
+ if (selected.session === 'spawned' && session.spawn !== 'allowed') wrong('/selected/session', `${String(selected.runnerKind)} has spawn: never, so the host cannot spawn it`);
302
+ if (chosen.drive !== undefined && selected.drive !== undefined && selected.drive !== chosen.drive) wrong('/selected/drive', `the route runs ${String(selected.runnerKind)} through ${String(chosen.drive)}, not ${String(selected.drive)}`);
303
+ if (event.sticky === true) {
304
+ // A sticky relaunch keeps the conversation's runner and walks nothing above it.
305
+ if (route.recovery === 'reprobe') wrong('/sticky', 'a route with recovery: reprobe walks from the top at every launch');
306
+ if (event.conversation === undefined) wrong('/sticky', 'only a conversation can keep its runner');
307
+ expectProbes(0);
308
+ } else expectProbes(indexOf(selected));
309
+ }
310
+ if (event.kind === 'runner-switched') {
311
+ const from = objectAt(event, 'from');
312
+ candidateAt(from, '/from');
313
+ if (indexOf(from) === indexOf(selected)) wrong('/from', 'a switch moves to another candidate');
314
+ if (event.reason === 'preferred-available') {
315
+ if (route.recovery !== 'reprobe') wrong('/reason', 'only a route with recovery: reprobe moves a conversation back to a preferred runner');
316
+ if (!(indexOf(selected) < indexOf(from))) wrong('/reason', 'preferred-available moves to a higher-preference candidate');
317
+ }
318
+ }
319
+ return findings;
320
+ }
321
+
322
+ /**
323
+ * The contract's routeRules: findings for a `runner-route` (FAC-SEM-028, 029), a `route-bundle` `{route, admissions,
324
+ * binding?, catalogue?}` (030, 032, 033) or a `route-event` `{route, event}` (034); undefined for another kind or a
325
+ * value that is not an object.
326
+ */
327
+ export function routeProblems(kind, value) {
328
+ if (!isObject(value)) return undefined;
329
+ return { 'runner-route': runnerRoute, 'route-bundle': routeBundle, 'route-event': routeEvent }[kind]?.(value);
330
+ }
@@ -866,6 +866,12 @@ function byCodePoint(a, b) {
866
866
  * strings escaped as JSON requires and nothing else, integers in decimal — Python's
867
867
  * `json.dumps(value, sort_keys=True, ensure_ascii=False)`. Refuses a fraction, an unsafe integer and non-JSON.
868
868
  */
869
+ /** CT-25 (DEC-0052): a lone surrogate in a string or a key has no canonical form — Python cannot encode it as UTF-8. */
870
+ function settingsString(text) {
871
+ if (/\p{Surrogate}/u.test(text)) throw new ServiceError('a lone surrogate has no canonical form');
872
+ return JSON.stringify(text);
873
+ }
874
+
869
875
  export function settingsCanonicalJson(value) {
870
876
  if (value === null) return 'null';
871
877
  if (typeof value === 'boolean') return value ? 'true' : 'false';
@@ -873,10 +879,10 @@ export function settingsCanonicalJson(value) {
873
879
  if (!Number.isSafeInteger(value)) throw new ServiceError(`${value} has no canonical form: a settings backup stores a fraction as its decimal string`);
874
880
  return String(value);
875
881
  }
876
- if (typeof value === 'string') return JSON.stringify(value);
882
+ if (typeof value === 'string') return settingsString(value);
877
883
  if (Array.isArray(value)) return `[${value.map(settingsCanonicalJson).join(', ')}]`;
878
884
  if (isPlainObject(value) && [Object.prototype, null].includes(Object.getPrototypeOf(value))) {
879
- return `{${Object.keys(value).sort(byCodePoint).map((key) => `${JSON.stringify(key)}: ${settingsCanonicalJson(value[key])}`).join(', ')}}`;
885
+ return `{${Object.keys(value).sort(byCodePoint).map((key) => `${settingsString(key)}: ${settingsCanonicalJson(value[key])}`).join(', ')}}`;
880
886
  }
881
887
  throw new ServiceError(`${typeof value} is not a JSON value`);
882
888
  }