@silmaril-security/sdk 0.7.0 → 0.7.2

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 CHANGED
@@ -2,14 +2,30 @@
2
2
 
3
3
  All notable changes to the Silmaril Firewall TypeScript SDK are documented here.
4
4
 
5
+ ## 0.7.2 - 2026-10-05
6
+
7
+ - Clear the attempt timeout immediately on caller cancellation, including when
8
+ retry-response body disposal never settles. This prevents residual timers
9
+ from keeping command-hook processes alive beyond their shared deadline.
10
+ - Make retried-response cleanup respect caller cancellation and attempt timeouts
11
+ even when a stream cancellation promise never settles.
12
+
13
+ ## 0.7.1 - 2026-10-04
14
+
15
+ - Reject malformed batch responses and result counts that do not match the
16
+ serialized request, including retried requests whose caller inputs change.
17
+
5
18
  ## 0.7.0 - 2026-09-30
6
19
 
7
20
  - Add optional Deep Agents middleware and protected constructors for root, general-purpose,
8
21
  declarative, and explicitly protected compiled subagents. The compiled graph factory
9
22
  records middleware installation and constructors reject unverified graphs.
23
+ - Declare optional peer dependencies for Deep Agents:
24
+ `deepagents@^1.14.1`, `langchain@^1.5.10`, `@langchain/langgraph@^1.4.10`, and
25
+ existing `@langchain/core@>=0.3.0`.
10
26
  - Enforce input, tool-call, tool-result, and non-streamed model-output decisions in
11
- Block mode with safe continuation and a repeated-denial cap. Shadow and Warn report
12
- decisions without changing content.
27
+ Block mode with safe continuation and a repeated-denial cap (`maxBlockedAttempts`,
28
+ default `3`). Shadow and Warn report decisions without changing content.
13
29
  - Enable LangChain tool start/end callbacks by default and send the LangChain run ID
14
30
  as correlation metadata alongside a distinct classification request ID per event.
15
31
 
package/README.md CHANGED
@@ -18,13 +18,13 @@ This SDK provides the low-level TypeScript interface for that workflow:
18
18
  - Classify user input, tool calls, tool responses, model output, or system
19
19
  prompt content.
20
20
  - Preserve hook and tool-name context for more accurate decisions.
21
- - Enforce backend-owned adaptive thresholds and effective Shadow, Warn, or
21
+ - Honor backend threat and governance decisions and effective Shadow, Warn, or
22
22
  Block behavior in adapters.
23
23
  - Send each complete sanitized event in one request.
24
- - Preserve exact `metadata.conversationId` sequence identity and add one event ID.
24
+ - On individual `classify()` calls, preserve exact `metadata.conversationId` as sequence identity and add one event ID.
25
25
  - Retry API rate-limit responses.
26
- - Optionally attach the firewall to Vercel AI SDK middleware and LangChain.js
27
- callback flows.
26
+ - Optionally attach the firewall to Vercel AI SDK middleware, LangChain.js
27
+ callback flows, and Deep Agents middleware/constructors.
28
28
 
29
29
  ## Install
30
30
 
@@ -37,7 +37,7 @@ 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.7.0
40
+ npm install @silmaril-security/sdk@0.7.2
41
41
  ```
42
42
 
43
43
  Requires Node 20 or later.
@@ -63,10 +63,10 @@ Optional LangChain.js support:
63
63
  npm install @langchain/core @langchain/openai
64
64
  ```
65
65
 
66
- Optional Deep Agents support:
66
+ Optional Deep Agents support (peer ranges shipped with `0.7.0`):
67
67
 
68
68
  ```sh
69
- npm install deepagents langchain @langchain/core @langchain/langgraph
69
+ npm install "deepagents@^1.14.1" "langchain@^1.5.10" "@langchain/core@>=0.3.0" "@langchain/langgraph@^1.4.10"
70
70
  ```
71
71
 
72
72
  ## Configuration
@@ -119,15 +119,21 @@ console.log(`tool output: ${toolResult.prediction} ${toolResult.score.toFixed(4)
119
119
  ```
120
120
 
121
121
  `classify()` and `classifyBatch()` return the server's prediction, score, and
122
- internally applied threshold. Direct calls do not throw on malicious verdicts.
123
- The Vercel AI SDK and LangChain.js adapters use `result.threshold` and throw
124
- `FirewallBlockedException` when enforcement is enabled.
122
+ diagnostic threshold. Direct calls do not throw on a `MALICIOUS` prediction or
123
+ a governance block. The Vercel AI SDK and LangChain.js adapters throw
124
+ `FirewallBlockedException` only when `prediction` is `MALICIOUS` or
125
+ `governance.action` is `block`, and the effective mode is `block`.
126
+ `result.threshold` is backend-returned diagnostic metadata carried on the
127
+ result and the exception. Disabled and observe policy paths can retain the
128
+ compatibility threshold. The adapters do not compare `score` to a local
129
+ threshold.
125
130
 
126
131
  ## Concurrency and Cancellation
127
132
 
128
133
  A `Firewall` instance holds no per-request state, so one client can serve any
129
- number of concurrent calls in a runtime. Responses are matched to their own
130
- promise regardless of completion order:
134
+ number of concurrent calls in a runtime. Concurrent calls are appropriate for
135
+ independent events or different conversations. Responses are matched to their
136
+ own promise regardless of completion order:
131
137
 
132
138
  ```ts
133
139
  const [userResult, toolResult] = await Promise.all([
@@ -136,6 +142,10 @@ const [userResult, toolResult] = await Promise.all([
136
142
  ]);
137
143
  ```
138
144
 
145
+ Events that belong to one conversation are not independent. Send them with
146
+ ordered individual `classify()` calls that share `metadata.conversationId`, and
147
+ wait for each call before sending the next event for that conversation.
148
+
139
149
  Pass a `signal` to cancel one call without touching its siblings:
140
150
 
141
151
  ```ts
@@ -242,17 +252,16 @@ to each attempt and is combined with any caller-supplied `signal`.
242
252
 
243
253
  ## Backend Thresholding
244
254
 
245
- Customers do not tune score thresholds in the SDK. Tenant Firewall config owns
246
- the adaptive threshold schedule. The default backend config is
247
- `base_threshold=0.5`, `target_sequence_fpr=0.01`, and
248
- `max_adaptive_threshold=0.9`, which keeps the current schedule: 1 scoring
249
- opportunity uses `0.5`, 2 use about `0.6661`, 5 use about `0.8328`, and 10 or
250
- more are capped at `0.9`.
255
+ Customers do not tune score thresholds in the SDK. The Firewall backend owns
256
+ the threat decision and threshold policy. The current Cascade backend resolves
257
+ decision thresholds from a tenant default or a hook-specific override; it does
258
+ not raise them as text length, token-window count, batch size, or conversation
259
+ length grows.
251
260
 
252
- The SDK does not send `threshold` in request payloads. The backend owns the
253
- applied threshold, which remains available on
254
- `BlockResult.threshold` and `FirewallBlockedException.threshold` as diagnostic
255
- metadata.
261
+ The SDK does not send `threshold` in request payloads. `BlockResult.threshold`
262
+ and `FirewallBlockedException.threshold` are backend-returned diagnostic
263
+ metadata. Disabled and observe policy paths can retain the compatibility
264
+ threshold.
256
265
 
257
266
  ## Modes
258
267
 
@@ -350,14 +359,21 @@ await fw.classify(text, {
350
359
  ```
351
360
 
352
361
  The SDK preserves caller metadata and adds a reserved `metadata.silmaril`
353
- namespace to every request. SDK-controlled fields are `sdk_language`,
354
- `sdk_version`, and `request_id`; batches additionally carry `input_index` for
355
- diagnostics and remain stateless. `governance` is also SDK-controlled whenever
356
- the typed governance option is supplied; it replaces any caller-provided
357
- `metadata.silmaril.governance` value. Exact `metadata.conversationId` is
358
- preserved as the backend sequence identity. No aliases are inspected. If
359
- callers provide `metadata.silmaril`, it must be an object and SDK-reserved keys
360
- are overwritten by the SDK.
362
+ namespace to every request, including each batch item. SDK-controlled fields
363
+ are `sdk_language`, `sdk_version`, and `request_id`; batches additionally
364
+ carry `input_index` for diagnostics. `governance` is also SDK-controlled
365
+ whenever the typed governance option is supplied; it replaces any
366
+ caller-provided `metadata.silmaril.governance` value. On individual
367
+ `classify()` calls, exact `metadata.conversationId` is preserved as the
368
+ backend sequence identity. Batch items also preserve per-item metadata,
369
+ including `metadata.conversationId`, but current Cascade treats each input
370
+ independently and neither reads nor updates conversation history. Giving items
371
+ the same `metadata.conversationId` does not connect them into a sequence. For
372
+ conversation-aware checks, send complete events through ordered individual
373
+ `classify()` calls with the same `metadata.conversationId`, and wait for each
374
+ call before sending the next event for that conversation. No aliases are
375
+ inspected. If callers provide `metadata.silmaril`, it must be an object and
376
+ SDK-reserved keys are overwritten by the SDK.
361
377
 
362
378
  The `governance` option on `classify()` and per-item `governance` array on
363
379
  `classifyBatch()` send typed agent/resource context. `BlockResult.governance`
@@ -370,16 +386,22 @@ responses without governance remain valid.
370
386
  - `SilmarilApiError`: thrown when the firewall API responds with a non-2xx or redirect status. Carries `status`, `statusText`, a 64 KiB-capped `body`, and any parsed malformed-input diagnostics. The default error message omits the body to keep logs clean.
371
387
  - `FirewallBlockedException`: thrown by the Vercel AI SDK and LangChain.js adapters when a malicious or governance-block decision has effective Block mode. The message distinguishes governance-policy denials from threat-score denials. Carries `score`, `threshold`, `promptText`, and optional `runId`, `hook`, `toolName`, `toolCallId`, and `result`.
372
388
 
373
- `PromptBlockedException` remains as a deprecated alias for one release.
389
+ `PromptBlockedException` remains as a deprecated alias of
390
+ `FirewallBlockedException`.
374
391
 
375
392
  All SDK exception types extend `Error` and work with `instanceof`.
376
393
 
377
394
  ## Complete events
378
395
 
379
396
  `classify()` sanitizes invalid Unicode surrogate fragments and sends the full
380
- logical event once. The backend owns token-window processing and sequence
381
- ordering. `classifyBatch()` continues to send independent stateless texts as one
382
- batch request.
397
+ logical event once. For conversation-aware checks, send those complete events
398
+ through ordered individual `classify()` calls with the same
399
+ `metadata.conversationId`, and wait for each call before sending the next
400
+ event for that conversation. The backend owns token-window processing and
401
+ sequence ordering for those individual calls. `classifyBatch()` sends
402
+ independent texts in one request: each item keeps its metadata, including
403
+ `metadata.conversationId`, but Cascade neither reads nor updates conversation
404
+ history for the batch.
383
405
 
384
406
  ## Batch Classification
385
407
 
@@ -403,9 +425,36 @@ console.log(`classified ${results.length} items`);
403
425
  ```
404
426
 
405
427
  Batch requests preserve result order and can carry per-item hooks, tool names,
406
- and metadata. Hook, tool-name, and metadata arrays must match the number of
407
- texts. Each batch carries SDK metadata per item so the backend can apply
408
- tenant-owned thresholding.
428
+ metadata, and governance. Hook, tool-name, metadata, and governance arrays must
429
+ match the number of texts. Each batch item preserves caller metadata, including
430
+ `metadata.conversationId`, and carries SDK metadata so the backend can apply
431
+ tenant-owned thresholding. Current Cascade treats each input independently and
432
+ neither reads nor updates conversation history. Giving items the same
433
+ `metadata.conversationId` does not connect them into a sequence.
434
+
435
+ For conversation-aware checks, send complete events through ordered individual
436
+ `classify()` calls with the same `metadata.conversationId`. Wait for each call
437
+ before sending the next event for that conversation:
438
+
439
+ ```ts
440
+ const conversationId = "customer-conversation-123";
441
+
442
+ const first = await fw.classify(firstEvent, {
443
+ hook: HookLabel.USER_INPUT,
444
+ metadata: { conversationId },
445
+ });
446
+
447
+ const second = await fw.classify(secondEvent, {
448
+ hook: HookLabel.TOOL_RESPONSE,
449
+ toolName: "read_file",
450
+ metadata: { conversationId },
451
+ });
452
+ ```
453
+
454
+ Batch responses must contain a `predictions` array with exactly one result per
455
+ sent input. A missing array or mismatched count raises an `Error` before
456
+ results are returned or blocking callbacks run. Retries use the original
457
+ payload snapshot, even if caller-owned inputs change while the request is in flight.
409
458
 
410
459
  ## Migration Notes
411
460
 
@@ -416,7 +465,7 @@ tenant/backend config, adds SDK reconstruction metadata, renames blocking
416
465
  exceptions to `FirewallBlockedException`, keeps optional LangChain.js types out
417
466
  of the root package declarations, adds typed CJS/ESM export conditions, supports
418
467
  Vercel AI SDK v5 and v6. The deprecated
419
- `PromptBlockedException` alias remains available for one release.
468
+ `PromptBlockedException` alias remains available.
420
469
 
421
470
  ## Vercel AI SDK Middleware
422
471
 
@@ -439,10 +488,13 @@ const { text } = await generateText({ model, prompt: "Hello" });
439
488
  console.log(text);
440
489
  ```
441
490
 
442
- Middleware scans input by default. Set `scanOutput: true` to classify model
443
- text, and `scanToolCalls: true` to classify tool-call arguments. Infrastructure
444
- errors and blocking decisions are fail-closed by default and bubble up to the
445
- caller.
491
+ Middleware scans input by default (`scanInput` defaults to true). It classifies
492
+ that message's tool-result parts as `tool_response` only when the newest prompt
493
+ message is a tool message. Otherwise it classifies the latest user message as
494
+ `user_input` and does not scan earlier tool results. Set `scanOutput: true` to
495
+ classify model text, and `scanToolCalls: true` to classify tool-call arguments
496
+ on generate results. Infrastructure errors and blocking decisions are
497
+ fail-closed by default and bubble up to the caller.
446
498
 
447
499
  When `scanOutput: true` is combined with streaming, output is classified in the
448
500
  stream's `flush` after all deltas have been emitted, so blocking is advisory:
@@ -473,11 +525,11 @@ await model.invoke("Hello");
473
525
 
474
526
  The LangChain handler is fail-open by default: infrastructure errors are logged
475
527
  and the LLM call proceeds. Set `failOpen: false` to make API errors bubble up.
476
- Blocking decisions still throw `FirewallBlockedException` unless shadow mode is
477
- enabled. `PromptBlockedException` continues to work as a deprecated alias for
478
- one release.
528
+ Blocking decisions throw `FirewallBlockedException` only when the effective mode
529
+ is Block; Shadow and Warn preserve the host flow.
530
+ `PromptBlockedException` continues to work as a deprecated alias.
479
531
  Model-start, tool-start, and tool-end hooks are enabled by default; retriever
480
- hooks remain opt-in. The LangChain run ID is sent as
532
+ and model-end (`LLM_END`) hooks remain opt-in. The LangChain run ID is sent as
481
533
  `metadata.langgraph.run_id`; every classification gets a distinct
482
534
  `metadata.silmaril.request_id`. Supply `conversationId` to the handler for
483
535
  sequence identity; it sends `metadata.conversationId`. Callback blocks throw
@@ -515,17 +567,19 @@ model, tools })` and pass its returned spec through
515
567
  `protectedCompiledSubagents`. That factory installs middleware before
516
568
  compilation. The parent constructor verifies the exact graph and Firewall
517
569
  client; it rejects arbitrary compiled runnables. Root custom middleware does
518
- not automatically reach every subagent.
570
+ not automatically reach every subagent. For custom agent construction, import
571
+ `createDeepAgentsMiddleware` from the same adapter subpath.
519
572
 
520
573
  The middleware checks input before model use, tool calls before execution,
521
574
  tool results before the next model call, and non-streamed model output before
522
575
  the graph consumes it. In Block mode, denied tool interactions become a fixed
523
576
  safe `ToolMessage` with the original call ID. The agent can choose an allowed
524
- alternative; repeated denials end with a fixed safe response. Denied model
525
- output is replaced. Shadow and Warn report decisions through `onClassify`
526
- without replacing content. Classification errors allow model and tool execution
527
- by default; set `silmaril: { failOpen: false }` to require a successful
528
- classification. Already emitted streaming text cannot be recalled.
577
+ alternative; after `maxBlockedAttempts` denials (default `3`), the middleware
578
+ returns a fixed safe final response. Denied model output is replaced. Shadow
579
+ and Warn report decisions through `onClassify` without replacing content.
580
+ Classification errors allow model and tool execution by default; set
581
+ `silmaril: { failOpen: false }` to require a successful classification. Already
582
+ emitted streaming text cannot be recalled.
529
583
 
530
584
  ## Retries
531
585
 
package/dist/index.cjs CHANGED
@@ -818,7 +818,7 @@ function sanitizeText(text) {
818
818
  }
819
819
 
820
820
  // src/firewall.ts
821
- var SDK_VERSION = "0.7.0";
821
+ var SDK_VERSION = "0.7.2";
822
822
  var DEFAULT_TIMEOUT_MS = 1e4;
823
823
  var DEFAULT_MAX_RETRIES = 5;
824
824
  var MAX_BACKOFF_SECONDS = 30;
@@ -962,7 +962,7 @@ async function readCappedErrorBody(response) {
962
962
  }
963
963
  return new TextDecoder().decode(body);
964
964
  }
965
- async function discardResponseBody(response) {
965
+ async function releaseDiscardedResponseBody(response) {
966
966
  try {
967
967
  if (response.body) {
968
968
  if (!response.body.locked) {
@@ -974,6 +974,19 @@ async function discardResponseBody(response) {
974
974
  } catch {
975
975
  }
976
976
  }
977
+ async function discardResponseBody(response, signal) {
978
+ signal.throwIfAborted();
979
+ let onAbort;
980
+ const aborted = new Promise((_resolve, reject) => {
981
+ onAbort = () => reject(signal.reason);
982
+ signal.addEventListener("abort", onAbort, { once: true });
983
+ });
984
+ try {
985
+ await Promise.race([releaseDiscardedResponseBody(response), aborted]);
986
+ } finally {
987
+ signal.removeEventListener("abort", onAbort);
988
+ }
989
+ }
977
990
  function isAbortLikeError(error) {
978
991
  if (typeof error !== "object" || error === null) {
979
992
  return false;
@@ -999,6 +1012,7 @@ function createAttemptSignal(timeoutMs, callerSignal) {
999
1012
  );
1000
1013
  }, timeoutMs);
1001
1014
  const onCallerAbort = () => {
1015
+ clearTimeout(timer);
1002
1016
  controller.abort(callerSignal?.reason);
1003
1017
  };
1004
1018
  callerSignal?.addEventListener("abort", onCallerAbort, { once: true });
@@ -1109,6 +1123,14 @@ var Firewall = class {
1109
1123
  })
1110
1124
  );
1111
1125
  const data = await this.postWithRetry(payload, options.signal);
1126
+ if (!Array.isArray(data.predictions)) {
1127
+ throw new Error("Firewall: response predictions must be an array");
1128
+ }
1129
+ if (data.predictions.length !== payload.texts.length) {
1130
+ throw new Error(
1131
+ `Firewall: response predictions length ${data.predictions.length} does not match submitted texts length ${payload.texts.length}`
1132
+ );
1133
+ }
1112
1134
  return data.predictions.map((p) => blockResultFromResponse(p, requestedMode));
1113
1135
  }
1114
1136
  asLangChainHandler(options = {}) {
@@ -1148,7 +1170,7 @@ var Firewall = class {
1148
1170
  attemptSignal.signal
1149
1171
  );
1150
1172
  }
1151
- await discardResponseBody(response);
1173
+ await discardResponseBody(response, attemptSignal.signal);
1152
1174
  } finally {
1153
1175
  attemptSignal.dispose();
1154
1176
  }