Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down Expand Up @@ -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`/
Expand Down
13 changes: 13 additions & 0 deletions config/default.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
49 changes: 30 additions & 19 deletions crates/zeph-config/src/migrate/features.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ static TUI_HEADER_RE: std::sync::LazyLock<Regex> = 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<MigrationResult, MigrateError> {
// 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,
Expand All @@ -35,10 +35,8 @@ pub fn migrate_tui_delights(toml_src: &str) -> Result<MigrationResult, MigrateEr
}

// Idempotency: scan for [tui.delights] already present (active or commented-out).
let already_present = toml_src.lines().any(|l| {
let t = l.trim().trim_start_matches('#').trim();
t == "[tui.delights]" || t.starts_with("[tui.delights]")
});
let already_present = section_header_present(toml_src, "tui.delights")
|| toml_src.lines().any(|l| l.trim() == "# [tui.delights]");
if already_present {
return Ok(MigrationResult {
output: toml_src.to_owned(),
Expand Down Expand Up @@ -101,7 +99,7 @@ pub fn migrate_tui_delights(toml_src: &str) -> Result<MigrationResult, MigrateEr
///
/// Returns `MigrateError::TomlParse` if the input is not valid TOML; infallible otherwise.
pub fn migrate_tui_mouse(toml_src: &str) -> Result<MigrationResult, MigrateError> {
if !toml_src.contains("[tui]") {
if !section_header_present(toml_src, "tui") {
return Ok(MigrationResult {
output: toml_src.to_owned(),
changed_count: 0,
Expand Down Expand Up @@ -171,7 +169,7 @@ pub fn migrate_compression_predictor_config(
) -> Result<MigrationResult, MigrateError> {
// 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 {
Expand Down Expand Up @@ -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<MigrationResult, MigrateError> {
// 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,
Expand Down Expand Up @@ -262,7 +262,9 @@ pub fn migrate_microcompact_config(toml_src: &str) -> Result<MigrationResult, Mi
/// Returns `MigrateError::Parse` if the TOML cannot be parsed.
pub fn migrate_autodream_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
// 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,
Expand Down Expand Up @@ -367,7 +369,7 @@ pub fn migrate_orchestration_persistence(toml_src: &str) -> Result<MigrationResu
}

// Only inject under an existing [orchestration] section.
if !toml_src.contains("[orchestration]") {
if !section_header_present(toml_src, "orchestration") {
return Ok(MigrationResult {
output: toml_src.to_owned(),
changed_count: 0,
Expand Down Expand Up @@ -403,7 +405,7 @@ pub fn migrate_orchestration_persistence(toml_src: &str) -> Result<MigrationResu
///
/// Returns [`MigrateError::Parse`] when `toml_src` is not valid TOML.
pub fn migrate_goals_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
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,
Expand Down Expand Up @@ -435,7 +437,7 @@ pub fn migrate_goals_config(toml_src: &str) -> Result<MigrationResult, MigrateEr
/// This function is infallible in practice; the `Result` return type matches the
/// migration function convention for use in chained pipelines.
pub fn migrate_caveman_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
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,
Expand Down Expand Up @@ -466,7 +468,7 @@ pub fn migrate_caveman_config(toml_src: &str) -> Result<MigrationResult, Migrate
/// This function is infallible in practice; the `Result` return type matches the migration
/// function convention.
pub fn migrate_deep_link_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
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,
Expand Down Expand Up @@ -498,7 +500,9 @@ pub fn migrate_deep_link_config(toml_src: &str) -> Result<MigrationResult, Migra
///
/// Returns `MigrateError::Parse` if the TOML cannot be parsed.
pub fn migrate_five_signal_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
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,
Expand Down Expand Up @@ -552,7 +556,7 @@ pub fn migrate_five_signal_config(toml_src: &str) -> Result<MigrationResult, Mig
///
/// Returns `MigrateError::Parse` if the TOML cannot be parsed.
pub fn migrate_knowledge_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
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,
Expand All @@ -578,6 +582,10 @@ pub fn migrate_knowledge_config(toml_src: &str) -> Result<MigrationResult, Migra
})
}

// `migrate_tui_theme_defaults` below relies on this exact-header regex (not
// `section_header_present`) to place active `name`/`color_mode` keys correctly on
// subtable-only configs (e.g. `[tui.theme.colors]` without a bare `[tui.theme]`) — do not
// simplify that guard to `section_header_present` alone without preserving this regex check.
static TUI_THEME_HEADER_RE: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
Regex::new(r"(?m)^[ \t]*\[tui\.theme\][ \t]*(?:#[^\r\n]*)?\r?\n").expect("static pattern")
});
Expand Down Expand Up @@ -627,7 +635,7 @@ pub fn migrate_tui_theme_defaults(toml_src: &str) -> Result<MigrationResult, Mig
let has_color_mode = key_in_tui_theme("color_mode");

// If [tui.theme] is absent, step 65 handles it — this step is a no-op.
let has_section = toml_src.contains("[tui.theme]");
let has_section = section_header_present(toml_src, "tui.theme");
if !has_section || (has_name && has_color_mode) {
return Ok(MigrationResult {
output: toml_src.to_owned(),
Expand Down Expand Up @@ -719,7 +727,10 @@ pub fn migrate_tui_theme_config(toml_src: &str) -> Result<MigrationResult, Migra
})
};

if in_tui_section || toml_src.contains("[tui.theme]") {
if in_tui_section
|| section_header_present(toml_src, "tui.theme")
|| toml_src.lines().any(|l| l.trim() == "# [tui.theme]")
{
return Ok(MigrationResult {
output: toml_src.to_owned(),
changed_count: 0,
Expand Down Expand Up @@ -773,7 +784,7 @@ pub fn migrate_orchestration_asset_sensitivity(
});
}

if !toml_src.contains("[orchestration]") {
if !section_header_present(toml_src, "orchestration") {
return Ok(MigrationResult {
output: toml_src.to_owned(),
changed_count: 0,
Expand Down Expand Up @@ -819,7 +830,7 @@ pub fn migrate_orchestration_asset_sensitivity(
/// Returns [`MigrateError::Parse`] if the TOML cannot be parsed.
pub fn migrate_skills_registry(toml_src: &str) -> Result<MigrationResult, MigrateError> {
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,
Expand Down
58 changes: 51 additions & 7 deletions crates/zeph-config/src/migrate/infra.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,9 @@ pub fn migrate_telemetry_config(toml_src: &str) -> Result<MigrationResult, Migra
/// Returns `MigrateError::Parse` if `toml_src` is not valid TOML.
pub fn migrate_supervisor_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
// 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,
Expand Down Expand Up @@ -238,7 +240,7 @@ pub fn migrate_otel_filter(toml_src: &str) -> Result<MigrationResult, MigrateErr
///
/// Returns [`MigrateError`] if the TOML source cannot be parsed.
pub fn migrate_egress_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
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,
Expand Down Expand Up @@ -269,7 +271,8 @@ pub fn migrate_egress_config(toml_src: &str) -> Result<MigrationResult, MigrateE
///
/// Returns [`MigrateError`] if the TOML source cannot be parsed.
pub fn migrate_vigil_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
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,
Expand Down Expand Up @@ -353,7 +356,7 @@ pub fn migrate_sandbox_config(toml_src: &str) -> Result<MigrationResult, Migrate
/// Returns [`MigrateError`] if the TOML document cannot be parsed.
pub fn migrate_sandbox_egress_filter(toml_src: &str) -> Result<MigrationResult, MigrateError> {
// 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,
Expand Down Expand Up @@ -411,9 +414,8 @@ pub fn migrate_sandbox_egress_filter(toml_src: &str) -> Result<MigrationResult,
/// This function is infallible in practice; the `Result` return type matches the
/// migration function convention for use in chained pipelines.
pub fn migrate_scheduler_daemon_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
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(),
Expand Down Expand Up @@ -861,3 +863,45 @@ pub fn migrate_pii_filter_names(toml_src: &str) -> Result<MigrationResult, Migra
},
})
}

/// Adds a commented-out `[security.shadow_sentinel]` section to configs that predate the
/// `ShadowSentinel` Phase 2 defence-in-depth safety probe (spec 050, #5934). Idempotent: no-op
/// when the real or commented `[security.shadow_sentinel]` header is already present, so running
/// `--migrate-config` twice does not duplicate it. Existing configs gain the section as comments
/// (no behavior change — `enabled = false`).
///
/// # Errors
///
/// Returns [`MigrateError`] if the source is not valid TOML.
pub fn migrate_shadow_sentinel_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
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::<DocumentMut>()?;

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()],
})
}
2 changes: 1 addition & 1 deletion crates/zeph-config/src/migrate/llm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1162,7 +1162,7 @@ pub fn migrate_llm_stream_limits(toml_src: &str) -> Result<MigrationResult, Migr
.any(|l| l.trim() == "# [llm.stream_limits]");
if section_header_present(toml_src, "llm.stream_limits")
|| commented_present
|| !toml_src.contains("[llm]")
|| !section_header_present(toml_src, "llm")
{
return Ok(MigrationResult {
output: toml_src.to_owned(),
Expand Down
Loading
Loading