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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]
### Changed

- `zeph-config`/`zeph-core`: the tool rate limiter (`[security.rate_limit]`) and the daily
LLM cost cap (`[cost].max_daily_cents`) are now enabled by default, guarding a fresh install
against a runaway agent loop hammering a tool category or burning unbounded API spend
(#6469). `RateLimitConfig::default().enabled` flips `false` → `true` (per-category limits
unchanged: shell 30/min, web 20/min, memory 60/min, mcp 40/min, other 60/min, 30s cooldown),
and `default_max_daily_cents()` flips `0` (unlimited) → `2500` ($25.00/day; local-only
Ollama/Candle usage always costs `0` and never trips this cap). A named-fn serde default
(`default_rate_limit_enabled`) replaces `RateLimitConfig::enabled`'s bare
`#[serde(default)]` so a present-but-partial `[security.rate_limit]` section (only one
sub-limit set) resolves `enabled = true` consistently with an absent section — previously
these two cases silently disagreed. **Only sparse/absent `[cost]`/`[security.rate_limit]`
sections pick up the new defaults** — any config file that already sets `enabled` or
`max_daily_cents` explicitly (including the old defaults `false`/`0`) is unaffected. The
`--init` wizard's rate-limiter prompt now defaults to "yes", and a new prompt asks for the
daily cost cap in USD (pre-filled $25.00). Budget-exhausted and rate-limit-trip messages now
include a one-line remediation hint pointing at the relevant config key. A new
`zeph_cost_budget_exhausted_total` Prometheus counter mirrors the existing
`zeph_security_rate_limit_trips` metric.

### Fixed

- `zeph-core`: the vault-anchor reconcile sweep (`run_anchor_sweep`) hard-deleted a transcript/
Expand Down
2 changes: 1 addition & 1 deletion book/src/advanced/observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Per-model cost tracking with daily budget enforcement.
```toml
[cost]
enabled = true
max_daily_cents = 500 # Daily spending limit in cents (USD)
max_daily_cents = 2500 # Daily spending limit in cents (default: 2500 = $25.00; 0 = unlimited)
```

### Built-in Pricing
Expand Down
3 changes: 2 additions & 1 deletion book/src/getting-started/wizard.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,8 @@ Debug dump is intended for context debugging — use it when you need to inspect
Configure security features:

- **PII filter** — scrub emails, phone numbers, SSNs, and credit card numbers from tool outputs before they reach the LLM context and debug dumps (default: enabled)
- **Tool rate limiter** — sliding-window per-category limits (shell 30/min, web 20/min, memory 60/min) to prevent runaway tool calls (default: disabled)
- **Tool rate limiter** — sliding-window per-category limits (shell 30/min, web 20/min, memory 60/min) to prevent runaway tool calls (default: enabled)
- **Daily LLM cost cap** — refuses new turns once cumulative daily spend crosses the cap, guarding against unbounded API spend from a runaway loop; local-only (Ollama/Candle) usage always costs `0` and never trips it (default: $25.00/day; `0` = unlimited)
- **Skill scan on load** — scan skill content for injection patterns when skills are loaded; logs warnings but does not block execution (default: enabled)
- **Pre-execution verification** — block destructive commands (e.g. `rm -rf /`) and injection patterns before every tool call (default: enabled)
- **Allowed paths** — comma-separated path prefixes where destructive commands are permitted (empty = deny all). Example: `/tmp,/home/user/scratch`
Expand Down
13 changes: 11 additions & 2 deletions book/src/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,15 @@ block_markdown_images = true # Strip external markdown images from LLM output
validate_tool_urls = true # Flag tool calls using URLs from injection-flagged content
guard_memory_writes = true # Skip Qdrant embedding for injection-flagged content

[security.rate_limit]
enabled = true # Per-category sliding-window tool rate limiter (default: true)
shell_calls_per_minute = 30
web_calls_per_minute = 20
memory_calls_per_minute = 60
mcp_calls_per_minute = 40
other_calls_per_minute = 60
circuit_breaker_cooldown_secs = 30 # Cooldown after a limit trips

[timeouts]
llm_seconds = 120 # LLM chat completion timeout
embedding_seconds = 30 # Embedding generation timeout
Expand All @@ -684,8 +693,8 @@ exporter = "none" # "none" or "otlp" (requires `otel` feature)
endpoint = "http://localhost:4317"

[cost]
enabled = false
max_daily_cents = 500 # Daily budget in cents (USD), UTC midnight reset
enabled = true
max_daily_cents = 2500 # Daily budget in cents, UTC midnight reset (default: 2500 = $25.00; 0 = unlimited)

[cocoon]
# show_balance = true # Display TON balance in TUI sidebar (default: true). Set to false to show "*** TON" instead
Expand Down
22 changes: 20 additions & 2 deletions config/default.toml
Original file line number Diff line number Diff line change
Expand Up @@ -658,8 +658,9 @@ strict = false
[cost]
# Track LLM API costs and enforce daily budget
enabled = true
# Maximum daily spend in cents (0 = unlimited)
max_daily_cents = 0
# Maximum daily spend in cents (0 = unlimited). Default: 2500 ($25.00/day) — a runaway-loop
# guardrail; local-only (Ollama/Candle) usage always costs 0 so never trips this cap.
max_daily_cents = 2500


[vault]
Expand Down Expand Up @@ -1027,6 +1028,23 @@ subagent_inheritance_factor = 0.5
# [security.capability_scopes.general]
# patterns = ["*"]

[security.rate_limit]
# Per-category sliding-window tool rate limiter with circuit breaker. Enabled by default —
# guards against a runaway agent loop hammering a single tool category (issue #6469).
enabled = true
# Maximum shell tool calls per minute (default: 30)
shell_calls_per_minute = 30
# Maximum web tool calls per minute (default: 20)
web_calls_per_minute = 20
# Maximum memory tool calls per minute (default: 60)
memory_calls_per_minute = 60
# Maximum MCP tool calls per minute (default: 40)
mcp_calls_per_minute = 40
# Maximum other tool calls per minute (default: 60)
other_calls_per_minute = 60
# Seconds the circuit breaker stays tripped after a limit is exceeded (default: 30)
circuit_breaker_cooldown_secs = 30

# 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.
Expand Down
37 changes: 34 additions & 3 deletions crates/zeph-config/src/features.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ impl std::fmt::Display for VaultBackend {
}

fn default_max_daily_cents() -> u32 {
0
2500
}

fn default_otlp_endpoint() -> String {
Expand Down Expand Up @@ -907,14 +907,14 @@ impl Default for VaultConfig {
/// ```toml
/// [cost]
/// enabled = true
/// max_daily_cents = 500 # $5.00 per day
/// max_daily_cents = 2500 # $25.00 per day (the default)
/// ```
#[derive(Debug, Deserialize, Serialize)]
pub struct CostConfig {
/// Track and display token costs. Default: `true`.
#[serde(default = "default_true")]
pub enabled: bool,
/// Daily spending cap in US cents (`0` = unlimited). Default: `0`.
/// Daily spending cap in US cents (`0` = unlimited). Default: `2500` ($25.00/day).
#[serde(default = "default_max_daily_cents")]
pub max_daily_cents: u32,
}
Expand Down Expand Up @@ -1491,6 +1491,37 @@ mod tests {
fn registry_backend_kind_display() {
assert_eq!(RegistryBackendKind::SkillsSh.to_string(), "skills-sh");
}

// --- CostConfig defaults (issue #6469) ---

#[test]
fn cost_config_default_has_nonzero_daily_cap() {
let cfg = CostConfig::default();
assert!(cfg.enabled);
assert_eq!(cfg.max_daily_cents, 2500);
}

#[test]
fn cost_config_absent_section_picks_up_new_default() {
let cfg: CostConfig = toml::from_str("").unwrap();
assert!(cfg.enabled);
assert_eq!(cfg.max_daily_cents, 2500);
}

#[test]
fn cost_config_explicit_zero_is_preserved() {
let cfg: CostConfig = toml::from_str("max_daily_cents = 0").unwrap();
assert_eq!(
cfg.max_daily_cents, 0,
"explicit 0 must mean unlimited, not be overridden"
);
}

#[test]
fn cost_config_explicit_nonzero_is_preserved() {
let cfg: CostConfig = toml::from_str("max_daily_cents = 500").unwrap();
assert_eq!(cfg.max_daily_cents, 500);
}
}

// --- CompressionSpectrumConfig defaults ---
Expand Down
75 changes: 75 additions & 0 deletions crates/zeph-config/src/migrate/features.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1126,3 +1126,78 @@ pub fn migrate_skill_trust_require_check(toml_src: &str) -> Result<MigrationResu
},
})
}

/// Step 101 — appends a commented `[security.rate_limit]` advisory block when the section is
/// entirely absent (issue #6469).
///
/// The tool rate limiter now defaults to `enabled = true` at the struct-default level
/// (`RateLimitConfig::default()`), so an absent section already behaves as if it were present
/// with the values below — this step is purely for discoverability, matching
/// [`migrate_integrity_config`](super::migrate_integrity_config)'s advisory-only pattern. It
/// never activates a live value: the runtime posture comes entirely from serde field defaults,
/// not from this migration.
///
/// # Errors
///
/// Returns [`MigrateError`] if the source is not valid TOML.
pub fn migrate_rate_limit_advisory(toml_src: &str) -> Result<MigrationResult, MigrateError> {
if section_header_present(toml_src, "security.rate_limit")
|| toml_src.contains("# [security.rate_limit]")
{
return Ok(MigrationResult {
output: toml_src.to_owned(),
changed_count: 0,
sections_changed: Vec::new(),
});
}

let comment = "\n# [security.rate_limit] — per-category sliding-window tool rate limiter with \
circuit breaker. Enabled by default; values below match the built-in defaults (issue #6469).\n\
# [security.rate_limit]\n\
# enabled = true\n\
# shell_calls_per_minute = 30\n\
# web_calls_per_minute = 20\n\
# memory_calls_per_minute = 60\n\
# mcp_calls_per_minute = 40\n\
# other_calls_per_minute = 60\n\
# circuit_breaker_cooldown_secs = 30\n";
let output = format!("{toml_src}{comment}");

Ok(MigrationResult {
output,
changed_count: 1,
sections_changed: vec!["security.rate_limit".to_owned()],
})
}

#[cfg(test)]
mod rate_limit_advisory_tests {
use super::*;

#[test]
fn migrate_rate_limit_advisory_appends_block() {
let base = "[agent]\nname = \"zeph\"\n";
let result = migrate_rate_limit_advisory(base).unwrap();
assert_eq!(result.changed_count, 1);
assert!(result.output.contains("# [security.rate_limit]"));
assert!(result.output.contains("# enabled = true"));
assert!(result.output.contains("# shell_calls_per_minute = 30"));
}

#[test]
fn migrate_rate_limit_advisory_idempotent_on_commented_output() {
let base = "[agent]\nname = \"zeph\"\n";
let first = migrate_rate_limit_advisory(base).unwrap();
let second = migrate_rate_limit_advisory(&first.output).unwrap();
assert_eq!(second.changed_count, 0, "second run must not double-append");
assert_eq!(second.output, first.output);
}

#[test]
fn migrate_rate_limit_advisory_noop_when_active_section_present() {
let base = "[security.rate_limit]\nenabled = false\n";
let result = migrate_rate_limit_advisory(base).unwrap();
assert_eq!(result.changed_count, 0);
assert_eq!(result.output, base);
}
}
27 changes: 15 additions & 12 deletions crates/zeph-config/src/migrate/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ pub use features::{
migrate_orchestration_asset_sensitivity, migrate_orchestration_command_config,
migrate_orchestration_ensemble, migrate_orchestration_idle_timeout,
migrate_orchestration_persistence, migrate_orchestration_whole_plan_verifier_timeout,
migrate_skill_trust_require_check, migrate_skills_registry, migrate_tui_delights,
migrate_tui_mouse, migrate_tui_theme_config, migrate_tui_theme_defaults,
migrate_rate_limit_advisory, migrate_skill_trust_require_check, migrate_skills_registry,
migrate_tui_delights, migrate_tui_mouse, migrate_tui_theme_config, migrate_tui_theme_defaults,
};
pub use infra::*;
pub use integrity::migrate_integrity_config;
Expand Down Expand Up @@ -627,16 +627,17 @@ use steps::{
MigrateOverflowMaxPerCallOverride, MigratePiiFilterNames, MigratePlannerModelToProvider,
MigratePluginsReputationConfig, MigratePolicyProviderAndUtilityWindow,
MigrateProviderMaxConcurrent, MigrateQdrantApiKey, MigrateQdrantTimeoutSecs,
MigrateQualityConfig, MigrateSandboxConfig, MigrateSandboxEgressFilter, MigrateSchedulerDaemon,
MigrateSearchConfig, MigrateSecretMaskingConfig, MigrateServeConfig,
MigrateSessionPersistProviderOverrides, MigrateSessionPersistenceConfig,
MigrateSessionProviderPersistence, MigrateSessionRecapConfig, MigrateSessionResumeConfig,
MigrateShadowSentinelConfig, MigrateShellCheckpointsConfig, MigrateShellTransactional,
MigrateSkillTrustRequireCheck, MigrateSkillsRegistry, MigrateSttToProvider,
MigrateSupervisorConfig, MigrateTelemetryConfig, MigrateToolsCompressionConfig,
MigrateTraceMetadata, MigrateTuiDelights, MigrateTuiMouse, MigrateTuiThemeConfig,
MigrateTuiThemeDefaults, MigrateUtilityHighGainTools, MigrateVigilConfig,
MigrateWorktreeConfig, MigrateWorktreeGitTimeout, MigrateWorktreeQuotaFields,
MigrateQualityConfig, MigrateRateLimitAdvisory, MigrateSandboxConfig,
MigrateSandboxEgressFilter, MigrateSchedulerDaemon, MigrateSearchConfig,
MigrateSecretMaskingConfig, MigrateServeConfig, MigrateSessionPersistProviderOverrides,
MigrateSessionPersistenceConfig, MigrateSessionProviderPersistence, MigrateSessionRecapConfig,
MigrateSessionResumeConfig, MigrateShadowSentinelConfig, MigrateShellCheckpointsConfig,
MigrateShellTransactional, MigrateSkillTrustRequireCheck, MigrateSkillsRegistry,
MigrateSttToProvider, MigrateSupervisorConfig, MigrateTelemetryConfig,
MigrateToolsCompressionConfig, MigrateTraceMetadata, MigrateTuiDelights, MigrateTuiMouse,
MigrateTuiThemeConfig, MigrateTuiThemeDefaults, MigrateUtilityHighGainTools,
MigrateVigilConfig, MigrateWorktreeConfig, MigrateWorktreeGitTimeout,
MigrateWorktreeQuotaFields,
};

/// Ordered registry of all sequential migration steps (steps 1–99).
Expand Down Expand Up @@ -844,6 +845,8 @@ pub static MIGRATIONS: std::sync::LazyLock<Vec<Box<dyn Migration + Send + Sync>>
// Step 100 — add [integrity] advisory block for vault-anchor downgrade-resistance
// (issue #6449)
Box::new(MigrateIntegrityConfig),
// Step 101 — advisory notice for [security.rate_limit] default-on posture (#6469)
Box::new(MigrateRateLimitAdvisory),
]
});

Expand Down
29 changes: 23 additions & 6 deletions crates/zeph-config/src/migrate/steps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,11 @@
//! step 98 adds a `[plugins.reputation]` advisory block for the install-time
//! name-similarity/typosquat check (spec-043, #5864); step 99 adds a documentation-only
//! advisory comment to an existing active `[durable]` table noting that row-HMAC +
//! high-water-mark tamper-evidence (issue #6360) is unconditional, not a new opt-in toggle.
//! high-water-mark tamper-evidence (issue #6360) is unconditional, not a new opt-in toggle;
//! step 100 adds a commented `[integrity]` advisory block for vault-anchor
//! downgrade-resistance (issue #6449); step 101 adds a commented `[security.rate_limit]`
//! advisory block — purely documentary, the tool rate limiter already defaults to enabled
//! at the struct-default level (issue #6469).
//!
//! Each struct is a zero-size type that delegates to the corresponding free function in
//! `super`. They exist solely to satisfy the object-safe [`super::Migration`] trait so the
Expand Down Expand Up @@ -111,11 +115,11 @@ use super::{
migrate_planner_model_to_provider, migrate_plugins_reputation_config,
migrate_policy_provider_and_utility_window, migrate_provider_max_concurrent,
migrate_qdrant_api_key, migrate_qdrant_timeout_secs, migrate_quality_config,
migrate_sandbox_config, migrate_sandbox_egress_filter, migrate_scheduler_daemon_config,
migrate_search_config, migrate_secret_masking_config, migrate_serve_config,
migrate_session_persist_provider_overrides, migrate_session_persistence_config,
migrate_session_provider_persistence, migrate_session_recap_config,
migrate_session_resume_config, migrate_shadow_sentinel_config,
migrate_rate_limit_advisory, migrate_sandbox_config, migrate_sandbox_egress_filter,
migrate_scheduler_daemon_config, migrate_search_config, migrate_secret_masking_config,
migrate_serve_config, migrate_session_persist_provider_overrides,
migrate_session_persistence_config, migrate_session_provider_persistence,
migrate_session_recap_config, migrate_session_resume_config, migrate_shadow_sentinel_config,
migrate_shell_checkpoints_config, migrate_shell_transactional,
migrate_skill_trust_require_check, migrate_skills_registry, migrate_stt_to_provider,
migrate_supervisor_config, migrate_telemetry_config, migrate_tools_compression_config,
Expand Down Expand Up @@ -1282,3 +1286,16 @@ impl Migration for MigrateIntegrityConfig {
migrate_integrity_config(toml_src)
}
}

/// Step 101 — adds a commented `[security.rate_limit]` advisory block; purely documentary,
/// the tool rate limiter already defaults to enabled at the struct-default level (#6469).
pub(super) struct MigrateRateLimitAdvisory;
impl Migration for MigrateRateLimitAdvisory {
fn name(&self) -> &'static str {
"migrate_rate_limit_advisory"
}

fn apply(&self, toml_src: &str) -> Result<MigrationResult, MigrateError> {
migrate_rate_limit_advisory(toml_src)
}
}
7 changes: 4 additions & 3 deletions crates/zeph-config/src/migrate/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ use super::*;
fn migrations_registry_has_all_steps() {
assert_eq!(
MIGRATIONS.len(),
100,
"MIGRATIONS registry must contain all 100 sequential steps"
101,
"MIGRATIONS registry must contain all 101 sequential steps"
);
for m in MIGRATIONS.iter() {
assert!(
Expand Down Expand Up @@ -2093,7 +2093,7 @@ fn migrate_focus_auto_consolidate_noop_when_only_commented_section() {

#[test]
fn registry_has_fifty_entries() {
assert_eq!(MIGRATIONS.len(), 100);
assert_eq!(MIGRATIONS.len(), 101);
}

#[test]
Expand Down Expand Up @@ -2235,6 +2235,7 @@ fn registry_preserves_order_matches_dispatch() {
"migrate_plugins_reputation_config",
"migrate_durable_hwm_advisory",
"migrate_integrity_config",
"migrate_rate_limit_advisory",
];
let actual: Vec<&str> = MIGRATIONS.iter().map(|m| m.name()).collect();
assert_eq!(actual, expected);
Expand Down
Loading
Loading