use super::{MigrateError, MigrationResult, insert_after_section, section_header_present};
use regex::Regex;
use toml_edit::DocumentMut;
pub fn migrate_database_url(toml_src: &str) -> Result<MigrationResult, MigrateError> {
if toml_src.contains("database_url") {
return Ok(MigrationResult {
output: toml_src.to_owned(),
changed_count: 0,
sections_changed: Vec::new(),
});
}
let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
if !doc.contains_key("memory") {
doc.insert("memory", toml_edit::Item::Table(toml_edit::Table::new()));
}
let comment = "\n# PostgreSQL connection URL (used when binary is compiled with --features postgres).\n\
# Leave empty and store the actual URL in the vault:\n\
# zeph vault set ZEPH_DATABASE_URL \"postgres://user:pass@localhost:5432/zeph\"\n\
# database_url = \"\"\n";
let raw = doc.to_string();
let output = format!("{raw}{comment}");
Ok(MigrationResult {
output,
changed_count: 1,
sections_changed: vec!["memory.database_url".to_owned()],
})
}
pub fn migrate_shell_transactional(toml_src: &str) -> Result<MigrationResult, MigrateError> {
if toml_src.contains("transactional") {
return Ok(MigrationResult {
output: toml_src.to_owned(),
changed_count: 0,
sections_changed: Vec::new(),
});
}
let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
let tools_shell_exists = doc
.get("tools")
.and_then(toml_edit::Item::as_table)
.is_some_and(|t| t.contains_key("shell"));
if !tools_shell_exists {
return Ok(MigrationResult {
output: toml_src.to_owned(),
changed_count: 0,
sections_changed: Vec::new(),
});
}
let comment = "\n# Transactional shell: snapshot files before write commands, rollback on failure.\n\
# transactional = false\n\
# transaction_scope = [] # glob patterns; empty = all extracted paths\n\
# auto_rollback = false # rollback when exit code >= 2\n\
# auto_rollback_exit_codes = [] # explicit exit codes; overrides >= 2 heuristic\n\
# snapshot_required = false # abort if snapshot fails (default: warn and proceed)\n";
let raw = doc.to_string();
let output = format!("{raw}{comment}");
Ok(MigrationResult {
output,
changed_count: 1,
sections_changed: vec!["tools.shell.transactional".to_owned()],
})
}
pub fn migrate_telemetry_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
if doc.contains_key("telemetry") || toml_src.contains("# [telemetry]") {
return Ok(MigrationResult {
output: toml_src.to_owned(),
changed_count: 0,
sections_changed: Vec::new(),
});
}
let comment = "\n\
# Profiling and distributed tracing (requires --features profiling). All\n\
# instrumentation points are zero-overhead when the feature is absent.\n\
# [telemetry]\n\
# enabled = false\n\
# backend = \"local\" # \"local\" (Chrome JSON), \"otlp\", or \"pyroscope\"\n\
# trace_dir = \".local/traces\"\n\
# include_args = false\n\
# service_name = \"zeph-agent\"\n\
# sample_rate = 1.0\n\
# otel_filter = \"info\" # base EnvFilter for OTLP layer; noisy-crate exclusions always appended\n";
let raw = doc.to_string();
let output = format!("{raw}{comment}");
Ok(MigrationResult {
output,
changed_count: 1,
sections_changed: vec!["telemetry".to_owned()],
})
}
pub fn migrate_supervisor_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
if toml_src.contains("[agent.supervisor]") || toml_src.contains("# [agent.supervisor]") {
return Ok(MigrationResult {
output: toml_src.to_owned(),
changed_count: 0,
sections_changed: Vec::new(),
});
}
let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
if !doc.contains_key("agent") {
return Ok(MigrationResult {
output: toml_src.to_owned(),
changed_count: 0,
sections_changed: Vec::new(),
});
}
let comment = "\n\
# Background task supervisor tuning (optional — defaults shown, #2883).\n\
# [agent.supervisor]\n\
# enrichment_limit = 4\n\
# telemetry_limit = 8\n\
# abort_enrichment_on_turn = false\n";
let raw = doc.to_string();
let output = format!("{raw}{comment}");
Ok(MigrationResult {
output,
changed_count: 1,
sections_changed: vec!["agent.supervisor".to_owned()],
})
}
pub fn migrate_otel_filter(toml_src: &str) -> Result<MigrationResult, MigrateError> {
if toml_src.contains("otel_filter") {
return Ok(MigrationResult {
output: toml_src.to_owned(),
changed_count: 0,
sections_changed: Vec::new(),
});
}
let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
if !doc.contains_key("telemetry") {
return Ok(MigrationResult {
output: toml_src.to_owned(),
changed_count: 0,
sections_changed: Vec::new(),
});
}
let comment = "\n# Base EnvFilter for the OTLP tracing layer. Noisy-crate exclusions \
(tonic=warn etc.) are always appended (#2997).\n\
# otel_filter = \"info\"\n";
let raw = doc.to_string();
let output = insert_after_section(&raw, "telemetry", comment);
Ok(MigrationResult {
output,
changed_count: 1,
sections_changed: vec!["telemetry.otel_filter".to_owned()],
})
}
pub fn migrate_egress_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
if toml_src.contains("[tools.egress]") || toml_src.contains("tools.egress") {
return Ok(MigrationResult {
output: toml_src.to_owned(),
changed_count: 0,
sections_changed: Vec::new(),
});
}
let comment = "\n# Egress network logging — records outbound HTTP requests to the audit log\n\
# with per-hop correlation IDs, response metadata, and block reasons (#3058).\n\
# [tools.egress]\n\
# enabled = true # set to false to disable all egress event recording\n\
# log_blocked = true # record scheme/domain/SSRF-blocked requests\n\
# log_response_bytes = true\n\
# log_hosts_to_tui = true\n";
let mut output = toml_src.to_owned();
output.push_str(comment);
Ok(MigrationResult {
output,
changed_count: 1,
sections_changed: vec!["tools.egress".to_owned()],
})
}
pub fn migrate_vigil_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
if toml_src.contains("[security.vigil]") || toml_src.contains("security.vigil") {
return Ok(MigrationResult {
output: toml_src.to_owned(),
changed_count: 0,
sections_changed: Vec::new(),
});
}
let comment = "\n# VIGIL verify-before-commit intent-anchoring gate (#3058).\n\
# Runs a regex tripwire on every tool output before it enters LLM context.\n\
# [security.vigil]\n\
# enabled = true # master switch; false bypasses VIGIL entirely\n\
# strict_mode = false # true: block (replace with sentinel); false: truncate+annotate\n\
# sanitize_max_chars = 2048\n\
# extra_patterns = [] # operator-supplied additional injection patterns (max 64)\n\
# exempt_tools = [\"memory_search\", \"read_overflow\", \"load_skill\", \"schedule_deferred\"]\n";
let mut output = toml_src.to_owned();
output.push_str(comment);
Ok(MigrationResult {
output,
changed_count: 1,
sections_changed: vec!["security.vigil".to_owned()],
})
}
pub fn migrate_sandbox_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
let doc: DocumentMut = toml_src.parse()?;
let already_present = doc
.get("tools")
.and_then(|t| t.as_table())
.and_then(|t| t.get("sandbox"))
.is_some();
if already_present || toml_src.contains("# [tools.sandbox]") {
return Ok(MigrationResult {
output: toml_src.to_owned(),
changed_count: 0,
sections_changed: Vec::new(),
});
}
let comment = "\n# OS-level subprocess sandbox for shell commands (#3070).\n\
# macOS: sandbox-exec (Seatbelt); Linux: bwrap + Landlock + seccomp (requires `sandbox` feature).\n\
# Applies ONLY to subprocess executors — in-process tools are unaffected.\n\
# [tools.sandbox]\n\
# enabled = false # set to true to wrap shell commands\n\
# profile = \"workspace\" # \"workspace\" | \"read-only\" | \"network-allow-all\" | \"off\"\n\
# backend = \"auto\" # \"auto\" | \"seatbelt\" | \"landlock-bwrap\" | \"noop\"\n\
# strict = true # fail startup if sandbox init fails (fail-closed)\n\
# allow_read = [] # additional read-allowed absolute paths\n\
# allow_write = [] # additional write-allowed absolute paths\n";
let mut output = toml_src.to_owned();
output.push_str(comment);
Ok(MigrationResult {
output,
changed_count: 1,
sections_changed: vec!["tools.sandbox".to_owned()],
})
}
pub fn migrate_sandbox_egress_filter(toml_src: &str) -> Result<MigrationResult, MigrateError> {
if !toml_src.contains("[tools.sandbox]") {
return Ok(MigrationResult {
output: toml_src.to_owned(),
changed_count: 0,
sections_changed: Vec::new(),
});
}
let already_has_denied =
toml_src.contains("denied_domains") || toml_src.contains("# denied_domains");
let already_has_fail =
toml_src.contains("fail_if_unavailable") || toml_src.contains("# fail_if_unavailable");
if already_has_denied && already_has_fail {
return Ok(MigrationResult {
output: toml_src.to_owned(),
changed_count: 0,
sections_changed: Vec::new(),
});
}
let mut comment = String::new();
if !already_has_denied {
comment.push_str(
"# denied_domains = [] \
# hostnames denied egress from sandboxed processes (\"pastebin.com\", \"*.evil.com\")\n",
);
}
if !already_has_fail {
comment.push_str(
"# fail_if_unavailable = false \
# abort startup when no effective OS sandbox is available\n",
);
}
let output = toml_src.replacen(
"[tools.sandbox]\n",
&format!("[tools.sandbox]\n{comment}"),
1,
);
Ok(MigrationResult {
output,
changed_count: 1,
sections_changed: vec!["tools.sandbox.denied_domains".to_owned()],
})
}
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]")
{
return Ok(MigrationResult {
output: toml_src.to_owned(),
changed_count: 0,
sections_changed: Vec::new(),
});
}
let comment = "\n# [scheduler.daemon] — daemon process config for `zeph serve` (#3332).\n\
# [scheduler.daemon]\n\
# pid_file = \"/tmp/zeph-scheduler.pid\" # PID file path (must be on a local filesystem)\n\
# log_file = \"/tmp/zeph-scheduler.log\" # daemon log file path (append-only; rotate externally)\n\
# tick_secs = 60 # scheduler tick interval in seconds (clamped 5..=3600)\n\
# shutdown_grace_secs = 30 # grace period after SIGTERM before process exits\n\
# catch_up = true # replay missed cron tasks on daemon restart\n";
let output = format!("{toml_src}{comment}");
Ok(MigrationResult {
output,
changed_count: 1,
sections_changed: vec!["scheduler.daemon".to_owned()],
})
}
pub fn migrate_trace_metadata(toml_src: &str) -> Result<MigrationResult, MigrateError> {
if toml_src.contains("trace_metadata") {
return Ok(MigrationResult {
output: toml_src.to_owned(),
changed_count: 0,
sections_changed: Vec::new(),
});
}
let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
if !doc.contains_key("telemetry") {
return Ok(MigrationResult {
output: toml_src.to_owned(),
changed_count: 0,
sections_changed: Vec::new(),
});
}
let comment = "\n# Custom key/value pairs attached as OpenTelemetry resource attributes (#4160).\n\
# Appear on every exported span. Values are plaintext — do not store secrets here.\n\
# [telemetry.trace_metadata]\n\
# \"deployment.environment\" = \"production\"\n\
# \"vcs.revision\" = \"abc1234\"\n";
let raw = doc.to_string();
let output = insert_after_section(&raw, "telemetry", comment);
Ok(MigrationResult {
output,
changed_count: 1,
sections_changed: vec!["telemetry.trace_metadata".to_owned()],
})
}
pub fn migrate_worktree_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
let commented_present = toml_src.lines().any(|l| l.trim() == "# [worktree]");
if section_header_present(toml_src, "worktree") || commented_present {
return Ok(MigrationResult {
output: toml_src.to_owned(),
changed_count: 0,
sections_changed: Vec::new(),
});
}
let _doc = toml_src.parse::<toml_edit::DocumentMut>()?;
let block = "\n# Native worktree isolation for background sub-agents (#4679).\n\
# [worktree]\n\
# enabled = false\n\
# base_ref = \"head\"\n\
# default_branch = \"main\"\n\
# root = \".claude/worktrees\"\n\
# branch_prefix = \"agent/\"\n\
# prune_branch_on_remove = false\n\
# cleanup_on_completion = true\n\
# bg_isolation = \"worktree\"\n";
let output = format!("{}{}", toml_src.trim_end(), block);
Ok(MigrationResult {
output,
changed_count: 1,
sections_changed: vec!["worktree".to_owned()],
})
}
pub fn migrate_worktree_git_timeout(toml_src: &str) -> Result<MigrationResult, MigrateError> {
static WORKTREE_HEADER_RE: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
Regex::new(r"(?m)^[ \t]*\[worktree\][ \t]*(?:#[^\r\n]*)?\r?\n").expect("static pattern")
});
if toml_src.contains("git_timeout_secs") || !WORKTREE_HEADER_RE.is_match(toml_src) {
return Ok(MigrationResult {
output: toml_src.to_owned(),
changed_count: 0,
sections_changed: Vec::new(),
});
}
let comment = "# git_timeout_secs = 30 \
# per-command timeout for git invocations (seconds)\n";
let output = WORKTREE_HEADER_RE
.replacen(toml_src, 1, |caps: ®ex::Captures| {
format!("{}{comment}", &caps[0])
})
.into_owned();
let changed = output != toml_src;
Ok(MigrationResult {
output,
changed_count: usize::from(changed),
sections_changed: if changed {
vec!["worktree".to_owned()]
} else {
Vec::new()
},
})
}
pub fn migrate_durable_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
let commented_present = toml_src.lines().any(|l| l.trim() == "# [durable]");
if section_header_present(toml_src, "durable") || 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# Native durable execution layer (spec-064, #4949). Opt-in, default-off.\n\
# [durable]\n\
# enabled = false\n\
# backend = \"local\"\n\
# encrypt_payload = true\n\
# agent_turns = true\n\
# orchestration = true\n\
# scheduler = true\n\
# subagent = true\n\
# journal_flush_interval_ms = 10\n\
# journal_ack_timeout_ms = 5000\n\
# max_steps_per_execution = 10000\n\
# max_payload_bytes = 1048576\n\
# promise_poll_interval_secs = 2\n\
# max_parked_promises = 1000\n\
#\n\
# [durable.retention]\n\
# ttl_completed_secs = 604800\n\
# ttl_failed_secs = 2592000\n\
# max_executions = 10000\n\
# max_journal_bytes = 1073741824\n\
# prune_batch_size = 500\n\
# prune_interval_secs = 3600\n";
let output = format!("{}{}", toml_src.trim_end(), block);
Ok(MigrationResult {
output,
changed_count: 1,
sections_changed: vec!["durable".to_owned()],
})
}
pub fn migrate_nli_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
let commented_present = toml_src
.lines()
.any(|l| l.trim() == "# [security.content_isolation.nli]");
if section_header_present(toml_src, "security.content_isolation.nli") || 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# SONAR NLI entailment check: probabilistic injection detection, observe-only\n\
# (never blocks). Complements the regex sanitizer above (#5438). Opt-in, default-off.\n\
# [security.content_isolation.nli]\n\
# enabled = false\n\
# provider = \"\"\n\
# threshold = 0.75\n\
# timeout_ms = 5000\n\
# max_content_len = 2048\n";
let output = format!("{}{}", toml_src.trim_end(), block);
Ok(MigrationResult {
output,
changed_count: 1,
sections_changed: vec!["security.content_isolation.nli".to_owned()],
})
}
pub fn migrate_secret_masking_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
let commented_present = toml_src
.lines()
.any(|l| l.trim() == "# [security.content_isolation.secret_masking]");
if section_header_present(toml_src, "security.content_isolation.secret_masking")
|| 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# PAAC secret placeholder masking: substitutes vault-resolved secrets with opaque\n\
# per-session placeholders before outbound LLM calls (#5437). Opt-in, default-off.\n\
# [security.content_isolation.secret_masking]\n\
# enabled = false\n\
# min_secret_len = 8\n";
let output = format!("{}{}", toml_src.trim_end(), block);
Ok(MigrationResult {
output,
changed_count: 1,
sections_changed: vec!["security.content_isolation.secret_masking".to_owned()],
})
}
pub fn migrate_pii_filter_names(toml_src: &str) -> Result<MigrationResult, MigrateError> {
static PII_FILTER_HEADER_RE: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
Regex::new(r"(?m)^[ \t]*\[security\.pii_filter\][ \t]*(?:#[^\r\n]*)?\r?\n")
.expect("static pattern")
});
if !section_header_present(toml_src, "security.pii_filter") {
return Ok(MigrationResult {
output: toml_src.to_owned(),
changed_count: 0,
sections_changed: Vec::new(),
});
}
let already_present = toml_src.lines().any(|l| {
let t = l.trim().trim_start_matches('#').trim();
t.starts_with("filter_names")
});
if already_present || !PII_FILTER_HEADER_RE.is_match(toml_src) {
return Ok(MigrationResult {
output: toml_src.to_owned(),
changed_count: 0,
sections_changed: Vec::new(),
});
}
let comment = "# filter_names = false # capitalized-word-sequence name heuristic \
(opt-in; also flags technical/product terms like \"Docker Compose\", #5530)\n";
let output = PII_FILTER_HEADER_RE
.replacen(toml_src, 1, |caps: ®ex::Captures| {
format!("{}{comment}", &caps[0])
})
.into_owned();
let changed = output != toml_src;
let changed_count = usize::from(changed);
Ok(MigrationResult {
output,
changed_count,
sections_changed: if changed {
vec!["security.pii_filter".to_owned()]
} else {
Vec::new()
},
})
}