@silmaril-security/sdk 0.6.1 → 0.6.3
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 +25 -0
- package/README.md +52 -8
- package/dist/adapters/langchain.d.cts +1 -1
- package/dist/adapters/langchain.d.ts +1 -1
- package/dist/adapters/vercel.d.cts +1 -1
- package/dist/adapters/vercel.d.ts +1 -1
- package/dist/{firewall-Cdnk_UWY.d.cts → firewall-DkArls03.d.cts} +19 -0
- package/dist/{firewall-Cdnk_UWY.d.ts → firewall-DkArls03.d.ts} +19 -0
- package/dist/index.cjs +105 -22
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +105 -22
- package/dist/index.js.map +1 -1
- package/package.json +5 -4
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the Silmaril Firewall TypeScript SDK are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.6.3 - 2026-09-24
|
|
6
|
+
|
|
7
|
+
- Add an optional `signal` option to `classify()` and `classifyBatch()`.
|
|
8
|
+
Aborting stops the in-flight request or the 429 retry wait, rejects with the
|
|
9
|
+
signal's reason, and leaves sibling calls on the same client unaffected. An
|
|
10
|
+
already-aborted signal rejects without sending a request.
|
|
11
|
+
- Combine the caller signal with the existing per-attempt `timeoutMs` without
|
|
12
|
+
changing timeout behavior, and release the abort listener and timer once an
|
|
13
|
+
attempt finishes.
|
|
14
|
+
- Discard a retried 429 response body before backing off so a retry does not
|
|
15
|
+
leak the connection.
|
|
16
|
+
- Serialize each request payload once before retries, so mutating the objects
|
|
17
|
+
passed to a call can no longer change an in-flight logical event.
|
|
18
|
+
- Reject a `timeoutMs` above 2147483647 ms in the constructor instead of
|
|
19
|
+
letting a timer silently collapse it to an immediate abort.
|
|
20
|
+
- Document that `Firewall` state is immutable and one client is reusable for
|
|
21
|
+
concurrent promises within a runtime, while each worker thread owns its own
|
|
22
|
+
client.
|
|
23
|
+
|
|
24
|
+
## 0.6.2 - 2026-08-24
|
|
25
|
+
|
|
26
|
+
- Align the runtime `sdk_version` metadata with the published package version.
|
|
27
|
+
- Add a release invariant test so package metadata and runtime provenance cannot
|
|
28
|
+
drift in future releases.
|
|
29
|
+
|
|
5
30
|
## 0.6.1 - 2026-08-24
|
|
6
31
|
|
|
7
32
|
- Add typed governance request context for agents, hooks, principals, and
|
package/README.md
CHANGED
|
@@ -37,10 +37,10 @@ npm install @silmaril-security/sdk
|
|
|
37
37
|
For reproducible installs, pin a tagged release:
|
|
38
38
|
|
|
39
39
|
```sh
|
|
40
|
-
npm install @silmaril-security/sdk@0.6.
|
|
40
|
+
npm install @silmaril-security/sdk@0.6.2
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
-
Requires Node
|
|
43
|
+
Requires Node 20 or later.
|
|
44
44
|
|
|
45
45
|
The package name and SDK import path are both `@silmaril-security/sdk`, so call
|
|
46
46
|
sites use `Firewall`, `HookLabel`, and `FirewallBlockedException` from that
|
|
@@ -117,6 +117,47 @@ internally applied threshold. Direct calls do not throw on malicious verdicts.
|
|
|
117
117
|
The Vercel AI SDK and LangChain.js adapters use `result.threshold` and throw
|
|
118
118
|
`FirewallBlockedException` when enforcement is enabled.
|
|
119
119
|
|
|
120
|
+
## Concurrency and Cancellation
|
|
121
|
+
|
|
122
|
+
A `Firewall` instance holds no per-request state, so one client can serve any
|
|
123
|
+
number of concurrent calls in a runtime. Responses are matched to their own
|
|
124
|
+
promise regardless of completion order:
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
const [userResult, toolResult] = await Promise.all([
|
|
128
|
+
fw.classify(userInput, { hook: HookLabel.USER_INPUT }),
|
|
129
|
+
fw.classify(toolOutput, { hook: HookLabel.TOOL_RESPONSE, toolName: "read_file" }),
|
|
130
|
+
]);
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Pass a `signal` to cancel one call without touching its siblings:
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
const controller = new AbortController();
|
|
137
|
+
request.on("close", () => controller.abort());
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const result = await fw.classify(userInput, {
|
|
141
|
+
hook: HookLabel.USER_INPUT,
|
|
142
|
+
signal: controller.signal,
|
|
143
|
+
});
|
|
144
|
+
return result;
|
|
145
|
+
} catch (err) {
|
|
146
|
+
if (controller.signal.aborted) {
|
|
147
|
+
return; // caller went away
|
|
148
|
+
}
|
|
149
|
+
throw err;
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Aborting stops the in-flight request or the 429 backoff wait and rejects with
|
|
154
|
+
the signal's reason. An already-aborted signal rejects without sending a
|
|
155
|
+
request. `signal` composes with `timeoutMs`, which still applies per attempt.
|
|
156
|
+
`classifyBatch()` accepts the same option.
|
|
157
|
+
|
|
158
|
+
Each worker thread owns its own client: construct a `Firewall` inside the
|
|
159
|
+
thread instead of sharing one instance across `worker_threads` boundaries.
|
|
160
|
+
|
|
120
161
|
## Handle Outcomes
|
|
121
162
|
|
|
122
163
|
Direct calls expose typed outcome labels so applications can choose different
|
|
@@ -183,14 +224,15 @@ Outcome taxonomy:
|
|
|
183
224
|
interface FirewallOptions {
|
|
184
225
|
apiKey: string; // required
|
|
185
226
|
apiUrl: string; // required
|
|
186
|
-
timeoutMs?: number; // default: 10000 ms
|
|
227
|
+
timeoutMs?: number; // default: 10000 ms, max 2147483647
|
|
187
228
|
mode?: "shadow" | "warn" | "block"; // omitted uses backend configuration
|
|
188
229
|
shadowMode?: boolean; // deprecated legacy mapping
|
|
189
230
|
}
|
|
190
231
|
```
|
|
191
232
|
|
|
192
|
-
The SDK uses native `fetch`,
|
|
193
|
-
`x-api-key` and `content-type` headers.
|
|
233
|
+
The SDK uses native `fetch`, an abort signal per request attempt, and JSON
|
|
234
|
+
request bodies with `x-api-key` and `content-type` headers. `timeoutMs` applies
|
|
235
|
+
to each attempt and is combined with any caller-supplied `signal`.
|
|
194
236
|
|
|
195
237
|
## Backend Thresholding
|
|
196
238
|
|
|
@@ -439,9 +481,11 @@ const handler = await createLangChainHandler(fw);
|
|
|
439
481
|
## Retries
|
|
440
482
|
|
|
441
483
|
HTTP 429 responses are retried with exponential backoff capped at 30s, up to 5
|
|
442
|
-
times.
|
|
443
|
-
|
|
444
|
-
|
|
484
|
+
times. The retried response body is discarded before the wait, and each request
|
|
485
|
+
payload is serialized once so every attempt sends the same logical event.
|
|
486
|
+
Redirects are rejected rather than followed. Other non-2xx responses are
|
|
487
|
+
surfaced as `SilmarilApiError`, and transport, timeout, or cancellation failures
|
|
488
|
+
are surfaced unchanged.
|
|
445
489
|
|
|
446
490
|
## Development
|
|
447
491
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { BaseCallbackHandler } from '@langchain/core/callbacks/base';
|
|
2
|
-
import { e as Firewall, L as LangChainAdapterOptions } from '../firewall-
|
|
2
|
+
import { e as Firewall, L as LangChainAdapterOptions } from '../firewall-DkArls03.cjs';
|
|
3
3
|
|
|
4
4
|
declare function createLangChainHandler(firewall: Firewall, options?: LangChainAdapterOptions): Promise<BaseCallbackHandler>;
|
|
5
5
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { BaseCallbackHandler } from '@langchain/core/callbacks/base';
|
|
2
|
-
import { e as Firewall, L as LangChainAdapterOptions } from '../firewall-
|
|
2
|
+
import { e as Firewall, L as LangChainAdapterOptions } from '../firewall-DkArls03.js';
|
|
3
3
|
|
|
4
4
|
declare function createLangChainHandler(firewall: Firewall, options?: LangChainAdapterOptions): Promise<BaseCallbackHandler>;
|
|
5
5
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export { g as FirewallMiddleware, Q as createMiddleware } from '../firewall-
|
|
1
|
+
export { g as FirewallMiddleware, Q as createMiddleware } from '../firewall-DkArls03.cjs';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export { g as FirewallMiddleware, Q as createMiddleware } from '../firewall-
|
|
1
|
+
export { g as FirewallMiddleware, Q as createMiddleware } from '../firewall-DkArls03.js';
|
|
@@ -116,6 +116,11 @@ interface ClassifyOptions {
|
|
|
116
116
|
governance?: GovernanceContext;
|
|
117
117
|
metadata?: ClassificationMetadata;
|
|
118
118
|
requestId?: string;
|
|
119
|
+
/**
|
|
120
|
+
* Cancels this call only. Aborting stops the in-flight request or retry wait
|
|
121
|
+
* and rejects with the signal's reason; sibling calls are unaffected.
|
|
122
|
+
*/
|
|
123
|
+
signal?: AbortSignal;
|
|
119
124
|
}
|
|
120
125
|
interface ClassifyBatchOptions {
|
|
121
126
|
mode?: FirewallMode;
|
|
@@ -124,6 +129,11 @@ interface ClassifyBatchOptions {
|
|
|
124
129
|
governance?: readonly (GovernanceContext | undefined)[];
|
|
125
130
|
metadata?: readonly (ClassificationMetadata | undefined)[];
|
|
126
131
|
requestId?: string;
|
|
132
|
+
/**
|
|
133
|
+
* Cancels this batch call only. Aborting stops the in-flight request or retry
|
|
134
|
+
* wait and rejects with the signal's reason; sibling calls are unaffected.
|
|
135
|
+
*/
|
|
136
|
+
signal?: AbortSignal;
|
|
127
137
|
}
|
|
128
138
|
interface LangChainAdapterOptions {
|
|
129
139
|
hooks?: ReadonlySet<FirewallHook>;
|
|
@@ -219,6 +229,15 @@ interface FirewallMiddleware {
|
|
|
219
229
|
declare function createMiddleware(firewall: Firewall, options?: MiddlewareOptions): FirewallMiddleware;
|
|
220
230
|
|
|
221
231
|
declare const DEFAULT_TIMEOUT_MS = 10000;
|
|
232
|
+
/**
|
|
233
|
+
* Firewall client for the `/classify` API.
|
|
234
|
+
*
|
|
235
|
+
* Instance state is set once in the constructor and never mutated by a request,
|
|
236
|
+
* so one client is safe to share across any number of concurrent `classify()`
|
|
237
|
+
* and `classifyBatch()` promises within a single JavaScript runtime. Each
|
|
238
|
+
* worker thread owns its own client: `Firewall` instances are not transferable
|
|
239
|
+
* across `worker_threads` boundaries.
|
|
240
|
+
*/
|
|
222
241
|
declare class Firewall {
|
|
223
242
|
readonly apiKey: string;
|
|
224
243
|
readonly apiUrl: string;
|
|
@@ -116,6 +116,11 @@ interface ClassifyOptions {
|
|
|
116
116
|
governance?: GovernanceContext;
|
|
117
117
|
metadata?: ClassificationMetadata;
|
|
118
118
|
requestId?: string;
|
|
119
|
+
/**
|
|
120
|
+
* Cancels this call only. Aborting stops the in-flight request or retry wait
|
|
121
|
+
* and rejects with the signal's reason; sibling calls are unaffected.
|
|
122
|
+
*/
|
|
123
|
+
signal?: AbortSignal;
|
|
119
124
|
}
|
|
120
125
|
interface ClassifyBatchOptions {
|
|
121
126
|
mode?: FirewallMode;
|
|
@@ -124,6 +129,11 @@ interface ClassifyBatchOptions {
|
|
|
124
129
|
governance?: readonly (GovernanceContext | undefined)[];
|
|
125
130
|
metadata?: readonly (ClassificationMetadata | undefined)[];
|
|
126
131
|
requestId?: string;
|
|
132
|
+
/**
|
|
133
|
+
* Cancels this batch call only. Aborting stops the in-flight request or retry
|
|
134
|
+
* wait and rejects with the signal's reason; sibling calls are unaffected.
|
|
135
|
+
*/
|
|
136
|
+
signal?: AbortSignal;
|
|
127
137
|
}
|
|
128
138
|
interface LangChainAdapterOptions {
|
|
129
139
|
hooks?: ReadonlySet<FirewallHook>;
|
|
@@ -219,6 +229,15 @@ interface FirewallMiddleware {
|
|
|
219
229
|
declare function createMiddleware(firewall: Firewall, options?: MiddlewareOptions): FirewallMiddleware;
|
|
220
230
|
|
|
221
231
|
declare const DEFAULT_TIMEOUT_MS = 10000;
|
|
232
|
+
/**
|
|
233
|
+
* Firewall client for the `/classify` API.
|
|
234
|
+
*
|
|
235
|
+
* Instance state is set once in the constructor and never mutated by a request,
|
|
236
|
+
* so one client is safe to share across any number of concurrent `classify()`
|
|
237
|
+
* and `classifyBatch()` promises within a single JavaScript runtime. Each
|
|
238
|
+
* worker thread owns its own client: `Firewall` instances are not transferable
|
|
239
|
+
* across `worker_threads` boundaries.
|
|
240
|
+
*/
|
|
222
241
|
declare class Firewall {
|
|
223
242
|
readonly apiKey: string;
|
|
224
243
|
readonly apiUrl: string;
|
package/dist/index.cjs
CHANGED
|
@@ -811,11 +811,12 @@ function sanitizeText(text) {
|
|
|
811
811
|
}
|
|
812
812
|
|
|
813
813
|
// src/firewall.ts
|
|
814
|
-
var SDK_VERSION = "0.6.
|
|
814
|
+
var SDK_VERSION = "0.6.3";
|
|
815
815
|
var DEFAULT_TIMEOUT_MS = 1e4;
|
|
816
816
|
var DEFAULT_MAX_RETRIES = 5;
|
|
817
817
|
var MAX_BACKOFF_SECONDS = 30;
|
|
818
818
|
var MAX_ERROR_BODY_BYTES = 1 << 16;
|
|
819
|
+
var MAX_TIMEOUT_MS = 2147483647;
|
|
819
820
|
function resolveMode(value, requestedMode) {
|
|
820
821
|
let responseMode;
|
|
821
822
|
if (value === "shadow" || value === "warn" || value === "block") {
|
|
@@ -954,6 +955,71 @@ async function readCappedErrorBody(response) {
|
|
|
954
955
|
}
|
|
955
956
|
return new TextDecoder().decode(body);
|
|
956
957
|
}
|
|
958
|
+
async function discardResponseBody(response) {
|
|
959
|
+
try {
|
|
960
|
+
if (response.body) {
|
|
961
|
+
if (!response.body.locked) {
|
|
962
|
+
await response.body.cancel();
|
|
963
|
+
}
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
await response.text();
|
|
967
|
+
} catch {
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
function isAbortLikeError(error) {
|
|
971
|
+
if (typeof error !== "object" || error === null) {
|
|
972
|
+
return false;
|
|
973
|
+
}
|
|
974
|
+
const name = error.name;
|
|
975
|
+
return name === "AbortError" || name === "TimeoutError";
|
|
976
|
+
}
|
|
977
|
+
async function readBodyUnderSignal(read, signal) {
|
|
978
|
+
try {
|
|
979
|
+
return await read();
|
|
980
|
+
} catch (error) {
|
|
981
|
+
if (signal.aborted && isAbortLikeError(error)) {
|
|
982
|
+
throw signal.reason;
|
|
983
|
+
}
|
|
984
|
+
throw error;
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
function createAttemptSignal(timeoutMs, callerSignal) {
|
|
988
|
+
const controller = new AbortController();
|
|
989
|
+
const timer = setTimeout(() => {
|
|
990
|
+
controller.abort(
|
|
991
|
+
new DOMException("The operation was aborted due to timeout", "TimeoutError")
|
|
992
|
+
);
|
|
993
|
+
}, timeoutMs);
|
|
994
|
+
const onCallerAbort = () => {
|
|
995
|
+
controller.abort(callerSignal?.reason);
|
|
996
|
+
};
|
|
997
|
+
callerSignal?.addEventListener("abort", onCallerAbort, { once: true });
|
|
998
|
+
return {
|
|
999
|
+
signal: controller.signal,
|
|
1000
|
+
dispose: () => {
|
|
1001
|
+
clearTimeout(timer);
|
|
1002
|
+
callerSignal?.removeEventListener("abort", onCallerAbort);
|
|
1003
|
+
}
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
function sleep(ms, signal) {
|
|
1007
|
+
return new Promise((resolve, reject) => {
|
|
1008
|
+
if (signal?.aborted) {
|
|
1009
|
+
reject(signal.reason);
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
const onAbort = () => {
|
|
1013
|
+
clearTimeout(timer);
|
|
1014
|
+
reject(signal?.reason);
|
|
1015
|
+
};
|
|
1016
|
+
const timer = setTimeout(() => {
|
|
1017
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1018
|
+
resolve();
|
|
1019
|
+
}, ms);
|
|
1020
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
957
1023
|
var Firewall = class {
|
|
958
1024
|
apiKey;
|
|
959
1025
|
apiUrl;
|
|
@@ -974,6 +1040,11 @@ var Firewall = class {
|
|
|
974
1040
|
if (typeof this.timeoutMs !== "number" || !Number.isFinite(this.timeoutMs) || this.timeoutMs < 0) {
|
|
975
1041
|
throw new Error(`Firewall: timeoutMs must be a finite non-negative number, got ${this.timeoutMs}`);
|
|
976
1042
|
}
|
|
1043
|
+
if (this.timeoutMs > MAX_TIMEOUT_MS) {
|
|
1044
|
+
throw new Error(
|
|
1045
|
+
`Firewall: timeoutMs must be at most ${MAX_TIMEOUT_MS} ms, got ${this.timeoutMs}`
|
|
1046
|
+
);
|
|
1047
|
+
}
|
|
977
1048
|
this.mode = options.mode ?? legacyMode(options.shadowMode);
|
|
978
1049
|
this.shadowMode = this.mode === "shadow";
|
|
979
1050
|
this.headers = Object.freeze({
|
|
@@ -1030,7 +1101,7 @@ var Firewall = class {
|
|
|
1030
1101
|
...options.governance?.[index] === void 0 ? {} : { governance: options.governance[index] }
|
|
1031
1102
|
})
|
|
1032
1103
|
);
|
|
1033
|
-
const data = await this.postWithRetry(payload);
|
|
1104
|
+
const data = await this.postWithRetry(payload, options.signal);
|
|
1034
1105
|
return data.predictions.map((p) => blockResultFromResponse(p, requestedMode));
|
|
1035
1106
|
}
|
|
1036
1107
|
asLangChainHandler(options = {}) {
|
|
@@ -1041,28 +1112,40 @@ var Firewall = class {
|
|
|
1041
1112
|
asMiddleware(options = {}) {
|
|
1042
1113
|
return createMiddleware(this, options);
|
|
1043
1114
|
}
|
|
1044
|
-
async postWithRetry(payload, maxRetries = DEFAULT_MAX_RETRIES) {
|
|
1115
|
+
async postWithRetry(payload, callerSignal, maxRetries = DEFAULT_MAX_RETRIES) {
|
|
1116
|
+
callerSignal?.throwIfAborted();
|
|
1117
|
+
const body = JSON.stringify(payload);
|
|
1045
1118
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1119
|
+
callerSignal?.throwIfAborted();
|
|
1120
|
+
const attemptSignal = createAttemptSignal(this.timeoutMs, callerSignal);
|
|
1121
|
+
try {
|
|
1122
|
+
const response = await fetch(this.apiUrl, {
|
|
1123
|
+
method: "POST",
|
|
1124
|
+
headers: this.headers,
|
|
1125
|
+
body,
|
|
1126
|
+
redirect: "error",
|
|
1127
|
+
signal: attemptSignal.signal
|
|
1128
|
+
});
|
|
1129
|
+
if (response.status !== 429 || attempt === maxRetries) {
|
|
1130
|
+
if (!response.ok) {
|
|
1131
|
+
const errorBody = await readCappedErrorBody(response);
|
|
1132
|
+
attemptSignal.signal.throwIfAborted();
|
|
1133
|
+
throw new SilmarilApiError({
|
|
1134
|
+
status: response.status,
|
|
1135
|
+
statusText: response.statusText,
|
|
1136
|
+
body: errorBody
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
return await readBodyUnderSignal(
|
|
1140
|
+
() => response.json(),
|
|
1141
|
+
attemptSignal.signal
|
|
1142
|
+
);
|
|
1061
1143
|
}
|
|
1062
|
-
|
|
1144
|
+
await discardResponseBody(response);
|
|
1145
|
+
} finally {
|
|
1146
|
+
attemptSignal.dispose();
|
|
1063
1147
|
}
|
|
1064
|
-
|
|
1065
|
-
await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1e3));
|
|
1148
|
+
await sleep(Math.min(2 ** attempt, MAX_BACKOFF_SECONDS) * 1e3, callerSignal);
|
|
1066
1149
|
}
|
|
1067
1150
|
throw new Error("Firewall: exhausted retries (unreachable)");
|
|
1068
1151
|
}
|
|
@@ -1082,7 +1165,7 @@ var Firewall = class {
|
|
|
1082
1165
|
...metadataInfo,
|
|
1083
1166
|
...options.governance === void 0 ? {} : { governance: options.governance }
|
|
1084
1167
|
});
|
|
1085
|
-
const data = await this.postWithRetry(payload);
|
|
1168
|
+
const data = await this.postWithRetry(payload, options.signal);
|
|
1086
1169
|
return blockResultFromResponse(data, requestedMode);
|
|
1087
1170
|
}
|
|
1088
1171
|
};
|