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.
This commit is contained in:
Shaun Arman 2026-05-31 20:01:07 -05:00
parent e9c576f606
commit 631221dbf1
4 changed files with 32 additions and 34 deletions

View File

@ -277,13 +277,15 @@ pub async fn chat_message(
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 preview = &content[..content.len().min(8000)];
let spans = crate::pii::PiiDetector::new().detect(preview);
let body = if spans.is_empty() {
preview.to_string()
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();
@ -295,9 +297,22 @@ pub async fn chat_message(
pii_count = spans.len(),
"PII detected in chat attachment — auto-redacting before AI send"
);
crate::pii::apply_redactions(preview, &spans)
crate::pii::apply_redactions(&content, &spans)
};
msg.push_str(&format!("\n\n--- Attached: {} ---\n{}", file_name, body));
// 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
};

View File

@ -450,10 +450,19 @@ pub async fn detect_pii(
})
}
/// 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 to warn users before sending typed chat messages to AI providers.
/// 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();

View File

@ -283,7 +283,7 @@ export const chatMessageCmd = (
invoke<ChatResponse>("chat_message", {
issueId,
message,
logFileIds: logFileIds.length > 0 ? logFileIds : null,
logFileIds: logFileIds.length > 0 ? logFileIds : undefined,
providerConfig,
systemPrompt: systemPrompt ?? null,
});

View File

@ -8,7 +8,6 @@ import { useSessionStore } from "@/stores/sessionStore";
import { useSettingsStore } from "@/stores/settingsStore";
import {
chatMessageCmd,
detectPiiCmd,
getIssueCmd,
getIssueMessagesCmd,
uploadLogFileCmd,
@ -41,7 +40,6 @@ export default function Triage() {
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
// Track the last user message so we can save it as a resolution step when why level advances
const lastUserMsgRef = useRef<string>("");
@ -135,25 +133,6 @@ export default function Triage() {
setIsLoading(true);
setError(null);
setNotice(null);
// Pre-send attachment PII scan: surface a notice to the user about what will be
// auto-redacted. The send is NOT blocked — the backend performs the actual redaction.
const piiNotices: string[] = [];
for (const f of pendingFiles) {
try {
const result = await detectPiiCmd(f.logFileId);
if (result.total_pii_found > 0) {
const types = [...new Set(result.detections.map((d) => d.pii_type))].join(", ");
piiNotices.push(`"${f.name}" (${types})`);
}
} catch {
// Non-fatal — backend will still scan before sending to AI
}
}
if (piiNotices.length > 0) {
setNotice(`PII auto-redacted before sending: ${piiNotices.join("; ")}`);
}
const fileNames = pendingFiles.map((f) => f.name);
const displayContent =
@ -257,11 +236,6 @@ export default function Triage() {
<TriageProgress currentLevel={Math.min(currentWhyLevel, 5)} />
</div>
{notice && (
<div className="mx-6 mt-3 p-3 bg-amber-50 border border-amber-200 rounded-md text-sm text-amber-700">
{notice}
</div>
)}
{error && (
<div className="mx-6 mt-3 p-3 bg-destructive/10 border border-destructive/20 rounded-md text-sm text-destructive">
{error}