Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 23 additions & 9 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
name: Release

on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
tag:
description: "Existing release tag to (re)publish, e.g. v0.4.0"
required: true
type: string

permissions:
contents: write # create the GitHub Release
id-token: write # NuGet Trusted Publishing (OIDC)

# Release is manual-only: the tag to publish comes from the workflow_dispatch input.
# Every job/step uses REF_NAME (not GITHUB_REF_NAME) and checks out this tag.
env:
REF_NAME: ${{ github.event.inputs.tag }}

jobs:
release:
runs-on: ubuntu-latest
Expand All @@ -16,10 +25,10 @@ jobs:

- name: Verify tag matches VersionPrefix
run: |
VERSION="${GITHUB_REF_NAME#v}"
VERSION="${REF_NAME#v}"
PREFIX=$(grep -oPm1 '(?<=<VersionPrefix>)[^<]+' Directory.Build.props)
if [ "$VERSION" != "$PREFIX" ]; then
echo "Tag $GITHUB_REF_NAME does not match VersionPrefix $PREFIX in Directory.Build.props" >&2
echo "Tag $REF_NAME does not match VersionPrefix $PREFIX in Directory.Build.props" >&2
exit 1
fi

Expand Down Expand Up @@ -59,12 +68,17 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
run: |
args=(--title "BotWire $GITHUB_REF_NAME" --generate-notes)
notes="docs/release-notes/$GITHUB_REF_NAME.md"
args=(--title "BotWire $REF_NAME" --generate-notes)
notes="docs/release-notes/$REF_NAME.md"
if [ -f "$notes" ]; then
args+=(--notes-file "$notes")
fi
gh release create "$GITHUB_REF_NAME" artifacts/*.nupkg artifacts/*.snupkg "${args[@]}"
# On a re-run the release may already exist; create it, else just upload assets.
if gh release view "$REF_NAME" >/dev/null 2>&1; then
gh release upload "$REF_NAME" artifacts/*.nupkg artifacts/*.snupkg --clobber
else
gh release create "$REF_NAME" artifacts/*.nupkg artifacts/*.snupkg "${args[@]}"
fi

npm:
needs: release # only publish the JS SDK once the .NET release succeeded
Expand All @@ -80,10 +94,10 @@ jobs:

- name: Verify tag matches package.json version
run: |
VERSION="${GITHUB_REF_NAME#v}"
VERSION="${REF_NAME#v}"
PKG=$(node -p "require('./package.json').version")
if [ "$VERSION" != "$PKG" ]; then
echo "Tag $GITHUB_REF_NAME does not match botwire-js version $PKG in package.json" >&2
echo "Tag $REF_NAME does not match botwire-js version $PKG in package.json" >&2
exit 1
fi

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,6 @@ yarn-error.log*
.DS_Store
Thumbs.db
.mcp.json
.claude/settings.json
samples/RedisShop/web/tsconfig.tsbuildinfo
samples/RedisShop/web/.vite/
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<LangVersion>latest</LangVersion>

<!-- Version -->
<VersionPrefix>0.3.0</VersionPrefix>
<VersionPrefix>0.4.0</VersionPrefix>

<!-- NuGet metadata -->
<Authors>Object IT Limited</Authors>
Expand Down
28 changes: 23 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -400,10 +400,11 @@ provider charges. When you deploy BotWire:
### Customer PII

Handling your customers' personal data is **your responsibility**. BotWire ships
a best-effort PII guard (enabled by default) that **blocks** user messages
matching common patterns — email addresses, phone numbers, and credit-card-like
numbers — before they are sent to the AI provider. Add your own patterns via
`PiiGuard.AdditionalPatterns`:
a best-effort PII guard (enabled by default), backed by
[RedactWire](https://github.com/adamy/RedactWire), that **blocks** user messages
containing personal data — email addresses, credit-card numbers, IP addresses,
IBANs, API keys/secrets, and country-specific identifiers — before they are sent
to the AI provider. Add your own patterns via `PiiGuard.AdditionalPatterns`:

```csharp
builder.Services.AddBotWire(opts =>
Expand All @@ -412,7 +413,24 @@ builder.Services.AddBotWire(opts =>
});
```

This guard is regex-based and **not exhaustive**: it will not catch every form
For full control — extra country rule packs, custom `IPiiRule`s, overlap strategy —
configure the underlying RedactWire detector via `PiiGuard.ConfigureDetector`:

```csharp
using System.Globalization;
using RedactWire;

builder.Services.AddBotWire(opts =>
{
opts.PiiGuard.ConfigureDetector = b => b
.AddCulture(new CultureInfo("en-US")); // enable a country's ID rule pack
});
```

Secret detection and the culture-agnostic rules (email, credit card, IP, IBAN)
are on by default; the default culture is `CultureInfo.CurrentCulture`.

This guard is regex/checksum-based and **not exhaustive**: it will not catch every form
of personal data, and it rejects rather than redacts. You must confirm, for your
own jurisdiction and data, that no personal data you are not permitted to share
is sent to your AI provider — for example by tuning the patterns, restricting
Expand Down
2 changes: 1 addition & 1 deletion npm/botwire-js/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "botwire-js",
"version": "0.3.0",
"version": "0.4.0",
"description": "Framework-agnostic JS/TS client for the BotWire support API — chat, SSE streaming, and session management with zero DOM dependencies.",
"license": "AGPL-3.0-or-later",
"author": "Object IT Limited",
Expand Down
2 changes: 1 addition & 1 deletion npm/botwire-js/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export interface BotWireConfig {

/** Result of a non-streaming {@link BotWireClient.chat} call. */
export interface BotWireResponse {
/** Server status, e.g. `Answered`, `NeedHuman`, `TicketCreated`, `Blocked`, `RateLimited`. */
/** Server status, e.g. `Answered`, `NeedHuman`, `TicketCreated`, `Blocked`, `PiiBlocked`, `RateLimited`. */
status: string;
/** The assistant message (or status explanation). */
message: string;
Expand Down
9 changes: 7 additions & 2 deletions npm/botwire-js/src/widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -541,9 +541,14 @@ class BotWireWidget extends HTMLElement {
if (err instanceof DOMException && err.name === 'AbortError') {
// user navigated away / aborted — stay silent
} else if (err instanceof BotWireError) {
// server rejected the turn — surface the host-configured message
this.errorOccurred = true;
this.appendMessage('sys', this.errorMessage);
// Guard rejections (PII, prompt-injection, message-too-long, rate-limit) carry a
// deliberate, user-facing message from the server — surface it so the user knows
// why their message was refused (e.g. it contained personal data) instead of a
// generic failure. True transport/server faults fall back to the configured error.
const GUARD_STATUSES = ['PiiBlocked', 'Blocked', 'RateLimited'];
const msg = GUARD_STATUSES.includes(err.status) && err.message ? err.message : this.errorMessage;
this.appendMessage('sys', msg);
} else {
this.appendMessage('sys', 'Connection error. Please try again.');
}
Expand Down
16 changes: 15 additions & 1 deletion npm/botwire-js/tests/widget.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,21 @@ describe('widget session self-healing', () => {
expect(calls.filter(c => c.url.endsWith('/chat/stream'))).toHaveLength(1);
expect(calls.filter(c => c.url.endsWith('/session'))).toHaveLength(0);
expect(sessionStorage.getItem(STORAGE_KEY)).toBe('valid-token');
expect(messagesOf(el, '.msg-sys')).toHaveLength(1);
// Guard rejections surface the server's specific message, not the generic error.
expect(messagesOf(el, '.msg-sys')).toEqual(['Message too long.']);
});

it('shows the server message for a PiiBlocked rejection, not the generic error', async () => {
sessionStorage.setItem(STORAGE_KEY, 'valid-token');
const pii = 'Your message contains sensitive information and cannot be processed.';
stubFetch(() =>
jsonResponse(400, { status: 'PiiBlocked', message: pii, sessionToken: 'valid-token' }));

const el = mountWidget();
sendMessage(el, 'email me at a@b.com');
await streamFinished(el);

expect(messagesOf(el, '.msg-sys')).toEqual([pii]);
});
});

Expand Down
8 changes: 7 additions & 1 deletion samples/RedisShop/web/src/ChatWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,13 @@ export const ChatWidget = forwardRef<ChatHandle>(function ChatWidget(_props, ref
}
} catch (e) {
if (controller.signal.aborted) return; // reset/close superseded this turn
const msg = e instanceof BotWireError ? e.message : 'Something went wrong. Please try again.';
// Guard rejections (PII, prompt-injection, message-too-long, rate-limit) carry a
// deliberate, user-facing message from the server — surface it so the user knows why
// their message was refused (e.g. it contained personal data). Duck-typed on `status`
// so it holds across botwire-js versions; transport faults get a generic message.
const err = e as Partial<BotWireError>;
const isGuard = !!err?.status && ['PiiBlocked', 'Blocked', 'RateLimited'].includes(err.status);
const msg = isGuard && err.message ? err.message : 'Something went wrong. Please try again.';
setMessages((m) => replaceLastBotIfEmpty(m, msg));
} finally {
if (abortRef.current === controller) {
Expand Down
33 changes: 18 additions & 15 deletions src/BotWire.AspNetCore/BotWireChatService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ internal sealed class BotWireChatService
private readonly IAuditLogger _audit;
private readonly IOptions<BotWireOptions> _options;
private readonly IOptions<PiiGuardOptions> _piiOptions;
private readonly IOptions<PromptInjectionOptions> _injectionOptions;

public BotWireChatService(
IAnswerProvider answers,
Expand All @@ -80,20 +81,22 @@ public BotWireChatService(
ISummaryCompressor compressor,
IAuditLogger audit,
IOptions<BotWireOptions> options,
IOptions<PiiGuardOptions> piiOptions)
IOptions<PiiGuardOptions> piiOptions,
IOptions<PromptInjectionOptions> injectionOptions)
{
_answers = answers;
_sessions = sessions;
_tokens = tokens;
_piiGuard = piiGuard;
_injectionGuard = injectionGuard;
_rateLimiter = rateLimiter;
_rl = rl;
_rlOptions = rlOptions.Value;
_compressor = compressor;
_audit = audit;
_options = options;
_piiOptions = piiOptions;
_answers = answers;
_sessions = sessions;
_tokens = tokens;
_piiGuard = piiGuard;
_injectionGuard = injectionGuard;
_rateLimiter = rateLimiter;
_rl = rl;
_rlOptions = rlOptions.Value;
_compressor = compressor;
_audit = audit;
_options = options;
_piiOptions = piiOptions;
_injectionOptions = injectionOptions;
}

/// <summary>
Expand Down Expand Up @@ -441,13 +444,13 @@ private static int CountUserMessages(ConversationSession session)
if (pii.Blocked)
{
await _audit.LogAsync(AuditEvents.GuardBlocked(sessionId, "pii"), ct);
return new ChatResult("Blocked", _piiOptions.Value.RejectionMessage, sessionId, null, 400);
return new ChatResult("PiiBlocked", _piiOptions.Value.RejectionMessage, sessionId, null, 400);
}

if (_injectionGuard.IsInjectionAttempt(message))
{
await _audit.LogAsync(AuditEvents.GuardBlocked(sessionId, "prompt_injection"), ct);
return new ChatResult("Blocked", _piiOptions.Value.RejectionMessage, sessionId, null, 400);
return new ChatResult("Blocked", _injectionOptions.Value.RejectionMessage, sessionId, null, 400);
}

return null;
Expand Down
24 changes: 22 additions & 2 deletions src/BotWire.AspNetCore/BotWireEndpointExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,10 @@ await service.CommitStreamAsync(

private static readonly byte[] _widgetJs = LoadWidgetJs();

// Content-hash ETag so browsers revalidate cheaply (304) yet pick up a rebuilt widget
// immediately — a long max-age would otherwise serve a stale bundle for up to an hour.
private static readonly string _widgetJsETag = ComputeETag(_widgetJs);

private static byte[] LoadWidgetJs()
{
var asm = typeof(BotWireEndpointExtensions).Assembly;
Expand All @@ -210,10 +214,26 @@ private static byte[] LoadWidgetJs()
return ms.ToArray();
}

private static string ComputeETag(byte[] bytes)
{
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
return $"\"{Convert.ToHexString(hash, 0, 8)}\"";
}

private static async Task HandleWidgetJs(HttpContext context)
{
context.Response.ContentType = "application/javascript; charset=utf-8";
context.Response.Headers.CacheControl = "public, max-age=3600";
// must-revalidate: cache is allowed but the browser must check the ETag on every use,
// so a new widget build is served the moment it changes (no hour-long staleness).
context.Response.Headers.CacheControl = "no-cache";
context.Response.Headers.ETag = _widgetJsETag;

if (context.Request.Headers.IfNoneMatch.ToString().Contains(_widgetJsETag))
{
context.Response.StatusCode = StatusCodes.Status304NotModified;
return;
}

context.Response.ContentType = "application/javascript; charset=utf-8";
await context.Response.Body.WriteAsync(_widgetJs);
}

Expand Down
6 changes: 3 additions & 3 deletions src/BotWire.AspNetCore/BotWireOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,9 @@ public sealed class BotWireOptions
public PromptInjectionOptions PromptInjection { get; set; } = new();

/// <summary>
/// PII detection settings. Enabled by default — blocks user messages matching
/// common personal-data patterns (email, phone, credit-card) before they reach
/// the AI provider. Add your own patterns via <see cref="PiiGuardOptions.AdditionalPatterns"/>.
/// PII detection settings. Enabled by default — blocks user messages containing
/// personal data (email, phone, credit-card, secrets, ...) before they reach the AI
/// provider. Backed by RedactWire; customize via <see cref="PiiGuardOptions.ConfigureDetector"/>.
/// </summary>
public PiiGuardOptions PiiGuard { get; set; } = new();

Expand Down
2 changes: 2 additions & 0 deletions src/BotWire.AspNetCore/BotWireServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,13 @@ public static IServiceCollection AddBotWire(
o.Enabled = opts.PiiGuard.Enabled;
o.RejectionMessage = opts.PiiGuard.RejectionMessage;
o.AdditionalPatterns = opts.PiiGuard.AdditionalPatterns;
o.ConfigureDetector = opts.PiiGuard.ConfigureDetector;
},
configureRateLimit: o => o.MaxRequestsPerIpPerMinute = opts.MaxRequestsPerIpPerMinute,
configureInjection: o =>
{
o.Enabled = opts.PromptInjection.Enabled;
o.RejectionMessage = opts.PromptInjection.RejectionMessage;
o.AdditionalPatterns = opts.PromptInjection.AdditionalPatterns;
});

Expand Down
Loading