@silmaril-security/sdk 0.1.3 → 0.3.1

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/dist/index.js CHANGED
@@ -9,6 +9,22 @@ var __export = (target, all) => {
9
9
  };
10
10
 
11
11
  // src/exceptions.ts
12
+ function parseErrorBody(body) {
13
+ try {
14
+ const parsed = JSON.parse(body);
15
+ if (!parsed || typeof parsed !== "object") {
16
+ return { details: void 0, error: void 0, apiMessage: void 0 };
17
+ }
18
+ const data = parsed;
19
+ return {
20
+ details: data.details && typeof data.details === "object" ? data.details : void 0,
21
+ error: typeof data.error === "string" ? data.error : void 0,
22
+ apiMessage: typeof data.message === "string" ? data.message : void 0
23
+ };
24
+ } catch {
25
+ return { details: void 0, error: void 0, apiMessage: void 0 };
26
+ }
27
+ }
12
28
  var MAX_PROMPT_DISPLAY_LEN, PromptBlockedException, SilmarilApiError;
13
29
  var init_exceptions = __esm({
14
30
  "src/exceptions.ts"() {
@@ -37,12 +53,20 @@ var init_exceptions = __esm({
37
53
  status;
38
54
  statusText;
39
55
  body;
56
+ details;
57
+ error;
58
+ apiMessage;
40
59
  constructor(params) {
41
- super(`Silmaril API error ${params.status} ${params.statusText}: ${params.body}`);
60
+ const statusText = params.statusText ? ` ${params.statusText}` : "";
61
+ super(`Silmaril API error ${params.status}${statusText}`);
42
62
  this.name = "SilmarilApiError";
43
63
  this.status = params.status;
44
64
  this.statusText = params.statusText;
45
65
  this.body = params.body;
66
+ const parsed = parseErrorBody(params.body);
67
+ this.details = params.details ?? parsed.details;
68
+ this.error = params.error ?? parsed.error;
69
+ this.apiMessage = params.apiMessage ?? parsed.apiMessage;
46
70
  Object.setPrototypeOf(this, _SilmarilApiError.prototype);
47
71
  }
48
72
  };
@@ -78,7 +102,7 @@ function prependToolName(text, toolName) {
78
102
  }
79
103
  return `[TOOL:${toolName}] ${text}`;
80
104
  }
81
- var HookLabel, FirewallHook, DEFAULT_HOOKS, INPUT_HOOKS, OUTPUT_HOOKS, ALL_HOOKS, FIREWALL_HOOK_TO_LABEL, DEFAULT_HOOK_THRESHOLDS, FIREWALL_HOOK_VALUES;
105
+ var HookLabel, FirewallHook, DEFAULT_HOOKS, INPUT_HOOKS, OUTPUT_HOOKS, ALL_HOOKS, FIREWALL_HOOK_TO_LABEL, FIREWALL_HOOK_VALUES;
82
106
  var init_hooks = __esm({
83
107
  "src/hooks.ts"() {
84
108
  "use strict";
@@ -127,14 +151,6 @@ var init_hooks = __esm({
127
151
  [FirewallHook.RETRIEVER_END]: HookLabel.TOOL_RESPONSE,
128
152
  [FirewallHook.LLM_END]: HookLabel.LLM_OUTPUT
129
153
  };
130
- DEFAULT_HOOK_THRESHOLDS = {
131
- [HookLabel.USER_INPUT]: 0.5,
132
- [HookLabel.SYSTEM_PROMPT]: 0.5,
133
- [HookLabel.TOOL_CALL]: 0.5,
134
- [HookLabel.TOOL_RESPONSE]: 0.5,
135
- [HookLabel.LLM_OUTPUT]: 0.5,
136
- [HookLabel.UNKNOWN]: 0.5
137
- };
138
154
  FIREWALL_HOOK_VALUES = new Set(Object.values(FirewallHook));
139
155
  }
140
156
  });
@@ -221,19 +237,8 @@ async function createLangChainHandler(firewall, options = {}) {
221
237
  const logger = options.logger ?? ((message, error) => {
222
238
  console.warn(`silmaril.firewall: ${message}`, error);
223
239
  });
224
- const overrideThreshold = options.threshold;
225
- const overrideHookThresholds = options.hookThresholds ?? {};
226
240
  const shadowMode = options.shadowMode ?? firewall.shadowMode;
227
241
  const onClassify = options.onClassify;
228
- const effectiveThreshold = (label) => {
229
- if (overrideHookThresholds[label] !== void 0) {
230
- return overrideHookThresholds[label];
231
- }
232
- if (firewall.hookThresholds[label] !== void 0) {
233
- return firewall.hookThresholds[label];
234
- }
235
- return overrideThreshold ?? firewall.threshold;
236
- };
237
242
  const fireOnClassify = (hookLabel, text, result, blocked, toolName) => {
238
243
  if (!onClassify) {
239
244
  return;
@@ -265,7 +270,7 @@ async function createLangChainHandler(firewall, options = {}) {
265
270
  logger("classification failed, allowing prompt through", err);
266
271
  return;
267
272
  }
268
- const threshold = effectiveThreshold(hookLabel);
273
+ const threshold = result.threshold;
269
274
  const blocked = result.score >= threshold;
270
275
  fireOnClassify(hookLabel, text, result, blocked, toolName);
271
276
  if (blocked && !shadowMode) {
@@ -438,18 +443,7 @@ function findLastUserMessage(messages) {
438
443
  function createMiddleware(firewall, options = {}) {
439
444
  const scanInput = options.scanInput ?? true;
440
445
  const scanOutput = options.scanOutput ?? false;
441
- const overrideThreshold = options.threshold;
442
- const overrideHookThresholds = options.hookThresholds ?? {};
443
446
  const shadowMode = options.shadowMode ?? firewall.shadowMode;
444
- const effectiveThreshold = (label) => {
445
- if (overrideHookThresholds[label] !== void 0) {
446
- return overrideHookThresholds[label];
447
- }
448
- if (firewall.hookThresholds[label] !== void 0) {
449
- return firewall.hookThresholds[label];
450
- }
451
- return overrideThreshold ?? firewall.threshold;
452
- };
453
447
  const classifyOrBlock = async (text, hook, toolName) => {
454
448
  if (!text.trim()) {
455
449
  return;
@@ -458,7 +452,7 @@ function createMiddleware(firewall, options = {}) {
458
452
  text,
459
453
  toolName !== void 0 ? { hook, toolName } : { hook }
460
454
  );
461
- const threshold = effectiveThreshold(hook);
455
+ const threshold = result.threshold;
462
456
  const blocked = result.score >= threshold;
463
457
  options.onClassify?.({
464
458
  hook,
@@ -574,14 +568,52 @@ function createMiddleware(firewall, options = {}) {
574
568
 
575
569
  // src/chunking.ts
576
570
  var CHARS_PER_TOKEN = 4;
577
- var MAX_INPUT_TOKENS = 10240;
571
+ var MAX_INPUT_TOKENS = 81920;
578
572
  var CHUNK_WINDOW = 400;
579
573
  var CHUNK_OVERLAP = 64;
580
574
  var MAX_INPUT_CHARS = MAX_INPUT_TOKENS * CHARS_PER_TOKEN;
581
575
  var CHUNK_WINDOW_CHARS = CHUNK_WINDOW * CHARS_PER_TOKEN;
582
576
  var CHUNK_OVERLAP_CHARS = CHUNK_OVERLAP * CHARS_PER_TOKEN;
583
577
  var CHUNK_STRIDE_CHARS = CHUNK_WINDOW_CHARS - CHUNK_OVERLAP_CHARS;
578
+ function isHighSurrogate(code) {
579
+ return code >= 55296 && code <= 56319;
580
+ }
581
+ function isLowSurrogate(code) {
582
+ return code >= 56320 && code <= 57343;
583
+ }
584
+ function sanitizeText(text) {
585
+ let out = "";
586
+ for (let i = 0; i < text.length; i++) {
587
+ const code = text.charCodeAt(i);
588
+ if (isHighSurrogate(code)) {
589
+ if (i + 1 < text.length && isLowSurrogate(text.charCodeAt(i + 1))) {
590
+ out += text[i];
591
+ out += text[i + 1];
592
+ i++;
593
+ }
594
+ continue;
595
+ }
596
+ if (isLowSurrogate(code)) {
597
+ continue;
598
+ }
599
+ out += text[i];
600
+ }
601
+ return out;
602
+ }
603
+ function safeChunkStart(text, start) {
604
+ if (start > 0 && start < text.length && isLowSurrogate(text.charCodeAt(start)) && isHighSurrogate(text.charCodeAt(start - 1))) {
605
+ return start + 1;
606
+ }
607
+ return start;
608
+ }
609
+ function safeChunkEnd(text, end) {
610
+ if (end > 0 && end < text.length && isHighSurrogate(text.charCodeAt(end - 1)) && isLowSurrogate(text.charCodeAt(end))) {
611
+ return end - 1;
612
+ }
613
+ return end;
614
+ }
584
615
  function chunkText(text) {
616
+ text = sanitizeText(text);
585
617
  const n = text.length;
586
618
  if (n > MAX_INPUT_CHARS) {
587
619
  throw new Error(
@@ -593,8 +625,12 @@ function chunkText(text) {
593
625
  }
594
626
  const chunks = [];
595
627
  for (let start = 0; start < n; start += CHUNK_STRIDE_CHARS) {
596
- const end = start + CHUNK_WINDOW_CHARS;
597
- chunks.push(text.slice(start, end));
628
+ const safeStart = safeChunkStart(text, start);
629
+ if (safeStart >= n) {
630
+ break;
631
+ }
632
+ const end = safeChunkEnd(text, safeStart + CHUNK_WINDOW_CHARS);
633
+ chunks.push(text.slice(safeStart, end));
598
634
  if (end >= n) {
599
635
  break;
600
636
  }
@@ -604,14 +640,33 @@ function chunkText(text) {
604
640
 
605
641
  // src/firewall.ts
606
642
  init_exceptions();
607
- var DEFAULT_THRESHOLD = 0.5;
643
+ var BASE_THRESHOLD = 0.5;
644
+ var TARGET_SEQUENCE_FPR = 0.01;
645
+ var MAX_ADAPTIVE_THRESHOLD = 0.9;
608
646
  var DEFAULT_TIMEOUT_MS = 1e4;
647
+ var DEFAULT_CHUNK_CONCURRENCY = 8;
609
648
  var DEFAULT_MAX_RETRIES = 5;
610
649
  var MAX_BACKOFF_SECONDS = 30;
611
- function blockResultFromResponse(data) {
650
+ var MAX_ERROR_BODY_BYTES = 1 << 16;
651
+ function adaptiveThreshold(scoringOpportunityCount) {
652
+ if (!Number.isInteger(scoringOpportunityCount) || scoringOpportunityCount < 1) {
653
+ throw new Error(
654
+ `Firewall: scoringOpportunityCount must be an integer >= 1, got ${scoringOpportunityCount}`
655
+ );
656
+ }
657
+ if (scoringOpportunityCount === 1) {
658
+ return BASE_THRESHOLD;
659
+ }
660
+ const targetChunkFpr = 1 - Math.pow(1 - TARGET_SEQUENCE_FPR, 1 / scoringOpportunityCount);
661
+ const oddsRatio = TARGET_SEQUENCE_FPR / targetChunkFpr;
662
+ const rawThreshold = oddsRatio / (1 + oddsRatio);
663
+ return Math.min(rawThreshold, MAX_ADAPTIVE_THRESHOLD);
664
+ }
665
+ function blockResultFromResponse(data, threshold) {
612
666
  const result = {
613
667
  prediction: data.prediction,
614
- score: Number(data.score)
668
+ score: Number(data.score),
669
+ threshold
615
670
  };
616
671
  if (data.primary_outcome !== void 0) {
617
672
  result.primaryOutcome = data.primary_outcome;
@@ -633,12 +688,73 @@ function blockResultFromResponse(data) {
633
688
  }
634
689
  return Object.freeze(result);
635
690
  }
691
+ async function readCappedErrorBody(response) {
692
+ if (!response.body) {
693
+ return response.text().then((body2) => body2.slice(0, MAX_ERROR_BODY_BYTES)).catch(() => "");
694
+ }
695
+ const reader = response.body.getReader();
696
+ const chunks = [];
697
+ let remaining = MAX_ERROR_BODY_BYTES;
698
+ try {
699
+ while (remaining > 0) {
700
+ const { done, value } = await reader.read();
701
+ if (done) {
702
+ break;
703
+ }
704
+ if (!value) {
705
+ continue;
706
+ }
707
+ const chunk = value.byteLength > remaining ? value.slice(0, remaining) : value;
708
+ chunks.push(chunk);
709
+ remaining -= chunk.byteLength;
710
+ if (chunk.byteLength < value.byteLength) {
711
+ break;
712
+ }
713
+ }
714
+ } catch {
715
+ return "";
716
+ } finally {
717
+ await reader.cancel().catch(() => void 0);
718
+ }
719
+ const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
720
+ const body = new Uint8Array(total);
721
+ let offset = 0;
722
+ for (const chunk of chunks) {
723
+ body.set(chunk, offset);
724
+ offset += chunk.byteLength;
725
+ }
726
+ return new TextDecoder().decode(body);
727
+ }
728
+ async function mapWithConcurrency(items, concurrency, task) {
729
+ const results = new Array(items.length);
730
+ let nextIndex = 0;
731
+ let firstError;
732
+ const workerCount = Math.min(concurrency, items.length);
733
+ async function worker() {
734
+ for (; ; ) {
735
+ const index = nextIndex;
736
+ nextIndex += 1;
737
+ if (index >= items.length) {
738
+ return;
739
+ }
740
+ try {
741
+ results[index] = await task(items[index], index);
742
+ } catch (err) {
743
+ firstError ??= err;
744
+ }
745
+ }
746
+ }
747
+ await Promise.all(Array.from({ length: workerCount }, () => worker()));
748
+ if (firstError !== void 0) {
749
+ throw firstError;
750
+ }
751
+ return results;
752
+ }
636
753
  var Firewall = class {
637
754
  apiKey;
638
755
  apiUrl;
639
- threshold;
640
756
  timeoutMs;
641
- hookThresholds;
757
+ chunkConcurrency;
642
758
  shadowMode;
643
759
  headers;
644
760
  constructor(options) {
@@ -650,9 +766,16 @@ var Firewall = class {
650
766
  }
651
767
  this.apiKey = options.apiKey;
652
768
  this.apiUrl = options.apiUrl;
653
- this.threshold = options.threshold ?? DEFAULT_THRESHOLD;
654
769
  this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
655
- this.hookThresholds = Object.freeze({ ...options.hookThresholds ?? {} });
770
+ if (typeof this.timeoutMs !== "number" || !Number.isFinite(this.timeoutMs) || this.timeoutMs < 0) {
771
+ throw new Error(`Firewall: timeoutMs must be a finite non-negative number, got ${this.timeoutMs}`);
772
+ }
773
+ this.chunkConcurrency = options.chunkConcurrency ?? DEFAULT_CHUNK_CONCURRENCY;
774
+ if (!Number.isInteger(this.chunkConcurrency) || this.chunkConcurrency < 1) {
775
+ throw new Error(
776
+ `Firewall: chunkConcurrency must be an integer >= 1, got ${this.chunkConcurrency}`
777
+ );
778
+ }
656
779
  this.shadowMode = options.shadowMode ?? false;
657
780
  this.headers = Object.freeze({
658
781
  "x-api-key": this.apiKey,
@@ -661,34 +784,21 @@ var Firewall = class {
661
784
  }
662
785
  async classify(text, options = {}) {
663
786
  const chunks = chunkText(text);
787
+ const threshold = adaptiveThreshold(chunks.length);
664
788
  if (chunks.length === 1) {
665
- const payload = { text: chunks[0] };
666
- if (options.hook !== void 0) {
667
- payload.hook = options.hook;
668
- }
669
- if (options.toolName !== void 0) {
670
- payload.tool_name = options.toolName;
671
- }
672
- if (options.metadata !== void 0) {
673
- payload.metadata = options.metadata;
674
- }
675
- const data = await this.postWithRetry(payload);
676
- return blockResultFromResponse(data);
789
+ return this.classifySingleChunk(chunks[0], options, threshold);
677
790
  }
678
- const batchOpts = {};
679
- if (options.hook !== void 0) {
680
- batchOpts.hooks = chunks.map(() => options.hook);
681
- }
682
- if (options.toolName !== void 0) {
683
- batchOpts.toolNames = chunks.map(() => options.toolName);
684
- }
685
- if (options.metadata !== void 0) {
686
- batchOpts.metadata = chunks.map(() => options.metadata);
687
- }
688
- const results = await this.classifyBatch(chunks, batchOpts);
791
+ const results = await mapWithConcurrency(
792
+ chunks,
793
+ this.chunkConcurrency,
794
+ (chunk) => this.classifySingleChunk(chunk, options, threshold)
795
+ );
689
796
  return results.reduce((best, r) => r.score > best.score ? r : best);
690
797
  }
691
798
  async classifyBatch(texts, options = {}) {
799
+ if (texts.length === 0) {
800
+ throw new Error("Firewall: texts must not be empty");
801
+ }
692
802
  if (options.hooks !== void 0 && options.hooks.length !== texts.length) {
693
803
  throw new Error(
694
804
  `Firewall: hooks length ${options.hooks.length} does not match texts length ${texts.length}`
@@ -704,7 +814,11 @@ var Firewall = class {
704
814
  `Firewall: metadata length ${options.metadata.length} does not match texts length ${texts.length}`
705
815
  );
706
816
  }
707
- const payload = { texts };
817
+ const threshold = adaptiveThreshold(texts.length);
818
+ const payload = {
819
+ texts: texts.map((text) => sanitizeText(text)),
820
+ threshold
821
+ };
708
822
  if (options.hooks && options.hooks.length > 0) {
709
823
  payload.hooks = options.hooks.map((h) => String(h));
710
824
  }
@@ -715,7 +829,7 @@ var Firewall = class {
715
829
  payload.metadata = options.metadata.map((m) => m === void 0 ? null : m);
716
830
  }
717
831
  const data = await this.postWithRetry(payload);
718
- return data.predictions.map((p) => blockResultFromResponse(p));
832
+ return data.predictions.map((p) => blockResultFromResponse(p, threshold));
719
833
  }
720
834
  asLangChainHandler(options = {}) {
721
835
  return Promise.resolve().then(() => (init_langchain(), langchain_exports)).then((m) => m.createLangChainHandler(this, options));
@@ -723,23 +837,18 @@ var Firewall = class {
723
837
  asMiddleware(options = {}) {
724
838
  return createMiddleware(this, options);
725
839
  }
726
- effectiveThreshold(hook) {
727
- if (hook === void 0) {
728
- return this.threshold;
729
- }
730
- return this.hookThresholds[hook] ?? this.threshold;
731
- }
732
840
  async postWithRetry(payload, maxRetries = DEFAULT_MAX_RETRIES) {
733
841
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
734
842
  const response = await fetch(this.apiUrl, {
735
843
  method: "POST",
736
844
  headers: this.headers,
737
845
  body: JSON.stringify(payload),
846
+ redirect: "error",
738
847
  signal: AbortSignal.timeout(this.timeoutMs)
739
848
  });
740
849
  if (response.status !== 429 || attempt === maxRetries) {
741
850
  if (!response.ok) {
742
- const body = await response.text().catch(() => "");
851
+ const body = await readCappedErrorBody(response);
743
852
  throw new SilmarilApiError({
744
853
  status: response.status,
745
854
  statusText: response.statusText,
@@ -753,6 +862,20 @@ var Firewall = class {
753
862
  }
754
863
  throw new Error("Firewall: exhausted retries (unreachable)");
755
864
  }
865
+ async classifySingleChunk(text, options, threshold) {
866
+ const payload = { text, threshold };
867
+ if (options.hook !== void 0) {
868
+ payload.hook = options.hook;
869
+ }
870
+ if (options.toolName !== void 0) {
871
+ payload.tool_name = options.toolName;
872
+ }
873
+ if (options.metadata !== void 0) {
874
+ payload.metadata = options.metadata;
875
+ }
876
+ const data = await this.postWithRetry(payload);
877
+ return blockResultFromResponse(data, threshold);
878
+ }
756
879
  };
757
880
 
758
881
  // src/index.ts
@@ -764,9 +887,8 @@ export {
764
887
  CHUNK_OVERLAP_CHARS,
765
888
  CHUNK_WINDOW,
766
889
  CHUNK_WINDOW_CHARS,
890
+ DEFAULT_CHUNK_CONCURRENCY,
767
891
  DEFAULT_HOOKS,
768
- DEFAULT_HOOK_THRESHOLDS,
769
- DEFAULT_THRESHOLD,
770
892
  DEFAULT_TIMEOUT_MS,
771
893
  FIREWALL_HOOK_TO_LABEL,
772
894
  Firewall,
@@ -781,6 +903,7 @@ export {
781
903
  chunkText,
782
904
  prependHook,
783
905
  prependToolName,
784
- resolveHooks
906
+ resolveHooks,
907
+ sanitizeText
785
908
  };
786
909
  //# sourceMappingURL=index.js.map