@passioncode-ai/fabric-agent-adapter 0.22.0 → 0.24.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 (23) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/README.md +1 -1
  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/dashboard-kit.md +7 -4
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +10 -7
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +32 -15
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +64 -14
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +9 -5
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +24 -4
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +35 -20
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +77 -9
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +317 -45
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +53 -19
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +107 -16
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +472 -62
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +10 -3
  21. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
  22. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  23. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1 -1
@@ -139,7 +139,7 @@ function keyFor(jwks, kid) {
139
139
  if (raw.length !== 32) return { problem: 'unusable-key' };
140
140
  if (isSmallOrderKey(raw)) return { problem: 'small-order-key' }; // before any verification (DEC-0045, CT-29)
141
141
  try {
142
- return { key: crypto.createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: jwk.x }, format: 'jwk' }), raw };
142
+ return { key: crypto.createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: jwk.x }, format: 'jwk' }), raw, x: jwk.x };
143
143
  } catch {
144
144
  return { problem: 'unusable-key' };
145
145
  }
@@ -175,7 +175,7 @@ function authentic(token, jwks, typ) {
175
175
  // Only an unknown kid is the token's fault; a published key that cannot be used is the issuer's (DL-13 row 3).
176
176
  if ('problem' in found) return found.problem === 'unknown-key' ? { problem: found.problem } : { problem: found.problem, issuer: true };
177
177
  if (compact.signature.length !== 64 || isSmallOrderKey(found.raw) || !crypto.verify(null, compact.signingInput, found.key, compact.signature)) return { problem: 'bad-signature' };
178
- return { compact, kid };
178
+ return { compact, kid, x: found.x };
179
179
  }
180
180
 
181
181
  /** Depth of an actor chain; Infinity for a chain that is not one (a missing or non-string sub at any level). */
@@ -249,33 +249,47 @@ function approvalShape(payload) {
249
249
  return payload;
250
250
  }
251
251
 
252
+ /** ECMAScript's time range in seconds (±8.64e15 ms): a NumericDate outside it is no instant a Date can hold. */
253
+ const MAX_CLOCK_SECONDS = 8.64e12;
254
+ /** A clock the time rows can compare (DEC-0060): a finite number of seconds within a Date's range. NaN or a string would pass every row. */
255
+ const readableClock = (now) => typeof now === 'number' && Number.isFinite(now) && Math.abs(now) <= MAX_CLOCK_SECONDS;
256
+
252
257
  /**
253
- * DL-13, in its fixed order: nothing about a token is believed before its signature is. `now` is seconds since
254
- * the epoch. Answers {ok: true, claims, principal, caller, chain, kid} or {ok: false, code, detail}.
255
- */
256
- /**
257
- * DL-14 with DEC-0055: a verifier that reads the estate record trusts a kid only while a current revision certifies it
258
- * for a machine and does not revoke it. `estate` is a revision the caller verified (fabric-estate.mjs
259
- * verifyEstateRecord) or a function returning the current one; undefined skips the rule. `[kind, detail]` or null.
258
+ * DL-14 row 4a (DEC-0055, DEC-0060), right after the signature: when the callee reads the estate record, the token's
259
+ * `kid` and the published key must be certified by that revision. `estate` is a revision the caller verified
260
+ * (fabric-estate.mjs verifyEstateRecord) and its machine applied, or a function returning it; only an absent `estate`
261
+ * skips the rule (null is an unreadable source). `estateApplied` is the highest revision the machine applied,
262
+ * `estateRevocations` the revocation delta's entries since (an array or a function). A source that throws is
263
+ * unreadable, never a pass. `[kind, detail]` or null.
260
264
  */
261
- function estateProblem(estate, jwks, kid, now) {
262
- if (estate === undefined || estate === null) return null;
265
+ function estateProblem({ estate, estateApplied, estateRevocations }, kid, x, now) {
266
+ if (estate === undefined) return null;
263
267
  let record;
268
+ let extra;
264
269
  try {
265
270
  record = typeof estate === 'function' ? estate() : estate;
271
+ extra = typeof estateRevocations === 'function' ? estateRevocations() : estateRevocations;
266
272
  } catch {
267
- return ['issuer', 'estate-unreadable']; // an unreadable record certifies nothing; it is never a pass
273
+ return ['issuer', 'estate-unreadable'];
268
274
  }
269
- const jwk = jwks.keys.find((candidate) => isObject(candidate) && candidate.kid === kid) ?? {};
270
- return hubKeyCertified(record, kid, jwk.x, now) ? null : ['token', 'kid-not-certified'];
275
+ const verdict = hubKeyCertified(record, kid, x, now, estateApplied, extra === undefined ? undefined : { extraRevocations: extra });
276
+ // The clock was refused before row 1, so a verdict here is the token's or the trust source's.
277
+ return verdict.certified ? null : [verdict.fault === 'token' ? 'token' : 'issuer', verdict.detail];
271
278
  }
272
279
 
273
- export function verifyDelegation(token, { audience, jwks, now, capability, effect, issuer, readOnly, estate } = {}) {
280
+ /**
281
+ * DL-13, in its fixed order: nothing about a token is believed before its signature is. `now` is seconds since
282
+ * the epoch; a clock that is not one is refused `unknown-state` `no-clock` before row 1. Answers {ok: true, claims,
283
+ * principal, caller, chain, kid} or {ok: false, code, detail}.
284
+ */
285
+ export function verifyDelegation(token, { audience, jwks, now, capability, effect, issuer, readOnly, estate, estateApplied, estateRevocations } = {}) {
286
+ // Before row 1: every time row compares against the clock, and a comparison with NaN is always false.
287
+ if (!readableClock(now)) return refuse('unknown-state', 'no-clock');
274
288
  if (!isString(issuer)) return refuse('issuer-unavailable', 'no-trusted-issuer');
275
289
  if (!hasKeys(jwks)) return refuse('issuer-unavailable', 'no-published-keys');
276
290
  const checked = authentic(token, jwks, DELEGATION_TYP);
277
291
  if ('problem' in checked) return refuse(checked.issuer ? 'issuer-unavailable' : 'unauthenticated', checked.problem);
278
- const uncertified = estateProblem(estate, jwks, checked.kid, now);
292
+ const uncertified = estateProblem({ estate, estateApplied, estateRevocations }, checked.kid, checked.x, now);
279
293
  if (uncertified) return refuse(uncertified[0] === 'issuer' ? 'issuer-unavailable' : 'unauthenticated', uncertified[1]);
280
294
  const shaped = delegationShape(checked.compact.payload);
281
295
  if ('problem' in shaped) return refuse('unauthenticated', shaped.problem);
@@ -295,14 +309,15 @@ export function verifyDelegation(token, { audience, jwks, now, capability, effec
295
309
  }
296
310
 
297
311
  /** DL-18: approval-required carries the digest; any fault is approval-invalid; the jti is consumed last. */
298
- export function verifyApproval(approval, { audience, jwks, now, actionDigest: expected, issuer, jtiStore, estate } = {}) {
312
+ export function verifyApproval(approval, { audience, jwks, now, actionDigest: expected, issuer, jtiStore, estate, estateApplied, estateRevocations } = {}) {
299
313
  if (approval === undefined || approval === null || approval === '') return refuse('approval-required', 'no-approval', { actionDigest: expected });
300
314
  if (!isObject(jtiStore) || typeof jtiStore.consume !== 'function') return refuse('unknown-state', 'no-jti-store');
315
+ if (!readableClock(now)) return refuse('unknown-state', 'no-clock');
301
316
  if (!isString(issuer)) return refuse('issuer-unavailable', 'no-trusted-issuer');
302
317
  if (!hasKeys(jwks)) return refuse('issuer-unavailable', 'no-published-keys');
303
318
  const checked = authentic(approval, jwks, APPROVAL_TYP);
304
319
  if ('problem' in checked) return refuse(checked.issuer ? 'issuer-unavailable' : 'approval-invalid', checked.problem);
305
- const uncertified = estateProblem(estate, jwks, checked.kid, now);
320
+ const uncertified = estateProblem({ estate, estateApplied, estateRevocations }, checked.kid, checked.x, now);
306
321
  if (uncertified) return refuse(uncertified[0] === 'issuer' ? 'issuer-unavailable' : 'approval-invalid', uncertified[1]);
307
322
  const claims = approvalShape(checked.compact.payload);
308
323
  if (!claims) return refuse('approval-invalid', 'malformed-claims');
@@ -469,7 +484,7 @@ export const TIERS = Object.freeze(['T0', 'T1', 'T2']);
469
484
  * relayed from off the machine). A T0 or T1 caller is never required to present a token: with none, the call passes
470
485
  * with no delegation. A present token is verified at every tier. `issuer` (auth.delegation.issuer) is required.
471
486
  */
472
- export function delegationGate({ audience, jwks, effects, issuer, readOnly = false, clock = () => Date.now() / 1000, estate }) {
487
+ export function delegationGate({ audience, jwks, effects, issuer, readOnly = false, clock = () => Date.now() / 1000, estate, estateApplied, estateRevocations }) {
473
488
  if (!isString(issuer)) throw new Error('the gate needs the issuer it trusts (auth.delegation.issuer)');
474
489
  return async (tool, headers, tier = 'T2') => {
475
490
  const effect = effects?.[tool];
@@ -480,7 +495,7 @@ export function delegationGate({ audience, jwks, effects, issuer, readOnly = fal
480
495
  return refuse('unauthenticated', 'no-token');
481
496
  }
482
497
  const keys = jwks instanceof JwksCache ? await jwks.get(kidOf(carried.token)) : jwks;
483
- const verdict = verifyDelegation(carried.token, { audience, jwks: keys, now: clock(), capability: tool, effect, readOnly, issuer, estate });
498
+ const verdict = verifyDelegation(carried.token, { audience, jwks: keys, now: clock(), capability: tool, effect, readOnly, issuer, estate, estateApplied, estateRevocations });
484
499
  if (verdict.ok) verdict.tier = TIERS.includes(tier) ? tier : 'T2';
485
500
  return verdict;
486
501
  };
@@ -17,6 +17,8 @@ import { secretValue } from './fabric-secrets.mjs';
17
17
  export const ESTATE_RECORD_SCHEMA = 'estate-record/1';
18
18
  export const ESTATE_CHALLENGE_TYPE = 'estate-revision/1';
19
19
  export const MAX_VALIDITY_SECONDS = 30 * 24 * 60 * 60;
20
+ /** How far ahead of the verifier's clock a signed `issuedAt` may be before it is `not-yet-valid` (DEC-0066). */
21
+ export const CLOCK_SKEW_SECONDS = 60;
20
22
  export const ASSERTION_ALGORITHMS = Object.freeze(['ES256', 'EdDSA']);
21
23
  export const FLAGS = Object.freeze({ UP: 0x01, UV: 0x04, BE: 0x08, BS: 0x10, AT: 0x40, ED: 0x80 });
22
24
 
@@ -153,6 +155,34 @@ export function estateTime(value) {
153
155
  if (date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day || hour > 23 || minute > 59 || second > 59) return Number.NaN;
154
156
  return Date.parse(value);
155
157
  }
158
+ const LABEL = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?';
159
+ /** `sign.` and at least two more labels, lower case: a signing RP ID is never a bare `sign.<tld>` (DEC-0063). */
160
+ const SIGNING_RP = new RegExp(`^sign(?:\\.${LABEL}){2,}$`);
161
+
162
+ /**
163
+ * Whether an RP ID is a dedicated signing RP (v4 §7, DEC-0063): first label `sign`, then the organization's or
164
+ * PassionCode's domain, lower case. A credential under any other RP ID is a sign-in credential and never signs.
165
+ */
166
+ export const isSigningRpId = (rpId) => isString(rpId) && rpId.length <= 253 && SIGNING_RP.test(rpId);
167
+
168
+ /** Whether a pinned origin may run a signing ceremony for `rpId`: https, no port, no path, the RP ID or a host under it. */
169
+ export function originWithin(origin, rpId) {
170
+ const host = isString(origin) ? /^https:\/\/([a-z0-9.-]{1,253})$/.exec(origin)?.[1] : undefined;
171
+ return host !== undefined && (host === rpId || host.endsWith(`.${rpId}`));
172
+ }
173
+
174
+ /** FAC-SEM-092 on one signing credential: a signing RP ID, and every pinned origin within it. */
175
+ function signingCredential(credential, path, flag) {
176
+ const value = isObject(credential) ? credential : {};
177
+ if (!isSigningRpId(value.rpId)) {
178
+ flag(`${path}/rpId`, 'a passkey signs only under a dedicated signing RP, sign.<domain>, never under the RP ID it signs in with');
179
+ return;
180
+ }
181
+ (Array.isArray(value.origins) ? value.origins : []).forEach((origin, index) => {
182
+ if (!originWithin(origin, value.rpId)) flag(`${path}/origins/${index}`, `a signing ceremony runs only on ${value.rpId} or a host under it, over https without a port`);
183
+ });
184
+ }
185
+
156
186
  const revocationKey = (entry) => `${String(entry.kind)} ${String(entry.ref)}`;
157
187
  const clockOf = (at) => (at instanceof Date ? at.getTime() : (typeof at === 'number' && Number.isFinite(at) ? at * 1000 : Number.NaN));
158
188
 
@@ -206,13 +236,18 @@ export function verifyEstateRecord(record, trust) {
206
236
  credentialId: String(authority.credentialId), alg: String(authority.alg), publicKey: isObject(authority.publicKey) ? authority.publicKey : {},
207
237
  rpId: String(authority.rpId), origins: Array.isArray(authority.origins) ? authority.origins.filter(isString) : [],
208
238
  };
239
+ // DEC-0063: the estate passkey is a signing credential. An RP ID that is not sign.<domain> is wrong-rp before the
240
+ // assertion is read; an origin outside the RP ID is not pinned, so a ceremony that ran there is wrong-origin.
241
+ if (!isSigningRpId(authority.rpId)) return { ok: false, problem: 'wrong-rp' };
242
+ credential.origins = credential.origins.filter((origin) => originWithin(origin, credential.rpId));
209
243
  const assertion = verifyAssertion(signature, credential, estateChallenge(digest));
210
244
  if (!assertion.ok) return { ok: false, problem: assertion.problem };
211
245
 
212
246
  const issuedAt = estateTime(record.issuedAt);
213
247
  const validUntil = estateTime(record.validUntil);
214
248
  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' };
249
+ // A revision issued a moment ahead of this clock is admitted within the skew every relay time allows (DEC-0066).
250
+ if (at < issuedAt - CLOCK_SKEW_SECONDS * 1000) return { ok: false, problem: 'not-yet-valid' };
216
251
  if (at > validUntil) return { ok: false, problem: 'expired' };
217
252
  const codes = [...new Set(recordFindings(record).map((finding) => finding.code))];
218
253
  if (codes.length > 0) return { ok: false, problem: 'rule-violation', codes };
@@ -227,19 +262,50 @@ export function placementTrust(record, machineId, agentRef) {
227
262
  return { publisher: list(record.publisherKeys).filter((key) => key.kid === placement.publisherKid) };
228
263
  }
229
264
 
265
+ const MAX_WINDOW_MS = MAX_VALIDITY_SECONDS * 1000;
266
+ const isRevision = (value) => Number.isSafeInteger(value) && value >= 1;
267
+ const refused = (fault, detail) => ({ certified: false, fault, detail });
268
+
230
269
  /**
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.
270
+ * DL-14 (DEC-0055, DEC-0060): whether `record` certifies the delegation key `kid` with the published key's `x` at `at`
271
+ * (a Date or epoch seconds), for a verifier whose machine applied `applied` (`{revision, digest?}`). `extraRevocations`
272
+ * are the revocation delta's entries applied since the revision (DEC-0054). The contract's `hubKeyCertified`, in its
273
+ * order; never throws. Answers `{certified: true, machine, revision}` or `{certified: false, fault, detail}`: fault
274
+ * `token` (`kid-not-certified`), `estate` (`estate-unreadable`, `estate-outside-window`, `estate-rolled-back`,
275
+ * `estate-forked`) or `clock` (`no-clock`).
233
276
  */
234
- export function hubKeyCertified(record, kid, x, at) {
277
+ export function hubKeyCertified(record, kid, x, at, applied, options) {
235
278
  const now = clockOf(at);
236
- if (!isObject(record) || !Number.isFinite(now)) return false;
279
+ if (Number.isNaN(now)) return refused('clock', 'no-clock');
280
+ if (!isObject(record) || !isRevision(record.revision) || !isString(record.digest) || !Array.isArray(record.machines)) return refused('estate', 'estate-unreadable');
281
+ if (record.revocations !== undefined && !Array.isArray(record.revocations)) return refused('estate', 'estate-unreadable');
282
+ const extra = options?.extraRevocations;
283
+ if (extra !== undefined && !Array.isArray(extra)) return refused('estate', 'estate-unreadable');
237
284
  const issuedAt = estateTime(record.issuedAt);
238
285
  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)}`));
286
+ // A window that is not a time, not positive or longer than 30 days is no window: NaN fails every comparison below.
287
+ if (Number.isNaN(issuedAt) || Number.isNaN(validUntil) || validUntil <= issuedAt || validUntil - issuedAt > MAX_WINDOW_MS) return refused('estate', 'estate-unreadable');
288
+ if (applied !== undefined) {
289
+ if (!isObject(applied) || !isRevision(applied.revision) || (applied.digest !== undefined && !isString(applied.digest))) return refused('estate', 'estate-unreadable');
290
+ // An older revision may still be inside its window and list a key the applied one revokes (a rollback).
291
+ if (record.revision < applied.revision) return refused('estate', 'estate-rolled-back');
292
+ if (record.revision === applied.revision && applied.digest !== undefined && record.digest !== applied.digest) return refused('estate', 'estate-forked');
293
+ }
294
+ // The window verifyEstateRecord admits the revision in: from 60 s before issuedAt to validUntil (DEC-0066).
295
+ if (now < issuedAt - CLOCK_SKEW_SECONDS * 1000 || now > validUntil) return refused('estate', 'estate-outside-window');
296
+
297
+ const revocations = [...(record.revocations ?? []), ...(extra ?? [])];
298
+ // A ref is compared exactly, never as a string conversion.
299
+ const revokes = (kind, ref) => revocations.some((entry) => isObject(entry) && entry.kind === kind && entry.ref === ref);
300
+ if (!isString(kid) || revokes('hub-key', kid)) return refused('token', 'kid-not-certified');
301
+ const holders = record.machines.filter((machine) => isObject(machine) && isObject(machine.hubKey) && machine.hubKey.kid === kid);
302
+ // Two machines under one kid break FAC-SEM-073; such a revision certifies neither.
303
+ if (holders.length !== 1) return refused('token', 'kid-not-certified');
304
+ const [machine] = holders;
305
+ // A missing x never equals a missing x: the published key must be the certified one.
306
+ if (!isString(x) || machine.hubKey.x !== x) return refused('token', 'kid-not-certified');
307
+ if (revokes('machine', machine.id)) return refused('token', 'kid-not-certified');
308
+ return { certified: true, machine: String(machine.id), revision: record.revision };
243
309
  }
244
310
 
245
311
  // ---- FAC-SEM-072…076 ----
@@ -396,6 +462,8 @@ function recordFindings(value) {
396
462
  placements(value, flagger(findings, 'FAC-SEM-074'));
397
463
  secretReferences(value, flagger(findings, 'FAC-SEM-075'));
398
464
  grantsAndRevocations(value, flagger(findings, 'FAC-SEM-076'));
465
+ // FAC-SEM-092 (DEC-0063) on each passkey the record lists.
466
+ list(value.authorities).forEach((authority, index) => signingCredential(authority, `/authorities/${index}`, flagger(findings, 'FAC-SEM-092')));
399
467
  return findings;
400
468
  }
401
469