diff --git a/AGENTS.md b/AGENTS.md index d17a6c7fe..33453af78 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Project Structure & Module Organization Zeph is a Rust workspace (`Cargo.toml`) with the CLI entrypoint in `src/` and domain crates in `crates/` (for example: `zeph-core`, `zeph-llm`, `zeph-memory`, `zeph-tools`, `zeph-tui`). -Top-level integration tests live in `tests/`, while crate-specific tests and benches live under each crate’s `tests/` and `benches/` directories. Documentation sources are in `docs/src/` (mdBook), runtime defaults are in `config/default.toml`, and container assets are in `docker/`. +Top-level integration tests live in `tests/`, while crate-specific tests and benches live under each crate’s `tests/` and `benches/` directories. Documentation sources are in `book/src/` (mdBook), runtime defaults are in `config/default.toml`, and container assets are in `docker/`. Do not create git worktrees inside this repository. Create separate worktrees only under the sibling directory `../worktrees/`. ## Specifications (MANDATORY) @@ -24,7 +24,7 @@ All feature and system specifications live in `specs/`. **Compliance is non-nego Path-specific instructions for GitHub Copilot live in `.github/instructions/*.instructions.md` with `applyTo` frontmatter. - Use `cargo nextest run` as the default test runner. -- Keep Rust changes compatible with Edition 2024 and MSRV `1.88`. +- Keep Rust changes compatible with Edition 2024 and MSRV `1.97`. - Prefer zero-warning `clippy`; avoid `unwrap`/`expect` in production code when proper error propagation is possible. - Any user-facing change must update relevant docs, config defaults, and `CHANGELOG.md` (`[Unreleased]` section). - For new functionality, provide all integration points: config section, CLI subcommand/argument, TUI command palette entry, `--init` wizard, `--migrate-config` migration step, live testing playbook in `.local/testing/playbooks/`, and coverage row in `.local/testing/coverage-status.md`. @@ -69,7 +69,7 @@ All secrets and API keys are stored exclusively in the Zeph age vault. Never use - `cargo llvm-cov --all-features --workspace`: Generate coverage locally. ## Coding Style & Naming Conventions -Use Rust 2024 edition and MSRV `1.88`. Follow `rustfmt` defaults (4-space indentation) and keep Clippy warnings at zero where practical. +Use Rust 2024 edition and MSRV `1.97`. Follow `rustfmt` defaults (4-space indentation) and keep Clippy warnings at zero where practical. Use `snake_case` for functions/modules/files, `PascalCase` for types/traits, and `SCREAMING_SNAKE_CASE` for constants. Prefer small modules with explicit responsibilities; keep public APIs in `lib.rs` minimal and re-export intentionally. ## Testing Guidelines diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c656ba9b..77e97d26c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ 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] + +## [0.22.1] - 2026-07-15 ### Security - Bumped the transitively-pinned `spin` crate off two yanked versions in `Cargo.lock`: @@ -13,23 +15,289 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). by `cargo audit` / `cargo deny check advisories`; the patch-level bumps carry no API changes and required no `Cargo.toml` edits (#6249). -### Added +- `zeph-plugins`: the HTTPS-downgrade-redirect protection in `registry.rs` + (a `reqwest::redirect::Policy::custom` that rejects any redirect leaving the `https` + scheme) previously guarded only `add_remote_ephemeral`'s session-scoped install path. + `PluginManager::add_remote` (permanent plugin install) and `download_archive` (used by + the unattended `check_auto_updates`/`update_one_plugin` auto-update path, which runs on + every process startup for any plugin with `auto_update = true`) both used the bare + `reqwest::get(url)` global client instead, following up to 10 redirects with no + scheme-downgrade restriction (#6099). The anti-downgrade client construction is now a + shared `https_safe_client()` helper used by all three archive-download call sites. +- `zeph-plugins`: `PluginManager::add_remote` and `download_archive` called `response.bytes()` + unconditionally with no upper bound on body size, allowing a malicious or compromised host to + exhaust process memory (#6108). Unlike `download_and_extract`, which already rejected an + oversized `Content-Length` before reading the body, these two call sites had no such check — + and `download_archive` backs the unattended `check_auto_updates` path that runs on every + process startup for any plugin with `auto_update = true`. All three call sites now share a + single `fetch_archive_bytes` helper that rejects a declared `Content-Length` above + `MAX_ARCHIVE_BYTES` (52 MiB) before reading the body, removing the duplicated inline check + that previously lived only in `download_and_extract`. +- `zeph-mcp`: `PinningOAuthHttpClient` (#6074) resolved, SSRF-validated, and DNS-pinned + the target host of every OAuth HTTP request, but its underlying `reqwest::Client` + only disabled auto-following redirects for `OAuthHttpRedirectPolicy::Stop` requests + — for `Follow` (dynamic client registration is rmcp's only current caller), reqwest's + own redirect-following stayed active, so a `3xx` response pointing at a *different* + host had that hop resolved independently and unpinned by reqwest itself, reopening a + redirect-scoped DNS-rebinding TOCTOU (#6089). `build_client` now disables redirects + unconditionally, and `execute()` follows `Follow`-policy redirects manually via a new + bounded loop (`MAX_OAUTH_REDIRECT_HOPS = 10`) that re-runs the identical + validate-and-pin step for every hop, mirroring standard redirect semantics (`303` + always downgrades to `GET`; `301`/`302` downgrade a `POST` to `GET`; `307`/`308` + preserve method and body). The manual reimplementation also now mirrors reqwest's + cross-origin header handling: `Authorization`, `Cookie`, `Proxy-Authorization`, and + `WWW-Authenticate` are dropped when a redirect hop's scheme, host, or port differs + from the previous hop's (a validated, SSRF-safe redirect target only proves it isn't + a private address — it can still be attacker-controlled, so credentials must not + follow it cross-origin), and `Content-Length`/`Content-Type`/`Content-Encoding` are + dropped whenever the body is emptied by a method downgrade. +- `zeph-scheduler`: `Scheduler::init()`'s DB-hydration loop read `TaskProvenance` verbatim from + the writer-controllable `scheduled_jobs.provenance` column, so a direct-SQL / out-of-process + writer could self-label a row `"static"` or `"user_added"` to dodge the RTW-A re-entry defenses + (#6114). This is latent hardening, not an active bypass fix — hydration only ever loads + periodic rows with `Value::Null` config, and the provenance-gated injection check only fires + for oneshot+`Custom` tasks, so no hydrated row reaches it today. `init()` now forces + `TaskProvenance::External` on every hydrated row regardless of the stored label, latching the + invariant that a row not written by this process's trusted in-session path is untrusted. +- `zeph-mcp`: closed three gaps in the MCP tool trust pipeline (#6071, #6072, #6073). + - `sanitize_tools`'s depth-cap drop path (see #6068 below) dropped unsanitizable + `input_schema`/`output_schema` content beyond `MAX_SCHEMA_DEPTH` but never incremented + `SanitizeResult::injection_count`, so `apply_injection_penalties` early-returned and a + server nesting an injection payload 11+ levels deep evaded both sanitization *and* the + trust-score penalty / `registration_injection` audit warning (#6071). Both depth-cap + drop sites now count as an injection. `SanitizeResult::input_schemas_dropped` and + `output_schemas_dropped` were also write-only — added to `ServerConnectOutcome` / + `zeph-core`'s `McpServerStatus` and surfaced in the TUI's MCP server status line + (`schema-drop:N`) alongside the existing connected/tool-count indicator. + - MCP tool schema-drift detection (the "rug-pull" mitigation documented in + `attestation.rs`) never fired: `apply_attestation()` hardcoded `previous_fingerprints` + to `None` on every call, so the reconnect-comparison branch in `attest_tools()` was + dead code outside its own unit test (#6072). `McpManager` now caches each server's + tool fingerprints (`server_fingerprints`, populated only when `expected_tools` is + configured — attestation must be enabled for drift detection to run) and threads them + into `attest_tools()` on every reconnect and `tools/list_changed` refresh, so a tool + description/schema that silently changes between sessions now logs a + `tracing::warn!`. Trust/filtering decisions are unchanged — this is detection only. + - `TrustScoreStore::load_and_apply_delta` — the only write path used in production, + gating `Trusted`/`Untrusted`/`Sandboxed` classification — was a non-atomic + read-then-write: it called `load()` (decay-aware) and then issued an unconditional + `UPDATE SET score = excluded.score`, so two concurrent callers for the same + `server_id` could both read the same pre-update score and the second writer's + unconditional overwrite would silently clobber the first's delta (#6073). It's now a + single atomic `INSERT ... ON CONFLICT DO UPDATE` that recomputes the asymmetric + time-decay from the stored `updated_at_secs` entirely inside the SQL expression + (`CASE WHEN` + dialect `LEAST`/`GREATEST`), so the whole + read-decay-delta-clamp-write sequence is one atomic row-level operation. The former + `apply_delta()` (atomic but decay-blind, unused in production) is removed — both + properties now live in one method, eliminating the two-divergent-write-paths root + cause. +- `zeph-common`: consolidated three duplicated/diverging security-sanitization + implementations into single canonical sources, closing real defense-in-depth gaps + (#5925, #5915, #5917). `zeph_common::sanitize::strip_control_chars` and + `strip_control_chars_preserve_whitespace` previously stripped only ASCII controls plus + `BiDi` overrides — missing zero-width space/joiners, soft hyphen, BOM, Hangul/Khmer/ + Mongolian fillers, and the Unicode Tags block that `zeph_common::patterns:: + strip_format_chars` already covered. Both now share a single bypass-codepoint denylist + (`patterns::is_bypass_codepoint`), so `zeph-memory`'s graph-resolver `sanitize_fact`/ + `sanitize_relation` (LLM-extracted entity/relation text stored in the graph and later + replayed into community-summarization prompts) transitively gain the stronger coverage + (#5925). `zeph-memory::graph::community`'s local `scrub_content` (only 4 filtered + categories) is removed; both call sites now use `strip_format_chars` directly (#5915). + `zeph-core::redact`'s `SECRET_PREFIXES`/`PATH_REGEX` and `zeph-memory::store:: + compression_guidelines`'s `SECRET_RE`/`PATH_RE` duplicated the same prefix list + character-for-character while drifting apart — `zeph-memory` had gained `Authorization: + Bearer` header and standalone-JWT redaction that `zeph-core` lacked. A new + `zeph_common::secrets` module is now the single source of truth for + `SECRET_PREFIXES`/`PATH_PREFIXES`/`BEARER_TOKEN_PATTERN`/`JWT_PATTERN`; both crates build + their own `regex::Regex` from it (matching the existing `zeph_common::patterns` + raw-pattern convention), and `zeph-core::redact::redact_secrets`/`scrub_content` now also + redact Bearer headers and JWTs (#5917). +- `zeph-mcp`: `sanitize_tools` walks `input_schema` and `output_schema` with the same + recursive walker, which enforces `MAX_SCHEMA_DEPTH` (10) by returning without sanitizing + the subtree at all once the cap is hit — no injection-pattern check, no truncation. + `output_schema` correctly dropped the whole field on a depth-cap hit, but `input_schema` + did not: `input_depth_cap` was computed and then never read, so an untrusted/compromised + MCP server could nest an injection payload 11+ levels deep and have it pass through + completely unsanitized straight into the LLM system prompt (`input_schema` is always + present, unlike the optional `output_schema`, and is always rendered verbatim by + `zeph-tools::registry::format_schema_params`) (#6068). `input_schema` is now dropped to + an empty object on a depth-cap hit, mirroring the existing `output_schema` handling + exactly, and the drop is tracked via a new `SanitizeResult::input_schemas_dropped` + counter. A related pre-existing gap — depth-cap drops on either schema field never + increment `injection_count`, so `apply_injection_penalties` never fires a trust-score + penalty or audit log for depth-cap evasion specifically — is tracked separately in #6071. +- `zeph-core`: `load_skill` (`SkillLoaderExecutor`) had no trust check at all — it read the raw + `SKILL.md` body straight from `SkillRegistry::body` and returned it verbatim, bypassing the + entire skill-trust defense-in-depth pipeline that `invoke_skill` already enforced: `Blocked` + skills were never refused, `Quarantined`/non-Trusted bodies were never sanitized or wrapped, + and the LLM-supplied `skill_name` was echoed unsanitized into the not-found error. The turn- + level `TrustGateExecutor` gate does not close this gap — it only denies `load_skill` when the + turn's folded `effective_trust` is Quarantined, and never inspects the `skill_name` argument + itself, so a `load_skill` call naming a Blocked/Quarantined skill sailed through on any turn + where the active skill set wasn't already Quarantined (#6050). `SkillLoaderExecutor` now shares + the exact same trust pipeline as `SkillInvokeExecutor` via a new `SkillTrustGate` (extracted + into `crates/zeph-core/src/skill_trust_gate.rs`, #6049): Blocked is refused before any body + read, non-Trusted bodies are sanitized, Quarantined bodies are additionally wrapped, the + per-invocation blake3 integrity re-check now also applies to `load_skill`, and `skill_name` is + sanitized on every output path (found, blocked, and not-found). `SkillLoaderExecutor::new` now + takes the same `trust_snapshot` `Arc` as `SkillInvokeExecutor`; a new + `agent_setup::build_skill_executors` helper constructs both executors around one shared `Arc` + so `load_skill` and `invoke_skill` can no longer observe divergent trust state or be wired up + independently, replacing five duplicated inline construction sites across `src/runner.rs`, + `src/acp.rs`, and `src/daemon.rs` (prod and test). +- `zeph-mcp`: closed a DNS-rebinding TOCTOU gap in the HTTP transport SSRF guard + (#6057). `validate_url_ssrf()` resolved and validated the target hostname as a + standalone pre-flight check, then discarded the result — the actual connection + (`connect_url`, `connect_url_with_headers`, `connect_url_oauth`, including both the + cached-token fast path and the post-callback `complete_oauth` path) performed its + own independent DNS resolution moments later via a bare `reqwest::Client::default()` + with the default redirect policy. An attacker controlling DNS for the target + hostname could pass validation with a public IP, then rebind to a private/internal + address before the transport's later resolution, or simply 3xx-redirect the request + toward an internal target. All three connect paths now call a new + `validate_and_pin_url()` helper that resolves the hostname once via + `zeph_common::net::resolve_and_validate` and threads the exact validated addresses + through to a hardened `reqwest::Client` (`resolve_to_addrs` + `Policy::none()`), + eliminating both the re-resolution window and the redirect bypass. The OAuth flow's + `OAuthPending` now carries the addresses pinned at the start of + `connect_url_oauth` through to `complete_oauth`, since re-resolving after the user's + browser interaction (unbounded duration) would reopen the same race. `connect_url_oauth` + also now routes `AuthorizationManager`'s internal OAuth HTTP traffic (metadata + discovery, token exchange, refresh) through the same SSRF-pinned client via + `OAuthState::new(url, Some(hardened_client))`, instead of letting it build its own + default, unpinned `reqwest::Client` internally — closing the same DNS-rebinding + window for OAuth requests to the original server host (#6069, sibling of #6057, + same PR). Note: `Policy::none()` is applied unconditionally, so `trusted` (operator + static-config) servers also stop auto-following redirects — previously they inherited + `reqwest`'s default of following up to 10 — a deliberate pre-1.0 hardening, not a + regression. +- `zeph-mcp`: closed the residual cross-origin discovered-issuer OAuth SSRF/DNS-rebinding + TOCTOU that #6069's single-host `resolve_to_addrs` pinning could not cover — per SEP-985, + `token_endpoint`, `authorization_endpoint`, `jwks_uri`, and `registration_endpoint` can + legitimately live on a different host than the MCP server itself, so `AuthorizationManager` + fell back to its own independent, unpinned DNS resolution for them. `connect_url_oauth` now + routes OAuth HTTP traffic through a new `PinningOAuthHttpClient` + (`rmcp::transport::auth::OAuthHttpClient` impl) that resolves, SSRF-validates, and DNS-pins + each request individually by its own target host at execution time, rather than reusing a + client pinned to the original server's host (#6074). +- `zeph-mcp`: `McpClient::connect_url_oauth`'s cached-token fast path and + `complete_oauth`'s post-callback path now wrap `handler.serve(transport)` in + `tokio::time::timeout`, matching every other connect entry point + (`connect_stdio`, `connect_url`, `connect_url_with_headers`); previously these two + sites could hang indefinitely if the server never responded (#6064). +- `zeph-durable`/`zeph`: `zeph_durable::encryption_gate` (the documented INV-8 AEAD enforcement + policy) is now actually invoked at runtime — previously it was a unit-tested pure function that + no call site ever reached, so `src/commands/durable.rs::load_write_cipher` (the durable journal + write path) and `open_backend` (the `zeph durable` CLI read path, including `--reveal`) each + made their own cipher decision by checking only `[durable] encrypt_payload`, ignoring backend + and shared-database status entirely. A deployment with `encrypt_payload = false` on a durable + journal database reachable by more than one process/client (e.g. a shared volume, or a future + Postgres-backed deployment) would silently persist tool outputs and agent-turn state in + plaintext with zero warning and zero error (#5996). Both call sites now evaluate + `encryption_gate` before making the cipher decision: `encrypt_payload = false` combined with a + non-local backend or a shared database now fails closed with a hard error at startup / on the + CLI command, and the permitted single-user local override now emits the documented startup + `tracing::warn!`. Added a new `[durable] shared_db` config field (default `false`) so operators + can declare a shared-database deployment explicitly; a `postgres://`/`postgresql://` resolved + journal URL is also treated as shared automatically, as defense in depth. The TUI durable panel + poller (`durable_poll_task`, feature `tui`) now evaluates the same policy before opening the + journal — previously it bypassed the gate entirely, so the TUI panel could render a journal the + `zeph durable` CLI refused to open; a rejection now degrades gracefully to a distinct + `GateRejected` panel status instead of erroring (see #6041 below for why it no longer reuses + the plain "non-durable mode" state). +- `zeph-commands`: 19 privileged slash-command handlers now override `requires_auth()` to + return `true`, closing a trust-gate gap where they ran the default `false` and were therefore + reachable from untrusted remote channels (Telegram/Discord/Slack) as well as trusted local + sessions (#6003). Gated handlers: `UndoCommand`/`RedoCommand` (`/undo`, `/redo` — mutate the + on-disk working tree), `PlanCommand` (`/plan` — executes tools/shell via orchestration), + `SkillCommand` (`/skill` — installs/removes/trusts executable skills), `FeedbackCommand` + (`/feedback` — writes persistent self-learning input), `KnowledgeSlashCommand` (`/knowledge` — + unconfirmed destructive `rollback`), `AgentCommand`/`AgentsFleetCommand` (`/agent`, `/agents` — + spawn/mutate sub-agent definitions), `ConvCommand` (`/conv`, feature `session` — session + hijack/cross-session disclosure via `resume`/`fork`), `MemoryCommand`/`GraphCommand` + (`/memory`, `/graph` — mutate semantic memory / knowledge graph, LLM cost), `GoalCommand` + (`/goal` — mutates persisted goal FSM, can drive autonomous execution), `AcpCommand` (`/acp`, + feature `acp` — discloses ACP allowlist/auth/bind-address config), `CocoonCommand` (`/cocoon`, + feature `cocoon` — discloses sidecar state and TON balance), `CompactCommand`/ + `NewConversationCommand` (`/compact`, `/new` — mutate conversation state, LLM cost/DoS), and + `ClearCommand`/`ResetCommand`/`ClearQueueCommand` (`/clear`, `/reset`, `/clear-queue` — + remote wipe of the operator's live session). `RecapCommand`, `SkillsCommand`, + `GuidelinesCommand`, `ExitCommand`, `QuitCommand`, and `HelpCommand` were audited and left at + the default (read-only, or already self-gated via `supports_exit()`). The + `CommandHandler::requires_auth()` default value itself is unchanged — revisiting the default + is deferred to a follow-up issue. +- `zeph-db`: `redact_url` no longer leaks the tail of a Postgres password containing `@` (#5969). + The previous regex (`://[^:]+:[^@]+@`) stopped at the first `@`, so + `postgres://user:p@ss@host/db` only redacted up to `p`, leaking `ss@host` verbatim into + `DbError::Connection` and CLI error output (`src/commands/db.rs`). Replaced with + `url::Url`-based userinfo parsing, which splits on the *last* `@` before the authority ends — + matching real client behavior — so passwords/usernames containing `@`, multiple `@`, and IPv6 + hosts are all handled correctly. Also now recognizes two non-userinfo libpq credential forms and + redacts the whole URL for them: query-param URIs (`?password=...`) and key-value DSNs + (`host=... password=...`), neither of which the old regex covered at all. +- **BREAKING**: `vault.backend` now defaults to `age` instead of `env` when a config omits the + `[vault]` section entirely (#5953). This aligns runtime behavior with the already-documented + default in `specs/010-security/spec.md` and `specs/038-vault/spec.md` — a fresh config with no + `[vault]` section previously resolved secrets from process environment variables silently; + it now requires an age vault identity (`~/.config/zeph/vault-key.txt` and `secrets.age`, or + `--vault-key`/`--vault-path`). Existing deployments that relied on the implicit `env` default + must either run `zeph vault init` to provision an age vault, or explicitly set + `vault.backend = "env"` in `config.toml` (understanding this stores secrets in plaintext + environment variables). `config/default.toml` and `crates/zeph-core/config/default.toml` were + updated to match; no config migration step was added because the previous `env` default was + never a persisted value — only a parse-time fallback applied to configs that omit the key. +- `parse_backend_str` (the parser for the `--vault` CLI flag and `ZEPH_VAULT_BACKEND` env var) + now rejects unrecognized backend names with a hard error instead of silently falling back to + the weaker `env` backend with only a `tracing::warn!` log line (#5954). Combined with the + #5953 default change, a typo in `--vault`/`ZEPH_VAULT_BACKEND` (e.g. `--vault aeg`) previously + downgraded the effective secret-storage backend for the whole process without a startup + failure. `parse_vault_args` now returns `Result`; `AppBuilder::new` and the + `bench`/`doctor`/`gonka`/`cocoon` commands that call it propagate the error instead of + continuing with a silently-downgraded backend. -- **Durable execution**: added a crash-orphan sweep to the durable retention loop (`Journal::sweep_orphans`, - #6254) that reclaims `status='running'` executions whose owner process died without finalizing — - previously invisible to the TTL prune (which only ever considers `finalized_at IS NOT NULL` rows) and - stuck `running` forever. A `status='running'` row whose `updated_at` is older than the new - `[durable.retention] stale_running_after_secs` (default 3600s, `0` disables the sweep) becomes a - candidate; it is hard-aborted only after a non-blocking try-acquire of its INV-15 advisory - `ExecutionLock` succeeds — a live owner (`ExecutionLocked`) short-circuits to skip, since staleness of - `updated_at` alone never proves the owner is dead. The sweep runs before `prune()` on every retention - tick (same supervised loop, no new spawn site) and is a documented no-op (warn-once) on backends - without an on-disk lock directory (`:memory:`, Postgres, non-Unix). `zeph durable prune` now runs the - sweep before the TTL prune and `--dry-run` reports both counts separately. -- **Durable execution**: `zeph-orchestration`'s `journal_budget` (P2) and `zeph-scheduler`'s - `fire_with_durable` (P3) now open their execution via `open_execution_exclusive` instead of the plain - `open_execution`, making their `DagRun`/`ScheduledJob` rows' liveness observable to the crash-orphan - sweep; on `DurableError::ExecutionLocked` both adapters log and return `Ok(())` — a graceful skip, never +- `zeph-config` / `zeph-llm`: removed derived `Debug` from 7 secret-bearing config structs that + printed their plaintext secret verbatim in any `{:?}` output — logs, panics, error chains + (#5952, #5963). `GatewayConfig::auth_token`, `ProviderEntry::api_key`/`cocoon_access_hash`, + `ClassifiersConfig::hf_token`, `CandleConfig::hf_token`, `CandleInlineConfig::hf_token`, + `OpenAiConfig::api_key`, and `CompatibleConfig::api_key` are now redacted (`"[REDACTED]"` in + `zeph-config`, `""` in `zeph-llm`, matching each crate's existing manual-`Debug` + precedent) by a hand-written `impl Debug` that still lists every other field. `Option` + secrets preserve the `None`-vs-`Some` distinction. `CandleInlineConfig` is embedded inside + `ProviderEntry`, so both were fixed together to avoid a transitive leak through nested `Debug`. +- `zeph-memory`/`zeph-core`/`zeph`: `SqliteStore::set_requires_trust_check` (the setter for the + per-invocation blake3 integrity re-check gated by `SkillTrustSnapshot::requires_trust_check`, + `crates/zeph-core/src/skill_trust_gate.rs`) had zero production callers anywhere in the + codebase — the column defaulted to `0` for every skill in every deployment and could never be + set to `1` by any CLI flag, config knob, or in-session command, making the entire defense + unreachable in practice despite being fully implemented and unit-tested on the consumption side + since #4306/#6062 (#6080). `zeph skill trust --require-check` and the in-session + `/skill trust --require-check` now call the setter after updating the trust + level. Separately, the CLI's `zeph skill invoke ` preview command (`src/commands/skill.rs`) + was a hand-rolled reimplementation of the trust-gating pipeline that predated #6062's + consolidation onto the shared `SkillTrustGate`: it never checked `requires_trust_check` at all, + and echoed the raw unsanitized skill name into its not-found error instead of the sanitized form + every other trust-gated path uses (#6079). `SkillTrustGate` and `SkillBodyResolution` are now + `pub` at the `zeph-core` crate root, and `SkillCommand::Invoke` calls + `SkillTrustGate::resolve_body` directly — the same pipeline `load_skill`/`invoke_skill` use — + so the CLI preview can no longer drift from the agent-facing tools. + +### Added + +- **Durable execution**: added a crash-orphan sweep to the durable retention loop (`Journal::sweep_orphans`, + #6254) that reclaims `status='running'` executions whose owner process died without finalizing — + previously invisible to the TTL prune (which only ever considers `finalized_at IS NOT NULL` rows) and + stuck `running` forever. A `status='running'` row whose `updated_at` is older than the new + `[durable.retention] stale_running_after_secs` (default 3600s, `0` disables the sweep) becomes a + candidate; it is hard-aborted only after a non-blocking try-acquire of its INV-15 advisory + `ExecutionLock` succeeds — a live owner (`ExecutionLocked`) short-circuits to skip, since staleness of + `updated_at` alone never proves the owner is dead. The sweep runs before `prune()` on every retention + tick (same supervised loop, no new spawn site) and is a documented no-op (warn-once) on backends + without an on-disk lock directory (`:memory:`, Postgres, non-Unix). `zeph durable prune` now runs the + sweep before the TTL prune and `--dry-run` reports both counts separately. +- **Durable execution**: `zeph-orchestration`'s `journal_budget` (P2) and `zeph-scheduler`'s + `fire_with_durable` (P3) now open their execution via `open_execution_exclusive` instead of the plain + `open_execution`, making their `DagRun`/`ScheduledJob` rows' liveness observable to the crash-orphan + sweep; on `DurableError::ExecutionLocked` both adapters log and return `Ok(())` — a graceful skip, never a task failure or retry. - **TUI**: added a read-only settings view (`S` key or the `settings` command-palette entry) listing configured LLM providers, MCP servers, and sub-agent definitions in @@ -131,6 +399,146 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). copy. No runtime-visible behavior change beyond no longer persisting image bytes that were already dead weight. +- **A2A**: added optional JWS Agent Card signature verification (A2A 1.0.0 §8.4) and a + `require`/`prefer`/`ignore` trust policy for peer discovery (#5928): + - `AgentCard.signatures: Vec` — new, additive, `#[serde(default, + skip_serializing_if = "Vec::is_empty")]` field; unsigned/0.2.x peer cards round-trip + unchanged (empty on the wire). + - New `card-signing` Cargo feature (`crates/zeph-a2a`, `p256` + `serde_json_canonicalizer`, + pure-Rust, no `openssl-sys`) enabling ES256 (P-256) verification. Off by default; propagated + through the root `a2a` feature (and `full`) alongside the existing `ibct`/`server` features + so CI actually compiles and tests the crypto path. + - `AgentRegistry::with_trust(policy, trusted_keys)` runs a crypto-free URL-origin + (scheme+host+port) consistency check plus signature verification in `discover()`, combining + both axes by taking the most severe outcome (reject > warn > accept) and returning + `A2aError::UntrustedCard` / `A2aError::UrlMismatch`. + - `[a2a_client]` gains `card_trust_policy` (`CardTrustPolicy`, default `ignore` — byte-identical + to prior behavior) and `trusted_agent_keys` (`Vec`, public keys stored + inline, not vault-referenced). `Config::validate()` fails fast if `card_trust_policy = + "require"` is set without the `card-signing` feature compiled in, rather than silently + degrading or bricking discovery. New `ZEPH_A2A_CARD_TRUST_POLICY` env override and + `--migrate-config` step 82 (commented advisory block for existing configs). + - **Known limitations, tracked as follow-ups**: `AgentRegistry` had no runtime construction + site and the JCS canonicalization was unvalidated against a real `a2a-sdk`-produced signed + card — both addressed later in this same `[Unreleased]` section, see the two `A2A` entries + above (#6200, #6201). The well-known discovery path remains `/.well-known/agent.json` + (0.2.x); a pure-1.0.0 peer serving `/.well-known/agent-card.json` is not yet discoverable. + `A2A_PROTOCOL_VERSION` stays `"0.2.1"` — this change is one additive 1.0.0 feature, not full + 1.0.0 conformance. `jku`/JWKS auto-fetch, EdDSA/RS256, and signing our own served card are + all still deferred. +- **Worktree**: added disk-quota and automatic reconciliation to the `zeph-worktree` subsystem + (#5924). Four new `[worktree]` config fields: `max_worktrees` (creation-time admission cap, + enforced as `WorktreeError::QuotaExceeded`), `disk_quota_mb` (soft total-disk-usage threshold), + `auto_reconcile_secs` (periodic reconcile+quota sweep interval via `TaskSupervisor`, `0` = + disabled), and `reconcile_on_startup` (default `true` — one reconcile+quota sweep at bootstrap). + Automatic reclamation only ever removes worktrees git itself reports as `prunable` — an intact + worktree is never force-removed to satisfy either threshold (spec-063 INV-6, extends INV-5). + `WorktreeManager` gains `disk_usage()`/`cached_disk_usage()` (filesystem walk, offloaded to + `spawn_blocking`, never called on the `create()` hot path) and `sweep()` (reconcile + + prunable-only reclaim + quota evaluation). `zeph worktree list` and the agent-side + `/worktree list` slash command both always print a fresh usage/quota summary footer (no + cross-mode divergence). `Config::validate` rejects `Some(0)` for `max_worktrees`/ + `disk_quota_mb`, rejects `disk_quota_mb` being set with no automatic evaluation path enabled + (neither `reconcile_on_startup` nor `auto_reconcile_secs`), and rejects a sub-60-second + `auto_reconcile_secs` (too short an interval would run a filesystem walk in a tight loop). + `--migrate-config` step 83 surfaces the four new fields as commented advisories on existing + `[worktree]` tables; the `--init` wizard gained matching prompts with the same validation. +- **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`, + `tool_execution.rs`/tests, `notifications.rs`, `secrets.rs`, `doctor.rs`, + `compression_guidelines.rs`, the `api-request` skill doc, and A2A + `ZEPH_A2A_IBCT_KEY`/`VAULT_A2A_IBCT_KEY_1` env-var names), none a real + credential. Extends (not replaces) gitleaks' default ruleset via + `[extend] useDefault = true`; allowlist entries match the literal dummy + string so future test fixtures reusing the same established pattern are + also covered, not just the already-known commits. `gitleaks detect` now + exits clean (0 findings) instead of re-surfacing the same 31 hits on every + scan. `SECURITY.md` documents the convention: reuse an existing dummy + pattern in new test fixtures where possible, or add a new allowlist entry + (#6056, #6081). +- **Memory**: wired the five remaining memory-maintenance loops — guidelines + (`mem-guidelines`), tree-consolidation (`mem-tree-consolidation`), hebbian-consolidation + (`mem-hebbian-consolidation`), episodic-consolidation (`mem-episodic-consolidation`), and + optical-forgetting (`mem-optical-forgetting`) — into `src/acp.rs` (`build_acp_deps`), + `src/daemon.rs` (`run_daemon`), and `src/serve/deps.rs` (`spawn_memory_maintenance_loops`), + matching the CLI/TUI path (`src/runner.rs`) and the first five loops wired by #5978. Each new + loop is gated by its own `[memory.*] enabled` config flag, exactly as in `runner.rs`; ACP and + `/sessions*`-created agents pass `None` for the hebbian loop's status sender since neither + entry point has a status-sender handle in scope, while the daemon reuses its own + `status_tx` (#5979). +- **Skills**: `[skills.trust] require_integrity_check_on_promote` (default `true`) automatically + arms the per-invocation BLAKE3 integrity re-check (`requires_trust_check`) whenever a skill is + promoted to `trusted`/`verified`, at both the CLI (`zeph skill trust`) and in-session + (`/skill trust`) promotion handlers. Previously the re-check could only be armed manually via + `--require-check`, so an operator who forgot the flag left a promoted skill's tampered-on-disk + `SKILL.md` undetected between promotions (#6087). `--require-check`/`--no-require-check` + (mutually exclusive) continue to override the config default per command; promotion to + `quarantined`/`blocked` leaves `requires_trust_check` untouched. A migration step (80) adds a + commented advisory for the new key to existing configs that already declare `[skills.trust]`. + Self-learning/heuristic auto-promotion and reload trust-assignment are intentionally out of + scope for this change — see the PR description. + +- `feat(acp)`: `[[acp.auth_clients]]` — named bearer-token clients for the ACP HTTP/WS + transport (#5868), enabling genuine multi-tenant/multi-window isolation of persisted ACP + session listing. `crates/zeph-acp/src/transport/auth.rs`'s `BearerAuthLayer` now authenticates + against a named-client credential set instead of one server-wide token; the matched client's + stable `id` becomes the connection's `owner_key`, threaded through `build_agent_state` and + scoping every session-persistence access path (`list_sessions`, `load_session`, + `resume_session`, `fork_session`, the REST `/sessions*` CRUD handlers, and the deprecated + `_session/*` ext methods). The legacy `[acp] auth_token` scalar keeps working unchanged, + synthesized as a client with id `"default"`; unauthenticated HTTP and stdio both resolve to + the `"acp-local"` bucket, matching pre-#5868 behavior for every deployment that does not + configure `auth_clients`. Each entry accepts an inline `token` or a `token_vault_key` resolved + from the age vault at startup (mirrors `[serve] auth_token_vault_key`). Config validation + rejects the reserved ids `"default"`/`"acp-local"`, duplicate ids, and duplicate tokens across + `auth_token` + `auth_clients` (inline collisions at config-load time; vault-resolved + collisions at startup, after the vault unlocks). `--init` gained a matching wizard prompt and + `migrate-config` a new step (77) that surfaces the new array as a commented block. + Note: this closes the isolation gap for genuine multi-token **HTTP/WS** deployments only — the + literal Zed-over-stdio scenario from the issue is unaffected by this change (stdio has no + token multiplexing to redesign; use distinct `sqlite_path` values per window instead). +- `feat(plugins,skills,config,cli)`: added an opt-in skill/plugin discovery-and-install + marketplace (spec-045, #5869) — `zeph skill search ` / `zeph skill get ` + and `zeph plugin search ` / `zeph plugin get `, closing the "no discovery + path, only `--plugin-url`" gap identified against Cline's marketplace and Vercel's + `skills.sh` in a competitive parity scan. A new `crates/zeph-plugins/src/marketplace` module + defines a dyn-compatible `RegistryClient` trait (mirroring `VaultProvider`'s boxed-future + pattern) with a `SkillsShClient` implementation for the public skills.sh registry, gated + behind a new `registry` Cargo feature (included in the `full` bundle) that gates only the + network-touching client code — CLI arg definitions and `[skills.registry]` config parsing + always compile, printing an actionable "rebuild with `--features registry`" message when the + feature is off. Registry lookups are strictly opt-in and off by default + (`skills.registry.enabled = false`): zero network calls and zero vault access occur unless + explicitly enabled. Fetched packages route through the existing, unmodified install + pipelines — `SkillManager::install_from_path` (frontmatter validation + Quarantined-trust + upsert) for skills, `PluginManager::add` (manifest validation, MCP allowlist, injection scan) + for plugins — so no new content-safety bypass is introduced. Auth token resolved exclusively + via `VaultProvider` (`skills.registry.auth_vault_key`), never a plain config field. Adds the + `--init` wizard step, a `--migrate-config` step (idempotent, always writes `enabled = false` + in the advisory template), and `MockRegistryClient` proving the trait boundary is real. + +- `feat(llm,commands,core)`: added runtime `/think-tokens [N|Nk|NM|off]` and + `/reasoning-effort [low|medium|high]` slash commands that mutate the active LLM provider's + thinking-token budget or reasoning-effort level mid-session, taking effect on the very next + turn — no restart required (#3098). Session-only: never persisted across restarts or + `/provider` switches (the switch confirmation now warns when an active override is dropped). + Supported per-provider: Claude (`Extended`/`Adaptive` thinking, mutually exclusive — setting + one overrides the other), OpenAI/Compatible (`reasoning_effort`), Gemini (`thinking_budget`/ + `thinking_level`); unsupported providers return an explicit "not supported" message rather + than a silent no-op. Fixed a latent bug in `ClaudeProvider::with_thinking` along the way: a + `base_max_tokens` snapshot now makes `max_tokens` restoration exact on disable, instead of the + previous construction-only logic which could only ever raise `max_tokens` to the 16k thinking + floor and never lower it back. Added a new `--reasoning-effort ` CLI flag and + a matching `--init` wizard prompt for OpenAI providers (Claude and Gemini already prompt for + their equivalent reasoning-depth setting). + ### Changed - **BEHAVIOR CHANGE**: `RunInline` orchestration tasks (dispatched when no sub-agent matches) @@ -212,22 +620,160 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). denies a retry to later same-turn consumers sharing the cache (previously each retried independently) — an accepted hot-path trade-off, not a correctness issue (#6266, #6267). -### Removed +- **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). -- **bench**: removed `BenchIsolation` (`zeph-bench::isolation`), dead code whose module doc - claimed the runner calls `reset()` to delete/recreate a shared bench-namespaced `SQLite` - database before each scenario. No production code ever constructed or called it — the - runner has always used a distinct per-scenario `SQLite` file (`bench-{run_id}-{scenario.id}.db`), - which needs no reset step since there is never a shared file to clean between scenarios - (#5966). +- **Architecture**: `zeph-agent-context` — refactored the graph-retrieval-strategy call + chain in `helpers.rs` (`dispatch_graph_strategy`, `run_graph_strategy`, + `run_synapse_strategy`, `run_hybrid_strategy`, `recall_by_classified_strategy`, + `fetch_semantic_recall_raw`, `append_graph_facts`) away from the "parameter bag" + anti-pattern — each function threaded 8-14 positional arguments and individually + suppressed `clippy::too_many_arguments`. Introduced `GraphStrategyParams` (per-call + query state), `GraphRecallConfig` (shared immutable config), `GraphRecallBudget` + (mutable token-budget accumulator), and `SemanticRecallRawParams`, grouped by + mutability/ownership rather than bundled arbitrarily. Removed all six + `clippy::too_many_arguments` allows and the crate's `#![recursion_limit = "256"]` + override, which the refactor's reduced Future-nesting depth no longer requires. Pure + internal signature refactor — no behavior change (#5992). +- **DRY**: consolidated the four near-identical memory-maintenance-loop spawn blocks in + `src/runner.rs` (CLI/TUI, previously inline), `src/acp.rs` (`build_acp_deps`, previously + inline), `src/daemon.rs` (`run_daemon`, previously inline), and `src/serve/deps.rs` + (`spawn_memory_maintenance_loops`) into a single shared + `agent_setup::spawn_memory_maintenance_loops`, now called by all four entry points (#6180). + The function takes `status_tx: Option<&UnboundedSender>` to unify the ACP/`/sessions*` + (`None`) vs. CLI/TUI/daemon (`Some`) difference in the hebbian-consolidation loop's status + sender, and a `skip_eviction: bool` preserving `runner.rs`'s pre-existing `--bare` gate, which + only ever applied to the eviction loop. The `acp.rs`/`daemon.rs` unit tests previously + reconstructed a hand-written copy of the production spawn block (unlike `serve/deps.rs`'s + test, which already called the real function); both now call the shared production function + directly via `AppBuilder::for_test`, closing a test-realism gap left by #6170. No behavior + change for any entry point. +- **DRY**: `zeph-mcp` — extracted `McpManager::commit_pending` to replace the near-identical + `commit_connect_outputs`/`commit_oauth_outputs` pair in `manager/connect.rs`, and + `finish_connect` to replace the handler-build + timeout-wrapped-handshake + error-classify + scaffolding duplicated across all five `McpClient` connect paths (`connect`, `connect_url`, + `connect_url_with_headers`, `connect_url_oauth`'s cached-token branch, `complete_oauth`) in + `client.rs` (#6070, #6065). Pure internal refactor, no observable behavior change: the + never-hold-a-lock-across-an-`.await` invariant is preserved on every path, and + `finish_connect` now routes every site through `classify_connect_error` uniformly (verified + byte-for-byte equivalent to the prior inline mapping used by the stdio `connect` path). One + intrinsic side effect of unifying two helpers that committed `server_tools` and + `server_fingerprints` in opposite relative orders: `commit_pending` adopts the OAuth path's + order (`fingerprints` then `tools`) on `connect_all` too, where it was previously reversed. + Both are independent `RwLock`s never held simultaneously by any code in the module, so this + ordering swap has no observable effect. -- **BREAKING**: removed the inert `A2aServerConfig::require_tls`/`.ssrf_protection` fields - (`[a2a]` TOML section) and their `ZEPH_A2A_REQUIRE_TLS`/`ZEPH_A2A_SSRF_PROTECTION` - environment variables. Neither was ever read by any code path — the daemon's own A2A server - never checked them, and the former client-side reader was moved to the independent - `[a2a_client]` section by #5878. `[a2a_client].require_tls`/`.ssrf_protection` (governing - outbound `zeph --connect ` connections) are unaffected and continue to work exactly as - before. `--migrate-config` drops any leftover `require_tls`/`ssrf_protection` keys from an +- **DRY**: extracted the `zeph worktree clean` reconcile → remove → prune pipeline and its + removed/skipped/errored counting into a single `WorktreeManager::clean` method plus a + `format_clean_summary` free function (`zeph-worktree`), now shared by both the CLI + (`src/commands/worktree.rs`) and the agent-side `/worktree clean` slash command + (`crates/zeph-core/src/agent/worktree_commands.rs`) (#6142). These two call sites + previously duplicated the same loop independently, which already caused a real bug during + #6141's review (the agent-side path originally discarded the `prune()` failure instead of + reporting it, diverging from the CLI's warn-and-continue behavior). Only the `--force` + hint text in the skip warning still differs between the two surfaces (`force_hint` + parameter), since that's the one piece of legitimately surface-specific UX. + +- **BREAKING**: `zeph-db`'s `DbConfig` collapses `max_connections` and `pool_size` into a + single `pool_size: u32` field, used as `sqlx`'s `.max_connections()` for both `SQLite` and + `PostgreSQL` (#5970). Previously `pool_size` was documented "`SQLite` only" but was actually + what `connect_postgres` passed to `PgPoolOptions::max_connections()` (`max_connections` was + never read under Postgres), and `SQLite` combined the two fields as + `max_connections.max(pool_size)` — the larger value won, not a cap. Every call site already + set both fields to the same value by convention; this removes the redundant, contradictory + field. All in-tree `DbConfig` construction sites (`zeph-scheduler`, `zeph-memory`, `zeph-mcp`, + `zeph-durable`, `zeph-orchestration`, `zeph-index`, `src/bootstrap/mod.rs`, + `src/commands/db.rs`) are updated; `max_connections` was never exposed as a user-facing + `config.toml` key, so no config migration step is needed. +- `refactor(common)`: deduplicated the near-identical `Arc`-backed newtype boilerplate in + `ToolName`, `ProviderName`, and `SkillName` (`crates/zeph-common/src/types.rs`) — each + independently reimplemented the same ~115-line block (`Default`, `Display`, `AsRef`, + `Borrow`, `From<&str>`, `From`, `FromStr`, and 5 hand-written `PartialEq` + directions) — behind a private `macro_rules! arc_str_newtype!`, parameterized per type via + captured doc-comment attributes so each type keeps its own tailored rustdoc and doctests + (#5927). `ProviderName`'s `is_empty`/`as_non_empty` empty-sentinel helpers stay in a separate + hand-written `impl` block, unchanged. Pure refactor, no behavior change. + +- `ci`: migrate the workspace lint-warning gate from the global `RUSTFLAGS: "-D warnings"` + CI env var to Cargo's native `build.warnings = "deny"` (new `.cargo/config.toml`, stabilized + in Rust 1.97, cargo PR rust-lang/cargo#16796). Unlike `RUSTFLAGS`, toggling `build.warnings` + does not change rustc's invocation fingerprint, so it no longer forces a full recompile of + unchanged units when switching between a plain `cargo build` and a warnings-denied one — + verified locally via `cargo build -v` fingerprint comparison (`Fresh` in both directions vs. + full recompile on `RUSTFLAGS` toggle). Coverage-parity verified for the warning classes the + previous gate caught (unused imports, dead code, unused variables); `build.warnings` was also + found to independently catch `rustdoc::broken_intra_doc_links` and `cargo clippy` lints, wider + than expected, but `RUSTDOCFLAGS="--deny rustdoc::broken_intra_doc_links"` and clippy's own + `-- -D warnings` CLI flag are left unchanged as defense-in-depth (#5873). The `coverage` job's + former `RUSTFLAGS: ""` reset (needed because it builds `--features full`, a superset of the + `lint-clippy` matrix, and must not fail on lint status — that's `lint-clippy`'s job) is now + `CARGO_BUILD_WARNINGS: "allow"`, the per-job env override for the same repo-wide config key. + The `rustdoc` job gets the same override: `build.warnings` being wider than `RUSTDOCFLAGS` + means it independently denies `rustdoc::private_intra_doc_links`/`redundant_explicit_links` + too, and 37 pre-existing instances across 10 crates (unrelated to this change) would have + newly failed that job; the override keeps it enforcing exactly what it always has pending a + separate doc-cleanup pass. +- `chore`: raise the workspace MSRV from Rust 1.96 to 1.97 (`Cargo.toml` + `rust-version`, CI `msrv` job, all crate README badges/notes, `specs/constitution.md`). + Rust 1.97 (stable 2026-07-07) is now the minimum supported toolchain. This also unifies + MSRV references that had drifted between 1.95 and 1.96 across README/spec docs. No source + changes accompany the bump: a review against Rust 1.89-1.97 stabilizations found no + 1.97-specific stdlib API with a real use site (the codebase already uses `floor_char_boundary` + for UTF-8-safe truncation; existing `compare_exchange` sites are one-shot guards, not + CAS-update loops; `with_extension` sites intentionally replace the suffix). +- `refactor(session)`: extracted a shared `finish_torn_tail` helper in + `crates/zeph-session/src/log.rs` for the identical torn-tail warn+repair epilogue duplicated + between `read_events` and `read_events_chunked` (#5852). +- `perf(scheduler)`: `daemon_status()`'s `recent_runs` fetched every active job via + `list_jobs_full()` and sorted/truncated in Rust rather than pushing the ordering and limit + into SQL (#6115). Added `JobStore::list_recent_runs`/`count_active_jobs`; ordering uses + `ORDER BY last_run IS NULL, last_run DESC LIMIT ?`, which evaluates to a sortable `0`/`1` + (`SQLite`) or `false`/`true` (`PostgreSQL`) value on both backends without relying on + `PostgreSQL`-only `NULLS LAST` syntax. +- `chore(scheduler)`: removed the unused `blake3` and `uuid` dependencies from + `crates/zeph-scheduler/Cargo.toml` — neither was referenced anywhere in the crate's source + (#6098). +- `chore(agent-tools)`: removed 7 unused `zeph-*` dependencies (`zeph-agent-persistence`, + `zeph-config`, `zeph-context`, `zeph-mcp`, `zeph-orchestration`, `zeph-sanitizer`, + `zeph-skills`) from `crates/zeph-agent-tools/Cargo.toml` — leftover scaffolding from the + abandoned `ToolDispatcher` extraction (#3516, closed) with zero references in `src/`. + Narrowed the `sqlite`/`postgres` feature gates to forward only to `zeph-tools`, the sole + remaining backend-gated dependency (#6084). + +### Removed + +- **bench**: removed `BenchIsolation` (`zeph-bench::isolation`), dead code whose module doc + claimed the runner calls `reset()` to delete/recreate a shared bench-namespaced `SQLite` + database before each scenario. No production code ever constructed or called it — the + runner has always used a distinct per-scenario `SQLite` file (`bench-{run_id}-{scenario.id}.db`), + which needs no reset step since there is never a shared file to clean between scenarios + (#5966). + +- **BREAKING**: removed the inert `A2aServerConfig::require_tls`/`.ssrf_protection` fields + (`[a2a]` TOML section) and their `ZEPH_A2A_REQUIRE_TLS`/`ZEPH_A2A_SSRF_PROTECTION` + environment variables. Neither was ever read by any code path — the daemon's own A2A server + never checked them, and the former client-side reader was moved to the independent + `[a2a_client]` section by #5878. `[a2a_client].require_tls`/`.ssrf_protection` (governing + outbound `zeph --connect ` connections) are unaffected and continue to work exactly as + before. `--migrate-config` drops any leftover `require_tls`/`ssrf_protection` keys from an existing `[a2a]` table, warning the user, without erroring (#5885). - **ACP**: removed `DynSchedulerExecutor`, a hand-maintained `ToolExecutor` wrapper around @@ -255,6 +801,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). for every dispatch path. `shell_executor_handle` (used only for TUI background-run metrics via the concrete `ShellExecutor::background_runs_snapshot()` inherent method) is unaffected (#6224). +- `crates/zeph-sanitizer/src/pipeline.rs`: deleted the composable `Pipeline`/`Stage`/ + `SanitizeContext` abstraction. It had zero production consumers — `ContentSanitizer` + (`sanitizer.rs`) implements its own inline sequence of steps (truncate, strip, detect, + escape, spotlight) as ordinary method calls and never used `Pipeline`/`add_stage` (#5911). + No `pub use` re-export existed and no other crate in the workspace referenced these types, + so removal is a pure dead-code cleanup with no behavior change. + ### Docs - **LLM**: `AnyProvider`/`Router`/`Triage`'s `capability_delegation_advisory()` rustdoc comments @@ -284,2189 +837,1660 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). grant revoke — currently unreachable since every `revoke_all()` call site pairs with `cancel.cancel()` or runs only after the loop has reported a terminal status (#6124). -### Fixed +- `src/acp.rs`/`src/daemon.rs`: the memory-maintenance-loop regression tests + (`acp_memory_maintenance_loops_registered_on_connection_supervisor`, + `daemon_memory_maintenance_loops_registered_on_mem_supervisor`) reconstructed the + spawn blocks inline with a mock `TaskSupervisor` instead of calling the real + production wiring path, so a broken or inverted `config.memory.*.enabled` guard in + `build_acp_deps`/`run_daemon` would go undetected by either test (#6170). Extracted + the wiring into standalone `spawn_acp_memory_maintenance_loops`/ + `spawn_daemon_memory_maintenance_loops` functions, mirroring the existing + `spawn_memory_maintenance_loops` pattern in `src/serve/deps.rs` (#5979); both tests + now call the extracted functions directly. No behavior change — all ten loops still + spawn identically, gated the same way, via `TaskSupervisor::spawn`. +- `zeph-llm`: reduced the discoverability of bypassing the Claude no-prefill funnel introduced + by #6154, and closed a real HTTP-level coverage gap (#6155, #6156). `split_messages`/ + `split_messages_structured` in `crates/zeph-llm/src/claude/request.rs` are now + `pub(in crate::claude)` instead of `pub(super)`. In the current module nesting (`request` is + a direct child of `claude`, i.e. `mod.rs`) the two are functionally identical — Rust has no + way to grant a child module's item to its parent while excluding the parent's other children, + so this is a self-documenting anchor to the intended boundary, not a new compile-time barrier; + `claude::tests` remains exactly as reachable as before in raw visibility terms. A true + compile-error barrier is architecturally impossible while the no-prefill funnel + (`structured_history`/`plain_history`) lives in the parent module (`claude`/`mod.rs`) — a new + request-construction path added there calling the raw split functions directly would still + compile fine, so this remains a code-review catch rather than a compiler error. The actual + hardening is that the ~22 pure message-conversion tests (tool-use pairing, cache breakpoints, + image blocks, thinking/redacted-thinking blocks, compaction round-trip) that used to call + these functions directly moved from `claude/tests.rs` into a same-file `mod tests` inside + `request.rs`, so `claude::tests` no longer imports or calls them — removing the habit/ + discoverability path a future author would reach for, not adding a compiler-enforced one. A + genuine compile-time guarantee (a newtype only constructible via `structured_history`/ + `plain_history`, so a request body cannot accept an ungated history) is tracked as a + follow-up: #6158. Separately, `ClaudeProvider`'s Messages API + base URL is now an injectable field (`api_url`, defaulting to the real `API_URL` in + production) with a `#[cfg(test)]`-only `with_api_url` override, replacing a dead hand-rolled + TCP mock server in `claude/tests.rs` that had given up on HTTP-level assertions ("We can't + override API_URL from outside"). New `wiremock`-backed tests drive `chat_with_tools`, + `chat_with_tools_stream`, and `chat_typed` through a real (mocked) HTTP round-trip and assert + the no-prefill gate strips a trailing assistant message in the request actually sent over the + wire, closing the gap where prior coverage only exercised the identical body-construction + sequence via `debug_request_json` as a same-order proxy. +- `zeph-worktree`: added a regression test confirming `WorktreeManager::remove()`'s single + `git worktree remove --force` still refuses a `git worktree lock`-ed worktree (requires + `-f -f`), the second safety layer alongside the `prunable`-gating from #6076 — previously + only confirmed manually against real git 2.50.1 (#6077). +- `src/commands/worktree.rs`: added an end-to-end test calling the real `handle_worktree_command` + handler (not a reimplementation of its skip-gating logic) against a real git repo with a + mixed stale list (N>1 prunable + M>1 in-use entries in the same run) (#6077). +- `zeph-worktree`: added `FakeGitRunner`-backed unit tests for the new shared + `WorktreeManager::clean`/`format_clean_summary` covering the mixed prunable/non-prunable + case, the `--force` bypass, a `remove()` failure being counted as `errored`, and — the + exact divergence caught in #6141's review — a final `prune()` failure recording a warning + without discarding an already-successful removal count (#6142). +- `zeph-core`: added a live-`SubAgentManager` fixture test suite for `Agent:: + handle_worktree_list_as_string`/`handle_worktree_clean_as_string` (`crates/zeph-core/src/ + agent/worktree_commands.rs`) — a real `DefaultWorktreeManager` over a temp git repo wired + into a real `SubAgentManager` via `set_worktree_manager`, exercising the live-manager + `Some(wm)` branch end to end (0/1/N active worktrees, mixed prunable/in-use stale lists, + `--force` reaching `WorktreeManager` with correct semantics). Previously only the + disabled-subsystem `None` short-circuit had coverage, via `zeph-commands`' + `NullAgent`-backed tests (#6142). +- `tests/workspace_lints.rs`: added a guard test asserting + `workspace.lints.rust.linker_messages` stays `"allow"` in root `Cargo.toml`, closing a CI + blind spot where an accidental removal would only surface on a manual `ci-non-linux.yml` + macOS/arm64 run or the next tagged release build, not on any automated per-PR check (#5961). -- **core**: `build_tier_call_futures` fired each tier's `PreToolUse` hooks sequentially, - adding `N × hook_latency` of purely serial blocking on the agent turn loop before the - tier's already-parallelized tool execution even began — the same defect class already - fixed for the `PostToolUse` side (#6128) but never mirrored to `PreToolUse`. Hooks now - fire concurrently, bounded by the tier semaphore, with the per-call invariant preserved - (each call's own hook still fires before that call's own gate check) (#6259). -- **core**: `AgentAccess::graph_backfill` extracted entities/edges from each unprocessed - message strictly sequentially — one LLM call plus SQLite/Qdrant write at a time — despite - the store's `UNIQUE(canonical_name, entity_type)` upsert already making concurrent - extraction across messages safe. Now uses `futures::stream::iter(...).buffer_unordered(4)`, - matching the existing `semantic_scan_plugin_add` pattern, cutting backfill wall time - roughly 4x with no correctness change (#6261). -- **core**: `Agent::begin_turn` re-derived the MAGE `(AuditSignalType, Severity)` pair from - the raw trajectory-signal `u8` code via an independent hand-rolled match, duplicating the - code-to-meaning table already authoritative in `RiskSignal::from_code` — the two tables - were not compiler-coupled and could silently drift. Now matches on the already-computed - `RiskSignal` enum value instead; zero behavior change (#6272). -- **Security (`ShadowSentinel`)**: `check_tool_call` awaited its two pre-tool-dispatch DB reads - (`get_trajectory`, `get_tool_history`) with no timeout, so a stalled DB connection (e.g. a - slow/unresponsive Postgres backend) could block dispatch of every `Shell`/`FileWrite`/ - `ExfilCapable`/`McpUnclassified` tool call for the whole session. Both reads are now wrapped - in `tokio::time::timeout`, bounded by the existing `probe_timeout_ms.min(2000)` (no new config - field). A timeout logs a warning and falls back to the same empty/partial trajectory the - pre-existing DB-error branch already produced — fail-open, matching `ShadowSentinel`'s - documented defence-in-depth contract; the primary `PolicyGateExecutor`/`TrajectorySentinel` - gates are unaffected and continue to run regardless (#6269). -- **Orchestration**: `PlanVerifier::verify_plan()` (whole-plan completeness verification, run - once after all DAG tasks complete) had no tool-call grounding — only the per-task `verify()` - path gained deterministic grounding against the real tool-execution trace in #6278/PR #6286, - which explicitly scoped whole-plan out with a `TODO(critic)` marker. A hallucinated - aggregated-output claim (e.g. "ran the full test suite across all tasks") could pass whole-plan - verification ungrounded. `verify_plan()` now grounds against the DAG-wide **union** of every - completed task's real `tool_trace`, rebuilt from transcripts at whole-plan-verify time by - reimplementing the same resolution logic `build_tool_trace_for_task` uses for the per-task path - (independent of whether per-task `Verify` ran for a given task — this is deliberate - defense-in-depth against a future dispatch mode that skips it). Trace - availability is all-or-nothing at the DAG level: the aggregate is `Some(union)` only if every - completed task's trace resolves; any one unavailable trace (e.g. a `RunInline` task, whose - in-loop trace is never persisted) degrades the whole aggregate to `None` and grounding fails - open, exactly reproducing prior ungrounded behavior (now with a `DEBUG` log noting the - degradation). Whole-plan grounding is strictly weaker than per-task grounding at catching a - single task's own hallucination (a claim grounds if *any* task in the plan really performed - it) — it is additive defense-in-depth, not a replacement. The trace-path resolution loop is - offloaded to `spawn_blocking` to avoid N synchronous transcript reads blocking the async - finalization path. `specs/009-orchestration/spec.md` updated with the new grounding contract, - Key Invariant, and AC-13..AC-16 (#6287). -- **Orchestration**: `execute_partial_replan_dag` (whole-plan replan execution) silently rejected - every replan attempt against a non-empty graph — i.e. always, in practice, since whole-plan - replan only ever runs after at least one task has completed. `replan_from_plan` assigns gap-task - IDs continuing the parent graph's numbering (so the final merge into `completed_graph.tasks` - stays globally unique), but the standalone partial `TaskGraph` built to execute those gap tasks - is validated by `dag::validate`, which requires 0-based positional IDs (`tasks[i].id == - TaskId(i)`) for any freestanding graph. The mismatch made `DagScheduler::new` reject the partial - graph outright (`"invalid graph: task at index 0 has id 1 (expected 0)"`), fail-opening to no - replan every time. This is a distinct, pre-existing defect independent of the whole-plan - grounding work above — unrelated to the matching/grounding contract, purely a task-ID-numbering - bug in replan execution — surfaced by the new end-to-end test added for #6287's review pass. - Gap-task IDs are now remapped to local 0-based IDs for the partial scheduler run and back to the - original global IDs on the way out. -- **Durable execution**: `open_execution`/`open_execution_exclusive`'s reopen path un-finalized only - `completed`/`failed` rows, leaving `aborted` rows untouched on reopen (INV-16). This was safe before - #6254 because `aborted` was a rare, immediately-redriven outcome, but became a hazard once the new - crash-orphan sweep makes `aborted` the common outcome of a resumable crash: a resumed execution whose - row kept `finalized_at` set was prunable out from under the active resume — the exact hazard the - completed/failed un-finalize was built to prevent. Reopening now resets `status='running'` and clears - `finalized_at` for a row in ANY terminal status. -- **Worktree**: `--bare` silently skipped the entire worktree subsystem bootstrap - (`WorktreeManager` construction, `probe_capabilities`) with no warning when - `worktree.enabled = true` in the active config — the 6th confirmed instance of the `--bare` - mode silently skipping a whole subsystem class with no operator-visible signal. A sub-agent - definition with `permissions.worktree = true` running under `--bare` lost its worktree - isolation guarantee (INV-1/INV-3, `specs/063-worktree-subsystem/spec.md`) with nothing in the - logs at default verbosity. `src/runner.rs`'s worktree bootstrap gate now emits a - `tracing::warn!` for the `worktree.enabled = true` + `--bare` combination explaining that - isolation is being skipped; `WorktreeManager` is still never constructed under `--bare` by - design (`--bare` remains a fast, dependency-light path). `Agent::with_bare_mode`'s doc comment - now lists the worktree subsystem among those `--bare` skips (#6256). -- **core**: `apply_tier_results`'s Phase 2 hook-firing future (`RuntimeLayer::after_tool` + - `PostToolUse`) discarded the `Result` from `sem.acquire()`, silently proceeding as if a - permit were held if the local semaphore were ever closed. It now matches the sibling - `make_exec_future` pattern: on a closed semaphore it logs via `tracing::warn!` and returns - the original tool result unchanged, skipping hook firing for that index instead of running - unbounded (#6258). -- **core**: `crates/zeph-core/src/agent/tests/ensemble_scheduler_loop_tests.rs` declared its - helper functions and `zeph_orchestration` imports at module scope with no feature gate, - while its four test functions were individually gated `#[cfg(feature = "scheduler")]`. Since - `zeph-core`'s default features don't include `scheduler`, a plain `cargo nextest run -p - zeph-core` compiled the now-unused helpers/imports as dead code, which the workspace's - `build.warnings = "deny"` turned into a hard build failure. The module declaration in - `agent/tests/mod.rs` is now gated `#[cfg(all(test, feature = "scheduler"))]` so the whole - file compiles only when the feature enabling its content is enabled (#6274). -- **Security (config)**: `PiiFilterConfig` and `SecretMaskingConfig` (the PAAC secret - placeholder masking registry) both shipped `enabled = false` by default even though both - controls are cheap synchronous substitutions (regex scrub / placeholder swap, no LLM call) - whose entire purpose is keeping vault-resolved secrets and PII out of LLM payloads, SQLite - message history, and debug dumps — an operator accepting `--init` defaults ended up with - neither protection active. Flipped both fields' effective default to `true`: the `enabled` - field itself now uses `#[serde(default = "default_true")]` (not a bare `#[serde(default)]`, - which only resolves via `bool::default()` and would have stayed `false` for any config where - the section is present but the key was never written — matching the existing - `query_bias_correction` field convention), so both a missing section and a present-but-key- - omitted section now correctly resolve to enabled. `PiiFilterConfig::default()` and - `SecretMaskingConfig::default()` updated to match. `--init` prompts now default to `true` - with a "(recommended)" suffix. An operator's explicit `enabled = false` in an existing - `config.toml` is always respected — this is a serde default-value change, not a migration - that rewrites live config. Also updated the shipped `config/default.toml` and - `crates/zeph-core/config/default.toml` reference files (both previously wrote an explicit - `enabled = false`, which would otherwise have silently overridden the new safe default) and - the `--migrate-config` step-73 advisory comment for `[security.content_isolation.secret_masking]` - (#6263). -- **Security (config)**: `GatewayConfig.auth_token` is hydrated in place from the vault - (`ZEPH_GATEWAY_TOKEN`) at startup but derived `Serialize` only had `#[serde(default)]`, no - `#[serde(skip_serializing)]` — the same latent-leak class already fixed for - `TelegramConfig.token`/`DiscordConfig.token`/`SlackConfig.bot_token`/`SlackConfig.signing_secret` - in #6173. Unlike `A2aServerConfig.auth_token`, `--init` has no wizard path that persists - `gateway.auth_token` to `config.toml`, so redacting it in `Serialize` carries no round-trip - risk. Added `#[serde(skip_serializing)]`; `Deserialize` is untouched so an inline token in a - hand-edited `config.toml` still loads (#6248). -- **ACP**: `session/delete` only removed the in-memory session entry, never touching the - configured persistence store — a deleted session's `acp_sessions` row (and its associated - conversation history / config snapshot) survived, so it could resurrect via a subsequent - `session/load` or `session/resume`. `do_delete_session` now also calls - `SqliteStore::delete_acp_session_for_owner`, matching the owner-scoped pattern already used - by `do_load_session`/`do_fork_session`/`do_resume_session`. In-memory removal remains - unconditional and always happens first; a persisted-store deletion failure is now surfaced - to the caller as an error (rather than logged and silently swallowed) so a transient DB - failure never reports false success while the persisted row — and the resurrection risk it - carries — survives. The delete is idempotent by id, so the client can safely retry (#6271). -- **Security (a2a ibct)**: `zeph-a2a`'s IBCT (Invocation-Bound Capability Token) mechanism was - fully implemented (HMAC-SHA256 issuance/verification, key rotation, constant-time comparison) - and documented in `A2aServerConfig`/specs/README as an active per-task authorization layer on - top of the coarse bearer token, but nothing called `Ibct::issue` outside its own unit tests and - nothing in the server's axum handlers called `Ibct::verify` or read the `X-Zeph-IBCT` header — - the documented enforcement never existed (CWE-862 Missing Authorization, #6260). Fixed by - wiring both sides: server-side, `zeph_a2a::server::router::ibct_middleware` (layered inside the - bearer-auth middleware) rejects requests to `/a2a`/`/a2a/stream` with `401` (missing/undecodable - header) or `403` (signature/expiry/scope mismatch) whenever `A2aServer::with_ibct_keys` is - configured with a non-empty key set — populated in `src/daemon.rs` from `[a2a] ibct_keys` - (inline hex, via new `IbctKey::from_hex`) and `ibct_signing_key_vault_ref` (vault-resolved, - startup now fails if the ref is set but unresolvable, per the spec's documented invariant); an - empty key set stays a no-op, matching the existing bearer-auth opt-in pattern. Client-side, - `A2aClient::with_ibct_key` makes `rpc_call`/`stream_message` issue and attach a token scoped to - the request's `task_id` (`params.id` for `tasks/get`/`tasks/cancel`, `params.message.taskId` for - `message/send`/`message/stream`, empty-string sentinel for a not-yet-assigned task ID) and to - the **origin** of the target endpoint (`ibct_scope_origin` strips the path) — required because - `/a2a` and `/a2a/stream` are different paths verified against the one pathless `AgentCard::url`, - so a token scoped to a full per-route URL would 403 on whichever route it wasn't issued for - (caught in review as S1; regression-tested in both `client.rs` and `router.rs`). Corrected the - four doc surfaces (`A2aServerConfig` doc comments, `crates/zeph-a2a/README.md`, - `specs/014-a2a/spec.md`, `specs/010-security/spec.md`) that overclaimed active enforcement, and - added an explicit deployment caveat to all four: no caller bundled in this repository (incl. - `src/tui_remote.rs`'s `--connect` client) attaches `X-Zeph-IBCT` yet, so enabling `ibct_keys` - today rejects unauthenticated/non-Zeph A2A traffic but does not by itself protect any - delegated-subagent flow — a follow-up delegation client wiring `with_ibct_key` is required for - that (S2). `message/send` always creates a fresh task server-side and is therefore always - scoped to the empty-string sentinel rather than a request-specific ID — documented as a known - MVP limitation rather than closed in this fix (S3). `ibct_scope_origin` moved from `client.rs` - into a shared `pub(crate)` function in `ibct.rs` and is now applied to the server's own - `card.url` in `A2aServer::serve()` too, not just to the client's `endpoint` argument — an - unnormalized `public_url` (trailing slash, stray path, explicit default port, mixed-case - scheme/host) would otherwise silently 403 every request even from a correctly-behaving client, - reintroducing S1's bug class on the server side (review finding M6). -- **TUI/metrics**: the turn-latency panel (`latency ctx:… llm:… tool:… save:…`) showed - `llm:0ms` for tool-enabled turns even when the real LLM call took 25+ seconds. Root cause: - `MetricsBridge::WATCHED_SPANS` watched the bare `llm.chat` span, but every tool-enabled turn - (i.e. essentially every turn) dispatches through `chat_with_tools()` (`llm.chat_with_tools`) - instead — a span name `WATCHED_SPANS` never matched. The bare `llm.chat` span still fires from - several *auxiliary* call sites within the same turn (MARCH self-check, compaction probe, magic - docs, background learning, session digest, heuristic promotion), so whichever of those closed - last silently overwrote the correct manually-timed `chat_with_tools` duration with its own, - typically much smaller one. Fixed by watching `llm.chat_with_tools` instead of `llm.chat` - (mirroring the existing `persist_message_ms` exclusion rationale from #6111) and accumulating - (rather than overwriting) its duration across the multiple `chat_with_tools` calls a single - multi-round tool-loop turn can make. `llm.chat_with_tools` also fires from concurrent - in-process sub-agents and the scheduler's `RunInline` inline tool loop, neither of which - wraps the call in the main turn's `llm.turn_call` span — the bridge now scopes the `LlmChat` - field strictly to spans nested under `llm.turn_call`, so a sub-agent's or scheduler task's - own `chat_with_tools` timing can no longer inflate or corrupt the main turn's `llm_chat_ms` - (#6275, review follow-up). -- **TUI/observability**: the `bg: N enrich, M telem` background-task status segment only - refreshed at the top of the next turn, so it stayed stale/invisible for the entire idle window - after a turn's response was sent — exactly when background enrichment/telemetry extraction - (spawned from `persist_message`) is actually running. Added a periodic `bg_metrics_tick` - (`LoopEvent::BgMetricsTick`, 2s interval) to the existing `Agent::next_event` `tokio::select!` - loop that reuses `reap_background_tasks_and_update_metrics` between turns, so the TUI now - reflects real in-flight background work continuously. No new `tokio::spawn`: the tick rides - the agent's own already-supervised event loop and is lazily constructed on first use since - `tokio::time::interval` requires an active Tokio runtime that plain-`#[test]`-constructed - agents do not have. Uses `tokio::time::interval_at` to defer the first tick by a full interval - rather than firing it immediately at construction — a plain `interval(..)` would have raced - the pre-existing channel-closed/shutdown `select!` arms on every agent startup, occasionally - forcing one spurious extra loop iteration before a closed channel was observed (#6279). -- **CLI**: `--init` / `--migrate-config` were documented as top-level flags everywhere (both - `CLAUDE.md` files, `.zeph/zeph.md`, `crates/zeph-config/AGENTS.md`, the worktree-disk-quota - playbook, and `src/cli.rs`'s own doc comments) but only existed as `clap` subcommands (`zeph - init`, `zeph migrate-config`), so any session following the documented syntax hit a clap - "unexpected argument" error. Added `--init`, `--migrate-config`, `--in-place`, and `--diff` as - top-level `Cli` flags, coexisting with the unchanged `init`/`migrate-config` subcommands (same - pattern already proven by `--vault`/`vault`) and routed in `runner.rs` to the identical handler - functions the subcommand arms call — no duplicated business logic. `--in-place`/`--diff` now - `requires = "migrate_config"`, so using them without `--migrate-config` is a clean clap error - instead of silently falling through to the interactive agent (#6277). -- **TUI**: the task registry panel (`/tasks` or the `t` key) always showed "supervisor not - available" on the default `--tui` launch path. The two-phase/early-start TUI startup - (`run_tui_agent` in `src/tui_bridge.rs`) forwarded the cancel signal and metrics channel - into the running `App` via `AgentEvent`, but never the `TaskSupervisor` handle — only the - legacy (dead-in-practice) startup path wired it correctly via `App::with_task_supervisor`. - Fixed by adding `AgentEvent::SetTaskSupervisor` and forwarding it alongside the existing - `SetCancelSignal`/`SetMetricsRx` sends. Also wired the `t` keybinding to the previously - unreachable `Action::ToggleTaskPanel` (#6276). -- **Security (auth)**: `AuthConfig::new` (`zeph-common`'s shared bearer-auth middleware, used - by `zeph-gateway`, `zeph-a2a`, and `zeph serve-sessions`) hashed a configured token without - checking for emptiness, so a vault-resolved secret that resolved to `""` produced a valid - `Some(blake3::hash(b""))`. `auth_middleware` defaults a request's submitted token to `""` - whenever no `Authorization` header is present, so that empty-vs-empty hash comparison - matched — silently authenticating every unauthenticated request instead of rejecting them - or letting `require_auth` reject them outright. Fixed by treating an empty token the same as - `None` in `AuthConfig::new`, checking non-emptiness (not `is_some()`/`is_none()`) at the - `zeph-gateway` startup-warning and `zeph serve-sessions` bind-refusal guard call sites, and - skipping the `ZEPH_A2A_AUTH_TOKEN`/`ZEPH_GATEWAY_TOKEN` vault-hydration assignment in - `zeph-core`'s config resolver when the resolved secret is an empty string (#6268). -- **Security (ACP auth)**: `resolve_acp_auth_clients` pushed a `[[acp.auth_clients]]` entry's - vault-resolved token straight into the client list with no non-emptiness check, so a vault - secret resolving to `""` produced an `AcpClientToken` with `token: ""`. `zeph-acp`'s bearer - middleware hashes tokens independently of the shared `AuthConfig` primitive, so it was not - covered by the `AuthConfig::new` fix above: a request with `Authorization: Bearer ` (empty - presented token) hashed to `blake3::hash(b"")` and matched the empty-token client. Fixed by - treating an empty/whitespace-only vault-resolved token the same as a missing one (warn and - skip) in `resolve_acp_auth_clients`, plus a defense-in-depth filter in `zeph-acp`'s - `BearerAuthLayer::new` that drops any client constructed with an empty or whitespace-only - token regardless of caller. Additionally, when *every* configured `auth_token`/ - `auth_clients` entry fails to resolve (leaving the client set empty), `resolve_acp_auth_clients` - now fails startup instead of silently falling back to the empty-list state that - `zeph_acp::transport::router` treats as intentionally unauthenticated — a declared-but- - unresolvable auth configuration must never downgrade to "fully public" (#6270). -- **Orchestration**: `PlanVerifier::verify()` judged per-task completion purely from the - sub-agent's narrated output text, with no cross-check against the real `ToolUse`/`ToolResult` - evidence already recorded for the task — a cheap `verify_provider` could rate a purely - narrated completion (e.g. "I ran `cargo test` and it passed", with no real tool call behind - it) as `complete: true`, silently accepting a hallucinated task completion (#6278). Fixed by - adding a deterministic grounding stage: the verify LLM response now deserializes into a - `VerifyResponse` DTO carrying a `claimed_executions: Vec` field (`": "` - entries the narration claims occurred), and a new pure `ground()` function cross-checks every - claim against the task's real tool-call trace (tool-name match plus bidirectional - normalized-command-substring containment) before projecting the grounded result into the - existing `VerificationResult` — `VerificationResult` itself gains no new field. An unmatched - claim on an available trace forces `complete = false` with a `Critical` gap, regardless of the - LLM's own verdict; an unavailable trace (transcript read failed) fails open on grounding - specifically, never spuriously replanning honest work. Applies uniformly to both the spawn - dispatch path (trace read from the sub-agent transcript) and the `RunInline` path (trace - collected in-loop) and to the ensemble-merge path (spec 073), which grounds the union of - `claimed_executions` across all responded members as a stage after `merge()`. No new config — - grounding is always-on whenever `verify_completeness = true`. See - `specs/009-orchestration/spec.md` § "Verifier Tool-Call Grounding" for the full contract. - The spawn-path trace read now uses a new strict `TranscriptReader::load_strict` (fails - closed the moment any transcript line is skipped) instead of the lenient `load`, so a - torn/malformed line from a canceled sub-agent can no longer masquerade a partial trace as a - complete one and false-positive an honest claim. -- **Durable execution**: `zeph_durable::retention::DurableRetentionService` (the periodic - background prune sweep documented in spec-064 "Retention & Compaction") was never - instantiated outside its own doctest — the only production prune path was the manual - `zeph durable prune` one-shot CLI subcommand, so a running deployment's `durable.db` - journal grew unbounded regardless of `[durable.retention]` TTLs. Now spawned via - `TaskSupervisor::spawn` (task name `durable.retention_sweep`, `RestartPolicy::Restart` - with exponential backoff) alongside the `JournalWriter` actor, at every production - call site that already opens a durable backend: the shared `open_durable_backend` helper - (`crates/zeph-core/src/agent/durable_bootstrap.rs`, covering both the P1 agent-turn and - P2 orchestration adapters) and the P3 scheduler daemon's `build_durable_adapter` - (`src/commands/scheduler_daemon.rs`). Gated by the same per-adapter conditions that already - guard backend construction at each call site — `durable.enabled && (durable.agent_turns || - durable.orchestration)` for P1/P2 (each adapter checks its own flag before invoking the - shared helper), `durable.enabled && durable.scheduler` for P3 — no new config surface was - added (#6264). -- **Docs**: fixed 11 stale Claude model ID examples across 5 mdBook pages (`acp.md`, - `sub-agents.md`, `experiments.md`, `wizard.md`, `configuration.md`) that still used the - outdated `claude-sonnet-4-5`/`claude-sonnet-4-20250514` naming (incorrectly pairing the - Sonnet 4.5 generation name with the Sonnet 4 base-release date) — missed by the #5901/#5902 - sweeps, which only covered `book/src/advanced/context.md`. Replaced with the dateless - `claude-sonnet-5` convention established there. Documentation-only change, no source code - modified (#6147). -- **Docs**: fixed stale `claude-opus-4-5` model ID references in `book/src/advanced/acp.md` - (4 occurrences in code examples and configuration tables), updated to `claude-opus-4-8` - for consistency. Documentation-only change (#6211). -- **Security (shell sandbox)**: `ShellExecutor::resolve_context`'s no-`cwd_override` fallback - branch started the subprocess from the raw, unvalidated `std::env::current_dir()` with no - check against `allowed_paths`, while the `cwd_override` branch already canonicalized and - validated. When `allowed_paths` was configured non-empty and the real process cwd fell - outside every allowed root (a non-default launch/config), commands whose file references - were all bare filenames or absent (`ls`, `pwd`, `cat foo` — bare filenames are not extracted - as path tokens by `extract_paths`) could read/list outside the intended sandbox. Fixed by - clamping the fallback cwd into the sandbox (first allowed root, preferring a directory entry) - whenever the canonicalized process cwd is outside `allowed_paths`, reusing the shared - `zeph_common::security::is_path_within` validator; absolute path tokens in commands were - never affected (already canonicalized and rejected by `validate_sandbox_with_cwd`). Clamping - rather than rejecting avoids turning common bare commands into hard sandbox-violation errors - (#6208). -- **Security (shell sandbox)**: `ShellExecutor::spawn_background` was a second, parallel - production-reachable path for starting a backgrounded shell command that bypassed - `resolve_context` entirely — it validated against the raw `std::env::current_dir()` - (`validate_sandbox`) and spawned via `run_background_task`/`build_bash_command` without - setting `current_dir` on the child, so it never inherited the `allowed_paths` clamp fixed - in #6208 for the structured tool-call path. Audit confirmed zero production callers (the - agent's `bash` tool only ever reaches `execute_tool_call` -> `resolve_context` -> - `spawn_background_with_context`), so this was a structural footgun rather than a live - vulnerability. Converted `spawn_background` into a `#[cfg(test)]`-gated thin wrapper over - `resolve_context` + `spawn_background_with_context`, deleted the now-dead - `run_background_task`, and gated `validate_sandbox` (its only non-test caller) `#[cfg(test)]` - too. This makes "exactly one sandboxed subprocess path" a compile-time guarantee — a future - production caller of the old context-less path fails to compile — instead of a convention - (#6217). -- **Dependencies**: `Cargo.lock` had drifted — `rmcp` was pinned to `2.0.0` while crates.io's - latest release within the existing `^2.0.0` manifest range (`Cargo.toml` unchanged) had moved to - `2.2.0`. Updated the lockfile to `rmcp 2.2.0` / `rmcp-macros 2.2.0`; `sse-stream` also bumped to - `0.2.4` (required — rmcp 2.2.0's `transport-streamable-http-client-reqwest` feature path calls - `SseStream::from_bytes_stream`, added in `sse-stream` 0.2.4, which is not present in 0.2.3; - rmcp's own manifest constraint of `sse-stream = "0.2"` under-specifies this, so a plain - `cargo update -p rmcp` alone resolves to a broken combination — tracked upstream separately). - Picks up rmcp's 2.1.0/2.2.0 fixes: reject auth servers lacking S256 PKCE support (2.2.0, #955), - block redirect header leaks (2.1.0, #936), make `AsyncRwTransport::receive` cancel-safe - (#941/#947), fail orphaned streamable HTTP responses on reinit (#914), and negotiate protocol - version in the handler (#930). No source changes required in `crates/zeph-mcp` (#5897). -- **A2A**: `AgentRegistry` (JWS Agent Card signature verification + `card_trust_policy`, - #5928) had no runtime construction site anywhere outside `crates/zeph-a2a`'s own tests — - setting `[a2a_client].card_trust_policy = "require"` had no effect on a running agent. - Wired `AgentRegistry::discover` into `zeph --connect ` (`src/tui_remote.rs`): the - peer's card is now fetched and its signature/URL-origin trust policy enforced before the - SSE session is established, using an explicit (no-wildcard-by-name) conversion from - `zeph_config::channels::CardTrustPolicy`/`TrustedAgentKey` to their `zeph-a2a` - counterparts. The discovery fetch is hardened with the same `require_tls`/ - `ssrf_protection` posture and DNS-rebinding-safe address pinning already applied to the - `A2aClient` connection to the same URL. Fixes a bug where the discovery URL was - constructed from the full `--connect` target (including its RPC path, e.g. - `/a2a/stream`) instead of the origin root where `/.well-known/agent.json` is actually - served. A discovery-fetch failure (peer serves no card, network error, timeout) only - aborts `--connect` when `card_trust_policy = "require"`; under the default `ignore` (and - `prefer`) it is logged and tolerated so a peer that serves no agent card at all still - connects, matching pre-#6200 behavior. A trust-check rejection (untrusted signature or - URL-origin mismatch) always aborts regardless of policy, since `check_trust` has already - folded the policy into that verdict (#6200). -- **A2A**: `card_signing::canonical_payload` canonicalized the raw received card JSON - verbatim (`signatures` key removed only), which rejects a genuinely valid card from any - signer that strips proto3-default-valued fields (empty string/`false`/`0`/empty - array/object) before signing per the A2A spec text, while transmitting the card with - those defaults present — a fail-closed availability bug. `canonical_payload` now strips - the same proto3-default fields recursively before JCS canonicalization, so a signature - computed over either shape verifies against the other; covered by a new synthetic - regression test (real `a2a-sdk` interop is still unvalidated — `card_trust_policy` - remains `"ignore"` by default pending a real signed-card vector) (#6201). -- **Docs**: `specs/010-security/spec.md` and `specs/014-a2a/spec.md` described two different IBCT - (Invocation-Bound Capability Token) wire formats, and neither fully matched the actual - implementation in `crates/zeph-a2a/src/ibct.rs`. Reconciled both specs against `ibct.rs` and - `crates/zeph-config/src/channels.rs` ground truth: the token is a single base64-encoded JSON - blob (not a dot-separated triplet) signed over `{key_id}|{task_id}|{endpoint}|{issued_at}| - {expires_at}`, default TTL is 300s (not 60s), and `ibct_keys` is an array of `{key_id, key_hex}` - entries (not a `key_id → vault_ref` map) with `ibct_signing_key_vault_ref` as the separate - vault-resolved primary-key path. Also corrected both specs' claim that IBCT prevents replay - attacks — `Ibct::verify` performs no `invocation_id`/nonce dedup, so a captured, still-valid - token is replayable against the same `task_id` + `endpoint` until it expires; this is now - documented as a known implementation limitation. Documentation-only change, no source code - modified (#6197). -- **Persistence**: `PersistMessageRequest`'s doc comment carried a stale TODO describing an - unimplemented R3 batching design and a pending-request-loss risk that does not exist — - persistence is inline and synchronous today (`Agent::persist_message` builds the request and - immediately awaits `PersistenceService::persist_message`, with no queue/buffer layer). Replaced - the TODO with a doc comment describing the actual inline/synchronous behavior (#5962). -- **Persistence**: removed the dead `PersistMessageOutcome::redaction_applied` field — it was - hardcoded to `false` at all four construction sites in `PersistenceService::persist_message` and - read by nothing, since no redaction logic exists in the service. Breaking change to a `pub` - struct, acceptable pre-v1.0.0 (#5995). - -- **LLM**: the Claude request funnel's no-prefill gate (`ClaudeProvider::structured_history`/ - `plain_history`) was convention-enforced only — `request::split_messages`/ - `split_messages_structured` stayed reachable from anywhere inside `crate::claude`, so a new - request-construction path added directly in `mod.rs` (or any sibling file) could call the raw - split functions and skip the no-prefill strip, silently reintroducing the bug class fixed by - #5903/#6145/#6146/#6154. Introduced `GatedStructuredHistory`/`GatedPlainHistory` newtypes with - a private inner `Vec`, constructible only via `structured_history`/`plain_history`. - `RequestBody`, `ToolRequestBody`, `VisionRequestBody`, and `TypedToolRequestBody`'s `messages` - field now require the gated type instead of a bare `Vec`/slice, so bypassing the funnel is a - compile error (wrong type) instead of a code-review catch. Pure refactor — wire format is - byte-identical (verified via existing insta snapshots) (#6158). -- **Core**: durable sub-agent replay (`handle_agent_spawn_foreground`) fired its channel side - effects — the "replayed from durable journal" user notice and the TUI completion event — as - plain awaits outside any dedup guard. A parent that restarted more than once after taking the - replay branch would re-fire both side effects on every subsequent restart. An initial fix - gated them behind a `ctx.step()` created only on the replay path, but that step consumed a - durable step id on replay runs only, shifting every subsequent step id relative to the fresh - run's journal and causing a hard `ReplayDivergence` abort whenever another durable step - (e.g. an LLM turn) followed the spawn in the same execution — a regression, not a fix. - Replaced it with an out-of-band `notified_at` claim column on `durable_promises` (migration - 109, sqlite+postgres): the first caller to win a conditional - `UPDATE ... WHERE notified_at IS NULL` fires the side effects, every later replay is - suppressed. The claim consumes zero durable step ids, so it cannot perturb step-id - determinism (INV-2) or cause `ReplayDivergence` under any restart count (#6027). - -- **CI**: the `registry` feature (`zeph-plugins/registry`, spec-045) was declared in root - `Cargo.toml` and bundled into `full`, but excluded from every PR-gating CI job's feature - string — `lint-clippy`, `msrv`, `build-tests` (and by extension the sharded `test` job that - consumes its archive), `rustdoc`, and `release-build` in `.github/workflows/ci.yml` all built - without it. Only the post-merge `coverage` job and the compile-only `bundle-check` matrix - (via `full`) ever touched it, so a regression in the marketplace/skills.sh registry client - (`crates/zeph-plugins/src/marketplace/skills_sh.rs`, wiremock-mocked HTTP tests) could merge - to `main` without a single PR-gating job catching it (#6176). Added `registry` to the - feature strings at all five call sites above; `.claude/rules/branching.md`'s documented - local commands are updated to match so they still mirror CI exactly. -- **Rustdoc**: enabling `registry` in the `rustdoc` CI job (above) surfaced two pre-existing - rustdoc lint failures, both in code documented for the first time by that job: - - `crates/zeph-plugins/src/marketplace/skills_sh.rs`'s module-level doc comment linked to - `` [`SkillSummary`] `` and `` [`parse_files`] ``, both genuinely private items — the paths - resolve fine, but `rustdoc::private_intra_doc_links` rejects a public doc comment linking to - a private item. Both are correctly private (internal deserialization details, not public - API), so the fix drops the link brackets and keeps plain inline code instead of qualifying a - path or widening visibility. - - `crates/zeph-plugins/src/marketplace/mod.rs:86`'s doc comment contained the literal strings - `` and `` outside of backticks, which `rustdoc::invalid_html_tags` - misparses as unclosed HTML tags. Wrapped both in backticks so they render as inline code - instead. - (#6176) -- **zeph-worktree**: `WorktreeManager::create()` enforced `config.max_worktrees` with a - check-then-act sequence that was not atomic — no lock was held across the `reconcile()`/ - `git worktree add` `.await`s between the quota read and the final in-memory registration, - so two concurrent in-process `create()` calls could both pass the `current >= max` check and - both proceed, silently exceeding `max_worktrees`. Added an internal `admission_lock: - tokio::sync::Mutex<()>`, held across the full quota-check-through-registration sequence, - making in-process admission a hard guarantee. The existing cross-process soft cap (no locking - across separate zeph sessions sharing the same `root`) is unchanged (#6250). -- **Durable execution**: `finalize(Completed/Failed)` was never called in production — every - consumer (`zeph-orchestration`'s budget journal, `zeph-scheduler`'s job fire, `zeph-core`'s - per-conversation `AgentTurn`) drove steps through the durable journal but never marked the - execution row terminal, so `durable_executions.status` stayed `'running'` forever and the - TTL-based retention prune sweep could never reclaim any row. Wired `finalize(Completed)` on - success and `finalize(Failed)` on unrecoverable step failure into all three production execution - models, plus `finalize(Aborted)` on the hard step-cap (`DurableError::StepCapExceeded`), matching - the already-documented retention contract (`specs/064-durable-execution/spec.md`). Made - `finalize` idempotent against a race with the internal replay-divergence `Aborted` transition, and - made reopening a finalized execution (e.g. a resumed conversation, a same-slot scheduler retry) - automatically un-finalize it back to `running` so the retention sweep can never prune a row that's - actively being reused — closed a TOCTOU window between the prune sweep's candidate selection and - a concurrent reopen by moving the selection inside the same write transaction as the deletes. - Known residual gap, intentionally not addressed here: an execution that ends via an ungraceful - process exit (crash, OOM, `SIGKILL`) still has no reclamation path if never resumed — this needs a - separate periodic staleness-sweep mechanism, tracked in a follow-up issue (#6251). -- **Security (zeph-mcp)**: `name_referenced_in`'s two regex memoization caches - (`crates/zeph-mcp/src/sanitize.rs`) were process-lifetime `static`s keyed by lowercased MCP - tool name with no eviction — since tool names are attacker-influenced (untrusted MCP server - input), a malicious/compromised server rotating its advertised tool names on reconnect or - catalog refresh could grow both caches without bound, leaking memory over the lifetime of a - long-running daemon/gateway/serve process. Replaced both `HashMap` caches with - `lru::LruCache` capped at 256 entries, using `get_or_insert` in place of - `entry().or_insert_with()` — same single-lock-acquisition semantics, no new attack surface - (#6255). - -### Added - -- **A2A**: added optional JWS Agent Card signature verification (A2A 1.0.0 §8.4) and a - `require`/`prefer`/`ignore` trust policy for peer discovery (#5928): - - `AgentCard.signatures: Vec` — new, additive, `#[serde(default, - skip_serializing_if = "Vec::is_empty")]` field; unsigned/0.2.x peer cards round-trip - unchanged (empty on the wire). - - New `card-signing` Cargo feature (`crates/zeph-a2a`, `p256` + `serde_json_canonicalizer`, - pure-Rust, no `openssl-sys`) enabling ES256 (P-256) verification. Off by default; propagated - through the root `a2a` feature (and `full`) alongside the existing `ibct`/`server` features - so CI actually compiles and tests the crypto path. - - `AgentRegistry::with_trust(policy, trusted_keys)` runs a crypto-free URL-origin - (scheme+host+port) consistency check plus signature verification in `discover()`, combining - both axes by taking the most severe outcome (reject > warn > accept) and returning - `A2aError::UntrustedCard` / `A2aError::UrlMismatch`. - - `[a2a_client]` gains `card_trust_policy` (`CardTrustPolicy`, default `ignore` — byte-identical - to prior behavior) and `trusted_agent_keys` (`Vec`, public keys stored - inline, not vault-referenced). `Config::validate()` fails fast if `card_trust_policy = - "require"` is set without the `card-signing` feature compiled in, rather than silently - degrading or bricking discovery. New `ZEPH_A2A_CARD_TRUST_POLICY` env override and - `--migrate-config` step 82 (commented advisory block for existing configs). - - **Known limitations, tracked as follow-ups**: `AgentRegistry` had no runtime construction - site and the JCS canonicalization was unvalidated against a real `a2a-sdk`-produced signed - card — both addressed later in this same `[Unreleased]` section, see the two `A2A` entries - above (#6200, #6201). The well-known discovery path remains `/.well-known/agent.json` - (0.2.x); a pure-1.0.0 peer serving `/.well-known/agent-card.json` is not yet discoverable. - `A2A_PROTOCOL_VERSION` stays `"0.2.1"` — this change is one additive 1.0.0 feature, not full - 1.0.0 conformance. `jku`/JWKS auto-fetch, EdDSA/RS256, and signing our own served card are - all still deferred. -- **Worktree**: added disk-quota and automatic reconciliation to the `zeph-worktree` subsystem - (#5924). Four new `[worktree]` config fields: `max_worktrees` (creation-time admission cap, - enforced as `WorktreeError::QuotaExceeded`), `disk_quota_mb` (soft total-disk-usage threshold), - `auto_reconcile_secs` (periodic reconcile+quota sweep interval via `TaskSupervisor`, `0` = - disabled), and `reconcile_on_startup` (default `true` — one reconcile+quota sweep at bootstrap). - Automatic reclamation only ever removes worktrees git itself reports as `prunable` — an intact - worktree is never force-removed to satisfy either threshold (spec-063 INV-6, extends INV-5). - `WorktreeManager` gains `disk_usage()`/`cached_disk_usage()` (filesystem walk, offloaded to - `spawn_blocking`, never called on the `create()` hot path) and `sweep()` (reconcile + - prunable-only reclaim + quota evaluation). `zeph worktree list` and the agent-side - `/worktree list` slash command both always print a fresh usage/quota summary footer (no - cross-mode divergence). `Config::validate` rejects `Some(0)` for `max_worktrees`/ - `disk_quota_mb`, rejects `disk_quota_mb` being set with no automatic evaluation path enabled - (neither `reconcile_on_startup` nor `auto_reconcile_secs`), and rejects a sub-60-second - `auto_reconcile_secs` (too short an interval would run a filesystem walk in a tight loop). - `--migrate-config` step 83 surfaces the four new fields as commented advisories on existing - `[worktree]` tables; the `--init` wizard gained matching prompts with the same validation. -- **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`, - `tool_execution.rs`/tests, `notifications.rs`, `secrets.rs`, `doctor.rs`, - `compression_guidelines.rs`, the `api-request` skill doc, and A2A - `ZEPH_A2A_IBCT_KEY`/`VAULT_A2A_IBCT_KEY_1` env-var names), none a real - credential. Extends (not replaces) gitleaks' default ruleset via - `[extend] useDefault = true`; allowlist entries match the literal dummy - string so future test fixtures reusing the same established pattern are - also covered, not just the already-known commits. `gitleaks detect` now - exits clean (0 findings) instead of re-surfacing the same 31 hits on every - scan. `SECURITY.md` documents the convention: reuse an existing dummy - pattern in new test fixtures where possible, or add a new allowlist entry - (#6056, #6081). -- **Memory**: wired the five remaining memory-maintenance loops — guidelines - (`mem-guidelines`), tree-consolidation (`mem-tree-consolidation`), hebbian-consolidation - (`mem-hebbian-consolidation`), episodic-consolidation (`mem-episodic-consolidation`), and - optical-forgetting (`mem-optical-forgetting`) — into `src/acp.rs` (`build_acp_deps`), - `src/daemon.rs` (`run_daemon`), and `src/serve/deps.rs` (`spawn_memory_maintenance_loops`), - matching the CLI/TUI path (`src/runner.rs`) and the first five loops wired by #5978. Each new - loop is gated by its own `[memory.*] enabled` config flag, exactly as in `runner.rs`; ACP and - `/sessions*`-created agents pass `None` for the hebbian loop's status sender since neither - entry point has a status-sender handle in scope, while the daemon reuses its own - `status_tx` (#5979). -- **Skills**: `[skills.trust] require_integrity_check_on_promote` (default `true`) automatically - arms the per-invocation BLAKE3 integrity re-check (`requires_trust_check`) whenever a skill is - promoted to `trusted`/`verified`, at both the CLI (`zeph skill trust`) and in-session - (`/skill trust`) promotion handlers. Previously the re-check could only be armed manually via - `--require-check`, so an operator who forgot the flag left a promoted skill's tampered-on-disk - `SKILL.md` undetected between promotions (#6087). `--require-check`/`--no-require-check` - (mutually exclusive) continue to override the config default per command; promotion to - `quarantined`/`blocked` leaves `requires_trust_check` untouched. A migration step (80) adds a - commented advisory for the new key to existing configs that already declare `[skills.trust]`. - 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`/ - `SanitizeContext` abstraction. It had zero production consumers — `ContentSanitizer` - (`sanitizer.rs`) implements its own inline sequence of steps (truncate, strip, detect, - escape, spotlight) as ordinary method calls and never used `Pipeline`/`add_stage` (#5911). - No `pub use` re-export existed and no other crate in the workspace referenced these types, - so removal is a pure dead-code cleanup with no behavior change. - -### Fixed - -- `crates/zeph-vault/src/age.rs`: `AgeVaultProvider::set_secret_mut` silently overwrote an - existing secret with no confirmation, diff, or backup — the same defect class as the - `ZEPH_DURABLE_KEY` incident fixed for the `zeph init` wizard in #5880/#5874, but one layer - down in the vault crate itself, so every caller (CLI, wizards, OAuth credential store) - inherited the same silent-overwrite risk (#5955). `set_secret_mut` now takes an explicit - `overwrite: bool` and returns `AgeVaultError::AlreadyExists` when a key already exists and - `overwrite` is `false`, leaving the previous value untouched. `zeph vault set ` - gained a `--force` flag: without it, attempting to overwrite an existing key fails with a - clear error telling the operator to re-run with `--force`; the previous secret value is never - printed, only its presence. Call sites that intentionally always overwrite (OAuth token - refresh in `src/bootstrap/oauth.rs`, and `zeph init`'s durable-key wizard step, which already - gates rotation behind its own explicit "rotate" confirmation phrase) pass `overwrite: true` - explicitly. -- `src/commands/skill.rs`/`src/commands/plugin.rs`: `zeph skill search`/`get` and - `zeph plugin search`/`get` printed the correct, actionable FR-004 message - (`REGISTRY_NOT_CONFIGURED_MSG`) to stdout when the registry is disabled, then - immediately failed with a second, differently-worded `anyhow::bail!` on stderr — and - for the plugin subcommands that second message incorrectly said "skill registry" - instead of "plugin registry" (#5943). All four call sites now reuse - `REGISTRY_NOT_CONFIGURED_MSG` for the bail as well, so stdout and the error both show - the same, subsystem-neutral, actionable message. -- `crates/zeph-core/src/agent/tool_execution/tool_result.rs`: the `reasoning_amplification` - anomaly (`AnomalyOutcome::ReasoningQualityFailure`, arXiv:2510.22977) fed `self.provider.name()` - — the provider *instance* name (e.g. `"openai"`, or a custom `[[llm.providers]]` name) — into - `is_reasoning_model()`, which pattern-matches *model identifiers* (`o3-mini`, `deepseek-r1`, - `claude-*-think`, ...). Instance names never match those patterns, so the branch was permanently - unreachable (#5909). Both the detection call and the stored `model:` field now use - `self.provider.model_identifier()`. Also added a missing `model_identifier()` override on - `GeminiProvider`, which fell back to the trait default (`""`) for the same reason. -- `crates/zeph-llm/src/provider.rs`: `RouterProvider`, `TriageRouter`, and `CandleProvider` - still fed a value that could never match `is_reasoning_model()`'s patterns into the - `reasoning_amplification` anomaly check fixed by #5909 above — `Router`/`TriageRouter` - hardcode the stable routing-policy label `"router"`/`""` from `model_identifier()`, and - `CandleProvider` had no override at all, so detection stayed permanently unreachable for - these three providers, the same defect class one call site removed (#6182, follow-up - #6183). Added `LlmProvider::effective_model_identifier()` (defaults to - `model_identifier()`) and switched the `tool_result.rs` call site to it: `RouterProvider` - and `TriageRouter` override it to resolve the sub-provider that actually served the most - recent dispatch (reusing the existing `last_active_provider`/`last_provider_idx` state - already read by reputation attribution), and `CandleProvider` gets a genuine static - `model_id` field derived from its `ModelSource` (`repo_id` for `HuggingFace` sources, file - stem for `Local` sources). `MaskedProvider` (the outbound-secret-masking wrapper applied to - every provider by default) also needed an explicit override forwarding to its inner - provider's `effective_model_identifier()` — without it, a masked Router/TriageRouter - (the structural default configuration) silently fell back to the trait default and the fix - never took effect. -- `src/commands/db.rs`: `zeph db migrate` fell back to the raw, unredacted database URL - in error messages and the migration-status line whenever `redact_url` returned `None` - — which only means the URL didn't match a recognized credential-bearing shape, not that - it's credential-free (#6026). All three call sites now fall back to the literal - `"[redacted]"` marker, matching the existing safe pattern in - `crates/zeph-db/src/pool.rs::connect_postgres`. -- `src/commands/migrate.rs`: `zeph migrate-config` operated on the raw TOML document and - never validated the result against the strict `Config` schema, so an invalid existing - value (e.g. an unrecognized `vault.backend`) silently survived migration while real - startup (`Config::load`, hardened by #6025) would reject it (#6038). `migrate-config` - now attempts to deserialize the migrated document into `Config` and prints a warning - with the underlying error if that fails, without turning the migration itself into a - hard failure — its job remains adding missing keys, not fixing preexisting invalid - values. -- `src/cli.rs`/`src/runner.rs`: `--tui` silently fell through to plain CLI mode with zero - diagnostic when the binary was compiled without the `tui` feature, since `Cli::tui` was - never feature-gated but every consumer of it was (#6016). Added - `warn_if_tui_requested_but_unavailable`, mirroring the existing - `warn_if_acp_enabled_but_unavailable` precedent but returning a hard error instead of a - warning, since silently falling back to a different mode than requested is a materially - different UX. -- `src/serve`: boxed all 17 `build_agent_factory(...).await` call sites in `agent_factory.rs` - and `handlers.rs` with `Box::pin(...)` to resolve `clippy::large_futures` (16456-byte future, - over the 16384-byte threshold) that only triggered on macOS/aarch64 due to platform-specific - stack layout differences (#6163). Same fix pattern as #3521: move the large future onto the - heap rather than tuning its size. No behavior change. -- `src/acp.rs`, `src/serve/agent_factory.rs`, `src/serve/test_support.rs`: boxed 3 new - `build_combined_deps(...).await` call sites with `Box::pin(...)` that #6169 added unboxed, - reintroducing `clippy::large_futures` after #6168 had already closed the same lint class - (#6175). Same fix pattern as #6168/#3521. No behavior change. -- `crates/zeph-sanitizer/src/sanitizer.rs`: `with_classifier_metrics`'s doc comment linked - `[`ClassifierMetrics`]` unqualified, which rustdoc could not resolve since the type - (`zeph_llm::ClassifierMetrics`) is never imported unqualified in this module, breaking the - `rustdoc::broken_intra_doc_links` gate (#6177). Changed the link to the fully-qualified path - `[`ClassifierMetrics`](zeph_llm::ClassifierMetrics)`. -- `crates/zeph-durable/src/handle.rs`: `DurableContext::checked_step_id` and `run_step_at` - repeated the identical per-execution step-cap condition verbatim. Extracted into a single - `enforce_step_cap` helper called from both sites (#6082). Pure refactor, no behavior change. -- `crates/zeph-config/src/migrate/infra.rs`: `--migrate-config`'s `migrate_durable_shared_db` - step silently left an unsafe combination undetected — `durable.encrypt_payload = false` with - `shared_db` unset passes the INV-8 `encryption_gate`'s local-only override even when the - durable journal database actually lives on a network-shared mount, since `shared_db` is - purely operator-declared and cannot be inferred from the filesystem (#6042). The migration - step now emits a `tracing::warn!`/stderr warning (matching the existing - `migrate_llm_to_providers` warning convention) asking the operator to confirm their - deployment topology whenever this combination is detected. Filesystem-type detection and - path-prefix heuristics remain explicitly out of scope, deferred to a future issue. -- `crates/zeph-worktree/src/manager.rs`: `WorktreeManager::create()` redundantly - re-canonicalised the worktree root (`spawn_blocking` + `create_dir_all` + two - `canonicalize` syscalls) on every call even though it is identical to the value - `WorktreeManager::new()` already validated once at construction and discarded - (#5940). `new()` now caches the canonicalised root on `self` and `create()` reuses - it directly, removing the redundant blocking round-trip from the subagent-spawn hot - path. Also reordered `crates/zeph-worktree/src/sanitize.rs::canonicalize_root` to - validate containment against the nearest existing ancestor before calling - `create_dir_all`, so a configured root that resolves outside the repository is - rejected without mutating the filesystem first. -- **Sub-agents**: `WorktreeManager::reconcile()`'s admission-quota count included every - git worktree registered to the repository (`git worktree list --porcelain`), not just - ones the subagent worktree subsystem itself created — so worktrees created by - unrelated tooling (including this project's own `EnterWorktree` workflow) counted - against `max_worktrees` and could silently trip `QuotaExceeded` on a background - `/agent bg` spawn. Three compounding gaps then turned that single failure into a - permanently stuck task: the error was never logged, `TaskSupervisor::spawn_oneshot` - classified a failed inner `Result` as a normal completion, and the task's status - channel was never updated past its initial `Submitted` state, so `poll_subagents()` - could never collect it — leaking one `max_concurrent` slot per occurrence (#6257). - Fixed by: scoping `reconcile()`'s counted entries to the subsystem's own worktree - root; logging `WorktreeError` at `warn` in `create()`; adding - `CompletionKind::Failed` and a generic `TaskSupervisor::spawn_oneshot_classified` - that inspects the inner `Result` (subagent spawns now classify via `Result::is_ok`; - the existing `spawn_oneshot` and its ~20 call sites are unaffected); and sending a - terminal `Failed` status from all three fallible pre-loop setup points in - `spawn()`'s task closure (`wm.create()`, `CwdRestoreGuard::new()`, - `CwdRestoreGuard::acquire()`) so a setup failure is now visible via `/agent - list`/`/agent status`, its concurrency slot is released, and the real error is - retrievable via `collect()`. - -### Testing - -- `src/acp.rs`/`src/daemon.rs`: the memory-maintenance-loop regression tests - (`acp_memory_maintenance_loops_registered_on_connection_supervisor`, - `daemon_memory_maintenance_loops_registered_on_mem_supervisor`) reconstructed the - spawn blocks inline with a mock `TaskSupervisor` instead of calling the real - production wiring path, so a broken or inverted `config.memory.*.enabled` guard in - `build_acp_deps`/`run_daemon` would go undetected by either test (#6170). Extracted - the wiring into standalone `spawn_acp_memory_maintenance_loops`/ - `spawn_daemon_memory_maintenance_loops` functions, mirroring the existing - `spawn_memory_maintenance_loops` pattern in `src/serve/deps.rs` (#5979); both tests - now call the extracted functions directly. No behavior change — all ten loops still - spawn identically, gated the same way, via `TaskSupervisor::spawn`. -- `zeph-llm`: reduced the discoverability of bypassing the Claude no-prefill funnel introduced - by #6154, and closed a real HTTP-level coverage gap (#6155, #6156). `split_messages`/ - `split_messages_structured` in `crates/zeph-llm/src/claude/request.rs` are now - `pub(in crate::claude)` instead of `pub(super)`. In the current module nesting (`request` is - a direct child of `claude`, i.e. `mod.rs`) the two are functionally identical — Rust has no - way to grant a child module's item to its parent while excluding the parent's other children, - so this is a self-documenting anchor to the intended boundary, not a new compile-time barrier; - `claude::tests` remains exactly as reachable as before in raw visibility terms. A true - compile-error barrier is architecturally impossible while the no-prefill funnel - (`structured_history`/`plain_history`) lives in the parent module (`claude`/`mod.rs`) — a new - request-construction path added there calling the raw split functions directly would still - compile fine, so this remains a code-review catch rather than a compiler error. The actual - hardening is that the ~22 pure message-conversion tests (tool-use pairing, cache breakpoints, - image blocks, thinking/redacted-thinking blocks, compaction round-trip) that used to call - these functions directly moved from `claude/tests.rs` into a same-file `mod tests` inside - `request.rs`, so `claude::tests` no longer imports or calls them — removing the habit/ - discoverability path a future author would reach for, not adding a compiler-enforced one. A - genuine compile-time guarantee (a newtype only constructible via `structured_history`/ - `plain_history`, so a request body cannot accept an ungated history) is tracked as a - follow-up: #6158. Separately, `ClaudeProvider`'s Messages API - base URL is now an injectable field (`api_url`, defaulting to the real `API_URL` in - production) with a `#[cfg(test)]`-only `with_api_url` override, replacing a dead hand-rolled - TCP mock server in `claude/tests.rs` that had given up on HTTP-level assertions ("We can't - override API_URL from outside"). New `wiremock`-backed tests drive `chat_with_tools`, - `chat_with_tools_stream`, and `chat_typed` through a real (mocked) HTTP round-trip and assert - the no-prefill gate strips a trailing assistant message in the request actually sent over the - wire, closing the gap where prior coverage only exercised the identical body-construction - sequence via `debug_request_json` as a same-order proxy. -- `zeph-worktree`: added a regression test confirming `WorktreeManager::remove()`'s single - `git worktree remove --force` still refuses a `git worktree lock`-ed worktree (requires - `-f -f`), the second safety layer alongside the `prunable`-gating from #6076 — previously - only confirmed manually against real git 2.50.1 (#6077). -- `src/commands/worktree.rs`: added an end-to-end test calling the real `handle_worktree_command` - handler (not a reimplementation of its skip-gating logic) against a real git repo with a - mixed stale list (N>1 prunable + M>1 in-use entries in the same run) (#6077). -- `zeph-worktree`: added `FakeGitRunner`-backed unit tests for the new shared - `WorktreeManager::clean`/`format_clean_summary` covering the mixed prunable/non-prunable - case, the `--force` bypass, a `remove()` failure being counted as `errored`, and — the - exact divergence caught in #6141's review — a final `prune()` failure recording a warning - without discarding an already-successful removal count (#6142). -- `zeph-core`: added a live-`SubAgentManager` fixture test suite for `Agent:: - handle_worktree_list_as_string`/`handle_worktree_clean_as_string` (`crates/zeph-core/src/ - agent/worktree_commands.rs`) — a real `DefaultWorktreeManager` over a temp git repo wired - into a real `SubAgentManager` via `set_worktree_manager`, exercising the live-manager - `Some(wm)` branch end to end (0/1/N active worktrees, mixed prunable/in-use stale lists, - `--force` reaching `WorktreeManager` with correct semantics). Previously only the - disabled-subsystem `None` short-circuit had coverage, via `zeph-commands`' - `NullAgent`-backed tests (#6142). -- `tests/workspace_lints.rs`: added a guard test asserting - `workspace.lints.rust.linker_messages` stays `"allow"` in root `Cargo.toml`, closing a CI - blind spot where an accidental removal would only surface on a manual `ci-non-linux.yml` - macOS/arm64 run or the next tagged release build, not on any automated per-PR check (#5961). - -### Changed - -- **Architecture**: `zeph-agent-context` — refactored the graph-retrieval-strategy call - chain in `helpers.rs` (`dispatch_graph_strategy`, `run_graph_strategy`, - `run_synapse_strategy`, `run_hybrid_strategy`, `recall_by_classified_strategy`, - `fetch_semantic_recall_raw`, `append_graph_facts`) away from the "parameter bag" - anti-pattern — each function threaded 8-14 positional arguments and individually - suppressed `clippy::too_many_arguments`. Introduced `GraphStrategyParams` (per-call - query state), `GraphRecallConfig` (shared immutable config), `GraphRecallBudget` - (mutable token-budget accumulator), and `SemanticRecallRawParams`, grouped by - mutability/ownership rather than bundled arbitrarily. Removed all six - `clippy::too_many_arguments` allows and the crate's `#![recursion_limit = "256"]` - override, which the refactor's reduced Future-nesting depth no longer requires. Pure - internal signature refactor — no behavior change (#5992). -- **DRY**: consolidated the four near-identical memory-maintenance-loop spawn blocks in - `src/runner.rs` (CLI/TUI, previously inline), `src/acp.rs` (`build_acp_deps`, previously - inline), `src/daemon.rs` (`run_daemon`, previously inline), and `src/serve/deps.rs` - (`spawn_memory_maintenance_loops`) into a single shared - `agent_setup::spawn_memory_maintenance_loops`, now called by all four entry points (#6180). - The function takes `status_tx: Option<&UnboundedSender>` to unify the ACP/`/sessions*` - (`None`) vs. CLI/TUI/daemon (`Some`) difference in the hebbian-consolidation loop's status - sender, and a `skip_eviction: bool` preserving `runner.rs`'s pre-existing `--bare` gate, which - only ever applied to the eviction loop. The `acp.rs`/`daemon.rs` unit tests previously - reconstructed a hand-written copy of the production spawn block (unlike `serve/deps.rs`'s - test, which already called the real function); both now call the shared production function - directly via `AppBuilder::for_test`, closing a test-realism gap left by #6170. No behavior - change for any entry point. -- **DRY**: `zeph-mcp` — extracted `McpManager::commit_pending` to replace the near-identical - `commit_connect_outputs`/`commit_oauth_outputs` pair in `manager/connect.rs`, and - `finish_connect` to replace the handler-build + timeout-wrapped-handshake + error-classify - scaffolding duplicated across all five `McpClient` connect paths (`connect`, `connect_url`, - `connect_url_with_headers`, `connect_url_oauth`'s cached-token branch, `complete_oauth`) in - `client.rs` (#6070, #6065). Pure internal refactor, no observable behavior change: the - never-hold-a-lock-across-an-`.await` invariant is preserved on every path, and - `finish_connect` now routes every site through `classify_connect_error` uniformly (verified - byte-for-byte equivalent to the prior inline mapping used by the stdio `connect` path). One - intrinsic side effect of unifying two helpers that committed `server_tools` and - `server_fingerprints` in opposite relative orders: `commit_pending` adopts the OAuth path's - order (`fingerprints` then `tools`) on `connect_all` too, where it was previously reversed. - Both are independent `RwLock`s never held simultaneously by any code in the module, so this - ordering swap has no observable effect. - -- **DRY**: extracted the `zeph worktree clean` reconcile → remove → prune pipeline and its - removed/skipped/errored counting into a single `WorktreeManager::clean` method plus a - `format_clean_summary` free function (`zeph-worktree`), now shared by both the CLI - (`src/commands/worktree.rs`) and the agent-side `/worktree clean` slash command - (`crates/zeph-core/src/agent/worktree_commands.rs`) (#6142). These two call sites - previously duplicated the same loop independently, which already caused a real bug during - #6141's review (the agent-side path originally discarded the `prune()` failure instead of - reporting it, diverging from the CLI's warn-and-continue behavior). Only the `--force` - hint text in the skip warning still differs between the two surfaces (`force_hint` - parameter), since that's the one piece of legitimately surface-specific UX. - -- **BREAKING**: `zeph-db`'s `DbConfig` collapses `max_connections` and `pool_size` into a - single `pool_size: u32` field, used as `sqlx`'s `.max_connections()` for both `SQLite` and - `PostgreSQL` (#5970). Previously `pool_size` was documented "`SQLite` only" but was actually - what `connect_postgres` passed to `PgPoolOptions::max_connections()` (`max_connections` was - never read under Postgres), and `SQLite` combined the two fields as - `max_connections.max(pool_size)` — the larger value won, not a cap. Every call site already - set both fields to the same value by convention; this removes the redundant, contradictory - field. All in-tree `DbConfig` construction sites (`zeph-scheduler`, `zeph-memory`, `zeph-mcp`, - `zeph-durable`, `zeph-orchestration`, `zeph-index`, `src/bootstrap/mod.rs`, - `src/commands/db.rs`) are updated; `max_connections` was never exposed as a user-facing - `config.toml` key, so no config migration step is needed. -- `refactor(common)`: deduplicated the near-identical `Arc`-backed newtype boilerplate in - `ToolName`, `ProviderName`, and `SkillName` (`crates/zeph-common/src/types.rs`) — each - independently reimplemented the same ~115-line block (`Default`, `Display`, `AsRef`, - `Borrow`, `From<&str>`, `From`, `FromStr`, and 5 hand-written `PartialEq` - directions) — behind a private `macro_rules! arc_str_newtype!`, parameterized per type via - captured doc-comment attributes so each type keeps its own tailored rustdoc and doctests - (#5927). `ProviderName`'s `is_empty`/`as_non_empty` empty-sentinel helpers stay in a separate - hand-written `impl` block, unchanged. Pure refactor, no behavior change. - -### Fixed - -- `zeph-config`/`zeph-a2a`/`zeph-mcp`: `IbctKeyConfig`, `IbctKey`, and `McpTransport` derived - `Serialize` unredacted, so any future `serde_json`/`toml::to_string`/log/status/ACP path - serializing one of these types would have leaked the raw secret even though their `Debug` - impls were already redacted by #6005 (#6006). Replaced the derived `Serialize` on all three - with hand-written impls mirroring the existing `Debug` redaction: `IbctKeyConfig.key_hex` and - `IbctKey.key_bytes` emit `"[REDACTED]"`; `McpTransport::Stdio.env` and `McpTransport::Http - .headers` redact values only, keeping keys for diagnostics (`ServerEntry` inherits this - automatically since it nests `McpTransport`). `Deserialize` is untouched on all three — config - load, the ACP `mcp/add` handler, and IBCT token decoding still need the real values on the way - in. No live leak existed before this fix (audited: `--migrate-config` is text-based, the - `--init` wizard never populates these fields with raw secrets, and the runtime types have no - serialize-to-output path today) — this closes the latent risk for any future caller. -- `zeph-commands`/`zeph-core`/`zeph-tui`: `/help` and TUI slash autocomplete were both - hand-maintained lists that had drifted from the real command registrations (#5987, #5875). - `/conv`, `/cocoon`, `/quit`, and `/worktree` were dispatchable but missing from - `zeph_commands::COMMANDS`, hiding them from `/help`; added the four missing entries. - `Agent::run`'s two command-registry constructions (session/debug and agent-command) are now - extracted into `zeph_commands::build_session_debug_registry`/`build_agent_command_registry` - (`crates/zeph-core/src/agent/slash_commands.rs`) so a new regression test - (`commands_rs_drift_tests`) can assert every registered handler has a matching `COMMANDS` - entry, closing the door on this recurring drift class. Separately, `zeph-tui`'s `/`-triggered - autocomplete (`SlashAutocompleteState`/`filter_commands`) never sourced from - `zeph_commands::COMMANDS` at all, so every channel-agnostic `AgentAccess` command - (`/model`, `/provider`, `/skill`, `/policy`, `/think-tokens`, `/reasoning-effort`, etc.) was - dispatchable when typed in full but never suggested. Added `TuiCommand::SendVerbatim`/ - `TuiCommand::PrefillVerbatim` and `zeph_tui::command::zeph_commands_entries()`, which - projects `zeph_commands::COMMANDS` into `CommandEntry`s and merges them into - `filter_commands`, so future `AgentAccess` commands get TUI autocomplete automatically - instead of requiring a parallel hand-authored registration. Commands whose bare (no-argument) - form is not a valid default (e.g. `/image `, `/feedback `) prefill the - input for the user to complete instead of submitting an incomplete command, mirroring the - existing `*Prompt` variants' behavior. Deduplicated against existing hand-authored entries - that already cover the identical bare command; entries gated behind a Cargo feature that is - actually unified with this crate's own feature of the same name (currently only `cocoon`) - are excluded when that feature is off, so a feature-off build cannot show a dead command in - autocomplete. -- `zeph-config`: closed the sibling cohort of the #6006 Serialize-leak class for channel/A2A/MCP - config fields that have a redacting `Debug` (from #6004/#6005) but derived (plaintext) - `Serialize` (#6166). `TelegramConfig.token`, `DiscordConfig.token`, `SlackConfig.bot_token`, - and `SlackConfig.signing_secret` are always `None` at every `--init` persist point (the real - secret goes to the vault; runtime resolution hydrates it back into the field), so they now - carry `#[serde(skip_serializing)]` — the field is simply absent from any future diagnostic - `Serialize`, and `Deserialize`/config loading is unaffected. `DiscordConfig.application_id` - is a public Discord snowflake, not a secret, and is left untouched. `A2aServerConfig - .auth_token`, `McpServerConfig.env`, and `McpServerConfig.headers` legitimately hold raw - values that `--init` (or a hand-written config) persists to `config.toml`, so their derived - `Serialize` is intentionally kept plaintext — each now carries a `# Security` doc note - directing any log/dump/status output to the existing redacting `Debug` impl instead. No live - leak existed before this fix (same audit posture as #6006/#6165: no serialize-to-output path - reaches these types today). Noted below as follow-ups (not yet filed as GitHub issues): the - identical latent pattern on `GatewayConfig.auth_token` and `AcpConfig.auth_token`/ - `auth_clients[].token`, and aligning the A2A wizard to the vault-backed pattern used by the - other channel tokens. -- `zeph-orchestration`/`zeph-subagent`/`zeph-core`: `TaskNode::network_scope: Deny` was - advisory-only — the field was never read at dispatch time, so a planner-emitted - `network_scope: Deny` silently left a task's network egress unrestricted (spec - `069-threat-model` OQ-1, #6030). Now enforced on both dispatch paths via the new - `NetworkDenyToolExecutor`, which blocks `bash` invocations of `curl`, `wget`, `nc`, - `ncat`, `netcat`, and any call to the native `web_scrape`/`fetch` tool: - - Spawned sub-agents: `handle_scheduler_spawn_action` sets the new - `SpawnContext::network_denied` flag; `build_filtered_executor` wraps the sub-agent's - tool executor when set — sibling tasks and the parent agent's own executor are - unaffected. - - `RunInline` tasks: `handle_run_inline_action` temporarily wraps the parent agent's own - `tool_executor` for the duration of that single inline turn (there is no per-task - executor to wrap independently, since `RunInline` shares the parent's tool loop), - restoring it afterward. - - Known gap: MCP-provided tools are not inspected and may still perform their own HTTP - egress. This is a best-effort tool/command-identity block, not a sandbox-level - guarantee — see `specs/069-threat-model/spec.md` INVARIANT-5. - -- `zeph-config`/`zeph-memory`/`zeph-experiments`/`zeph-common`/`zeph-index`: 9 `Display` - impls (`ProviderKind`, `MemoryTier`, `ContentFidelity`, `EntityType`, `SourceKind`, - `ParameterKind`, `ExperimentSource`, `EdgeType`, `Lang`) used `Formatter::write_str`, - which silently ignores width/fill/align flags from the caller's format spec — only - `Formatter::pad` respects them. Switched all 9 to `f.pad(...)`, closing off the same - latent width-spec bug already fixed for `SessionKind`/`SessionStatus`/`SessionChannel` - in #6060 (#6066). - -- `zeph-skills`/`src/acp.rs`/`src/serve/`: `SkillOrchestra`'s RL routing head lost learned - updates under concurrent ACP/`/sessions` agents (#5974). `#5921` wired `RoutingHead` - persistence into `spawn_acp_agent` and `build_agent_factory`, but each session independently - loaded its own in-memory copy from the `routing_head_weights` singleton row and persisted - back independently — concurrent sessions clobbered each other's REINFORCE weights - (last-write-wins). The head is now loaded/cold-started exactly once in - `crate::acp::build_shared_core` and cloned (a cheap `Arc` clone) into every session sharing - that core, so all sessions mutate the same `Arc>` and updates - serialize through that mutex instead of racing across independent copies. Also added - `RoutingHead::persist_snapshot()`, capturing `embed_dim`/weights/baseline/`update_count` - under one lock acquisition, closing a related TOCTOU where a concurrent `update()` could land - between the previously-separate locked reads used to build the persisted DB row. No config or - behavior change for `rl_routing_enabled = false` (still the default) or single-session - (`runner`/`daemon`) deployments. -- `zeph-llm` Claude provider: the no-prefill gate that strips a trailing assistant - message for models that reject assistant prefill (`ClaudeProvider::no_prefill`, - added by #5903 and extended to cover the unconditional `rejects_prefill` case by - #6145 — see that `[Unreleased]/Fixed` entry for exactly which models/ - thinking-states the gate covers) was only applied in `build_request()`. The - other four request-construction paths on `ClaudeProvider` — - `chat_with_tools_stream` (the agent's primary tool-use loop), `chat_with_tools`, - `chat_typed`, and `debug_request_json` — built their request bodies - independently and never applied the gate, so a trailing-assistant-message - history routed through any of them could still trigger the same class of 400 - for any model/thinking-state the gate is meant to cover (#6146). This is the - second time this bug class was filed, so the split step and the no-prefill - strip are now bundled into two funnel methods — - `ClaudeProvider::structured_history`/`plain_history` — that every - request-construction path calls to obtain its message history; the raw - `split_messages`/`split_messages_structured` functions are no longer imported - at the `claude` module's top level, so a future request path cannot easily - split a history without the strip being applied. -- MagicDocs registration (`crates/zeph-core/src/agent/magic_docs.rs`) never fired for a - turn's terminal assistant text response — the two sites in `tier_loop.rs` that push the - final response (`process_response_native_tools`'s semantic-cache-hit branch and - `process_single_native_turn`'s `ChatResponse::Text` branch) wrote directly to - `self.msg.messages` instead of calling `self.push_message(...)`, bypassing - `detect_magic_docs_in_messages()` entirely (#6127). Detection only ran retroactively, the - next time an `Assistant` message was pushed via `push_message` — typically a *further* - tool call later in the same conversation — so a single read-then-respond turn (the - canonical `--bare -p "read X"` usage) never registered a `# MAGIC DOC:` file, even though - the read call itself succeeded and returned the marked content. Not a `--bare`-conditional - gate: `with_bare_mode` was unaffected and required no change; this was a general tool-loop - message-push coverage gap that `--bare`'s single-shot invocation pattern exposed - deterministically, while interactive multi-tool-call sessions usually masked it. Both push - sites now route through `push_message`, matching the pattern already used correctly by - `push_assistant_tool_use_message` and `process_tool_result_batch`. As a side effect, both - paths now also update `cached_prompt_tokens` and `last_assistant_at`, which the raw pushes - were silently skipping — a pre-existing token-accounting gap on the semantic-cache-hit and - plain-text-response paths, corrected incidentally by the same fix. - `detect_magic_docs_in_messages()`'s own detection guard was the deeper defect underneath - the above: it only scanned when the *last* pushed message was `Role::Assistant`, so - detection for a magic-doc read was always deferred to the next assistant push — a turn - that instead exited the native tool loop via a shutdown/user-cancel/doom-loop break or - `max_iterations` exhaustion, leaving an unpaired `Role::User` tool-result as the last - message, would still silently drop the doc even after the two `push_message` routing - fixes above. The guard now also scans when the last message is a `Role::User` message - carrying `ToolResult`/`ToolOutput` parts, so detection fires uniformly at the point the - content actually arrives, covering all native-loop exit paths, not just the two terminal - push sites. `focus.rs`'s context-compression checkpoint re-push (a `ToolUse`-only - assistant message with no paired result yet present) is intentionally left as a raw push - and documented inline as such. -- `.github/deny.toml`: the `cargo-deny` advisories gate scanned only the `candle` - and `tui` features, excluding `pdf` and every other feature shipped by the - blocking `bundle-check` (`full`) CI job and release builds — any RUSTSEC - advisory affecting a `pdf`-only dependency (or any of `acp`, `gateway`, `a2a`, - `discord`, `slack`, `scheduler`, `profiling`, `sandbox`, `gonka`, `cocoon`, - `registry`, `session`) was invisible to the security gate despite shipping in - production (#5994). `[graph] features` now scans the `full` bundle itself - instead of an enumerated feature list, so the gate tracks whatever `full` - includes without manual upkeep. This surfaced `RUSTSEC-2026-0192` (`ttf-parser` - 0.25.1 unmaintained, no patched version, via `pdf-extract` -> `lopdf` -> - `ttf-parser`), added to the advisories `ignore` list alongside the existing - unmaintained-dependency entries, with a comment noting the suggested - alternative (`skrifa`) for a future migration (#6085). -- `zeph-llm`: Claude requests to the Sonnet 4.6+/Opus 4.7+/Sonnet 5 generation with - extended thinking disabled, and to legacy Sonnet 4.6 with thinking enabled, could - send a trailing assistant message ("prefill") that the API rejects with a 400 - (#5903). The no-prefill gate was `cap.prefers_effort && thinking_param.is_some()`, - which conflated the effort-vs-`budget_tokens` conversion decision with prefill - rejection — the two only happened to align for Opus 4.7/4.8 and Sonnet 5 while - thinking was on. Added a `rejects_prefill` capability flag, independent of thinking - state, covering Sonnet 4.6+ and Opus 4.7+/Sonnet 5 unconditionally; Opus 4.6 keeps - its existing thinking-gated behavior via `prefers_effort`. -- `book/src/advanced/context.md`: corrected an internally inconsistent example model - ID, `claude-sonnet-4-5-20250514` — `20250514` is the base-release date for - `claude-sonnet-4`, not `claude-sonnet-4-5` (#5902). Replaced with `claude-sonnet-5`, - matching the dateless convention used for the current Sonnet release elsewhere in - the book since #5901. -- `zeph worktree clean`'s `Removed N, skipped M` summary omitted entries whose - `WorktreeManager::remove()` call itself failed (e.g. a locked worktree with `--force`), - silently undercounting the actual outcomes (#6077). Added an `errored` count, folded into a - new `format_clean_summary` helper so the total now always adds up to the number of stale - entries `reconcile()` discovered. Also fixed `WorktreeManager::reconcile()`'s doc comment, - which claimed it was "used at startup" — its only callers are the `zeph worktree list`/`clean` - CLI subcommands; there is no startup caller. -- `zeph-common`: `TaskSupervisor::shutdown_all(timeout)` could silently drop clean task - completions and misreport them `Aborted` (#5926). The reap driver's post-cancel drain - phase enforced its own hardcoded 5s `SHUTDOWN_DRAIN_TIMEOUT`, independent of the - caller's `timeout` — it gave up and stopped listening for completions before - `shutdown_all`'s real deadline, since the shared `CancellationToken` is cancelled - out-of-band by a shutdown bridge/signal handler well before `shutdown_all` is ever - invoked at every real call site (`src/runner.rs`, `src/serve/mod.rs`). A task that - finished cleanly after the 5s fallback but before the caller's actual timeout had its - completion dropped and was force-aborted and marked `Aborted` in the registry instead - of `Completed`. Removed the reap driver's independent deadline entirely — it now - drains until no tasks remain active, and `shutdown_all`'s own `sleep(timeout)` + - force-abort is the sole deadline authority for the whole shutdown sequence. -- `zeph-session`: `session_dir()` double-nested every on-disk session path as - `/sessions/` instead of `/` (#5981), since - `data_dir` (default `.zeph/sessions`) already names the sessions root. All callers - (`zeph-core`, `zeph-acp`, `src/runner.rs`, `src/commands/sessions.rs`, `src/acp.rs`, - `src/serve/agent_factory.rs`) resolve session paths exclusively through this function, so the - fix is a single-point change; the crate's own doc-test asserted the buggy double-nested path - and is corrected alongside it. A new `zeph_session::migrate_legacy_session_layout` runs once at - process startup (`src/runner.rs`, before any command dispatch), moving any session directory - still sitting at the old `/sessions/` path up one level to - `/`; a destination that already exists is left in place (skipped, with a - warning) rather than clobbered. Idempotent and a cheap no-op on installs with nothing to - migrate — without this, a pre-fix session would silently resume as a blank conversation - (`SessionEventLog::open` creates an empty log at the new, previously-unused path) with zero - error or warning. -- `zeph-session`: `ForkEngine::fork` never implemented spec-068 §7.2 step 6 (#5982) — it copied - every raw event (including any `UserMessage.image_refs`) into the child's `events.jsonl` but - never copied the referenced files from the parent's `blobs/` directory into the child's. - Currently dormant (no production call site populates `image_refs` yet), but would have - silently dropped attachments once wired up. `ForkEngine::fork` now hard-links (falling back to - a copy on cross-device hard-link failure) each blob referenced in the copied event range into - the child's `blobs/` directory, creating it with `0o700` permissions (matching the sibling - session directory) only when needed; a blob missing on the parent's disk is logged and skipped - rather than failing the fork. Each `image_refs` hash is now validated as a non-empty, bare hex - string before being used in a `PathBuf::join` — an unvalidated entry containing a path - separator, `..`, or an absolute path would otherwise have let a fork read or write outside the - session's `blobs/` directory; the hash list is also deduped before copying so a hash referenced - twice in one fork range hard-links once instead of falling into the cross-device copy fallback - on the second occurrence. -- `zeph-session`: `ForkEngine::fork`'s `copy_referenced_blobs` (added by #5982, see the entry - above) treated any `hard_link` failure other than a missing source as a genuine cross-device - (`EXDEV`) error and fell back to `fs::copy` (#6153). That fallback also caught the case where - the destination already exists as a hard-link to the same source — e.g. a fork retried against - the same `new_id` after a partial failure — and `fs::copy` onto an existing hard-link does not - "waste a copy", it silently truncates the shared inode to 0 bytes, corrupting the content for - every hard-link pointing at it, including the parent session's own blob. Currently dormant (no - production call site populates `image_refs` yet) but confirmed with a deterministic out-of-repo - repro during #6152's testing pass. `copy_referenced_blobs` now matches `AlreadyExists` - separately and treats it as a no-op (blobs are content-addressed by hash, so a pre-existing - entry at the hash-named path is assumed to already hold the right content), leaving the - `fs::copy` fallback to run only when the destination genuinely does not exist. -- `zeph-gateway` and `zeph-a2a`: fixed a bearer-token brute-force bypass caused by - middleware layer order (#6110, CWE-307). `auth_middleware` returns `401` directly - without calling `next.run`, so with auth layered outside `rate_limit_middleware`, - failed-auth requests never reached the per-IP counter — an attacker could brute-force - the bearer token with zero rate limiting. Swapped the `.layer()` order in - `build_router` (`zeph-gateway/src/router.rs`) and `build_router_with_full_config` - (`zeph-a2a/src/server/router.rs`) so `rate_limit_middleware` wraps `auth_middleware`, - guaranteeing every request — including failed-auth ones — increments the counter - before the auth check runs. -- `zeph-mcp`: `tool_list_locked` entries could outlive their server, leaking orphaned - locks (#6139, follow-up to #6118). `remove_server` and `shutdown_all_shared` - (`crates/zeph-mcp/src/manager/server.rs`) cleared `server_tools`, `server_trust`, - `server_fingerprints`, and `last_refresh` on disconnect but never removed the - corresponding `tool_list_locked` entry; and `handle_connect_result` - (`crates/zeph-mcp/src/manager/connect.rs`), used by the `connect_all`/ - `connect_oauth_deferred` path, released the lock on connection or `list_tools` - failure but not when the pre-connect probe blocked the connection — asymmetric with - the equivalent `add_server` path (`probe_or_cleanup`), which already cleaned up - correctly. All four cleanup sites now release `tool_list_locked` consistently. -- `zeph-mcp`: `lock_tool_list` hardening silently exempted OAuth-transport MCP servers - from post-attestation tool-injection protection (#6118). `tool_list_locked` was only - populated by the two non-OAuth connection paths (`spawn_non_oauth_connections`, - `connect_and_list_tools`); `spawn_oauth_connections` — the sole connection path for - OAuth servers — never inserted the server ID, so `tools/list_changed` notifications - from an OAuth server were never rejected regardless of the `lock_tool_list` config - value. `spawn_oauth_connections` now inserts into `tool_list_locked` before the - handshake starts, mirroring the non-OAuth path (with matching cleanup on connection - failure in `process_oauth_results`), and the `lock_tool_list`/`tool_list_locked` - invariant now has test coverage for the first time. -- `doctor`, `bench`, `gonka doctor`, and `cocoon doctor` all called - `parse_vault_args(&config, None, None, None)`, silently discarding the global - `--vault`/`--vault-key`/`--vault-path` CLI flags — only the real application startup path - (`AppBuilder::new`) threaded them through (#6037). `--vault ` and related flags - are now respected by all four commands, matching `AppBuilder::new`'s behavior; an - unrecognized `--vault` value is now rejected the same way on these paths as it already was - on the main startup path (#6025). -- `IndexMcpServer` registration (`apply_code_retrieval` in `src/agent_setup.rs`) hardcoded - `std::env::current_dir()` and never read `[index] workspace_root`, unlike the sibling - background-indexer path (`apply_code_indexer`), which resolved it correctly. Scoping - `workspace_root` to a subdirectory had no effect whenever `index.mcp_enabled = true`; - `IndexMcpServer` always walked the full process working directory instead (#6129). - Extracted the existing resolution logic into a shared `resolve_workspace_root` helper so - both call sites resolve `workspace_root` identically. -- `zeph-core`: `apply_tier_results` processed each tool result's `RuntimeLayer::after_tool` - chain and `PostToolUse` hook firing sequentially, one index at a time, even though the - tier's tool execution itself already runs bounded-parallel (#6128). A tier with many - `PostToolUse`-matching calls paid N sequential subprocess spawns after already paying for - parallel tool execution. The layer/hook phase now runs concurrently across a tier's - indices via `futures::future::join_all`, bounded by the same `max_parallel` semaphore the - tier's tool execution uses. -- `zeph-core`: `MetricsBridge::WATCHED_SPANS` (profiling feature) named three spans - (`agent.prepare_context`, `agent.tool_loop`, `agent.persist_message`) that never matched any - real `tracing` span, silently making their `on_close` handling dead code — only `llm.chat` - was ever observed (#6111). Renamed the first two to their real span names - (`core.context.prepare_context`, `core.tool.native_loop`). `agent.persist_message` is - intentionally left unwatched: its real span (`core.persist.persist_message`) fires 7+ times - per turn, not once, so bridging it would report the wrong (last) call's duration instead of - the first user-message persist that `TurnTimings::persist_message_ms` is meant to measure; - that field stays manually-timed only. -- `zeph-tui`: closed two metrics/data wiring gaps left by PR #6131 (#6132, #6059). - - `TuiCommand::WorktreeList`/`WorktreeClean` were CLI-redirect stubs pointing users at - `zeph worktree list`/`clean` because no path existed from `zeph-tui` to the running - agent's live `WorktreeManager` (private inside `zeph-subagent::SubAgentManager`). Added a - real `/worktree list`/`/worktree clean [--force]` slash command - (`zeph-commands::handlers::worktree::WorktreeCommand`, wired through a new - `AgentAccess::list_worktrees`/`clean_worktrees` pair and - `Agent::handle_worktree_list_as_string`/`handle_worktree_clean_as_string` in - `zeph-core`), backed by `SubAgentManager::worktree_manager()` — a new public getter for - the same live manager instance `spawn` already uses. The TUI reducer now forwards - `TuiCommand::WorktreeList`/`WorktreeClean` as `Effect::SendUserInput("/worktree list" / - "/worktree clean")` instead of pushing a static message, so results reflect this - session's actual worktree state, consistent with how `/skill`, `/mcp`, and `/scheduler` - already work. `WorktreeManager` gained a `prune_branch_on_remove()` getter so - `/worktree clean` can read `WorktreeConfig::prune_branch_on_remove` directly from the - manager it already holds, without `SubAgentManager` needing to duplicate it or - `zeph-core` needing to depend on the full worktree config. - - `MetricsSnapshot::classifier` (p50/p95 latency per classifier task) and - `avg_turn_timings`/`max_turn_timings` (rolling-window turn latency) were populated every - turn but had zero consumers in `zeph-tui` — only `last_turn_timings.{prepare_context_ms, - llm_chat_ms}` ever reached a widget. The resources side panel's latency line now also - shows `tool_exec_ms`/`persist_message_ms`, and a new classifier-latency line (p50 only, - compact) appears once any classifier has recorded a call. A new `view:latency` command - (`TuiCommand::ViewLatency`, following the same pattern as `/cost`'s `view:cost`) prints - the full avg/max turn-latency breakdown plus classifier p50/p95/call-count via - `App::format_latency_stats`. -- `serve-sessions`: closed three follow-up gaps in `/sessions*` HTTP+SSE session wiring - (#6045, #6046, #6008). - - `ScopedToolExecutor` (`[security.capability_scopes]`) is no longer built once, eagerly, and - shared across every concurrent `/sessions*` agent — it is now wrapped fresh per session in - `agent_factory::build_agent_factory`, mirroring `src/acp.rs`'s per-connection wrap, so each - session's `OutOfScope` capability-scope denials feed that session's own `TrajectorySentinel` - risk-escalation signal queue instead of being invisible to it. `assemble_serve_deps` still - validates the configured scope compiles against the tool registry at server startup (fatal - on a bad config, unchanged), it just no longer keeps the compiled instance — that startup - validation and the per-session wrap now share one `compose_session_tool_tree` helper so - both compile against the identical tool-id surface (including the `skill_loader`/ - `invoke_skill`/`memory`/`overflow` tools below), fixing a false-positive startup abort for - any scope pattern that referenced one of those tools. - - `/sessions*` agents now get `skill_loader`/`invoke_skill`/`memory`/`overflow` tool executors, - matching CLI/TUI/ACP/daemon's tool surface — previously these were entirely absent from - serve's composite tool chain. MCP tools, the scheduler executor, and skill/config hot-reload - broadcast forwarding remain a separately-tracked known gap. - - A `[tools.policy]`/`[tools.authorization]` compile failure now aborts `serve-sessions` - startup instead of silently starting with declarative policy enforcement disabled — serve is - an HTTP-facing entrypoint with potentially remote/less-trusted callers, unlike CLI/TUI/ACP/ - daemon (which stay intentionally fail-open on the same failure, unchanged). -- `zeph-tui`: closed three independent dispatch/state gaps (#6061, #5984, #5983). - - The task-registry overlay's supervisor-unavailable fallback (`render_subagents_slot`) - drew its "supervisor not available" message directly into the shared subagents-slot - `Rect` without a preceding `Clear`, unlike every other overlay in the crate, letting - stale glyphs from whatever rendered underneath bleed through. More significantly, - `active_panel` (Fleet/Durable/SubAgents/Tasks) and `show_task_panel` were tracked as - independent fields with no mutual-exclusion invariant, so the task panel could render - simultaneously with Fleet, Durable, or the interactive sub-agent sidebar — the latter - case left `j`/`k`/`Enter` still routed to a sidebar hidden behind the task panel. - Added `App::set_active_panel`, a single method all `active_panel`-mutating call sites - (`Action::SetActivePanel`, `Action::CyclePanelFocus`/Tab, `Action::ToggleTaskPanel`, - `TuiCommand::TaskPanel`/`FleetPanel`/`DurablePanel`) now go through, keeping - `show_task_panel` in sync so only one of the shared-Rect panels renders per frame. - - Loading a user theme file (`apply_theme`) and loading a sub-agent transcript - (`start_transcript_load`) both offload to `spawn_blocking` without ever setting - `status_label`, violating the "every background operation shows a status indicator" - convention every other async path in the TUI follows. Both now set `status_label` - before dispatch and clear it in their poll handlers on completion — and also on - cancellation, which the first pass missed: applying a built-in theme preset while a - user-file load is still in flight, or pressing Esc out of a sub-agent transcript view - before its load resolves, previously left the "loading..." label stuck indefinitely. - - `TuiCommand::SandboxStatus`/`TafcStatus` were parsed from the command palette but never - reached their already-implemented handlers in `forward_tui_commands` - (`src/tui_bridge.rs`) because `execute_command` never forwarded them through - `command_tx` — both are now wired into the existing `command_tx`-forwarding arm - alongside `ViewConfig`/`ViewAutonomy`. `TuiCommand::WorktreeList`/`WorktreeClean` were - silent no-ops with no backing implementation reachable from a running TUI session (the - live `WorktreeManager` instance is private to the agent's `SubAgentManager`, and the - CLI's `zeph worktree` subcommands construct their own, disconnected manager per - invocation); both now push a system message pointing to the equivalent CLI command - (`zeph worktree list` / `zeph worktree clean [--force]`) instead of doing nothing. -- `zeph-durable`/`zeph-core`: durable `agent_turn` executions no longer race two processes into - corrupting the same journal when they derive the same `ExecutionId` (#6122). - - `Agent::ensure_session_durable_ctx` derives the P1 `ExecutionId` deterministically from - `(ConversationId, sqlite_path)`, so two CLI processes sharing `memory.sqlite_path` and - resolving the same conversation (the common non-`--resume` path) always agree on the id. - `LocalBackend::open_execution` alone (a plain SELECT-then-INSERT, no transaction) let both - processes race the same row and independently drive `next_step` from `0`, corrupting the - journal and surfacing as `ReplayDivergence`/`ReplayIntegrity` on whichever process lost. - - Added `LocalBackend::open_execution_exclusive`, which takes a non-blocking, `flock(2)`-backed - process-exclusivity lock on the `ExecutionId` (a new `zeph_durable::ExecutionLock`) before - touching the row. A second concurrent process gets `DurableError::ExecutionLocked` and - degrades to non-durable instead of racing. The lock is released automatically by the kernel on - process exit (including `SIGKILL`), so a hard-killed process never leaves a stale lock; unlike - `zeph_common::pidfile::PidLockGuard`, the lock file is never unlinked on drop (a permanent - sentinel, matching `zeph-session::log::SessionEventLog`'s own `AdvisoryLock`) — unlinking would - reopen a `flock`+`unlink` TOCTOU race under this lock's much higher per-turn contention. SQLite - 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). - - `SubAgentManager::deliver_secret` previously sent the bare resolved `Secret` value over - the sub-agent's channel; the running agent loop cached it for the rest of its - `run_agent_loop` invocation and kept injecting it into every subsequent tool call's - `ExecutionContext`, even after the originating grant's TTL had elapsed (#5991). Added - `grants::GrantedSecret` (value + absolute expiry, computed from the active grant via the - new `PermissionGrants::expires_at`) as the channel payload, and `handle_tool_step` now - evicts expired entries from `granted_secrets` before building the `ExecutionContext` for - every tool call, not just once at approval time. - - `handle_agent_approve`'s explicit `/agent approve ` path polled - `SubAgentManager::try_recv_secret_request` (which pops the first pending request across - *all* sub-agents) and filtered by task ID, silently discarding a different sub-agent's - pending request when it happened to pop first (#5993). Added - `SubAgentManager::try_recv_secret_request_for(task_id)`, which polls only that sub-agent's - own request channel, and switched the approve path to use it. -- `zeph-durable`/`zeph-core`/`zeph`: the INV-8 control-entry row HMAC — documented in - `specs/064-durable-execution/spec.md` as closing the `EffectIntent`-forgery attack vector - (security HIGH-2b) for shared-DB/Restate deployments — was never wired to a production key or - verified on read (#6043, #6044). `LocalBackend::with_hmac_key` had no production caller, so - `hmac_key` was always `None`, every control entry's `hmac` column was always written `NULL` - regardless of deployment, and no code path recomputed or compared it on read. Added - `zeph_core::durable::derive_control_hmac_key_b64`, which derives the HMAC key as a BLAKE3 - `derive_key` subkey of the same vault-resolved `ZEPH_DURABLE_KEY` used for the AEAD payload - cipher (domain-separated, so the two keys are cryptographically independent despite sharing one - vault secret — no new vault entry required). The key is now resolved and attached at every - production choke point that opens a `LocalBackend` — the P1/P2 agent-loop adapters - (`crates/zeph-core/src/agent/durable_bootstrap.rs`, `plan.rs`), the `zeph durable` CLI write and - read paths (`load_write_hmac_key`, `open_backend` in `src/commands/durable.rs`), and the - scheduler daemon (`src/commands/scheduler_daemon.rs`) — gated by the same - `shared_db`/`postgres://`-detection policy as the AEAD `encryption_gate`: a single-user local, - non-shared database never resolves the key (matching the documented stance that its control - entries carry no HMAC), and a declared/detected shared database fails closed if - `ZEPH_DURABLE_KEY` cannot be resolved. `LocalBackend` now also verifies every `EffectIntent` it - reads: `verify_control_hmac` recomputes the HMAC and constant-time-compares it (`blake3::Hash` - equality, mirroring the existing promise resolver-token check) against the stored value, and - fails closed with the new `DurableError::ControlIntegrity` variant on a mismatch or a missing - HMAC on a keyed backend — closing the actual forgery vector the spec claimed was already closed. -- `zeph-channels`/`zeph-core`: hardened the channel send/retry/status path (#6094, #6106, #6095). - - `Channel::send_status` was awaited inline on the agent turn hot path at ~45 call sites via - `let _ = self.channel.send_status(...).await;`, with no outer timeout. Under sustained - HTTP 429s, Slack/Discord's retry-with-backoff loop (`http_retry::send_with_retry`) could take - up to several minutes, stalling the turn for that long (#6094). Added - `Channel::send_status_best_effort` — a default trait method that wraps `send_status` in a - 10s `tokio::time::timeout` and logs the outcome (`tracing::debug!` on success, - `tracing::warn!` on error or timeout) instead of returning a `Result`. All discard call - sites now use this method, which also closes #6106's "failures are silently discarded with - no logging" gap in one place rather than touching every call site individually. - - `TelegramChannel::send`/`send_status`/`send_or_edit` (backing `flush_chunks`) called - `self.bot.send_message`/`edit_message_text` directly via plain `teloxide::Bot`, bypassing the - 429 retry-with-backoff resilience that Discord, Slack, and `TelegramApiClient` already have - (#6106). Added `common::teloxide_retry::send_teloxide_with_retry`, mirroring - `http_retry::send_with_retry`'s backoff semantics for `teloxide::RequestError::RetryAfter`, - and routed all three call sites through it. - - Discord's `RestClient` had no test-injectable base URL, unlike `SlackApi`/`TelegramApiClient` - (#6095). Added a `base_url` field defaulting to Discord's real API base plus a - `#[cfg(test)]` `with_base_url` constructor, and added wiremock tests confirming - `send_message`, `edit_message`, and `trigger_typing` each retry transparently on a 429. -- `zeph-scheduler`: fixed three independent bugs surfaced by live-testing (#6096, #5950, #5947). - - `daemon_status()`'s `recent_runs` was alphabetically ordered (`ORDER BY name`) instead of - recency-ordered, and `last_run` was hardcoded to an empty string despite the database - tracking it — `zeph status --json` and the TUI `/daemon status` command presented a "recent - runs" list that was neither recent nor informative. `ScheduledTaskInfo` now carries - `last_run`, `list_jobs_full` selects it, and `daemon_status` sorts by `last_run` descending - (never-run jobs last) before truncating to `recent_n`; the CLI printer renders `last:` - alongside `next:` (#6096). - - `TaskProvenance::is_external()` was dead code — every RTW-A reentry-defense mechanism gated - on `!= TaskProvenance::Static`, collapsing the documented three-tier trust model - (`Static`/`UserAdded`/`External`) into a binary one. The injection-pattern-check mechanism - now applies unconditionally to `External`-provenance tasks regardless of the - `injection_pattern_check` config toggle, while `UserAdded` still respects the toggle, - giving `External` genuinely stricter handling without weakening the existing baseline for - either tier (#5950). - - Scheduled/cron experiment runs used the primary agent provider as both judge and subject, - silently defeating the self-judge-bias mitigation that `[experiments] eval_provider` exists - to provide — the interactive `/experiment` command and `--experiment-run` CLI flag already - resolved a distinct judge via `build_eval_provider()`, but the scheduler path never did. - `ExperimentTaskHandler` now resolves `eval_provider` the same way, falling back to the - primary provider only when unset (#5947). -- `zeph-core`: `TracingCollector::finish()` wrote `trace.json` via synchronous `std::fs` - I/O, reachable from the async agent turn loop (#6107). `write_trace_file` now offloads to - `tokio::task::spawn_blocking` when a Tokio runtime is active, falling back to an inline - synchronous write when none is present — `finish()` is also reachable from `Drop` (which - cannot `.await`) and from plain non-async unit tests, so a fallback was required rather than - making the offload unconditional. `finish()` now returns the write's `JoinHandle` so the - session-end call site (`agent/mod.rs`, where nothing else will write this session's trace) - can await it and guarantee the file lands before the process/runtime tears down, instead of - racing it fire-and-forget; the mid-session `/dump-format` switch site and `Drop` keep the - fire-and-forget behavior, mirroring `DebugDumper::write` from #6101, since a lost dump there - doesn't lose the only copy. -- `zeph-core` (`profiling` feature): `MetricsBridge` derives per-phase turn timings from - tracing span durations, but `Agent::flush_turn_timings` unconditionally overwrote - `last_turn_timings` with the manually-timed (`Instant::now()`) value every turn, discarding - whatever the bridge had just written (#5946). The clobbering itself is now fixed: - `MetricsBridge` marks a bitmask (`MetricsSnapshot::bridge_timings_written`) for each field it - writes; `flush_turn_timings` reads that mask, reconciles it against the manual value, and - clears it, all inside a single `send_modify` closure so a concurrent `MetricsBridge::on_close` - write cannot land in the gap between reading and clearing the mask. Fields the bridge did not - mark this turn still fall back to the manual value. This is **not** the same as "`MetricsBridge` - is now fully functional" — three of the four span names in `WATCHED_SPANS` - (`agent.prepare_context`, `agent.tool_loop`, `agent.persist_message`) do not currently match - any real span in the codebase, so in practice the bridge only ever populates `llm_chat_ms`; - the other three fields continue to come from manual timing exactly as before this fix, just - no longer at risk of losing bridge data for fields the bridge was never actually producing. - Reconciling those span names, and `persist_message`'s multi-call-per-turn semantics (which - don't map 1:1 to the single-span-instance model `MetricsBridge` assumes), is tracked - separately in #6111. -- `zeph-db`/`zeph-session`/`zeph-memory`: `SessionStore::list`, `list_acp_sessions`, - `list_acp_sessions_for_owner`, and `list_agent_sessions` bound `LIMIT ?` with `-1` as their - `limit == 0` ("unlimited") sentinel — a `SQLite`-only convenience that `PostgreSQL` rejects at - execution time (`ERROR: LIMIT must not be negative`) (#5980). Any caller passing `limit = 0` - against a Postgres-backed deployment got a hard SQL error instead of "all rows". Added a - shared `zeph_db::limit_clause` helper that omits the `LIMIT` clause entirely when unlimited - (the only cross-backend-safe encoding — binding `NULL` in its place is separately rejected by - `SQLite`) and applied it at all four call sites. Also fixed a related, previously-uncovered - defect surfaced while adding Postgres test coverage: `SessionStore::get`/`list`/ - `get_by_conversation_id` decoded `created_at`/`updated_at` straight into `String`, which fails - against Postgres's `TIMESTAMPTZ` columns regardless of `limit` — every `zeph-session` query - was broken on Postgres, caught only because the crate previously had no Postgres integration - test file at all. Added Postgres integration tests for `zeph-session` (new - `crates/zeph-session/tests/postgres_integration.rs`, `test-utils` feature) and extended - `crates/zeph-memory/tests/postgres_integration.rs` to cover the `limit = 0` path. -- `zeph-core`: removed two blocking-I/O sites from the async agent turn loop (#6020, #6029). - - `rebuild_system_prompt` called `project::discover_project_configs`/`load_project_context` - directly on every turn — a filesystem walk from cwd to the root plus a `read_to_string` - per discovered config, executed synchronously on the async worker thread. Now offloaded - via `tokio::task::spawn_blocking`, mirroring the existing `generate_repo_map` pattern in - the same function (#6020). - - `DebugDumper::write` (backing `dump_request`/`dump_response`/`dump_tool_output`/ - `dump_tool_error`/`dump_focus_knowledge`) called `fs_secure::write_private` synchronously - from the LLM dispatch and tool-execution hot paths. It's now fire-and-forget via - `spawn_blocking` — callers never waited on the write result, so no signature changes - were needed at any call site. Dump methods reachable only from synchronous contexts - (`dump_anchored_summary`, `dump_compaction_probe`, `dump_sidequest_eviction`, and the two - test-only pruning dumps) keep the original synchronous write and are tracked as a - follow-up. -- `zeph-acp`: closed three ACP wiring gaps that only reached the CLI/TUI entry point (#5959, - #5986, #6022). - - Shutdown-summary config (`[memory] shutdown_summary*`) and channel-scoped provider - persistence (`[session] provider_persistence`/`persist_provider_overrides`) were wired only - in `src/runner.rs`; ACP sessions never produced a shutdown summary and never - persisted/restored a "last-used provider" preference (#5959). Fixing the latter uncovered a - critical collision: naively wiring channel-scoped persistence into ACP would have silently - overwritten a resumed session's own remembered provider (`AcpSessionConfigSnapshot`, #5373) - with another session's channel-wide preference. `Agent::restore_channel_provider` now skips - the channel-wide restore whenever a caller has already primed an explicit provider override, - so a session-specific choice always takes priority. - - ACP's native `/help` rendered a hardcoded 5-command string instead of the real 49-command - registry, and `/model refresh` errored instead of refreshing the model cache (#5986). `/help` - now renders from the same command registry the CLI/TUI use; `/model refresh` refreshes the - session's active provider's model cache instead of failing. - - Automatic code-RAG context retrieval and repo-map/`IndexMcpServer` injection - (`[index] enabled`) were wired only in `src/runner.rs`; ACP and the daemon (A2A server) never - received repo context regardless of configuration (#6022). Both entry points now wire it the - same way the CLI does. -- `zeph-scheduler`: closed two RTW-A re-entry-defense gaps (#6120, #6119). - - Mechanism 4 (capability attenuation) hardcoded `matches!(task.kind, TaskKind::UpdateCheck)` - as the only way to mark a tick "external-read", so `SkillRefresh` tasks and operator-registered - `TaskKind::Custom` handlers (e.g. `zeph-memory`'s `five_signal_consolidation` daemon, which - re-surfaces stored facts that may themselves carry externally-sourced content) were invisible - to the mechanism regardless of what they actually read (#6120). `TaskHandler` gained a - `reads_external_content()` method (default `false`); the scheduler's tick loop now attenuates - based on the resolved handler's declaration instead of the task's `kind`. Overridden to `true` - on `UpdateCheckHandler` and `ConsolidationHandler`; any current or future handler (including - `Custom`-kind ones) can opt in the same way without touching `zeph-scheduler` internals. - Attenuation was also only ever *consumed* on the no-handler-registered fallback path - (`inject_custom_task`), so the production `CustomTaskHandler` — registered under - `TaskKind::Custom("custom")` and feeding the same agent-facing prompt channel from inside its - own `execute()` — bypassed suppression entirely, the exact scenario #6120's attack description - named. `TaskHandler` gained a second method, `injects_agent_prompt()` (default `false`, - overridden to `true` on `CustomTaskHandler`); the tick loop now suppresses any handler that - declares it whenever an earlier task in the same tick already read external content, closing - the production dispatch path alongside the existing fallback. - - Mechanism 3 (injection-pattern detection) matched `INJECTION_PATTERNS` via plain - case-insensitive substring search, and the pre-check cleaning step only stripped ASCII - control characters below `U+0020` — a zero-width space or other Unicode format character - inserted mid-pattern (e.g. `"sy\u{200b}stem:"`) defeated `.contains("system:")` while still - reading as `"system:"` to an LLM tokenizer (#6119). `sanitize_task_prompt_checked`/ - `sanitize_task_prompt` now route their cleaning step through - `zeph_common::sanitize::strip_control_chars_preserve_whitespace`, which also strips the - shared bypass-codepoint denylist (zero-width spaces, soft hyphens, BOM, Hangul/Khmer/Mongolian - fillers, the Unicode Tags block) — the same defense already used by `zeph-memory`'s community - summarization pipeline — before truncating to 512 code points, so padding attacks cannot hide - a pattern past the truncation window either. `zeph-common` is now a required (non-optional) - dependency of `zeph-scheduler` rather than gated behind the `daemon` feature. - -### Security - -- `zeph-plugins`: the HTTPS-downgrade-redirect protection in `registry.rs` - (a `reqwest::redirect::Policy::custom` that rejects any redirect leaving the `https` - scheme) previously guarded only `add_remote_ephemeral`'s session-scoped install path. - `PluginManager::add_remote` (permanent plugin install) and `download_archive` (used by - the unattended `check_auto_updates`/`update_one_plugin` auto-update path, which runs on - every process startup for any plugin with `auto_update = true`) both used the bare - `reqwest::get(url)` global client instead, following up to 10 redirects with no - scheme-downgrade restriction (#6099). The anti-downgrade client construction is now a - shared `https_safe_client()` helper used by all three archive-download call sites. -- `zeph-plugins`: `PluginManager::add_remote` and `download_archive` called `response.bytes()` - unconditionally with no upper bound on body size, allowing a malicious or compromised host to - exhaust process memory (#6108). Unlike `download_and_extract`, which already rejected an - oversized `Content-Length` before reading the body, these two call sites had no such check — - and `download_archive` backs the unattended `check_auto_updates` path that runs on every - process startup for any plugin with `auto_update = true`. All three call sites now share a - single `fetch_archive_bytes` helper that rejects a declared `Content-Length` above - `MAX_ARCHIVE_BYTES` (52 MiB) before reading the body, removing the duplicated inline check - that previously lived only in `download_and_extract`. -- `zeph-mcp`: `PinningOAuthHttpClient` (#6074) resolved, SSRF-validated, and DNS-pinned - the target host of every OAuth HTTP request, but its underlying `reqwest::Client` - only disabled auto-following redirects for `OAuthHttpRedirectPolicy::Stop` requests - — for `Follow` (dynamic client registration is rmcp's only current caller), reqwest's - own redirect-following stayed active, so a `3xx` response pointing at a *different* - host had that hop resolved independently and unpinned by reqwest itself, reopening a - redirect-scoped DNS-rebinding TOCTOU (#6089). `build_client` now disables redirects - unconditionally, and `execute()` follows `Follow`-policy redirects manually via a new - bounded loop (`MAX_OAUTH_REDIRECT_HOPS = 10`) that re-runs the identical - validate-and-pin step for every hop, mirroring standard redirect semantics (`303` - always downgrades to `GET`; `301`/`302` downgrade a `POST` to `GET`; `307`/`308` - preserve method and body). The manual reimplementation also now mirrors reqwest's - cross-origin header handling: `Authorization`, `Cookie`, `Proxy-Authorization`, and - `WWW-Authenticate` are dropped when a redirect hop's scheme, host, or port differs - from the previous hop's (a validated, SSRF-safe redirect target only proves it isn't - a private address — it can still be attacker-controlled, so credentials must not - follow it cross-origin), and `Content-Length`/`Content-Type`/`Content-Encoding` are - dropped whenever the body is emptied by a method downgrade. -- `zeph-scheduler`: `Scheduler::init()`'s DB-hydration loop read `TaskProvenance` verbatim from - the writer-controllable `scheduled_jobs.provenance` column, so a direct-SQL / out-of-process - writer could self-label a row `"static"` or `"user_added"` to dodge the RTW-A re-entry defenses - (#6114). This is latent hardening, not an active bypass fix — hydration only ever loads - periodic rows with `Value::Null` config, and the provenance-gated injection check only fires - for oneshot+`Custom` tasks, so no hydrated row reaches it today. `init()` now forces - `TaskProvenance::External` on every hydrated row regardless of the stored label, latching the - invariant that a row not written by this process's trusted in-session path is untrusted. -- `zeph-mcp`: closed three gaps in the MCP tool trust pipeline (#6071, #6072, #6073). - - `sanitize_tools`'s depth-cap drop path (see #6068 below) dropped unsanitizable - `input_schema`/`output_schema` content beyond `MAX_SCHEMA_DEPTH` but never incremented - `SanitizeResult::injection_count`, so `apply_injection_penalties` early-returned and a - server nesting an injection payload 11+ levels deep evaded both sanitization *and* the - trust-score penalty / `registration_injection` audit warning (#6071). Both depth-cap - drop sites now count as an injection. `SanitizeResult::input_schemas_dropped` and - `output_schemas_dropped` were also write-only — added to `ServerConnectOutcome` / - `zeph-core`'s `McpServerStatus` and surfaced in the TUI's MCP server status line - (`schema-drop:N`) alongside the existing connected/tool-count indicator. - - MCP tool schema-drift detection (the "rug-pull" mitigation documented in - `attestation.rs`) never fired: `apply_attestation()` hardcoded `previous_fingerprints` - to `None` on every call, so the reconnect-comparison branch in `attest_tools()` was - dead code outside its own unit test (#6072). `McpManager` now caches each server's - tool fingerprints (`server_fingerprints`, populated only when `expected_tools` is - configured — attestation must be enabled for drift detection to run) and threads them - into `attest_tools()` on every reconnect and `tools/list_changed` refresh, so a tool - description/schema that silently changes between sessions now logs a - `tracing::warn!`. Trust/filtering decisions are unchanged — this is detection only. - - `TrustScoreStore::load_and_apply_delta` — the only write path used in production, - gating `Trusted`/`Untrusted`/`Sandboxed` classification — was a non-atomic - read-then-write: it called `load()` (decay-aware) and then issued an unconditional - `UPDATE SET score = excluded.score`, so two concurrent callers for the same - `server_id` could both read the same pre-update score and the second writer's - unconditional overwrite would silently clobber the first's delta (#6073). It's now a - single atomic `INSERT ... ON CONFLICT DO UPDATE` that recomputes the asymmetric - time-decay from the stored `updated_at_secs` entirely inside the SQL expression - (`CASE WHEN` + dialect `LEAST`/`GREATEST`), so the whole - read-decay-delta-clamp-write sequence is one atomic row-level operation. The former - `apply_delta()` (atomic but decay-blind, unused in production) is removed — both - properties now live in one method, eliminating the two-divergent-write-paths root - cause. -- `zeph-common`: consolidated three duplicated/diverging security-sanitization - implementations into single canonical sources, closing real defense-in-depth gaps - (#5925, #5915, #5917). `zeph_common::sanitize::strip_control_chars` and - `strip_control_chars_preserve_whitespace` previously stripped only ASCII controls plus - `BiDi` overrides — missing zero-width space/joiners, soft hyphen, BOM, Hangul/Khmer/ - Mongolian fillers, and the Unicode Tags block that `zeph_common::patterns:: - strip_format_chars` already covered. Both now share a single bypass-codepoint denylist - (`patterns::is_bypass_codepoint`), so `zeph-memory`'s graph-resolver `sanitize_fact`/ - `sanitize_relation` (LLM-extracted entity/relation text stored in the graph and later - replayed into community-summarization prompts) transitively gain the stronger coverage - (#5925). `zeph-memory::graph::community`'s local `scrub_content` (only 4 filtered - categories) is removed; both call sites now use `strip_format_chars` directly (#5915). - `zeph-core::redact`'s `SECRET_PREFIXES`/`PATH_REGEX` and `zeph-memory::store:: - compression_guidelines`'s `SECRET_RE`/`PATH_RE` duplicated the same prefix list - character-for-character while drifting apart — `zeph-memory` had gained `Authorization: - Bearer` header and standalone-JWT redaction that `zeph-core` lacked. A new - `zeph_common::secrets` module is now the single source of truth for - `SECRET_PREFIXES`/`PATH_PREFIXES`/`BEARER_TOKEN_PATTERN`/`JWT_PATTERN`; both crates build - their own `regex::Regex` from it (matching the existing `zeph_common::patterns` - raw-pattern convention), and `zeph-core::redact::redact_secrets`/`scrub_content` now also - redact Bearer headers and JWTs (#5917). -- `zeph-mcp`: `sanitize_tools` walks `input_schema` and `output_schema` with the same - recursive walker, which enforces `MAX_SCHEMA_DEPTH` (10) by returning without sanitizing - the subtree at all once the cap is hit — no injection-pattern check, no truncation. - `output_schema` correctly dropped the whole field on a depth-cap hit, but `input_schema` - did not: `input_depth_cap` was computed and then never read, so an untrusted/compromised - MCP server could nest an injection payload 11+ levels deep and have it pass through - completely unsanitized straight into the LLM system prompt (`input_schema` is always - present, unlike the optional `output_schema`, and is always rendered verbatim by - `zeph-tools::registry::format_schema_params`) (#6068). `input_schema` is now dropped to - an empty object on a depth-cap hit, mirroring the existing `output_schema` handling - exactly, and the drop is tracked via a new `SanitizeResult::input_schemas_dropped` - counter. A related pre-existing gap — depth-cap drops on either schema field never - increment `injection_count`, so `apply_injection_penalties` never fires a trust-score - penalty or audit log for depth-cap evasion specifically — is tracked separately in #6071. -- `zeph-core`: `load_skill` (`SkillLoaderExecutor`) had no trust check at all — it read the raw - `SKILL.md` body straight from `SkillRegistry::body` and returned it verbatim, bypassing the - entire skill-trust defense-in-depth pipeline that `invoke_skill` already enforced: `Blocked` - skills were never refused, `Quarantined`/non-Trusted bodies were never sanitized or wrapped, - and the LLM-supplied `skill_name` was echoed unsanitized into the not-found error. The turn- - level `TrustGateExecutor` gate does not close this gap — it only denies `load_skill` when the - turn's folded `effective_trust` is Quarantined, and never inspects the `skill_name` argument - itself, so a `load_skill` call naming a Blocked/Quarantined skill sailed through on any turn - where the active skill set wasn't already Quarantined (#6050). `SkillLoaderExecutor` now shares - the exact same trust pipeline as `SkillInvokeExecutor` via a new `SkillTrustGate` (extracted - into `crates/zeph-core/src/skill_trust_gate.rs`, #6049): Blocked is refused before any body - read, non-Trusted bodies are sanitized, Quarantined bodies are additionally wrapped, the - per-invocation blake3 integrity re-check now also applies to `load_skill`, and `skill_name` is - sanitized on every output path (found, blocked, and not-found). `SkillLoaderExecutor::new` now - takes the same `trust_snapshot` `Arc` as `SkillInvokeExecutor`; a new - `agent_setup::build_skill_executors` helper constructs both executors around one shared `Arc` - so `load_skill` and `invoke_skill` can no longer observe divergent trust state or be wired up - independently, replacing five duplicated inline construction sites across `src/runner.rs`, - `src/acp.rs`, and `src/daemon.rs` (prod and test). -- `zeph-mcp`: closed a DNS-rebinding TOCTOU gap in the HTTP transport SSRF guard - (#6057). `validate_url_ssrf()` resolved and validated the target hostname as a - standalone pre-flight check, then discarded the result — the actual connection - (`connect_url`, `connect_url_with_headers`, `connect_url_oauth`, including both the - cached-token fast path and the post-callback `complete_oauth` path) performed its - own independent DNS resolution moments later via a bare `reqwest::Client::default()` - with the default redirect policy. An attacker controlling DNS for the target - hostname could pass validation with a public IP, then rebind to a private/internal - address before the transport's later resolution, or simply 3xx-redirect the request - toward an internal target. All three connect paths now call a new - `validate_and_pin_url()` helper that resolves the hostname once via - `zeph_common::net::resolve_and_validate` and threads the exact validated addresses - through to a hardened `reqwest::Client` (`resolve_to_addrs` + `Policy::none()`), - eliminating both the re-resolution window and the redirect bypass. The OAuth flow's - `OAuthPending` now carries the addresses pinned at the start of - `connect_url_oauth` through to `complete_oauth`, since re-resolving after the user's - browser interaction (unbounded duration) would reopen the same race. `connect_url_oauth` - also now routes `AuthorizationManager`'s internal OAuth HTTP traffic (metadata - discovery, token exchange, refresh) through the same SSRF-pinned client via - `OAuthState::new(url, Some(hardened_client))`, instead of letting it build its own - default, unpinned `reqwest::Client` internally — closing the same DNS-rebinding - window for OAuth requests to the original server host (#6069, sibling of #6057, - same PR). Note: `Policy::none()` is applied unconditionally, so `trusted` (operator - static-config) servers also stop auto-following redirects — previously they inherited - `reqwest`'s default of following up to 10 — a deliberate pre-1.0 hardening, not a - regression. -- `zeph-mcp`: closed the residual cross-origin discovered-issuer OAuth SSRF/DNS-rebinding - TOCTOU that #6069's single-host `resolve_to_addrs` pinning could not cover — per SEP-985, - `token_endpoint`, `authorization_endpoint`, `jwks_uri`, and `registration_endpoint` can - legitimately live on a different host than the MCP server itself, so `AuthorizationManager` - fell back to its own independent, unpinned DNS resolution for them. `connect_url_oauth` now - routes OAuth HTTP traffic through a new `PinningOAuthHttpClient` - (`rmcp::transport::auth::OAuthHttpClient` impl) that resolves, SSRF-validates, and DNS-pins - each request individually by its own target host at execution time, rather than reusing a - client pinned to the original server's host (#6074). -- `zeph-mcp`: `McpClient::connect_url_oauth`'s cached-token fast path and - `complete_oauth`'s post-callback path now wrap `handler.serve(transport)` in - `tokio::time::timeout`, matching every other connect entry point - (`connect_stdio`, `connect_url`, `connect_url_with_headers`); previously these two - sites could hang indefinitely if the server never responded (#6064). -- `zeph-durable`/`zeph`: `zeph_durable::encryption_gate` (the documented INV-8 AEAD enforcement - policy) is now actually invoked at runtime — previously it was a unit-tested pure function that - no call site ever reached, so `src/commands/durable.rs::load_write_cipher` (the durable journal - write path) and `open_backend` (the `zeph durable` CLI read path, including `--reveal`) each - made their own cipher decision by checking only `[durable] encrypt_payload`, ignoring backend - and shared-database status entirely. A deployment with `encrypt_payload = false` on a durable - journal database reachable by more than one process/client (e.g. a shared volume, or a future - Postgres-backed deployment) would silently persist tool outputs and agent-turn state in - plaintext with zero warning and zero error (#5996). Both call sites now evaluate - `encryption_gate` before making the cipher decision: `encrypt_payload = false` combined with a - non-local backend or a shared database now fails closed with a hard error at startup / on the - CLI command, and the permitted single-user local override now emits the documented startup - `tracing::warn!`. Added a new `[durable] shared_db` config field (default `false`) so operators - can declare a shared-database deployment explicitly; a `postgres://`/`postgresql://` resolved - journal URL is also treated as shared automatically, as defense in depth. The TUI durable panel - poller (`durable_poll_task`, feature `tui`) now evaluates the same policy before opening the - journal — previously it bypassed the gate entirely, so the TUI panel could render a journal the - `zeph durable` CLI refused to open; a rejection now degrades gracefully to a distinct - `GateRejected` panel status instead of erroring (see #6041 below for why it no longer reuses - the plain "non-durable mode" state). -- `zeph-commands`: 19 privileged slash-command handlers now override `requires_auth()` to - return `true`, closing a trust-gate gap where they ran the default `false` and were therefore - reachable from untrusted remote channels (Telegram/Discord/Slack) as well as trusted local - sessions (#6003). Gated handlers: `UndoCommand`/`RedoCommand` (`/undo`, `/redo` — mutate the - on-disk working tree), `PlanCommand` (`/plan` — executes tools/shell via orchestration), - `SkillCommand` (`/skill` — installs/removes/trusts executable skills), `FeedbackCommand` - (`/feedback` — writes persistent self-learning input), `KnowledgeSlashCommand` (`/knowledge` — - unconfirmed destructive `rollback`), `AgentCommand`/`AgentsFleetCommand` (`/agent`, `/agents` — - spawn/mutate sub-agent definitions), `ConvCommand` (`/conv`, feature `session` — session - hijack/cross-session disclosure via `resume`/`fork`), `MemoryCommand`/`GraphCommand` - (`/memory`, `/graph` — mutate semantic memory / knowledge graph, LLM cost), `GoalCommand` - (`/goal` — mutates persisted goal FSM, can drive autonomous execution), `AcpCommand` (`/acp`, - feature `acp` — discloses ACP allowlist/auth/bind-address config), `CocoonCommand` (`/cocoon`, - feature `cocoon` — discloses sidecar state and TON balance), `CompactCommand`/ - `NewConversationCommand` (`/compact`, `/new` — mutate conversation state, LLM cost/DoS), and - `ClearCommand`/`ResetCommand`/`ClearQueueCommand` (`/clear`, `/reset`, `/clear-queue` — - remote wipe of the operator's live session). `RecapCommand`, `SkillsCommand`, - `GuidelinesCommand`, `ExitCommand`, `QuitCommand`, and `HelpCommand` were audited and left at - the default (read-only, or already self-gated via `supports_exit()`). The - `CommandHandler::requires_auth()` default value itself is unchanged — revisiting the default - is deferred to a follow-up issue. -- `zeph-db`: `redact_url` no longer leaks the tail of a Postgres password containing `@` (#5969). - The previous regex (`://[^:]+:[^@]+@`) stopped at the first `@`, so - `postgres://user:p@ss@host/db` only redacted up to `p`, leaking `ss@host` verbatim into - `DbError::Connection` and CLI error output (`src/commands/db.rs`). Replaced with - `url::Url`-based userinfo parsing, which splits on the *last* `@` before the authority ends — - matching real client behavior — so passwords/usernames containing `@`, multiple `@`, and IPv6 - hosts are all handled correctly. Also now recognizes two non-userinfo libpq credential forms and - redacts the whole URL for them: query-param URIs (`?password=...`) and key-value DSNs - (`host=... password=...`), neither of which the old regex covered at all. -- **BREAKING**: `vault.backend` now defaults to `age` instead of `env` when a config omits the - `[vault]` section entirely (#5953). This aligns runtime behavior with the already-documented - default in `specs/010-security/spec.md` and `specs/038-vault/spec.md` — a fresh config with no - `[vault]` section previously resolved secrets from process environment variables silently; - it now requires an age vault identity (`~/.config/zeph/vault-key.txt` and `secrets.age`, or - `--vault-key`/`--vault-path`). Existing deployments that relied on the implicit `env` default - must either run `zeph vault init` to provision an age vault, or explicitly set - `vault.backend = "env"` in `config.toml` (understanding this stores secrets in plaintext - environment variables). `config/default.toml` and `crates/zeph-core/config/default.toml` were - updated to match; no config migration step was added because the previous `env` default was - never a persisted value — only a parse-time fallback applied to configs that omit the key. -- `parse_backend_str` (the parser for the `--vault` CLI flag and `ZEPH_VAULT_BACKEND` env var) - now rejects unrecognized backend names with a hard error instead of silently falling back to - the weaker `env` backend with only a `tracing::warn!` log line (#5954). Combined with the - #5953 default change, a typo in `--vault`/`ZEPH_VAULT_BACKEND` (e.g. `--vault aeg`) previously - downgraded the effective secret-storage backend for the whole process without a startup - failure. `parse_vault_args` now returns `Result`; `AppBuilder::new` and the - `bench`/`doctor`/`gonka`/`cocoon` commands that call it propagate the error instead of - continuing with a silently-downgraded backend. - -- `zeph-config` / `zeph-llm`: removed derived `Debug` from 7 secret-bearing config structs that - printed their plaintext secret verbatim in any `{:?}` output — logs, panics, error chains - (#5952, #5963). `GatewayConfig::auth_token`, `ProviderEntry::api_key`/`cocoon_access_hash`, - `ClassifiersConfig::hf_token`, `CandleConfig::hf_token`, `CandleInlineConfig::hf_token`, - `OpenAiConfig::api_key`, and `CompatibleConfig::api_key` are now redacted (`"[REDACTED]"` in - `zeph-config`, `""` in `zeph-llm`, matching each crate's existing manual-`Debug` - precedent) by a hand-written `impl Debug` that still lists every other field. `Option` - secrets preserve the `None`-vs-`Some` distinction. `CandleInlineConfig` is embedded inside - `ProviderEntry`, so both were fixed together to avoid a transitive leak through nested `Debug`. -- `zeph-memory`/`zeph-core`/`zeph`: `SqliteStore::set_requires_trust_check` (the setter for the - per-invocation blake3 integrity re-check gated by `SkillTrustSnapshot::requires_trust_check`, - `crates/zeph-core/src/skill_trust_gate.rs`) had zero production callers anywhere in the - codebase — the column defaulted to `0` for every skill in every deployment and could never be - set to `1` by any CLI flag, config knob, or in-session command, making the entire defense - unreachable in practice despite being fully implemented and unit-tested on the consumption side - since #4306/#6062 (#6080). `zeph skill trust --require-check` and the in-session - `/skill trust --require-check` now call the setter after updating the trust - level. Separately, the CLI's `zeph skill invoke ` preview command (`src/commands/skill.rs`) - was a hand-rolled reimplementation of the trust-gating pipeline that predated #6062's - consolidation onto the shared `SkillTrustGate`: it never checked `requires_trust_check` at all, - and echoed the raw unsanitized skill name into its not-found error instead of the sanitized form - every other trust-gated path uses (#6079). `SkillTrustGate` and `SkillBodyResolution` are now - `pub` at the `zeph-core` crate root, and `SkillCommand::Invoke` calls - `SkillTrustGate::resolve_body` directly — the same pipeline `load_skill`/`invoke_skill` use — - so the CLI preview can no longer drift from the agent-facing tools. - -### Fixed - -- `zeph-channels`: Telegram's raw API extension client and Slack's Web API client had no - 429 retry-with-backoff, unlike Discord's REST client (#4728) — a rate-limited request - surfaced as a hard error on the first attempt instead of transparently retrying - (#5949). Discord's `send_with_retry` (reads the `Retry-After` header, falls back to the - JSON body's `retry_after` field, clamps to 60s, retries up to 3 times) is now a shared - `crate::common::http_retry::send_with_retry` helper used by all three adapters: - `TelegramApiClient::post`, and all four `SlackApi` methods (`auth_test`, `post_message`, - `update_message`, `download_file`). `SlackApi` gained a `base_url` field (test-only - override) for wiremock testability, and its previous per-call - `tokio::time::timeout(15s, ...)` was replaced with a per-attempt `.timeout(15s)` on the - `RequestBuilder`, since an outer timeout is incompatible with a retry loop that - legitimately needs to run longer than one request when backing off — worst-case - wall-clock under sustained 429 is now bounded by attempts x per-attempt timeout plus - backoff sleeps, documented on the helper. -- `zeph-channels`: `TelegramChannel` never implemented `Channel::send_status`, unlike its - `DiscordChannel`/`SlackChannel` peers, so Telegram users got no visibility into any of - the ~15 background/implicit-operation status messages (skill reload, MCP elicitation, - memory recall, etc.) that Discord/Slack users see — only the generic `typing…` chat - action (#5923). `TelegramChannel::send_status` now posts the status text as a plain-text - message, mirroring the Discord/Slack pattern, and no-ops when the text is empty, there is - no active chat, or the channel is in a Guest Mode context (a guest reply can only be sent - once via `answerGuestQuery`). -- `zeph-core`: config hot-reload (`Agent::reload_config`) bypassed `Config::validate()` entirely - — `load_config_with_overlay` called `Config::load` plus the plugin overlay merge and returned - the result straight to the live agent without ever validating it, unlike the startup path - (`src/bootstrap/mod.rs`, `src/tui_remote.rs`), which always calls `.validate()` immediately - after `Config::load()`. An invalid config edited into the running config file (empty/duplicate - LLM providers, inverted ACON/fidelity/trajectory thresholds, etc.) was silently applied to the - live runtime on the next reload instead of being rejected (#6063). `load_config_with_overlay` - now calls `config.validate()` on the fully-assembled config (after the plugin overlay merge, so - overlay-introduced invalid values are also caught) and returns `None` on failure, following the - same warn-and-keep-previous-state pattern already used for the `Config::load` and overlay-merge - error branches in the same function. -- **BREAKING**: `ToolExecutor` and `ErasedToolExecutor` no longer provide permissive default - bodies for the six risk-bearing cross-cutting methods — `requires_confirmation`, - `execute_tool_call_confirmed`, the checkpoint trio (`checkpoint_undo`/`checkpoint_redo`/ - `checkpoint_list`), and `is_tool_speculatable`, plus their `_erased` counterparts on the - object-safe trait (#6019). This recurring defect class — a wrapper forgetting to override one - of these and silently inheriting a default that disabled a security check, a checkpoint - capability, or a confirmation gate — required five prior one-off patches (#5930, #6011, #5998, - #6036, and the #5999/#6001/#6012 cluster). Every implementor of either trait must now supply - all six explicitly; the compiler rejects any omission at build time instead of the gap - surfacing only when a specific wrapper composition is exercised. Four new `macro_rules!` - helpers in `zeph-tools::executor_delegate` (`tool_executor_forward!`, - `tool_executor_no_inner_defaults!`, `erased_tool_executor_forward!`, - `erased_tool_executor_no_inner_defaults!`) keep the compiler-forced boilerplate to one line for - the common wrapper-forwards-to-inner and leaf-has-no-inner shapes; both `*_no_inner_defaults!` - macros carry a rustdoc warning against use on a type with a delegate field, since - `macro_rules!` cannot enforce that structurally. Closes three live gaps on the erased side - (previously dormant, gated on whether the wrapped executor supports checkpoints): the removed - `execute_tool_call_confirmed_erased` default already forwarded correctly - (`self.execute_tool_call_erased(call)`), so omitting it was not itself a live bug — the actual - gaps were the checkpoint trio and `is_tool_speculatable_erased`, whose removed defaults silently - reported "unsupported"/`false` regardless of the wrapped executor's real capability. - `FilteredToolExecutor` and `PlanModeExecutor` (`zeph-subagent::filter`) now forward the - checkpoint trio and `is_tool_speculatable_erased` to their inner executor; `PlanModeExecutor` - also now forwards `set_effective_trust`, which previously no-op'd silently, so a trust - cap set by an outer wrapper (e.g. `PolicyGateExecutor`) never reached the underlying tool while - a sub-agent was in plan mode. `PlanModeExecutor`'s checkpoint trio now intentionally forwards to - `inner` rather than staying "unsupported" — plan mode blocks new tool *execution* but checkpoint - undo/redo/list are metadata/administrative operations on already-executed side effects, not new - execution, so this is a deliberate scope decision, not an oversight. - `execute_tool_call_confirmed_erased` was nonetheless hand-written (not macro-forwarded) on all - three wrappers now that the trait requires it explicitly: `FilteredToolExecutor` and - `PlanModeExecutor` delegate to their own `execute_tool_call_erased` to preserve policy - enforcement / the execution block; `MemoryAwareExecutor` (`zeph-subagent::manager::spawn`) - replicates the `SandboxViolation` -> memory-tool fallback already present on its unconfirmed - path, since a blind forward to `inner` would have dropped that fallback specifically on the - confirmed path. `ShellExecutor` gained explicit - `requires_confirmation`/`execute_tool_call_confirmed`/`is_tool_speculatable` (previously - relying on the removed defaults despite implementing real checkpoints). -- `fix(config)`: `Config::validate()` now calls 7 subsystem `validate()` functions that existed - with real invariant checks but were unreachable from the production config-load path — only - their own unit tests exercised them (#5932). `validate_pool()` was the most severe gap: two - code comments elsewhere in the codebase state, verbatim, that it "rejects an empty - `[[llm.providers]]` list at config-validation time" and treat that as a load-bearing guarantee, - but it was never actually invoked outside its own test module — a config with zero providers, - duplicate provider names, or multiple `default = true` entries previously loaded and validated - without error. The other six wired checks: `LlmConfig::validate_stt()` (dangling - `[llm.stt].provider` reference), `TrajectorySentinelConfig::validate()` (inverted risk - thresholds), `GatewayConfig::validate()`, `UtilityScoringConfig::validate()`, - `FidelityConfig::validate()`, and `AconConfig::validate()`. Also added `#[must_use]` to - `LlmConfig::validate_stt()` (missed by the earlier #4943/#4963 sweep since added afterward). - Because `Config::default()` had an empty provider pool, wiring `validate_pool()` made - `Config::default().validate()` fail, which in turn broke `--dump-config-defaults` and the - no-config-file fallback in `zeph --tui --connect`; fixed by seeding `Config::default()` with one - `ProviderEntry::default()` (`type = "ollama"`), matching the shipped `config/default.toml` - reference, which was already self-consistent. -- `fix(config)`: two more `--migrate-config --in-place` steps re-appended their advisory block on - every run instead of converging after one, same defect class as #5945 (#6018). The - `mcp`/`mcp.elicitation`/`mcp` max-connect-attempts/retry-and-tool-timeout steps anchored on a - raw `toml_src.contains("[mcp]\n")` substring, which also matches inside a *commented* `# [mcp]` - stub left by the top-level migrator's catch-all pass when `[mcp]` was absent on a prior run — - now gated by `section_header_present(toml_src, "mcp")`, which correctly excludes commented - headers. `migrate_memory_retrieval_query_bias`'s idempotency guard checked for an *uncommented* - `query_bias_correction` field while the step only ever writes a *commented* advisory line, so - the guard never matched its own prior output; changed to a `contains` check on the raw source - (and switched to `section_header_present` for its section-presence check, for consistency with - the sibling `[memory.hebbian]` steps fixed in #5945). -- `fix(tui)`: the durable executions overlay panel (`D` key) no longer bleeds stray glyphs from - whatever widget last drew into the same sidebar `Rect` earlier in the frame (e.g. a trailing - `e> t` fragment left over from the subagents/plan/security view rendered underneath) — `Clear` - was missing before the panel's own draw calls, unlike the other 8 overlay widgets in this - crate that already blank their area first (#6048). Also, `encryption_gate` (INV-8) rejecting - the deployment's configuration is now visually distinguishable from a plain "journal - unavailable" state: the panel previously collapsed both into the identical - `STATUS_UNAVAILABLE` message, so an operator had no way to tell a security-policy rejection - from durable execution simply being unconfigured; `DurableSnapshot.available: bool` is - replaced with a `DurableStatus` enum (`Unavailable` / `GateRejected` / `Available`) and gate - rejections now render the distinct `STATUS_GATE_REJECTED` message (#6041). -- `fix(tui)`: the Fleet and Task Registry sidebar overlay panels (`f`/task-registry keys) had the - same missing-`Clear` stray-glyph bug as the durable panel above (#6048) — neither called - `Clear` before drawing into their shared sidebar `Rect`, so leftover glyphs from whatever - widget last rendered into that area could bleed through. Both now clear their `Rect` first, - matching the crate-wide overlay convention (#6054). Also, the Fleet panel's session table - rendered its KIND/STATUS/CH columns with no separating whitespace (e.g. - `interactiveactivetuigpt-4o-mini`) because `SessionKind`/`SessionStatus`/`SessionChannel` - implemented `Display` via `Formatter::write_str`, which silently ignores the width/fill/align - flags carried by `fleet.rs`'s `{: Checker MARCH - response-quality verification, `config.quality.self_check`) is now built and attached via - `Agent::with_quality_pipeline` in all four entry points, extracted into a shared - `agent_setup::build_quality_pipeline` helper so the four call sites cannot diverge (#5951). - `TrajectorySentinel` (spec-050 risk-escalation state machine, `[security.trajectory]`) is now - wired via `Agent::with_trajectory_config`/`with_trajectory_risk_slot`/`with_signal_queue` in - ACP (per session), daemon, and serve (per session); the paired - `PolicyGateExecutor::with_trajectory_risk`/`with_signal_queue` and - `ScopedToolExecutor::with_signal_queue` plumbing (already threaded through - `agent_setup::apply_policy_gate_chain`'s `trajectory` parameter, added by #5978's PR in - anticipation of this fix) is now populated in all three entry points — serve's globally-shared - `ScopedToolExecutor` (built once in `assemble_serve_deps`, before any per-session queue - exists) is a documented exception: its `OutOfScope` denials do not feed the trajectory signal - queue, since doing so would leak one session's signals into every other session sharing that - executor (#5958). `SkillInvokeExecutor` (the `invoke_skill` tool — per-invocation trust check - including a blake3 integrity re-check and fail-closed refusal on `Blocked`/quarantine-denied - classification) is now constructed and registered as a tool executor, with its trust-snapshot - `Arc` also threaded onto the `Agent` via `with_trust_snapshot`, in ACP and daemon — previously - `invoke_skill` was effectively CLI-only (#5975). -- `fix(worktree)`: `WorktreeManager::reconcile()` no longer silently drops detached-`HEAD` - worktrees (#5936). `git worktree list --porcelain` emits a `detached` line instead of - `branch refs/heads/` for these worktrees; the porcelain parser previously only flushed a - parsed block when both a `worktree ` line and a `branch` line were seen, so detached - entries were discarded and became permanently invisible to `zeph worktree list`/`zeph worktree - clean`. A block is now flushed whenever its `worktree ` line is seen, and detached - entries get the new `zeph_worktree::DETACHED_BRANCH_SENTINEL` (`"(detached HEAD)"` — the - embedded space makes it an invalid git ref name, so it can never collide with a real branch, - including one on a worktree foreign to zeph) as their `branch_name`; `WorktreeManager::remove` - skips the `git branch -D` step for this sentinel since there is no real branch to prune, while - the `git worktree remove --force` path is unaffected (it operates on `path`, not - `branch_name`). -- `fix(worktree)`: `zeph worktree clean` now runs `git worktree prune` after removing stale - entries, per spec-063 FR-CLEANUP-04 (#5937). Previously it only issued `git worktree remove - --force` for entries discovered by `reconcile()`, leaving any stale administrative files (e.g. - from a worktree directory deleted outside Zeph) in the git registry. Added - `WorktreeManager::prune()`. -- `fix(worktree)`: `DefaultGitRunner` now clamps `git_timeout_secs = 0` (and any sub-second - timeout) to a 1-second floor inside `new()`/`with_timeout()` itself, per spec-063's NEVER - invariant (#5939). Previously the clamp was duplicated ad hoc at two call sites - (`src/runner.rs`, `src/commands/worktree.rs`) and the crate's own `Default` impl bypassed both, - so `DefaultGitRunner::default()` (and any other future call site) could still construct a - zero-timeout runner where every `git` invocation failed instantly. Both call-site `.max(1)` - duplications were removed now that the crate enforces the invariant internally; the - `git_timeout_secs` doc comment on `WorktreeConfig` was corrected to say so. -- `fix(worktree)`: `zeph worktree clean` no longer force-removes another, concurrently running - zeph session's worktree (#6055). `WorktreeManager::reconcile()` classified "stale" as simply - "not in this process's own in-memory `handles`", which is unconditionally empty for the - fresh, one-shot `WorktreeManager` that `zeph worktree clean` constructs per CLI invocation — - so every other git-registered worktree, including one with live uncommitted work belonging to - a separate session, was force-removed via `git worktree remove --force` with no dirty-tree - check. `reconcile()` now returns `Vec` (**breaking**: was - `Vec`) — each entry carries git's own `prunable ` porcelain-output - verdict, captured by a rewritten `parse_worktree_list_porcelain`, alongside the resolved - `WorktreeHandle`. `StaleWorktree::is_safe_to_force_remove()` is `true` only when git itself - reports the worktree's directory or `.git` gitdir-link as gone/broken — the one condition - under which force-removal cannot discard live work, regardless of which process created the - worktree. `zeph worktree clean` now skips (with a warning) any stale entry that is not - `prunable`, unless the new `--force` flag is passed; it prints a `Removed N, skipped M` - summary. `zeph worktree list` now surfaces each stale entry's prunable reason, or an - "in use — not marked prunable" note, so operators can decide before running `clean --force`. - `WorktreeManager::remove()` itself is unchanged — it still issues a single `--force`, which - correctly does not override an explicit `git worktree lock`. -- `fix(worktree)`: `parse_worktree_list_porcelain` no longer mislabels a bare repository's main - worktree as detached-`HEAD` (#6052, found during #6051 review). `git worktree list --porcelain` - emits a `bare` line (no `HEAD`/`branch`/`detached` line at all) for these entries; they now get - the new, distinct `zeph_worktree::BARE_WORKTREE_SENTINEL` (`"(bare repository)"`) instead of - `DETACHED_BRANCH_SENTINEL`. -- `fix(tools)`: `CompressedExecutor`, `ToolFilter`, and `Arc` now forward the - remaining cross-cutting `ToolExecutor` methods to their inner/wrapped executor instead of - silently falling through to the trait's no-op defaults (#6012). `CompressedExecutor` now - forwards `requires_confirmation` and the `checkpoint_undo`/`checkpoint_redo`/`checkpoint_list` - trio. `ToolFilter` (wrapping the ACP `FileExecutor`) previously forwarded none of the - cross-cutting methods — it now forwards `execute_tool_call_confirmed` (respecting tool - suppression), `set_skill_env`, `set_effective_trust`, `is_tool_retryable`, - `is_tool_speculatable`, `requires_confirmation`, and the checkpoint trio. `Arc` - now also forwards `execute_confirmed`, `execute_tool_call_confirmed`, `set_effective_trust`, - `is_tool_retryable`, `is_tool_speculatable`, and `requires_confirmation` — the - `execute_confirmed` forward closes a currently-dormant gap (its only caller today, - `handle_confirmation_required` in `tool_result.rs`, is `#[cfg(test)]`-gated; production - confirmation dispatch goes through `execute_tool_call_confirmed` via `tier_loop.rs` instead) - but is worth fixing now as defense-in-depth, matching the pattern of every other wrapper, in - case that path is ever re-enabled. Same defect class as #5899/#5905/#5906 (fixed by #5930) and - #5900/#5938/#5931 (fixed by #6011). -- `fix(tools)`: `capture_snapshot_for` no longer silently drops a checkpoint for a - newly-created file whose path lives under a symlinked `allowed_paths` prefix on macOS (e.g. - `/tmp` -> `/private/tmp`, `/var` -> `/private/var`) (#5999). For a file that does not exist - yet, `canonicalize()` fails, and the previous fallback (`std::path::absolute`) does not - resolve symlinks, so the file's path stayed under the raw prefix while `allowed_paths` - (canonicalized at construction time) held the resolved prefix — the containment check failed - and the checkpoint was dropped with only a `tracing::warn!`. Both `capture_snapshot_for` and - `validate_sandbox_with_cwd` now share a new `canonicalize_or_nearest_ancestor` helper that - walks up to the nearest existing ancestor, canonicalizes it, and reattaches the non-existent - suffix. +- `test(tools)`: added regression coverage for the `ShellExecutor` checkpoint stack (#6001): + `checkpoint_redo` with no prior `checkpoint_undo` (no-op "Nothing to redo.", not a panic), + `checkpoint_list` ordering with 3 recorded checkpoints (most-recent-first, matching the + `index` field), and a multi-step undo/redo/undo sequence pinning undo-stack depth + bookkeeping. + +- `fix(serve,gateway)`: `POST /sessions/:id/prompt` and the gateway `POST /webhook` path now + dispatch recognized slash commands (e.g. `/status`) locally instead of silently forwarding + them as a full, billed chat turn (#5898, #5904). Both endpoints previously ran + `ContentSanitizer::sanitize` unconditionally before queueing the message, wrapping the text + in an `` delimiter that hid the leading `/` from the agent's dispatch + registries. Text now checked against `zeph_commands::is_recognized_command` (backed by the + crate's own `COMMANDS` registry) before sanitization; a match is forwarded raw so the + existing dispatch/authorization path (`CommandHandler::requires_auth` + `trusted`, already + used identically for Telegram/Discord/Slack) decides whether it may run. Non-command text is + sanitized exactly as before — no change to that path. The gateway handler + (`crates/zeph-gateway/src/handlers.rs`) now forwards a `WebhookMessage { sender, channel, + body }` instead of a pre-formatted `"[sender@channel] body"` string, so the + `"[sender@channel]"` display prefix is applied only to non-command chat text in + `forward_webhooks` (`src/gateway_spawn.rs`), which is where the command-detection decision + and sanitization both now happen. + - Security-critical follow-up caught in review before merge: `GatewayChannel` (used to merge + webhook input into the main agent when `[gateway] enabled = true` alongside a CLI/TUI + primary channel) previously delegated `supports_exit()` — the signal `zeph-core`'s turn loop + reads as `trusted` for command-dispatch authorization — unconditionally to the primary + channel. A CLI/TUI host reports `supports_exit() == true`, so once recognized commands could + dispatch at all, a bearer-token holder could run every `requires_auth` command (`/policy`, + `/mcp`, `/plugins`, ...) at the *host's* trust level. `GatewayChannel` now forces + `supports_exit() == false` for the exact turn processing a webhook-sourced message + (`recv()`-only delivery for webhook input — never opportunistically drained via `try_recv()`, + since `zeph-core`'s queue has no way to carry a per-message trust distinction across turns). + - Also caught in review: `/subagent spawn ` (external ACP process spawn) is dispatched by + `dispatch_slash_command` with no `trusted`/`requires_auth` check at all, independent of the + trust-boundary issue above — a webhook `/subagent spawn ` would have been unconditional + remote code execution on any build with `acp_subagent_spawn_fn` installed (`full`/`ide` + + `gateway`). `zeph_commands::is_recognized_command` now excludes `/subagent` + (`UNGATED_DISPATCH_COMMANDS`), so it falls back to the pre-fix sanitized/wrapped behavior — + inert, as before this PR. +- `fix(tools)`: `CompositeExecutor`, `AdversarialPolicyGateExecutor`, and `PolicyGateExecutor` + now forward `requires_confirmation`/`is_tool_speculatable`/`execute_tool_call_confirmed` to + their inner executors instead of silently falling through to the `ToolExecutor` trait's + no-op defaults (#5900, #5938, #5931). `CompositeExecutor::requires_confirmation` now + OR-forwards to both `first`/`second` leaves, mirroring the existing `is_tool_retryable`/ + `is_tool_speculatable` pattern; `CompositeExecutor::execute_tool_call_confirmed` now + first-match-wins forwards, mirroring the existing `execute_confirmed` override — without it, + a confirmation-gating executor composed inside a `CompositeExecutor` would have its + confirm-bypass silently re-run the full (still-gating) `execute_tool_call` path after the + user approved. `AdversarialPolicyGateExecutor` and `PolicyGateExecutor` now plain-delegate + `requires_confirmation` (and `AdversarialPolicyGateExecutor` also `is_tool_speculatable`) to + `self.inner`. Same defect class as #5899/#5905/#5906 (fixed by #5930) — these three wrapper + layers were explicitly scoped out of that PR to keep it minimal. +- `fix(subagent,core)`: durable sub-agent resume now replays the journaled result instead of + unconditionally re-spawning the child (#5944, spec-064 §P4). With `[durable].subagent = true`, + restarting a crashed parent whose sub-agent had already finished (and resolved its durable + promise) previously discarded that promise and spawned a brand-new child from scratch, + duplicating LLM calls and any side-effecting tool calls (git operations, GitHub issue/PR + creation, file writes) the finished child had already performed. `maybe_make_durable_seat` is + replaced by `resolve_durable_spawn_gate`, which on a resumed run performs a non-blocking check + (`zeph_durable::DurableContext::take_resolved_promise`, exposed via the new + `zeph_subagent::try_replay_durable_subagent`) for whether the child already resolved its + promise before the crash; if so, `handle_agent_background` and `handle_agent_spawn_foreground` + skip `mgr.spawn(...)` entirely and feed the journaled `SubagentResult` through the normal + completion path instead. A still-pending resumed promise (child's fate unknown after a crash — + its resolver token is unrecoverable per INV-9) falls back to the pre-existing spawn behavior, + matching the documented v1 scope boundary in `zeph-subagent/src/durable.rs`, and now logs a + `tracing::warn!` at that fallback so the residual duplicate-spawn window is observable rather + than silent (tracked as a known v1 limitation in #6010). +- `fix(config)`: `--migrate-config --in-place` no longer duplicates advisory comment blocks or + falsely reports changes on repeated runs (#5945). `migrate_llm_stream_limits` (#4750) and the + two `[memory.hebbian]` splice steps (HL-F3/F4 #3345, HL-F5 #3346) guarded their idempotency by + checking for an *uncommented* field, but each step only ever writes a *commented* advisory + block — so the guard never matched the step's own prior output and re-appended the identical + block on every run. Guards now recognize the commented form they themselves write. + Separately, `migrate_orchestration_persistence` (#3107) and + `migrate_orchestration_asset_sensitivity` (spec-068, #3934) matched an exact + `"[orchestration]\n"` substring via `String::replacen` and unconditionally reported + `changed_count: 1` regardless of whether the replacement actually happened — `replacen` + silently no-ops when the pattern isn't found, so a header not immediately followed by a bare + newline caused a false "added" report on every run without ever writing anything. Both now + insert via the existing `insert_after_section` helper and only report a change when the output + actually differs from the input. Two same-defect-class siblings found during review are fixed + alongside these: `migrate_memory_hebbian_config` (HL-F1/F2, #3344) required an exact + `[memory.hebbian]` line match that fails against the header actually shipped in + `config/default.toml` (which carries a trailing inline comment), so it wrongly concluded the + section was absent and appended a duplicate block on the first run — this also blocked + `migrate_memory_hebbian_consolidation_config`/`migrate_memory_hebbian_spread_config` from ever + engaging against the real shipped config, since they shared the same exact-match precondition; + and `migrate_magic_docs_config` (#2702), which had the identical "guard checks for an + uncommented key the step never writes" defect as bug 1 above. +- `fix(commands)`: `/mcp` and `/plugins` now require a trusted (local) session, closing an + unauthenticated remote code execution path (#5997, CWE-284). `McpCommand` and `PluginsCommand` + previously left `requires_auth()` at its default `false`, so Telegram/Discord/Slack/gateway + callers could invoke `/mcp add [args...]` to spawn an arbitrary local subprocess + (with the shipped default `mcp.allowed_commands = ["npx", "uvx", "node", "python", "python3"]`, + e.g. `python3 -c "..."`) or `/plugins add ` to install a plugin from an arbitrary local + path — both without any authentication. Both handlers now override `requires_auth()` to return + `true`, matching the same defect class fixed for `/image` in #5967; the commands remain + available from local CLI/TUI/ACP-stdio sessions. +- `fix(config,mcp,a2a)`: three more secret-bearing config/runtime structs no longer leak + vault-resolved credentials through plain `Debug`/`Serialize` derives (#5968, #5965, #5964). + `MemoryConfig::database_url` (the resolved Postgres connection string, including embedded + username/password) is now wrapped in `zeph_common::secret::Secret` and skipped on + serialization, mirroring the existing `qdrant_api_key` field; `IbctKeyConfig::key_hex` + (`zeph-config`) and `IbctKey::key_bytes` (`zeph-a2a`) — the A2A HMAC signing key material — + now hand-write a redacting `Debug` impl that prints only `key_id`; and `McpTransport`'s + `Http.headers` and `Stdio.env` values (which commonly carry resolved MCP server bearer + tokens / API keys) are now redacted in `Debug` output while keeping header/env-var names + visible for diagnostics — `ServerEntry`'s derived `Debug` picks up the fix automatically via + per-field delegation. `Serialize` was intentionally left unchanged for all three (needed for + TOML config round-tripping); resolved secrets can still appear in a JSON serialization of + these structs, tracked as a follow-up (#6006). +- `fix(commands)`: `/image` now requires a trusted (local) session, closing a remote arbitrary + file read (#5967, CWE-284). `ImageCommand` previously left `requires_auth()` at its default + `false`, so Telegram/Discord/Slack/gateway callers — none of which are trusted by + `CommandRegistry::dispatch` — could invoke `/image ` to read any file under the server's + working directory that the process could access. `ImageCommand` now overrides `requires_auth()` + to return `true`, matching the existing `/cache-stats` and `/notify-test` handlers in the same + module; the command remains available from local CLI/TUI/ACP-stdio sessions. +- `fix(tools)`: `checkpoint_undo`/`checkpoint_redo`/`checkpoint_list` are now forwarded by + `impl ToolExecutor for std::sync::Arc` (#5985). This impl block predates the + checkpoint feature and only overrode `execute`/`tool_definitions`/`execute_tool_call`/ + `set_skill_env`, so the three checkpoint methods fell through to the trait's no-op default — + `/undo`, `/redo`, and `/undo list` always reported "Checkpoints are not enabled" in the plain + default production wiring (no `capability_scopes`/`shadow_sentinel` gating required to + reproduce), because `agent_setup.rs` wraps the shell executor in an `Arc` before building the + executor chain, so every checkpoint call resolved against the incomplete `Arc` + impl rather than `ShellExecutor`'s own correct one. This is the same defect class as + #5899/#5905/#5906 but at the leaf type's own smart-pointer shadow-impl rather than a decorator + wrapper. +- `fix(serve)`: `zeph serve-sessions` `/sessions`-created agents now enforce the same + trust/policy/adversarial-policy gate stack as the CLI, ACP, and daemon entry points + (#5973, #5977, #5886). Previously `serve/deps.rs::build_tool_executor` built only a bare + file/shell/scrape/cwd composite with none of the three gates, so a Quarantined skill's tools + and a configured `[tools.policy]`/`[tools.adversarial_policy]` deny rule were silently + unenforced for any session created via `POST /sessions`. Each session now gets its own + `TrustGateExecutor`/`PolicyGateExecutor`/`AdversarialPolicyGateExecutor` instance, wrapped + per-session in `serve/agent_factory.rs::build_agent_factory` around the shared base composite + — gating eagerly, once, at startup would let one concurrent session's trust level clobber + another's on a shared mutable trust-state atomic. The verbatim-triplicated + policy-file-load/provider-resolve/`PolicyEnforcer`-compile block previously copy-pasted + across `runner.rs`, `acp.rs`, and `daemon.rs` (source of the #5881 ordering bug) is now a + single shared helper in `src/agent_setup.rs` (`build_policy_gate_pieces` + + `apply_policy_gate_chain`), used by all four entry points. +- `fix(mcp)`: Qdrant-backed MCP tool registry now rehydrates real `input_schema` (plus + `output_schema`/`security_meta`) for semantically-matched tools instead of surfacing them to + the LLM with an empty `{}` schema (#5935). `Agent::match_mcp_tools()` no longer trusts + `McpToolRegistry::search()`'s stub schema directly — it now looks up each `(server_id, name)` + hit against the live `self.services.mcp.tools` list and substitutes the full tool definition, + dropping (with a `WARN` log) any hit that no longer has a live counterpart. This only affected + the default/recommended deployment configuration (`memory.semantic.enabled = true` with a + Qdrant backend); the in-memory `SemanticToolIndex` fallback path was never affected. +- `fix(subagent)`: the sub-agent vault-secret request/approval flow now actually resolves + and delivers the secret's real value instead of discarding it (#5941, #5942). All three + approval call sites (`scheduler_loop::process_pending_secret_requests`, + `subagent_commands::poll_subagent_until_done`, `subagent_commands::handle_agent_approve`) + now resolve the requested key against the vault-backed custom-secrets map (the same + `ZEPH_SECRET_*`-derived store used for skill `requires_secrets`) before calling + `SubAgentManager::deliver_secret`, which now carries a `zeph_common::secret::Secret` value + instead of echoing back the key name. On the sub-agent side, `agent_loop.rs` no longer + discards the received value — it is attached as a per-tool-call `ExecutionContext` env + override (scoped to the requesting sub-agent's own tool calls, not the shared + `ShellExecutor::skill_env` slot the parent agent also uses) so a subsequent shell command + can reference `$KEY_NAME`. `PermissionGrants::is_active` is now load-bearing: + `deliver_secret` refuses to hand over a value unless there is a currently active grant for + that key, closing the gap where the TTL/grant bookkeeping was never actually consulted. +- `fix(tools)`: `checkpoint_undo`/`checkpoint_redo`/`checkpoint_list` are now forwarded to the + wrapped inner executor by `TrustGateExecutor`, `PolicyGateExecutor`, + `AdversarialPolicyGateExecutor` (#5899), `ScopedToolExecutor`, and `ShadowProbeExecutor` + (#5905). Previously none of these `ToolExecutor` wrappers overrode the three checkpoint + methods, so calls fell through to the trait's no-op default instead of reaching + `ShellExecutor` — `/undo`, `/redo`, and `/undo list` always reported "Checkpoints are not + enabled" whenever trust/policy/adversarial gating, `capability_scopes`, or `shadow_sentinel` + wrapped the executor chain, even with `[tools.shell] checkpoints_enabled = true` set — the + standard, default-recommended production configuration, not an edge case. Also fixes + `ScopedToolExecutor::requires_confirmation` (#5906), previously hardcoded to the trait + default `false` regardless of the real policy underneath, affecting the (currently dormant) + speculative-dispatch engine. +- `fix(core,acp,tools)`: the five ongoing memory-maintenance sweeps + (eviction/tier-promotion/scene-consolidation/consolidation/forgetting) and the Spec 050 + `capability_scopes`/`shadow_sentinel` tool-executor wrappers previously ran only under the + CLI/TUI (`src/runner.rs`) entry point — ACP sessions (`--acp`, `serve-sessions --acp`), the + daemon (A2A server), and `/sessions*` (`serve-sessions`) silently skipped all of it regardless + of config (#5914, #5913). Memory now stays maintained (evicted, promoted, consolidated, + forgotten) identically across every entry point, gated by the same `[memory.*]` flags; when + configured, `[security.capability_scopes]` now narrows the tool surface and + `[security.shadow_sentinel]`'s pre-execution LLM safety probe now applies uniformly too, + regardless of which entry point built the agent. A misconfigured `capability_scopes` entry + (a glob matching zero registered tools) is a fatal startup error in the daemon and + `serve-sessions` entry points, same as the CLI — both build one static, process-wide tool + registry, so the misconfiguration is knowable before any session/request is served. ACP's + per-connection/per-session registry cannot be validated quite that early; a misconfigured + scope there now fails **closed** for that one session (denies all tool access) rather than + silently falling back to the unscoped executor, which would have granted full tool access on + a config typo — the opposite of what `capability_scopes` is for. Added `ToolScope::empty()` + (`crates/zeph-tools/src/scope.rs`) as the reusable deny-all primitive backing that fail-closed + path. + +### Fixed + +- **core**: `build_tier_call_futures` fired each tier's `PreToolUse` hooks sequentially, + adding `N × hook_latency` of purely serial blocking on the agent turn loop before the + tier's already-parallelized tool execution even began — the same defect class already + fixed for the `PostToolUse` side (#6128) but never mirrored to `PreToolUse`. Hooks now + fire concurrently, bounded by the tier semaphore, with the per-call invariant preserved + (each call's own hook still fires before that call's own gate check) (#6259). +- **core**: `AgentAccess::graph_backfill` extracted entities/edges from each unprocessed + message strictly sequentially — one LLM call plus SQLite/Qdrant write at a time — despite + the store's `UNIQUE(canonical_name, entity_type)` upsert already making concurrent + extraction across messages safe. Now uses `futures::stream::iter(...).buffer_unordered(4)`, + matching the existing `semantic_scan_plugin_add` pattern, cutting backfill wall time + roughly 4x with no correctness change (#6261). +- **core**: `Agent::begin_turn` re-derived the MAGE `(AuditSignalType, Severity)` pair from + the raw trajectory-signal `u8` code via an independent hand-rolled match, duplicating the + code-to-meaning table already authoritative in `RiskSignal::from_code` — the two tables + were not compiler-coupled and could silently drift. Now matches on the already-computed + `RiskSignal` enum value instead; zero behavior change (#6272). +- **Security (`ShadowSentinel`)**: `check_tool_call` awaited its two pre-tool-dispatch DB reads + (`get_trajectory`, `get_tool_history`) with no timeout, so a stalled DB connection (e.g. a + slow/unresponsive Postgres backend) could block dispatch of every `Shell`/`FileWrite`/ + `ExfilCapable`/`McpUnclassified` tool call for the whole session. Both reads are now wrapped + in `tokio::time::timeout`, bounded by the existing `probe_timeout_ms.min(2000)` (no new config + field). A timeout logs a warning and falls back to the same empty/partial trajectory the + pre-existing DB-error branch already produced — fail-open, matching `ShadowSentinel`'s + documented defence-in-depth contract; the primary `PolicyGateExecutor`/`TrajectorySentinel` + gates are unaffected and continue to run regardless (#6269). +- **Orchestration**: `PlanVerifier::verify_plan()` (whole-plan completeness verification, run + once after all DAG tasks complete) had no tool-call grounding — only the per-task `verify()` + path gained deterministic grounding against the real tool-execution trace in #6278/PR #6286, + which explicitly scoped whole-plan out with a `TODO(critic)` marker. A hallucinated + aggregated-output claim (e.g. "ran the full test suite across all tasks") could pass whole-plan + verification ungrounded. `verify_plan()` now grounds against the DAG-wide **union** of every + completed task's real `tool_trace`, rebuilt from transcripts at whole-plan-verify time by + reimplementing the same resolution logic `build_tool_trace_for_task` uses for the per-task path + (independent of whether per-task `Verify` ran for a given task — this is deliberate + defense-in-depth against a future dispatch mode that skips it). Trace + availability is all-or-nothing at the DAG level: the aggregate is `Some(union)` only if every + completed task's trace resolves; any one unavailable trace (e.g. a `RunInline` task, whose + in-loop trace is never persisted) degrades the whole aggregate to `None` and grounding fails + open, exactly reproducing prior ungrounded behavior (now with a `DEBUG` log noting the + degradation). Whole-plan grounding is strictly weaker than per-task grounding at catching a + single task's own hallucination (a claim grounds if *any* task in the plan really performed + it) — it is additive defense-in-depth, not a replacement. The trace-path resolution loop is + offloaded to `spawn_blocking` to avoid N synchronous transcript reads blocking the async + finalization path. `specs/009-orchestration/spec.md` updated with the new grounding contract, + Key Invariant, and AC-13..AC-16 (#6287). +- **Orchestration**: `execute_partial_replan_dag` (whole-plan replan execution) silently rejected + every replan attempt against a non-empty graph — i.e. always, in practice, since whole-plan + replan only ever runs after at least one task has completed. `replan_from_plan` assigns gap-task + IDs continuing the parent graph's numbering (so the final merge into `completed_graph.tasks` + stays globally unique), but the standalone partial `TaskGraph` built to execute those gap tasks + is validated by `dag::validate`, which requires 0-based positional IDs (`tasks[i].id == + TaskId(i)`) for any freestanding graph. The mismatch made `DagScheduler::new` reject the partial + graph outright (`"invalid graph: task at index 0 has id 1 (expected 0)"`), fail-opening to no + replan every time. This is a distinct, pre-existing defect independent of the whole-plan + grounding work above — unrelated to the matching/grounding contract, purely a task-ID-numbering + bug in replan execution — surfaced by the new end-to-end test added for #6287's review pass. + Gap-task IDs are now remapped to local 0-based IDs for the partial scheduler run and back to the + original global IDs on the way out. +- **Durable execution**: `open_execution`/`open_execution_exclusive`'s reopen path un-finalized only + `completed`/`failed` rows, leaving `aborted` rows untouched on reopen (INV-16). This was safe before + #6254 because `aborted` was a rare, immediately-redriven outcome, but became a hazard once the new + crash-orphan sweep makes `aborted` the common outcome of a resumable crash: a resumed execution whose + row kept `finalized_at` set was prunable out from under the active resume — the exact hazard the + completed/failed un-finalize was built to prevent. Reopening now resets `status='running'` and clears + `finalized_at` for a row in ANY terminal status. +- **Worktree**: `--bare` silently skipped the entire worktree subsystem bootstrap + (`WorktreeManager` construction, `probe_capabilities`) with no warning when + `worktree.enabled = true` in the active config — the 6th confirmed instance of the `--bare` + mode silently skipping a whole subsystem class with no operator-visible signal. A sub-agent + definition with `permissions.worktree = true` running under `--bare` lost its worktree + isolation guarantee (INV-1/INV-3, `specs/063-worktree-subsystem/spec.md`) with nothing in the + logs at default verbosity. `src/runner.rs`'s worktree bootstrap gate now emits a + `tracing::warn!` for the `worktree.enabled = true` + `--bare` combination explaining that + isolation is being skipped; `WorktreeManager` is still never constructed under `--bare` by + design (`--bare` remains a fast, dependency-light path). `Agent::with_bare_mode`'s doc comment + now lists the worktree subsystem among those `--bare` skips (#6256). +- **core**: `apply_tier_results`'s Phase 2 hook-firing future (`RuntimeLayer::after_tool` + + `PostToolUse`) discarded the `Result` from `sem.acquire()`, silently proceeding as if a + permit were held if the local semaphore were ever closed. It now matches the sibling + `make_exec_future` pattern: on a closed semaphore it logs via `tracing::warn!` and returns + the original tool result unchanged, skipping hook firing for that index instead of running + unbounded (#6258). +- **core**: `crates/zeph-core/src/agent/tests/ensemble_scheduler_loop_tests.rs` declared its + helper functions and `zeph_orchestration` imports at module scope with no feature gate, + while its four test functions were individually gated `#[cfg(feature = "scheduler")]`. Since + `zeph-core`'s default features don't include `scheduler`, a plain `cargo nextest run -p + zeph-core` compiled the now-unused helpers/imports as dead code, which the workspace's + `build.warnings = "deny"` turned into a hard build failure. The module declaration in + `agent/tests/mod.rs` is now gated `#[cfg(all(test, feature = "scheduler"))]` so the whole + file compiles only when the feature enabling its content is enabled (#6274). +- **Security (config)**: `PiiFilterConfig` and `SecretMaskingConfig` (the PAAC secret + placeholder masking registry) both shipped `enabled = false` by default even though both + controls are cheap synchronous substitutions (regex scrub / placeholder swap, no LLM call) + whose entire purpose is keeping vault-resolved secrets and PII out of LLM payloads, SQLite + message history, and debug dumps — an operator accepting `--init` defaults ended up with + neither protection active. Flipped both fields' effective default to `true`: the `enabled` + field itself now uses `#[serde(default = "default_true")]` (not a bare `#[serde(default)]`, + which only resolves via `bool::default()` and would have stayed `false` for any config where + the section is present but the key was never written — matching the existing + `query_bias_correction` field convention), so both a missing section and a present-but-key- + omitted section now correctly resolve to enabled. `PiiFilterConfig::default()` and + `SecretMaskingConfig::default()` updated to match. `--init` prompts now default to `true` + with a "(recommended)" suffix. An operator's explicit `enabled = false` in an existing + `config.toml` is always respected — this is a serde default-value change, not a migration + that rewrites live config. Also updated the shipped `config/default.toml` and + `crates/zeph-core/config/default.toml` reference files (both previously wrote an explicit + `enabled = false`, which would otherwise have silently overridden the new safe default) and + the `--migrate-config` step-73 advisory comment for `[security.content_isolation.secret_masking]` + (#6263). +- **Security (config)**: `GatewayConfig.auth_token` is hydrated in place from the vault + (`ZEPH_GATEWAY_TOKEN`) at startup but derived `Serialize` only had `#[serde(default)]`, no + `#[serde(skip_serializing)]` — the same latent-leak class already fixed for + `TelegramConfig.token`/`DiscordConfig.token`/`SlackConfig.bot_token`/`SlackConfig.signing_secret` + in #6173. Unlike `A2aServerConfig.auth_token`, `--init` has no wizard path that persists + `gateway.auth_token` to `config.toml`, so redacting it in `Serialize` carries no round-trip + risk. Added `#[serde(skip_serializing)]`; `Deserialize` is untouched so an inline token in a + hand-edited `config.toml` still loads (#6248). +- **ACP**: `session/delete` only removed the in-memory session entry, never touching the + configured persistence store — a deleted session's `acp_sessions` row (and its associated + conversation history / config snapshot) survived, so it could resurrect via a subsequent + `session/load` or `session/resume`. `do_delete_session` now also calls + `SqliteStore::delete_acp_session_for_owner`, matching the owner-scoped pattern already used + by `do_load_session`/`do_fork_session`/`do_resume_session`. In-memory removal remains + unconditional and always happens first; a persisted-store deletion failure is now surfaced + to the caller as an error (rather than logged and silently swallowed) so a transient DB + failure never reports false success while the persisted row — and the resurrection risk it + carries — survives. The delete is idempotent by id, so the client can safely retry (#6271). +- **Security (a2a ibct)**: `zeph-a2a`'s IBCT (Invocation-Bound Capability Token) mechanism was + fully implemented (HMAC-SHA256 issuance/verification, key rotation, constant-time comparison) + and documented in `A2aServerConfig`/specs/README as an active per-task authorization layer on + top of the coarse bearer token, but nothing called `Ibct::issue` outside its own unit tests and + nothing in the server's axum handlers called `Ibct::verify` or read the `X-Zeph-IBCT` header — + the documented enforcement never existed (CWE-862 Missing Authorization, #6260). Fixed by + wiring both sides: server-side, `zeph_a2a::server::router::ibct_middleware` (layered inside the + bearer-auth middleware) rejects requests to `/a2a`/`/a2a/stream` with `401` (missing/undecodable + header) or `403` (signature/expiry/scope mismatch) whenever `A2aServer::with_ibct_keys` is + configured with a non-empty key set — populated in `src/daemon.rs` from `[a2a] ibct_keys` + (inline hex, via new `IbctKey::from_hex`) and `ibct_signing_key_vault_ref` (vault-resolved, + startup now fails if the ref is set but unresolvable, per the spec's documented invariant); an + empty key set stays a no-op, matching the existing bearer-auth opt-in pattern. Client-side, + `A2aClient::with_ibct_key` makes `rpc_call`/`stream_message` issue and attach a token scoped to + the request's `task_id` (`params.id` for `tasks/get`/`tasks/cancel`, `params.message.taskId` for + `message/send`/`message/stream`, empty-string sentinel for a not-yet-assigned task ID) and to + the **origin** of the target endpoint (`ibct_scope_origin` strips the path) — required because + `/a2a` and `/a2a/stream` are different paths verified against the one pathless `AgentCard::url`, + so a token scoped to a full per-route URL would 403 on whichever route it wasn't issued for + (caught in review as S1; regression-tested in both `client.rs` and `router.rs`). Corrected the + four doc surfaces (`A2aServerConfig` doc comments, `crates/zeph-a2a/README.md`, + `specs/014-a2a/spec.md`, `specs/010-security/spec.md`) that overclaimed active enforcement, and + added an explicit deployment caveat to all four: no caller bundled in this repository (incl. + `src/tui_remote.rs`'s `--connect` client) attaches `X-Zeph-IBCT` yet, so enabling `ibct_keys` + today rejects unauthenticated/non-Zeph A2A traffic but does not by itself protect any + delegated-subagent flow — a follow-up delegation client wiring `with_ibct_key` is required for + that (S2). `message/send` always creates a fresh task server-side and is therefore always + scoped to the empty-string sentinel rather than a request-specific ID — documented as a known + MVP limitation rather than closed in this fix (S3). `ibct_scope_origin` moved from `client.rs` + into a shared `pub(crate)` function in `ibct.rs` and is now applied to the server's own + `card.url` in `A2aServer::serve()` too, not just to the client's `endpoint` argument — an + unnormalized `public_url` (trailing slash, stray path, explicit default port, mixed-case + scheme/host) would otherwise silently 403 every request even from a correctly-behaving client, + reintroducing S1's bug class on the server side (review finding M6). +- **TUI/metrics**: the turn-latency panel (`latency ctx:… llm:… tool:… save:…`) showed + `llm:0ms` for tool-enabled turns even when the real LLM call took 25+ seconds. Root cause: + `MetricsBridge::WATCHED_SPANS` watched the bare `llm.chat` span, but every tool-enabled turn + (i.e. essentially every turn) dispatches through `chat_with_tools()` (`llm.chat_with_tools`) + instead — a span name `WATCHED_SPANS` never matched. The bare `llm.chat` span still fires from + several *auxiliary* call sites within the same turn (MARCH self-check, compaction probe, magic + docs, background learning, session digest, heuristic promotion), so whichever of those closed + last silently overwrote the correct manually-timed `chat_with_tools` duration with its own, + typically much smaller one. Fixed by watching `llm.chat_with_tools` instead of `llm.chat` + (mirroring the existing `persist_message_ms` exclusion rationale from #6111) and accumulating + (rather than overwriting) its duration across the multiple `chat_with_tools` calls a single + multi-round tool-loop turn can make. `llm.chat_with_tools` also fires from concurrent + in-process sub-agents and the scheduler's `RunInline` inline tool loop, neither of which + wraps the call in the main turn's `llm.turn_call` span — the bridge now scopes the `LlmChat` + field strictly to spans nested under `llm.turn_call`, so a sub-agent's or scheduler task's + own `chat_with_tools` timing can no longer inflate or corrupt the main turn's `llm_chat_ms` + (#6275, review follow-up). +- **TUI/observability**: the `bg: N enrich, M telem` background-task status segment only + refreshed at the top of the next turn, so it stayed stale/invisible for the entire idle window + after a turn's response was sent — exactly when background enrichment/telemetry extraction + (spawned from `persist_message`) is actually running. Added a periodic `bg_metrics_tick` + (`LoopEvent::BgMetricsTick`, 2s interval) to the existing `Agent::next_event` `tokio::select!` + loop that reuses `reap_background_tasks_and_update_metrics` between turns, so the TUI now + reflects real in-flight background work continuously. No new `tokio::spawn`: the tick rides + the agent's own already-supervised event loop and is lazily constructed on first use since + `tokio::time::interval` requires an active Tokio runtime that plain-`#[test]`-constructed + agents do not have. Uses `tokio::time::interval_at` to defer the first tick by a full interval + rather than firing it immediately at construction — a plain `interval(..)` would have raced + the pre-existing channel-closed/shutdown `select!` arms on every agent startup, occasionally + forcing one spurious extra loop iteration before a closed channel was observed (#6279). +- **CLI**: `--init` / `--migrate-config` were documented as top-level flags everywhere (both + `CLAUDE.md` files, `.zeph/zeph.md`, `crates/zeph-config/AGENTS.md`, the worktree-disk-quota + playbook, and `src/cli.rs`'s own doc comments) but only existed as `clap` subcommands (`zeph + init`, `zeph migrate-config`), so any session following the documented syntax hit a clap + "unexpected argument" error. Added `--init`, `--migrate-config`, `--in-place`, and `--diff` as + top-level `Cli` flags, coexisting with the unchanged `init`/`migrate-config` subcommands (same + pattern already proven by `--vault`/`vault`) and routed in `runner.rs` to the identical handler + functions the subcommand arms call — no duplicated business logic. `--in-place`/`--diff` now + `requires = "migrate_config"`, so using them without `--migrate-config` is a clean clap error + instead of silently falling through to the interactive agent (#6277). +- **TUI**: the task registry panel (`/tasks` or the `t` key) always showed "supervisor not + available" on the default `--tui` launch path. The two-phase/early-start TUI startup + (`run_tui_agent` in `src/tui_bridge.rs`) forwarded the cancel signal and metrics channel + into the running `App` via `AgentEvent`, but never the `TaskSupervisor` handle — only the + legacy (dead-in-practice) startup path wired it correctly via `App::with_task_supervisor`. + Fixed by adding `AgentEvent::SetTaskSupervisor` and forwarding it alongside the existing + `SetCancelSignal`/`SetMetricsRx` sends. Also wired the `t` keybinding to the previously + unreachable `Action::ToggleTaskPanel` (#6276). +- **Security (auth)**: `AuthConfig::new` (`zeph-common`'s shared bearer-auth middleware, used + by `zeph-gateway`, `zeph-a2a`, and `zeph serve-sessions`) hashed a configured token without + checking for emptiness, so a vault-resolved secret that resolved to `""` produced a valid + `Some(blake3::hash(b""))`. `auth_middleware` defaults a request's submitted token to `""` + whenever no `Authorization` header is present, so that empty-vs-empty hash comparison + matched — silently authenticating every unauthenticated request instead of rejecting them + or letting `require_auth` reject them outright. Fixed by treating an empty token the same as + `None` in `AuthConfig::new`, checking non-emptiness (not `is_some()`/`is_none()`) at the + `zeph-gateway` startup-warning and `zeph serve-sessions` bind-refusal guard call sites, and + skipping the `ZEPH_A2A_AUTH_TOKEN`/`ZEPH_GATEWAY_TOKEN` vault-hydration assignment in + `zeph-core`'s config resolver when the resolved secret is an empty string (#6268). +- **Security (ACP auth)**: `resolve_acp_auth_clients` pushed a `[[acp.auth_clients]]` entry's + vault-resolved token straight into the client list with no non-emptiness check, so a vault + secret resolving to `""` produced an `AcpClientToken` with `token: ""`. `zeph-acp`'s bearer + middleware hashes tokens independently of the shared `AuthConfig` primitive, so it was not + covered by the `AuthConfig::new` fix above: a request with `Authorization: Bearer ` (empty + presented token) hashed to `blake3::hash(b"")` and matched the empty-token client. Fixed by + treating an empty/whitespace-only vault-resolved token the same as a missing one (warn and + skip) in `resolve_acp_auth_clients`, plus a defense-in-depth filter in `zeph-acp`'s + `BearerAuthLayer::new` that drops any client constructed with an empty or whitespace-only + token regardless of caller. Additionally, when *every* configured `auth_token`/ + `auth_clients` entry fails to resolve (leaving the client set empty), `resolve_acp_auth_clients` + now fails startup instead of silently falling back to the empty-list state that + `zeph_acp::transport::router` treats as intentionally unauthenticated — a declared-but- + unresolvable auth configuration must never downgrade to "fully public" (#6270). +- **Orchestration**: `PlanVerifier::verify()` judged per-task completion purely from the + sub-agent's narrated output text, with no cross-check against the real `ToolUse`/`ToolResult` + evidence already recorded for the task — a cheap `verify_provider` could rate a purely + narrated completion (e.g. "I ran `cargo test` and it passed", with no real tool call behind + it) as `complete: true`, silently accepting a hallucinated task completion (#6278). Fixed by + adding a deterministic grounding stage: the verify LLM response now deserializes into a + `VerifyResponse` DTO carrying a `claimed_executions: Vec` field (`": "` + entries the narration claims occurred), and a new pure `ground()` function cross-checks every + claim against the task's real tool-call trace (tool-name match plus bidirectional + normalized-command-substring containment) before projecting the grounded result into the + existing `VerificationResult` — `VerificationResult` itself gains no new field. An unmatched + claim on an available trace forces `complete = false` with a `Critical` gap, regardless of the + LLM's own verdict; an unavailable trace (transcript read failed) fails open on grounding + specifically, never spuriously replanning honest work. Applies uniformly to both the spawn + dispatch path (trace read from the sub-agent transcript) and the `RunInline` path (trace + collected in-loop) and to the ensemble-merge path (spec 073), which grounds the union of + `claimed_executions` across all responded members as a stage after `merge()`. No new config — + grounding is always-on whenever `verify_completeness = true`. See + `specs/009-orchestration/spec.md` § "Verifier Tool-Call Grounding" for the full contract. + The spawn-path trace read now uses a new strict `TranscriptReader::load_strict` (fails + closed the moment any transcript line is skipped) instead of the lenient `load`, so a + torn/malformed line from a canceled sub-agent can no longer masquerade a partial trace as a + complete one and false-positive an honest claim. +- **Durable execution**: `zeph_durable::retention::DurableRetentionService` (the periodic + background prune sweep documented in spec-064 "Retention & Compaction") was never + instantiated outside its own doctest — the only production prune path was the manual + `zeph durable prune` one-shot CLI subcommand, so a running deployment's `durable.db` + journal grew unbounded regardless of `[durable.retention]` TTLs. Now spawned via + `TaskSupervisor::spawn` (task name `durable.retention_sweep`, `RestartPolicy::Restart` + with exponential backoff) alongside the `JournalWriter` actor, at every production + call site that already opens a durable backend: the shared `open_durable_backend` helper + (`crates/zeph-core/src/agent/durable_bootstrap.rs`, covering both the P1 agent-turn and + P2 orchestration adapters) and the P3 scheduler daemon's `build_durable_adapter` + (`src/commands/scheduler_daemon.rs`). Gated by the same per-adapter conditions that already + guard backend construction at each call site — `durable.enabled && (durable.agent_turns || + durable.orchestration)` for P1/P2 (each adapter checks its own flag before invoking the + shared helper), `durable.enabled && durable.scheduler` for P3 — no new config surface was + added (#6264). +- **Docs**: fixed 11 stale Claude model ID examples across 5 mdBook pages (`acp.md`, + `sub-agents.md`, `experiments.md`, `wizard.md`, `configuration.md`) that still used the + outdated `claude-sonnet-4-5`/`claude-sonnet-4-20250514` naming (incorrectly pairing the + Sonnet 4.5 generation name with the Sonnet 4 base-release date) — missed by the #5901/#5902 + sweeps, which only covered `book/src/advanced/context.md`. Replaced with the dateless + `claude-sonnet-5` convention established there. Documentation-only change, no source code + modified (#6147). +- **Docs**: fixed stale `claude-opus-4-5` model ID references in `book/src/advanced/acp.md` + (4 occurrences in code examples and configuration tables), updated to `claude-opus-4-8` + for consistency. Documentation-only change (#6211). +- **Security (shell sandbox)**: `ShellExecutor::resolve_context`'s no-`cwd_override` fallback + branch started the subprocess from the raw, unvalidated `std::env::current_dir()` with no + check against `allowed_paths`, while the `cwd_override` branch already canonicalized and + validated. When `allowed_paths` was configured non-empty and the real process cwd fell + outside every allowed root (a non-default launch/config), commands whose file references + were all bare filenames or absent (`ls`, `pwd`, `cat foo` — bare filenames are not extracted + as path tokens by `extract_paths`) could read/list outside the intended sandbox. Fixed by + clamping the fallback cwd into the sandbox (first allowed root, preferring a directory entry) + whenever the canonicalized process cwd is outside `allowed_paths`, reusing the shared + `zeph_common::security::is_path_within` validator; absolute path tokens in commands were + never affected (already canonicalized and rejected by `validate_sandbox_with_cwd`). Clamping + rather than rejecting avoids turning common bare commands into hard sandbox-violation errors + (#6208). +- **Security (shell sandbox)**: `ShellExecutor::spawn_background` was a second, parallel + production-reachable path for starting a backgrounded shell command that bypassed + `resolve_context` entirely — it validated against the raw `std::env::current_dir()` + (`validate_sandbox`) and spawned via `run_background_task`/`build_bash_command` without + setting `current_dir` on the child, so it never inherited the `allowed_paths` clamp fixed + in #6208 for the structured tool-call path. Audit confirmed zero production callers (the + agent's `bash` tool only ever reaches `execute_tool_call` -> `resolve_context` -> + `spawn_background_with_context`), so this was a structural footgun rather than a live + vulnerability. Converted `spawn_background` into a `#[cfg(test)]`-gated thin wrapper over + `resolve_context` + `spawn_background_with_context`, deleted the now-dead + `run_background_task`, and gated `validate_sandbox` (its only non-test caller) `#[cfg(test)]` + too. This makes "exactly one sandboxed subprocess path" a compile-time guarantee — a future + production caller of the old context-less path fails to compile — instead of a convention + (#6217). +- **Dependencies**: `Cargo.lock` had drifted — `rmcp` was pinned to `2.0.0` while crates.io's + latest release within the existing `^2.0.0` manifest range (`Cargo.toml` unchanged) had moved to + `2.2.0`. Updated the lockfile to `rmcp 2.2.0` / `rmcp-macros 2.2.0`; `sse-stream` also bumped to + `0.2.4` (required — rmcp 2.2.0's `transport-streamable-http-client-reqwest` feature path calls + `SseStream::from_bytes_stream`, added in `sse-stream` 0.2.4, which is not present in 0.2.3; + rmcp's own manifest constraint of `sse-stream = "0.2"` under-specifies this, so a plain + `cargo update -p rmcp` alone resolves to a broken combination — tracked upstream separately). + Picks up rmcp's 2.1.0/2.2.0 fixes: reject auth servers lacking S256 PKCE support (2.2.0, #955), + block redirect header leaks (2.1.0, #936), make `AsyncRwTransport::receive` cancel-safe + (#941/#947), fail orphaned streamable HTTP responses on reinit (#914), and negotiate protocol + version in the handler (#930). No source changes required in `crates/zeph-mcp` (#5897). +- **A2A**: `AgentRegistry` (JWS Agent Card signature verification + `card_trust_policy`, + #5928) had no runtime construction site anywhere outside `crates/zeph-a2a`'s own tests — + setting `[a2a_client].card_trust_policy = "require"` had no effect on a running agent. + Wired `AgentRegistry::discover` into `zeph --connect ` (`src/tui_remote.rs`): the + peer's card is now fetched and its signature/URL-origin trust policy enforced before the + SSE session is established, using an explicit (no-wildcard-by-name) conversion from + `zeph_config::channels::CardTrustPolicy`/`TrustedAgentKey` to their `zeph-a2a` + counterparts. The discovery fetch is hardened with the same `require_tls`/ + `ssrf_protection` posture and DNS-rebinding-safe address pinning already applied to the + `A2aClient` connection to the same URL. Fixes a bug where the discovery URL was + constructed from the full `--connect` target (including its RPC path, e.g. + `/a2a/stream`) instead of the origin root where `/.well-known/agent.json` is actually + served. A discovery-fetch failure (peer serves no card, network error, timeout) only + aborts `--connect` when `card_trust_policy = "require"`; under the default `ignore` (and + `prefer`) it is logged and tolerated so a peer that serves no agent card at all still + connects, matching pre-#6200 behavior. A trust-check rejection (untrusted signature or + URL-origin mismatch) always aborts regardless of policy, since `check_trust` has already + folded the policy into that verdict (#6200). +- **A2A**: `card_signing::canonical_payload` canonicalized the raw received card JSON + verbatim (`signatures` key removed only), which rejects a genuinely valid card from any + signer that strips proto3-default-valued fields (empty string/`false`/`0`/empty + array/object) before signing per the A2A spec text, while transmitting the card with + those defaults present — a fail-closed availability bug. `canonical_payload` now strips + the same proto3-default fields recursively before JCS canonicalization, so a signature + computed over either shape verifies against the other; covered by a new synthetic + regression test (real `a2a-sdk` interop is still unvalidated — `card_trust_policy` + remains `"ignore"` by default pending a real signed-card vector) (#6201). +- **Docs**: `specs/010-security/spec.md` and `specs/014-a2a/spec.md` described two different IBCT + (Invocation-Bound Capability Token) wire formats, and neither fully matched the actual + implementation in `crates/zeph-a2a/src/ibct.rs`. Reconciled both specs against `ibct.rs` and + `crates/zeph-config/src/channels.rs` ground truth: the token is a single base64-encoded JSON + blob (not a dot-separated triplet) signed over `{key_id}|{task_id}|{endpoint}|{issued_at}| + {expires_at}`, default TTL is 300s (not 60s), and `ibct_keys` is an array of `{key_id, key_hex}` + entries (not a `key_id → vault_ref` map) with `ibct_signing_key_vault_ref` as the separate + vault-resolved primary-key path. Also corrected both specs' claim that IBCT prevents replay + attacks — `Ibct::verify` performs no `invocation_id`/nonce dedup, so a captured, still-valid + token is replayable against the same `task_id` + `endpoint` until it expires; this is now + documented as a known implementation limitation. Documentation-only change, no source code + modified (#6197). +- **Persistence**: `PersistMessageRequest`'s doc comment carried a stale TODO describing an + unimplemented R3 batching design and a pending-request-loss risk that does not exist — + persistence is inline and synchronous today (`Agent::persist_message` builds the request and + immediately awaits `PersistenceService::persist_message`, with no queue/buffer layer). Replaced + the TODO with a doc comment describing the actual inline/synchronous behavior (#5962). +- **Persistence**: removed the dead `PersistMessageOutcome::redaction_applied` field — it was + hardcoded to `false` at all four construction sites in `PersistenceService::persist_message` and + read by nothing, since no redaction logic exists in the service. Breaking change to a `pub` + struct, acceptable pre-v1.0.0 (#5995). -### Testing +- **LLM**: the Claude request funnel's no-prefill gate (`ClaudeProvider::structured_history`/ + `plain_history`) was convention-enforced only — `request::split_messages`/ + `split_messages_structured` stayed reachable from anywhere inside `crate::claude`, so a new + request-construction path added directly in `mod.rs` (or any sibling file) could call the raw + split functions and skip the no-prefill strip, silently reintroducing the bug class fixed by + #5903/#6145/#6146/#6154. Introduced `GatedStructuredHistory`/`GatedPlainHistory` newtypes with + a private inner `Vec`, constructible only via `structured_history`/`plain_history`. + `RequestBody`, `ToolRequestBody`, `VisionRequestBody`, and `TypedToolRequestBody`'s `messages` + field now require the gated type instead of a bare `Vec`/slice, so bypassing the funnel is a + compile error (wrong type) instead of a code-review catch. Pure refactor — wire format is + byte-identical (verified via existing insta snapshots) (#6158). +- **Core**: durable sub-agent replay (`handle_agent_spawn_foreground`) fired its channel side + effects — the "replayed from durable journal" user notice and the TUI completion event — as + plain awaits outside any dedup guard. A parent that restarted more than once after taking the + replay branch would re-fire both side effects on every subsequent restart. An initial fix + gated them behind a `ctx.step()` created only on the replay path, but that step consumed a + durable step id on replay runs only, shifting every subsequent step id relative to the fresh + run's journal and causing a hard `ReplayDivergence` abort whenever another durable step + (e.g. an LLM turn) followed the spawn in the same execution — a regression, not a fix. + Replaced it with an out-of-band `notified_at` claim column on `durable_promises` (migration + 109, sqlite+postgres): the first caller to win a conditional + `UPDATE ... WHERE notified_at IS NULL` fires the side effects, every later replay is + suppressed. The claim consumes zero durable step ids, so it cannot perturb step-id + determinism (INV-2) or cause `ReplayDivergence` under any restart count (#6027). -- `test(tools)`: added regression coverage for the `ShellExecutor` checkpoint stack (#6001): - `checkpoint_redo` with no prior `checkpoint_undo` (no-op "Nothing to redo.", not a panic), - `checkpoint_list` ordering with 3 recorded checkpoints (most-recent-first, matching the - `index` field), and a multi-step undo/redo/undo sequence pinning undo-stack depth - bookkeeping. +- **CI**: the `registry` feature (`zeph-plugins/registry`, spec-045) was declared in root + `Cargo.toml` and bundled into `full`, but excluded from every PR-gating CI job's feature + string — `lint-clippy`, `msrv`, `build-tests` (and by extension the sharded `test` job that + consumes its archive), `rustdoc`, and `release-build` in `.github/workflows/ci.yml` all built + without it. Only the post-merge `coverage` job and the compile-only `bundle-check` matrix + (via `full`) ever touched it, so a regression in the marketplace/skills.sh registry client + (`crates/zeph-plugins/src/marketplace/skills_sh.rs`, wiremock-mocked HTTP tests) could merge + to `main` without a single PR-gating job catching it (#6176). Added `registry` to the + feature strings at all five call sites above; `.claude/rules/branching.md`'s documented + local commands are updated to match so they still mirror CI exactly. +- **Rustdoc**: enabling `registry` in the `rustdoc` CI job (above) surfaced two pre-existing + rustdoc lint failures, both in code documented for the first time by that job: + - `crates/zeph-plugins/src/marketplace/skills_sh.rs`'s module-level doc comment linked to + `` [`SkillSummary`] `` and `` [`parse_files`] ``, both genuinely private items — the paths + resolve fine, but `rustdoc::private_intra_doc_links` rejects a public doc comment linking to + a private item. Both are correctly private (internal deserialization details, not public + API), so the fix drops the link brackets and keeps plain inline code instead of qualifying a + path or widening visibility. + - `crates/zeph-plugins/src/marketplace/mod.rs:86`'s doc comment contained the literal strings + `` and `` outside of backticks, which `rustdoc::invalid_html_tags` + misparses as unclosed HTML tags. Wrapped both in backticks so they render as inline code + instead. + (#6176) +- **zeph-worktree**: `WorktreeManager::create()` enforced `config.max_worktrees` with a + check-then-act sequence that was not atomic — no lock was held across the `reconcile()`/ + `git worktree add` `.await`s between the quota read and the final in-memory registration, + so two concurrent in-process `create()` calls could both pass the `current >= max` check and + both proceed, silently exceeding `max_worktrees`. Added an internal `admission_lock: + tokio::sync::Mutex<()>`, held across the full quota-check-through-registration sequence, + making in-process admission a hard guarantee. The existing cross-process soft cap (no locking + across separate zeph sessions sharing the same `root`) is unchanged (#6250). +- **Durable execution**: `finalize(Completed/Failed)` was never called in production — every + consumer (`zeph-orchestration`'s budget journal, `zeph-scheduler`'s job fire, `zeph-core`'s + per-conversation `AgentTurn`) drove steps through the durable journal but never marked the + execution row terminal, so `durable_executions.status` stayed `'running'` forever and the + TTL-based retention prune sweep could never reclaim any row. Wired `finalize(Completed)` on + success and `finalize(Failed)` on unrecoverable step failure into all three production execution + models, plus `finalize(Aborted)` on the hard step-cap (`DurableError::StepCapExceeded`), matching + the already-documented retention contract (`specs/064-durable-execution/spec.md`). Made + `finalize` idempotent against a race with the internal replay-divergence `Aborted` transition, and + made reopening a finalized execution (e.g. a resumed conversation, a same-slot scheduler retry) + automatically un-finalize it back to `running` so the retention sweep can never prune a row that's + actively being reused — closed a TOCTOU window between the prune sweep's candidate selection and + a concurrent reopen by moving the selection inside the same write transaction as the deletes. + Known residual gap, intentionally not addressed here: an execution that ends via an ungraceful + process exit (crash, OOM, `SIGKILL`) still has no reclamation path if never resumed — this needs a + separate periodic staleness-sweep mechanism, tracked in a follow-up issue (#6251). +- **Security (zeph-mcp)**: `name_referenced_in`'s two regex memoization caches + (`crates/zeph-mcp/src/sanitize.rs`) were process-lifetime `static`s keyed by lowercased MCP + tool name with no eviction — since tool names are attacker-influenced (untrusted MCP server + input), a malicious/compromised server rotating its advertised tool names on reconnect or + catalog refresh could grow both caches without bound, leaking memory over the lifetime of a + long-running daemon/gateway/serve process. Replaced both `HashMap` caches with + `lru::LruCache` capped at 256 entries, using `get_or_insert` in place of + `entry().or_insert_with()` — same single-lock-acquisition semantics, no new attack surface + (#6255). -- `fix(serve,gateway)`: `POST /sessions/:id/prompt` and the gateway `POST /webhook` path now - dispatch recognized slash commands (e.g. `/status`) locally instead of silently forwarding - them as a full, billed chat turn (#5898, #5904). Both endpoints previously ran - `ContentSanitizer::sanitize` unconditionally before queueing the message, wrapping the text - in an `` delimiter that hid the leading `/` from the agent's dispatch - registries. Text now checked against `zeph_commands::is_recognized_command` (backed by the - crate's own `COMMANDS` registry) before sanitization; a match is forwarded raw so the - existing dispatch/authorization path (`CommandHandler::requires_auth` + `trusted`, already - used identically for Telegram/Discord/Slack) decides whether it may run. Non-command text is - sanitized exactly as before — no change to that path. The gateway handler - (`crates/zeph-gateway/src/handlers.rs`) now forwards a `WebhookMessage { sender, channel, - body }` instead of a pre-formatted `"[sender@channel] body"` string, so the - `"[sender@channel]"` display prefix is applied only to non-command chat text in - `forward_webhooks` (`src/gateway_spawn.rs`), which is where the command-detection decision - and sanitization both now happen. - - Security-critical follow-up caught in review before merge: `GatewayChannel` (used to merge - webhook input into the main agent when `[gateway] enabled = true` alongside a CLI/TUI - primary channel) previously delegated `supports_exit()` — the signal `zeph-core`'s turn loop - reads as `trusted` for command-dispatch authorization — unconditionally to the primary - channel. A CLI/TUI host reports `supports_exit() == true`, so once recognized commands could - dispatch at all, a bearer-token holder could run every `requires_auth` command (`/policy`, - `/mcp`, `/plugins`, ...) at the *host's* trust level. `GatewayChannel` now forces - `supports_exit() == false` for the exact turn processing a webhook-sourced message - (`recv()`-only delivery for webhook input — never opportunistically drained via `try_recv()`, - since `zeph-core`'s queue has no way to carry a per-message trust distinction across turns). - - Also caught in review: `/subagent spawn ` (external ACP process spawn) is dispatched by - `dispatch_slash_command` with no `trusted`/`requires_auth` check at all, independent of the - trust-boundary issue above — a webhook `/subagent spawn ` would have been unconditional - remote code execution on any build with `acp_subagent_spawn_fn` installed (`full`/`ide` + - `gateway`). `zeph_commands::is_recognized_command` now excludes `/subagent` - (`UNGATED_DISPATCH_COMMANDS`), so it falls back to the pre-fix sanitized/wrapped behavior — - inert, as before this PR. -- `fix(tools)`: `CompositeExecutor`, `AdversarialPolicyGateExecutor`, and `PolicyGateExecutor` - now forward `requires_confirmation`/`is_tool_speculatable`/`execute_tool_call_confirmed` to - their inner executors instead of silently falling through to the `ToolExecutor` trait's - no-op defaults (#5900, #5938, #5931). `CompositeExecutor::requires_confirmation` now - OR-forwards to both `first`/`second` leaves, mirroring the existing `is_tool_retryable`/ - `is_tool_speculatable` pattern; `CompositeExecutor::execute_tool_call_confirmed` now - first-match-wins forwards, mirroring the existing `execute_confirmed` override — without it, - a confirmation-gating executor composed inside a `CompositeExecutor` would have its - confirm-bypass silently re-run the full (still-gating) `execute_tool_call` path after the - user approved. `AdversarialPolicyGateExecutor` and `PolicyGateExecutor` now plain-delegate - `requires_confirmation` (and `AdversarialPolicyGateExecutor` also `is_tool_speculatable`) to - `self.inner`. Same defect class as #5899/#5905/#5906 (fixed by #5930) — these three wrapper - layers were explicitly scoped out of that PR to keep it minimal. -- `fix(subagent,core)`: durable sub-agent resume now replays the journaled result instead of - unconditionally re-spawning the child (#5944, spec-064 §P4). With `[durable].subagent = true`, - restarting a crashed parent whose sub-agent had already finished (and resolved its durable - promise) previously discarded that promise and spawned a brand-new child from scratch, - duplicating LLM calls and any side-effecting tool calls (git operations, GitHub issue/PR - creation, file writes) the finished child had already performed. `maybe_make_durable_seat` is - replaced by `resolve_durable_spawn_gate`, which on a resumed run performs a non-blocking check - (`zeph_durable::DurableContext::take_resolved_promise`, exposed via the new - `zeph_subagent::try_replay_durable_subagent`) for whether the child already resolved its - promise before the crash; if so, `handle_agent_background` and `handle_agent_spawn_foreground` - skip `mgr.spawn(...)` entirely and feed the journaled `SubagentResult` through the normal - completion path instead. A still-pending resumed promise (child's fate unknown after a crash — - its resolver token is unrecoverable per INV-9) falls back to the pre-existing spawn behavior, - matching the documented v1 scope boundary in `zeph-subagent/src/durable.rs`, and now logs a - `tracing::warn!` at that fallback so the residual duplicate-spawn window is observable rather - than silent (tracked as a known v1 limitation in #6010). -- `fix(config)`: `--migrate-config --in-place` no longer duplicates advisory comment blocks or - falsely reports changes on repeated runs (#5945). `migrate_llm_stream_limits` (#4750) and the - two `[memory.hebbian]` splice steps (HL-F3/F4 #3345, HL-F5 #3346) guarded their idempotency by - checking for an *uncommented* field, but each step only ever writes a *commented* advisory - block — so the guard never matched the step's own prior output and re-appended the identical - block on every run. Guards now recognize the commented form they themselves write. - Separately, `migrate_orchestration_persistence` (#3107) and - `migrate_orchestration_asset_sensitivity` (spec-068, #3934) matched an exact - `"[orchestration]\n"` substring via `String::replacen` and unconditionally reported - `changed_count: 1` regardless of whether the replacement actually happened — `replacen` - silently no-ops when the pattern isn't found, so a header not immediately followed by a bare - newline caused a false "added" report on every run without ever writing anything. Both now - insert via the existing `insert_after_section` helper and only report a change when the output - actually differs from the input. Two same-defect-class siblings found during review are fixed - alongside these: `migrate_memory_hebbian_config` (HL-F1/F2, #3344) required an exact - `[memory.hebbian]` line match that fails against the header actually shipped in - `config/default.toml` (which carries a trailing inline comment), so it wrongly concluded the - section was absent and appended a duplicate block on the first run — this also blocked - `migrate_memory_hebbian_consolidation_config`/`migrate_memory_hebbian_spread_config` from ever - engaging against the real shipped config, since they shared the same exact-match precondition; - and `migrate_magic_docs_config` (#2702), which had the identical "guard checks for an - uncommented key the step never writes" defect as bug 1 above. -- `fix(commands)`: `/mcp` and `/plugins` now require a trusted (local) session, closing an - unauthenticated remote code execution path (#5997, CWE-284). `McpCommand` and `PluginsCommand` - previously left `requires_auth()` at its default `false`, so Telegram/Discord/Slack/gateway - callers could invoke `/mcp add [args...]` to spawn an arbitrary local subprocess - (with the shipped default `mcp.allowed_commands = ["npx", "uvx", "node", "python", "python3"]`, - e.g. `python3 -c "..."`) or `/plugins add ` to install a plugin from an arbitrary local - path — both without any authentication. Both handlers now override `requires_auth()` to return - `true`, matching the same defect class fixed for `/image` in #5967; the commands remain - available from local CLI/TUI/ACP-stdio sessions. -- `fix(config,mcp,a2a)`: three more secret-bearing config/runtime structs no longer leak - vault-resolved credentials through plain `Debug`/`Serialize` derives (#5968, #5965, #5964). - `MemoryConfig::database_url` (the resolved Postgres connection string, including embedded - username/password) is now wrapped in `zeph_common::secret::Secret` and skipped on - serialization, mirroring the existing `qdrant_api_key` field; `IbctKeyConfig::key_hex` - (`zeph-config`) and `IbctKey::key_bytes` (`zeph-a2a`) — the A2A HMAC signing key material — - now hand-write a redacting `Debug` impl that prints only `key_id`; and `McpTransport`'s - `Http.headers` and `Stdio.env` values (which commonly carry resolved MCP server bearer - tokens / API keys) are now redacted in `Debug` output while keeping header/env-var names - visible for diagnostics — `ServerEntry`'s derived `Debug` picks up the fix automatically via - per-field delegation. `Serialize` was intentionally left unchanged for all three (needed for - TOML config round-tripping); resolved secrets can still appear in a JSON serialization of - these structs, tracked as a follow-up (#6006). -- `fix(commands)`: `/image` now requires a trusted (local) session, closing a remote arbitrary - file read (#5967, CWE-284). `ImageCommand` previously left `requires_auth()` at its default - `false`, so Telegram/Discord/Slack/gateway callers — none of which are trusted by - `CommandRegistry::dispatch` — could invoke `/image ` to read any file under the server's - working directory that the process could access. `ImageCommand` now overrides `requires_auth()` - to return `true`, matching the existing `/cache-stats` and `/notify-test` handlers in the same - module; the command remains available from local CLI/TUI/ACP-stdio sessions. -- `fix(tools)`: `checkpoint_undo`/`checkpoint_redo`/`checkpoint_list` are now forwarded by - `impl ToolExecutor for std::sync::Arc` (#5985). This impl block predates the - checkpoint feature and only overrode `execute`/`tool_definitions`/`execute_tool_call`/ - `set_skill_env`, so the three checkpoint methods fell through to the trait's no-op default — - `/undo`, `/redo`, and `/undo list` always reported "Checkpoints are not enabled" in the plain - default production wiring (no `capability_scopes`/`shadow_sentinel` gating required to - reproduce), because `agent_setup.rs` wraps the shell executor in an `Arc` before building the - executor chain, so every checkpoint call resolved against the incomplete `Arc` - impl rather than `ShellExecutor`'s own correct one. This is the same defect class as - #5899/#5905/#5906 but at the leaf type's own smart-pointer shadow-impl rather than a decorator - wrapper. -- `fix(serve)`: `zeph serve-sessions` `/sessions`-created agents now enforce the same - trust/policy/adversarial-policy gate stack as the CLI, ACP, and daemon entry points - (#5973, #5977, #5886). Previously `serve/deps.rs::build_tool_executor` built only a bare - file/shell/scrape/cwd composite with none of the three gates, so a Quarantined skill's tools - and a configured `[tools.policy]`/`[tools.adversarial_policy]` deny rule were silently - unenforced for any session created via `POST /sessions`. Each session now gets its own - `TrustGateExecutor`/`PolicyGateExecutor`/`AdversarialPolicyGateExecutor` instance, wrapped - per-session in `serve/agent_factory.rs::build_agent_factory` around the shared base composite - — gating eagerly, once, at startup would let one concurrent session's trust level clobber - another's on a shared mutable trust-state atomic. The verbatim-triplicated - policy-file-load/provider-resolve/`PolicyEnforcer`-compile block previously copy-pasted - across `runner.rs`, `acp.rs`, and `daemon.rs` (source of the #5881 ordering bug) is now a - single shared helper in `src/agent_setup.rs` (`build_policy_gate_pieces` + - `apply_policy_gate_chain`), used by all four entry points. -- `fix(mcp)`: Qdrant-backed MCP tool registry now rehydrates real `input_schema` (plus - `output_schema`/`security_meta`) for semantically-matched tools instead of surfacing them to - the LLM with an empty `{}` schema (#5935). `Agent::match_mcp_tools()` no longer trusts - `McpToolRegistry::search()`'s stub schema directly — it now looks up each `(server_id, name)` - hit against the live `self.services.mcp.tools` list and substitutes the full tool definition, - dropping (with a `WARN` log) any hit that no longer has a live counterpart. This only affected - the default/recommended deployment configuration (`memory.semantic.enabled = true` with a - Qdrant backend); the in-memory `SemanticToolIndex` fallback path was never affected. -- `fix(subagent)`: the sub-agent vault-secret request/approval flow now actually resolves - and delivers the secret's real value instead of discarding it (#5941, #5942). All three - approval call sites (`scheduler_loop::process_pending_secret_requests`, - `subagent_commands::poll_subagent_until_done`, `subagent_commands::handle_agent_approve`) - now resolve the requested key against the vault-backed custom-secrets map (the same - `ZEPH_SECRET_*`-derived store used for skill `requires_secrets`) before calling - `SubAgentManager::deliver_secret`, which now carries a `zeph_common::secret::Secret` value - instead of echoing back the key name. On the sub-agent side, `agent_loop.rs` no longer - discards the received value — it is attached as a per-tool-call `ExecutionContext` env - override (scoped to the requesting sub-agent's own tool calls, not the shared - `ShellExecutor::skill_env` slot the parent agent also uses) so a subsequent shell command - can reference `$KEY_NAME`. `PermissionGrants::is_active` is now load-bearing: - `deliver_secret` refuses to hand over a value unless there is a currently active grant for - that key, closing the gap where the TTL/grant bookkeeping was never actually consulted. -- `fix(tools)`: `checkpoint_undo`/`checkpoint_redo`/`checkpoint_list` are now forwarded to the - wrapped inner executor by `TrustGateExecutor`, `PolicyGateExecutor`, - `AdversarialPolicyGateExecutor` (#5899), `ScopedToolExecutor`, and `ShadowProbeExecutor` - (#5905). Previously none of these `ToolExecutor` wrappers overrode the three checkpoint - methods, so calls fell through to the trait's no-op default instead of reaching - `ShellExecutor` — `/undo`, `/redo`, and `/undo list` always reported "Checkpoints are not - enabled" whenever trust/policy/adversarial gating, `capability_scopes`, or `shadow_sentinel` - wrapped the executor chain, even with `[tools.shell] checkpoints_enabled = true` set — the - standard, default-recommended production configuration, not an edge case. Also fixes - `ScopedToolExecutor::requires_confirmation` (#5906), previously hardcoded to the trait - default `false` regardless of the real policy underneath, affecting the (currently dormant) - speculative-dispatch engine. -- `fix(core,acp,tools)`: the five ongoing memory-maintenance sweeps - (eviction/tier-promotion/scene-consolidation/consolidation/forgetting) and the Spec 050 - `capability_scopes`/`shadow_sentinel` tool-executor wrappers previously ran only under the - CLI/TUI (`src/runner.rs`) entry point — ACP sessions (`--acp`, `serve-sessions --acp`), the - daemon (A2A server), and `/sessions*` (`serve-sessions`) silently skipped all of it regardless - of config (#5914, #5913). Memory now stays maintained (evicted, promoted, consolidated, - forgotten) identically across every entry point, gated by the same `[memory.*]` flags; when - configured, `[security.capability_scopes]` now narrows the tool surface and - `[security.shadow_sentinel]`'s pre-execution LLM safety probe now applies uniformly too, - regardless of which entry point built the agent. A misconfigured `capability_scopes` entry - (a glob matching zero registered tools) is a fatal startup error in the daemon and - `serve-sessions` entry points, same as the CLI — both build one static, process-wide tool - registry, so the misconfiguration is knowable before any session/request is served. ACP's - per-connection/per-session registry cannot be validated quite that early; a misconfigured - scope there now fails **closed** for that one session (denies all tool access) rather than - silently falling back to the unscoped executor, which would have granted full tool access on - a config typo — the opposite of what `capability_scopes` is for. Added `ToolScope::empty()` - (`crates/zeph-tools/src/scope.rs`) as the reusable deny-all primitive backing that fail-closed - path. +- `crates/zeph-vault/src/age.rs`: `AgeVaultProvider::set_secret_mut` silently overwrote an + existing secret with no confirmation, diff, or backup — the same defect class as the + `ZEPH_DURABLE_KEY` incident fixed for the `zeph init` wizard in #5880/#5874, but one layer + down in the vault crate itself, so every caller (CLI, wizards, OAuth credential store) + inherited the same silent-overwrite risk (#5955). `set_secret_mut` now takes an explicit + `overwrite: bool` and returns `AgeVaultError::AlreadyExists` when a key already exists and + `overwrite` is `false`, leaving the previous value untouched. `zeph vault set ` + gained a `--force` flag: without it, attempting to overwrite an existing key fails with a + clear error telling the operator to re-run with `--force`; the previous secret value is never + printed, only its presence. Call sites that intentionally always overwrite (OAuth token + refresh in `src/bootstrap/oauth.rs`, and `zeph init`'s durable-key wizard step, which already + gates rotation behind its own explicit "rotate" confirmation phrase) pass `overwrite: true` + explicitly. +- `src/commands/skill.rs`/`src/commands/plugin.rs`: `zeph skill search`/`get` and + `zeph plugin search`/`get` printed the correct, actionable FR-004 message + (`REGISTRY_NOT_CONFIGURED_MSG`) to stdout when the registry is disabled, then + immediately failed with a second, differently-worded `anyhow::bail!` on stderr — and + for the plugin subcommands that second message incorrectly said "skill registry" + instead of "plugin registry" (#5943). All four call sites now reuse + `REGISTRY_NOT_CONFIGURED_MSG` for the bail as well, so stdout and the error both show + the same, subsystem-neutral, actionable message. +- `crates/zeph-core/src/agent/tool_execution/tool_result.rs`: the `reasoning_amplification` + anomaly (`AnomalyOutcome::ReasoningQualityFailure`, arXiv:2510.22977) fed `self.provider.name()` + — the provider *instance* name (e.g. `"openai"`, or a custom `[[llm.providers]]` name) — into + `is_reasoning_model()`, which pattern-matches *model identifiers* (`o3-mini`, `deepseek-r1`, + `claude-*-think`, ...). Instance names never match those patterns, so the branch was permanently + unreachable (#5909). Both the detection call and the stored `model:` field now use + `self.provider.model_identifier()`. Also added a missing `model_identifier()` override on + `GeminiProvider`, which fell back to the trait default (`""`) for the same reason. +- `crates/zeph-llm/src/provider.rs`: `RouterProvider`, `TriageRouter`, and `CandleProvider` + still fed a value that could never match `is_reasoning_model()`'s patterns into the + `reasoning_amplification` anomaly check fixed by #5909 above — `Router`/`TriageRouter` + hardcode the stable routing-policy label `"router"`/`""` from `model_identifier()`, and + `CandleProvider` had no override at all, so detection stayed permanently unreachable for + these three providers, the same defect class one call site removed (#6182, follow-up + #6183). Added `LlmProvider::effective_model_identifier()` (defaults to + `model_identifier()`) and switched the `tool_result.rs` call site to it: `RouterProvider` + and `TriageRouter` override it to resolve the sub-provider that actually served the most + recent dispatch (reusing the existing `last_active_provider`/`last_provider_idx` state + already read by reputation attribution), and `CandleProvider` gets a genuine static + `model_id` field derived from its `ModelSource` (`repo_id` for `HuggingFace` sources, file + stem for `Local` sources). `MaskedProvider` (the outbound-secret-masking wrapper applied to + every provider by default) also needed an explicit override forwarding to its inner + provider's `effective_model_identifier()` — without it, a masked Router/TriageRouter + (the structural default configuration) silently fell back to the trait default and the fix + never took effect. +- `src/commands/db.rs`: `zeph db migrate` fell back to the raw, unredacted database URL + in error messages and the migration-status line whenever `redact_url` returned `None` + — which only means the URL didn't match a recognized credential-bearing shape, not that + it's credential-free (#6026). All three call sites now fall back to the literal + `"[redacted]"` marker, matching the existing safe pattern in + `crates/zeph-db/src/pool.rs::connect_postgres`. +- `src/commands/migrate.rs`: `zeph migrate-config` operated on the raw TOML document and + never validated the result against the strict `Config` schema, so an invalid existing + value (e.g. an unrecognized `vault.backend`) silently survived migration while real + startup (`Config::load`, hardened by #6025) would reject it (#6038). `migrate-config` + now attempts to deserialize the migrated document into `Config` and prints a warning + with the underlying error if that fails, without turning the migration itself into a + hard failure — its job remains adding missing keys, not fixing preexisting invalid + values. +- `src/cli.rs`/`src/runner.rs`: `--tui` silently fell through to plain CLI mode with zero + diagnostic when the binary was compiled without the `tui` feature, since `Cli::tui` was + never feature-gated but every consumer of it was (#6016). Added + `warn_if_tui_requested_but_unavailable`, mirroring the existing + `warn_if_acp_enabled_but_unavailable` precedent but returning a hard error instead of a + warning, since silently falling back to a different mode than requested is a materially + different UX. +- `src/serve`: boxed all 17 `build_agent_factory(...).await` call sites in `agent_factory.rs` + and `handlers.rs` with `Box::pin(...)` to resolve `clippy::large_futures` (16456-byte future, + over the 16384-byte threshold) that only triggered on macOS/aarch64 due to platform-specific + stack layout differences (#6163). Same fix pattern as #3521: move the large future onto the + heap rather than tuning its size. No behavior change. +- `src/acp.rs`, `src/serve/agent_factory.rs`, `src/serve/test_support.rs`: boxed 3 new + `build_combined_deps(...).await` call sites with `Box::pin(...)` that #6169 added unboxed, + reintroducing `clippy::large_futures` after #6168 had already closed the same lint class + (#6175). Same fix pattern as #6168/#3521. No behavior change. +- `crates/zeph-sanitizer/src/sanitizer.rs`: `with_classifier_metrics`'s doc comment linked + `[`ClassifierMetrics`]` unqualified, which rustdoc could not resolve since the type + (`zeph_llm::ClassifierMetrics`) is never imported unqualified in this module, breaking the + `rustdoc::broken_intra_doc_links` gate (#6177). Changed the link to the fully-qualified path + `[`ClassifierMetrics`](zeph_llm::ClassifierMetrics)`. +- `crates/zeph-durable/src/handle.rs`: `DurableContext::checked_step_id` and `run_step_at` + repeated the identical per-execution step-cap condition verbatim. Extracted into a single + `enforce_step_cap` helper called from both sites (#6082). Pure refactor, no behavior change. +- `crates/zeph-config/src/migrate/infra.rs`: `--migrate-config`'s `migrate_durable_shared_db` + step silently left an unsafe combination undetected — `durable.encrypt_payload = false` with + `shared_db` unset passes the INV-8 `encryption_gate`'s local-only override even when the + durable journal database actually lives on a network-shared mount, since `shared_db` is + purely operator-declared and cannot be inferred from the filesystem (#6042). The migration + step now emits a `tracing::warn!`/stderr warning (matching the existing + `migrate_llm_to_providers` warning convention) asking the operator to confirm their + deployment topology whenever this combination is detected. Filesystem-type detection and + path-prefix heuristics remain explicitly out of scope, deferred to a future issue. +- `crates/zeph-worktree/src/manager.rs`: `WorktreeManager::create()` redundantly + re-canonicalised the worktree root (`spawn_blocking` + `create_dir_all` + two + `canonicalize` syscalls) on every call even though it is identical to the value + `WorktreeManager::new()` already validated once at construction and discarded + (#5940). `new()` now caches the canonicalised root on `self` and `create()` reuses + it directly, removing the redundant blocking round-trip from the subagent-spawn hot + path. Also reordered `crates/zeph-worktree/src/sanitize.rs::canonicalize_root` to + validate containment against the nearest existing ancestor before calling + `create_dir_all`, so a configured root that resolves outside the repository is + rejected without mutating the filesystem first. +- **Sub-agents**: `WorktreeManager::reconcile()`'s admission-quota count included every + git worktree registered to the repository (`git worktree list --porcelain`), not just + ones the subagent worktree subsystem itself created — so worktrees created by + unrelated tooling (including this project's own `EnterWorktree` workflow) counted + against `max_worktrees` and could silently trip `QuotaExceeded` on a background + `/agent bg` spawn. Three compounding gaps then turned that single failure into a + permanently stuck task: the error was never logged, `TaskSupervisor::spawn_oneshot` + classified a failed inner `Result` as a normal completion, and the task's status + channel was never updated past its initial `Submitted` state, so `poll_subagents()` + could never collect it — leaking one `max_concurrent` slot per occurrence (#6257). + Fixed by: scoping `reconcile()`'s counted entries to the subsystem's own worktree + root; logging `WorktreeError` at `warn` in `create()`; adding + `CompletionKind::Failed` and a generic `TaskSupervisor::spawn_oneshot_classified` + that inspects the inner `Result` (subagent spawns now classify via `Result::is_ok`; + the existing `spawn_oneshot` and its ~20 call sites are unaffected); and sending a + terminal `Failed` status from all three fallible pre-loop setup points in + `spawn()`'s task closure (`wm.create()`, `CwdRestoreGuard::new()`, + `CwdRestoreGuard::acquire()`) so a setup failure is now visible via `/agent + list`/`/agent status`, its concurrency slot is released, and the real error is + retrievable via `collect()`. -### Added +- `zeph-config`/`zeph-a2a`/`zeph-mcp`: `IbctKeyConfig`, `IbctKey`, and `McpTransport` derived + `Serialize` unredacted, so any future `serde_json`/`toml::to_string`/log/status/ACP path + serializing one of these types would have leaked the raw secret even though their `Debug` + impls were already redacted by #6005 (#6006). Replaced the derived `Serialize` on all three + with hand-written impls mirroring the existing `Debug` redaction: `IbctKeyConfig.key_hex` and + `IbctKey.key_bytes` emit `"[REDACTED]"`; `McpTransport::Stdio.env` and `McpTransport::Http + .headers` redact values only, keeping keys for diagnostics (`ServerEntry` inherits this + automatically since it nests `McpTransport`). `Deserialize` is untouched on all three — config + load, the ACP `mcp/add` handler, and IBCT token decoding still need the real values on the way + in. No live leak existed before this fix (audited: `--migrate-config` is text-based, the + `--init` wizard never populates these fields with raw secrets, and the runtime types have no + serialize-to-output path today) — this closes the latent risk for any future caller. +- `zeph-commands`/`zeph-core`/`zeph-tui`: `/help` and TUI slash autocomplete were both + hand-maintained lists that had drifted from the real command registrations (#5987, #5875). + `/conv`, `/cocoon`, `/quit`, and `/worktree` were dispatchable but missing from + `zeph_commands::COMMANDS`, hiding them from `/help`; added the four missing entries. + `Agent::run`'s two command-registry constructions (session/debug and agent-command) are now + extracted into `zeph_commands::build_session_debug_registry`/`build_agent_command_registry` + (`crates/zeph-core/src/agent/slash_commands.rs`) so a new regression test + (`commands_rs_drift_tests`) can assert every registered handler has a matching `COMMANDS` + entry, closing the door on this recurring drift class. Separately, `zeph-tui`'s `/`-triggered + autocomplete (`SlashAutocompleteState`/`filter_commands`) never sourced from + `zeph_commands::COMMANDS` at all, so every channel-agnostic `AgentAccess` command + (`/model`, `/provider`, `/skill`, `/policy`, `/think-tokens`, `/reasoning-effort`, etc.) was + dispatchable when typed in full but never suggested. Added `TuiCommand::SendVerbatim`/ + `TuiCommand::PrefillVerbatim` and `zeph_tui::command::zeph_commands_entries()`, which + projects `zeph_commands::COMMANDS` into `CommandEntry`s and merges them into + `filter_commands`, so future `AgentAccess` commands get TUI autocomplete automatically + instead of requiring a parallel hand-authored registration. Commands whose bare (no-argument) + form is not a valid default (e.g. `/image `, `/feedback `) prefill the + input for the user to complete instead of submitting an incomplete command, mirroring the + existing `*Prompt` variants' behavior. Deduplicated against existing hand-authored entries + that already cover the identical bare command; entries gated behind a Cargo feature that is + actually unified with this crate's own feature of the same name (currently only `cocoon`) + are excluded when that feature is off, so a feature-off build cannot show a dead command in + autocomplete. +- `zeph-config`: closed the sibling cohort of the #6006 Serialize-leak class for channel/A2A/MCP + config fields that have a redacting `Debug` (from #6004/#6005) but derived (plaintext) + `Serialize` (#6166). `TelegramConfig.token`, `DiscordConfig.token`, `SlackConfig.bot_token`, + and `SlackConfig.signing_secret` are always `None` at every `--init` persist point (the real + secret goes to the vault; runtime resolution hydrates it back into the field), so they now + carry `#[serde(skip_serializing)]` — the field is simply absent from any future diagnostic + `Serialize`, and `Deserialize`/config loading is unaffected. `DiscordConfig.application_id` + is a public Discord snowflake, not a secret, and is left untouched. `A2aServerConfig + .auth_token`, `McpServerConfig.env`, and `McpServerConfig.headers` legitimately hold raw + values that `--init` (or a hand-written config) persists to `config.toml`, so their derived + `Serialize` is intentionally kept plaintext — each now carries a `# Security` doc note + directing any log/dump/status output to the existing redacting `Debug` impl instead. No live + leak existed before this fix (same audit posture as #6006/#6165: no serialize-to-output path + reaches these types today). Noted below as follow-ups (not yet filed as GitHub issues): the + identical latent pattern on `GatewayConfig.auth_token` and `AcpConfig.auth_token`/ + `auth_clients[].token`, and aligning the A2A wizard to the vault-backed pattern used by the + other channel tokens. +- `zeph-orchestration`/`zeph-subagent`/`zeph-core`: `TaskNode::network_scope: Deny` was + advisory-only — the field was never read at dispatch time, so a planner-emitted + `network_scope: Deny` silently left a task's network egress unrestricted (spec + `069-threat-model` OQ-1, #6030). Now enforced on both dispatch paths via the new + `NetworkDenyToolExecutor`, which blocks `bash` invocations of `curl`, `wget`, `nc`, + `ncat`, `netcat`, and any call to the native `web_scrape`/`fetch` tool: + - Spawned sub-agents: `handle_scheduler_spawn_action` sets the new + `SpawnContext::network_denied` flag; `build_filtered_executor` wraps the sub-agent's + tool executor when set — sibling tasks and the parent agent's own executor are + unaffected. + - `RunInline` tasks: `handle_run_inline_action` temporarily wraps the parent agent's own + `tool_executor` for the duration of that single inline turn (there is no per-task + executor to wrap independently, since `RunInline` shares the parent's tool loop), + restoring it afterward. -- `feat(acp)`: `[[acp.auth_clients]]` — named bearer-token clients for the ACP HTTP/WS - transport (#5868), enabling genuine multi-tenant/multi-window isolation of persisted ACP - session listing. `crates/zeph-acp/src/transport/auth.rs`'s `BearerAuthLayer` now authenticates - against a named-client credential set instead of one server-wide token; the matched client's - stable `id` becomes the connection's `owner_key`, threaded through `build_agent_state` and - scoping every session-persistence access path (`list_sessions`, `load_session`, - `resume_session`, `fork_session`, the REST `/sessions*` CRUD handlers, and the deprecated - `_session/*` ext methods). The legacy `[acp] auth_token` scalar keeps working unchanged, - synthesized as a client with id `"default"`; unauthenticated HTTP and stdio both resolve to - the `"acp-local"` bucket, matching pre-#5868 behavior for every deployment that does not - configure `auth_clients`. Each entry accepts an inline `token` or a `token_vault_key` resolved - from the age vault at startup (mirrors `[serve] auth_token_vault_key`). Config validation - rejects the reserved ids `"default"`/`"acp-local"`, duplicate ids, and duplicate tokens across - `auth_token` + `auth_clients` (inline collisions at config-load time; vault-resolved - collisions at startup, after the vault unlocks). `--init` gained a matching wizard prompt and - `migrate-config` a new step (77) that surfaces the new array as a commented block. - Note: this closes the isolation gap for genuine multi-token **HTTP/WS** deployments only — the - literal Zed-over-stdio scenario from the issue is unaffected by this change (stdio has no - token multiplexing to redesign; use distinct `sqlite_path` values per window instead). -- `feat(plugins,skills,config,cli)`: added an opt-in skill/plugin discovery-and-install - marketplace (spec-045, #5869) — `zeph skill search ` / `zeph skill get ` - and `zeph plugin search ` / `zeph plugin get `, closing the "no discovery - path, only `--plugin-url`" gap identified against Cline's marketplace and Vercel's - `skills.sh` in a competitive parity scan. A new `crates/zeph-plugins/src/marketplace` module - defines a dyn-compatible `RegistryClient` trait (mirroring `VaultProvider`'s boxed-future - pattern) with a `SkillsShClient` implementation for the public skills.sh registry, gated - behind a new `registry` Cargo feature (included in the `full` bundle) that gates only the - network-touching client code — CLI arg definitions and `[skills.registry]` config parsing - always compile, printing an actionable "rebuild with `--features registry`" message when the - feature is off. Registry lookups are strictly opt-in and off by default - (`skills.registry.enabled = false`): zero network calls and zero vault access occur unless - explicitly enabled. Fetched packages route through the existing, unmodified install - pipelines — `SkillManager::install_from_path` (frontmatter validation + Quarantined-trust - upsert) for skills, `PluginManager::add` (manifest validation, MCP allowlist, injection scan) - for plugins — so no new content-safety bypass is introduced. Auth token resolved exclusively - via `VaultProvider` (`skills.registry.auth_vault_key`), never a plain config field. Adds the - `--init` wizard step, a `--migrate-config` step (idempotent, always writes `enabled = false` - in the advisory template), and `MockRegistryClient` proving the trait boundary is real. + Known gap: MCP-provided tools are not inspected and may still perform their own HTTP + egress. This is a best-effort tool/command-identity block, not a sandbox-level + guarantee — see `specs/069-threat-model/spec.md` INVARIANT-5. -- `feat(llm,commands,core)`: added runtime `/think-tokens [N|Nk|NM|off]` and - `/reasoning-effort [low|medium|high]` slash commands that mutate the active LLM provider's - thinking-token budget or reasoning-effort level mid-session, taking effect on the very next - turn — no restart required (#3098). Session-only: never persisted across restarts or - `/provider` switches (the switch confirmation now warns when an active override is dropped). - Supported per-provider: Claude (`Extended`/`Adaptive` thinking, mutually exclusive — setting - one overrides the other), OpenAI/Compatible (`reasoning_effort`), Gemini (`thinking_budget`/ - `thinking_level`); unsupported providers return an explicit "not supported" message rather - than a silent no-op. Fixed a latent bug in `ClaudeProvider::with_thinking` along the way: a - `base_max_tokens` snapshot now makes `max_tokens` restoration exact on disable, instead of the - previous construction-only logic which could only ever raise `max_tokens` to the 16k thinking - floor and never lower it back. Added a new `--reasoning-effort ` CLI flag and - a matching `--init` wizard prompt for OpenAI providers (Claude and Gemini already prompt for - their equivalent reasoning-depth setting). +- `zeph-config`/`zeph-memory`/`zeph-experiments`/`zeph-common`/`zeph-index`: 9 `Display` + impls (`ProviderKind`, `MemoryTier`, `ContentFidelity`, `EntityType`, `SourceKind`, + `ParameterKind`, `ExperimentSource`, `EdgeType`, `Lang`) used `Formatter::write_str`, + which silently ignores width/fill/align flags from the caller's format spec — only + `Formatter::pad` respects them. Switched all 9 to `f.pad(...)`, closing off the same + latent width-spec bug already fixed for `SessionKind`/`SessionStatus`/`SessionChannel` + in #6060 (#6066). + +- `zeph-skills`/`src/acp.rs`/`src/serve/`: `SkillOrchestra`'s RL routing head lost learned + updates under concurrent ACP/`/sessions` agents (#5974). `#5921` wired `RoutingHead` + persistence into `spawn_acp_agent` and `build_agent_factory`, but each session independently + loaded its own in-memory copy from the `routing_head_weights` singleton row and persisted + back independently — concurrent sessions clobbered each other's REINFORCE weights + (last-write-wins). The head is now loaded/cold-started exactly once in + `crate::acp::build_shared_core` and cloned (a cheap `Arc` clone) into every session sharing + that core, so all sessions mutate the same `Arc>` and updates + serialize through that mutex instead of racing across independent copies. Also added + `RoutingHead::persist_snapshot()`, capturing `embed_dim`/weights/baseline/`update_count` + under one lock acquisition, closing a related TOCTOU where a concurrent `update()` could land + between the previously-separate locked reads used to build the persisted DB row. No config or + behavior change for `rl_routing_enabled = false` (still the default) or single-session + (`runner`/`daemon`) deployments. +- `zeph-llm` Claude provider: the no-prefill gate that strips a trailing assistant + message for models that reject assistant prefill (`ClaudeProvider::no_prefill`, + added by #5903 and extended to cover the unconditional `rejects_prefill` case by + #6145 — see that `[Unreleased]/Fixed` entry for exactly which models/ + thinking-states the gate covers) was only applied in `build_request()`. The + other four request-construction paths on `ClaudeProvider` — + `chat_with_tools_stream` (the agent's primary tool-use loop), `chat_with_tools`, + `chat_typed`, and `debug_request_json` — built their request bodies + independently and never applied the gate, so a trailing-assistant-message + history routed through any of them could still trigger the same class of 400 + for any model/thinking-state the gate is meant to cover (#6146). This is the + second time this bug class was filed, so the split step and the no-prefill + strip are now bundled into two funnel methods — + `ClaudeProvider::structured_history`/`plain_history` — that every + request-construction path calls to obtain its message history; the raw + `split_messages`/`split_messages_structured` functions are no longer imported + at the `claude` module's top level, so a future request path cannot easily + split a history without the strip being applied. +- MagicDocs registration (`crates/zeph-core/src/agent/magic_docs.rs`) never fired for a + turn's terminal assistant text response — the two sites in `tier_loop.rs` that push the + final response (`process_response_native_tools`'s semantic-cache-hit branch and + `process_single_native_turn`'s `ChatResponse::Text` branch) wrote directly to + `self.msg.messages` instead of calling `self.push_message(...)`, bypassing + `detect_magic_docs_in_messages()` entirely (#6127). Detection only ran retroactively, the + next time an `Assistant` message was pushed via `push_message` — typically a *further* + tool call later in the same conversation — so a single read-then-respond turn (the + canonical `--bare -p "read X"` usage) never registered a `# MAGIC DOC:` file, even though + the read call itself succeeded and returned the marked content. Not a `--bare`-conditional + gate: `with_bare_mode` was unaffected and required no change; this was a general tool-loop + message-push coverage gap that `--bare`'s single-shot invocation pattern exposed + deterministically, while interactive multi-tool-call sessions usually masked it. Both push + sites now route through `push_message`, matching the pattern already used correctly by + `push_assistant_tool_use_message` and `process_tool_result_batch`. As a side effect, both + paths now also update `cached_prompt_tokens` and `last_assistant_at`, which the raw pushes + were silently skipping — a pre-existing token-accounting gap on the semantic-cache-hit and + plain-text-response paths, corrected incidentally by the same fix. + `detect_magic_docs_in_messages()`'s own detection guard was the deeper defect underneath + the above: it only scanned when the *last* pushed message was `Role::Assistant`, so + detection for a magic-doc read was always deferred to the next assistant push — a turn + that instead exited the native tool loop via a shutdown/user-cancel/doom-loop break or + `max_iterations` exhaustion, leaving an unpaired `Role::User` tool-result as the last + message, would still silently drop the doc even after the two `push_message` routing + fixes above. The guard now also scans when the last message is a `Role::User` message + carrying `ToolResult`/`ToolOutput` parts, so detection fires uniformly at the point the + content actually arrives, covering all native-loop exit paths, not just the two terminal + push sites. `focus.rs`'s context-compression checkpoint re-push (a `ToolUse`-only + assistant message with no paired result yet present) is intentionally left as a raw push + and documented inline as such. +- `.github/deny.toml`: the `cargo-deny` advisories gate scanned only the `candle` + and `tui` features, excluding `pdf` and every other feature shipped by the + blocking `bundle-check` (`full`) CI job and release builds — any RUSTSEC + advisory affecting a `pdf`-only dependency (or any of `acp`, `gateway`, `a2a`, + `discord`, `slack`, `scheduler`, `profiling`, `sandbox`, `gonka`, `cocoon`, + `registry`, `session`) was invisible to the security gate despite shipping in + production (#5994). `[graph] features` now scans the `full` bundle itself + instead of an enumerated feature list, so the gate tracks whatever `full` + includes without manual upkeep. This surfaced `RUSTSEC-2026-0192` (`ttf-parser` + 0.25.1 unmaintained, no patched version, via `pdf-extract` -> `lopdf` -> + `ttf-parser`), added to the advisories `ignore` list alongside the existing + unmaintained-dependency entries, with a comment noting the suggested + alternative (`skrifa`) for a future migration (#6085). +- `zeph-llm`: Claude requests to the Sonnet 4.6+/Opus 4.7+/Sonnet 5 generation with + extended thinking disabled, and to legacy Sonnet 4.6 with thinking enabled, could + send a trailing assistant message ("prefill") that the API rejects with a 400 + (#5903). The no-prefill gate was `cap.prefers_effort && thinking_param.is_some()`, + which conflated the effort-vs-`budget_tokens` conversion decision with prefill + rejection — the two only happened to align for Opus 4.7/4.8 and Sonnet 5 while + thinking was on. Added a `rejects_prefill` capability flag, independent of thinking + state, covering Sonnet 4.6+ and Opus 4.7+/Sonnet 5 unconditionally; Opus 4.6 keeps + its existing thinking-gated behavior via `prefers_effort`. +- `book/src/advanced/context.md`: corrected an internally inconsistent example model + ID, `claude-sonnet-4-5-20250514` — `20250514` is the base-release date for + `claude-sonnet-4`, not `claude-sonnet-4-5` (#5902). Replaced with `claude-sonnet-5`, + matching the dateless convention used for the current Sonnet release elsewhere in + the book since #5901. +- `zeph worktree clean`'s `Removed N, skipped M` summary omitted entries whose + `WorktreeManager::remove()` call itself failed (e.g. a locked worktree with `--force`), + silently undercounting the actual outcomes (#6077). Added an `errored` count, folded into a + new `format_clean_summary` helper so the total now always adds up to the number of stale + entries `reconcile()` discovered. Also fixed `WorktreeManager::reconcile()`'s doc comment, + which claimed it was "used at startup" — its only callers are the `zeph worktree list`/`clean` + CLI subcommands; there is no startup caller. +- `zeph-common`: `TaskSupervisor::shutdown_all(timeout)` could silently drop clean task + completions and misreport them `Aborted` (#5926). The reap driver's post-cancel drain + phase enforced its own hardcoded 5s `SHUTDOWN_DRAIN_TIMEOUT`, independent of the + caller's `timeout` — it gave up and stopped listening for completions before + `shutdown_all`'s real deadline, since the shared `CancellationToken` is cancelled + out-of-band by a shutdown bridge/signal handler well before `shutdown_all` is ever + invoked at every real call site (`src/runner.rs`, `src/serve/mod.rs`). A task that + finished cleanly after the 5s fallback but before the caller's actual timeout had its + completion dropped and was force-aborted and marked `Aborted` in the registry instead + of `Completed`. Removed the reap driver's independent deadline entirely — it now + drains until no tasks remain active, and `shutdown_all`'s own `sleep(timeout)` + + force-abort is the sole deadline authority for the whole shutdown sequence. +- `zeph-session`: `session_dir()` double-nested every on-disk session path as + `/sessions/` instead of `/` (#5981), since + `data_dir` (default `.zeph/sessions`) already names the sessions root. All callers + (`zeph-core`, `zeph-acp`, `src/runner.rs`, `src/commands/sessions.rs`, `src/acp.rs`, + `src/serve/agent_factory.rs`) resolve session paths exclusively through this function, so the + fix is a single-point change; the crate's own doc-test asserted the buggy double-nested path + and is corrected alongside it. A new `zeph_session::migrate_legacy_session_layout` runs once at + process startup (`src/runner.rs`, before any command dispatch), moving any session directory + still sitting at the old `/sessions/` path up one level to + `/`; a destination that already exists is left in place (skipped, with a + warning) rather than clobbered. Idempotent and a cheap no-op on installs with nothing to + migrate — without this, a pre-fix session would silently resume as a blank conversation + (`SessionEventLog::open` creates an empty log at the new, previously-unused path) with zero + error or warning. +- `zeph-session`: `ForkEngine::fork` never implemented spec-068 §7.2 step 6 (#5982) — it copied + every raw event (including any `UserMessage.image_refs`) into the child's `events.jsonl` but + never copied the referenced files from the parent's `blobs/` directory into the child's. + Currently dormant (no production call site populates `image_refs` yet), but would have + silently dropped attachments once wired up. `ForkEngine::fork` now hard-links (falling back to + a copy on cross-device hard-link failure) each blob referenced in the copied event range into + the child's `blobs/` directory, creating it with `0o700` permissions (matching the sibling + session directory) only when needed; a blob missing on the parent's disk is logged and skipped + rather than failing the fork. Each `image_refs` hash is now validated as a non-empty, bare hex + string before being used in a `PathBuf::join` — an unvalidated entry containing a path + separator, `..`, or an absolute path would otherwise have let a fork read or write outside the + session's `blobs/` directory; the hash list is also deduped before copying so a hash referenced + twice in one fork range hard-links once instead of falling into the cross-device copy fallback + on the second occurrence. +- `zeph-session`: `ForkEngine::fork`'s `copy_referenced_blobs` (added by #5982, see the entry + above) treated any `hard_link` failure other than a missing source as a genuine cross-device + (`EXDEV`) error and fell back to `fs::copy` (#6153). That fallback also caught the case where + the destination already exists as a hard-link to the same source — e.g. a fork retried against + the same `new_id` after a partial failure — and `fs::copy` onto an existing hard-link does not + "waste a copy", it silently truncates the shared inode to 0 bytes, corrupting the content for + every hard-link pointing at it, including the parent session's own blob. Currently dormant (no + production call site populates `image_refs` yet) but confirmed with a deterministic out-of-repo + repro during #6152's testing pass. `copy_referenced_blobs` now matches `AlreadyExists` + separately and treats it as a no-op (blobs are content-addressed by hash, so a pre-existing + entry at the hash-named path is assumed to already hold the right content), leaving the + `fs::copy` fallback to run only when the destination genuinely does not exist. +- `zeph-gateway` and `zeph-a2a`: fixed a bearer-token brute-force bypass caused by + middleware layer order (#6110, CWE-307). `auth_middleware` returns `401` directly + without calling `next.run`, so with auth layered outside `rate_limit_middleware`, + failed-auth requests never reached the per-IP counter — an attacker could brute-force + the bearer token with zero rate limiting. Swapped the `.layer()` order in + `build_router` (`zeph-gateway/src/router.rs`) and `build_router_with_full_config` + (`zeph-a2a/src/server/router.rs`) so `rate_limit_middleware` wraps `auth_middleware`, + guaranteeing every request — including failed-auth ones — increments the counter + before the auth check runs. +- `zeph-mcp`: `tool_list_locked` entries could outlive their server, leaking orphaned + locks (#6139, follow-up to #6118). `remove_server` and `shutdown_all_shared` + (`crates/zeph-mcp/src/manager/server.rs`) cleared `server_tools`, `server_trust`, + `server_fingerprints`, and `last_refresh` on disconnect but never removed the + corresponding `tool_list_locked` entry; and `handle_connect_result` + (`crates/zeph-mcp/src/manager/connect.rs`), used by the `connect_all`/ + `connect_oauth_deferred` path, released the lock on connection or `list_tools` + failure but not when the pre-connect probe blocked the connection — asymmetric with + the equivalent `add_server` path (`probe_or_cleanup`), which already cleaned up + correctly. All four cleanup sites now release `tool_list_locked` consistently. +- `zeph-mcp`: `lock_tool_list` hardening silently exempted OAuth-transport MCP servers + from post-attestation tool-injection protection (#6118). `tool_list_locked` was only + populated by the two non-OAuth connection paths (`spawn_non_oauth_connections`, + `connect_and_list_tools`); `spawn_oauth_connections` — the sole connection path for + OAuth servers — never inserted the server ID, so `tools/list_changed` notifications + from an OAuth server were never rejected regardless of the `lock_tool_list` config + value. `spawn_oauth_connections` now inserts into `tool_list_locked` before the + handshake starts, mirroring the non-OAuth path (with matching cleanup on connection + failure in `process_oauth_results`), and the `lock_tool_list`/`tool_list_locked` + invariant now has test coverage for the first time. +- `doctor`, `bench`, `gonka doctor`, and `cocoon doctor` all called + `parse_vault_args(&config, None, None, None)`, silently discarding the global + `--vault`/`--vault-key`/`--vault-path` CLI flags — only the real application startup path + (`AppBuilder::new`) threaded them through (#6037). `--vault ` and related flags + are now respected by all four commands, matching `AppBuilder::new`'s behavior; an + unrecognized `--vault` value is now rejected the same way on these paths as it already was + on the main startup path (#6025). +- `IndexMcpServer` registration (`apply_code_retrieval` in `src/agent_setup.rs`) hardcoded + `std::env::current_dir()` and never read `[index] workspace_root`, unlike the sibling + background-indexer path (`apply_code_indexer`), which resolved it correctly. Scoping + `workspace_root` to a subdirectory had no effect whenever `index.mcp_enabled = true`; + `IndexMcpServer` always walked the full process working directory instead (#6129). + Extracted the existing resolution logic into a shared `resolve_workspace_root` helper so + both call sites resolve `workspace_root` identically. +- `zeph-core`: `apply_tier_results` processed each tool result's `RuntimeLayer::after_tool` + chain and `PostToolUse` hook firing sequentially, one index at a time, even though the + tier's tool execution itself already runs bounded-parallel (#6128). A tier with many + `PostToolUse`-matching calls paid N sequential subprocess spawns after already paying for + parallel tool execution. The layer/hook phase now runs concurrently across a tier's + indices via `futures::future::join_all`, bounded by the same `max_parallel` semaphore the + tier's tool execution uses. +- `zeph-core`: `MetricsBridge::WATCHED_SPANS` (profiling feature) named three spans + (`agent.prepare_context`, `agent.tool_loop`, `agent.persist_message`) that never matched any + real `tracing` span, silently making their `on_close` handling dead code — only `llm.chat` + was ever observed (#6111). Renamed the first two to their real span names + (`core.context.prepare_context`, `core.tool.native_loop`). `agent.persist_message` is + intentionally left unwatched: its real span (`core.persist.persist_message`) fires 7+ times + per turn, not once, so bridging it would report the wrong (last) call's duration instead of + the first user-message persist that `TurnTimings::persist_message_ms` is meant to measure; + that field stays manually-timed only. +- `zeph-tui`: closed two metrics/data wiring gaps left by PR #6131 (#6132, #6059). + - `TuiCommand::WorktreeList`/`WorktreeClean` were CLI-redirect stubs pointing users at + `zeph worktree list`/`clean` because no path existed from `zeph-tui` to the running + agent's live `WorktreeManager` (private inside `zeph-subagent::SubAgentManager`). Added a + real `/worktree list`/`/worktree clean [--force]` slash command + (`zeph-commands::handlers::worktree::WorktreeCommand`, wired through a new + `AgentAccess::list_worktrees`/`clean_worktrees` pair and + `Agent::handle_worktree_list_as_string`/`handle_worktree_clean_as_string` in + `zeph-core`), backed by `SubAgentManager::worktree_manager()` — a new public getter for + the same live manager instance `spawn` already uses. The TUI reducer now forwards + `TuiCommand::WorktreeList`/`WorktreeClean` as `Effect::SendUserInput("/worktree list" / + "/worktree clean")` instead of pushing a static message, so results reflect this + session's actual worktree state, consistent with how `/skill`, `/mcp`, and `/scheduler` + already work. `WorktreeManager` gained a `prune_branch_on_remove()` getter so + `/worktree clean` can read `WorktreeConfig::prune_branch_on_remove` directly from the + manager it already holds, without `SubAgentManager` needing to duplicate it or + `zeph-core` needing to depend on the full worktree config. + - `MetricsSnapshot::classifier` (p50/p95 latency per classifier task) and + `avg_turn_timings`/`max_turn_timings` (rolling-window turn latency) were populated every + turn but had zero consumers in `zeph-tui` — only `last_turn_timings.{prepare_context_ms, + llm_chat_ms}` ever reached a widget. The resources side panel's latency line now also + shows `tool_exec_ms`/`persist_message_ms`, and a new classifier-latency line (p50 only, + compact) appears once any classifier has recorded a call. A new `view:latency` command + (`TuiCommand::ViewLatency`, following the same pattern as `/cost`'s `view:cost`) prints + the full avg/max turn-latency breakdown plus classifier p50/p95/call-count via + `App::format_latency_stats`. +- `serve-sessions`: closed three follow-up gaps in `/sessions*` HTTP+SSE session wiring + (#6045, #6046, #6008). + - `ScopedToolExecutor` (`[security.capability_scopes]`) is no longer built once, eagerly, and + shared across every concurrent `/sessions*` agent — it is now wrapped fresh per session in + `agent_factory::build_agent_factory`, mirroring `src/acp.rs`'s per-connection wrap, so each + session's `OutOfScope` capability-scope denials feed that session's own `TrajectorySentinel` + risk-escalation signal queue instead of being invisible to it. `assemble_serve_deps` still + validates the configured scope compiles against the tool registry at server startup (fatal + on a bad config, unchanged), it just no longer keeps the compiled instance — that startup + validation and the per-session wrap now share one `compose_session_tool_tree` helper so + both compile against the identical tool-id surface (including the `skill_loader`/ + `invoke_skill`/`memory`/`overflow` tools below), fixing a false-positive startup abort for + any scope pattern that referenced one of those tools. + - `/sessions*` agents now get `skill_loader`/`invoke_skill`/`memory`/`overflow` tool executors, + matching CLI/TUI/ACP/daemon's tool surface — previously these were entirely absent from + serve's composite tool chain. MCP tools, the scheduler executor, and skill/config hot-reload + broadcast forwarding remain a separately-tracked known gap. + - A `[tools.policy]`/`[tools.authorization]` compile failure now aborts `serve-sessions` + startup instead of silently starting with declarative policy enforcement disabled — serve is + an HTTP-facing entrypoint with potentially remote/less-trusted callers, unlike CLI/TUI/ACP/ + daemon (which stay intentionally fail-open on the same failure, unchanged). +- `zeph-tui`: closed three independent dispatch/state gaps (#6061, #5984, #5983). + - The task-registry overlay's supervisor-unavailable fallback (`render_subagents_slot`) + drew its "supervisor not available" message directly into the shared subagents-slot + `Rect` without a preceding `Clear`, unlike every other overlay in the crate, letting + stale glyphs from whatever rendered underneath bleed through. More significantly, + `active_panel` (Fleet/Durable/SubAgents/Tasks) and `show_task_panel` were tracked as + independent fields with no mutual-exclusion invariant, so the task panel could render + simultaneously with Fleet, Durable, or the interactive sub-agent sidebar — the latter + case left `j`/`k`/`Enter` still routed to a sidebar hidden behind the task panel. + Added `App::set_active_panel`, a single method all `active_panel`-mutating call sites + (`Action::SetActivePanel`, `Action::CyclePanelFocus`/Tab, `Action::ToggleTaskPanel`, + `TuiCommand::TaskPanel`/`FleetPanel`/`DurablePanel`) now go through, keeping + `show_task_panel` in sync so only one of the shared-Rect panels renders per frame. + - Loading a user theme file (`apply_theme`) and loading a sub-agent transcript + (`start_transcript_load`) both offload to `spawn_blocking` without ever setting + `status_label`, violating the "every background operation shows a status indicator" + convention every other async path in the TUI follows. Both now set `status_label` + before dispatch and clear it in their poll handlers on completion — and also on + cancellation, which the first pass missed: applying a built-in theme preset while a + user-file load is still in flight, or pressing Esc out of a sub-agent transcript view + before its load resolves, previously left the "loading..." label stuck indefinitely. + - `TuiCommand::SandboxStatus`/`TafcStatus` were parsed from the command palette but never + reached their already-implemented handlers in `forward_tui_commands` + (`src/tui_bridge.rs`) because `execute_command` never forwarded them through + `command_tx` — both are now wired into the existing `command_tx`-forwarding arm + alongside `ViewConfig`/`ViewAutonomy`. `TuiCommand::WorktreeList`/`WorktreeClean` were + silent no-ops with no backing implementation reachable from a running TUI session (the + live `WorktreeManager` instance is private to the agent's `SubAgentManager`, and the + CLI's `zeph worktree` subcommands construct their own, disconnected manager per + invocation); both now push a system message pointing to the equivalent CLI command + (`zeph worktree list` / `zeph worktree clean [--force]`) instead of doing nothing. +- `zeph-durable`/`zeph-core`: durable `agent_turn` executions no longer race two processes into + corrupting the same journal when they derive the same `ExecutionId` (#6122). + - `Agent::ensure_session_durable_ctx` derives the P1 `ExecutionId` deterministically from + `(ConversationId, sqlite_path)`, so two CLI processes sharing `memory.sqlite_path` and + resolving the same conversation (the common non-`--resume` path) always agree on the id. + `LocalBackend::open_execution` alone (a plain SELECT-then-INSERT, no transaction) let both + processes race the same row and independently drive `next_step` from `0`, corrupting the + journal and surfacing as `ReplayDivergence`/`ReplayIntegrity` on whichever process lost. + - Added `LocalBackend::open_execution_exclusive`, which takes a non-blocking, `flock(2)`-backed + process-exclusivity lock on the `ExecutionId` (a new `zeph_durable::ExecutionLock`) before + touching the row. A second concurrent process gets `DurableError::ExecutionLocked` and + degrades to non-durable instead of racing. The lock is released automatically by the kernel on + process exit (including `SIGKILL`), so a hard-killed process never leaves a stale lock; unlike + `zeph_common::pidfile::PidLockGuard`, the lock file is never unlinked on drop (a permanent + sentinel, matching `zeph-session::log::SessionEventLog`'s own `AdvisoryLock`) — unlinking would + reopen a `flock`+`unlink` TOCTOU race under this lock's much higher per-turn contention. SQLite + 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). + - `SubAgentManager::deliver_secret` previously sent the bare resolved `Secret` value over + the sub-agent's channel; the running agent loop cached it for the rest of its + `run_agent_loop` invocation and kept injecting it into every subsequent tool call's + `ExecutionContext`, even after the originating grant's TTL had elapsed (#5991). Added + `grants::GrantedSecret` (value + absolute expiry, computed from the active grant via the + new `PermissionGrants::expires_at`) as the channel payload, and `handle_tool_step` now + evicts expired entries from `granted_secrets` before building the `ExecutionContext` for + every tool call, not just once at approval time. + - `handle_agent_approve`'s explicit `/agent approve ` path polled + `SubAgentManager::try_recv_secret_request` (which pops the first pending request across + *all* sub-agents) and filtered by task ID, silently discarding a different sub-agent's + pending request when it happened to pop first (#5993). Added + `SubAgentManager::try_recv_secret_request_for(task_id)`, which polls only that sub-agent's + own request channel, and switched the approve path to use it. +- `zeph-durable`/`zeph-core`/`zeph`: the INV-8 control-entry row HMAC — documented in + `specs/064-durable-execution/spec.md` as closing the `EffectIntent`-forgery attack vector + (security HIGH-2b) for shared-DB/Restate deployments — was never wired to a production key or + verified on read (#6043, #6044). `LocalBackend::with_hmac_key` had no production caller, so + `hmac_key` was always `None`, every control entry's `hmac` column was always written `NULL` + regardless of deployment, and no code path recomputed or compared it on read. Added + `zeph_core::durable::derive_control_hmac_key_b64`, which derives the HMAC key as a BLAKE3 + `derive_key` subkey of the same vault-resolved `ZEPH_DURABLE_KEY` used for the AEAD payload + cipher (domain-separated, so the two keys are cryptographically independent despite sharing one + vault secret — no new vault entry required). The key is now resolved and attached at every + production choke point that opens a `LocalBackend` — the P1/P2 agent-loop adapters + (`crates/zeph-core/src/agent/durable_bootstrap.rs`, `plan.rs`), the `zeph durable` CLI write and + read paths (`load_write_hmac_key`, `open_backend` in `src/commands/durable.rs`), and the + scheduler daemon (`src/commands/scheduler_daemon.rs`) — gated by the same + `shared_db`/`postgres://`-detection policy as the AEAD `encryption_gate`: a single-user local, + non-shared database never resolves the key (matching the documented stance that its control + entries carry no HMAC), and a declared/detected shared database fails closed if + `ZEPH_DURABLE_KEY` cannot be resolved. `LocalBackend` now also verifies every `EffectIntent` it + reads: `verify_control_hmac` recomputes the HMAC and constant-time-compares it (`blake3::Hash` + equality, mirroring the existing promise resolver-token check) against the stored value, and + fails closed with the new `DurableError::ControlIntegrity` variant on a mismatch or a missing + HMAC on a keyed backend — closing the actual forgery vector the spec claimed was already closed. +- `zeph-channels`/`zeph-core`: hardened the channel send/retry/status path (#6094, #6106, #6095). + - `Channel::send_status` was awaited inline on the agent turn hot path at ~45 call sites via + `let _ = self.channel.send_status(...).await;`, with no outer timeout. Under sustained + HTTP 429s, Slack/Discord's retry-with-backoff loop (`http_retry::send_with_retry`) could take + up to several minutes, stalling the turn for that long (#6094). Added + `Channel::send_status_best_effort` — a default trait method that wraps `send_status` in a + 10s `tokio::time::timeout` and logs the outcome (`tracing::debug!` on success, + `tracing::warn!` on error or timeout) instead of returning a `Result`. All discard call + sites now use this method, which also closes #6106's "failures are silently discarded with + no logging" gap in one place rather than touching every call site individually. + - `TelegramChannel::send`/`send_status`/`send_or_edit` (backing `flush_chunks`) called + `self.bot.send_message`/`edit_message_text` directly via plain `teloxide::Bot`, bypassing the + 429 retry-with-backoff resilience that Discord, Slack, and `TelegramApiClient` already have + (#6106). Added `common::teloxide_retry::send_teloxide_with_retry`, mirroring + `http_retry::send_with_retry`'s backoff semantics for `teloxide::RequestError::RetryAfter`, + and routed all three call sites through it. + - Discord's `RestClient` had no test-injectable base URL, unlike `SlackApi`/`TelegramApiClient` + (#6095). Added a `base_url` field defaulting to Discord's real API base plus a + `#[cfg(test)]` `with_base_url` constructor, and added wiremock tests confirming + `send_message`, `edit_message`, and `trigger_typing` each retry transparently on a 429. +- `zeph-scheduler`: fixed three independent bugs surfaced by live-testing (#6096, #5950, #5947). + - `daemon_status()`'s `recent_runs` was alphabetically ordered (`ORDER BY name`) instead of + recency-ordered, and `last_run` was hardcoded to an empty string despite the database + tracking it — `zeph status --json` and the TUI `/daemon status` command presented a "recent + runs" list that was neither recent nor informative. `ScheduledTaskInfo` now carries + `last_run`, `list_jobs_full` selects it, and `daemon_status` sorts by `last_run` descending + (never-run jobs last) before truncating to `recent_n`; the CLI printer renders `last:` + alongside `next:` (#6096). + - `TaskProvenance::is_external()` was dead code — every RTW-A reentry-defense mechanism gated + on `!= TaskProvenance::Static`, collapsing the documented three-tier trust model + (`Static`/`UserAdded`/`External`) into a binary one. The injection-pattern-check mechanism + now applies unconditionally to `External`-provenance tasks regardless of the + `injection_pattern_check` config toggle, while `UserAdded` still respects the toggle, + giving `External` genuinely stricter handling without weakening the existing baseline for + either tier (#5950). + - Scheduled/cron experiment runs used the primary agent provider as both judge and subject, + silently defeating the self-judge-bias mitigation that `[experiments] eval_provider` exists + to provide — the interactive `/experiment` command and `--experiment-run` CLI flag already + resolved a distinct judge via `build_eval_provider()`, but the scheduler path never did. + `ExperimentTaskHandler` now resolves `eval_provider` the same way, falling back to the + primary provider only when unset (#5947). +- `zeph-core`: `TracingCollector::finish()` wrote `trace.json` via synchronous `std::fs` + I/O, reachable from the async agent turn loop (#6107). `write_trace_file` now offloads to + `tokio::task::spawn_blocking` when a Tokio runtime is active, falling back to an inline + synchronous write when none is present — `finish()` is also reachable from `Drop` (which + cannot `.await`) and from plain non-async unit tests, so a fallback was required rather than + making the offload unconditional. `finish()` now returns the write's `JoinHandle` so the + session-end call site (`agent/mod.rs`, where nothing else will write this session's trace) + can await it and guarantee the file lands before the process/runtime tears down, instead of + racing it fire-and-forget; the mid-session `/dump-format` switch site and `Drop` keep the + fire-and-forget behavior, mirroring `DebugDumper::write` from #6101, since a lost dump there + doesn't lose the only copy. +- `zeph-core` (`profiling` feature): `MetricsBridge` derives per-phase turn timings from + tracing span durations, but `Agent::flush_turn_timings` unconditionally overwrote + `last_turn_timings` with the manually-timed (`Instant::now()`) value every turn, discarding + whatever the bridge had just written (#5946). The clobbering itself is now fixed: + `MetricsBridge` marks a bitmask (`MetricsSnapshot::bridge_timings_written`) for each field it + writes; `flush_turn_timings` reads that mask, reconciles it against the manual value, and + clears it, all inside a single `send_modify` closure so a concurrent `MetricsBridge::on_close` + write cannot land in the gap between reading and clearing the mask. Fields the bridge did not + mark this turn still fall back to the manual value. This is **not** the same as "`MetricsBridge` + is now fully functional" — three of the four span names in `WATCHED_SPANS` + (`agent.prepare_context`, `agent.tool_loop`, `agent.persist_message`) do not currently match + any real span in the codebase, so in practice the bridge only ever populates `llm_chat_ms`; + the other three fields continue to come from manual timing exactly as before this fix, just + no longer at risk of losing bridge data for fields the bridge was never actually producing. + Reconciling those span names, and `persist_message`'s multi-call-per-turn semantics (which + don't map 1:1 to the single-span-instance model `MetricsBridge` assumes), is tracked + separately in #6111. +- `zeph-db`/`zeph-session`/`zeph-memory`: `SessionStore::list`, `list_acp_sessions`, + `list_acp_sessions_for_owner`, and `list_agent_sessions` bound `LIMIT ?` with `-1` as their + `limit == 0` ("unlimited") sentinel — a `SQLite`-only convenience that `PostgreSQL` rejects at + execution time (`ERROR: LIMIT must not be negative`) (#5980). Any caller passing `limit = 0` + against a Postgres-backed deployment got a hard SQL error instead of "all rows". Added a + shared `zeph_db::limit_clause` helper that omits the `LIMIT` clause entirely when unlimited + (the only cross-backend-safe encoding — binding `NULL` in its place is separately rejected by + `SQLite`) and applied it at all four call sites. Also fixed a related, previously-uncovered + defect surfaced while adding Postgres test coverage: `SessionStore::get`/`list`/ + `get_by_conversation_id` decoded `created_at`/`updated_at` straight into `String`, which fails + against Postgres's `TIMESTAMPTZ` columns regardless of `limit` — every `zeph-session` query + was broken on Postgres, caught only because the crate previously had no Postgres integration + test file at all. Added Postgres integration tests for `zeph-session` (new + `crates/zeph-session/tests/postgres_integration.rs`, `test-utils` feature) and extended + `crates/zeph-memory/tests/postgres_integration.rs` to cover the `limit = 0` path. +- `zeph-core`: removed two blocking-I/O sites from the async agent turn loop (#6020, #6029). + - `rebuild_system_prompt` called `project::discover_project_configs`/`load_project_context` + directly on every turn — a filesystem walk from cwd to the root plus a `read_to_string` + per discovered config, executed synchronously on the async worker thread. Now offloaded + via `tokio::task::spawn_blocking`, mirroring the existing `generate_repo_map` pattern in + the same function (#6020). + - `DebugDumper::write` (backing `dump_request`/`dump_response`/`dump_tool_output`/ + `dump_tool_error`/`dump_focus_knowledge`) called `fs_secure::write_private` synchronously + from the LLM dispatch and tool-execution hot paths. It's now fire-and-forget via + `spawn_blocking` — callers never waited on the write result, so no signature changes + were needed at any call site. Dump methods reachable only from synchronous contexts + (`dump_anchored_summary`, `dump_compaction_probe`, `dump_sidequest_eviction`, and the two + test-only pruning dumps) keep the original synchronous write and are tracked as a + follow-up. +- `zeph-acp`: closed three ACP wiring gaps that only reached the CLI/TUI entry point (#5959, + #5986, #6022). + - Shutdown-summary config (`[memory] shutdown_summary*`) and channel-scoped provider + persistence (`[session] provider_persistence`/`persist_provider_overrides`) were wired only + in `src/runner.rs`; ACP sessions never produced a shutdown summary and never + persisted/restored a "last-used provider" preference (#5959). Fixing the latter uncovered a + critical collision: naively wiring channel-scoped persistence into ACP would have silently + overwritten a resumed session's own remembered provider (`AcpSessionConfigSnapshot`, #5373) + with another session's channel-wide preference. `Agent::restore_channel_provider` now skips + the channel-wide restore whenever a caller has already primed an explicit provider override, + so a session-specific choice always takes priority. + - ACP's native `/help` rendered a hardcoded 5-command string instead of the real 49-command + registry, and `/model refresh` errored instead of refreshing the model cache (#5986). `/help` + now renders from the same command registry the CLI/TUI use; `/model refresh` refreshes the + session's active provider's model cache instead of failing. + - Automatic code-RAG context retrieval and repo-map/`IndexMcpServer` injection + (`[index] enabled`) were wired only in `src/runner.rs`; ACP and the daemon (A2A server) never + received repo context regardless of configuration (#6022). Both entry points now wire it the + same way the CLI does. +- `zeph-scheduler`: closed two RTW-A re-entry-defense gaps (#6120, #6119). + - Mechanism 4 (capability attenuation) hardcoded `matches!(task.kind, TaskKind::UpdateCheck)` + as the only way to mark a tick "external-read", so `SkillRefresh` tasks and operator-registered + `TaskKind::Custom` handlers (e.g. `zeph-memory`'s `five_signal_consolidation` daemon, which + re-surfaces stored facts that may themselves carry externally-sourced content) were invisible + to the mechanism regardless of what they actually read (#6120). `TaskHandler` gained a + `reads_external_content()` method (default `false`); the scheduler's tick loop now attenuates + based on the resolved handler's declaration instead of the task's `kind`. Overridden to `true` + on `UpdateCheckHandler` and `ConsolidationHandler`; any current or future handler (including + `Custom`-kind ones) can opt in the same way without touching `zeph-scheduler` internals. + Attenuation was also only ever *consumed* on the no-handler-registered fallback path + (`inject_custom_task`), so the production `CustomTaskHandler` — registered under + `TaskKind::Custom("custom")` and feeding the same agent-facing prompt channel from inside its + own `execute()` — bypassed suppression entirely, the exact scenario #6120's attack description + named. `TaskHandler` gained a second method, `injects_agent_prompt()` (default `false`, + overridden to `true` on `CustomTaskHandler`); the tick loop now suppresses any handler that + declares it whenever an earlier task in the same tick already read external content, closing + the production dispatch path alongside the existing fallback. + - Mechanism 3 (injection-pattern detection) matched `INJECTION_PATTERNS` via plain + case-insensitive substring search, and the pre-check cleaning step only stripped ASCII + control characters below `U+0020` — a zero-width space or other Unicode format character + inserted mid-pattern (e.g. `"sy\u{200b}stem:"`) defeated `.contains("system:")` while still + reading as `"system:"` to an LLM tokenizer (#6119). `sanitize_task_prompt_checked`/ + `sanitize_task_prompt` now route their cleaning step through + `zeph_common::sanitize::strip_control_chars_preserve_whitespace`, which also strips the + shared bypass-codepoint denylist (zero-width spaces, soft hyphens, BOM, Hangul/Khmer/Mongolian + fillers, the Unicode Tags block) — the same defense already used by `zeph-memory`'s community + summarization pipeline — before truncating to 512 code points, so padding attacks cannot hide + a pattern past the truncation window either. `zeph-common` is now a required (non-optional) + dependency of `zeph-scheduler` rather than gated behind the `daemon` feature. -### Fixed +- `zeph-channels`: Telegram's raw API extension client and Slack's Web API client had no + 429 retry-with-backoff, unlike Discord's REST client (#4728) — a rate-limited request + surfaced as a hard error on the first attempt instead of transparently retrying + (#5949). Discord's `send_with_retry` (reads the `Retry-After` header, falls back to the + JSON body's `retry_after` field, clamps to 60s, retries up to 3 times) is now a shared + `crate::common::http_retry::send_with_retry` helper used by all three adapters: + `TelegramApiClient::post`, and all four `SlackApi` methods (`auth_test`, `post_message`, + `update_message`, `download_file`). `SlackApi` gained a `base_url` field (test-only + override) for wiremock testability, and its previous per-call + `tokio::time::timeout(15s, ...)` was replaced with a per-attempt `.timeout(15s)` on the + `RequestBuilder`, since an outer timeout is incompatible with a retry loop that + legitimately needs to run longer than one request when backing off — worst-case + wall-clock under sustained 429 is now bounded by attempts x per-attempt timeout plus + backoff sleeps, documented on the helper. +- `zeph-channels`: `TelegramChannel` never implemented `Channel::send_status`, unlike its + `DiscordChannel`/`SlackChannel` peers, so Telegram users got no visibility into any of + the ~15 background/implicit-operation status messages (skill reload, MCP elicitation, + memory recall, etc.) that Discord/Slack users see — only the generic `typing…` chat + action (#5923). `TelegramChannel::send_status` now posts the status text as a plain-text + message, mirroring the Discord/Slack pattern, and no-ops when the text is empty, there is + no active chat, or the channel is in a Guest Mode context (a guest reply can only be sent + once via `answerGuestQuery`). +- `zeph-core`: config hot-reload (`Agent::reload_config`) bypassed `Config::validate()` entirely + — `load_config_with_overlay` called `Config::load` plus the plugin overlay merge and returned + the result straight to the live agent without ever validating it, unlike the startup path + (`src/bootstrap/mod.rs`, `src/tui_remote.rs`), which always calls `.validate()` immediately + after `Config::load()`. An invalid config edited into the running config file (empty/duplicate + LLM providers, inverted ACON/fidelity/trajectory thresholds, etc.) was silently applied to the + live runtime on the next reload instead of being rejected (#6063). `load_config_with_overlay` + now calls `config.validate()` on the fully-assembled config (after the plugin overlay merge, so + overlay-introduced invalid values are also caught) and returns `None` on failure, following the + same warn-and-keep-previous-state pattern already used for the `Config::load` and overlay-merge + error branches in the same function. +- **BREAKING**: `ToolExecutor` and `ErasedToolExecutor` no longer provide permissive default + bodies for the six risk-bearing cross-cutting methods — `requires_confirmation`, + `execute_tool_call_confirmed`, the checkpoint trio (`checkpoint_undo`/`checkpoint_redo`/ + `checkpoint_list`), and `is_tool_speculatable`, plus their `_erased` counterparts on the + object-safe trait (#6019). This recurring defect class — a wrapper forgetting to override one + of these and silently inheriting a default that disabled a security check, a checkpoint + capability, or a confirmation gate — required five prior one-off patches (#5930, #6011, #5998, + #6036, and the #5999/#6001/#6012 cluster). Every implementor of either trait must now supply + all six explicitly; the compiler rejects any omission at build time instead of the gap + surfacing only when a specific wrapper composition is exercised. Four new `macro_rules!` + helpers in `zeph-tools::executor_delegate` (`tool_executor_forward!`, + `tool_executor_no_inner_defaults!`, `erased_tool_executor_forward!`, + `erased_tool_executor_no_inner_defaults!`) keep the compiler-forced boilerplate to one line for + the common wrapper-forwards-to-inner and leaf-has-no-inner shapes; both `*_no_inner_defaults!` + macros carry a rustdoc warning against use on a type with a delegate field, since + `macro_rules!` cannot enforce that structurally. Closes three live gaps on the erased side + (previously dormant, gated on whether the wrapped executor supports checkpoints): the removed + `execute_tool_call_confirmed_erased` default already forwarded correctly + (`self.execute_tool_call_erased(call)`), so omitting it was not itself a live bug — the actual + gaps were the checkpoint trio and `is_tool_speculatable_erased`, whose removed defaults silently + reported "unsupported"/`false` regardless of the wrapped executor's real capability. + `FilteredToolExecutor` and `PlanModeExecutor` (`zeph-subagent::filter`) now forward the + checkpoint trio and `is_tool_speculatable_erased` to their inner executor; `PlanModeExecutor` + also now forwards `set_effective_trust`, which previously no-op'd silently, so a trust + cap set by an outer wrapper (e.g. `PolicyGateExecutor`) never reached the underlying tool while + a sub-agent was in plan mode. `PlanModeExecutor`'s checkpoint trio now intentionally forwards to + `inner` rather than staying "unsupported" — plan mode blocks new tool *execution* but checkpoint + undo/redo/list are metadata/administrative operations on already-executed side effects, not new + execution, so this is a deliberate scope decision, not an oversight. + `execute_tool_call_confirmed_erased` was nonetheless hand-written (not macro-forwarded) on all + three wrappers now that the trait requires it explicitly: `FilteredToolExecutor` and + `PlanModeExecutor` delegate to their own `execute_tool_call_erased` to preserve policy + enforcement / the execution block; `MemoryAwareExecutor` (`zeph-subagent::manager::spawn`) + replicates the `SandboxViolation` -> memory-tool fallback already present on its unconfirmed + path, since a blind forward to `inner` would have dropped that fallback specifically on the + confirmed path. `ShellExecutor` gained explicit + `requires_confirmation`/`execute_tool_call_confirmed`/`is_tool_speculatable` (previously + relying on the removed defaults despite implementing real checkpoints). +- `fix(config)`: `Config::validate()` now calls 7 subsystem `validate()` functions that existed + with real invariant checks but were unreachable from the production config-load path — only + their own unit tests exercised them (#5932). `validate_pool()` was the most severe gap: two + code comments elsewhere in the codebase state, verbatim, that it "rejects an empty + `[[llm.providers]]` list at config-validation time" and treat that as a load-bearing guarantee, + but it was never actually invoked outside its own test module — a config with zero providers, + duplicate provider names, or multiple `default = true` entries previously loaded and validated + without error. The other six wired checks: `LlmConfig::validate_stt()` (dangling + `[llm.stt].provider` reference), `TrajectorySentinelConfig::validate()` (inverted risk + thresholds), `GatewayConfig::validate()`, `UtilityScoringConfig::validate()`, + `FidelityConfig::validate()`, and `AconConfig::validate()`. Also added `#[must_use]` to + `LlmConfig::validate_stt()` (missed by the earlier #4943/#4963 sweep since added afterward). + Because `Config::default()` had an empty provider pool, wiring `validate_pool()` made + `Config::default().validate()` fail, which in turn broke `--dump-config-defaults` and the + no-config-file fallback in `zeph --tui --connect`; fixed by seeding `Config::default()` with one + `ProviderEntry::default()` (`type = "ollama"`), matching the shipped `config/default.toml` + reference, which was already self-consistent. +- `fix(config)`: two more `--migrate-config --in-place` steps re-appended their advisory block on + every run instead of converging after one, same defect class as #5945 (#6018). The + `mcp`/`mcp.elicitation`/`mcp` max-connect-attempts/retry-and-tool-timeout steps anchored on a + raw `toml_src.contains("[mcp]\n")` substring, which also matches inside a *commented* `# [mcp]` + stub left by the top-level migrator's catch-all pass when `[mcp]` was absent on a prior run — + now gated by `section_header_present(toml_src, "mcp")`, which correctly excludes commented + headers. `migrate_memory_retrieval_query_bias`'s idempotency guard checked for an *uncommented* + `query_bias_correction` field while the step only ever writes a *commented* advisory line, so + the guard never matched its own prior output; changed to a `contains` check on the raw source + (and switched to `section_header_present` for its section-presence check, for consistency with + the sibling `[memory.hebbian]` steps fixed in #5945). +- `fix(tui)`: the durable executions overlay panel (`D` key) no longer bleeds stray glyphs from + whatever widget last drew into the same sidebar `Rect` earlier in the frame (e.g. a trailing + `e> t` fragment left over from the subagents/plan/security view rendered underneath) — `Clear` + was missing before the panel's own draw calls, unlike the other 8 overlay widgets in this + crate that already blank their area first (#6048). Also, `encryption_gate` (INV-8) rejecting + the deployment's configuration is now visually distinguishable from a plain "journal + unavailable" state: the panel previously collapsed both into the identical + `STATUS_UNAVAILABLE` message, so an operator had no way to tell a security-policy rejection + from durable execution simply being unconfigured; `DurableSnapshot.available: bool` is + replaced with a `DurableStatus` enum (`Unavailable` / `GateRejected` / `Available`) and gate + rejections now render the distinct `STATUS_GATE_REJECTED` message (#6041). +- `fix(tui)`: the Fleet and Task Registry sidebar overlay panels (`f`/task-registry keys) had the + same missing-`Clear` stray-glyph bug as the durable panel above (#6048) — neither called + `Clear` before drawing into their shared sidebar `Rect`, so leftover glyphs from whatever + widget last rendered into that area could bleed through. Both now clear their `Rect` first, + matching the crate-wide overlay convention (#6054). Also, the Fleet panel's session table + rendered its KIND/STATUS/CH columns with no separating whitespace (e.g. + `interactiveactivetuigpt-4o-mini`) because `SessionKind`/`SessionStatus`/`SessionChannel` + implemented `Display` via `Formatter::write_str`, which silently ignores the width/fill/align + flags carried by `fleet.rs`'s `{: Checker MARCH + response-quality verification, `config.quality.self_check`) is now built and attached via + `Agent::with_quality_pipeline` in all four entry points, extracted into a shared + `agent_setup::build_quality_pipeline` helper so the four call sites cannot diverge (#5951). + `TrajectorySentinel` (spec-050 risk-escalation state machine, `[security.trajectory]`) is now + wired via `Agent::with_trajectory_config`/`with_trajectory_risk_slot`/`with_signal_queue` in + ACP (per session), daemon, and serve (per session); the paired + `PolicyGateExecutor::with_trajectory_risk`/`with_signal_queue` and + `ScopedToolExecutor::with_signal_queue` plumbing (already threaded through + `agent_setup::apply_policy_gate_chain`'s `trajectory` parameter, added by #5978's PR in + anticipation of this fix) is now populated in all three entry points — serve's globally-shared + `ScopedToolExecutor` (built once in `assemble_serve_deps`, before any per-session queue + exists) is a documented exception: its `OutOfScope` denials do not feed the trajectory signal + queue, since doing so would leak one session's signals into every other session sharing that + executor (#5958). `SkillInvokeExecutor` (the `invoke_skill` tool — per-invocation trust check + including a blake3 integrity re-check and fail-closed refusal on `Blocked`/quarantine-denied + classification) is now constructed and registered as a tool executor, with its trust-snapshot + `Arc` also threaded onto the `Agent` via `with_trust_snapshot`, in ACP and daemon — previously + `invoke_skill` was effectively CLI-only (#5975). +- `fix(worktree)`: `WorktreeManager::reconcile()` no longer silently drops detached-`HEAD` + worktrees (#5936). `git worktree list --porcelain` emits a `detached` line instead of + `branch refs/heads/` for these worktrees; the porcelain parser previously only flushed a + parsed block when both a `worktree ` line and a `branch` line were seen, so detached + entries were discarded and became permanently invisible to `zeph worktree list`/`zeph worktree + clean`. A block is now flushed whenever its `worktree ` line is seen, and detached + entries get the new `zeph_worktree::DETACHED_BRANCH_SENTINEL` (`"(detached HEAD)"` — the + embedded space makes it an invalid git ref name, so it can never collide with a real branch, + including one on a worktree foreign to zeph) as their `branch_name`; `WorktreeManager::remove` + skips the `git branch -D` step for this sentinel since there is no real branch to prune, while + the `git worktree remove --force` path is unaffected (it operates on `path`, not + `branch_name`). +- `fix(worktree)`: `zeph worktree clean` now runs `git worktree prune` after removing stale + entries, per spec-063 FR-CLEANUP-04 (#5937). Previously it only issued `git worktree remove + --force` for entries discovered by `reconcile()`, leaving any stale administrative files (e.g. + from a worktree directory deleted outside Zeph) in the git registry. Added + `WorktreeManager::prune()`. +- `fix(worktree)`: `DefaultGitRunner` now clamps `git_timeout_secs = 0` (and any sub-second + timeout) to a 1-second floor inside `new()`/`with_timeout()` itself, per spec-063's NEVER + invariant (#5939). Previously the clamp was duplicated ad hoc at two call sites + (`src/runner.rs`, `src/commands/worktree.rs`) and the crate's own `Default` impl bypassed both, + so `DefaultGitRunner::default()` (and any other future call site) could still construct a + zero-timeout runner where every `git` invocation failed instantly. Both call-site `.max(1)` + duplications were removed now that the crate enforces the invariant internally; the + `git_timeout_secs` doc comment on `WorktreeConfig` was corrected to say so. +- `fix(worktree)`: `zeph worktree clean` no longer force-removes another, concurrently running + zeph session's worktree (#6055). `WorktreeManager::reconcile()` classified "stale" as simply + "not in this process's own in-memory `handles`", which is unconditionally empty for the + fresh, one-shot `WorktreeManager` that `zeph worktree clean` constructs per CLI invocation — + so every other git-registered worktree, including one with live uncommitted work belonging to + a separate session, was force-removed via `git worktree remove --force` with no dirty-tree + check. `reconcile()` now returns `Vec` (**breaking**: was + `Vec`) — each entry carries git's own `prunable ` porcelain-output + verdict, captured by a rewritten `parse_worktree_list_porcelain`, alongside the resolved + `WorktreeHandle`. `StaleWorktree::is_safe_to_force_remove()` is `true` only when git itself + reports the worktree's directory or `.git` gitdir-link as gone/broken — the one condition + under which force-removal cannot discard live work, regardless of which process created the + worktree. `zeph worktree clean` now skips (with a warning) any stale entry that is not + `prunable`, unless the new `--force` flag is passed; it prints a `Removed N, skipped M` + summary. `zeph worktree list` now surfaces each stale entry's prunable reason, or an + "in use — not marked prunable" note, so operators can decide before running `clean --force`. + `WorktreeManager::remove()` itself is unchanged — it still issues a single `--force`, which + correctly does not override an explicit `git worktree lock`. +- `fix(worktree)`: `parse_worktree_list_porcelain` no longer mislabels a bare repository's main + worktree as detached-`HEAD` (#6052, found during #6051 review). `git worktree list --porcelain` + emits a `bare` line (no `HEAD`/`branch`/`detached` line at all) for these entries; they now get + the new, distinct `zeph_worktree::BARE_WORKTREE_SENTINEL` (`"(bare repository)"`) instead of + `DETACHED_BRANCH_SENTINEL`. +- `fix(tools)`: `CompressedExecutor`, `ToolFilter`, and `Arc` now forward the + remaining cross-cutting `ToolExecutor` methods to their inner/wrapped executor instead of + silently falling through to the trait's no-op defaults (#6012). `CompressedExecutor` now + forwards `requires_confirmation` and the `checkpoint_undo`/`checkpoint_redo`/`checkpoint_list` + trio. `ToolFilter` (wrapping the ACP `FileExecutor`) previously forwarded none of the + cross-cutting methods — it now forwards `execute_tool_call_confirmed` (respecting tool + suppression), `set_skill_env`, `set_effective_trust`, `is_tool_retryable`, + `is_tool_speculatable`, `requires_confirmation`, and the checkpoint trio. `Arc` + now also forwards `execute_confirmed`, `execute_tool_call_confirmed`, `set_effective_trust`, + `is_tool_retryable`, `is_tool_speculatable`, and `requires_confirmation` — the + `execute_confirmed` forward closes a currently-dormant gap (its only caller today, + `handle_confirmation_required` in `tool_result.rs`, is `#[cfg(test)]`-gated; production + confirmation dispatch goes through `execute_tool_call_confirmed` via `tier_loop.rs` instead) + but is worth fixing now as defense-in-depth, matching the pattern of every other wrapper, in + case that path is ever re-enabled. Same defect class as #5899/#5905/#5906 (fixed by #5930) and + #5900/#5938/#5931 (fixed by #6011). +- `fix(tools)`: `capture_snapshot_for` no longer silently drops a checkpoint for a + newly-created file whose path lives under a symlinked `allowed_paths` prefix on macOS (e.g. + `/tmp` -> `/private/tmp`, `/var` -> `/private/var`) (#5999). For a file that does not exist + yet, `canonicalize()` fails, and the previous fallback (`std::path::absolute`) does not + resolve symlinks, so the file's path stayed under the raw prefix while `allowed_paths` + (canonicalized at construction time) held the resolved prefix — the containment check failed + and the checkpoint was dropped with only a `tracing::warn!`. Both `capture_snapshot_for` and + `validate_sandbox_with_cwd` now share a new `canonicalize_or_nearest_ancestor` helper that + walks up to the nearest existing ancestor, canonicalizes it, and reattaches the non-existent + suffix. - `fix`: allowed the `linker_messages` rustc lint via `[workspace.lints.rust]` so plain `cargo build`/`cargo run` succeeds on macOS/arm64, where Apple's `ld` linker warns on binaries @@ -2620,56 +2644,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Apache License, Version 2.0 text) alongside the existing MIT `LICENSE`, so both halves of the dual-license declaration have a corresponding license file (#5855). -### Changed - -- `ci`: migrate the workspace lint-warning gate from the global `RUSTFLAGS: "-D warnings"` - CI env var to Cargo's native `build.warnings = "deny"` (new `.cargo/config.toml`, stabilized - in Rust 1.97, cargo PR rust-lang/cargo#16796). Unlike `RUSTFLAGS`, toggling `build.warnings` - does not change rustc's invocation fingerprint, so it no longer forces a full recompile of - unchanged units when switching between a plain `cargo build` and a warnings-denied one — - verified locally via `cargo build -v` fingerprint comparison (`Fresh` in both directions vs. - full recompile on `RUSTFLAGS` toggle). Coverage-parity verified for the warning classes the - previous gate caught (unused imports, dead code, unused variables); `build.warnings` was also - found to independently catch `rustdoc::broken_intra_doc_links` and `cargo clippy` lints, wider - than expected, but `RUSTDOCFLAGS="--deny rustdoc::broken_intra_doc_links"` and clippy's own - `-- -D warnings` CLI flag are left unchanged as defense-in-depth (#5873). The `coverage` job's - former `RUSTFLAGS: ""` reset (needed because it builds `--features full`, a superset of the - `lint-clippy` matrix, and must not fail on lint status — that's `lint-clippy`'s job) is now - `CARGO_BUILD_WARNINGS: "allow"`, the per-job env override for the same repo-wide config key. - The `rustdoc` job gets the same override: `build.warnings` being wider than `RUSTDOCFLAGS` - means it independently denies `rustdoc::private_intra_doc_links`/`redundant_explicit_links` - too, and 37 pre-existing instances across 10 crates (unrelated to this change) would have - newly failed that job; the override keeps it enforcing exactly what it always has pending a - separate doc-cleanup pass. -- `chore`: raise the workspace MSRV from Rust 1.96 to 1.97 (`Cargo.toml` - `rust-version`, CI `msrv` job, all crate README badges/notes, `specs/constitution.md`). - Rust 1.97 (stable 2026-07-07) is now the minimum supported toolchain. This also unifies - MSRV references that had drifted between 1.95 and 1.96 across README/spec docs. No source - changes accompany the bump: a review against Rust 1.89-1.97 stabilizations found no - 1.97-specific stdlib API with a real use site (the codebase already uses `floor_char_boundary` - for UTF-8-safe truncation; existing `compare_exchange` sites are one-shot guards, not - CAS-update loops; `with_extension` sites intentionally replace the suffix). -- `refactor(session)`: extracted a shared `finish_torn_tail` helper in - `crates/zeph-session/src/log.rs` for the identical torn-tail warn+repair epilogue duplicated - between `read_events` and `read_events_chunked` (#5852). -- `perf(scheduler)`: `daemon_status()`'s `recent_runs` fetched every active job via - `list_jobs_full()` and sorted/truncated in Rust rather than pushing the ordering and limit - into SQL (#6115). Added `JobStore::list_recent_runs`/`count_active_jobs`; ordering uses - `ORDER BY last_run IS NULL, last_run DESC LIMIT ?`, which evaluates to a sortable `0`/`1` - (`SQLite`) or `false`/`true` (`PostgreSQL`) value on both backends without relying on - `PostgreSQL`-only `NULLS LAST` syntax. -- `chore(scheduler)`: removed the unused `blake3` and `uuid` dependencies from - `crates/zeph-scheduler/Cargo.toml` — neither was referenced anywhere in the crate's source - (#6098). -- `chore(agent-tools)`: removed 7 unused `zeph-*` dependencies (`zeph-agent-persistence`, - `zeph-config`, `zeph-context`, `zeph-mcp`, `zeph-orchestration`, `zeph-sanitizer`, - `zeph-skills`) from `crates/zeph-agent-tools/Cargo.toml` — leftover scaffolding from the - abandoned `ToolDispatcher` extraction (#3516, closed) with zero references in `src/`. - Narrowed the `sqlite`/`postgres` feature gates to forward only to `zeph-tools`, the sole - remaining backend-gated dependency (#6084). - -### Fixed - - `docs(orchestration)`: corrected `zeph-orchestration` crate-level doc comment claiming `llm-planning` feature is enabled by default; it is not (default feature set is `sqlite` only). Updated docs to clarify opt-in behavior and reference `LlmPlanner`/`LlmAggregator` APIs (#5856). - `fix(tools)`: `[tools.adversarial_policy]`'s fixed 3s `timeout_ms` made the fail-closed adversarial gate deny effectively every tool call when `policy_provider` pointed at a local @@ -14778,7 +14752,8 @@ let agent = Agent::new(provider, channel, &skills_prompt, executor); [0.16.0]: https://github.com/bug-ops/zeph/compare/v0.15.3...v0.16.0 -[Unreleased]: https://github.com/bug-ops/zeph/compare/v0.22.0...HEAD +[Unreleased]: https://github.com/bug-ops/zeph/compare/v0.22.1...HEAD +[0.22.1]: https://github.com/bug-ops/zeph/compare/v0.22.0...v0.22.1 [0.22.0]: https://github.com/bug-ops/zeph/compare/v0.21.4...v0.22.0 [0.21.4]: https://github.com/bug-ops/zeph/compare/v0.21.3...v0.21.4 [0.21.3]: https://github.com/bug-ops/zeph/compare/v0.21.2...v0.21.3 diff --git a/Cargo.lock b/Cargo.lock index 0d57631bf..7cca52f1e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10484,7 +10484,7 @@ dependencies = [ [[package]] name = "zeph" -version = "0.22.0" +version = "0.22.1" dependencies = [ "agent-client-protocol", "anyhow", @@ -10569,7 +10569,7 @@ dependencies = [ [[package]] name = "zeph-a2a" -version = "0.22.0" +version = "0.22.1" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -10600,7 +10600,7 @@ dependencies = [ [[package]] name = "zeph-acp" -version = "0.22.0" +version = "0.22.1" dependencies = [ "agent-client-protocol", "agent-client-protocol-schema", @@ -10644,7 +10644,7 @@ dependencies = [ [[package]] name = "zeph-agent-context" -version = "0.22.0" +version = "0.22.1" dependencies = [ "chrono", "futures", @@ -10669,7 +10669,7 @@ dependencies = [ [[package]] name = "zeph-agent-feedback" -version = "0.22.0" +version = "0.22.1" dependencies = [ "regex", "schemars 1.2.1", @@ -10684,7 +10684,7 @@ dependencies = [ [[package]] name = "zeph-agent-persistence" -version = "0.22.0" +version = "0.22.1" dependencies = [ "serde", "serde_json", @@ -10703,7 +10703,7 @@ dependencies = [ [[package]] name = "zeph-agent-tools" -version = "0.22.0" +version = "0.22.1" dependencies = [ "futures", "serde", @@ -10718,7 +10718,7 @@ dependencies = [ [[package]] name = "zeph-bench" -version = "0.22.0" +version = "0.22.1" dependencies = [ "clap", "schemars 1.2.1", @@ -10740,7 +10740,7 @@ dependencies = [ [[package]] name = "zeph-channels" -version = "0.22.0" +version = "0.22.1" dependencies = [ "axum 0.8.9", "criterion", @@ -10769,7 +10769,7 @@ dependencies = [ [[package]] name = "zeph-commands" -version = "0.22.0" +version = "0.22.1" dependencies = [ "serde", "thiserror 2.0.18", @@ -10780,7 +10780,7 @@ dependencies = [ [[package]] name = "zeph-common" -version = "0.22.0" +version = "0.22.1" dependencies = [ "axum 0.8.9", "blake3", @@ -10819,7 +10819,7 @@ dependencies = [ [[package]] name = "zeph-config" -version = "0.22.0" +version = "0.22.1" dependencies = [ "dirs", "insta", @@ -10839,7 +10839,7 @@ dependencies = [ [[package]] name = "zeph-context" -version = "0.22.0" +version = "0.22.1" dependencies = [ "blake3", "criterion", @@ -10860,7 +10860,7 @@ dependencies = [ [[package]] name = "zeph-core" -version = "0.22.0" +version = "0.22.1" dependencies = [ "age", "base64 0.22.1", @@ -10932,7 +10932,7 @@ dependencies = [ [[package]] name = "zeph-db" -version = "0.22.0" +version = "0.22.1" dependencies = [ "regex", "sqlx", @@ -10949,7 +10949,7 @@ dependencies = [ [[package]] name = "zeph-durable" -version = "0.22.0" +version = "0.22.1" dependencies = [ "blake3", "bytes", @@ -10975,7 +10975,7 @@ dependencies = [ [[package]] name = "zeph-experiments" -version = "0.22.0" +version = "0.22.1" dependencies = [ "futures", "ordered-float 5.3.0", @@ -10999,7 +10999,7 @@ dependencies = [ [[package]] name = "zeph-gateway" -version = "0.22.0" +version = "0.22.1" dependencies = [ "axum 0.8.9", "http-body-util", @@ -11016,7 +11016,7 @@ dependencies = [ [[package]] name = "zeph-index" -version = "0.22.0" +version = "0.22.1" dependencies = [ "futures", "ignore", @@ -11049,7 +11049,7 @@ dependencies = [ [[package]] name = "zeph-llm" -version = "0.22.0" +version = "0.22.1" dependencies = [ "async-stream", "audioadapter-buffers", @@ -11098,7 +11098,7 @@ dependencies = [ [[package]] name = "zeph-mcp" -version = "0.22.0" +version = "0.22.1" dependencies = [ "async-trait", "blake3", @@ -11137,7 +11137,7 @@ dependencies = [ [[package]] name = "zeph-memory" -version = "0.22.0" +version = "0.22.1" dependencies = [ "arc-swap", "blake3", @@ -11179,7 +11179,7 @@ dependencies = [ [[package]] name = "zeph-orchestration" -version = "0.22.0" +version = "0.22.1" dependencies = [ "blake3", "chrono", @@ -11209,7 +11209,7 @@ dependencies = [ [[package]] name = "zeph-plugins" -version = "0.22.0" +version = "0.22.1" dependencies = [ "dirs", "flate2", @@ -11235,7 +11235,7 @@ dependencies = [ [[package]] name = "zeph-sanitizer" -version = "0.22.0" +version = "0.22.1" dependencies = [ "parking_lot", "proptest", @@ -11257,7 +11257,7 @@ dependencies = [ [[package]] name = "zeph-scheduler" -version = "0.22.0" +version = "0.22.1" dependencies = [ "chrono", "cron", @@ -11280,7 +11280,7 @@ dependencies = [ [[package]] name = "zeph-session" -version = "0.22.0" +version = "0.22.1" dependencies = [ "rustix 1.1.4", "serde", @@ -11300,7 +11300,7 @@ dependencies = [ [[package]] name = "zeph-skills" -version = "0.22.0" +version = "0.22.1" dependencies = [ "anyhow", "blake3", @@ -11337,7 +11337,7 @@ dependencies = [ [[package]] name = "zeph-subagent" -version = "0.22.0" +version = "0.22.1" dependencies = [ "dirs", "indoc", @@ -11368,7 +11368,7 @@ dependencies = [ [[package]] name = "zeph-tools" -version = "0.22.0" +version = "0.22.1" dependencies = [ "arc-swap", "dashmap", @@ -11413,7 +11413,7 @@ dependencies = [ [[package]] name = "zeph-tui" -version = "0.22.0" +version = "0.22.1" dependencies = [ "arboard", "base64 0.22.1", @@ -11464,7 +11464,7 @@ dependencies = [ [[package]] name = "zeph-vault" -version = "0.22.0" +version = "0.22.1" dependencies = [ "age", "proptest", @@ -11481,7 +11481,7 @@ dependencies = [ [[package]] name = "zeph-worktree" -version = "0.22.0" +version = "0.22.1" dependencies = [ "parking_lot", "serde", diff --git a/Cargo.toml b/Cargo.toml index 88983b075..9f938476b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ resolver = "3" [workspace.package] edition = "2024" rust-version = "1.97" -version = "0.22.0" +version = "0.22.1" authors = ["bug-ops"] license = "MIT OR Apache-2.0" repository = "https://github.com/bug-ops/zeph" @@ -153,38 +153,38 @@ url = "2.5.8" uuid = "1.23.4" walkdir = "2.5" wiremock = "0.6.5" -zeph-a2a = { path = "crates/zeph-a2a", version = "0.22.0" } -zeph-acp = { path = "crates/zeph-acp", default-features = false, version = "0.22.0" } -zeph-agent-context = { path = "crates/zeph-agent-context", default-features = false, version = "0.22.0" } -zeph-agent-feedback = { path = "crates/zeph-agent-feedback", version = "0.22.0" } -zeph-agent-persistence = { path = "crates/zeph-agent-persistence", default-features = false, version = "0.22.0" } -zeph-agent-tools = { path = "crates/zeph-agent-tools", default-features = false, version = "0.22.0" } -zeph-bench = { path = "crates/zeph-bench", default-features = false, version = "0.22.0" } -zeph-channels = { path = "crates/zeph-channels", default-features = false, version = "0.22.0" } -zeph-commands = { path = "crates/zeph-commands", version = "0.22.0" } -zeph-common = { path = "crates/zeph-common", version = "0.22.0" } -zeph-config = { path = "crates/zeph-config", version = "0.22.0" } -zeph-context = { path = "crates/zeph-context", version = "0.22.0" } -zeph-core = { path = "crates/zeph-core", default-features = false, version = "0.22.0" } -zeph-db = { path = "crates/zeph-db", default-features = false, version = "0.22.0" } -zeph-durable = { path = "crates/zeph-durable", default-features = false, version = "0.22.0" } -zeph-experiments = { path = "crates/zeph-experiments", default-features = false, version = "0.22.0" } -zeph-gateway = { path = "crates/zeph-gateway", version = "0.22.0" } -zeph-index = { path = "crates/zeph-index", default-features = false, version = "0.22.0" } -zeph-llm = { path = "crates/zeph-llm", version = "0.22.0" } -zeph-mcp = { path = "crates/zeph-mcp", default-features = false, version = "0.22.0" } -zeph-memory = { path = "crates/zeph-memory", default-features = false, version = "0.22.0" } -zeph-orchestration = { path = "crates/zeph-orchestration", default-features = false, version = "0.22.0" } -zeph-plugins = { path = "crates/zeph-plugins", default-features = false, version = "0.22.0" } -zeph-sanitizer = { path = "crates/zeph-sanitizer", default-features = false, version = "0.22.0" } -zeph-scheduler = { path = "crates/zeph-scheduler", default-features = false, version = "0.22.0" } -zeph-session = { path = "crates/zeph-session", default-features = false, version = "0.22.0" } -zeph-skills = { path = "crates/zeph-skills", default-features = false, version = "0.22.0" } -zeph-subagent = { path = "crates/zeph-subagent", default-features = false, version = "0.22.0" } -zeph-tools = { path = "crates/zeph-tools", default-features = false, version = "0.22.0" } -zeph-tui = { path = "crates/zeph-tui", default-features = false, version = "0.22.0" } -zeph-worktree = { path = "crates/zeph-worktree", version = "0.22.0" } -zeph-vault = { path = "crates/zeph-vault", version = "0.22.0" } +zeph-a2a = { path = "crates/zeph-a2a", version = "0.22.1" } +zeph-acp = { path = "crates/zeph-acp", default-features = false, version = "0.22.1" } +zeph-agent-context = { path = "crates/zeph-agent-context", default-features = false, version = "0.22.1" } +zeph-agent-feedback = { path = "crates/zeph-agent-feedback", version = "0.22.1" } +zeph-agent-persistence = { path = "crates/zeph-agent-persistence", default-features = false, version = "0.22.1" } +zeph-agent-tools = { path = "crates/zeph-agent-tools", default-features = false, version = "0.22.1" } +zeph-bench = { path = "crates/zeph-bench", default-features = false, version = "0.22.1" } +zeph-channels = { path = "crates/zeph-channels", default-features = false, version = "0.22.1" } +zeph-commands = { path = "crates/zeph-commands", version = "0.22.1" } +zeph-common = { path = "crates/zeph-common", version = "0.22.1" } +zeph-config = { path = "crates/zeph-config", version = "0.22.1" } +zeph-context = { path = "crates/zeph-context", version = "0.22.1" } +zeph-core = { path = "crates/zeph-core", default-features = false, version = "0.22.1" } +zeph-db = { path = "crates/zeph-db", default-features = false, version = "0.22.1" } +zeph-durable = { path = "crates/zeph-durable", default-features = false, version = "0.22.1" } +zeph-experiments = { path = "crates/zeph-experiments", default-features = false, version = "0.22.1" } +zeph-gateway = { path = "crates/zeph-gateway", version = "0.22.1" } +zeph-index = { path = "crates/zeph-index", default-features = false, version = "0.22.1" } +zeph-llm = { path = "crates/zeph-llm", version = "0.22.1" } +zeph-mcp = { path = "crates/zeph-mcp", default-features = false, version = "0.22.1" } +zeph-memory = { path = "crates/zeph-memory", default-features = false, version = "0.22.1" } +zeph-orchestration = { path = "crates/zeph-orchestration", default-features = false, version = "0.22.1" } +zeph-plugins = { path = "crates/zeph-plugins", default-features = false, version = "0.22.1" } +zeph-sanitizer = { path = "crates/zeph-sanitizer", default-features = false, version = "0.22.1" } +zeph-scheduler = { path = "crates/zeph-scheduler", default-features = false, version = "0.22.1" } +zeph-session = { path = "crates/zeph-session", default-features = false, version = "0.22.1" } +zeph-skills = { path = "crates/zeph-skills", default-features = false, version = "0.22.1" } +zeph-subagent = { path = "crates/zeph-subagent", default-features = false, version = "0.22.1" } +zeph-tools = { path = "crates/zeph-tools", default-features = false, version = "0.22.1" } +zeph-tui = { path = "crates/zeph-tui", default-features = false, version = "0.22.1" } +zeph-worktree = { path = "crates/zeph-worktree", version = "0.22.1" } +zeph-vault = { path = "crates/zeph-vault", version = "0.22.1" } zeroize = { version = "1.9.0", default-features = false } [workspace.lints.rust] diff --git a/README.md b/README.md index 3627e8f05..598a37345 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ [![CI](https://img.shields.io/github/actions/workflow/status/bug-ops/zeph/ci.yml?branch=main&label=CI)](https://github.com/bug-ops/zeph/actions) [![codecov](https://codecov.io/gh/bug-ops/zeph/graph/badge.svg?token=S5O0GR9U6G)](https://codecov.io/gh/bug-ops/zeph) [![MSRV](https://img.shields.io/badge/MSRV-1.97-blue)](https://www.rust-lang.org) - [![Tests](https://img.shields.io/badge/tests-12621-brightgreen)](https://github.com/bug-ops/zeph/actions) + [![Tests](https://img.shields.io/badge/tests-13792-brightgreen)](https://github.com/bug-ops/zeph/actions) [![License: MIT OR Apache-2.0](https://img.shields.io/badge/License-MIT%20OR%20Apache--2.0-yellow.svg)](LICENSE) diff --git a/book/AGENTS.md b/book/AGENTS.md index daed73853..9c5a56e20 100644 --- a/book/AGENTS.md +++ b/book/AGENTS.md @@ -3,6 +3,6 @@ This directory holds the mdBook source for Zeph documentation. - Keep docs aligned with the current code, config defaults, and CLI behavior. -- When adding or moving pages under `docs/src/`, update `docs/src/SUMMARY.md`. +- When adding or moving pages under `book/src/`, update `book/src/SUMMARY.md`. - Prefer concise, technical documentation over marketing copy in reference and guide pages. - If a code change affects user-facing behavior, configuration, architecture, or workflows, update the relevant docs in the same change. diff --git a/book/src/advanced/tui.md b/book/src/advanced/tui.md index fc5b6b651..61b2dc7c0 100644 --- a/book/src/advanced/tui.md +++ b/book/src/advanced/tui.md @@ -70,6 +70,8 @@ When using `--connect`, the TUI renders token-by-token streaming from the remote | `e` | Toggle expanded/compact view for tool output and diffs | | `d` | Toggle side panels on/off | | `p` | Toggle Plan View / Sub-agents view in the side panel | +| `s` / `S` | Toggle Settings view (read-only panels showing LLM providers, MCP servers, and sub-agent definitions) | +| `Ctrl+F` | Open transcript search (case-insensitive substring search across message content and tool names) | | `Tab` | Cycle side panel focus (includes SubAgents panel) | | `a` | Focus the SubAgents panel | @@ -199,6 +201,38 @@ When a destructive command requires confirmation, a modal overlay appears: All other keys are blocked while the modal is visible. +### Settings View + +Press `S` in Normal mode to open a read-only side panel showing your current configuration: + +- **Providers tab** — all LLM providers defined in `[[llm.providers]]` with their model, type, and whether they're marked as default or for embedding +- **MCP Servers tab** — all connected MCP servers and their hosted tools +- **Sub-Agents tab** — all registered sub-agent definitions with their routing hints + +The view refreshes automatically when providers are switched or the config is reloaded mid-session. API keys and vault-based secrets are never displayed in the settings view. + +| Key | Action | +|-----|--------| +| `s` / `S` | Toggle settings view open/closed | +| `Tab` / `Right` | Move to next tab | +| `Shift+Tab` / `Left` | Move to previous tab | +| `Up` / `Down` | Scroll within the active tab | +| `Escape` | Close settings and return to Normal mode | + +### Transcript Search + +Press `Ctrl+F` in Normal mode to open a search overlay that searches across all messages in the current session. Search is case-insensitive and matches substrings in message content and tool names. + +| Key | Action | +|-----|--------| +| Any character | Add to search query (incremental match highlighting) | +| `Enter` or `Ctrl+F` | Jump to next match and close search | +| `Shift+Enter` | Jump to previous match and close search | +| `Backspace` | Remove last query character (dismisses if query is empty) | +| `Escape` | Close search without jumping and restore previous scroll position | + +Matches are highlighted inline within the transcript and the view scrolls to a visible anchor even if the match is inside a collapsed tool-output block. The search uses the live-rendered message content, so matches are always accurate. + ## Markdown Rendering Chat messages are rendered with full markdown support via `pulldown-cmark`: diff --git a/book/src/guides/worktree.md b/book/src/guides/worktree.md index a8120f71f..54162ae4b 100644 --- a/book/src/guides/worktree.md +++ b/book/src/guides/worktree.md @@ -132,6 +132,53 @@ If worktree creation is slow on your system: 3. **Check disk**: Low disk space can slow git operations 4. **Use `head` mode**: Avoids a remote fetch; only copies the local worktree +## Disk Quota Management + +When running many concurrent sub-agents or long-running sessions, worktree storage can accumulate. Zeph provides automatic disk quota management to keep worktree directories under control: + +### Configuration + +Add the following to your `[worktree]` section: + +```toml +[worktree] +enabled = true +max_worktrees = 10 # Hard limit on active worktrees (default: 10) +disk_quota_mb = 2048 # Max total disk space for all worktrees in MB (default: 2048, ~2 GiB) +auto_reconcile_secs = 3600 # Periodic sweep interval in seconds (default: 3600, 1 hour) +reconcile_on_startup = true # Run a cleanup sweep on agent startup (default: true) +``` + +### How It Works + +1. **On startup**: Zeph runs a sweep to clean up orphaned worktrees from previous sessions +2. **On worktree creation**: Checks if adding a new worktree would exceed `disk_quota_mb` or `max_worktrees`. If so, automatically prunes the oldest unused worktrees until there's room +3. **Periodic reconciliation**: Every `auto_reconcile_secs`, the background supervisor runs a cleanup sweep to remove stale worktrees not linked to any active sub-agent +4. **Pruning strategy**: Only removes worktrees that have been pruned via git (git has marked them as prunable); never force-deletes intact checkouts + +### Quotas in Action + +Example: if `disk_quota_mb = 2048` and `max_worktrees = 10`: + +- When you spawn the 11th concurrent sub-agent, the oldest idle worktree is pruned to make room +- If total worktree disk usage would exceed 2 GiB, older worktrees are pruned before creating a new one +- A warning is logged when quota is tight or sweeps are frequent (may indicate undersized quotas) + +### Manual Cleanup + +Even with automatic quotas, you can manually clean up anytime: + +```bash +zeph worktree clean # Remove stale/pruned worktrees +zeph worktree list # See current usage and breakdown +``` + +To temporarily disable periodic reconciliation (e.g., for performance testing): + +```bash +auto_reconcile_secs = 0 # Disable periodic sweep; only clean on startup and admission +``` + ## Troubleshooting ### "Git operation timed out" diff --git a/book/src/reference/cli.md b/book/src/reference/cli.md index c15dc7959..85cd03fde 100644 --- a/book/src/reference/cli.md +++ b/book/src/reference/cli.md @@ -700,6 +700,24 @@ Generate an on-demand summary of the current conversation. Useful for understand Configuration: Set `[session.recap]` in your config to control which LLM provider and whether to auto-recap on session resume. +### `/cd` + +Change the working directory for the agent. This updates the active `cwd` used by tools like `shell` and `read_file`, invalidates the cached repo-map, and re-discovers `CLAUDE.md` and `AGENTS.md` files in the new directory. The system-prompt context block is preserved across the change. + +``` +/cd +``` + +Examples: + +```bash +> /cd ../sibling-project +> /cd /home/user/workspace/myproject +> /cd . # reset to current directory +``` + +The path must be within the allowed `[tools.shell] allowed_paths` sandbox. Attempting to change to a path outside the sandbox will produce an error. + ### `/conv` Browse, resume, or fork durable conversation-sessions (see [Session Persistence and @@ -725,6 +743,7 @@ channels. | Flag | Description | |------|-------------| | `--bare` | Strip the agent to essentials for scripted/CI usage: skips memory initialization, scheduler startup, skill loading, and watcher registration. Faster startup, suitable for piping and non-interactive workflows. Incompatible with `--tui`, `--acp`, and messaging channels | +| `--safe-mode` | Disable project-context, plugins, skills (including hot-reload), hooks, and MCP servers for a single session for troubleshooting. Unlike `--bare` (which is for CI scripting), `--safe-mode` preserves the full agent loop and allows normal interaction — it just strips optional features. Also set via `ZEPH_SAFE_MODE=true` | | `--json` | Emit structured JSONL events to stdout (boot, chunk, response_end, tool_call, tool_result, cost, error) for programmatic integration. All tool output is redacted. Incompatible with `--tui`, `--acp`, and messaging channels. Tracing redirected to stderr | | `-y` / `--auto` | Enable full autonomy: skip all tool confirmation prompts. Shell blocklist and adversarial policy enforcement remain active. Use in trusted scripted environments | | `--tui` | Run with the TUI dashboard (requires the `tui` feature) | diff --git a/book/src/reference/configuration.md b/book/src/reference/configuration.md index e2263f932..bacb13a20 100644 --- a/book/src/reference/configuration.md +++ b/book/src/reference/configuration.md @@ -379,6 +379,20 @@ strategy = "heuristic" # Routing strategy for memory backend selection (d # MemORAI adaptive retrieval settings for graph-based memory # deep_reasoning_query_conditioned = false # Use query-adaptive SYNAPSE weighting when deep reasoning active (default: false) +# [memory.type_aware_compose] +# MemGuard-inspired type-aware retrieval composition — opt-in, default off (#6086) +# Retrieval-only gate: no new storage, no write-path change, byte-for-byte no-op when disabled. +# When enabled, context assembly composes only the configured functional types instead of +# all memory sources. Functional types: episodic (past actions), user_fact (known facts), +# behavioral_rule (heuristics), reasoning_strategy (problem-solving patterns), +# cross_session_summary (continuity across sessions), graph_fact (knowledge graph entities). +# Corrections stay unconditionally composed as a safety-critical invariant. +# enabled = false +# # Functional types always composed; empty = all types (default). Unknown strings = hard config error. +# default_compose_types = [] # ["episodic", "user_fact", "behavioral_rule", "reasoning_strategy", "cross_session_summary", "graph_fact"] +# # Widen the active set per classified query intent; no new LLM call (reuses HeuristicRouter) +# intent_scoped = false + # [memory.admission] # enabled = false # Enable A-MAC adaptive memory admission control (default: false) # threshold = 0.40 # Composite score threshold; messages below this are rejected (default: 0.40) @@ -755,6 +769,7 @@ max_parallel = 4 # Max concurrent task executions (defau default_failure_strategy = "abort" # abort, retry, skip, or ask (default: "abort") default_max_retries = 3 # Retries for the "retry" strategy (default: 3) task_timeout_secs = 300 # Per-task timeout in seconds, 0 = no timeout (default: 300) +# default_idle_timeout_secs = 60 # Global default idle/no-progress timeout in seconds; individual tasks can override via `timeout` field. RESERVED — not yet enforced (see specs/075-orchestration-node-control-parity/spec.md) # planner_provider = "quality" # Provider name from [[llm.providers]] for planning LLM calls; empty = primary provider planner_max_tokens = 4096 # Max tokens for planner LLM response (default: 4096; reserved — not yet enforced) dependency_context_budget = 16384 # Character budget for cross-task context injection (default: 16384) @@ -871,6 +886,7 @@ max_parked_promises = 1000 # Above this, promise resolution fall [durable.retention] ttl_completed_secs = 604800 # Prune completed executions older than this (7 days) ttl_failed_secs = 2592000 # Prune failed/aborted executions older than this (30 days) +stale_running_after_secs = 3600 # Crash-orphan sweep: abort running executions after this many seconds with no owner process (0 disables). Requires advisory-lock backend (SQLite on Unix). See specs/064-durable-execution/spec.md §crash-orphan-sweep. max_executions = 10000 # LRU cap on stored executions max_journal_bytes = 1073741824 # Journal size cap in bytes (1 GiB) prune_batch_size = 500 # Rows deleted per transaction during a sweep diff --git a/crates/AGENTS.md b/crates/AGENTS.md index 4fd95bc3c..b51a259a7 100644 --- a/crates/AGENTS.md +++ b/crates/AGENTS.md @@ -5,4 +5,4 @@ This directory contains the workspace crates. The root [`AGENTS.md`](../AGENTS.m - Keep changes local to the crate you are editing unless shared APIs require coordinated updates. - Default verification is crate-first: `cargo build -p `, `cargo nextest run -p `, then workspace checks only if the change crosses crate boundaries. - Use `cargo clippy -p --all-targets -- -D warnings` before considering crate-local work done. -- If a crate's public behavior or configuration changes, update the crate `README.md`, root docs in `docs/src/`, and any relevant config or CLI surfaces. +- If a crate's public behavior or configuration changes, update the crate `README.md`, root docs in `book/src/`, and any relevant config or CLI surfaces. diff --git a/crates/zeph-a2a/AGENTS.md b/crates/zeph-a2a/AGENTS.md index 36671bc0a..6816255d2 100644 --- a/crates/zeph-a2a/AGENTS.md +++ b/crates/zeph-a2a/AGENTS.md @@ -3,6 +3,11 @@ Protocol client/server work for A2A lives here. - Start with crate-local checks: `cargo build -p zeph-a2a`, `cargo nextest run -p zeph-a2a`, `cargo clippy -p zeph-a2a --all-targets -- -D warnings`. +- Read `specs/014-a2a/spec.md` before changing discovery, trust policy, or IBCT; honor its `## Key Invariants` and `NEVER` sections. - Keep changes isolated to A2A transport, discovery, JSON-RPC, and server behavior unless a shared API truly requires cross-crate edits. - Preserve protocol-facing behavior unless the task explicitly calls for a spec-aligned change. +- The trust anchor for `AgentCard` signature verification is only the operator-configured `[a2a_client].trusted_agent_keys` store — never a card-supplied `jku` URL (self-signed-forgery + SSRF risk). +- IBCT tokens (`ibct.rs`) are HMAC-signed bearer credentials scoped to `task_id` + endpoint origin — never log or dump a raw token, and keep `IbctKey`/`IbctKeyConfig` on hand-written `Debug`/`Serialize` impls that redact `key_hex`/`key_bytes`; a derived impl reopens the leak (#6005, #6165). +- `rate_limit_middleware` must wrap `auth_middleware`, never the reverse, in `build_router_with_full_config` — `auth_middleware` short-circuits with 401 without calling `next.run`, so if it sits outside the rate limiter, failed-auth requests bypass the per-IP counter entirely (#6136). +- An empty-string bearer/vault token must never be treated as "no auth configured" — normalize or reject it before hashing, never let it hash-match a missing header (#6282). - If external behavior changes, update `crates/zeph-a2a/README.md` and the relevant docs in `docs/src/advanced/a2a.md`. diff --git a/crates/zeph-a2a/README.md b/crates/zeph-a2a/README.md index 327d0516a..b9ba96985 100644 --- a/crates/zeph-a2a/README.md +++ b/crates/zeph-a2a/README.md @@ -16,7 +16,7 @@ Implements the Agent-to-Agent (A2A) protocol over JSON-RPC 2.0, enabling Zeph to - **client** — `A2aClient` for sending tasks and messages to remote agents - **server** — `A2aServer` exposing an A2A-compliant endpoint with `ProcessorEvent` streaming via `mpsc::Sender` (requires `server` feature) - **card** — `AgentCardBuilder` for constructing agent capability cards; includes `protocolVersion` field set to `A2A_PROTOCOL_VERSION` constant (`"0.2.1"`) in the default card served at `/.well-known/agent.json` -- **discovery** — `AgentRegistry` for agent lookup and registration +- **discovery** — `AgentRegistry` for agent lookup and registration, with an optional card-signing + URL-origin trust policy applied in `discover()` (see below) - **jsonrpc** — JSON-RPC 2.0 request/response types - **types** — shared protocol types (Task, Message, Artifact, etc.) - **error** — `A2aError` error types @@ -55,18 +55,57 @@ key_hex = "68656c6c6f2d7365637265742d6b6579" # legacy inline path; prefer the - Setting `ibct_keys` to a non-empty list makes the server require `X-Zeph-IBCT` on every `/a2a` and `/a2a/stream` request. Since nothing in this repository attaches that header, doing so will `401` `zeph --connect`'s own `tui_remote` client and any standard (non-Zeph) A2A peer that has no knowledge of this header — it does not, by itself, protect a delegated subagent task from a leaked bearer token, because no delegation client using IBCT exists yet to protect. - To get real protection from IBCT, an operator (or a follow-up change) must build/wire a caller — most likely a task-delegation client for subagent orchestration — that calls `with_ibct_key` and scopes tokens to the tasks it delegates, *before* enabling `ibct_keys` on the receiving server. +## Agent Card trust policy (JWS signature verification) + +`AgentRegistry` supports an optional, feature-gated (`card-signing`) A2A 1.0.0 `AgentCardSignature` +check applied inside `discover()`, closing the card-spoofing/impersonation gap where a peer card was +trusted unauthenticated with no cross-check between the queried base URL and the card's own `url` +field. + +`AgentRegistry::with_trust(policy, trusted_keys)` configures a tri-state `CardTrustPolicy` +(`Ignore` / `Prefer` / `Require`, default `Ignore`) combining signature verification and +URL-origin consistency via most-severe-wins precedence, checked against an out-of-band +operator-configured trusted-key store (never the card-supplied `jku`, which would reopen an SSRF +surface this crate already guards against elsewhere). Not calling `with_trust` leaves the registry +at `Ignore` with no trusted keys — zero behavior change for existing callers. + +```rust +use zeph_a2a::{AgentRegistry, CardTrustPolicy}; +use std::time::Duration; + +let registry = AgentRegistry::new(reqwest::Client::new(), Duration::from_secs(300)) + .with_trust(CardTrustPolicy::Prefer, vec![]); +``` + +`zeph --connect ` — the only outbound A2A client path in the binary — wires +`AgentRegistry::discover` with the operator's configured `[a2a] card_trust_policy` and +`trusted_agent_keys`. + +> [!WARNING] +> Canonicalization is implemented per the A2A spec text but has not been validated against a real +> `a2a-sdk`-produced signed-card vector — `require` may reject genuinely valid peers until this is +> proven (tracked in [#6201](https://github.com/bug-ops/zeph/issues/6201)). + ## Authentication `A2aServer` supports bearer token authentication via the `with_auth()` builder method. When `auth_token` is `None`, the server emits a `tracing::warn!` at startup indicating that the endpoint is unauthenticated. -```rust -A2aServer::new(addr, sender) - .with_auth(Some("secret-token".to_string())) +```rust,ignore +use std::sync::Arc; +use tokio::sync::watch; +use zeph_a2a::{A2aServer, AgentCardBuilder}; + +let card = AgentCardBuilder::new("my-agent", "http://localhost:9090", "0.1.0").build(); +let (_shutdown_tx, shutdown_rx) = watch::channel(false); + +A2aServer::new(card, Arc::new(my_processor), "0.0.0.0", 9090, shutdown_rx) + .with_auth(Some("secret-token")) + .with_rate_limit(120) // requests per 60s window per IP; 0 disables .serve() .await?; ``` -The token is hashed once at construction time; each request compares blake3 hashes of both sides to prevent timing attacks. `A2aServer::require_auth(true)` rejects all requests when no token is configured. +The token is hashed once at construction time; each request compares blake3 hashes of both sides to prevent timing attacks. `A2aServer::with_require_auth(true)` rejects all requests when no token is configured. Failed-auth requests (missing/invalid bearer token or IBCT header) are also subject to the same per-IP rate limit as ordinary requests, closing a brute-force vector against the auth layer itself. ## Features @@ -74,6 +113,7 @@ The token is hashed once at construction time; each request compares blake3 hash |---------|-------------| | `server` | Enables `A2aServer`, `TaskManager`, and `TaskProcessor` with an axum HTTP handler and bearer auth (requires `axum`, `tower`, `tower-http`) | | `ibct` | Enables `Ibct` token issuance and verification (HMAC-SHA256) | +| `card-signing` | Enables A2A 1.0.0 `AgentCardSignature` verification and the `CardTrustPolicy` trust check in `AgentRegistry::discover` (requires `p256`, `serde_json_canonicalizer`). Must be compiled in together with `zeph-config`'s matching marker feature — `card_trust_policy = "require"` fails config validation otherwise. | ## Installation diff --git a/crates/zeph-acp/AGENTS.md b/crates/zeph-acp/AGENTS.md index 746ad294e..1b9b2bfe3 100644 --- a/crates/zeph-acp/AGENTS.md +++ b/crates/zeph-acp/AGENTS.md @@ -3,6 +3,9 @@ IDE embedding, ACP transport, permissions, and session handling live here. - Start with crate-local checks: `cargo build -p zeph-acp`, `cargo nextest run -p zeph-acp`, `cargo clippy -p zeph-acp --all-targets -- -D warnings`. +- Read `specs/013-acp/spec.md` before changing session lifecycle, permission gates, or auth; honor its `## Key Invariants` section. - Be careful with session lifecycle, permission gates, HTTP/WebSocket transport, and filesystem/terminal bridging. +- `session/delete` (and any future permanent-deletion path) must purge the persisted store row (`store.delete_acp_session_for_owner`) in addition to the in-memory entry — a deleted session must never resurrect via `session/load`/`session/resume` (#6271, #6284). +- An empty-string bearer/vault token must never be treated as "no auth configured" — `BearerAuthLayer::new` filters these out at construction as defense-in-depth even if an upstream caller fails to normalize one (#6282). - Changes in ACP behavior should stay aligned with the CLI flags in the root binary and with ACP-related docs. - If user-visible behavior changes, update `crates/zeph-acp/README.md` and the relevant docs in `docs/src/advanced/acp.md` or `docs/src/guides/ide-integration.md`. diff --git a/crates/zeph-acp/README.md b/crates/zeph-acp/README.md index cf3842856..e025803d1 100644 --- a/crates/zeph-acp/README.md +++ b/crates/zeph-acp/README.md @@ -28,6 +28,8 @@ zeph-acp = { version = "0.22", features = ["acp-http"] } | Feature | Description | Default | |---------|-------------|---------| +| `sqlite` | SQLite backend forwarded to `zeph-memory`/`zeph-session`/`zeph-core`/`zeph-mcp`/`zeph-tools` | Yes | +| `postgres` | PostgreSQL backend forwarded to the same crates | No | | `acp-http` | HTTP+SSE transport via axum (`AcpHttpState`, `acp_router`, `post_handler`, `get_handler`) | No | **Tip:** diff --git a/crates/zeph-agent-context/AGENTS.md b/crates/zeph-agent-context/AGENTS.md index f68d38918..8df9242b4 100644 --- a/crates/zeph-agent-context/AGENTS.md +++ b/crates/zeph-agent-context/AGENTS.md @@ -4,6 +4,7 @@ Context-assembly service (`ContextService`): system prompt rebuild, memory injec - Start with crate-local checks: `cargo build -p zeph-agent-context`, `cargo nextest run -p zeph-agent-context`, `cargo clippy -p zeph-agent-context --all-targets -- -D warnings`. - Read `specs/021-zeph-context/spec.md` before changing assembly, budget, or compaction behavior; honor its `## Key Invariants` and `NEVER` sections. +- Read `specs/004-memory/004-16-memory-type-aware-retrieval.md` (MemGuard type-aware retrieval composition, #6226/#6086) before changing `type_aware_compose.rs` or the active-`FunctionalType`-set resolution it feeds into `zeph_context::assembler::schedule_context_fetchers`. NEVER gate `fetch_corrections`/`BehavioralRule` behind the active set — past-correction recall is always composed, unconditionally, regardless of config. NEVER let an unrecognised `default_compose_types` string silently widen to "all types" — it must be a hard config-load error. Note: in-code comments cite this as "spec 064" — that is a naming collision with the permanent `/specs/064-durable-execution/` slot; the real spec lives at the path above. - Core invariant: this crate MUST NOT depend on `zeph-core`. The decoupling is the whole point — never add a `zeph-core` dependency to satisfy a borrow. - Keep the borrow-lens views (`MessageWindowView`, `ContextAssemblyView`, `ContextSummarizationView`) narrow; `zeph-core` constructs them from `Agent` field projections. - Features: `sqlite` (default) / `postgres` forwarded to `zeph-memory`, plus `index` for `IndexAccess` integration. Run `cargo nextest run -p zeph-agent-context --features index` when touching index-backed assembly views. diff --git a/crates/zeph-agent-context/README.md b/crates/zeph-agent-context/README.md index d6198769c..82591488c 100644 --- a/crates/zeph-agent-context/README.md +++ b/crates/zeph-agent-context/README.md @@ -7,7 +7,10 @@ Agent context-assembly service for the [Zeph](https://github.com/bug-ops/zeph) AI agent. -Provides `ContextService` — a stateless façade for all context operations: system prompt rebuilds, memory injection, conversation compaction, and summarization. Previously this logic lived directly on `Agent` inside `zeph-core`; extracting it means editing context assembly does not trigger recompilation of the tool dispatcher (`zeph-agent-tools`) or the persistence layer (`zeph-agent-persistence`). +Provides `ContextService` — a stateless façade for context operations: memory injection, skill disambiguation, conversation compaction, and summarization. Previously this logic lived directly on `Agent` inside `zeph-core`; extracting it means editing context assembly does not trigger recompilation of the tool dispatcher (`zeph-agent-tools`) or the persistence layer (`zeph-agent-persistence`). + +> [!NOTE] +> System prompt rebuild (`rebuild_system_prompt`) stayed on `Agent` in `zeph-core` — it was never migrated into a `ContextService` method, and an early dead stub of the same name was later removed from this crate. ## Installation @@ -23,36 +26,34 @@ zeph-agent-context = { version = "0.22", workspace = true } All methods on `ContextService` are stateless. State flows exclusively through explicit borrow-lens view parameters — structs of `&`/`&mut` references that `zeph-core`'s shim layer constructs from disjoint `Agent` fields. The borrow checker proves field disjointness at the literal struct expressions in the shim. -### Rebuild system prompt +### Prepare context (memory injection) ```rust,no_run -use zeph_agent_context::{ContextService, ContextAssemblyView, MessageWindowView, ProviderHandles}; +use zeph_agent_context::ContextService; let svc = ContextService::new(); // `window` and `view` are constructed by zeph-core's shim from Agent fields. -svc.rebuild_system_prompt( - query, - &mut window, - &mut view, - &providers, - &trust_gate, - &status_sink, -).await; +let delta = svc.prepare_context(query, &mut window, &mut view).await?; +// `delta.code_context`, if present, is applied by the caller (zeph-core keeps +// `inject_code_context` on `Agent` per the extraction scope decision). ``` -### Prepare context (memory injection) +### Compaction ```rust,no_run -svc.prepare_context(query, &mut window, &mut view, &providers, &status_sink) - .await - .map_err(AgentError::context)?; +// `status` implements the `StatusSink` trait so collected messages can be +// forwarded to the channel after the call returns. +svc.maybe_compact(&mut summ, &status).await?; ``` -### Compaction +### Skill disambiguation ```rust,no_run -svc.maybe_compact(&mut summ, &providers, &status_sink).await?; +use zeph_agent_context::ContextService; + +let svc = ContextService::new(); +let chosen_order = svc.disambiguate_skills(query, &all_meta, &scored, &providers).await; ``` ## Key Types @@ -66,6 +67,11 @@ svc.maybe_compact(&mut summ, &providers, &status_sink).await?; | `ContextSummarizationView<'a>` | Borrow-lens over fields needed for compaction, scheduling, and pruning | | `ProviderHandles` | Arc-cloned primary and embedding LLM provider handles | +`type_aware_compose::resolve_active_functional_types` resolves the MemGuard-inspired active +`FunctionalType` set (spec-064, #6086) that `prepare_context` uses to gate memory-fetcher +composition per turn — retrieval-only, no storage or write-path change; a byte-for-byte no-op +when `[memory.type_aware_compose]` is disabled (the default). + ## Borrow-Lens Pattern Views hold `&`/`&mut` references to field types from lower-level crates. No view embeds a whole `*State` aggregator from `zeph-core` — each field maps directly to a concrete type from `zeph-memory`, `zeph-skills`, `zeph-config`, etc. diff --git a/crates/zeph-agent-persistence/AGENTS.md b/crates/zeph-agent-persistence/AGENTS.md index 606809242..d2c37e4c6 100644 --- a/crates/zeph-agent-persistence/AGENTS.md +++ b/crates/zeph-agent-persistence/AGENTS.md @@ -4,7 +4,9 @@ Persistence service (`PersistenceService`): loads conversation history from and - Start with crate-local checks: `cargo build -p zeph-agent-persistence`, `cargo nextest run -p zeph-agent-persistence`, `cargo clippy -p zeph-agent-persistence --all-targets -- -D warnings`. - Read `specs/057-agent-persistence/spec.md` before changing history loading, persistence, or extraction enqueueing. +- Read `specs/068-session-persistence/spec.md` (INV-SP-1..4) before changing session-open/hydration behavior — `hydrate_from_event_log` (`hydrate.rs`) is the single sanctioned pipeline for ACP resume/load/fork, CLI `sessions resume`, and `/conv resume`; its `messages` fold MUST go through `zeph_session::ReplayEngine::replay`'s bounded/chunked reader, never `fold()` on a cloned event `Vec` (regression fixed in #5861 — that call doubled peak memory on every resume path). - Core invariant: this crate MUST NOT depend on `zeph-core`. Keep the borrow-lens views (`MemoryPersistenceView`, `SecurityView`, `MetricsView`) narrow; `zeph-core` builds them from `Agent` fields. +- Ephemeral media invariant (spec-072 §4 C1): callers into this crate's persistence path always receive `Image`-free `MessagePart` slices — `zeph-core`'s `Agent::persist_message` strips `MessagePart::Image` before invoking `PersistMessageRequest`/`svc.persist_message`. Do not add code here that assumes `Image` parts need filtering again downstream, and do not weaken the assumption that this crate never itself sees an unstripped slice. - Features: `sqlite` (default) / `postgres` forwarded to `zeph-memory` — verify behavior is identical across both backends; silent divergence is a first-class bug. - LLM serialization gate: tool-pair sanitization (`sanitize.rs`, `request.rs`) controls whether `tool_use`/`tool_result` blocks are well-formed. A malformed pairing causes hard LLM 400/422 errors that unit tests do not catch — changes here require a live multi-turn + tool-call session test before merge. - Embedding dimension mismatches are a recurring source of bugs: whenever the embedding model or vector collection config changes, verify stored and query vector dimensions match before running tests. diff --git a/crates/zeph-agent-persistence/README.md b/crates/zeph-agent-persistence/README.md index f0c3e06f3..c1e638bb5 100644 --- a/crates/zeph-agent-persistence/README.md +++ b/crates/zeph-agent-persistence/README.md @@ -65,8 +65,8 @@ async fn example( ## Architecture `zeph-agent-persistence` depends on `zeph-memory`, `zeph-llm`, `zeph-context`, `zeph-config`, -and `zeph-common`. It does **not** depend on `zeph-core`. This is the core invariant that keeps -the persistence and tool-dispatch subsystems independently evolvable. +`zeph-session`, and `zeph-common`. It does **not** depend on `zeph-core`. This is the core +invariant that keeps the persistence and tool-dispatch subsystems independently evolvable. `zeph-core` depends on this crate and constructs the borrow-lens views (`MemoryPersistenceView`, `SecurityView`, `MetricsView`) from disjoint field projections of `Agent`, then delegates to diff --git a/crates/zeph-agent-tools/AGENTS.md b/crates/zeph-agent-tools/AGENTS.md index 3f5cc9240..8417b5545 100644 --- a/crates/zeph-agent-tools/AGENTS.md +++ b/crates/zeph-agent-tools/AGENTS.md @@ -6,6 +6,6 @@ Tool-dispatch primitives consumed by the tool loop in `zeph-core`: the sealed `A - Read `specs/006-tools/spec.md` before changing the dispatch contract or tool-result handling. - Architecture invariant: this crate MUST NOT depend on `zeph-core` or `zeph-channels`. `AgentChannel` is a minimal, sealed trait specifically to avoid the circular dependency that `zeph-core::channel::Channel` would create — never break this by adding those deps. - `AgentChannel` is sealed via the `Sealed` trait: external implementations are forbidden by design. `zeph-core` implements it through its local `AgentChannelView<'a, C>` adapter. -- Crate status: Phase-2 scaffolding (issue #3516). Full `ToolDispatcher` extraction from `zeph-core` is a follow-up — keep changes minimal and aligned with that direction rather than adding speculative surface. +- Crate status: Phase-2 scaffolding (issue #3516, closed). The `AgentChannel` trait and borrowed event carriers are complete, but no `zeph-core` adapter implements them and no `ToolDispatcher` extraction has landed or is in flight — that plan was abandoned, not deferred; re-opening it requires a new tracking issue. Per #6222/#6084, the crate now declares only the 3 `zeph-*` deps it actually uses (`zeph-common`, `zeph-llm`, `zeph-tools`) — do not re-add `zeph-agent-persistence`/`zeph-config`/`zeph-context`/`zeph-mcp`/`zeph-orchestration`/`zeph-sanitizer`/`zeph-skills` speculatively; add a dependency only alongside the code that actually needs it. - Doom-loop detection is agent-safety critical: any change to `doom_loop_hash` or its hashing inputs needs regression coverage so repeated tool calls are still detected. - LLM serialization gate: once tool dispatch / batch tool-result processing is extracted here, changes to those paths require a live session test with a real tool call before merge. diff --git a/crates/zeph-channels/AGENTS.md b/crates/zeph-channels/AGENTS.md index fcd53c9dd..d49571b67 100644 --- a/crates/zeph-channels/AGENTS.md +++ b/crates/zeph-channels/AGENTS.md @@ -6,4 +6,5 @@ CLI and chat-channel adapters live here. - Keep changes isolated to channel adapters, rendering, and streaming behavior unless shared channel traits require coordinated edits. - Secrets (Telegram bot token, webhook secrets) are resolved exclusively from the age vault at startup — never from env vars, config files, or hardcoded values. - Validate formatting and rendering changes against existing markdown and channel tests. +- Outbound API calls in Telegram/Discord/Slack must go through the shared `common::http_retry::send_with_retry` helper (429 retry-with-backoff) rather than hand-rolled retry logic — see `discord/rest.rs`, `slack/api.rs`, `telegram_api_ext.rs` (#6100, #6113). - If external behavior changes, update `crates/zeph-channels/README.md` and the relevant channel docs. diff --git a/crates/zeph-channels/README.md b/crates/zeph-channels/README.md index 2fe6e649d..beb602b27 100644 --- a/crates/zeph-channels/README.md +++ b/crates/zeph-channels/README.md @@ -17,7 +17,7 @@ Implements I/O channel adapters that connect the agent to different frontends. S |--------|-------------| | `cli` | `CliChannel` — interactive terminal I/O with persistent input history (rustyline), prefix search, and `/image` command for vision input | | `json_cli` | `JsonCliChannel` — active under `--json`; emits JSONL events to stdout and reads prompts from stdin for programmatic/embedding use (logs forced to stderr) | -| `telegram` | Telegram adapter via teloxide with streaming; voice/audio message detection and file download; photo message support for vision input; configurable streaming edit interval (`stream_interval_ms`, default 3000 ms, minimum 500 ms) | +| `telegram` | Telegram adapter via teloxide with streaming; voice/audio message detection and file download; photo message support for vision input; configurable streaming edit interval (`stream_interval_ms`, default 3000 ms, minimum 500 ms); send/edit paths retry on HTTP 429 with backoff (mirroring Discord/Slack) | | `telegram::guest` | Guest Mode — transparent local axum HTTP proxy that intercepts `getUpdates` responses and surfaces `guest_message` entries (Bot API 10.0) without a second `getUpdates` connection | | `telegram::bot_to_bot` | Bot-to-Bot communication — registers via `setManagedBotAccessSettings` on startup; per-chat reply-depth tracking via `BotReplyCounters`; configurable `max_bot_chain_depth` | | `telegram::api` | `TelegramApiClient` — raw HTTP wrapper for Bot API 10.0 methods unavailable in teloxide 0.17: `answer_guest_query`, `get/set_managed_bot_access_settings`, `delete_message_reaction`, `delete_all_message_reactions` | diff --git a/crates/zeph-commands/AGENTS.md b/crates/zeph-commands/AGENTS.md index 974b2f386..f5a3bf79d 100644 --- a/crates/zeph-commands/AGENTS.md +++ b/crates/zeph-commands/AGENTS.md @@ -4,5 +4,6 @@ Slash command registry, `CommandHandler` trait, `ChannelSink` abstraction, and ` - Start with crate-local checks: `cargo build -p zeph-commands`, `cargo nextest run -p zeph-commands`, `cargo clippy -p zeph-commands --all-targets -- -D warnings`. - Keep command dispatch logic thin; push reusable business logic into the appropriate domain crate. -- When adding a new slash command, wire it in the root binary (`src/`), update the `--help` output, and add a TUI command palette entry where applicable. +- `CommandHandler::requires_auth()` defaults to `true` (fail-closed, since #6203): a new handler only reaches untrusted remote channels (Telegram/Discord/Slack) if it explicitly overrides this to `false`. Only do so for read-only or already self-gated commands — this was a 4x recurring fail-open defect class (#5967, #5997, #6003/#6033, #6034) before the default flipped. +- When adding a new slash command, wire it in the root binary (`src/`), update the `--help` output, and add a TUI command palette entry where applicable. A regression test asserts every registered handler has a matching `zeph_commands::COMMANDS` entry (#6172) — do not hand-maintain `COMMANDS` separately from the registry. - If the command surface changes, update `crates/zeph-commands/README.md` and the relevant docs. diff --git a/crates/zeph-commands/README.md b/crates/zeph-commands/README.md index f2c8acdb8..d2c825134 100644 --- a/crates/zeph-commands/README.md +++ b/crates/zeph-commands/README.md @@ -45,6 +45,20 @@ and restores the registry. This avoids borrow-checker conflicts with the channel `NullSink` and `NullAgent` are zero-cost sentinels for dispatch blocks that do not need channel I/O or agent-access commands respectively. +### Authorization is fail-closed by default + +`CommandHandler::requires_auth()` defaults to `true`: a handler that does not override it +requires a trusted (local) caller, and `CommandRegistry::dispatch` rejects it with a +`CommandError` when the dispatch site passes `trusted = false` (e.g. a remote channel such as +Telegram/Discord/Slack). Read-only or self-gated commands that are safe to expose on remote +channels must explicitly override `requires_auth()` to return `false`. + +> [!NOTE] +> This default was flipped from permissive (`false`) to fail-closed (`true`) after repeated +> incidents where a new handler silently stayed reachable from untrusted channels until an +> audit caught it. New handlers — including the `PingHandler` example below — now require a +> trusted session unless they explicitly opt out. + ## Usage ### Register and dispatch commands @@ -103,6 +117,13 @@ Commands are grouped into categories for `/help` output: | `Integration` | `/mcp`, `/image`, `/agent`, … | | `Advanced` | `/experiment`, `/policy`, `/scheduler`, … | +## Features + +| Feature | Description | Default | +|---------|-------------|---------| +| `cocoon` | Enables the `/cocoon` handler (Cocoon sidecar status and model listing) | No | +| `profiling` | Extra `tracing` instrumentation spans for dispatch latency profiling | No | + ## License Licensed under either of [MIT](../../LICENSE) or [Apache License, Version 2.0](../../LICENSE-APACHE) at your option. diff --git a/crates/zeph-common/AGENTS.md b/crates/zeph-common/AGENTS.md index 9d7e1a7e1..54e88a2f6 100644 --- a/crates/zeph-common/AGENTS.md +++ b/crates/zeph-common/AGENTS.md @@ -6,3 +6,4 @@ Shared utility functions, security primitives (`Secret`, `VaultError`), and stro - This crate must have zero dependencies on other `zeph-*` crates to remain a safe shared foundation. - Security primitives (`Secret`) must never expose inner values through `Debug`, `Display`, or serialization — verify before adding new impls. - If the public API changes, downstream crates will be affected workspace-wide; run `cargo build --workspace` after changes here. +- `zeph_common::secrets` is the canonical source for secret-prefix/path-prefix/Bearer-JWT redaction patterns shared by `zeph-core` and `zeph-memory` — do not hand-roll a duplicate list in a consuming crate, the two existing copies had already drifted apart before consolidation (#6091, #5917). diff --git a/crates/zeph-common/README.md b/crates/zeph-common/README.md index 85c23308f..4c15ae88b 100644 --- a/crates/zeph-common/README.md +++ b/crates/zeph-common/README.md @@ -19,7 +19,8 @@ Provides foundational utilities used across multiple Zeph crates: Unicode-safe s | `net` | Network helpers — `is_private_ip()` for IPv4/IPv6 private range detection; used by the SSRF guard in `zeph-tools` and `zeph-acp` | | `sanitize` | Low-level sanitization primitives (null byte stripping, control character removal) | | `fs_secure` | Secure file I/O helpers — `open_private_truncate`, `append_private`, `write_private`, `atomic_write_private`; all create files with mode `0o600` independent of process umask; `atomic_write_private` uses `O_EXCL` on the temp file and fsyncs before rename for crash safety | -| `treesitter` | Tree-sitter query constants and parser helpers for Rust, Python, JavaScript, TypeScript, Go (optional, requires `treesitter` feature) | +| `secrets` | Canonical secret-token and path-prefix constants (`SECRET_PREFIXES`, `PATH_PREFIXES`, `BEARER_TOKEN_PATTERN`, `JWT_PATTERN`) shared by redaction layers across crates — the single source of truth so `zeph-core::redact` and `zeph-memory` compression guidelines can't drift apart | +| `treesitter` | Tree-sitter query constants and parser helpers for Rust, Python, JavaScript, TypeScript, Go, Bash, TOML, JSON, Markdown (optional, requires `treesitter` feature) | ## Usage diff --git a/crates/zeph-config/AGENTS.md b/crates/zeph-config/AGENTS.md index aa0c14d7e..b413fdf63 100644 --- a/crates/zeph-config/AGENTS.md +++ b/crates/zeph-config/AGENTS.md @@ -4,6 +4,8 @@ Configuration structs, TOML loader, `ZEPH_*` env var overrides, validation, and - Start with crate-local checks: `cargo build -p zeph-config`, `cargo nextest run -p zeph-config`, `cargo clippy -p zeph-config --all-targets -- -D warnings`. - `ZEPH_*` env var overrides are for non-secret values only — secrets are resolved from the age vault, never from env vars or config files. -- When adding or renaming config keys, add a `--migrate-config` migration step so existing configs upgrade automatically. +- When adding, renaming, or removing config keys, add a `--migrate-config` migration step so existing configs upgrade (or drop leftover keys with a warning) automatically — see #6218 for the removal case. - Keep config structs, `config/default.toml`, docs, and the `--init` wizard in sync for every new field. - If the config surface changes, update `crates/zeph-config/README.md` and `docs/src/` reference pages. +- Any config struct with a secret-shaped field (API key, token, credential) must hand-write `Debug` (and audit `Serialize`) to redact it — plain `#[derive(Debug)]` has leaked plaintext secrets into logs/panics repeatedly (#6004, #6005, #6165, #6173); never add a new secret field without a redaction test. +- New optional subsystems that touch the network or vault (e.g. the skill/plugin registry marketplace, #5910) must default fully off — zero network or vault access unless explicitly enabled in config. diff --git a/crates/zeph-config/README.md b/crates/zeph-config/README.md index ae1443896..76678a79e 100644 --- a/crates/zeph-config/README.md +++ b/crates/zeph-config/README.md @@ -69,6 +69,7 @@ zeph migrate-config --in-place # update file in place | Feature | Description | |---------|-------------| | `deep-link` | Enables `zeph://` deep-link config fields (paired with the `deep-link` feature in `zeph-common`) | +| `card-signing` | Marker feature (no deps) letting `Config::validate()` fail fast when `card_trust_policy = "require"` is set without the A2A Agent Card signing crypto compiled in. MUST be enabled together with `zeph-a2a/card-signing` (paired automatically by the root `a2a` feature) | ## Environment variable overrides diff --git a/crates/zeph-context/AGENTS.md b/crates/zeph-context/AGENTS.md index c03ba7647..17a66623f 100644 --- a/crates/zeph-context/AGENTS.md +++ b/crates/zeph-context/AGENTS.md @@ -3,6 +3,7 @@ Context budget, lifecycle management, compaction strategy, and context assembler live here. This crate is stateless and data-only — it has no dependency on `zeph-core`. - Start with crate-local checks: `cargo build -p zeph-context`, `cargo nextest run -p zeph-context`, `cargo clippy -p zeph-context --all-targets -- -D warnings`. +- Read `specs/004-memory/004-16-memory-type-aware-retrieval.md` (MemGuard type-aware retrieval composition, #6226/#6086) before changing `assembler.rs`'s `schedule_context_fetchers` type-gating logic. `enabled = false` (default) and empty `default_compose_types` must both stay byte-for-byte no-ops (every fetcher runs unfiltered); NEVER gate `fetch_corrections`/`BehavioralRule` behind the active `FunctionalType` set. In-code comments cite this as "spec 064" — a naming collision with the permanent `/specs/064-durable-execution/` slot; the real spec is at the path above. - LLM serialization gate: changes to context assembly structs (`MessagePart`, `Message`, assembled `messages` array) require a live API session test before merge — verify no 400/422 errors and a well-formed payload in the debug dump. - Multi-model: compaction uses an LLM — expose a `compaction_provider` config field referencing `[[llm.providers]]` by name; never hardcode a model. - Keep `IndexAccess` trait contract stable; callers in `zeph-core` implement it and breakage is silent at the trait boundary. diff --git a/crates/zeph-core/AGENTS.md b/crates/zeph-core/AGENTS.md index 5c2ca4031..6a1632166 100644 --- a/crates/zeph-core/AGENTS.md +++ b/crates/zeph-core/AGENTS.md @@ -7,5 +7,6 @@ Core agent orchestration, config, context building, sanitization, and subagent p - Any change here may require follow-up updates in config, CLI wiring, docs, and integration tests. - Be especially careful with context assembly, sanitization, config loading, and feature-gated surfaces. - LLM serialization gate: changes to context assembly (`src/agent/context/`), `MessagePart`, `Message`, or any struct in LLM request/response paths require a live API session test before merge — verify no 400/422 errors and a well-formed `messages` array in the debug dump. +- Ephemeral media invariant (spec-072 §4 C1): `MessagePart::Image` is current-turn-only and MUST NEVER reach SQLite `parts_json`, the Qdrant embed path, or the durable JSONL session log. `Agent::persist_message` (`src/agent/persistence/store.rs`) strips all `Image` parts once, above both persistence writers, before calling `sink.record_message` and building `PersistMessageRequest`; the in-memory `Message` keeps its `Image` parts for the current turn's provider request. Any new persistence writer or call path into `persist_message` must go through this strip point, not bypass it. - Multi-model: every subsystem calling an LLM must expose a `*_provider` config field referencing a named entry in `[[llm.providers]]`; never hardcode a model name. - TUI: every background operation triggered from core must surface a visible spinner/status message in the TUI layer. diff --git a/crates/zeph-core/README.md b/crates/zeph-core/README.md index f4821f8b7..eafa6e2b8 100644 --- a/crates/zeph-core/README.md +++ b/crates/zeph-core/README.md @@ -313,6 +313,10 @@ Key `OrchestrationConfig` fields (TOML section `[orchestration]`): | `dependency_context_budget` | usize | `16384` | Character budget injected as cross-task context | | `confirm_before_execute` | bool | `true` | Require `/plan confirm` before executing a new plan | | `aggregator_max_tokens` | u32 | `4096` | Token budget for the `LlmAggregator` synthesis call; divided equally across completed tasks | +| `default_idle_timeout_secs` | `Option` | `None` | Graph-wide default for `TaskNode::idle_timeout_secs` (reserved, currently a documented no-op); per-task `run_timeout` enforcement applies independently to both spawned and `RunInline` dispatch, defaulting `RunInline` tasks to the graph-global 300s bound instead of running unbounded | + +> [!NOTE] +> A task whose verification (plan-level or per-task) judges output incomplete and for which no automatic repair resolves it now surfaces a visible signal to the user instead of only a debug/warn log line. `state_injection` recovery lets the graph continue past a failed node on terminal `Abort`/retry-exhausted failures instead of pausing unrelated work. ## Experiment Commands @@ -351,7 +355,7 @@ In-session commands for autonomous self-experimentation (integrates `zeph-experi - **SSE decoding path** — `claude_sse_to_tool_stream` emits `ToolBlockStart` at `content_block_start`; when confidence exceeds `confidence_threshold`, `try_dispatch(Trusted)` fires with a 2 s timeout. - **PASTE pattern path** — `run_paste_skill_activation` calls `PatternStore::predict` per active skill and dispatches candidates above threshold with per-skill trust; `observe_paste_transition` records transitions for future pattern learning. -`requires_confirmation` defaults to `true` for all executors, making speculative dispatch safe-by-default. Only executors that explicitly opt out can be speculatively dispatched. +`requires_confirmation` and `is_tool_speculatable` are required methods with no trait default (#6067) — every `ToolExecutor`/`ErasedToolExecutor` implementor, including wrappers, must state its policy explicitly rather than inherit a permissive fallback. This closed a recurring wrapper-forwarding defect class where a decorator silently fell back to an inherited default instead of forwarding to its inner executor. Only executors that explicitly return `true` from `is_tool_speculatable` (and `false` from `requires_confirmation`) can be speculatively dispatched. Configure via `[tools.speculative]` in `config.toml`: @@ -381,7 +385,7 @@ probe_timeout_ms = 2000 # per-probe timeout; fail-open on expiry ``` > [!NOTE] -> ShadowSentinel is fail-open by default — a timed-out or failed probe does not block execution. Set `fail_closed = true` to block tool calls when the probe cannot complete within `probe_timeout_ms`. +> ShadowSentinel is fail-open by default — a timed-out or failed probe does not block execution. Set `deny_on_timeout = true` to block tool calls when the probe cannot complete within `probe_timeout_ms`. Hot-path trajectory/tool-history reads are themselves bounded by `probe_timeout_ms.min(2000)` so a stalled DB connection cannot block dispatch (#6293). ## Reactive hooks diff --git a/crates/zeph-db/AGENTS.md b/crates/zeph-db/AGENTS.md index 0a179c91e..eb491d250 100644 --- a/crates/zeph-db/AGENTS.md +++ b/crates/zeph-db/AGENTS.md @@ -7,3 +7,5 @@ Database abstraction layer (`DbPool`, `DbRow`, `DbTransaction`, `DbQueryResult`) - Schema changes require a migration; never alter existing migration files after they have been applied. - Test with both backends (`--features sqlite` and `--features postgres`) before merging; silent divergence between backends is a first-class bug. - If the public API changes, run `cargo build --workspace` — downstream crates depend on these type aliases. +- List-style queries with "0 means unlimited" semantics must use `zeph_db::limit_clause()` — never bind `LIMIT -1` (rejected by PostgreSQL) or a `NULL` bind value (rejected by SQLite); it is the only cross-backend-safe way to omit the clause (#6121). +- Never log or print a raw database URL; always redact it through `zeph_db::redact_url()` first (covers userinfo, libpq query-param, and key-value DSN credential forms — a password containing `@` used to leak its tail, #6013). A `None` return means no recognized credential form was found, not a guarantee the URL is safe. diff --git a/crates/zeph-db/README.md b/crates/zeph-db/README.md index 74f8f0fab..6ac1f5490 100644 --- a/crates/zeph-db/README.md +++ b/crates/zeph-db/README.md @@ -18,6 +18,7 @@ Database abstraction layer for [Zeph](https://github.com/bug-ops/zeph) — unifi - **Automatic migrations** — `DbConfig::connect` runs `migrations/sqlite/` or `migrations/postgres/` on startup; WAL checkpoint applied after SQLite migrations - **`FullDriver` super-trait** — reduces sqlx bound repetition in generic impl blocks across consumer crates - **FTS helpers** — backend-aware `WHERE`/`JOIN`/rank fragments for messages and graph entity full-text search +- **`limit_clause()` helper** — cross-backend "`0` means unlimited" `LIMIT` fragment; omits the clause entirely instead of relying on the SQLite-only `LIMIT -1` sentinel, which PostgreSQL rejects - **Safe URL logging** — `redact_url` strips credentials from connection strings before they appear in logs - **Write transactions** — `begin_write` issues `BEGIN IMMEDIATE` on SQLite (prevents `SQLITE_BUSY`); falls back to standard `BEGIN` on PostgreSQL @@ -128,6 +129,19 @@ sqlx::query("INSERT INTO t (name) VALUES (?)").bind("foo").execute(&mut *tx).awa tx.commit().await?; ``` +### Cross-backend `LIMIT` clause + +```rust +use zeph_db::limit_clause; + +let (fragment, bind) = limit_clause(page_size); // 0 => unlimited, omits the clause entirely +let sql = format!("SELECT id FROM messages WHERE conversation_id = ?{fragment}"); +let mut query = sqlx::query(&sql).bind(conversation_id); +if let Some(limit) = bind { + query = query.bind(limit); +} +``` + ### FTS helpers ```rust diff --git a/crates/zeph-durable/AGENTS.md b/crates/zeph-durable/AGENTS.md index b76132fae..7798016c0 100644 --- a/crates/zeph-durable/AGENTS.md +++ b/crates/zeph-durable/AGENTS.md @@ -9,3 +9,4 @@ Layer-0 durable execution: journals control flow (steps, inputs/outputs, promise - INV-14 (schema ownership): this crate owns no `.sql` files and no `sqlx::migrate!`. All `durable_*` schema lives as numbered migrations in `zeph-db/migrations/{sqlite,postgres}/`, applied via `zeph_db::run_migrations` against a dedicated `durable.db` pool. Any schema change goes through `zeph-db` with both-backend migration parity. - Crypto is fail-closed and security-sensitive: `PayloadCipher` is AEAD, `PayloadAad` binds entries, and the read-side `max_payload` guard rejects oversized payloads. Never weaken the cipher contract, AAD binding, or the guard without an explicit security review. - Preserve the `JournalWriter` actor invariants: group-commit for buffered appends, flush-before-commit ACK for exactly-once entries, and `MAX(seq)` restart resume. The `ExecutionBackend` trait is sealed — keep dispatch through `DurableBackendEnum`. +- Crash-recovery invariant cluster (INV-15/16/17, #6122/#6254): an `ExecutionId` is process-exclusive via a non-blocking `flock`-backed `ExecutionLock` (INV-15); reopening an execution un-finalizes it from ANY terminal status — `completed`, `failed`, or `aborted` (INV-16), not just the first two; the crash-orphan sweep (`Journal::sweep_orphans`, gated by `RetentionPolicy::stale_running_after_secs`, folded into the retention tick before `prune()`) may hard-abort a stale `running` row only after a non-blocking try-acquire of that row's `ExecutionLock` confirms no live owner — staleness of `updated_at` alone is never sufficient grounds to abort. diff --git a/crates/zeph-durable/README.md b/crates/zeph-durable/README.md index 44784830e..7908264d1 100644 --- a/crates/zeph-durable/README.md +++ b/crates/zeph-durable/README.md @@ -9,16 +9,18 @@ Native durable execution layer for [Zeph](https://github.com/bug-ops/zeph) — j flow* of an execution (steps, promises, timers) so a crashed or interrupted run can resume at the point of failure instead of restarting from scratch. -> [!IMPORTANT] -> This crate is **under active construction** (spec-064, epic -> [#4707](https://github.com/bug-ops/zeph/issues/4707)). The type-level foundation, the AEAD payload -> contract, the persistence engine (`LocalBackend`, the background `JournalWriter` actor, the sealed -> `ExecutionBackend` dispatcher), and the execution heart — the `&self` `DurableContext` with +> [!NOTE] +> Spec-064 (epic [#4707](https://github.com/bug-ops/zeph/issues/4707)) is complete — all 11 child +> issues shipped and the epic is closed. The type-level foundation, the AEAD payload contract, the +> persistence engine (`LocalBackend`, the background `JournalWriter` actor, the sealed +> `ExecutionBackend` dispatcher), the execution heart (the `&self` `DurableContext` with > deterministic step ids, the fingerprint-guarded replay cursor, the exactly-once intent/result -> protocol, and `parallel()` batches — have landed, as have the promise/timer layer -> (`DurablePromise`, `DurableHandle`, `DurableTimerService`) and journal retention -> (`DurableRetentionService`). The CLI/TUI integration and the consuming adapters land in follow-up -> issues of the epic. +> protocol, and `parallel()` batches), the promise/timer layer (`DurablePromise`, `DurableHandle`, +> `DurableTimerService`), and journal retention (`DurableRetentionService`, including the +> flock-verified crash-orphan staleness sweep) have all landed. The `zeph durable` CLI (`list` / +> `show` / ...) and the TUI durable-execution widget are wired, and all four consuming adapters — +> agent tool loop, orchestration (`/plan resume`), scheduler, and subagent — journal their steps +> through `DurableContext`. ## Overview @@ -56,7 +58,9 @@ a dedicated `durable.db` (SQLite) or a feature-gated Restate backend. section, with spec defaults applied on deserialization. - **backend** — the sealed `ExecutionBackend` trait, `BackendCapabilities`, the `DurableBackendEnum` enum dispatcher, and `LocalBackend` (a dedicated `durable.db` pool implementing `Journal`, sealing - payloads through the injected cipher). + payloads through the injected cipher). Includes `open_execution_exclusive`, a `flock(2)`-backed + process-exclusivity lock that rejects a second concurrent holder for the same `ExecutionId` + (guards against colliding `agent_turn` executions across processes). - **writer** — the background `JournalWriter` actor and its cloneable `JournalWriterHandle`: group-commit for buffered appends, flush-before-commit ACKs for exactly-once entries, and `MAX(seq)` restart resume. @@ -64,7 +68,10 @@ a dedicated `durable.db` (SQLite) or a feature-gated Restate backend. point) and `DurableHandle` for out-of-band resolution. - **timer** — `DurableTimerService`, a polling actor that fires journaled timers on resume. - **retention** — `DurableRetentionService`, the background pruner that enforces `RetentionPolicy` - (TTL, execution/journal-byte caps) against the `durable.db` pool. + (TTL, execution/journal-byte caps) against the `durable.db` pool. Also folds in the crash-orphan + staleness sweep: a `stale_running_after_secs` knob reclaims `status='running'` rows abandoned by + an ungraceful process exit, gated on a non-blocking advisory-lock liveness probe so a still-live + owner is never aborted out from under it. - **error** — the crate-wide `DurableError`. ## Architecture & invariants diff --git a/crates/zeph-gateway/AGENTS.md b/crates/zeph-gateway/AGENTS.md index 13f6eda53..492eae5f8 100644 --- a/crates/zeph-gateway/AGENTS.md +++ b/crates/zeph-gateway/AGENTS.md @@ -3,7 +3,11 @@ HTTP gateway and webhook ingestion code lives here. - Start with crate-local checks: `cargo build -p zeph-gateway`, `cargo nextest run -p zeph-gateway`, `cargo clippy -p zeph-gateway --all-targets -- -D warnings`. +- Read `specs/019-gateway/spec.md` before changing routing, auth, or rate-limiting; honor its `## Key Invariants` section. - Treat auth, request validation, limits, and tracing as security-sensitive behavior. - Bearer tokens and webhook secrets are resolved exclusively from the age vault — never hardcoded or passed via env vars. +- `rate_limit_middleware` must wrap `auth_middleware` in `build_router`, never the reverse — `auth_middleware` short-circuits with 401 without calling `next.run`, so if it sits outside the rate limiter, failed-auth requests bypass the per-IP counter entirely and the bearer token becomes brute-forceable (#6136). +- An empty-string bearer token must never be treated as "no auth configured" — normalize or reject it before hashing, never let it hash-match a missing header (#6282). +- `/webhook` and any other trust-boundary entry point must check `zeph_commands::is_recognized_command` on the raw body before sanitizing, and `GatewayChannel::supports_exit()` must stay `false` for webhook-sourced turns — webhook input is untrusted and must go through the same `requires_auth`/trusted dispatch gate as other channels, not be treated as CLI/TUI-trusted (#6039). - Keep gateway behavior aligned with root CLI/config surfaces. - If external behavior changes, update `crates/zeph-gateway/README.md` and `docs/src/advanced/gateway.md`. diff --git a/crates/zeph-gateway/README.md b/crates/zeph-gateway/README.md index fea6a4674..2151adaad 100644 --- a/crates/zeph-gateway/README.md +++ b/crates/zeph-gateway/README.md @@ -32,7 +32,7 @@ auth_token = "your-secret-token" # optional, see authentication below cargo run --features gateway -- --daemon # starts agent + gateway server ``` -The gateway is wired via `src/gateway_spawn.rs` into both `daemon.rs` and `runner.rs`. A background drain task logs incoming webhook payloads; agent loopback forwarding is a planned follow-up. +The gateway is wired via `src/gateway_spawn.rs` into both `daemon.rs` and `runner.rs`. A background `forward_webhooks` task drains incoming webhook payloads and forwards each one into the agent's input queue as a `ChannelMessage`: a payload recognized as a known slash command is forwarded as-is (subject to the same `CommandHandler::requires_auth` authorization as any other channel); every other payload is sanitized via `ContentSanitizer` (classified `ExternalUntrusted`) before it reaches the agent loop, since a valid bearer token proves only that the sender knows the shared secret, not that the content is safe. ## Authentication @@ -52,7 +52,7 @@ GatewayServer::new("127.0.0.1", 8080, webhook_tx, shutdown_rx) .await?; ``` -Token comparison uses BLAKE3 + `subtle::ConstantTimeEq` to prevent timing attacks. +Token comparison uses BLAKE3 + `subtle::ConstantTimeEq` to prevent timing attacks. The rate limiter wraps the auth check (not the reverse), so requests with a missing or invalid bearer token still count against the per-IP limit — a brute-force attempt against the token cannot bypass rate limiting. ## Features diff --git a/crates/zeph-index/AGENTS.md b/crates/zeph-index/AGENTS.md index 41aea4318..5cabb03e4 100644 --- a/crates/zeph-index/AGENTS.md +++ b/crates/zeph-index/AGENTS.md @@ -5,4 +5,5 @@ Code indexing, repo map generation, and code retrieval live here. - Start with crate-local checks: `cargo build -p zeph-index`, `cargo nextest run -p zeph-index`, `cargo clippy -p zeph-index --all-targets -- -D warnings`. - Preserve deterministic indexing behavior where possible; retrieval regressions should get tests. - Be careful with filesystem walking, tree-sitter parsing, and persistence interactions with memory/index stores. +- Extension-to-language mapping is centralized in `zeph_common::treesitter::lang_for_ext` — don't hand-roll a separate mapping in `languages.rs`; extend the shared table in `zeph-common` instead (#5971 consolidated a drifted duplicate between this crate and `zeph-tools`). - If user-facing behavior changes, update `crates/zeph-index/README.md` and the relevant indexing docs. diff --git a/crates/zeph-llm/AGENTS.md b/crates/zeph-llm/AGENTS.md index 9a602b67d..e1a5c7be5 100644 --- a/crates/zeph-llm/AGENTS.md +++ b/crates/zeph-llm/AGENTS.md @@ -4,6 +4,9 @@ Provider implementations, orchestration, routing, and inference behavior live he - Start with crate-local checks: `cargo build -p zeph-llm`, `cargo nextest run -p zeph-llm`, `cargo clippy -p zeph-llm --all-targets -- -D warnings`. - Changes here are high impact: preserve provider contracts, streaming behavior, retries, and schema extraction semantics unless explicitly changing them. +- `LlmProvider::name()` (the config/instance name from `[[llm.providers]]`) and `model_identifier()` / `effective_model_identifier()` (the actual model id pattern-matched by `is_reasoning_model()` and reasoning/routing checks) are distinct — conflating them has recurred 3+ times (#5879, #6182, #6190). Any new provider wrapper (masking, router, triage, candle) needs an explicit `effective_model_identifier()` override or the check silently becomes unreachable. +- Claude no-prefill gate is compile-time enforced: `RequestBody`/`ToolRequestBody`/`VisionRequestBody`/`TypedToolRequestBody.messages` only accept `GatedStructuredHistory`/`GatedPlainHistory`, constructible only via `ClaudeProvider::structured_history`/`plain_history`. Never call `split_messages`/`split_messages_structured` directly from a new request-construction path — that reintroduces the prefill bug fixed five times (#5903/#6145/#6146/#6154/#6158). +- `ImageData` (vision/MCP image passthrough, spec-072) has a hand-written `Debug` impl redacting raw bytes to `[image: , N bytes]` — never restore `#[derive(Debug)]` on it or any future media-carrying struct. - LLM serialization gate: any change to `claude.rs`, `openai.rs`, `ollama.rs`, `compatible.rs`, or any `#[derive(Serialize, Deserialize)]` struct on the request/response path requires a live multi-turn + tool-call session test before merge. - Multi-model: all provider backends resolve through the `[[llm.providers]]` registry by name; subsystems reference providers via `*_provider` fields — never inline model strings. - Keep model/provider docs and config examples in sync with behavior. diff --git a/crates/zeph-llm/README.md b/crates/zeph-llm/README.md index 1ec736acc..9a9aa1868 100644 --- a/crates/zeph-llm/README.md +++ b/crates/zeph-llm/README.md @@ -23,7 +23,7 @@ Defines the `LlmProvider` trait and ships concrete backends for Ollama, Claude, | `compatible` | Generic OpenAI-compatible endpoint backend | | `gonka` | Gonka native inference backend — signed HTTP transport via `RequestSigner`, `EndpointPool` for weighted multi-node load balancing; supports `chat`, `chat_stream`, `embed`, and `chat_with_tools` (feature `gonka`) | | `candle_provider` | Local inference via Candle (feature `candle`) | -| `any` | `AnyProvider` enum wrapping every backend for uniform dispatch | +| `any` | `AnyProvider` enum wrapping every backend for uniform dispatch; `set_thinking_budget()` / `apply_reasoning_effort()` / `current_thinking_budget()` / `current_reasoning_effort()` mutate the active provider's thinking/reasoning settings at runtime (session-only, never persisted), delegating through `Router`/`Triage` to the last-active inner provider | | `router` | `RouterProvider` selects among backends via four strategies: EMA latency tracking, Thompson sampling (Beta distributions), cascade escalation, and LinUCB bandit. Providers stored as `Arc<[AnyProvider]>` — `clone()` on every LLM request is O(1) regardless of chain length | | `extractor` | `Extractor` / `chat_typed()` — typed LLM output via JSON Schema (`schemars`); per-`TypeId` schema caching | | `sse` | Shared `sse_to_chat_stream()` helpers for Claude and OpenAI SSE parsing | @@ -211,6 +211,8 @@ thinking = { mode = "extended", budget_tokens = 16000 } CLI: `--thinking extended:16000` or `--thinking adaptive`. When thinking is enabled and `max_tokens` is below 16000, it is raised automatically. Thinking deltas are parsed from the SSE stream and suppressed from the user-facing output; `MessagePart::ThinkingBlock` variants preserve thinking blocks verbatim across tool-use turns. +The thinking budget and OpenAI/Compatible/Gemini `reasoning_effort` can also be changed mid-session via `AnyProvider::set_thinking_budget()` / `apply_reasoning_effort()` — surfaced as the `/think-tokens [N|Nk|NM|off]` and `/reasoning-effort [low|medium|high]` slash commands (and a matching `--reasoning-effort` CLI flag / `--init` wizard prompt). Overrides are session-only: never persisted across restarts or `/provider` switches, and unsupported providers return an explicit "not supported" message instead of a silent no-op. + ## Prompt cache TTL `ClaudeProvider` supports a configurable prompt cache TTL via the `CacheTtl` enum: diff --git a/crates/zeph-mcp/AGENTS.md b/crates/zeph-mcp/AGENTS.md index 819ea2097..54f60329f 100644 --- a/crates/zeph-mcp/AGENTS.md +++ b/crates/zeph-mcp/AGENTS.md @@ -5,4 +5,6 @@ MCP client lifecycle, registry, policies, and tool execution bridging live here. - Start with crate-local checks: `cargo build -p zeph-mcp`, `cargo nextest run -p zeph-mcp`, `cargo clippy -p zeph-mcp --all-targets -- -D warnings`. - Treat policy enforcement, rate limits, transport setup, and tool exposure as security-sensitive behavior. - Keep changes isolated to MCP behavior unless shared tool abstractions require coordinated edits. +- Any cache keyed by server-supplied data (tool names, schemas) is attacker-influenced and must be bounded (`lru::LruCache`), never an unbounded process-lifetime `HashMap` — confirmed unbounded-growth bug in `name_referenced_in` (#6296). +- `tool_list_locked` must be released on every cleanup path (disconnect, connect failure, list_tools failure, pre-connect probe-block) — two prior omissions (#6138 OAuth-transport connect, #6143 removal/probe-block) each left a server permanently locked. - If external behavior changes, update `crates/zeph-mcp/README.md` and the relevant MCP docs. diff --git a/crates/zeph-mcp/README.md b/crates/zeph-mcp/README.md index ccef09002..f1c496113 100644 --- a/crates/zeph-mcp/README.md +++ b/crates/zeph-mcp/README.md @@ -99,6 +99,11 @@ expected_tools = ["read_file", "write_file", "list_directory"] **Important:** > Leave `expected_tools` empty (or omit it) to allow all tools from a server. Setting it to an empty list `[]` blocks all tools from that server. +`McpManager` also caches each server's tool fingerprints (Blake3 of name + description + +`input_schema`) across reconnects. On the next connect or `tools/list_changed` refresh, a tool +whose description or schema silently changed since the previous session logs a schema-drift +("rug-pull") warning — detection only, no automatic blocking. + ## Elicitation MCP servers can request structured user input via the `elicitation/create` method. When enabled, Zeph presents a phishing-prevention header before displaying the server's form and routes the response back over a bounded channel. @@ -124,6 +129,8 @@ elicitation_timeout = 120 - **Tool-list snapshot locking** — set `lock_tool_list = true` on a server entry to reject any `tools/list_changed` refresh after the initial snapshot. Prevents malicious servers from injecting new tools mid-session. - **Per-server stdio env isolation** — `env_isolation = true` (or `default_env_isolation = true` globally) strips the inherited process environment before spawning stdio MCP servers, preventing accidental secret leakage via `PATH`, `HOME`, and similar variables. Explicitly declared `env` keys are still passed through. - **Intent-anchor nonce boundaries** — tool output from MCP servers is wrapped with per-call nonce delimiters before entering the LLM context, reducing prompt injection surface. +- **Schema depth-cap dropping** — both `input_schema` and `output_schema` are dropped to an empty object when a tool definition nests past `MAX_SCHEMA_DEPTH` (10 levels), closing an injection vector where a malicious server buries a payload too deep for pattern matching to reach. Each drop counts as an injection for trust-score purposes; the `input_schemas_dropped`/`output_schemas_dropped` counters are surfaced through `ServerConnectOutcome`/`McpServerStatus` into the TUI. +- **Bounded cross-reference regex cache** — `name_referenced_in`'s per-tool-name regex caches are capped at 256 entries via `lru::LruCache`, so a server that rotates its advertised tool names cannot grow memory unbounded over the lifetime of a long-running daemon/gateway process. ```toml [mcp] diff --git a/crates/zeph-memory/AGENTS.md b/crates/zeph-memory/AGENTS.md index 7c27b36d1..916af0ce4 100644 --- a/crates/zeph-memory/AGENTS.md +++ b/crates/zeph-memory/AGENTS.md @@ -4,6 +4,9 @@ Conversation persistence, embeddings, semantic recall, document ingestion, and g - Start with crate-local checks: `cargo build -p zeph-memory`, `cargo nextest run -p zeph-memory`, `cargo clippy -p zeph-memory --all-targets -- -D warnings`. - Be careful with persistence schema changes, token counting, vector-store behavior, and retention/eviction logic. +- `[memory.type_aware_compose]` (MemGuard, spec `004-memory/004-16-memory-type-aware-retrieval.md`, #6226) gates five of the six `ContextAssembler` fetchers by functional memory type at retrieval time. Corrections are exempt and MUST stay unconditionally composed regardless of the active type set — that exemption is safety-critical, never gate it. +- Cross-backend correctness: never use a raw `LIMIT -1` SQLite unlimited-sentinel — it crashes on Postgres. Use `zeph_db::limit_clause()` (added after #6121 broke `SessionStore::list` and sibling `list_*` helpers) and decode `created_at`/`updated_at` via the shared timestamp helper, not raw `String`, since Postgres uses `TIMESTAMPTZ`. +- Control-char stripping and secret-prefix/Bearer/JWT redaction must go through `zeph_common::sanitize` / `zeph_common::secrets` — don't hand-roll a stripper or prefix list here; a divergent, weaker stripper in the graph community summarizer reintroduced a newline/tab prompt-injection vector after the #6091 consolidation (fixed by #6135). - Embedding dimension mismatches are a recurring source of bugs: whenever the embedding model or vector collection config changes, verify that stored and query vector dimensions match before running tests. - Multi-model: summarization, compaction, and graph extraction each use an LLM — expose `*_provider` fields referencing `[[llm.providers]]` names; never hardcode a model. - Memory-related bug fixes should get regression coverage near the changed code or in crate tests. diff --git a/crates/zeph-memory/README.md b/crates/zeph-memory/README.md index 8aae58de5..cf7cf274b 100644 --- a/crates/zeph-memory/README.md +++ b/crates/zeph-memory/README.md @@ -29,6 +29,8 @@ Includes a document ingestion subsystem for loading, chunking, and storing user **GAAMA episode nodes** extend the graph memory with episode-typed entities that capture temporal context boundaries — start/end timestamps and associated entity sets — enabling episodic recall alongside semantic and graph retrieval. +**MemGuard type-aware retrieval composition** gates context-assembly fetchers on a `FunctionalType` active set (episodic, user facts, behavioral rules, reasoning strategies, cross-session summaries, graph facts) so a turn can compose only the functionally relevant memory types instead of always fetching all sources. Configure via `[memory.type_aware_compose]`. + ## Key modules | Module | Description | @@ -69,7 +71,7 @@ Includes a document ingestion subsystem for loading, chunking, and storing user | `eviction` | Graph eviction — cleanup of expired edges, orphan entities, and entity cap enforcement | | `error` | `MemoryError` — unified error type | -**Re-exports:** `MemoryError`, `QdrantOps`, `ConversationId`, `MessageId`, `Document`, `DocumentLoader`, `TextLoader`, `TextSplitter`, `IngestionPipeline`, `Chunk`, `SplitterConfig`, `DocumentError`, `DocumentMetadata`, `PdfLoader` (behind `pdf` feature), `Embeddable`, `EmbeddingRegistry`, `ResponseCache`, `MemorySnapshot`, `TokenCounter`, `UserCorrection`, `FeedbackDetector`, `AnchoredSummary`, `CompactionProbeConfig`, `validate_compaction` +**Re-exports:** `MemoryError`, `QdrantOps`, `ConversationId`, `MessageId`, `Document`, `DocumentLoader`, `TextLoader`, `TextSplitter`, `IngestionPipeline`, `Chunk`, `SplitterConfig`, `DocumentError`, `DocumentMetadata`, `PdfLoader` (behind `pdf` feature), `Embeddable`, `EmbeddingRegistry`, `ResponseCache`, `MemorySnapshot`, `TokenCounter`, `UserCorrection`, `FeedbackDetector`, `AnchoredSummary`, `CompactionProbeConfig`, `validate_compaction`, `FunctionalType` (from `zeph-common`), `TypeAwareComposeConfig` ## Breaking changes in v0.18.2 @@ -220,6 +222,20 @@ top_k = 1 # number of session digests retrieved per session context_strategy = "adaptive" # "memory_first" | "adaptive" (default: "memory_first") ``` +## MemGuard type-aware retrieval composition + +`TypeAwareComposeConfig` (`[memory.type_aware_compose]`) is a retrieval-only, fetch-time gate on context assembly: when enabled, a turn composes only the functionally relevant `FunctionalType` memory sources instead of always fetching all six. `BehavioralRule` (past-correction recall) is never gated — it stays unconditionally composed as a safety-critical invariant regardless of the active set. + +```toml +[memory.type_aware_compose] +enabled = false # off by default; byte-for-byte no-op when disabled +default_compose_types = [] # empty = all types; unknown strings are a hard config-load error +intent_scoped = false # widen the active set per classified query intent (no new LLM call; reuses HeuristicRouter) +``` + +**Note:** +> No new storage, no new Qdrant collection, no write-path change. `FunctionalType` lives in `zeph-common` (re-exported here for taxonomy discoverability) because `zeph-context` — the crate that gates fetchers on this type — deliberately has no `zeph-memory` dependency. + ## Document RAG `IngestionPipeline` loads, chunks, embeds, and stores documents into the `zeph_documents` Qdrant collection. When `memory.documents.rag_enabled = true`, the agent automatically queries this collection on every turn and prepends the top-K most relevant chunks to the context window. diff --git a/crates/zeph-orchestration/AGENTS.md b/crates/zeph-orchestration/AGENTS.md index 5709a2edf..d81fb4056 100644 --- a/crates/zeph-orchestration/AGENTS.md +++ b/crates/zeph-orchestration/AGENTS.md @@ -2,8 +2,11 @@ Multi-model task orchestration: DAG decomposition, concurrent sub-agent execution, failure propagation, and result synthesis live here. -- Start with crate-local checks: `cargo build -p zeph-orchestration`, `cargo nextest run -p zeph-orchestration`, `cargo clippy -p zeph-orchestration --all-targets -- -D warnings`. +- Start with crate-local checks: `cargo build -p zeph-orchestration`, `cargo nextest run -p zeph-orchestration`, `cargo clippy -p zeph-orchestration --all-targets -- -D warnings`. The default feature set is `sqlite` only — `llm-planning` (planner, aggregator, verifier, plan_cache, adaptorch, ensemble) is opt-in, so add `--features llm-planning` to actually build/test/lint that code (CI closed this exact PR-gating gap for `zeph-plugins`' analogous `registry` feature in #6189 — the same gap applies here for local runs). +- Read `specs/009-orchestration/spec.md` before changing `PlanVerifier`, `DagScheduler`, or task dispatch; also see `specs/073-orch-ensemble-merge/spec.md` (verifier ensemble) and `specs/075-orchestration-node-control-parity/spec.md` (per-task `TimeoutPolicy`/`RecoveryAction`) for the invariants behind those subsystems. - Multi-model: planner and synthesizer use LLMs — expose `planner_provider` and `synthesizer_provider` config fields referencing `[[llm.providers]]` by name; use the most capable model for planning and reasoning tasks. - DAG execution must handle partial failure gracefully; do not silently drop sub-task errors. +- Verifier grounding is load-bearing: `verify()`/`verify_plan()` must run the deterministic `ground()` stage against the real `ToolUse`/`ToolResult` trace before trusting the LLM verify-provider's `complete` verdict — NEVER let a narrated-but-unexecuted claim pass verification ungrounded (#6286, #6299). +- `TaskNode::network_scope: Deny` must stay enforced at dispatch via `zeph_subagent::NetworkDenyToolExecutor` (wired in both the spawned and `RunInline` paths) — never let it regress to advisory-only (#6161); MCP-provided tools remain a known, documented gap (specs/069-threat-model). - LLM serialization gate: changes to task decomposition or result synthesis structs require a live multi-turn session test before merge. - If external behavior changes, update `crates/zeph-orchestration/README.md` and the relevant orchestration docs. diff --git a/crates/zeph-orchestration/README.md b/crates/zeph-orchestration/README.md index c0831260a..1a9c11533 100644 --- a/crates/zeph-orchestration/README.md +++ b/crates/zeph-orchestration/README.md @@ -15,7 +15,7 @@ Implements the multi-agent task orchestration pipeline extracted from `zeph-core | Module | Description | |--------|-------------| -| `graph` | `TaskGraph`, `TaskNode`, `TaskId`, `GraphId` typed identifiers; `TaskStatus`, `GraphStatus`, `FailureStrategy` (abort/retry/skip/ask) | +| `graph` | `TaskGraph`, `TaskNode`, `TaskId`, `GraphId` typed identifiers; `TaskStatus`, `GraphStatus`, `FailureStrategy` (abort/retry/skip/ask); per-task `TimeoutPolicy` (`run_timeout_secs`) and Mode-1 `RecoveryAction` (`state_injection`) | | `dag` | DAG validation (cycle detection via topological sort), `ready_tasks`, `propagate_failure`, `reset_for_retry` | | `scheduler` | `DagScheduler` tick-based execution engine; `SchedulerAction` command pattern; `TaskEvent`, `TaskOutcome` | | `topology` | `TopologyClassifier`, `Topology`, `DispatchStrategy` — DAG shape analysis for dispatch selection | @@ -26,7 +26,8 @@ Implements the multi-agent task orchestration pipeline extracted from `zeph-core | `error` | `OrchestrationError` unified error type | | `planner` | `Planner` trait + `LlmPlanner` — goal decomposition via `chat_typed` structured output (feature `llm-planning`) | | `aggregator` | `Aggregator` trait + `LlmAggregator` — synthesizes completed task outputs; content-sanitized before injection (feature `llm-planning`) | -| `verifier` | `PlanVerifier` — post-task completeness verifier with targeted replan (feature `llm-planning`) | +| `verifier` | `PlanVerifier` — post-task and whole-plan completeness verifier with targeted replan, grounded against the DAG-wide tool-call trace (feature `llm-planning`) | +| `ensemble` | `EnsembleVerifier`, `EnsembleTracker` — N-fold parallel dispatch of `PlanVerifier` gap-severity checks across configured providers with deterministic majority-vote merge (spec `073-orch-ensemble-merge`, `[orchestration.ensemble]`, feature `llm-planning`) | | `plan_cache` | `PlanCache` — caches plan templates by normalized goal hash; `normalize_goal` + `goal_hash` for deterministic cache keys (feature `llm-planning`) | | `adaptorch` | `TopologyAdvisor` — adaptive topology hints for the scheduler (feature `llm-planning`) | @@ -58,6 +59,8 @@ confirm_before_execute = true # require /plan confirm before starting aggregator_max_tokens = 4096 # token budget for LlmAggregator synthesis call ``` +Deterministic ensemble-merge verification (opt-in, disabled by default) has its own `[orchestration.ensemble]` table — see the `ensemble` module above. + ## Failure strategies | Strategy | Behavior when a task fails | @@ -67,6 +70,9 @@ aggregator_max_tokens = 4096 # token budget for LlmAggregator synthesis ca | `Skip` | Mark the task skipped and continue with dependents | | `Ask` | Pause the graph and wait for `/plan resume` from the user | +> [!NOTE] +> A `TaskNode` can also carry a declarative `recovery: RecoveryAction` (Mode 1, spec `075-orchestration-node-control-parity`). On an `Abort`-default or retry-exhausted `Retry` failure, a node with `recovery` set is marked `Completed` with `state_injection` substituted as its output instead of failing the graph, letting downstream tasks proceed. `run_timeout_secs` on the same node bounds both spawned and `RunInline` dispatch; `None` falls back to `OrchestrationConfig::task_timeout_secs`. + ## Plan template caching When a goal is decomposed into a task graph, the resulting structure is cached as a `PlanTemplate` keyed by a normalized goal hash. Subsequent requests with semantically equivalent goals reuse the cached template instead of invoking the LLM planner, reducing latency and token costs for repeated orchestration patterns. @@ -84,7 +90,7 @@ When a goal is decomposed into a task graph, the resulting structure is cached a |---------|---------|-------------| | `sqlite` | yes | SQLite backend for graph persistence (via `zeph-db`, `zeph-durable`, `zeph-memory`, `zeph-subagent`) | | `postgres` | no | PostgreSQL backend | -| `llm-planning` | no | Enables the LLM-dependent modules (`planner`, `aggregator`, `verifier`, `verify_predicate`, `plan_cache`, `adaptorch`) and the `zeph-llm` dependency | +| `llm-planning` | no | Enables the LLM-dependent modules (`planner`, `aggregator`, `verifier`, `verify_predicate`, `plan_cache`, `adaptorch`, `ensemble`) and the `zeph-llm` dependency | | `test-utils` | no | Testcontainers for PostgreSQL integration tests (implies `postgres` and `llm-planning`) | > [!NOTE] diff --git a/crates/zeph-plugins/AGENTS.md b/crates/zeph-plugins/AGENTS.md index d4cb8c0c6..e1cf65ec7 100644 --- a/crates/zeph-plugins/AGENTS.md +++ b/crates/zeph-plugins/AGENTS.md @@ -2,12 +2,13 @@ Plugin packaging, installation, and management: a plugin is a directory (local or remote git) with a `plugin.toml` manifest, skill directories, optional MCP server declarations, and a config overlay. Plugins install to `~/.local/share/zeph/plugins//` and load at agent startup. -- Start with crate-local checks: `cargo build -p zeph-plugins`, `cargo nextest run -p zeph-plugins`, `cargo clippy -p zeph-plugins --all-targets -- -D warnings`. +- Start with crate-local checks: `cargo build -p zeph-plugins`, `cargo nextest run -p zeph-plugins`, `cargo clippy -p zeph-plugins --all-targets -- -D warnings`. The `marketplace` module (skill/plugin discovery via `RegistryClient`/`SkillsShClient`) is gated behind the `registry` feature, off by default — add `--features registry` to actually exercise it; local `lint-clippy`/`build-tests` silently skipped this code until CI was fixed to do the same in #6189. - Read `specs/058-plugins/spec.md` before changing the manifest format, install flow, or overlay resolution. - The security model is non-negotiable — every guarantee below needs regression coverage when its code path changes: - Config overlays are **tighten-only**: they may add to `blocked_commands`, narrow `allowed_commands`, or raise `disambiguation_threshold` — never loosen a constraint (`overlay.rs`). - Plugin MCP entries are validated against `mcp.allowed_commands` at install time. - `.bundled` markers are stripped recursively from all plugin skill trees. - Skill-name conflicts with managed, bundled, or other plugin skills are hard errors at install. + - Every archive fetch (`add_remote`, `download_archive`, `download_and_extract` in `manager/registry.rs`) MUST go through the shared `https_safe_client()`/`get_https_safe()` (anti-downgrade redirect policy, CWE-601/CWE-319) and `fetch_archive_bytes()` (`MAX_ARCHIVE_BYTES` cap) helpers — never call `reqwest::get`/a bare client directly for a plugin archive (#6104, #6112). - `integrity.rs` and `manager/security.rs` are security-sensitive; do not relax integrity checks or install-time validation without an explicit security review. - If install, overlay, or manifest behavior changes, update `crates/zeph-plugins/README.md` and the relevant plugin docs. diff --git a/crates/zeph-plugins/README.md b/crates/zeph-plugins/README.md index a14d1d8b8..f0c6e96c6 100644 --- a/crates/zeph-plugins/README.md +++ b/crates/zeph-plugins/README.md @@ -28,6 +28,7 @@ Manages the full lifecycle of Zeph plugin packages: installing from a path or UR | `manager` | `PluginManager` — install/remove/list with path-traversal defense (`canonicalize + starts_with(root)`), recursive `.bundled` marker stripping, symlink skip, and atomic install-then-verify | | `manifest` | `plugin.toml` schema (`PluginManifest`, `PluginMeta`, `SkillEntry`, `McpSection`) | | `overlay` | `apply_plugin_config_overlays` — scans installed plugins, validates overlays, and merges tighten-only keys into the live `Config` struct | +| `marketplace` | `RegistryClient` trait, `RegistryEntry`, `PackageArchive`, `RegistryError` — opt-in skill/plugin discovery-and-install marketplace backing `zeph plugin search`/`get` (feature `registry`) | | `error` | `PluginError` typed error enum | | `types` | `PluginName` validated identifier | @@ -96,6 +97,18 @@ zeph plugin remove my-plugin /plugins remove # uninstall a plugin ``` +### Marketplace discovery (opt-in) + +```bash +# Requires the `registry` feature and [skills.registry] enabled = true in config.toml +zeph plugin search +zeph plugin get +``` + +Backed by the `marketplace` module's `RegistryClient` trait (default backend: `skills.sh`). Disabled +by default (`FR-004`): when `skills.registry.enabled = false`, both subcommands print an actionable +opt-in message and make zero network calls. + ## Config overlay merge At bootstrap (`AppBuilder::new`) and on hot-reload (`reload_config`), `apply_plugin_config_overlays` is called to merge all installed plugin overlays into the live `Config`. The merge is deterministic: plugins are processed in directory-sorted order to ensure reproducible results. @@ -131,12 +144,14 @@ Enabled automatically when the `zeph-plugins` crate is a dependency of the root ## Feature flags -`zeph-skills`/`zeph-tools` (and transitively `zeph-db`) require a database backend to compile, so exactly one of these must be enabled: +`zeph-skills`/`zeph-tools` (and transitively `zeph-db`) require a database backend to compile, so exactly one of `sqlite`/`postgres` must be enabled: | Feature | Default | Description | |---------|---------|-------------| | `sqlite` | yes | SQLite backend — the default, lets the crate build in isolation | | `postgres` | no | PostgreSQL backend for PostgreSQL deployments (#4956) | +| `registry` | no | Enables the `marketplace` module body backing `zeph plugin search`/`get` (spec-045). Adds `reqwest`'s `query` Cargo feature only — no new crate | +| `mock` | no | Exposes `marketplace::mock::MockRegistryClient` outside `#[cfg(test)]` for downstream crates' `dev-dependencies`. No-op unless `registry` is also enabled | ## Documentation diff --git a/crates/zeph-sanitizer/AGENTS.md b/crates/zeph-sanitizer/AGENTS.md index bb394c47c..a8f727093 100644 --- a/crates/zeph-sanitizer/AGENTS.md +++ b/crates/zeph-sanitizer/AGENTS.md @@ -7,3 +7,4 @@ Untrusted content isolation: sanitization pipeline, injection detection, truncat - Every new injection pattern or bypass discovered in live testing must get a regression test before the fix is merged. - Do not weaken truncation limits or bypass conditions without an explicit security review. - If sanitization behavior changes, verify cross-channel consistency (CLI, TUI, Telegram) — a bypass in one channel is a bypass everywhere. +- PII filtering (`pii.rs`) and secret masking (`secret_mask.rs`) are enabled by default (`enabled: true`, #6295) — treat any new gate in this crate that defaults to disabled as a security regression requiring explicit sign-off. diff --git a/crates/zeph-sanitizer/README.md b/crates/zeph-sanitizer/README.md index 1160afe83..25c7cf3f7 100644 --- a/crates/zeph-sanitizer/README.md +++ b/crates/zeph-sanitizer/README.md @@ -20,12 +20,37 @@ Implements a multi-stage security pipeline that processes all external data befo | `ContentSourceKind` | Source category (tool output, web scrape, document, etc.) | | `SanitizedContent` | Output with `body`, `source`, `injection_flags`, and `was_truncated` | | `InjectionFlag` | Detected injection pattern (`pattern_name`, `byte_offset`, `matched_text`) | +| `pii::PiiFilter` | Regex PII scrubber (email, phone, SSN, credit card; opt-in name heuristic) | +| `guardrail::GuardrailFilter` | LLM-based pre-screener at the input boundary | | `quarantine::QuarantinedSummarizer` | Dual LLM pattern — routes high-risk content through an isolated, tool-less LLM call | +| `response_verifier::ResponseVerifier` | Post-LLM response scanner | | `exfiltration::ExfiltrationGuard` | Three outbound guards: markdown image tracking, tool URL cross-validation, memory write suppression | +| `memory_validation::MemoryWriteValidator` | Structural write guards for the memory store | +| `causal_ipi::TurnCausalAnalyzer` | Behavioral deviation detection at tool-return boundaries | +| `nli::NliSanitizer` | Probabilistic NLI entailment check for injected instructions | +| `secret_mask::SecretMaskRegistry` | Vault-secret placeholder masking at the LLM boundary | +| `ipi_filter::IpiFilter` / `IpiVerdict` | Indirect prompt injection filter and verdict type | | `ContentSource` | Source metadata with `ContentSourceKind` and optional `MemorySourceHint` for memory retrieval classification | | `MemorySourceHint` | `ConversationHistory` / `LlmSummary` / `ExternalContent` — classifies memory retrieval sources to suppress false positive injection flags on recalled user text and LLM-generated summaries | -## Sanitization pipeline +## Architecture + +The crate is a layered defense-in-depth pipeline; each layer is independently configurable and optional except layer 1: + +| Layer | Type | Description | +|-------|------|-------------| +| 1 | `ContentSanitizer` | Regex-based injection detection + spotlighting | +| 2 | `pii::PiiFilter` | Regex PII scrubber (email, phone, SSN, credit card) | +| 3 | `guardrail::GuardrailFilter` | LLM-based pre-screener at the input boundary | +| 4 | `quarantine::QuarantinedSummarizer` | Isolated LLM fact extractor | +| 5 | `response_verifier::ResponseVerifier` | Post-LLM response scanner | +| 6 | `exfiltration::ExfiltrationGuard` | Outbound channel guards (markdown images, tool URLs) | +| 7 | `memory_validation::MemoryWriteValidator` | Structural write guards for the memory store | +| 8 | `causal_ipi::TurnCausalAnalyzer` | Behavioral deviation detection at tool-return boundaries | +| 9 | `nli::NliSanitizer` | Probabilistic NLI entailment check for injected instructions | +| 10 | `secret_mask::SecretMaskRegistry` | Vault-secret placeholder masking at the LLM boundary | + +### Sanitization pipeline (layer 1 detail) ``` External data @@ -74,8 +99,19 @@ enabled = true block_markdown_images = true validate_tool_urls = true block_injection_flagged_memory_writes = true + +[security.pii_filter] +enabled = true # default: true — scrubs email/phone/SSN/credit-card before LLM context and debug dumps +filter_names = false # opt-in: higher-recall, lower-precision name heuristic + +[security.content_isolation.secret_masking] +enabled = true # default: true — vault-resolved secrets replaced with placeholders before outbound LLM calls ``` +> [!NOTE] +> `PiiFilterConfig` and `SecretMaskingConfig` both default to `enabled = true`. An operator's +> explicit `enabled = false` in an existing `config.toml` is always respected. + ## Features `zeph-memory` (and transitively `zeph-db`) needs exactly one backend selected to compile; `sqlite` is the default so the crate builds in isolation. diff --git a/crates/zeph-scheduler/AGENTS.md b/crates/zeph-scheduler/AGENTS.md index 574988188..0293469fd 100644 --- a/crates/zeph-scheduler/AGENTS.md +++ b/crates/zeph-scheduler/AGENTS.md @@ -3,6 +3,9 @@ Scheduled task persistence, cron handling, and update-check jobs live here. - Start with crate-local checks: `cargo build -p zeph-scheduler`, `cargo nextest run -p zeph-scheduler`, `cargo clippy -p zeph-scheduler --all-targets -- -D warnings`. +- Read `specs/018-scheduler/spec.md` ("RTW-A Temporal Re-Entry Defense") before changing task provenance, trust gating, or adding a new `TaskHandler`. - Preserve deterministic scheduling and storage behavior; time parsing and job-state transitions should get explicit test coverage. +- A new `TaskHandler` must declare `reads_external_content()`/`injects_agent_prompt()` accurately — RTW-A Mech4 (external-read suppression) gates on these capability flags for every registered handler, not on a `TaskKind` match (#6126). +- `TaskProvenance` is a trust boundary, not a display label: DB-hydrated jobs have their provenance forced to `External` at `init()` regardless of the stored column value — never trust a writer-controllable provenance field verbatim (#6125). - Keep scheduler behavior aligned with root CLI/config and any built-in scheduling skills. - If external behavior changes, update `crates/zeph-scheduler/README.md` and the relevant scheduler docs. diff --git a/crates/zeph-scheduler/README.md b/crates/zeph-scheduler/README.md index adcbb15fa..5d5624894 100644 --- a/crates/zeph-scheduler/README.md +++ b/crates/zeph-scheduler/README.md @@ -15,7 +15,7 @@ Manages recurring and deferred background tasks. Periodic tasks run on a cron sc - **scheduler** — `Scheduler` event loop; evaluates due tasks on each tick, drains the `SchedulerMessage` channel, and dispatches execution to registered handlers - **store** — `JobStore` for SQLite-backed job persistence (upsert, record_run, mark_done, delete, next_run management) -- **task** — `ScheduledTask`, `TaskDescriptor`, `TaskHandler`, `TaskKind`, `TaskMode` — core type definitions +- **task** — `ScheduledTask`, `TaskDescriptor`, `TaskHandler`, `TaskKind`, `TaskMode`, `TaskProvenance` (`Static`/`UserAdded`/`External` trust tiers for RTW-A re-entry defense) — core type definitions - **handlers** — `CustomTaskHandler` — injects a sanitized prompt into the agent loop via `mpsc::Sender` - **sanitize** — `sanitize_task_prompt` — strips control characters and truncates to 512 code points - **update_check** — `UpdateCheckHandler` for GitHub releases version check @@ -137,23 +137,45 @@ scheduler.run_with_interval(30).await; // tick every 30 seconds Previously, a periodic task with a missing `next_run` value in the store would fire immediately on the next tick regardless of its cron schedule. The fix: when `next_run` is `NULL`, the scheduler computes and persists the next occurrence from the cron expression and skips the current tick. Tasks now only fire when `next_run <= now`. +## RTW-A re-entry defense + +`scheduled_jobs` rows carry a `provenance` column (`TaskProvenance::Static`/`UserAdded`/`External`) +used by RTW-A (Read-Then-Write-Attack) re-entry defense mechanisms that gate custom-prompt +dispatch: a trust gate (only `static`/`user_added` tasks may run custom prompts), external-read +suppression for the current tick (a handler's `reads_external_content()`/`injects_agent_prompt()` +declares whether it participates), and Unicode-aware injection-pattern scanning of `task_data` via +`sanitize::sanitize_task_prompt_checked` (distinct from the unchecked `sanitize_task_prompt` used +elsewhere). `JobStore::init()` forces every DB-hydrated job to `TaskProvenance::External` +regardless of its stored column value, closing a spoofing gap where a direct-SQL actor could +self-label a row as trusted. Controlled by `[scheduler.security]` (`enabled`, +`injection_pattern_check`, `attenuate_after_external_read`, all default `true`); call +`Scheduler::with_reentry_defense(enabled, injection_pattern_check, attenuate_after_external_read)` +to override the defaults, e.g. to relax checks in a trusted test environment. + ## JobStore Schema ```sql CREATE TABLE IF NOT EXISTS scheduled_jobs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE, - cron_expr TEXT NOT NULL DEFAULT '', - kind TEXT NOT NULL, - last_run TEXT, - next_run TEXT, - status TEXT NOT NULL DEFAULT 'pending', - task_mode TEXT NOT NULL DEFAULT 'periodic', - run_at TEXT + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + cron_expr TEXT NOT NULL DEFAULT '', + kind TEXT NOT NULL, + last_run TEXT, + next_run TEXT, + status TEXT NOT NULL DEFAULT 'pending', + task_mode TEXT NOT NULL DEFAULT 'periodic', + run_at TEXT, + task_data TEXT NOT NULL DEFAULT '', + provenance TEXT NOT NULL DEFAULT 'external' ) ``` -`task_mode` is `'periodic'` or `'oneshot'`. `run_at` holds the ISO 8601 UTC timestamp for one-shot tasks. The `init()` method applies `ALTER TABLE` migrations for older schemas that lack `task_mode` and `run_at`. +`task_mode` is `'periodic'` or `'oneshot'`. `run_at` holds the ISO 8601 UTC timestamp for one-shot +tasks. `task_data` carries the arbitrary JSON `TaskDescriptor::config` forwarded to the handler. +`provenance` is the RTW-A trust tier (see above); existing rows default to `'external'`, the most +restrictive tier, on upgrade. The schema itself is owned by numbered migrations in +`zeph-db/migrations/{sqlite,postgres}/` (`051_scheduler_jobs.sql` plus later `ALTER TABLE` steps for +`task_data`/`provenance`), applied via `JobStore::init()` calling `zeph_db::run_migrations`. ## CLI subcommand diff --git a/crates/zeph-session/AGENTS.md b/crates/zeph-session/AGENTS.md new file mode 100644 index 000000000..f8c26f343 --- /dev/null +++ b/crates/zeph-session/AGENTS.md @@ -0,0 +1,18 @@ +# zeph-session Guide + +Conversation-session persistence: the append-only `events.jsonl` event log, its SQLite/PostgreSQL +`acp_sessions`/`messages` projection, and the replay, condensation, and fork engines built on top. +Analogous in placement to `zeph-durable` (same journal-first design, message-level rather than +step-level). + +- Start with crate-local checks: `cargo build -p zeph-session`, `cargo nextest run -p zeph-session`, `cargo clippy -p zeph-session --all-targets -- -D warnings`. +- Test both backends — mutually exclusive features: `cargo nextest run -p zeph-session --no-default-features --features sqlite` and `... --features postgres`. +- Read `specs/068-session-persistence/spec.md` before any change; honor its `## 13. Key Invariants` and `## 15. NEVER` sections. +- INV-SP-1 (log-first ordering): `events.jsonl` is flushed before the SQLite projection or `acp_sessions.last_seq` is updated — the projection never leads the log. +- INV-SP-2/INV-D2 (single writer, torn-tail truncation): only a session's `SessionActor` task (or the single active agent process) may write `events.jsonl`; any other writer breaks the torn-append-truncation guarantee. Session actors are spawned via `TaskSupervisor::spawn`, never raw `tokio::spawn`. +- INV-SP-3 (reconcile-from-log on open): the event log is always authoritative — SQLite is a derivable projection rebuilt forward from the log when `last_seq` lags. +- INV-SP-4 (condensation non-overlap): a `Condensation`/`Compaction` event's `replaced_seq_range` must never overlap a prior one in the same session log. +- Never make `zeph-session` depend on `zeph-durable`, or vice versa — the two journals are independent and reference each other only by opaque IDs. +- Hydration/resume paths must go through the bounded `ReplayEngine::replay`, not `ReplayEngine::fold`, to preserve the memory bound established for session-resume (#5861, #5844). +- Fork's blob copy (`ForkEngine::fork`) hard-links content-addressed blobs with fallback to `fs::copy` — but only when the destination genuinely does not exist; treat `ErrorKind::AlreadyExists` as a no-op, since copying onto an existing hard-link silently truncates the shared inode for every session pointing at it (#6157). Validate `image_refs` hashes as bare hex before using them in a path to prevent traversal (#6152). +- If external behavior changes, update `crates/zeph-session/README.md` and `book/src/advanced/session-persistence.md`. diff --git a/crates/zeph-session/README.md b/crates/zeph-session/README.md index c3e81436c..6dd080136 100644 --- a/crates/zeph-session/README.md +++ b/crates/zeph-session/README.md @@ -47,7 +47,7 @@ See `specs/068-session-persistence/spec.md` and `plan.md` for the full design an | `replay` | `ReplayEngine` — deterministic fold of an event log into agent-ready messages; never calls the LLM | | `condenser` | `Condenser` trait contract and the non-overlap guard (INV-SP-4) | | `llm_condenser` | `LlmCondenser` — default `Condenser`, reusing `zeph_context::summarization` | -| `fork` | `ForkEngine` — eager-copy session forking | +| `fork` | `ForkEngine` — eager-copy session forking; blobs referenced by `UserMessage.image_refs` are hard-linked into the child session's blobs directory (falling back to a copy), with referenced hashes validated as bare hex to prevent path traversal | | `error` | `SessionError` — crate-wide error enum | ## Usage @@ -61,6 +61,10 @@ let dir = zeph_session::session_dir(Path::new(".zeph/sessions"), "abc-123"); assert_eq!(dir, Path::new(".zeph/sessions/abc-123")); ``` +`migrate_legacy_session_layout` is a one-time startup migration that moves session directories still +sitting at the pre-fix on-disk layout (`/sessions//`) up one level to the +current layout, returning a `MigrationReport` (counts migrated vs. skipped-because-destination-exists). + ## Features Exactly one storage backend must be selected for the `acp_sessions` metadata index; `sqlite` is the default. @@ -69,6 +73,7 @@ Exactly one storage backend must be selected for the `acp_sessions` metadata ind |---------|---------|-------------| | `sqlite` | yes | SQLite backend for `zeph-db` | | `postgres` | no | PostgreSQL backend for `zeph-db` | +| `test-utils` | no | Enables `testcontainers`-based PostgreSQL integration test utilities (implies `postgres`) | ## Installation diff --git a/crates/zeph-skills/AGENTS.md b/crates/zeph-skills/AGENTS.md index e1e00c014..d9d892759 100644 --- a/crates/zeph-skills/AGENTS.md +++ b/crates/zeph-skills/AGENTS.md @@ -4,6 +4,7 @@ Skill loading, matching, trust, and evolution logic live here. - Start with crate-local checks: `cargo build -p zeph-skills`, `cargo nextest run -p zeph-skills`, `cargo clippy -p zeph-skills --all-targets -- -D warnings`. - Preserve matcher quality and trust semantics unless the task explicitly changes retrieval or scoring behavior. +- `RoutingHead` (RL routing head, `rl_head.rs`) must be a single shared `Arc` instance across concurrent ACP/serve sessions — loading a fresh copy per session clobbers persisted REINFORCE weights via independent racing writes (#5974). Use `RoutingHead::persist_snapshot()` to build the persisted row; don't reintroduce separate locked reads that can race with a concurrent `update()`. - Multi-model: skill matching, embedding, and self-learning evolution call LLMs — expose `*_provider` fields referencing `[[llm.providers]]` names; never hardcode a model. - Changes to skill loading or trust should be checked against instruction-file and skill-related docs. - If external behavior changes, update `crates/zeph-skills/README.md` and the relevant skills docs. diff --git a/crates/zeph-subagent/AGENTS.md b/crates/zeph-subagent/AGENTS.md index 04a6188b4..74748f0b5 100644 --- a/crates/zeph-subagent/AGENTS.md +++ b/crates/zeph-subagent/AGENTS.md @@ -3,7 +3,11 @@ Sub-agent spawning, capability grants, transcript management, and lifecycle hooks live here. - Start with crate-local checks: `cargo build -p zeph-subagent`, `cargo nextest run -p zeph-subagent`, `cargo clippy -p zeph-subagent --all-targets -- -D warnings`. -- Treat grant scoping and capability propagation as security-sensitive: a sub-agent must never inherit more permissions than explicitly granted. +- Read `specs/044-subagent-lifecycle/spec.md` before changing spawn, grant, or constraint-propagation logic; see `specs/064-durable-execution/spec.md` for the durable-resume contract below. +- Treat grant scoping and capability propagation as security-sensitive: a sub-agent must never inherit more permissions than explicitly granted. Granted secrets must be TTL-rechecked before every tool call (not only at delivery), and secret-request lookups must key on the specific task id — never pop-then-filter a shared queue, which can drop a concurrent sibling's pending request (#6123). +- `filter.rs` hosts `FilteredExecutor`, `PlanModeExecutor`, and `NetworkDenyToolExecutor` — all `ErasedToolExecutor` decorators. `ToolExecutor`/`ErasedToolExecutor` have no default bodies for `requires_confirmation`, `execute_tool_call_confirmed`, the checkpoint trio, or `is_tool_speculatable` (#6067 breaking change): every wrapper here must explicitly forward each of these to its inner executor, and every new wrapper needs regression coverage per forwarded method — this exact silent-fallback-to-default defect has recurred 5+ times. +- On a resumed durable execution, check for an already-resolved child promise (`try_replay_durable_subagent`) before spawning a new child — never blindly respawn a subagent whose result was already journaled, which would duplicate LLM/tool side effects (#6014). +- Every fallible pre-loop setup step inside `spawn()`'s task closure must send a terminal `Failed` status (via `spawn_oneshot_classified`), not leave the status channel in its initial `Submitted` state — otherwise `poll_subagents()` can never reclaim the `max_concurrent` slot, leaking a permanent zombie task (#6283). - LLM serialization gate: changes to sub-agent message passing or transcript structs require a live session test with actual sub-agent spawning before merge. - TUI: sub-agent spawning and active tasks must surface visible spinner/status feedback. - If external behavior changes, update `crates/zeph-subagent/README.md` and the relevant sub-agent docs. diff --git a/crates/zeph-tools/AGENTS.md b/crates/zeph-tools/AGENTS.md index f6b7fb0f6..c8f2a7efd 100644 --- a/crates/zeph-tools/AGENTS.md +++ b/crates/zeph-tools/AGENTS.md @@ -5,4 +5,6 @@ Tool execution, shell permissions, filtering, scraping, and audit behavior live - Start with crate-local checks: `cargo build -p zeph-tools`, `cargo nextest run -p zeph-tools`, `cargo clippy -p zeph-tools --all-targets -- -D warnings`. - Treat shell execution, permissions, trust gating, network access, and audit logging as security-sensitive behavior. - Prefer explicit safeguards over implicit defaults; regressions here can affect the whole agent. +- `ToolExecutor`/`ErasedToolExecutor` have no permissive default method bodies (removed in #6067 after 5 recurring silent-forwarding bugs) — any new wrapper impl must explicitly forward every method. Use the `tool_executor_forward!`/`erased_tool_executor_forward!` macros in `executor_delegate.rs`, and prefer the `DynExecutor`/`ErasedToolExecutor` adapter over a hand-written `impl ToolExecutor` wrapper. +- This crate depends on `zeph-llm` for `ImageData` (`ToolOutput.media: Vec`, spec-072 MCP image passthrough plumbing, #6238). Media is ephemeral-only — never persisted — and `ImageData`'s `Debug` impl is redacted; do not add a `Debug`/log path that exposes raw image bytes. - If external behavior changes, update `crates/zeph-tools/README.md` and the relevant tools/security docs. diff --git a/crates/zeph-tools/README.md b/crates/zeph-tools/README.md index d5665abc4..89f95056d 100644 --- a/crates/zeph-tools/README.md +++ b/crates/zeph-tools/README.md @@ -30,6 +30,7 @@ Defines the `ToolExecutor` trait for sandboxed tool invocation and ships concret | `schema_filter` | `ToolSchemaFilter` — dynamic tool schema filtering via embedding similarity; selects top-K relevant tools per query. `ToolDependencyGraph` — dependency graph with `requirements_met()` gate preventing tool execution until prerequisites are completed; `DependencyExclusion` marks tools excluded by unmet deps | | `cache` | `ToolResultCache` — in-memory LRU cache for deterministic tool results with TTL expiry; `CacheKey` hashes tool name + args; `is_cacheable()` whitelist for safe-to-cache tools | | `tool_filter` | `ToolFilter` — executor wrapper that suppresses specified tools from the LLM tool set | +| `executor_delegate` | Forwarding macros (`tool_executor_forward!`, `tool_executor_no_inner_defaults!`, and their `erased_*` counterparts) that implement the required `ToolExecutor`/`ErasedToolExecutor` methods for wrapper and leaf executors, respectively | | `overflow` | (removed — overflow storage migrated to SQLite in `zeph-memory`) | | `shell::transaction` | Transactional shell executor — snapshot/rollback filesystem state around shell commands; captures pre-execution state and reverts on failure or user request | | `adversarial_policy` | Adversarial policy agent — pre-execution LLM validation that evaluates tool calls for safety before dispatch | @@ -184,6 +185,31 @@ Rules are merged into `PolicyEnforcer` at startup. `[tools.policy]` rules always `ToolCall::caller_id: Option` carries the originating agent or sub-agent identifier. Set automatically by the orchestrator for sub-agent dispatches; `None` for the primary agent. Recorded in audit log entries. +## ToolExecutor trait contract + +> [!WARNING] +> `requires_confirmation`, `execute_tool_call_confirmed`, `checkpoint_undo`/`checkpoint_redo`/ +> `checkpoint_list`, and `is_tool_speculatable` (and their `_erased` counterparts on +> `ErasedToolExecutor`) have **no default implementation**. A wrapper or leaf executor that +> omits one of these no longer silently inherits a permissive fallback — it fails to compile. +> Leaf executors with no wrapped inner should implement them via +> [`tool_executor_no_inner_defaults!`](https://docs.rs/zeph-tools); wrappers that forward to an +> inner executor should use [`tool_executor_forward!`](https://docs.rs/zeph-tools). This closed +> a recurring defect class where a decorator's `impl` block quietly fell back to an overly +> permissive trait default instead of forwarding to its inner executor. + +## Multimodal tool output (media passthrough) + +`ToolOutput.media: Vec` carries validated image data across the tool +boundary (e.g. MCP `ContentBlock::Image` passthrough, spec-072), introducing a `zeph-tools` → +`zeph-llm` dependency edge. `ImageData` has a redacting `Debug` impl so raw image bytes never +reach logs or debug dumps. + +> [!NOTE] +> As of this release `media` is never populated by any executor — this is foundational, +> behavior-preserving plumbing only. Decode/validate/attach logic, the persistence strip, +> vision-tier routing, and the config/CLI/TUI surface are deferred to follow-up work. + ## Features | Feature | Description | diff --git a/crates/zeph-tui/AGENTS.md b/crates/zeph-tui/AGENTS.md index 07fd1e091..885e877b2 100644 --- a/crates/zeph-tui/AGENTS.md +++ b/crates/zeph-tui/AGENTS.md @@ -3,6 +3,9 @@ The ratatui dashboard, UI state, event loop, and visual feedback live here. - Start with crate-local checks: `cargo build -p zeph-tui`, `cargo nextest run -p zeph-tui`, `cargo clippy -p zeph-tui --all-targets -- -D warnings`. +- Read `specs/011-tui/spec.md` before changing panel state, the spinner rule, or app-event wiring; honor its `## Key Invariants` sections. - Any background or implicit operation must surface visible status/spinner feedback in the UI. - Preserve keyboard flow, redraw behavior, and test coverage for regressions in event handling. +- New `App` state fed via `AgentEvent` (cancel signal, metrics receiver, `TaskSupervisor`, etc.) must be wired into BOTH TUI startup paths — the phase-2/early-start path (`run_tui_agent` in `src/tui_bridge.rs`) and the legacy path — or the feature silently degrades on whichever path was missed (#6276/#6281). +- Views that render provider/server/agent config (e.g. the Settings panel) must build their display structs via explicit whitelist field-copy, never by deriving `Serialize`/`Debug` on config types that may carry secret fields (#6246). - If external behavior changes, update `crates/zeph-tui/README.md` and the relevant TUI docs. diff --git a/crates/zeph-tui/README.md b/crates/zeph-tui/README.md index 7e6348e08..7bdaa7734 100644 --- a/crates/zeph-tui/README.md +++ b/crates/zeph-tui/README.md @@ -23,7 +23,7 @@ Provides a terminal UI for monitoring the Zeph agent in real time. Built on rata - **layout** — panel arrangement and responsive grid - **metrics** — `MetricsCollector`, `MetricsSnapshot` for live telemetry; skill confidence bars rendered as `[████░░░░] 73% (42 uses)` using Wilson score posterior from the skills registry; filter savings percentage shown in the status bar (e.g. `Filters: 78%`); `SEC` indicator in status bar shows injection flag count when nonzero; compaction probe metrics panel showing pass/soft-fail/fail/error rates; `Backfilling embeddings: N/M (X%)` status bar entry during embed backfill (clears on completion) - **theme** — color palette and style definitions -- **widgets** — reusable ratatui widget components; includes `subagents` widget with a 5-state FSM panel (`List` → `Detail` → `Create` → `Edit` → `ConfirmDelete`) for interactive management of sub-agent definition files; `security` widget renders a side panel with a real-time security event feed (injection flags, exfiltration blocks, quarantine invocations, truncations); `plan_view` widget renders a live task graph table with per-row status spinners, status colors (Running=Yellow, Completed=Green, Failed=Red), and a 30-second stale cleanup — toggled with `p` (requires `orchestration` feature); `memory` widget displays compaction probe metrics (pass/soft-fail/fail/error distribution with percentage bars) +- **widgets** — reusable ratatui widget components; includes `subagents` widget with a 5-state FSM panel (`List` → `Detail` → `Create` → `Edit` → `ConfirmDelete`) for interactive management of sub-agent definition files; `security` widget renders a side panel with a real-time security event feed (injection flags, exfiltration blocks, quarantine invocations, truncations); `plan_view` widget renders a live task graph table with per-row status spinners, status colors (Running=Yellow, Completed=Green, Failed=Red), and a 30-second stale cleanup — toggled with `p` (requires `orchestration` feature); `memory` widget displays compaction probe metrics (pass/soft-fail/fail/error distribution with percentage bars); `settings` widget renders a read-only, tabbed (Providers / MCP / Agents) view of live configuration sourced from `MetricsSnapshot`, toggled with `S`; `transcript_search` widget implements a Ctrl+F highlight-and-scroll transcript search overlay (mirrors the Ctrl+R reverse-search pattern); `task_registry` widget shows the live `TaskSupervisor` task list, toggled with `t` - **error** — `TuiError` typed error enum (Io, Channel) ## Agents management panel @@ -99,6 +99,22 @@ Enable debug dump mid-session without restarting the agent: Files are written to `{output_dir}/{unix_timestamp}/` with numbered `request.json`, `response.txt`, and `tool-{name}.txt` files for each LLM call and tool execution. +## Settings view + +Press `S` (or the `settings` command-palette entry) to open a read-only, tabbed view of the running session's live configuration, sourced from `MetricsSnapshot`: + +| Tab | Shows | +|-----|-------| +| Providers | Configured `[[llm.providers]]` entries (name, type, model) — secret fields are never surfaced, via an explicit whitelist field-copy | +| MCP | Configured MCP servers and their live connection status | +| Agents | Configured sub-agent definitions (templates), not runtime instances | + +Write/edit is out of scope for this view — it is read-only by design. + +## Transcript search + +Press `Ctrl+F` (or the `search:transcript` command-palette entry) to open a case-insensitive substring search overlay over the conversation transcript, mirroring the existing `Ctrl+R` reverse-search interaction: highlight-and-scroll (not filter), cycle matches, `Esc` restores the pre-search scroll position, `Enter` accepts. + ## Command palette The command palette is opened with `:` in normal mode. Type to fuzzy-filter entries, then press Enter to execute. @@ -120,6 +136,9 @@ The command palette is opened with `:` in normal mode. Type to fuzzy-filter entr | `graph:backfill` | Backfill graph from existing messages — requires `graph-memory` feature | | `scheduler:list` | List active scheduled tasks (name, kind, mode, next run) — requires `scheduler` feature | | `gateway:status` | Show gateway server state — requires `gateway` feature | +| `tasks` | Toggle the task registry panel (`t` shortcut), showing live `TaskSupervisor` tasks | +| `settings` | Browse configured providers, MCP servers, and agents (`S` shortcut) | +| `search:transcript` | Find in conversation (`Ctrl+F` shortcut) | | `security:events` | Show security event history | | `plan:status` | Print current plan progress to chat | | `plan:confirm` | Confirm and execute the pending plan | diff --git a/crates/zeph-tui/src/widgets/snapshots/zeph_tui__widgets__splash__tests__splash_ansi16_full_layout.snap b/crates/zeph-tui/src/widgets/snapshots/zeph_tui__widgets__splash__tests__splash_ansi16_full_layout.snap index 453f4a2d6..25bbac5c3 100644 --- a/crates/zeph-tui/src/widgets/snapshots/zeph_tui__widgets__splash__tests__splash_ansi16_full_layout.snap +++ b/crates/zeph-tui/src/widgets/snapshots/zeph_tui__widgets__splash__tests__splash_ansi16_full_layout.snap @@ -5,6 +5,6 @@ expression: output ≈ zeph think further. - v0.22.0 + v0.22.1 / commands @ files ? keys Tab panels diff --git a/crates/zeph-tui/src/widgets/snapshots/zeph_tui__widgets__splash__tests__splash_ascii_only_full_layout.snap b/crates/zeph-tui/src/widgets/snapshots/zeph_tui__widgets__splash__tests__splash_ascii_only_full_layout.snap index dd11bef62..bd2715406 100644 --- a/crates/zeph-tui/src/widgets/snapshots/zeph_tui__widgets__splash__tests__splash_ascii_only_full_layout.snap +++ b/crates/zeph-tui/src/widgets/snapshots/zeph_tui__widgets__splash__tests__splash_ascii_only_full_layout.snap @@ -5,6 +5,6 @@ expression: output ~ zeph think further. - v0.22.0 + v0.22.1 / commands @ files ? keys Tab panels diff --git a/crates/zeph-tui/src/widgets/snapshots/zeph_tui__widgets__splash__tests__splash_truecolor_full_layout.snap b/crates/zeph-tui/src/widgets/snapshots/zeph_tui__widgets__splash__tests__splash_truecolor_full_layout.snap index 453f4a2d6..25bbac5c3 100644 --- a/crates/zeph-tui/src/widgets/snapshots/zeph_tui__widgets__splash__tests__splash_truecolor_full_layout.snap +++ b/crates/zeph-tui/src/widgets/snapshots/zeph_tui__widgets__splash__tests__splash_truecolor_full_layout.snap @@ -5,6 +5,6 @@ expression: output ≈ zeph think further. - v0.22.0 + v0.22.1 / commands @ files ? keys Tab panels diff --git a/crates/zeph-vault/AGENTS.md b/crates/zeph-vault/AGENTS.md index 6ab1fb56c..0e958f510 100644 --- a/crates/zeph-vault/AGENTS.md +++ b/crates/zeph-vault/AGENTS.md @@ -5,5 +5,6 @@ Secret storage with pluggable backends and age encryption (`VaultProvider` trait - Start with crate-local checks: `cargo build -p zeph-vault`, `cargo nextest run -p zeph-vault`, `cargo clippy -p zeph-vault --all-targets -- -D warnings`. - Treat every change here as highest-sensitivity: this crate is the only authorized path for secret access in the workspace. - `Secret` values must never appear in logs, `Debug` output, error messages, or serialized payloads — audit any new `impl` that touches the inner value. -- The `env` backend is for testing only and must never be enabled in production configs (`ZEPH_VAULT_BACKEND=env` is forbidden outside test contexts). +- The `env` backend is for testing only and must never be enabled in production configs (`ZEPH_VAULT_BACKEND=env` is forbidden outside test contexts); the default backend is `age` — an unrecognized `--vault`/`ZEPH_VAULT_BACKEND` value must abort startup, never silently fall back (#6025). - Do not add new secret resolution paths outside this crate; callers must go through `VaultProvider`. +- Never let a write silently clobber an existing secret: `AgeVaultProvider::set_secret_mut` requires an explicit `overwrite: bool` and returns `AlreadyExists` when a key is already present and overwrite wasn't requested — every caller (CLI, OAuth refresh, wizards) must state its overwrite intent explicitly (#6191, closes the same defect class as the #5874 incident). diff --git a/crates/zeph-vault/README.md b/crates/zeph-vault/README.md index 1b5f4e71f..f8dfd460c 100644 --- a/crates/zeph-vault/README.md +++ b/crates/zeph-vault/README.md @@ -51,11 +51,17 @@ CLI usage: ```bash zeph vault set ZEPH_CLAUDE_API_KEY sk-ant-... +zeph vault set ZEPH_CLAUDE_API_KEY sk-ant-... --force # overwrite an existing key zeph vault get ZEPH_CLAUDE_API_KEY zeph vault list zeph vault delete ZEPH_CLAUDE_API_KEY ``` +> [!NOTE] +> `zeph vault set` refuses to overwrite an existing key unless `--force` is passed — mirrored at +> the library level by `AgeVaultProvider::set_secret_mut`'s `overwrite` parameter, which returns +> `AgeVaultError::AlreadyExists` instead of silently replacing the value. + ## Configuration ```toml diff --git a/crates/zeph-worktree/AGENTS.md b/crates/zeph-worktree/AGENTS.md index 3caf897e5..e98563a71 100644 --- a/crates/zeph-worktree/AGENTS.md +++ b/crates/zeph-worktree/AGENTS.md @@ -8,3 +8,4 @@ Per-subagent git worktree lifecycle: `WorktreeManager` creates, removes, lists, - All git invocations go through the `GitRunner` abstraction so tests can mock them — never shell out to `git` directly elsewhere in the crate. - `sanitize.rs` feeds agent ids and branch names into git CLI arguments; treat it as security-sensitive (argument/shell injection). Every new input shape needs a sanitization regression test before merge. - Worktrees are never created inside this repository — managed worktrees live under the sibling `../worktrees/` directory. Path-construction and reconciliation logic must respect that convention. +- `max_worktrees` admission and `reconcile()`'s stale/quota accounting must scope strictly to entries under this manager's own canonicalized `worktree_root` — `git worktree list --porcelain` reports every worktree registered to the repo, including ones from unrelated tooling (e.g. `EnterWorktree`, a manual `git worktree add` elsewhere), which must never count toward quota or be removed by `clean` (#6257, #6283). `create()`'s quota check-through-registration sequence must stay atomic within a process (internal `admission_lock`) — do not reintroduce the check-then-act race fixed in #6252. diff --git a/crates/zeph-worktree/README.md b/crates/zeph-worktree/README.md index defaf2cae..c023cea49 100644 --- a/crates/zeph-worktree/README.md +++ b/crates/zeph-worktree/README.md @@ -75,6 +75,10 @@ bg_isolation = "worktree" # "none" | "worktree" base_ref = "head" # "head" | "fresh" git_timeout_secs = 30 # clamped to max(1, value) cleanup_on_completion = true +# max_worktrees = 20 # optional admission cap; None = unlimited +# disk_quota_mb = 5000 # optional soft disk-usage threshold; None = disabled +auto_reconcile_secs = 0 # periodic reconcile-and-quota sweep; 0 disables it +reconcile_on_startup = true ``` | Field | Default | Description | @@ -84,12 +88,18 @@ cleanup_on_completion = true | `base_ref` | `"head"` | `"head"` branches off current HEAD; `"fresh"` fetches and branches off `origin/` | | `git_timeout_secs` | `30` | Per-command timeout for all git subprocess calls | | `cleanup_on_completion` | `true` | Remove the worktree when the subagent finishes | +| `max_worktrees` | `None` (unlimited) | Creation-time admission cap on concurrent git-registered worktrees under `root`. Counts worktrees from other concurrently running Zeph sessions over the same `root`, not just this session's own. `Some(0)` is rejected at config-validation time | +| `disk_quota_mb` | `None` (disabled) | Soft total-disk-usage threshold (sum of logical file sizes) across all worktrees under `root`. When exceeded, the reconcile sweep auto-reclaims only git-`prunable` entries — an intact worktree is never force-removed to satisfy this threshold | +| `auto_reconcile_secs` | `0` (disabled) | Interval for the supervised background reconcile-and-quota sweep. `Config::validate` rejects values in `1..60` | +| `reconcile_on_startup` | `true` | Run one reconcile-and-quota sweep at bootstrap, recovering from crash-left `prunable` worktrees without waiting for the first periodic tick | ## Invariants - Path sanitization rejects absolute paths, `..` components, and names starting with `-` before any git call. - `base_ref = "fresh"` never silently falls back to HEAD on fetch failure — it returns an error. - `git_timeout_secs = 0` is clamped to `1` by `DefaultGitRunner`. +- `create()` admission (quota check + worktree add) closes the check-then-act race under concurrent callers within the same process. +- Lowering `max_worktrees` below the current worktree count does not evict existing worktrees; it only blocks new admissions until `zeph worktree clean` runs or the limit is raised. ## License diff --git a/specs/004-memory/004-16-memory-type-aware-retrieval.md b/specs/004-memory/004-16-memory-type-aware-retrieval.md new file mode 100644 index 000000000..8495bb55d --- /dev/null +++ b/specs/004-memory/004-16-memory-type-aware-retrieval.md @@ -0,0 +1,250 @@ +--- +aliases: + - MemGuard + - Type-Aware Retrieval Composition + - Functional Memory Type Gating +tags: + - sdd + - spec + - memory + - context + - research +created: 2026-07-15 +status: implemented +related: + - "[[MOC-specs]]" + - "[[constitution]]" + - "[[004-memory/spec]]" + - "[[021-zeph-context/spec]]" + - "[[024-multi-model-design/spec]]" + - "[[043-zeph-common/spec]]" +--- + +# Spec: MemGuard — Type-Aware Retrieval Composition + +> [!info] +> Backfilled after-the-fact per this project's `/sdd` convention (CLAUDE.md: "If a spec is +> missing... create or update it in `/specs/` using `/sdd` before writing code"). The feature +> shipped in commit `2e8d0969` (#6226, closing research issue #6086) with only an ephemeral +> planning doc at `.local/specs/064-memguard-type-aware-memory-retrieval/spec.md` (a session-local +> working file, not the permanent `/specs/` index) — that ephemeral spec's rustdoc citations +> ("spec 064 §4", "spec 064 §3 Q3") shipped verbatim into the production code comments in +> `crates/zeph-common/src/memory.rs`, `crates/zeph-config/src/memory/retrieval.rs`, and +> `crates/zeph-agent-context/src/type_aware_compose.rs`. **This is a naming collision**: `064` in +> the permanent `/specs/` numbering is already assigned to +> [[064-durable-execution/spec|Durable Execution]] — a reader following those in-code citations +> into `/specs/064-durable-execution/spec.md` would land on the wrong subsystem entirely. This +> document is filed as `004-16` (memory sub-spec numbering, following the `004-N` convention +> already used for 004-1 through 004-15) specifically to avoid colliding with the permanent `064` +> slot. The in-code comment citations are a known, minor documentation-staleness artifact — not +> corrected here since this task is scoped to `/specs/`, not `crates/`; flagged for a future +> doc-only follow-up. + +## Sources + +### External +- **MemGuard: Preventing Memory Contamination in Long-Term Memory-Augmented Large Language + Models** (arXiv:2605.28009) — memory contamination occurs when distinct functional memory + categories (user facts, episodic events, behavioral rules) collapse into one shared retrieval + pool; the paper's fix is type-at-creation-time isolation plus retrieval that selectively + composes only the functionally relevant type(s), reporting up to 28.27% memory-reliability + improvement while retrieving up to 5.8x fewer memory tokens. + +### Internal +| File | Contents | +|---|---| +| `crates/zeph-common/src/memory.rs` | `FunctionalType` enum (`Episodic`, `UserFact`, `BehavioralRule`, `ReasoningStrategy`, `CrossSessionSummary`, `GraphFact`), `#[non_exhaustive]`, strict `FromStr` (unknown string is a hard error, never a silent "all types" fallback) | +| `crates/zeph-config/src/memory/retrieval.rs` | `TypeAwareComposeConfig { enabled, default_compose_types, intent_scoped }` | +| `crates/zeph-config/src/memory/root.rs` | `MemoryConfig.type_aware_compose: TypeAwareComposeConfig` | +| `crates/zeph-agent-context/src/type_aware_compose.rs` | `resolve_active_functional_types(config, query) -> Vec` — pure active-set resolution; static `IntentClass -> FunctionalType[]` widening table | +| `crates/zeph-context/src/assembler.rs` | `schedule_context_fetchers` — gates five of the six `ContextAssembler` fetchers on the active set | +| `crates/zeph-memory/src/semantic/recall.rs` | `recall_with_category` — pre-existing category filter this feature finally wires a caller onto | +| `crates/zeph-memory/src/tiered_retrieval.rs` | `MemFlow` tiered pipeline; previously passed `category: None` unconditionally | +| `config/default.toml` | `[memory.type_aware_compose]` documented-commented-out block | + +--- + +## 1. Overview + +### Problem Statement + +`zeph-memory` already has strong *storage*-side functional isolation — separate SQLite tables +per memory function (`persona_memory`, `consolidated_facts`, `user_corrections`, +`learned_preferences`, `trajectory_memory`, `graph_episodes`/`graph_entities`/`graph_edges`) — but +the *retrieval* side has no equivalent. `recall_with_category` (`semantic/recall.rs`) existed as +dead code from the production path's perspective: `MemFlow`'s default recall passed +`category: None` unconditionally, so every turn composed all functional memory types into one +undifferentiated pool regardless of the actual retrieval need, per the code-grounded audit filed +as issue #6086. + +### Goal + +Context assembly can, when opted in, compose only the functionally relevant memory type(s) for a +turn instead of always fetching all six sources — reducing irrelevant-context token usage and the +contamination risk MemGuard documents, consistent with the paper's approach and Zeph's own +already-partitioned SQL-table structure. + +### Out of Scope + +- No new storage tier, no new Qdrant collection, no write-path change — retrieval-only, + fetch-time gate. +- `BehavioralRule` (past-correction recall, `fetch_corrections`) is never gated — it stays + unconditionally composed as a safety-critical invariant regardless of the active set (§4). +- No LLM-based intent classification — `intent_scoped` reuses the existing no-I/O + `HeuristicRouter`, adding zero new LLM calls. +- Per-model/per-provider composition tuning — out of scope; the active set is global per turn. + +--- + +## 2. Functional Requirements + +| ID | Requirement | Priority | +|----|------------|----------| +| FR-001 | WHEN `memory.type_aware_compose.enabled = false` (default) THE SYSTEM SHALL compose every memory source exactly as it did before this feature — byte-for-byte no-op | must | +| FR-002 | WHEN `enabled = true` AND `default_compose_types` is non-empty THE SYSTEM SHALL gate `schedule_context_fetchers` to compose only the functional types in the active set | must | +| FR-003 | WHEN `enabled = true` AND `default_compose_types` is empty AND `intent_scoped = false` THE SYSTEM SHALL treat the active set as "all types" — identical to `enabled = false` | must | +| FR-004 | WHEN `intent_scoped = true` THE SYSTEM SHALL classify the query via the existing no-LLM `HeuristicRouter`/`IntentClass` and widen (never narrow) the active set per a static `IntentClass -> FunctionalType[]` table | must | +| FR-005 | WHEN a config value in `default_compose_types` does not match a known `FunctionalType` variant THE SYSTEM SHALL fail config load with a hard error — never silently widen to "all types" | must | +| FR-006 | WHEN `fetch_corrections` (`BehavioralRule`) would run THE SYSTEM SHALL always schedule it regardless of the active set | must | +| FR-007 | WHEN a future `FunctionalType` variant is added to the `#[non_exhaustive]` enum THE SYSTEM SHALL treat it as always-composed until a fetcher explicitly gates on it — never silently dropped | should | + +--- + +## 3. Architecture + +### 3.1 Data Model + +```rust +#[non_exhaustive] +pub enum FunctionalType { + Episodic, // fetch_semantic_recall -> zeph_conversations + UserFact, // fetch_persona_facts -> SQL persona_memory + BehavioralRule, // fetch_corrections -> zeph_corrections (always-on, never gated) + ReasoningStrategy, // fetch_reasoning_strategies -> reasoning_strategies + CrossSessionSummary, // fetch_summaries/fetch_cross_session -> zeph_session_summaries + GraphFact, // fetch_graph_facts -> zeph_graph_entities +} +``` + +`FunctionalType` lives in `zeph-common` (not `zeph-memory`) because `zeph-context` — the crate +whose `schedule_context_fetchers` gates on this type — deliberately has no `zeph-memory` +dependency (issue #3665). `zeph-memory` re-exports it at its crate root for taxonomy +discoverability. This is orthogonal to `CompressionLevel`/`MemoryTier` (a storage-tier axis) and +`MemoryRoute` (a routing-backend axis) — a `zeph_conversations` vector is simultaneously +`Episodic`-tier and the `Episodic` functional type. + +### 3.2 Config Schema + +```toml +[memory.type_aware_compose] +enabled = false # off by default (#6086) +default_compose_types = [] # empty = all types; strict parse, unknown string is a hard error +intent_scoped = false # widen active set per classified intent; reuses HeuristicRouter, no new LLM call +``` + +### 3.3 Active-Set Resolution + +``` +resolve_active_functional_types(config, query) -> Vec + │ + ├── !config.enabled ────────────────────────────> [] (no gating — compose all) + │ + ├── default_compose_types.is_empty() && !intent_scoped -> [] (no gating — compose all) + │ + └── active = default_compose_types.clone() + │ + └── intent_scoped? -> classify(query) via HeuristicRouter -> IntentClass + -> widen `active` per static table (dedup, never narrow) +``` + +Static `IntentClass -> FunctionalType[]` widening table (v1): + +| `IntentClass` | Widens active set with | +|---|---| +| `ProfileLookup` | `UserFact` | +| `TargetedRetrieval` | `Episodic`, `UserFact`, `CrossSessionSummary`, `GraphFact` | +| `DeepReasoning` | `Episodic`, `ReasoningStrategy`, `CrossSessionSummary`, `GraphFact` | +| any other (future, `#[non_exhaustive]`) | `[]` (conservative — no accidental over-composition) | + +`schedule_context_fetchers` (`zeph-context/src/assembler.rs`) treats an empty resolved `Vec` as +"compose everything" — the same code path as today, before this feature existed. + +--- + +## 4. Key Invariants + +### Always (without asking) + +- `enabled = false` (default) reproduces the exact current unfiltered composition, + byte-for-byte (FR-001). +- `fetch_corrections` (`BehavioralRule`) is scheduled unconditionally, regardless of the active + set — safety-critical past-correction recall is never gated out (FR-006). +- `resolve_active_functional_types` is pure — no I/O, no LLM call, no randomness; `intent_scoped` + classification reuses the existing synchronous `HeuristicRouter` (FR-004). +- An unknown/typo'd string in `default_compose_types` is a hard config-load error, never a + silent widen-to-all fallback (FR-005, critic finding S4 from the originating design review). +- Widening via the `IntentClass` table only adds types to the already-resolved default set — it + never narrows or replaces it. + +### Ask First + +- Adding a per-model or per-provider variant of the active-set resolution. +- Extending `intent_scoped` widening to use an LLM classifier instead of `HeuristicRouter` — the + current design's "no new LLM call" guarantee is a deliberate multi-model-design trade-off + ([[024-multi-model-design/spec]]). + +### Never + +- **NEVER** gate `fetch_corrections`/`BehavioralRule` behind the active set — it is always + composed (FR-006). +- **NEVER** let an unrecognised `default_compose_types` string silently fall back to "all + types" — fail config load instead (FR-005). +- **NEVER** narrow the active set via intent-scoped widening — the table only adds types. + +--- + +## 5. Edge Cases and Error Handling + +| Scenario | Expected Behavior | +|----------|-------------------| +| `enabled = false` | All memory types composed, identical to pre-feature behavior (FR-001) | +| `enabled = true`, `default_compose_types = []`, `intent_scoped = false` | Treated as "all types" — same as disabled (FR-003) | +| `enabled = true`, `default_compose_types = ["user_fact"]` | Only `UserFact` composed; `BehavioralRule` still always composed alongside it | +| `intent_scoped = true`, query classifies as `DeepReasoning` | Active set widened with `Episodic`/`ReasoningStrategy`/`CrossSessionSummary`/`GraphFact`, deduplicated against any overlapping default types | +| `default_compose_types = ["user_facts"]` (typo, trailing `s`) | Config load fails hard — never silently treated as unknown-so-compose-all | +| A future `FunctionalType` variant is added upstream but no fetcher gates on it yet | Always composed — new variants are opt-in-to-gate, not opt-in-to-compose | + +--- + +## 6. Success Criteria + +- [x] `enabled = false` byte-for-byte no-op verified (unit test: `enabled_with_empty_default_and_no_intent_scoping_resolves_to_empty_set`) +- [x] `FunctionalType::from_str` round-trips every variant; rejects unknown/typo strings (`functional_type_from_str_rejects_unknown_string`) +- [x] serde round-trip for every `FunctionalType` variant; rejects unknown JSON variant +- [x] Intent-scoped widening deduplicates against the default set (`intent_scoped_widens_default_set_without_duplicates`) +- [x] `BehavioralRule` never appears in any `IntentClass` widening table entry (`intent_functional_types_never_include_behavioral_rule`) +- [x] `cargo +nightly fmt --check`, `cargo clippy --profile ci ... -D warnings`, `cargo nextest run ...` pass (landed in #6226/PR closing #6086) + +--- + +## 7. Relationship to Existing Specs + +| This spec | Existing spec | Relationship | +|-----------|---------------|---------------| +| `FunctionalType`, active-set gating | [[004-memory/spec]] | Retrieval-only extension; no change to the storage-side per-function SQL table isolation already documented there | +| `schedule_context_fetchers` gating | [[021-zeph-context/spec]] | Extends `ContextAssembler`'s fetcher scheduling with an opt-in active-type filter | +| `intent_scoped` reuse of `HeuristicRouter`, no new LLM call | [[024-multi-model-design/spec]] | Complies with the "no hardcoded model, resolve via provider registry" principle by adding no new LLM call at all for this feature | +| `FunctionalType` location in `zeph-common`, not `zeph-memory` | [[043-zeph-common/spec]] | Follows the existing no-`zeph-memory`-dependency boundary for `zeph-context` (issue #3665) | + +--- + +## 8. See Also + +- [[MOC-specs]] — Map of all specifications +- [[constitution]] — Project-wide principles +- [[004-memory/spec]] — Parent memory pipeline spec +- [[021-zeph-context/spec]] — `ContextAssembler`/`schedule_context_fetchers` this feature gates +- [[024-multi-model-design/spec]] — `*_provider`/no-hardcoded-model principle +- [[043-zeph-common/spec]] — Shared-primitives crate boundary rationale for `FunctionalType`'s placement +- GitHub issue #6086 (research) — closed by #6226 +- Paper: MemGuard (arXiv:2605.28009) diff --git a/specs/009-orchestration/spec.md b/specs/009-orchestration/spec.md index 8adf005a0..337c876b2 100644 --- a/specs/009-orchestration/spec.md +++ b/specs/009-orchestration/spec.md @@ -377,6 +377,42 @@ VMAO (Verify-and-Modify Adaptive Orchestration) extends Plan Verification with a - Grounding on the ensemble path MUST run as one `ground()` call over the **union** of `claimed_executions` across all responded members, after `merge()` — never inside `merge()`, never majority/intersection - `verify_plan()` MUST run the same deterministic `ground()` stage over the **DAG-wide union** of every completed task's `tool_trace`; the aggregate MUST be `None` (fail open) if **any** completed-with-result task's trace is unavailable — never `Some(partial_union)`, which could false-positive an honest claim — see [[#Whole-Plan Grounding (issue #6287)]] +### User-Visible Incompleteness Signal (issue #6265) + +Prior to #6265, a verifier judging output incomplete with no successful automatic repair was +silent to the user — only `tracing::debug!`/`tracing::warn!` recorded it, and +`finalize_plan_execution` only branches user-visible messaging on `GraphStatus` +(`Completed`/`Failed`/`Paused`/`Canceled`), which reflects task *execution* outcome, not the +verifier's *completeness* judgment. A plan whose only task technically completed (produced some +output) was reported as an unqualified success even when verification confidently judged that +output wrong. + +Both verification scopes now emit an independent, fail-open `channel.send(...)` notice whenever +`result.complete == false` and no repair resolves the gap: + +- **Whole-plan** (`run_whole_plan_verify`, `agent/plan.rs`): `signal_plan_incomplete()` sends + `"Note: the plan output may be incomplete — verification found {N} unresolved gap(s) + (verification confidence {C}%) and automatic repair did not resolve it."` — fired when + `should_replan` is false but `!result.complete` (confidently incomplete or no actionable gaps), + when `replan_from_plan()` errors, when it returns no gap tasks, or when + `execute_partial_replan_dag()` returns `None` (replan ran but produced nothing usable). +- **Per-task** (`scheduler_loop.rs`, ensemble/verify branch): a task-scoped notice — + `"Note: task \"{title}\" verification found {N} unresolved gap(s) (verification confidence + {C}%)."` — fired when `!result.complete && !repaired`, worded local to the task since a later + whole-plan replan may still self-heal the gap. + +**Key invariants (additive to the list above):** + +- `result.complete == true` never emits a signal — nothing to report, no replan is attempted. +- The signal is best-effort: a `channel.send` failure is logged via `tracing::warn!` and never + propagated as a turn error — this is a notice, not a control-flow gate. +- The signal is emitted at most once per verification outcome (whole-plan: at the single + `return None` point reached for that verdict; per-task: once per task's verify branch) — never + duplicated across the fail-open retry paths within the same verification call. +- This is purely a user-visibility addition — it changes no `VerificationResult`/`GraphStatus` + shape, no replan gating, and no grounding behavior; `should_replan`'s computation (§ above) is + unmodified. + --- ## Verifier Tool-Call Grounding diff --git a/specs/077-safe-mode-and-cd-command/spec.md b/specs/077-safe-mode-and-cd-command/spec.md new file mode 100644 index 000000000..da8afa6c8 --- /dev/null +++ b/specs/077-safe-mode-and-cd-command/spec.md @@ -0,0 +1,255 @@ +--- +aliases: + - Safe Mode + - /cd Command + - Session Working-Directory Switch + - Customization Isolation Flag +tags: + - sdd + - spec + - cli + - commands + - troubleshooting +created: 2026-07-15 +status: implemented +related: + - "[[MOC-specs]]" + - "[[constitution]]" + - "[[001-system-invariants/spec]]" + - "[[047-cli-modes/spec]]" + - "[[042-zeph-commands/spec]]" + - "[[028-hooks/spec]]" + - "[[043-zeph-common/spec]]" + - "[[003-llm-providers/spec]]" +issues: + - "#6031" + - "#6032" + - "#6207" +--- + +# Spec: `--safe-mode` Troubleshooting Flag and `/cd` Working-Directory Command + +> [!info] +> Backfilled after-the-fact per this project's `/sdd` convention. Both features shipped together +> in commit `9b16183f` (#6207, closing research issues #6031 and #6032) with only ephemeral +> planning docs at `.local/specs/062-safe-mode-troubleshooting-flag/spec.md` and +> `.local/specs/063-mid-session-cd-command/spec.md` — neither is a permanent `/specs/` entry, and +> no line in [[047-cli-modes/spec]] (the closest existing spec, covering `--bare`/`--json`/`-y`/ +> `/loop`/`/recap`) mentions either feature. This document is the missing permanent contract, +> derived from the two research specs and the shipped diff (39 files, `9b16183f`). + +## Sources + +### External +- Claude Code v2.1.205 (2026-07-08) — `--safe-mode` CLI flag / `CLAUDE_CODE_SAFE_MODE` env var: + disables project context, plugins, skills, hooks, and MCP servers for one session, to isolate + whether a misbehaving customization is the cause of a problem. +- Claude Code v2.1.206 (2026-07-09) — `/cd` slash command: moves the current session to a new + working directory mid-conversation without breaking the prompt cache (complements the + pre-existing `/add-dir`, which only adds a supplementary context directory). + +### Internal +| File | Contents | +|---|---| +| `crates/zeph-commands/src/handlers/cd.rs` | `/cd ` handler (new) | +| `crates/zeph-commands/src/traits/agent.rs` | Trait surface extension for the `/cd` handler to invoke the shared cwd-change pipeline | +| `crates/zeph-common/src/security.rs` | New shared path-resolution/sandbox-validation module (`allowed_paths` check), used by both `/cd` and the pre-existing `set_working_directory` tool | +| `crates/zeph-tools/src/cwd.rs` | Pre-existing `set_working_directory` tool; extended to route through the new shared `zeph_common::security` validation | +| `crates/zeph-core/src/agent/hooks_dispatch.rs` | `check_cwd_changed`/`cwd_changed` hook pipeline (spec 028); both `/cd` and the LLM-invoked tool converge here | +| `crates/zeph-core/src/agent/agent_access_impl.rs` | `/cd` invalidates the repo-map memo and re-runs `CLAUDE.md`/`AGENTS.md` discovery for the new root | +| `crates/zeph-core/src/context.rs` | System-prompt volatile-block-only rebuild on cwd change (cache-preserving) | +| `crates/zeph-config/src/cli.rs`, `crates/zeph-config/src/env.rs` | `--safe-mode` CLI flag; `ZEPH_SAFE_MODE` env var | +| `crates/zeph-core/src/agent/skill_reload.rs` | Skill hot-reload gated off under `--safe-mode` | +| `src/execution_mode.rs` | `ExecMode` gains the safe-mode flag alongside the pre-existing `bare` flag | +| `src/runner.rs`, `src/daemon.rs`, `src/acp.rs`, `src/serve/*.rs` | All 6 session entry points (runner, daemon, standalone ACP, ACP-HTTP, serve, serve --acp) gated consistently | +| `crates/zeph-tools/src/diagnostics.rs`, `file.rs` | Sandbox path validation reused by the new shared security module | + +--- + +## 1. Overview + +### Problem Statement + +Zeph had no single-flag way to isolate whether a customization source (`ZEPH.md`/project +context, an installed plugin, a skill, a configured hook, or a connected MCP server) is causing +unwanted behavior — a user had to manually disable each source one at a time. The pre-existing +`--bare` flag ([[047-cli-modes/spec]]) looks superficially similar but solves a different problem: +it is a test-session mode that skips memory/MCP-tool-registry/background-task overhead, and does +**not** gate project-context injection, plugin loading, skill loading, or hook execution. + +Separately, Zeph had an *agent-invoked* `set_working_directory` tool +(`crates/zeph-tools/src/cwd.rs`) and a `cwd_changed` hook-dispatch pipeline +([[028-hooks/spec]]), but no **user-facing** command to trigger the same switch directly, and the +existing pipeline did not re-scope the `zeph-index` repo-map or re-run `CLAUDE.md`/`AGENTS.md` +discovery for the new directory, nor did it define an interaction with Claude prompt-cache +breakpoints — a naive full system-prompt rebuild on cwd change would defeat the project's existing +caching investment. + +### Goal + +1. A `--safe-mode` flag (and `ZEPH_SAFE_MODE` env var) that disables project-context loading, + plugin loading, skill loading (including hot-reload), hook execution, and MCP server + connections for a single session — distinct from and composable with `--bare`. +2. A user-facing `/cd ` slash command (CLI/TUI/ACP) that reuses the existing + `set_working_directory`/`check_cwd_changed` pipeline, invalidates the repo-map memo, re-runs + `CLAUDE.md`/`AGENTS.md` discovery, and preserves the cached system-prompt block across the + directory change. + +### Out of Scope + +- Any change to `--bare`'s existing behavior or documented meaning — `--bare` remains the + test-session-isolation primitive; `--safe-mode` is a distinct, orthogonal, composable flag + (a user may pass both, e.g. `--bare --safe-mode`). +- Per-customization-source individual disable flags (e.g. `--no-hooks`, `--no-plugins`) as + separately addressable flags — only the single all-in-one `--safe-mode` flag is in scope. +- Persisting `--safe-mode` as a `config.toml` setting — single-session, non-persistent only, + mirroring Claude Code's CLI-flag/env-var-only design. +- `/add-dir`-equivalent supplementary-directory support (adding a directory alongside the primary + one without switching) — noted as related prior art, not designed here. +- Multi-root / multi-workspace simultaneous indexing. + +--- + +## 2. Functional Requirements + +| ID | Requirement | Priority | +|----|------------|----------| +| FR-001 | WHEN `--safe-mode` is passed (or `ZEPH_SAFE_MODE` is set) THE SYSTEM SHALL skip project-context (`ZEPH.md`/`.zeph/config.md`/`CLAUDE.md`/`AGENTS.md`) discovery and injection for the session | must | +| FR-002 | WHEN `--safe-mode` is active THE SYSTEM SHALL skip plugin loading for the session | must | +| FR-003 | WHEN `--safe-mode` is active THE SYSTEM SHALL skip skill loading and matching, including skill hot-reload | must | +| FR-004 | WHEN `--safe-mode` is active THE SYSTEM SHALL skip hook execution (all hook classes) for the session | must | +| FR-005 | WHEN `--safe-mode` is active THE SYSTEM SHALL skip MCP server connections for the session | must | +| FR-006 | WHEN `--safe-mode` is active THE SYSTEM SHALL still run a normal turn loop, LLM provider calls, and (unless `--bare` is also passed) memory and tool execution as usual | must | +| FR-007 | WHEN `--safe-mode` is active THE SYSTEM SHALL apply consistently across all 6 session entry points: runner, daemon, standalone ACP, ACP-HTTP, `serve`, `serve --acp` | must | +| FR-008 | WHEN a user runs `/cd ` THE SYSTEM SHALL route through the same `set_working_directory`/`check_cwd_changed` pipeline the LLM-invoked tool already uses — not a parallel implementation | must | +| FR-009 | WHEN `/cd ` succeeds THE SYSTEM SHALL invalidate the repo-map memo and re-run `CLAUDE.md`/`AGENTS.md` discovery for the new root | must | +| FR-010 | WHEN `/cd ` succeeds on a Claude-backed session THE SYSTEM SHALL rebuild only the volatile system-prompt block, preserving existing `cache_control` breakpoints on the stable/tools blocks | must | +| FR-011 | WHEN `/cd ` or `set_working_directory` resolves a target path THE SYSTEM SHALL validate it against the shell sandbox's `allowed_paths` via the shared `zeph_common::security` module | must | +| FR-012 | WHEN the target path for `/cd` does not exist or is not a directory THE SYSTEM SHALL fail safely with a clear error and leave the session's working directory unchanged | must | + +--- + +## 3. Architecture + +### 3.1 `--safe-mode` + +A new orthogonal flag on `ExecMode` (`src/execution_mode.rs`), independent of the pre-existing +`bare` flag. Gated consistently at all 6 session entry points rather than in a single shared +runner path, since ACP/daemon/serve each construct their own agent-bootstrap sequence (the same +per-entry-point wiring pattern already required for other cross-cutting session flags — see the +recurring "wire X into ACP/serve/daemon" defect class tracked in this project's CI history). + +### 3.2 `/cd ` + +``` +/cd (crates/zeph-commands/src/handlers/cd.rs) + │ + ▼ +zeph_common::security path resolution + allowed_paths validation (FR-011) + │ + ▼ +same set_working_directory / check_cwd_changed pipeline the LLM tool uses (FR-008) + │ + ├── repo-map memo invalidated (FR-009) + ├── CLAUDE.md/AGENTS.md discovery re-run for new root (FR-009) + └── cwd_changed hooks fire (spec 028, unless --safe-mode) + │ + ▼ +next system-prompt rebuild: only the volatile block is regenerated (FR-010) +— CACHE_MARKER_STABLE / CACHE_MARKER_TOOLS breakpoints preserved +``` + +The command handler does not reimplement path resolution or cwd-mutation logic — it is a thin +user-facing entry point onto the pre-existing agent-invoked pipeline, satisfying the "reuse, don't +duplicate" requirement from the originating research spec. + +--- + +## 4. Key Invariants + +### Always (without asking) + +- `--safe-mode` and `--bare` are independent and composable — passing one never implies or + disables the other. +- `--safe-mode` never alters `--bare`'s documented behavior or its existing gate sites. +- `/cd` and `set_working_directory` converge on the same underlying cwd-change pipeline — no + parallel/duplicate implementation exists. +- A `/cd` directory switch never triggers a full system-prompt rebuild — only the volatile block + is regenerated, preserving Claude `cache_control` breakpoints on stable/tools blocks (FR-010). +- Every `/cd`/`set_working_directory` path resolution goes through the shared + `zeph_common::security` sandbox validation — never a raw, unvalidated `set_current_dir`. +- `--safe-mode` is session-only — it is never written to `config.toml` or otherwise persisted. + +### Ask First + +- Adding individual per-source disable flags (`--no-hooks`, `--no-plugins`, etc.) as a + complement to the all-in-one `--safe-mode` flag. +- Adding an `/add-dir`-equivalent supplementary-directory command. + +### Never + +- **NEVER** let `/cd` bypass the shell sandbox's `allowed_paths` validation — every resolution + goes through `zeph_common::security` (FR-011). +- **NEVER** rebuild the full (stable + tools + volatile) system prompt on a `/cd` switch when the + provider is Claude — this defeats the prompt-cache economics the feature exists to preserve + (FR-010). +- **NEVER** gate `--safe-mode` at only a subset of the 6 session entry points — inconsistent + gating reintroduces exactly the cross-mode divergence class this project's CI process treats as + a first-class bug (see `.claude/rules/continuous-improvement.md`, Cross-Mode Consistency + Testing). + +--- + +## 5. Edge Cases and Error Handling + +| Scenario | Expected Behavior | +|----------|-------------------| +| `--safe-mode` combined with `--bare` | Both apply independently; project-context/plugins/skills/hooks/MCP are skipped (safe-mode) AND memory/MCP-tool-registry/background-tasks are skipped (bare) | +| `/cd` target path does not exist | Command fails with a clear error; session's working directory is unchanged (FR-012) | +| `/cd` target path resolves outside `allowed_paths` | Rejected by the shared sandbox validation before any cwd mutation (FR-011) | +| `/cd` invoked on a non-Claude provider without prompt-cache breakpoints | Directory switch proceeds normally; the cache-preservation behavior (FR-010) is a Claude-specific optimization, not a correctness requirement for other providers | +| LLM calls `set_working_directory` while `--safe-mode` is active | Hooks (`cwd_changed`) are skipped under safe-mode per FR-004; the cwd mutation itself and repo-map/instruction re-scoping still occur, since those are not hook-gated | +| `ZEPH_SAFE_MODE` env var set alongside a config.toml with plugins/hooks/skills configured | Safe-mode wins for the session; nothing in `config.toml` is mutated or migrated | + +--- + +## 6. Success Criteria + +- [x] `--safe-mode` gated consistently across runner, daemon, standalone ACP, ACP-HTTP, `serve`, + `serve --acp` (FR-007) +- [x] `/cd` reuses `set_working_directory`/`check_cwd_changed` — no parallel implementation + (FR-008) +- [x] Shared `zeph_common::security` sandbox validation used by both `/cd` and + `set_working_directory` (FR-011) +- [x] `cargo +nightly fmt --check`, `cargo clippy --profile ci ... -D warnings`, + `cargo nextest run ...` pass (landed in #6207) +- [ ] Live verification that a `/cd` switch on a Claude-backed session preserves the + `cache_control` breakpoint on the stable/tools blocks (LLM Serialization Gate, + `.claude/rules/continuous-improvement.md`) — not confirmed via a live session as part of + this backfill; flagged for the next CI cycle's coverage sweep + +--- + +## 7. Relationship to Existing Specs + +| This spec | Existing spec | Relationship | +|-----------|---------------|---------------| +| `--safe-mode` flag, orthogonal to `--bare` | [[047-cli-modes/spec]] | Adds a fourth CLI execution mode alongside `--bare`/`--json`/`-y`; that spec should gain a cross-reference to this one | +| `/cd` slash command | [[042-zeph-commands/spec]] | New handler in the existing `CommandRegistry`/`CommandHandler` object-safe dispatch | +| `cwd_changed` hook reuse, hook suppression under `--safe-mode` | [[028-hooks/spec]] | `/cd` converges on the existing hook-dispatch pipeline; `--safe-mode` adds a new suppression condition to hook firing | +| Shared sandbox path validation | [[043-zeph-common/spec]] | New `zeph_common::security` module, following the crate's "no `zeph-*` peer dependency" boundary | +| Prompt-cache-preserving volatile-block-only rebuild | [[003-llm-providers/spec]] | Extends the existing `cache_control`/`CACHE_MARKER_*` breakpoint mechanism to the `/cd` cwd-change path | + +--- + +## 8. See Also + +- [[MOC-specs]] — Map of all specifications +- [[constitution]] — Project-wide principles +- [[047-cli-modes/spec]] — Sibling CLI execution modes (`--bare`, `--json`, `-y`, `/loop`, `/recap`) +- [[042-zeph-commands/spec]] — Slash command registry `/cd` registers into +- [[028-hooks/spec]] — `cwd_changed` hook pipeline reused by `/cd` +- [[043-zeph-common/spec]] — Shared primitives crate hosting the new `security` module +- [[003-llm-providers/spec]] — Prompt caching mechanism this feature's cache-preservation invariant extends +- GitHub issues #6031 (`--safe-mode` research), #6032 (`/cd` research) — both closed by #6207 +- `.local/specs/062-safe-mode-troubleshooting-flag/spec.md`, `.local/specs/063-mid-session-cd-command/spec.md` — originating ephemeral research specs this document formalizes into the permanent index diff --git a/specs/MOC-specs.md b/specs/MOC-specs.md index 500bd5635..e371c8118 100644 --- a/specs/MOC-specs.md +++ b/specs/MOC-specs.md @@ -52,6 +52,7 @@ status: moc - [[004-memory/spec|Memory Pipeline]] — SQLite + Qdrant dual backend, semantic response cache, anchored summarization, compaction probe, importance scoring, A-MAC admission control, MemScene consolidation, cost-sensitive store routing, temporal decay, multi-vector chunking, GAAMA episode nodes, BATS budget hints, Focus compression, SleepGate forgetting pass, persona/trajectory/category-aware memory, TiMem tree, microcompact, autoDream, MagicDocs, embed backfill batching - [[012-graph-memory/spec|Entity Graph Memory]] — entity graph, BFS recall, community detection, MAGMA typed edges, SYNAPSE spreading activation; works with [[004-memory/spec|Memory Pipeline]] - [[004-memory/004-6-graph-memory|Graph Memory (memory sub-spec)]] — concise reference within the memory subsystem: data model overview, MAGMA edge types, SYNAPSE config, key invariants + - [[004-memory/004-16-memory-type-aware-retrieval|MemGuard Type-Aware Retrieval (memory sub-spec)]] — opt-in fetch-time gate on `schedule_context_fetchers`, `FunctionalType` enum, intent-scoped widening via existing `HeuristicRouter` (no new LLM call), `BehavioralRule` always-composed safety invariant; retrieval-only, byte-for-byte no-op when disabled; GitHub #6086, #6226 - [[067-knowledge-ingest/spec|Knowledge Ingest]] — `zeph knowledge ingest` operator command; static artifacts → semantic notes (existing `IngestionPipeline`, no graph), subagent transcripts → graph (gated by measurement spike); Phase 0 provenance (`origin`/`import_batch_id`/`source_uri`) + `rollback`; honors write-gate (004-9) + admission (004-3), bypasses only RPE; sanitizer on write path; external Claude/Codex import deferred; code stays in [[018-index/spec|zeph-index]] ### Configuration & Loading @@ -59,6 +60,7 @@ status: moc - [[022-config-simplification/spec|Provider Registry]] — see LLM Providers section above - [[037-config-schema/spec|Config Schema]] — canonical TOML section inventory, validation rules, env-var override table, migration mechanism for `zeph-config` crate - [[076-cli-init-migrate-config-flag-mismatch/spec|CLI Init/Migrate-Config Flag Mismatch]] — bug spec: `init`/`migrate-config` exist only as clap subcommands, but every mandatory doc (both CLAUDE.md files, `.zeph/zeph.md`, `crates/zeph-config/AGENTS.md`, a live-testing playbook, and `src/cli.rs`'s own doc comments) documents them as `--init`/`--migrate-config` flags; two remediation paths (flag-alias restoration per #587 precedent, or doc correction) left open for a future planning session +- [[077-safe-mode-and-cd-command/spec|Safe Mode & /cd Command]] — backfilled spec: `--safe-mode`/`ZEPH_SAFE_MODE` disables project-context/plugin/skill/hook/MCP loading for one session (orthogonal to `--bare`, gated across all 6 session entry points); `/cd ` reuses the existing `set_working_directory`/`check_cwd_changed` pipeline, re-scopes the repo-map and `CLAUDE.md`/`AGENTS.md` discovery, and rebuilds only the volatile system-prompt block to preserve Claude prompt-cache breakpoints; GitHub #6031, #6032 ### Background Task Management - [[039-background-task-supervisor/spec|Supervised Background Task Manager]] — (proposed) AgentTaskSupervisor with JoinSet, task priority classes (Critical/Enrichment/Telemetry), queue depth limits, turn-boundary cleanup, metrics integration (`bg_inflight`, `bg_dropped`, `bg_completed`); addresses GitHub issue #2816 @@ -251,6 +253,7 @@ status: moc | 074 | [[074-orchestration-hitl-interrupt/spec\|Declarative HITL Interrupt]] | tasks | draft | | 075 | [[075-orchestration-node-control-parity/spec\|Node Timeout / Retry-Exhausted Recovery]] | tasks | approved | | 076 | [[076-cli-init-migrate-config-flag-mismatch/spec\|CLI Init/Migrate-Config Flag Mismatch]] | specify | draft | +| 077 | [[077-safe-mode-and-cd-command/spec\|Safe Mode & /cd Command]] | specify | implemented | --- diff --git a/specs/README.md b/specs/README.md index 7e26f6cce..d7db6da9e 100644 --- a/specs/README.md +++ b/specs/README.md @@ -88,6 +88,7 @@ Spec IDs (001–069) follow a logical grouping: | `004-memory/004-13-memory-memcot.md` | MemCoT: SemanticStateAccumulator, Zoom-In evidence localization, Zoom-Out causal expansion (#3592) | `zeph-memory` | | `004-memory/004-14-memory-tiering-rfc-decision.md` | RFC #4217 decision: memory tiering architecture analysis (MEMTIER, BudgetMem, Multi-Layer, LCM, MemRouter); adopt frequency signal + tier-aware gating + cost-aware routing (#4217) | `zeph-memory` | | `004-memory/004-15-memory-skill-coevolution-rfc-decision.md` | RFC #4218 decision: memory–skill coevolution analysis (MemQ, δ-mem, EvolveMem, SAGE-GraphMem, NanoResearch, Cognifold); adopt Cognifold idle-time folding + EvolveMem feedback routing; defer MemQ to P3 (#4218) | `zeph-memory`, `zeph-skills` | +| `004-memory/004-16-memory-type-aware-retrieval.md` | MemGuard type-aware retrieval composition: `FunctionalType` enum (episodic/user-fact/behavioral-rule/reasoning-strategy/cross-session-summary/graph-fact), opt-in fetch-time gate on `schedule_context_fetchers`, intent-scoped widening via existing `HeuristicRouter` (no new LLM call), `BehavioralRule` always-composed safety invariant; retrieval-only, byte-for-byte no-op when disabled (#6086, #6226) | `zeph-common`, `zeph-config`, `zeph-context`, `zeph-agent-context`, `zeph-memory` | | `005-skills/spec.md` | SKILL.md format, registry, matching, hot-reload, skill trust governance, two-stage matching, Wilson score confidence intervals, hub install pipeline, agent-invocable skills (`invoke_skill`), recursive WalkDir discovery (max depth 16), `SkillExtensions` manifest parser, concurrent semantic scan (`buffer_unordered(4)`, 300s timeout), skill egress attribution in `ToolCall`/`AuditEntry`/`EgressEvent` | `zeph-skills` | | `006-tools/spec.md` | ToolExecutor, CompositeExecutor, TAFC, schema filter, result cache, dependency graph, tool invocation phase taxonomy, native `tool_use` only; `invoke_skill`/`load_skill` utility-gate exemption | `zeph-tools` | | `007-channels/spec.md` | Channel trait, AnyChannel dispatch, streaming, channel feature parity, `stream_interval_ms` (Bot API 10.0, #3727); `TelegramApiClient` 30s `REQUEST_TIMEOUT` on reqwest client (#3780); Telegram reaction moderation tools `telegram_delete_reaction` / `telegram_delete_all_reactions` (#3770); CJK false-positive fix in FeedbackDetector; `send_status` added to Discord and Slack adapters (#4228) | `zeph-channels` | @@ -169,3 +170,4 @@ Spec IDs (001–069) follow a logical grouping: | `074-orchestration-hitl-interrupt/spec.md` | Declarative task-level HITL interrupt for the orchestration DAG (LangGraph `interrupt()` parity, #5918): `TaskNode.interrupt_before`/`resolved_input` + `TaskGraph.pause_reason: Option` (blob-only, ALT-1 — no `DurablePromise` in Phase 1, `PromiseId` forward-compat hook only), `GraphStatus::Paused` reused not extended, pre-dispatch gate in `dispatch_ready_tasks` (leaves gated `TaskStatus::Ready` to avoid a `check_graph_completion` false-deadlock), `/plan provide ` command, prompt-interpolation injection into `build_task_prompt`, `/plan retry` blocked on an `AwaitingInput` pause, `interrupt_enabled` config toggle (default off); Phase 2 (imperative mid-loop interrupt) and `AcpPermissionGate`→`DurablePromise` migration deferred as follow-ups; GitHub #5918 [draft] | `zeph-orchestration`, `zeph-core`, `zeph-config`, `zeph-commands` | | `075-orchestration-node-control-parity/spec.md` | Orchestration node control parity (LangGraph `TimeoutPolicy`/error-handler parity, #6021): optional per-task `TimeoutPolicy { run_timeout_secs, idle_timeout_secs }` on `TaskNode` — `run_timeout` fully enforced (spawned via `check_timeouts()` per-task effective deadline, RunInline via a third `tokio::time::timeout` branch in the inline `select!`), `idle_timeout_secs` defined/config-surfaced but a documented no-op in v1 (no progress-signal plumbing exists yet; eviction-safe coalescing `Arc` design spec'd as the Alt-A future target); optional `RecoveryAction { state_injection }` — Mode-1-only substitute-and-continue recovery on terminal `Abort`-default or retry-exhausted `Retry` failure (failed node → `Completed` with synthetic `TaskResult`, zero new consumption machinery), inert under `Skip`/`Ask` (validate warns), rejected alongside `verify_predicate`; cascade-abort takes precedence over recovery (existing event-path ordering, no code reordering) with the pre-existing timeout-vs-event asymmetry documented; no resume re-scan needed (same-tick snapshot atomicity); `route_to` reroute-to-alternate (Mode 2) explicitly deferred — its `depends_on`-based dormancy is inverted (fires on the source task's success, not failure) and needs a `TaskStatus::Dormant`/on-failure-edge redesign; new `default_idle_timeout_secs` config field with full `--init`/`--migrate-config` integration; three-round architect/critic design review (final verdict: minor/approved); GitHub #6021 [approved] | `zeph-orchestration`, `zeph-core`, `zeph-config` | | `076-cli-init-migrate-config-flag-mismatch/spec.md` | CLI `init`/`migrate-config` flag mismatch (bug spec): both operations exist only as clap subcommands (`zeph init`, `zeph migrate-config`), yet every mandatory instruction file (user-global and project `CLAUDE.md`, `.zeph/zeph.md`, `crates/zeph-config/AGENTS.md`), a live-testing playbook, and `src/cli.rs`'s own doc comments document them as top-level flags (`--init`, `--migrate-config`), causing an immediate clap parse error for anyone following the documented convention; two remediation paths left open (flag-alias restoration per #587 precedent vs. doc correction) for a future `/sdd plan` session; GitHub #587 (precedent) [draft] | `zeph-config`, `src/` (binary), docs | +| `077-safe-mode-and-cd-command/spec.md` | Backfilled spec (#6031/#6032/#6207): `--safe-mode`/`ZEPH_SAFE_MODE` disables project-context/plugin/skill/hook/MCP loading for one session, orthogonal to and composable with `--bare`, gated consistently across all 6 session entry points (runner, daemon, standalone ACP, ACP-HTTP, serve, serve --acp); `/cd ` user-facing slash command reusing the existing `set_working_directory`/`check_cwd_changed` pipeline, invalidating the repo-map memo, re-running `CLAUDE.md`/`AGENTS.md` discovery, and rebuilding only the volatile system-prompt block to preserve Claude `cache_control` breakpoints; new shared `zeph_common::security` sandbox path-validation module; GitHub #6031, #6032 [implemented] | `zeph-commands`, `zeph-common`, `zeph-config`, `zeph-core`, `zeph-tools`, `src/` (binary) | diff --git a/src/AGENTS.md b/src/AGENTS.md index 5a387e997..b9dbaae67 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -4,6 +4,7 @@ This directory contains the top-level binary wiring for CLI commands, runtime st - Changes here usually coordinate existing crate APIs rather than introducing core logic from scratch. - When adding or changing a feature, provide all integration points: config section, CLI subcommand/argument, TUI command palette entry, `--init` wizard update, `--migrate-config` migration step, live testing playbook in `.local/testing/playbooks/`, and coverage row in `.local/testing/coverage-status.md`. +- This directory has multiple parallel session entry points (`runner.rs`, `daemon.rs`, `acp.rs`, `serve/`, plus gateway spawn paths) that each build their own agent/session wiring. Verify new functionality is wired into every entry point that constructs a session, not just the one you're testing — inconsistent cross-entry-point wiring has been the most common defect class in this directory (e.g. #6031/#6032, #5978, #5976, #6169, #6039, #6047, #6102, #6140). - Secrets are never passed via environment variables or flags; all `ZEPH_*` keys are resolved from the age vault at startup. - Keep command handling thin; prefer pushing reusable logic into the appropriate crate. - Validate that CLI flags and subcommands stay aligned with docs and `config/default.toml`.