@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.cjs CHANGED
@@ -31,6 +31,22 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
31
31
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
32
32
 
33
33
  // src/exceptions.ts
34
+ function parseErrorBody(body) {
35
+ try {
36
+ const parsed = JSON.parse(body);
37
+ if (!parsed || typeof parsed !== "object") {
38
+ return { details: void 0, error: void 0, apiMessage: void 0 };
39
+ }
40
+ const data = parsed;
41
+ return {
42
+ details: data.details && typeof data.details === "object" ? data.details : void 0,
43
+ error: typeof data.error === "string" ? data.error : void 0,
44
+ apiMessage: typeof data.message === "string" ? data.message : void 0
45
+ };
46
+ } catch {
47
+ return { details: void 0, error: void 0, apiMessage: void 0 };
48
+ }
49
+ }
34
50
  var MAX_PROMPT_DISPLAY_LEN, PromptBlockedException, SilmarilApiError;
35
51
  var init_exceptions = __esm({
36
52
  "src/exceptions.ts"() {
@@ -59,12 +75,20 @@ var init_exceptions = __esm({
59
75
  status;
60
76
  statusText;
61
77
  body;
78
+ details;
79
+ error;
80
+ apiMessage;
62
81
  constructor(params) {
63
- super(`Silmaril API error ${params.status} ${params.statusText}: ${params.body}`);
82
+ const statusText = params.statusText ? ` ${params.statusText}` : "";
83
+ super(`Silmaril API error ${params.status}${statusText}`);
64
84
  this.name = "SilmarilApiError";
65
85
  this.status = params.status;
66
86
  this.statusText = params.statusText;
67
87
  this.body = params.body;
88
+ const parsed = parseErrorBody(params.body);
89
+ this.details = params.details ?? parsed.details;
90
+ this.error = params.error ?? parsed.error;
91
+ this.apiMessage = params.apiMessage ?? parsed.apiMessage;
68
92
  Object.setPrototypeOf(this, _SilmarilApiError.prototype);
69
93
  }
70
94
  };
@@ -100,7 +124,7 @@ function prependToolName(text, toolName) {
100
124
  }
101
125
  return `[TOOL:${toolName}] ${text}`;
102
126
  }
103
- var HookLabel, FirewallHook, DEFAULT_HOOKS, INPUT_HOOKS, OUTPUT_HOOKS, ALL_HOOKS, FIREWALL_HOOK_TO_LABEL, DEFAULT_HOOK_THRESHOLDS, FIREWALL_HOOK_VALUES;
127
+ var HookLabel, FirewallHook, DEFAULT_HOOKS, INPUT_HOOKS, OUTPUT_HOOKS, ALL_HOOKS, FIREWALL_HOOK_TO_LABEL, FIREWALL_HOOK_VALUES;
104
128
  var init_hooks = __esm({
105
129
  "src/hooks.ts"() {
106
130
  "use strict";
@@ -149,14 +173,6 @@ var init_hooks = __esm({
149
173
  [FirewallHook.RETRIEVER_END]: HookLabel.TOOL_RESPONSE,
150
174
  [FirewallHook.LLM_END]: HookLabel.LLM_OUTPUT
151
175
  };
152
- DEFAULT_HOOK_THRESHOLDS = {
153
- [HookLabel.USER_INPUT]: 0.5,
154
- [HookLabel.SYSTEM_PROMPT]: 0.5,
155
- [HookLabel.TOOL_CALL]: 0.5,
156
- [HookLabel.TOOL_RESPONSE]: 0.5,
157
- [HookLabel.LLM_OUTPUT]: 0.5,
158
- [HookLabel.UNKNOWN]: 0.5
159
- };
160
176
  FIREWALL_HOOK_VALUES = new Set(Object.values(FirewallHook));
161
177
  }
162
178
  });
@@ -243,19 +259,8 @@ async function createLangChainHandler(firewall, options = {}) {
243
259
  const logger = options.logger ?? ((message, error) => {
244
260
  console.warn(`silmaril.firewall: ${message}`, error);
245
261
  });
246
- const overrideThreshold = options.threshold;
247
- const overrideHookThresholds = options.hookThresholds ?? {};
248
262
  const shadowMode = options.shadowMode ?? firewall.shadowMode;
249
263
  const onClassify = options.onClassify;
250
- const effectiveThreshold = (label) => {
251
- if (overrideHookThresholds[label] !== void 0) {
252
- return overrideHookThresholds[label];
253
- }
254
- if (firewall.hookThresholds[label] !== void 0) {
255
- return firewall.hookThresholds[label];
256
- }
257
- return overrideThreshold ?? firewall.threshold;
258
- };
259
264
  const fireOnClassify = (hookLabel, text, result, blocked, toolName) => {
260
265
  if (!onClassify) {
261
266
  return;
@@ -287,7 +292,7 @@ async function createLangChainHandler(firewall, options = {}) {
287
292
  logger("classification failed, allowing prompt through", err);
288
293
  return;
289
294
  }
290
- const threshold = effectiveThreshold(hookLabel);
295
+ const threshold = result.threshold;
291
296
  const blocked = result.score >= threshold;
292
297
  fireOnClassify(hookLabel, text, result, blocked, toolName);
293
298
  if (blocked && !shadowMode) {
@@ -408,9 +413,8 @@ __export(index_exports, {
408
413
  CHUNK_OVERLAP_CHARS: () => CHUNK_OVERLAP_CHARS,
409
414
  CHUNK_WINDOW: () => CHUNK_WINDOW,
410
415
  CHUNK_WINDOW_CHARS: () => CHUNK_WINDOW_CHARS,
416
+ DEFAULT_CHUNK_CONCURRENCY: () => DEFAULT_CHUNK_CONCURRENCY,
411
417
  DEFAULT_HOOKS: () => DEFAULT_HOOKS,
412
- DEFAULT_HOOK_THRESHOLDS: () => DEFAULT_HOOK_THRESHOLDS,
413
- DEFAULT_THRESHOLD: () => DEFAULT_THRESHOLD,
414
418
  DEFAULT_TIMEOUT_MS: () => DEFAULT_TIMEOUT_MS,
415
419
  FIREWALL_HOOK_TO_LABEL: () => FIREWALL_HOOK_TO_LABEL,
416
420
  Firewall: () => Firewall,
@@ -425,7 +429,8 @@ __export(index_exports, {
425
429
  chunkText: () => chunkText,
426
430
  prependHook: () => prependHook,
427
431
  prependToolName: () => prependToolName,
428
- resolveHooks: () => resolveHooks
432
+ resolveHooks: () => resolveHooks,
433
+ sanitizeText: () => sanitizeText
429
434
  });
430
435
  module.exports = __toCommonJS(index_exports);
431
436
 
@@ -489,18 +494,7 @@ function findLastUserMessage(messages) {
489
494
  function createMiddleware(firewall, options = {}) {
490
495
  const scanInput = options.scanInput ?? true;
491
496
  const scanOutput = options.scanOutput ?? false;
492
- const overrideThreshold = options.threshold;
493
- const overrideHookThresholds = options.hookThresholds ?? {};
494
497
  const shadowMode = options.shadowMode ?? firewall.shadowMode;
495
- const effectiveThreshold = (label) => {
496
- if (overrideHookThresholds[label] !== void 0) {
497
- return overrideHookThresholds[label];
498
- }
499
- if (firewall.hookThresholds[label] !== void 0) {
500
- return firewall.hookThresholds[label];
501
- }
502
- return overrideThreshold ?? firewall.threshold;
503
- };
504
498
  const classifyOrBlock = async (text, hook, toolName) => {
505
499
  if (!text.trim()) {
506
500
  return;
@@ -509,7 +503,7 @@ function createMiddleware(firewall, options = {}) {
509
503
  text,
510
504
  toolName !== void 0 ? { hook, toolName } : { hook }
511
505
  );
512
- const threshold = effectiveThreshold(hook);
506
+ const threshold = result.threshold;
513
507
  const blocked = result.score >= threshold;
514
508
  options.onClassify?.({
515
509
  hook,
@@ -625,14 +619,52 @@ function createMiddleware(firewall, options = {}) {
625
619
 
626
620
  // src/chunking.ts
627
621
  var CHARS_PER_TOKEN = 4;
628
- var MAX_INPUT_TOKENS = 10240;
622
+ var MAX_INPUT_TOKENS = 81920;
629
623
  var CHUNK_WINDOW = 400;
630
624
  var CHUNK_OVERLAP = 64;
631
625
  var MAX_INPUT_CHARS = MAX_INPUT_TOKENS * CHARS_PER_TOKEN;
632
626
  var CHUNK_WINDOW_CHARS = CHUNK_WINDOW * CHARS_PER_TOKEN;
633
627
  var CHUNK_OVERLAP_CHARS = CHUNK_OVERLAP * CHARS_PER_TOKEN;
634
628
  var CHUNK_STRIDE_CHARS = CHUNK_WINDOW_CHARS - CHUNK_OVERLAP_CHARS;
629
+ function isHighSurrogate(code) {
630
+ return code >= 55296 && code <= 56319;
631
+ }
632
+ function isLowSurrogate(code) {
633
+ return code >= 56320 && code <= 57343;
634
+ }
635
+ function sanitizeText(text) {
636
+ let out = "";
637
+ for (let i = 0; i < text.length; i++) {
638
+ const code = text.charCodeAt(i);
639
+ if (isHighSurrogate(code)) {
640
+ if (i + 1 < text.length && isLowSurrogate(text.charCodeAt(i + 1))) {
641
+ out += text[i];
642
+ out += text[i + 1];
643
+ i++;
644
+ }
645
+ continue;
646
+ }
647
+ if (isLowSurrogate(code)) {
648
+ continue;
649
+ }
650
+ out += text[i];
651
+ }
652
+ return out;
653
+ }
654
+ function safeChunkStart(text, start) {
655
+ if (start > 0 && start < text.length && isLowSurrogate(text.charCodeAt(start)) && isHighSurrogate(text.charCodeAt(start - 1))) {
656
+ return start + 1;
657
+ }
658
+ return start;
659
+ }
660
+ function safeChunkEnd(text, end) {
661
+ if (end > 0 && end < text.length && isHighSurrogate(text.charCodeAt(end - 1)) && isLowSurrogate(text.charCodeAt(end))) {
662
+ return end - 1;
663
+ }
664
+ return end;
665
+ }
635
666
  function chunkText(text) {
667
+ text = sanitizeText(text);
636
668
  const n = text.length;
637
669
  if (n > MAX_INPUT_CHARS) {
638
670
  throw new Error(
@@ -644,8 +676,12 @@ function chunkText(text) {
644
676
  }
645
677
  const chunks = [];
646
678
  for (let start = 0; start < n; start += CHUNK_STRIDE_CHARS) {
647
- const end = start + CHUNK_WINDOW_CHARS;
648
- chunks.push(text.slice(start, end));
679
+ const safeStart = safeChunkStart(text, start);
680
+ if (safeStart >= n) {
681
+ break;
682
+ }
683
+ const end = safeChunkEnd(text, safeStart + CHUNK_WINDOW_CHARS);
684
+ chunks.push(text.slice(safeStart, end));
649
685
  if (end >= n) {
650
686
  break;
651
687
  }
@@ -655,14 +691,33 @@ function chunkText(text) {
655
691
 
656
692
  // src/firewall.ts
657
693
  init_exceptions();
658
- var DEFAULT_THRESHOLD = 0.5;
694
+ var BASE_THRESHOLD = 0.5;
695
+ var TARGET_SEQUENCE_FPR = 0.01;
696
+ var MAX_ADAPTIVE_THRESHOLD = 0.9;
659
697
  var DEFAULT_TIMEOUT_MS = 1e4;
698
+ var DEFAULT_CHUNK_CONCURRENCY = 8;
660
699
  var DEFAULT_MAX_RETRIES = 5;
661
700
  var MAX_BACKOFF_SECONDS = 30;
662
- function blockResultFromResponse(data) {
701
+ var MAX_ERROR_BODY_BYTES = 1 << 16;
702
+ function adaptiveThreshold(scoringOpportunityCount) {
703
+ if (!Number.isInteger(scoringOpportunityCount) || scoringOpportunityCount < 1) {
704
+ throw new Error(
705
+ `Firewall: scoringOpportunityCount must be an integer >= 1, got ${scoringOpportunityCount}`
706
+ );
707
+ }
708
+ if (scoringOpportunityCount === 1) {
709
+ return BASE_THRESHOLD;
710
+ }
711
+ const targetChunkFpr = 1 - Math.pow(1 - TARGET_SEQUENCE_FPR, 1 / scoringOpportunityCount);
712
+ const oddsRatio = TARGET_SEQUENCE_FPR / targetChunkFpr;
713
+ const rawThreshold = oddsRatio / (1 + oddsRatio);
714
+ return Math.min(rawThreshold, MAX_ADAPTIVE_THRESHOLD);
715
+ }
716
+ function blockResultFromResponse(data, threshold) {
663
717
  const result = {
664
718
  prediction: data.prediction,
665
- score: Number(data.score)
719
+ score: Number(data.score),
720
+ threshold
666
721
  };
667
722
  if (data.primary_outcome !== void 0) {
668
723
  result.primaryOutcome = data.primary_outcome;
@@ -684,12 +739,73 @@ function blockResultFromResponse(data) {
684
739
  }
685
740
  return Object.freeze(result);
686
741
  }
742
+ async function readCappedErrorBody(response) {
743
+ if (!response.body) {
744
+ return response.text().then((body2) => body2.slice(0, MAX_ERROR_BODY_BYTES)).catch(() => "");
745
+ }
746
+ const reader = response.body.getReader();
747
+ const chunks = [];
748
+ let remaining = MAX_ERROR_BODY_BYTES;
749
+ try {
750
+ while (remaining > 0) {
751
+ const { done, value } = await reader.read();
752
+ if (done) {
753
+ break;
754
+ }
755
+ if (!value) {
756
+ continue;
757
+ }
758
+ const chunk = value.byteLength > remaining ? value.slice(0, remaining) : value;
759
+ chunks.push(chunk);
760
+ remaining -= chunk.byteLength;
761
+ if (chunk.byteLength < value.byteLength) {
762
+ break;
763
+ }
764
+ }
765
+ } catch {
766
+ return "";
767
+ } finally {
768
+ await reader.cancel().catch(() => void 0);
769
+ }
770
+ const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
771
+ const body = new Uint8Array(total);
772
+ let offset = 0;
773
+ for (const chunk of chunks) {
774
+ body.set(chunk, offset);
775
+ offset += chunk.byteLength;
776
+ }
777
+ return new TextDecoder().decode(body);
778
+ }
779
+ async function mapWithConcurrency(items, concurrency, task) {
780
+ const results = new Array(items.length);
781
+ let nextIndex = 0;
782
+ let firstError;
783
+ const workerCount = Math.min(concurrency, items.length);
784
+ async function worker() {
785
+ for (; ; ) {
786
+ const index = nextIndex;
787
+ nextIndex += 1;
788
+ if (index >= items.length) {
789
+ return;
790
+ }
791
+ try {
792
+ results[index] = await task(items[index], index);
793
+ } catch (err) {
794
+ firstError ??= err;
795
+ }
796
+ }
797
+ }
798
+ await Promise.all(Array.from({ length: workerCount }, () => worker()));
799
+ if (firstError !== void 0) {
800
+ throw firstError;
801
+ }
802
+ return results;
803
+ }
687
804
  var Firewall = class {
688
805
  apiKey;
689
806
  apiUrl;
690
- threshold;
691
807
  timeoutMs;
692
- hookThresholds;
808
+ chunkConcurrency;
693
809
  shadowMode;
694
810
  headers;
695
811
  constructor(options) {
@@ -701,9 +817,16 @@ var Firewall = class {
701
817
  }
702
818
  this.apiKey = options.apiKey;
703
819
  this.apiUrl = options.apiUrl;
704
- this.threshold = options.threshold ?? DEFAULT_THRESHOLD;
705
820
  this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
706
- this.hookThresholds = Object.freeze({ ...options.hookThresholds ?? {} });
821
+ if (typeof this.timeoutMs !== "number" || !Number.isFinite(this.timeoutMs) || this.timeoutMs < 0) {
822
+ throw new Error(`Firewall: timeoutMs must be a finite non-negative number, got ${this.timeoutMs}`);
823
+ }
824
+ this.chunkConcurrency = options.chunkConcurrency ?? DEFAULT_CHUNK_CONCURRENCY;
825
+ if (!Number.isInteger(this.chunkConcurrency) || this.chunkConcurrency < 1) {
826
+ throw new Error(
827
+ `Firewall: chunkConcurrency must be an integer >= 1, got ${this.chunkConcurrency}`
828
+ );
829
+ }
707
830
  this.shadowMode = options.shadowMode ?? false;
708
831
  this.headers = Object.freeze({
709
832
  "x-api-key": this.apiKey,
@@ -712,34 +835,21 @@ var Firewall = class {
712
835
  }
713
836
  async classify(text, options = {}) {
714
837
  const chunks = chunkText(text);
838
+ const threshold = adaptiveThreshold(chunks.length);
715
839
  if (chunks.length === 1) {
716
- const payload = { text: chunks[0] };
717
- if (options.hook !== void 0) {
718
- payload.hook = options.hook;
719
- }
720
- if (options.toolName !== void 0) {
721
- payload.tool_name = options.toolName;
722
- }
723
- if (options.metadata !== void 0) {
724
- payload.metadata = options.metadata;
725
- }
726
- const data = await this.postWithRetry(payload);
727
- return blockResultFromResponse(data);
840
+ return this.classifySingleChunk(chunks[0], options, threshold);
728
841
  }
729
- const batchOpts = {};
730
- if (options.hook !== void 0) {
731
- batchOpts.hooks = chunks.map(() => options.hook);
732
- }
733
- if (options.toolName !== void 0) {
734
- batchOpts.toolNames = chunks.map(() => options.toolName);
735
- }
736
- if (options.metadata !== void 0) {
737
- batchOpts.metadata = chunks.map(() => options.metadata);
738
- }
739
- const results = await this.classifyBatch(chunks, batchOpts);
842
+ const results = await mapWithConcurrency(
843
+ chunks,
844
+ this.chunkConcurrency,
845
+ (chunk) => this.classifySingleChunk(chunk, options, threshold)
846
+ );
740
847
  return results.reduce((best, r) => r.score > best.score ? r : best);
741
848
  }
742
849
  async classifyBatch(texts, options = {}) {
850
+ if (texts.length === 0) {
851
+ throw new Error("Firewall: texts must not be empty");
852
+ }
743
853
  if (options.hooks !== void 0 && options.hooks.length !== texts.length) {
744
854
  throw new Error(
745
855
  `Firewall: hooks length ${options.hooks.length} does not match texts length ${texts.length}`
@@ -755,7 +865,11 @@ var Firewall = class {
755
865
  `Firewall: metadata length ${options.metadata.length} does not match texts length ${texts.length}`
756
866
  );
757
867
  }
758
- const payload = { texts };
868
+ const threshold = adaptiveThreshold(texts.length);
869
+ const payload = {
870
+ texts: texts.map((text) => sanitizeText(text)),
871
+ threshold
872
+ };
759
873
  if (options.hooks && options.hooks.length > 0) {
760
874
  payload.hooks = options.hooks.map((h) => String(h));
761
875
  }
@@ -766,7 +880,7 @@ var Firewall = class {
766
880
  payload.metadata = options.metadata.map((m) => m === void 0 ? null : m);
767
881
  }
768
882
  const data = await this.postWithRetry(payload);
769
- return data.predictions.map((p) => blockResultFromResponse(p));
883
+ return data.predictions.map((p) => blockResultFromResponse(p, threshold));
770
884
  }
771
885
  asLangChainHandler(options = {}) {
772
886
  return Promise.resolve().then(() => (init_langchain(), langchain_exports)).then((m) => m.createLangChainHandler(this, options));
@@ -774,23 +888,18 @@ var Firewall = class {
774
888
  asMiddleware(options = {}) {
775
889
  return createMiddleware(this, options);
776
890
  }
777
- effectiveThreshold(hook) {
778
- if (hook === void 0) {
779
- return this.threshold;
780
- }
781
- return this.hookThresholds[hook] ?? this.threshold;
782
- }
783
891
  async postWithRetry(payload, maxRetries = DEFAULT_MAX_RETRIES) {
784
892
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
785
893
  const response = await fetch(this.apiUrl, {
786
894
  method: "POST",
787
895
  headers: this.headers,
788
896
  body: JSON.stringify(payload),
897
+ redirect: "error",
789
898
  signal: AbortSignal.timeout(this.timeoutMs)
790
899
  });
791
900
  if (response.status !== 429 || attempt === maxRetries) {
792
901
  if (!response.ok) {
793
- const body = await response.text().catch(() => "");
902
+ const body = await readCappedErrorBody(response);
794
903
  throw new SilmarilApiError({
795
904
  status: response.status,
796
905
  statusText: response.statusText,
@@ -804,6 +913,20 @@ var Firewall = class {
804
913
  }
805
914
  throw new Error("Firewall: exhausted retries (unreachable)");
806
915
  }
916
+ async classifySingleChunk(text, options, threshold) {
917
+ const payload = { text, threshold };
918
+ if (options.hook !== void 0) {
919
+ payload.hook = options.hook;
920
+ }
921
+ if (options.toolName !== void 0) {
922
+ payload.tool_name = options.toolName;
923
+ }
924
+ if (options.metadata !== void 0) {
925
+ payload.metadata = options.metadata;
926
+ }
927
+ const data = await this.postWithRetry(payload);
928
+ return blockResultFromResponse(data, threshold);
929
+ }
807
930
  };
808
931
 
809
932
  // src/index.ts
@@ -816,9 +939,8 @@ init_hooks();
816
939
  CHUNK_OVERLAP_CHARS,
817
940
  CHUNK_WINDOW,
818
941
  CHUNK_WINDOW_CHARS,
942
+ DEFAULT_CHUNK_CONCURRENCY,
819
943
  DEFAULT_HOOKS,
820
- DEFAULT_HOOK_THRESHOLDS,
821
- DEFAULT_THRESHOLD,
822
944
  DEFAULT_TIMEOUT_MS,
823
945
  FIREWALL_HOOK_TO_LABEL,
824
946
  Firewall,
@@ -833,6 +955,7 @@ init_hooks();
833
955
  chunkText,
834
956
  prependHook,
835
957
  prependToolName,
836
- resolveHooks
958
+ resolveHooks,
959
+ sanitizeText
837
960
  });
838
961
  //# sourceMappingURL=index.cjs.map