perf(security): reuse the canonical-workspace cache in the sync path checks - #5519
Conversation
…checks `is_path_string_allowed` and `is_resolved_path_allowed_for` run on every file/shell tool call and each re-invoked `workspace_dir.canonicalize()` — one `stat(2)` + symlink walk on the same immutable input per call. The async `validate_path` already memoizes that in the `canonical_workspace` OnceCell; the two sync validators bypassed it. Add a sync `workspace_root_sync()` that hydrates the **same** cell via tokio `OnceCell`'s synchronous `get`/`set`, and route both sync checks through it. No new field, no second cache: `std::fs::canonicalize` here and the async path's `tokio::fs::canonicalize` resolve the same input to the same canonical form, so whichever hydrates first, both paths converge on one value. `workspace_dir` is immutable per policy — `live_policy` rebuilds a fresh policy on reload and `security_for_tool_context` only overrides `action_dir`/`trusted_roots` — so the cached value stays correct across config updates (same argument the existing field doc already makes). `is_workspace_internal_path` is left as-is on purpose: its `workspace_dir.canonicalize()` is coupled all-or-nothing with a per-call `path.canonicalize()` at a fail-closed security boundary, so caching only the workspace half would change its comparison semantics. Test pins that the sync helper hydrates the shared cell once, reuses it, and returns the same value the async `workspace_root` does. Claude-Session: https://claude.ai/code/session_01ACB4Ugi5pJMQqoCbZnVo6f
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
📝 WalkthroughWalkthroughThe change adds synchronous workspace-root resolution with shared canonical caching. Synchronous symlink and resolved-path checks now use this resolver. A regression test verifies cache sharing with asynchronous validation. ChangesWorkspace cache resolution
Estimated code review effort: 2 (Simple) | ~10 minutes Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches 💡 1🛠️ Fix failing CI checks 💡
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Pull request overview
Improves performance in the Rust core’s SecurityPolicy path-validation hot path by making the synchronous validators reuse the already-established canonical_workspace memoization, avoiding repeated workspace_dir.canonicalize() syscalls on every file/shell tool call.
Changes:
- Added
workspace_root_sync()to synchronously hydrate/read the existingcanonical_workspaceOnceCell. - Routed sync validators to use
workspace_root_sync()instead of re-canonicalizingworkspace_direach time. - Added a regression test ensuring sync/async paths share the same cache and return identical results.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
src/openhuman/security/policy/path_checks.rs |
Introduces workspace_root_sync() and updates sync validation call sites to reuse the shared canonical-workspace cache. |
src/openhuman/security/policy/policy_tests.rs |
Adds a test that pins cache sharing and sync/async agreement for workspace canonicalization. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
tinysweeper found nothing blocking. Approving.
$0.0261 · 17,726 in / 7,617 out · 8,839 cached (50%) · z-ai/glm-5.2
critique: $0.0144 · 5,071 in / 4,633 out · 832 cached (16%) · z-ai/glm-5.2
security: $0.0051 · 5,029 in / 1,043 out · 1,870 cached (37%) · z-ai/glm-5.2
tests: $0.0036 · 3,300 in / 1,186 out · 2,795 cached (85%) · z-ai/glm-5.2
description: $0.0030 · 4,326 in / 755 out · 3,342 cached (77%) · z-ai/glm-5.2
CI note — the two red Rust lanes are pre-existing on
|
`main` currently fails `cargo check --locked --features <product> --tests` in three independent ways. Each was hidden behind the previous one, and none is visible to CI Lite, which scopes Rust work to changed areas — so a submodule advance that breaks an unrelated file, a stale lockfile, and integration tests that no changed-file heuristic selects can all land green and stay green. Every PR then inherits all three and looks individually broken. #5519, #5521, #5523 and #5533 are currently red on `Rust Core Coverage` for this reason. 1. `flows/n8n_import.rs` — `WorkflowGraph` gained an `agents` field when tinyflows advanced (#5537), and the struct literal here was not updated. Filled with `Vec::new()`, matching the adjacent `inputs`: an n8n workflow has no agent declaration, so an import brings none across. Inventing agents the source never described would be worse than declaring none. 2. Root `Cargo.lock` — stale against the manifest, so `--locked` refuses outright. Regenerated; no `--workspace` sweep. 3. `Config::cli_inference_snapshot` was introduced as `pub(crate)` in 2c7142c. `Config` is built with struct-literal syntax by EIGHT integration tests, which are external crates, and that syntax requires every field to be visible — so one crate-private field makes the whole struct unconstructible from outside: agent_retrieval_e2e keyring_secretstore_e2e json_rpc_e2e keyring_secretstore_fresh_e2e memory_golden_fixture_e2e memory_roundtrip_e2e memory_sync_pipeline_e2e memory_tree_summarizer_e2e Restored to `pub`, along with the type it names and a public path to it. This does partially relax "keep CLI override module export-only", so to be explicit about the tradeoff: that commit's goal was module structure, and breaking `Config`'s external constructibility reads as collateral rather than intent. Only the type appearing in `Config`'s public field list becomes public; every function in the module stays crate-internal, so it remains export-only. `#[serde(skip)]` and `#[schemars(skip)]` are what keep the field off the wire and out of the JSON schema — visibility was never doing that work. The alternative was rewriting eight test files to build by mutation. That is more churn, and it leaves the trap armed for the ninth. Verified: `cargo check --features <product> --tests` and `cargo clippy --features <product> --lib -- -D warnings` both clean. Co-authored-by: Medulla <medulla@tinyhumans.ai>
Summary
is_path_string_allowedandis_resolved_path_allowed_forre-ranworkspace_dir.canonicalize()— astat(2)+ symlink walk on the same immutable input — on every file/shell tool call.validate_pathalready memoizes that resolution in thecanonical_workspaceOnceCell; the sync validators bypassed it and paid the syscall each time.workspace_root_sync()that hydrates the same cell viatokio::sync::OnceCell's synchronousget/set. No new field, no second cache.Problem
A single agent turn performs many file reads/edits/greps and shell path validations.
validate_pathcalls bothis_path_string_allowed(string + symlink-containment) andis_resolved_path_allowed_for(resolved-path containment), and each independently did:workspace_diris immutable for a given policy, so this is the samestat(2)+ symlink walk repeated per call. The struct's owncanonical_workspacedoc comment already makes this argument for the async path — the sync validators simply never adopted the cache (they can't.awaitit).Solution
Add a sync
workspace_root_sync()that reads/populates the existingcanonical_workspacecell throughOnceCell's syncget/set, and call it from the two sync validators.std::fs::canonicalize(sync) andtokio::fs::canonicalize(async) resolve the same input to the same canonical form, so whichever hydrates the cell first, the sync and async paths converge on one value. On the rare race where the async initializer wins,setfails and the helper returns its locally-resolved value — equal to what was stored.canonicalize().unwrap_or_else(clone)expression, so the cached value is byte-identical to what they computed inline.workspace_dirnever mutates on a live policy:live_policyrebuilds a fresh policy on reload (*guard = Arc::new(rebuilt)), andsecurity_for_tool_contextonly overridesaction_dir/trusted_roots.is_workspace_internal_pathis deliberately left unchanged — itsworkspace_dir.canonicalize()is coupled all-or-nothing with a per-callpath.canonicalize()at a fail-closed security boundary, so caching only the workspace half would change its comparison semantics.Submission Checklist
workspace_root_sync_hydrates_and_shares_the_async_cachepins hydrate-once, reuse, and sync/async agreement; the existingvalidate_path_*and containment suites cover the behavior-preservation edges.is_resolved_path_allowed/is_path_string_allowedsuites.N/A: behaviour-preserving performance change, no feature row added/removed/renamed.## Related—N/A: no feature-level change.N/A: internal caching only, no user-visible surface.Closes #NNN—N/A: no tracking issue (self-identified hot-path micro-optimization).Impact
stat(2)+ symlink walk from each of the two sync path checks after the first (they now share the async path's single cached canonicalization).is_workspace_internal_pathcheck is untouched.Related
is_within_trusted_rootre-canonicalizes the trusted roots + turn workspace per call — a sibling caching opportunity, intentionally out of scope here to keep this single-concern.AI Authored PR Metadata (required for Codex/Linear PRs)
Linear Issue
Commit & Branch
perf/security-cache-sync-workspace-canonicalize