From 8436ce14fed4ff8adc413e756e3156f0b0349faa Mon Sep 17 00:00:00 2001 From: Adam Yang Date: Wed, 24 Jun 2026 20:55:42 +1200 Subject: [PATCH 1/5] feat: replace RegexPiiGuard with RedactWire-backed PII guard Swap the built-in 4-pattern RegexPiiGuard for RedactWire (sister project): invariant rules (email, credit card, IP, IBAN) + secret/credential detection, running against the default culture (CultureInfo.CurrentCulture). - Add RedactWire 0.3.0 PackageReference to BotWire.Core. - PiiGuardOptions gains ConfigureDetector (Action) for full RedactWire customization (cultures, custom IPiiRule, overlap strategy). - AdditionalPatterns retained (no breaking change), bridged to RedactWire custom rules via TimeoutRegexRule, which compiles each user regex with a 100ms match timeout to bound ReDoS; on timeout it logs and degrades to no-match. - Check() reports the earliest in-text match and short-circuits blank input. Note: PiiGuardOptions now exposes RedactWire's PiiDetectorBuilder, so RedactWire (and System.Text.Json transitively) is a public dependency of BotWire.Core. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 1 + README.md | 28 +++- src/BotWire.AspNetCore/BotWireOptions.cs | 6 +- .../BotWireServiceCollectionExtensions.cs | 1 + src/BotWire.Core/BotWire.Core.csproj | 1 + .../Guard/GuardServiceCollectionExtensions.cs | 4 +- src/BotWire.Core/Guard/PiiGuardOptions.cs | 23 ++- src/BotWire.Core/Guard/RedactWirePiiGuard.cs | 86 ++++++++++ src/BotWire.Core/Guard/RegexPiiGuard.cs | 85 ---------- src/BotWire.Core/Guard/TimeoutRegexRule.cs | 68 ++++++++ .../Guard/RedactWirePiiGuardTests.cs | 156 ++++++++++++++++++ .../Guard/RegexPiiGuardTests.cs | 100 ----------- 12 files changed, 360 insertions(+), 199 deletions(-) create mode 100644 src/BotWire.Core/Guard/RedactWirePiiGuard.cs delete mode 100644 src/BotWire.Core/Guard/RegexPiiGuard.cs create mode 100644 src/BotWire.Core/Guard/TimeoutRegexRule.cs create mode 100644 tests/BotWire.Core.Tests/Guard/RedactWirePiiGuardTests.cs delete mode 100644 tests/BotWire.Core.Tests/Guard/RegexPiiGuardTests.cs diff --git a/.gitignore b/.gitignore index fc4c216..a1e7b4e 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md index f720002..291ef54 100644 --- a/README.md +++ b/README.md @@ -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 => @@ -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 diff --git a/src/BotWire.AspNetCore/BotWireOptions.cs b/src/BotWire.AspNetCore/BotWireOptions.cs index f1b2f33..057e7a2 100644 --- a/src/BotWire.AspNetCore/BotWireOptions.cs +++ b/src/BotWire.AspNetCore/BotWireOptions.cs @@ -80,9 +80,9 @@ public sealed class BotWireOptions public PromptInjectionOptions PromptInjection { get; set; } = new(); /// - /// 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 . + /// 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 . /// public PiiGuardOptions PiiGuard { get; set; } = new(); diff --git a/src/BotWire.AspNetCore/BotWireServiceCollectionExtensions.cs b/src/BotWire.AspNetCore/BotWireServiceCollectionExtensions.cs index 77e4884..7442bf0 100644 --- a/src/BotWire.AspNetCore/BotWireServiceCollectionExtensions.cs +++ b/src/BotWire.AspNetCore/BotWireServiceCollectionExtensions.cs @@ -93,6 +93,7 @@ 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 => diff --git a/src/BotWire.Core/BotWire.Core.csproj b/src/BotWire.Core/BotWire.Core.csproj index 228eb2c..a2814e6 100644 --- a/src/BotWire.Core/BotWire.Core.csproj +++ b/src/BotWire.Core/BotWire.Core.csproj @@ -29,6 +29,7 @@ + diff --git a/src/BotWire.Core/Guard/GuardServiceCollectionExtensions.cs b/src/BotWire.Core/Guard/GuardServiceCollectionExtensions.cs index 9b05a11..d18bb82 100644 --- a/src/BotWire.Core/Guard/GuardServiceCollectionExtensions.cs +++ b/src/BotWire.Core/Guard/GuardServiceCollectionExtensions.cs @@ -47,13 +47,13 @@ public static IServiceCollection AddBotWireGuard( services.AddSingleton(sp => { var opts = sp.GetRequiredService>(); - var logger = sp.GetRequiredService>(); + var logger = sp.GetRequiredService>(); if (!opts.Value.Enabled) { logger.LogInformation("BotWire: PiiGuard disabled."); return NullPiiGuard.Instance; } - return new RegexPiiGuard(opts, logger); + return new RedactWirePiiGuard(opts, logger); }); var rlBuilder = services.AddOptions(); diff --git a/src/BotWire.Core/Guard/PiiGuardOptions.cs b/src/BotWire.Core/Guard/PiiGuardOptions.cs index a41091c..2f8ed6c 100644 --- a/src/BotWire.Core/Guard/PiiGuardOptions.cs +++ b/src/BotWire.Core/Guard/PiiGuardOptions.cs @@ -15,24 +15,39 @@ // along with this program. If not, see . using System.ComponentModel.DataAnnotations; +using RedactWire; namespace BotWire.Core.Guard; -/// Configuration for PII detection. +/// Configuration for PII detection. Detection is backed by +/// RedactWire; use +/// to customize the underlying detector directly. public sealed class PiiGuardOptions { - /// Enables PII pattern matching. When false a is used and no patterns are evaluated. Defaults to . + /// Enables PII detection. When false a is used and no rules are evaluated. Defaults to . public bool Enabled { get; set; } = true; - /// Message returned to the caller when a PII pattern is matched. + /// Message returned to the caller when PII is detected. [Required] public string RejectionMessage { get; set; } = "Your message contains sensitive information and cannot be processed."; /// - /// Additional regex patterns (case-insensitive) to block, appended after the built-in defaults. + /// Additional regex patterns (case-insensitive) to block, added as culture-agnostic + /// custom rules after the built-in defaults. Each becomes a RedactWire + /// rule; matches report as invariant:custom-{index}. /// Invalid regex strings are skipped with a warning log at startup. /// public IList AdditionalPatterns { get; set; } = []; + /// + /// Optional hook to configure the RedactWire directly — + /// add cultures, custom IPiiRules, or an overlap strategy. The builder starts from + /// (invariant rules) with + /// already applied and any + /// bridged in; when this is the + /// detector runs those defaults against the default culture + /// (). + /// + public Action? ConfigureDetector { get; set; } } diff --git a/src/BotWire.Core/Guard/RedactWirePiiGuard.cs b/src/BotWire.Core/Guard/RedactWirePiiGuard.cs new file mode 100644 index 0000000..997b51b --- /dev/null +++ b/src/BotWire.Core/Guard/RedactWirePiiGuard.cs @@ -0,0 +1,86 @@ +// BotWire +// Copyright (C) 2026 Object IT Limited +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +using BotWire.Core.Abstractions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using RedactWire; + +namespace BotWire.Core.Guard; + +/// +/// PII guard backed by RedactWire. +/// Builds a single immutable at construction time from the default +/// invariant rules plus secret/credential detection, running against the default culture. +/// Consumers can customize the detector via . +/// +internal sealed class RedactWirePiiGuard : IPiiGuard +{ + // Cap on how long any one user-supplied pattern may scan a message, to bound ReDoS risk. + private static readonly TimeSpan PatternTimeout = TimeSpan.FromMilliseconds(100); + + private readonly bool _enabled; + private readonly PiiDetector _detector; + + public RedactWirePiiGuard(IOptions options, ILogger logger) + { + var opts = options.Value; + _enabled = opts.Enabled; + + // CreateDefault() = invariant rules (email, credit card, IP, IBAN); AddSecretDetection() + // = API keys/tokens. With no AddCulture, Build() resolves against CurrentCulture. + var builder = PiiDetectorBuilder.CreateDefault().AddSecretDetection(); + + // Bridge user regex patterns into culture-agnostic custom rules (case-insensitive, + // matching the legacy guard). Compiled with a match timeout to bound ReDoS. Invalid + // patterns are skipped with a warning, not thrown. + for (var i = 0; i < opts.AdditionalPatterns.Count; i++) + { + try + { + builder.AddInvariantRule( + new TimeoutRegexRule($"custom-{i}", opts.AdditionalPatterns[i], PatternTimeout, logger)); + } + catch (ArgumentException ex) + { + logger.LogWarning(ex, "BotWire: AdditionalPatterns[{Index}] is not a valid regex and was skipped.", i); + } + } + + opts.ConfigureDetector?.Invoke(builder); + _detector = builder.Build(); + + logger.LogInformation("BotWire: PiiGuard enabled (RedactWire), secret detection on."); + } + + public bool IsEnabled => _enabled; + + public PiiCheckResult Check(string message) + { + // Blank input can hold no PII — skip detection (and its per-message allocation). + if (!_enabled || string.IsNullOrWhiteSpace(message)) + return new PiiCheckResult(false, null); + + var result = _detector.Detect(message); + if (!result.HasPii) + return new PiiCheckResult(false, null); + + // Report the earliest match in the text (overlap resolution runs per group, so + // AllMatches is not position-ordered). Rule id e.g. "invariant:Email", "en-US:SSN". + var first = result.AllMatches.OrderBy(m => m.Start).First(); + return new PiiCheckResult(true, first.Rule); + } +} diff --git a/src/BotWire.Core/Guard/RegexPiiGuard.cs b/src/BotWire.Core/Guard/RegexPiiGuard.cs deleted file mode 100644 index edd028c..0000000 --- a/src/BotWire.Core/Guard/RegexPiiGuard.cs +++ /dev/null @@ -1,85 +0,0 @@ -// BotWire -// Copyright (C) 2026 Object IT Limited -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -using System.Text.RegularExpressions; -using BotWire.Core.Abstractions; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace BotWire.Core.Guard; - -/// -/// Regex-based PII guard. Compiles default patterns (email, phone-cn, phone-intl, credit-card) -/// plus any user-supplied patterns at construction time and checks every message against them. -/// -internal sealed class RegexPiiGuard : IPiiGuard -{ - private static readonly (string Pattern, string Name)[] DefaultPatterns = - [ - (@"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}", "email"), - (@"1[3-9]\d{9}", "phone-cn"), - (@"\+\d{7,15}", "phone-intl"), - (@"\b(?:\d[ -]?){13,16}\b", "credit-card"), - ]; - - private readonly bool _enabled; - private readonly (Regex Regex, string Name)[] _compiled; - - public RegexPiiGuard(IOptions options, ILogger logger) - { - var opts = options.Value; - _enabled = opts.Enabled; - - var list = new List<(Regex, string)>(DefaultPatterns.Length + opts.AdditionalPatterns.Count); - - foreach (var (pattern, name) in DefaultPatterns) - list.Add((new Regex(pattern, RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), name)); - - for (var i = 0; i < opts.AdditionalPatterns.Count; i++) - { - try - { - list.Add(( - new Regex(opts.AdditionalPatterns[i], RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), - $"custom-{i}")); - } - catch (ArgumentException ex) - { - logger.LogWarning(ex, "BotWire: AdditionalPatterns[{Index}] is not a valid regex and was skipped.", i); - } - } - - _compiled = [.. list]; - - logger.LogInformation("BotWire: PiiGuard enabled, patterns: {Patterns}", - string.Join(", ", _compiled.Select(p => p.Name))); - } - - public bool IsEnabled => _enabled; - - public PiiCheckResult Check(string message) - { - if (!_enabled) - return new PiiCheckResult(false, null); - - foreach (var (regex, name) in _compiled) - { - if (regex.IsMatch(message)) - return new PiiCheckResult(true, name); - } - return new PiiCheckResult(false, null); - } -} diff --git a/src/BotWire.Core/Guard/TimeoutRegexRule.cs b/src/BotWire.Core/Guard/TimeoutRegexRule.cs new file mode 100644 index 0000000..785e9f9 --- /dev/null +++ b/src/BotWire.Core/Guard/TimeoutRegexRule.cs @@ -0,0 +1,68 @@ +// BotWire +// Copyright (C) 2026 Object IT Limited +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; +using RedactWire; + +namespace BotWire.Core.Guard; + +/// +/// A custom RedactWire for a user-supplied regex pattern, compiled with a +/// match timeout so a catastrophic-backtracking pattern cannot hang message scanning (ReDoS). +/// On timeout the rule logs a warning and reports no match — a single slow pattern degrades to a +/// miss rather than stalling the request. Used to bridge . +/// +internal sealed class TimeoutRegexRule : IPiiRule +{ + private readonly Regex _re; + private readonly ILogger _logger; + + public string Name { get; } + public PiiType Type => PiiType.Custom; + public string? Subtype { get; } + + public TimeoutRegexRule(string name, string pattern, TimeSpan timeout, ILogger logger) + { + Name = name; + Subtype = name; + _logger = logger; + // Throws ArgumentException for an invalid pattern; the caller catches and skips it. + _re = new Regex(pattern, + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, + timeout); + } + + public IEnumerable Find(string text) + { + var hits = new List(); + try + { + foreach (Match m in _re.Matches(text)) + { + var g = m.Groups["v"].Success ? m.Groups["v"] : (Group)m; + hits.Add(new RuleHit(g.Value, g.Index, g.Length, 1.0, Subtype: Subtype)); + } + } + catch (RegexMatchTimeoutException ex) + { + _logger.LogWarning(ex, + "BotWire: PII custom pattern '{Name}' timed out scanning a message; treated as no match.", Name); + return Array.Empty(); + } + return hits; + } +} diff --git a/tests/BotWire.Core.Tests/Guard/RedactWirePiiGuardTests.cs b/tests/BotWire.Core.Tests/Guard/RedactWirePiiGuardTests.cs new file mode 100644 index 0000000..dc28626 --- /dev/null +++ b/tests/BotWire.Core.Tests/Guard/RedactWirePiiGuardTests.cs @@ -0,0 +1,156 @@ +// BotWire +// Copyright (C) 2026 Object IT Limited +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +using System.Globalization; +using BotWire.Core.Guard; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using RedactWire; + +namespace BotWire.Core.Tests.Guard; + +public class RedactWirePiiGuardTests +{ + private static RedactWirePiiGuard Create(Action? configure = null) + { + var opts = new PiiGuardOptions(); + configure?.Invoke(opts); + return new RedactWirePiiGuard(Options.Create(opts), NullLogger.Instance); + } + + // ----- IsEnabled ----- + + [Fact] + public void IsEnabled_ReturnsTrue_WhenEnabledOption() + { + Assert.True(Create().IsEnabled); + } + + [Fact] + public void Check_ReturnsNotBlocked_WhenDisabled() + { + var result = Create(o => o.Enabled = false).Check("contact me at user@example.com"); + Assert.False(result.Blocked); + Assert.Null(result.MatchedPattern); + } + + // ----- Default invariant rules block (culture-agnostic) ----- + + [Theory] + [InlineData("contact me at user@example.com please", "invariant:Email")] + [InlineData("card 4111 1111 1111 1111", "invariant:CreditCard")] + [InlineData("server is 192.168.0.1 today", "invariant:IPv4")] + public void Check_DefaultInvariantRules_Block(string message, string expectedRule) + { + var result = Create().Check(message); + Assert.True(result.Blocked); + Assert.Equal(expectedRule, result.MatchedPattern); + } + + // ----- Secret detection on by default (AddSecretDetection) ----- + + [Fact] + public void Check_SecretToken_Blocked() + { + var result = Create().Check("my key is sk-proj-abcdefghijklmnopqrstuvwxyz0123"); + Assert.True(result.Blocked); + Assert.Equal("invariant:OpenAiKey", result.MatchedPattern); + } + + // ----- Clean messages pass ----- + + [Theory] + [InlineData("Hello, I need help with my order.")] + [InlineData("How do I reset my password?")] + public void Check_CleanMessage_NotBlocked(string message) + { + var result = Create().Check(message); + Assert.False(result.Blocked); + Assert.Null(result.MatchedPattern); + } + + // ----- AdditionalPatterns (bridged to RedactWire custom rules) ----- + + [Fact] + public void Check_AdditionalPattern_Blocks() + { + var guard = Create(o => o.AdditionalPatterns.Add(@"\bSECRET\b")); + var result = guard.Check("The value is SECRET"); + Assert.True(result.Blocked); + Assert.Equal("invariant:custom-0", result.MatchedPattern); + } + + [Fact] + public void Check_InvalidAdditionalPattern_SkippedWithoutThrow() + { + var guard = Create(o => o.AdditionalPatterns.Add("[invalid")); + var result = guard.Check("Hello"); + Assert.False(result.Blocked); + } + + [Fact] + public void Check_AdditionalPatternAfterInvalidOne_StillWorks() + { + var guard = Create(o => + { + o.AdditionalPatterns.Add("[invalid"); + o.AdditionalPatterns.Add(@"\bTOKEN\b"); + }); + var result = guard.Check("my TOKEN here"); + Assert.True(result.Blocked); + Assert.Equal("invariant:custom-1", result.MatchedPattern); + } + + [Fact] + public void Check_CatastrophicAdditionalPattern_TimesOutAndDoesNotBlock() + { + // Classic exponential-backtracking pattern + non-matching tail. The match timeout + // must trip, degrade to "no match", and return quickly rather than hang. + var guard = Create(o => o.AdditionalPatterns.Add(@"(a+)+$")); + var input = new string('a', 40) + "!"; + + var sw = System.Diagnostics.Stopwatch.StartNew(); + var result = guard.Check(input); + sw.Stop(); + + Assert.False(result.Blocked); + Assert.True(sw.Elapsed < TimeSpan.FromSeconds(2), $"took {sw.ElapsedMilliseconds}ms"); + } + + // ----- ConfigureDetector escape hatch ----- + + [Fact] + public void Check_CustomRuleViaConfigureDetector_Blocks() + { + var guard = Create(o => o.ConfigureDetector = b => + b.AddInvariantRule(new RegexRule("AcmeAccount", PiiType.Custom, + @"(?\bACME-\d{6}\b)", subtype: "AcmeAccount"))); + + var result = guard.Check("ref ACME-123456 on file"); + Assert.True(result.Blocked); + Assert.Equal("invariant:AcmeAccount", result.MatchedPattern); + } + + [Fact] + public void Check_CultureRuleViaConfigureDetector_Blocks() + { + var guard = Create(o => o.ConfigureDetector = b => b.AddCulture(new CultureInfo("en-US"))); + + var result = guard.Check("my ssn is 123-45-6789"); + Assert.True(result.Blocked); + Assert.StartsWith("en-US:", result.MatchedPattern); + } +} diff --git a/tests/BotWire.Core.Tests/Guard/RegexPiiGuardTests.cs b/tests/BotWire.Core.Tests/Guard/RegexPiiGuardTests.cs deleted file mode 100644 index 64810fd..0000000 --- a/tests/BotWire.Core.Tests/Guard/RegexPiiGuardTests.cs +++ /dev/null @@ -1,100 +0,0 @@ -// BotWire -// Copyright (C) 2026 Object IT Limited -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -using BotWire.Core.Guard; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; - -namespace BotWire.Core.Tests.Guard; - -public class RegexPiiGuardTests -{ - private static RegexPiiGuard Create(Action? configure = null) - { - var opts = new PiiGuardOptions(); - configure?.Invoke(opts); - return new RegexPiiGuard(Options.Create(opts), NullLogger.Instance); - } - - // ----- IsEnabled ----- - - [Fact] - public void IsEnabled_ReturnsTrue_WhenEnabledOption() - { - Assert.True(Create().IsEnabled); - } - - // ----- Default pattern blocks ----- - - [Theory] - [InlineData("contact me at user@example.com please", "email")] - [InlineData("my email is Test.User+tag@Sub.Domain.co.uk", "email")] - [InlineData("call me on 13812345678", "phone-cn")] - [InlineData("ring +447911123456", "phone-intl")] - [InlineData("card 4111 1111 1111 1111", "credit-card")] - [InlineData("card 4111-1111-1111-1111", "credit-card")] - public void Check_DefaultPatterns_Block(string message, string expectedPattern) - { - var result = Create().Check(message); - Assert.True(result.Blocked); - Assert.Equal(expectedPattern, result.MatchedPattern); - } - - // ----- Clean messages pass ----- - - [Theory] - [InlineData("Hello, I need help with my order.")] - [InlineData("My account number is 12345")] - [InlineData("How do I reset my password?")] - public void Check_CleanMessage_NotBlocked(string message) - { - var result = Create().Check(message); - Assert.False(result.Blocked); - Assert.Null(result.MatchedPattern); - } - - // ----- AdditionalPatterns ----- - - [Fact] - public void Check_AdditionalPattern_Blocks() - { - var guard = Create(o => o.AdditionalPatterns.Add(@"\bSECRET\b")); - var result = guard.Check("The value is SECRET"); - Assert.True(result.Blocked); - Assert.Equal("custom-0", result.MatchedPattern); - } - - [Fact] - public void Check_InvalidAdditionalPattern_SkippedWithoutThrow() - { - var guard = Create(o => o.AdditionalPatterns.Add("[invalid")); - var result = guard.Check("Hello"); - Assert.False(result.Blocked); - } - - [Fact] - public void Check_AdditionalPatternAfterInvalidOne_StillWorks() - { - var guard = Create(o => - { - o.AdditionalPatterns.Add("[invalid"); - o.AdditionalPatterns.Add(@"\bTOKEN\b"); - }); - var result = guard.Check("my TOKEN here"); - Assert.True(result.Blocked); - Assert.Equal("custom-1", result.MatchedPattern); - } -} From 6db9054e8d2491e110f0835f789d7318261f4c94 Mon Sep 17 00:00:00 2001 From: Adam Yang Date: Sat, 27 Jun 2026 11:26:06 +1200 Subject: [PATCH 2/5] chore: bump RedactWire to 0.4.0 0.4.0 moves JSON/XML/object scanning into the RedactWire.Structured add-on, so the base package no longer pulls System.Text.Json. BotWire.Core uses only string detection, so no code change is needed. Co-Authored-By: Claude Opus 4.8 --- src/BotWire.Core/BotWire.Core.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/BotWire.Core/BotWire.Core.csproj b/src/BotWire.Core/BotWire.Core.csproj index a2814e6..190d25a 100644 --- a/src/BotWire.Core/BotWire.Core.csproj +++ b/src/BotWire.Core/BotWire.Core.csproj @@ -29,7 +29,7 @@ - + From 97cf5a9eb5cab8de286c2912b34a209d261b8580 Mon Sep 17 00:00:00 2001 From: Adam Yang Date: Sat, 27 Jun 2026 11:26:21 +1200 Subject: [PATCH 3/5] feat: show a clear PII message on blocked messages A blocked message used to surface the widget's generic "Something went wrong" instead of the configured PII rejection, so users had no idea why their message was refused. Make the PII rejection legible end to end: - Server: PII blocks return a distinct "PiiBlocked" status (carrying the configurable PiiGuardOptions.RejectionMessage), so clients can tell a PII refusal apart from a generic failure. - Prompt-injection blocks no longer reuse the PII copy; they get their own neutral PromptInjectionOptions.RejectionMessage. - Widget + RedisShop sample: surface the server's message for guard rejections (PiiBlocked / Blocked / RateLimited) instead of the generic error. The sample duck-types on status so it holds across botwire-js versions. - Widget.js endpoint: serve with no-cache + a content-hash ETag instead of a one-hour max-age, so a rebuilt widget reaches browsers immediately (304 when unchanged). This long cache was why the fix appeared not to take effect. Co-Authored-By: Claude Opus 4.8 --- npm/botwire-js/src/types.ts | 2 +- npm/botwire-js/src/widget.ts | 9 +++-- npm/botwire-js/tests/widget.spec.ts | 16 ++++++++- samples/RedisShop/web/src/ChatWidget.tsx | 8 ++++- src/BotWire.AspNetCore/BotWireChatService.cs | 33 ++++++++++--------- .../BotWireEndpointExtensions.cs | 24 ++++++++++++-- .../BotWireServiceCollectionExtensions.cs | 1 + src/BotWire.AspNetCore/botwire.js | 8 ++--- .../Guard/PromptInjectionOptions.cs | 10 ++++++ .../ChatEndpointTests.cs | 23 ++++++++++++- .../BotWireChatServiceAuditTests.cs | 3 +- .../BotWireChatServiceTests.cs | 5 +-- 12 files changed, 112 insertions(+), 30 deletions(-) diff --git a/npm/botwire-js/src/types.ts b/npm/botwire-js/src/types.ts index 11f3d12..567d6d7 100644 --- a/npm/botwire-js/src/types.ts +++ b/npm/botwire-js/src/types.ts @@ -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; diff --git a/npm/botwire-js/src/widget.ts b/npm/botwire-js/src/widget.ts index bad0db0..6861b79 100644 --- a/npm/botwire-js/src/widget.ts +++ b/npm/botwire-js/src/widget.ts @@ -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.'); } diff --git a/npm/botwire-js/tests/widget.spec.ts b/npm/botwire-js/tests/widget.spec.ts index f7eae56..46fa123 100644 --- a/npm/botwire-js/tests/widget.spec.ts +++ b/npm/botwire-js/tests/widget.spec.ts @@ -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]); }); }); diff --git a/samples/RedisShop/web/src/ChatWidget.tsx b/samples/RedisShop/web/src/ChatWidget.tsx index b3bbe5c..d8ea2e4 100644 --- a/samples/RedisShop/web/src/ChatWidget.tsx +++ b/samples/RedisShop/web/src/ChatWidget.tsx @@ -79,7 +79,13 @@ export const ChatWidget = forwardRef(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; + 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) { diff --git a/src/BotWire.AspNetCore/BotWireChatService.cs b/src/BotWire.AspNetCore/BotWireChatService.cs index 1cfcce7..43531cd 100644 --- a/src/BotWire.AspNetCore/BotWireChatService.cs +++ b/src/BotWire.AspNetCore/BotWireChatService.cs @@ -67,6 +67,7 @@ internal sealed class BotWireChatService private readonly IAuditLogger _audit; private readonly IOptions _options; private readonly IOptions _piiOptions; + private readonly IOptions _injectionOptions; public BotWireChatService( IAnswerProvider answers, @@ -80,20 +81,22 @@ public BotWireChatService( ISummaryCompressor compressor, IAuditLogger audit, IOptions options, - IOptions piiOptions) + IOptions piiOptions, + IOptions 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; } /// @@ -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; diff --git a/src/BotWire.AspNetCore/BotWireEndpointExtensions.cs b/src/BotWire.AspNetCore/BotWireEndpointExtensions.cs index 2facfeb..b3e3854 100644 --- a/src/BotWire.AspNetCore/BotWireEndpointExtensions.cs +++ b/src/BotWire.AspNetCore/BotWireEndpointExtensions.cs @@ -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; @@ -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); } diff --git a/src/BotWire.AspNetCore/BotWireServiceCollectionExtensions.cs b/src/BotWire.AspNetCore/BotWireServiceCollectionExtensions.cs index 7442bf0..c6ab12b 100644 --- a/src/BotWire.AspNetCore/BotWireServiceCollectionExtensions.cs +++ b/src/BotWire.AspNetCore/BotWireServiceCollectionExtensions.cs @@ -99,6 +99,7 @@ public static IServiceCollection AddBotWire( configureInjection: o => { o.Enabled = opts.PromptInjection.Enabled; + o.RejectionMessage = opts.PromptInjection.RejectionMessage; o.AdditionalPatterns = opts.PromptInjection.AdditionalPatterns; }); diff --git a/src/BotWire.AspNetCore/botwire.js b/src/BotWire.AspNetCore/botwire.js index 2afa9f1..004cf37 100644 --- a/src/BotWire.AspNetCore/botwire.js +++ b/src/BotWire.AspNetCore/botwire.js @@ -1,5 +1,5 @@ -"use strict";(()=>{var m="/support",o=class extends Error{constructor(e,i,s){super(i);this.status=e;this.httpStatus=s;this.name="BotWireError"}},l=class{constructor(t={}){this._sessionToken=null;this.endpoint=x(t.endpoint??m),this.publicKey=t.publicKey;let e=t.fetch??globalThis.fetch;if(!e)throw new Error("BotWireClient: no global fetch available \u2014 pass config.fetch");this._fetch=e.bind(globalThis)}getSessionToken(){return this._sessionToken}setSessionToken(t){this._sessionToken=t}async initSession(t){let e=await this.post(`${this.endpoint}/session`,{},t);if(!e.ok)throw await this.toError(e);let i=await e.json();return this._sessionToken=i.sessionToken,{sessionToken:i.sessionToken,needsName:i.needsName??!1,errorMessage:i.errorMessage}}async chat(t,e={}){await this.ensureSession(e.signal);let i=await this.post(`${this.endpoint}/chat`,this.body(t,e),e.signal);await this.staleSession(i)&&(await this.initSession(e.signal),i=await this.post(`${this.endpoint}/chat`,this.body(t,e),e.signal));let s;try{s=await i.json()}catch{throw await this.toError(i)}return s.sessionToken&&(this._sessionToken=s.sessionToken),s}async*streamChat(t,e={}){await this.ensureSession(e.signal);let i=await this.post(`${this.endpoint}/chat/stream`,this.body(t,e),e.signal);if(await this.staleSession(i)&&(await this.initSession(e.signal),i=await this.post(`${this.endpoint}/chat/stream`,this.body(t,e),e.signal)),!i.ok||!i.body)throw await this.toError(i);yield*this.parseSse(i.body)}async ensureSession(t){this._sessionToken||await this.initSession(t)}body(t,e){let i={message:t,sessionToken:this._sessionToken};return e.contactEmail&&(i.contactEmail=e.contactEmail),i}async staleSession(t){if(t.status!==400)return!1;try{if((await t.clone().json()).status==="InvalidSession")return this._sessionToken=null,!0}catch{}return!1}async*parseSse(t){let e=t.getReader(),i=new TextDecoder,s="";try{for(;;){let{value:a,done:r}=await e.read();if(r)break;s+=i.decode(a,{stream:!0});let c;for(;(c=s.indexOf(` -`))!==-1;){let p=s.slice(0,c);if(s=s.slice(c+1),!p.startsWith("data: "))continue;let u=p.slice(6);if(u==="[DONE]"){yield{type:"done"};return}let g=v(u);g&&(yield g)}}}finally{e.releaseLock()}}post(t,e,i){let s={"Content-Type":"application/json"};return this.publicKey&&(s["X-BotWire-Key"]=this.publicKey),this._fetch(t,{method:"POST",headers:s,body:JSON.stringify(e),signal:i})}async toError(t){let e="Error",i=`BotWire request failed (HTTP ${t.status})`;try{let s=await t.clone().json();s.status&&(e=s.status),s.message&&(i=s.message)}catch{}return new o(e,i,t.status)}};function x(n){let t=n.length;for(;t>0&&n.charCodeAt(t-1)===47;)t--;return n.slice(0,t)}function v(n){let t;try{t=JSON.parse(n)}catch{return null}switch(t.type){case"token":return{type:"delta",delta:t.value??""};case"collect_contact":return{type:"collect_contact"};case"escalated":return{type:"escalated",ticketId:t.ticketId??"",message:t.message??""};case"blocked":return{type:"blocked",reason:t.reason??""};default:return null}}var d="botwire_session",f={en:{title:"Support",greeting:"How can we help you today?",placeholder:"Type a message\u2026",sendLabel:"Send",contactPrompt:"Please leave your email address so our team can follow up with you.",emailPlaceholder:"your@email.com",submitLabel:"Submit",cancelLabel:"Cancel",cancelMessage:"You have ended this conversation."},"zh-CN":{title:"\u5728\u7EBF\u5BA2\u670D",greeting:"\u8BF7\u95EE\u6709\u4EC0\u4E48\u53EF\u4EE5\u5E2E\u60A8\uFF1F",placeholder:"\u8F93\u5165\u6D88\u606F\u2026",sendLabel:"\u53D1\u9001",contactPrompt:"\u8BF7\u7559\u4E0B\u60A8\u7684\u90AE\u7BB1\uFF0C\u65B9\u4FBF\u6211\u4EEC\u7684\u56E2\u961F\u8DDF\u8FDB\u3002",emailPlaceholder:"your@email.com",submitLabel:"\u63D0\u4EA4",cancelLabel:"\u53D6\u6D88",cancelMessage:"\u60A8\u5DF2\u7ED3\u675F\u672C\u6B21\u4F1A\u8BDD\u3002"},ja:{title:"\u30B5\u30DD\u30FC\u30C8",greeting:"\u3054\u7528\u4EF6\u3092\u304A\u77E5\u3089\u305B\u304F\u3060\u3055\u3044\u3002",placeholder:"\u30E1\u30C3\u30BB\u30FC\u30B8\u3092\u5165\u529B\u2026",sendLabel:"\u9001\u4FE1",contactPrompt:"\u30E1\u30FC\u30EB\u30A2\u30C9\u30EC\u30B9\u3092\u3054\u8A18\u5165\u3044\u305F\u3060\u3051\u308C\u3070\u3001\u62C5\u5F53\u8005\u3088\u308A\u3054\u9023\u7D61\u3044\u305F\u3057\u307E\u3059\u3002",emailPlaceholder:"your@email.com",submitLabel:"\u9001\u4FE1\u3059\u308B",cancelLabel:"\u30AD\u30E3\u30F3\u30BB\u30EB",cancelMessage:"\u3053\u306E\u4F1A\u8A71\u3092\u7D42\u4E86\u3057\u307E\u3057\u305F\u3002"}};function y(n){if(!n)return"en";let t=n.toLowerCase();return t==="zh"||t.startsWith("zh-")||t.startsWith("zh_")?"zh-CN":t==="ja"||t.startsWith("ja-")||t.startsWith("ja_")?"ja":"en"}function w(n){return n.replace(/\\/g,"\\\\").replace(/'/g,"\\'")}function k(n,t,e){let s=t==="bottom-left"?"left":"right";return` +"use strict";(()=>{var m="/support",o=class extends Error{constructor(e,i,s){super(i);this.status=e;this.httpStatus=s;this.name="BotWireError"}},c=class{constructor(t={}){this._sessionToken=null;this.endpoint=x(t.endpoint??m),this.publicKey=t.publicKey;let e=t.fetch??globalThis.fetch;if(!e)throw new Error("BotWireClient: no global fetch available \u2014 pass config.fetch");this._fetch=e.bind(globalThis)}getSessionToken(){return this._sessionToken}setSessionToken(t){this._sessionToken=t}async initSession(t){let e=await this.post(`${this.endpoint}/session`,{},t);if(!e.ok)throw await this.toError(e);let i=await e.json();return this._sessionToken=i.sessionToken,{sessionToken:i.sessionToken,needsName:i.needsName??!1,errorMessage:i.errorMessage}}async chat(t,e={}){await this.ensureSession(e.signal);let i=await this.post(`${this.endpoint}/chat`,this.body(t,e),e.signal);await this.staleSession(i)&&(await this.initSession(e.signal),i=await this.post(`${this.endpoint}/chat`,this.body(t,e),e.signal));let s;try{s=await i.json()}catch{throw await this.toError(i)}return s.sessionToken&&(this._sessionToken=s.sessionToken),s}async*streamChat(t,e={}){await this.ensureSession(e.signal);let i=await this.post(`${this.endpoint}/chat/stream`,this.body(t,e),e.signal);if(await this.staleSession(i)&&(await this.initSession(e.signal),i=await this.post(`${this.endpoint}/chat/stream`,this.body(t,e),e.signal)),!i.ok||!i.body)throw await this.toError(i);yield*this.parseSse(i.body)}async ensureSession(t){this._sessionToken||await this.initSession(t)}body(t,e){let i={message:t,sessionToken:this._sessionToken};return e.contactEmail&&(i.contactEmail=e.contactEmail),i}async staleSession(t){if(t.status!==400)return!1;try{if((await t.clone().json()).status==="InvalidSession")return this._sessionToken=null,!0}catch{}return!1}async*parseSse(t){let e=t.getReader(),i=new TextDecoder,s="";try{for(;;){let{value:a,done:r}=await e.read();if(r)break;s+=i.decode(a,{stream:!0});let l;for(;(l=s.indexOf(` +`))!==-1;){let d=s.slice(0,l);if(s=s.slice(l+1),!d.startsWith("data: "))continue;let u=d.slice(6);if(u==="[DONE]"){yield{type:"done"};return}let g=v(u);g&&(yield g)}}}finally{e.releaseLock()}}post(t,e,i){let s={"Content-Type":"application/json"};return this.publicKey&&(s["X-BotWire-Key"]=this.publicKey),this._fetch(t,{method:"POST",headers:s,body:JSON.stringify(e),signal:i})}async toError(t){let e="Error",i=`BotWire request failed (HTTP ${t.status})`;try{let s=await t.clone().json();s.status&&(e=s.status),s.message&&(i=s.message)}catch{}return new o(e,i,t.status)}};function x(n){let t=n.length;for(;t>0&&n.charCodeAt(t-1)===47;)t--;return n.slice(0,t)}function v(n){let t;try{t=JSON.parse(n)}catch{return null}switch(t.type){case"token":return{type:"delta",delta:t.value??""};case"collect_contact":return{type:"collect_contact"};case"escalated":return{type:"escalated",ticketId:t.ticketId??"",message:t.message??""};case"blocked":return{type:"blocked",reason:t.reason??""};default:return null}}var h="botwire_session",f={en:{title:"Support",greeting:"How can we help you today?",placeholder:"Type a message\u2026",sendLabel:"Send",contactPrompt:"Please leave your email address so our team can follow up with you.",emailPlaceholder:"your@email.com",submitLabel:"Submit",cancelLabel:"Cancel",cancelMessage:"You have ended this conversation."},"zh-CN":{title:"\u5728\u7EBF\u5BA2\u670D",greeting:"\u8BF7\u95EE\u6709\u4EC0\u4E48\u53EF\u4EE5\u5E2E\u60A8\uFF1F",placeholder:"\u8F93\u5165\u6D88\u606F\u2026",sendLabel:"\u53D1\u9001",contactPrompt:"\u8BF7\u7559\u4E0B\u60A8\u7684\u90AE\u7BB1\uFF0C\u65B9\u4FBF\u6211\u4EEC\u7684\u56E2\u961F\u8DDF\u8FDB\u3002",emailPlaceholder:"your@email.com",submitLabel:"\u63D0\u4EA4",cancelLabel:"\u53D6\u6D88",cancelMessage:"\u60A8\u5DF2\u7ED3\u675F\u672C\u6B21\u4F1A\u8BDD\u3002"},ja:{title:"\u30B5\u30DD\u30FC\u30C8",greeting:"\u3054\u7528\u4EF6\u3092\u304A\u77E5\u3089\u305B\u304F\u3060\u3055\u3044\u3002",placeholder:"\u30E1\u30C3\u30BB\u30FC\u30B8\u3092\u5165\u529B\u2026",sendLabel:"\u9001\u4FE1",contactPrompt:"\u30E1\u30FC\u30EB\u30A2\u30C9\u30EC\u30B9\u3092\u3054\u8A18\u5165\u3044\u305F\u3060\u3051\u308C\u3070\u3001\u62C5\u5F53\u8005\u3088\u308A\u3054\u9023\u7D61\u3044\u305F\u3057\u307E\u3059\u3002",emailPlaceholder:"your@email.com",submitLabel:"\u9001\u4FE1\u3059\u308B",cancelLabel:"\u30AD\u30E3\u30F3\u30BB\u30EB",cancelMessage:"\u3053\u306E\u4F1A\u8A71\u3092\u7D42\u4E86\u3057\u307E\u3057\u305F\u3002"}};function y(n){if(!n)return"en";let t=n.toLowerCase();return t==="zh"||t.startsWith("zh-")||t.startsWith("zh_")?"zh-CN":t==="ja"||t.startsWith("ja-")||t.startsWith("ja_")?"ja":"en"}function w(n){return n.replace(/\\/g,"\\\\").replace(/'/g,"\\'")}function k(n,t,e){let s=t==="bottom-left"?"left":"right";return` *,*::before,*::after{box-sizing:border-box;margin:0;padding:0} #bubble{ @@ -146,7 +146,7 @@ border-radius:0;border-top-left-radius:16px;border-top-right-radius:16px} #bubble{bottom:16px;${s}:16px} } -`}var b='',E='',T='',h=class extends HTMLElement{constructor(){super();this.streaming=!1;this.streamAbort=null;this.awaitingEmail=!1;this.ticketCreated=!1;this.errorOccurred=!1;this.errorMessage="Something went wrong. Please try again.";this.shadow=this.attachShadow({mode:"open"})}get endpoint(){return this.dataset.endpoint??"/support"}get primary(){return this.dataset.primaryColor??"#6366f1"}get position(){return this.dataset.position??"bottom-right"}get publicKey(){return this.dataset.publicKey}get offtopicMessage(){return this.dataset.offtopicMessage}get resetEnabled(){return this.dataset.reset!=="false"}get resetConfirm(){return this.dataset.resetConfirm!=="false"}get langKey(){return y(this.dataset.lang)}t(e){let i=this.dataset[e];return i!==void 0?i:f[this.langKey]?.[e]??f.en[e]}get widgetTitle(){return this.t("title")}get placeholder(){return this.t("placeholder")}get contactPrompt(){return this.t("contactPrompt")}get emailPlaceholder(){return this.t("emailPlaceholder")}get sendLabel(){return this.t("sendLabel")}get submitLabel(){return this.t("submitLabel")}get cancelLabel(){return this.t("cancelLabel")}get cancelMessage(){return this.t("cancelMessage")}get greeting(){return this.t("greeting")}get starters(){return(this.dataset.starters??"").split("|").map(e=>e.trim()).filter(e=>e.length>0)}connectedCallback(){this.mount(),this.client=new l({endpoint:this.endpoint,publicKey:this.publicKey}),this.client.setSessionToken(sessionStorage.getItem(d)),this.client.getSessionToken()||this.initSession()}mount(){this.shadow.innerHTML=` +`}var b='',E='',T='',p=class extends HTMLElement{constructor(){super();this.streaming=!1;this.streamAbort=null;this.awaitingEmail=!1;this.ticketCreated=!1;this.errorOccurred=!1;this.errorMessage="Something went wrong. Please try again.";this.shadow=this.attachShadow({mode:"open"})}get endpoint(){return this.dataset.endpoint??"/support"}get primary(){return this.dataset.primaryColor??"#6366f1"}get position(){return this.dataset.position??"bottom-right"}get publicKey(){return this.dataset.publicKey}get offtopicMessage(){return this.dataset.offtopicMessage}get resetEnabled(){return this.dataset.reset!=="false"}get resetConfirm(){return this.dataset.resetConfirm!=="false"}get langKey(){return y(this.dataset.lang)}t(e){let i=this.dataset[e];return i!==void 0?i:f[this.langKey]?.[e]??f.en[e]}get widgetTitle(){return this.t("title")}get placeholder(){return this.t("placeholder")}get contactPrompt(){return this.t("contactPrompt")}get emailPlaceholder(){return this.t("emailPlaceholder")}get sendLabel(){return this.t("sendLabel")}get submitLabel(){return this.t("submitLabel")}get cancelLabel(){return this.t("cancelLabel")}get cancelMessage(){return this.t("cancelMessage")}get greeting(){return this.t("greeting")}get starters(){return(this.dataset.starters??"").split("|").map(e=>e.trim()).filter(e=>e.length>0)}connectedCallback(){this.mount(),this.client=new c({endpoint:this.endpoint,publicKey:this.publicKey}),this.client.setSessionToken(sessionStorage.getItem(h)),this.client.getSessionToken()||this.initSession()}mount(){this.shadow.innerHTML=` -`,this.panel=this.q("#panel"),this.bubble=this.q("#bubble"),this.messages=this.q("#messages"),this.startersBox=this.q("#starters"),this.resetBtn=this.q("#reset"),this.typing=this.q("#typing"),this.inputArea=this.q("#input-area"),this.input=this.q("#input"),this.sendBtn=this.q("#send"),this.contact=this.q("#contact-form"),this.emailIn=this.q("#email-input"),this.cancelBtn=this.q("#contact-cancel"),this.ticket=this.q("#ticket-card"),this.bubble.addEventListener("click",()=>this.toggle()),this.q("#close").addEventListener("click",()=>this.close()),this.resetBtn.addEventListener("click",()=>this.handleReset()),this.sendBtn.addEventListener("click",()=>this.handleSend()),this.input.addEventListener("keydown",e=>{e.key==="Enter"&&!e.shiftKey&&(e.preventDefault(),this.handleSend())}),this.input.addEventListener("input",()=>this.autoResize()),this.contact.addEventListener("submit",e=>{e.preventDefault(),this.handleContactSubmit()}),this.cancelBtn.addEventListener("click",()=>this.handleContactCancel()),this.resetBtn.hidden=!this.resetEnabled,this.renderStarters()}async initSession(){try{let e=await this.client.initSession();sessionStorage.setItem(d,e.sessionToken),e.errorMessage&&(this.errorMessage=e.errorMessage)}catch{}}toggle(){this.panel.hidden?this.open():this.ticketCreated?(this.resetConversation(),this.input.focus()):this.close()}open(){this.ticketCreated&&this.resetConversation(),this.panel.hidden=!1,this.bubble.innerHTML=E,this.bubble.setAttribute("aria-expanded","true"),this.awaitingEmail?this.emailIn.focus():this.input.focus()}resetConversation(){this.streamAbort?.abort(),this.streamAbort=null,this.messages.innerHTML="",this.ticketCreated=!1,this.awaitingEmail=!1,this.streaming=!1,this.errorOccurred=!1,this.contact.hidden=!0,this.ticket.hidden=!0,this.inputArea.hidden=!1,this.sendBtn.disabled=!1,this.emailIn.value="",this.client.setSessionToken(null),sessionStorage.removeItem(d),this.renderStarters(),this.initSession()}handleReset(){if(this.resetConfirm){let e=this.dataset.resetConfirmMessage??"Start a new conversation?";if(typeof confirm=="function"&&!confirm(e))return}this.resetConversation(),!this.panel.hidden&&!this.awaitingEmail&&this.input.focus()}renderStarters(){this.startersBox.innerHTML="";let e=this.starters;if(e.length===0){this.startersBox.hidden=!0;return}for(let i of e){let s=document.createElement("button");s.type="button",s.className="starter",s.textContent=i,s.addEventListener("click",()=>{this.input.value=i,this.handleSend()}),this.startersBox.appendChild(s)}this.startersBox.hidden=!1}close(){this.errorOccurred&&this.resetConversation(),this.panel.hidden=!0,this.bubble.innerHTML=b,this.bubble.setAttribute("aria-expanded","false")}handleSend(){if(this.streaming||this.awaitingEmail)return;let e=this.input.value.trim();e&&(this.input.value="",this.autoResize(),this.startersBox.hidden=!0,this.appendMessage("user",e),this.stream(e))}handleContactSubmit(){let e=this.emailIn.value.trim();e&&(this.contact.hidden=!0,this.awaitingEmail=!1,this.stream("",e))}handleContactCancel(){this.contact.hidden=!0,this.awaitingEmail=!1,this.ticketCreated=!0,this.appendMessage("sys",this.cancelMessage)}async stream(e,i){if(this.streaming)return;this.streaming=!0,this.sendBtn.disabled=!0,this.typing.hidden=!1;let s=new AbortController;this.streamAbort=s;let a=null;try{for await(let r of this.client.streamChat(e,{contactEmail:i,signal:s.signal}))switch(this.typing.hidden=!0,r.type){case"delta":a||(a=this.appendMessage("bot","")),a.textContent+=r.delta,this.scrollBottom();break;case"collect_contact":this.inputArea.hidden=!0,this.contact.hidden=!1,this.awaitingEmail=!0,requestAnimationFrame(()=>{this.scrollBottom(),this.emailIn.focus()});break;case"escalated":this.ticketCreated=!0,this.ticket.hidden=!1,this.ticket.textContent=r.message,this.inputArea.hidden=!0;break;case"blocked":this.appendMessage("bot",this.offtopicMessage??r.reason);break;case"done":break}}catch(r){if(s.signal.aborted)return;this.typing.hidden=!0,r instanceof DOMException&&r.name==="AbortError"||(r instanceof o?(this.errorOccurred=!0,this.appendMessage("sys",this.errorMessage)):this.appendMessage("sys","Connection error. Please try again."))}finally{if(this.streamAbort===s){this.streamAbort=null;let r=this.client.getSessionToken();r&&sessionStorage.setItem(d,r),this.typing.hidden=!0,this.streaming=!1,!this.awaitingEmail&&!this.ticketCreated&&(this.sendBtn.disabled=!1,this.panel.hidden||this.input.focus())}}}appendMessage(e,i){let s=document.createElement("div");return s.className=`msg msg-${e}`,s.textContent=i,this.messages.appendChild(s),this.scrollBottom(),s}scrollBottom(){this.messages.scrollTop=this.messages.scrollHeight}autoResize(){this.input.style.height="auto",this.input.style.height=`${Math.min(this.input.scrollHeight,100)}px`}esc(e){return e.replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}q(e){return this.shadow.querySelector(e)}};customElements.define("botwire-widget",h);})(); +`,this.panel=this.q("#panel"),this.bubble=this.q("#bubble"),this.messages=this.q("#messages"),this.startersBox=this.q("#starters"),this.resetBtn=this.q("#reset"),this.typing=this.q("#typing"),this.inputArea=this.q("#input-area"),this.input=this.q("#input"),this.sendBtn=this.q("#send"),this.contact=this.q("#contact-form"),this.emailIn=this.q("#email-input"),this.cancelBtn=this.q("#contact-cancel"),this.ticket=this.q("#ticket-card"),this.bubble.addEventListener("click",()=>this.toggle()),this.q("#close").addEventListener("click",()=>this.close()),this.resetBtn.addEventListener("click",()=>this.handleReset()),this.sendBtn.addEventListener("click",()=>this.handleSend()),this.input.addEventListener("keydown",e=>{e.key==="Enter"&&!e.shiftKey&&(e.preventDefault(),this.handleSend())}),this.input.addEventListener("input",()=>this.autoResize()),this.contact.addEventListener("submit",e=>{e.preventDefault(),this.handleContactSubmit()}),this.cancelBtn.addEventListener("click",()=>this.handleContactCancel()),this.resetBtn.hidden=!this.resetEnabled,this.renderStarters()}async initSession(){try{let e=await this.client.initSession();sessionStorage.setItem(h,e.sessionToken),e.errorMessage&&(this.errorMessage=e.errorMessage)}catch{}}toggle(){this.panel.hidden?this.open():this.ticketCreated?(this.resetConversation(),this.input.focus()):this.close()}open(){this.ticketCreated&&this.resetConversation(),this.panel.hidden=!1,this.bubble.innerHTML=E,this.bubble.setAttribute("aria-expanded","true"),this.awaitingEmail?this.emailIn.focus():this.input.focus()}resetConversation(){this.streamAbort?.abort(),this.streamAbort=null,this.messages.innerHTML="",this.ticketCreated=!1,this.awaitingEmail=!1,this.streaming=!1,this.errorOccurred=!1,this.contact.hidden=!0,this.ticket.hidden=!0,this.inputArea.hidden=!1,this.sendBtn.disabled=!1,this.emailIn.value="",this.client.setSessionToken(null),sessionStorage.removeItem(h),this.renderStarters(),this.initSession()}handleReset(){if(this.resetConfirm){let e=this.dataset.resetConfirmMessage??"Start a new conversation?";if(typeof confirm=="function"&&!confirm(e))return}this.resetConversation(),!this.panel.hidden&&!this.awaitingEmail&&this.input.focus()}renderStarters(){this.startersBox.innerHTML="";let e=this.starters;if(e.length===0){this.startersBox.hidden=!0;return}for(let i of e){let s=document.createElement("button");s.type="button",s.className="starter",s.textContent=i,s.addEventListener("click",()=>{this.input.value=i,this.handleSend()}),this.startersBox.appendChild(s)}this.startersBox.hidden=!1}close(){this.errorOccurred&&this.resetConversation(),this.panel.hidden=!0,this.bubble.innerHTML=b,this.bubble.setAttribute("aria-expanded","false")}handleSend(){if(this.streaming||this.awaitingEmail)return;let e=this.input.value.trim();e&&(this.input.value="",this.autoResize(),this.startersBox.hidden=!0,this.appendMessage("user",e),this.stream(e))}handleContactSubmit(){let e=this.emailIn.value.trim();e&&(this.contact.hidden=!0,this.awaitingEmail=!1,this.stream("",e))}handleContactCancel(){this.contact.hidden=!0,this.awaitingEmail=!1,this.ticketCreated=!0,this.appendMessage("sys",this.cancelMessage)}async stream(e,i){if(this.streaming)return;this.streaming=!0,this.sendBtn.disabled=!0,this.typing.hidden=!1;let s=new AbortController;this.streamAbort=s;let a=null;try{for await(let r of this.client.streamChat(e,{contactEmail:i,signal:s.signal}))switch(this.typing.hidden=!0,r.type){case"delta":a||(a=this.appendMessage("bot","")),a.textContent+=r.delta,this.scrollBottom();break;case"collect_contact":this.inputArea.hidden=!0,this.contact.hidden=!1,this.awaitingEmail=!0,requestAnimationFrame(()=>{this.scrollBottom(),this.emailIn.focus()});break;case"escalated":this.ticketCreated=!0,this.ticket.hidden=!1,this.ticket.textContent=r.message,this.inputArea.hidden=!0;break;case"blocked":this.appendMessage("bot",this.offtopicMessage??r.reason);break;case"done":break}}catch(r){if(s.signal.aborted)return;if(this.typing.hidden=!0,!(r instanceof DOMException&&r.name==="AbortError"))if(r instanceof o){this.errorOccurred=!0;let d=["PiiBlocked","Blocked","RateLimited"].includes(r.status)&&r.message?r.message:this.errorMessage;this.appendMessage("sys",d)}else this.appendMessage("sys","Connection error. Please try again.")}finally{if(this.streamAbort===s){this.streamAbort=null;let r=this.client.getSessionToken();r&&sessionStorage.setItem(h,r),this.typing.hidden=!0,this.streaming=!1,!this.awaitingEmail&&!this.ticketCreated&&(this.sendBtn.disabled=!1,this.panel.hidden||this.input.focus())}}}appendMessage(e,i){let s=document.createElement("div");return s.className=`msg msg-${e}`,s.textContent=i,this.messages.appendChild(s),this.scrollBottom(),s}scrollBottom(){this.messages.scrollTop=this.messages.scrollHeight}autoResize(){this.input.style.height="auto",this.input.style.height=`${Math.min(this.input.scrollHeight,100)}px`}esc(e){return e.replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}q(e){return this.shadow.querySelector(e)}};customElements.define("botwire-widget",p);})(); diff --git a/src/BotWire.Core/Guard/PromptInjectionOptions.cs b/src/BotWire.Core/Guard/PromptInjectionOptions.cs index 97ee5c5..0f82b9a 100644 --- a/src/BotWire.Core/Guard/PromptInjectionOptions.cs +++ b/src/BotWire.Core/Guard/PromptInjectionOptions.cs @@ -14,6 +14,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +using System.ComponentModel.DataAnnotations; + namespace BotWire.Core.Guard; /// Configuration for heuristic prompt injection detection. @@ -25,6 +27,14 @@ public sealed class PromptInjectionOptions /// public bool Enabled { get; set; } = true; + /// + /// Message returned to the caller when a prompt-injection attempt is blocked. Kept + /// deliberately neutral (it does not reveal that injection detection fired). + /// + [Required] + public string RejectionMessage { get; set; } = + "Your message couldn't be processed. Please rephrase and try again."; + /// /// Additional regex patterns (case-insensitive) appended after the built-in defaults. /// Invalid regex strings are skipped with a warning log at startup. diff --git a/tests/BotWire.AspNetCore.IntegrationTests/ChatEndpointTests.cs b/tests/BotWire.AspNetCore.IntegrationTests/ChatEndpointTests.cs index 2683e72..48abaae 100644 --- a/tests/BotWire.AspNetCore.IntegrationTests/ChatEndpointTests.cs +++ b/tests/BotWire.AspNetCore.IntegrationTests/ChatEndpointTests.cs @@ -172,7 +172,7 @@ public async Task PiiGuard_Returns400() Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); var body = await resp.Content.ReadFromJsonAsync(); - Assert.Equal("Blocked", body!.Status); + Assert.Equal("PiiBlocked", body!.Status); } [Fact] @@ -213,4 +213,25 @@ public async Task InvalidToken_StreamEndpoint_Returns400WithInvalidSessionStatus var body = await resp.Content.ReadFromJsonAsync(); Assert.Equal("InvalidSession", body!.Status); } + + [Fact] + public async Task WidgetJs_RevalidatesViaETag_NoLongCache() + { + await using var host = await BotWireTestHost.CreateAsync(); + + var first = await host.Client.GetAsync("/botwire/widget.js"); + first.EnsureSuccessStatusCode(); + var etag = first.Headers.ETag; + + // Must revalidate (no stale hour-long cache) and carry an ETag so updates propagate. + Assert.NotNull(etag); + Assert.True(first.Headers.CacheControl!.NoCache); + Assert.Null(first.Headers.CacheControl.MaxAge); + + // A conditional re-request with the same ETag is a cheap 304, not a re-download. + var conditional = new HttpRequestMessage(HttpMethod.Get, "/botwire/widget.js"); + conditional.Headers.IfNoneMatch.Add(etag!); + var second = await host.Client.SendAsync(conditional); + Assert.Equal(HttpStatusCode.NotModified, second.StatusCode); + } } diff --git a/tests/BotWire.AspNetCore.Tests/BotWireChatServiceAuditTests.cs b/tests/BotWire.AspNetCore.Tests/BotWireChatServiceAuditTests.cs index 921cd01..9976e06 100644 --- a/tests/BotWire.AspNetCore.Tests/BotWireChatServiceAuditTests.cs +++ b/tests/BotWire.AspNetCore.Tests/BotWireChatServiceAuditTests.cs @@ -57,7 +57,8 @@ private static (BotWireChatService svc, FakeAuditLogger audit) CreateAudited( new FakeSummaryCompressor(), audit, Options.Create(new BotWireOptions()), - Options.Create(new PiiGuardOptions())); + Options.Create(new PiiGuardOptions()), + Options.Create(new PromptInjectionOptions())); return (svc, audit); } diff --git a/tests/BotWire.AspNetCore.Tests/BotWireChatServiceTests.cs b/tests/BotWire.AspNetCore.Tests/BotWireChatServiceTests.cs index 8d05e5b..f047e14 100644 --- a/tests/BotWire.AspNetCore.Tests/BotWireChatServiceTests.cs +++ b/tests/BotWire.AspNetCore.Tests/BotWireChatServiceTests.cs @@ -64,7 +64,8 @@ private static (BotWireChatService svc, FakeConversationStore store) Create( new FakeSummaryCompressor(), NullAuditLogger.Instance, Options.Create(new BotWireOptions { MaxMessageLength = maxMsg }), - Options.Create(new PiiGuardOptions())); + Options.Create(new PiiGuardOptions()), + Options.Create(new PromptInjectionOptions())); return (svc, store); } @@ -169,7 +170,7 @@ public async Task AnswerAsync_PiiBlocked_Returns400() var (svc, _) = Create(piiBlocks: true); var result = await svc.AnswerAsync(ChatReq(), "1.2.3.4"); Assert.Equal(400, result.HttpStatusCode); - Assert.Equal("Blocked", result.Status); + Assert.Equal("PiiBlocked", result.Status); } [Fact] From dd90aeed0fbae3b72cc13ac59cd2116fde62b848 Mon Sep 17 00:00:00 2001 From: Adam Yang Date: Sat, 27 Jun 2026 11:26:33 +1200 Subject: [PATCH 4/5] ci: make release workflow manual-only Release no longer fires on tag push; it runs solely via workflow_dispatch with a required `tag` input. Every job resolves REF_NAME from that input, checks out the tag, and the GitHub Release step is re-run-safe (upload --clobber if it exists). Co-Authored-By: Claude Opus 4.8 --- .github/workflows/release.yml | 36 ++++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 71f5a2b..2541d34 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,25 +1,36 @@ 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 steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ env.REF_NAME }} - name: Verify tag matches VersionPrefix run: | - VERSION="${GITHUB_REF_NAME#v}" + VERSION="${REF_NAME#v}" PREFIX=$(grep -oPm1 '(?<=)[^<]+' 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 @@ -59,12 +70,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 @@ -77,13 +93,15 @@ jobs: working-directory: npm/botwire-js steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ env.REF_NAME }} - 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 From 130a9f75de71990d170b4a69c922965857b25a31 Mon Sep 17 00:00:00 2001 From: Adam Yang Date: Sat, 27 Jun 2026 11:26:33 +1200 Subject: [PATCH 5/5] chore(release): bump BotWire to 0.4.0 VersionPrefix (all .NET packages) and botwire-js package.json to 0.4.0. Co-Authored-By: Claude Opus 4.8 --- Directory.Build.props | 2 +- npm/botwire-js/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index ea7217c..fe430fe 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,7 +5,7 @@ latest - 0.3.0 + 0.4.0 Object IT Limited diff --git a/npm/botwire-js/package.json b/npm/botwire-js/package.json index 7439fd4..c4c43c9 100644 --- a/npm/botwire-js/package.json +++ b/npm/botwire-js/package.json @@ -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",