mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* fix: comprehensive security hardening across all layers Critical: - Replace --dangerously-skip-permissions with explicit tool allowlist via settings.json (Claude Code bridge) - Constant-time token comparison (subtle crate) in web auth and orchestrator auth to prevent timing attacks High: - Revoke tokens and clean up handles on container creation failure - Drop SETUID/SETGID capabilities from containers (keep only CHOWN) - Disable redirect following in HTTP tool and WASM wrapper (SSRF) - Reject URL userinfo (@) in WASM allowlist parser (host confusion) - Fix binary body bypassing leak detection (from_utf8 -> from_utf8_lossy) - Protect identity files from LLM overwrites (prompt injection defense) - Prevent tool shadowing: built-in tools cannot be replaced dynamically - User-scoped job APIs: list/detail/cancel/restart/prompt/events/files - CORS restricted to localhost origins, WebSocket origin validation - Sandbox shell fail-closed: no silent fallback to unsandboxed execution - Scrub secrets from log broadcaster before SSE broadcast - XSS sanitization on rendered markdown in web UI - WASM epoch ticker thread so timeout deadlines actually fire Medium: - Cap state transition history at 200 entries - SSE/WebSocket connection limit (100 max) - Request body size limit (1MB) - Response body size limit enforcement in WASM HTTP - UTF-8 safe string truncation (routine engine, shell tool) - Fix PolicyAction::Sanitize to actually run the sanitizer - TOCTOU fix in scheduler and context manager (hold write lock) - Project file serving moved behind auth - Path traversal guard on project_id - Session file permissions set to 0600 on unix - AtomicUsize for routine running_count (panic-safe) - Completion detection hardened against false positives and tool injection - Tool output no longer drives job completion (only LLM response) Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address security review findings across all layers - Fix path traversal sandbox bypass via lexical normalization (file.rs) - Fix SSRF via DNS rebinding with pre-request hostname resolution (http.rs) - Add token budget enforcement on LLM calls (reasoning.rs, state.rs) - Fix cross-user chat history leak with ownership verification (store.rs, server.rs) - Add sliding-window rate limiter on gateway chat endpoint (server.rs) - Harden extension install: HTTPS-only, 50MB cap, WASM magic validation (manager.rs) - Add destructive command blocklist that overrides shell auto-approval (shell.rs) - Add 5MB response body size cap to HTTP tool (http.rs) Co-Authored-By: Claude Opus 4.6 <[email protected]> * refactor: deduplicate shared helpers and remove dead code Extract floor_char_boundary and llm_signals_completion into src/util.rs, unifying diverging phrase lists from agent/worker.rs and worker/runtime.rs. Remove dead RespondResult::usage(), duplicate PROTECTED_IDENTITY_FILES constant, double LeakDetector scanning in WebLogLayer, and invalid 0.0.0.0 origin from WebSocket allow list. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address PR review findings and CI test failures - Fix record_failed_approve: .truncate(true) wiped the attempts file before reading, so failed pairing attempts never accumulated and rate limiting never triggered. - Guard wizard WASM test: skip gracefully when channel build artifacts are absent (CI doesn't compile wasm32-wasip2 targets). - Fix DNS rebinding check: use port 0 instead of hardcoded 443, since the port is irrelevant for hostname resolution. - Remove hardcoded CORS port 3001: the dynamic addr.port() entries already cover the actual server port. - Require WebSocket Origin header: reject connections that omit it entirely, since browsers always send Origin for WS upgrades and a missing header indicates a non-browser client bypassing the check. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address second round of PR review findings - store.rs: reintroduce file locking around read-modify-write in record_failed_approve (concurrent callers could clobber each other). - sse.rs: replace load+check+fetch_add with atomic fetch_update in both subscribe_raw() and subscribe() to prevent overshooting max_connections. - ws.rs: decrement WS tracker before early return when subscribe_raw() returns None (connection limit reached), fixing a counter leak. - server.rs: parse WS Origin host exactly instead of prefix matching, preventing bypass via crafted origins like http://localhost.evil.com. - workspace_integration.rs: skip tests gracefully when Postgres is unreachable instead of panicking (fixes 10 CI failures). Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: add Origin header to WS integration tests The Origin header requirement added in a3b0190 broke the WS gateway integration tests. Test clients now send Origin: http://127.0.0.1:{port} to match the server's localhost validation. Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
179 lines
5.6 KiB
Rust
179 lines
5.6 KiB
Rust
//! Shared utility functions used across the codebase.
|
|
|
|
/// Find the largest valid UTF-8 char boundary at or before `pos`.
|
|
///
|
|
/// Polyfill for `str::floor_char_boundary` (nightly-only). Use when
|
|
/// truncating strings by byte position to avoid panicking on multi-byte
|
|
/// characters.
|
|
pub fn floor_char_boundary(s: &str, pos: usize) -> usize {
|
|
if pos >= s.len() {
|
|
return s.len();
|
|
}
|
|
let mut i = pos;
|
|
while i > 0 && !s.is_char_boundary(i) {
|
|
i -= 1;
|
|
}
|
|
i
|
|
}
|
|
|
|
/// Check if an LLM response explicitly signals that a job/task is complete.
|
|
///
|
|
/// Uses phrase-level matching to avoid false positives from bare words like
|
|
/// "done" or "complete" appearing in non-completion contexts (e.g. "not done yet",
|
|
/// "the download is incomplete").
|
|
pub fn llm_signals_completion(response: &str) -> bool {
|
|
let lower = response.to_lowercase();
|
|
|
|
// Superset of phrases from agent/worker.rs and worker/runtime.rs.
|
|
let positive_phrases = [
|
|
"job is complete",
|
|
"job is done",
|
|
"job is finished",
|
|
"task is complete",
|
|
"task is done",
|
|
"task is finished",
|
|
"work is complete",
|
|
"work is done",
|
|
"work is finished",
|
|
"successfully completed",
|
|
"have completed the job",
|
|
"have completed the task",
|
|
"have finished the job",
|
|
"have finished the task",
|
|
"all steps are complete",
|
|
"all steps are done",
|
|
"i have completed",
|
|
"i've completed",
|
|
"all done",
|
|
"all tasks complete",
|
|
];
|
|
|
|
let negative_phrases = [
|
|
"not complete",
|
|
"not done",
|
|
"not finished",
|
|
"incomplete",
|
|
"unfinished",
|
|
"isn't done",
|
|
"isn't complete",
|
|
"isn't finished",
|
|
"not yet done",
|
|
"not yet complete",
|
|
"not yet finished",
|
|
];
|
|
|
|
let has_negative = negative_phrases.iter().any(|p| lower.contains(p));
|
|
if has_negative {
|
|
return false;
|
|
}
|
|
|
|
positive_phrases.iter().any(|p| lower.contains(p))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use crate::util::{floor_char_boundary, llm_signals_completion};
|
|
|
|
// ── floor_char_boundary ──
|
|
|
|
#[test]
|
|
fn floor_char_boundary_at_valid_boundary() {
|
|
assert_eq!(floor_char_boundary("hello", 3), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn floor_char_boundary_mid_multibyte_char() {
|
|
// h = 1 byte, é = 2 bytes, total 3 bytes
|
|
let s = "hé";
|
|
assert_eq!(floor_char_boundary(s, 2), 1); // byte 2 is mid-é, back up to 1
|
|
}
|
|
|
|
#[test]
|
|
fn floor_char_boundary_past_end() {
|
|
assert_eq!(floor_char_boundary("hi", 100), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn floor_char_boundary_at_zero() {
|
|
assert_eq!(floor_char_boundary("hello", 0), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn floor_char_boundary_empty_string() {
|
|
assert_eq!(floor_char_boundary("", 5), 0);
|
|
}
|
|
|
|
// ── llm_signals_completion ──
|
|
|
|
#[test]
|
|
fn signals_completion_positive() {
|
|
assert!(llm_signals_completion("The job is complete."));
|
|
assert!(llm_signals_completion("I have completed the task."));
|
|
assert!(llm_signals_completion("All done, here are the results."));
|
|
assert!(llm_signals_completion("Task is finished successfully."));
|
|
assert!(llm_signals_completion(
|
|
"I have completed the task successfully."
|
|
));
|
|
assert!(llm_signals_completion(
|
|
"All steps are complete and verified."
|
|
));
|
|
assert!(llm_signals_completion(
|
|
"I've done all the work. The work is done."
|
|
));
|
|
assert!(llm_signals_completion(
|
|
"Successfully completed the migration."
|
|
));
|
|
assert!(llm_signals_completion(
|
|
"I have completed the job ahead of schedule."
|
|
));
|
|
assert!(llm_signals_completion("I have finished the task."));
|
|
assert!(llm_signals_completion("All steps are done now."));
|
|
assert!(llm_signals_completion("I've completed everything."));
|
|
assert!(llm_signals_completion("All tasks complete."));
|
|
}
|
|
|
|
#[test]
|
|
fn signals_completion_negative() {
|
|
assert!(!llm_signals_completion("The task is not complete yet."));
|
|
assert!(!llm_signals_completion("This is not done."));
|
|
assert!(!llm_signals_completion("The work is incomplete."));
|
|
assert!(!llm_signals_completion("Build is unfinished."));
|
|
assert!(!llm_signals_completion(
|
|
"The migration is not yet finished."
|
|
));
|
|
assert!(!llm_signals_completion("The job isn't done yet."));
|
|
assert!(!llm_signals_completion("This remains unfinished."));
|
|
}
|
|
|
|
#[test]
|
|
fn signals_completion_no_bare_substrings() {
|
|
assert!(!llm_signals_completion("The download completed."));
|
|
assert!(!llm_signals_completion(
|
|
"Function done_callback was called."
|
|
));
|
|
assert!(!llm_signals_completion("Set is_complete = true"));
|
|
assert!(!llm_signals_completion("Running step 3 of 5"));
|
|
assert!(!llm_signals_completion(
|
|
"I need to complete more work first."
|
|
));
|
|
assert!(!llm_signals_completion(
|
|
"Let me finish the remaining steps."
|
|
));
|
|
assert!(!llm_signals_completion(
|
|
"I'm done analyzing, now let me fix it."
|
|
));
|
|
assert!(!llm_signals_completion(
|
|
"I completed step 1 but step 2 remains."
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn signals_completion_tool_output_injection() {
|
|
assert!(!llm_signals_completion("TASK_COMPLETE"));
|
|
assert!(!llm_signals_completion("JOB_DONE"));
|
|
assert!(!llm_signals_completion(
|
|
"The tool returned: TASK_COMPLETE signal"
|
|
));
|
|
}
|
|
}
|