@felspar/sdk 0.0.0-stage → 0.1.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.
@@ -0,0 +1,583 @@
1
+ import {
2
+ createHostedFelsparClient
3
+ } from "./chunk-C2VMKF3S.js";
4
+
5
+ // src/draft-action-gate-outputs.ts
6
+ var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
7
+ function parsedJson(text2) {
8
+ try {
9
+ return JSON.parse(text2);
10
+ } catch {
11
+ return null;
12
+ }
13
+ }
14
+ function replyOf(output) {
15
+ let value = output;
16
+ for (let depth = 0; depth < 5; depth += 1) {
17
+ if (typeof value === "string") {
18
+ value = parsedJson(value);
19
+ } else if (Array.isArray(value)) {
20
+ value = value.find((part) => isRecord(part) && part.type === "text");
21
+ } else if (!isRecord(value) || value.isError === true) {
22
+ return null;
23
+ } else if (typeof value.checkId === "string") {
24
+ return value;
25
+ } else if (isRecord(value.structuredContent)) {
26
+ value = value.structuredContent;
27
+ } else {
28
+ value = Array.isArray(value.content) ? value.content : value.text;
29
+ }
30
+ }
31
+ return null;
32
+ }
33
+ var text = (value) => typeof value === "string" ? value : null;
34
+ function outputCheckFromResult(jobId, output) {
35
+ const reply = replyOf(output);
36
+ const receipt = isRecord(reply?.receipt) ? reply.receipt : null;
37
+ const decision = isRecord(reply?.decision) ? reply.decision : null;
38
+ const fields = {
39
+ checkId: text(reply?.checkId),
40
+ output: text(reply?.output),
41
+ decisionReceiptDigest: text(decision?.receiptDigest),
42
+ artifactDigest: text(receipt?.artifactDigest),
43
+ receiptDigest: text(receipt?.receiptDigest)
44
+ };
45
+ if (reply?.status !== "pass" || Object.values(fields).includes(null)) {
46
+ return null;
47
+ }
48
+ return {
49
+ checkId: fields.checkId ?? "",
50
+ jobId,
51
+ output: fields.output ?? "",
52
+ status: "pass",
53
+ decisionReceiptDigest: fields.decisionReceiptDigest ?? "",
54
+ artifactDigest: fields.artifactDigest ?? "",
55
+ receiptDigest: fields.receiptDigest ?? ""
56
+ };
57
+ }
58
+ async function outputTextDigest(value) {
59
+ const encoder = new TextEncoder();
60
+ const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(value)));
61
+ return `sha256:${Array.from(hash, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
62
+ }
63
+ function checkedText(rule, input) {
64
+ const wanted = new Set(rule.fields.map((field) => field.toLowerCase()));
65
+ const values = Object.entries(input).filter(([field]) => wanted.has(field.toLowerCase())).map(([, value]) => value);
66
+ const [only] = values;
67
+ return typeof only === "string" && values.length === 1 ? only : null;
68
+ }
69
+ async function outputCheckProblem(rule, call) {
70
+ const value = checkedText(rule, call.input);
71
+ if (value === null) {
72
+ return `the call must set exactly one text field of ${rule.fields.join(", ")}`;
73
+ }
74
+ const digest = await outputTextDigest(value);
75
+ let check;
76
+ for (const row of call.checks) {
77
+ const isOutput = rule.output === void 0 || row.output === rule.output;
78
+ const isText = row.artifactDigest === digest;
79
+ const isDecision = row.decisionReceiptDigest === call.decisionReceiptDigest;
80
+ const isPass = row.status === "pass";
81
+ if (isPass && isText && isDecision && isOutput) {
82
+ check = row;
83
+ }
84
+ }
85
+ if (check === void 0) {
86
+ return "Felspar has not passed this exact text for this decision; call check_output first";
87
+ }
88
+ if (call.verify === void 0) {
89
+ return "no output-check verifier is configured";
90
+ }
91
+ const confirmed = await call.verify(check);
92
+ const isConfirmed = confirmed?.status === "pass" && confirmed.receiptDigest === check.receiptDigest && confirmed.artifactDigest === check.artifactDigest && confirmed.decisionReceiptDigest === check.decisionReceiptDigest;
93
+ return isConfirmed ? null : "Felspar did not confirm the output check";
94
+ }
95
+ function felsparOutputCheckVerifier(configuration) {
96
+ let client;
97
+ return async (check) => {
98
+ client ??= createHostedFelsparClient(configuration);
99
+ const kept = await client.readOutputCheck(check.jobId, check.checkId);
100
+ return {
101
+ status: kept.status,
102
+ receiptDigest: kept.receipt?.receiptDigest ?? null,
103
+ artifactDigest: kept.receipt?.artifactDigest ?? null,
104
+ decisionReceiptDigest: kept.decision.receiptDigest
105
+ };
106
+ };
107
+ }
108
+
109
+ // src/draft-action-gate-decisions.ts
110
+ var isRecord2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
111
+ function parsedJson2(text2) {
112
+ try {
113
+ return JSON.parse(text2);
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
118
+ function innerValue(value) {
119
+ if (typeof value === "string") {
120
+ return parsedJson2(value);
121
+ }
122
+ if (Array.isArray(value)) {
123
+ return value.find((part) => isRecord2(part) && part.type === "text") ?? null;
124
+ }
125
+ if (!isRecord2(value)) {
126
+ return void 0;
127
+ }
128
+ if (value.isError === true) {
129
+ return null;
130
+ }
131
+ if (isRecord2(value.structuredContent)) {
132
+ return value.structuredContent;
133
+ }
134
+ if (Array.isArray(value.content)) {
135
+ return value.content;
136
+ }
137
+ return value.type === "text" && typeof value.text === "string" ? value.text : void 0;
138
+ }
139
+ function jobResponse(output) {
140
+ let value = output;
141
+ for (let depth = 0; depth < 5; depth += 1) {
142
+ const inner = innerValue(value);
143
+ if (inner === void 0) {
144
+ return isRecord2(value) && isRecord2(value.result) ? value : null;
145
+ }
146
+ value = inner;
147
+ }
148
+ return null;
149
+ }
150
+ function jobOf(input) {
151
+ return isRecord2(input) && typeof input.job === "string" ? input.job : void 0;
152
+ }
153
+ function entityIdOf(input) {
154
+ return isRecord2(input) && typeof input.entity_id === "string" ? input.entity_id : void 0;
155
+ }
156
+ function freshnessOf(explanation) {
157
+ return isRecord2(explanation) && typeof explanation.evidenceFreshUntil === "string" ? explanation.evidenceFreshUntil : null;
158
+ }
159
+ function decisionFromResult(entity, output) {
160
+ const result = jobResponse(output)?.result;
161
+ if (!isRecord2(result) || entity.length === 0) {
162
+ return null;
163
+ }
164
+ const { receipt } = result;
165
+ if (typeof result.outcome !== "string" || typeof result.executionObjectId !== "string" || !isRecord2(receipt) || typeof receipt.receiptDigest !== "string") {
166
+ return null;
167
+ }
168
+ return {
169
+ entity,
170
+ outcome: result.outcome,
171
+ executionObjectId: result.executionObjectId,
172
+ receipt: { ...receipt, receiptDigest: receipt.receiptDigest },
173
+ context: result.context ?? null,
174
+ evidenceFreshUntil: freshnessOf(result.explanation)
175
+ };
176
+ }
177
+ function trim(map, limit) {
178
+ for (const key of map.keys()) {
179
+ if (map.size <= limit) {
180
+ return;
181
+ }
182
+ map.delete(key);
183
+ }
184
+ }
185
+ function createDecisionRecorder(limits = {}) {
186
+ const maxSessions = limits.sessions ?? 1e3;
187
+ const maxDecisions = limits.decisionsPerSession ?? 200;
188
+ const maxPending = limits.pendingRequests ?? 1e3;
189
+ const requested = /* @__PURE__ */ new Map();
190
+ const decided = /* @__PURE__ */ new Map();
191
+ const checked = /* @__PURE__ */ new Map();
192
+ function record(sessionId, entity, output) {
193
+ const decision = decisionFromResult(entity, output);
194
+ if (decision === null) {
195
+ return null;
196
+ }
197
+ const rows = [...decided.get(sessionId) ?? [], decision].slice(-maxDecisions);
198
+ decided.delete(sessionId);
199
+ decided.set(sessionId, rows);
200
+ trim(decided, maxSessions);
201
+ return decision;
202
+ }
203
+ return {
204
+ record,
205
+ noteRequest(callId, entity) {
206
+ requested.set(callId, entity);
207
+ trim(requested, maxPending);
208
+ },
209
+ noteResult(sessionId, callId, output) {
210
+ const entity = requested.get(callId);
211
+ requested.delete(callId);
212
+ return entity === void 0 ? null : record(sessionId, entity, output);
213
+ },
214
+ decisionsIn: (sessionId) => decided.get(sessionId) ?? [],
215
+ recordOutputCheck(sessionId, jobId, output) {
216
+ const check = outputCheckFromResult(jobId, output);
217
+ if (check === null) {
218
+ return null;
219
+ }
220
+ const rows = [...checked.get(sessionId) ?? [], check].slice(-maxDecisions);
221
+ checked.delete(sessionId);
222
+ checked.set(sessionId, rows);
223
+ trim(checked, maxSessions);
224
+ return check;
225
+ },
226
+ outputChecksIn: (sessionId) => checked.get(sessionId) ?? [],
227
+ forget(sessionId) {
228
+ decided.delete(sessionId);
229
+ checked.delete(sessionId);
230
+ }
231
+ };
232
+ }
233
+
234
+ // src/draft-action-gate-domains.ts
235
+ var publicSuffixes = [
236
+ "ac.uk",
237
+ "co.uk",
238
+ "gov.uk",
239
+ "ltd.uk",
240
+ "me.uk",
241
+ "net.uk",
242
+ "org.uk",
243
+ "plc.uk",
244
+ "sch.uk",
245
+ "com.au",
246
+ "edu.au",
247
+ "gov.au",
248
+ "net.au",
249
+ "org.au",
250
+ "co.nz",
251
+ "net.nz",
252
+ "org.nz",
253
+ "ac.jp",
254
+ "co.jp",
255
+ "ne.jp",
256
+ "or.jp",
257
+ "co.kr",
258
+ "or.kr",
259
+ "co.in",
260
+ "net.in",
261
+ "org.in",
262
+ "co.za",
263
+ "org.za",
264
+ "co.il",
265
+ "co.id",
266
+ "co.ke",
267
+ "co.th",
268
+ "com.ar",
269
+ "com.br",
270
+ "com.cn",
271
+ "com.co",
272
+ "com.eg",
273
+ "com.hk",
274
+ "com.mx",
275
+ "com.my",
276
+ "com.ng",
277
+ "com.ph",
278
+ "com.pk",
279
+ "com.sa",
280
+ "com.sg",
281
+ "com.tr",
282
+ "com.tw",
283
+ "com.ua",
284
+ "com.vn",
285
+ "net.cn",
286
+ "org.cn",
287
+ "gov.cn",
288
+ "edu.cn",
289
+ "appspot.com",
290
+ "azurewebsites.net",
291
+ "blogspot.com",
292
+ "cloudfront.net",
293
+ "firebaseapp.com",
294
+ "github.io",
295
+ "herokuapp.com",
296
+ "netlify.app",
297
+ "pages.dev",
298
+ "vercel.app",
299
+ "web.app",
300
+ "workers.dev"
301
+ ];
302
+ var freeMail = [
303
+ "126.com",
304
+ "163.com",
305
+ "aol.com",
306
+ "fastmail.com",
307
+ "gmail.com",
308
+ "googlemail.com",
309
+ "gmx.com",
310
+ "gmx.de",
311
+ "gmx.net",
312
+ "hey.com",
313
+ "hotmail.co.uk",
314
+ "hotmail.com",
315
+ "icloud.com",
316
+ "live.com",
317
+ "mac.com",
318
+ "mail.com",
319
+ "mail.ru",
320
+ "me.com",
321
+ "msn.com",
322
+ "outlook.com",
323
+ "pm.me",
324
+ "proton.me",
325
+ "protonmail.com",
326
+ "qq.com",
327
+ "tuta.io",
328
+ "tutanota.com",
329
+ "web.de",
330
+ "yahoo.co.uk",
331
+ "yahoo.com",
332
+ "yandex.com",
333
+ "yandex.ru",
334
+ "ymail.com",
335
+ "zoho.com"
336
+ ];
337
+ var sharedDomains = [...publicSuffixes, ...freeMail];
338
+ var builtIn = new Set(sharedDomains);
339
+ var domainShape = /^[a-z0-9-]+(?:\.[a-z0-9-]+)+$/u;
340
+ function isCompanyDomain(entity, extraShared = []) {
341
+ return domainShape.test(entity) && !builtIn.has(entity) && extraShared.every((domain) => domain.toLowerCase() !== entity);
342
+ }
343
+
344
+ // src/draft-action-gate-verifier.ts
345
+ function felsparVerifier(configuration) {
346
+ let client;
347
+ return async (decision) => {
348
+ client ??= createHostedFelsparClient(configuration);
349
+ const verification = await client.verifyReceipt(decision.executionObjectId, {
350
+ receipt: decision.receipt,
351
+ expectedReceiptDigest: decision.receipt.receiptDigest,
352
+ deliveredCapsule: decision.context ?? null
353
+ });
354
+ return verification.status === "reproduced" && verification.decided !== void 0 ? verification.decided : null;
355
+ };
356
+ }
357
+
358
+ // src/draft-action-gate.ts
359
+ var emailRecipientFields = ["to", "cc", "bcc", "replyTo", "reply_to"];
360
+ var emailSubjectFields = ["subject"];
361
+ function gatedRule(rules, toolName) {
362
+ return Object.hasOwn(rules.tools, toolName) ? rules.tools[toolName] : void 0;
363
+ }
364
+ var isInput = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
365
+ var sameField = (names) => {
366
+ const lower = new Set(names.map((name) => name.toLowerCase()));
367
+ return (field) => lower.has(field.toLowerCase());
368
+ };
369
+ function fieldProblem(rules, rule, input) {
370
+ const isAllowed = sameField(rule.fields);
371
+ const isRefused = sameField(rules.refuse ?? []);
372
+ const unknown = Object.keys(input).filter((field) => !isAllowed(field));
373
+ if (unknown.length > 0) {
374
+ return `the call sets ${unknown.join(", ")}, which this tool's rule doesn't allow`;
375
+ }
376
+ const refused = Object.keys(input).filter((field) => isRefused(field));
377
+ return refused.length > 0 ? `the call sets ${refused.join(", ")}` : null;
378
+ }
379
+ function fieldValues(input, names) {
380
+ const isWanted = sameField(names);
381
+ const values = [];
382
+ const unreadable = [];
383
+ for (const [field, value] of Object.entries(input)) {
384
+ if (!isWanted(field)) {
385
+ continue;
386
+ }
387
+ for (const item of [value].flat()) {
388
+ if (typeof item === "string") {
389
+ values.push(item.trim());
390
+ } else if (item !== void 0 && item !== null) {
391
+ unreadable.push(field);
392
+ }
393
+ }
394
+ }
395
+ return { values, unreadable };
396
+ }
397
+ var isAscii = (text2) => /^[!-~]+$/u.test(text2);
398
+ var plainAddress = /^[a-z0-9._+-]+@([a-z0-9-]+(?:\.[a-z0-9-]+)+)$/u;
399
+ var isUnder = (domain, entity) => domain === entity || domain.endsWith(`.${entity}`);
400
+ function domainsIn(text2, entities) {
401
+ return entities.filter((entity) => {
402
+ const escaped = entity.replaceAll(".", String.raw`\.`);
403
+ const whole = new RegExp(`(?:^|[^a-z0-9.-])${escaped}(?:$|[^a-z0-9-])`, "u");
404
+ return whole.test(text2);
405
+ });
406
+ }
407
+ function testCompany(recipients, subject, scope) {
408
+ const listed = new Set((scope.rules.testRecipients ?? []).map((address) => address.trim().toLowerCase()));
409
+ const outside = recipients.filter((address) => !listed.has(address));
410
+ if (outside.length > 0) {
411
+ return { reason: `recipient not on the test list: ${outside.join(", ")}` };
412
+ }
413
+ const named = domainsIn(subject.toLowerCase(), scope.entities);
414
+ return named.length === 1 ? { company: named[0] } : { reason: `the subject names ${named.length} decided companies, not one` };
415
+ }
416
+ function recipientCompany(recipients, entities) {
417
+ const companies = /* @__PURE__ */ new Set();
418
+ for (const address of recipients) {
419
+ const domain = plainAddress.exec(address)?.[1] ?? "";
420
+ const owners = entities.filter((entity) => isUnder(domain, entity));
421
+ if (owners.length === 0) {
422
+ return { reason: `${address} is not at a company Felspar decided on` };
423
+ }
424
+ for (const owner of owners) {
425
+ companies.add(owner);
426
+ }
427
+ }
428
+ return companies.size === 1 ? { company: [...companies][0] } : { reason: "the recipients belong to more than one company" };
429
+ }
430
+ function companyFor(scope, input) {
431
+ const { rules, rule } = scope;
432
+ const recipientFields = rule.recipientFields ?? rules.recipientFields ?? emailRecipientFields;
433
+ const recipients = fieldValues(input, recipientFields);
434
+ if (recipients.unreadable.length > 0) {
435
+ return { reason: `${recipients.unreadable.join(", ")} holds a value that is not an address` };
436
+ }
437
+ if (recipients.values.length === 0) {
438
+ return { reason: "the call has no recipients" };
439
+ }
440
+ const malformed = recipients.values.find((address) => !isAscii(address) || !plainAddress.test(address.toLowerCase()));
441
+ if (malformed !== void 0) {
442
+ return { reason: `"${malformed}" is not one plain address` };
443
+ }
444
+ const addresses = recipients.values.map((address) => address.toLowerCase());
445
+ if (rules.testRecipients === void 0) {
446
+ return recipientCompany(addresses, scope.entities);
447
+ }
448
+ const subjectFields = rule.subjectFields ?? rules.subjectFields ?? emailSubjectFields;
449
+ const subject = fieldValues(input, subjectFields).values.join("\n");
450
+ return testCompany(addresses, subject, scope);
451
+ }
452
+ function replayProblem(rules, company, verified) {
453
+ if (verified.jobId !== rules.job) {
454
+ return `the receipt is for job ${verified.jobId}, not ${rules.job}`;
455
+ }
456
+ if (verified.entityId.toLowerCase() !== company) {
457
+ return `the receipt is for ${verified.entityId}, not ${company}`;
458
+ }
459
+ const { releases } = rules;
460
+ const isUnpinned = releases !== void 0 && !releases.includes(verified.releaseId ?? "");
461
+ return isUnpinned ? `the receipt is for release ${verified.releaseId ?? "(none)"}, which is not allowed` : null;
462
+ }
463
+ function staleness(decision, now) {
464
+ const until = decision.evidenceFreshUntil;
465
+ if (typeof until !== "string") {
466
+ return null;
467
+ }
468
+ return Date.parse(until) > now ? null : `the decision's evidence went stale at ${until}`;
469
+ }
470
+ async function verifiedFor(scope, found, request) {
471
+ const { rules, rule } = scope;
472
+ const { company, decision } = found;
473
+ const stale = staleness(decision, (request.now ?? Date.now)());
474
+ if (stale !== null) {
475
+ return { allow: false, reason: stale };
476
+ }
477
+ const verified = await request.verify(decision);
478
+ if (verified === null) {
479
+ return { allow: false, reason: `Felspar did not reproduce ${company}'s receipt` };
480
+ }
481
+ const problem = replayProblem(rules, company, verified);
482
+ if (problem !== null) {
483
+ return { allow: false, reason: problem };
484
+ }
485
+ if (verified.outcome === null || !rule.outcomes.includes(verified.outcome)) {
486
+ const decided = verified.outcome ?? "undecided";
487
+ return { allow: false, reason: `${verified.entityId} is ${decided}, which doesn't allow this` };
488
+ }
489
+ if (rule.outputCheck !== void 0) {
490
+ const checked = await outputCheckProblem(rule.outputCheck, {
491
+ input: found.input,
492
+ decisionReceiptDigest: decision.receipt.receiptDigest,
493
+ checks: request.outputChecks ?? [],
494
+ verify: request.verifyOutputCheck
495
+ });
496
+ if (checked !== null) {
497
+ return { allow: false, reason: checked };
498
+ }
499
+ }
500
+ return {
501
+ allow: true,
502
+ reason: `${company} is ${verified.outcome}; Felspar reproduced the receipt`,
503
+ decision: { ...decision, outcome: verified.outcome }
504
+ };
505
+ }
506
+ async function decide(request) {
507
+ const { rules, toolName, decisions } = request;
508
+ const rule = gatedRule(rules, toolName);
509
+ if (rule === void 0) {
510
+ return { allow: false, reason: "not a gated tool" };
511
+ }
512
+ const input = isInput(request.input) ? request.input : {};
513
+ const problem = fieldProblem(rules, rule, input);
514
+ if (problem !== null) {
515
+ return { allow: false, reason: problem };
516
+ }
517
+ const entities = [...new Set(decisions.map((row) => row.entity.toLowerCase()))].filter((entity) => isCompanyDomain(entity, rules.sharedDomains));
518
+ const scope = { rules, rule, entities };
519
+ const { company, reason } = companyFor(scope, input);
520
+ if (company === void 0) {
521
+ return { allow: false, reason: reason ?? "no company" };
522
+ }
523
+ const decision = decisions.filter((row) => row.entity.toLowerCase() === company).at(-1);
524
+ return decision === void 0 ? { allow: false, reason: `no decision for ${company}` } : await verifiedFor(scope, { company, decision, input }, request);
525
+ }
526
+ async function checkAction(request) {
527
+ try {
528
+ return await decide(request);
529
+ } catch (error) {
530
+ return { allow: false, reason: `gate error: ${String(error)}` };
531
+ }
532
+ }
533
+ function report(onVerdict, toolName, verdict) {
534
+ if (onVerdict === void 0) {
535
+ return;
536
+ }
537
+ try {
538
+ onVerdict(toolName, verdict);
539
+ } catch {
540
+ }
541
+ }
542
+ function createActionGate(config) {
543
+ const { verify, decisions = createDecisionRecorder(), decisionTool, outputCheckTool, verifyOutputCheck, now, onVerdict, ...rules } = config;
544
+ return {
545
+ rules,
546
+ decisions,
547
+ isGated: (toolName) => gatedRule(rules, toolName) !== void 0,
548
+ isDecisionTool: (toolName) => toolName === decisionTool,
549
+ isOutputCheckTool: (toolName) => outputCheckTool !== void 0 && toolName === outputCheckTool,
550
+ async check(sessionId, toolName, input) {
551
+ const verdict = await checkAction({
552
+ rules,
553
+ toolName,
554
+ input,
555
+ decisions: decisions.decisionsIn(sessionId),
556
+ verify,
557
+ outputChecks: decisions.outputChecksIn(sessionId),
558
+ ...verifyOutputCheck !== void 0 && { verifyOutputCheck },
559
+ ...now !== void 0 && { now }
560
+ });
561
+ report(onVerdict, toolName, verdict);
562
+ return verdict;
563
+ }
564
+ };
565
+ }
566
+
567
+ export {
568
+ outputCheckFromResult,
569
+ outputTextDigest,
570
+ felsparOutputCheckVerifier,
571
+ jobOf,
572
+ entityIdOf,
573
+ decisionFromResult,
574
+ createDecisionRecorder,
575
+ sharedDomains,
576
+ isCompanyDomain,
577
+ felsparVerifier,
578
+ emailRecipientFields,
579
+ emailSubjectFields,
580
+ gatedRule,
581
+ checkAction,
582
+ createActionGate
583
+ };