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
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,33 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
only — a `:memory:` database or a Postgres deployment has no derivable lock directory and
degrades to unenforced exclusivity, matching `SessionEventLog::open_exclusive`'s existing
non-Unix degrade.
- `zeph-memory`/`zeph-core`: the MAGE trajectory-risk soft-escalation tier (spec 004-16
FR-006) is now wired into the agent loop (#5956). `TrajectoryRiskAccumulator::should_escalate()`
and `record_escalation()` existed but were never queried — only the hard-block tier
(`is_blocked()`) gated tool dispatch. When cumulative trajectory risk lands in
`[escalation_threshold, risk_threshold)`, the agent now requires a single batch-level human
confirmation before dispatching the tool batch through the *normal* tier execution loop —
so `check_trust`/`PermissionPolicy` (Ask/Deny rules) and the shadow-probe safety gate still
apply per call exactly as they would without escalation. Denial cancels the whole batch
(same tombstone path as any other user-cancelled turn). `record_escalation()` increments the
`shadow_memory_escalations_total` Prometheus counter (NFR-007). No new config surface —
`escalation_threshold` was already a `[memory.shadow_memory]` config field.
- An earlier version of this fix synthesized `ToolError::ConfirmationRequired` per call and
dispatched approved calls through `execute_tool_call_confirmed_erased`, which intentionally
bypasses `check_trust` for the already-approved call — that let a policy-`Deny` tool execute
under MAGE escalation (including unattended, under auto-approve/`-y`/`--bare`/non-TTY CLI
modes) precisely when accumulated risk signals made that the worst possible moment to drop
the gate. Caught in adversarial review before merge; fixed by gating on one up-front
confirmation and falling through to the unmodified, fully-gated tier execution loop.
- `zeph-memory`: `classify_communities` (`graph/community.rs`) no longer lets `\n`/`\t` survive
into community `entity_names`/`intra_facts` (#6093). PR #6091 had replaced a local
`scrub_content` helper (stripped all control chars) with
`zeph_common::patterns::strip_format_chars`, which deliberately preserves `\t`/`\n` — an
entity name or fact containing an embedded newline (e.g. from untrusted tool output) could
break the single-line `Entities: ...` framing built by `generate_community_summary` and
inject prompt content into the downstream summarization LLM call. Both call sites now use
`zeph_common::sanitize::strip_control_chars` instead, per that function's own documented
guidance for single-line normalized values like entity names and dedup keys.
- `zeph-subagent`: sub-agent vault secrets now re-validate their grant TTL live instead of
only gating once at delivery time, and a targeted secret-request lookup no longer drops a
concurrent sibling sub-agent's pending request (#5991, #5993).
Expand Down
43 changes: 43 additions & 0 deletions crates/zeph-config/src/loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,12 @@ impl Config {
.acon
.validate()
.map_err(ConfigError::Validation)?;
if self.memory.shadow_memory.enabled {
self.memory
.shadow_memory
.validate()
.map_err(ConfigError::Validation)?;
}
Ok(())
}

Expand Down Expand Up @@ -1261,6 +1267,43 @@ weight = 0.3
);
}

#[test]
fn validate_rejects_shadow_memory_inverted_thresholds() {
let mut cfg = Config::default();
cfg.memory.shadow_memory.enabled = true;
cfg.memory.shadow_memory.escalation_threshold = 0.75;
cfg.memory.shadow_memory.risk_threshold = 0.50;
let err = cfg.validate().unwrap_err().to_string();
assert!(
err.contains("escalation_threshold") && err.contains("risk_threshold"),
"expected shadow_memory threshold-ordering error, got: {err}"
);
}

#[test]
fn validate_rejects_shadow_memory_equal_thresholds() {
let mut cfg = Config::default();
cfg.memory.shadow_memory.enabled = true;
cfg.memory.shadow_memory.escalation_threshold = 0.6;
cfg.memory.shadow_memory.risk_threshold = 0.6;
assert!(
cfg.validate().is_err(),
"equal thresholds must be rejected — the escalation band would be empty"
);
}

#[test]
fn validate_ignores_shadow_memory_thresholds_when_disabled() {
let mut cfg = Config::default();
cfg.memory.shadow_memory.enabled = false;
cfg.memory.shadow_memory.escalation_threshold = 0.9;
cfg.memory.shadow_memory.risk_threshold = 0.1;
assert!(
cfg.validate().is_ok(),
"inverted thresholds on a disabled shadow_memory config must not fail validation"
);
}

/// Regression test (critic S1): `Config::default()` must itself satisfy `validate_pool`
/// so `--dump-config-defaults` (which serializes `Config::default()` verbatim,
/// `src/runner.rs`) emits a config that `zeph --config <dump>` can actually load and
Expand Down
26 changes: 26 additions & 0 deletions crates/zeph-config/src/memory/persona.rs
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,32 @@ fn default_tra_signal_history_cap() -> usize {
200
}

impl TrajectoryRiskAccumulatorConfig {
/// Validate threshold ordering after deserialization.
///
/// Returns an error string if `escalation_threshold >= risk_threshold`. An
/// inverted/equal pair silently disables the soft-escalation tier (`should_escalate`'s
/// `[escalation_threshold, risk_threshold)` band becomes empty) — the hard block
/// (`is_blocked`) still works, so this is a degraded-but-safe misconfiguration, not a
/// security gap; validation exists to surface it instead of leaving it silent (critic
/// finding F4, spec 004-16).
///
/// # Errors
///
/// Returns a descriptive error string when the threshold ordering invariant is violated.
#[must_use = "validation result must be checked"]
pub fn validate(&self) -> Result<(), String> {
if self.escalation_threshold >= self.risk_threshold {
return Err(format!(
"memory.shadow_memory: escalation_threshold ({}) must be < risk_threshold ({}) \
— otherwise the escalation band is empty and soft-escalation never fires",
self.escalation_threshold, self.risk_threshold
));
}
Ok(())
}
}

impl Default for TrajectoryRiskAccumulatorConfig {
fn default() -> Self {
Self {
Expand Down
14 changes: 13 additions & 1 deletion crates/zeph-core/src/agent/tests/agent_tests/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ pub(crate) struct MockChannel {
pub(crate) sent: Arc<Mutex<Vec<String>>>,
pub(crate) chunks: Arc<Mutex<Vec<String>>>,
pub(crate) confirmations: Arc<Mutex<Vec<bool>>>,
/// Records the exact prompt text passed to every `Channel::confirm` call, in order — lets
/// tests assert on prompt content (e.g. that a real command is shown, not a generic string).
pub(crate) confirmed_prompts: Arc<Mutex<Vec<String>>>,
pub(crate) statuses: Arc<Mutex<Vec<String>>>,
pub(crate) tool_starts: Arc<Mutex<Vec<ToolStartEvent>>>,
pub(crate) exit_supported: bool,
Expand All @@ -109,6 +112,7 @@ impl MockChannel {
sent: Arc::new(Mutex::new(Vec::new())),
chunks: Arc::new(Mutex::new(Vec::new())),
confirmations: Arc::new(Mutex::new(Vec::new())),
confirmed_prompts: Arc::new(Mutex::new(Vec::new())),
statuses: Arc::new(Mutex::new(Vec::new())),
tool_starts: Arc::new(Mutex::new(Vec::new())),
exit_supported: true,
Expand Down Expand Up @@ -143,6 +147,10 @@ impl MockChannel {
pub(crate) fn sent_messages(&self) -> Vec<String> {
self.sent.lock().unwrap().clone()
}

pub(crate) fn confirmed_prompts(&self) -> Vec<String> {
self.confirmed_prompts.lock().unwrap().clone()
}
}

impl Channel for MockChannel {
Expand Down Expand Up @@ -204,7 +212,11 @@ impl Channel for MockChannel {
Ok(())
}

async fn confirm(&mut self, _prompt: &str) -> Result<bool, crate::channel::ChannelError> {
async fn confirm(&mut self, prompt: &str) -> Result<bool, crate::channel::ChannelError> {
self.confirmed_prompts
.lock()
.unwrap()
.push(prompt.to_owned());
let mut confs = self.confirmations.lock().unwrap();
Ok(if confs.is_empty() {
true
Expand Down
11 changes: 11 additions & 0 deletions crates/zeph-core/src/agent/tool_execution/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,17 @@ struct ToolDispatchContext {
/// When `Some((score, top_signals))`, all tool calls in this batch are blocked with
/// `ToolError::TrajectoryRiskExceeded`. Set when `mage_accumulator.is_blocked()` at dispatch time.
mage_blocked: Option<(f64, Vec<String>)>,
/// MAGE trajectory risk soft-escalation gate (spec 004-16 FR-006).
///
/// When `true`, the batch requires a single up-front human confirmation
/// (`Agent::confirm_mage_escalation`) before the normal tier execution loop runs — approval
/// falls through to `run_tier_execution_loop` so `check_trust`/`PermissionPolicy`/
/// shadow-probe still apply per call; denial cancels the whole batch. Set when
/// `mage_accumulator.should_escalate()` at dispatch time — i.e. risk is in
/// `[escalation_threshold, risk_threshold)`. Mutually exclusive with `mage_blocked` (the
/// ranges never overlap). Must never bypass the per-call trust/policy gate (critic finding
/// F1 caught an earlier version that did).
mage_escalate: bool,
/// System hints injected by the utility-window early-stop logic during `compute_utility_actions`.
early_stop_hints: Vec<String>,
/// Set when `compute_utility_actions` exhausted the consecutive-low window.
Expand Down
Loading
Loading