@passioncode-ai/fabric-agent-adapter 0.20.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +125 -0
- package/README.md +40 -8
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +2 -2
- package/plugins/fabric-agent-adapter/hooks/hooks.json +43 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +5 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +14 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +90 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +26 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +443 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +8 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +32 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +826 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +7 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +217 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +99 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +111 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1265 -0
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs
ADDED
|
@@ -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
|
+
}
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs
CHANGED
|
@@ -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
|
|
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) => `${
|
|
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
|
}
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py
CHANGED
|
@@ -432,11 +432,30 @@ def _approval_shape(payload: Dict[str, Any]) -> bool:
|
|
|
432
432
|
return payload.get("method") in ("prompt", "operator-channel") # DEC-0045: `device` is removed
|
|
433
433
|
|
|
434
434
|
|
|
435
|
+
def _estate_problem(estate: Any, jwks: Dict[str, Any], kid: str, now: float) -> Optional[Tuple[str, str]]:
|
|
436
|
+
"""DL-14 with DEC-0055: a verifier that reads the estate record trusts a kid only while a current revision certifies
|
|
437
|
+
it for a machine and does not revoke it. `estate` is a revision the caller verified (fabric_estate.
|
|
438
|
+
verify_estate_record) or a callable returning the current one; None skips the rule. (refusal kind, detail) or None."""
|
|
439
|
+
if estate is None:
|
|
440
|
+
return None
|
|
441
|
+
try:
|
|
442
|
+
record = estate() if callable(estate) else estate
|
|
443
|
+
except Exception: # an unreadable record certifies nothing; it is never a pass
|
|
444
|
+
return ("issuer", "estate-unreadable")
|
|
445
|
+
import fabric_estate # beside this file; imported here, since fabric_estate imports this module
|
|
446
|
+
jwk = next((k for k in jwks["keys"] if isinstance(k, dict) and k.get("kid") == kid), {})
|
|
447
|
+
if not fabric_estate.hub_key_certified(record, kid, jwk.get("x"), now):
|
|
448
|
+
return ("token", "kid-not-certified")
|
|
449
|
+
return None
|
|
450
|
+
|
|
451
|
+
|
|
435
452
|
def verify_delegation(token: Any, *, audience: str, jwks: Any, now: float, capability: str, effect: str,
|
|
436
|
-
issuer: Optional[str], read_only: bool = False) -> Dict[str, Any]:
|
|
453
|
+
issuer: Optional[str], read_only: bool = False, estate: Any = None) -> Dict[str, Any]:
|
|
437
454
|
"""DL-13, in its fixed order: nothing about a token is believed before its signature is.
|
|
438
455
|
`now` is seconds since the epoch. Returns {ok: True, claims, principal, caller, chain, kid} or a refusal
|
|
439
|
-
{ok: False, code, detail[, data]} whose detail is a short machine word, never the token. `issuer` is required.
|
|
456
|
+
{ok: False, code, detail[, data]} whose detail is a short machine word, never the token. `issuer` is required.
|
|
457
|
+
With `estate` (a verified estate revision, or a callable returning the current one), the token's kid must be one
|
|
458
|
+
the revision certifies for a machine (DL-14, DEC-0055)."""
|
|
440
459
|
if not _is_string(issuer):
|
|
441
460
|
return _refuse("issuer-unavailable", "no-trusted-issuer")
|
|
442
461
|
if not _has_keys(jwks):
|
|
@@ -444,6 +463,9 @@ def verify_delegation(token: Any, *, audience: str, jwks: Any, now: float, capab
|
|
|
444
463
|
checked = _authentic(token, jwks, DELEGATION_TYP)
|
|
445
464
|
if "problem" in checked:
|
|
446
465
|
return _refuse("issuer-unavailable" if checked.get("issuer") else "unauthenticated", checked["problem"])
|
|
466
|
+
uncertified = _estate_problem(estate, jwks, checked["kid"], now)
|
|
467
|
+
if uncertified:
|
|
468
|
+
return _refuse("issuer-unavailable" if uncertified[0] == "issuer" else "unauthenticated", uncertified[1])
|
|
447
469
|
claims = checked["compact"]["payload"]
|
|
448
470
|
problem, depth = _delegation_shape(claims)
|
|
449
471
|
if problem:
|
|
@@ -543,7 +565,7 @@ UNSAFE_NO_JTI_STORE = _NoReplayProtection()
|
|
|
543
565
|
|
|
544
566
|
|
|
545
567
|
def verify_approval(approval: Optional[str], *, audience: str, jwks: Any, now: float, action_digest: str,
|
|
546
|
-
jti_store: Any, issuer: Optional[str]) -> Dict[str, Any]:
|
|
568
|
+
jti_store: Any, issuer: Optional[str], estate: Any = None) -> Dict[str, Any]:
|
|
547
569
|
"""DL-18: a missing approval is approval-required carrying the action digest; any fault of the document is
|
|
548
570
|
approval-invalid; a second use is approval-reused. The jti is consumed last."""
|
|
549
571
|
if approval is None or approval == "":
|
|
@@ -557,6 +579,9 @@ def verify_approval(approval: Optional[str], *, audience: str, jwks: Any, now: f
|
|
|
557
579
|
checked = _authentic(approval, jwks, APPROVAL_TYP)
|
|
558
580
|
if "problem" in checked:
|
|
559
581
|
return _refuse("issuer-unavailable" if checked.get("issuer") else "approval-invalid", checked["problem"])
|
|
582
|
+
uncertified = _estate_problem(estate, jwks, checked["kid"], now)
|
|
583
|
+
if uncertified:
|
|
584
|
+
return _refuse("issuer-unavailable" if uncertified[0] == "issuer" else "approval-invalid", uncertified[1])
|
|
560
585
|
claims = checked["compact"]["payload"]
|
|
561
586
|
if not _approval_shape(claims):
|
|
562
587
|
return _refuse("approval-invalid", "malformed-claims")
|
|
@@ -795,7 +820,7 @@ TIERS = ("T0", "T1", "T2")
|
|
|
795
820
|
|
|
796
821
|
|
|
797
822
|
def delegation_gate(*, audience: str, jwks: Any, effects: Mapping[str, str], issuer: str,
|
|
798
|
-
read_only: bool = False, clock: Callable[[], float] = time.time) -> Callable[..., Dict[str, Any]]:
|
|
823
|
+
read_only: bool = False, clock: Callable[[], float] = time.time, estate: Any = None) -> Callable[..., Dict[str, Any]]:
|
|
799
824
|
"""A check for one tools/call: `gate(tool, headers, tier="T2")` answers the accepted delegation or a DL-19
|
|
800
825
|
refusal. `effects` maps each tool to its declared effect class (its manifest entry); a tool with none is
|
|
801
826
|
refused rather than guessed. `jwks` is a JwksCache or a key set; `issuer` (auth.delegation.issuer) is required.
|
|
@@ -804,7 +829,8 @@ def delegation_gate(*, audience: str, jwks: Any, effects: Mapping[str, str], iss
|
|
|
804
829
|
own local floor has authenticated; T1 an enrolled machine of the same owner; T2 everything else, and any call
|
|
805
830
|
relayed from off the machine. A T0 or T1 caller is never required to present a token: with none, the call
|
|
806
831
|
passes with no delegation ({ok: True, tier, principal: None, ...}). A present token is verified at every tier
|
|
807
|
-
and refused when invalid. Unknown tiers count as T2.
|
|
832
|
+
and refused when invalid. Unknown tiers count as T2. `estate` (a callable returning the current verified estate
|
|
833
|
+
revision) makes the gate trust only kids that revision certifies (DL-14, DEC-0055)."""
|
|
808
834
|
if not _is_string(issuer):
|
|
809
835
|
raise ValueError("the gate needs the issuer it trusts (auth.delegation.issuer)")
|
|
810
836
|
|
|
@@ -819,7 +845,7 @@ def delegation_gate(*, audience: str, jwks: Any, effects: Mapping[str, str], iss
|
|
|
819
845
|
return _refuse("unauthenticated", "no-token")
|
|
820
846
|
keys = jwks.get(_kid_of(carried["token"])) if isinstance(jwks, JwksCache) else jwks
|
|
821
847
|
verdict = verify_delegation(carried["token"], audience=audience, jwks=keys, now=clock(), capability=tool,
|
|
822
|
-
effect=effect, issuer=issuer, read_only=read_only)
|
|
848
|
+
effect=effect, issuer=issuer, read_only=read_only, estate=estate)
|
|
823
849
|
if verdict["ok"]:
|
|
824
850
|
verdict["tier"] = tier if tier in TIERS else "T2"
|
|
825
851
|
return verdict
|