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
24 changes: 24 additions & 0 deletions Core/Resgrid.Chatbot/Services/ChatbotDepartmentConfigService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ public async Task<ChatbotDepartmentConfig> SaveConfigAsync(ChatbotDepartmentConf
else
config.LlmApiKey = _encryptionService.Encrypt(newPlaintextLlmKey);

ValidateColumnLengths(config);

if (existing == null)
{
config.Id = Guid.NewGuid().ToString("N");
Expand Down Expand Up @@ -151,6 +153,28 @@ public async Task InvalidateCacheAsync(int departmentId)
}
}

/// <summary>
/// Guards against SQL truncation (error 8152) by validating string fields against the
/// ChatbotDepartmentConfigs column sizes (M0068/M0070) before hitting the database.
/// LlmApiKey is checked post-encryption since the ciphertext is what gets stored.
/// Throws ArgumentException so API callers can map the failure to a 400 response;
/// messages are caller-safe (no parameter-name suffix).
/// </summary>
private static void ValidateColumnLengths(ChatbotDepartmentConfig config)
{
if (config.AllowedPlatforms != null && config.AllowedPlatforms.Length > 500)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules low

Magic number obscures the inline column-size limit of 500, violating Rule [9] by breaking explicit schema contracts. Extract this literal to a private const (e.g., MaxAllowedPlatformsLength) for the length check and error message, and apply the same fix to CallsController.cs:1710 and lines 166, 169, and 172 of ChatbotDepartmentConfigService.cs.

Kody rule violation: Replace magic numbers with named constants

Prompt for LLM

File Core/Resgrid.Chatbot/Services/ChatbotDepartmentConfigService.cs:

Line 163:

Magic number obscures the inline column-size limit of 500, violating Rule [9] by breaking explicit schema contracts. Extract this literal to a private const (e.g., `MaxAllowedPlatformsLength`) for the length check and error message, and apply the same fix to `CallsController.cs:1710` and lines 166, 169, and 172 of `ChatbotDepartmentConfigService.cs`.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

throw new ArgumentException("AllowedPlatforms cannot exceed 500 characters.");

if (config.LlmApiEndpoint != null && config.LlmApiEndpoint.Length > 500)
throw new ArgumentException("LlmApiEndpoint cannot exceed 500 characters.");

if (config.LlmModelName != null && config.LlmModelName.Length > 200)
throw new ArgumentException("LlmModelName cannot exceed 200 characters.");

if (config.LlmApiKey != null && config.LlmApiKey.Length > 1000)
throw new ArgumentException("Encrypted LlmApiKey cannot exceed 1000 characters; supply a shorter API key.");
}

private static string CacheKey(int departmentId) => $"ChatbotDeptConfig_{departmentId}";
}
}
10 changes: 10 additions & 0 deletions Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1701,9 +1701,19 @@ public async Task<ActionResult<CallHistoryResult>> GetCallHistory(int callId)
/// <returns>Array of CallResult objects for each call in the department within the range</returns>
[HttpGet("GetCalls")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[Authorize(Policy = ResgridResources.Call_View)]
public async Task<ActionResult<ActiveCallsResult>> GetCalls(DateTime startDate, DateTime endDate)
{
// Missing query params bind to DateTime.MinValue (0001-01-01), which is below the SQL
// Server datetime floor (1753-01-01) and throws SqlDateTime overflow at the repository.
var sqlMinDate = new DateTime(1753, 1, 1);
if (startDate < sqlMinDate || endDate < sqlMinDate)
return BadRequest("startDate and endDate are required and must be valid dates (on or after 1753-01-01).");

if (endDate < startDate)
return BadRequest("endDate must be on or after startDate.");

var result = new ActiveCallsResult();

var calls = (await _callsService.GetAllCallsByDepartmentDateRangeAsync(DepartmentId, startDate, endDate)).OrderByDescending(x => x.LoggedOn);
Expand Down
6 changes: 6 additions & 0 deletions Web/Resgrid.Web.Services/Controllers/v4/ChatbotController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ public async Task<IActionResult> GetConfig()
/// </summary>
[HttpPut("Config")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> UpdateConfig([FromBody] ChatbotConfigRequest request)
{
try
Expand Down Expand Up @@ -301,6 +302,11 @@ public async Task<IActionResult> UpdateConfig([FromBody] ChatbotConfigRequest re

return Ok(new { success = true });
}
catch (ArgumentException ex)
{
// Field-length (column size) validation from the config service.
return BadRequest(new { error = ex.Message });
}
Comment on lines +305 to +309

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -nP -C 8 \
  'throw\s+new\s+ArgumentException|catch\s*\(\s*ArgumentException|SaveConfigAsync' \
  --glob '*.cs' . || true

Repository: Resgrid/Core

Length of output: 50369


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '--- target file location ---\n'
git ls-files | rg 'Web/Resgrid.Web.Services/Controllers/v4/ChatbotController\.cs$|ChatbotController\.cs$' || true

printf '\n--- ChatbotController outline ---\n'
ast-grep outline 'Web/Resgrid.Web.Services/Controllers/v4/ChatbotController.cs' --view compact || true

printf '\n--- UpdateConfig and SaveConfigAsync sections ---\n'
rg -n -C 12 'UpdateConfig|SaveConfigAsync|ArgumentException|try|catch'Web/Resgrid.Web.Services/Controllers/v4/ChatbotController.cs || true

printf '\n--- save-config service/repository implementations ---\n'
rg -n -C 8 'SaveConfigAsync|UpdateConfig\(\)' --glob '**/*.cs' . | sed -n '1,220p' || true

Repository: Resgrid/Core

Length of output: 241


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- target file location ---'
git ls-files | rg 'Web/Resgrid.Web.Services/Controllers/v4/ChatbotController\.cs$|ChatbotController\.cs$' || true

printf '%s\n' ''
printf '%s\n' '--- ChatbotController outline ---'
ast-grep outline 'Web/Resgrid.Web.Services/Controllers/v4/ChatbotController.cs' --view compact || true

printf '%s\n' ''
printf '%s\n' '--- UpdateConfig and SaveConfigAsync sections ---'
rg -n -C 12 'UpdateConfig|SaveConfigAsync|ArgumentException|try|catch' 'Web/Resgrid.Web.Services/Controllers/v4/ChatbotController.cs' || true

printf '%s\n' ''
printf '%s\n' '--- save-config service/repository implementations ---'
rg -n -C 8 'SaveConfigAsync|UpdateConfig\(\)' --glob '**/*.cs' . | sed -n '1,220p' || true

Repository: Resgrid/Core

Length of output: 25542


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- ChatbotDepartmentConfigService SaveConfigAsync ---'
sed -n '100,155p' 'Core/Resgrid.Chatbot/Services/ChatbotDepartmentConfigService.cs'

printf '%s\n' ''
printf '%s\n' '--- repo Save implementation candidates ---'
rg -n -C 10 'class .*DepartmentConfig|SaveConfigAsync|UpdateAsync\(.*ChatbotDepartmentConfig' --glob '**/*.cs' . | sed -n '1,260p' || true

printf '%s\n' ''
printf '%s\n' '--- precise repository/implementation files ---'
git ls-files | rg 'Chatbot.*Config.*Repository|DepartmentConfig.*Repository|\bRepository.*\.cs$' | rg -i 'chatbot|config' || true

Repository: Resgrid/Core

Length of output: 27268


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- ValidateColumnLengths implementation ---'
rg -n -C 25 'ValidateColumnLengths|class ChatbotDepartmentConfigService' 'Core/Resgrid.Chatbot/Services/ChatbotDepartmentConfigService.cs' || true

printf '%s\n' ''
printf '%s\n' '--- Repository method bodies ---'
sed -n '20,220p' 'Repositories/Resgrid.Repositories.DataRepository/ChatbotDepartmentConfigRepository.cs' || true

Repository: Resgrid/Core

Length of output: 4936


Narrow the ArgumentException handling to config validation only.

ChatbotController.UpdateConfig() wraps authorization, DTO validation, and SaveConfigAsync() in a single try. SaveConfigAsync() also catches and rethrows downstream failures, so the broad catch can return authorization/repository failures as HTTP 400 with internal exception text. Move column-length validation to an explicit 400 condition before saving, wrap only SaveConfigAsync() in a narrow ArgumentException block if it keeps that contract, or introduce a dedicated config-validation exception type. Return a stable client-safe message in the 400 path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Web/Resgrid.Web.Services/Controllers/v4/ChatbotController.cs` around lines
305 - 309, Narrow the ArgumentException handling in
ChatbotController.UpdateConfig() so authorization and DTO validation failures
cannot be converted to HTTP 400 responses. Perform column-length config
validation explicitly before SaveConfigAsync(), then wrap only SaveConfigAsync()
if its ArgumentException contract must remain; return a stable client-safe
message instead of ex.Message, while preserving downstream failure propagation.

Source: Coding guidelines

Comment on lines +305 to +309

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Bug high

Overly broad exception handling in the catch (ArgumentException ex) block captures internal server errors from _encryptionService.Encrypt() where Rfc2898DeriveBytes throws ArgumentNullException for invalid encryption salts. Introduce a dedicated ColumnLengthValidationException thrown by ValidateColumnLengths and catch only that specific type to ensure genuine ArgumentExceptions fall through to catch(Exception).

// Throw a dedicated ColumnLengthValidationException from ValidateColumnLengths instead of ArgumentException,
// then catch only that here so internal ArgumentException subclasses (e.g. from Rfc2898DeriveBytes in the
// encryption path) still fall through to catch(Exception) for proper logging and 500 response.
catch (ColumnLengthValidationException ex)
{
    return BadRequest(new { error = ex.Message });
}
Prompt for LLM

File Web/Resgrid.Web.Services/Controllers/v4/ChatbotController.cs:

Line 305 to 309:

Overly broad exception handling in the `catch (ArgumentException ex)` block captures internal server errors from `_encryptionService.Encrypt()` where `Rfc2898DeriveBytes` throws `ArgumentNullException` for invalid encryption salts. Introduce a dedicated `ColumnLengthValidationException` thrown by `ValidateColumnLengths` and catch only that specific type to ensure genuine `ArgumentExceptions` fall through to `catch(Exception)`.

Suggested Code:

// Throw a dedicated ColumnLengthValidationException from ValidateColumnLengths instead of ArgumentException,
// then catch only that here so internal ArgumentException subclasses (e.g. from Rfc2898DeriveBytes in the
// encryption path) still fall through to catch(Exception) for proper logging and 500 response.
catch (ColumnLengthValidationException ex)
{
    return BadRequest(new { error = ex.Message });
}

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

catch (Exception ex)
{
Logging.LogException(ex);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,19 @@ public async Task<IActionResult> Index(ChatbotSettingsModel model, CancellationT
if (!await _authorizationService.CanUserModifyDepartmentAsync(UserId, DepartmentId))
return Unauthorized();

// Same SSRF guard as the v4 API config writer (ChatbotController.UpdateConfig).
if (!string.IsNullOrWhiteSpace(model.LlmApiEndpoint) &&
!Resgrid.Chatbot.NLU.LlmEndpointValidator.IsValid(model.LlmApiEndpoint, out var llmEndpointError))
ModelState.AddModelError(nameof(model.LlmApiEndpoint), llmEndpointError);

if (!ModelState.IsValid)
{
var existing = await _chatbotConfigService.GetConfigAsync(DepartmentId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Unguarded async operation occurs because the awaited call to GetConfigAsync on line 60 falls outside the try/catch block, violating Rule [1]. Wrap this await in error handling or move the ModelState branch inside the existing try block to log and handle exceptions with context.

Kody rule violation: Handle async operations with proper error handling

Prompt for LLM

File Web/Resgrid.Web/Areas/User/Controllers/ChatbotSettingsController.cs:

Line 60:

Unguarded async operation occurs because the awaited call to `GetConfigAsync` on line 60 falls outside the try/catch block, violating Rule [1]. Wrap this await in error handling or move the `ModelState` branch inside the existing try block to log and handle exceptions with context.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Unguarded external call on line 60 leaves GetConfigAsync exposed to unhandled exceptions, violating Rule [27]. Enclose this call in a try/catch block to log contextual data (DepartmentId) and map the error to a fallback view or application-level exception.

Kody rule violation: Add try-catch blocks for external calls

Prompt for LLM

File Web/Resgrid.Web/Areas/User/Controllers/ChatbotSettingsController.cs:

Line 60:

Unguarded external call on line 60 leaves `GetConfigAsync` exposed to unhandled exceptions, violating Rule [27]. Enclose this call in a try/catch block to log contextual data (`DepartmentId`) and map the error to a fallback view or application-level exception.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

model.HasLlmApiKey = !string.IsNullOrWhiteSpace(existing?.LlmApiKey);
model.LlmApiKey = null;
return View(model);
}

try
{
var config = new ChatbotDepartmentConfig
Expand Down
15 changes: 14 additions & 1 deletion Web/Resgrid.Web/Areas/User/Models/ChatbotSettingsModel.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
using System.ComponentModel.DataAnnotations;
using Resgrid.Web.Attributes;

namespace Resgrid.Web.Areas.User.Models
{
public class ChatbotSettingsModel : BaseUserModel
Expand All @@ -8,6 +11,7 @@ public class ChatbotSettingsModel : BaseUserModel
public bool IsEnabled { get; set; }

/// <summary>Comma-separated platform names allowed for this department, or "*" for all.</summary>
[StringLength(500, ErrorMessage = "Allowed platforms cannot exceed 500 characters.")]
public string AllowedPlatforms { get; set; } = "*";

public bool AllowDispatchViaChatbot { get; set; }
Expand All @@ -24,10 +28,19 @@ public class ChatbotSettingsModel : BaseUserModel

// Department's own LLM/AI provider (optional). When set, the chatbot keeps this department's
// processing with their provider instead of the Resgrid system LLM.
[StringLength(500, ErrorMessage = "API endpoint cannot exceed 500 characters.")]
public string LlmApiEndpoint { get; set; }
Comment thread
coderabbitai[bot] marked this conversation as resolved.

[StringLength(200, ErrorMessage = "Model name cannot exceed 200 characters.")]
public string LlmModelName { get; set; }

/// <summary>Write-only: a new API key to store. Never populated on read (see HasLlmApiKey).</summary>
/// <summary>
/// Write-only: a new API key to store. Never populated on read (see HasLlmApiKey).
/// Cap is 700 UTF-8 bytes (encryption operates on bytes) so the AES+base64 ciphertext
/// fits the 1000-char LlmApiKey column; StringLength adds the client-side char cap.
/// </summary>
[StringLength(700, ErrorMessage = "API key cannot exceed 700 characters.")]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
[MaxUtf8Bytes(700, ErrorMessage = "API key cannot exceed 700 bytes when UTF-8 encoded (non-ASCII characters count as multiple bytes).")]
public string LlmApiKey { get; set; }

/// <summary>True when an LLM API key is already stored (so the UI can indicate it without exposing it).</summary>
Expand Down
12 changes: 8 additions & 4 deletions Web/Resgrid.Web/Areas/User/Views/ChatbotSettings/Index.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@
<div class="form-group">
<label class="col-sm-3 control-label">@localizer["ChatbotAllowedPlatforms"]</label>
<div class="col-sm-9">
<input type="text" class="form-control" asp-for="AllowedPlatforms" placeholder="*">
<input type="text" class="form-control" asp-for="AllowedPlatforms" placeholder="*" maxlength="500">
<span asp-validation-for="AllowedPlatforms" class="text-danger"></span>
<span class="help-block m-b-none">@localizer["ChatbotAllowedPlatformsHelp"]</span>
</div>
</div>
Expand Down Expand Up @@ -118,21 +119,24 @@
<div class="form-group">
<label class="col-sm-3 control-label">@localizer["ChatbotLlmEndpoint"]</label>
<div class="col-sm-9">
<input type="text" class="form-control" asp-for="LlmApiEndpoint" placeholder="https://api.your-provider.com/v1/chat/completions">
<input type="text" class="form-control" asp-for="LlmApiEndpoint" placeholder="https://api.your-provider.com/v1/chat/completions" maxlength="500">
<span asp-validation-for="LlmApiEndpoint" class="text-danger"></span>
</div>
</div>

<div class="form-group">
<label class="col-sm-3 control-label">@localizer["ChatbotLlmModel"]</label>
<div class="col-sm-9">
<input type="text" class="form-control" asp-for="LlmModelName" placeholder="gpt-4o, deepseek-chat, ...">
<input type="text" class="form-control" asp-for="LlmModelName" placeholder="gpt-4o, deepseek-chat, ..." maxlength="200">
<span asp-validation-for="LlmModelName" class="text-danger"></span>
</div>
</div>

<div class="form-group">
<label class="col-sm-3 control-label">@localizer["ChatbotLlmApiKey"]</label>
<div class="col-sm-9">
<input type="password" class="form-control" asp-for="LlmApiKey" autocomplete="new-password">
<input type="password" class="form-control" asp-for="LlmApiKey" autocomplete="new-password" maxlength="700">
<span asp-validation-for="LlmApiKey" class="text-danger"></span>
<span class="help-block m-b-none">
@if (Model.HasLlmApiKey)
{
Expand Down
30 changes: 30 additions & 0 deletions Web/Resgrid.Web/Attributes/MaxUtf8BytesAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using System.ComponentModel.DataAnnotations;
using System.Text;

namespace Resgrid.Web.Attributes
{
/// <summary>
/// Validates that a string's UTF-8 encoded byte count does not exceed a maximum. Use when the
/// stored representation depends on byte length (e.g. values encrypted before persisting), where
/// a character-count check (StringLength) would pass multi-byte Unicode input that overflows a

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules low

Unsafe type casting violates team standards. Apply the as operator or pattern matching for all casts, ensuring proper null checks before usage.

Kody rule violation: Use safe type casting with as operator

Prompt for LLM

File Web/Resgrid.Web/Attributes/MaxUtf8BytesAttribute.cs:

Line 9:

Unsafe type casting violates team standards. Apply the `as` operator or pattern matching for all casts, ensuring proper null checks before usage.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

/// fixed-size column.
/// </summary>
public sealed class MaxUtf8BytesAttribute : ValidationAttribute
{
private readonly int _maxBytes;

public MaxUtf8BytesAttribute(int maxBytes)
: base($"Cannot exceed {maxBytes} bytes when UTF-8 encoded.")
{
_maxBytes = maxBytes;
}

public override bool IsValid(object value)
{
if (value is null)
return true;

return value is string s && Encoding.UTF8.GetByteCount(s) <= _maxBytes;
}
}
}
1 change: 1 addition & 0 deletions Web/Resgrid.Web/Resgrid.Web.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@
<ProjectReference Include="..\..\Core\Resgrid.Model\Resgrid.Model.csproj" />
<ProjectReference Include="..\..\Core\Resgrid.Services\Resgrid.Services.csproj" />
<ProjectReference Include="..\..\Core\Resgrid.Chatbot\Resgrid.Chatbot.csproj" />
<ProjectReference Include="..\..\Core\Resgrid.Chatbot.NLU\Resgrid.Chatbot.NLU.csproj" />
<ProjectReference Include="..\..\Providers\Resgrid.Providers.AddressVerification\Resgrid.Providers.AddressVerification.csproj" />
<ProjectReference Include="..\..\Providers\Resgrid.Providers.Bus.Rabbit\Resgrid.Providers.Bus.Rabbit.csproj" />
<ProjectReference Include="..\..\Providers\Resgrid.Providers.Bus\Resgrid.Providers.Bus.csproj" />
Expand Down
Loading