fix(security): bound MCP catalog revalidation and harden hunk patch parsing

Pre-launch security review 2026-10-27 (two of its findings, plus hygiene):

- mcp: a server-supplied list ttlMs had no floor, so ttlMs 0 made the
  catalog stale on every 5s tick, and revalidation runs discovery while
  holding the MCP pool lock. Any configured server could keep every MCP
  operation waiting. Catalog TTLs are now clamped to at least 60s.
  Still open: running discovery outside the lock, and rate-limiting
  list_changed-driven revalidation.
- runtime_api/git: validated_patch sliced the first byte off each @@
  range token, which panicked on a multi-byte character and fired the
  process panic hook. It now strips the expected -/+ sign and refuses
  anything else as a malformed @@ line.
- .gitignore: ignore private key and certificate files, with an
  exception for the existing test-only fixture key.

Verified: scripts/dev-test.sh runtime_api/git.rs git_ -> 146 tests run:
146 passed (includes git_routes_drive_a_real_workspace_repo and
git_write_preconditions_reject_stale_state); scripts/dev-test.sh mcp.rs
mcp:: -> 405 tests run: 405 passed. No new tests, per repo policy.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-10-27 18:31:57 -07:00
1 parent 804b7fca89
commit cf10ca1446
3 files changed
+14 -3

No files matched your search

+9
View File
@@ -181,3 +181,12 @@ orca-evidence/
# Portable pet build entry points are source.
!pet/**/*.sh
!pet/verify.sh
# Private keys and certificates never belong in the tree.
*.pem
*.key
*.p12
*.p8
id_rsa*
id_ed25519*
!docs/cloud-facts/fixtures/test-only-signing-key.pem
+3 -1
View File
@@ -1738,6 +1738,7 @@ pub(crate) trait McpTransport: Send + Sync {
const MAX_MCP_CATALOG_PAGES: usize = 64;
const MAX_MCP_CATALOG_ITEMS: usize = 4_096;
const MAX_MCP_CATALOG_BYTES: usize = 32 * 1024 * 1024;
const MIN_MCP_CATALOG_TTL_MS: u64 = 60_000;
const MAX_MCP_CATALOG_TTL_MS: u64 = 86_400_000;
pub(crate) fn is_mcp_list_changed_method(method: &str) -> bool {
@@ -2466,7 +2467,8 @@ impl McpConnection {
self.prompts = prompts.unwrap_or_default();
self.skills = skills.unwrap_or_default();
self.catalog_fresh_until = budget.ttl_ms.map(|ttl| {
std::time::Instant::now() + Duration::from_millis(ttl.min(MAX_MCP_CATALOG_TTL_MS))
std::time::Instant::now()
+ Duration::from_millis(ttl.clamp(MIN_MCP_CATALOG_TTL_MS, MAX_MCP_CATALOG_TTL_MS))
});
self.catalog_stale = false;
Ok(())
+2 -2
View File
@@ -1928,8 +1928,8 @@ fn validated_patch(patch: &str, path: &str) -> Result<(), ApiError> {
if !(seen_old && seen_new) {
return refuse(&format!("must start with --- a/{path} and +++ b/{path}"));
}
let mut counts = range.split_whitespace().take(2).map(|side| {
let side = &side[1..];
let mut counts = range.split_whitespace().zip(['-', '+']).map(|(side, sign)| {
let side = side.strip_prefix(sign)?;
match side.split_once(',') {
Some((_, count)) => count.parse::<usize>().ok(),
None => side.parse::<usize>().ok().map(|_| 1),