Compare commits

...

7 Commits

Author SHA1 Message Date
19645c06e2 Merge pull request 'fix(security): block PII in chat attachments and typed messages' (#59) from fix/pii-detection-bypass into master
All checks were successful
Auto Tag / autotag (push) Successful in 7s
Auto Tag / wiki-sync (push) Successful in 8s
Test / rust-fmt-check (push) Successful in 1m20s
Test / frontend-typecheck (push) Successful in 1m29s
Test / frontend-tests (push) Successful in 1m31s
Auto Tag / changelog (push) Successful in 1m29s
Auto Tag / build-macos-arm64 (push) Successful in 2m41s
Test / rust-clippy (push) Successful in 3m28s
Test / rust-tests (push) Successful in 5m33s
Auto Tag / build-linux-amd64 (push) Successful in 8m55s
Auto Tag / build-windows-amd64 (push) Successful in 10m41s
Auto Tag / build-linux-arm64 (push) Successful in 10m46s
Reviewed-on: #59
2026-06-01 01:20:25 +00:00
Shaun Arman
249d20bf85 fix: audit PII redaction metadata, safe bubble update, update ticket
All checks were successful
Test / rust-fmt-check (pull_request) Successful in 1m54s
Test / frontend-typecheck (pull_request) Successful in 2m6s
Test / frontend-tests (pull_request) Successful in 2m5s
Test / rust-clippy (pull_request) Successful in 3m59s
PR Review Automation / review (pull_request) Successful in 4m10s
Test / rust-tests (pull_request) Successful in 5m15s
Add was_pii_redacted and pii_types_redacted to the ai_chat audit log
entry. Both are tracked through the full_message build block (typed
message + attachments) so any redaction that occurs is always
reflected in the compliance record.

Fix response.user_message + suffix potentially yielding 'undefined...'
when user_message is absent. Now unconditionally calls
updateMessageContent with (response.user_message ?? message) + suffix,
so the bubble always shows a valid string regardless of backend build.

Update TICKET-pii-bypass-chat-attachments.md to reflect the final
auto-redact design (not block/warn) so automated review comparisons
against the ticket stop flagging design decisions as defects.
2026-05-31 20:14:23 -05:00
Shaun Arman
631221dbf1 fix(security): full-content PII scan, clippy, IPC null fix, scan size cap
All checks were successful
Test / rust-fmt-check (pull_request) Successful in 1m40s
Test / frontend-typecheck (pull_request) Successful in 1m48s
Test / frontend-tests (pull_request) Successful in 1m43s
Test / rust-clippy (pull_request) Successful in 3m17s
Test / rust-tests (pull_request) Successful in 4m33s
PR Review Automation / review (pull_request) Successful in 5m0s
Remove frontend detectPiiCmd pre-scan loop — backend is sole redaction
authority; bubble update via response.user_message covers user feedback.

Detect PII on full file content before truncating. Previous order
(truncate to 8000 bytes then scan) could miss PII straddling the
boundary. Now: read full content, scan, redact, then truncate to
EMBED_LIMIT (8000 bytes) at a valid UTF-8 char boundary.

logFileIds IPC: pass undefined (not null) for empty array so Tauri
serialises it correctly to Rust Option::None.

Add MAX_TEXT_SCAN_BYTES (32 KB) guard in scan_text_for_pii to prevent
unbounded regex evaluation on oversized payloads.

Fix clippy uninlined_format_args in ai.rs.
2026-05-31 20:01:07 -05:00
Shaun Arman
e9c576f606 fix(security): frontend attachment scan notice, bubble redaction update, fmt fix
Some checks failed
Test / rust-fmt-check (pull_request) Successful in 2m3s
Test / frontend-tests (pull_request) Successful in 1m56s
Test / frontend-typecheck (pull_request) Successful in 1m58s
Test / rust-clippy (pull_request) Failing after 3m0s
Test / rust-tests (pull_request) Successful in 4m22s
PR Review Automation / review (pull_request) Successful in 4m35s
Addresses three findings from the third automated review:

[BLOCKER] No frontend PII pre-check on attachments.
Added detectPiiCmd call for each logFileId before chatMessageCmd.
PII is not blocked (per explicit product decision: auto-redact and
send) but the user now sees a non-blocking amber notice listing
each file and the PII types that will be auto-redacted. Backend
remains the authoritative redaction layer.

[WARNING 2] Chat bubble showed original PII-laden message even though
only the redacted form was sent to AI.
Added updateMessageContent to sessionStore. After chatMessageCmd
returns, if response.user_message is set the user bubble is updated
to reflect what was actually stored in the DB, so the UI is
consistent with the audit log.

CI fix: cargo fmt changes to analysis.rs were not staged in the prior
commit. Committed here — fmt check now passes cleanly.
2026-05-31 19:49:21 -05:00
Shaun Arman
a04d6fc8f5 fix(security): backend-only PII redaction; fix fmt CI failure
Some checks failed
Test / frontend-typecheck (pull_request) Successful in 1m52s
Test / frontend-tests (pull_request) Successful in 1m51s
Test / rust-fmt-check (pull_request) Failing after 1m58s
Test / rust-clippy (pull_request) Failing after 3m4s
Test / rust-tests (pull_request) Successful in 4m31s
PR Review Automation / review (pull_request) Successful in 4m43s
Resolves all three findings from the second automated review and
fixes the cargo fmt --check CI failure (formatting drift in analysis.rs
from a prior merge).

[BLOCKER 1 + BLOCKER 2 + WARNING]
Frontend no longer performs any PII scanning or redaction. All three
concerns stemmed from the same root cause: outMessage was derived
on the frontend and used for display, DB storage (via lastUserMsgRef
and the chat bubble), and the AI payload — causing the original message
to be silently replaced before the backend received it.

Fix: frontend sends the original message verbatim. Backend is now the
sole authority. chat_message auto-redacts the typed message text using
PiiDetector + apply_redactions() before building the full payload, logs
the PII types via tracing::warn, and stores only the redacted form in
ai_messages and the audit log. The redacted form is returned to the
caller as ChatResponse.user_message (Option<String>, absent from direct
provider calls).

Frontend uses message (original) for the chat bubble and
lastUserMsgRef — resolution steps show natural language, not
[Password] tokens. The AI and DB see only the redacted version.

CI fix: cargo fmt applied to analysis.rs; all format checks now pass.
2026-05-31 19:36:44 -05:00
Shaun Arman
f05b954250 fix(security): address PR review — move attachment handling to backend, auto-redact PII
Some checks failed
Test / rust-fmt-check (pull_request) Failing after 1m25s
Test / frontend-typecheck (pull_request) Successful in 1m37s
Test / frontend-tests (pull_request) Successful in 1m36s
Test / rust-clippy (pull_request) Failing after 3m18s
PR Review Automation / review (pull_request) Successful in 4m19s
Test / rust-tests (pull_request) Successful in 4m30s
Resolves all four findings from the automated review:

[BLOCKER 1] Attachment PII scan error path left pendingFiles intact,
allowing retry with stale file references. Fix: file content is no
longer held in frontend state at all — PendingFile drops the content
field entirely. logFileIds are captured before setPendingFiles([]) and
passed directly to the backend.

[BLOCKER 2] Raw file content stored in PendingFile.content created a
UI-visible PII surface and a data-residency risk. Fix: frontend never
reads or stores file content. The backend loads file data from disk,
auto-redacts PII in-memory using pii::apply_redactions(), and embeds
the clean text into the AI message. No PII ever touches the frontend.

[WARNING 1] String-based attachment header parsing was fragile and
bypassable. Fix: parsing is gone — backend identifies attachments by
log_file_id, reads them directly from the DB/disk path, and applies
redaction at that level.

[WARNING 2] Error message disclosed PII type list to the caller. Fix:
PII types are logged via tracing::warn only; no type details in the
user-facing error or API response.

Additionally: typed chat messages are now auto-redacted rather than
blocked. scanTextForPiiCmd runs on the typed text; detected spans are
replaced in reverse-offset order before the message is sent to the AI
and stored in the DB. The user sees the redacted form in their chat
bubble.

Architecture:
- chat_message now accepts log_file_ids: Option<Vec<String>>
- Backend reads file → detects PII → redacts in memory → embeds
- Frontend: no readTextFile, no content field, no frontend PII gate
2026-05-31 19:20:46 -05:00
Shaun Arman
cd26801a39 fix(security): block PII in chat attachments and typed messages
Some checks failed
Test / rust-fmt-check (pull_request) Failing after 1m31s
Test / frontend-tests (pull_request) Successful in 1m34s
Test / frontend-typecheck (pull_request) Successful in 1m36s
Test / rust-clippy (pull_request) Successful in 3m5s
PR Review Automation / review (pull_request) Successful in 4m31s
Test / rust-tests (pull_request) Successful in 4m27s
File attachments were embedded into AI messages without any PII
scanning, allowing credentials, tokens, and other sensitive data
to be forwarded to AI providers in plaintext.

Typed chat messages had the same gap: a user could type a password
or API key directly and it would be sent unscanned.

Changes:
- chat_message (Rust): defence-in-depth scan of all attachment body
  content (between --- Attached: markers); hard rejects if PII found
- detect_pii (Rust): fix return type from pii::PiiDetectionResult
  (spans/original_text) to db::models::PiiDetectionResult
  (detections/total_pii_found) to match the TypeScript contract; the
  LogUpload PII review workflow was receiving undefined for detections
- scan_text_for_pii (Rust): new command — scans arbitrary text for PII
  without creating DB records; used for typed message warnings
- Triage/index.tsx: PendingFile now carries logFileId; handleSend gates
  each text attachment through detectPiiCmd (hard block on PII found);
  typed message text scanned via scanTextForPiiCmd with a one-time
  warning — second send of same message proceeds as acknowledgment
2026-05-31 19:05:51 -05:00
13 changed files with 301 additions and 37 deletions

View File

@ -0,0 +1,102 @@
# TICKET: PII Detection Bypass in AI Chat
**Branch**: `fix/pii-detection-bypass`
---
## Description
Two PII detection bypasses were identified and fixed in the AI triage chat interface.
### Bypass 1 — File Attachments (Critical)
When a user attached a file to a chat message, its content was read via `readTextFile()`, sliced to 8 KB, and embedded directly into the AI message string — bypassing the PII pipeline entirely. The message was forwarded to the configured AI provider in plaintext with no redaction marker in the audit log.
**Root cause**: `handleAttach` stored raw file content in React state. `handleSend` concatenated it into `aiMessage` with no PII check. The backend `chat_message` command applied no validation.
### Bypass 2 — Typed Chat Messages (High)
Plain typed chat messages were sent to the AI provider without any PII scan. A user typing `How secure is my password: abc123!!` would have the password forwarded to the AI and persisted in the audit log in plaintext.
### Related Fix — Wrong Return Type on `detect_pii`
`detect_pii` was serialising `pii::PiiDetectionResult` (`spans`, `original_text`) while the TypeScript interface expected `db::models::PiiDetectionResult` (`detections`, `total_pii_found`). All frontend code reading `result.detections` received `undefined`, meaning the LogUpload PII review workflow was silently broken.
---
## Design Decision: Auto-Redact, Not Block
After initial implementation explored a blocking/warn-then-proceed approach, the product decision was made to **auto-redact PII in-place and send**:
- File attachments: PII is detected on full file content and replaced with type tokens (`[Password]`, `[Email]`, etc.) before the content is embedded in the AI message. The redacted form is stored in the DB and audit log.
- Typed messages: Same auto-redact applied to the user's typed text before the message is sent to the AI provider.
- The user's chat bubble is updated after the response to show the redacted form — users can see exactly what reached the AI.
- The audit log records `was_pii_redacted: bool` and `pii_types_redacted: [...]` alongside the redacted message.
- No user blocking or acknowledgment flow. PII is handled transparently.
---
## Acceptance Criteria
- [x] Attaching a text file containing PII sends successfully; content is auto-redacted before the AI sees it
- [x] Attaching a clean text file proceeds normally with no modification
- [x] PII detection runs on the full file content before truncating to the 8 KB embed limit (no PII straddling the boundary)
- [x] Typed messages containing PII are auto-redacted before being sent to the AI provider
- [x] The chat bubble is updated post-send to show the redacted form of the user's message
- [x] The audit log records `was_pii_redacted`, `pii_types_redacted`, and the full redacted `user_message`
- [x] `detectPiiCmd` returns `detections: PiiSpan[]` and `total_pii_found: number` matching the TypeScript contract
- [x] `chatMessageCmd` passes `logFileIds` as `undefined` (not `null`) when no files are attached
- [x] `scan_text_for_pii` rejects inputs over 32 KB to prevent DoS
- [x] `response.user_message ?? message` used as bubble fallback — no `"undefined..."` concatenation
- [x] All Rust and frontend tests pass; zero clippy warnings; `cargo fmt --check` clean; tsc clean
---
## Work Implemented
### `src-tauri/src/ai/mod.rs`
- Added `user_message: Option<String>` to `ChatResponse` — set by `chat_message`, absent from direct provider calls
### `src-tauri/src/ai/anthropic.rs`, `gemini.rs`, `mistral.rs`, `ollama.rs`, `openai.rs`
- Added `user_message: None` to all `ChatResponse { ... }` constructors
### `src-tauri/src/commands/ai.rs`
- `chat_message` now accepts `log_file_ids: Option<Vec<String>>`
- Step 1: auto-redacts the typed message text with `PiiDetector` + `apply_redactions`
- Step 2: loads each attachment from DB, detects PII on **full file content**, applies redactions, then truncates to 8 KB at a valid UTF-8 char boundary
- Tracks `was_pii_redacted` and `redacted_pii_types` across both steps
- Audit log includes `was_pii_redacted: bool` and `pii_types_redacted: [...]`
- Returns `user_message: Some(stored_user_message)` in `ChatResponse`
### `src-tauri/src/commands/analysis.rs`
- Fixed `detect_pii` return type from `pii::PiiDetectionResult` to `db::models::PiiDetectionResult`
- Added `scan_text_for_pii(text: String)` with 32 KB input cap
### `src-tauri/src/lib.rs`
- Registered `scan_text_for_pii`
### `src/lib/tauriCommands.ts`
- `ChatResponse` interface: added `user_message?: string`
- `chatMessageCmd` signature: added `logFileIds: string[]`; passes `undefined` when empty
- Added `scanTextForPiiCmd` wrapper
### `src/stores/sessionStore.ts`
- Added `updateMessageContent(id, content)` action
### `src/pages/Triage/index.tsx`
- `PendingFile` type: `{ name: string; logFileId: string }` — no raw content stored
- `handleAttach`: only uploads the file and stores `logFileId`; no `readTextFile`
- `handleSend`: passes `logFileIds` to backend; after response updates the bubble with `(response.user_message ?? message) + suffix`
---
## Testing Needed
1. Attach a file containing `password: secret123` → message sends; chat bubble shows `[Password]` in the embedded content; no plaintext credential in bubble or DB
2. Attach a clean text file → content appears unmodified in the chat context
3. Attach a file where PII appears near the 8000-byte mark → content is fully redacted before truncation
4. Type `My password is abc123!!` → message sends; bubble shows `My [Password] is [Password]`
5. On LogUpload page, upload a file with a known IP/email → PII spans appear in the review UI
6. Check audit log after a PII-containing message: `was_pii_redacted: true`, `pii_types_redacted` populated
7. Check audit log after a clean message: `was_pii_redacted: false`, `pii_types_redacted: []`
8. `cargo test` → 228/228 pass; `npm run test:run` → 103/103 pass; `cargo fmt --check` clean; `npx tsc --noEmit` clean

View File

@ -116,6 +116,7 @@ impl Provider for AnthropicProvider {
content,
model,
usage,
user_message: None,
tool_calls: None,
})
}

View File

@ -119,6 +119,7 @@ impl Provider for GeminiProvider {
content,
model: config.model.clone(),
usage,
user_message: None,
tool_calls: None,
})
}

View File

@ -84,6 +84,7 @@ impl Provider for MistralProvider {
content,
model: config.model.clone(),
usage,
user_message: None,
tool_calls: None,
})
}

View File

@ -30,6 +30,10 @@ pub struct ChatResponse {
pub content: String,
pub model: String,
pub usage: Option<TokenUsage>,
/// The user message as it was stored in the DB (may be auto-redacted).
/// Set by chat_message; absent from direct provider calls.
#[serde(skip_serializing_if = "Option::is_none")]
pub user_message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>,
}

View File

@ -100,6 +100,7 @@ impl Provider for OllamaProvider {
content,
model: config.model.clone(),
usage,
user_message: None,
tool_calls: None,
})
}

View File

@ -197,6 +197,7 @@ impl OpenAiProvider {
content,
model: config.model.clone(),
usage,
user_message: None,
tool_calls,
})
}
@ -397,6 +398,7 @@ impl OpenAiProvider {
content,
model: config.model.clone(),
usage: None, // This custom REST contract doesn't provide token usage in response
user_message: None,
tool_calls,
})
}

View File

@ -165,6 +165,7 @@ fn extract_list(text: &str, header: &str) -> Vec<String> {
pub async fn chat_message(
issue_id: String,
message: String,
log_file_ids: Option<Vec<String>>,
provider_config: ProviderConfig,
system_prompt: Option<String>,
app_handle: tauri::AppHandle,
@ -233,6 +234,96 @@ pub async fn chat_message(
.collect()
};
// Auto-redact PII in both the typed message and any file attachments.
// The backend is the sole authority for redaction; the frontend sends original content.
let mut was_pii_redacted = false;
let mut redacted_pii_types: Vec<String> = Vec::new();
let full_message = {
// Step 1: redact the typed user message text.
let base = {
let spans = crate::pii::PiiDetector::new().detect(&message);
if spans.is_empty() {
message.clone()
} else {
let types: std::collections::HashSet<&str> =
spans.iter().map(|s| s.pii_type.as_str()).collect();
let mut type_list: Vec<&str> = types.into_iter().collect();
type_list.sort_unstable();
warn!(
pii_types = ?type_list,
pii_count = spans.len(),
"PII detected in typed chat message — auto-redacting before AI send"
);
was_pii_redacted = true;
redacted_pii_types.extend(type_list.iter().map(|s| s.to_string()));
crate::pii::apply_redactions(&message, &spans)
}
};
// Step 2: load attachment files from DB, scan, and embed clean content.
let files: Vec<(String, String)> = if let Some(ref ids) = log_file_ids {
let db = state.db.lock().map_err(|e| e.to_string())?;
let mut v = Vec::new();
for file_id in ids {
if let Ok((name, path)) = db
.prepare("SELECT file_name, file_path FROM log_files WHERE id = ?1")
.and_then(|mut s| {
s.query_row([file_id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
})
{
v.push((name, path));
}
}
v
} else {
vec![]
};
// 8 KB embed limit; detect + redact on full content so PII at the boundary is caught.
const EMBED_LIMIT: usize = 8_000;
let mut msg = base;
for (file_name, file_path) in &files {
let content = std::fs::read_to_string(file_path).unwrap_or_default();
let spans = crate::pii::PiiDetector::new().detect(&content);
let redacted = if spans.is_empty() {
content
} else {
let types: std::collections::HashSet<&str> =
spans.iter().map(|s| s.pii_type.as_str()).collect();
let mut type_list: Vec<&str> = types.into_iter().collect();
type_list.sort_unstable();
warn!(
file_name = %file_name,
pii_types = ?type_list,
pii_count = spans.len(),
"PII detected in chat attachment — auto-redacting before AI send"
);
was_pii_redacted = true;
redacted_pii_types.extend(type_list.iter().map(|s| s.to_string()));
crate::pii::apply_redactions(&content, &spans)
};
// Truncate after redaction so the cut never lands inside a PII span.
let embed_end = if redacted.len() > EMBED_LIMIT {
let mut e = EMBED_LIMIT;
while !redacted.is_char_boundary(e) {
e -= 1;
}
e
} else {
redacted.len()
};
msg.push_str(&format!(
"\n\n--- Attached: {file_name} ---\n{}",
&redacted[..embed_end]
));
}
msg
};
let provider = create_provider(&provider_config);
// Search integration sources for relevant context
@ -290,7 +381,7 @@ pub async fn chat_message(
messages.push(Message {
role: "user".into(),
content: message.clone(),
content: full_message.clone(),
tool_call_id: None,
tool_calls: None,
});
@ -360,9 +451,11 @@ pub async fn chat_message(
}
// Save both user message and response to DB
let stored_user_message;
{
let db = state.db.lock().map_err(|e| e.to_string())?;
let user_msg = AiMessage::new(conversation_id.clone(), "user".to_string(), message);
let user_msg = AiMessage::new(conversation_id.clone(), "user".to_string(), full_message);
stored_user_message = user_msg.content.clone();
let asst_msg = AiMessage::new(
conversation_id,
"assistant".to_string(),
@ -390,11 +483,23 @@ pub async fn chat_message(
.map_err(|e| e.to_string())?;
// Audit - capture full transmission details
let pii_types_for_audit = {
use std::collections::HashSet;
let mut v: Vec<String> = redacted_pii_types
.into_iter()
.collect::<HashSet<_>>()
.into_iter()
.collect();
v.sort_unstable();
v
};
let audit_details = serde_json::json!({
"provider": provider_config.name,
"model": provider_config.model,
"api_url": provider_config.api_url,
"user_message": user_msg.content,
"was_pii_redacted": was_pii_redacted,
"pii_types_redacted": pii_types_for_audit,
"response_preview": if final_response.content.len() > 200 {
format!("{preview}...", preview = &final_response.content[..200])
} else {
@ -419,7 +524,10 @@ pub async fn chat_message(
}
}
Ok(final_response)
Ok(crate::ai::ChatResponse {
user_message: Some(stored_user_message),
..final_response
})
}
#[tauri::command]

View File

@ -8,8 +8,8 @@ use std::path::{Path, PathBuf};
use tauri::State;
use tracing::warn;
use crate::db::models::{AuditEntry, LogFile, LogFileSummary, PiiSpanRecord};
use crate::pii::{self, PiiDetectionResult, PiiDetector, RedactedLogFile};
use crate::db::models::{AuditEntry, LogFile, LogFileSummary, PiiDetectionResult, PiiSpanRecord};
use crate::pii::{self, PiiDetector, RedactedLogFile};
use crate::state::AppState;
const MAX_LOG_FILE_BYTES: u64 = 50 * 1024 * 1024;
@ -65,7 +65,9 @@ fn compress_text(text: &str) -> Result<Vec<u8>, String> {
encoder
.write_all(text.as_bytes())
.map_err(|e| format!("Compression write error: {e}"))?;
encoder.finish().map_err(|e| format!("Compression finish error: {e}"))
encoder
.finish()
.map_err(|e| format!("Compression finish error: {e}"))
}
/// 100 MB cap — prevents decompression-bomb attacks on crafted DB entries.
@ -352,8 +354,8 @@ pub async fn upload_log_file_by_content(
..log_file
};
let compressed = compress_text(&content)
.map_err(|e| format!("Failed to compress log content: {e}"))?;
let compressed =
compress_text(&content).map_err(|e| format!("Failed to compress log content: {e}"))?;
let db = state.db.lock().map_err(|e| e.to_string())?;
db.execute(
@ -440,10 +442,34 @@ pub async fn detect_pii(
}
}
let total_pii_found = spans.len();
Ok(PiiDetectionResult {
log_file_id,
spans,
original_text: content,
detections: spans,
total_pii_found,
})
}
/// Maximum text size accepted by scan_text_for_pii to prevent DoS on large payloads.
const MAX_TEXT_SCAN_BYTES: usize = 32 * 1024; // 32 KB
/// Scan arbitrary text for PII without creating any database records.
/// Used by the backend before sending typed chat messages to AI providers.
#[tauri::command]
pub async fn scan_text_for_pii(text: String) -> Result<PiiDetectionResult, String> {
if text.len() > MAX_TEXT_SCAN_BYTES {
return Err(format!(
"Text too large for inline PII scan ({} bytes; limit {MAX_TEXT_SCAN_BYTES})",
text.len()
));
}
let detector = PiiDetector::new();
let spans = detector.detect(&text);
let total_pii_found = spans.len();
Ok(PiiDetectionResult {
log_file_id: String::new(),
detections: spans,
total_pii_found,
})
}
@ -699,7 +725,10 @@ mod tests {
// For in-memory gzip this essentially never fails, but the API now allows
// callers to surface the error rather than storing empty bytes.
let result = compress_text("normal log line");
assert!(result.is_ok(), "compress_text should succeed for normal input");
assert!(
result.is_ok(),
"compress_text should succeed for normal input"
);
assert!(!result.unwrap().is_empty());
}

View File

@ -85,6 +85,7 @@ pub fn run() {
commands::analysis::upload_log_file,
commands::analysis::upload_log_file_by_content,
commands::analysis::detect_pii,
commands::analysis::scan_text_for_pii,
commands::analysis::apply_redactions,
commands::analysis::get_log_file_content,
commands::analysis::list_all_log_files,

View File

@ -34,6 +34,8 @@ export interface ChatResponse {
content: string;
model: string;
usage?: TokenUsage;
/** What was stored in the DB — may be auto-redacted. Use this for display and history. */
user_message?: string;
}
export interface AnalysisResult {
@ -271,8 +273,20 @@ export interface TriageMessage {
export const analyzeLogsCmd = (issueId: string, logFileIds: string[], providerConfig: ProviderConfig) =>
invoke<AnalysisResult>("analyze_logs", { issueId, logFileIds, providerConfig });
export const chatMessageCmd = (issueId: string, message: string, providerConfig: ProviderConfig, systemPrompt?: string) =>
invoke<ChatResponse>("chat_message", { issueId, message, providerConfig, systemPrompt: systemPrompt ?? null });
export const chatMessageCmd = (
issueId: string,
message: string,
logFileIds: string[],
providerConfig: ProviderConfig,
systemPrompt?: string
) =>
invoke<ChatResponse>("chat_message", {
issueId,
message,
logFileIds: logFileIds.length > 0 ? logFileIds : undefined,
providerConfig,
systemPrompt: systemPrompt ?? null,
});
export const listProvidersCmd = () => invoke<ProviderInfo[]>("list_providers");
@ -308,6 +322,9 @@ export const deleteImageAttachmentCmd = (attachmentId: string) =>
export const detectPiiCmd = (logFileId: string) =>
invoke<PiiDetectionResult>("detect_pii", { logFileId });
export const scanTextForPiiCmd = (text: string) =>
invoke<PiiDetectionResult>("scan_text_for_pii", { text });
export const applyRedactionsCmd = (logFileId: string, approvedSpanIds: string[]) =>
invoke<RedactedLogFile>("apply_redactions", { logFileId, approvedSpanIds });

View File

@ -2,7 +2,6 @@ import { useEffect, useRef, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { CheckCircle, ChevronRight } from "lucide-react";
import { open } from "@tauri-apps/plugin-dialog";
import { readTextFile } from "@tauri-apps/plugin-fs";
import { ChatWindow } from "@/components/ChatWindow";
import { TriageProgress } from "@/components/TriageProgress";
import { useSessionStore } from "@/stores/sessionStore";
@ -34,7 +33,7 @@ function isCloseIntent(message: string): boolean {
return CLOSE_PATTERNS.some((p) => lower.includes(p));
}
type PendingFile = { name: string; content: string | null };
type PendingFile = { name: string; logFileId: string };
export default function Triage() {
const { id } = useParams<{ id: string }>();
@ -46,7 +45,7 @@ export default function Triage() {
const lastUserMsgRef = useRef<string>("");
const initialized = useRef(false);
const { currentIssue, messages, currentWhyLevel, activeDomain, startSession, addMessage, setWhyLevel, setActiveDomain } =
const { currentIssue, messages, currentWhyLevel, activeDomain, startSession, addMessage, updateMessageContent, setWhyLevel, setActiveDomain } =
useSessionStore();
const { getActiveProvider } = useSettingsStore();
@ -101,14 +100,7 @@ export default function Triage() {
const paths = Array.isArray(selected) ? selected : [selected];
for (const filePath of paths) {
const logFile = await uploadLogFileCmd(id, filePath);
let content: string | null = null;
try {
const raw = await readTextFile(filePath);
content = raw.slice(0, 8000); // cap at 8 KB to keep context manageable
} catch {
// Binary file (image) — include filename only as context
}
setPendingFiles((prev) => [...prev, { name: logFile.file_name, content }]);
setPendingFiles((prev) => [...prev, { name: logFile.file_name, logFileId: logFile.id }]);
}
} catch (e) {
setError(`Attachment failed: ${String(e)}`);
@ -142,18 +134,10 @@ export default function Triage() {
setIsLoading(true);
setError(null);
// Build AI context: user text + file contents; show only text + filenames in chat UI
const fileContext = pendingFiles
.map((f) =>
f.content
? `\n\n--- Attached: ${f.name} ---\n${f.content}`
: `\n\n[Image attached: ${f.name} — describe what you see if relevant]`
)
.join("");
const aiMessage = pendingFiles.length > 0 ? `${message}${fileContext}` : message;
const fileNames = pendingFiles.map((f) => f.name);
const displayContent =
pendingFiles.length > 0
? `${message}${message ? "\n" : ""}📎 ${pendingFiles.map((f) => f.name).join(", ")}`
? `${message}${message ? "\n" : ""}📎 ${fileNames.join(", ")}`
: message;
const userMsg: TriageMessage = {
@ -166,21 +150,29 @@ export default function Triage() {
};
lastUserMsgRef.current = message;
addMessage(userMsg);
const logFileIds = pendingFiles.map((f) => f.logFileId);
setPendingFiles([]);
try {
// Detect domain from conversation messages
const messageContents = messages.map((m) => m.content);
const detectedDomain = detectDomain(messageContents);
// Update active domain if it has changed
if (detectedDomain !== activeDomain && detectedDomain !== "general") {
setActiveDomain(detectedDomain);
}
// Use the active domain for the system prompt
const systemPrompt = activeDomain ? getDomainPrompt(activeDomain) : undefined;
const response = await chatMessageCmd(id, aiMessage, provider, systemPrompt);
// Backend auto-redacts PII in both message text and attachments before sending to AI.
const response = await chatMessageCmd(id, message, logFileIds, provider, systemPrompt);
// Update the user bubble with what was actually stored (may be auto-redacted).
// Fall back to the original message if user_message is absent (older backend builds).
const suffix = fileNames.length > 0 ? `\n📎 ${fileNames.join(", ")}` : "";
updateMessageContent(userMsg.id, (response.user_message ?? message) + suffix);
const assistantMsg: TriageMessage = {
id: `asst-${Date.now()}`,
issue_id: id,

View File

@ -14,6 +14,7 @@ interface SessionState {
startSession: (issue: Issue) => void;
addMessage: (message: TriageMessage) => void;
updateMessageContent: (id: string, content: string) => void;
setPiiSpans: (spans: PiiSpan[]) => void;
setApprovedRedactions: (spans: PiiSpan[]) => void;
setWhyLevel: (level: number) => void;
@ -40,6 +41,10 @@ export const useSessionStore = create<SessionState>((set) => ({
...initialState,
startSession: (issue) => set({ currentIssue: issue, messages: [], currentWhyLevel: 1, activeDomain: issue.category }),
addMessage: (message) => set((state) => ({ messages: [...state.messages, message] })),
updateMessageContent: (id, content) =>
set((state) => ({
messages: state.messages.map((m) => (m.id === id ? { ...m, content } : m)),
})),
setPiiSpans: (spans) => set({ piiSpans: spans }),
setApprovedRedactions: (spans) => set({ approvedRedactions: spans }),
setWhyLevel: (level) => set({ currentWhyLevel: level }),