fix(runtime-mcp): preserve tool annotations for parallel safety classification (PR-6/6)#3147
Merged
Conversation
…ification (PR-6/6) LibreFang's MCP `tools/list` parsing was discarding the `annotations` field across all three tool-discovery paths (SSE, Streamable HTTP, stdio). MCP spec 0.5+ uses these annotations (`readOnlyHint` / `destructiveHint` / `idempotentHint`) to tell clients which tools are safe to fan out and which need to serialise. Without preserving them, every MCP tool ended up at `ToolApprovalClass::Unknown` → `ParallelSafety::WriteShared` — defeating the whole point of parallel-safe MCP tools. This PR adds a single helper, `inject_annotation_class`, that maps the MCP annotations onto a `metadata.tool_class` field in the tool's input schema. The existing `runtime/tool_classifier.rs::classify_tool` already reads `metadata.tool_class` (its primary escape hatch) so the projection lights up automatically once the annotations land in the schema. Mapping rule: - `readOnlyHint=true && destructiveHint=false` → `"readonly_search"` - otherwise → `"mutating"` Per spec, `destructiveHint` defaults to `true` when missing, so an absent annotation block is treated as "destructive" — exactly the existing pre-fix behaviour, just made explicit. Three call sites updated: - SSE: `lib.rs:1050` (raw JSON `tools/list` response loop) - Streamable HTTP: same parse path, around line 1232 - stdio (rmcp SDK): SDK's `Tool` struct exposes `annotations` — serialise it with the tool's input_schema before injection so the same helper handles all three branches uniformly. Companion fix: `scheduler.rs:555` baseline `clippy::manual_pattern_char_comparison` lint (`s.find(|c: char| c == '/' || c == '?' || c == '#')` → `s.find(['/', '?', '#'])`). The lint surfaces when running clippy on librefang-types via librefang-runtime-mcp's transitive compilation; identical fix is also queued in PR #3144 (parallel-tool-calls PR-3) so whichever lands first the other rebases cleanly. Tests (6 new in `runtime_mcp::tests`): - inject_annotation_readonly_sets_readonly_search - inject_annotation_destructive_sets_mutating - inject_annotation_default_destructive_when_missing - inject_annotation_no_annotations_preserves_schema - inject_annotation_preserves_existing_metadata - inject_annotation_existing_tool_class_overwritten Verification: - cargo test -p librefang-runtime-mcp --lib: 76 ok - cargo clippy -p librefang-runtime-mcp --all-targets -- -D warnings: clean Stack: independent, base main. Once this lands the parallel dispatcher (PR-4/5 of the parallel-tool-calls series) gets accurate ParallelSafety classification for every annotated MCP tool.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
4 tasks
houko
added a commit
that referenced
this pull request
Apr 25, 2026
… fix) Closes a high-severity gap from PR #3145 review: `PreScript.env` was unvalidated, so an attacker-controlled cron config could set `LD_PRELOAD=/tmp/x.so` (or the Darwin equivalent `DYLD_INSERT_LIBRARIES`, or `PATH=/tmp/evil:$PATH`, or `IFS=...`) and fully bypass the path allowlist on `argv[0]`. The "scripts under ~/.librefang/scripts only" promise is moot if the spawned binary gets hijacked at link / shell-parse time. Changes: - New `DANGEROUS_ENV_KEYS` const listing 8 dynamic-linker / PATH / IFS knobs that we refuse to honor in `PreScript.env`. Match is case-insensitive (Windows env keys are case-insensitive natively; defence-in-depth elsewhere). - New `PreScriptValidationError::DangerousEnvKey { key }` variant. - `validate_pre_script` rejects before any file IO so the cheap check fails first. - Hooked into the `CronJob::validate_action` path so config-load rejects bad pre_scripts even before the dispatcher runs them (review medium issue: `validate_pre_script` was previously dead code outside its own unit tests). Companion: same `scheduler.rs` host-parse `clippy::manual_pattern_char_comparison` fix as in #3144 / #3147 / #3149 (the line moved from :555 to :785 once the denylist + tests landed; identical 3-char fix). Whichever PR lands first the others rebase cleanly. Tests (5 new in `scheduler::tests`): - pre_script_validation_rejects_ld_preload - pre_script_validation_rejects_path_override - pre_script_validation_rejects_dyld_insert - pre_script_validation_rejects_dangerous_env_case_insensitive - pre_script_validation_accepts_safe_env_keys Verification: - cargo test -p librefang-types --lib scheduler: 81 ok - cargo clippy -p librefang-types -p librefang-kernel --all-targets -- -D warnings: clean
4 tasks
houko
enabled auto-merge (squash)
April 25, 2026 15:04
Self-review caught that the existing tests verify
`inject_annotation_class` in isolation but never check that the literals
it emits ("readonly_search", "mutating") actually parse via
`ToolApprovalClass::from_snake_case` on the consumer side. A typo or a
future rename in either crate would silently fall back to Unknown →
WriteShared and the whole MCP-tool parallelisation fix becomes a no-op.
Add `injected_class_strings_parse_into_approval_class` to round-trip
both literals through `ToolApprovalClass::from_snake_case` so the
contract is enforced in CI.
Also extend the `inject_annotation_class` doc comment to record why
`idempotentHint` / `openWorldHint` are intentionally dropped at this
layer (no matching ParallelSafety variant today) so a future maintainer
doesn't think it's an oversight.
4 tasks
houko
added a commit
that referenced
this pull request
Apr 25, 2026
… fix) Closes a high-severity gap from PR #3145 review: `PreScript.env` was unvalidated, so an attacker-controlled cron config could set `LD_PRELOAD=/tmp/x.so` (or the Darwin equivalent `DYLD_INSERT_LIBRARIES`, or `PATH=/tmp/evil:$PATH`, or `IFS=...`) and fully bypass the path allowlist on `argv[0]`. The "scripts under ~/.librefang/scripts only" promise is moot if the spawned binary gets hijacked at link / shell-parse time. Changes: - New `DANGEROUS_ENV_KEYS` const listing 8 dynamic-linker / PATH / IFS knobs that we refuse to honor in `PreScript.env`. Match is case-insensitive (Windows env keys are case-insensitive natively; defence-in-depth elsewhere). - New `PreScriptValidationError::DangerousEnvKey { key }` variant. - `validate_pre_script` rejects before any file IO so the cheap check fails first. - Hooked into the `CronJob::validate_action` path so config-load rejects bad pre_scripts even before the dispatcher runs them (review medium issue: `validate_pre_script` was previously dead code outside its own unit tests). Companion: same `scheduler.rs` host-parse `clippy::manual_pattern_char_comparison` fix as in #3144 / #3147 / #3149 (the line moved from :555 to :785 once the denylist + tests landed; identical 3-char fix). Whichever PR lands first the others rebase cleanly. Tests (5 new in `scheduler::tests`): - pre_script_validation_rejects_ld_preload - pre_script_validation_rejects_path_override - pre_script_validation_rejects_dyld_insert - pre_script_validation_rejects_dangerous_env_case_insensitive - pre_script_validation_accepts_safe_env_keys Verification: - cargo test -p librefang-types --lib scheduler: 81 ok - cargo clippy -p librefang-types -p librefang-kernel --all-targets -- -D warnings: clean
houko
added a commit
that referenced
this pull request
Apr 25, 2026
* feat(types): cron pre_script + silent_marker schema (PR-1/3)
Adds the configuration schema for cron pre-script injection +
silent-response markers. Pure schema work — no scheduler dispatch
code reads these fields yet (M2 will wire execution; M3 will add
the dashboard editor + optional TOTP approval).
`CronAction::AgentTurn` grows two optional fields:
- `pre_script: Option<PreScript>` — when set, the M2 dispatcher
will run the argv before the scheduled prompt fires, capture
stdout, and inject it into the LLM context. Distinct from the
existing `pre_check_script` (which gates whether the agent runs
at all via `{"wakeAgent": false}`); both can coexist.
- `silent_marker: Option<String>` — when present at the end of
the response, the dispatcher suppresses delivery. Match is
"last non-empty trimmed line == marker" — strict, doesn't
trigger on mid-response mentions of `[SILENT]`. None means
delivery always fires.
`PreScript` (new struct in `librefang-types::scheduler`):
- argv: Vec<String> — split form, no shell parsing.
- cwd: Option<String> — defaults to workspace root.
- env: HashMap<String, String> — added on top of daemon env;
per-job secrets should still go through LIBREFANG_VAULT_KEY.
- `#[serde(deny_unknown_fields)]` so typos surface instead of
being silently ignored.
`validate_pre_script(script, home_dir)` enforces a path allowlist:
argv[0] must canonicalize to a file under `<home_dir>/scripts/`.
Component-level prefix comparison via `Path::starts_with` (so
`/home/X-other` doesn't match `/home/X`). `..` escapes are
rejected after canonicalize. Errors typed via
`PreScriptValidationError` (thiserror): EmptyArgv,
OutsideAllowlist { path, home_dir }, NotFound { path }.
Companion changes (mechanical, fields propagated to existing
`CronAction::AgentTurn` construction sites):
- librefang-api/src/channel_bridge.rs: 2 lines (pre_script +
silent_marker = None).
- librefang-api/src/routes/workflows.rs: 2 lines (same).
- librefang-types/Cargo.toml: 1 line (test-only addition).
Tests (8 new in `scheduler::tests`):
- pre_script_validation_accepts_path_under_scripts_dir
- pre_script_validation_rejects_outside_allowlist
- pre_script_validation_rejects_dot_dot_escape
- pre_script_validation_rejects_empty_argv
- pre_script_validation_rejects_nonexistent_path
- cron_action_agent_turn_serde_round_trip_with_pre_script
- cron_action_agent_turn_serde_compat_no_pre_script (legacy
config without the field deserialises to None)
- silent_marker_default_via_serde_skip (None doesn't serialise)
Verification:
- cargo test -p librefang-types --lib: 628 ok
- cargo check --workspace --lib: clean
- cargo clippy -p librefang-types -p librefang-api --all-targets
-- -D warnings: clean
Stack: independent, base main. PR-2 (next) will wire kernel
dispatcher: argv exec + stdout capture + prompt injection +
silent_marker check + wake-gate path-whitelist hardening.
* style: cargo fmt for scheduler.rs
* fix(types): reject dangerous env keys in PreScript validation (review fix)
Closes a high-severity gap from PR #3145 review: `PreScript.env` was
unvalidated, so an attacker-controlled cron config could set
`LD_PRELOAD=/tmp/x.so` (or the Darwin equivalent
`DYLD_INSERT_LIBRARIES`, or `PATH=/tmp/evil:$PATH`, or `IFS=...`)
and fully bypass the path allowlist on `argv[0]`. The "scripts under
~/.librefang/scripts only" promise is moot if the spawned binary
gets hijacked at link / shell-parse time.
Changes:
- New `DANGEROUS_ENV_KEYS` const listing 8 dynamic-linker / PATH /
IFS knobs that we refuse to honor in `PreScript.env`. Match is
case-insensitive (Windows env keys are case-insensitive natively;
defence-in-depth elsewhere).
- New `PreScriptValidationError::DangerousEnvKey { key }` variant.
- `validate_pre_script` rejects before any file IO so the cheap
check fails first.
- Hooked into the `CronJob::validate_action` path so config-load
rejects bad pre_scripts even before the dispatcher runs them
(review medium issue: `validate_pre_script` was previously
dead code outside its own unit tests).
Companion: same `scheduler.rs` host-parse `clippy::manual_pattern_char_comparison`
fix as in #3144 / #3147 / #3149 (the line moved from :555 to :785
once the denylist + tests landed; identical 3-char fix). Whichever
PR lands first the others rebase cleanly.
Tests (5 new in `scheduler::tests`):
- pre_script_validation_rejects_ld_preload
- pre_script_validation_rejects_path_override
- pre_script_validation_rejects_dyld_insert
- pre_script_validation_rejects_dangerous_env_case_insensitive
- pre_script_validation_accepts_safe_env_keys
Verification:
- cargo test -p librefang-types --lib scheduler: 81 ok
- cargo clippy -p librefang-types -p librefang-kernel
--all-targets -- -D warnings: clean
* style: post-rebase fmt drift
houko
added a commit
that referenced
this pull request
Apr 25, 2026
* feat(runtime): cache_hit_ratio metric + trajectory field (M2/2) Builds on PR-1 (#3126, the system_and_3 stamping). Adds the single-value observability metric the dashboard surfaces so users can see at a glance whether prompt caching is paying off for their workload. Pure observability — no behaviour change. `cache_hit_ratio = cache_read / (cache_read + cache_creation)`, in `[0.0, 1.0]`. Returns `0.0` when both numerators are zero (caching not active for this turn / model doesn't support it). Wire-up: - `crates/librefang-runtime/src/agent_loop.rs`: new `cache_hit_ratio` helper alongside `accumulate_token_usage`. Per-turn `tracing::info!` on `librefang::cache` target with agent / hit_ratio / creation / read so existing log pipelines (Tempo, Grafana) pick it up without dashboard changes. - `crates/librefang-kernel/src/trajectory/mod.rs`: `TrajectoryMetadata` grows `cache_hit_ratio: Option<f32>` (`#[serde(default, skip_serializing_if = "Option::is_none")]`) so legacy trajectories deserialise to None and replays don't need to know about caching to round-trip. Tests (6 new): - cache_hit_ratio_zero_when_no_caching (both 0) - cache_hit_ratio_full_hit (read=100, creation=0 → 1.0) - cache_hit_ratio_partial (read=70, creation=30 → 0.7) - cache_hit_ratio_cold_start (read=0, creation=100 → 0.0) - trajectory_metadata_cache_hit_ratio_serde_round_trip - trajectory_metadata_cache_hit_ratio_legacy_compat (legacy JSON without the field deserialises to None, no panic) Companion fix: `scheduler.rs:555` baseline `clippy::manual_pattern_char_comparison` (`s.find(|c: char| c == '/' || c == '?' || c == '#')` → `s.find(['/', '?', '#'])`). Same one-line fix is also queued in PR #3144 / #3147; whichever lands first the others rebase cleanly (identical content). Verification: - cargo test -p librefang-runtime --lib agent_loop: 167 ok - cargo test -p librefang-kernel --lib trajectory: 14 ok - cargo clippy -p librefang-runtime -p librefang-kernel --all-targets -- -D warnings: clean Stack: independent, base main. Closes the prompt-caching-system-and-3 plan — system_and_3 stamping (PR-1) plus this PR cover the full "borrow hermes-agent's cache strategy" scope. * refactor(types): consolidate cache_hit_ratio onto TokenUsage Two parallel implementations of the same cache-ratio math (one in agent_loop with f32/0.0-on-empty, one in trajectory with Option<f32>) were drifting apart in semantics. Move the authoritative implementation to TokenUsage::cache_hit_ratio() in librefang-types and have both callers delegate: - runtime: tracing log uses .unwrap_or(0.0) for the f32 field, with a comment explaining how to disambiguate "no caching" from "0% hit" via the creation/read totals. - kernel: trajectory::compute_cache_hit_ratio() becomes a thin re-export; with_cache_hit_ratio builder doc clarified that the exporter never sees TokenUsage and call-site wiring is a follow-up. Test coverage for the math is now centralized in librefang-types (4 tests), runtime drops its 4 redundant tests, kernel keeps a smoke test for the re-export plus the builder/serde/legacy-compat tests.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes a latent bug: LibreFang's MCP
tools/listparsing was discarding theannotationsfield across all three tool-discovery paths (SSE, Streamable HTTP, stdio). MCP spec 0.5+ usesreadOnlyHint/destructiveHint/idempotentHintto tell clients which tools are safe to fan out and which need to serialise. Without preserving them, every MCP tool ended up atToolApprovalClass::Unknown→ParallelSafety::WriteShared— defeating the whole point of parallel-safe MCP tools.This PR adds a single helper,
inject_annotation_class, that maps the MCP annotations onto ametadata.tool_classfield in the tool's input schema. The existingruntime/tool_classifier.rs::classify_toolalready readsmetadata.tool_class(its primary escape hatch), so the projection lights up automatically once the annotations land in the schema.Mapping
readOnlyHintdestructiveHint(defaulttrue)metadata.tool_classtruefalse"readonly_search""mutating"Per spec,
destructiveHintdefaults totruewhen missing, so an absent annotation block is treated as "destructive" — exactly the existing pre-fix behaviour, just made explicit.Three call sites updated
lib.rs:1050) — raw JSONtools/listresponse looplib.rs:~1232) — same parse pathToolstruct exposesannotations. Serialise it with the tool's input_schema before injection so the same helper handles all three branches uniformly.Companion fix
scheduler.rs:555baselineclippy::manual_pattern_char_comparisonlint (s.find(|c: char| c == '/' || c == '?' || c == '#')→s.find(['/', '?', '#'])). The lint surfaces when running clippy onlibrefang-typesvialibrefang-runtime-mcp's transitive compilation. Identical fix is also queued in PR #3144 (parallel-tool-calls PR-3) so whichever lands first, the other rebases cleanly.Tests (6 new)
inject_annotation_readonly_sets_readonly_search{readOnlyHint: true, destructiveHint: false}→readonly_searchinject_annotation_destructive_sets_mutating{destructiveHint: true}→mutatinginject_annotation_default_destructive_when_missingdestructiveHintdefaults totrue→mutatinginject_annotation_no_annotations_preserves_schemaannotations: None→ schema unchangedinject_annotation_preserves_existing_metadatametadata.fooalready present → preserved + newtool_classaddedinject_annotation_existing_tool_class_overwrittenmetadata.tool_classoverwritten by new annotationTest plan
cargo test -p librefang-runtime-mcp --lib— 76 ok (70 baseline + 6 new)cargo clippy -p librefang-runtime-mcp --all-targets -- -D warnings— cleanStack
Independent, base
main. Once this lands the parallel dispatcher (PR-4/5 of the parallel-tool-calls series) gets accurateParallelSafetyclassification for every annotated MCP tool.See
.plans/parallel-tool-calls.md§4 for the full rationale.