diff --git a/CHANGELOG.md b/CHANGELOG.md index be1512b03..dc4a1feca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- **Config**: documented `[security.shadow_sentinel]` (`ShadowSentinelConfig`) as a commented + advisory block in `config/default.toml`, and added migration step 81 + (`migrate_shadow_sentinel_config`) so existing configs gain the same discoverable block via + `zeph --migrate-config`. The section was previously implemented and wired through + `SecurityConfig`/`validate_provider_names` but absent from both the shipped default config and + the migration registry (#5934). - **Security**: added `.gitleaks.toml` allowlisting the 31 known-benign gitleaks findings from a full git-history scan — all fake/example secrets in test fixtures, doctests, and documentation (`secret_mask.rs`, `redact.rs`, @@ -69,6 +75,29 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Self-learning/heuristic auto-promotion and reload trust-assignment are intentionally out of scope for this change — see the PR description. +### Changed + +- **Config**: replaced hand-rolled TOML section-header idempotency checks (raw + `toml_src.contains("[name]")` substring matches and unanchored exact-line comparisons) across + `crates/zeph-config/src/migrate/{features,memory,tools,session,serve,infra,llm}.rs` with the + shared `section_header_present()` helper, which correctly recognizes inline-commented headers + (`[name] # note`) and excludes fully commented-out headers (`# [name]`) — a stricter, more + correct check than the substring/exact-line patterns it replaces. Per-key idempotency checks + (e.g. detecting a specific field inside a section) and array-of-tables headers (`[[name]]`, + unsupported by `section_header_present()`) were left as-is. Behavior is unchanged for all + existing migration test expectations except five now-corrected/narrowed guards: + `migrate_goals_config` and `migrate_memory_graph_config`'s `[memory.graph.beam_search]` check + now also explicitly recognize a fully commented-out header instead of relying on substring + coincidence; `migrate_egress_config`, `migrate_vigil_config`, and + `migrate_tools_compression_config` previously used a broad, bracket-less substring guard + (e.g. `contains("[tools.egress]") || contains("tools.egress")`, effectively just + `contains("tools.egress")`) that also suppressed re-injection for unrelated matches such as an + inline table (`compression = { enabled = true }`) or a root dotted key (`tools.egress.enabled + = ...`) — this was the exact copy-paste anti-pattern #5933 targets, not a deliberate design + choice, so the guard is now narrowed to the real header-only check. The narrowing only affects + which configs receive a commented advisory block on `--migrate-config`; it never touches active + config values and remains fully idempotent (#5933). + ### Removed - `crates/zeph-sanitizer/src/pipeline.rs`: deleted the composable `Pipeline`/`Stage`/ diff --git a/config/default.toml b/config/default.toml index f30e409e5..664c377e7 100644 --- a/config/default.toml +++ b/config/default.toml @@ -953,6 +953,19 @@ subagent_inheritance_factor = 0.5 # [security.capability_scopes.general] # patterns = ["*"] +# ShadowSentinel Phase 2: persistent safety event stream + LLM pre-execution probe (spec 050). +# Defence-in-depth only — PolicyGateExecutor and TrajectorySentinel remain the primary gate. +# Disabled by default. +# [security.shadow_sentinel] +# enabled = false +# provider name from [[llm.providers]]; empty = primary. Prefer a fast/cheap model. +# probe_provider = "" +# max_context_events = 50 +# probe_timeout_ms = 2000 +# max_probes_per_turn = 3 +# probe_patterns = ["builtin:shell", "builtin:write", "builtin:edit", "*write*", "*edit*", "*delete*", "*exec*"] +# deny_on_timeout = false + # [telegram] # token = "your-bot-token" # Allowed usernames (empty = allow all except for /start command) diff --git a/crates/zeph-config/src/migrate/features.rs b/crates/zeph-config/src/migrate/features.rs index 563179340..6352492b6 100644 --- a/crates/zeph-config/src/migrate/features.rs +++ b/crates/zeph-config/src/migrate/features.rs @@ -26,7 +26,7 @@ static TUI_HEADER_RE: std::sync::LazyLock = std::sync::LazyLock::new(|| { /// Returns `MigrateError::TomlParse` if the input is not valid TOML; infallible otherwise. pub fn migrate_tui_delights(toml_src: &str) -> Result { // No [tui] section → no-op. - if !toml_src.contains("[tui]") { + if !section_header_present(toml_src, "tui") { return Ok(MigrationResult { output: toml_src.to_owned(), changed_count: 0, @@ -35,10 +35,8 @@ pub fn migrate_tui_delights(toml_src: &str) -> Result Result Result { - if !toml_src.contains("[tui]") { + if !section_header_present(toml_src, "tui") { return Ok(MigrationResult { output: toml_src.to_owned(), changed_count: 0, @@ -171,7 +169,7 @@ pub fn migrate_compression_predictor_config( ) -> Result { // Strip any [memory.compression.predictor] section (active or commented-out) that // prior migrate-config runs may have injected. The feature is removed (#3251). - let has_active = toml_src.contains("[memory.compression.predictor]"); + let has_active = section_header_present(toml_src, "memory.compression.predictor"); let has_commented = toml_src.contains("# [memory.compression.predictor]"); if !has_active && !has_commented { return Ok(MigrationResult { @@ -223,7 +221,9 @@ pub fn migrate_compression_predictor_config( /// Returns `MigrateError::Parse` if the TOML cannot be parsed. pub fn migrate_microcompact_config(toml_src: &str) -> Result { // Idempotency: comments are invisible to toml_edit, so check the raw source. - if toml_src.contains("[memory.microcompact]") || toml_src.contains("# [memory.microcompact]") { + if section_header_present(toml_src, "memory.microcompact") + || toml_src.contains("# [memory.microcompact]") + { return Ok(MigrationResult { output: toml_src.to_owned(), changed_count: 0, @@ -262,7 +262,9 @@ pub fn migrate_microcompact_config(toml_src: &str) -> Result Result { // Idempotency: comments are invisible to toml_edit, so check the raw source. - if toml_src.contains("[memory.autodream]") || toml_src.contains("# [memory.autodream]") { + if section_header_present(toml_src, "memory.autodream") + || toml_src.contains("# [memory.autodream]") + { return Ok(MigrationResult { output: toml_src.to_owned(), changed_count: 0, @@ -367,7 +369,7 @@ pub fn migrate_orchestration_persistence(toml_src: &str) -> Result Result Result { - if toml_src.contains("[goals]") { + if section_header_present(toml_src, "goals") || toml_src.contains("# [goals]") { return Ok(MigrationResult { output: toml_src.to_owned(), changed_count: 0, @@ -435,7 +437,7 @@ pub fn migrate_goals_config(toml_src: &str) -> Result Result { - if toml_src.contains("[caveman]") || toml_src.contains("# [caveman]") { + if section_header_present(toml_src, "caveman") || toml_src.contains("# [caveman]") { return Ok(MigrationResult { output: toml_src.to_owned(), changed_count: 0, @@ -466,7 +468,7 @@ pub fn migrate_caveman_config(toml_src: &str) -> Result Result { - if toml_src.contains("[deep_link]") || toml_src.contains("# [deep_link]") { + if section_header_present(toml_src, "deep_link") || toml_src.contains("# [deep_link]") { return Ok(MigrationResult { output: toml_src.to_owned(), changed_count: 0, @@ -498,7 +500,9 @@ pub fn migrate_deep_link_config(toml_src: &str) -> Result Result { - if toml_src.contains("[memory.five_signal]") || toml_src.contains("# [memory.five_signal]") { + if section_header_present(toml_src, "memory.five_signal") + || toml_src.contains("# [memory.five_signal]") + { return Ok(MigrationResult { output: toml_src.to_owned(), changed_count: 0, @@ -552,7 +556,7 @@ pub fn migrate_five_signal_config(toml_src: &str) -> Result Result { - if toml_src.contains("[knowledge]") || toml_src.contains("# [knowledge]") { + if section_header_present(toml_src, "knowledge") || toml_src.contains("# [knowledge]") { return Ok(MigrationResult { output: toml_src.to_owned(), changed_count: 0, @@ -578,6 +582,10 @@ pub fn migrate_knowledge_config(toml_src: &str) -> Result = std::sync::LazyLock::new(|| { Regex::new(r"(?m)^[ \t]*\[tui\.theme\][ \t]*(?:#[^\r\n]*)?\r?\n").expect("static pattern") }); @@ -627,7 +635,7 @@ pub fn migrate_tui_theme_defaults(toml_src: &str) -> Result Result Result { let commented_present = toml_src.lines().any(|l| l.trim() == "# [skills.registry]"); - if toml_src.contains("[skills.registry]") || commented_present { + if section_header_present(toml_src, "skills.registry") || commented_present { return Ok(MigrationResult { output: toml_src.to_owned(), changed_count: 0, diff --git a/crates/zeph-config/src/migrate/infra.rs b/crates/zeph-config/src/migrate/infra.rs index 3af0f0035..ce215eaab 100644 --- a/crates/zeph-config/src/migrate/infra.rs +++ b/crates/zeph-config/src/migrate/infra.rs @@ -150,7 +150,9 @@ pub fn migrate_telemetry_config(toml_src: &str) -> Result Result { // Idempotency: skip if already present (either as real section or commented-out block). - if toml_src.contains("[agent.supervisor]") || toml_src.contains("# [agent.supervisor]") { + if section_header_present(toml_src, "agent.supervisor") + || toml_src.contains("# [agent.supervisor]") + { return Ok(MigrationResult { output: toml_src.to_owned(), changed_count: 0, @@ -238,7 +240,7 @@ pub fn migrate_otel_filter(toml_src: &str) -> Result Result { - if toml_src.contains("[tools.egress]") || toml_src.contains("tools.egress") { + if section_header_present(toml_src, "tools.egress") || toml_src.contains("# [tools.egress]") { return Ok(MigrationResult { output: toml_src.to_owned(), changed_count: 0, @@ -269,7 +271,8 @@ pub fn migrate_egress_config(toml_src: &str) -> Result Result { - if toml_src.contains("[security.vigil]") || toml_src.contains("security.vigil") { + if section_header_present(toml_src, "security.vigil") || toml_src.contains("# [security.vigil]") + { return Ok(MigrationResult { output: toml_src.to_owned(), changed_count: 0, @@ -353,7 +356,7 @@ pub fn migrate_sandbox_config(toml_src: &str) -> Result Result { // Only inject when [tools.sandbox] already exists. - if !toml_src.contains("[tools.sandbox]") { + if !section_header_present(toml_src, "tools.sandbox") { return Ok(MigrationResult { output: toml_src.to_owned(), changed_count: 0, @@ -411,9 +414,8 @@ pub fn migrate_sandbox_egress_filter(toml_src: &str) -> Result Result { - if toml_src - .lines() - .any(|l| l.trim() == "[scheduler.daemon]" || l.trim() == "# [scheduler.daemon]") + if section_header_present(toml_src, "scheduler.daemon") + || toml_src.lines().any(|l| l.trim() == "# [scheduler.daemon]") { return Ok(MigrationResult { output: toml_src.to_owned(), @@ -861,3 +863,45 @@ pub fn migrate_pii_filter_names(toml_src: &str) -> Result Result { + let commented_present = toml_src + .lines() + .any(|l| l.trim() == "# [security.shadow_sentinel]"); + if section_header_present(toml_src, "security.shadow_sentinel") || commented_present { + return Ok(MigrationResult { + output: toml_src.to_owned(), + changed_count: 0, + sections_changed: Vec::new(), + }); + } + + let _doc = toml_src.parse::()?; + + let block = "\n# ShadowSentinel Phase 2: persistent safety event stream + LLM pre-execution probe\n\ + # (spec 050, #5934). Defence-in-depth only — PolicyGateExecutor and TrajectorySentinel\n\ + # remain the primary enforcement mechanisms. Opt-in, default-off.\n\ + # [security.shadow_sentinel]\n\ + # enabled = false\n\ + # probe_provider = \"\"\n\ + # max_context_events = 50\n\ + # probe_timeout_ms = 2000\n\ + # max_probes_per_turn = 3\n\ + # probe_patterns = [\"builtin:shell\", \"builtin:write\", \"builtin:edit\", \"*write*\", \"*edit*\", \"*delete*\", \"*exec*\"]\n\ + # deny_on_timeout = false\n"; + let output = format!("{}{}", toml_src.trim_end(), block); + Ok(MigrationResult { + output, + changed_count: 1, + sections_changed: vec!["security.shadow_sentinel".to_owned()], + }) +} diff --git a/crates/zeph-config/src/migrate/llm.rs b/crates/zeph-config/src/migrate/llm.rs index d814fd667..ab84c1fea 100644 --- a/crates/zeph-config/src/migrate/llm.rs +++ b/crates/zeph-config/src/migrate/llm.rs @@ -1162,7 +1162,7 @@ pub fn migrate_llm_stream_limits(toml_src: &str) -> Result Result { // Idempotency: comments are invisible to toml_edit, so check the raw source. - if toml_src.contains("[memory.forgetting]") || toml_src.contains("# [memory.forgetting]") { + if section_header_present(toml_src, "memory.forgetting") + || toml_src.contains("# [memory.forgetting]") + { return Ok(MigrationResult { output: toml_src.to_owned(), changed_count: 0, @@ -67,7 +69,7 @@ pub fn migrate_forgetting_config(toml_src: &str) -> Result Result { if toml_src.contains("retrieval_strategy") - || toml_src.contains("[memory.graph.beam_search]") + || section_header_present(toml_src, "memory.graph.beam_search") || toml_src.contains("# [memory.graph.beam_search]") { return Ok(MigrationResult { @@ -111,9 +113,8 @@ pub fn migrate_memory_graph_config(toml_src: &str) -> Result Result { - if toml_src - .lines() - .any(|l| l.trim() == "[memory.retrieval]" || l.trim() == "# [memory.retrieval]") + if section_header_present(toml_src, "memory.retrieval") + || toml_src.lines().any(|l| l.trim() == "# [memory.retrieval]") { return Ok(MigrationResult { output: toml_src.to_owned(), @@ -152,9 +153,8 @@ pub fn migrate_memory_retrieval_config(toml_src: &str) -> Result Result { - if toml_src - .lines() - .any(|l| l.trim() == "[memory.reasoning]" || l.trim() == "# [memory.reasoning]") + if section_header_present(toml_src, "memory.reasoning") + || toml_src.lines().any(|l| l.trim() == "# [memory.reasoning]") { return Ok(MigrationResult { output: toml_src.to_owned(), @@ -199,7 +199,7 @@ pub fn migrate_memory_reasoning_config(toml_src: &str) -> Result Result { - let has_section = toml_src.lines().any(|l| l.trim() == "[memory.reasoning]"); + let has_section = section_header_present(toml_src, "memory.reasoning"); if !has_section { return Ok(MigrationResult { output: toml_src.to_owned(), @@ -481,7 +481,7 @@ pub fn migrate_focus_auto_consolidate_min_window( } // Only inject when [agent.focus] exists as a live section (not a comment). - if !toml_src.lines().any(|l| l.trim() == "[agent.focus]") { + if !section_header_present(toml_src, "agent.focus") { return Ok(MigrationResult { output: toml_src.to_owned(), changed_count: 0, @@ -563,9 +563,8 @@ pub fn migrate_memory_retrieval_query_bias( /// /// Infallible in practice; `Result` matches the migration convention. pub fn migrate_memory_persona_config(toml_src: &str) -> Result { - if toml_src - .lines() - .any(|l| l.trim() == "[memory.persona]" || l.trim() == "# [memory.persona]") + if section_header_present(toml_src, "memory.persona") + || toml_src.lines().any(|l| l.trim() == "# [memory.persona]") { return Ok(MigrationResult { output: toml_src.to_owned(), @@ -605,7 +604,7 @@ pub fn migrate_memory_graph_recall_include_imported( toml_src: &str, ) -> Result { // Only inject when [memory.graph] exists as a live section. - if !toml_src.lines().any(|l| l.trim() == "[memory.graph]") { + if !section_header_present(toml_src, "memory.graph") { return Ok(MigrationResult { output: toml_src.to_owned(), changed_count: 0, @@ -755,7 +754,7 @@ pub fn migrate_fidelity_timeout_defaults(toml_src: &str) -> Result> // Step 80 — add require_integrity_check_on_promote advisory to an existing active // [skills.trust] table (#6087) Box::new(MigrateSkillTrustRequireCheck), + // Step 81 — add [security.shadow_sentinel] advisory block (spec 050, #5934) + Box::new(MigrateShadowSentinelConfig), ] }); diff --git a/crates/zeph-config/src/migrate/serve.rs b/crates/zeph-config/src/migrate/serve.rs index 2ebffb68c..a3556c863 100644 --- a/crates/zeph-config/src/migrate/serve.rs +++ b/crates/zeph-config/src/migrate/serve.rs @@ -3,7 +3,7 @@ //! `[serve]` config migration step (spec-068 §9, #5343). -use super::{MigrateError, MigrationResult}; +use super::{MigrateError, MigrationResult, section_header_present}; /// Append a commented-out `[serve]` block if the config lacks it (spec-068 §9, #5343). /// @@ -18,9 +18,8 @@ use super::{MigrateError, MigrationResult}; /// /// Infallible in practice; `Result` matches the migration convention. pub fn migrate_serve_config(toml_src: &str) -> Result { - if toml_src - .lines() - .any(|l| l.trim() == "[serve]" || l.trim() == "# [serve]") + if section_header_present(toml_src, "serve") + || toml_src.lines().any(|l| l.trim() == "# [serve]") { return Ok(MigrationResult { output: toml_src.to_owned(), diff --git a/crates/zeph-config/src/migrate/session.rs b/crates/zeph-config/src/migrate/session.rs index 7563e29d5..3ad96677f 100644 --- a/crates/zeph-config/src/migrate/session.rs +++ b/crates/zeph-config/src/migrate/session.rs @@ -7,7 +7,7 @@ //! the [`Migration`](super::Migration) trait, and the [`MIGRATIONS`](super::MIGRATIONS) //! registry remain in the parent module. -use super::{MigrateError, MigrationResult}; +use super::{MigrateError, MigrationResult, section_header_present}; /// Add commented-out `[session.recap]` block if absent (#3064). /// @@ -18,7 +18,7 @@ use super::{MigrateError, MigrationResult}; /// Returns `MigrateError::Parse` if the TOML cannot be parsed. pub fn migrate_session_recap_config(toml_src: &str) -> Result { // Idempotency: check both active and commented forms. - if toml_src.contains("[session.recap]") || toml_src.contains("# [session.recap]") { + if section_header_present(toml_src, "session.recap") || toml_src.contains("# [session.recap]") { return Ok(MigrationResult { output: toml_src.to_owned(), changed_count: 0, @@ -53,9 +53,8 @@ pub fn migrate_session_recap_config(toml_src: &str) -> Result Result { - if toml_src - .lines() - .any(|l| l.trim() == "[acp.subagents]" || l.trim() == "# [acp.subagents]") + if section_header_present(toml_src, "acp.subagents") + || toml_src.lines().any(|l| l.trim() == "# [acp.subagents]") { return Ok(MigrationResult { output: toml_src.to_owned(), @@ -219,9 +218,8 @@ pub fn migrate_hooks_turn_complete_config(toml_src: &str) -> Result Result { - if toml_src - .lines() - .any(|l| l.trim() == "[session]" || l.trim() == "# [session]") + if section_header_present(toml_src, "session") + || toml_src.lines().any(|l| l.trim() == "# [session]") { return Ok(MigrationResult { output: toml_src.to_owned(), @@ -268,7 +266,10 @@ pub fn migrate_session_persist_provider_overrides( sections_changed: Vec::new(), }); } - if !toml_src.lines().any(|l| l.trim() == "[session]") { + // Also require the literal `"[session]\n"` anchor used by `replacen` below — guards + // against an inline-commented header (`[session] # note`) passing `section_header_present` + // but not matching the anchor, which would otherwise report a change that never happened. + if !section_header_present(toml_src, "session") || !toml_src.contains("[session]\n") { return Ok(MigrationResult { output: toml_src.to_owned(), changed_count: 0, @@ -309,7 +310,10 @@ pub fn migrate_session_persistence_config(toml_src: &str) -> Result &'static str { + "migrate_shadow_sentinel_config" + } + + fn apply(&self, toml_src: &str) -> Result { + migrate_shadow_sentinel_config(toml_src) + } +} diff --git a/crates/zeph-config/src/migrate/tests.rs b/crates/zeph-config/src/migrate/tests.rs index 1bf67ffa6..09c7278dc 100644 --- a/crates/zeph-config/src/migrate/tests.rs +++ b/crates/zeph-config/src/migrate/tests.rs @@ -9,8 +9,8 @@ use super::*; fn migrations_registry_has_all_steps() { assert_eq!( MIGRATIONS.len(), - 80, - "MIGRATIONS registry must contain all 80 sequential steps" + 81, + "MIGRATIONS registry must contain all 81 sequential steps" ); for m in MIGRATIONS.iter() { assert!( @@ -1753,7 +1753,7 @@ fn migrate_focus_auto_consolidate_noop_when_only_commented_section() { #[test] fn registry_has_fifty_entries() { - assert_eq!(MIGRATIONS.len(), 80); + assert_eq!(MIGRATIONS.len(), 81); } #[test] @@ -1791,7 +1791,7 @@ fn registry_is_idempotent_on_empty_input() { #[test] fn registry_preserves_order_matches_dispatch() { - // Names must follow the documented step order (steps 1–79). + // Names must follow the documented step order (steps 1–81). let expected = [ "migrate_stt_to_provider", "migrate_planner_model_to_provider", @@ -1873,6 +1873,7 @@ fn registry_preserves_order_matches_dispatch() { "migrate_skills_registry", "migrate_durable_shared_db", "migrate_skill_trust_require_check", + "migrate_shadow_sentinel_config", ]; let actual: Vec<&str> = MIGRATIONS.iter().map(|m| m.name()).collect(); assert_eq!(actual, expected); @@ -4139,3 +4140,95 @@ fn step_80_idempotent_on_own_output() { "output unchanged on second run" ); } + +// ── migrate_shadow_sentinel_config tests (step 81, spec 050, #5934) ────────── + +#[test] +fn step_81_adds_shadow_sentinel_block_when_absent() { + let src = "[agent]\nname = \"zeph\"\n"; + let result = migrate_shadow_sentinel_config(src).expect("migrate"); + assert_eq!(result.changed_count, 1); + assert!(result.output.contains("# [security.shadow_sentinel]")); + assert!(result.output.contains("# enabled = false")); + assert_eq!( + result.sections_changed, + vec!["security.shadow_sentinel".to_owned()] + ); +} + +#[test] +fn step_81_noop_when_shadow_sentinel_section_already_active() { + let src = "[security.shadow_sentinel]\nenabled = true\n"; + let result = migrate_shadow_sentinel_config(src).expect("migrate"); + assert_eq!(result.changed_count, 0); + assert_eq!(result.output, src); +} + +#[test] +fn step_81_noop_when_shadow_sentinel_comment_already_present() { + let src = "# [security.shadow_sentinel]\n# enabled = false\n"; + let result = migrate_shadow_sentinel_config(src).expect("migrate"); + assert_eq!(result.changed_count, 0); + assert_eq!(result.output, src); +} + +#[test] +fn step_81_idempotent_on_own_output() { + let src = "[agent]\nname = \"zeph\"\n"; + let first = migrate_shadow_sentinel_config(src).expect("first migrate"); + assert_eq!(first.changed_count, 1); + let second = migrate_shadow_sentinel_config(&first.output).expect("second migrate"); + assert_eq!(second.changed_count, 0, "second run must be a no-op"); + assert_eq!( + second.output, first.output, + "output unchanged on second run" + ); +} + +// ── M1 regression tests: narrower `section_header_present`-based guards (#5933) ───────── +// +// `migrate_egress_config`, `migrate_vigil_config`, and `migrate_tools_compression_config` +// previously used a broad, bracket-less substring guard (e.g. +// `contains("[tools.egress]") || contains("tools.egress")`, effectively just +// `contains("tools.egress")`) that also suppressed re-injection for unrelated matches such as a +// root dotted key (`tools.egress.enabled = ...`) or an inline table +// (`compression = { enabled = true }`). The guards now correctly recognize only real section +// headers (active or commented), so these inputs trigger the commented advisory block — this is +// the intended, narrower behavior (not a bug): the old broad match was the exact copy-paste +// anti-pattern #5933 targets, not a deliberate design choice. These tests pin the new behavior. + +#[test] +fn migrate_egress_config_injects_on_dotted_key_form_not_a_real_section_header() { + // A root dotted key mentioning "tools.egress" is not a `[tools.egress]` header — the old + // broad `contains("tools.egress")` guard used to suppress this input; the new + // `section_header_present`-based guard does not. + let src = "tools.egress.enabled = true\n"; + let result = migrate_egress_config(src).expect("migrate"); + assert_eq!(result.changed_count, 1); + assert!(result.output.contains("# [tools.egress]")); + assert_eq!(result.sections_changed, vec!["tools.egress".to_owned()]); +} + +#[test] +fn migrate_vigil_config_injects_on_dotted_key_form_not_a_real_section_header() { + let src = "security.vigil.enabled = true\n"; + let result = migrate_vigil_config(src).expect("migrate"); + assert_eq!(result.changed_count, 1); + assert!(result.output.contains("# [security.vigil]")); + assert_eq!(result.sections_changed, vec!["security.vigil".to_owned()]); +} + +#[test] +fn migrate_tools_compression_config_injects_on_inline_table_form_not_a_real_section_header() { + // An inline table under `[tools]` satisfies the old broad + // `contains("[tools]\n") && contains("compression")` guard without ever declaring a real + // `[tools.compression]` header — the new guard correctly treats this as absent. + let src = "[tools]\ncompression = { enabled = true }\n"; + let result = migrate_tools_compression_config(src).expect("migrate"); + assert_eq!(result.changed_count, 1); + assert!(result.output.contains("# [tools.compression]")); + assert_eq!( + result.sections_changed, + vec!["tools.compression".to_owned()] + ); +} diff --git a/crates/zeph-config/src/migrate/tools.rs b/crates/zeph-config/src/migrate/tools.rs index e437aab26..bb3661602 100644 --- a/crates/zeph-config/src/migrate/tools.rs +++ b/crates/zeph-config/src/migrate/tools.rs @@ -7,7 +7,7 @@ //! the [`Migration`](super::Migration) trait, and the [`MIGRATIONS`](super::MIGRATIONS) //! registry remain in the parent module. -use super::{MigrateError, MigrationResult}; +use super::{MigrateError, MigrationResult, section_header_present}; /// Migrate `[agent].max_tool_retries` → `[tools.retry].max_attempts` and /// `[agent].max_retry_duration_secs` → `[tools.retry].budget_secs`. @@ -153,9 +153,8 @@ pub fn migrate_agent_budget_hint(toml_src: &str) -> Result Result { // Idempotency: line-anchored check avoids false-positives on [quality.foo] subtables. - if toml_src - .lines() - .any(|l| l.trim() == "[quality]" || l.trim() == "# [quality]") + if section_header_present(toml_src, "quality") + || toml_src.lines().any(|l| l.trim() == "# [quality]") { return Ok(MigrationResult { output: toml_src.to_owned(), @@ -193,8 +192,10 @@ pub fn migrate_quality_config(toml_src: &str) -> Result Result { - if toml_src.contains("tools.compression") - || toml_src.contains("[tools]\n") && toml_src.contains("compression") + if section_header_present(toml_src, "tools.compression") + || toml_src + .lines() + .any(|l| l.trim() == "# [tools.compression]") { return Ok(MigrationResult { output: toml_src.to_owned(),