security: prompt-injection hardening — path jail, metadata neutralization, CSPRNG fencing#92
security: prompt-injection hardening — path jail, metadata neutralization, CSPRNG fencing#92narthanaj wants to merge 5 commits intoyvgude:mainfrom
Conversation
- Introduced a test in `no_unsanitized_llm_output.rs` to scan for direct filesystem calls in tool modules, ensuring they route through the appropriate jailed wrappers. - Implemented a whitelist mechanism for specific tools that require raw filesystem access, with detailed justifications for each entry. - Added a second test in `prompt_injection_tests.rs` to validate the path-jail functionality, including various rejection scenarios for absolute paths, symlinks, and non-regular files. - Ensured that the tests cover edge cases such as TOCTOU races and size limitations for file reads, enhancing the security posture of the system.
…nced instruction blocks
… jail check to top of handle_with_options - diff and auto-delta\n paths no longer bypass the path jail (ctx_read.rs)\n2. Skip symlink entries in ctx_search walker before read_to_string\n to prevent symlink-based exfiltration (ctx_search.rs)\n3. Add OsStr import for non-Linux Unix compile (pathjail.rs)\n4. Fix false-positive truncation in read_pipe_bounded - use take(cap+1)\n so exact-cap streams are not incorrectly marked truncated (server.rs)\n5. Correct LLM contract line to match real close-marker shape\n LCTX_*_{hex}>>> (instructions.rs)\n6. Add serial_test crate + #[serial] on all 26 env-mutating integration\n tests to prevent races without --test-threads=1\n7. Single-pass early-exit in neutralize_metadata - budget of 264 chars\n prevents CPU/memory DoS from multi-megabyte fact values (sanitize.rs)\n\nCo-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR hardens lean-ctx against prompt-injection-driven data exfiltration by enforcing a project-root “path jail” for MCP-facing file operations, adding bounded capture for shell outputs, and neutralizing/fencing untrusted metadata before it reaches the LLM instruction stream.
Changes:
- Introduces
core::pathjail(openat2 + fallbacks, TOCTOU defenses, file-type rejection, size caps) and wires it into tool entry points. - Adds
core::sanitizefor metadata neutralization and CSPRNG-fenced data blocks; fences knowledge/gotcha content ininstructions.rs. - Adds integration tests + a CI guard test to prevent new unjailed filesystem reads in tool/pattern modules.
Reviewed changes
Copilot reviewed 16 out of 17 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| rust/src/core/pathjail.rs | New jailed open/read primitives with TOCTOU and file-type/size protections. |
| rust/src/core/sanitize.rs | New metadata neutralization + CSPRNG fencing helpers. |
| rust/src/tools/ctx_read.rs | Applies jail check early; reads via read_in_jail and adds truncation marker. |
| rust/src/tools/ctx_search.rs | Jails search root; skips symlink DirEntry before read_to_string. |
| rust/src/tools/ctx_tree.rs | Jails tree root before walking. |
| rust/src/tools/ctx_smart_read.rs | Ensures pre-read mode selection can’t leak via unjailed reads. |
| rust/src/tools/ctx_edit.rs | Adds edit-target jail validation and size cap enforcement for pre-edit read. |
| rust/src/server.rs | Adds bounded parallel stdout/stderr capture with truncation + kill-on-cap. |
| rust/src/shell.rs | Applies bounded capture for CLI execution path as well. |
| rust/src/instructions.rs | Wraps knowledge/gotcha blocks in CSPRNG fences and adds LLM contract line. |
| rust/src/core/knowledge.rs | Neutralizes knowledge fact fields before formatting. |
| rust/src/core/gotcha_tracker.rs | Neutralizes gotcha trigger/resolution fields before formatting. |
| rust/tests/prompt_injection_tests.rs | New integration tests covering jail behavior, TOCTOU, fencing/neutralization. |
| rust/tests/no_unsanitized_llm_output.rs | New CI guard scanning tool/pattern modules for raw fs usage. |
| rust/src/core/mod.rs | Exposes new pathjail and sanitize modules. |
| rust/Cargo.toml / rust/Cargo.lock | Adds rustix, getrandom, and serial_test dependencies/lock entries. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| let mut ancestor = target.as_path(); | ||
| let mut tail_names: Vec<std::ffi::OsString> = Vec::new(); | ||
| while !ancestor.exists() { | ||
| match ancestor.file_name() { | ||
| Some(name) => tail_names.push(name.to_os_string()), | ||
| None => return Err(format!("ERROR: cannot resolve '{path}'")), | ||
| } | ||
| match ancestor.parent() { | ||
| Some(p) => ancestor = p, | ||
| None => return Err(format!("ERROR: cannot resolve '{path}'")), | ||
| } | ||
| } | ||
| let canon_ancestor = std::fs::canonicalize(ancestor) | ||
| .map_err(|e| format!("ERROR: cannot resolve '{path}': {e}"))?; | ||
| let mut out = canon_ancestor; | ||
| for name in tail_names.iter().rev() { | ||
| out.push(name); | ||
| } | ||
| out |
There was a problem hiding this comment.
jail_edit_target can return a path containing .. components in the allow_create branch (when canonicalize(target) fails and you re-append tail_names). Because the later write path uses std::fs::create_dir_all/std::fs::write on this returned path, .. can become effective once missing components are created, allowing a write outside the jail despite the starts_with check (which doesn't resolve ..). Consider rejecting any ParentDir/CurDir components in the final path (or logically-normalizing before the starts_with check) so create-mode cannot traverse out of the allowed roots.
| fn generate_csprng_token() -> String { | ||
| let mut buf = [0u8; 16]; | ||
| getrandom::fill(&mut buf).expect("getrandom failed — OS CSPRNG unavailable"); | ||
| hex_encode(&buf) |
There was a problem hiding this comment.
generate_csprng_token uses expect(...) on getrandom::fill, which will panic/abort the whole process if the OS RNG is unavailable or blocked in the runtime environment. Since this is used to build LLM instructions, it would be safer to return a Result from fence_content (or otherwise surface a recoverable error) rather than crashing the server/CLI.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
|
@copilot apply changes based on the comments in this thread |
security: prompt-injection hardening — path jail, metadata neutralization, CSPRNG fencing
Reduces the attack surface for prompt injection when lean-ctx runs as an MCP server. An attacker who can influence files, git history, or
~/.lean-ctx/knowledgeentries in a project can no longer inject forged<system-reminder>tags or exfiltrate files outside the project root.What changed
Phase 0 — Core gates
openat2(RESOLVE_BENEATH|NO_SYMLINKS|NO_MAGICLINKS)with TOCTOU defense (/proc/self/fd/Nre-verify), FIFO/socket/device rejection, andO_NOFOLLOWper-component fallback for non-Linux Unixctx_read,ctx_multi_read,ctx_smart_read,ctx_edit(read + write),ctx_search,ctx_treehandle_with_options— diff mode and auto-delta re-reads cannot bypass itDirEntryitems skipped inctx_searchwalker beforeread_to_stringfstatbefore any read/hash/compressspawn()time with parallel readers and child kill on cap hit — usestake(cap+1)for accurate truncation detectionno_unsanitized_llm_output.rs) scanstools/andpatterns/for rawstd::fscalls outside an explicit whitelist — fails closed on new additionsPhase A — Neutralize metadata + fence instruction blocks
neutralize_metadata:<to‹(U+2039),>to›(U+203A), backtick to', C0 control char strip, 200-char truncation — NOT HTML entities (LLMs decode those). Single-pass with early exit at 264 chars to prevent DoS on large inputsformat_aaak: category, key, value) and gotcha blocks (format_injection_block: trigger, resolution)getrandom, 32-hex token per block) wrap knowledge and gotcha blocks ininstructions.rs:sanitize::fence_contentis reusable for Phase B (file/shell/auto-context fencing)New files
rust/src/core/pathjail.rsopen_in_jail(openat2 + fallbacks),read_in_jail(size cap), FIFO/device rejection, TOCTOU detectionrust/src/core/sanitize.rsneutralize_metadata+fence_content(CSPRNG markers via getrandom)rust/tests/prompt_injection_tests.rsrenameat2 RENAME_EXCHANGEswap under load)rust/tests/no_unsanitized_llm_output.rsNew dependencies
rustix 1.1cfg(unix)openat2/openatbindings for the path jailgetrandom 0.3serial_test 3Tests
Known residual (documented, tracked for Phase B)
ctx_editre-reads viastd::fs::readafteropen_in_jailvalidation — narrow TOCTOU window (microseconds between kernel-confirmedopenat2and the re-open). Phase B3 will close it by reading directly from the jailed FD.What's next